Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions Sources/GhosttyApp+RuntimeClipboardRead.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,16 @@ extension GhosttyApp {
case .insertText(let text):
completeClipboardRequest(with: text)
case .fileURLs(let fileURLs):
let target = requestTerminalSurface
.resolvedImageTransferTarget()
let target = await requestTerminalSurface
.resolvedImageTransferTargetAsync()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
guard !operation.isCancelled,
requestSurfaceIdentity.matches(requestTerminalSurface) else {
preparedContent.cleanupTransferredTemporaryFiles(
using: terminalPasteboard
)
completeClipboardRequest(with: "")
return
}
let plan = TerminalImageTransferPlanner.plan(
fileURLs: fileURLs,
target: target
Expand Down
91 changes: 79 additions & 12 deletions Sources/GhosttyNSView+PreparedImageTransfer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,86 @@ extension GhosttyNSView {
_ preparedContent: TerminalImageTransferPreparedContent,
mode: TerminalImageTransferMode = .drop,
onCancel: @escaping () -> Void
) -> Bool {
switch preparedContent {
case .reject, .rejectOversizedImage:
return false
case .insertText(let text):
return terminalSurface?.sendText(text) ?? false
case .fileURLs:
guard let terminalSurface else {
preparedContent.cleanupTransferredTemporaryFiles(
using: GhosttyApp.terminalPasteboard
)
return false
}
let knownTarget = terminalSurface.resolvedImageTransferTarget(mode: mode)
guard terminalSurface.imageTransferDetectionTTY(mode: mode) != nil else {
return executePreparedImageTransfer(
preparedContent,
mode: mode,
target: knownTarget,
onCancel: onCancel
)
}
let runtimeGeneration = terminalSurface.runtimeSurfaceGeneration
let task = Task { @MainActor [weak self, weak terminalSurface] in
guard let self, let terminalSurface else {
preparedContent.cleanupTransferredTemporaryFiles(
using: GhosttyApp.terminalPasteboard
)
return
}
let target = await terminalSurface
.resolvedImageTransferTargetAsync(mode: mode)
guard self.terminalSurface === terminalSurface,
terminalSurface.runtimeSurfaceGeneration == runtimeGeneration else {
preparedContent.cleanupTransferredTemporaryFiles(
using: GhosttyApp.terminalPasteboard
)
return
}
_ = self.executePreparedImageTransfer(
preparedContent,
mode: mode,
target: target,
onCancel: onCancel
)
}
// The AppKit drop/paste callbacks are synchronous. Returning true
// acknowledges ownership while the target lookup runs off-main.
_ = task
return true
}
}

@MainActor
@discardableResult
private func executePreparedImageTransfer(
_ preparedContent: TerminalImageTransferPreparedContent,
mode: TerminalImageTransferMode,
target: TerminalImageTransferTarget,
onCancel: @escaping () -> Void
) -> Bool {
if mode == .paste, case .fileURLs(let urls) = preparedContent,
resolvedImageTransferTarget(mode: mode) == .cloud,
deferRuntimeInputDuringClipboardRead(estimatedBytes: urls.reduce(0) { $0 + $1.path.utf8.count + 256 }, replay: { [weak self] in
if let self {
_ = self.executePreparedImageTransfer(preparedContent, mode: mode, onCancel: onCancel)
} else {
preparedContent.cleanupTransferredTemporaryFiles(using: GhosttyApp.terminalPasteboard)
target == .cloud,
deferRuntimeInputDuringClipboardRead(
estimatedBytes: urls.reduce(0) { $0 + $1.path.utf8.count + 256 },
replay: { [weak self] in
if let self {
_ = self.executePreparedImageTransfer(
preparedContent,
mode: mode,
target: target,
onCancel: onCancel
)
} else {
preparedContent.cleanupTransferredTemporaryFiles(
using: GhosttyApp.terminalPasteboard
)
}
}
}) {
) {
return true
}
switch preparedContent {
Expand All @@ -27,7 +97,7 @@ extension GhosttyNSView {
case .fileURLs(let fileURLs):
let plan = TerminalImageTransferPlanner.plan(
fileURLs: fileURLs,
target: resolvedImageTransferTarget(mode: mode),
target: target,
mode: mode
)
guard plan != .reject else {
Expand All @@ -36,10 +106,7 @@ extension GhosttyNSView {
)
return false
}
return executeImageTransferPlan(
plan,
onCancel: onCancel
)
return executeImageTransferPlan(plan, onCancel: onCancel)
}
}

Expand Down
45 changes: 45 additions & 0 deletions Sources/TerminalSSHSessionDetectionTimeoutGate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

/// Owns the one-shot result of a bounded SSH process lookup.
actor TerminalSSHSessionDetectionTimeoutGate {
private var completed = false
private var hasPendingResult = false
private var pendingResult: DetectedSSHSession?
private var continuation: CheckedContinuation<DetectedSSHSession?, Never>?
private var worker: Task<Void, Never>?
private var timeoutTask: Task<Void, Never>?

func install(
continuation: CheckedContinuation<DetectedSSHSession?, Never>,
worker: Task<Void, Never>,
timeoutTask: Task<Void, Never>
) {
guard !completed else {
worker.cancel()
timeoutTask.cancel()
continuation.resume(
returning: hasPendingResult ? pendingResult : nil
)
return
}
self.continuation = continuation
self.worker = worker
self.timeoutTask = timeoutTask
}

func finish(_ result: DetectedSSHSession?) {
guard !completed else { return }
completed = true
worker?.cancel()
timeoutTask?.cancel()
if let continuation {
continuation.resume(returning: result)
} else {
hasPendingResult = true
pendingResult = result
}
self.continuation = nil
worker = nil
timeoutTask = nil
}
}
59 changes: 59 additions & 0 deletions Sources/TerminalSSHSessionDetector+Async.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation

extension TerminalSSHSessionDetector {
static let defaultDetectionTimeout: TimeInterval = 0.25

static func defaultDetectionTimeoutSleep(_ timeout: TimeInterval) async {
let milliseconds = Int64(max(0, timeout) * 1_000)
try? await ContinuousClock().sleep(
for: .milliseconds(milliseconds)
)
}

/// Resolves an ad-hoc SSH session without making the caller wait for a
/// process that may be stuck reading another process's memory.
///
/// The synchronous detector runs on a detached worker. The deadline task
/// completes the request independently; a worker that is wedged in
/// `KERN_PROCARGS2` is intentionally abandoned and its late result is
/// ignored.
#if compiler(>=6.2)
@concurrent
#else
@Sendable
#endif
static func detectAsync(
forTTY ttyName: String,
timeout: TimeInterval = defaultDetectionTimeout,
detector: @escaping @Sendable (String) -> DetectedSSHSession? = { tty in
detect(forTTY: tty)
},
timeoutSleep: @escaping @Sendable (TimeInterval) async -> Void = {
timeout in
await TerminalSSHSessionDetector
.defaultDetectionTimeoutSleep(timeout)
}
) async -> DetectedSSHSession? {
let gate = TerminalSSHSessionDetectionTimeoutGate()
return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
let worker = Task.detached(priority: .utility) {
await gate.finish(detector(ttyName))
}
let timeoutTask = Task.detached(priority: .utility) {
await timeoutSleep(timeout)
await gate.finish(nil)
}
Task {
await gate.install(
continuation: continuation,
worker: worker,
timeoutTask: timeoutTask
)
}
}
} onCancel: {
Task { await gate.finish(nil) }
}
}
}
77 changes: 77 additions & 0 deletions Sources/TerminalSSHSessionDetector+ProcessInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Darwin

extension TerminalSSHSessionDetector {
static func processSnapshots(forTTY ttyName: String) -> [ProcessSnapshot] {
guard let ttyDevice = CmuxTopProcessSnapshot.deviceIdentifier(forTTYName: ttyName),
ttyDevice <= Int64(UInt32.max) else {
return []
}

var capacity = 64
while capacity <= 4096 {
var pids = [pid_t](repeating: 0, count: capacity)
let byteCount = pids.withUnsafeMutableBytes { rawBuffer in
proc_listpids(
UInt32(PROC_TTY_ONLY),
UInt32(ttyDevice),
rawBuffer.baseAddress,
Int32(rawBuffer.count)
)
}
guard byteCount > 0 else { return [] }
let count = min(Int(byteCount) / MemoryLayout<pid_t>.stride, pids.count)
if byteCount < Int32(pids.count * MemoryLayout<pid_t>.stride) {
return pids.prefix(count).compactMap { pid in
processSnapshot(
for: pid,
ttyName: ttyName,
ttyDevice: UInt32(ttyDevice)
)
}
}
if capacity == 4096 {
return pids.compactMap { pid in
processSnapshot(
for: pid,
ttyName: ttyName,
ttyDevice: UInt32(ttyDevice)
)
}
}
capacity *= 2
}
return []
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private static func processSnapshot(
for pid: pid_t,
ttyName: String,
ttyDevice: UInt32
) -> ProcessSnapshot? {
guard pid > 0 else { return nil }
var info = proc_bsdinfo()
let expectedSize = MemoryLayout<proc_bsdinfo>.stride
let size = proc_pidinfo(
pid,
PROC_PIDTBSDINFO,
0,
&info,
Int32(expectedSize)
)
guard size == expectedSize,
info.e_tdev == ttyDevice,
info.e_tpgid > 0,
info.pbi_pgid > 0 else {
return nil
}
return ProcessSnapshot(
pid: pid,
pgid: Int32(info.pbi_pgid),
tpgid: Int32(info.e_tpgid),
tty: ttyName,
executableName: CmuxTopProcessSnapshot
.fixedString(info.pbi_comm)
.lowercased()
)
}
}
Loading