cmux/Sources/Workspace.swift
Matt Van Horn f0f734de0a
feat(dialog): show tab name in close tab confirmation
Include the panel's custom title, terminal title, or directory name in
the close tab confirmation dialog's informative text so users know which
tab will be closed. Falls back to the generic message when no name is
available. The dialog title stays "Close tab?" for consistency with the
close-confirmation detection in AppDelegate.

Fixes #1603

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 21:19:36 -07:00

10021 lines
396 KiB
Swift

import Foundation
import SwiftUI
import AppKit
import Bonsplit
import Combine
import CryptoKit
import Darwin
import Network
import CoreText
func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String {
switch context {
case GHOSTTY_SURFACE_CONTEXT_WINDOW:
return "window"
case GHOSTTY_SURFACE_CONTEXT_TAB:
return "tab"
case GHOSTTY_SURFACE_CONTEXT_SPLIT:
return "split"
default:
return "unknown(\(context))"
}
}
func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? {
guard let quicklookFont = ghostty_surface_quicklook_font(surface) else {
return nil
}
let ctFont = Unmanaged<CTFont>.fromOpaque(quicklookFont).takeUnretainedValue()
let points = Float(CTFontGetSize(ctFont))
guard points > 0 else { return nil }
return points
}
func cmuxInheritedSurfaceConfig(
sourceSurface: ghostty_surface_t,
context: ghostty_surface_context_e
) -> ghostty_surface_config_s {
let inherited = ghostty_surface_inherited_config(sourceSurface, context)
var config = inherited
// Make runtime zoom inheritance explicit, even when Ghostty's
// inherit-font-size config is disabled.
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface)
if let points = runtimePoints {
config.font_size = points
}
#if DEBUG
let inheritedText = String(format: "%.2f", inherited.font_size)
let runtimeText = runtimePoints.map { String(format: "%.2f", $0) } ?? "nil"
let finalText = String(format: "%.2f", config.font_size)
dlog(
"zoom.inherit context=\(cmuxSurfaceContextName(context)) " +
"inherited=\(inheritedText) runtime=\(runtimeText) final=\(finalText)"
)
#endif
return config
}
struct SidebarStatusEntry {
let key: String
let value: String
let icon: String?
let color: String?
let url: URL?
let priority: Int
let format: SidebarMetadataFormat
let timestamp: Date
init(
key: String,
value: String,
icon: String? = nil,
color: String? = nil,
url: URL? = nil,
priority: Int = 0,
format: SidebarMetadataFormat = .plain,
timestamp: Date = Date()
) {
self.key = key
self.value = value
self.icon = icon
self.color = color
self.url = url
self.priority = priority
self.format = format
self.timestamp = timestamp
}
}
struct SidebarMetadataBlock {
let key: String
let markdown: String
let priority: Int
let timestamp: Date
}
enum SidebarMetadataFormat: String {
case plain
case markdown
}
private struct SessionPaneRestoreEntry {
let paneId: PaneID
let snapshot: SessionPaneLayoutSnapshot
}
struct WorkspaceRemoteDaemonManifest: Decodable, Equatable {
struct Entry: Decodable, Equatable {
let goOS: String
let goArch: String
let assetName: String
let downloadURL: String
let sha256: String
}
let schemaVersion: Int
let appVersion: String
let releaseTag: String
let releaseURL: String
let checksumsAssetName: String
let checksumsURL: String
let entries: [Entry]
func entry(goOS: String, goArch: String) -> Entry? {
entries.first { $0.goOS == goOS && $0.goArch == goArch }
}
}
extension Workspace {
nonisolated static let remoteDaemonManifestInfoKey = WorkspaceRemoteSessionController.remoteDaemonManifestInfoKey
nonisolated static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? {
WorkspaceRemoteSessionController.remoteDaemonManifest(from: infoDictionary)
}
nonisolated static func remoteDaemonCachedBinaryURL(
version: String,
goOS: String,
goArch: String,
fileManager: FileManager = .default
) throws -> URL {
try WorkspaceRemoteSessionController.remoteDaemonCachedBinaryURL(
version: version,
goOS: goOS,
goArch: goArch,
fileManager: fileManager
)
}
func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot {
let tree = bonsplitController.treeSnapshot()
let layout = sessionLayoutSnapshot(from: tree)
let orderedPanelIds = sidebarOrderedPanelIds()
var seen: Set<UUID> = []
var allPanelIds: [UUID] = []
for panelId in orderedPanelIds where seen.insert(panelId).inserted {
allPanelIds.append(panelId)
}
for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted {
allPanelIds.append(panelId)
}
let panelSnapshots = allPanelIds
.prefix(SessionPersistencePolicy.maxPanelsPerWorkspace)
.compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) }
let statusSnapshots = statusEntries.values
.sorted { lhs, rhs in lhs.key < rhs.key }
.map { entry in
SessionStatusEntrySnapshot(
key: entry.key,
value: entry.value,
icon: entry.icon,
color: entry.color,
timestamp: entry.timestamp.timeIntervalSince1970
)
}
let logSnapshots = logEntries.map { entry in
SessionLogEntrySnapshot(
message: entry.message,
level: entry.level.rawValue,
source: entry.source,
timestamp: entry.timestamp.timeIntervalSince1970
)
}
let progressSnapshot = progress.map { progress in
SessionProgressSnapshot(value: progress.value, label: progress.label)
}
let gitBranchSnapshot = gitBranch.map { branch in
SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty)
}
return SessionWorkspaceSnapshot(
processTitle: processTitle,
customTitle: customTitle,
customColor: customColor,
isPinned: isPinned,
currentDirectory: currentDirectory,
focusedPanelId: focusedPanelId,
layout: layout,
panels: panelSnapshots,
statusEntries: statusSnapshots,
logEntries: logSnapshots,
progress: progressSnapshot,
gitBranch: gitBranchSnapshot
)
}
func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) {
restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false)
let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
if !normalizedCurrentDirectory.isEmpty {
currentDirectory = normalizedCurrentDirectory
}
let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) })
let leafEntries = restoreSessionLayout(snapshot.layout)
var oldToNewPanelIds: [UUID: UUID] = [:]
for entry in leafEntries {
restorePane(
entry.paneId,
snapshot: entry.snapshot,
panelSnapshotsById: panelSnapshotsById,
oldToNewPanelIds: &oldToNewPanelIds
)
}
pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys))
applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot())
applyProcessTitle(snapshot.processTitle)
setCustomTitle(snapshot.customTitle)
setCustomColor(snapshot.customColor)
isPinned = snapshot.isPinned
// Status entries and agent PIDs are ephemeral runtime state tied to running
// processes (e.g. claude_code "Running"). Don't restore them across app
// restarts because the processes that set them are gone.
statusEntries.removeAll()
agentPIDs.removeAll()
logEntries = snapshot.logEntries.map { entry in
SidebarLogEntry(
message: entry.message,
level: SidebarLogLevel(rawValue: entry.level) ?? .info,
source: entry.source,
timestamp: Date(timeIntervalSince1970: entry.timestamp)
)
}
progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) }
gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) }
recomputeListeningPorts()
if let focusedOldPanelId = snapshot.focusedPanelId,
let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId],
panels[focusedNewPanelId] != nil {
focusPanel(focusedNewPanelId)
} else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil {
focusPanel(fallbackFocusedPanelId)
} else {
scheduleFocusReconcile()
}
}
private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot {
switch node {
case .pane(let pane):
let panelIds = sessionPanelIDs(for: pane)
let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:))
return .pane(
SessionPaneLayoutSnapshot(
panelIds: panelIds,
selectedPanelId: selectedPanelId
)
)
case .split(let split):
return .split(
SessionSplitLayoutSnapshot(
orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal,
dividerPosition: split.dividerPosition,
first: sessionLayoutSnapshot(from: split.first),
second: sessionLayoutSnapshot(from: split.second)
)
)
}
}
private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] {
var panelIds: [UUID] = []
var seen = Set<UUID>()
for tab in pane.tabs {
guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue }
if seen.insert(panelId).inserted {
panelIds.append(panelId)
}
}
return panelIds
}
private func sessionPanelID(forExternalTabIDString tabIDString: String) -> UUID? {
guard let tabUUID = UUID(uuidString: tabIDString) else { return nil }
for (surfaceId, panelId) in surfaceIdToPanelId {
guard let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { continue }
if surfaceUUID == tabUUID {
return panelId
}
}
return nil
}
private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? {
struct EncodedSurfaceID: Decodable {
let id: UUID
}
guard let data = try? JSONEncoder().encode(surfaceId),
let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else {
return nil
}
return decoded.id
}
private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? {
guard let panel = panels[panelId] else { return nil }
let panelTitle = panelTitle(panelId: panelId)
let customTitle = panelCustomTitles[panelId]
let directory = panelDirectories[panelId]
let isPinned = pinnedPanelIds.contains(panelId)
let isManuallyUnread = manualUnreadPanelIds.contains(panelId)
let branchSnapshot = panelGitBranches[panelId].map {
SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty)
}
let listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted()
let ttyName = surfaceTTYNames[panelId]
let terminalSnapshot: SessionTerminalPanelSnapshot?
let browserSnapshot: SessionBrowserPanelSnapshot?
let markdownSnapshot: SessionMarkdownPanelSnapshot?
switch panel.panelType {
case .terminal:
guard let terminalPanel = panel as? TerminalPanel else { return nil }
let shouldPersistScrollback = terminalPanel.shouldPersistScrollbackForSessionSnapshot()
let capturedScrollback = includeScrollback && shouldPersistScrollback
? TerminalController.shared.readTerminalTextForSnapshot(
terminalPanel: terminalPanel,
includeScrollback: true,
lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal
)
: nil
let resolvedScrollback = terminalSnapshotScrollback(
panelId: panelId,
capturedScrollback: capturedScrollback,
includeScrollback: includeScrollback,
allowFallbackScrollback: shouldPersistScrollback
)
terminalSnapshot = SessionTerminalPanelSnapshot(
workingDirectory: panelDirectories[panelId],
scrollback: resolvedScrollback
)
browserSnapshot = nil
markdownSnapshot = nil
case .browser:
guard let browserPanel = panel as? BrowserPanel else { return nil }
terminalSnapshot = nil
let historySnapshot = browserPanel.sessionNavigationHistorySnapshot()
browserSnapshot = SessionBrowserPanelSnapshot(
urlString: browserPanel.preferredURLStringForOmnibar(),
profileID: browserPanel.profileID,
shouldRenderWebView: browserPanel.shouldRenderWebView,
pageZoom: Double(browserPanel.currentPageZoomFactor()),
developerToolsVisible: browserPanel.isDeveloperToolsVisible(),
backHistoryURLStrings: historySnapshot.backHistoryURLStrings,
forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings
)
markdownSnapshot = nil
case .markdown:
guard let markdownPanel = panel as? MarkdownPanel else { return nil }
terminalSnapshot = nil
browserSnapshot = nil
markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath)
}
return SessionPanelSnapshot(
id: panelId,
type: panel.panelType,
title: panelTitle,
customTitle: customTitle,
directory: directory,
isPinned: isPinned,
isManuallyUnread: isManuallyUnread,
gitBranch: branchSnapshot,
listeningPorts: listeningPorts,
ttyName: ttyName,
terminal: terminalSnapshot,
browser: browserSnapshot,
markdown: markdownSnapshot
)
}
nonisolated static func resolvedSnapshotTerminalScrollback(
capturedScrollback: String?,
fallbackScrollback: String?,
allowFallbackScrollback: Bool = true
) -> String? {
if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) {
return captured
}
guard allowFallbackScrollback else { return nil }
return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback)
}
private func terminalSnapshotScrollback(
panelId: UUID,
capturedScrollback: String?,
includeScrollback: Bool,
allowFallbackScrollback: Bool = true
) -> String? {
guard includeScrollback else { return nil }
let fallback = allowFallbackScrollback ? restoredTerminalScrollbackByPanelId[panelId] : nil
let resolved = Self.resolvedSnapshotTerminalScrollback(
capturedScrollback: capturedScrollback,
fallbackScrollback: fallback,
allowFallbackScrollback: allowFallbackScrollback
)
if let resolved {
restoredTerminalScrollbackByPanelId[panelId] = resolved
} else {
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
}
return resolved
}
private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] {
guard let rootPaneId = bonsplitController.allPaneIds.first else {
return []
}
var leaves: [SessionPaneRestoreEntry] = []
restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves)
return leaves
}
private func restoreSessionLayoutNode(
_ node: SessionWorkspaceLayoutSnapshot,
inPane paneId: PaneID,
leaves: inout [SessionPaneRestoreEntry]
) {
switch node {
case .pane(let pane):
leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane))
case .split(let split):
var anchorPanelId = bonsplitController
.tabs(inPane: paneId)
.compactMap { panelIdFromSurfaceId($0.id) }
.first
if anchorPanelId == nil {
anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id
}
guard let anchorPanelId,
let newSplitPanel = newTerminalSplit(
from: anchorPanelId,
orientation: split.orientation.splitOrientation,
insertFirst: false,
focus: false
),
let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else {
leaves.append(
SessionPaneRestoreEntry(
paneId: paneId,
snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)
)
)
return
}
restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves)
restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves)
}
}
private func restorePane(
_ paneId: PaneID,
snapshot: SessionPaneLayoutSnapshot,
panelSnapshotsById: [UUID: SessionPanelSnapshot],
oldToNewPanelIds: inout [UUID: UUID]
) {
let existingPanelIds = bonsplitController
.tabs(inPane: paneId)
.compactMap { panelIdFromSurfaceId($0.id) }
let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil }
var createdPanelIds: [UUID] = []
for oldPanelId in desiredOldPanelIds {
guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue }
guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue }
createdPanelIds.append(createdPanelId)
oldToNewPanelIds[oldPanelId] = createdPanelId
}
guard !createdPanelIds.isEmpty else { return }
for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) {
_ = closePanel(oldPanelId, force: true)
}
for (index, panelId) in createdPanelIds.enumerated() {
_ = reorderSurface(panelId: panelId, toIndex: index)
}
let selectedPanelId: UUID? = {
if let selectedOldId = snapshot.selectedPanelId {
return oldToNewPanelIds[selectedOldId]
}
return createdPanelIds.first
}()
if let selectedPanelId,
let selectedTabId = surfaceIdFromPanelId(selectedPanelId) {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(selectedTabId)
}
}
private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? {
switch snapshot.type {
case .terminal:
let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory
let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment(
for: snapshot.terminal?.scrollback
)
guard let terminalPanel = newTerminalSurface(
inPane: paneId,
focus: false,
workingDirectory: workingDirectory,
startupEnvironment: replayEnvironment
) else {
return nil
}
let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback)
if let fallbackScrollback {
restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback
} else {
restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id)
}
applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id)
return terminalPanel.id
case .browser:
let initialURL = snapshot.browser?.urlString.flatMap { URL(string: $0) }
guard let browserPanel = newBrowserSurface(
inPane: paneId,
url: initialURL,
focus: false,
preferredProfileID: snapshot.browser?.profileID
) else {
return nil
}
applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id)
return browserPanel.id
case .markdown:
guard let filePath = snapshot.markdown?.filePath,
let markdownPanel = newMarkdownSurface(
inPane: paneId,
filePath: filePath,
focus: false
) else {
return nil
}
applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id)
return markdownPanel.id
}
}
private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) {
if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
panelTitles[panelId] = title
}
setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle)
setPanelPinned(panelId: panelId, pinned: snapshot.isPinned)
if snapshot.isManuallyUnread {
markPanelUnread(panelId)
} else {
clearManualUnread(panelId: panelId)
}
if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty {
updatePanelDirectory(panelId: panelId, directory: directory)
}
if let branch = snapshot.gitBranch {
panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty)
} else {
panelGitBranches.removeValue(forKey: panelId)
}
surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted()
if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty {
surfaceTTYNames[panelId] = ttyName
} else {
surfaceTTYNames.removeValue(forKey: panelId)
}
if let browserSnapshot = snapshot.browser,
let browserPanel = browserPanel(for: panelId) {
browserPanel.restoreSessionNavigationHistory(
backHistoryURLStrings: browserSnapshot.backHistoryURLStrings ?? [],
forwardHistoryURLStrings: browserSnapshot.forwardHistoryURLStrings ?? [],
currentURLString: browserSnapshot.urlString
)
let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom)))
if pageZoom.isFinite {
_ = browserPanel.setPageZoomFactor(pageZoom)
}
if browserSnapshot.developerToolsVisible {
_ = browserPanel.showDeveloperTools()
browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore")
} else {
_ = browserPanel.hideDeveloperTools()
}
}
}
private func applySessionDividerPositions(
snapshotNode: SessionWorkspaceLayoutSnapshot,
liveNode: ExternalTreeNode
) {
switch (snapshotNode, liveNode) {
case (.split(let snapshotSplit), .split(let liveSplit)):
if let splitID = UUID(uuidString: liveSplit.id) {
_ = bonsplitController.setDividerPosition(
CGFloat(snapshotSplit.dividerPosition),
forSplit: splitID,
fromExternal: true
)
}
applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first)
applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second)
default:
return
}
}
}
final class WorkspaceRemoteDaemonPendingCallRegistry {
final class PendingCall {
let id: Int
fileprivate let semaphore = DispatchSemaphore(value: 0)
fileprivate var response: [String: Any]?
fileprivate var failureMessage: String?
fileprivate init(id: Int) {
self.id = id
}
}
enum WaitOutcome {
case response([String: Any])
case failure(String)
case missing
case timedOut
}
private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.pending.\(UUID().uuidString)")
private var nextRequestID = 1
private var pendingCalls: [Int: PendingCall] = [:]
func reset() {
queue.sync {
nextRequestID = 1
pendingCalls.removeAll(keepingCapacity: false)
}
}
func register() -> PendingCall {
queue.sync {
let call = PendingCall(id: nextRequestID)
nextRequestID += 1
pendingCalls[call.id] = call
return call
}
}
@discardableResult
func resolve(id: Int, payload: [String: Any]) -> Bool {
queue.sync {
guard let pendingCall = pendingCalls[id] else { return false }
pendingCall.response = payload
pendingCall.semaphore.signal()
return true
}
}
func failAll(_ message: String) {
queue.sync {
let calls = Array(pendingCalls.values)
for call in calls {
guard call.response == nil, call.failureMessage == nil else { continue }
call.failureMessage = message
call.semaphore.signal()
}
}
}
func remove(_ call: PendingCall) {
_ = queue.sync {
pendingCalls.removeValue(forKey: call.id)
}
}
func wait(for call: PendingCall, timeout: TimeInterval) -> WaitOutcome {
if call.semaphore.wait(timeout: .now() + timeout) == .timedOut {
_ = queue.sync {
pendingCalls.removeValue(forKey: call.id)
}
// A response can win the race immediately before timeout cleanup removes the call.
// Drain any late signal so DispatchSemaphore is not deallocated with a positive count.
_ = call.semaphore.wait(timeout: .now())
return .timedOut
}
return queue.sync {
guard let pendingCall = pendingCalls.removeValue(forKey: call.id) else {
return .missing
}
if let failure = pendingCall.failureMessage {
return .failure(failure)
}
guard let response = pendingCall.response else {
return .missing
}
return .response(response)
}
}
}
private final class WorkspaceRemoteDaemonRPCClient {
private static let maxStdoutBufferBytes = 256 * 1024
static let requiredProxyStreamCapability = "proxy.stream.push"
enum StreamEvent {
case data(Data)
case eof(Data)
case error(String)
}
private struct StreamSubscription {
let queue: DispatchQueue
let handler: (StreamEvent) -> Void
}
private let configuration: WorkspaceRemoteConfiguration
private let remotePath: String
private let onUnexpectedTermination: (String) -> Void
private let writeQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.write.\(UUID().uuidString)")
private let stateQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.state.\(UUID().uuidString)")
private let pendingCalls = WorkspaceRemoteDaemonPendingCallRegistry()
private var process: Process?
private var stdinHandle: FileHandle?
private var stdoutHandle: FileHandle?
private var stderrHandle: FileHandle?
private var isClosed = true
private var shouldReportTermination = true
private var stdoutBuffer = Data()
private var stderrBuffer = ""
private var streamSubscriptions: [String: StreamSubscription] = [:]
init(
configuration: WorkspaceRemoteConfiguration,
remotePath: String,
onUnexpectedTermination: @escaping (String) -> Void
) {
self.configuration = configuration
self.remotePath = remotePath
self.onUnexpectedTermination = onUnexpectedTermination
}
func start() throws {
let process = Process()
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
process.arguments = Self.daemonArguments(configuration: configuration, remotePath: remotePath)
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
self?.stateQueue.async {
self?.consumeStdoutData(data)
}
}
stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
self?.stateQueue.async {
self?.consumeStderrData(data)
}
}
process.terminationHandler = { [weak self] terminated in
self?.stateQueue.async {
self?.handleProcessTermination(terminated)
}
}
do {
try process.run()
} catch {
throw NSError(domain: "cmux.remote.daemon.rpc", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to launch SSH daemon transport: \(error.localizedDescription)",
])
}
stateQueue.sync {
self.process = process
self.stdinHandle = stdinPipe.fileHandleForWriting
self.stdoutHandle = stdoutPipe.fileHandleForReading
self.stderrHandle = stderrPipe.fileHandleForReading
self.isClosed = false
self.shouldReportTermination = true
self.stdoutBuffer = Data()
self.stderrBuffer = ""
self.streamSubscriptions.removeAll(keepingCapacity: false)
}
pendingCalls.reset()
do {
let hello = try call(method: "hello", params: [:], timeout: 8.0)
let capabilities = (hello["capabilities"] as? [String]) ?? []
guard capabilities.contains(Self.requiredProxyStreamCapability) else {
throw NSError(domain: "cmux.remote.daemon.rpc", code: 2, userInfo: [
NSLocalizedDescriptionKey: "remote daemon missing required capability \(Self.requiredProxyStreamCapability)",
])
}
} catch {
stop(suppressTerminationCallback: true)
throw error
}
}
func stop() {
stop(suppressTerminationCallback: true)
}
func openStream(host: String, port: Int, timeoutMs: Int = 10000) throws -> String {
let result = try call(
method: "proxy.open",
params: [
"host": host,
"port": port,
"timeout_ms": timeoutMs,
],
timeout: 12.0
)
let streamID = (result["stream_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !streamID.isEmpty else {
throw NSError(domain: "cmux.remote.daemon.rpc", code: 3, userInfo: [
NSLocalizedDescriptionKey: "proxy.open missing stream_id",
])
}
return streamID
}
func writeStream(streamID: String, data: Data) throws {
_ = try call(
method: "proxy.write",
params: [
"stream_id": streamID,
"data_base64": data.base64EncodedString(),
],
timeout: 8.0
)
}
func attachStream(
streamID: String,
queue: DispatchQueue,
onEvent: @escaping (StreamEvent) -> Void
) throws {
let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedStreamID.isEmpty else {
throw NSError(domain: "cmux.remote.daemon.rpc", code: 17, userInfo: [
NSLocalizedDescriptionKey: "proxy.stream.subscribe requires stream_id",
])
}
stateQueue.sync {
streamSubscriptions[trimmedStreamID] = StreamSubscription(queue: queue, handler: onEvent)
}
do {
_ = try call(
method: "proxy.stream.subscribe",
params: ["stream_id": trimmedStreamID],
timeout: 8.0
)
} catch {
unregisterStream(streamID: trimmedStreamID)
throw error
}
}
func unregisterStream(streamID: String) {
let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedStreamID.isEmpty else { return }
_ = stateQueue.sync {
streamSubscriptions.removeValue(forKey: trimmedStreamID)
}
}
func closeStream(streamID: String) {
unregisterStream(streamID: streamID)
_ = try? call(
method: "proxy.close",
params: ["stream_id": streamID],
timeout: 4.0
)
}
private func call(method: String, params: [String: Any], timeout: TimeInterval) throws -> [String: Any] {
let pendingCall = pendingCalls.register()
let requestID = pendingCall.id
let payload: Data
do {
payload = try Self.encodeJSON([
"id": requestID,
"method": method,
"params": params,
])
} catch {
pendingCalls.remove(pendingCall)
throw NSError(domain: "cmux.remote.daemon.rpc", code: 10, userInfo: [
NSLocalizedDescriptionKey: "failed to encode daemon RPC request \(method): \(error.localizedDescription)",
])
}
do {
try writeQueue.sync {
try writePayload(payload)
}
} catch {
pendingCalls.remove(pendingCall)
throw error
}
let response: [String: Any]
switch pendingCalls.wait(for: pendingCall, timeout: timeout) {
case .timedOut:
stop(suppressTerminationCallback: false)
throw NSError(domain: "cmux.remote.daemon.rpc", code: 11, userInfo: [
NSLocalizedDescriptionKey: "daemon RPC timeout waiting for \(method) response",
])
case .failure(let failure):
throw NSError(domain: "cmux.remote.daemon.rpc", code: 12, userInfo: [
NSLocalizedDescriptionKey: failure,
])
case .missing:
throw NSError(domain: "cmux.remote.daemon.rpc", code: 13, userInfo: [
NSLocalizedDescriptionKey: "daemon RPC \(method) returned empty response",
])
case .response(let pendingResponse):
response = pendingResponse
}
let ok = (response["ok"] as? Bool) ?? false
if ok {
return (response["result"] as? [String: Any]) ?? [:]
}
let errorObject = (response["error"] as? [String: Any]) ?? [:]
let code = (errorObject["code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "rpc_error"
let message = (errorObject["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "daemon RPC call failed"
throw NSError(domain: "cmux.remote.daemon.rpc", code: 14, userInfo: [
NSLocalizedDescriptionKey: "\(method) failed (\(code)): \(message)",
])
}
private func writePayload(_ payload: Data) throws {
let stdinHandle: FileHandle = stateQueue.sync {
self.stdinHandle ?? FileHandle.nullDevice
}
if stdinHandle === FileHandle.nullDevice {
throw NSError(domain: "cmux.remote.daemon.rpc", code: 15, userInfo: [
NSLocalizedDescriptionKey: "daemon transport is not connected",
])
}
do {
try stdinHandle.write(contentsOf: payload)
try stdinHandle.write(contentsOf: Data([0x0A]))
} catch {
stop(suppressTerminationCallback: false)
throw NSError(domain: "cmux.remote.daemon.rpc", code: 16, userInfo: [
NSLocalizedDescriptionKey: "failed writing daemon RPC request: \(error.localizedDescription)",
])
}
}
private func consumeStdoutData(_ data: Data) {
guard !data.isEmpty else {
signalPendingFailureLocked("daemon transport closed stdout")
return
}
stdoutBuffer.append(data)
if stdoutBuffer.count > Self.maxStdoutBufferBytes {
stdoutBuffer.removeAll(keepingCapacity: false)
signalPendingFailureLocked("daemon transport stdout exceeded \(Self.maxStdoutBufferBytes) bytes without message framing")
process?.terminate()
return
}
while let newlineIndex = stdoutBuffer.firstIndex(of: 0x0A) {
var lineData = Data(stdoutBuffer[..<newlineIndex])
stdoutBuffer.removeSubrange(...newlineIndex)
if let carriageIndex = lineData.lastIndex(of: 0x0D), carriageIndex == lineData.index(before: lineData.endIndex) {
lineData.remove(at: carriageIndex)
}
guard !lineData.isEmpty else { continue }
guard let payload = try? JSONSerialization.jsonObject(with: lineData, options: []) as? [String: Any] else {
continue
}
if let responseID = Self.responseID(in: payload) {
_ = pendingCalls.resolve(id: responseID, payload: payload)
continue
}
consumeEventPayload(payload)
}
}
private func consumeStderrData(_ data: Data) {
guard !data.isEmpty else { return }
guard let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty else { return }
stderrBuffer.append(chunk)
if stderrBuffer.count > 8192 {
stderrBuffer.removeFirst(stderrBuffer.count - 8192)
}
}
private func consumeEventPayload(_ payload: [String: Any]) {
guard let eventName = (payload["event"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!eventName.isEmpty,
let streamID = (payload["stream_id"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!streamID.isEmpty else {
return
}
let subscription: StreamSubscription?
let event: StreamEvent?
switch eventName {
case "proxy.stream.data":
subscription = streamSubscriptions[streamID]
event = .data(Self.decodeBase64Data(payload["data_base64"]))
case "proxy.stream.eof":
subscription = streamSubscriptions.removeValue(forKey: streamID)
event = .eof(Self.decodeBase64Data(payload["data_base64"]))
case "proxy.stream.error":
subscription = streamSubscriptions.removeValue(forKey: streamID)
let detail = ((payload["error"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 }
?? "stream error"
event = .error(detail)
default:
return
}
guard let subscription, let event else { return }
subscription.queue.async {
subscription.handler(event)
}
}
private func handleProcessTermination(_ process: Process) {
let shouldNotify: Bool = {
guard self.process === process else { return false }
return !isClosed && shouldReportTermination
}()
let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport exited with status \(process.terminationStatus)"
isClosed = true
self.process = nil
stdinHandle = nil
stdoutHandle?.readabilityHandler = nil
stdoutHandle = nil
stderrHandle?.readabilityHandler = nil
stderrHandle = nil
streamSubscriptions.removeAll(keepingCapacity: false)
signalPendingFailureLocked(detail)
guard shouldNotify else { return }
onUnexpectedTermination(detail)
}
private func stop(suppressTerminationCallback: Bool) {
let captured: (Process?, FileHandle?, FileHandle?, FileHandle?, Bool, String) = stateQueue.sync {
let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport stopped"
let shouldNotify = !suppressTerminationCallback && !isClosed
shouldReportTermination = !suppressTerminationCallback
if isClosed {
return (nil, nil, nil, nil, false, detail)
}
isClosed = true
signalPendingFailureLocked("daemon transport stopped")
let capturedProcess = process
let capturedStdin = stdinHandle
let capturedStdout = stdoutHandle
let capturedStderr = stderrHandle
process = nil
stdinHandle = nil
stdoutHandle = nil
stderrHandle = nil
streamSubscriptions.removeAll(keepingCapacity: false)
return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail)
}
captured.2?.readabilityHandler = nil
captured.3?.readabilityHandler = nil
try? captured.1?.close()
try? captured.2?.close()
try? captured.3?.close()
if let process = captured.0, process.isRunning {
process.terminate()
}
if captured.4 {
onUnexpectedTermination(captured.5)
}
}
private func signalPendingFailureLocked(_ message: String) {
pendingCalls.failAll(message)
}
private static func responseID(in payload: [String: Any]) -> Int? {
if let intValue = payload["id"] as? Int {
return intValue
}
if let numberValue = payload["id"] as? NSNumber {
return numberValue.intValue
}
return nil
}
private static func decodeBase64Data(_ value: Any?) -> Data {
guard let encoded = value as? String, !encoded.isEmpty else { return Data() }
return Data(base64Encoded: encoded) ?? Data()
}
private static func encodeJSON(_ object: [String: Any]) throws -> Data {
try JSONSerialization.data(withJSONObject: object, options: [])
}
private static func daemonArguments(configuration: WorkspaceRemoteConfiguration, remotePath: String) -> [String] {
let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio"
// Use non-login sh so remote ~/.profile noise does not interfere with daemon transport startup.
let command = "sh -c \(shellSingleQuoted(script))"
return ["-T", "-S", "none"]
+ sshCommonArguments(configuration: configuration, batchMode: true)
+ ["-o", "RequestTTY=no", configuration.destination, command]
}
private static let batchSSHControlOptionKeys: Set<String> = [
"controlmaster",
"controlpersist",
]
private static func sshCommonArguments(configuration: WorkspaceRemoteConfiguration, batchMode: Bool) -> [String] {
let effectiveSSHOptions: [String] = {
if batchMode {
return backgroundSSHOptions(configuration.sshOptions)
}
return normalizedSSHOptions(configuration.sshOptions)
}()
var args: [String] = [
"-o", "ConnectTimeout=6",
"-o", "ServerAliveInterval=20",
"-o", "ServerAliveCountMax=2",
]
if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") {
args += ["-o", "StrictHostKeyChecking=accept-new"]
}
if batchMode {
args += ["-o", "BatchMode=yes"]
// Batch helpers should reuse an existing ControlPath if one was configured,
// but must never try to negotiate a new master connection.
args += ["-o", "ControlMaster=no"]
}
if let port = configuration.port {
args += ["-p", String(port)]
}
if let identityFile = configuration.identityFile,
!identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args += ["-i", identityFile]
}
for option in effectiveSSHOptions {
args += ["-o", option]
}
return args
}
private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool {
let loweredKey = key.lowercased()
for option in options {
let token = sshOptionKey(option)
if token == loweredKey {
return true
}
}
return false
}
private static func normalizedSSHOptions(_ options: [String]) -> [String] {
options.compactMap { option in
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed
}
}
private static func backgroundSSHOptions(_ options: [String]) -> [String] {
normalizedSSHOptions(options).filter { option in
guard let key = sshOptionKey(option) else { return false }
return !batchSSHControlOptionKeys.contains(key)
}
}
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 shellSingleQuoted(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}
private static func bestErrorLine(stderr: String) -> String? {
let lines = stderr
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
for line in lines.reversed() where !isNoiseLine(line) {
return line
}
return lines.last
}
private static func isNoiseLine(_ line: String) -> Bool {
let lowered = line.lowercased()
if lowered.hasPrefix("warning: permanently added") { return true }
if lowered.hasPrefix("debug") { return true }
if lowered.hasPrefix("transferred:") { return true }
if lowered.hasPrefix("openbsd_") { return true }
if lowered.contains("pseudo-terminal will not be allocated") { return true }
return false
}
}
enum RemoteLoopbackHTTPRequestRewriter {
private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a])
private static let canonicalLoopbackHost = "localhost"
private static let requestLineMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "PRI"]
static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data {
rewriteIfNeeded(data: data, aliasHost: aliasHost, allowIncompleteHeadersAtEOF: false)
}
static func rewriteIfNeeded(data: Data, aliasHost: String, allowIncompleteHeadersAtEOF: Bool) -> Data {
let headerData: Data
let remainder: Data
if let headerRange = data.range(of: headerDelimiter) {
headerData = Data(data[..<headerRange.upperBound])
remainder = Data(data[headerRange.upperBound...])
} else if allowIncompleteHeadersAtEOF {
headerData = data
remainder = Data()
} else {
return data
}
guard let headerText = String(data: headerData, encoding: .utf8) else { return data }
var lines = headerText.components(separatedBy: "\r\n")
guard !lines.isEmpty else { return data }
guard let requestLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data }
guard requestLineLooksHTTP(lines[requestLineIndex]) else { return data }
let rewrittenRequestLine = rewriteRequestLine(lines[requestLineIndex], aliasHost: aliasHost)
if rewrittenRequestLine != lines[requestLineIndex] {
lines[requestLineIndex] = rewrittenRequestLine
}
for index in (requestLineIndex + 1)..<lines.count where !lines[index].isEmpty {
lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost)
}
let rewrittenHeaderText = lines.joined(separator: "\r\n")
guard rewrittenHeaderText != headerText else { return data }
return Data(rewrittenHeaderText.utf8) + remainder
}
private static func requestLineLooksHTTP(_ requestLine: String) -> Bool {
let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines)
let method = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init)?.uppercased() ?? ""
return requestLineMethods.contains(method)
}
private static func rewriteRequestLine(_ requestLine: String, aliasHost: String) -> String {
let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false)
guard parts.count >= 3 else { return requestLine }
var components = URLComponents(string: String(parts[1]))
guard let host = components?.host,
BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
return requestLine
}
components?.host = canonicalLoopbackHost
guard let rewrittenURL = components?.string else { return requestLine }
var rewritten = parts
rewritten[1] = Substring(rewrittenURL)
let leadingTrivia = requestLine.prefix { $0.isWhitespace || $0.isNewline }
let trailingTrivia = String(requestLine.reversed().prefix { $0.isWhitespace || $0.isNewline }.reversed())
return String(leadingTrivia) + rewritten.joined(separator: " ") + trailingTrivia
}
private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String {
guard let colonIndex = line.firstIndex(of: ":") else { return line }
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let valueStart = line.index(after: colonIndex)
let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines)
switch name {
case "host":
guard let rewrittenHost = rewriteHostValue(rawValue, aliasHost: aliasHost) else { return line }
return "\(line[..<valueStart]) \(rewrittenHost)"
case "origin", "referer":
guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line }
return "\(line[..<valueStart]) \(rewrittenURL)"
default:
return line
}
}
private static func rewriteHostValue(_ value: String, aliasHost: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("["),
let closing = trimmed.firstIndex(of: "]") {
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closing])
guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
return nil
}
let remainder = String(trimmed[closing...].dropFirst())
return canonicalLoopbackHost + remainder
}
if let colonIndex = trimmed.lastIndex(of: ":"), !trimmed[..<colonIndex].contains(":") {
let host = String(trimmed[..<colonIndex])
guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
return nil
}
return canonicalLoopbackHost + trimmed[colonIndex...]
}
guard BrowserInsecureHTTPSettings.normalizeHost(trimmed) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
return nil
}
return canonicalLoopbackHost
}
private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? {
var components = URLComponents(string: value)
guard let host = components?.host,
BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else {
return nil
}
components?.host = canonicalLoopbackHost
return components?.string
}
}
struct RemoteLoopbackHTTPRequestStreamRewriter {
private static let maxHeaderBytes = 64 * 1024
private static let headerDelimiter = Data([0x0D, 0x0A, 0x0D, 0x0A])
private let aliasHost: String
private var pendingHeaderBytes = Data()
private var hasForwardedHeaders = false
init(aliasHost: String) {
self.aliasHost = aliasHost
}
mutating func rewriteNextChunk(_ data: Data, eof: Bool) -> Data {
guard !hasForwardedHeaders else { return data }
pendingHeaderBytes.append(data)
if pendingHeaderBytes.count > Self.maxHeaderBytes {
hasForwardedHeaders = true
let payload = pendingHeaderBytes
pendingHeaderBytes = Data()
return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded(
data: payload,
aliasHost: aliasHost,
allowIncompleteHeadersAtEOF: true
)
}
guard pendingHeaderBytes.range(of: Self.headerDelimiter) != nil else {
guard eof else { return Data() }
hasForwardedHeaders = true
let payload = pendingHeaderBytes
pendingHeaderBytes = Data()
return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded(
data: payload,
aliasHost: aliasHost,
allowIncompleteHeadersAtEOF: true
)
}
hasForwardedHeaders = true
let payload = pendingHeaderBytes
pendingHeaderBytes = Data()
return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded(
data: payload,
aliasHost: aliasHost
)
}
}
enum RemoteLoopbackHTTPResponseRewriter {
private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a])
private static let canonicalLoopbackHost = "localhost"
static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data {
guard let headerRange = data.range(of: headerDelimiter) else { return data }
let headerData = Data(data[..<headerRange.upperBound])
guard let headerText = String(data: headerData, encoding: .utf8) else { return data }
var lines = headerText.components(separatedBy: "\r\n")
guard let statusLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data }
guard lines[statusLineIndex].uppercased().hasPrefix("HTTP/") else { return data }
for index in (statusLineIndex + 1)..<lines.count where !lines[index].isEmpty {
lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost)
}
let rewrittenHeaderText = lines.joined(separator: "\r\n")
guard rewrittenHeaderText != headerText else { return data }
return Data(rewrittenHeaderText.utf8) + data[headerRange.upperBound...]
}
private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String {
guard let colonIndex = line.firstIndex(of: ":") else { return line }
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let valueStart = line.index(after: colonIndex)
let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines)
switch name {
case "location", "content-location", "origin", "referer", "access-control-allow-origin":
guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line }
return "\(line[..<valueStart]) \(rewrittenURL)"
case "set-cookie":
guard let rewrittenCookie = rewriteCookieValue(rawValue, aliasHost: aliasHost) else { return line }
return "\(line[..<valueStart]) \(rewrittenCookie)"
default:
return line
}
}
private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? {
var components = URLComponents(string: value)
guard let host = components?.host,
BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else {
return nil
}
components?.host = aliasHost
return components?.string
}
private static func rewriteCookieValue(_ value: String, aliasHost: String) -> String? {
let parts = value.split(separator: ";", omittingEmptySubsequences: false).map(String.init)
guard !parts.isEmpty else { return nil }
var didRewrite = false
let rewrittenParts = parts.map { part -> String in
let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.lowercased().hasPrefix("domain=") else { return part }
let domainValue = String(trimmed.dropFirst("domain=".count))
guard BrowserInsecureHTTPSettings.normalizeHost(domainValue) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else {
return part
}
didRewrite = true
let leadingWhitespace = part.prefix { $0.isWhitespace }
return "\(leadingWhitespace)Domain=\(aliasHost)"
}
return didRewrite ? rewrittenParts.joined(separator: ";") : nil
}
}
private final class WorkspaceRemoteDaemonProxyTunnel {
private final class ProxySession {
private static let maxHandshakeBytes = 64 * 1024
private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me"
private enum HandshakeProtocol {
case undecided
case socks5
case connect
}
private enum SocksStage {
case greeting
case request
}
private struct SocksRequest {
let host: String
let port: Int
let command: UInt8
let consumedBytes: Int
}
let id = UUID()
private let connection: NWConnection
private let rpcClient: WorkspaceRemoteDaemonRPCClient
private let queue: DispatchQueue
private let onClose: (UUID) -> Void
private var isClosed = false
private var protocolKind: HandshakeProtocol = .undecided
private var socksStage: SocksStage = .greeting
private var handshakeBuffer = Data()
private var streamID: String?
private var localInputEOF = false
private var rewritesLoopbackHTTPHeaders = false
private var loopbackRequestHeaderRewriter: RemoteLoopbackHTTPRequestStreamRewriter?
private var pendingRemoteHTTPHeaderBytes = Data()
private var hasForwardedRemoteHTTPHeaders = false
init(
connection: NWConnection,
rpcClient: WorkspaceRemoteDaemonRPCClient,
queue: DispatchQueue,
onClose: @escaping (UUID) -> Void
) {
self.connection = connection
self.rpcClient = rpcClient
self.queue = queue
self.onClose = onClose
}
func start() {
connection.stateUpdateHandler = { [weak self] state in
guard let self else { return }
switch state {
case .failed(let error):
self.close(reason: "proxy client connection failed: \(error)")
case .cancelled:
self.close(reason: nil)
default:
break
}
}
connection.start(queue: queue)
receiveNext()
}
func stop() {
close(reason: nil)
}
private func receiveNext() {
guard !isClosed else { return }
connection.receive(minimumIncompleteLength: 1, maximumLength: 32768) { [weak self] data, _, isComplete, error in
guard let self, !self.isClosed else { return }
if let data, !data.isEmpty {
if self.streamID == nil {
if self.handshakeBuffer.count + data.count > Self.maxHandshakeBytes {
self.close(reason: "proxy handshake exceeded \(Self.maxHandshakeBytes) bytes")
return
}
self.handshakeBuffer.append(data)
self.processHandshakeBuffer()
} else {
self.forwardToRemote(data, eof: isComplete)
}
}
if isComplete {
// Treat local EOF as a half-close: keep remote read loop alive so we can
// drain upstream response bytes (for example curl closing write-side after
// sending an HTTP request through SOCKS/CONNECT).
self.localInputEOF = true
if self.streamID != nil, data?.isEmpty ?? true {
self.forwardToRemote(Data(), eof: true, allowAfterEOF: true)
}
if self.streamID == nil {
self.close(reason: nil)
}
return
}
if let error {
self.close(reason: "proxy client receive error: \(error)")
return
}
self.receiveNext()
}
}
private func processHandshakeBuffer() {
guard !isClosed else { return }
while streamID == nil {
switch protocolKind {
case .undecided:
guard let first = handshakeBuffer.first else { return }
protocolKind = (first == 0x05) ? .socks5 : .connect
case .socks5:
if !processSocksHandshakeStep() {
return
}
case .connect:
if !processConnectHandshakeStep() {
return
}
}
}
}
private func processSocksHandshakeStep() -> Bool {
switch socksStage {
case .greeting:
guard handshakeBuffer.count >= 2 else { return false }
let methodCount = Int(handshakeBuffer[1])
let total = 2 + methodCount
guard handshakeBuffer.count >= total else { return false }
let methods = [UInt8](handshakeBuffer[2..<total])
handshakeBuffer = Data(handshakeBuffer.dropFirst(total))
socksStage = .request
if !methods.contains(0x00) {
sendAndClose(Data([0x05, 0xFF]))
return false
}
sendLocal(Data([0x05, 0x00]))
return true
case .request:
let request: SocksRequest
do {
guard let parsed = try parseSocksRequest(from: handshakeBuffer) else { return false }
request = parsed
} catch {
sendAndClose(Data([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]))
return false
}
let pending = handshakeBuffer.count > request.consumedBytes
? Data(handshakeBuffer[request.consumedBytes...])
: Data()
handshakeBuffer = Data()
guard request.command == 0x01 else {
sendAndClose(Data([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]))
return false
}
openRemoteStream(
host: request.host,
port: request.port,
successResponse: Data([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
failureResponse: Data([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
pendingPayload: pending
)
return false
}
}
private func parseSocksRequest(from data: Data) throws -> SocksRequest? {
let bytes = [UInt8](data)
guard bytes.count >= 4 else { return nil }
guard bytes[0] == 0x05 else {
throw NSError(domain: "cmux.remote.proxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS version"])
}
let command = bytes[1]
let addressType = bytes[3]
var cursor = 4
let host: String
switch addressType {
case 0x01:
guard bytes.count >= cursor + 4 + 2 else { return nil }
let octets = bytes[cursor..<(cursor + 4)].map { String($0) }
host = octets.joined(separator: ".")
cursor += 4
case 0x03:
guard bytes.count >= cursor + 1 else { return nil }
let length = Int(bytes[cursor])
cursor += 1
guard bytes.count >= cursor + length + 2 else { return nil }
let hostData = Data(bytes[cursor..<(cursor + length)])
host = String(data: hostData, encoding: .utf8) ?? ""
cursor += length
case 0x04:
guard bytes.count >= cursor + 16 + 2 else { return nil }
var address = in6_addr()
withUnsafeMutableBytes(of: &address) { target in
for i in 0..<16 {
target[i] = bytes[cursor + i]
}
}
var text = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
let pointer = withUnsafePointer(to: &address) {
inet_ntop(AF_INET6, UnsafeRawPointer($0), &text, socklen_t(INET6_ADDRSTRLEN))
}
host = pointer != nil ? String(cString: text) : ""
cursor += 16
default:
throw NSError(domain: "cmux.remote.proxy", code: 2, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS address type"])
}
guard !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw NSError(domain: "cmux.remote.proxy", code: 3, userInfo: [NSLocalizedDescriptionKey: "empty SOCKS host"])
}
guard bytes.count >= cursor + 2 else { return nil }
let port = Int(UInt16(bytes[cursor]) << 8 | UInt16(bytes[cursor + 1]))
cursor += 2
guard port > 0 && port <= 65535 else {
throw NSError(domain: "cmux.remote.proxy", code: 4, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS port"])
}
return SocksRequest(host: host, port: port, command: command, consumedBytes: cursor)
}
private func processConnectHandshakeStep() -> Bool {
let marker = Data([0x0D, 0x0A, 0x0D, 0x0A])
guard let headerRange = handshakeBuffer.range(of: marker) else { return false }
let headerData = Data(handshakeBuffer[..<headerRange.upperBound])
let pending = headerRange.upperBound < handshakeBuffer.count
? Data(handshakeBuffer[headerRange.upperBound...])
: Data()
handshakeBuffer = Data()
guard let headerText = String(data: headerData, encoding: .utf8) else {
sendAndClose(Self.httpResponse(status: "400 Bad Request"))
return false
}
let firstLine = headerText.components(separatedBy: "\r\n").first ?? ""
let parts = firstLine.split(whereSeparator: \.isWhitespace).map(String.init)
guard parts.count >= 2, parts[0].uppercased() == "CONNECT" else {
sendAndClose(Self.httpResponse(status: "400 Bad Request"))
return false
}
guard let (host, port) = Self.parseConnectAuthority(parts[1]) else {
sendAndClose(Self.httpResponse(status: "400 Bad Request"))
return false
}
openRemoteStream(
host: host,
port: port,
successResponse: Self.httpResponse(status: "200 Connection Established", closeAfterResponse: false),
failureResponse: Self.httpResponse(status: "502 Bad Gateway", closeAfterResponse: true),
pendingPayload: pending
)
return false
}
private func openRemoteStream(
host: String,
port: Int,
successResponse: Data,
failureResponse: Data,
pendingPayload: Data
) {
guard !isClosed else { return }
do {
rewritesLoopbackHTTPHeaders =
BrowserInsecureHTTPSettings.normalizeHost(host)
== BrowserInsecureHTTPSettings.normalizeHost(Self.remoteLoopbackProxyAliasHost)
loopbackRequestHeaderRewriter = rewritesLoopbackHTTPHeaders
? RemoteLoopbackHTTPRequestStreamRewriter(aliasHost: Self.remoteLoopbackProxyAliasHost)
: nil
pendingRemoteHTTPHeaderBytes = Data()
hasForwardedRemoteHTTPHeaders = false
let targetHost = Self.normalizedProxyTargetHost(host)
let streamID = try rpcClient.openStream(host: targetHost, port: port)
self.streamID = streamID
try rpcClient.attachStream(streamID: streamID, queue: queue) { [weak self] event in
self?.handleRemoteStreamEvent(streamID: streamID, event: event)
}
connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in
guard let self else { return }
if let error {
self.close(reason: "proxy client send error: \(error)")
return
}
if !pendingPayload.isEmpty {
self.forwardToRemote(pendingPayload, allowAfterEOF: true)
}
})
} catch {
sendAndClose(failureResponse)
}
}
private func forwardToRemote(_ data: Data, eof: Bool = false, allowAfterEOF: Bool = false) {
guard !isClosed else { return }
guard !localInputEOF || allowAfterEOF else { return }
guard let streamID else { return }
do {
let outgoingData: Data
if rewritesLoopbackHTTPHeaders {
outgoingData = loopbackRequestHeaderRewriter?.rewriteNextChunk(data, eof: eof) ?? data
} else {
outgoingData = data
}
guard !outgoingData.isEmpty else { return }
try rpcClient.writeStream(streamID: streamID, data: outgoingData)
} catch {
close(reason: "proxy.write failed: \(error.localizedDescription)")
}
}
private func handleRemoteStreamEvent(
streamID: String,
event: WorkspaceRemoteDaemonRPCClient.StreamEvent
) {
guard !isClosed else { return }
guard self.streamID == streamID else { return }
switch event {
case .data(let data):
forwardRemotePayloadToLocal(data, eof: false)
case .eof(let data):
forwardRemotePayloadToLocal(data, eof: true)
case .error(let detail):
close(reason: "proxy.stream failed: \(detail)")
}
}
private func forwardRemotePayloadToLocal(_ data: Data, eof: Bool) {
let localData = rewriteRemoteResponseIfNeeded(data, eof: eof)
if !localData.isEmpty {
connection.send(content: localData, completion: .contentProcessed { [weak self] error in
guard let self else { return }
if let error {
self.close(reason: "proxy client send error: \(error)")
return
}
if eof {
self.close(reason: nil)
}
})
return
}
if eof {
close(reason: nil)
}
}
private func rewriteRemoteResponseIfNeeded(_ data: Data, eof: Bool) -> Data {
guard rewritesLoopbackHTTPHeaders else { return data }
guard !data.isEmpty else { return data }
guard !hasForwardedRemoteHTTPHeaders else { return data }
pendingRemoteHTTPHeaderBytes.append(data)
let marker = Data([0x0D, 0x0A, 0x0D, 0x0A])
guard pendingRemoteHTTPHeaderBytes.range(of: marker) != nil else {
guard eof else { return Data() }
hasForwardedRemoteHTTPHeaders = true
let payload = pendingRemoteHTTPHeaderBytes
pendingRemoteHTTPHeaderBytes = Data()
return payload
}
hasForwardedRemoteHTTPHeaders = true
let payload = pendingRemoteHTTPHeaderBytes
pendingRemoteHTTPHeaderBytes = Data()
return RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded(
data: payload,
aliasHost: Self.remoteLoopbackProxyAliasHost
)
}
private func close(reason: String?) {
guard !isClosed else { return }
isClosed = true
let streamID = self.streamID
self.streamID = nil
if let streamID {
rpcClient.closeStream(streamID: streamID)
}
connection.cancel()
onClose(id)
}
private func sendLocal(_ data: Data) {
guard !isClosed else { return }
connection.send(content: data, completion: .contentProcessed { [weak self] error in
guard let self else { return }
if let error {
self.close(reason: "proxy client send error: \(error)")
}
})
}
private func sendAndClose(_ data: Data) {
guard !isClosed else { return }
connection.send(content: data, completion: .contentProcessed { [weak self] _ in
self?.close(reason: nil)
})
}
private static func parseConnectAuthority(_ authority: String) -> (host: String, port: Int)? {
let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("[") {
guard let closing = trimmed.firstIndex(of: "]") else { return nil }
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closing])
let portStart = trimmed.index(after: closing)
guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil }
let portString = String(trimmed[trimmed.index(after: portStart)...])
guard let port = Int(portString), port > 0, port <= 65535 else { return nil }
return (host, port)
}
guard let colon = trimmed.lastIndex(of: ":") else { return nil }
let host = String(trimmed[..<colon])
let portString = String(trimmed[trimmed.index(after: colon)...])
guard !host.isEmpty else { return nil }
guard let port = Int(portString), port > 0, port <= 65535 else { return nil }
return (host, port)
}
private static func normalizedProxyTargetHost(_ host: String) -> String {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed
.trimmingCharacters(in: CharacterSet(charactersIn: "."))
.lowercased()
// BrowserPanel rewrites loopback URLs to this alias so proxy routing works.
// Resolve it back to true loopback before dialing from the remote daemon.
if normalized == remoteLoopbackProxyAliasHost {
return "127.0.0.1"
}
return host
}
private static func httpResponse(status: String, closeAfterResponse: Bool = true) -> Data {
var text = "HTTP/1.1 \(status)\r\nProxy-Agent: cmux\r\n"
if closeAfterResponse {
text += "Connection: close\r\n"
}
text += "\r\n"
return Data(text.utf8)
}
}
private let configuration: WorkspaceRemoteConfiguration
private let remotePath: String
private let localPort: Int
private let onFatalError: (String) -> Void
private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-tunnel.\(UUID().uuidString)", qos: .utility)
private var listener: NWListener?
private var rpcClient: WorkspaceRemoteDaemonRPCClient?
private var sessions: [UUID: ProxySession] = [:]
private var isStopped = false
init(
configuration: WorkspaceRemoteConfiguration,
remotePath: String,
localPort: Int,
onFatalError: @escaping (String) -> Void
) {
self.configuration = configuration
self.remotePath = remotePath
self.localPort = localPort
self.onFatalError = onFatalError
}
func start() throws {
var capturedError: Error?
queue.sync {
guard !isStopped else {
capturedError = NSError(domain: "cmux.remote.proxy", code: 20, userInfo: [
NSLocalizedDescriptionKey: "proxy tunnel already stopped",
])
return
}
do {
let client = WorkspaceRemoteDaemonRPCClient(
configuration: configuration,
remotePath: remotePath
) { [weak self] detail in
self?.queue.async {
self?.failLocked("Remote daemon transport failed: \(detail)")
}
}
try client.start()
let listener = try Self.makeLoopbackListener(port: localPort)
listener.newConnectionHandler = { [weak self] connection in
self?.queue.async {
self?.acceptConnectionLocked(connection)
}
}
listener.stateUpdateHandler = { [weak self] state in
self?.queue.async {
self?.handleListenerStateLocked(state)
}
}
self.rpcClient = client
self.listener = listener
listener.start(queue: queue)
} catch {
capturedError = error
stopLocked(notify: false)
}
}
if let capturedError {
throw capturedError
}
}
func stop() {
queue.sync {
stopLocked(notify: false)
}
}
private func handleListenerStateLocked(_ state: NWListener.State) {
guard !isStopped else { return }
switch state {
case .failed(let error):
failLocked("Local proxy listener failed: \(error)")
default:
break
}
}
private func acceptConnectionLocked(_ connection: NWConnection) {
guard !isStopped else {
connection.cancel()
return
}
guard let rpcClient else {
connection.cancel()
return
}
let session = ProxySession(
connection: connection,
rpcClient: rpcClient,
queue: queue
) { [weak self] id in
self?.queue.async {
self?.sessions.removeValue(forKey: id)
}
}
sessions[session.id] = session
session.start()
}
private func failLocked(_ detail: String) {
guard !isStopped else { return }
stopLocked(notify: false)
onFatalError(detail)
}
private func stopLocked(notify: Bool) {
guard !isStopped else { return }
isStopped = true
listener?.stateUpdateHandler = nil
listener?.newConnectionHandler = nil
listener?.cancel()
listener = nil
let activeSessions = sessions.values
sessions.removeAll()
for session in activeSessions {
session.stop()
}
rpcClient?.stop()
rpcClient = nil
}
private static func makeLoopbackListener(port: Int) throws -> NWListener {
guard let localPort = NWEndpoint.Port(rawValue: UInt16(port)) else {
throw NSError(domain: "cmux.remote.proxy", code: 21, userInfo: [
NSLocalizedDescriptionKey: "invalid local proxy port \(port)",
])
}
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.noDelay = true
let parameters = NWParameters(tls: nil, tcp: tcpOptions)
parameters.allowLocalEndpointReuse = true
parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: localPort)
return try NWListener(using: parameters)
}
}
private final class WorkspaceRemoteProxyBroker {
enum Update {
case connecting
case ready(BrowserProxyEndpoint)
case error(String)
}
final class Lease {
private let key: String
private let subscriberID: UUID
private weak var broker: WorkspaceRemoteProxyBroker?
private var isReleased = false
fileprivate init(key: String, subscriberID: UUID, broker: WorkspaceRemoteProxyBroker) {
self.key = key
self.subscriberID = subscriberID
self.broker = broker
}
func release() {
guard !isReleased else { return }
isReleased = true
broker?.release(key: key, subscriberID: subscriberID)
}
deinit {
release()
}
}
private final class Entry {
let configuration: WorkspaceRemoteConfiguration
var remotePath: String
var tunnel: WorkspaceRemoteDaemonProxyTunnel?
var endpoint: BrowserProxyEndpoint?
var restartWorkItem: DispatchWorkItem?
var subscribers: [UUID: (Update) -> Void] = [:]
init(configuration: WorkspaceRemoteConfiguration, remotePath: String) {
self.configuration = configuration
self.remotePath = remotePath
}
}
static let shared = WorkspaceRemoteProxyBroker()
private let queue = DispatchQueue(label: "com.cmux.remote-ssh.proxy-broker", qos: .utility)
private var entries: [String: Entry] = [:]
func acquire(
configuration: WorkspaceRemoteConfiguration,
remotePath: String,
onUpdate: @escaping (Update) -> Void
) -> Lease {
queue.sync {
let key = Self.transportKey(for: configuration)
let subscriberID = UUID()
let entry: Entry
if let existing = entries[key] {
entry = existing
if existing.remotePath != remotePath {
existing.remotePath = remotePath
if existing.tunnel != nil {
stopEntryRuntimeLocked(existing)
notifyLocked(existing, update: .connecting)
}
}
} else {
entry = Entry(configuration: configuration, remotePath: remotePath)
entries[key] = entry
}
entry.subscribers[subscriberID] = onUpdate
if let endpoint = entry.endpoint {
onUpdate(.ready(endpoint))
} else {
onUpdate(.connecting)
}
if entry.tunnel == nil, entry.restartWorkItem == nil {
startEntryLocked(key: key, entry: entry)
}
return Lease(key: key, subscriberID: subscriberID, broker: self)
}
}
private func release(key: String, subscriberID: UUID) {
queue.async { [weak self] in
guard let self, let entry = self.entries[key] else { return }
entry.subscribers.removeValue(forKey: subscriberID)
guard entry.subscribers.isEmpty else { return }
self.teardownEntryLocked(key: key, entry: entry)
}
}
private func startEntryLocked(key: String, entry: Entry) {
entry.restartWorkItem?.cancel()
entry.restartWorkItem = nil
let localPort: Int
if let forcedLocalPort = entry.configuration.localProxyPort {
// Internal deterministic test hook used by docker regressions to force bind conflicts.
localPort = forcedLocalPort
} else {
guard let allocatedPort = Self.allocateLoopbackPort() else {
notifyLocked(
entry,
update: .error("Failed to allocate local proxy port\(Self.retrySuffix(delay: 3.0))")
)
scheduleRestartLocked(key: key, entry: entry, delay: 3.0)
return
}
localPort = allocatedPort
}
do {
let tunnel = WorkspaceRemoteDaemonProxyTunnel(
configuration: entry.configuration,
remotePath: entry.remotePath,
localPort: localPort
) { [weak self] detail in
self?.queue.async {
self?.handleTunnelFailureLocked(key: key, detail: detail)
}
}
try tunnel.start()
entry.tunnel = tunnel
let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: localPort)
entry.endpoint = endpoint
notifyLocked(entry, update: .ready(endpoint))
} catch {
stopEntryRuntimeLocked(entry)
let detail = "Failed to start local daemon proxy: \(error.localizedDescription)"
notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: 3.0))"))
scheduleRestartLocked(key: key, entry: entry, delay: 3.0)
}
}
private func handleTunnelFailureLocked(key: String, detail: String) {
guard let entry = entries[key], entry.tunnel != nil else { return }
stopEntryRuntimeLocked(entry)
notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: 3.0))"))
scheduleRestartLocked(key: key, entry: entry, delay: 3.0)
}
private func scheduleRestartLocked(key: String, entry: Entry, delay: TimeInterval) {
guard !entry.subscribers.isEmpty else {
teardownEntryLocked(key: key, entry: entry)
return
}
guard entry.restartWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self, let currentEntry = self.entries[key] else { return }
currentEntry.restartWorkItem = nil
guard !currentEntry.subscribers.isEmpty else {
self.teardownEntryLocked(key: key, entry: currentEntry)
return
}
self.notifyLocked(currentEntry, update: .connecting)
self.startEntryLocked(key: key, entry: currentEntry)
}
entry.restartWorkItem = workItem
queue.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func teardownEntryLocked(key: String, entry: Entry) {
entry.restartWorkItem?.cancel()
entry.restartWorkItem = nil
stopEntryRuntimeLocked(entry)
entries.removeValue(forKey: key)
}
private func stopEntryRuntimeLocked(_ entry: Entry) {
entry.tunnel?.stop()
entry.tunnel = nil
entry.endpoint = nil
}
private func notifyLocked(_ entry: Entry, update: Update) {
for callback in entry.subscribers.values {
callback(update)
}
}
private static func transportKey(for configuration: WorkspaceRemoteConfiguration) -> String {
configuration.proxyBrokerTransportKey
}
private static func allocateLoopbackPort() -> Int? {
for _ in 0..<8 {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
defer { close(fd) }
var yes: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = in_port_t(0)
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bindResult == 0 else { continue }
var bound = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
let nameResult = withUnsafeMutablePointer(to: &bound) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
getsockname(fd, sockaddrPtr, &len)
}
}
guard nameResult == 0 else { continue }
let port = Int(UInt16(bigEndian: bound.sin_port))
if port > 0 && port <= 65535 {
return port
}
}
return nil
}
private static func retrySuffix(delay: TimeInterval) -> String {
let seconds = max(1, Int(delay.rounded()))
return " (retry in \(seconds)s)"
}
}
private final class WorkspaceRemoteCLIRelayServer {
private final class Session {
private enum Phase {
case awaitingAuth
case awaitingCommand
case forwarding
case closed
}
private let connection: NWConnection
private let localSocketPath: String
private let relayID: String
private let relayToken: Data
private let queue: DispatchQueue
private let onClose: () -> Void
private let challengeProtocol = "cmux-relay-auth"
private let challengeVersion = 1
private let minimumFailureDelay: TimeInterval = 0.05
private let maximumFrameBytes = 16 * 1024
private var buffer = Data()
private var phase: Phase = .awaitingAuth
private var challengeNonce = ""
private var challengeSentAt = Date()
private var isClosed = false
init(
connection: NWConnection,
localSocketPath: String,
relayID: String,
relayToken: Data,
queue: DispatchQueue,
onClose: @escaping () -> Void
) {
self.connection = connection
self.localSocketPath = localSocketPath
self.relayID = relayID
self.relayToken = relayToken
self.queue = queue
self.onClose = onClose
}
func start() {
connection.stateUpdateHandler = { [weak self] state in
self?.queue.async {
self?.handleState(state)
}
}
connection.start(queue: queue)
}
func stop() {
close()
}
private func handleState(_ state: NWConnection.State) {
guard !isClosed else { return }
switch state {
case .ready:
sendChallenge()
receive()
case .failed, .cancelled:
close()
default:
break
}
}
private func sendChallenge() {
challengeSentAt = Date()
challengeNonce = Self.randomHex(byteCount: 16)
let challenge: [String: Any] = [
"protocol": challengeProtocol,
"version": challengeVersion,
"relay_id": relayID,
"nonce": challengeNonce,
]
sendJSONLine(challenge) { _ in }
}
private func receive() {
guard !isClosed else { return }
connection.receive(minimumIncompleteLength: 1, maximumLength: maximumFrameBytes) { [weak self] data, _, isComplete, error in
guard let self else { return }
self.queue.async {
if error != nil {
self.close()
return
}
if let data, !data.isEmpty {
self.buffer.append(data)
if self.buffer.count > self.maximumFrameBytes {
self.sendFailureAndClose()
return
}
self.processBufferedLines()
}
if isComplete {
self.close()
return
}
if !self.isClosed {
self.receive()
}
}
}
}
private func processBufferedLines() {
while let newlineIndex = buffer.firstIndex(of: 0x0A), !isClosed {
let lineData = buffer.prefix(upTo: newlineIndex)
buffer.removeSubrange(...newlineIndex)
let line = String(data: lineData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
switch phase {
case .awaitingAuth:
handleAuthLine(line)
case .awaitingCommand:
handleCommandLine(Data(lineData) + Data([0x0A]))
case .forwarding, .closed:
return
}
}
}
private func handleAuthLine(_ line: String) {
guard let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let receivedRelayID = object["relay_id"] as? String,
receivedRelayID == relayID,
let macHex = object["mac"] as? String,
let receivedMAC = Self.hexData(from: macHex)
else {
sendFailureAndClose()
return
}
let message = Self.authMessage(relayID: relayID, nonce: challengeNonce, version: challengeVersion)
let expectedMAC = Self.authMAC(token: relayToken, message: message)
guard Self.constantTimeEqual(receivedMAC, expectedMAC) else {
sendFailureAndClose()
return
}
phase = .awaitingCommand
sendJSONLine(["ok": true]) { [weak self] _ in
self?.queue.async {
self?.processBufferedLines()
}
}
}
private func handleCommandLine(_ commandLine: Data) {
guard !commandLine.isEmpty else {
sendFailureAndClose()
return
}
phase = .forwarding
DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in
let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) }
queue.async { [weak self] in
guard let self else { return }
switch result {
case .success(let response):
self.connection.send(content: response, completion: .contentProcessed { [weak self] _ in
self?.queue.async {
self?.close()
}
})
case .failure:
self.sendFailureAndClose()
}
}
}
}
private func sendFailureAndClose() {
let elapsed = Date().timeIntervalSince(challengeSentAt)
let delay = max(0, minimumFailureDelay - elapsed)
phase = .closed
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.sendJSONLine(["ok": false]) { [weak self] _ in
self?.queue.async {
self?.close()
}
}
}
}
private func sendJSONLine(_ object: [String: Any], completion: @escaping (NWError?) -> Void) {
guard !isClosed else {
completion(nil)
return
}
guard let payload = try? JSONSerialization.data(withJSONObject: object) else {
completion(nil)
return
}
connection.send(content: payload + Data([0x0A]), completion: .contentProcessed(completion))
}
private func close() {
guard !isClosed else { return }
isClosed = true
phase = .closed
connection.stateUpdateHandler = nil
connection.cancel()
onClose()
}
private static func authMessage(relayID: String, nonce: String, version: Int) -> Data {
Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8)
}
private static func authMAC(token: Data, message: Data) -> Data {
let key = SymmetricKey(data: token)
let code = HMAC<SHA256>.authenticationCode(for: message, using: key)
return Data(code)
}
private static func constantTimeEqual(_ lhs: Data, _ rhs: Data) -> Bool {
guard lhs.count == rhs.count else { return false }
var diff: UInt8 = 0
for index in lhs.indices {
diff |= lhs[index] ^ rhs[index]
}
return diff == 0
}
fileprivate static func hexData(from string: String) -> Data? {
let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard normalized.count.isMultiple(of: 2), !normalized.isEmpty else { return nil }
var data = Data(capacity: normalized.count / 2)
var cursor = normalized.startIndex
while cursor < normalized.endIndex {
let next = normalized.index(cursor, offsetBy: 2)
guard let byte = UInt8(normalized[cursor..<next], radix: 16) else { return nil }
data.append(byte)
cursor = next
}
return data
}
private static func randomHex(byteCount: Int) -> String {
var bytes = [UInt8](repeating: 0, count: byteCount)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return bytes.map { String(format: "%02x", $0) }.joined()
}
private static func roundTripUnixSocket(socketPath: String, request: Data) throws -> Data {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else {
throw NSError(domain: "cmux.remote.relay", code: 1, userInfo: [
NSLocalizedDescriptionKey: "failed to create local relay socket",
])
}
defer { Darwin.close(fd) }
var timeout = timeval(tv_sec: 15, tv_usec: 0)
withUnsafePointer(to: &timeout) { pointer in
_ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size))
_ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size))
}
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
let pathBytes = Array(socketPath.utf8CString)
guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else {
throw NSError(domain: "cmux.remote.relay", code: 2, userInfo: [
NSLocalizedDescriptionKey: "local relay socket path is too long",
])
}
let sunPathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
withUnsafeMutableBytes(of: &address) { rawBuffer in
let destination = rawBuffer.baseAddress!.advanced(by: sunPathOffset)
pathBytes.withUnsafeBytes { pathBuffer in
destination.copyMemory(from: pathBuffer.baseAddress!, byteCount: pathBytes.count)
}
}
let addressLength = socklen_t(MemoryLayout.size(ofValue: address.sun_family) + pathBytes.count)
let connectResult = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.connect(fd, $0, addressLength)
}
}
guard connectResult == 0 else {
throw NSError(domain: "cmux.remote.relay", code: 3, userInfo: [
NSLocalizedDescriptionKey: "failed to connect to local cmux socket",
])
}
try request.withUnsafeBytes { rawBuffer in
guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return }
var bytesRemaining = rawBuffer.count
var pointer = baseAddress
while bytesRemaining > 0 {
let written = Darwin.write(fd, pointer, bytesRemaining)
if written <= 0 {
throw NSError(domain: "cmux.remote.relay", code: 4, userInfo: [
NSLocalizedDescriptionKey: "failed to write relay request",
])
}
bytesRemaining -= written
pointer = pointer.advanced(by: written)
}
}
_ = shutdown(fd, SHUT_WR)
var response = Data()
var scratch = [UInt8](repeating: 0, count: 4096)
while true {
let count = Darwin.read(fd, &scratch, scratch.count)
if count > 0 {
response.append(scratch, count: count)
continue
}
if count == 0 {
break
}
if errno == EAGAIN || errno == EWOULDBLOCK {
if !response.isEmpty {
break
}
throw NSError(domain: "cmux.remote.relay", code: 5, userInfo: [
NSLocalizedDescriptionKey: "timed out waiting for local cmux response",
])
}
throw NSError(domain: "cmux.remote.relay", code: 6, userInfo: [
NSLocalizedDescriptionKey: "failed to read local cmux response",
])
}
return response
}
}
private let localSocketPath: String
private let relayID: String
private let relayToken: Data
private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility)
private var listener: NWListener?
private var sessions: [UUID: Session] = [:]
private var isStopped = false
private(set) var localPort: Int?
init(localSocketPath: String, relayID: String, relayTokenHex: String) throws {
guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else {
throw NSError(domain: "cmux.remote.relay", code: 7, userInfo: [
NSLocalizedDescriptionKey: "invalid relay token",
])
}
self.localSocketPath = localSocketPath
self.relayID = relayID
self.relayToken = relayToken
}
func start() throws -> Int {
if let existingPort = queue.sync(execute: { localPort }) {
return existingPort
}
let listener = try Self.makeLoopbackListener()
let readySemaphore = DispatchSemaphore(value: 0)
let stateLock = NSLock()
var capturedError: Error?
var boundPort: Int?
listener.newConnectionHandler = { [weak self] connection in
self?.queue.async {
self?.acceptConnectionLocked(connection)
}
}
listener.stateUpdateHandler = { listenerState in
switch listenerState {
case .ready:
stateLock.lock()
boundPort = listener.port.map { Int($0.rawValue) }
stateLock.unlock()
readySemaphore.signal()
case .failed(let error):
stateLock.lock()
capturedError = error
stateLock.unlock()
readySemaphore.signal()
default:
break
}
}
listener.start(queue: queue)
let waitResult = readySemaphore.wait(timeout: .now() + 5.0)
stateLock.lock()
let startupError = capturedError
let startupPort = boundPort
stateLock.unlock()
if waitResult != .success {
listener.newConnectionHandler = nil
listener.stateUpdateHandler = nil
listener.cancel()
throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [
NSLocalizedDescriptionKey: "timed out waiting for local relay listener",
])
}
if let startupError {
listener.newConnectionHandler = nil
listener.stateUpdateHandler = nil
listener.cancel()
throw startupError
}
guard let startupPort, startupPort > 0 else {
listener.newConnectionHandler = nil
listener.stateUpdateHandler = nil
listener.cancel()
throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [
NSLocalizedDescriptionKey: "failed to bind local relay listener",
])
}
return queue.sync {
if let localPort {
listener.newConnectionHandler = nil
listener.stateUpdateHandler = nil
listener.cancel()
return localPort
}
self.listener = listener
self.localPort = startupPort
return startupPort
}
}
func stop() {
queue.sync {
guard !isStopped else { return }
isStopped = true
listener?.newConnectionHandler = nil
listener?.stateUpdateHandler = nil
listener?.cancel()
listener = nil
localPort = nil
let activeSessions = sessions.values
sessions.removeAll()
for session in activeSessions {
session.stop()
}
}
}
private func acceptConnectionLocked(_ connection: NWConnection) {
guard !isStopped else {
connection.cancel()
return
}
let sessionID = UUID()
let session = Session(
connection: connection,
localSocketPath: localSocketPath,
relayID: relayID,
relayToken: relayToken,
queue: queue
) { [weak self] in
self?.sessions.removeValue(forKey: sessionID)
}
sessions[sessionID] = session
session.start()
}
private static func makeLoopbackListener() throws -> NWListener {
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.noDelay = true
let parameters = NWParameters(tls: nil, tcp: tcpOptions)
parameters.allowLocalEndpointReuse = true
parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: .any)
return try NWListener(using: parameters)
}
}
final class WorkspaceRemoteSessionController {
private struct CommandResult {
let status: Int32
let stdout: String
let stderr: String
}
private struct RemotePlatform {
let goOS: String
let goArch: String
}
private struct RemoteBootstrapState {
let platform: RemotePlatform
let binaryExists: Bool
}
private struct DaemonHello {
let name: String
let version: String
let capabilities: [String]
let remotePath: String
}
private let queue = DispatchQueue(label: "com.cmux.remote-ssh.\(UUID().uuidString)", qos: .utility)
private let queueKey = DispatchSpecificKey<Void>()
private weak var workspace: Workspace?
private let configuration: WorkspaceRemoteConfiguration
private let controllerID: UUID
private var isStopping = false
private var proxyLease: WorkspaceRemoteProxyBroker.Lease?
private var proxyEndpoint: BrowserProxyEndpoint?
private var daemonReady = false
private var daemonBootstrapVersion: String?
private var daemonRemotePath: String?
private var reverseRelayProcess: Process?
private var cliRelayServer: WorkspaceRemoteCLIRelayServer?
private var reverseRelayStderrPipe: Pipe?
private var reverseRelayRestartWorkItem: DispatchWorkItem?
private var reverseRelayStderrBuffer = ""
private var reconnectRetryCount = 0
private var reconnectWorkItem: DispatchWorkItem?
private var heartbeatCount: Int = 0
private var connectionAttemptStartedAt: Date?
private static let reverseRelayStartupGracePeriod: TimeInterval = 0.5
init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) {
self.workspace = workspace
self.configuration = configuration
self.controllerID = controllerID
queue.setSpecific(key: queueKey, value: ())
}
func start() {
debugLog("remote.session.start \(debugConfigSummary())")
queue.async { [weak self] in
guard let self else { return }
guard !self.isStopping else { return }
self.beginConnectionAttemptLocked()
}
}
func stop() {
if DispatchQueue.getSpecific(key: queueKey) != nil {
stopAllLocked()
return
}
queue.async { [self] in
stopAllLocked()
}
}
private func stopAllLocked() {
debugLog("remote.session.stop \(debugConfigSummary())")
isStopping = true
reconnectWorkItem?.cancel()
reconnectWorkItem = nil
reconnectRetryCount = 0
reverseRelayRestartWorkItem?.cancel()
reverseRelayRestartWorkItem = nil
stopReverseRelayLocked()
proxyLease?.release()
proxyLease = nil
proxyEndpoint = nil
daemonReady = false
daemonBootstrapVersion = nil
daemonRemotePath = nil
publishProxyEndpoint(nil)
publishPortsSnapshotLocked()
}
private func beginConnectionAttemptLocked() {
guard !isStopping else { return }
connectionAttemptStartedAt = Date()
debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())")
reconnectWorkItem = nil
let connectDetail: String
let bootstrapDetail: String
if reconnectRetryCount > 0 {
connectDetail = "Reconnecting to \(configuration.displayTarget) (retry \(reconnectRetryCount))"
bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget) (retry \(reconnectRetryCount))"
} else {
connectDetail = "Connecting to \(configuration.displayTarget)"
bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget)"
}
publishState(.connecting, detail: connectDetail)
publishDaemonStatus(.bootstrapping, detail: bootstrapDetail)
do {
let hello = try bootstrapDaemonLocked()
guard hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) else {
throw NSError(domain: "cmux.remote.daemon", code: 43, userInfo: [
NSLocalizedDescriptionKey: "remote daemon missing required capability \(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability)",
])
}
daemonReady = true
daemonBootstrapVersion = hello.version
daemonRemotePath = hello.remotePath
publishDaemonStatus(
.ready,
detail: "Remote daemon ready",
version: hello.version,
name: hello.name,
capabilities: hello.capabilities,
remotePath: hello.remotePath
)
recordHeartbeatActivityLocked()
startReverseRelayLocked(remotePath: hello.remotePath)
startProxyLocked()
} catch {
daemonReady = false
daemonBootstrapVersion = nil
daemonRemotePath = nil
let nextRetry = scheduleReconnectLocked(delay: 4.0)
let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 4.0)
let detail = "Remote daemon bootstrap failed: \(error.localizedDescription)\(retrySuffix)"
publishDaemonStatus(.error, detail: detail)
publishState(.error, detail: detail)
}
}
private func startProxyLocked() {
guard !isStopping else { return }
guard daemonReady else { return }
guard proxyLease == nil else { return }
guard let remotePath = daemonRemotePath,
!remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
let nextRetry = scheduleReconnectLocked(delay: 4.0)
let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 4.0)
let detail = "Remote daemon did not provide a valid remote path\(retrySuffix)"
publishDaemonStatus(.error, detail: detail)
publishState(.error, detail: detail)
return
}
let lease = WorkspaceRemoteProxyBroker.shared.acquire(
configuration: configuration,
remotePath: remotePath
) { [weak self] update in
self?.queue.async {
self?.handleProxyBrokerUpdateLocked(update)
}
}
proxyLease = lease
}
private func startReverseRelayLocked(remotePath: String) {
guard !isStopping else { return }
guard daemonReady else { return }
guard let relayPort = configuration.relayPort, relayPort > 0,
let relayID = configuration.relayID?.trimmingCharacters(in: .whitespacesAndNewlines),
!relayID.isEmpty,
let relayToken = configuration.relayToken?.trimmingCharacters(in: .whitespacesAndNewlines),
!relayToken.isEmpty,
let localSocketPath = configuration.localSocketPath?
.trimmingCharacters(in: .whitespacesAndNewlines),
!localSocketPath.isEmpty else {
return
}
guard reverseRelayProcess == nil else { return }
reverseRelayRestartWorkItem?.cancel()
reverseRelayRestartWorkItem = nil
var relayServer: WorkspaceRemoteCLIRelayServer?
do {
let server = try ensureCLIRelayServerLocked(
localSocketPath: localSocketPath,
relayID: relayID,
relayToken: relayToken
)
relayServer = server
let localRelayPort = try server.start()
Self.killOrphanedRelayProcesses(relayPort: relayPort, destination: configuration.destination)
let process = Process()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
process.arguments = reverseRelayArguments(relayPort: relayPort, localRelayPort: localRelayPort)
process.standardInput = FileHandle.nullDevice
process.standardOutput = FileHandle.nullDevice
process.standardError = stderrPipe
process.terminationHandler = { [weak self] terminated in
self?.queue.async {
self?.handleReverseRelayTerminationLocked(process: terminated)
}
}
try process.run()
if let startupFailure = Self.reverseRelayStartupFailureDetail(
process: process,
stderrPipe: stderrPipe
) {
let retryDelay = 2.0
let retrySeconds = max(1, Int(retryDelay.rounded()))
debugLog(
"remote.relay.startFailed relayPort=\(relayPort) " +
"error=\(startupFailure)"
)
relayServer?.stop()
publishDaemonStatus(
.error,
detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)"
)
scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: retryDelay)
return
}
installReverseRelayStderrHandlerLocked(stderrPipe)
reverseRelayProcess = process
cliRelayServer = relayServer
reverseRelayStderrPipe = stderrPipe
reverseRelayStderrBuffer = ""
do {
try installRemoteRelayMetadataLocked(
remotePath: remotePath,
relayPort: relayPort,
relayID: relayID,
relayToken: relayToken
)
} catch {
debugLog("remote.relay.metadata.error \(error.localizedDescription)")
stopReverseRelayLocked()
scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0)
return
}
recordHeartbeatActivityLocked()
debugLog(
"remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " +
"target=\(configuration.displayTarget)"
)
} catch {
debugLog(
"remote.relay.startFailed relayPort=\(relayPort) " +
"error=\(error.localizedDescription)"
)
relayServer?.stop()
cliRelayServer = nil
scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0)
}
}
private func installReverseRelayStderrHandlerLocked(_ stderrPipe: Pipe) {
stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else {
handle.readabilityHandler = nil
return
}
self?.queue.async {
guard let self else { return }
if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty {
self.reverseRelayStderrBuffer.append(chunk)
if self.reverseRelayStderrBuffer.count > 8192 {
self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192)
}
}
}
}
}
private func handleReverseRelayTerminationLocked(process: Process) {
guard reverseRelayProcess === process else { return }
let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer)
reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil
reverseRelayProcess = nil
reverseRelayStderrPipe = nil
guard !isStopping else { return }
guard let remotePath = daemonRemotePath,
!remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
let detail = stderrDetail ?? "status=\(process.terminationStatus)"
debugLog("remote.relay.exit \(detail)")
scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0)
}
private func scheduleReverseRelayRestartLocked(remotePath: String, delay: TimeInterval) {
guard !isStopping else { return }
reverseRelayRestartWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.reverseRelayRestartWorkItem = nil
guard !self.isStopping else { return }
guard self.reverseRelayProcess == nil else { return }
guard self.daemonReady else { return }
self.startReverseRelayLocked(remotePath: self.daemonRemotePath ?? remotePath)
}
reverseRelayRestartWorkItem = workItem
queue.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func stopReverseRelayLocked() {
reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil
if let reverseRelayProcess, reverseRelayProcess.isRunning {
reverseRelayProcess.terminate()
}
reverseRelayProcess = nil
reverseRelayStderrPipe = nil
reverseRelayStderrBuffer = ""
cliRelayServer?.stop()
cliRelayServer = nil
removeRemoteRelayMetadataLocked()
}
private func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) {
guard !isStopping else { return }
switch update {
case .connecting:
debugLog("remote.proxy.connecting \(debugConfigSummary())")
if proxyEndpoint == nil {
publishState(.connecting, detail: "Connecting to \(configuration.displayTarget)")
}
case .ready(let endpoint):
debugLog("remote.proxy.ready host=\(endpoint.host) port=\(endpoint.port) \(debugConfigSummary())")
reconnectWorkItem?.cancel()
reconnectWorkItem = nil
reconnectRetryCount = 0
guard proxyEndpoint != endpoint else {
recordHeartbeatActivityLocked()
return
}
proxyEndpoint = endpoint
publishProxyEndpoint(endpoint)
publishPortsSnapshotLocked()
publishState(
.connected,
detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)"
)
recordHeartbeatActivityLocked()
case .error(let detail):
debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())")
proxyEndpoint = nil
publishProxyEndpoint(nil)
publishPortsSnapshotLocked()
publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)")
guard Self.shouldEscalateProxyErrorToBootstrap(detail) else { return }
proxyLease?.release()
proxyLease = nil
daemonReady = false
daemonBootstrapVersion = nil
daemonRemotePath = nil
let nextRetry = scheduleReconnectLocked(delay: 2.0)
let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 2.0)
publishDaemonStatus(
.error,
detail: "Remote daemon transport needs re-bootstrap after proxy failure\(retrySuffix)"
)
}
}
@discardableResult
private func scheduleReconnectLocked(delay: TimeInterval) -> Int {
guard !isStopping else { return reconnectRetryCount }
reconnectWorkItem?.cancel()
reconnectRetryCount += 1
let retryNumber = reconnectRetryCount
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.reconnectWorkItem = nil
guard !self.isStopping else { return }
guard self.proxyLease == nil else { return }
self.beginConnectionAttemptLocked()
}
reconnectWorkItem = workItem
queue.asyncAfter(deadline: .now() + delay, execute: workItem)
return retryNumber
}
private func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) {
let controllerID = self.controllerID
DispatchQueue.main.async { [weak workspace] in
guard let workspace else { return }
guard workspace.activeRemoteSessionControllerID == controllerID else { return }
workspace.applyRemoteConnectionStateUpdate(
state,
detail: detail,
target: workspace.remoteDisplayTarget ?? "remote host"
)
}
}
private func publishDaemonStatus(
_ state: WorkspaceRemoteDaemonState,
detail: String?,
version: String? = nil,
name: String? = nil,
capabilities: [String] = [],
remotePath: String? = nil
) {
let controllerID = self.controllerID
let status = WorkspaceRemoteDaemonStatus(
state: state,
detail: detail,
version: version,
name: name,
capabilities: capabilities,
remotePath: remotePath
)
DispatchQueue.main.async { [weak workspace] in
guard let workspace else { return }
guard workspace.activeRemoteSessionControllerID == controllerID else { return }
workspace.applyRemoteDaemonStatusUpdate(
status,
target: workspace.remoteDisplayTarget ?? "remote host"
)
}
}
private func publishProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) {
let controllerID = self.controllerID
DispatchQueue.main.async { [weak workspace] in
guard let workspace else { return }
guard workspace.activeRemoteSessionControllerID == controllerID else { return }
workspace.applyRemoteProxyEndpointUpdate(endpoint)
}
}
private func publishPortsSnapshotLocked() {
let controllerID = self.controllerID
DispatchQueue.main.async { [weak workspace] in
guard let workspace else { return }
guard workspace.activeRemoteSessionControllerID == controllerID else { return }
workspace.applyRemotePortsSnapshot(
detected: [],
forwarded: [],
conflicts: [],
target: workspace.remoteDisplayTarget ?? "remote host"
)
}
}
private func recordHeartbeatActivityLocked() {
heartbeatCount += 1
publishHeartbeat(count: heartbeatCount, at: Date())
}
private func publishHeartbeat(count: Int, at date: Date?) {
let controllerID = self.controllerID
DispatchQueue.main.async { [weak workspace] in
guard let workspace else { return }
guard workspace.activeRemoteSessionControllerID == controllerID else { return }
workspace.applyRemoteHeartbeatUpdate(count: count, lastSeenAt: date)
}
}
private func reverseRelayArguments(relayPort: Int, localRelayPort: Int) -> [String] {
// `-o ControlPath=none` is not enough on macOS OpenSSH, the client can still
// attach to an existing master and exit immediately with its status.
// `-S none` forces a standalone transport for the reverse relay.
var args: [String] = ["-N", "-T", "-S", "none"]
args += sshCommonArguments(batchMode: true)
args += [
"-o", "ExitOnForwardFailure=yes",
"-o", "RequestTTY=no",
"-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)",
configuration.destination,
]
return args
}
private static let remotePlatformProbeOSMarker = "__CMUX_REMOTE_OS__="
private static let remotePlatformProbeArchMarker = "__CMUX_REMOTE_ARCH__="
private static let remotePlatformProbeExistsMarker = "__CMUX_REMOTE_EXISTS__="
private func sshCommonArguments(batchMode: Bool) -> [String] {
let effectiveSSHOptions: [String] = {
if batchMode {
return backgroundSSHOptions(configuration.sshOptions)
}
return normalizedSSHOptions(configuration.sshOptions)
}()
var args: [String] = [
"-o", "ConnectTimeout=6",
"-o", "ServerAliveInterval=20",
"-o", "ServerAliveCountMax=2",
]
if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") {
args += ["-o", "StrictHostKeyChecking=accept-new"]
}
if batchMode {
args += ["-o", "BatchMode=yes"]
args += ["-o", "ControlMaster=no"]
}
if let port = configuration.port {
args += ["-p", String(port)]
}
if let identityFile = configuration.identityFile,
!identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args += ["-i", identityFile]
}
for option in effectiveSSHOptions {
args += ["-o", option]
}
return args
}
private func hasSSHOptionKey(_ options: [String], key: String) -> Bool {
let loweredKey = key.lowercased()
for option in options {
let token = sshOptionKey(option)
if token == loweredKey {
return true
}
}
return false
}
private func normalizedSSHOptions(_ options: [String]) -> [String] {
options.compactMap { option in
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed
}
}
private func backgroundSSHOptions(_ options: [String]) -> [String] {
let batchSSHControlOptionKeys: Set<String> = [
"controlmaster",
"controlpersist",
]
return normalizedSSHOptions(options).filter { option in
guard let key = sshOptionKey(option) else { return false }
return !batchSSHControlOptionKeys.contains(key)
}
}
private 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 func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult {
try runProcess(
executable: "/usr/bin/ssh",
arguments: arguments,
stdin: stdin,
timeout: timeout
)
}
private func scpExec(arguments: [String], timeout: TimeInterval = 30) throws -> CommandResult {
try runProcess(
executable: "/usr/bin/scp",
arguments: arguments,
stdin: nil,
timeout: timeout
)
}
private func runProcess(
executable: String,
arguments: [String],
environment: [String: String]? = nil,
currentDirectory: URL? = nil,
stdin: Data?,
timeout: TimeInterval
) throws -> CommandResult {
debugLog(
"remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
"timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))"
)
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
if let environment {
process.environment = environment
}
if let currentDirectory {
process.currentDirectoryURL = currentDirectory
}
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
if stdin != nil {
process.standardInput = Pipe()
} else {
process.standardInput = FileHandle.nullDevice
}
let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading
let captureQueue = DispatchQueue(label: "cmux.remote.process.capture")
let exitSemaphore = DispatchSemaphore(value: 0)
var stdoutData = Data()
var stderrData = Data()
let captureGroup = DispatchGroup()
process.terminationHandler = { _ in
exitSemaphore.signal()
}
captureGroup.enter()
DispatchQueue.global(qos: .utility).async {
let data = stdoutHandle.readDataToEndOfFile()
captureQueue.sync {
stdoutData = data
}
captureGroup.leave()
}
captureGroup.enter()
DispatchQueue.global(qos: .utility).async {
let data = stderrHandle.readDataToEndOfFile()
captureQueue.sync {
stderrData = data
}
captureGroup.leave()
}
do {
try process.run()
} catch {
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForWriting.close()
debugLog(
"remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
"error=\(error.localizedDescription)"
)
throw NSError(domain: "cmux.remote.process", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)",
])
}
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForWriting.close()
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 {
process.terminate()
let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success
if !terminatedGracefully, process.isRunning {
_ = Darwin.kill(process.processIdentifier, SIGKILL)
process.waitUntilExit()
}
debugLog(
"remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
"timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))"
)
throw NSError(domain: "cmux.remote.process", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s",
])
}
_ = captureGroup.wait(timeout: .now() + 2.0)
try? stdoutHandle.close()
try? stderrHandle.close()
let stdout = String(data: stdoutData, encoding: .utf8) ?? ""
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
debugLog(
"remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
"status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " +
"stderr=\(Self.debugLogSnippet(stderr))"
)
return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr)
}
private func bootstrapDaemonLocked() throws -> DaemonHello {
debugLog("remote.bootstrap.begin \(debugConfigSummary())")
let version = Self.remoteDaemonVersion()
let bootstrapState = try probeRemoteBootstrapStateLocked(version: version)
let platform = bootstrapState.platform
let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch)
let explicitOverrideBinary = Self.explicitRemoteDaemonBinaryURL()
let forceExplicitOverrideInstall = explicitOverrideBinary != nil
debugLog(
"remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " +
"version=\(version) remotePath=\(remotePath) " +
"allowLocalBuildFallback=\(Self.allowLocalDaemonBuildFallback() ? 1 : 0) " +
"explicitOverride=\(forceExplicitOverrideInstall ? 1 : 0)"
)
let hadExistingBinary = bootstrapState.binaryExists
debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)")
if forceExplicitOverrideInstall || !hadExistingBinary {
let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version)
try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath)
}
var hello: DaemonHello
do {
hello = try helloRemoteDaemonLocked(remotePath: remotePath)
} catch {
guard hadExistingBinary else {
throw error
}
debugLog(
"remote.bootstrap.helloRetry remotePath=\(remotePath) " +
"detail=\(error.localizedDescription)"
)
let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version)
try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath)
hello = try helloRemoteDaemonLocked(remotePath: remotePath)
}
if hadExistingBinary, !hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) {
debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))")
let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version)
try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath)
hello = try helloRemoteDaemonLocked(remotePath: remotePath)
}
debugLog(
"remote.bootstrap.ready name=\(hello.name) version=\(hello.version) " +
"capabilities=\(hello.capabilities.joined(separator: ",")) remotePath=\(hello.remotePath)"
)
if let connectionAttemptStartedAt {
debugLog(
"remote.timing.bootstrap.ready elapsedMs=\(Int(Date().timeIntervalSince(connectionAttemptStartedAt) * 1000)) " +
"\(debugConfigSummary())"
)
}
return hello
}
private func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer {
if let cliRelayServer {
return cliRelayServer
}
let relayServer = try WorkspaceRemoteCLIRelayServer(
localSocketPath: localSocketPath,
relayID: relayID,
relayTokenHex: relayToken
)
cliRelayServer = relayServer
return relayServer
}
private func installRemoteRelayMetadataLocked(
remotePath: String,
relayPort: Int,
relayID: String,
relayToken: String
) throws {
let script = Self.remoteRelayMetadataInstallScript(
daemonRemotePath: remotePath,
relayPort: relayPort,
relayID: relayID,
relayToken: relayToken
)
let command = "sh -c \(Self.shellSingleQuoted(script))"
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8)
guard result.status == 0 else {
let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)"
throw NSError(domain: "cmux.remote.relay", code: 70, userInfo: [
NSLocalizedDescriptionKey: "failed to install remote relay metadata: \(detail)",
])
}
}
private func removeRemoteRelayMetadataLocked() {
guard let relayPort = configuration.relayPort, relayPort > 0 else { return }
let script = Self.remoteRelayMetadataCleanupScript(relayPort: relayPort)
let command = "sh -c \(Self.shellSingleQuoted(script))"
do {
_ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8)
} catch {
debugLog("remote.relay.cleanup.error \(error.localizedDescription)")
}
}
static func remoteRelayMetadataCleanupScript(relayPort: Int) -> String {
"""
relay_socket='127.0.0.1:\(relayPort)'
socket_addr_file="$HOME/.cmux/socket_addr"
if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then
rm -f "$socket_addr_file"
fi
rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path"
"""
}
private func probeRemoteBootstrapStateLocked(version: String) throws -> RemoteBootstrapState {
let script = """
cmux_uname_os="$(uname -s)"
cmux_uname_arch="$(uname -m)"
printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$cmux_uname_os"
printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$cmux_uname_arch"
case "$(printf '%s' "$cmux_uname_os" | tr '[:upper:]' '[:lower:]')" in
linux|darwin|freebsd) cmux_go_os="$(printf '%s' "$cmux_uname_os" | tr '[:upper:]' '[:lower:]')" ;;
*) exit 70 ;;
esac
case "$(printf '%s' "$cmux_uname_arch" | tr '[:upper:]' '[:lower:]')" in
x86_64|amd64) cmux_go_arch=amd64 ;;
aarch64|arm64) cmux_go_arch=arm64 ;;
armv7l) cmux_go_arch=arm ;;
*) exit 71 ;;
esac
cmux_remote_path="$HOME/.cmux/bin/cmuxd-remote/\(version)/${cmux_go_os}-${cmux_go_arch}/cmuxd-remote"
if [ -x "$cmux_remote_path" ]; then
printf '%syes\\n' '\(Self.remotePlatformProbeExistsMarker)'
else
printf '%sno\\n' '\(Self.remotePlatformProbeExistsMarker)'
fi
"""
let command = "sh -c \(Self.shellSingleQuoted(script))"
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20)
let lines = result.stdout
.split(separator: "\n", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let unameOS = lines.first { $0.hasPrefix(Self.remotePlatformProbeOSMarker) }
.map { String($0.dropFirst(Self.remotePlatformProbeOSMarker.count)) }
let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) }
.map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) }
guard let unameOS, let unameArch else {
let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)"
throw NSError(domain: "cmux.remote.daemon", code: 11, userInfo: [
NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)",
])
}
guard let goOS = Self.mapUnameOS(unameOS),
let goArch = Self.mapUnameArch(unameArch) else {
throw NSError(domain: "cmux.remote.daemon", code: 12, userInfo: [
NSLocalizedDescriptionKey: "unsupported remote platform \(unameOS)/\(unameArch)",
])
}
let binaryExists = lines.first { $0.hasPrefix(Self.remotePlatformProbeExistsMarker) }
.map { String($0.dropFirst(Self.remotePlatformProbeExistsMarker.count)) == "yes" }
if result.status != 0, binaryExists == nil {
let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)"
throw NSError(domain: "cmux.remote.daemon", code: 13, userInfo: [
NSLocalizedDescriptionKey: "failed to query remote daemon state: \(detail)",
])
}
return RemoteBootstrapState(
platform: RemotePlatform(goOS: goOS, goArch: goArch),
binaryExists: binaryExists ?? false
)
}
static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON"
static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? {
guard let rawManifest = infoDictionary?[remoteDaemonManifestInfoKey] as? String else { return nil }
let trimmed = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let data = trimmed.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data)
}
private static func remoteDaemonManifest() -> WorkspaceRemoteDaemonManifest? {
remoteDaemonManifest(from: Bundle.main.infoDictionary)
}
private static func remoteDaemonCacheRoot(fileManager: FileManager = .default) throws -> URL {
let appSupportRoot = try fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let cacheRoot = appSupportRoot
.appendingPathComponent("cmux", isDirectory: true)
.appendingPathComponent("remote-daemons", isDirectory: true)
try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true)
return cacheRoot
}
static func remoteDaemonCachedBinaryURL(
version: String,
goOS: String,
goArch: String,
fileManager: FileManager = .default
) throws -> URL {
try remoteDaemonCacheRoot(fileManager: fileManager)
.appendingPathComponent(version, isDirectory: true)
.appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true)
.appendingPathComponent("cmuxd-remote", isDirectory: false)
}
private static func sha256Hex(forFile url: URL) throws -> String {
let data = try Data(contentsOf: url)
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private static func allowLocalDaemonBuildFallback(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool {
environment["CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1"
}
private static func explicitRemoteDaemonBinaryURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? {
guard allowLocalDaemonBuildFallback(environment: environment) else { return nil }
guard let path = environment["CMUX_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!path.isEmpty else {
return nil
}
return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL
}
private static func versionedRemoteDaemonBuildURL(goOS: String, goArch: String, version: String) -> URL {
URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("cmux-remote-daemon-build", isDirectory: true)
.appendingPathComponent(version, isDirectory: true)
.appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true)
.appendingPathComponent("cmuxd-remote", isDirectory: false)
}
private func downloadRemoteDaemonBinaryLocked(entry: WorkspaceRemoteDaemonManifest.Entry, version: String) throws -> URL {
guard let url = URL(string: entry.downloadURL) else {
throw NSError(domain: "cmux.remote.daemon", code: 25, userInfo: [
NSLocalizedDescriptionKey: "remote daemon manifest has an invalid download URL",
])
}
let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: version, goOS: entry.goOS, goArch: entry.goArch)
let fileManager = FileManager.default
try fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true)
let request = NSMutableURLRequest(url: url)
request.timeoutInterval = 60
request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent")
let session = URLSession(configuration: .ephemeral)
let semaphore = DispatchSemaphore(value: 0)
var downloadedURL: URL?
var downloadError: Error?
session.downloadTask(with: request as URLRequest) { localURL, response, error in
defer { semaphore.signal() }
if let error {
downloadError = error
return
}
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
downloadError = NSError(domain: "cmux.remote.daemon", code: 26, userInfo: [
NSLocalizedDescriptionKey: "remote daemon download failed with HTTP \(httpResponse.statusCode)",
])
return
}
downloadedURL = localURL
}.resume()
_ = semaphore.wait(timeout: .now() + 75.0)
session.finishTasksAndInvalidate()
if let downloadError {
throw downloadError
}
guard let downloadedURL else {
throw NSError(domain: "cmux.remote.daemon", code: 27, userInfo: [
NSLocalizedDescriptionKey: "remote daemon download did not produce a file",
])
}
let downloadedSHA = try Self.sha256Hex(forFile: downloadedURL)
guard downloadedSHA == entry.sha256.lowercased() else {
throw NSError(domain: "cmux.remote.daemon", code: 28, userInfo: [
NSLocalizedDescriptionKey: "remote daemon checksum mismatch for \(entry.assetName)",
])
}
let tempURL = cacheURL.deletingLastPathComponent()
.appendingPathComponent(".\(cacheURL.lastPathComponent).tmp-\(UUID().uuidString)")
try? fileManager.removeItem(at: tempURL)
try fileManager.moveItem(at: downloadedURL, to: tempURL)
try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempURL.path)
try? fileManager.removeItem(at: cacheURL)
try fileManager.moveItem(at: tempURL, to: cacheURL)
return cacheURL
}
private func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL {
if let explicitBinary = Self.explicitRemoteDaemonBinaryURL(),
FileManager.default.isExecutableFile(atPath: explicitBinary.path) {
debugLog("remote.build.explicit path=\(explicitBinary.path)")
return explicitBinary
}
if let manifest = Self.remoteDaemonManifest(),
manifest.appVersion == version,
let entry = manifest.entry(goOS: goOS, goArch: goArch) {
let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: manifest.appVersion, goOS: goOS, goArch: goArch)
if FileManager.default.fileExists(atPath: cacheURL.path) {
let cachedSHA = try Self.sha256Hex(forFile: cacheURL)
if cachedSHA == entry.sha256.lowercased(),
FileManager.default.isExecutableFile(atPath: cacheURL.path) {
debugLog("remote.build.cached path=\(cacheURL.path)")
return cacheURL
}
try? FileManager.default.removeItem(at: cacheURL)
}
let downloadedURL = try downloadRemoteDaemonBinaryLocked(entry: entry, version: manifest.appVersion)
debugLog("remote.build.downloaded path=\(downloadedURL.path)")
return downloadedURL
}
guard Self.allowLocalDaemonBuildFallback() else {
throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [
NSLocalizedDescriptionKey: "this build does not include a verified cmuxd-remote manifest for \(goOS)-\(goArch). Use a release/nightly build, or set CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback.",
])
}
guard let repoRoot = Self.findRepoRoot() else {
throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [
NSLocalizedDescriptionKey: "cannot locate cmux repo root for dev-only cmuxd-remote build fallback",
])
}
let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true)
let goModPath = daemonRoot.appendingPathComponent("go.mod").path
guard FileManager.default.fileExists(atPath: goModPath) else {
throw NSError(domain: "cmux.remote.daemon", code: 21, userInfo: [
NSLocalizedDescriptionKey: "missing daemon module at \(goModPath)",
])
}
guard let goBinary = Self.which("go") else {
throw NSError(domain: "cmux.remote.daemon", code: 22, userInfo: [
NSLocalizedDescriptionKey: "go is required for the dev-only cmuxd-remote build fallback",
])
}
let output = Self.versionedRemoteDaemonBuildURL(goOS: goOS, goArch: goArch, version: version)
try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true)
var env = ProcessInfo.processInfo.environment
env["GOOS"] = goOS
env["GOARCH"] = goArch
env["CGO_ENABLED"] = "0"
let ldflags = "-s -w -X main.version=\(version)"
let result = try runProcess(
executable: goBinary,
arguments: ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", output.path, "./cmd/cmuxd-remote"],
environment: env,
currentDirectory: daemonRoot,
stdin: nil,
timeout: 90
)
guard result.status == 0 else {
let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "go build failed with status \(result.status)"
throw NSError(domain: "cmux.remote.daemon", code: 23, userInfo: [
NSLocalizedDescriptionKey: "failed to build cmuxd-remote: \(detail)",
])
}
guard FileManager.default.isExecutableFile(atPath: output.path) else {
throw NSError(domain: "cmux.remote.daemon", code: 24, userInfo: [
NSLocalizedDescriptionKey: "cmuxd-remote build output is not executable",
])
}
debugLog("remote.build.output path=\(output.path)")
return output
}
private func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws {
let remoteDirectory = (remotePath as NSString).deletingLastPathComponent
let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))"
debugLog(
"remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)"
)
let mkdirScript = "mkdir -p \(Self.shellSingleQuoted(remoteDirectory))"
let mkdirCommand = "sh -c \(Self.shellSingleQuoted(mkdirScript))"
let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12)
guard mkdirResult.status == 0 else {
let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)"
throw NSError(domain: "cmux.remote.daemon", code: 30, userInfo: [
NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)",
])
}
let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions)
var scpArgs: [String] = ["-q"]
if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") {
scpArgs += ["-o", "StrictHostKeyChecking=accept-new"]
}
scpArgs += ["-o", "ControlMaster=no"]
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 += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"]
let scpResult = try scpExec(arguments: scpArgs, timeout: 45)
guard scpResult.status == 0 else {
let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)"
throw NSError(domain: "cmux.remote.daemon", code: 31, userInfo: [
NSLocalizedDescriptionKey: "failed to upload cmuxd-remote: \(detail)",
])
}
let finalizeScript = """
chmod 755 \(Self.shellSingleQuoted(remoteTempPath)) && \
mv \(Self.shellSingleQuoted(remoteTempPath)) \(Self.shellSingleQuoted(remotePath))
"""
let finalizeCommand = "sh -c \(Self.shellSingleQuoted(finalizeScript))"
let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12)
guard finalizeResult.status == 0 else {
let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)"
throw NSError(domain: "cmux.remote.daemon", code: 32, userInfo: [
NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)",
])
}
}
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"
let command = "sh -c \(Self.shellSingleQuoted(script))"
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 12)
guard result.status == 0 else {
let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)"
throw NSError(domain: "cmux.remote.daemon", code: 40, userInfo: [
NSLocalizedDescriptionKey: "failed to start remote daemon: \(detail)",
])
}
let responseLine = result.stdout
.split(separator: "\n")
.map(String.init)
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) ?? ""
guard !responseLine.isEmpty,
let data = responseLine.data(using: .utf8),
let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
throw NSError(domain: "cmux.remote.daemon", code: 41, userInfo: [
NSLocalizedDescriptionKey: "remote daemon hello returned invalid JSON",
])
}
if let ok = payload["ok"] as? Bool, !ok {
let errorMessage: String = {
if let errorObject = payload["error"] as? [String: Any],
let message = errorObject["message"] as? String,
!message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return message
}
return "hello call failed"
}()
throw NSError(domain: "cmux.remote.daemon", code: 42, userInfo: [
NSLocalizedDescriptionKey: "remote daemon hello failed: \(errorMessage)",
])
}
let resultObject = payload["result"] as? [String: Any] ?? [:]
let name = (resultObject["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let version = (resultObject["version"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let capabilities = (resultObject["capabilities"] as? [String]) ?? []
return DaemonHello(
name: (name?.isEmpty == false ? name! : "cmuxd-remote"),
version: (version?.isEmpty == false ? version! : "dev"),
capabilities: capabilities,
remotePath: remotePath
)
}
private func debugLog(_ message: @autoclosure () -> String) {
#if DEBUG
dlog(message())
#endif
}
private func debugConfigSummary() -> String {
let controlPath = Self.debugSSHOptionValue(named: "ControlPath", in: configuration.sshOptions) ?? "nil"
return
"target=\(configuration.displayTarget) port=\(configuration.port.map(String.init) ?? "nil") " +
"relayPort=\(configuration.relayPort.map(String.init) ?? "nil") " +
"localSocket=\(configuration.localSocketPath ?? "nil") " +
"controlPath=\(controlPath)"
}
private func debugShellCommand(executable: String, arguments: [String]) -> String {
([URL(fileURLWithPath: executable).lastPathComponent] + arguments)
.map(Self.shellSingleQuoted)
.joined(separator: " ")
}
private static func debugSSHOptionValue(named key: String, in options: [String]) -> String? {
let loweredKey = key.lowercased()
for option in options {
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
if parts.count == 2,
parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey {
return parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
}
}
return nil
}
private static func debugLogSnippet(_ text: String, limit: Int = 160) -> String {
let normalized = text
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return "\"\"" }
if normalized.count <= limit {
return normalized
}
return String(normalized.prefix(limit)) + "..."
}
private static func shellSingleQuoted(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}
static func remoteCLIWrapperScript() -> String {
"""
#!/usr/bin/env bash
set -euo pipefail
daemon="$HOME/.cmux/bin/cmuxd-remote-current"
socket_path="${CMUX_SOCKET_PATH:-}"
if [ -z "$socket_path" ] && [ -r "$HOME/.cmux/socket_addr" ]; then
socket_path="$(tr -d '\\r\\n' < "$HOME/.cmux/socket_addr")"
fi
if [ -n "$socket_path" ] && [ "${socket_path#/}" = "$socket_path" ] && [ "${socket_path#*:}" != "$socket_path" ]; then
relay_port="${socket_path##*:}"
relay_map="$HOME/.cmux/relay/${relay_port}.daemon_path"
if [ -r "$relay_map" ]; then
mapped_daemon="$(tr -d '\\r\\n' < "$relay_map")"
if [ -n "$mapped_daemon" ] && [ -x "$mapped_daemon" ]; then
daemon="$mapped_daemon"
fi
fi
fi
exec "$daemon" "$@"
"""
}
static func remoteCLIWrapperInstallScript(daemonRemotePath: String) -> String {
let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines)
return """
mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay"
ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current"
wrapper_tmp="$HOME/.cmux/bin/.cmux-wrapper.tmp.$$"
cat > "$wrapper_tmp" <<'CMUXWRAPPER'
\(remoteCLIWrapperScript())
CMUXWRAPPER
chmod 755 "$wrapper_tmp"
mv -f "$wrapper_tmp" "$HOME/.cmux/bin/cmux"
"""
}
static func remoteRelayMetadataInstallScript(
daemonRemotePath: String,
relayPort: Int,
relayID: String,
relayToken: String
) -> String {
let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines)
let authPayload = """
{"relay_id":"\(relayID)","relay_token":"\(relayToken)"}
"""
return """
umask 077
mkdir -p "$HOME/.cmux" "$HOME/.cmux/relay"
chmod 700 "$HOME/.cmux/relay"
\(remoteCLIWrapperInstallScript(daemonRemotePath: trimmedRemotePath))
printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path"
cat > "$HOME/.cmux/relay/\(relayPort).auth" <<'CMUXRELAYAUTH'
\(authPayload)
CMUXRELAYAUTH
chmod 600 "$HOME/.cmux/relay/\(relayPort).auth"
printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.cmux/socket_addr"
"""
}
private static func mapUnameOS(_ raw: String) -> String? {
switch raw.lowercased() {
case "linux":
return "linux"
case "darwin":
return "darwin"
case "freebsd":
return "freebsd"
default:
return nil
}
}
private static func mapUnameArch(_ raw: String) -> String? {
switch raw.lowercased() {
case "x86_64", "amd64":
return "amd64"
case "aarch64", "arm64":
return "arm64"
case "armv7l":
return "arm"
default:
return nil
}
}
private static func remoteDaemonVersion() -> String {
let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let baseVersion = (bundleVersion?.isEmpty == false) ? bundleVersion! : "dev"
guard allowLocalDaemonBuildFallback(),
let sourceFingerprint = remoteDaemonSourceFingerprint(),
!sourceFingerprint.isEmpty else {
return baseVersion
}
return "\(baseVersion)-dev-\(sourceFingerprint)"
}
private static let cachedRemoteDaemonSourceFingerprint: String? = computeRemoteDaemonSourceFingerprint()
private static func remoteDaemonSourceFingerprint() -> String? {
cachedRemoteDaemonSourceFingerprint
}
private static func computeRemoteDaemonSourceFingerprint(fileManager: FileManager = .default) -> String? {
guard let repoRoot = findRepoRoot() else { return nil }
let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true)
guard let enumerator = fileManager.enumerator(
at: daemonRoot,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else {
return nil
}
var relativePaths: [String] = []
for case let fileURL as URL in enumerator {
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]),
resourceValues.isRegularFile == true else {
continue
}
let relativePath = fileURL.path.replacingOccurrences(of: daemonRoot.path + "/", with: "")
if relativePath == "go.mod" || relativePath == "go.sum" || relativePath.hasSuffix(".go") {
relativePaths.append(relativePath)
}
}
guard !relativePaths.isEmpty else { return nil }
let digest = SHA256.hash(data: relativePaths.sorted().reduce(into: Data()) { partialResult, relativePath in
let fileURL = daemonRoot.appendingPathComponent(relativePath, isDirectory: false)
guard let fileData = try? Data(contentsOf: fileURL) else { return }
partialResult.append(Data(relativePath.utf8))
partialResult.append(0)
partialResult.append(fileData)
partialResult.append(0)
})
let hex = digest.map { String(format: "%02x", $0) }.joined()
return String(hex.prefix(12))
}
private static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String {
".cmux/bin/cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote"
}
private static func killOrphanedRelayProcesses(relayPort: Int, destination: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:\(relayPort):127\\.0\\.0\\.1:[0-9]+.*\(destination)"]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
} catch {
// Best effort cleanup only.
}
}
private static func which(_ executable: String) -> String? {
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
for component in path.split(separator: ":") {
let candidate = String(component) + "/" + executable
if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate
}
}
return nil
}
private static func findRepoRoot() -> URL? {
var candidates: [URL] = []
let compileTimeRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // Sources
.deletingLastPathComponent() // repo root
candidates.append(compileTimeRoot)
let environment = ProcessInfo.processInfo.environment
if let envRoot = environment["CMUX_REMOTE_DAEMON_SOURCE_ROOT"],
!envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true))
}
if let envRoot = environment["CMUXTERM_REPO_ROOT"],
!envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true))
}
candidates.append(URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true))
if let executable = Bundle.main.executableURL?.deletingLastPathComponent() {
candidates.append(executable)
candidates.append(executable.deletingLastPathComponent())
candidates.append(executable.deletingLastPathComponent().deletingLastPathComponent())
}
let fm = FileManager.default
for base in candidates {
var cursor = base.standardizedFileURL
for _ in 0..<10 {
let marker = cursor.appendingPathComponent("daemon/remote/go.mod").path
if fm.fileExists(atPath: marker) {
return cursor
}
let parent = cursor.deletingLastPathComponent()
if parent.path == cursor.path {
break
}
cursor = parent
}
}
return nil
}
private static func bestErrorLine(stderr: String, stdout: String = "") -> String? {
if let stderrLine = meaningfulErrorLine(in: stderr) {
return stderrLine
}
if let stdoutLine = meaningfulErrorLine(in: stdout) {
return stdoutLine
}
return nil
}
static func reverseRelayStartupFailureDetail(
process: Process,
stderrPipe: Pipe,
gracePeriod: TimeInterval = reverseRelayStartupGracePeriod
) -> String? {
if process.isRunning {
let originalTerminationHandler = process.terminationHandler
let exitSemaphore = DispatchSemaphore(value: 0)
process.terminationHandler = { terminated in
originalTerminationHandler?(terminated)
exitSemaphore.signal()
}
if !process.isRunning {
exitSemaphore.signal()
}
guard exitSemaphore.wait(timeout: .now() + max(0, gracePeriod)) == .success else {
return nil
}
}
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
return bestErrorLine(stderr: stderr) ?? "status=\(process.terminationStatus)"
}
private static func meaningfulErrorLine(in text: String) -> String? {
let lines = text
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
for line in lines.reversed() where !isNoiseLine(line) {
return line
}
return lines.last
}
private static func isNoiseLine(_ line: String) -> Bool {
let lowered = line.lowercased()
if lowered.hasPrefix("warning: permanently added") { return true }
if lowered.hasPrefix("debug") { return true }
if lowered.hasPrefix("transferred:") { return true }
if lowered.hasPrefix("openbsd_") { return true }
if lowered.contains("pseudo-terminal will not be allocated") { return true }
return false
}
private static func retrySuffix(retry: Int, delay: TimeInterval) -> String {
let seconds = max(1, Int(delay.rounded()))
return " (retry \(retry) in \(seconds)s)"
}
private static func shouldEscalateProxyErrorToBootstrap(_ detail: String) -> Bool {
let lowered = detail.lowercased()
return lowered.contains("remote daemon transport failed")
|| lowered.contains("daemon transport closed stdout")
|| lowered.contains("daemon transport exited")
|| lowered.contains("daemon transport is not connected")
|| lowered.contains("daemon transport stopped")
}
}
enum SidebarLogLevel: String {
case info
case progress
case success
case warning
case error
}
struct SidebarLogEntry {
let message: String
let level: SidebarLogLevel
let source: String?
let timestamp: Date
}
struct SidebarProgressState {
let value: Double
let label: String?
}
struct SidebarGitBranchState {
let branch: String
let isDirty: Bool
}
enum WorkspaceRemoteConnectionState: String {
case disconnected
case connecting
case connected
case error
}
enum WorkspaceRemoteDaemonState: String {
case unavailable
case bootstrapping
case ready
case error
}
struct WorkspaceRemoteDaemonStatus: Equatable {
var state: WorkspaceRemoteDaemonState = .unavailable
var detail: String?
var version: String?
var name: String?
var capabilities: [String] = []
var remotePath: String?
func payload() -> [String: Any] {
[
"state": state.rawValue,
"detail": detail ?? NSNull(),
"version": version ?? NSNull(),
"name": name ?? NSNull(),
"capabilities": capabilities,
"remote_path": remotePath ?? NSNull(),
]
}
}
struct WorkspaceRemoteConfiguration: Equatable {
let destination: String
let port: Int?
let identityFile: String?
let sshOptions: [String]
let localProxyPort: Int?
let relayPort: Int?
let relayID: String?
let relayToken: String?
let localSocketPath: String?
let terminalStartupCommand: String?
var displayTarget: String {
guard let port else { return destination }
return "\(destination):\(port)"
}
var proxyBrokerTransportKey: String {
let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPort = port.map(String.init) ?? ""
let normalizedIdentity = identityFile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let normalizedLocalProxyPort = localProxyPort.map(String.init) ?? ""
let normalizedOptions = Self.proxyBrokerSSHOptions(sshOptions).joined(separator: "\u{1f}")
return [normalizedDestination, normalizedPort, normalizedIdentity, normalizedOptions, normalizedLocalProxyPort]
.joined(separator: "\u{1e}")
}
private static func proxyBrokerSSHOptions(_ options: [String]) -> [String] {
options.compactMap { option in
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed
}.filter { option in
proxyBrokerSSHOptionKey(option) != "controlpath"
}
}
private static func proxyBrokerSSHOptionKey(_ 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()
}
}
enum SidebarPullRequestStatus: String {
case open
case merged
case closed
}
enum SidebarPullRequestChecksStatus: String {
case pass
case fail
case pending
}
private func normalizedSidebarBranchName(_ branch: String?) -> String? {
guard let branch else { return nil }
let trimmed = branch.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
struct SidebarPullRequestState: Equatable {
let number: Int
let label: String
let url: URL
let status: SidebarPullRequestStatus
let branch: String?
let checks: SidebarPullRequestChecksStatus?
init(
number: Int,
label: String,
url: URL,
status: SidebarPullRequestStatus,
branch: String? = nil,
checks: SidebarPullRequestChecksStatus? = nil
) {
self.number = number
self.label = label
self.url = url
self.status = status
self.branch = normalizedSidebarBranchName(branch)
self.checks = checks
}
}
enum SidebarBranchOrdering {
struct BranchEntry: Equatable {
let name: String
let isDirty: Bool
}
struct BranchDirectoryEntry: Equatable {
let branch: String?
let isDirty: Bool
let directory: String?
}
fileprivate static func normalizedDirectory(_ text: String?) -> String? {
guard let text else { return nil }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func relativePathFromTilde(_ directory: String) -> String? {
let normalized = normalizedDirectory(directory)
switch normalized {
case "~":
return ""
case let path? where path.hasPrefix("~/"):
return String(path.dropFirst(2))
default:
return nil
}
}
private static func commonHomeDirectoryPrefix(from absoluteDirectory: String) -> String? {
guard let normalized = normalizedDirectory(absoluteDirectory) else { return nil }
let standardized = NSString(string: normalized).standardizingPath
if standardized == "/root" || standardized.hasPrefix("/root/") {
return "/root"
}
let components = NSString(string: standardized).pathComponents
if components.count >= 3, components[0] == "/", components[1] == "Users" {
return NSString.path(withComponents: Array(components.prefix(3)))
}
if components.count >= 3, components[0] == "/", components[1] == "home" {
return NSString.path(withComponents: Array(components.prefix(3)))
}
if components.count >= 4, components[0] == "/", components[1] == "var", components[2] == "home" {
return NSString.path(withComponents: Array(components.prefix(4)))
}
return nil
}
private static func inferredHomeDirectory(
matchingTildeDirectory tildeDirectory: String,
absoluteDirectory: String
) -> String? {
guard let relativePath = relativePathFromTilde(tildeDirectory),
let normalizedAbsolute = normalizedDirectory(absoluteDirectory) else { return nil }
let standardizedAbsolute = NSString(string: normalizedAbsolute).standardizingPath
let homeDirectory: String
if relativePath.isEmpty {
homeDirectory = standardizedAbsolute
} else {
let suffix = "/" + relativePath
guard standardizedAbsolute.hasSuffix(suffix) else { return nil }
homeDirectory = String(standardizedAbsolute.dropLast(suffix.count))
}
guard commonHomeDirectoryPrefix(from: homeDirectory) == homeDirectory else { return nil }
return homeDirectory
}
fileprivate static func inferredRemoteHomeDirectory(
from directories: [String],
fallbackDirectory: String?
) -> String? {
let candidates = directories + [fallbackDirectory].compactMap { $0 }
let tildeDirectories = candidates.compactMap { directory -> String? in
guard let normalized = normalizedDirectory(directory),
relativePathFromTilde(normalized) != nil else { return nil }
return normalized
}
let absoluteDirectories = candidates.compactMap { directory -> String? in
guard let normalized = normalizedDirectory(directory), normalized.hasPrefix("/") else { return nil }
return NSString(string: normalized).standardizingPath
}
let inferredHomes = Set(
tildeDirectories.flatMap { tildeDirectory in
absoluteDirectories.compactMap { absoluteDirectory in
inferredHomeDirectory(
matchingTildeDirectory: tildeDirectory,
absoluteDirectory: absoluteDirectory
)
}
}
)
if inferredHomes.count == 1 {
return inferredHomes.first
}
if !inferredHomes.isEmpty {
return nil
}
return absoluteDirectories.lazy.compactMap(commonHomeDirectoryPrefix(from:)).first
}
private static func expandedTildePath(
_ directory: String,
homeDirectoryForTildeExpansion: String?
) -> String {
guard let relativePath = relativePathFromTilde(directory),
let homeDirectory = normalizedDirectory(homeDirectoryForTildeExpansion) else {
return directory
}
if relativePath.isEmpty {
return homeDirectory
}
return NSString(string: homeDirectory).appendingPathComponent(relativePath)
}
fileprivate static func canonicalDirectoryKey(
_ directory: String?,
homeDirectoryForTildeExpansion: String?
) -> String? {
guard let directory = normalizedDirectory(directory) else { return nil }
let expanded = expandedTildePath(
directory,
homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion
)
let standardized = NSString(string: expanded).standardizingPath
let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines)
return cleaned.isEmpty ? nil : cleaned
}
private static func preferredDisplayedDirectory(
existing: String?,
replacement: String?,
homeDirectoryForTildeExpansion: String?
) -> String? {
guard let replacement = normalizedDirectory(replacement) else { return existing }
guard let existing = normalizedDirectory(existing) else { return replacement }
let existingUsesTilde = relativePathFromTilde(existing) != nil
let replacementUsesTilde = relativePathFromTilde(replacement) != nil
if existingUsesTilde != replacementUsesTilde {
return replacementUsesTilde ? existing : replacement
}
if canonicalDirectoryKey(existing, homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion)
== canonicalDirectoryKey(
replacement,
homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion
) {
return existing
}
return replacement
}
static func orderedPaneIds(tree: ExternalTreeNode) -> [String] {
switch tree {
case .pane(let pane):
return [pane.id]
case .split(let split):
// Bonsplit split order matches visual order for both horizontal and vertical splits.
return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second)
}
}
static func orderedPanelIds(
tree: ExternalTreeNode,
paneTabs: [String: [UUID]],
fallbackPanelIds: [UUID]
) -> [UUID] {
var ordered: [UUID] = []
var seen: Set<UUID> = []
for paneId in orderedPaneIds(tree: tree) {
for panelId in paneTabs[paneId] ?? [] {
if seen.insert(panelId).inserted {
ordered.append(panelId)
}
}
}
for panelId in fallbackPanelIds {
if seen.insert(panelId).inserted {
ordered.append(panelId)
}
}
return ordered
}
static func orderedUniqueBranches(
orderedPanelIds: [UUID],
panelBranches: [UUID: SidebarGitBranchState],
fallbackBranch: SidebarGitBranchState?
) -> [BranchEntry] {
var orderedNames: [String] = []
var branchDirty: [String: Bool] = [:]
for panelId in orderedPanelIds {
guard let state = panelBranches[panelId] else { continue }
let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { continue }
if branchDirty[name] == nil {
orderedNames.append(name)
branchDirty[name] = state.isDirty
} else if state.isDirty {
branchDirty[name] = true
}
}
if orderedNames.isEmpty, let fallbackBranch {
let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines)
if !name.isEmpty {
return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)]
}
}
return orderedNames.map { name in
BranchEntry(name: name, isDirty: branchDirty[name] ?? false)
}
}
static func orderedUniquePullRequests(
orderedPanelIds: [UUID],
panelPullRequests: [UUID: SidebarPullRequestState],
fallbackPullRequest: SidebarPullRequestState?
) -> [SidebarPullRequestState] {
func statusPriority(_ status: SidebarPullRequestStatus) -> Int {
switch status {
case .merged: return 3
case .open: return 2
case .closed: return 1
}
}
func checksPriority(_ checks: SidebarPullRequestChecksStatus?) -> Int {
switch checks {
case .fail: return 3
case .pending: return 2
case .pass: return 1
case nil: return 0
}
}
func normalizedReviewURLKey(for url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url.absoluteString
}
// Treat URL variants that differ only by query/fragment as the same review item.
components.query = nil
components.fragment = nil
let scheme = components.scheme?.lowercased() ?? ""
let host = components.host?.lowercased() ?? ""
let port = components.port.map { ":\($0)" } ?? ""
var path = components.path
if path.hasSuffix("/"), path.count > 1 {
path.removeLast()
}
return "\(scheme)://\(host)\(port)\(path)"
}
func reviewKey(for state: SidebarPullRequestState) -> String {
"\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))"
}
var orderedKeys: [String] = []
var pullRequestsByKey: [String: SidebarPullRequestState] = [:]
for panelId in orderedPanelIds {
guard let state = panelPullRequests[panelId] else { continue }
let key = reviewKey(for: state)
if pullRequestsByKey[key] == nil {
orderedKeys.append(key)
pullRequestsByKey[key] = state
continue
}
guard let existing = pullRequestsByKey[key] else { continue }
if statusPriority(state.status) > statusPriority(existing.status) {
pullRequestsByKey[key] = state
} else if state.status == existing.status,
checksPriority(state.checks) > checksPriority(existing.checks) {
pullRequestsByKey[key] = state
}
}
if orderedKeys.isEmpty, let fallbackPullRequest {
return [fallbackPullRequest]
}
return orderedKeys.compactMap { pullRequestsByKey[$0] }
}
static func orderedUniqueBranchDirectoryEntries(
orderedPanelIds: [UUID],
panelBranches: [UUID: SidebarGitBranchState],
panelDirectories: [UUID: String],
defaultDirectory: String?,
homeDirectoryForTildeExpansion: String?,
fallbackBranch: SidebarGitBranchState?
) -> [BranchDirectoryEntry] {
struct EntryKey: Hashable {
let directory: String?
let branch: String?
}
struct MutableEntry {
var branch: String?
var isDirty: Bool
var directory: String?
}
let normalized = normalizedDirectory
let normalizedFallbackBranch = normalized(fallbackBranch?.branch)
let shouldUseFallbackBranchPerPanel = !orderedPanelIds.contains {
normalized(panelBranches[$0]?.branch) != nil
}
let defaultBranchForPanels = shouldUseFallbackBranchPerPanel ? normalizedFallbackBranch : nil
let defaultBranchDirty = shouldUseFallbackBranchPerPanel ? (fallbackBranch?.isDirty ?? false) : false
var order: [EntryKey] = []
var entries: [EntryKey: MutableEntry] = [:]
for panelId in orderedPanelIds {
let panelBranch = normalized(panelBranches[panelId]?.branch)
let branch = panelBranch ?? defaultBranchForPanels
let directory = normalized(panelDirectories[panelId])
guard branch != nil || directory != nil else { continue }
let panelDirty = panelBranch != nil
? (panelBranches[panelId]?.isDirty ?? false)
: defaultBranchDirty
let key: EntryKey
if let directoryKey = canonicalDirectoryKey(
directory,
homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion
) {
// Keep one line per directory and allow the latest branch state to overwrite.
key = EntryKey(directory: directoryKey, branch: nil)
} else {
key = EntryKey(directory: nil, branch: branch)
}
guard key.directory != nil || key.branch != nil else { continue }
if var existing = entries[key] {
if key.directory != nil {
if let branch {
existing.branch = branch
existing.isDirty = panelDirty
} else if existing.branch == nil {
existing.isDirty = panelDirty
}
existing.directory = preferredDisplayedDirectory(
existing: existing.directory,
replacement: directory,
homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion
)
entries[key] = existing
} else if panelDirty {
existing.isDirty = true
entries[key] = existing
}
} else {
order.append(key)
entries[key] = MutableEntry(branch: branch, isDirty: panelDirty, directory: directory)
}
}
if order.isEmpty {
let fallbackDirectory = normalized(defaultDirectory)
if normalizedFallbackBranch != nil || fallbackDirectory != nil {
return [
BranchDirectoryEntry(
branch: normalizedFallbackBranch,
isDirty: fallbackBranch?.isDirty ?? false,
directory: fallbackDirectory
)
]
}
}
return order.compactMap { key in
guard let entry = entries[key] else { return nil }
return BranchDirectoryEntry(
branch: entry.branch,
isDirty: entry.isDirty,
directory: entry.directory
)
}
}
}
struct ClosedBrowserPanelRestoreSnapshot {
let workspaceId: UUID
let url: URL?
let profileID: UUID?
let originalPaneId: UUID
let originalTabIndex: Int
let fallbackSplitOrientation: SplitOrientation?
let fallbackSplitInsertFirst: Bool
let fallbackAnchorPaneId: UUID?
}
/// Workspace represents a sidebar tab.
/// Each workspace contains one BonsplitController that manages split panes and nested surfaces.
@MainActor
final class Workspace: Identifiable, ObservableObject {
let id: UUID
@Published var title: String
@Published var customTitle: String?
@Published var isPinned: Bool = false
@Published var customColor: String? // hex string, e.g. "#C0392B"
@Published var currentDirectory: String
private(set) var preferredBrowserProfileID: UUID?
/// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session)
var portOrdinal: Int = 0
/// The bonsplit controller managing the split panes for this workspace
let bonsplitController: BonsplitController
/// Mapping from bonsplit TabID to our Panel instances
@Published private(set) var panels: [UUID: any Panel] = [:]
/// Subscriptions for panel updates (e.g., browser title changes)
private var panelSubscriptions: [UUID: AnyCancellable] = [:]
/// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels)
private var isProgrammaticSplit = false
private var debugStressPreloadSelectionDepth = 0
/// Last terminal panel used as an inheritance source (typically last focused terminal).
private var lastTerminalConfigInheritancePanelId: UUID?
/// Last known terminal font points from inheritance sources. Used as fallback when
/// no live terminal surface is currently available.
private var lastTerminalConfigInheritanceFontPoints: Float?
/// Per-panel inherited zoom lineage. Descendants reuse this root value unless
/// a panel is explicitly re-zoomed by the user.
private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:]
/// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore.
var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)?
weak var owningTabManager: TabManager?
// Closing tabs mutates split layout immediately; terminal views handle their own AppKit
// layout/size synchronization.
/// The currently focused pane's panel ID
var focusedPanelId: UUID? {
guard let paneId = bonsplitController.focusedPaneId,
let tab = bonsplitController.selectedTab(inPane: paneId) else {
return nil
}
return panelIdFromSurfaceId(tab.id)
}
/// The currently focused terminal panel (if any)
var focusedTerminalPanel: TerminalPanel? {
guard let panelId = focusedPanelId,
let panel = panels[panelId] as? TerminalPanel else {
return nil
}
return panel
}
func effectiveSelectedPanelId(inPane paneId: PaneID) -> UUID? {
bonsplitController.selectedTab(inPane: paneId).flatMap { panelIdFromSurfaceId($0.id) }
}
enum FocusPanelTrigger {
case standard
case terminalFirstResponder
}
/// Published directory for each panel
@Published var panelDirectories: [UUID: String] = [:]
@Published var panelTitles: [UUID: String] = [:]
@Published private(set) var panelCustomTitles: [UUID: String] = [:]
@Published private(set) var pinnedPanelIds: Set<UUID> = []
@Published private(set) var manualUnreadPanelIds: Set<UUID> = []
private var manualUnreadMarkedAt: [UUID: Date] = [:]
nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2
nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2
@Published var statusEntries: [String: SidebarStatusEntry] = [:]
@Published var metadataBlocks: [String: SidebarMetadataBlock] = [:]
@Published var logEntries: [SidebarLogEntry] = []
@Published var progress: SidebarProgressState?
@Published var gitBranch: SidebarGitBranchState?
@Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:]
@Published var pullRequest: SidebarPullRequestState?
@Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:]
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
@Published var remoteConfiguration: WorkspaceRemoteConfiguration?
@Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected
@Published var remoteConnectionDetail: String?
@Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus()
@Published var remoteDetectedPorts: [Int] = []
@Published var remoteForwardedPorts: [Int] = []
@Published var remotePortConflicts: [Int] = []
@Published var remoteProxyEndpoint: BrowserProxyEndpoint?
@Published var remoteHeartbeatCount: Int = 0
@Published var remoteLastHeartbeatAt: Date?
@Published var listeningPorts: [Int] = []
@Published private(set) var activeRemoteTerminalSessionCount: Int = 0
var surfaceTTYNames: [UUID: String] = [:]
private var remoteSessionController: WorkspaceRemoteSessionController?
fileprivate var activeRemoteSessionControllerID: UUID?
private var remoteLastErrorFingerprint: String?
private var remoteLastDaemonErrorFingerprint: String?
private var remoteLastPortConflictFingerprint: String?
private var activeRemoteTerminalSurfaceIds: Set<UUID> = []
private static let remoteErrorStatusKey = "remote.error"
private static let remotePortConflictStatusKey = "remote.port_conflicts"
private static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:]
/// PIDs associated with agent status entries (e.g. claude_code), keyed by status key.
/// Used for stale-session detection: if the PID is dead, the status entry is cleared.
var agentPIDs: [String: pid_t] = [:]
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
private static func isProxyOnlyRemoteError(_ detail: String) -> Bool {
let lowered = detail.lowercased()
return lowered.contains("remote proxy")
|| lowered.contains("proxy_unavailable")
|| lowered.contains("local daemon proxy")
|| lowered.contains("proxy failure")
|| lowered.contains("daemon transport")
}
private var preservesSSHTerminalConnection: Bool {
activeRemoteTerminalSessionCount > 0
&& remoteConfiguration?.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
private var hasProxyOnlyRemoteSidebarError: Bool {
guard let entry = statusEntries[Self.remoteErrorStatusKey]?.value else { return false }
return entry.lowercased().contains("remote proxy unavailable")
}
var focusedSurfaceId: UUID? { focusedPanelId }
var surfaceDirectories: [UUID: String] {
get { panelDirectories }
set { panelDirectories = newValue }
}
private var processTitle: String
private enum SurfaceKind {
static let terminal = "terminal"
static let browser = "browser"
static let markdown = "markdown"
}
enum PanelShellActivityState: String {
case unknown
case promptIdle
case commandRunning
}
nonisolated static func resolveCloseConfirmation(
shellActivityState: PanelShellActivityState?,
fallbackNeedsConfirmClose: Bool
) -> Bool {
switch shellActivityState ?? .unknown {
case .promptIdle:
return false
case .commandRunning:
return true
case .unknown:
return fallbackNeedsConfirmClose
}
}
// MARK: - Initialization
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
BonsplitConfiguration.SplitButtonTooltips(
newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"),
newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"),
splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"),
splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down")
)
}
private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance {
bonsplitAppearance(
from: config.backgroundColor,
backgroundOpacity: config.backgroundOpacity
)
}
static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String {
let themedColor = GhosttyBackgroundTheme.color(
backgroundColor: backgroundColor,
opacity: backgroundOpacity
)
let includeAlpha = themedColor.alphaComponent < 0.999
return themedColor.hexString(includeAlpha: includeAlpha)
}
nonisolated static func resolvedChromeColors(
from backgroundColor: NSColor
) -> BonsplitConfiguration.Appearance.ChromeColors {
.init(backgroundHex: backgroundColor.hexString())
}
private static func bonsplitAppearance(
from backgroundColor: NSColor,
backgroundOpacity: Double
) -> BonsplitConfiguration.Appearance {
BonsplitConfiguration.Appearance(
splitButtonTooltips: Self.currentSplitButtonTooltips(),
enableAnimations: false,
chromeColors: .init(
backgroundHex: Self.bonsplitChromeHex(
backgroundColor: backgroundColor,
backgroundOpacity: backgroundOpacity
)
)
)
}
func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") {
applyGhosttyChrome(
backgroundColor: config.backgroundColor,
backgroundOpacity: config.backgroundOpacity,
reason: reason
)
}
func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") {
let nextHex = Self.bonsplitChromeHex(
backgroundColor: backgroundColor,
backgroundOpacity: backgroundOpacity
)
let currentChromeColors = bonsplitController.configuration.appearance.chromeColors
let isNoOp = currentChromeColors.backgroundHex == nextHex
if GhosttyApp.shared.backgroundLogEnabled {
let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil"
GhosttyApp.shared.logBackground(
"theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)"
)
}
if isNoOp {
return
}
bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex
if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground(
"theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")"
)
}
}
init(
title: String = "Terminal",
workingDirectory: String? = nil,
portOrdinal: Int = 0,
configTemplate: ghostty_surface_config_s? = nil,
initialTerminalCommand: String? = nil,
initialTerminalEnvironment: [String: String] = [:]
) {
self.id = UUID()
self.portOrdinal = portOrdinal
self.processTitle = title
self.title = title
self.customTitle = nil
let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty
self.currentDirectory = hasWorkingDirectory
? trimmedWorkingDirectory
: FileManager.default.homeDirectoryForCurrentUser.path
// Configure bonsplit with keepAllAlive to preserve terminal state
// and keep split entry instantaneous.
// Avoid re-reading/parsing Ghostty config on every new workspace; this hot path
// runs for socket/CLI workspace creation and can cause visible typing lag.
let appearance = Self.bonsplitAppearance(
from: GhosttyApp.shared.defaultBackgroundColor,
backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity
)
let config = BonsplitConfiguration(
allowSplits: true,
allowCloseTabs: true,
allowCloseLastPane: false,
allowTabReordering: true,
allowCrossPaneTabMove: true,
autoCloseEmptyPanes: true,
contentViewLifecycle: .keepAllAlive,
newTabPosition: .current,
appearance: appearance
)
self.bonsplitController = BonsplitController(configuration: config)
bonsplitController.contextMenuShortcuts = Self.buildContextMenuShortcuts()
// Remove the default "Welcome" tab that bonsplit creates
let welcomeTabIds = bonsplitController.allTabIds
// Create initial terminal panel
let terminalPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_TAB,
configTemplate: configTemplate,
workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil,
portOrdinal: portOrdinal,
initialCommand: initialTerminalCommand,
initialEnvironmentOverrides: initialTerminalEnvironment
)
panels[terminalPanel.id] = terminalPanel
panelTitles[terminalPanel.id] = terminalPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate)
// Create initial tab in bonsplit and store the mapping
var initialTabId: TabID?
if let tabId = bonsplitController.createTab(
title: title,
icon: "terminal.fill",
kind: SurfaceKind.terminal,
isDirty: false,
isPinned: false
) {
surfaceIdToPanelId[tabId] = terminalPanel.id
initialTabId = tabId
}
// Close the default Welcome tab(s)
for welcomeTabId in welcomeTabIds {
bonsplitController.closeTab(welcomeTabId)
}
bonsplitController.onExternalTabDrop = { [weak self] request in
self?.handleExternalTabDrop(request) ?? false
}
bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in
self?.markExplicitClose(surfaceId: tabId)
}
// Set ourselves as delegate
bonsplitController.delegate = self
// Ensure bonsplit has a focused pane and our didSelectTab handler runs for the
// initial terminal. bonsplit's createTab selects internally but does not emit
// didSelectTab, and focusedPaneId can otherwise be nil until user interaction.
if let initialTabId {
// Focus the pane containing the initial tab (or the first pane as fallback).
let paneToFocus: PaneID? = {
for paneId in bonsplitController.allPaneIds {
if bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == initialTabId }) {
return paneId
}
}
return bonsplitController.allPaneIds.first
}()
if let paneToFocus {
bonsplitController.focusPane(paneToFocus)
}
bonsplitController.selectTab(initialTabId)
}
}
deinit {
activeRemoteSessionControllerID = nil
remoteSessionController?.stop()
}
func refreshSplitButtonTooltips() {
let tooltips = Self.currentSplitButtonTooltips()
var configuration = bonsplitController.configuration
guard configuration.appearance.splitButtonTooltips != tooltips else { return }
configuration.appearance.splitButtonTooltips = tooltips
bonsplitController.configuration = configuration
}
// MARK: - Surface ID to Panel ID Mapping
/// Mapping from bonsplit TabID (surface ID) to panel UUID
private var surfaceIdToPanelId: [TabID: UUID] = [:]
/// Tab IDs that are allowed to close even if they would normally require confirmation.
/// This is used by app-level confirmation prompts (e.g., Cmd+W "Close Tab?") so the
/// Bonsplit delegate doesn't block the close after the user already confirmed.
private var forceCloseTabIds: Set<TabID> = []
/// Tab IDs that are currently showing (or about to show) a close confirmation prompt.
/// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs.
private var pendingCloseConfirmTabIds: Set<TabID> = []
/// Tab IDs whose next close attempt should be treated as an explicit
/// workspace-close gesture from the user (the tab-strip X button, or Cmd+W when
/// the shortcut preference is set to close the workspace on the last surface),
/// rather than an internal close/move flow.
private var explicitUserCloseTabIds: Set<TabID> = []
/// Deterministic tab selection to apply after a tab closes.
/// Keyed by the closing tab ID, value is the tab ID we want to select next.
private var postCloseSelectTabId: [TabID: TabID] = [:]
/// Panel IDs that were in a pane when a pane-close operation was approved.
/// Bonsplit pane-close does not emit per-tab didClose callbacks.
private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:]
private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:]
private var isApplyingTabSelection = false
private struct PendingTabSelectionRequest {
let tabId: TabID
let pane: PaneID
let reassertAppKitFocus: Bool
let focusIntent: PanelFocusIntent?
let previousTerminalHostedView: GhosttySurfaceScrollView?
}
private var pendingTabSelection: PendingTabSelectionRequest?
private var isReconcilingFocusState = false
private var focusReconcileScheduled = false
#if DEBUG
private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0
private var debugLastDidMoveTabTimestamp: TimeInterval = 0
private var debugDidMoveTabEventCount: UInt64 = 0
#endif
private var layoutFollowUpObservers: [NSObjectProtocol] = []
private var layoutFollowUpPanelsCancellable: AnyCancellable?
private var layoutFollowUpTimeoutWorkItem: DispatchWorkItem?
private var layoutFollowUpReason: String?
private var layoutFollowUpTerminalFocusPanelId: UUID?
private var layoutFollowUpBrowserPanelId: UUID?
private var layoutFollowUpBrowserExitFocusPanelId: UUID?
private var layoutFollowUpNeedsGeometryPass = false
private var layoutFollowUpAttemptScheduled = false
private var layoutFollowUpStalledAttemptCount = 0
private var isAttemptingLayoutFollowUp = false
private var isNormalizingPinnedTabOrder = false
private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert?
private var nonFocusSplitFocusReassertGeneration: UInt64 = 0
private struct PendingNonFocusSplitFocusReassert {
let generation: UInt64
let preferredPanelId: UUID
let splitPanelId: UUID
}
struct DetachedSurfaceTransfer {
let panelId: UUID
let panel: any Panel
let title: String
let icon: String?
let iconImageData: Data?
let kind: String?
let isLoading: Bool
let isPinned: Bool
let directory: String?
let cachedTitle: String?
let customTitle: String?
let manuallyUnread: Bool
}
private var detachingTabIds: Set<TabID> = []
private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:]
private var activeDetachCloseTransactions: Int = 0
private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 }
#if DEBUG
private func debugElapsedMs(since start: TimeInterval) -> String {
let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000
return String(format: "%.2f", ms)
}
#endif
func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? {
surfaceIdToPanelId[surfaceId]
}
func markExplicitClose(surfaceId: TabID) {
explicitUserCloseTabIds.insert(surfaceId)
}
func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? {
surfaceIdToPanelId.first { $0.value == panelId }?.key
}
private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) {
let subscription = Publishers.CombineLatest3(
browserPanel.$pageTitle.removeDuplicates(),
browserPanel.$isLoading.removeDuplicates(),
browserPanel.$faviconPNGData.removeDuplicates(by: { $0 == $1 })
)
.receive(on: DispatchQueue.main)
.sink { [weak self, weak browserPanel] _, isLoading, favicon in
guard let self = self,
let browserPanel = browserPanel,
let tabId = self.surfaceIdFromPanelId(browserPanel.id) else { return }
guard let existing = self.bonsplitController.tab(tabId) else { return }
let nextTitle = browserPanel.displayTitle
if self.panelTitles[browserPanel.id] != nextTitle {
self.panelTitles[browserPanel.id] = nextTitle
}
let resolvedTitle = self.resolvedPanelTitle(panelId: browserPanel.id, fallback: nextTitle)
let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle
let faviconUpdate: Data?? = existing.iconImageData == favicon ? nil : .some(favicon)
let loadingUpdate: Bool? = existing.isLoading == isLoading ? nil : isLoading
guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil else { return }
self.bonsplitController.updateTab(
tabId,
title: titleUpdate,
iconImageData: faviconUpdate,
hasCustomTitle: self.panelCustomTitles[browserPanel.id] != nil,
isLoading: loadingUpdate
)
}
panelSubscriptions[browserPanel.id] = subscription
setPreferredBrowserProfileID(browserPanel.profileID)
}
func setPreferredBrowserProfileID(_ profileID: UUID?) {
guard let profileID else {
preferredBrowserProfileID = nil
return
}
guard BrowserProfileStore.shared.profileDefinition(id: profileID) != nil else { return }
preferredBrowserProfileID = profileID
}
private func resolvedNewBrowserProfileID(
preferredProfileID: UUID? = nil,
sourcePanelId: UUID? = nil
) -> UUID {
if let preferredProfileID,
BrowserProfileStore.shared.profileDefinition(id: preferredProfileID) != nil {
return preferredProfileID
}
if let sourcePanelId,
let sourceBrowserPanel = browserPanel(for: sourcePanelId),
BrowserProfileStore.shared.profileDefinition(id: sourceBrowserPanel.profileID) != nil {
return sourceBrowserPanel.profileID
}
if let preferredBrowserProfileID,
BrowserProfileStore.shared.profileDefinition(id: preferredBrowserProfileID) != nil {
return preferredBrowserProfileID
}
return BrowserProfileStore.shared.effectiveLastUsedProfileID
}
private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) {
let subscription = markdownPanel.$displayTitle
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self, weak markdownPanel] newTitle in
guard let self,
let markdownPanel,
let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return }
guard let existing = self.bonsplitController.tab(tabId) else { return }
if self.panelTitles[markdownPanel.id] != newTitle {
self.panelTitles[markdownPanel.id] = newTitle
}
let resolvedTitle = self.resolvedPanelTitle(panelId: markdownPanel.id, fallback: newTitle)
guard existing.title != resolvedTitle else { return }
self.bonsplitController.updateTab(
tabId,
title: resolvedTitle,
hasCustomTitle: self.panelCustomTitles[markdownPanel.id] != nil
)
}
panelSubscriptions[markdownPanel.id] = subscription
}
private func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? {
guard let target = remoteDisplayTarget else { return nil }
return BrowserRemoteWorkspaceStatus(
target: target,
connectionState: remoteConnectionState,
heartbeatCount: remoteHeartbeatCount,
lastHeartbeatAt: remoteLastHeartbeatAt
)
}
private func applyBrowserRemoteWorkspaceStatusToPanels() {
let snapshot = browserRemoteWorkspaceStatusSnapshot()
for panel in panels.values {
guard let browserPanel = panel as? BrowserPanel else { continue }
browserPanel.setRemoteWorkspaceStatus(snapshot)
}
}
// MARK: - Panel Access
func panel(for surfaceId: TabID) -> (any Panel)? {
guard let panelId = panelIdFromSurfaceId(surfaceId) else { return nil }
return panels[panelId]
}
func terminalPanel(for panelId: UUID) -> TerminalPanel? {
panels[panelId] as? TerminalPanel
}
func browserPanel(for panelId: UUID) -> BrowserPanel? {
panels[panelId] as? BrowserPanel
}
func markdownPanel(for panelId: UUID) -> MarkdownPanel? {
panels[panelId] as? MarkdownPanel
}
private func surfaceKind(for panel: any Panel) -> String {
switch panel.panelType {
case .terminal:
return SurfaceKind.terminal
case .browser:
return SurfaceKind.browser
case .markdown:
return SurfaceKind.markdown
}
}
private func resolvedPanelTitle(panelId: UUID, fallback: String) -> String {
let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackTitle = trimmedFallback.isEmpty ? "Tab" : trimmedFallback
if let custom = panelCustomTitles[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines),
!custom.isEmpty {
return custom
}
return fallbackTitle
}
private func syncPinnedStateForTab(_ tabId: TabID, panelId: UUID) {
let isPinned = pinnedPanelIds.contains(panelId)
if let panel = panels[panelId] {
bonsplitController.updateTab(
tabId,
kind: .some(surfaceKind(for: panel)),
isPinned: isPinned
)
} else {
bonsplitController.updateTab(tabId, isPinned: isPinned)
}
}
private func hasUnreadNotification(panelId: UUID) -> Bool {
AppDelegate.shared?.notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: panelId) ?? false
}
private func syncUnreadBadgeStateForPanel(_ panelId: UUID) {
guard let tabId = surfaceIdFromPanelId(panelId) else { return }
let shouldShowUnread = Self.shouldShowUnreadIndicator(
hasUnreadNotification: hasUnreadNotification(panelId: panelId),
isManuallyUnread: manualUnreadPanelIds.contains(panelId)
)
if let existing = bonsplitController.tab(tabId), existing.showsNotificationBadge == shouldShowUnread {
return
}
bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread)
}
private func normalizePinnedTabs(in paneId: PaneID) {
guard !isNormalizingPinnedTabOrder else { return }
isNormalizingPinnedTabOrder = true
defer { isNormalizingPinnedTabOrder = false }
let tabs = bonsplitController.tabs(inPane: paneId)
let pinnedTabs = tabs.filter { tab in
guard let panelId = panelIdFromSurfaceId(tab.id) else { return false }
return pinnedPanelIds.contains(panelId)
}
let unpinnedTabs = tabs.filter { tab in
guard let panelId = panelIdFromSurfaceId(tab.id) else { return true }
return !pinnedPanelIds.contains(panelId)
}
let desiredOrder = pinnedTabs + unpinnedTabs
for (index, desiredTab) in desiredOrder.enumerated() {
let currentTabs = bonsplitController.tabs(inPane: paneId)
guard let currentIndex = currentTabs.firstIndex(where: { $0.id == desiredTab.id }) else { continue }
if currentIndex != index {
_ = bonsplitController.reorderTab(desiredTab.id, toIndex: index)
}
}
}
private func insertionIndexToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> Int {
let tabs = bonsplitController.tabs(inPane: paneId)
guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count }
let pinnedCount = tabs.reduce(into: 0) { count, tab in
if let panelId = panelIdFromSurfaceId(tab.id), pinnedPanelIds.contains(panelId) {
count += 1
}
}
let rawTarget = min(anchorIndex + 1, tabs.count)
return max(rawTarget, pinnedCount)
}
func setPanelCustomTitle(panelId: UUID, title: String?) {
guard panels[panelId] != nil else { return }
let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let previous = panelCustomTitles[panelId]
if trimmed.isEmpty {
guard previous != nil else { return }
panelCustomTitles.removeValue(forKey: panelId)
} else {
guard previous != trimmed else { return }
panelCustomTitles[panelId] = trimmed
}
guard let panel = panels[panelId], let tabId = surfaceIdFromPanelId(panelId) else { return }
let baseTitle = panelTitles[panelId] ?? panel.displayTitle
bonsplitController.updateTab(
tabId,
title: resolvedPanelTitle(panelId: panelId, fallback: baseTitle),
hasCustomTitle: panelCustomTitles[panelId] != nil
)
}
func isPanelPinned(_ panelId: UUID) -> Bool {
pinnedPanelIds.contains(panelId)
}
func panelKind(panelId: UUID) -> String? {
guard let panel = panels[panelId] else { return nil }
return surfaceKind(for: panel)
}
func requestBackgroundTerminalSurfaceStartIfNeeded() {
for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) {
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
}
@discardableResult
func preloadTerminalPanelForDebugStress(
tabId: TabID,
inPane paneId: PaneID
) -> TerminalPanel? {
guard let panelId = panelIdFromSurfaceId(tabId),
let terminalPanel = panels[panelId] as? TerminalPanel else {
return nil
}
debugStressPreloadSelectionDepth += 1
defer { debugStressPreloadSelectionDepth -= 1 }
let isVisibleSelection =
bonsplitController.focusedPaneId == paneId &&
bonsplitController.selectedTab(inPane: paneId)?.id == tabId &&
terminalPanel.hostedView.window != nil &&
terminalPanel.hostedView.superview != nil
if isVisibleSelection {
terminalPanel.requestViewReattach()
scheduleTerminalGeometryReconcile()
}
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
return terminalPanel
}
func scheduleDebugStressTerminalGeometryReconcile() {
scheduleTerminalGeometryReconcile()
}
func hasLoadedTerminalSurface() -> Bool {
let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel }
guard !terminalPanels.isEmpty else { return true }
return terminalPanels.contains { $0.surface.surface != nil }
}
func panelTitle(panelId: UUID) -> String? {
guard let panel = panels[panelId] else { return nil }
let fallback = panelTitles[panelId] ?? panel.displayTitle
return resolvedPanelTitle(panelId: panelId, fallback: fallback)
}
func setPanelPinned(panelId: UUID, pinned: Bool) {
guard panels[panelId] != nil else { return }
let wasPinned = pinnedPanelIds.contains(panelId)
guard wasPinned != pinned else { return }
if pinned {
pinnedPanelIds.insert(panelId)
} else {
pinnedPanelIds.remove(panelId)
}
guard let tabId = surfaceIdFromPanelId(panelId),
let paneId = paneId(forPanelId: panelId) else { return }
bonsplitController.updateTab(tabId, isPinned: pinned)
normalizePinnedTabs(in: paneId)
}
func markPanelUnread(_ panelId: UUID) {
guard panels[panelId] != nil else { return }
guard manualUnreadPanelIds.insert(panelId).inserted else { return }
manualUnreadMarkedAt[panelId] = Date()
syncUnreadBadgeStateForPanel(panelId)
}
func markPanelRead(_ panelId: UUID) {
guard panels[panelId] != nil else { return }
AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId)
clearManualUnread(panelId: panelId)
}
func clearManualUnread(panelId: UUID) {
let didRemoveUnread = manualUnreadPanelIds.remove(panelId) != nil
manualUnreadMarkedAt.removeValue(forKey: panelId)
guard didRemoveUnread else { return }
syncUnreadBadgeStateForPanel(panelId)
}
static func shouldClearManualUnread(
previousFocusedPanelId: UUID?,
nextFocusedPanelId: UUID,
isManuallyUnread: Bool,
markedAt: Date?,
now: Date = Date(),
sameTabGraceInterval: TimeInterval = manualUnreadFocusGraceInterval
) -> Bool {
guard isManuallyUnread else { return false }
if let previousFocusedPanelId, previousFocusedPanelId != nextFocusedPanelId {
return true
}
guard let markedAt else { return true }
return now.timeIntervalSince(markedAt) >= sameTabGraceInterval
}
static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool {
hasUnreadNotification || isManuallyUnread
}
// MARK: - Title Management
var hasCustomTitle: Bool {
let trimmed = customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !trimmed.isEmpty
}
func applyProcessTitle(_ title: String) {
processTitle = title
guard customTitle == nil else { return }
self.title = title
}
func setCustomColor(_ hex: String?) {
if let hex {
customColor = WorkspaceTabColorSettings.normalizedHex(hex)
} else {
customColor = nil
}
}
func setCustomTitle(_ title: String?) {
let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
customTitle = nil
self.title = processTitle
} else {
customTitle = trimmed
self.title = trimmed
}
}
// MARK: - Directory Updates
func updatePanelDirectory(panelId: UUID, directory: String) {
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if panelDirectories[panelId] != trimmed {
panelDirectories[panelId] = trimmed
}
// Update current directory if this is the focused panel
if panelId == focusedPanelId, currentDirectory != trimmed {
currentDirectory = trimmed
}
}
func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) {
guard panels[panelId] != nil else { return }
let previousState = panelShellActivityStates[panelId] ?? .unknown
guard previousState != state else { return }
panelShellActivityStates[panelId] = state
#if DEBUG
dlog(
"surface.shellState workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) from=\(previousState.rawValue) to=\(state.rawValue)"
)
#endif
}
func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool {
Self.resolveCloseConfirmation(
shellActivityState: panelShellActivityStates[panelId],
fallbackNeedsConfirmClose: fallbackNeedsConfirmClose
)
}
func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) {
let state = SidebarGitBranchState(branch: branch, isDirty: isDirty)
let existing = panelGitBranches[panelId]
let branchChanged = existing?.branch != nil && existing?.branch != branch
if existing?.branch != branch || existing?.isDirty != isDirty {
panelGitBranches[panelId] = state
}
if branchChanged {
panelPullRequests.removeValue(forKey: panelId)
if panelId == focusedPanelId {
pullRequest = nil
}
}
if panelId == focusedPanelId {
gitBranch = state
}
}
func clearPanelGitBranch(panelId: UUID) {
panelGitBranches.removeValue(forKey: panelId)
panelPullRequests.removeValue(forKey: panelId)
if panelId == focusedPanelId {
gitBranch = nil
pullRequest = nil
}
}
func updatePanelPullRequest(
panelId: UUID,
number: Int,
label: String,
url: URL,
status: SidebarPullRequestStatus,
branch: String? = nil,
checks: SidebarPullRequestChecksStatus? = nil
) {
let existing = panelPullRequests[panelId]
let normalizedBranch = normalizedSidebarBranchName(branch)
let currentPanelBranch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch)
let resolvedBranch: String? = {
if let normalizedBranch {
return normalizedBranch
}
if let currentPanelBranch {
return currentPanelBranch
}
guard let existing,
existing.number == number,
existing.label == label,
existing.url == url,
existing.status == status else {
return nil
}
return existing.branch
}()
let resolvedChecks: SidebarPullRequestChecksStatus? = {
if let checks {
return checks
}
guard let existing,
existing.number == number,
existing.label == label,
existing.url == url,
existing.status == status else {
return nil
}
return existing.checks
}()
let state = SidebarPullRequestState(
number: number,
label: label,
url: url,
status: status,
branch: resolvedBranch,
checks: resolvedChecks
)
if existing != state {
panelPullRequests[panelId] = state
}
if panelId == focusedPanelId {
pullRequest = state
}
}
func clearPanelPullRequest(panelId: UUID) {
panelPullRequests.removeValue(forKey: panelId)
if panelId == focusedPanelId {
pullRequest = nil
}
}
func resetSidebarContext(reason: String = "unspecified") {
statusEntries.removeAll()
agentPIDs.removeAll()
logEntries.removeAll()
progress = nil
gitBranch = nil
panelGitBranches.removeAll()
pullRequest = nil
panelPullRequests.removeAll()
surfaceListeningPorts.removeAll()
listeningPorts.removeAll()
metadataBlocks.removeAll()
resetBrowserPanelsForContextChange(reason: reason)
}
func resetBrowserPanelsForContextChange(reason: String) {
let browserPanels = panels.values.compactMap { $0 as? BrowserPanel }
guard !browserPanels.isEmpty else { return }
#if DEBUG
dlog(
"workspace.contextReset.browserPanels workspace=\(id.uuidString.prefix(5)) " +
"reason=\(reason) count=\(browserPanels.count)"
)
#endif
for browserPanel in browserPanels {
browserPanel.resetForWorkspaceContextChange(reason: reason)
let nextTitle = browserPanel.displayTitle
_ = updatePanelTitle(panelId: browserPanel.id, title: nextTitle)
guard let tabId = surfaceIdFromPanelId(browserPanel.id),
let existing = bonsplitController.tab(tabId) else {
continue
}
let faviconUpdate: Data?? = existing.iconImageData == nil ? nil : .some(nil)
let loadingUpdate: Bool? = existing.isLoading ? false : nil
guard faviconUpdate != nil || loadingUpdate != nil else {
continue
}
bonsplitController.updateTab(
tabId,
iconImageData: faviconUpdate,
hasCustomTitle: panelCustomTitles[browserPanel.id] != nil,
isLoading: loadingUpdate
)
}
}
@discardableResult
func updatePanelTitle(panelId: UUID, title: String) -> Bool {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
var didMutate = false
if panelTitles[panelId] != trimmed {
panelTitles[panelId] = trimmed
didMutate = true
}
// Update bonsplit tab title only when this panel's title changed.
if didMutate,
let tabId = surfaceIdFromPanelId(panelId),
let panel = panels[panelId] {
let baseTitle = panelTitles[panelId] ?? panel.displayTitle
let resolvedTitle = resolvedPanelTitle(panelId: panelId, fallback: baseTitle)
bonsplitController.updateTab(
tabId,
title: resolvedTitle,
hasCustomTitle: panelCustomTitles[panelId] != nil
)
}
// If this is the only panel and no custom title, update workspace title
if panels.count == 1, customTitle == nil {
if self.title != trimmed {
self.title = trimmed
didMutate = true
}
if processTitle != trimmed {
processTitle = trimmed
}
}
return didMutate
}
func pruneSurfaceMetadata(validSurfaceIds: Set<UUID>) {
panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) }
panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) }
panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) }
pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) }
manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) }
panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) }
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) }
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
recomputeListeningPorts()
}
func recomputeListeningPorts() {
let unique = Set(surfaceListeningPorts.values.flatMap { $0 }).union(remoteForwardedPorts)
let next = unique.sorted()
if listeningPorts != next {
listeningPorts = next
}
}
func sidebarOrderedPanelIds() -> [UUID] {
let paneTabs: [String: [UUID]] = Dictionary(
uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in
let panelIds = bonsplitController
.tabs(inPane: paneId)
.compactMap { panelIdFromSurfaceId($0.id) }
return (paneId.id.uuidString, panelIds)
}
)
let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString }
let tree = bonsplitController.treeSnapshot()
return SidebarBranchOrdering.orderedPanelIds(
tree: tree,
paneTabs: paneTabs,
fallbackPanelIds: fallbackPanelIds
)
}
private func normalizedSidebarDirectory(_ directory: String?) -> String? {
guard let directory else { return nil }
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func sidebarHomeDirectoryForCanonicalization(
resolvedPanelDirectories: [UUID: String]
) -> String? {
if isRemoteWorkspace {
return SidebarBranchOrdering.inferredRemoteHomeDirectory(
from: Array(resolvedPanelDirectories.values),
fallbackDirectory: normalizedSidebarDirectory(currentDirectory)
)
}
return FileManager.default.homeDirectoryForCurrentUser.path
}
private func sidebarResolvedDirectory(for panelId: UUID) -> String? {
if let directory = normalizedSidebarDirectory(panelDirectories[panelId]) {
return directory
}
if let requestedDirectory = normalizedSidebarDirectory(
terminalPanel(for: panelId)?.requestedWorkingDirectory
) {
return requestedDirectory
}
guard panelId == focusedPanelId else { return nil }
return normalizedSidebarDirectory(currentDirectory)
}
private func sidebarResolvedPanelDirectories(orderedPanelIds: [UUID]) -> [UUID: String] {
var resolved: [UUID: String] = [:]
for panelId in orderedPanelIds {
if let directory = sidebarResolvedDirectory(for: panelId) {
resolved[panelId] = directory
}
}
return resolved
}
func sidebarDirectoriesInDisplayOrder(orderedPanelIds: [UUID]) -> [String] {
let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds)
let homeDirectoryForCanonicalization = sidebarHomeDirectoryForCanonicalization(
resolvedPanelDirectories: resolvedDirectories
)
var ordered: [String] = []
var seen: Set<String> = []
for panelId in orderedPanelIds {
guard let directory = resolvedDirectories[panelId],
let key = SidebarBranchOrdering.canonicalDirectoryKey(
directory,
homeDirectoryForTildeExpansion: homeDirectoryForCanonicalization
) else { continue }
if seen.insert(key).inserted {
ordered.append(directory)
}
}
if ordered.isEmpty, let fallbackDirectory = normalizedSidebarDirectory(currentDirectory) {
return [fallbackDirectory]
}
return ordered
}
func sidebarDirectoriesInDisplayOrder() -> [String] {
sidebarDirectoriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds())
}
func sidebarGitBranchesInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarGitBranchState] {
SidebarBranchOrdering
.orderedUniqueBranches(
orderedPanelIds: orderedPanelIds,
panelBranches: panelGitBranches,
fallbackBranch: gitBranch
)
.map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) }
}
func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] {
sidebarGitBranchesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds())
}
func sidebarBranchDirectoryEntriesInDisplayOrder(
orderedPanelIds: [UUID]
) -> [SidebarBranchOrdering.BranchDirectoryEntry] {
let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds)
return SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
orderedPanelIds: orderedPanelIds,
panelBranches: panelGitBranches,
panelDirectories: resolvedDirectories,
defaultDirectory: normalizedSidebarDirectory(currentDirectory),
homeDirectoryForTildeExpansion: sidebarHomeDirectoryForCanonicalization(
resolvedPanelDirectories: resolvedDirectories
),
fallbackBranch: gitBranch
)
}
func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] {
sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds())
}
func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] {
let validPanelPullRequests = panelPullRequests.filter { panelId, state in
guard let pullRequestBranch = normalizedSidebarBranchName(state.branch) else {
return true
}
return normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) == pullRequestBranch
}
return SidebarBranchOrdering.orderedUniquePullRequests(
orderedPanelIds: orderedPanelIds,
panelPullRequests: validPanelPullRequests,
fallbackPullRequest: nil
)
}
func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] {
sidebarPullRequestsInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds())
}
func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] {
statusEntries.values.sorted { lhs, rhs in
if lhs.priority != rhs.priority { return lhs.priority > rhs.priority }
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
return lhs.key < rhs.key
}
}
func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] {
metadataBlocks.values.sorted { lhs, rhs in
if lhs.priority != rhs.priority { return lhs.priority > rhs.priority }
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
return lhs.key < rhs.key
}
}
var isRemoteWorkspace: Bool {
remoteConfiguration != nil
}
var remoteDisplayTarget: String? {
remoteConfiguration?.displayTarget
}
var hasActiveRemoteTerminalSessions: Bool {
activeRemoteTerminalSessionCount > 0
}
func remoteStatusPayload() -> [String: Any] {
let heartbeatAgeSeconds: Any = {
guard let last = remoteLastHeartbeatAt else { return NSNull() }
return max(0, Date().timeIntervalSince(last))
}()
let heartbeatTimestamp: Any = {
guard let last = remoteLastHeartbeatAt else { return NSNull() }
return Self.remoteHeartbeatDateFormatter.string(from: last)
}()
var payload: [String: Any] = [
"enabled": remoteConfiguration != nil,
"state": remoteConnectionState.rawValue,
"connected": remoteConnectionState == .connected,
"active_terminal_sessions": activeRemoteTerminalSessionCount,
"daemon": remoteDaemonStatus.payload(),
"detected_ports": remoteDetectedPorts,
"forwarded_ports": remoteForwardedPorts,
"conflicted_ports": remotePortConflicts,
"detail": remoteConnectionDetail ?? NSNull(),
"heartbeat": [
"count": remoteHeartbeatCount,
"last_seen_at": heartbeatTimestamp,
"age_seconds": heartbeatAgeSeconds,
],
]
if let endpoint = remoteProxyEndpoint {
payload["proxy"] = [
"state": "ready",
"host": endpoint.host,
"port": endpoint.port,
"schemes": ["socks5", "http_connect"],
"url": "socks5://\(endpoint.host):\(endpoint.port)",
]
} else {
let proxyState: String
if hasProxyOnlyRemoteSidebarError {
proxyState = "error"
} else {
switch remoteConnectionState {
case .connecting:
proxyState = "connecting"
case .error:
proxyState = "error"
default:
proxyState = "unavailable"
}
}
payload["proxy"] = [
"state": proxyState,
"host": NSNull(),
"port": NSNull(),
"schemes": ["socks5", "http_connect"],
"url": NSNull(),
"error_code": proxyState == "error" ? "proxy_unavailable" : NSNull(),
]
}
if let remoteConfiguration {
payload["destination"] = remoteConfiguration.destination
payload["port"] = remoteConfiguration.port ?? NSNull()
payload["has_identity_file"] = remoteConfiguration.identityFile != nil
payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty
payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull()
} else {
payload["destination"] = NSNull()
payload["port"] = NSNull()
payload["has_identity_file"] = false
payload["has_ssh_options"] = false
payload["local_proxy_port"] = NSNull()
}
return payload
}
func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) {
remoteConfiguration = configuration
seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration)
remoteDetectedPorts = []
remoteForwardedPorts = []
remotePortConflicts = []
remoteProxyEndpoint = nil
remoteHeartbeatCount = 0
remoteLastHeartbeatAt = nil
remoteConnectionDetail = nil
remoteDaemonStatus = WorkspaceRemoteDaemonStatus()
statusEntries.removeValue(forKey: Self.remoteErrorStatusKey)
statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey)
remoteLastErrorFingerprint = nil
remoteLastDaemonErrorFingerprint = nil
remoteLastPortConflictFingerprint = nil
recomputeListeningPorts()
let previousController = remoteSessionController
activeRemoteSessionControllerID = nil
remoteSessionController = nil
previousController?.stop()
applyRemoteProxyEndpointUpdate(nil)
applyBrowserRemoteWorkspaceStatusToPanels()
guard autoConnect else {
remoteConnectionState = .disconnected
applyBrowserRemoteWorkspaceStatusToPanels()
return
}
remoteConnectionState = .connecting
applyBrowserRemoteWorkspaceStatusToPanels()
let controllerID = UUID()
let controller = WorkspaceRemoteSessionController(
workspace: self,
configuration: configuration,
controllerID: controllerID
)
activeRemoteSessionControllerID = controllerID
remoteSessionController = controller
controller.start()
}
func reconnectRemoteConnection() {
guard let configuration = remoteConfiguration else { return }
configureRemoteConnection(configuration, autoConnect: true)
}
func disconnectRemoteConnection(clearConfiguration: Bool = false) {
let previousController = remoteSessionController
activeRemoteSessionControllerID = nil
remoteSessionController = nil
previousController?.stop()
activeRemoteTerminalSurfaceIds.removeAll()
activeRemoteTerminalSessionCount = 0
remoteDetectedPorts = []
remoteForwardedPorts = []
remotePortConflicts = []
remoteProxyEndpoint = nil
remoteHeartbeatCount = 0
remoteLastHeartbeatAt = nil
remoteConnectionState = .disconnected
remoteConnectionDetail = nil
remoteDaemonStatus = WorkspaceRemoteDaemonStatus()
statusEntries.removeValue(forKey: Self.remoteErrorStatusKey)
statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey)
remoteLastErrorFingerprint = nil
remoteLastDaemonErrorFingerprint = nil
remoteLastPortConflictFingerprint = nil
if clearConfiguration {
remoteConfiguration = nil
}
applyRemoteProxyEndpointUpdate(nil)
applyBrowserRemoteWorkspaceStatusToPanels()
recomputeListeningPorts()
}
private func clearRemoteConfigurationIfWorkspaceBecameLocal() {
guard panels.isEmpty, remoteConfiguration != nil else { return }
disconnectRemoteConnection(clearConfiguration: true)
}
private func seedInitialRemoteTerminalSessionIfNeeded(configuration: WorkspaceRemoteConfiguration) {
guard configuration.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
return
}
guard activeRemoteTerminalSurfaceIds.isEmpty else { return }
let terminalIds = panels.compactMap { panelId, panel in
panel is TerminalPanel ? panelId : nil
}
guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return }
trackRemoteTerminalSurface(initialPanelId)
}
private func trackRemoteTerminalSurface(_ panelId: UUID) {
guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return }
activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count
}
private func untrackRemoteTerminalSurface(_ panelId: UUID) {
guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return }
activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count
maybeDemoteRemoteWorkspaceAfterSSHSessionEnded()
}
private func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() {
guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return }
let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel }
if !hasBrowserPanels {
if remoteConnectionState == .error || remoteDaemonStatus.state == .error || remoteConnectionState == .connecting {
return
}
disconnectRemoteConnection(clearConfiguration: true)
}
}
func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) {
guard let relayPort,
relayPort > 0,
remoteConfiguration?.relayPort == relayPort else {
return
}
untrackRemoteTerminalSurface(surfaceId)
}
func teardownRemoteConnection() {
disconnectRemoteConnection(clearConfiguration: true)
}
func applyRemoteConnectionStateUpdate(
_ state: WorkspaceRemoteConnectionState,
detail: String?,
target: String
) {
let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines)
let proxyOnlyError = trimmedDetail.map(Self.isProxyOnlyRemoteError) ?? false
let preserveConnectedStateForRetry =
state == .connecting && preservesSSHTerminalConnection && hasProxyOnlyRemoteSidebarError
let effectiveState: WorkspaceRemoteConnectionState
if state == .error && proxyOnlyError && preservesSSHTerminalConnection {
effectiveState = .connected
} else if preserveConnectedStateForRetry {
effectiveState = .connected
} else {
effectiveState = state
}
remoteConnectionState = effectiveState
remoteConnectionDetail = detail
applyBrowserRemoteWorkspaceStatusToPanels()
if let trimmedDetail, !trimmedDetail.isEmpty, (state == .error || proxyOnlyError) {
let statusPrefix = proxyOnlyError ? "Remote proxy unavailable" : "SSH error"
let statusIcon = proxyOnlyError ? "exclamationmark.triangle.fill" : "network.slash"
let notificationTitle = proxyOnlyError ? "Remote Proxy Unavailable" : "Remote SSH Error"
let logSource = proxyOnlyError ? "remote-proxy" : "remote"
statusEntries[Self.remoteErrorStatusKey] = SidebarStatusEntry(
key: Self.remoteErrorStatusKey,
value: "\(statusPrefix) (\(target)): \(trimmedDetail)",
icon: statusIcon,
color: nil,
timestamp: Date()
)
let fingerprint = "connection:\(trimmedDetail)"
if remoteLastErrorFingerprint != fingerprint {
remoteLastErrorFingerprint = fingerprint
appendSidebarLog(
message: "\(statusPrefix) (\(target)): \(trimmedDetail)",
level: .error,
source: logSource
)
AppDelegate.shared?.notificationStore?.addNotification(
tabId: id,
surfaceId: nil,
title: notificationTitle,
subtitle: target,
body: trimmedDetail
)
}
return
}
if !preserveConnectedStateForRetry && state != .error {
statusEntries.removeValue(forKey: Self.remoteErrorStatusKey)
remoteLastErrorFingerprint = nil
}
}
fileprivate func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) {
remoteDaemonStatus = status
applyBrowserRemoteWorkspaceStatusToPanels()
guard status.state == .error else {
remoteLastDaemonErrorFingerprint = nil
return
}
let trimmedDetail = status.detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "remote daemon error"
let fingerprint = "daemon:\(trimmedDetail)"
guard remoteLastDaemonErrorFingerprint != fingerprint else { return }
remoteLastDaemonErrorFingerprint = fingerprint
appendSidebarLog(
message: "Remote daemon error (\(target)): \(trimmedDetail)",
level: .error,
source: "remote-daemon"
)
}
fileprivate func applyRemoteProxyEndpointUpdate(_ endpoint: BrowserProxyEndpoint?) {
remoteProxyEndpoint = endpoint
for panel in panels.values {
guard let browserPanel = panel as? BrowserPanel else { continue }
browserPanel.setRemoteProxyEndpoint(endpoint)
}
applyBrowserRemoteWorkspaceStatusToPanels()
}
fileprivate func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) {
remoteHeartbeatCount = max(0, count)
remoteLastHeartbeatAt = lastSeenAt
applyBrowserRemoteWorkspaceStatusToPanels()
}
fileprivate func applyRemotePortsSnapshot(detected: [Int], forwarded: [Int], conflicts: [Int], target: String) {
remoteDetectedPorts = detected
remoteForwardedPorts = forwarded
remotePortConflicts = conflicts
recomputeListeningPorts()
if conflicts.isEmpty {
statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey)
remoteLastPortConflictFingerprint = nil
return
}
let conflictsList = conflicts.map { ":\($0)" }.joined(separator: ", ")
statusEntries[Self.remotePortConflictStatusKey] = SidebarStatusEntry(
key: Self.remotePortConflictStatusKey,
value: "SSH port conflicts (\(target)): \(conflictsList)",
icon: "exclamationmark.triangle.fill",
color: nil,
timestamp: Date()
)
let fingerprint = conflicts.map(String.init).joined(separator: ",")
guard remoteLastPortConflictFingerprint != fingerprint else { return }
remoteLastPortConflictFingerprint = fingerprint
appendSidebarLog(
message: "Port conflicts while forwarding \(target): \(conflictsList)",
level: .warning,
source: "remote-forward"
)
}
private func appendSidebarLog(message: String, level: SidebarLogLevel, source: String?) {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
logEntries.append(SidebarLogEntry(message: trimmed, level: level, source: source, timestamp: Date()))
let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50
let limit = max(1, min(500, configuredLimit))
if logEntries.count > limit {
logEntries.removeFirst(logEntries.count - limit)
}
}
// MARK: - Panel Operations
private func seedTerminalInheritanceFontPoints(
panelId: UUID,
configTemplate: ghostty_surface_config_s?
) {
guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return }
terminalInheritanceFontPointsByPanelId[panelId] = fontPoints
lastTerminalConfigInheritanceFontPoints = fontPoints
}
private func resolvedTerminalInheritanceFontPoints(
for terminalPanel: TerminalPanel,
sourceSurface: ghostty_surface_t,
inheritedConfig: ghostty_surface_config_s
) -> Float? {
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface)
if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 {
if let runtimePoints, abs(runtimePoints - rooted) > 0.05 {
// Runtime zoom changed after lineage was seeded (manual zoom on descendant);
// treat runtime as the new root for future descendants.
return runtimePoints
}
return rooted
}
if inheritedConfig.font_size > 0 {
return inheritedConfig.font_size
}
return runtimePoints
}
private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) {
lastTerminalConfigInheritancePanelId = terminalPanel.id
if let sourceSurface = terminalPanel.surface.surface,
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) {
let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id]
if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 {
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints
}
lastTerminalConfigInheritanceFontPoints =
terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints
}
}
func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? {
guard let panelId = lastTerminalConfigInheritancePanelId else { return nil }
return terminalPanel(for: panelId)
}
func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? {
lastTerminalConfigInheritanceFontPoints
}
/// Candidate terminal panels used as the source when creating inherited Ghostty config.
/// Preference order:
/// 1) explicitly preferred terminal panel (when the caller has one),
/// 2) selected terminal in the target pane,
/// 3) currently focused terminal in the workspace,
/// 4) last remembered terminal source,
/// 5) first terminal tab in the target pane,
/// 6) deterministic workspace fallback.
private func terminalPanelConfigInheritanceCandidates(
preferredPanelId: UUID? = nil,
inPane preferredPaneId: PaneID? = nil
) -> [TerminalPanel] {
var candidates: [TerminalPanel] = []
var seen: Set<UUID> = []
func appendCandidate(_ panel: TerminalPanel?) {
guard let panel, seen.insert(panel.id).inserted else { return }
candidates.append(panel)
}
if let preferredPanelId,
let terminalPanel = terminalPanel(for: preferredPanelId) {
appendCandidate(terminalPanel)
}
if let preferredPaneId,
let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id,
let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId),
let selectedTerminalPanel = terminalPanel(for: selectedPanelId) {
appendCandidate(selectedTerminalPanel)
}
if let focusedTerminalPanel {
appendCandidate(focusedTerminalPanel)
}
if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() {
appendCandidate(rememberedTerminalPanel)
}
if let preferredPaneId {
for tab in bonsplitController.tabs(inPane: preferredPaneId) {
guard let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = terminalPanel(for: panelId) else { continue }
appendCandidate(terminalPanel)
}
}
for terminalPanel in panels.values
.compactMap({ $0 as? TerminalPanel })
.sorted(by: { $0.id.uuidString < $1.id.uuidString }) {
appendCandidate(terminalPanel)
}
return candidates
}
/// Picks the first terminal panel candidate used as the inheritance source.
func terminalPanelForConfigInheritance(
preferredPanelId: UUID? = nil,
inPane preferredPaneId: PaneID? = nil
) -> TerminalPanel? {
terminalPanelConfigInheritanceCandidates(
preferredPanelId: preferredPanelId,
inPane: preferredPaneId
).first
}
private func inheritedTerminalConfig(
preferredPanelId: UUID? = nil,
inPane preferredPaneId: PaneID? = nil
) -> ghostty_surface_config_s? {
// Walk candidates in priority order and use the first panel with a live surface.
// This avoids returning nil when the top candidate exists but is not attached yet.
for terminalPanel in terminalPanelConfigInheritanceCandidates(
preferredPanelId: preferredPanelId,
inPane: preferredPaneId
) {
guard let sourceSurface = terminalPanel.surface.surface else { continue }
var config = cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT
)
if let rootedFontPoints = resolvedTerminalInheritanceFontPoints(
for: terminalPanel,
sourceSurface: sourceSurface,
inheritedConfig: config
), rootedFontPoints > 0 {
config.font_size = rootedFontPoints
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints
}
rememberTerminalConfigInheritanceSource(terminalPanel)
if config.font_size > 0 {
lastTerminalConfigInheritanceFontPoints = config.font_size
}
return config
}
if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints {
var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints
#if DEBUG
dlog(
"zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))"
)
#endif
return config
}
return nil
}
/// Create a new split with a terminal panel
@discardableResult
func newTerminalSplit(
from panelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
focus: Bool = true
) -> TerminalPanel? {
// Find the pane containing the source panel
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
var sourcePaneId: PaneID?
for paneId in bonsplitController.allPaneIds {
let tabs = bonsplitController.tabs(inPane: paneId)
if tabs.contains(where: { $0.id == sourceTabId }) {
sourcePaneId = paneId
break
}
}
guard let paneId = sourcePaneId else { return nil }
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
let remoteTerminalStartupCommand = remoteTerminalStartupCommand()
// Inherit working directory: prefer the source panel's reported cwd,
// then its requested startup cwd if shell integration has not reported
// back yet, and finally fall back to the workspace's current directory.
let splitWorkingDirectory: String? = {
if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines),
!panelDirectory.isEmpty {
return panelDirectory
}
if let requestedWorkingDirectory = terminalPanel(for: panelId)?
.requestedWorkingDirectory?
.trimmingCharacters(in: .whitespacesAndNewlines),
!requestedWorkingDirectory.isEmpty {
return requestedWorkingDirectory
}
let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
return workspaceDirectory.isEmpty ? nil : workspaceDirectory
}()
#if DEBUG
dlog(
"split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")"
)
#endif
// Create the new terminal panel.
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig,
workingDirectory: splitWorkingDirectory,
portOrdinal: portOrdinal,
initialCommand: remoteTerminalStartupCommand
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
if remoteTerminalStartupCommand != nil {
trackRemoteTerminalSurface(newPanel.id)
}
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
// Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit
// mutates layout state (avoids transient "Empty Panel" flashes during split).
let newTab = Bonsplit.Tab(
title: newPanel.displayTitle,
icon: newPanel.displayIcon,
kind: SurfaceKind.terminal,
isDirty: newPanel.isDirty,
isPinned: false
)
surfaceIdToPanelId[newTab.id] = newPanel.id
let previousFocusedPanelId = focusedPanelId
// Capture the source terminal's hosted view before bonsplit mutates focusedPaneId,
// so we can hand it to focusPanel as the "move focus FROM" view.
let previousHostedView = focusedTerminalPanel?.hostedView
// Create the split with the new tab already present in the new pane.
isProgrammaticSplit = true
defer { isProgrammaticSplit = false }
guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
surfaceIdToPanelId.removeValue(forKey: newTab.id)
if remoteTerminalStartupCommand != nil {
untrackRemoteTerminalSurface(newPanel.id)
}
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return nil
}
#if DEBUG
dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)")
#endif
// Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting.
// Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view,
// stealing focus from the new panel and creating model/surface divergence.
if focus {
previousHostedView?.suppressReparentFocus()
focusPanel(newPanel.id, previousHostedView: previousHostedView)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
preserveFocusAfterNonFocusSplit(
preferredPanelId: previousFocusedPanelId,
splitPanelId: newPanel.id,
previousHostedView: previousHostedView
)
}
return newPanel
}
/// Create a new surface (nested tab) in the specified pane with a terminal panel.
/// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior),
/// true = force focus/selection of the new surface,
/// false = never focus (used for internal placeholder repair paths).
@discardableResult
func newTerminalSurface(
inPane paneId: PaneID,
focus: Bool? = nil,
workingDirectory: String? = nil,
startupEnvironment: [String: String] = [:]
) -> TerminalPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
let previousFocusedPanelId = focusedPanelId
let previousHostedView = focusedTerminalPanel?.hostedView
let inheritedConfig = inheritedTerminalConfig(inPane: paneId)
let remoteTerminalStartupCommand = remoteTerminalStartupCommand()
// Create new terminal panel
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig,
workingDirectory: workingDirectory,
portOrdinal: portOrdinal,
initialCommand: remoteTerminalStartupCommand,
additionalEnvironment: startupEnvironment
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
if remoteTerminalStartupCommand != nil {
trackRemoteTerminalSurface(newPanel.id)
}
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
// Create tab in bonsplit
guard let newTabId = bonsplitController.createTab(
title: newPanel.displayTitle,
icon: newPanel.displayIcon,
kind: SurfaceKind.terminal,
isDirty: newPanel.isDirty,
isPinned: false,
inPane: paneId
) else {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
if remoteTerminalStartupCommand != nil {
untrackRemoteTerminalSurface(newPanel.id)
}
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return nil
}
surfaceIdToPanelId[newTabId] = newPanel.id
// bonsplit's createTab may not reliably emit didSelectTab, and its internal selection
// updates can be deferred. Force a deterministic selection + focus path so the new
// surface becomes interactive immediately (no "frozen until pane switch" state).
if shouldFocusNewTab {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(newTabId)
newPanel.focus()
applyTabSelection(tabId: newTabId, inPane: paneId)
} else {
preserveFocusAfterNonFocusSplit(
preferredPanelId: previousFocusedPanelId,
splitPanelId: newPanel.id,
previousHostedView: previousHostedView
)
}
return newPanel
}
private func remoteTerminalStartupCommand() -> String? {
guard let command = remoteConfiguration?.terminalStartupCommand?
.trimmingCharacters(in: .whitespacesAndNewlines),
!command.isEmpty else {
return nil
}
return command
}
/// Create a new browser panel split
@discardableResult
func newBrowserSplit(
from panelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil,
preferredProfileID: UUID? = nil,
focus: Bool = true
) -> BrowserPanel? {
// Find the pane containing the source panel
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
var sourcePaneId: PaneID?
for paneId in bonsplitController.allPaneIds {
let tabs = bonsplitController.tabs(inPane: paneId)
if tabs.contains(where: { $0.id == sourceTabId }) {
sourcePaneId = paneId
break
}
}
guard let paneId = sourcePaneId else { return nil }
// Create browser panel
let browserPanel = BrowserPanel(
workspaceId: id,
profileID: resolvedNewBrowserProfileID(
preferredProfileID: preferredProfileID,
sourcePanelId: panelId
),
initialURL: url,
proxyEndpoint: remoteProxyEndpoint,
isRemoteWorkspace: isRemoteWorkspace,
remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil
)
panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle
// Pre-generate the bonsplit tab ID so the mapping exists before the split lands.
let newTab = Bonsplit.Tab(
title: browserPanel.displayTitle,
icon: browserPanel.displayIcon,
kind: SurfaceKind.browser,
isDirty: browserPanel.isDirty,
isLoading: browserPanel.isLoading,
isPinned: false
)
surfaceIdToPanelId[newTab.id] = browserPanel.id
let previousFocusedPanelId = focusedPanelId
// Create the split with the browser tab already present.
// Mark this split as programmatic so didSplitPane doesn't auto-create a terminal.
isProgrammaticSplit = true
defer { isProgrammaticSplit = false }
guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else {
surfaceIdToPanelId.removeValue(forKey: newTab.id)
panels.removeValue(forKey: browserPanel.id)
panelTitles.removeValue(forKey: browserPanel.id)
return nil
}
setPreferredBrowserProfileID(browserPanel.profileID)
// See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting.
let previousHostedView = focusedTerminalPanel?.hostedView
if focus {
previousHostedView?.suppressReparentFocus()
focusPanel(browserPanel.id)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
preserveFocusAfterNonFocusSplit(
preferredPanelId: previousFocusedPanelId,
splitPanelId: browserPanel.id,
previousHostedView: previousHostedView
)
}
installBrowserPanelSubscription(browserPanel)
browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot())
return browserPanel
}
/// Create a new browser surface in the specified pane.
/// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior),
/// true = force focus/selection of the new surface,
/// false = never focus (used for internal placeholder repair paths).
@discardableResult
func newBrowserSurface(
inPane paneId: PaneID,
url: URL? = nil,
focus: Bool? = nil,
insertAtEnd: Bool = false,
preferredProfileID: UUID? = nil,
bypassInsecureHTTPHostOnce: String? = nil
) -> BrowserPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
let sourcePanelId = effectiveSelectedPanelId(inPane: paneId)
let previousFocusedPanelId = focusedPanelId
let previousHostedView = focusedTerminalPanel?.hostedView
let browserPanel = BrowserPanel(
workspaceId: id,
profileID: resolvedNewBrowserProfileID(
preferredProfileID: preferredProfileID,
sourcePanelId: sourcePanelId
),
initialURL: url,
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce,
proxyEndpoint: remoteProxyEndpoint,
isRemoteWorkspace: isRemoteWorkspace,
remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil
)
panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle
guard let newTabId = bonsplitController.createTab(
title: browserPanel.displayTitle,
icon: browserPanel.displayIcon,
kind: SurfaceKind.browser,
isDirty: browserPanel.isDirty,
isLoading: browserPanel.isLoading,
isPinned: false,
inPane: paneId
) else {
panels.removeValue(forKey: browserPanel.id)
panelTitles.removeValue(forKey: browserPanel.id)
return nil
}
surfaceIdToPanelId[newTabId] = browserPanel.id
setPreferredBrowserProfileID(browserPanel.profileID)
// Keyboard/browser-open paths want "new tab at end" regardless of global new-tab placement.
if insertAtEnd {
let targetIndex = max(0, bonsplitController.tabs(inPane: paneId).count - 1)
_ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex)
}
// Match terminal behavior: enforce deterministic selection + focus.
if shouldFocusNewTab {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(newTabId)
browserPanel.focus()
applyTabSelection(tabId: newTabId, inPane: paneId)
} else {
preserveFocusAfterNonFocusSplit(
preferredPanelId: previousFocusedPanelId,
splitPanelId: browserPanel.id,
previousHostedView: previousHostedView
)
}
installBrowserPanelSubscription(browserPanel)
browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot())
return browserPanel
}
func newMarkdownSplit(
from panelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
filePath: String,
focus: Bool = true
) -> MarkdownPanel? {
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
var sourcePaneId: PaneID?
for paneId in bonsplitController.allPaneIds {
let tabs = bonsplitController.tabs(inPane: paneId)
if tabs.contains(where: { $0.id == sourceTabId }) {
sourcePaneId = paneId
break
}
}
guard let paneId = sourcePaneId else { return nil }
let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath)
panels[markdownPanel.id] = markdownPanel
panelTitles[markdownPanel.id] = markdownPanel.displayTitle
let newTab = Bonsplit.Tab(
title: markdownPanel.displayTitle,
icon: markdownPanel.displayIcon,
kind: SurfaceKind.markdown,
isDirty: markdownPanel.isDirty,
isLoading: false,
isPinned: false
)
surfaceIdToPanelId[newTab.id] = markdownPanel.id
let previousFocusedPanelId = focusedPanelId
isProgrammaticSplit = true
defer { isProgrammaticSplit = false }
guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else {
surfaceIdToPanelId.removeValue(forKey: newTab.id)
panels.removeValue(forKey: markdownPanel.id)
panelTitles.removeValue(forKey: markdownPanel.id)
return nil
}
let previousHostedView = focusedTerminalPanel?.hostedView
if focus {
previousHostedView?.suppressReparentFocus()
focusPanel(markdownPanel.id)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
preserveFocusAfterNonFocusSplit(
preferredPanelId: previousFocusedPanelId,
splitPanelId: markdownPanel.id,
previousHostedView: previousHostedView
)
}
installMarkdownPanelSubscription(markdownPanel)
return markdownPanel
}
@discardableResult
func newMarkdownSurface(
inPane paneId: PaneID,
filePath: String,
focus: Bool? = nil
) -> MarkdownPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
let previousFocusedPanelId = focusedPanelId
let previousHostedView = focusedTerminalPanel?.hostedView
let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath)
panels[markdownPanel.id] = markdownPanel
panelTitles[markdownPanel.id] = markdownPanel.displayTitle
guard let newTabId = bonsplitController.createTab(
title: markdownPanel.displayTitle,
icon: markdownPanel.displayIcon,
kind: SurfaceKind.markdown,
isDirty: markdownPanel.isDirty,
isLoading: false,
isPinned: false,
inPane: paneId
) else {
panels.removeValue(forKey: markdownPanel.id)
panelTitles.removeValue(forKey: markdownPanel.id)
return nil
}
surfaceIdToPanelId[newTabId] = markdownPanel.id
if shouldFocusNewTab {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(newTabId)
applyTabSelection(tabId: newTabId, inPane: paneId)
} else {
preserveFocusAfterNonFocusSplit(
preferredPanelId: previousFocusedPanelId,
splitPanelId: markdownPanel.id,
previousHostedView: previousHostedView
)
}
installMarkdownPanelSubscription(markdownPanel)
return markdownPanel
}
/// Tear down all panels in this workspace, freeing their Ghostty surfaces.
/// Called before the workspace is removed from TabManager to ensure child
/// processes receive SIGHUP even if ARC deallocation is delayed.
func teardownAllPanels() {
let panelEntries = Array(panels)
for (panelId, panel) in panelEntries {
panelSubscriptions.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
panel.close()
}
panels.removeAll(keepingCapacity: false)
surfaceIdToPanelId.removeAll(keepingCapacity: false)
panelSubscriptions.removeAll(keepingCapacity: false)
pruneSurfaceMetadata(validSurfaceIds: [])
restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false)
terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false)
lastTerminalConfigInheritancePanelId = nil
lastTerminalConfigInheritanceFontPoints = nil
}
/// Close a panel.
/// Returns true when a bonsplit tab close request was issued.
func closePanel(_ panelId: UUID, force: Bool = false) -> Bool {
if let tabId = surfaceIdFromPanelId(panelId) {
if force {
forceCloseTabIds.insert(tabId)
}
// Close the tab in bonsplit (this triggers delegate callback)
return bonsplitController.closeTab(tabId)
}
// Mapping can transiently drift during split-tree mutations. If the target panel is
// currently focused (or is the active terminal first responder), close whichever tab
// bonsplit marks selected in that focused pane.
let firstResponderPanelId = cmuxOwningGhosttyView(
for: NSApp.keyWindow?.firstResponder ?? NSApp.mainWindow?.firstResponder
)?.terminalSurface?.id
let targetIsActive = focusedPanelId == panelId || firstResponderPanelId == panelId
guard targetIsActive,
let focusedPane = bonsplitController.focusedPaneId,
let selected = bonsplitController.selectedTab(inPane: focusedPane) else {
#if DEBUG
dlog(
"surface.close.fallback.skip panel=\(panelId.uuidString.prefix(5)) " +
"focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " +
"firstResponderPanel=\(firstResponderPanelId?.uuidString.prefix(5) ?? "nil") " +
"focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil")"
)
#endif
return false
}
if force {
forceCloseTabIds.insert(selected.id)
}
let closed = bonsplitController.closeTab(selected.id)
#if DEBUG
dlog(
"surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " +
"selectedTab=\(String(describing: selected.id).prefix(5)) " +
"closed=\(closed ? 1 : 0)"
)
#endif
return closed
}
func paneId(forPanelId panelId: UUID) -> PaneID? {
guard let tabId = surfaceIdFromPanelId(panelId) else { return nil }
return bonsplitController.allPaneIds.first { paneId in
bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId })
}
}
func indexInPane(forPanelId panelId: UUID) -> Int? {
guard let tabId = surfaceIdFromPanelId(panelId),
let paneId = paneId(forPanelId: panelId) else { return nil }
return bonsplitController.tabs(inPane: paneId).firstIndex(where: { $0.id == tabId })
}
/// Returns the nearest right-side sibling pane for browser placement.
/// The search is local to the source pane's ancestry in the split tree:
/// use the closest horizontal ancestor where the source is in the first (left) branch.
func preferredBrowserTargetPane(fromPanelId panelId: UUID) -> PaneID? {
guard let sourcePane = paneId(forPanelId: panelId) else { return nil }
let sourcePaneId = sourcePane.id.uuidString
let tree = bonsplitController.treeSnapshot()
guard let path = browserPathToPane(targetPaneId: sourcePaneId, node: tree) else { return nil }
let layout = bonsplitController.layoutSnapshot()
let paneFrameById = Dictionary(uniqueKeysWithValues: layout.panes.map { ($0.paneId, $0.frame) })
let sourceFrame = paneFrameById[sourcePaneId]
let sourceCenterY = sourceFrame.map { $0.y + ($0.height * 0.5) } ?? 0
let sourceRightX = sourceFrame.map { $0.x + $0.width } ?? 0
for crumb in path {
guard crumb.split.orientation == "horizontal", crumb.branch == .first else { continue }
var candidateNodes: [ExternalPaneNode] = []
browserCollectPaneNodes(node: crumb.split.second, into: &candidateNodes)
if candidateNodes.isEmpty { continue }
let sorted = candidateNodes.sorted { lhs, rhs in
let lhsDy = abs((lhs.frame.y + (lhs.frame.height * 0.5)) - sourceCenterY)
let rhsDy = abs((rhs.frame.y + (rhs.frame.height * 0.5)) - sourceCenterY)
if lhsDy != rhsDy { return lhsDy < rhsDy }
let lhsDx = abs(lhs.frame.x - sourceRightX)
let rhsDx = abs(rhs.frame.x - sourceRightX)
if lhsDx != rhsDx { return lhsDx < rhsDx }
if lhs.frame.x != rhs.frame.x { return lhs.frame.x < rhs.frame.x }
return lhs.id < rhs.id
}
for candidate in sorted {
guard let candidateUUID = UUID(uuidString: candidate.id),
candidateUUID != sourcePane.id,
let pane = bonsplitController.allPaneIds.first(where: { $0.id == candidateUUID }) else {
continue
}
return pane
}
}
return nil
}
/// Returns the top-right pane in the current split tree.
/// When a workspace is already split, sidebar PR opens should reuse an existing pane
/// instead of creating additional right splits.
func topRightBrowserReusePane() -> PaneID? {
let paneIds = bonsplitController.allPaneIds
guard paneIds.count > 1 else { return nil }
let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) })
var paneBounds: [String: CGRect] = [:]
browserCollectNormalizedPaneBounds(
node: bonsplitController.treeSnapshot(),
availableRect: CGRect(x: 0, y: 0, width: 1, height: 1),
into: &paneBounds
)
guard !paneBounds.isEmpty else {
return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first
}
let epsilon = 0.000_1
let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0
let sortedCandidates = paneBounds
.filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon }
.sorted { lhs, rhs in
if abs(lhs.value.minY - rhs.value.minY) > epsilon {
return lhs.value.minY < rhs.value.minY
}
if abs(lhs.value.minX - rhs.value.minX) > epsilon {
return lhs.value.minX > rhs.value.minX
}
return lhs.key < rhs.key
}
for candidate in sortedCandidates {
if let pane = paneById[candidate.key] {
return pane
}
}
return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first
}
private enum BrowserPaneBranch {
case first
case second
}
private struct BrowserPaneBreadcrumb {
let split: ExternalSplitNode
let branch: BrowserPaneBranch
}
private func browserPathToPane(targetPaneId: String, node: ExternalTreeNode) -> [BrowserPaneBreadcrumb]? {
switch node {
case .pane(let paneNode):
return paneNode.id == targetPaneId ? [] : nil
case .split(let splitNode):
if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.first) {
path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .first))
return path
}
if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.second) {
path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .second))
return path
}
return nil
}
}
private func browserCollectPaneNodes(node: ExternalTreeNode, into output: inout [ExternalPaneNode]) {
switch node {
case .pane(let paneNode):
output.append(paneNode)
case .split(let splitNode):
browserCollectPaneNodes(node: splitNode.first, into: &output)
browserCollectPaneNodes(node: splitNode.second, into: &output)
}
}
private func browserCollectNormalizedPaneBounds(
node: ExternalTreeNode,
availableRect: CGRect,
into output: inout [String: CGRect]
) {
switch node {
case .pane(let paneNode):
output[paneNode.id] = availableRect
case .split(let splitNode):
let divider = min(max(splitNode.dividerPosition, 0), 1)
let firstRect: CGRect
let secondRect: CGRect
if splitNode.orientation.lowercased() == "vertical" {
// Stacked split: first = top, second = bottom
firstRect = CGRect(
x: availableRect.minX,
y: availableRect.minY,
width: availableRect.width,
height: availableRect.height * divider
)
secondRect = CGRect(
x: availableRect.minX,
y: availableRect.minY + (availableRect.height * divider),
width: availableRect.width,
height: availableRect.height * (1 - divider)
)
} else {
// Side-by-side split: first = left, second = right
firstRect = CGRect(
x: availableRect.minX,
y: availableRect.minY,
width: availableRect.width * divider,
height: availableRect.height
)
secondRect = CGRect(
x: availableRect.minX + (availableRect.width * divider),
y: availableRect.minY,
width: availableRect.width * (1 - divider),
height: availableRect.height
)
}
browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output)
browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output)
}
}
private struct BrowserCloseFallbackPlan {
let orientation: SplitOrientation
let insertFirst: Bool
let anchorPaneId: UUID?
}
private func stageClosedBrowserRestoreSnapshotIfNeeded(for tab: Bonsplit.Tab, inPane pane: PaneID) {
guard let panelId = panelIdFromSurfaceId(tab.id),
let browserPanel = browserPanel(for: panelId),
let tabIndex = bonsplitController.tabs(inPane: pane).firstIndex(where: { $0.id == tab.id }) else {
pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tab.id)
return
}
let fallbackPlan = browserCloseFallbackPlan(
forPaneId: pane.id.uuidString,
in: bonsplitController.treeSnapshot()
)
let resolvedURL = browserPanel.currentURL
?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:))
pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot(
workspaceId: id,
url: resolvedURL,
profileID: browserPanel.profileID,
originalPaneId: pane.id,
originalTabIndex: tabIndex,
fallbackSplitOrientation: fallbackPlan?.orientation,
fallbackSplitInsertFirst: fallbackPlan?.insertFirst ?? false,
fallbackAnchorPaneId: fallbackPlan?.anchorPaneId
)
}
private func clearStagedClosedBrowserRestoreSnapshot(for tabId: TabID) {
pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId)
}
private func browserCloseFallbackPlan(
forPaneId targetPaneId: String,
in node: ExternalTreeNode
) -> BrowserCloseFallbackPlan? {
switch node {
case .pane:
return nil
case .split(let splitNode):
if case .pane(let firstPane) = splitNode.first, firstPane.id == targetPaneId {
return BrowserCloseFallbackPlan(
orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal,
insertFirst: true,
anchorPaneId: browserNearestPaneId(
in: splitNode.second,
targetCenter: browserPaneCenter(firstPane)
)
)
}
if case .pane(let secondPane) = splitNode.second, secondPane.id == targetPaneId {
return BrowserCloseFallbackPlan(
orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal,
insertFirst: false,
anchorPaneId: browserNearestPaneId(
in: splitNode.first,
targetCenter: browserPaneCenter(secondPane)
)
)
}
if let nested = browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.first) {
return nested
}
return browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.second)
}
}
private func browserPaneCenter(_ pane: ExternalPaneNode) -> (x: Double, y: Double) {
(
x: pane.frame.x + (pane.frame.width * 0.5),
y: pane.frame.y + (pane.frame.height * 0.5)
)
}
private func browserNearestPaneId(
in node: ExternalTreeNode,
targetCenter: (x: Double, y: Double)?
) -> UUID? {
var panes: [ExternalPaneNode] = []
browserCollectPaneNodes(node: node, into: &panes)
guard !panes.isEmpty else { return nil }
let bestPane: ExternalPaneNode?
if let targetCenter {
bestPane = panes.min { lhs, rhs in
let lhsCenter = browserPaneCenter(lhs)
let rhsCenter = browserPaneCenter(rhs)
let lhsDistance = pow(lhsCenter.x - targetCenter.x, 2) + pow(lhsCenter.y - targetCenter.y, 2)
let rhsDistance = pow(rhsCenter.x - targetCenter.x, 2) + pow(rhsCenter.y - targetCenter.y, 2)
if lhsDistance != rhsDistance {
return lhsDistance < rhsDistance
}
return lhs.id < rhs.id
}
} else {
bestPane = panes.first
}
guard let bestPane else { return nil }
return UUID(uuidString: bestPane.id)
}
@discardableResult
func moveSurface(panelId: UUID, toPane paneId: PaneID, atIndex index: Int? = nil, focus: Bool = true) -> Bool {
guard let tabId = surfaceIdFromPanelId(panelId) else { return false }
guard bonsplitController.allPaneIds.contains(paneId) else { return false }
guard bonsplitController.moveTab(tabId, toPane: paneId, atIndex: index) else { return false }
if focus {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(tabId)
focusPanel(panelId)
} else {
scheduleFocusReconcile()
}
scheduleTerminalGeometryReconcile()
return true
}
@discardableResult
func reorderSurface(panelId: UUID, toIndex index: Int) -> Bool {
guard let tabId = surfaceIdFromPanelId(panelId) else { return false }
guard bonsplitController.reorderTab(tabId, toIndex: index) else { return false }
if let paneId = paneId(forPanelId: panelId) {
applyTabSelection(tabId: tabId, inPane: paneId)
} else {
scheduleFocusReconcile()
}
scheduleTerminalGeometryReconcile()
return true
}
func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? {
guard let tabId = surfaceIdFromPanelId(panelId) else { return nil }
guard panels[panelId] != nil else { return nil }
#if DEBUG
let detachStart = ProcessInfo.processInfo.systemUptime
dlog(
"split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
"tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " +
"pendingDetached=\(pendingDetachedSurfaces.count)"
)
#endif
detachingTabIds.insert(tabId)
forceCloseTabIds.insert(tabId)
activeDetachCloseTransactions += 1
defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) }
guard bonsplitController.closeTab(tabId) else {
detachingTabIds.remove(tabId)
pendingDetachedSurfaces.removeValue(forKey: tabId)
forceCloseTabIds.remove(tabId)
#if DEBUG
dlog(
"split.detach.fail ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
"tab=\(tabId.uuid.uuidString.prefix(5)) reason=closeTabRejected elapsedMs=\(debugElapsedMs(since: detachStart))"
)
#endif
return nil
}
let detached = pendingDetachedSurfaces.removeValue(forKey: tabId)
#if DEBUG
dlog(
"split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
"tab=\(tabId.uuid.uuidString.prefix(5)) transfer=\(detached != nil ? 1 : 0) " +
"elapsedMs=\(debugElapsedMs(since: detachStart))"
)
#endif
return detached
}
@discardableResult
func attachDetachedSurface(
_ detached: DetachedSurfaceTransfer,
inPane paneId: PaneID,
atIndex index: Int? = nil,
focus: Bool = true
) -> UUID? {
#if DEBUG
let attachStart = ProcessInfo.processInfo.systemUptime
dlog(
"split.attach.begin ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
"pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0)"
)
#endif
guard bonsplitController.allPaneIds.contains(paneId) else {
#if DEBUG
dlog(
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
"reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))"
)
#endif
return nil
}
guard panels[detached.panelId] == nil else {
#if DEBUG
dlog(
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
"reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))"
)
#endif
return nil
}
panels[detached.panelId] = detached.panel
if let terminalPanel = detached.panel as? TerminalPanel {
terminalPanel.updateWorkspaceId(id)
} else if let browserPanel = detached.panel as? BrowserPanel {
browserPanel.reattachToWorkspace(
id,
isRemoteWorkspace: isRemoteWorkspace,
remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil,
proxyEndpoint: remoteProxyEndpoint,
remoteStatus: browserRemoteWorkspaceStatusSnapshot()
)
installBrowserPanelSubscription(browserPanel)
}
if let directory = detached.directory {
panelDirectories[detached.panelId] = directory
}
if let cachedTitle = detached.cachedTitle {
panelTitles[detached.panelId] = cachedTitle
}
if let customTitle = detached.customTitle {
panelCustomTitles[detached.panelId] = customTitle
}
if detached.isPinned {
pinnedPanelIds.insert(detached.panelId)
} else {
pinnedPanelIds.remove(detached.panelId)
}
if detached.manuallyUnread {
manualUnreadPanelIds.insert(detached.panelId)
manualUnreadMarkedAt[detached.panelId] = .distantPast
} else {
manualUnreadPanelIds.remove(detached.panelId)
manualUnreadMarkedAt.removeValue(forKey: detached.panelId)
}
guard let newTabId = bonsplitController.createTab(
title: detached.title,
hasCustomTitle: detached.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
icon: detached.icon,
iconImageData: detached.iconImageData,
kind: detached.kind,
isDirty: detached.panel.isDirty,
isLoading: detached.isLoading,
isPinned: detached.isPinned,
inPane: paneId
) else {
panels.removeValue(forKey: detached.panelId)
panelDirectories.removeValue(forKey: detached.panelId)
panelTitles.removeValue(forKey: detached.panelId)
panelCustomTitles.removeValue(forKey: detached.panelId)
pinnedPanelIds.remove(detached.panelId)
manualUnreadPanelIds.remove(detached.panelId)
manualUnreadMarkedAt.removeValue(forKey: detached.panelId)
panelSubscriptions.removeValue(forKey: detached.panelId)
#if DEBUG
dlog(
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
"reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))"
)
#endif
return nil
}
surfaceIdToPanelId[newTabId] = detached.panelId
if let index {
_ = bonsplitController.reorderTab(newTabId, toIndex: index)
}
syncPinnedStateForTab(newTabId, panelId: detached.panelId)
syncUnreadBadgeStateForPanel(detached.panelId)
normalizePinnedTabs(in: paneId)
if focus {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(newTabId)
detached.panel.focus()
applyTabSelection(tabId: newTabId, inPane: paneId)
} else {
scheduleFocusReconcile()
}
scheduleTerminalGeometryReconcile()
#if DEBUG
dlog(
"split.attach.end ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
"tab=\(newTabId.uuid.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5)) " +
"index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0) " +
"elapsedMs=\(debugElapsedMs(since: attachStart))"
)
#endif
return detached.panelId
}
// MARK: - Focus Management
private func preserveFocusAfterNonFocusSplit(
preferredPanelId: UUID?,
splitPanelId: UUID,
previousHostedView: GhosttySurfaceScrollView?
) {
guard let preferredPanelId, panels[preferredPanelId] != nil else {
clearNonFocusSplitFocusReassert()
scheduleFocusReconcile()
return
}
let generation = beginNonFocusSplitFocusReassert(
preferredPanelId: preferredPanelId,
splitPanelId: splitPanelId
)
// Bonsplit splitPane focuses the newly created pane and may emit one delayed
// didSelect/didFocus callback. Re-assert focus over multiple turns so model
// focus and AppKit first responder stay aligned with non-focus-intent splits.
reassertFocusAfterNonFocusSplit(
generation: generation,
preferredPanelId: preferredPanelId,
splitPanelId: splitPanelId,
previousHostedView: previousHostedView,
allowPreviousHostedView: true
)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.reassertFocusAfterNonFocusSplit(
generation: generation,
preferredPanelId: preferredPanelId,
splitPanelId: splitPanelId,
previousHostedView: previousHostedView,
allowPreviousHostedView: false
)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.reassertFocusAfterNonFocusSplit(
generation: generation,
preferredPanelId: preferredPanelId,
splitPanelId: splitPanelId,
previousHostedView: previousHostedView,
allowPreviousHostedView: false
)
self.scheduleFocusReconcile()
self.clearNonFocusSplitFocusReassert(generation: generation)
}
}
}
private func reassertFocusAfterNonFocusSplit(
generation: UInt64,
preferredPanelId: UUID,
splitPanelId: UUID,
previousHostedView: GhosttySurfaceScrollView?,
allowPreviousHostedView: Bool
) {
guard matchesPendingNonFocusSplitFocusReassert(
generation: generation,
preferredPanelId: preferredPanelId,
splitPanelId: splitPanelId
) else {
return
}
guard panels[preferredPanelId] != nil else {
clearNonFocusSplitFocusReassert(generation: generation)
return
}
if focusedPanelId == splitPanelId {
focusPanel(
preferredPanelId,
previousHostedView: allowPreviousHostedView ? previousHostedView : nil
)
return
}
guard focusedPanelId == preferredPanelId,
let terminalPanel = terminalPanel(for: preferredPanelId) else {
return
}
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId)
}
func focusPanel(
_ panelId: UUID,
previousHostedView: GhosttySurfaceScrollView? = nil,
trigger: FocusPanelTrigger = .standard
) {
markExplicitFocusIntent(on: panelId)
#if DEBUG
let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
let triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard"
dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)")
FocusLogStore.shared.append(
"Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)"
)
#endif
guard let tabId = surfaceIdFromPanelId(panelId) else { return }
let currentlyFocusedPanelId = focusedPanelId
// Capture the currently focused terminal view so we can explicitly move AppKit first
// responder when focusing another terminal (helps avoid "highlighted but typing goes to
// another pane" after heavy split/tab mutations).
// When a caller passes an explicit previousHostedView (e.g. during split creation where
// bonsplit has already mutated focusedPaneId), prefer it over the derived value.
let previousTerminalHostedView = previousHostedView ?? focusedTerminalPanel?.hostedView
// `selectTab` does not necessarily move bonsplit's focused pane. For programmatic focus
// (socket API, notification click, etc.), ensure the target tab's pane becomes focused
// so `focusedPanelId` and follow-on focus logic are coherent.
let targetPaneId = bonsplitController.allPaneIds.first(where: { paneId in
bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId })
})
let selectionAlreadyConverged: Bool = {
guard let targetPaneId else { return false }
return bonsplitController.focusedPaneId == targetPaneId &&
bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId
}()
let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged
#if DEBUG
let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
let selectedTabShort = bonsplitController.focusedPaneId
.flatMap { bonsplitController.selectedTab(inPane: $0)?.id }
.map { String($0.uuid.uuidString.prefix(5)) } ?? "nil"
let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"focus.panel.begin workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " +
"targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " +
"converged=\(selectionAlreadyConverged ? 1 : 0) " +
"currentPanel=\(currentPanelShort)"
)
if shouldSuppressReentrantRefocus {
dlog(
"focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " +
"reason=firstResponderAlreadyConverged"
)
}
#endif
if let targetPaneId, !selectionAlreadyConverged {
#if DEBUG
dlog(
"focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))"
)
#endif
bonsplitController.focusPane(targetPaneId)
}
if !selectionAlreadyConverged {
#if DEBUG
dlog(
"focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))"
)
#endif
bonsplitController.selectTab(tabId)
}
if let targetPaneId {
let activationIntent = panels[panelId]?.preferredFocusIntentForActivation()
applyTabSelection(
tabId: tabId,
inPane: targetPaneId,
reassertAppKitFocus: !shouldSuppressReentrantRefocus,
focusIntent: activationIntent,
previousTerminalHostedView: previousTerminalHostedView
)
}
if let browserPanel = panels[panelId] as? BrowserPanel {
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
}
if trigger == .terminalFirstResponder,
panels[panelId] is TerminalPanel {
beginEventDrivenLayoutFollowUp(
reason: "workspace.focusPanel.terminal",
terminalFocusPanelId: panelId
)
}
}
private func maybeAutoFocusBrowserAddressBarOnPanelFocus(
_ browserPanel: BrowserPanel,
trigger: FocusPanelTrigger
) {
guard trigger == .standard else { return }
guard !isCommandPaletteVisibleForWorkspaceWindow() else { return }
guard !browserPanel.shouldSuppressOmnibarAutofocus() else { return }
guard browserPanel.isShowingNewTabPage || browserPanel.preferredURLStringForOmnibar() == nil else { return }
_ = browserPanel.requestAddressBarFocus()
NotificationCenter.default.post(name: .browserFocusAddressBar, object: browserPanel.id)
}
private func isCommandPaletteVisibleForWorkspaceWindow() -> Bool {
guard let app = AppDelegate.shared else {
return false
}
if let manager = app.tabManagerFor(tabId: id),
let windowId = app.windowId(for: manager),
let window = app.mainWindow(for: windowId),
app.isCommandPaletteVisible(for: window) {
return true
}
if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) {
return true
}
if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) {
return true
}
return false
}
func moveFocus(direction: NavigationDirection) {
// Unfocus the currently-focused panel before navigating.
if let prevPanelId = focusedPanelId, let prev = panels[prevPanelId] {
prev.unfocus()
}
bonsplitController.navigateFocus(direction: direction)
// Always reconcile selection/focus after navigation so AppKit first-responder and
// bonsplit's focused pane stay aligned, even through split tree mutations.
if let paneId = bonsplitController.focusedPaneId,
let tabId = bonsplitController.selectedTab(inPane: paneId)?.id {
applyTabSelection(tabId: tabId, inPane: paneId)
}
}
// MARK: - Surface Navigation
/// Select the next surface in the currently focused pane
func selectNextSurface() {
bonsplitController.selectNextTab()
if let paneId = bonsplitController.focusedPaneId,
let tabId = bonsplitController.selectedTab(inPane: paneId)?.id {
applyTabSelection(tabId: tabId, inPane: paneId)
}
}
/// Select the previous surface in the currently focused pane
func selectPreviousSurface() {
bonsplitController.selectPreviousTab()
if let paneId = bonsplitController.focusedPaneId,
let tabId = bonsplitController.selectedTab(inPane: paneId)?.id {
applyTabSelection(tabId: tabId, inPane: paneId)
}
}
/// Select a surface by index in the currently focused pane
func selectSurface(at index: Int) {
guard let focusedPaneId = bonsplitController.focusedPaneId else { return }
let tabs = bonsplitController.tabs(inPane: focusedPaneId)
guard index >= 0 && index < tabs.count else { return }
bonsplitController.selectTab(tabs[index].id)
if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id {
applyTabSelection(tabId: tabId, inPane: focusedPaneId)
}
}
/// Select the last surface in the currently focused pane
func selectLastSurface() {
guard let focusedPaneId = bonsplitController.focusedPaneId else { return }
let tabs = bonsplitController.tabs(inPane: focusedPaneId)
guard let last = tabs.last else { return }
bonsplitController.selectTab(last.id)
if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id {
applyTabSelection(tabId: tabId, inPane: focusedPaneId)
}
}
/// Create a new terminal surface in the currently focused pane
@discardableResult
func newTerminalSurfaceInFocusedPane(focus: Bool? = nil) -> TerminalPanel? {
guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil }
return newTerminalSurface(inPane: focusedPaneId, focus: focus)
}
@discardableResult
func clearSplitZoom() -> Bool {
bonsplitController.clearPaneZoom()
}
@discardableResult
func toggleSplitZoom(panelId: UUID) -> Bool {
let wasSplitZoomed = bonsplitController.isSplitZoomed
guard let paneId = paneId(forPanelId: panelId) else { return false }
guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false }
focusPanel(panelId)
reconcileTerminalPortalVisibilityForCurrentRenderedLayout()
reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom")
if let browserPanel = browserPanel(for: panelId) {
browserPanel.preparePortalHostReplacementForNextDistinctClaim(
inPane: paneId,
reason: "workspace.toggleSplitZoom"
)
}
beginEventDrivenLayoutFollowUp(
reason: "workspace.toggleSplitZoom",
browserPanelId: browserPanel(for: panelId) != nil ? panelId : nil,
browserExitFocusPanelId: (wasSplitZoomed && !bonsplitController.isSplitZoomed) ? panelId : nil,
includeGeometry: true
)
return true
}
// MARK: - Context Menu Shortcuts
static func buildContextMenuShortcuts() -> [TabContextAction: KeyboardShortcut] {
var shortcuts: [TabContextAction: KeyboardShortcut] = [:]
let mappings: [(TabContextAction, KeyboardShortcutSettings.Action)] = [
(.rename, .renameTab),
(.toggleZoom, .toggleSplitZoom),
(.newTerminalToRight, .newSurface),
]
for (contextAction, settingsAction) in mappings {
let stored = KeyboardShortcutSettings.shortcut(for: settingsAction)
if let key = stored.keyEquivalent {
shortcuts[contextAction] = KeyboardShortcut(key, modifiers: stored.eventModifiers)
}
}
return shortcuts
}
// MARK: - Flash/Notification Support
func triggerFocusFlash(panelId: UUID) {
panels[panelId]?.triggerFlash()
}
func triggerNotificationFocusFlash(
panelId: UUID,
requiresSplit: Bool = false,
shouldFocus: Bool = true
) {
guard let terminalPanel = terminalPanel(for: panelId) else { return }
if shouldFocus {
focusPanel(panelId)
}
let isSplit = bonsplitController.allPaneIds.count > 1 || panels.count > 1
if requiresSplit && !isSplit {
return
}
terminalPanel.triggerFlash()
}
func triggerDebugFlash(panelId: UUID) {
triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: true)
}
// MARK: - Portal Lifecycle
/// Hide all terminal portal views for this workspace.
/// Called before the workspace is unmounted to prevent portal-hosted terminal
/// views from covering browser panes in the newly selected workspace.
func hideAllTerminalPortalViews() {
for panel in panels.values {
guard let terminal = panel as? TerminalPanel else { continue }
terminal.hostedView.setVisibleInUI(false)
TerminalWindowPortalRegistry.hideHostedView(terminal.hostedView)
}
}
func hideAllBrowserPortalViews() {
for panel in panels.values {
guard let browser = panel as? BrowserPanel else { continue }
browser.hideBrowserPortalView(source: "workspaceRetire")
}
}
// MARK: - Utility
/// Create a new terminal panel (used when replacing the last panel)
@discardableResult
func createReplacementTerminalPanel() -> TerminalPanel {
let inheritedConfig = inheritedTerminalConfig(
preferredPanelId: focusedPanelId,
inPane: bonsplitController.focusedPaneId
)
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_TAB,
configTemplate: inheritedConfig,
portOrdinal: portOrdinal
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
// Create tab in bonsplit
if let newTabId = bonsplitController.createTab(
title: newPanel.displayTitle,
icon: newPanel.displayIcon,
kind: SurfaceKind.terminal,
isDirty: newPanel.isDirty,
isPinned: false
) {
surfaceIdToPanelId[newTabId] = newPanel.id
}
return newPanel
}
/// Check if any panel needs close confirmation
func needsConfirmClose() -> Bool {
for (panelId, panel) in panels {
if let terminalPanel = panel as? TerminalPanel,
panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
return true
}
}
return false
}
private func reconcileFocusState() {
guard !isReconcilingFocusState else { return }
isReconcilingFocusState = true
defer { isReconcilingFocusState = false }
// Source of truth: bonsplit focused pane + selected tab.
// AppKit first responder must converge to this model state, not the other way around.
var targetPanelId: UUID?
if let focusedPane = bonsplitController.focusedPaneId,
let focusedTab = bonsplitController.selectedTab(inPane: focusedPane),
let mappedPanelId = panelIdFromSurfaceId(focusedTab.id),
panels[mappedPanelId] != nil {
targetPanelId = mappedPanelId
} else {
for pane in bonsplitController.allPaneIds {
guard let selectedTab = bonsplitController.selectedTab(inPane: pane),
let mappedPanelId = panelIdFromSurfaceId(selectedTab.id),
panels[mappedPanelId] != nil else { continue }
bonsplitController.focusPane(pane)
bonsplitController.selectTab(selectedTab.id)
targetPanelId = mappedPanelId
break
}
}
if targetPanelId == nil, let fallbackPanelId = panels.keys.first {
targetPanelId = fallbackPanelId
if let fallbackTabId = surfaceIdFromPanelId(fallbackPanelId),
let fallbackPane = bonsplitController.allPaneIds.first(where: { paneId in
bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == fallbackTabId })
}) {
bonsplitController.focusPane(fallbackPane)
bonsplitController.selectTab(fallbackTabId)
}
}
guard let targetPanelId, let targetPanel = panels[targetPanelId] else { return }
for (panelId, panel) in panels where panelId != targetPanelId {
panel.unfocus()
}
targetPanel.focus()
if let terminalPanel = targetPanel as? TerminalPanel {
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId)
}
if let dir = panelDirectories[targetPanelId] {
currentDirectory = dir
}
gitBranch = panelGitBranches[targetPanelId]
pullRequest = panelPullRequests[targetPanelId]
}
/// Reconcile focus/first-responder convergence.
/// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first.
private func scheduleFocusReconcile() {
#if DEBUG
if isDetachingCloseTransaction {
debugFocusReconcileScheduledDuringDetachCount += 1
}
#endif
guard !focusReconcileScheduled else { return }
focusReconcileScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.focusReconcileScheduled = false
self.reconcileFocusState()
}
}
private func beginEventDrivenLayoutFollowUp(
reason: String,
browserPanelId: UUID? = nil,
browserExitFocusPanelId: UUID? = nil,
terminalFocusPanelId: UUID? = nil,
includeGeometry: Bool = false
) {
layoutFollowUpReason = reason
if let browserPanelId {
layoutFollowUpBrowserPanelId = browserPanelId
}
if let browserExitFocusPanelId {
layoutFollowUpBrowserExitFocusPanelId = browserExitFocusPanelId
}
if let terminalFocusPanelId {
layoutFollowUpTerminalFocusPanelId = terminalFocusPanelId
}
layoutFollowUpNeedsGeometryPass = layoutFollowUpNeedsGeometryPass || includeGeometry
layoutFollowUpStalledAttemptCount = 0
if layoutFollowUpTimeoutWorkItem == nil {
installLayoutFollowUpObservers()
}
refreshLayoutFollowUpTimeout()
attemptEventDrivenLayoutFollowUp()
}
private func installLayoutFollowUpObservers() {
guard layoutFollowUpTimeoutWorkItem == nil else { return }
let enqueueAttempt: () -> Void = { [weak self] in
self?.scheduleLayoutFollowUpAttempt()
}
layoutFollowUpObservers.append(NotificationCenter.default.addObserver(
forName: NSWindow.didUpdateNotification,
object: nil,
queue: .main
) { _ in
enqueueAttempt()
})
layoutFollowUpObservers.append(NotificationCenter.default.addObserver(
forName: .terminalSurfaceDidBecomeReady,
object: nil,
queue: .main
) { _ in
enqueueAttempt()
})
layoutFollowUpObservers.append(NotificationCenter.default.addObserver(
forName: .terminalSurfaceHostedViewDidMoveToWindow,
object: nil,
queue: .main
) { _ in
enqueueAttempt()
})
layoutFollowUpObservers.append(NotificationCenter.default.addObserver(
forName: .terminalPortalVisibilityDidChange,
object: nil,
queue: .main
) { _ in
enqueueAttempt()
})
layoutFollowUpObservers.append(NotificationCenter.default.addObserver(
forName: .browserPortalRegistryDidChange,
object: nil,
queue: .main
) { _ in
enqueueAttempt()
})
layoutFollowUpObservers.append(NotificationCenter.default.addObserver(
forName: .ghosttyDidBecomeFirstResponderSurface,
object: nil,
queue: .main
) { _ in
enqueueAttempt()
})
layoutFollowUpObservers.append(NotificationCenter.default.addObserver(
forName: .browserDidBecomeFirstResponderWebView,
object: nil,
queue: .main
) { _ in
enqueueAttempt()
})
layoutFollowUpPanelsCancellable = $panels
.map { _ in () }
.sink { _ in
enqueueAttempt()
}
}
private func refreshLayoutFollowUpTimeout() {
layoutFollowUpTimeoutWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.clearLayoutFollowUp()
}
layoutFollowUpTimeoutWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem)
}
private func clearLayoutFollowUp() {
layoutFollowUpTimeoutWorkItem?.cancel()
layoutFollowUpTimeoutWorkItem = nil
layoutFollowUpObservers.forEach { NotificationCenter.default.removeObserver($0) }
layoutFollowUpObservers.removeAll()
layoutFollowUpPanelsCancellable?.cancel()
layoutFollowUpPanelsCancellable = nil
layoutFollowUpReason = nil
layoutFollowUpTerminalFocusPanelId = nil
layoutFollowUpBrowserPanelId = nil
layoutFollowUpBrowserExitFocusPanelId = nil
layoutFollowUpNeedsGeometryPass = false
layoutFollowUpAttemptScheduled = false
layoutFollowUpStalledAttemptCount = 0
}
private func scheduleLayoutFollowUpAttempt() {
guard layoutFollowUpTimeoutWorkItem != nil else { return }
guard !layoutFollowUpAttemptScheduled else { return }
layoutFollowUpAttemptScheduled = true
let delay = layoutFollowUpBackoffDelay()
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else { return }
self.layoutFollowUpAttemptScheduled = false
self.attemptEventDrivenLayoutFollowUp()
}
}
private func layoutFollowUpBackoffDelay() -> TimeInterval {
guard layoutFollowUpStalledAttemptCount > 0 else { return 0 }
let baseDelay: TimeInterval = 0.01
let exponent = min(layoutFollowUpStalledAttemptCount - 1, 5)
return min(0.25, baseDelay * pow(2.0, Double(exponent)))
}
private func flushWorkspaceWindowLayouts() {
for window in NSApp.windows {
window.contentView?.layoutSubtreeIfNeeded()
window.contentView?.displayIfNeeded()
}
}
private func browserPortalAnchorReady(for browserPanel: BrowserPanel) -> Bool {
let anchorView = browserPanel.portalAnchorView
return
anchorView.window != nil &&
anchorView.superview != nil &&
anchorView.bounds.width > 1 &&
anchorView.bounds.height > 1
}
private func browserPortalReady(for browserPanel: BrowserPanel) -> Bool {
browserPortalAnchorReady(for: browserPanel) &&
browserPanel.webView.window != nil &&
browserPanel.webView.superview != nil &&
BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: browserPanel.portalAnchorView)
}
private func browserSplitZoomExitFocusNeedsFollowUp(panelId: UUID) -> Bool {
guard let browserPanel = browserPanel(for: panelId),
let paneId = paneId(forPanelId: panelId),
let tabId = surfaceIdFromPanelId(panelId) else {
return false
}
let selectionConverged =
bonsplitController.focusedPaneId == paneId &&
bonsplitController.selectedTab(inPane: paneId)?.id == tabId
return !selectionConverged || !browserPortalAnchorReady(for: browserPanel)
}
private func terminalFocusNeedsFollowUp() -> Bool {
guard let panelId = layoutFollowUpTerminalFocusPanelId,
let terminalPanel = terminalPanel(for: panelId) else {
return false
}
return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder()
}
private func browserPanelNeedsFollowUp() -> Bool {
guard let panelId = layoutFollowUpBrowserPanelId,
let browserPanel = browserPanel(for: panelId) else {
return false
}
return !browserPortalReady(for: browserPanel)
}
private func attemptEventDrivenLayoutFollowUp() {
guard layoutFollowUpTimeoutWorkItem != nil, !isAttemptingLayoutFollowUp else { return }
isAttemptingLayoutFollowUp = true
defer { isAttemptingLayoutFollowUp = false }
flushWorkspaceWindowLayouts()
let geometryPendingBefore = layoutFollowUpNeedsGeometryPass
let terminalPortalPendingBefore = terminalPortalVisibilityNeedsFollowUp()
let browserVisibilityPendingBefore = browserPortalVisibilityNeedsFollowUp()
let terminalFocusPendingBefore = terminalFocusNeedsFollowUp()
let browserPanelPendingBefore = browserPanelNeedsFollowUp()
let browserExitPendingBefore = layoutFollowUpBrowserExitFocusPanelId != nil
if layoutFollowUpNeedsGeometryPass {
layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass()
}
if let terminalFocusPanelId = layoutFollowUpTerminalFocusPanelId {
if let terminalPanel = terminalPanel(for: terminalFocusPanelId),
focusedPanelId == terminalFocusPanelId {
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: terminalFocusPanelId)
if terminalPanel.hostedView.isSurfaceViewFirstResponder() {
layoutFollowUpTerminalFocusPanelId = nil
}
} else if terminalPanel(for: terminalFocusPanelId) == nil {
layoutFollowUpTerminalFocusPanelId = nil
}
}
reconcileTerminalPortalVisibilityForCurrentRenderedLayout()
let terminalPortalPending = terminalPortalVisibilityNeedsFollowUp()
let reason = layoutFollowUpReason ?? "workspace.layout"
reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason)
let browserVisibilityPending = browserPortalVisibilityNeedsFollowUp()
if let browserPanelId = layoutFollowUpBrowserPanelId {
if let browserPanel = browserPanel(for: browserPanelId) {
let anchorReady = browserPortalAnchorReady(for: browserPanel)
let wasReady = browserPortalReady(for: browserPanel)
if anchorReady && !wasReady {
BrowserWindowPortalRegistry.synchronizeForAnchor(browserPanel.portalAnchorView)
}
let isReady = browserPortalReady(for: browserPanel)
if isReady,
(!wasReady || BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView)?.containerHidden == true) {
BrowserWindowPortalRegistry.refresh(
webView: browserPanel.webView,
reason: reason
)
}
if isReady {
layoutFollowUpBrowserPanelId = nil
}
} else {
layoutFollowUpBrowserPanelId = nil
}
}
if let browserExitFocusPanelId = layoutFollowUpBrowserExitFocusPanelId {
if browserSplitZoomExitFocusNeedsFollowUp(panelId: browserExitFocusPanelId) {
if browserPanel(for: browserExitFocusPanelId) != nil {
focusPanel(browserExitFocusPanelId)
scheduleFocusReconcile()
} else {
layoutFollowUpBrowserExitFocusPanelId = nil
}
} else {
layoutFollowUpBrowserExitFocusPanelId = nil
}
}
let terminalFocusPending = terminalFocusNeedsFollowUp()
let browserPanelPending = browserPanelNeedsFollowUp()
let browserExitPending = layoutFollowUpBrowserExitFocusPanelId != nil
let needsMoreWork =
layoutFollowUpNeedsGeometryPass ||
terminalPortalPending ||
browserVisibilityPending ||
terminalFocusPending ||
browserPanelPending ||
browserExitPending
if !needsMoreWork {
clearLayoutFollowUp()
return
}
let didMakeProgress =
(geometryPendingBefore && !layoutFollowUpNeedsGeometryPass) ||
(terminalPortalPendingBefore && !terminalPortalPending) ||
(browserVisibilityPendingBefore && !browserVisibilityPending) ||
(terminalFocusPendingBefore && !terminalFocusPending) ||
(browserPanelPendingBefore && !browserPanelPending) ||
(browserExitPendingBefore && !browserExitPending)
if didMakeProgress {
layoutFollowUpStalledAttemptCount = 0
scheduleLayoutFollowUpAttempt()
} else {
layoutFollowUpStalledAttemptCount += 1
}
}
/// Reconcile remaining terminal view geometries after split topology changes.
/// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn.
private func reconcileTerminalGeometryPass() -> Bool {
var needsFollowUpPass = false
// Flush pending AppKit layout first so terminal-host bounds reflect latest split topology.
for window in NSApp.windows {
window.contentView?.layoutSubtreeIfNeeded()
}
for panel in panels.values {
guard let terminalPanel = panel as? TerminalPanel else { continue }
let hostedView = terminalPanel.hostedView
let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1
let hasSurface = terminalPanel.surface.surface != nil
let isAttached = hostedView.window != nil && hostedView.superview != nil
// Split close/reparent churn can transiently detach a surviving terminal view.
// Force one SwiftUI representable update so the portal binding reattaches it.
if !isAttached || !hasUsableBounds || !hasSurface {
terminalPanel.requestViewReattach()
needsFollowUpPass = true
}
hostedView.reconcileGeometryNow()
// Re-check surface after reconcileGeometryNow() which can trigger AppKit
// layout and view lifecycle changes that free surfaces (#432).
if terminalPanel.surface.surface != nil {
terminalPanel.surface.forceRefresh()
}
if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds {
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
needsFollowUpPass = true
}
}
return needsFollowUpPass
}
private func scheduleTerminalGeometryReconcile() {
beginEventDrivenLayoutFollowUp(
reason: "workspace.geometry",
includeGeometry: true
)
}
private func renderedVisiblePanelIdsForCurrentLayout() -> Set<UUID> {
let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds
var visiblePanelIds: Set<UUID> = []
for paneId in renderedPaneIds {
let selectedTab = bonsplitController.selectedTab(inPane: paneId) ?? bonsplitController.tabs(inPane: paneId).first
guard let selectedTab,
let panelId = panelIdFromSurfaceId(selectedTab.id),
panels[panelId] != nil else {
continue
}
visiblePanelIds.insert(panelId)
}
if let focusedPanelId,
panels[focusedPanelId] != nil,
let focusedPaneId = paneId(forPanelId: focusedPanelId),
renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) {
visiblePanelIds.insert(focusedPanelId)
}
return visiblePanelIds
}
@discardableResult
private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() -> Bool {
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
var didChange = false
for panel in panels.values {
guard let terminalPanel = panel as? TerminalPanel else { continue }
let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
if terminalPanel.hostedView.debugPortalVisibleInUI != shouldBeVisible {
terminalPanel.hostedView.setVisibleInUI(shouldBeVisible)
didChange = true
}
let shouldBeActive = shouldBeVisible && focusedPanelId == terminalPanel.id
if terminalPanel.hostedView.debugPortalActive != shouldBeActive {
terminalPanel.hostedView.setActive(shouldBeActive)
didChange = true
}
TerminalWindowPortalRegistry.updateEntryVisibility(
for: terminalPanel.hostedView,
visibleInUI: shouldBeVisible
)
}
return didChange
}
private func terminalPortalVisibilityNeedsFollowUp() -> Bool {
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
for panel in panels.values {
guard let terminalPanel = panel as? TerminalPanel else { continue }
let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
let hostedView = terminalPanel.hostedView
if shouldBeVisible {
if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil {
return true
}
} else if !hostedView.isHidden {
return true
}
}
return false
}
@discardableResult
private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) -> Bool {
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
var didChange = false
for panel in panels.values {
guard let browserPanel = panel as? BrowserPanel else { continue }
let shouldBeVisible = visiblePanelIds.contains(browserPanel.id)
let anchorView = browserPanel.portalAnchorView
let snapshot = BrowserWindowPortalRegistry.debugSnapshot(for: browserPanel.webView)
if shouldBeVisible {
if snapshot?.visibleInUI == false {
BrowserWindowPortalRegistry.updateEntryVisibility(
for: browserPanel.webView,
visibleInUI: true,
zPriority: 2
)
didChange = true
}
let anchorReady = browserPortalAnchorReady(for: browserPanel)
let portalReady = browserPortalReady(for: browserPanel)
if anchorReady && !portalReady {
BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView)
if browserPortalReady(for: browserPanel) {
BrowserWindowPortalRegistry.refresh(
webView: browserPanel.webView,
reason: reason
)
didChange = true
}
} else if anchorReady && snapshot?.containerHidden == true {
BrowserWindowPortalRegistry.refresh(
webView: browserPanel.webView,
reason: reason
)
didChange = true
}
} else {
let portalNeedsHide =
snapshot?.visibleInUI == true ||
snapshot?.containerHidden == false
if portalNeedsHide {
if snapshot?.visibleInUI == true {
BrowserWindowPortalRegistry.updateEntryVisibility(
for: browserPanel.webView,
visibleInUI: false,
zPriority: 0
)
}
BrowserWindowPortalRegistry.hide(
webView: browserPanel.webView,
source: reason
)
didChange = true
}
}
}
return didChange
}
private func browserPortalVisibilityNeedsFollowUp() -> Bool {
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
for panel in panels.values {
guard let browserPanel = panel as? BrowserPanel else { continue }
guard visiblePanelIds.contains(browserPanel.id) else { continue }
let anchorView = browserPanel.portalAnchorView
let anchorReady =
anchorView.window != nil &&
anchorView.superview != nil &&
anchorView.bounds.width > 1 &&
anchorView.bounds.height > 1
if !anchorReady ||
browserPanel.webView.window == nil ||
browserPanel.webView.superview == nil ||
!BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: anchorView) {
return true
}
}
return false
}
private func scheduleMovedTerminalRefresh(panelId: UUID) {
guard terminalPanel(for: panelId) != nil else { return }
// Force an NSViewRepresentable update after drag/move reparenting. This keeps
// portal host binding current when a pane auto-closes during tab moves.
terminalPanel(for: panelId)?.requestViewReattach()
let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
guard let self, let panel = self.terminalPanel(for: panelId) else { return }
panel.hostedView.reconcileGeometryNow()
if panel.surface.surface != nil {
panel.surface.forceRefresh()
}
if panel.surface.surface == nil {
panel.surface.requestBackgroundSurfaceStartIfNeeded()
}
}
}
// Run once immediately and once on the next turn so rapid split close/reparent
// sequences still get a post-layout redraw.
runRefreshPass(0)
runRefreshPass(0.03)
}
private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) {
for tabId in tabIds {
if skipPinned,
let panelId = panelIdFromSurfaceId(tabId),
pinnedPanelIds.contains(panelId) {
continue
}
_ = bonsplitController.closeTab(tabId)
}
}
private func tabIdsToLeft(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] {
let tabs = bonsplitController.tabs(inPane: paneId)
guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return [] }
return Array(tabs.prefix(index).map(\.id))
}
private func tabIdsToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] {
let tabs = bonsplitController.tabs(inPane: paneId)
guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }),
index + 1 < tabs.count else { return [] }
return Array(tabs.suffix(from: index + 1).map(\.id))
}
private func tabIdsToCloseOthers(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] {
bonsplitController.tabs(inPane: paneId)
.map(\.id)
.filter { $0 != anchorTabId }
}
private func createTerminalToRight(of anchorTabId: TabID, inPane paneId: PaneID) {
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
guard let newPanel = newTerminalSurface(inPane: paneId, focus: true) else { return }
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
}
private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) {
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
let preferredProfileID = panelIdFromSurfaceId(anchorTabId).flatMap { browserPanel(for: $0)?.profileID }
guard let newPanel = newBrowserSurface(
inPane: paneId,
url: url,
focus: true,
preferredProfileID: preferredProfileID
) else { return }
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
}
private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) {
guard let panelId = panelIdFromSurfaceId(anchorTabId),
let browser = browserPanel(for: panelId) else { return }
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
guard let newPanel = newBrowserSurface(
inPane: paneId,
url: browser.currentURL,
focus: true,
preferredProfileID: browser.profileID
) else { return }
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
}
private func promptRenamePanel(tabId: TabID) {
guard let panelId = panelIdFromSurfaceId(tabId),
let panel = panels[panelId] else { return }
let alert = NSAlert()
alert.messageText = "Rename Tab"
alert.informativeText = "Enter a custom name for this tab."
let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle
let input = NSTextField(string: currentTitle)
input.placeholderString = "Tab name"
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: "Rename")
alert.addButton(withTitle: "Cancel")
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
let response = alert.runModal()
guard response == .alertFirstButtonReturn else { return }
setPanelCustomTitle(panelId: panelId, title: input.stringValue)
}
private enum PanelMoveDestination {
case newWorkspaceInCurrentWindow
case selectedWorkspaceInNewWindow
case existingWorkspace(UUID)
}
private func promptMovePanel(tabId: TabID) {
guard let panelId = panelIdFromSurfaceId(tabId),
let app = AppDelegate.shared else { return }
let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) }
let workspaceTargets = app.workspaceMoveTargets(
excludingWorkspaceId: id,
referenceWindowId: currentWindowId
)
var options: [(title: String, destination: PanelMoveDestination)] = [
("New Workspace in Current Window", .newWorkspaceInCurrentWindow),
("Selected Workspace in New Window", .selectedWorkspaceInNewWindow),
]
options.append(contentsOf: workspaceTargets.map { target in
(target.label, .existingWorkspace(target.workspaceId))
})
let alert = NSAlert()
alert.messageText = "Move Tab"
alert.informativeText = "Choose a destination for this tab."
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false)
for option in options {
popup.addItem(withTitle: option.title)
}
popup.selectItem(at: 0)
alert.accessoryView = popup
alert.addButton(withTitle: "Move")
alert.addButton(withTitle: "Cancel")
guard alert.runModal() == .alertFirstButtonReturn else { return }
let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1))
let destination = options[selectedIndex].destination
let moved: Bool
switch destination {
case .newWorkspaceInCurrentWindow:
guard let manager = app.tabManagerFor(tabId: id) else { return }
let workspace = manager.addWorkspace(select: true)
moved = app.moveSurface(
panelId: panelId,
toWorkspace: workspace.id,
focus: true,
focusWindow: false
)
case .selectedWorkspaceInNewWindow:
let newWindowId = app.createMainWindow()
guard let destinationManager = app.tabManagerFor(windowId: newWindowId),
let destinationWorkspaceId = destinationManager.selectedTabId else {
return
}
moved = app.moveSurface(
panelId: panelId,
toWorkspace: destinationWorkspaceId,
focus: true,
focusWindow: true
)
if !moved {
_ = app.closeMainWindow(windowId: newWindowId)
}
case .existingWorkspace(let workspaceId):
moved = app.moveSurface(
panelId: panelId,
toWorkspace: workspaceId,
focus: true,
focusWindow: true
)
}
if !moved {
let failure = NSAlert()
failure.alertStyle = .warning
failure.messageText = "Move Failed"
failure.informativeText = "cmux could not move this tab to the selected destination."
failure.addButton(withTitle: "OK")
_ = failure.runModal()
}
}
private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool {
guard let app = AppDelegate.shared else { return false }
#if DEBUG
let dropStart = ProcessInfo.processInfo.systemUptime
#endif
let targetPane: PaneID
let targetIndex: Int?
let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)?
#if DEBUG
let destinationLabel: String
#endif
switch request.destination {
case .insert(let paneId, let index):
targetPane = paneId
targetIndex = index
splitTarget = nil
#if DEBUG
destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")"
#endif
case .split(let paneId, let orientation, let insertFirst):
targetPane = paneId
targetIndex = nil
splitTarget = (orientation, insertFirst)
#if DEBUG
destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)"
#endif
}
#if DEBUG
dlog(
"split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
"sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)"
)
#endif
let moved = app.moveBonsplitTab(
tabId: request.tabId.uuid,
toWorkspace: id,
targetPane: targetPane,
targetIndex: targetIndex,
splitTarget: splitTarget,
focus: true,
focusWindow: true
)
#if DEBUG
dlog(
"split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
"moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))"
)
#endif
return moved
}
}
// MARK: - BonsplitDelegate
extension Workspace: BonsplitDelegate {
@MainActor
private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool {
let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager
guard panels.count <= 1,
panelIdFromSurfaceId(tabId) != nil,
let manager,
manager.tabs.contains(where: { $0.id == id }) else {
return false
}
return true
}
@MainActor
private func confirmClosePanel(for tabId: TabID) async -> Bool {
let alert = NSAlert()
alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?")
let panelName: String? = {
guard let panelId = panelIdFromSurfaceId(tabId) else { return nil }
if let custom = panelCustomTitles[panelId], !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return custom
}
if let title = panelTitles[panelId], !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return title
}
if let dir = panelDirectories[panelId], !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return (dir as NSString).lastPathComponent
}
return nil
}()
if let panelName {
alert.informativeText = String(localized: "dialog.closeTab.messageNamed", defaultValue: "This will close \"\(panelName)\".")
} else {
alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.")
}
alert.alertStyle = .warning
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel"))
if let closeButton = alert.buttons.first {
closeButton.keyEquivalent = "\r"
closeButton.keyEquivalentModifierMask = []
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
alert.window.initialFirstResponder = closeButton
}
if let cancelButton = alert.buttons.dropFirst().first {
cancelButton.keyEquivalent = "\u{1b}"
}
// Prefer a sheet if we can find a window, otherwise fall back to modal.
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
return await withCheckedContinuation { continuation in
alert.beginSheetModal(for: window) { response in
continuation.resume(returning: response == .alertFirstButtonReturn)
}
}
}
return alert.runModal() == .alertFirstButtonReturn
}
/// Apply the side-effects of selecting a tab (unfocus others, focus this panel, update state).
/// bonsplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab).
private func applyTabSelection(
tabId: TabID,
inPane pane: PaneID,
reassertAppKitFocus: Bool = true,
focusIntent: PanelFocusIntent? = nil,
previousTerminalHostedView: GhosttySurfaceScrollView? = nil
) {
pendingTabSelection = PendingTabSelectionRequest(
tabId: tabId,
pane: pane,
reassertAppKitFocus: reassertAppKitFocus,
focusIntent: focusIntent,
previousTerminalHostedView: previousTerminalHostedView
)
guard !isApplyingTabSelection else { return }
isApplyingTabSelection = true
defer {
isApplyingTabSelection = false
pendingTabSelection = nil
}
var iterations = 0
while let request = pendingTabSelection {
pendingTabSelection = nil
iterations += 1
if iterations > 8 { break }
applyTabSelectionNow(
tabId: request.tabId,
inPane: request.pane,
reassertAppKitFocus: request.reassertAppKitFocus,
focusIntent: request.focusIntent,
previousTerminalHostedView: request.previousTerminalHostedView
)
}
}
private func applyTabSelectionNow(
tabId: TabID,
inPane pane: PaneID,
reassertAppKitFocus: Bool,
focusIntent: PanelFocusIntent?,
previousTerminalHostedView: GhosttySurfaceScrollView?
) {
let previousFocusedPanelId = focusedPanelId
#if DEBUG
let focusedPaneBefore = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
let selectedTabBefore = bonsplitController.focusedPaneId
.flatMap { bonsplitController.selectedTab(inPane: $0)?.id }
.map { String($0.uuid.uuidString.prefix(5)) } ?? "nil"
dlog(
"focus.split.apply.begin workspace=\(id.uuidString.prefix(5)) " +
"pane=\(pane.id.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5)) " +
"focusedPane=\(focusedPaneBefore) selectedTab=\(selectedTabBefore) " +
"reassert=\(reassertAppKitFocus ? 1 : 0)"
)
#endif
if bonsplitController.allPaneIds.contains(pane) {
if bonsplitController.focusedPaneId != pane {
bonsplitController.focusPane(pane)
}
if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }),
bonsplitController.selectedTab(inPane: pane)?.id != tabId {
bonsplitController.selectTab(tabId)
}
}
let focusedPane: PaneID
let selectedTabId: TabID
if let currentPane = bonsplitController.focusedPaneId,
let currentTabId = bonsplitController.selectedTab(inPane: currentPane)?.id {
focusedPane = currentPane
selectedTabId = currentTabId
} else if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }) {
focusedPane = pane
selectedTabId = tabId
bonsplitController.focusPane(focusedPane)
bonsplitController.selectTab(selectedTabId)
} else {
return
}
// Focus the selected panel, but keep the previously focused terminal active while a
// newly created split terminal is still unattached.
guard let selectedPanelId = panelIdFromSurfaceId(selectedTabId) else {
return
}
let effectiveFocusedPanelId = effectiveSelectedPanelId(inPane: focusedPane) ?? selectedPanelId
guard let panel = panels[effectiveFocusedPanelId] else {
return
}
if debugStressPreloadSelectionDepth > 0 {
if let terminalPanel = panel as? TerminalPanel {
terminalPanel.requestViewReattach()
scheduleTerminalGeometryReconcile()
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
return
}
if shouldTreatCurrentEventAsExplicitFocusIntent() {
markExplicitFocusIntent(on: effectiveFocusedPanelId)
}
let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation()
panel.prepareFocusIntentForActivation(activationIntent)
let panelId = effectiveFocusedPanelId
syncPinnedStateForTab(selectedTabId, panelId: selectedPanelId)
syncUnreadBadgeStateForPanel(selectedPanelId)
// Unfocus all other panels
for (id, p) in panels where id != effectiveFocusedPanelId {
p.unfocus()
}
if let focusWindow = activationWindow(for: panel) {
yieldForeignOwnedFocusIfNeeded(
in: focusWindow,
targetPanelId: panelId,
targetIntent: activationIntent
)
}
activatePanel(
panel,
focusIntent: activationIntent,
reassertAppKitFocus: reassertAppKitFocus
)
let focusIntentAllowsBrowserOmnibarAutofocus =
shouldTreatCurrentEventAsExplicitFocusIntent() ||
TerminalController.socketCommandAllowsInAppFocusMutations()
if let browserPanel = panel as? BrowserPanel,
shouldAllowBrowserOmnibarAutofocus(for: activationIntent),
previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus {
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard)
}
if let terminalPanel = panel as? TerminalPanel {
rememberTerminalConfigInheritanceSource(terminalPanel)
}
let isManuallyUnread = manualUnreadPanelIds.contains(panelId)
let markedAt = manualUnreadMarkedAt[panelId]
if Self.shouldClearManualUnread(
previousFocusedPanelId: previousFocusedPanelId,
nextFocusedPanelId: panelId,
isManuallyUnread: isManuallyUnread,
markedAt: markedAt
) {
triggerFocusFlash(panelId: panelId)
let clearDelay = Self.manualUnreadClearDelayAfterFocusFlash
if clearDelay <= 0 {
clearManualUnread(panelId: panelId)
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + clearDelay) { [weak self] in
self?.clearManualUnread(panelId: panelId)
}
}
}
// Converge AppKit first responder with bonsplit's selected tab in the focused pane.
// Without this, keyboard input can remain on a different terminal than the blue tab indicator.
if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel {
if shouldMoveTerminalSurfaceFocus(for: activationIntent),
!terminalPanel.hostedView.isSurfaceViewFirstResponder() {
#if DEBUG
let previousExists = previousTerminalHostedView != nil ? 1 : 0
dlog(
"focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " +
"to=\(panelId.uuidString.prefix(5))"
)
#endif
terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView)
}
#if DEBUG
dlog(
"focus.split.ensureFocus workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) pane=\(focusedPane.id.uuidString.prefix(5)) " +
"tab=\(selectedTabId.uuid.uuidString.prefix(5)) intent=\(String(describing: activationIntent))"
)
#endif
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId)
}
if shouldRestoreFocusIntentAfterActivation(activationIntent) {
_ = panel.restoreFocusIntent(activationIntent)
}
// Update current directory if this is a terminal
if let dir = panelDirectories[panelId] {
currentDirectory = dir
}
gitBranch = panelGitBranches[panelId]
pullRequest = panelPullRequests[panelId]
// Post notification
NotificationCenter.default.post(
name: .ghosttyDidFocusSurface,
object: nil,
userInfo: [
GhosttyNotificationKey.tabId: self.id,
GhosttyNotificationKey.surfaceId: panelId
]
)
#if DEBUG
let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"focus.split.apply.end workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) type=\(String(describing: type(of: panel))) " +
"focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " +
"prevPanel=\(prevPanelShort)"
)
#endif
}
private func activatePanel(
_ panel: any Panel,
focusIntent: PanelFocusIntent,
reassertAppKitFocus: Bool
) {
if let terminalPanel = panel as? TerminalPanel {
let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent)
terminalPanel.surface.setFocus(shouldFocusTerminalSurface)
terminalPanel.hostedView.setActive(true)
if reassertAppKitFocus && shouldFocusTerminalSurface {
terminalPanel.focus()
}
return
}
if let browserPanel = panel as? BrowserPanel {
guard shouldFocusBrowserWebView(for: focusIntent) else { return }
browserPanel.focus()
return
}
if reassertAppKitFocus {
panel.focus()
}
}
private func activationWindow(for panel: any Panel) -> NSWindow? {
if let terminalPanel = panel as? TerminalPanel {
return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
}
if let browserPanel = panel as? BrowserPanel {
return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
}
return NSApp.keyWindow ?? NSApp.mainWindow
}
private func yieldForeignOwnedFocusIfNeeded(
in window: NSWindow,
targetPanelId: UUID,
targetIntent: PanelFocusIntent
) {
guard let firstResponder = window.firstResponder else { return }
for (panelId, panel) in panels where panelId != targetPanelId {
guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue }
#if DEBUG
dlog(
"focus.handoff.begin workspace=\(id.uuidString.prefix(5)) " +
"fromPanel=\(panelId.uuidString.prefix(5)) toPanel=\(targetPanelId.uuidString.prefix(5)) " +
"fromIntent=\(String(describing: ownedIntent)) toIntent=\(String(describing: targetIntent))"
)
#endif
_ = panel.yieldFocusIntent(ownedIntent, in: window)
return
}
}
private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool {
switch intent {
case .terminal(.findField):
return false
default:
return true
}
}
private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool {
switch intent {
case .browser(.addressBar), .browser(.findField):
return false
default:
return true
}
}
private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool {
switch intent {
case .browser(.webView), .panel:
return true
default:
return false
}
}
private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool {
switch intent {
case .browser(.addressBar), .browser(.findField), .terminal(.findField):
return true
case .panel, .browser(.webView), .terminal(.surface):
return false
}
}
private func beginNonFocusSplitFocusReassert(
preferredPanelId: UUID,
splitPanelId: UUID
) -> UInt64 {
nonFocusSplitFocusReassertGeneration &+= 1
let generation = nonFocusSplitFocusReassertGeneration
pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert(
generation: generation,
preferredPanelId: preferredPanelId,
splitPanelId: splitPanelId
)
return generation
}
private func matchesPendingNonFocusSplitFocusReassert(
generation: UInt64,
preferredPanelId: UUID,
splitPanelId: UUID
) -> Bool {
guard let pending = pendingNonFocusSplitFocusReassert else { return false }
return pending.generation == generation &&
pending.preferredPanelId == preferredPanelId &&
pending.splitPanelId == splitPanelId
}
private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) {
guard let pending = pendingNonFocusSplitFocusReassert else { return }
if let generation, pending.generation != generation { return }
pendingNonFocusSplitFocusReassert = nil
}
private func shouldTreatCurrentEventAsExplicitFocusIntent() -> Bool {
guard let eventType = NSApp.currentEvent?.type else { return false }
switch eventType {
case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp,
.otherMouseDown, .otherMouseUp, .keyDown, .keyUp, .scrollWheel,
.gesture, .magnify, .rotate, .swipe:
return true
default:
return false
}
}
private func markExplicitFocusIntent(on panelId: UUID) {
guard let pending = pendingNonFocusSplitFocusReassert,
pending.splitPanelId == panelId else {
return
}
pendingNonFocusSplitFocusReassert = nil
}
func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool {
func recordPostCloseSelection() {
let tabs = controller.tabs(inPane: pane)
guard let idx = tabs.firstIndex(where: { $0.id == tab.id }) else {
postCloseSelectTabId.removeValue(forKey: tab.id)
return
}
let target: TabID? = {
if idx + 1 < tabs.count { return tabs[idx + 1].id }
if idx > 0 { return tabs[idx - 1].id }
return nil
}()
if let target {
postCloseSelectTabId[tab.id] = target
} else {
postCloseSelectTabId.removeValue(forKey: tab.id)
}
}
let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil
if forceCloseTabIds.contains(tab.id) {
stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
recordPostCloseSelection()
return true
}
if let panelId = panelIdFromSurfaceId(tab.id),
pinnedPanelIds.contains(panelId) {
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
NSSound.beep()
return false
}
if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) {
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
owningTabManager?.closeWorkspaceWithConfirmation(self)
return false
}
// Check if the panel needs close confirmation
guard let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = terminalPanel(for: panelId) else {
stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
recordPostCloseSelection()
return true
}
// If confirmation is required, Bonsplit will call into this delegate and we must return false.
// Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass
// this gating on the second pass.
if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
if pendingCloseConfirmTabIds.contains(tab.id) {
return false
}
pendingCloseConfirmTabIds.insert(tab.id)
let tabId = tab.id
DispatchQueue.main.async { [weak self] in
guard let self else { return }
Task { @MainActor in
defer { self.pendingCloseConfirmTabIds.remove(tabId) }
// If the tab disappeared while we were scheduling, do nothing.
guard self.panelIdFromSurfaceId(tabId) != nil else { return }
let confirmed = await self.confirmClosePanel(for: tabId)
guard confirmed else { return }
self.forceCloseTabIds.insert(tabId)
self.bonsplitController.closeTab(tabId)
}
}
return false
}
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
recordPostCloseSelection()
return true
}
func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) {
forceCloseTabIds.remove(tabId)
let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId)
let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId)
let isDetaching = detachingTabIds.remove(tabId) != nil || isDetachingCloseTransaction
// Clean up our panel
guard let panelId = panelIdFromSurfaceId(tabId) else {
#if DEBUG
NSLog("[Workspace] didCloseTab: no panelId for tabId")
#endif
scheduleTerminalGeometryReconcile()
if !isDetaching {
scheduleFocusReconcile()
}
return
}
#if DEBUG
NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)")
#endif
let panel = panels[panelId]
if isDetaching, let panel {
let browserPanel = panel as? BrowserPanel
let cachedTitle = panelTitles[panelId]
let transferFallbackTitle = cachedTitle ?? panel.displayTitle
pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer(
panelId: panelId,
panel: panel,
title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle),
icon: panel.displayIcon,
iconImageData: browserPanel?.faviconPNGData,
kind: surfaceKind(for: panel),
isLoading: browserPanel?.isLoading ?? false,
isPinned: pinnedPanelIds.contains(panelId),
directory: panelDirectories[panelId],
cachedTitle: cachedTitle,
customTitle: panelCustomTitles[panelId],
manuallyUnread: manualUnreadPanelIds.contains(panelId)
)
} else {
if let closedBrowserRestoreSnapshot {
onClosedBrowserPanel?(closedBrowserRestoreSnapshot)
}
panel?.close()
}
panels.removeValue(forKey: panelId)
untrackRemoteTerminalSurface(panelId)
surfaceIdToPanelId.removeValue(forKey: tabId)
panelDirectories.removeValue(forKey: panelId)
panelGitBranches.removeValue(forKey: panelId)
panelPullRequests.removeValue(forKey: panelId)
panelTitles.removeValue(forKey: panelId)
panelCustomTitles.removeValue(forKey: panelId)
pinnedPanelIds.remove(panelId)
manualUnreadPanelIds.remove(panelId)
manualUnreadMarkedAt.removeValue(forKey: panelId)
panelSubscriptions.removeValue(forKey: panelId)
panelShellActivityStates.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId)
if lastTerminalConfigInheritancePanelId == panelId {
lastTerminalConfigInheritancePanelId = nil
}
clearRemoteConfigurationIfWorkspaceBecameLocal()
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId)
// Keep the workspace invariant for normal close paths.
// Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can
// prune the source workspace/window after the tab is attached elsewhere.
if panels.isEmpty {
if isDetaching {
scheduleTerminalGeometryReconcile()
return
}
let replacement = createReplacementTerminalPanel()
if let replacementTabId = surfaceIdFromPanelId(replacement.id),
let replacementPane = bonsplitController.allPaneIds.first {
bonsplitController.focusPane(replacementPane)
bonsplitController.selectTab(replacementTabId)
applyTabSelection(tabId: replacementTabId, inPane: replacementPane)
}
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
return
}
if let selectTabId,
bonsplitController.allPaneIds.contains(pane),
bonsplitController.tabs(inPane: pane).contains(where: { $0.id == selectTabId }),
bonsplitController.focusedPaneId == pane {
// Keep selection/focus convergence in the same close transaction to avoid a transient
// frame where the pane has no selected content.
bonsplitController.selectTab(selectTabId)
applyTabSelection(tabId: selectTabId, inPane: pane)
} else if let focusedPane = bonsplitController.focusedPaneId,
let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id {
// When closing the last tab in a pane, Bonsplit may focus a different pane and skip
// emitting didSelectTab. Re-apply the focused selection so sidebar state stays in sync.
applyTabSelection(tabId: focusedTabId, inPane: focusedPane)
}
if bonsplitController.allPaneIds.contains(pane) {
normalizePinnedTabs(in: pane)
}
scheduleTerminalGeometryReconcile()
if !isDetaching {
scheduleFocusReconcile()
}
}
func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) {
applyTabSelection(tabId: tab.id, inPane: pane)
}
func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) {
#if DEBUG
let now = ProcessInfo.processInfo.systemUptime
let sincePrev: String
if debugLastDidMoveTabTimestamp > 0 {
sincePrev = String(format: "%.2f", (now - debugLastDidMoveTabTimestamp) * 1000)
} else {
sincePrev = "first"
}
debugLastDidMoveTabTimestamp = now
debugDidMoveTabEventCount += 1
let movedPanelId = panelIdFromSurfaceId(tab.id)
let movedPanel = movedPanelId?.uuidString.prefix(5) ?? "unknown"
let selectedBefore = controller.selectedTab(inPane: destination)
.map { String(String(describing: $0.id).prefix(5)) } ?? "nil"
let focusedPaneBefore = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
let focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil"
dlog(
"split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " +
"from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " +
"sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)"
)
dlog(
"split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " +
"destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)"
)
#endif
applyTabSelection(tabId: tab.id, inPane: destination)
#if DEBUG
let movedPanelIdAfter = panelIdFromSurfaceId(tab.id)
#endif
if let movedPanelId = panelIdFromSurfaceId(tab.id) {
scheduleMovedTerminalRefresh(panelId: movedPanelId)
}
#if DEBUG
let selectedAfter = controller.selectedTab(inPane: destination)
.map { String(String(describing: $0.id).prefix(5)) } ?? "nil"
let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil"
let movedPanelFocused = (movedPanelIdAfter != nil && movedPanelIdAfter == focusedPanelId) ? 1 : 0
dlog(
"split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " +
"destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " +
"movedFocused=\(movedPanelFocused)"
)
#endif
normalizePinnedTabs(in: source)
normalizePinnedTabs(in: destination)
scheduleTerminalGeometryReconcile()
if !isDetachingCloseTransaction {
scheduleFocusReconcile()
}
}
func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) {
// When a pane is focused, focus its selected tab's panel
guard let tab = controller.selectedTab(inPane: pane) else { return }
#if DEBUG
FocusLogStore.shared.append(
"Workspace.didFocusPane paneId=\(pane.id.uuidString) tabId=\(tab.id) focusedPane=\(controller.focusedPaneId?.id.uuidString ?? "nil")"
)
#endif
applyTabSelection(tabId: tab.id, inPane: pane)
// Apply window background for terminal
if let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = panels[panelId] as? TerminalPanel {
terminalPanel.applyWindowBackgroundIfActive()
}
}
func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) {
let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? []
let shouldScheduleFocusReconcile = !isDetachingCloseTransaction
if !closedPanelIds.isEmpty {
for panelId in closedPanelIds {
panels[panelId]?.close()
panels.removeValue(forKey: panelId)
untrackRemoteTerminalSurface(panelId)
panelDirectories.removeValue(forKey: panelId)
panelGitBranches.removeValue(forKey: panelId)
panelPullRequests.removeValue(forKey: panelId)
panelTitles.removeValue(forKey: panelId)
panelCustomTitles.removeValue(forKey: panelId)
pinnedPanelIds.remove(panelId)
manualUnreadPanelIds.remove(panelId)
panelSubscriptions.removeValue(forKey: panelId)
panelShellActivityStates.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
surfaceListeningPorts.removeValue(forKey: panelId)
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
}
let closedSet = Set(closedPanelIds)
surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) }
recomputeListeningPorts()
clearRemoteConfigurationIfWorkspaceBecameLocal()
if let focusedPane = bonsplitController.focusedPaneId,
let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id {
applyTabSelection(tabId: focusedTabId, inPane: focusedPane)
} else if shouldScheduleFocusReconcile {
scheduleFocusReconcile()
}
}
scheduleTerminalGeometryReconcile()
if shouldScheduleFocusReconcile {
scheduleFocusReconcile()
}
}
func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool {
// Check if any panel in this pane needs close confirmation
let tabs = controller.tabs(inPane: pane)
for tab in tabs {
if forceCloseTabIds.contains(tab.id) { continue }
if let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = terminalPanel(for: panelId),
panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
pendingPaneClosePanelIds.removeValue(forKey: pane.id)
return false
}
}
pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) }
return true
}
func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) {
#if DEBUG
let panelKindForTab: (TabID) -> String = { tabId in
guard let panelId = self.panelIdFromSurfaceId(tabId),
let panel = self.panels[panelId] else { return "placeholder" }
if panel is TerminalPanel { return "terminal" }
if panel is BrowserPanel { return "browser" }
return String(describing: type(of: panel))
}
let paneKindSummary: (PaneID) -> String = { paneId in
let tabs = controller.tabs(inPane: paneId)
guard !tabs.isEmpty else { return "-" }
return tabs.map { tab in
String(panelKindForTab(tab.id).prefix(1))
}.joined(separator: ",")
}
let originalSelectedKind = controller.selectedTab(inPane: originalPane).map { panelKindForTab($0.id) } ?? "none"
let newSelectedKind = controller.selectedTab(inPane: newPane).map { panelKindForTab($0.id) } ?? "none"
dlog(
"split.didSplit original=\(originalPane.id.uuidString.prefix(5)) new=\(newPane.id.uuidString.prefix(5)) " +
"orientation=\(orientation) programmatic=\(isProgrammaticSplit ? 1 : 0) " +
"originalTabs=\(controller.tabs(inPane: originalPane).count) newTabs=\(controller.tabs(inPane: newPane).count) " +
"originalSelected=\(originalSelectedKind) newSelected=\(newSelectedKind) " +
"originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]"
)
#endif
let rearmBrowserPortalHostReplacement: (PaneID, String) -> Void = { paneId, reason in
for tab in controller.tabs(inPane: paneId) {
guard let panelId = self.panelIdFromSurfaceId(tab.id),
let browserPanel = self.browserPanel(for: panelId) else {
continue
}
browserPanel.preparePortalHostReplacementForNextDistinctClaim(
inPane: paneId,
reason: reason
)
}
}
rearmBrowserPortalHostReplacement(originalPane, "workspace.didSplit.original")
rearmBrowserPortalHostReplacement(newPane, "workspace.didSplit.new")
// Only auto-create a terminal if the split came from bonsplit UI.
// Programmatic splits via newTerminalSplit() set isProgrammaticSplit and handle their own panels.
guard !isProgrammaticSplit else {
normalizePinnedTabs(in: originalPane)
normalizePinnedTabs(in: newPane)
scheduleTerminalGeometryReconcile()
return
}
// If the new pane already has a tab, this split moved an existing tab (drag-to-split).
//
// In the "drag the only tab to split edge" case, bonsplit inserts a placeholder "Empty"
// tab in the source pane to avoid leaving it tabless. In cmux, this is undesirable:
// it creates a pane with no real surfaces and leaves an "Empty" tab in the tab bar.
//
// Replace placeholder-only source panes with a real terminal surface, then drop the
// placeholder tabs so the UI stays consistent and pane lists don't contain empties.
if !controller.tabs(inPane: newPane).isEmpty {
let originalTabs = controller.tabs(inPane: originalPane)
let hasRealSurface = originalTabs.contains { panelIdFromSurfaceId($0.id) != nil }
#if DEBUG
dlog(
"split.didSplit.drag original=\(originalPane.id.uuidString.prefix(5)) " +
"new=\(newPane.id.uuidString.prefix(5)) originalTabs=\(originalTabs.count) " +
"newTabs=\(controller.tabs(inPane: newPane).count) hasRealSurface=\(hasRealSurface ? 1 : 0) " +
"originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]"
)
#endif
if !hasRealSurface {
let placeholderTabs = originalTabs.filter { panelIdFromSurfaceId($0.id) == nil }
#if DEBUG
dlog(
"split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " +
"action=reusePlaceholder placeholderCount=\(placeholderTabs.count)"
)
#endif
if let replacementTab = placeholderTabs.first {
// Keep the existing placeholder tab identity and replace only the panel mapping.
// This avoids an extra create+close tab churn that can transiently render an
// empty pane during drag-to-split of a single-tab pane.
let inheritedConfig = inheritedTerminalConfig(inPane: originalPane)
let replacementPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig,
portOrdinal: portOrdinal
)
panels[replacementPanel.id] = replacementPanel
panelTitles[replacementPanel.id] = replacementPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig)
surfaceIdToPanelId[replacementTab.id] = replacementPanel.id
bonsplitController.updateTab(
replacementTab.id,
title: replacementPanel.displayTitle,
icon: .some(replacementPanel.displayIcon),
iconImageData: .some(nil),
kind: .some(SurfaceKind.terminal),
hasCustomTitle: false,
isDirty: replacementPanel.isDirty,
showsNotificationBadge: false,
isLoading: false,
isPinned: false
)
for extraPlaceholder in placeholderTabs.dropFirst() {
bonsplitController.closeTab(extraPlaceholder.id)
}
} else {
#if DEBUG
dlog(
"split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " +
"fallback=createTerminalAndDropPlaceholders"
)
#endif
_ = newTerminalSurface(inPane: originalPane, focus: false)
for tab in controller.tabs(inPane: originalPane) {
if panelIdFromSurfaceId(tab.id) == nil {
bonsplitController.closeTab(tab.id)
}
}
}
}
normalizePinnedTabs(in: originalPane)
normalizePinnedTabs(in: newPane)
scheduleTerminalGeometryReconcile()
return
}
// Mirror Cmd+D behavior: split buttons should always seed a terminal in the new pane.
// When the focused source is a browser, inherit terminal config from nearby terminals
// (or fall back to defaults) instead of leaving an empty selector pane.
let sourceTabId = controller.selectedTab(inPane: originalPane)?.id
let sourcePanelId = sourceTabId.flatMap { panelIdFromSurfaceId($0) }
#if DEBUG
dlog(
"split.didSplit.autoCreate pane=\(newPane.id.uuidString.prefix(5)) " +
"fromPane=\(originalPane.id.uuidString.prefix(5)) sourcePanel=\(sourcePanelId.map { String($0.uuidString.prefix(5)) } ?? "none")"
)
#endif
let inheritedConfig = inheritedTerminalConfig(
preferredPanelId: sourcePanelId,
inPane: originalPane
)
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig,
portOrdinal: portOrdinal
)
panels[newPanel.id] = newPanel
panelTitles[newPanel.id] = newPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
guard let newTabId = bonsplitController.createTab(
title: newPanel.displayTitle,
icon: newPanel.displayIcon,
kind: SurfaceKind.terminal,
isDirty: newPanel.isDirty,
isPinned: false,
inPane: newPane
) else {
panels.removeValue(forKey: newPanel.id)
panelTitles.removeValue(forKey: newPanel.id)
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
return
}
surfaceIdToPanelId[newTabId] = newPanel.id
normalizePinnedTabs(in: newPane)
#if DEBUG
dlog(
"split.didSplit.autoCreate.done pane=\(newPane.id.uuidString.prefix(5)) " +
"panel=\(newPanel.id.uuidString.prefix(5))"
)
#endif
// `createTab` selects the new tab but does not emit didSelectTab; schedule an explicit
// selection so our focus/unfocus logic runs after this delegate callback returns.
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if self.bonsplitController.focusedPaneId == newPane {
self.bonsplitController.selectTab(newTabId)
}
self.scheduleTerminalGeometryReconcile()
self.scheduleFocusReconcile()
}
}
func splitTabBar(_ controller: BonsplitController, didRequestNewTab kind: String, inPane pane: PaneID) {
switch kind {
case "terminal":
_ = newTerminalSurface(inPane: pane)
case "browser":
_ = newBrowserSurface(inPane: pane)
default:
_ = newTerminalSurface(inPane: pane)
}
}
func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) {
switch action {
case .rename:
promptRenamePanel(tabId: tab.id)
case .clearName:
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
setPanelCustomTitle(panelId: panelId, title: nil)
case .closeToLeft:
closeTabs(tabIdsToLeft(of: tab.id, inPane: pane))
case .closeToRight:
closeTabs(tabIdsToRight(of: tab.id, inPane: pane))
case .closeOthers:
closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane))
case .move:
promptMovePanel(tabId: tab.id)
case .newTerminalToRight:
createTerminalToRight(of: tab.id, inPane: pane)
case .newBrowserToRight:
createBrowserToRight(of: tab.id, inPane: pane)
case .reload:
guard let panelId = panelIdFromSurfaceId(tab.id),
let browser = browserPanel(for: panelId) else { return }
browser.reload()
case .duplicate:
duplicateBrowserToRight(anchorTabId: tab.id, inPane: pane)
case .togglePin:
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
let shouldPin = !pinnedPanelIds.contains(panelId)
setPanelPinned(panelId: panelId, pinned: shouldPin)
case .markAsRead:
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
clearManualUnread(panelId: panelId)
case .markAsUnread:
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
markPanelUnread(panelId)
case .toggleZoom:
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
toggleSplitZoom(panelId: panelId)
@unknown default:
break
}
}
func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) {
_ = snapshot
scheduleTerminalGeometryReconcile()
if !isDetachingCloseTransaction {
scheduleFocusReconcile()
}
}
// No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups.
}