diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2d762f0a..0f9246cf 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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\"", diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index deeb1546..01d6d605 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -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 = ""; }; A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = ""; }; + A5001544 /* TerminalImageTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalImageTransfer.swift; sourceTree = ""; }; + A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; @@ -231,6 +237,7 @@ A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = ""; }; A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; + A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = ""; }; @@ -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 = ""; @@ -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; }; diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 491d354d..83254ff5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -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": "ドロップされたファイルのアップロードに失敗しました: %@" + } + } + } } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a6794e5c..d2f3ad41 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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)" diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 17fe87ab..4302bd21 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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 = [ .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) } - .joined(separator: " ") + 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() + } + } + } + + @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 + } + + 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 + ) } - if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty { - return Self.escapeDropForShell(rawURL) - } + 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 + } - if let str = pasteboard.string(forType: .string), !str.isEmpty { - return str + private func resolvedImageTransferTarget() -> TerminalImageTransferTarget { + MainActor.assumeIsolated { + terminalSurface?.resolvedImageTransferTarget() ?? .local } + } - return nil + 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) - return true + 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? 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? { diff --git a/Sources/RemoteRelayZshBootstrap.swift b/Sources/RemoteRelayZshBootstrap.swift new file mode 100644 index 00000000..d09a83c3 --- /dev/null +++ b/Sources/RemoteRelayZshBootstrap.swift @@ -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\"", + ] + } +} diff --git a/Sources/TerminalImageTransfer.swift b/Sources/TerminalImageTransfer.swift new file mode 100644 index 00000000..aeb00e5d --- /dev/null +++ b/Sources/TerminalImageTransfer.swift @@ -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 + } +} diff --git a/Sources/TerminalSSHSessionDetector.swift b/Sources/TerminalSSHSessionDetector.swift new file mode 100644 index 00000000..3186e486 --- /dev/null +++ b/Sources/TerminalSSHSessionDetector.swift @@ -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 = [ + "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.. 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 + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b2c0ca68..da7df21c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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 = [] @@ -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 { diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 9faffe0a..0f6a1101 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -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)) + } } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 5bf2fc3c..797c96a9 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -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()