Support image drag-and-drop into SSH terminals (#1838)

* Add remote image drag-and-drop uploads

* test: cover ssh image paste planning

* fix: upload images pasted into ssh terminals

* fix: share zsh history in cmux ssh relay shells

* fix: add cancellable ssh image transfer indicator

* fix: harden async ssh image transfer callbacks

* fix: address ssh image upload review feedback

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-20 18:31:19 -07:00 committed by GitHub
parent 8286c90863
commit 4376e6e19a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2469 additions and 74 deletions

View file

@ -3908,20 +3908,11 @@ struct CMUXCLI {
"hash -r >/dev/null 2>&1 || true",
"rehash >/dev/null 2>&1 || true",
])
let zshEnvLines = [
"[ -f \"$CMUX_REAL_ZDOTDIR/.zshenv\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshenv\"",
"if [ -n \"${ZDOTDIR:-}\" ] && [ \"$ZDOTDIR\" != \"\(shellStateDir)\" ]; then export CMUX_REAL_ZDOTDIR=\"$ZDOTDIR\"; fi",
"export ZDOTDIR=\"\(shellStateDir)\"",
]
let zshProfileLines = [
"[ -f \"$CMUX_REAL_ZDOTDIR/.zprofile\" ] && source \"$CMUX_REAL_ZDOTDIR/.zprofile\"",
]
let zshRCLines = [
"[ -f \"$CMUX_REAL_ZDOTDIR/.zshrc\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshrc\"",
] + commonShellLines
let zshLoginLines = [
"[ -f \"$CMUX_REAL_ZDOTDIR/.zlogin\" ] && source \"$CMUX_REAL_ZDOTDIR/.zlogin\"",
]
let zshBootstrap = RemoteRelayZshBootstrap(shellStateDir: shellStateDir)
let zshEnvLines = zshBootstrap.zshEnvLines
let zshProfileLines = zshBootstrap.zshProfileLines
let zshRCLines = zshBootstrap.zshRCLines(commonShellLines: commonShellLines)
let zshLoginLines = zshBootstrap.zshLoginLines
let bashRCLines = [
"if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi",
"[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"",

View file

@ -17,6 +17,8 @@
A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; };
A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; };
A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; };
A5001542 /* TerminalImageTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001544 /* TerminalImageTransfer.swift */; };
A5001543 /* TerminalSSHSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001545 /* TerminalSSHSessionDetector.swift */; };
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; };
@ -65,9 +67,11 @@
A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; };
A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; };
A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; };
A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; };
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; };
B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; };
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; };
C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; };
D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; };
@ -185,6 +189,8 @@
A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; };
A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = "<group>"; };
A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = "<group>"; };
A5001544 /* TerminalImageTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalImageTransfer.swift; sourceTree = "<group>"; };
A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = "<group>"; };
A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
@ -231,6 +237,7 @@
A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = "<group>"; };
A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = "<group>"; };
A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; };
A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = "<group>"; };
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; };
B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = "<group>"; };
@ -413,6 +420,8 @@
A5001533 /* BrowserWindowPortal.swift */,
A5001019 /* TerminalController.swift */,
A5001541 /* PortScanner.swift */,
A5001544 /* TerminalImageTransfer.swift */,
A5001545 /* TerminalSSHSessionDetector.swift */,
A5001225 /* SocketControlSettings.swift */,
A5001600 /* SentryHelper.swift */,
A5001620 /* AppleScriptSupport.swift */,
@ -448,6 +457,7 @@
A5001241 /* WindowDecorationsController.swift */,
A5001222 /* WindowAccessor.swift */,
A5001611 /* SessionPersistence.swift */,
A5001641 /* RemoteRelayZshBootstrap.swift */,
);
path = Sources;
sourceTree = "<group>";
@ -705,6 +715,8 @@
A5001534 /* BrowserWindowPortal.swift in Sources */,
A5001007 /* TerminalController.swift in Sources */,
A5001540 /* PortScanner.swift in Sources */,
A5001542 /* TerminalImageTransfer.swift in Sources */,
A5001543 /* TerminalSSHSessionDetector.swift in Sources */,
A5001226 /* SocketControlSettings.swift in Sources */,
A5001601 /* SentryHelper.swift in Sources */,
A5001621 /* AppleScriptSupport.swift in Sources */,
@ -740,6 +752,7 @@
A5001240 /* WindowDecorationsController.swift in Sources */,
A500120C /* WindowAccessor.swift in Sources */,
A5001610 /* SessionPersistence.swift in Sources */,
A5001640 /* RemoteRelayZshBootstrap.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -805,6 +818,7 @@
buildActionMask = 2147483647;
files = (
B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */,
B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -77627,6 +77627,57 @@
}
}
}
},
"error.remoteDrop.invalidFileURL": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Dropped item is not a file URL."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ドロップされた項目はファイル URL ではありません。"
}
}
}
},
"error.remoteDrop.unavailable": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Remote drop is unavailable."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "リモートへのドロップは利用できません。"
}
}
}
},
"error.remoteDrop.uploadFailed": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Failed to upload dropped file: %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ドロップされたファイルのアップロードに失敗しました: %@"
}
}
}
}
}
}

View file

@ -1921,7 +1921,7 @@ func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) ->
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation {
static var shared: AppDelegate?
nonisolated(unsafe) static var shared: AppDelegate?
private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment)
@ -11019,6 +11019,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
contextContainingTabId(tabId)?.tabManager
}
private func workspaceForMainActor(tabId: UUID) -> Workspace? {
contextContainingTabId(tabId)?.tabManager.tabs.first(where: { $0.id == tabId })
}
/// Returns the `Workspace` that owns `tabId`, if any.
@MainActor
func workspaceFor(tabId: UUID) -> Workspace? {
workspaceForMainActor(tabId: tabId)
}
func closeMainWindowContainingTabId(_ tabId: UUID) {
guard let context = contextContainingTabId(tabId) else { return }
let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)"

View file

@ -70,12 +70,13 @@ private func cmuxScalarHex(_ value: String?) -> String {
}
#endif
private enum GhosttyPasteboardHelper {
enum GhosttyPasteboardHelper {
private static let selectionPasteboard = NSPasteboard(
name: NSPasteboard.Name("com.mitchellh.ghostty.selection")
)
private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text")
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
private static let temporaryImageFilenamePrefix = "clipboard-"
private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!)
static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? {
@ -139,6 +140,9 @@ private enum GhosttyPasteboardHelper {
}
static func escapeForShell(_ value: String) -> String {
if value.contains(where: { $0 == "\n" || $0 == "\r" }) {
return shellSingleQuoted(value)
}
var result = value
for char in shellEscapeCharacters {
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
@ -146,6 +150,11 @@ private enum GhosttyPasteboardHelper {
return result
}
private static func shellSingleQuoted(_ value: String) -> String {
let escaped = value.replacingOccurrences(of: "'", with: "'\\''")
return "'\(escaped)'"
}
private static func attributedStringContents(
from pasteboard: NSPasteboard,
type: NSPasteboard.PasteboardType,
@ -295,11 +304,11 @@ private enum GhosttyPasteboardHelper {
/// When the clipboard contains only image data (or rich text that resolves to
/// an attachment-only image), saves it as a temporary image file and returns the
/// shell-escaped file path. Returns nil if the clipboard contains text or no image.
static func saveClipboardImageIfNeeded(
/// file URL. Returns nil if the clipboard contains text or no image.
static func saveImageFileURLIfNeeded(
from pasteboard: NSPasteboard = .general,
assumeNoText: Bool = false
) -> String? {
) -> URL? {
if !assumeNoText && stringContents(from: pasteboard) != nil { return nil }
let imageData: Data
@ -333,10 +342,10 @@ private enum GhosttyPasteboardHelper {
formatter.locale = Locale(identifier: "en_US_POSIX")
let timestamp = formatter.string(from: Date())
let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename)
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
do {
try imageData.write(to: URL(fileURLWithPath: path))
try imageData.write(to: fileURL)
} catch {
#if DEBUG
dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
@ -344,7 +353,31 @@ private enum GhosttyPasteboardHelper {
return nil
}
return escapeForShell(path)
return fileURL
}
/// When the clipboard contains only image data (or rich text that resolves to
/// an attachment-only image), saves it as a temporary image file and returns the
/// shell-escaped file path. Returns nil if the clipboard contains text or no image.
static func saveClipboardImageIfNeeded(
from pasteboard: NSPasteboard = .general,
assumeNoText: Bool = false
) -> String? {
saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: assumeNoText)
.map { escapeForShell($0.path) }
}
static func cleanupTransferredTemporaryImageFiles(_ fileURLs: [URL]) {
let temporaryDirectory = FileManager.default.temporaryDirectory.standardizedFileURL
for fileURL in fileURLs {
let normalizedURL = fileURL.standardizedFileURL
guard normalizedURL.isFileURL,
normalizedURL.deletingLastPathComponent() == temporaryDirectory,
normalizedURL.lastPathComponent.hasPrefix(temporaryImageFilenamePrefix) else {
continue
}
try? FileManager.default.removeItem(at: normalizedURL)
}
}
}
@ -353,6 +386,10 @@ func cmuxPasteboardStringContentsForTesting(_ pasteboard: NSPasteboard) -> Strin
GhosttyPasteboardHelper.stringContents(from: pasteboard)
}
func cmuxPasteboardImageFileURLForTesting(_ pasteboard: NSPasteboard) -> URL? {
GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard)
}
func cmuxPasteboardImagePathForTesting(_ pasteboard: NSPasteboard) -> String? {
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: pasteboard)
}
@ -1046,25 +1083,111 @@ class GhosttyApp {
return GhosttyApp.shared.handleAction(target: target, action: action)
}
runtimeConfig.read_clipboard_cb = { userdata, location, state in
// Read clipboard
guard let callbackContext = GhosttyApp.callbackContext(from: userdata),
let surface = callbackContext.runtimeSurface else { return }
guard let callbackContext = GhosttyApp.callbackContext(from: userdata) else { return }
let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location)
var value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? ""
DispatchQueue.main.async {
guard let requestSurface = callbackContext.runtimeSurface else { return }
// When clipboard has only image data (e.g. screenshot), save as temp
// PNG and paste the file path so CLI tools can receive images.
if value.isEmpty,
let imagePath = pasteboard.flatMap({
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: $0, assumeNoText: true)
})
{
value = imagePath
func completeClipboardRequest(with text: String) {
let finish = {
guard callbackContext.runtimeSurface == requestSurface else { return }
text.withCString { ptr in
ghostty_surface_complete_clipboard_request(requestSurface, ptr, state, false)
}
}
if Thread.isMainThread {
finish()
} else {
DispatchQueue.main.async(execute: finish)
}
}
value.withCString { ptr in
ghostty_surface_complete_clipboard_request(surface, ptr, state, false)
guard let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) else {
completeClipboardRequest(with: "")
return
}
let preparedContent = TerminalImageTransferPlanner.prepare(
pasteboard: pasteboard,
mode: .paste
)
switch preparedContent {
case .reject:
completeClipboardRequest(with: "")
case .insertText(let text):
completeClipboardRequest(with: text)
case .fileURLs(let fileURLs):
let operation = TerminalImageTransferOperation()
MainActor.assumeIsolated {
callbackContext.terminalSurface?.hostedView.beginImageTransferIndicator(
for: operation,
onCancel: {
completeClipboardRequest(with: "")
}
)
}
let target = MainActor.assumeIsolated {
callbackContext.terminalSurface?.resolvedImageTransferTarget() ?? .local
}
let plan = TerminalImageTransferPlanner.plan(
fileURLs: fileURLs,
target: target
)
TerminalImageTransferPlanner.execute(
plan: plan,
operation: operation,
uploadWorkspaceRemote: { fileURLs, operation, finish in
guard let workspace = MainActor.assumeIsolated({
callbackContext.terminalSurface?.owningWorkspace()
}) else {
finish(.failure(NSError(domain: "cmux.remote.paste", code: 3)))
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
return
}
workspace.uploadDroppedFilesForRemoteTerminal(
fileURLs,
operation: operation,
completion: { result in
finish(result)
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
}
)
},
uploadDetectedSSH: { session, fileURLs, operation, finish in
session.uploadDroppedFiles(
fileURLs,
operation: operation,
completion: { result in
finish(result)
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
}
)
},
insertText: { text in
MainActor.assumeIsolated {
callbackContext.terminalSurface?.hostedView.endImageTransferIndicator(
for: operation
)
}
completeClipboardRequest(with: text)
},
onFailure: { _ in
MainActor.assumeIsolated {
callbackContext.terminalSurface?.hostedView.endImageTransferIndicator(
for: operation
)
}
NSSound.beep()
#if DEBUG
dlog("terminal.remotePasteUpload.failed surface=\(callbackContext.surfaceId.uuidString.prefix(5))")
#endif
completeClipboardRequest(with: "")
}
)
}
}
}
runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in
@ -3730,6 +3853,13 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
extension TerminalSurface {
@MainActor
func owningWorkspace() -> Workspace? {
AppDelegate.shared?.workspaceFor(tabId: tabId)
}
}
// MARK: - Ghostty Surface View
class GhosttyNSView: NSView, NSUserInterfaceValidations {
@ -3739,14 +3869,25 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
return UserDefaults.standard.bool(forKey: "cmuxFocusDebug")
}()
internal enum DropPlan: Equatable {
case insertText(String)
case uploadFiles([URL])
case reject
}
private static let dropTypes: Set<NSPasteboard.PasteboardType> = [
.string,
.fileURL,
.URL
.URL,
.png,
.tiff,
NSPasteboard.PasteboardType(UTType.jpeg.identifier),
NSPasteboard.PasteboardType(UTType.gif.identifier),
NSPasteboard.PasteboardType(UTType.heic.identifier),
NSPasteboard.PasteboardType(UTType.heif.identifier)
]
private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder")
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
fileprivate static func focusLog(_ message: String) {
guard focusDebugEnabled else { return }
@ -5967,39 +6108,207 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
fileprivate static func escapeDropForShell(_ value: String) -> String {
var result = value
for char in shellEscapeCharacters {
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
}
return result
TerminalImageTransferPlanner.escapeForShell(value)
}
private func droppedContent(from pasteboard: NSPasteboard) -> String? {
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !urls.isEmpty {
return urls
.map { Self.escapeDropForShell($0.path) }
static func dropPlanForTesting(
pasteboard: NSPasteboard,
isRemoteTerminalSurface: Bool
) -> DropPlan {
let target: TerminalImageTransferTarget = isRemoteTerminalSurface ? .remote(.workspaceRemote) : .local
switch TerminalImageTransferPlanner.plan(
pasteboard: pasteboard,
mode: .drop,
target: target
) {
case .insertText(let text):
return .insertText(text)
case .uploadFiles(let fileURLs, _):
return .uploadFiles(fileURLs)
case .reject:
return .reject
}
}
static func performRemoteDropUploadForTesting(
upload: (@escaping (Result<[String], Error>) -> Void) -> Void,
sendText: @escaping (String) -> Void,
onFailure: @escaping () -> Void
) {
upload { result in
switch result {
case .success(let remotePaths):
let content = remotePaths
.map { Self.escapeDropForShell($0) }
.joined(separator: " ")
guard !content.isEmpty else {
onFailure()
return
}
sendText(content)
case .failure:
onFailure()
}
}
}
if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty {
return Self.escapeDropForShell(rawURL)
@discardableResult
static func handleDropForTesting(
pasteboard: NSPasteboard,
isRemoteTerminalSurface: Bool,
uploadRemote: ([URL], @escaping (Result<[String], Error>) -> Void) -> Void,
sendText: @escaping (String) -> Void,
onFailure: @escaping () -> Void
) -> Bool {
let target: TerminalImageTransferTarget = isRemoteTerminalSurface ? .remote(.workspaceRemote) : .local
let plan = TerminalImageTransferPlanner.plan(
pasteboard: pasteboard,
mode: .drop,
target: target
)
guard plan != .reject else { return false }
TerminalImageTransferPlanner.execute(
plan: plan,
uploadWorkspaceRemote: { urls, _, finish in
uploadRemote(urls) { result in
finish(result)
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(urls)
}
},
uploadDetectedSSH: { _, _, _, finish in
finish(.failure(NSError(domain: "cmux.remote.drop", code: 4)))
},
insertText: sendText,
onFailure: { _ in onFailure() }
)
return true
}
if let str = pasteboard.string(forType: .string), !str.isEmpty {
return str
}
private func executeImageTransferPlan(
_ plan: TerminalImageTransferPlan,
operation: TerminalImageTransferOperation? = nil,
onCancel: @escaping () -> Void = {}
) -> Bool {
guard plan != .reject else { return false }
let operation = operation ?? {
if case .uploadFiles = plan {
return TerminalImageTransferOperation()
}
return nil
}()
if let operation {
terminalSurface?.hostedView.beginImageTransferIndicator(
for: operation,
onCancel: onCancel
)
}
TerminalImageTransferPlanner.execute(
plan: plan,
operation: operation,
uploadWorkspaceRemote: { [weak self] fileURLs, operation, finish in
guard let workspace = MainActor.assumeIsolated({
self?.terminalSurface?.owningWorkspace()
}) else {
finish(.failure(NSError(domain: "cmux.remote.drop", code: 3)))
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
return
}
workspace.uploadDroppedFilesForRemoteTerminal(
fileURLs,
operation: operation,
completion: { result in
finish(result)
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
}
)
},
uploadDetectedSSH: { session, fileURLs, operation, finish in
session.uploadDroppedFiles(
fileURLs,
operation: operation,
completion: { result in
finish(result)
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
}
)
},
insertText: { [weak self] text in
let send = {
if let operation {
self?.terminalSurface?.hostedView.endImageTransferIndicator(for: operation)
}
// Use the text/paste path (ghostty_surface_text) instead of the key event
// path (ghostty_surface_key) so bracketed paste mode is triggered and the
// insertion is instant, matching upstream Ghostty behaviour.
self?.terminalSurface?.sendText(text)
}
if Thread.isMainThread {
send()
} else {
DispatchQueue.main.async(execute: send)
}
},
onFailure: { [weak self] _ in
if let operation {
self?.terminalSurface?.hostedView.endImageTransferIndicator(for: operation)
}
DispatchQueue.main.async {
NSSound.beep()
#if DEBUG
dlog("terminal.remoteDropUpload.failed surface=\(self?.terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
#endif
}
}
)
return true
}
private func resolvedImageTransferTarget() -> TerminalImageTransferTarget {
MainActor.assumeIsolated {
terminalSurface?.resolvedImageTransferTarget() ?? .local
}
}
fileprivate func handleDroppedFileURLs(_ urls: [URL]) -> Bool {
executePreparedImageTransfer(
.fileURLs(urls),
onCancel: {}
)
}
@discardableResult
fileprivate func insertDroppedPasteboard(_ pasteboard: NSPasteboard) -> Bool {
guard let content = droppedContent(from: pasteboard) else { return false }
// Use the text/paste path (ghostty_surface_text) instead of the key event
// path (ghostty_surface_key) so bracketed paste mode is triggered and the
// insertion is instant, matching upstream Ghostty behaviour.
terminalSurface?.sendText(content)
executePreparedImageTransfer(
TerminalImageTransferPlanner.prepare(
pasteboard: pasteboard,
mode: .drop
),
onCancel: {}
)
}
@discardableResult
private func executePreparedImageTransfer(
_ preparedContent: TerminalImageTransferPreparedContent,
onCancel: @escaping () -> Void
) -> Bool {
switch preparedContent {
case .reject:
return false
case .insertText(let text):
terminalSurface?.sendText(text)
return true
case .fileURLs(let fileURLs):
let plan = TerminalImageTransferPlanner.plan(
fileURLs: fileURLs,
target: resolvedImageTransferTarget()
)
return executeImageTransferPlan(plan, onCancel: onCancel)
}
}
#if DEBUG
@ -6184,8 +6493,15 @@ final class GhosttySurfaceScrollView: NSView {
private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView
private let keyboardCopyModeBadgeIconView: NSImageView
private let keyboardCopyModeBadgeLabel: NSTextField
private let imageTransferIndicatorContainerView: NSView
private let imageTransferIndicatorView: NSVisualEffectView
private let imageTransferIndicatorSpinner: NSProgressIndicator
private let imageTransferCancelButton: NSButton
private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>?
private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem?
private var imageTransferIndicatorShowWorkItem: DispatchWorkItem?
private var activeImageTransferOperation: TerminalImageTransferOperation?
private var activeImageTransferCancelHandler: (() -> Void)?
private var lastSearchOverlayStateID: ObjectIdentifier?
private var searchOverlayMutationGeneration: UInt64 = 0
private var observers: [NSObjectProtocol] = []
@ -6379,6 +6695,10 @@ final class GhosttySurfaceScrollView: NSView {
keyboardCopyModeBadgeView = GhosttyPassthroughVisualEffectView(frame: .zero)
keyboardCopyModeBadgeIconView = NSImageView(frame: .zero)
keyboardCopyModeBadgeLabel = NSTextField(labelWithString: terminalKeyboardCopyModeIndicatorText)
imageTransferIndicatorContainerView = NSView(frame: .zero)
imageTransferIndicatorView = NSVisualEffectView(frame: .zero)
imageTransferIndicatorSpinner = NSProgressIndicator(frame: .zero)
imageTransferCancelButton = NSButton(frame: .zero)
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = false
@ -6509,6 +6829,71 @@ final class GhosttySurfaceScrollView: NSView {
keyboardCopyModeBadgeContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
imageTransferIndicatorContainerView.translatesAutoresizingMaskIntoConstraints = false
imageTransferIndicatorContainerView.wantsLayer = true
imageTransferIndicatorContainerView.layer?.masksToBounds = false
imageTransferIndicatorContainerView.layer?.shadowColor = NSColor.black.cgColor
imageTransferIndicatorContainerView.layer?.shadowOpacity = 0.18
imageTransferIndicatorContainerView.layer?.shadowRadius = 8
imageTransferIndicatorContainerView.layer?.shadowOffset = CGSize(width: 0, height: 2)
imageTransferIndicatorView.translatesAutoresizingMaskIntoConstraints = false
imageTransferIndicatorView.wantsLayer = true
imageTransferIndicatorView.material = .hudWindow
imageTransferIndicatorView.blendingMode = .withinWindow
imageTransferIndicatorView.state = .active
imageTransferIndicatorView.layer?.cornerRadius = 16
imageTransferIndicatorView.layer?.masksToBounds = true
imageTransferIndicatorView.layer?.borderWidth = 1
imageTransferIndicatorView.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor
imageTransferIndicatorView.alphaValue = 0.95
imageTransferIndicatorSpinner.translatesAutoresizingMaskIntoConstraints = false
imageTransferIndicatorSpinner.style = .spinning
imageTransferIndicatorSpinner.controlSize = .small
imageTransferIndicatorSpinner.isDisplayedWhenStopped = false
imageTransferCancelButton.translatesAutoresizingMaskIntoConstraints = false
imageTransferCancelButton.isBordered = false
imageTransferCancelButton.imagePosition = .imageOnly
imageTransferCancelButton.image = NSImage(
systemSymbolName: "xmark.circle.fill",
accessibilityDescription: String(localized: "common.cancel", defaultValue: "Cancel")
)
imageTransferCancelButton.contentTintColor = NSColor.secondaryLabelColor
imageTransferCancelButton.toolTip = String(localized: "common.cancel", defaultValue: "Cancel")
imageTransferCancelButton.setAccessibilityLabel(
String(localized: "common.cancel", defaultValue: "Cancel")
)
imageTransferCancelButton.target = self
imageTransferCancelButton.action = #selector(handleImageTransferCancel)
imageTransferIndicatorContainerView.addSubview(imageTransferIndicatorView)
imageTransferIndicatorView.addSubview(imageTransferIndicatorSpinner)
imageTransferIndicatorView.addSubview(imageTransferCancelButton)
NSLayoutConstraint.activate([
imageTransferIndicatorView.topAnchor.constraint(equalTo: imageTransferIndicatorContainerView.topAnchor),
imageTransferIndicatorView.bottomAnchor.constraint(equalTo: imageTransferIndicatorContainerView.bottomAnchor),
imageTransferIndicatorView.leadingAnchor.constraint(equalTo: imageTransferIndicatorContainerView.leadingAnchor),
imageTransferIndicatorView.trailingAnchor.constraint(equalTo: imageTransferIndicatorContainerView.trailingAnchor),
imageTransferIndicatorSpinner.leadingAnchor.constraint(equalTo: imageTransferIndicatorView.leadingAnchor, constant: 10),
imageTransferIndicatorSpinner.centerYAnchor.constraint(equalTo: imageTransferIndicatorView.centerYAnchor),
imageTransferIndicatorSpinner.widthAnchor.constraint(equalToConstant: 14),
imageTransferIndicatorSpinner.heightAnchor.constraint(equalToConstant: 14),
imageTransferCancelButton.leadingAnchor.constraint(equalTo: imageTransferIndicatorSpinner.trailingAnchor, constant: 6),
imageTransferCancelButton.trailingAnchor.constraint(equalTo: imageTransferIndicatorView.trailingAnchor, constant: -8),
imageTransferCancelButton.centerYAnchor.constraint(equalTo: imageTransferIndicatorView.centerYAnchor),
imageTransferCancelButton.widthAnchor.constraint(equalToConstant: 16),
imageTransferCancelButton.heightAnchor.constraint(equalToConstant: 16),
imageTransferIndicatorSpinner.topAnchor.constraint(equalTo: imageTransferIndicatorView.topAnchor, constant: 8),
imageTransferIndicatorSpinner.bottomAnchor.constraint(equalTo: imageTransferIndicatorView.bottomAnchor, constant: -8),
])
imageTransferIndicatorContainerView.isHidden = true
addSubview(imageTransferIndicatorContainerView)
NSLayoutConstraint.activate([
imageTransferIndicatorContainerView.topAnchor.constraint(
equalTo: keyboardCopyModeBadgeContainerView.bottomAnchor,
constant: 8
),
imageTransferIndicatorContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
scrollView.contentView.postsBoundsChangedNotifications = true
observers.append(NotificationCenter.default.addObserver(
forName: NSView.boundsDidChangeNotification,
@ -6590,6 +6975,7 @@ final class GhosttySurfaceScrollView: NSView {
observers.forEach { NotificationCenter.default.removeObserver($0) }
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
deferredSearchOverlayMutationWorkItem?.cancel()
imageTransferIndicatorShowWorkItem?.cancel()
dropZoneOverlayView.removeFromSuperview()
cancelFocusRequest()
}
@ -6941,6 +7327,29 @@ final class GhosttySurfaceScrollView: NSView {
DispatchQueue.main.async(execute: work)
}
private func cancelImageTransferIndicatorShow() {
imageTransferIndicatorShowWorkItem?.cancel()
imageTransferIndicatorShowWorkItem = nil
}
private func updateImageTransferIndicatorZOrder(relativeTo overlay: NSView?) {
guard !imageTransferIndicatorContainerView.isHidden else { return }
if let overlay, overlay.superview === self {
addSubview(imageTransferIndicatorContainerView, positioned: .above, relativeTo: overlay)
return
}
if keyboardCopyModeBadgeContainerView.superview === self,
!keyboardCopyModeBadgeContainerView.isHidden {
addSubview(
imageTransferIndicatorContainerView,
positioned: .above,
relativeTo: keyboardCopyModeBadgeContainerView
)
return
}
addSubview(imageTransferIndicatorContainerView, positioned: .above, relativeTo: nil)
}
private func updateKeyboardCopyModeBadgeZOrder(relativeTo overlay: NSView?) {
guard !keyboardCopyModeBadgeContainerView.isHidden else { return }
if let overlay, overlay.superview === self {
@ -6948,6 +7357,65 @@ final class GhosttySurfaceScrollView: NSView {
} else {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil)
}
updateImageTransferIndicatorZOrder(relativeTo: overlay)
}
@objc private func handleImageTransferCancel() {
guard let operation = activeImageTransferOperation else { return }
let onCancel = activeImageTransferCancelHandler
guard operation.cancel() else { return }
endImageTransferIndicator(for: operation)
onCancel?()
}
func beginImageTransferIndicator(
for operation: TerminalImageTransferOperation,
onCancel: @escaping () -> Void
) {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.beginImageTransferIndicator(for: operation, onCancel: onCancel)
}
return
}
cancelImageTransferIndicatorShow()
activeImageTransferOperation = operation
activeImageTransferCancelHandler = onCancel
imageTransferIndicatorSpinner.stopAnimation(nil)
imageTransferIndicatorContainerView.isHidden = true
let work = DispatchWorkItem { [weak self] in
guard let self else { return }
guard self.activeImageTransferOperation === operation else { return }
guard !operation.isCancelled else { return }
self.imageTransferIndicatorShowWorkItem = nil
self.imageTransferIndicatorSpinner.startAnimation(nil)
self.imageTransferIndicatorContainerView.isHidden = false
self.updateImageTransferIndicatorZOrder(relativeTo: self.searchOverlayHostingView)
}
imageTransferIndicatorShowWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: work)
}
func endImageTransferIndicator(for operation: TerminalImageTransferOperation?) {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.endImageTransferIndicator(for: operation)
}
return
}
if let operation,
activeImageTransferOperation !== operation {
return
}
cancelImageTransferIndicatorShow()
activeImageTransferOperation = nil
activeImageTransferCancelHandler = nil
imageTransferIndicatorSpinner.stopAnimation(nil)
imageTransferIndicatorContainerView.isHidden = true
}
private func makeSearchOverlayRootView(
@ -7567,15 +8035,10 @@ final class GhosttySurfaceScrollView: NSView {
/// Handle file/URL drops, forwarding to the terminal as shell-escaped paths.
func handleDroppedURLs(_ urls: [URL]) -> Bool {
guard !urls.isEmpty else { return false }
let content = urls
.map { GhosttyNSView.escapeDropForShell($0.path) }
.joined(separator: " ")
#if DEBUG
dlog("terminal.swiftUIDrop surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") urls=\(urls.map(\.lastPathComponent))")
#endif
surfaceView.terminalSurface?.sendText(content)
return true
return surfaceView.handleDroppedFileURLs(urls)
}
func terminalViewForDrop(at point: NSPoint) -> GhosttyNSView? {

View file

@ -0,0 +1,38 @@
import Foundation
struct RemoteRelayZshBootstrap {
let shellStateDir: String
private var sharedHistoryLines: [String] {
[
"if [ -z \"${HISTFILE:-}\" ] || [ \"$HISTFILE\" = \"\(shellStateDir)/.zsh_history\" ]; then export HISTFILE=\"$CMUX_REAL_ZDOTDIR/.zsh_history\"; fi",
]
}
var zshEnvLines: [String] {
[
"[ -f \"$CMUX_REAL_ZDOTDIR/.zshenv\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshenv\"",
"if [ -n \"${ZDOTDIR:-}\" ] && [ \"$ZDOTDIR\" != \"\(shellStateDir)\" ]; then export CMUX_REAL_ZDOTDIR=\"$ZDOTDIR\"; fi",
] + sharedHistoryLines + [
"export ZDOTDIR=\"\(shellStateDir)\"",
]
}
var zshProfileLines: [String] {
[
"[ -f \"$CMUX_REAL_ZDOTDIR/.zprofile\" ] && source \"$CMUX_REAL_ZDOTDIR/.zprofile\"",
]
}
func zshRCLines(commonShellLines: [String]) -> [String] {
sharedHistoryLines + [
"[ -f \"$CMUX_REAL_ZDOTDIR/.zshrc\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshrc\"",
] + commonShellLines
}
var zshLoginLines: [String] {
[
"[ -f \"$CMUX_REAL_ZDOTDIR/.zlogin\" ] && source \"$CMUX_REAL_ZDOTDIR/.zlogin\"",
]
}
}

View file

@ -0,0 +1,330 @@
import Foundation
import AppKit
enum TerminalImageTransferMode {
case paste
case drop
}
enum TerminalRemoteUploadTarget: Equatable {
case workspaceRemote
case detectedSSH(DetectedSSHSession)
}
enum TerminalImageTransferTarget: Equatable {
case local
case remote(TerminalRemoteUploadTarget)
}
enum TerminalImageTransferPlan: Equatable {
case insertText(String)
case uploadFiles([URL], TerminalRemoteUploadTarget)
case reject
}
enum TerminalImageTransferPreparedContent: Equatable {
case insertText(String)
case fileURLs([URL])
case reject
}
enum TerminalImageTransferExecutionError: Error {
case cancelled
}
final class TerminalImageTransferOperation: @unchecked Sendable {
private enum State {
case running
case cancelled
case finished
}
private let lock = NSLock()
private var state: State = .running
private var cancellationHandler: (() -> Void)?
var isCancelled: Bool {
lock.lock()
defer { lock.unlock() }
return state == .cancelled
}
func installCancellationHandler(_ handler: @escaping () -> Void) {
var invokeImmediately = false
lock.lock()
switch state {
case .running:
cancellationHandler = handler
case .cancelled:
invokeImmediately = true
case .finished:
break
}
lock.unlock()
if invokeImmediately {
handler()
}
}
func clearCancellationHandler() {
lock.lock()
if state == .running {
cancellationHandler = nil
}
lock.unlock()
}
@discardableResult
func cancel() -> Bool {
let handler: (() -> Void)?
lock.lock()
guard state == .running else {
lock.unlock()
return false
}
state = .cancelled
handler = cancellationHandler
cancellationHandler = nil
lock.unlock()
handler?()
return true
}
@discardableResult
func finish() -> Bool {
lock.lock()
defer { lock.unlock() }
guard state == .running else { return false }
state = .finished
cancellationHandler = nil
return true
}
func throwIfCancelled() throws {
if isCancelled {
throw TerminalImageTransferExecutionError.cancelled
}
}
}
enum TerminalImageTransferPlanner {
static func plan(
pasteboard: NSPasteboard,
mode: TerminalImageTransferMode,
target: TerminalImageTransferTarget
) -> TerminalImageTransferPlan {
plan(
preparedContent: prepare(pasteboard: pasteboard, mode: mode),
target: target
)
}
static func plan(
pasteboard: NSPasteboard,
mode: TerminalImageTransferMode,
resolveTarget: () -> TerminalImageTransferTarget
) -> TerminalImageTransferPlan {
let preparedContent = prepare(pasteboard: pasteboard, mode: mode)
switch preparedContent {
case .insertText, .reject:
return plan(preparedContent: preparedContent, target: .local)
case .fileURLs:
return plan(preparedContent: preparedContent, target: resolveTarget())
}
}
static func prepare(
pasteboard: NSPasteboard,
mode: TerminalImageTransferMode
) -> TerminalImageTransferPreparedContent {
switch mode {
case .paste:
return preparePaste(pasteboard: pasteboard)
case .drop:
return prepareDrop(pasteboard: pasteboard)
}
}
static func plan(
preparedContent: TerminalImageTransferPreparedContent,
target: TerminalImageTransferTarget
) -> TerminalImageTransferPlan {
switch preparedContent {
case .insertText(let text):
return .insertText(text)
case .fileURLs(let fileURLs):
return plan(fileURLs: fileURLs, target: target)
case .reject:
return .reject
}
}
static func plan(fileURLs: [URL], target: TerminalImageTransferTarget) -> TerminalImageTransferPlan {
guard !fileURLs.isEmpty else { return .reject }
switch target {
case .local:
let text = fileURLs
.map { escapeForShell($0.path) }
.joined(separator: " ")
return .insertText(text)
case .remote(let remoteTarget):
return .uploadFiles(fileURLs, remoteTarget)
}
}
@discardableResult
static func executeForTesting(
plan: TerminalImageTransferPlan,
operation: TerminalImageTransferOperation? = nil,
uploadWorkspaceRemote: ([URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void,
uploadDetectedSSH: (DetectedSSHSession, [URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void,
insertText: @escaping (String) -> Void,
onFailure: @escaping (Error) -> Void
) -> TerminalImageTransferOperation? {
execute(
plan: plan,
operation: operation,
uploadWorkspaceRemote: uploadWorkspaceRemote,
uploadDetectedSSH: uploadDetectedSSH,
insertText: insertText,
onFailure: onFailure
)
}
@discardableResult
static func execute(
plan: TerminalImageTransferPlan,
operation: TerminalImageTransferOperation? = nil,
uploadWorkspaceRemote: ([URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void,
uploadDetectedSSH: (DetectedSSHSession, [URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void,
insertText: @escaping (String) -> Void,
onFailure: @escaping (Error) -> Void
) -> TerminalImageTransferOperation? {
switch plan {
case .insertText(let text):
if let operation, !operation.finish() {
return operation
}
insertText(text)
return operation
case .uploadFiles(let fileURLs, .workspaceRemote):
let operation = operation ?? TerminalImageTransferOperation()
uploadWorkspaceRemote(fileURLs, operation) { result in
guard operation.finish() else { return }
finishUpload(result: result, insertText: insertText, onFailure: onFailure)
}
return operation
case .uploadFiles(let fileURLs, .detectedSSH(let session)):
let operation = operation ?? TerminalImageTransferOperation()
uploadDetectedSSH(session, fileURLs, operation) { result in
guard operation.finish() else { return }
finishUpload(result: result, insertText: insertText, onFailure: onFailure)
}
return operation
case .reject:
return operation
}
}
static func escapeForShell(_ value: String) -> String {
GhosttyPasteboardHelper.escapeForShell(value)
}
private static func preparePaste(
pasteboard: NSPasteboard
) -> TerminalImageTransferPreparedContent {
let fileURLs = fileURLs(from: pasteboard)
if !fileURLs.isEmpty {
return .fileURLs(fileURLs)
}
if let string = GhosttyPasteboardHelper.stringContents(from: pasteboard), !string.isEmpty {
return .insertText(string)
}
if let imageURL = GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: true) {
return .fileURLs([imageURL])
}
if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty {
return .insertText(escapeForShell(rawURL))
}
return .reject
}
private static func prepareDrop(
pasteboard: NSPasteboard
) -> TerminalImageTransferPreparedContent {
let fileURLs = materializedFileURLs(from: pasteboard)
if !fileURLs.isEmpty {
return .fileURLs(fileURLs)
}
if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty {
return .insertText(escapeForShell(rawURL))
}
if let string = pasteboard.string(forType: .string), !string.isEmpty {
return .insertText(string)
}
return .reject
}
private static func materializedFileURLs(from pasteboard: NSPasteboard) -> [URL] {
let urls = fileURLs(from: pasteboard)
if !urls.isEmpty {
return urls
}
if let imageURL = GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: true) {
return [imageURL]
}
return []
}
private static func fileURLs(from pasteboard: NSPasteboard) -> [URL] {
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else {
return []
}
return urls.filter(\.isFileURL)
}
private static func finishUpload(
result: Result<[String], Error>,
insertText: @escaping (String) -> Void,
onFailure: @escaping (Error) -> Void
) {
switch result {
case .success(let remotePaths):
let content = remotePaths
.map(escapeForShell)
.joined(separator: " ")
guard !content.isEmpty else {
onFailure(NSError(domain: "cmux.remote.drop", code: 5))
return
}
insertText(content)
case .failure(let error):
onFailure(error)
}
}
}
extension TerminalSurface {
@MainActor
func resolvedImageTransferTarget() -> TerminalImageTransferTarget {
guard let workspace = owningWorkspace() else { return .local }
if workspace.isRemoteTerminalSurface(id) {
return .remote(.workspaceRemote)
}
if let ttyName = workspace.surfaceTTYNames[id],
let session = TerminalSSHSessionDetector.detect(forTTY: ttyName) {
return .remote(.detectedSSH(session))
}
return .local
}
}

View file

@ -0,0 +1,653 @@
import Foundation
import Darwin
struct DetectedSSHSession: Equatable {
let destination: String
let port: Int?
let identityFile: String?
let configFile: String?
let jumpHost: String?
let controlPath: String?
let useIPv4: Bool
let useIPv6: Bool
let forwardAgent: Bool
let compressionEnabled: Bool
let sshOptions: [String]
func uploadDroppedFiles(
_ fileURLs: [URL],
operation: TerminalImageTransferOperation,
completion: @escaping (Result<[String], Error>) -> Void
) {
let session = self
DispatchQueue.global(qos: .userInitiated).async {
let result = Result {
let remotePaths = try session.uploadDroppedFilesSync(fileURLs, operation: operation)
try operation.throwIfCancelled()
return remotePaths
}
DispatchQueue.main.async {
if operation.isCancelled {
completion(.failure(TerminalImageTransferExecutionError.cancelled))
} else {
completion(result)
}
}
}
}
func uploadDroppedFiles(
_ fileURLs: [URL],
completion: @escaping (Result<[String], Error>) -> Void
) {
uploadDroppedFiles(
fileURLs,
operation: TerminalImageTransferOperation(),
completion: completion
)
}
private func uploadDroppedFilesSync(
_ fileURLs: [URL],
operation: TerminalImageTransferOperation
) throws -> [String] {
guard !fileURLs.isEmpty else { return [] }
return try fileURLs.map { localURL in
try operation.throwIfCancelled()
let normalizedLocalURL = localURL.standardizedFileURL
guard normalizedLocalURL.isFileURL else {
throw NSError(domain: "cmux.detected-ssh.drop", code: 1, userInfo: [
NSLocalizedDescriptionKey: "dropped item is not a file URL",
])
}
let remotePath = WorkspaceRemoteSessionController.remoteDropPath(for: normalizedLocalURL)
let result = try Self.runProcess(
executable: "/usr/bin/scp",
arguments: scpArguments(localPath: normalizedLocalURL.path, remotePath: remotePath),
timeout: 45,
operation: operation
)
guard result.status == 0 else {
let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ??
"scp exited \(result.status)"
throw NSError(domain: "cmux.detected-ssh.drop", code: 2, userInfo: [
NSLocalizedDescriptionKey: "failed to upload dropped file: \(detail)",
])
}
return remotePath
}
}
private func scpArguments(localPath: String, remotePath: String) -> [String] {
var args: [String] = [
"-q",
"-o", "ConnectTimeout=6",
"-o", "ServerAliveInterval=20",
"-o", "ServerAliveCountMax=2",
"-o", "BatchMode=yes",
"-o", "ControlMaster=no",
]
if useIPv4 {
args.append("-4")
} else if useIPv6 {
args.append("-6")
}
if forwardAgent {
args.append("-A")
}
if compressionEnabled {
args.append("-C")
}
if let configFile, !configFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args += ["-F", configFile]
}
if let jumpHost, !jumpHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args += ["-J", jumpHost]
}
if let port {
args += ["-P", String(port)]
}
if let identityFile, !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args += ["-i", identityFile]
}
if let controlPath,
!controlPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!Self.hasSSHOptionKey(sshOptions, key: "ControlPath") {
args += ["-o", "ControlPath=\(controlPath)"]
}
if !Self.hasSSHOptionKey(sshOptions, key: "StrictHostKeyChecking") {
args += ["-o", "StrictHostKeyChecking=accept-new"]
}
for option in sshOptions {
args += ["-o", option]
}
args += [localPath, "\(destination):\(remotePath)"]
return args
}
private struct CommandResult {
let status: Int32
let stdout: String
let stderr: String
}
private static func runProcess(
executable: String,
arguments: [String],
timeout: TimeInterval,
operation: TerminalImageTransferOperation? = nil
) throws -> CommandResult {
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
process.standardInput = FileHandle.nullDevice
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
try operation?.throwIfCancelled()
try process.run()
operation?.installCancellationHandler {
if process.isRunning {
process.terminate()
}
}
defer { operation?.clearCancellationHandler() }
let exitSignal = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .userInitiated).async {
process.waitUntilExit()
exitSignal.signal()
}
func terminateProcessAndWait() {
process.terminate()
_ = exitSignal.wait(timeout: .now() + 1)
if process.isRunning {
_ = Darwin.kill(process.processIdentifier, SIGKILL)
process.waitUntilExit()
}
}
if exitSignal.wait(timeout: .now() + timeout) == .timedOut {
if operation?.isCancelled == true {
terminateProcessAndWait()
throw TerminalImageTransferExecutionError.cancelled
}
terminateProcessAndWait()
throw NSError(domain: "cmux.detected-ssh.drop", code: 3, userInfo: [
NSLocalizedDescriptionKey: "scp timed out",
])
}
let stdout = String(
data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(),
encoding: .utf8
) ?? ""
let stderr = String(
data: stderrPipe.fileHandleForReading.readDataToEndOfFile(),
encoding: .utf8
) ?? ""
if operation?.isCancelled == true {
throw TerminalImageTransferExecutionError.cancelled
}
return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr)
}
private static func bestErrorLine(stderr: String, stdout: String) -> String? {
let stderrLine = stderr
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.first(where: { !$0.isEmpty })
if let stderrLine {
return stderrLine
}
return stdout
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.first(where: { !$0.isEmpty })
}
private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool {
let loweredKey = key.lowercased()
return options.contains { optionKey($0) == loweredKey }
}
private static func optionKey(_ option: String) -> String? {
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed
.split(whereSeparator: { $0 == "=" || $0.isWhitespace })
.first
.map(String.init)?
.lowercased()
}
#if DEBUG
func scpArgumentsForTesting(localPath: String, remotePath: String) -> [String] {
scpArguments(localPath: localPath, remotePath: remotePath)
}
#endif
}
enum TerminalSSHSessionDetector {
struct ProcessSnapshot: Equatable {
let pid: Int32
let pgid: Int32
let tpgid: Int32
let tty: String
let executableName: String
}
static func detect(forTTY ttyName: String) -> DetectedSSHSession? {
let normalizedTTY = normalizeTTYName(ttyName)
guard !normalizedTTY.isEmpty else { return nil }
let processes = processSnapshots(forTTY: normalizedTTY)
guard !processes.isEmpty else { return nil }
var argumentsByPID: [Int32: [String]] = [:]
for process in processes where isForegroundSSHProcess(process, ttyName: normalizedTTY) {
if let args = commandLineArguments(forPID: process.pid) {
argumentsByPID[process.pid] = args
}
}
return detectForTesting(
ttyName: normalizedTTY,
processes: processes,
argumentsByPID: argumentsByPID
)
}
static func detectForTesting(
ttyName: String,
processes: [ProcessSnapshot],
argumentsByPID: [Int32: [String]]
) -> DetectedSSHSession? {
let normalizedTTY = normalizeTTYName(ttyName)
guard !normalizedTTY.isEmpty else { return nil }
let candidates = processes
.filter { isForegroundSSHProcess($0, ttyName: normalizedTTY) }
.sorted { lhs, rhs in
if lhs.pid != rhs.pid { return lhs.pid > rhs.pid }
return lhs.pgid > rhs.pgid
}
for candidate in candidates {
guard let arguments = argumentsByPID[candidate.pid],
let session = parseSSHCommandLine(arguments) else {
continue
}
return session
}
return nil
}
private static let psPath = "/bin/ps"
private static let noArgumentFlags = Set("46AaCfGgKkMNnqsTtVvXxYy")
private static let valueArgumentFlags = Set("BbcDEeFIiJLlmOopQRSWw")
private static let filteredSSHOptionKeys: Set<String> = [
"batchmode",
"controlmaster",
"controlpersist",
"forkafterauthentication",
"localcommand",
"permitlocalcommand",
"remotecommand",
"requesttty",
"sendenv",
"sessiontype",
"setenv",
"stdioforward",
]
private static func normalizeTTYName(_ ttyName: String) -> String {
let trimmed = ttyName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
if let lastComponent = trimmed.split(separator: "/").last {
return String(lastComponent)
}
return trimmed
}
private static func isForegroundSSHProcess(_ process: ProcessSnapshot, ttyName: String) -> Bool {
normalizeTTYName(process.tty) == normalizeTTYName(ttyName) &&
process.executableName == "ssh" &&
process.pgid > 0 &&
process.tpgid > 0 &&
process.pgid == process.tpgid
}
private static func processSnapshots(forTTY ttyName: String) -> [ProcessSnapshot] {
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: psPath)
process.arguments = ["-ww", "-t", ttyName, "-o", "pid=,pgid=,tpgid=,tty=,ucomm="]
process.standardInput = FileHandle.nullDevice
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
} catch {
return []
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0,
let output = String(data: data, encoding: .utf8) else {
return []
}
return output
.split(separator: "\n")
.compactMap(parseProcessSnapshot)
}
private static func parseProcessSnapshot(_ line: Substring) -> ProcessSnapshot? {
let parts = line.split(maxSplits: 4, whereSeparator: \.isWhitespace)
guard parts.count == 5,
let pid = Int32(parts[0]),
let pgid = Int32(parts[1]),
let tpgid = Int32(parts[2]) else {
return nil
}
return ProcessSnapshot(
pid: pid,
pgid: pgid,
tpgid: tpgid,
tty: String(parts[3]),
executableName: String(parts[4]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
)
}
private static func commandLineArguments(forPID pid: Int32) -> [String]? {
var mib = [CTL_KERN, KERN_PROCARGS2, pid]
var size: size_t = 0
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, size > 4 else {
return nil
}
var buffer = [UInt8](repeating: 0, count: size)
let success = buffer.withUnsafeMutableBytes { rawBuffer in
sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0
}
guard success else { return nil }
return parseKernProcArgs(Array(buffer.prefix(Int(size))))
}
private static func parseKernProcArgs(_ bytes: [UInt8]) -> [String]? {
guard bytes.count > 4 else { return nil }
var argcRaw: Int32 = 0
withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in
rawBuffer.copyBytes(from: bytes.prefix(4))
}
let argc = Int(Int32(littleEndian: argcRaw))
guard argc > 0 else { return nil }
var index = 4
while index < bytes.count, bytes[index] != 0 {
index += 1
}
while index < bytes.count, bytes[index] == 0 {
index += 1
}
var arguments: [String] = []
while index < bytes.count, arguments.count < argc {
let start = index
while index < bytes.count, bytes[index] != 0 {
index += 1
}
guard let argument = String(bytes: bytes[start..<index], encoding: .utf8) else {
return nil
}
arguments.append(argument)
while index < bytes.count, bytes[index] == 0 {
index += 1
}
}
return arguments.count == argc ? arguments : nil
}
private static func parseSSHCommandLine(_ arguments: [String]) -> DetectedSSHSession? {
guard !arguments.isEmpty else { return nil }
var index = 0
if let executable = arguments.first?.split(separator: "/").last,
executable == "ssh" {
index = 1
}
var destination: String?
var port: Int?
var identityFile: String?
var configFile: String?
var jumpHost: String?
var controlPath: String?
var loginName: String?
var useIPv4 = false
var useIPv6 = false
var forwardAgent = false
var compressionEnabled = false
var sshOptions: [String] = []
func consumeValue(_ value: String, for option: Character) -> Bool {
let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedValue.isEmpty else { return false }
switch option {
case "p":
guard let parsedPort = Int(trimmedValue) else { return false }
port = parsedPort
return true
case "i":
identityFile = trimmedValue
return true
case "F":
configFile = trimmedValue
return true
case "J":
jumpHost = trimmedValue
return true
case "S":
controlPath = trimmedValue
return true
case "l":
loginName = trimmedValue
return true
case "o":
return consumeSSHOption(
trimmedValue,
port: &port,
identityFile: &identityFile,
controlPath: &controlPath,
jumpHost: &jumpHost,
loginName: &loginName,
sshOptions: &sshOptions
)
default:
return valueArgumentFlags.contains(option)
}
}
while index < arguments.count {
let argument = arguments[index]
if argument == "--" {
index += 1
if index < arguments.count {
destination = arguments[index]
}
break
}
if !argument.hasPrefix("-") || argument == "-" {
destination = argument
break
}
if argument.count > 2,
let option = argument.dropFirst().first,
valueArgumentFlags.contains(option) {
guard consumeValue(String(argument.dropFirst(2)), for: option) else { return nil }
index += 1
continue
}
if argument.count == 2,
let optionCharacter = argument.dropFirst().first,
valueArgumentFlags.contains(optionCharacter) {
let nextIndex = index + 1
guard nextIndex < arguments.count,
consumeValue(arguments[nextIndex], for: optionCharacter) else {
return nil
}
index += 2
continue
}
let flags = Array(argument.dropFirst())
guard !flags.isEmpty, flags.allSatisfy({ noArgumentFlags.contains($0) }) else {
return nil
}
for flag in flags {
switch flag {
case "4":
useIPv4 = true
useIPv6 = false
case "6":
useIPv6 = true
useIPv4 = false
case "A":
forwardAgent = true
case "C":
compressionEnabled = true
default:
break
}
}
index += 1
}
guard let destination else { return nil }
let finalDestination = resolveDestination(destination, loginName: loginName)
guard !finalDestination.isEmpty else { return nil }
return DetectedSSHSession(
destination: finalDestination,
port: port,
identityFile: identityFile,
configFile: configFile,
jumpHost: jumpHost,
controlPath: controlPath,
useIPv4: useIPv4,
useIPv6: useIPv6,
forwardAgent: forwardAgent,
compressionEnabled: compressionEnabled,
sshOptions: sshOptions
)
}
private static func consumeSSHOption(
_ option: String,
port: inout Int?,
identityFile: inout String?,
controlPath: inout String?,
jumpHost: inout String?,
loginName: inout String?,
sshOptions: inout [String]
) -> Bool {
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let key = sshOptionKey(trimmed)
let value = sshOptionValue(trimmed)
switch key {
case "port":
if let value, let parsedPort = Int(value) {
port = parsedPort
return true
}
return false
case "identityfile":
if let value, !value.isEmpty {
identityFile = value
return true
}
return false
case "controlpath":
if let value, !value.isEmpty {
controlPath = value
return true
}
return false
case "proxyjump":
if let value, !value.isEmpty {
jumpHost = value
return true
}
return false
case "user":
if let value, !value.isEmpty {
loginName = value
return true
}
return false
case let key? where filteredSSHOptionKeys.contains(key):
return true
case .some, .none:
sshOptions.append(trimmed)
return true
}
}
private static func resolveDestination(_ destination: String, loginName: String?) -> String {
let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedDestination.isEmpty else { return "" }
guard let loginName = loginName?.trimmingCharacters(in: .whitespacesAndNewlines),
!loginName.isEmpty,
!trimmedDestination.contains("@") else {
return trimmedDestination
}
return "\(loginName)@\(trimmedDestination)"
}
private static func sshOptionKey(_ option: String) -> String? {
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed
.split(whereSeparator: { $0 == "=" || $0.isWhitespace })
.first
.map(String.init)?
.lowercased()
}
private static func sshOptionValue(_ option: String) -> String? {
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let equalIndex = trimmed.firstIndex(of: "=") {
let value = trimmed[trimmed.index(after: equalIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
let parts = trimmed.split(maxSplits: 1, whereSeparator: \.isWhitespace)
guard parts.count == 2 else { return nil }
let value = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}

View file

@ -107,6 +107,35 @@ private struct SessionPaneRestoreEntry {
let snapshot: SessionPaneLayoutSnapshot
}
private enum RemoteDropUploadError: LocalizedError {
case unavailable
case invalidFileURL
case uploadFailed(String)
var errorDescription: String? {
switch self {
case .unavailable:
String(
localized: "error.remoteDrop.unavailable",
defaultValue: "Remote drop is unavailable."
)
case .invalidFileURL:
String(
localized: "error.remoteDrop.invalidFileURL",
defaultValue: "Dropped item is not a file URL."
)
case .uploadFailed(let detail):
String.localizedStringWithFormat(
String(
localized: "error.remoteDrop.uploadFailed",
defaultValue: "Failed to upload dropped file: %@"
),
detail
)
}
}
}
struct WorkspaceRemoteDaemonManifest: Decodable, Equatable {
struct Entry: Decodable, Equatable {
let goOS: String
@ -2945,6 +2974,58 @@ final class WorkspaceRemoteSessionController {
}
}
func uploadDroppedFiles(
_ fileURLs: [URL],
operation: TerminalImageTransferOperation,
completion: @escaping (Result<[String], Error>) -> Void
) {
queue.async { [weak self] in
guard let self else {
DispatchQueue.main.async {
completion(.failure(RemoteDropUploadError.unavailable))
}
return
}
do {
try operation.throwIfCancelled()
let remotePaths = try self.uploadDroppedFilesLocked(fileURLs, operation: operation)
try operation.throwIfCancelled()
DispatchQueue.main.async { [weak self] in
if operation.isCancelled {
guard let self else {
completion(.failure(TerminalImageTransferExecutionError.cancelled))
return
}
self.queue.async { [weak self] in
self?.cleanupUploadedRemotePaths(remotePaths)
DispatchQueue.main.async {
completion(.failure(TerminalImageTransferExecutionError.cancelled))
}
}
} else {
completion(.success(remotePaths))
}
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}
func uploadDroppedFiles(
_ fileURLs: [URL],
completion: @escaping (Result<[String], Error>) -> Void
) {
uploadDroppedFiles(
fileURLs,
operation: TerminalImageTransferOperation(),
completion: completion
)
}
private func stopAllLocked() {
debugLog("remote.session.stop \(debugConfigSummary())")
isStopping = true
@ -3442,12 +3523,17 @@ final class WorkspaceRemoteSessionController {
)
}
private func scpExec(arguments: [String], timeout: TimeInterval = 30) throws -> CommandResult {
private func scpExec(
arguments: [String],
timeout: TimeInterval = 30,
operation: TerminalImageTransferOperation? = nil
) throws -> CommandResult {
try runProcess(
executable: "/usr/bin/scp",
arguments: arguments,
stdin: nil,
timeout: timeout
timeout: timeout,
operation: operation
)
}
@ -3457,7 +3543,8 @@ final class WorkspaceRemoteSessionController {
environment: [String: String]? = nil,
currentDirectory: URL? = nil,
stdin: Data?,
timeout: TimeInterval
timeout: TimeInterval,
operation: TerminalImageTransferOperation? = nil
) throws -> CommandResult {
debugLog(
"remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
@ -3512,6 +3599,7 @@ final class WorkspaceRemoteSessionController {
}
do {
try operation?.throwIfCancelled()
try process.run()
} catch {
try? stdoutPipe.fileHandleForWriting.close()
@ -3526,20 +3614,34 @@ final class WorkspaceRemoteSessionController {
}
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForWriting.close()
operation?.installCancellationHandler {
if process.isRunning {
process.terminate()
}
}
defer { operation?.clearCancellationHandler() }
if let stdin, let pipe = process.standardInput as? Pipe {
pipe.fileHandleForWriting.write(stdin)
try? pipe.fileHandleForWriting.close()
}
let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success
if !didExitBeforeTimeout, process.isRunning {
func terminateProcessAndWait() {
process.terminate()
let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success
if !terminatedGracefully, process.isRunning {
_ = Darwin.kill(process.processIdentifier, SIGKILL)
process.waitUntilExit()
}
}
let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success
if !didExitBeforeTimeout, process.isRunning {
if operation?.isCancelled == true {
terminateProcessAndWait()
throw TerminalImageTransferExecutionError.cancelled
}
terminateProcessAndWait()
debugLog(
"remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
"timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))"
@ -3554,6 +3656,9 @@ final class WorkspaceRemoteSessionController {
try? stderrHandle.close()
let stdout = String(data: stdoutData, encoding: .utf8) ?? ""
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
if operation?.isCancelled == true {
throw TerminalImageTransferExecutionError.cancelled
}
debugLog(
"remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
"status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " +
@ -4005,6 +4110,70 @@ final class WorkspaceRemoteSessionController {
}
}
private func uploadDroppedFilesLocked(
_ fileURLs: [URL],
operation: TerminalImageTransferOperation
) throws -> [String] {
guard !fileURLs.isEmpty else { return [] }
let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions)
var uploadedRemotePaths: [String] = []
do {
for localURL in fileURLs {
try operation.throwIfCancelled()
let normalizedLocalURL = localURL.standardizedFileURL
guard normalizedLocalURL.isFileURL else {
throw RemoteDropUploadError.invalidFileURL
}
let remotePath = Self.remoteDropPath(for: normalizedLocalURL)
uploadedRemotePaths.append(remotePath)
var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"]
if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") {
scpArgs += ["-o", "StrictHostKeyChecking=accept-new"]
}
if let port = configuration.port {
scpArgs += ["-P", String(port)]
}
if let identityFile = configuration.identityFile,
!identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
scpArgs += ["-i", identityFile]
}
for option in scpSSHOptions {
scpArgs += ["-o", option]
}
scpArgs += [normalizedLocalURL.path, "\(configuration.destination):\(remotePath)"]
let scpResult = try scpExec(arguments: scpArgs, timeout: 45, operation: operation)
guard scpResult.status == 0 else {
let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ??
"scp exited \(scpResult.status)"
throw RemoteDropUploadError.uploadFailed(detail)
}
}
return uploadedRemotePaths
} catch {
cleanupUploadedRemotePaths(uploadedRemotePaths)
throw error
}
}
static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String {
let extensionSuffix = fileURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
let lowercasedSuffix = extensionSuffix.isEmpty ? "" : ".\(extensionSuffix.lowercased())"
return "/tmp/cmux-drop-\(uuid.uuidString.lowercased())\(lowercasedSuffix)"
}
private func cleanupUploadedRemotePaths(_ remotePaths: [String]) {
guard !remotePaths.isEmpty else { return }
let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ")
let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))"
_ = try? sshExec(
arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand],
timeout: 8
)
}
private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello {
let request = #"{"id":1,"method":"hello","params":{}}"#
let script = "printf '%s\\n' \(Self.shellSingleQuoted(request)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio"
@ -5410,6 +5579,8 @@ final class Workspace: Identifiable, ObservableObject {
let cachedTitle: String?
let customTitle: String?
let manuallyUnread: Bool
let isRemoteTerminal: Bool
let remoteRelayPort: Int?
}
private var detachingTabIds: Set<TabID> = []
@ -6217,6 +6388,11 @@ final class Workspace: Identifiable, ObservableObject {
remoteConfiguration != nil
}
@MainActor
func isRemoteTerminalSurface(_ panelId: UUID) -> Bool {
activeRemoteTerminalSurfaceIds.contains(panelId)
}
var remoteDisplayTarget: String? {
remoteConfiguration?.displayTarget
}
@ -6225,6 +6401,31 @@ final class Workspace: Identifiable, ObservableObject {
activeRemoteTerminalSessionCount > 0
}
@MainActor
func uploadDroppedFilesForRemoteTerminal(
_ fileURLs: [URL],
operation: TerminalImageTransferOperation,
completion: @escaping (Result<[String], Error>) -> Void
) {
guard let controller = remoteSessionController else {
completion(.failure(RemoteDropUploadError.unavailable))
return
}
controller.uploadDroppedFiles(fileURLs, operation: operation, completion: completion)
}
@MainActor
func uploadDroppedFilesForRemoteTerminal(
_ fileURLs: [URL],
completion: @escaping (Result<[String], Error>) -> Void
) {
uploadDroppedFilesForRemoteTerminal(
fileURLs,
operation: TerminalImageTransferOperation(),
completion: completion
)
}
func remoteStatusPayload() -> [String: Any] {
let heartbeatAgeSeconds: Any = {
guard let last = remoteLastHeartbeatAt else { return NSNull() }
@ -7725,6 +7926,11 @@ final class Workspace: Identifiable, ObservableObject {
}
surfaceIdToPanelId[newTabId] = detached.panelId
if detached.isRemoteTerminal,
let detachedRelayPort = detached.remoteRelayPort,
detachedRelayPort == remoteConfiguration?.relayPort {
trackRemoteTerminalSurface(detached.panelId)
}
if let index {
_ = bonsplitController.reorderTab(newTabId, toIndex: index)
}
@ -9530,7 +9736,11 @@ extension Workspace: BonsplitDelegate {
directory: panelDirectories[panelId],
cachedTitle: cachedTitle,
customTitle: panelCustomTitles[panelId],
manuallyUnread: manualUnreadPanelIds.contains(panelId)
manuallyUnread: manualUnreadPanelIds.contains(panelId),
isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId),
remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId)
? remoteConfiguration?.relayPort
: nil
)
} else {
if let closedBrowserRestoreSnapshot {

View file

@ -15,6 +15,17 @@ import UserNotifications
@MainActor
final class GhosttyPasteboardHelperTests: XCTestCase {
private func make1x1PNG(color: NSColor) throws -> Data {
let image = NSImage(size: NSSize(width: 1, height: 1))
image.lockFocus()
color.setFill()
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
image.unlockFocus()
let tiffData = try XCTUnwrap(image.tiffRepresentation)
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
return try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
}
func testHTMLOnlyPasteboardExtractsPlainText() {
let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)"))
pasteboard.clearContents()
@ -168,6 +179,363 @@ final class GhosttyPasteboardHelperTests: XCTestCase {
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
}
func testImageOnlyPasteboardProducesTempFileURL() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-drop-image-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setData(try make1x1PNG(color: .red), forType: .png)
let fileURL = try XCTUnwrap(cmuxPasteboardImageFileURLForTesting(pasteboard))
defer { try? FileManager.default.removeItem(at: fileURL) }
XCTAssertEqual(fileURL.pathExtension, "png")
XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path))
}
func testRemoteImageDropPlanUploadsMaterializedFile() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-remote-drop-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setData(try make1x1PNG(color: .green), forType: .png)
let plan = GhosttyNSView.dropPlanForTesting(
pasteboard: pasteboard,
isRemoteTerminalSurface: true
)
guard case .uploadFiles(let urls) = plan else {
return XCTFail("expected remote upload plan, got \(plan)")
}
defer { urls.forEach { try? FileManager.default.removeItem(at: $0) } }
XCTAssertEqual(urls.count, 1)
XCTAssertEqual(urls[0].pathExtension, "png")
}
func testLocalImageDropPlanInsertsEscapedLocalPath() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-local-drop-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setData(try make1x1PNG(color: .orange), forType: .png)
let plan = GhosttyNSView.dropPlanForTesting(
pasteboard: pasteboard,
isRemoteTerminalSurface: false
)
guard case .insertText(let text) = plan else {
return XCTFail("expected local insert plan, got \(plan)")
}
let localPath = text.replacingOccurrences(of: "\\", with: "")
defer { try? FileManager.default.removeItem(atPath: localPath) }
XCTAssertTrue(text.contains("clipboard-"))
XCTAssertTrue(text.hasSuffix(".png"))
XCTAssertTrue(FileManager.default.fileExists(atPath: localPath))
}
func testRemoteImagePastePlanUploadsMaterializedFile() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-remote-paste-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setData(try make1x1PNG(color: .cyan), forType: .png)
let plan = TerminalImageTransferPlanner.plan(
pasteboard: pasteboard,
mode: .paste,
target: .remote(.workspaceRemote)
)
guard case .uploadFiles(let urls, .workspaceRemote) = plan else {
return XCTFail("expected workspace upload plan, got \(plan)")
}
defer { urls.forEach { try? FileManager.default.removeItem(at: $0) } }
XCTAssertEqual(urls.count, 1)
XCTAssertEqual(urls[0].pathExtension, "png")
}
func testRemoteFileURLPastePlanUploadsReadableFile() throws {
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-image-\(UUID().uuidString).png")
try make1x1PNG(color: .systemPink).write(to: fileURL)
defer { try? FileManager.default.removeItem(at: fileURL) }
let pasteboard = NSPasteboard(name: .init("cmux-test-remote-file-url-paste-\(UUID().uuidString)"))
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL]))
let plan = TerminalImageTransferPlanner.plan(
pasteboard: pasteboard,
mode: .paste,
target: .remote(.workspaceRemote)
)
guard case .uploadFiles(let urls, .workspaceRemote) = plan else {
return XCTFail("expected workspace upload plan, got \(plan)")
}
XCTAssertEqual(urls, [fileURL])
}
func testLazyPastePlanSkipsTargetResolutionForPlainText() {
let pasteboard = NSPasteboard(name: .init("cmux-test-lazy-text-paste-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setString("hello from clipboard", forType: .string)
var targetResolutionCount = 0
let plan = TerminalImageTransferPlanner.plan(
pasteboard: pasteboard,
mode: .paste,
resolveTarget: {
targetResolutionCount += 1
return .remote(.workspaceRemote)
}
)
XCTAssertEqual(plan, .insertText("hello from clipboard"))
XCTAssertEqual(targetResolutionCount, 0)
}
func testLazyPastePlanResolvesTargetForFileURLPaste() throws {
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-image-\(UUID().uuidString).png")
try make1x1PNG(color: .systemTeal).write(to: fileURL)
defer { try? FileManager.default.removeItem(at: fileURL) }
let pasteboard = NSPasteboard(name: .init("cmux-test-lazy-file-paste-\(UUID().uuidString)"))
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL]))
var targetResolutionCount = 0
let plan = TerminalImageTransferPlanner.plan(
pasteboard: pasteboard,
mode: .paste,
resolveTarget: {
targetResolutionCount += 1
return .remote(.workspaceRemote)
}
)
guard case .uploadFiles(let urls, .workspaceRemote) = plan else {
return XCTFail("expected workspace upload plan, got \(plan)")
}
XCTAssertEqual(urls, [fileURL])
XCTAssertEqual(targetResolutionCount, 1)
}
func testLocalImagePastePlanInsertsEscapedLocalPath() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-local-paste-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setData(try make1x1PNG(color: .magenta), forType: .png)
let plan = TerminalImageTransferPlanner.plan(
pasteboard: pasteboard,
mode: .paste,
target: .local
)
guard case .insertText(let text) = plan else {
return XCTFail("expected local insert plan, got \(plan)")
}
let localPath = text.replacingOccurrences(of: "\\", with: "")
defer { try? FileManager.default.removeItem(atPath: localPath) }
XCTAssertTrue(text.contains("clipboard-"))
XCTAssertTrue(text.hasSuffix(".png"))
XCTAssertTrue(FileManager.default.fileExists(atPath: localPath))
}
func testRemoteImagePasteExecutionUploadsAndCompletesWithRemotePath() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-test.png")
try make1x1PNG(color: .yellow).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
var completedText: String?
TerminalImageTransferPlanner.executeForTesting(
plan: .uploadFiles([url], .workspaceRemote),
uploadWorkspaceRemote: { _, _, finish in finish(.success(["/tmp/cmux-drop-123.png"])) },
uploadDetectedSSH: { _, _, _, finish in finish(.failure(NSError(domain: "unused", code: 0))) },
insertText: { completedText = $0 },
onFailure: { _ in XCTFail("unexpected failure") }
)
XCTAssertEqual(completedText, "/tmp/cmux-drop-123.png")
}
func testCancelledRemoteImagePasteExecutionSuppressesCompletionHandlers() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-cancel-test.png")
try make1x1PNG(color: .brown).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
let operation = TerminalImageTransferOperation()
var completion: ((Result<[String], Error>) -> Void)?
var cancellationHandlerCalls = 0
var insertedTexts: [String] = []
var failureCount = 0
let returnedOperation = TerminalImageTransferPlanner.executeForTesting(
plan: .uploadFiles([url], .workspaceRemote),
operation: operation,
uploadWorkspaceRemote: { _, operation, finish in
operation.installCancellationHandler {
cancellationHandlerCalls += 1
}
completion = finish
},
uploadDetectedSSH: { _, _, _, finish in
finish(.failure(NSError(domain: "unused", code: 0)))
},
insertText: { insertedTexts.append($0) },
onFailure: { _ in failureCount += 1 }
)
XCTAssertTrue(returnedOperation === operation)
XCTAssertTrue(operation.cancel())
completion?(.success(["/tmp/cmux-drop-cancelled.png"]))
XCTAssertEqual(cancellationHandlerCalls, 1)
XCTAssertTrue(insertedTexts.isEmpty)
XCTAssertEqual(failureCount, 0)
}
func testCancelledOperationSuppressesLateLocalInsert() {
let operation = TerminalImageTransferOperation()
var insertedTexts: [String] = []
var failureCount = 0
XCTAssertTrue(operation.cancel())
let returnedOperation = TerminalImageTransferPlanner.executeForTesting(
plan: .insertText("/tmp/cmux-drop-local.png"),
operation: operation,
uploadWorkspaceRemote: { _, _, finish in
finish(.failure(NSError(domain: "unused", code: 0)))
},
uploadDetectedSSH: { _, _, _, finish in
finish(.failure(NSError(domain: "unused", code: 0)))
},
insertText: { insertedTexts.append($0) },
onFailure: { _ in failureCount += 1 }
)
XCTAssertTrue(returnedOperation === operation)
XCTAssertTrue(insertedTexts.isEmpty)
XCTAssertEqual(failureCount, 0)
}
func testRemoteUploadResultEscapesSpacesBeforePaste() {
let escaped = TerminalImageTransferPlanner.escapeForShell("/tmp/Screen Shot.png")
XCTAssertEqual(escaped, "/tmp/Screen\\ Shot.png")
}
func testRemoteUploadResultSingleQuotesEmbeddedNewlinesBeforePaste() {
let escaped = TerminalImageTransferPlanner.escapeForShell("/tmp/Screen\nShot\r.png")
XCTAssertEqual(escaped, "'/tmp/Screen\nShot\r.png'")
}
func testRemoteImageDropHandlerUploadsAndSendsRemotePath() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-remote-handler-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setData(try make1x1PNG(color: .purple), forType: .png)
var uploadedURLs: [URL] = []
var sentText: [String] = []
var failureCount = 0
let handled = GhosttyNSView.handleDropForTesting(
pasteboard: pasteboard,
isRemoteTerminalSurface: true,
uploadRemote: { urls, finish in
uploadedURLs = urls
finish(.success(["/tmp/cmux-drop-abc123.png"]))
},
sendText: { sentText.append($0) },
onFailure: { failureCount += 1 }
)
defer { uploadedURLs.forEach { try? FileManager.default.removeItem(at: $0) } }
XCTAssertTrue(handled)
XCTAssertEqual(uploadedURLs.count, 1)
XCTAssertEqual(sentText, ["/tmp/cmux-drop-abc123.png"])
XCTAssertEqual(failureCount, 0)
}
func testRemoteImageDropHandlerCleansUpMaterializedTemporaryImageAfterSuccess() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-remote-handler-cleanup-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setData(try make1x1PNG(color: .orange), forType: .png)
var uploadedURL: URL?
let handled = GhosttyNSView.handleDropForTesting(
pasteboard: pasteboard,
isRemoteTerminalSurface: true,
uploadRemote: { urls, finish in
uploadedURL = urls.first
XCTAssertEqual(urls.count, 1)
XCTAssertTrue(FileManager.default.fileExists(atPath: urls[0].path))
finish(.success(["/tmp/cmux-drop-abc123.png"]))
},
sendText: { _ in },
onFailure: {}
)
XCTAssertTrue(handled)
let url = try XCTUnwrap(uploadedURL)
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
}
func testRemoteDropUploadFailureTriggersFailureHandler() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-remote-handler-fail-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setData(try make1x1PNG(color: .black), forType: .png)
var uploadedURLs: [URL] = []
var sentText: [String] = []
var failureCount = 0
let handled = GhosttyNSView.handleDropForTesting(
pasteboard: pasteboard,
isRemoteTerminalSurface: true,
uploadRemote: { urls, finish in
uploadedURLs = urls
finish(.failure(NSError(domain: "test", code: 1)))
},
sendText: { sentText.append($0) },
onFailure: { failureCount += 1 }
)
defer { uploadedURLs.forEach { try? FileManager.default.removeItem(at: $0) } }
XCTAssertTrue(handled)
XCTAssertEqual(uploadedURLs.count, 1)
XCTAssertTrue(sentText.isEmpty)
XCTAssertEqual(failureCount, 1)
}
func testRemoteImageDropHandlerCleansUpMaterializedTemporaryImageAfterFailure() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-remote-handler-failure-cleanup-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setData(try make1x1PNG(color: .cyan), forType: .png)
var uploadedURL: URL?
let handled = GhosttyNSView.handleDropForTesting(
pasteboard: pasteboard,
isRemoteTerminalSurface: true,
uploadRemote: { urls, finish in
uploadedURL = urls.first
XCTAssertEqual(urls.count, 1)
XCTAssertTrue(FileManager.default.fileExists(atPath: urls[0].path))
finish(.failure(NSError(domain: "test", code: 1)))
},
sendText: { _ in XCTFail("unexpected sendText") },
onFailure: {}
)
XCTAssertTrue(handled)
let url = try XCTUnwrap(uploadedURL)
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
}
}

View file

@ -61,6 +61,57 @@ final class WorkspaceRemoteConnectionTests: XCTestCase {
)
}
private func writeShellFile(at url: URL, lines: [String]) throws {
try lines.joined(separator: "\n")
.appending("\n")
.write(to: url, atomically: true, encoding: .utf8)
}
private func runRelayZshHistfile(
configureUserHome: (URL) throws -> URL
) throws -> String {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-zsh-\(UUID().uuidString)")
let relayDir = home.appendingPathComponent(".cmux/relay/64011.shell")
try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: home) }
let effectiveUserZdotdir = try configureUserHome(home)
let bootstrap = RemoteRelayZshBootstrap(shellStateDir: relayDir.path)
try writeShellFile(at: relayDir.appendingPathComponent(".zshenv"), lines: bootstrap.zshEnvLines)
try writeShellFile(at: relayDir.appendingPathComponent(".zprofile"), lines: bootstrap.zshProfileLines)
try writeShellFile(at: relayDir.appendingPathComponent(".zshrc"), lines: bootstrap.zshRCLines(commonShellLines: []))
try writeShellFile(at: relayDir.appendingPathComponent(".zlogin"), lines: bootstrap.zshLoginLines)
let result = runProcess(
executablePath: "/usr/bin/env",
arguments: [
"HOME=\(home.path)",
"TERM=xterm-256color",
"SHELL=/bin/zsh",
"USER=\(NSUserName())",
"CMUX_REAL_ZDOTDIR=\(home.path)",
"ZDOTDIR=\(relayDir.path)",
"/bin/zsh",
"-ilc",
"print -r -- \"$HISTFILE\"",
],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
let histfile = result.stdout
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.last(where: { !$0.isEmpty })
XCTAssertEqual(histfile, effectiveUserZdotdir.appendingPathComponent(".zsh_history").path)
return histfile ?? ""
}
func testRemoteRelayMetadataCleanupScriptRemovesMatchingSocketAddr() {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-\(UUID().uuidString)")
@ -125,6 +176,32 @@ final class WorkspaceRemoteConnectionTests: XCTestCase {
XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path))
}
func testRelayZshBootstrapUsesRealHomeHistoryByDefault() throws {
let histfile = try runRelayZshHistfile { home in
try ":\n".write(to: home.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8)
try ":\n".write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
return home
}
XCTAssertTrue(histfile.hasSuffix("/.zsh_history"))
}
func testRelayZshBootstrapUsesUserUpdatedZdotdirHistory() throws {
let histfile = try runRelayZshHistfile { home in
let altZdotdir = home.appendingPathComponent("dotfiles")
try FileManager.default.createDirectory(at: altZdotdir, withIntermediateDirectories: true)
try "export ZDOTDIR=\"$HOME/dotfiles\"\n".write(
to: home.appendingPathComponent(".zshenv"),
atomically: true,
encoding: .utf8
)
try ":\n".write(to: altZdotdir.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
return altZdotdir
}
XCTAssertTrue(histfile.contains("/dotfiles/.zsh_history"))
}
func testReverseRelayStartupFailureDetailCapturesImmediateForwardingFailure() throws {
let process = Process()
let stderrPipe = Pipe()
@ -145,6 +222,196 @@ final class WorkspaceRemoteConnectionTests: XCTestCase {
XCTAssertEqual(detail, "remote port forwarding failed for listen port 64009")
}
@MainActor
func testRemoteTerminalSurfaceLookupTracksOnlyActiveSSHSurfaces() throws {
let workspace = Workspace()
let config = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64007,
relayID: String(repeating: "a", count: 16),
relayToken: String(repeating: "b", count: 64),
localSocketPath: "/tmp/cmux-debug-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
workspace.configureRemoteConnection(config, autoConnect: false)
let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id)
XCTAssertTrue(workspace.isRemoteTerminalSurface(panelID))
workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64007)
XCTAssertFalse(workspace.isRemoteTerminalSurface(panelID))
}
func testRemoteDropPathUsesLowercasedExtensionAndProvidedUUID() throws {
let fileURL = URL(fileURLWithPath: "/Users/test/Screen Shot.PNG")
let uuid = try XCTUnwrap(UUID(uuidString: "12345678-1234-1234-1234-1234567890AB"))
let remotePath = WorkspaceRemoteSessionController.remoteDropPath(for: fileURL, uuid: uuid)
XCTAssertEqual(remotePath, "/tmp/cmux-drop-12345678-1234-1234-1234-1234567890ab.png")
}
@MainActor
func testDetachAttachPreservesRemoteTerminalSurfaceTracking() throws {
let workspace = Workspace()
let config = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64007,
relayID: String(repeating: "a", count: 16),
relayToken: String(repeating: "b", count: 64),
localSocketPath: "/tmp/cmux-debug-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
workspace.configureRemoteConnection(config, autoConnect: false)
let originalPanelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id)
let originalPaneID = try XCTUnwrap(workspace.paneId(forPanelId: originalPanelID))
let movedPanel = try XCTUnwrap(
workspace.newTerminalSplit(from: originalPanelID, orientation: .horizontal)
)
XCTAssertTrue(workspace.isRemoteTerminalSurface(originalPanelID))
XCTAssertTrue(workspace.isRemoteTerminalSurface(movedPanel.id))
let detached = try XCTUnwrap(workspace.detachSurface(panelId: movedPanel.id))
XCTAssertTrue(detached.isRemoteTerminal)
XCTAssertEqual(detached.remoteRelayPort, config.relayPort)
let restoredPanelID = workspace.attachDetachedSurface(
detached,
inPane: originalPaneID,
focus: false
)
XCTAssertEqual(restoredPanelID, movedPanel.id)
XCTAssertTrue(workspace.isRemoteTerminalSurface(movedPanel.id))
}
func testDetectsForegroundSSHSessionForTTY() {
let session = TerminalSSHSessionDetector.detectForTesting(
ttyName: "/dev/ttys004",
processes: [
.init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"),
],
argumentsByPID: [
2145: [
"ssh",
"-o", "ControlMaster=auto",
"-o", "ControlPath=/tmp/cmux-ssh-%C",
"-o", "StrictHostKeyChecking=accept-new",
"-p", "2200",
"-i", "/Users/test/.ssh/id_ed25519",
"lawrence@example.com",
],
]
)
XCTAssertEqual(
session,
DetectedSSHSession(
destination: "lawrence@example.com",
port: 2200,
identityFile: "/Users/test/.ssh/id_ed25519",
configFile: nil,
jumpHost: nil,
controlPath: "/tmp/cmux-ssh-%C",
useIPv4: false,
useIPv6: false,
forwardAgent: false,
compressionEnabled: false,
sshOptions: [
"StrictHostKeyChecking=accept-new",
]
)
)
}
func testDetectsForegroundSSHSessionWithShortControlPathFlag() {
let session = TerminalSSHSessionDetector.detectForTesting(
ttyName: "/dev/ttys004",
processes: [
.init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"),
],
argumentsByPID: [
2145: [
"ssh",
"-S", "/tmp/cmux-ssh-%C",
"-p", "2200",
"lawrence@example.com",
],
]
)
XCTAssertEqual(session?.controlPath, "/tmp/cmux-ssh-%C")
let scpArgs = session?.scpArgumentsForTesting(
localPath: "/tmp/local.png",
remotePath: "/tmp/cmux-drop-123.png"
) ?? []
XCTAssertTrue(scpArgs.contains("ControlPath=/tmp/cmux-ssh-%C"))
XCTAssertFalse(scpArgs.contains("-S"))
}
func testDetectsForegroundSSHSessionWithLowercaseAgentFlag() {
let session = TerminalSSHSessionDetector.detectForTesting(
ttyName: "/dev/ttys004",
processes: [
.init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"),
],
argumentsByPID: [
2145: [
"ssh",
"-a",
"lawrence@example.com",
],
]
)
XCTAssertEqual(session?.destination, "lawrence@example.com")
XCTAssertFalse(session?.forwardAgent ?? true)
}
func testDetectsForegroundSSHSessionIgnoringBindInterfaceValue() {
let session = TerminalSSHSessionDetector.detectForTesting(
ttyName: "/dev/ttys004",
processes: [
.init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"),
],
argumentsByPID: [
2145: [
"ssh",
"-B", "en0",
"lawrence@example.com",
],
]
)
XCTAssertEqual(session?.destination, "lawrence@example.com")
}
func testIgnoresBackgroundSSHProcessForTTY() {
let session = TerminalSSHSessionDetector.detectForTesting(
ttyName: "ttys004",
processes: [
.init(pid: 2145, pgid: 2145, tpgid: 1967, tty: "ttys004", executableName: "ssh"),
],
argumentsByPID: [
2145: ["ssh", "lawrence@example.com"],
]
)
XCTAssertNil(session)
}
@MainActor
func testProxyOnlyErrorsKeepSSHWorkspaceConnectedAndLoggedInSidebar() {
let workspace = Workspace()