Support image drag-and-drop into SSH terminals (#1838)
* Add remote image drag-and-drop uploads * test: cover ssh image paste planning * fix: upload images pasted into ssh terminals * fix: share zsh history in cmux ssh relay shells * fix: add cancellable ssh image transfer indicator * fix: harden async ssh image transfer callbacks * fix: address ssh image upload review feedback --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
parent
8286c90863
commit
4376e6e19a
11 changed files with 2469 additions and 74 deletions
|
|
@ -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\"",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; };
|
||||
A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; };
|
||||
A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; };
|
||||
A5001542 /* TerminalImageTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001544 /* TerminalImageTransfer.swift */; };
|
||||
A5001543 /* TerminalSSHSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001545 /* TerminalSSHSessionDetector.swift */; };
|
||||
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
|
||||
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
|
||||
A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; };
|
||||
|
|
@ -65,9 +67,11 @@
|
|||
A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; };
|
||||
A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; };
|
||||
A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; };
|
||||
A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; };
|
||||
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
|
||||
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
|
||||
B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; };
|
||||
B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; };
|
||||
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; };
|
||||
C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; };
|
||||
D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; };
|
||||
|
|
@ -185,6 +189,8 @@
|
|||
A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; };
|
||||
A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = "<group>"; };
|
||||
A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = "<group>"; };
|
||||
A5001544 /* TerminalImageTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalImageTransfer.swift; sourceTree = "<group>"; };
|
||||
A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = "<group>"; };
|
||||
A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
|
||||
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
|
||||
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
|
|
@ -231,6 +237,7 @@
|
|||
A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = "<group>"; };
|
||||
A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = "<group>"; };
|
||||
A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; };
|
||||
A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = "<group>"; };
|
||||
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
|
||||
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; };
|
||||
B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -413,6 +420,8 @@
|
|||
A5001533 /* BrowserWindowPortal.swift */,
|
||||
A5001019 /* TerminalController.swift */,
|
||||
A5001541 /* PortScanner.swift */,
|
||||
A5001544 /* TerminalImageTransfer.swift */,
|
||||
A5001545 /* TerminalSSHSessionDetector.swift */,
|
||||
A5001225 /* SocketControlSettings.swift */,
|
||||
A5001600 /* SentryHelper.swift */,
|
||||
A5001620 /* AppleScriptSupport.swift */,
|
||||
|
|
@ -448,6 +457,7 @@
|
|||
A5001241 /* WindowDecorationsController.swift */,
|
||||
A5001222 /* WindowAccessor.swift */,
|
||||
A5001611 /* SessionPersistence.swift */,
|
||||
A5001641 /* RemoteRelayZshBootstrap.swift */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -705,6 +715,8 @@
|
|||
A5001534 /* BrowserWindowPortal.swift in Sources */,
|
||||
A5001007 /* TerminalController.swift in Sources */,
|
||||
A5001540 /* PortScanner.swift in Sources */,
|
||||
A5001542 /* TerminalImageTransfer.swift in Sources */,
|
||||
A5001543 /* TerminalSSHSessionDetector.swift in Sources */,
|
||||
A5001226 /* SocketControlSettings.swift in Sources */,
|
||||
A5001601 /* SentryHelper.swift in Sources */,
|
||||
A5001621 /* AppleScriptSupport.swift in Sources */,
|
||||
|
|
@ -740,6 +752,7 @@
|
|||
A5001240 /* WindowDecorationsController.swift in Sources */,
|
||||
A500120C /* WindowAccessor.swift in Sources */,
|
||||
A5001610 /* SessionPersistence.swift in Sources */,
|
||||
A5001640 /* RemoteRelayZshBootstrap.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -805,6 +818,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */,
|
||||
B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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": "ドロップされたファイルのアップロードに失敗しました: %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -70,12 +70,13 @@ private func cmuxScalarHex(_ value: String?) -> String {
|
|||
}
|
||||
#endif
|
||||
|
||||
private enum GhosttyPasteboardHelper {
|
||||
enum GhosttyPasteboardHelper {
|
||||
private static let selectionPasteboard = NSPasteboard(
|
||||
name: NSPasteboard.Name("com.mitchellh.ghostty.selection")
|
||||
)
|
||||
private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text")
|
||||
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
|
||||
private static let temporaryImageFilenamePrefix = "clipboard-"
|
||||
private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!)
|
||||
|
||||
static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? {
|
||||
|
|
@ -139,6 +140,9 @@ private enum GhosttyPasteboardHelper {
|
|||
}
|
||||
|
||||
static func escapeForShell(_ value: String) -> String {
|
||||
if value.contains(where: { $0 == "\n" || $0 == "\r" }) {
|
||||
return shellSingleQuoted(value)
|
||||
}
|
||||
var result = value
|
||||
for char in shellEscapeCharacters {
|
||||
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
|
||||
|
|
@ -146,6 +150,11 @@ private enum GhosttyPasteboardHelper {
|
|||
return result
|
||||
}
|
||||
|
||||
private static func shellSingleQuoted(_ value: String) -> String {
|
||||
let escaped = value.replacingOccurrences(of: "'", with: "'\\''")
|
||||
return "'\(escaped)'"
|
||||
}
|
||||
|
||||
private static func attributedStringContents(
|
||||
from pasteboard: NSPasteboard,
|
||||
type: NSPasteboard.PasteboardType,
|
||||
|
|
@ -295,11 +304,11 @@ private enum GhosttyPasteboardHelper {
|
|||
|
||||
/// When the clipboard contains only image data (or rich text that resolves to
|
||||
/// an attachment-only image), saves it as a temporary image file and returns the
|
||||
/// shell-escaped file path. Returns nil if the clipboard contains text or no image.
|
||||
static func saveClipboardImageIfNeeded(
|
||||
/// file URL. Returns nil if the clipboard contains text or no image.
|
||||
static func saveImageFileURLIfNeeded(
|
||||
from pasteboard: NSPasteboard = .general,
|
||||
assumeNoText: Bool = false
|
||||
) -> String? {
|
||||
) -> URL? {
|
||||
if !assumeNoText && stringContents(from: pasteboard) != nil { return nil }
|
||||
|
||||
let imageData: Data
|
||||
|
|
@ -333,10 +342,10 @@ private enum GhosttyPasteboardHelper {
|
|||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
let timestamp = formatter.string(from: Date())
|
||||
let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
|
||||
let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename)
|
||||
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
||||
|
||||
do {
|
||||
try imageData.write(to: URL(fileURLWithPath: path))
|
||||
try imageData.write(to: fileURL)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
|
||||
|
|
@ -344,7 +353,31 @@ private enum GhosttyPasteboardHelper {
|
|||
return nil
|
||||
}
|
||||
|
||||
return escapeForShell(path)
|
||||
return fileURL
|
||||
}
|
||||
|
||||
/// When the clipboard contains only image data (or rich text that resolves to
|
||||
/// an attachment-only image), saves it as a temporary image file and returns the
|
||||
/// shell-escaped file path. Returns nil if the clipboard contains text or no image.
|
||||
static func saveClipboardImageIfNeeded(
|
||||
from pasteboard: NSPasteboard = .general,
|
||||
assumeNoText: Bool = false
|
||||
) -> String? {
|
||||
saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: assumeNoText)
|
||||
.map { escapeForShell($0.path) }
|
||||
}
|
||||
|
||||
static func cleanupTransferredTemporaryImageFiles(_ fileURLs: [URL]) {
|
||||
let temporaryDirectory = FileManager.default.temporaryDirectory.standardizedFileURL
|
||||
for fileURL in fileURLs {
|
||||
let normalizedURL = fileURL.standardizedFileURL
|
||||
guard normalizedURL.isFileURL,
|
||||
normalizedURL.deletingLastPathComponent() == temporaryDirectory,
|
||||
normalizedURL.lastPathComponent.hasPrefix(temporaryImageFilenamePrefix) else {
|
||||
continue
|
||||
}
|
||||
try? FileManager.default.removeItem(at: normalizedURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -353,6 +386,10 @@ func cmuxPasteboardStringContentsForTesting(_ pasteboard: NSPasteboard) -> Strin
|
|||
GhosttyPasteboardHelper.stringContents(from: pasteboard)
|
||||
}
|
||||
|
||||
func cmuxPasteboardImageFileURLForTesting(_ pasteboard: NSPasteboard) -> URL? {
|
||||
GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard)
|
||||
}
|
||||
|
||||
func cmuxPasteboardImagePathForTesting(_ pasteboard: NSPasteboard) -> String? {
|
||||
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: pasteboard)
|
||||
}
|
||||
|
|
@ -1046,25 +1083,111 @@ class GhosttyApp {
|
|||
return GhosttyApp.shared.handleAction(target: target, action: action)
|
||||
}
|
||||
runtimeConfig.read_clipboard_cb = { userdata, location, state in
|
||||
// Read clipboard
|
||||
guard let callbackContext = GhosttyApp.callbackContext(from: userdata),
|
||||
let surface = callbackContext.runtimeSurface else { return }
|
||||
guard let callbackContext = GhosttyApp.callbackContext(from: userdata) else { return }
|
||||
|
||||
let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location)
|
||||
var value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? ""
|
||||
DispatchQueue.main.async {
|
||||
guard let requestSurface = callbackContext.runtimeSurface else { return }
|
||||
|
||||
// When clipboard has only image data (e.g. screenshot), save as temp
|
||||
// PNG and paste the file path so CLI tools can receive images.
|
||||
if value.isEmpty,
|
||||
let imagePath = pasteboard.flatMap({
|
||||
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: $0, assumeNoText: true)
|
||||
})
|
||||
{
|
||||
value = imagePath
|
||||
}
|
||||
func completeClipboardRequest(with text: String) {
|
||||
let finish = {
|
||||
guard callbackContext.runtimeSurface == requestSurface else { return }
|
||||
text.withCString { ptr in
|
||||
ghostty_surface_complete_clipboard_request(requestSurface, ptr, state, false)
|
||||
}
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
finish()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: finish)
|
||||
}
|
||||
}
|
||||
|
||||
value.withCString { ptr in
|
||||
ghostty_surface_complete_clipboard_request(surface, ptr, state, false)
|
||||
guard let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) else {
|
||||
completeClipboardRequest(with: "")
|
||||
return
|
||||
}
|
||||
|
||||
let preparedContent = TerminalImageTransferPlanner.prepare(
|
||||
pasteboard: pasteboard,
|
||||
mode: .paste
|
||||
)
|
||||
|
||||
switch preparedContent {
|
||||
case .reject:
|
||||
completeClipboardRequest(with: "")
|
||||
case .insertText(let text):
|
||||
completeClipboardRequest(with: text)
|
||||
case .fileURLs(let fileURLs):
|
||||
let operation = TerminalImageTransferOperation()
|
||||
MainActor.assumeIsolated {
|
||||
callbackContext.terminalSurface?.hostedView.beginImageTransferIndicator(
|
||||
for: operation,
|
||||
onCancel: {
|
||||
completeClipboardRequest(with: "")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let target = MainActor.assumeIsolated {
|
||||
callbackContext.terminalSurface?.resolvedImageTransferTarget() ?? .local
|
||||
}
|
||||
let plan = TerminalImageTransferPlanner.plan(
|
||||
fileURLs: fileURLs,
|
||||
target: target
|
||||
)
|
||||
|
||||
TerminalImageTransferPlanner.execute(
|
||||
plan: plan,
|
||||
operation: operation,
|
||||
uploadWorkspaceRemote: { fileURLs, operation, finish in
|
||||
guard let workspace = MainActor.assumeIsolated({
|
||||
callbackContext.terminalSurface?.owningWorkspace()
|
||||
}) else {
|
||||
finish(.failure(NSError(domain: "cmux.remote.paste", code: 3)))
|
||||
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
|
||||
return
|
||||
}
|
||||
workspace.uploadDroppedFilesForRemoteTerminal(
|
||||
fileURLs,
|
||||
operation: operation,
|
||||
completion: { result in
|
||||
finish(result)
|
||||
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
|
||||
}
|
||||
)
|
||||
},
|
||||
uploadDetectedSSH: { session, fileURLs, operation, finish in
|
||||
session.uploadDroppedFiles(
|
||||
fileURLs,
|
||||
operation: operation,
|
||||
completion: { result in
|
||||
finish(result)
|
||||
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
|
||||
}
|
||||
)
|
||||
},
|
||||
insertText: { text in
|
||||
MainActor.assumeIsolated {
|
||||
callbackContext.terminalSurface?.hostedView.endImageTransferIndicator(
|
||||
for: operation
|
||||
)
|
||||
}
|
||||
completeClipboardRequest(with: text)
|
||||
},
|
||||
onFailure: { _ in
|
||||
MainActor.assumeIsolated {
|
||||
callbackContext.terminalSurface?.hostedView.endImageTransferIndicator(
|
||||
for: operation
|
||||
)
|
||||
}
|
||||
NSSound.beep()
|
||||
#if DEBUG
|
||||
dlog("terminal.remotePasteUpload.failed surface=\(callbackContext.surfaceId.uuidString.prefix(5))")
|
||||
#endif
|
||||
completeClipboardRequest(with: "")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in
|
||||
|
|
@ -3730,6 +3853,13 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
extension TerminalSurface {
|
||||
@MainActor
|
||||
func owningWorkspace() -> Workspace? {
|
||||
AppDelegate.shared?.workspaceFor(tabId: tabId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ghostty Surface View
|
||||
|
||||
class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||
|
|
@ -3739,14 +3869,25 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
return UserDefaults.standard.bool(forKey: "cmuxFocusDebug")
|
||||
}()
|
||||
internal enum DropPlan: Equatable {
|
||||
case insertText(String)
|
||||
case uploadFiles([URL])
|
||||
case reject
|
||||
}
|
||||
|
||||
private static let dropTypes: Set<NSPasteboard.PasteboardType> = [
|
||||
.string,
|
||||
.fileURL,
|
||||
.URL
|
||||
.URL,
|
||||
.png,
|
||||
.tiff,
|
||||
NSPasteboard.PasteboardType(UTType.jpeg.identifier),
|
||||
NSPasteboard.PasteboardType(UTType.gif.identifier),
|
||||
NSPasteboard.PasteboardType(UTType.heic.identifier),
|
||||
NSPasteboard.PasteboardType(UTType.heif.identifier)
|
||||
]
|
||||
private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
|
||||
private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder")
|
||||
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
|
||||
|
||||
fileprivate static func focusLog(_ message: String) {
|
||||
guard focusDebugEnabled else { return }
|
||||
|
|
@ -5967,39 +6108,207 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
|
||||
fileprivate static func escapeDropForShell(_ value: String) -> String {
|
||||
var result = value
|
||||
for char in shellEscapeCharacters {
|
||||
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
|
||||
}
|
||||
return result
|
||||
TerminalImageTransferPlanner.escapeForShell(value)
|
||||
}
|
||||
|
||||
private func droppedContent(from pasteboard: NSPasteboard) -> String? {
|
||||
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !urls.isEmpty {
|
||||
return urls
|
||||
.map { Self.escapeDropForShell($0.path) }
|
||||
.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<SurfaceSearchOverlay>?
|
||||
private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem?
|
||||
private var imageTransferIndicatorShowWorkItem: DispatchWorkItem?
|
||||
private var activeImageTransferOperation: TerminalImageTransferOperation?
|
||||
private var activeImageTransferCancelHandler: (() -> Void)?
|
||||
private var lastSearchOverlayStateID: ObjectIdentifier?
|
||||
private var searchOverlayMutationGeneration: UInt64 = 0
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
|
@ -6379,6 +6695,10 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
keyboardCopyModeBadgeView = GhosttyPassthroughVisualEffectView(frame: .zero)
|
||||
keyboardCopyModeBadgeIconView = NSImageView(frame: .zero)
|
||||
keyboardCopyModeBadgeLabel = NSTextField(labelWithString: terminalKeyboardCopyModeIndicatorText)
|
||||
imageTransferIndicatorContainerView = NSView(frame: .zero)
|
||||
imageTransferIndicatorView = NSVisualEffectView(frame: .zero)
|
||||
imageTransferIndicatorSpinner = NSProgressIndicator(frame: .zero)
|
||||
imageTransferCancelButton = NSButton(frame: .zero)
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = false
|
||||
scrollView.autohidesScrollers = false
|
||||
|
|
@ -6509,6 +6829,71 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
keyboardCopyModeBadgeContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
||||
])
|
||||
|
||||
imageTransferIndicatorContainerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageTransferIndicatorContainerView.wantsLayer = true
|
||||
imageTransferIndicatorContainerView.layer?.masksToBounds = false
|
||||
imageTransferIndicatorContainerView.layer?.shadowColor = NSColor.black.cgColor
|
||||
imageTransferIndicatorContainerView.layer?.shadowOpacity = 0.18
|
||||
imageTransferIndicatorContainerView.layer?.shadowRadius = 8
|
||||
imageTransferIndicatorContainerView.layer?.shadowOffset = CGSize(width: 0, height: 2)
|
||||
imageTransferIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageTransferIndicatorView.wantsLayer = true
|
||||
imageTransferIndicatorView.material = .hudWindow
|
||||
imageTransferIndicatorView.blendingMode = .withinWindow
|
||||
imageTransferIndicatorView.state = .active
|
||||
imageTransferIndicatorView.layer?.cornerRadius = 16
|
||||
imageTransferIndicatorView.layer?.masksToBounds = true
|
||||
imageTransferIndicatorView.layer?.borderWidth = 1
|
||||
imageTransferIndicatorView.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor
|
||||
imageTransferIndicatorView.alphaValue = 0.95
|
||||
imageTransferIndicatorSpinner.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageTransferIndicatorSpinner.style = .spinning
|
||||
imageTransferIndicatorSpinner.controlSize = .small
|
||||
imageTransferIndicatorSpinner.isDisplayedWhenStopped = false
|
||||
imageTransferCancelButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
imageTransferCancelButton.isBordered = false
|
||||
imageTransferCancelButton.imagePosition = .imageOnly
|
||||
imageTransferCancelButton.image = NSImage(
|
||||
systemSymbolName: "xmark.circle.fill",
|
||||
accessibilityDescription: String(localized: "common.cancel", defaultValue: "Cancel")
|
||||
)
|
||||
imageTransferCancelButton.contentTintColor = NSColor.secondaryLabelColor
|
||||
imageTransferCancelButton.toolTip = String(localized: "common.cancel", defaultValue: "Cancel")
|
||||
imageTransferCancelButton.setAccessibilityLabel(
|
||||
String(localized: "common.cancel", defaultValue: "Cancel")
|
||||
)
|
||||
imageTransferCancelButton.target = self
|
||||
imageTransferCancelButton.action = #selector(handleImageTransferCancel)
|
||||
imageTransferIndicatorContainerView.addSubview(imageTransferIndicatorView)
|
||||
imageTransferIndicatorView.addSubview(imageTransferIndicatorSpinner)
|
||||
imageTransferIndicatorView.addSubview(imageTransferCancelButton)
|
||||
NSLayoutConstraint.activate([
|
||||
imageTransferIndicatorView.topAnchor.constraint(equalTo: imageTransferIndicatorContainerView.topAnchor),
|
||||
imageTransferIndicatorView.bottomAnchor.constraint(equalTo: imageTransferIndicatorContainerView.bottomAnchor),
|
||||
imageTransferIndicatorView.leadingAnchor.constraint(equalTo: imageTransferIndicatorContainerView.leadingAnchor),
|
||||
imageTransferIndicatorView.trailingAnchor.constraint(equalTo: imageTransferIndicatorContainerView.trailingAnchor),
|
||||
imageTransferIndicatorSpinner.leadingAnchor.constraint(equalTo: imageTransferIndicatorView.leadingAnchor, constant: 10),
|
||||
imageTransferIndicatorSpinner.centerYAnchor.constraint(equalTo: imageTransferIndicatorView.centerYAnchor),
|
||||
imageTransferIndicatorSpinner.widthAnchor.constraint(equalToConstant: 14),
|
||||
imageTransferIndicatorSpinner.heightAnchor.constraint(equalToConstant: 14),
|
||||
imageTransferCancelButton.leadingAnchor.constraint(equalTo: imageTransferIndicatorSpinner.trailingAnchor, constant: 6),
|
||||
imageTransferCancelButton.trailingAnchor.constraint(equalTo: imageTransferIndicatorView.trailingAnchor, constant: -8),
|
||||
imageTransferCancelButton.centerYAnchor.constraint(equalTo: imageTransferIndicatorView.centerYAnchor),
|
||||
imageTransferCancelButton.widthAnchor.constraint(equalToConstant: 16),
|
||||
imageTransferCancelButton.heightAnchor.constraint(equalToConstant: 16),
|
||||
imageTransferIndicatorSpinner.topAnchor.constraint(equalTo: imageTransferIndicatorView.topAnchor, constant: 8),
|
||||
imageTransferIndicatorSpinner.bottomAnchor.constraint(equalTo: imageTransferIndicatorView.bottomAnchor, constant: -8),
|
||||
])
|
||||
imageTransferIndicatorContainerView.isHidden = true
|
||||
addSubview(imageTransferIndicatorContainerView)
|
||||
NSLayoutConstraint.activate([
|
||||
imageTransferIndicatorContainerView.topAnchor.constraint(
|
||||
equalTo: keyboardCopyModeBadgeContainerView.bottomAnchor,
|
||||
constant: 8
|
||||
),
|
||||
imageTransferIndicatorContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
||||
])
|
||||
|
||||
scrollView.contentView.postsBoundsChangedNotifications = true
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: NSView.boundsDidChangeNotification,
|
||||
|
|
@ -6590,6 +6975,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
deferredSearchOverlayMutationWorkItem?.cancel()
|
||||
imageTransferIndicatorShowWorkItem?.cancel()
|
||||
dropZoneOverlayView.removeFromSuperview()
|
||||
cancelFocusRequest()
|
||||
}
|
||||
|
|
@ -6941,6 +7327,29 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
DispatchQueue.main.async(execute: work)
|
||||
}
|
||||
|
||||
private func cancelImageTransferIndicatorShow() {
|
||||
imageTransferIndicatorShowWorkItem?.cancel()
|
||||
imageTransferIndicatorShowWorkItem = nil
|
||||
}
|
||||
|
||||
private func updateImageTransferIndicatorZOrder(relativeTo overlay: NSView?) {
|
||||
guard !imageTransferIndicatorContainerView.isHidden else { return }
|
||||
if let overlay, overlay.superview === self {
|
||||
addSubview(imageTransferIndicatorContainerView, positioned: .above, relativeTo: overlay)
|
||||
return
|
||||
}
|
||||
if keyboardCopyModeBadgeContainerView.superview === self,
|
||||
!keyboardCopyModeBadgeContainerView.isHidden {
|
||||
addSubview(
|
||||
imageTransferIndicatorContainerView,
|
||||
positioned: .above,
|
||||
relativeTo: keyboardCopyModeBadgeContainerView
|
||||
)
|
||||
return
|
||||
}
|
||||
addSubview(imageTransferIndicatorContainerView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
|
||||
private func updateKeyboardCopyModeBadgeZOrder(relativeTo overlay: NSView?) {
|
||||
guard !keyboardCopyModeBadgeContainerView.isHidden else { return }
|
||||
if let overlay, overlay.superview === self {
|
||||
|
|
@ -6948,6 +7357,65 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
} else {
|
||||
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
updateImageTransferIndicatorZOrder(relativeTo: overlay)
|
||||
}
|
||||
|
||||
@objc private func handleImageTransferCancel() {
|
||||
guard let operation = activeImageTransferOperation else { return }
|
||||
let onCancel = activeImageTransferCancelHandler
|
||||
guard operation.cancel() else { return }
|
||||
endImageTransferIndicator(for: operation)
|
||||
onCancel?()
|
||||
}
|
||||
|
||||
func beginImageTransferIndicator(
|
||||
for operation: TerminalImageTransferOperation,
|
||||
onCancel: @escaping () -> Void
|
||||
) {
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.beginImageTransferIndicator(for: operation, onCancel: onCancel)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cancelImageTransferIndicatorShow()
|
||||
activeImageTransferOperation = operation
|
||||
activeImageTransferCancelHandler = onCancel
|
||||
imageTransferIndicatorSpinner.stopAnimation(nil)
|
||||
imageTransferIndicatorContainerView.isHidden = true
|
||||
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.activeImageTransferOperation === operation else { return }
|
||||
guard !operation.isCancelled else { return }
|
||||
self.imageTransferIndicatorShowWorkItem = nil
|
||||
self.imageTransferIndicatorSpinner.startAnimation(nil)
|
||||
self.imageTransferIndicatorContainerView.isHidden = false
|
||||
self.updateImageTransferIndicatorZOrder(relativeTo: self.searchOverlayHostingView)
|
||||
}
|
||||
imageTransferIndicatorShowWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: work)
|
||||
}
|
||||
|
||||
func endImageTransferIndicator(for operation: TerminalImageTransferOperation?) {
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.endImageTransferIndicator(for: operation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let operation,
|
||||
activeImageTransferOperation !== operation {
|
||||
return
|
||||
}
|
||||
|
||||
cancelImageTransferIndicatorShow()
|
||||
activeImageTransferOperation = nil
|
||||
activeImageTransferCancelHandler = nil
|
||||
imageTransferIndicatorSpinner.stopAnimation(nil)
|
||||
imageTransferIndicatorContainerView.isHidden = true
|
||||
}
|
||||
|
||||
private func makeSearchOverlayRootView(
|
||||
|
|
@ -7567,15 +8035,10 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
|
||||
/// Handle file/URL drops, forwarding to the terminal as shell-escaped paths.
|
||||
func handleDroppedURLs(_ urls: [URL]) -> Bool {
|
||||
guard !urls.isEmpty else { return false }
|
||||
let content = urls
|
||||
.map { GhosttyNSView.escapeDropForShell($0.path) }
|
||||
.joined(separator: " ")
|
||||
#if DEBUG
|
||||
dlog("terminal.swiftUIDrop surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") urls=\(urls.map(\.lastPathComponent))")
|
||||
#endif
|
||||
surfaceView.terminalSurface?.sendText(content)
|
||||
return true
|
||||
return surfaceView.handleDroppedFileURLs(urls)
|
||||
}
|
||||
|
||||
func terminalViewForDrop(at point: NSPoint) -> GhosttyNSView? {
|
||||
|
|
|
|||
38
Sources/RemoteRelayZshBootstrap.swift
Normal file
38
Sources/RemoteRelayZshBootstrap.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import Foundation
|
||||
|
||||
struct RemoteRelayZshBootstrap {
|
||||
let shellStateDir: String
|
||||
|
||||
private var sharedHistoryLines: [String] {
|
||||
[
|
||||
"if [ -z \"${HISTFILE:-}\" ] || [ \"$HISTFILE\" = \"\(shellStateDir)/.zsh_history\" ]; then export HISTFILE=\"$CMUX_REAL_ZDOTDIR/.zsh_history\"; fi",
|
||||
]
|
||||
}
|
||||
|
||||
var zshEnvLines: [String] {
|
||||
[
|
||||
"[ -f \"$CMUX_REAL_ZDOTDIR/.zshenv\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshenv\"",
|
||||
"if [ -n \"${ZDOTDIR:-}\" ] && [ \"$ZDOTDIR\" != \"\(shellStateDir)\" ]; then export CMUX_REAL_ZDOTDIR=\"$ZDOTDIR\"; fi",
|
||||
] + sharedHistoryLines + [
|
||||
"export ZDOTDIR=\"\(shellStateDir)\"",
|
||||
]
|
||||
}
|
||||
|
||||
var zshProfileLines: [String] {
|
||||
[
|
||||
"[ -f \"$CMUX_REAL_ZDOTDIR/.zprofile\" ] && source \"$CMUX_REAL_ZDOTDIR/.zprofile\"",
|
||||
]
|
||||
}
|
||||
|
||||
func zshRCLines(commonShellLines: [String]) -> [String] {
|
||||
sharedHistoryLines + [
|
||||
"[ -f \"$CMUX_REAL_ZDOTDIR/.zshrc\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshrc\"",
|
||||
] + commonShellLines
|
||||
}
|
||||
|
||||
var zshLoginLines: [String] {
|
||||
[
|
||||
"[ -f \"$CMUX_REAL_ZDOTDIR/.zlogin\" ] && source \"$CMUX_REAL_ZDOTDIR/.zlogin\"",
|
||||
]
|
||||
}
|
||||
}
|
||||
330
Sources/TerminalImageTransfer.swift
Normal file
330
Sources/TerminalImageTransfer.swift
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import Foundation
|
||||
import AppKit
|
||||
|
||||
enum TerminalImageTransferMode {
|
||||
case paste
|
||||
case drop
|
||||
}
|
||||
|
||||
enum TerminalRemoteUploadTarget: Equatable {
|
||||
case workspaceRemote
|
||||
case detectedSSH(DetectedSSHSession)
|
||||
}
|
||||
|
||||
enum TerminalImageTransferTarget: Equatable {
|
||||
case local
|
||||
case remote(TerminalRemoteUploadTarget)
|
||||
}
|
||||
|
||||
enum TerminalImageTransferPlan: Equatable {
|
||||
case insertText(String)
|
||||
case uploadFiles([URL], TerminalRemoteUploadTarget)
|
||||
case reject
|
||||
}
|
||||
|
||||
enum TerminalImageTransferPreparedContent: Equatable {
|
||||
case insertText(String)
|
||||
case fileURLs([URL])
|
||||
case reject
|
||||
}
|
||||
|
||||
enum TerminalImageTransferExecutionError: Error {
|
||||
case cancelled
|
||||
}
|
||||
|
||||
final class TerminalImageTransferOperation: @unchecked Sendable {
|
||||
private enum State {
|
||||
case running
|
||||
case cancelled
|
||||
case finished
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var state: State = .running
|
||||
private var cancellationHandler: (() -> Void)?
|
||||
|
||||
var isCancelled: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return state == .cancelled
|
||||
}
|
||||
|
||||
func installCancellationHandler(_ handler: @escaping () -> Void) {
|
||||
var invokeImmediately = false
|
||||
lock.lock()
|
||||
switch state {
|
||||
case .running:
|
||||
cancellationHandler = handler
|
||||
case .cancelled:
|
||||
invokeImmediately = true
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
if invokeImmediately {
|
||||
handler()
|
||||
}
|
||||
}
|
||||
|
||||
func clearCancellationHandler() {
|
||||
lock.lock()
|
||||
if state == .running {
|
||||
cancellationHandler = nil
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func cancel() -> Bool {
|
||||
let handler: (() -> Void)?
|
||||
lock.lock()
|
||||
guard state == .running else {
|
||||
lock.unlock()
|
||||
return false
|
||||
}
|
||||
state = .cancelled
|
||||
handler = cancellationHandler
|
||||
cancellationHandler = nil
|
||||
lock.unlock()
|
||||
|
||||
handler?()
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func finish() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard state == .running else { return false }
|
||||
state = .finished
|
||||
cancellationHandler = nil
|
||||
return true
|
||||
}
|
||||
|
||||
func throwIfCancelled() throws {
|
||||
if isCancelled {
|
||||
throw TerminalImageTransferExecutionError.cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TerminalImageTransferPlanner {
|
||||
static func plan(
|
||||
pasteboard: NSPasteboard,
|
||||
mode: TerminalImageTransferMode,
|
||||
target: TerminalImageTransferTarget
|
||||
) -> TerminalImageTransferPlan {
|
||||
plan(
|
||||
preparedContent: prepare(pasteboard: pasteboard, mode: mode),
|
||||
target: target
|
||||
)
|
||||
}
|
||||
|
||||
static func plan(
|
||||
pasteboard: NSPasteboard,
|
||||
mode: TerminalImageTransferMode,
|
||||
resolveTarget: () -> TerminalImageTransferTarget
|
||||
) -> TerminalImageTransferPlan {
|
||||
let preparedContent = prepare(pasteboard: pasteboard, mode: mode)
|
||||
switch preparedContent {
|
||||
case .insertText, .reject:
|
||||
return plan(preparedContent: preparedContent, target: .local)
|
||||
case .fileURLs:
|
||||
return plan(preparedContent: preparedContent, target: resolveTarget())
|
||||
}
|
||||
}
|
||||
|
||||
static func prepare(
|
||||
pasteboard: NSPasteboard,
|
||||
mode: TerminalImageTransferMode
|
||||
) -> TerminalImageTransferPreparedContent {
|
||||
switch mode {
|
||||
case .paste:
|
||||
return preparePaste(pasteboard: pasteboard)
|
||||
case .drop:
|
||||
return prepareDrop(pasteboard: pasteboard)
|
||||
}
|
||||
}
|
||||
|
||||
static func plan(
|
||||
preparedContent: TerminalImageTransferPreparedContent,
|
||||
target: TerminalImageTransferTarget
|
||||
) -> TerminalImageTransferPlan {
|
||||
switch preparedContent {
|
||||
case .insertText(let text):
|
||||
return .insertText(text)
|
||||
case .fileURLs(let fileURLs):
|
||||
return plan(fileURLs: fileURLs, target: target)
|
||||
case .reject:
|
||||
return .reject
|
||||
}
|
||||
}
|
||||
|
||||
static func plan(fileURLs: [URL], target: TerminalImageTransferTarget) -> TerminalImageTransferPlan {
|
||||
guard !fileURLs.isEmpty else { return .reject }
|
||||
|
||||
switch target {
|
||||
case .local:
|
||||
let text = fileURLs
|
||||
.map { escapeForShell($0.path) }
|
||||
.joined(separator: " ")
|
||||
return .insertText(text)
|
||||
case .remote(let remoteTarget):
|
||||
return .uploadFiles(fileURLs, remoteTarget)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func executeForTesting(
|
||||
plan: TerminalImageTransferPlan,
|
||||
operation: TerminalImageTransferOperation? = nil,
|
||||
uploadWorkspaceRemote: ([URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void,
|
||||
uploadDetectedSSH: (DetectedSSHSession, [URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void,
|
||||
insertText: @escaping (String) -> Void,
|
||||
onFailure: @escaping (Error) -> Void
|
||||
) -> TerminalImageTransferOperation? {
|
||||
execute(
|
||||
plan: plan,
|
||||
operation: operation,
|
||||
uploadWorkspaceRemote: uploadWorkspaceRemote,
|
||||
uploadDetectedSSH: uploadDetectedSSH,
|
||||
insertText: insertText,
|
||||
onFailure: onFailure
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func execute(
|
||||
plan: TerminalImageTransferPlan,
|
||||
operation: TerminalImageTransferOperation? = nil,
|
||||
uploadWorkspaceRemote: ([URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void,
|
||||
uploadDetectedSSH: (DetectedSSHSession, [URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void,
|
||||
insertText: @escaping (String) -> Void,
|
||||
onFailure: @escaping (Error) -> Void
|
||||
) -> TerminalImageTransferOperation? {
|
||||
switch plan {
|
||||
case .insertText(let text):
|
||||
if let operation, !operation.finish() {
|
||||
return operation
|
||||
}
|
||||
insertText(text)
|
||||
return operation
|
||||
case .uploadFiles(let fileURLs, .workspaceRemote):
|
||||
let operation = operation ?? TerminalImageTransferOperation()
|
||||
uploadWorkspaceRemote(fileURLs, operation) { result in
|
||||
guard operation.finish() else { return }
|
||||
finishUpload(result: result, insertText: insertText, onFailure: onFailure)
|
||||
}
|
||||
return operation
|
||||
case .uploadFiles(let fileURLs, .detectedSSH(let session)):
|
||||
let operation = operation ?? TerminalImageTransferOperation()
|
||||
uploadDetectedSSH(session, fileURLs, operation) { result in
|
||||
guard operation.finish() else { return }
|
||||
finishUpload(result: result, insertText: insertText, onFailure: onFailure)
|
||||
}
|
||||
return operation
|
||||
case .reject:
|
||||
return operation
|
||||
}
|
||||
}
|
||||
|
||||
static func escapeForShell(_ value: String) -> String {
|
||||
GhosttyPasteboardHelper.escapeForShell(value)
|
||||
}
|
||||
|
||||
private static func preparePaste(
|
||||
pasteboard: NSPasteboard
|
||||
) -> TerminalImageTransferPreparedContent {
|
||||
let fileURLs = fileURLs(from: pasteboard)
|
||||
if !fileURLs.isEmpty {
|
||||
return .fileURLs(fileURLs)
|
||||
}
|
||||
|
||||
if let string = GhosttyPasteboardHelper.stringContents(from: pasteboard), !string.isEmpty {
|
||||
return .insertText(string)
|
||||
}
|
||||
|
||||
if let imageURL = GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: true) {
|
||||
return .fileURLs([imageURL])
|
||||
}
|
||||
|
||||
if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty {
|
||||
return .insertText(escapeForShell(rawURL))
|
||||
}
|
||||
|
||||
return .reject
|
||||
}
|
||||
|
||||
private static func prepareDrop(
|
||||
pasteboard: NSPasteboard
|
||||
) -> TerminalImageTransferPreparedContent {
|
||||
let fileURLs = materializedFileURLs(from: pasteboard)
|
||||
if !fileURLs.isEmpty {
|
||||
return .fileURLs(fileURLs)
|
||||
}
|
||||
|
||||
if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty {
|
||||
return .insertText(escapeForShell(rawURL))
|
||||
}
|
||||
|
||||
if let string = pasteboard.string(forType: .string), !string.isEmpty {
|
||||
return .insertText(string)
|
||||
}
|
||||
|
||||
return .reject
|
||||
}
|
||||
|
||||
private static func materializedFileURLs(from pasteboard: NSPasteboard) -> [URL] {
|
||||
let urls = fileURLs(from: pasteboard)
|
||||
if !urls.isEmpty {
|
||||
return urls
|
||||
}
|
||||
if let imageURL = GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: true) {
|
||||
return [imageURL]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func fileURLs(from pasteboard: NSPasteboard) -> [URL] {
|
||||
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else {
|
||||
return []
|
||||
}
|
||||
return urls.filter(\.isFileURL)
|
||||
}
|
||||
|
||||
private static func finishUpload(
|
||||
result: Result<[String], Error>,
|
||||
insertText: @escaping (String) -> Void,
|
||||
onFailure: @escaping (Error) -> Void
|
||||
) {
|
||||
switch result {
|
||||
case .success(let remotePaths):
|
||||
let content = remotePaths
|
||||
.map(escapeForShell)
|
||||
.joined(separator: " ")
|
||||
guard !content.isEmpty else {
|
||||
onFailure(NSError(domain: "cmux.remote.drop", code: 5))
|
||||
return
|
||||
}
|
||||
insertText(content)
|
||||
case .failure(let error):
|
||||
onFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TerminalSurface {
|
||||
@MainActor
|
||||
func resolvedImageTransferTarget() -> TerminalImageTransferTarget {
|
||||
guard let workspace = owningWorkspace() else { return .local }
|
||||
if workspace.isRemoteTerminalSurface(id) {
|
||||
return .remote(.workspaceRemote)
|
||||
}
|
||||
if let ttyName = workspace.surfaceTTYNames[id],
|
||||
let session = TerminalSSHSessionDetector.detect(forTTY: ttyName) {
|
||||
return .remote(.detectedSSH(session))
|
||||
}
|
||||
return .local
|
||||
}
|
||||
}
|
||||
653
Sources/TerminalSSHSessionDetector.swift
Normal file
653
Sources/TerminalSSHSessionDetector.swift
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
import Foundation
|
||||
import Darwin
|
||||
|
||||
struct DetectedSSHSession: Equatable {
|
||||
let destination: String
|
||||
let port: Int?
|
||||
let identityFile: String?
|
||||
let configFile: String?
|
||||
let jumpHost: String?
|
||||
let controlPath: String?
|
||||
let useIPv4: Bool
|
||||
let useIPv6: Bool
|
||||
let forwardAgent: Bool
|
||||
let compressionEnabled: Bool
|
||||
let sshOptions: [String]
|
||||
|
||||
func uploadDroppedFiles(
|
||||
_ fileURLs: [URL],
|
||||
operation: TerminalImageTransferOperation,
|
||||
completion: @escaping (Result<[String], Error>) -> Void
|
||||
) {
|
||||
let session = self
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let result = Result {
|
||||
let remotePaths = try session.uploadDroppedFilesSync(fileURLs, operation: operation)
|
||||
try operation.throwIfCancelled()
|
||||
return remotePaths
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if operation.isCancelled {
|
||||
completion(.failure(TerminalImageTransferExecutionError.cancelled))
|
||||
} else {
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uploadDroppedFiles(
|
||||
_ fileURLs: [URL],
|
||||
completion: @escaping (Result<[String], Error>) -> Void
|
||||
) {
|
||||
uploadDroppedFiles(
|
||||
fileURLs,
|
||||
operation: TerminalImageTransferOperation(),
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
private func uploadDroppedFilesSync(
|
||||
_ fileURLs: [URL],
|
||||
operation: TerminalImageTransferOperation
|
||||
) throws -> [String] {
|
||||
guard !fileURLs.isEmpty else { return [] }
|
||||
|
||||
return try fileURLs.map { localURL in
|
||||
try operation.throwIfCancelled()
|
||||
let normalizedLocalURL = localURL.standardizedFileURL
|
||||
guard normalizedLocalURL.isFileURL else {
|
||||
throw NSError(domain: "cmux.detected-ssh.drop", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "dropped item is not a file URL",
|
||||
])
|
||||
}
|
||||
|
||||
let remotePath = WorkspaceRemoteSessionController.remoteDropPath(for: normalizedLocalURL)
|
||||
let result = try Self.runProcess(
|
||||
executable: "/usr/bin/scp",
|
||||
arguments: scpArguments(localPath: normalizedLocalURL.path, remotePath: remotePath),
|
||||
timeout: 45,
|
||||
operation: operation
|
||||
)
|
||||
guard result.status == 0 else {
|
||||
let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ??
|
||||
"scp exited \(result.status)"
|
||||
throw NSError(domain: "cmux.detected-ssh.drop", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "failed to upload dropped file: \(detail)",
|
||||
])
|
||||
}
|
||||
|
||||
return remotePath
|
||||
}
|
||||
}
|
||||
|
||||
private func scpArguments(localPath: String, remotePath: String) -> [String] {
|
||||
var args: [String] = [
|
||||
"-q",
|
||||
"-o", "ConnectTimeout=6",
|
||||
"-o", "ServerAliveInterval=20",
|
||||
"-o", "ServerAliveCountMax=2",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ControlMaster=no",
|
||||
]
|
||||
|
||||
if useIPv4 {
|
||||
args.append("-4")
|
||||
} else if useIPv6 {
|
||||
args.append("-6")
|
||||
}
|
||||
if forwardAgent {
|
||||
args.append("-A")
|
||||
}
|
||||
if compressionEnabled {
|
||||
args.append("-C")
|
||||
}
|
||||
if let configFile, !configFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args += ["-F", configFile]
|
||||
}
|
||||
if let jumpHost, !jumpHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args += ["-J", jumpHost]
|
||||
}
|
||||
if let port {
|
||||
args += ["-P", String(port)]
|
||||
}
|
||||
if let identityFile, !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args += ["-i", identityFile]
|
||||
}
|
||||
if let controlPath,
|
||||
!controlPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!Self.hasSSHOptionKey(sshOptions, key: "ControlPath") {
|
||||
args += ["-o", "ControlPath=\(controlPath)"]
|
||||
}
|
||||
if !Self.hasSSHOptionKey(sshOptions, key: "StrictHostKeyChecking") {
|
||||
args += ["-o", "StrictHostKeyChecking=accept-new"]
|
||||
}
|
||||
for option in sshOptions {
|
||||
args += ["-o", option]
|
||||
}
|
||||
|
||||
args += [localPath, "\(destination):\(remotePath)"]
|
||||
return args
|
||||
}
|
||||
|
||||
private struct CommandResult {
|
||||
let status: Int32
|
||||
let stdout: String
|
||||
let stderr: String
|
||||
}
|
||||
|
||||
private static func runProcess(
|
||||
executable: String,
|
||||
arguments: [String],
|
||||
timeout: TimeInterval,
|
||||
operation: TerminalImageTransferOperation? = nil
|
||||
) throws -> CommandResult {
|
||||
let process = Process()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = arguments
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
try operation?.throwIfCancelled()
|
||||
try process.run()
|
||||
operation?.installCancellationHandler {
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
defer { operation?.clearCancellationHandler() }
|
||||
|
||||
let exitSignal = DispatchSemaphore(value: 0)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
process.waitUntilExit()
|
||||
exitSignal.signal()
|
||||
}
|
||||
|
||||
func terminateProcessAndWait() {
|
||||
process.terminate()
|
||||
_ = exitSignal.wait(timeout: .now() + 1)
|
||||
if process.isRunning {
|
||||
_ = Darwin.kill(process.processIdentifier, SIGKILL)
|
||||
process.waitUntilExit()
|
||||
}
|
||||
}
|
||||
|
||||
if exitSignal.wait(timeout: .now() + timeout) == .timedOut {
|
||||
if operation?.isCancelled == true {
|
||||
terminateProcessAndWait()
|
||||
throw TerminalImageTransferExecutionError.cancelled
|
||||
}
|
||||
terminateProcessAndWait()
|
||||
throw NSError(domain: "cmux.detected-ssh.drop", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "scp timed out",
|
||||
])
|
||||
}
|
||||
|
||||
let stdout = String(
|
||||
data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(),
|
||||
encoding: .utf8
|
||||
) ?? ""
|
||||
let stderr = String(
|
||||
data: stderrPipe.fileHandleForReading.readDataToEndOfFile(),
|
||||
encoding: .utf8
|
||||
) ?? ""
|
||||
if operation?.isCancelled == true {
|
||||
throw TerminalImageTransferExecutionError.cancelled
|
||||
}
|
||||
return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr)
|
||||
}
|
||||
|
||||
private static func bestErrorLine(stderr: String, stdout: String) -> String? {
|
||||
let stderrLine = stderr
|
||||
.split(separator: "\n")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.first(where: { !$0.isEmpty })
|
||||
if let stderrLine {
|
||||
return stderrLine
|
||||
}
|
||||
|
||||
return stdout
|
||||
.split(separator: "\n")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.first(where: { !$0.isEmpty })
|
||||
}
|
||||
|
||||
private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool {
|
||||
let loweredKey = key.lowercased()
|
||||
return options.contains { optionKey($0) == loweredKey }
|
||||
}
|
||||
|
||||
private static func optionKey(_ option: String) -> String? {
|
||||
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return trimmed
|
||||
.split(whereSeparator: { $0 == "=" || $0.isWhitespace })
|
||||
.first
|
||||
.map(String.init)?
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func scpArgumentsForTesting(localPath: String, remotePath: String) -> [String] {
|
||||
scpArguments(localPath: localPath, remotePath: remotePath)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
enum TerminalSSHSessionDetector {
|
||||
struct ProcessSnapshot: Equatable {
|
||||
let pid: Int32
|
||||
let pgid: Int32
|
||||
let tpgid: Int32
|
||||
let tty: String
|
||||
let executableName: String
|
||||
}
|
||||
|
||||
static func detect(forTTY ttyName: String) -> DetectedSSHSession? {
|
||||
let normalizedTTY = normalizeTTYName(ttyName)
|
||||
guard !normalizedTTY.isEmpty else { return nil }
|
||||
let processes = processSnapshots(forTTY: normalizedTTY)
|
||||
guard !processes.isEmpty else { return nil }
|
||||
|
||||
var argumentsByPID: [Int32: [String]] = [:]
|
||||
for process in processes where isForegroundSSHProcess(process, ttyName: normalizedTTY) {
|
||||
if let args = commandLineArguments(forPID: process.pid) {
|
||||
argumentsByPID[process.pid] = args
|
||||
}
|
||||
}
|
||||
|
||||
return detectForTesting(
|
||||
ttyName: normalizedTTY,
|
||||
processes: processes,
|
||||
argumentsByPID: argumentsByPID
|
||||
)
|
||||
}
|
||||
|
||||
static func detectForTesting(
|
||||
ttyName: String,
|
||||
processes: [ProcessSnapshot],
|
||||
argumentsByPID: [Int32: [String]]
|
||||
) -> DetectedSSHSession? {
|
||||
let normalizedTTY = normalizeTTYName(ttyName)
|
||||
guard !normalizedTTY.isEmpty else { return nil }
|
||||
|
||||
let candidates = processes
|
||||
.filter { isForegroundSSHProcess($0, ttyName: normalizedTTY) }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.pid != rhs.pid { return lhs.pid > rhs.pid }
|
||||
return lhs.pgid > rhs.pgid
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
guard let arguments = argumentsByPID[candidate.pid],
|
||||
let session = parseSSHCommandLine(arguments) else {
|
||||
continue
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static let psPath = "/bin/ps"
|
||||
private static let noArgumentFlags = Set("46AaCfGgKkMNnqsTtVvXxYy")
|
||||
private static let valueArgumentFlags = Set("BbcDEeFIiJLlmOopQRSWw")
|
||||
private static let filteredSSHOptionKeys: Set<String> = [
|
||||
"batchmode",
|
||||
"controlmaster",
|
||||
"controlpersist",
|
||||
"forkafterauthentication",
|
||||
"localcommand",
|
||||
"permitlocalcommand",
|
||||
"remotecommand",
|
||||
"requesttty",
|
||||
"sendenv",
|
||||
"sessiontype",
|
||||
"setenv",
|
||||
"stdioforward",
|
||||
]
|
||||
|
||||
private static func normalizeTTYName(_ ttyName: String) -> String {
|
||||
let trimmed = ttyName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
if let lastComponent = trimmed.split(separator: "/").last {
|
||||
return String(lastComponent)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func isForegroundSSHProcess(_ process: ProcessSnapshot, ttyName: String) -> Bool {
|
||||
normalizeTTYName(process.tty) == normalizeTTYName(ttyName) &&
|
||||
process.executableName == "ssh" &&
|
||||
process.pgid > 0 &&
|
||||
process.tpgid > 0 &&
|
||||
process.pgid == process.tpgid
|
||||
}
|
||||
|
||||
private static func processSnapshots(forTTY ttyName: String) -> [ProcessSnapshot] {
|
||||
let process = Process()
|
||||
let pipe = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: psPath)
|
||||
process.arguments = ["-ww", "-t", ttyName, "-o", "pid=,pgid=,tpgid=,tty=,ucomm="]
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0,
|
||||
let output = String(data: data, encoding: .utf8) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return output
|
||||
.split(separator: "\n")
|
||||
.compactMap(parseProcessSnapshot)
|
||||
}
|
||||
|
||||
private static func parseProcessSnapshot(_ line: Substring) -> ProcessSnapshot? {
|
||||
let parts = line.split(maxSplits: 4, whereSeparator: \.isWhitespace)
|
||||
guard parts.count == 5,
|
||||
let pid = Int32(parts[0]),
|
||||
let pgid = Int32(parts[1]),
|
||||
let tpgid = Int32(parts[2]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ProcessSnapshot(
|
||||
pid: pid,
|
||||
pgid: pgid,
|
||||
tpgid: tpgid,
|
||||
tty: String(parts[3]),
|
||||
executableName: String(parts[4]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
)
|
||||
}
|
||||
|
||||
private static func commandLineArguments(forPID pid: Int32) -> [String]? {
|
||||
var mib = [CTL_KERN, KERN_PROCARGS2, pid]
|
||||
var size: size_t = 0
|
||||
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, size > 4 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var buffer = [UInt8](repeating: 0, count: size)
|
||||
let success = buffer.withUnsafeMutableBytes { rawBuffer in
|
||||
sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0
|
||||
}
|
||||
guard success else { return nil }
|
||||
|
||||
return parseKernProcArgs(Array(buffer.prefix(Int(size))))
|
||||
}
|
||||
|
||||
private static func parseKernProcArgs(_ bytes: [UInt8]) -> [String]? {
|
||||
guard bytes.count > 4 else { return nil }
|
||||
|
||||
var argcRaw: Int32 = 0
|
||||
withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in
|
||||
rawBuffer.copyBytes(from: bytes.prefix(4))
|
||||
}
|
||||
let argc = Int(Int32(littleEndian: argcRaw))
|
||||
guard argc > 0 else { return nil }
|
||||
|
||||
var index = 4
|
||||
while index < bytes.count, bytes[index] != 0 {
|
||||
index += 1
|
||||
}
|
||||
while index < bytes.count, bytes[index] == 0 {
|
||||
index += 1
|
||||
}
|
||||
|
||||
var arguments: [String] = []
|
||||
while index < bytes.count, arguments.count < argc {
|
||||
let start = index
|
||||
while index < bytes.count, bytes[index] != 0 {
|
||||
index += 1
|
||||
}
|
||||
guard let argument = String(bytes: bytes[start..<index], encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
arguments.append(argument)
|
||||
while index < bytes.count, bytes[index] == 0 {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
return arguments.count == argc ? arguments : nil
|
||||
}
|
||||
|
||||
private static func parseSSHCommandLine(_ arguments: [String]) -> DetectedSSHSession? {
|
||||
guard !arguments.isEmpty else { return nil }
|
||||
|
||||
var index = 0
|
||||
if let executable = arguments.first?.split(separator: "/").last,
|
||||
executable == "ssh" {
|
||||
index = 1
|
||||
}
|
||||
|
||||
var destination: String?
|
||||
var port: Int?
|
||||
var identityFile: String?
|
||||
var configFile: String?
|
||||
var jumpHost: String?
|
||||
var controlPath: String?
|
||||
var loginName: String?
|
||||
var useIPv4 = false
|
||||
var useIPv6 = false
|
||||
var forwardAgent = false
|
||||
var compressionEnabled = false
|
||||
var sshOptions: [String] = []
|
||||
|
||||
func consumeValue(_ value: String, for option: Character) -> Bool {
|
||||
let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedValue.isEmpty else { return false }
|
||||
|
||||
switch option {
|
||||
case "p":
|
||||
guard let parsedPort = Int(trimmedValue) else { return false }
|
||||
port = parsedPort
|
||||
return true
|
||||
case "i":
|
||||
identityFile = trimmedValue
|
||||
return true
|
||||
case "F":
|
||||
configFile = trimmedValue
|
||||
return true
|
||||
case "J":
|
||||
jumpHost = trimmedValue
|
||||
return true
|
||||
case "S":
|
||||
controlPath = trimmedValue
|
||||
return true
|
||||
case "l":
|
||||
loginName = trimmedValue
|
||||
return true
|
||||
case "o":
|
||||
return consumeSSHOption(
|
||||
trimmedValue,
|
||||
port: &port,
|
||||
identityFile: &identityFile,
|
||||
controlPath: &controlPath,
|
||||
jumpHost: &jumpHost,
|
||||
loginName: &loginName,
|
||||
sshOptions: &sshOptions
|
||||
)
|
||||
default:
|
||||
return valueArgumentFlags.contains(option)
|
||||
}
|
||||
}
|
||||
|
||||
while index < arguments.count {
|
||||
let argument = arguments[index]
|
||||
if argument == "--" {
|
||||
index += 1
|
||||
if index < arguments.count {
|
||||
destination = arguments[index]
|
||||
}
|
||||
break
|
||||
}
|
||||
if !argument.hasPrefix("-") || argument == "-" {
|
||||
destination = argument
|
||||
break
|
||||
}
|
||||
|
||||
if argument.count > 2,
|
||||
let option = argument.dropFirst().first,
|
||||
valueArgumentFlags.contains(option) {
|
||||
guard consumeValue(String(argument.dropFirst(2)), for: option) else { return nil }
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if argument.count == 2,
|
||||
let optionCharacter = argument.dropFirst().first,
|
||||
valueArgumentFlags.contains(optionCharacter) {
|
||||
let nextIndex = index + 1
|
||||
guard nextIndex < arguments.count,
|
||||
consumeValue(arguments[nextIndex], for: optionCharacter) else {
|
||||
return nil
|
||||
}
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
|
||||
let flags = Array(argument.dropFirst())
|
||||
guard !flags.isEmpty, flags.allSatisfy({ noArgumentFlags.contains($0) }) else {
|
||||
return nil
|
||||
}
|
||||
for flag in flags {
|
||||
switch flag {
|
||||
case "4":
|
||||
useIPv4 = true
|
||||
useIPv6 = false
|
||||
case "6":
|
||||
useIPv6 = true
|
||||
useIPv4 = false
|
||||
case "A":
|
||||
forwardAgent = true
|
||||
case "C":
|
||||
compressionEnabled = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
guard let destination else { return nil }
|
||||
let finalDestination = resolveDestination(destination, loginName: loginName)
|
||||
guard !finalDestination.isEmpty else { return nil }
|
||||
|
||||
return DetectedSSHSession(
|
||||
destination: finalDestination,
|
||||
port: port,
|
||||
identityFile: identityFile,
|
||||
configFile: configFile,
|
||||
jumpHost: jumpHost,
|
||||
controlPath: controlPath,
|
||||
useIPv4: useIPv4,
|
||||
useIPv6: useIPv6,
|
||||
forwardAgent: forwardAgent,
|
||||
compressionEnabled: compressionEnabled,
|
||||
sshOptions: sshOptions
|
||||
)
|
||||
}
|
||||
|
||||
private static func consumeSSHOption(
|
||||
_ option: String,
|
||||
port: inout Int?,
|
||||
identityFile: inout String?,
|
||||
controlPath: inout String?,
|
||||
jumpHost: inout String?,
|
||||
loginName: inout String?,
|
||||
sshOptions: inout [String]
|
||||
) -> Bool {
|
||||
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let key = sshOptionKey(trimmed)
|
||||
let value = sshOptionValue(trimmed)
|
||||
|
||||
switch key {
|
||||
case "port":
|
||||
if let value, let parsedPort = Int(value) {
|
||||
port = parsedPort
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case "identityfile":
|
||||
if let value, !value.isEmpty {
|
||||
identityFile = value
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case "controlpath":
|
||||
if let value, !value.isEmpty {
|
||||
controlPath = value
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case "proxyjump":
|
||||
if let value, !value.isEmpty {
|
||||
jumpHost = value
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case "user":
|
||||
if let value, !value.isEmpty {
|
||||
loginName = value
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case let key? where filteredSSHOptionKeys.contains(key):
|
||||
return true
|
||||
case .some, .none:
|
||||
sshOptions.append(trimmed)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveDestination(_ destination: String, loginName: String?) -> String {
|
||||
let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedDestination.isEmpty else { return "" }
|
||||
guard let loginName = loginName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!loginName.isEmpty,
|
||||
!trimmedDestination.contains("@") else {
|
||||
return trimmedDestination
|
||||
}
|
||||
return "\(loginName)@\(trimmedDestination)"
|
||||
}
|
||||
|
||||
private static func sshOptionKey(_ option: String) -> String? {
|
||||
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return trimmed
|
||||
.split(whereSeparator: { $0 == "=" || $0.isWhitespace })
|
||||
.first
|
||||
.map(String.init)?
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
private static func sshOptionValue(_ option: String) -> String? {
|
||||
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if let equalIndex = trimmed.firstIndex(of: "=") {
|
||||
let value = trimmed[trimmed.index(after: equalIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
let parts = trimmed.split(maxSplits: 1, whereSeparator: \.isWhitespace)
|
||||
guard parts.count == 2 else { return nil }
|
||||
let value = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +107,35 @@ private struct SessionPaneRestoreEntry {
|
|||
let snapshot: SessionPaneLayoutSnapshot
|
||||
}
|
||||
|
||||
private enum RemoteDropUploadError: LocalizedError {
|
||||
case unavailable
|
||||
case invalidFileURL
|
||||
case uploadFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unavailable:
|
||||
String(
|
||||
localized: "error.remoteDrop.unavailable",
|
||||
defaultValue: "Remote drop is unavailable."
|
||||
)
|
||||
case .invalidFileURL:
|
||||
String(
|
||||
localized: "error.remoteDrop.invalidFileURL",
|
||||
defaultValue: "Dropped item is not a file URL."
|
||||
)
|
||||
case .uploadFailed(let detail):
|
||||
String.localizedStringWithFormat(
|
||||
String(
|
||||
localized: "error.remoteDrop.uploadFailed",
|
||||
defaultValue: "Failed to upload dropped file: %@"
|
||||
),
|
||||
detail
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceRemoteDaemonManifest: Decodable, Equatable {
|
||||
struct Entry: Decodable, Equatable {
|
||||
let goOS: String
|
||||
|
|
@ -2945,6 +2974,58 @@ final class WorkspaceRemoteSessionController {
|
|||
}
|
||||
}
|
||||
|
||||
func uploadDroppedFiles(
|
||||
_ fileURLs: [URL],
|
||||
operation: TerminalImageTransferOperation,
|
||||
completion: @escaping (Result<[String], Error>) -> Void
|
||||
) {
|
||||
queue.async { [weak self] in
|
||||
guard let self else {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(RemoteDropUploadError.unavailable))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try operation.throwIfCancelled()
|
||||
let remotePaths = try self.uploadDroppedFilesLocked(fileURLs, operation: operation)
|
||||
try operation.throwIfCancelled()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if operation.isCancelled {
|
||||
guard let self else {
|
||||
completion(.failure(TerminalImageTransferExecutionError.cancelled))
|
||||
return
|
||||
}
|
||||
self.queue.async { [weak self] in
|
||||
self?.cleanupUploadedRemotePaths(remotePaths)
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(TerminalImageTransferExecutionError.cancelled))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completion(.success(remotePaths))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uploadDroppedFiles(
|
||||
_ fileURLs: [URL],
|
||||
completion: @escaping (Result<[String], Error>) -> Void
|
||||
) {
|
||||
uploadDroppedFiles(
|
||||
fileURLs,
|
||||
operation: TerminalImageTransferOperation(),
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
private func stopAllLocked() {
|
||||
debugLog("remote.session.stop \(debugConfigSummary())")
|
||||
isStopping = true
|
||||
|
|
@ -3442,12 +3523,17 @@ final class WorkspaceRemoteSessionController {
|
|||
)
|
||||
}
|
||||
|
||||
private func scpExec(arguments: [String], timeout: TimeInterval = 30) throws -> CommandResult {
|
||||
private func scpExec(
|
||||
arguments: [String],
|
||||
timeout: TimeInterval = 30,
|
||||
operation: TerminalImageTransferOperation? = nil
|
||||
) throws -> CommandResult {
|
||||
try runProcess(
|
||||
executable: "/usr/bin/scp",
|
||||
arguments: arguments,
|
||||
stdin: nil,
|
||||
timeout: timeout
|
||||
timeout: timeout,
|
||||
operation: operation
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -3457,7 +3543,8 @@ final class WorkspaceRemoteSessionController {
|
|||
environment: [String: String]? = nil,
|
||||
currentDirectory: URL? = nil,
|
||||
stdin: Data?,
|
||||
timeout: TimeInterval
|
||||
timeout: TimeInterval,
|
||||
operation: TerminalImageTransferOperation? = nil
|
||||
) throws -> CommandResult {
|
||||
debugLog(
|
||||
"remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
|
||||
|
|
@ -3512,6 +3599,7 @@ final class WorkspaceRemoteSessionController {
|
|||
}
|
||||
|
||||
do {
|
||||
try operation?.throwIfCancelled()
|
||||
try process.run()
|
||||
} catch {
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
|
|
@ -3526,20 +3614,34 @@ final class WorkspaceRemoteSessionController {
|
|||
}
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
operation?.installCancellationHandler {
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
defer { operation?.clearCancellationHandler() }
|
||||
|
||||
if let stdin, let pipe = process.standardInput as? Pipe {
|
||||
pipe.fileHandleForWriting.write(stdin)
|
||||
try? pipe.fileHandleForWriting.close()
|
||||
}
|
||||
|
||||
let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success
|
||||
if !didExitBeforeTimeout, process.isRunning {
|
||||
func terminateProcessAndWait() {
|
||||
process.terminate()
|
||||
let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success
|
||||
if !terminatedGracefully, process.isRunning {
|
||||
_ = Darwin.kill(process.processIdentifier, SIGKILL)
|
||||
process.waitUntilExit()
|
||||
}
|
||||
}
|
||||
|
||||
let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success
|
||||
if !didExitBeforeTimeout, process.isRunning {
|
||||
if operation?.isCancelled == true {
|
||||
terminateProcessAndWait()
|
||||
throw TerminalImageTransferExecutionError.cancelled
|
||||
}
|
||||
terminateProcessAndWait()
|
||||
debugLog(
|
||||
"remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
|
||||
"timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))"
|
||||
|
|
@ -3554,6 +3656,9 @@ final class WorkspaceRemoteSessionController {
|
|||
try? stderrHandle.close()
|
||||
let stdout = String(data: stdoutData, encoding: .utf8) ?? ""
|
||||
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
|
||||
if operation?.isCancelled == true {
|
||||
throw TerminalImageTransferExecutionError.cancelled
|
||||
}
|
||||
debugLog(
|
||||
"remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
|
||||
"status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " +
|
||||
|
|
@ -4005,6 +4110,70 @@ final class WorkspaceRemoteSessionController {
|
|||
}
|
||||
}
|
||||
|
||||
private func uploadDroppedFilesLocked(
|
||||
_ fileURLs: [URL],
|
||||
operation: TerminalImageTransferOperation
|
||||
) throws -> [String] {
|
||||
guard !fileURLs.isEmpty else { return [] }
|
||||
|
||||
let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions)
|
||||
var uploadedRemotePaths: [String] = []
|
||||
do {
|
||||
for localURL in fileURLs {
|
||||
try operation.throwIfCancelled()
|
||||
let normalizedLocalURL = localURL.standardizedFileURL
|
||||
guard normalizedLocalURL.isFileURL else {
|
||||
throw RemoteDropUploadError.invalidFileURL
|
||||
}
|
||||
|
||||
let remotePath = Self.remoteDropPath(for: normalizedLocalURL)
|
||||
uploadedRemotePaths.append(remotePath)
|
||||
var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"]
|
||||
if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") {
|
||||
scpArgs += ["-o", "StrictHostKeyChecking=accept-new"]
|
||||
}
|
||||
if let port = configuration.port {
|
||||
scpArgs += ["-P", String(port)]
|
||||
}
|
||||
if let identityFile = configuration.identityFile,
|
||||
!identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
scpArgs += ["-i", identityFile]
|
||||
}
|
||||
for option in scpSSHOptions {
|
||||
scpArgs += ["-o", option]
|
||||
}
|
||||
scpArgs += [normalizedLocalURL.path, "\(configuration.destination):\(remotePath)"]
|
||||
|
||||
let scpResult = try scpExec(arguments: scpArgs, timeout: 45, operation: operation)
|
||||
guard scpResult.status == 0 else {
|
||||
let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ??
|
||||
"scp exited \(scpResult.status)"
|
||||
throw RemoteDropUploadError.uploadFailed(detail)
|
||||
}
|
||||
}
|
||||
return uploadedRemotePaths
|
||||
} catch {
|
||||
cleanupUploadedRemotePaths(uploadedRemotePaths)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String {
|
||||
let extensionSuffix = fileURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lowercasedSuffix = extensionSuffix.isEmpty ? "" : ".\(extensionSuffix.lowercased())"
|
||||
return "/tmp/cmux-drop-\(uuid.uuidString.lowercased())\(lowercasedSuffix)"
|
||||
}
|
||||
|
||||
private func cleanupUploadedRemotePaths(_ remotePaths: [String]) {
|
||||
guard !remotePaths.isEmpty else { return }
|
||||
let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ")
|
||||
let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))"
|
||||
_ = try? sshExec(
|
||||
arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand],
|
||||
timeout: 8
|
||||
)
|
||||
}
|
||||
|
||||
private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello {
|
||||
let request = #"{"id":1,"method":"hello","params":{}}"#
|
||||
let script = "printf '%s\\n' \(Self.shellSingleQuoted(request)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio"
|
||||
|
|
@ -5410,6 +5579,8 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
let cachedTitle: String?
|
||||
let customTitle: String?
|
||||
let manuallyUnread: Bool
|
||||
let isRemoteTerminal: Bool
|
||||
let remoteRelayPort: Int?
|
||||
}
|
||||
|
||||
private var detachingTabIds: Set<TabID> = []
|
||||
|
|
@ -6217,6 +6388,11 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
remoteConfiguration != nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func isRemoteTerminalSurface(_ panelId: UUID) -> Bool {
|
||||
activeRemoteTerminalSurfaceIds.contains(panelId)
|
||||
}
|
||||
|
||||
var remoteDisplayTarget: String? {
|
||||
remoteConfiguration?.displayTarget
|
||||
}
|
||||
|
|
@ -6225,6 +6401,31 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
activeRemoteTerminalSessionCount > 0
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func uploadDroppedFilesForRemoteTerminal(
|
||||
_ fileURLs: [URL],
|
||||
operation: TerminalImageTransferOperation,
|
||||
completion: @escaping (Result<[String], Error>) -> Void
|
||||
) {
|
||||
guard let controller = remoteSessionController else {
|
||||
completion(.failure(RemoteDropUploadError.unavailable))
|
||||
return
|
||||
}
|
||||
controller.uploadDroppedFiles(fileURLs, operation: operation, completion: completion)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func uploadDroppedFilesForRemoteTerminal(
|
||||
_ fileURLs: [URL],
|
||||
completion: @escaping (Result<[String], Error>) -> Void
|
||||
) {
|
||||
uploadDroppedFilesForRemoteTerminal(
|
||||
fileURLs,
|
||||
operation: TerminalImageTransferOperation(),
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
func remoteStatusPayload() -> [String: Any] {
|
||||
let heartbeatAgeSeconds: Any = {
|
||||
guard let last = remoteLastHeartbeatAt else { return NSNull() }
|
||||
|
|
@ -7725,6 +7926,11 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
surfaceIdToPanelId[newTabId] = detached.panelId
|
||||
if detached.isRemoteTerminal,
|
||||
let detachedRelayPort = detached.remoteRelayPort,
|
||||
detachedRelayPort == remoteConfiguration?.relayPort {
|
||||
trackRemoteTerminalSurface(detached.panelId)
|
||||
}
|
||||
if let index {
|
||||
_ = bonsplitController.reorderTab(newTabId, toIndex: index)
|
||||
}
|
||||
|
|
@ -9530,7 +9736,11 @@ extension Workspace: BonsplitDelegate {
|
|||
directory: panelDirectories[panelId],
|
||||
cachedTitle: cachedTitle,
|
||||
customTitle: panelCustomTitles[panelId],
|
||||
manuallyUnread: manualUnreadPanelIds.contains(panelId)
|
||||
manuallyUnread: manualUnreadPanelIds.contains(panelId),
|
||||
isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId),
|
||||
remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId)
|
||||
? remoteConfiguration?.relayPort
|
||||
: nil
|
||||
)
|
||||
} else {
|
||||
if let closedBrowserRestoreSnapshot {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue