* Add regression test for detached remote cleanup * Skip SSH cleanup after remote surface transfer * Add SSH lifecycle regression coverage * Add SSH detach cleanup transfer regressions * Transfer SSH cleanup ownership with detached remote terminals * Document intentional SSH workspace focus --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
10741 lines
426 KiB
Swift
10741 lines
426 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))"
|
|
}
|
|
}
|
|
|
|
private func cmuxPointerAppearsLive(_ pointer: UnsafeMutableRawPointer?) -> Bool {
|
|
guard let pointer,
|
|
malloc_zone_from_ptr(pointer) != nil else {
|
|
return false
|
|
}
|
|
return malloc_size(pointer) > 0
|
|
}
|
|
|
|
func cmuxSurfacePointerAppearsLive(_ surface: ghostty_surface_t) -> Bool {
|
|
// Best-effort check: reject pointers that no longer belong to an active
|
|
// malloc zone allocation. A Swift wrapper around `ghostty_surface_t` can
|
|
// remain non-nil after the backing native surface has already been freed.
|
|
cmuxPointerAppearsLive(surface)
|
|
}
|
|
|
|
func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? {
|
|
guard cmuxSurfacePointerAppearsLive(surface) else {
|
|
return nil
|
|
}
|
|
|
|
guard let quicklookFont = ghostty_surface_quicklook_font(surface) else {
|
|
return nil
|
|
}
|
|
|
|
// Best-effort check: reject unretained font pointers that no longer belong
|
|
// to a live malloc allocation. This does not prove the object is still a
|
|
// valid CTFont, but it filters out the common fully-freed/unmapped cases
|
|
// that previously crashed on Intel Macs (#1496, #1870).
|
|
guard cmuxPointerAppearsLive(quicklookFont) 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
|
|
}
|
|
|
|
private enum RemoteDropUploadError: LocalizedError {
|
|
case unavailable
|
|
case invalidFileURL
|
|
case uploadFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .unavailable:
|
|
String(
|
|
localized: "error.remoteDrop.unavailable",
|
|
defaultValue: "Remote drop is unavailable."
|
|
)
|
|
case .invalidFileURL:
|
|
String(
|
|
localized: "error.remoteDrop.invalidFileURL",
|
|
defaultValue: "Dropped item is not a file URL."
|
|
)
|
|
case .uploadFailed(let detail):
|
|
String.localizedStringWithFormat(
|
|
String(
|
|
localized: "error.remoteDrop.uploadFailed",
|
|
defaultValue: "Failed to upload dropped file: %@"
|
|
),
|
|
detail
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct WorkspaceRemoteDaemonManifest: Decodable, Equatable {
|
|
struct Entry: Decodable, Equatable {
|
|
let goOS: String
|
|
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:
|
|
guard let browserPanel = newBrowserSurface(
|
|
inPane: paneId,
|
|
url: nil,
|
|
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) {
|
|
let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom)))
|
|
if pageZoom.isFinite {
|
|
_ = browserPanel.setPageZoomFactor(pageZoom)
|
|
}
|
|
|
|
browserPanel.restoreSessionSnapshot(browserSnapshot)
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - cmux.json custom layout
|
|
|
|
extension Workspace {
|
|
|
|
func applyCustomLayout(_ layout: CmuxLayoutNode, baseCwd: String) {
|
|
guard let rootPaneId = bonsplitController.allPaneIds.first else { return }
|
|
|
|
var leaves: [(paneId: PaneID, surfaces: [CmuxSurfaceDefinition])] = []
|
|
buildCustomLayoutTree(layout, inPane: rootPaneId, leaves: &leaves)
|
|
|
|
// First leaf reuses the initial terminal created by addWorkspace;
|
|
// subsequent leaves were created via newTerminalSplit which also seeds
|
|
// a placeholder terminal.
|
|
var focusPanelId: UUID?
|
|
for leaf in leaves {
|
|
populateCustomPane(leaf.paneId, surfaces: leaf.surfaces, baseCwd: baseCwd, focusPanelId: &focusPanelId)
|
|
}
|
|
|
|
let liveRoot = bonsplitController.treeSnapshot()
|
|
applyCustomDividerPositions(configNode: layout, liveNode: liveRoot)
|
|
|
|
if let focusPanelId {
|
|
focusPanel(focusPanelId)
|
|
}
|
|
}
|
|
|
|
private func buildCustomLayoutTree(
|
|
_ node: CmuxLayoutNode,
|
|
inPane paneId: PaneID,
|
|
leaves: inout [(paneId: PaneID, surfaces: [CmuxSurfaceDefinition])]
|
|
) {
|
|
switch node {
|
|
case .pane(let pane):
|
|
leaves.append((paneId: paneId, surfaces: pane.surfaces))
|
|
|
|
case .split(let split):
|
|
guard split.children.count == 2 else {
|
|
NSLog("[CmuxConfig] split node requires exactly 2 children, got %d", split.children.count)
|
|
leaves.append((paneId: paneId, surfaces: []))
|
|
return
|
|
}
|
|
|
|
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.splitOrientation,
|
|
insertFirst: false,
|
|
focus: false
|
|
),
|
|
let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else {
|
|
leaves.append((paneId: paneId, surfaces: []))
|
|
return
|
|
}
|
|
|
|
buildCustomLayoutTree(split.children[0], inPane: paneId, leaves: &leaves)
|
|
buildCustomLayoutTree(split.children[1], inPane: secondPaneId, leaves: &leaves)
|
|
}
|
|
}
|
|
|
|
private func populateCustomPane(
|
|
_ paneId: PaneID,
|
|
surfaces: [CmuxSurfaceDefinition],
|
|
baseCwd: String,
|
|
focusPanelId: inout UUID?
|
|
) {
|
|
let existingPanelIds = bonsplitController
|
|
.tabs(inPane: paneId)
|
|
.compactMap { panelIdFromSurfaceId($0.id) }
|
|
|
|
guard !surfaces.isEmpty else { return }
|
|
|
|
let firstSurface = surfaces[0]
|
|
if let placeholderPanelId = existingPanelIds.first {
|
|
configureExistingSurface(
|
|
panelId: placeholderPanelId,
|
|
inPane: paneId,
|
|
surface: firstSurface,
|
|
baseCwd: baseCwd,
|
|
focusPanelId: &focusPanelId
|
|
)
|
|
}
|
|
|
|
for surfaceIndex in 1..<surfaces.count {
|
|
createNewSurface(
|
|
inPane: paneId,
|
|
surface: surfaces[surfaceIndex],
|
|
baseCwd: baseCwd,
|
|
focusPanelId: &focusPanelId
|
|
)
|
|
}
|
|
}
|
|
|
|
private func configureExistingSurface(
|
|
panelId: UUID,
|
|
inPane paneId: PaneID,
|
|
surface: CmuxSurfaceDefinition,
|
|
baseCwd: String,
|
|
focusPanelId: inout UUID?
|
|
) {
|
|
switch surface.type {
|
|
case .terminal where surface.cwd != nil || surface.env != nil:
|
|
// Placeholder can't change cwd/env — replace it
|
|
let resolvedCwd = CmuxConfigStore.resolveCwd(surface.cwd, relativeTo: baseCwd)
|
|
if let panel = newTerminalSurface(
|
|
inPane: paneId,
|
|
focus: false,
|
|
workingDirectory: resolvedCwd,
|
|
startupEnvironment: surface.env ?? [:]
|
|
) {
|
|
_ = closePanel(panelId, force: true)
|
|
if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) }
|
|
if surface.focus == true { focusPanelId = panel.id }
|
|
if let command = surface.command { sendInputWhenReady(command + "\n", to: panel) }
|
|
}
|
|
|
|
case .terminal:
|
|
if let name = surface.name { setPanelCustomTitle(panelId: panelId, title: name) }
|
|
if surface.focus == true { focusPanelId = panelId }
|
|
if let command = surface.command, let terminal = terminalPanel(for: panelId) {
|
|
sendInputWhenReady(command + "\n", to: terminal)
|
|
}
|
|
|
|
case .browser:
|
|
let url = surface.url.flatMap { URL(string: $0) }
|
|
if let panel = newBrowserSurface(inPane: paneId, url: url, focus: false) {
|
|
_ = closePanel(panelId, force: true)
|
|
if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) }
|
|
if surface.focus == true { focusPanelId = panel.id }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func createNewSurface(
|
|
inPane paneId: PaneID,
|
|
surface: CmuxSurfaceDefinition,
|
|
baseCwd: String,
|
|
focusPanelId: inout UUID?
|
|
) {
|
|
switch surface.type {
|
|
case .terminal:
|
|
let resolvedCwd = CmuxConfigStore.resolveCwd(surface.cwd, relativeTo: baseCwd)
|
|
if let panel = newTerminalSurface(
|
|
inPane: paneId,
|
|
focus: false,
|
|
workingDirectory: resolvedCwd,
|
|
startupEnvironment: surface.env ?? [:]
|
|
) {
|
|
if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) }
|
|
if surface.focus == true { focusPanelId = panel.id }
|
|
if let command = surface.command { sendInputWhenReady(command + "\n", to: panel) }
|
|
}
|
|
|
|
case .browser:
|
|
let url = surface.url.flatMap { URL(string: $0) }
|
|
if let panel = newBrowserSurface(inPane: paneId, url: url, focus: false) {
|
|
if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) }
|
|
if surface.focus == true { focusPanelId = panel.id }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applyCustomDividerPositions(
|
|
configNode: CmuxLayoutNode,
|
|
liveNode: ExternalTreeNode
|
|
) {
|
|
switch (configNode, liveNode) {
|
|
case (.split(let configSplit), .split(let liveSplit)):
|
|
if let splitID = UUID(uuidString: liveSplit.id) {
|
|
_ = bonsplitController.setDividerPosition(
|
|
CGFloat(configSplit.clampedSplitPosition),
|
|
forSplit: splitID,
|
|
fromExternal: true
|
|
)
|
|
}
|
|
if configSplit.children.count == 2 {
|
|
applyCustomDividerPositions(configNode: configSplit.children[0], liveNode: liveSplit.first)
|
|
applyCustomDividerPositions(configNode: configSplit.children[1], liveNode: liveSplit.second)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func sendInputWhenReady(_ text: String, to panel: TerminalPanel) {
|
|
if panel.surface.surface != nil {
|
|
panel.sendInput(text)
|
|
return
|
|
}
|
|
|
|
var resolved = false
|
|
var observer: NSObjectProtocol?
|
|
|
|
observer = NotificationCenter.default.addObserver(
|
|
forName: .terminalSurfaceDidBecomeReady,
|
|
object: panel.surface,
|
|
queue: .main
|
|
) { [weak panel] _ in
|
|
guard !resolved, let panel else { return }
|
|
resolved = true
|
|
if let observer { NotificationCenter.default.removeObserver(observer) }
|
|
panel.sendInput(text)
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
|
guard !resolved else { return }
|
|
resolved = true
|
|
if let observer { NotificationCenter.default.removeObserver(observer) }
|
|
NSLog("[CmuxConfig] surface not ready after 3s, dropping command (%d chars)", text.count)
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
func uploadDroppedFiles(
|
|
_ fileURLs: [URL],
|
|
operation: TerminalImageTransferOperation,
|
|
completion: @escaping (Result<[String], Error>) -> Void
|
|
) {
|
|
queue.async { [weak self] in
|
|
guard let self else {
|
|
DispatchQueue.main.async {
|
|
completion(.failure(RemoteDropUploadError.unavailable))
|
|
}
|
|
return
|
|
}
|
|
|
|
do {
|
|
try operation.throwIfCancelled()
|
|
let remotePaths = try self.uploadDroppedFilesLocked(fileURLs, operation: operation)
|
|
try operation.throwIfCancelled()
|
|
DispatchQueue.main.async { [weak self] in
|
|
if operation.isCancelled {
|
|
guard let self else {
|
|
completion(.failure(TerminalImageTransferExecutionError.cancelled))
|
|
return
|
|
}
|
|
self.queue.async { [weak self] in
|
|
self?.cleanupUploadedRemotePaths(remotePaths)
|
|
DispatchQueue.main.async {
|
|
completion(.failure(TerminalImageTransferExecutionError.cancelled))
|
|
}
|
|
}
|
|
} else {
|
|
completion(.success(remotePaths))
|
|
}
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
completion(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func uploadDroppedFiles(
|
|
_ fileURLs: [URL],
|
|
completion: @escaping (Result<[String], Error>) -> Void
|
|
) {
|
|
uploadDroppedFiles(
|
|
fileURLs,
|
|
operation: TerminalImageTransferOperation(),
|
|
completion: completion
|
|
)
|
|
}
|
|
|
|
private func stopAllLocked() {
|
|
debugLog("remote.session.stop \(debugConfigSummary())")
|
|
isStopping = true
|
|
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,
|
|
operation: TerminalImageTransferOperation? = nil
|
|
) throws -> CommandResult {
|
|
try runProcess(
|
|
executable: "/usr/bin/scp",
|
|
arguments: arguments,
|
|
stdin: nil,
|
|
timeout: timeout,
|
|
operation: operation
|
|
)
|
|
}
|
|
|
|
private func runProcess(
|
|
executable: String,
|
|
arguments: [String],
|
|
environment: [String: String]? = nil,
|
|
currentDirectory: URL? = nil,
|
|
stdin: Data?,
|
|
timeout: TimeInterval,
|
|
operation: TerminalImageTransferOperation? = nil
|
|
) 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 operation?.throwIfCancelled()
|
|
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()
|
|
operation?.installCancellationHandler {
|
|
if process.isRunning {
|
|
process.terminate()
|
|
}
|
|
}
|
|
defer { operation?.clearCancellationHandler() }
|
|
|
|
if let stdin, let pipe = process.standardInput as? Pipe {
|
|
pipe.fileHandleForWriting.write(stdin)
|
|
try? pipe.fileHandleForWriting.close()
|
|
}
|
|
|
|
func terminateProcessAndWait() {
|
|
process.terminate()
|
|
let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success
|
|
if !terminatedGracefully, process.isRunning {
|
|
_ = Darwin.kill(process.processIdentifier, SIGKILL)
|
|
process.waitUntilExit()
|
|
}
|
|
}
|
|
|
|
let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success
|
|
if !didExitBeforeTimeout, process.isRunning {
|
|
if operation?.isCancelled == true {
|
|
terminateProcessAndWait()
|
|
throw TerminalImageTransferExecutionError.cancelled
|
|
}
|
|
terminateProcessAndWait()
|
|
debugLog(
|
|
"remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " +
|
|
"timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))"
|
|
)
|
|
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) ?? ""
|
|
if operation?.isCancelled == true {
|
|
throw TerminalImageTransferExecutionError.cancelled
|
|
}
|
|
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 uploadDroppedFilesLocked(
|
|
_ fileURLs: [URL],
|
|
operation: TerminalImageTransferOperation
|
|
) throws -> [String] {
|
|
guard !fileURLs.isEmpty else { return [] }
|
|
|
|
let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions)
|
|
var uploadedRemotePaths: [String] = []
|
|
do {
|
|
for localURL in fileURLs {
|
|
try operation.throwIfCancelled()
|
|
let normalizedLocalURL = localURL.standardizedFileURL
|
|
guard normalizedLocalURL.isFileURL else {
|
|
throw RemoteDropUploadError.invalidFileURL
|
|
}
|
|
|
|
let remotePath = Self.remoteDropPath(for: normalizedLocalURL)
|
|
uploadedRemotePaths.append(remotePath)
|
|
var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"]
|
|
if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") {
|
|
scpArgs += ["-o", "StrictHostKeyChecking=accept-new"]
|
|
}
|
|
if let port = configuration.port {
|
|
scpArgs += ["-P", String(port)]
|
|
}
|
|
if let identityFile = configuration.identityFile,
|
|
!identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
scpArgs += ["-i", identityFile]
|
|
}
|
|
for option in scpSSHOptions {
|
|
scpArgs += ["-o", option]
|
|
}
|
|
scpArgs += [normalizedLocalURL.path, "\(configuration.destination):\(remotePath)"]
|
|
|
|
let scpResult = try scpExec(arguments: scpArgs, timeout: 45, operation: operation)
|
|
guard scpResult.status == 0 else {
|
|
let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ??
|
|
"scp exited \(scpResult.status)"
|
|
throw RemoteDropUploadError.uploadFailed(detail)
|
|
}
|
|
}
|
|
return uploadedRemotePaths
|
|
} catch {
|
|
cleanupUploadedRemotePaths(uploadedRemotePaths)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String {
|
|
let extensionSuffix = fileURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let lowercasedSuffix = extensionSuffix.isEmpty ? "" : ".\(extensionSuffix.lowercased())"
|
|
return "/tmp/cmux-drop-\(uuid.uuidString.lowercased())\(lowercasedSuffix)"
|
|
}
|
|
|
|
private func cleanupUploadedRemotePaths(_ remotePaths: [String]) {
|
|
guard !remotePaths.isEmpty else { return }
|
|
let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ")
|
|
let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))"
|
|
_ = try? sshExec(
|
|
arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand],
|
|
timeout: 8
|
|
)
|
|
}
|
|
|
|
private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello {
|
|
let request = #"{"id":1,"method":"hello","params":{}}"#
|
|
let script = "printf '%s\\n' \(Self.shellSingleQuoted(request)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio"
|
|
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> = []
|
|
@Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot?
|
|
@Published private(set) var tmuxWorkspaceFlashPanelId: UUID?
|
|
@Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason?
|
|
@Published private(set) var tmuxWorkspaceFlashToken: UInt64 = 0
|
|
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 var pendingRemoteTerminalChildExitSurfaceIds: Set<UUID> = []
|
|
|
|
private static let remoteErrorStatusKey = "remote.error"
|
|
private static let remotePortConflictStatusKey = "remote.port_conflicts"
|
|
private static let sshControlMasterCleanupQueue = DispatchQueue(
|
|
label: "com.cmux.remote-ssh.control-master-cleanup",
|
|
qos: .utility
|
|
)
|
|
private static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return formatter
|
|
}()
|
|
nonisolated(unsafe) static var runSSHControlMasterCommandOverrideForTesting: (([String]) -> Void)?
|
|
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
|
|
)
|
|
configureTerminalPanel(terminalPanel)
|
|
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)
|
|
}
|
|
tmuxLayoutSnapshot = bonsplitController.layoutSnapshot()
|
|
}
|
|
|
|
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 ttyName: String?
|
|
let cachedTitle: String?
|
|
let customTitle: String?
|
|
let manuallyUnread: Bool
|
|
let isRemoteTerminal: Bool
|
|
let remoteRelayPort: Int?
|
|
let remoteCleanupConfiguration: WorkspaceRemoteConfiguration?
|
|
|
|
func withRemoteCleanupConfiguration(_ configuration: WorkspaceRemoteConfiguration?) -> Self {
|
|
Self(
|
|
panelId: panelId,
|
|
panel: panel,
|
|
title: title,
|
|
icon: icon,
|
|
iconImageData: iconImageData,
|
|
kind: kind,
|
|
isLoading: isLoading,
|
|
isPinned: isPinned,
|
|
directory: directory,
|
|
ttyName: ttyName,
|
|
cachedTitle: cachedTitle,
|
|
customTitle: customTitle,
|
|
manuallyUnread: manuallyUnread,
|
|
isRemoteTerminal: isRemoteTerminal,
|
|
remoteRelayPort: remoteRelayPort,
|
|
remoteCleanupConfiguration: configuration
|
|
)
|
|
}
|
|
}
|
|
|
|
private var detachingTabIds: Set<TabID> = []
|
|
private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:]
|
|
private var activeDetachCloseTransactions: Int = 0
|
|
private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 }
|
|
// When the last live remote terminal is detached out, the source workspace may be
|
|
// closed immediately after the move succeeds. That teardown must not shut down the
|
|
// shared SSH control master that is still serving the moved terminal.
|
|
private var skipControlMasterCleanupAfterDetachedRemoteTransfer = false
|
|
private var transferredRemoteCleanupConfigurationsByPanelId: [UUID: WorkspaceRemoteConfiguration] = [:]
|
|
|
|
#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 configureTerminalPanel(_ terminalPanel: TerminalPanel) {
|
|
terminalPanel.onRequestWorkspacePaneFlash = { [weak self, weak terminalPanel] reason in
|
|
guard let self, let terminalPanel else { return }
|
|
self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason)
|
|
}
|
|
}
|
|
|
|
private func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) {
|
|
tmuxWorkspaceFlashPanelId = panelId
|
|
tmuxWorkspaceFlashReason = reason
|
|
tmuxWorkspaceFlashToken &+= 1
|
|
}
|
|
|
|
|
|
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?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false
|
|
}
|
|
|
|
private func attentionPersistentState() -> WorkspaceAttentionPersistentState {
|
|
let notificationStore = AppDelegate.shared?.notificationStore
|
|
let unreadPanelIDs = Set(
|
|
panels.keys.filter {
|
|
notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: $0) ?? false
|
|
}
|
|
)
|
|
return WorkspaceAttentionPersistentState(
|
|
unreadPanelIDs: unreadPanelIDs,
|
|
focusedReadPanelID: notificationStore?.focusedReadIndicatorSurfaceId(forTabId: id),
|
|
manualUnreadPanelIDs: manualUnreadPanelIds
|
|
)
|
|
}
|
|
|
|
private func requestAttentionFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) {
|
|
let decision = WorkspaceAttentionCoordinator.decideFlash(
|
|
targetPanelID: panelId,
|
|
reason: reason,
|
|
persistentState: attentionPersistentState()
|
|
)
|
|
guard decision.isAllowed else { return }
|
|
panels[panelId]?.triggerFlash(reason: reason)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
@MainActor
|
|
func isRemoteTerminalSurface(_ panelId: UUID) -> Bool {
|
|
activeRemoteTerminalSurfaceIds.contains(panelId)
|
|
}
|
|
|
|
@MainActor
|
|
func shouldDemoteWorkspaceAfterChildExit(surfaceId: UUID) -> Bool {
|
|
isRemoteWorkspace || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId)
|
|
}
|
|
|
|
var remoteDisplayTarget: String? {
|
|
remoteConfiguration?.displayTarget
|
|
}
|
|
|
|
var hasActiveRemoteTerminalSessions: Bool {
|
|
activeRemoteTerminalSessionCount > 0
|
|
}
|
|
|
|
@MainActor
|
|
func uploadDroppedFilesForRemoteTerminal(
|
|
_ fileURLs: [URL],
|
|
operation: TerminalImageTransferOperation,
|
|
completion: @escaping (Result<[String], Error>) -> Void
|
|
) {
|
|
guard let controller = remoteSessionController else {
|
|
completion(.failure(RemoteDropUploadError.unavailable))
|
|
return
|
|
}
|
|
controller.uploadDroppedFiles(fileURLs, operation: operation, completion: completion)
|
|
}
|
|
|
|
@MainActor
|
|
func uploadDroppedFilesForRemoteTerminal(
|
|
_ fileURLs: [URL],
|
|
completion: @escaping (Result<[String], Error>) -> Void
|
|
) {
|
|
uploadDroppedFilesForRemoteTerminal(
|
|
fileURLs,
|
|
operation: TerminalImageTransferOperation(),
|
|
completion: completion
|
|
)
|
|
}
|
|
|
|
func remoteStatusPayload() -> [String: Any] {
|
|
let heartbeatAgeSeconds: Any = {
|
|
guard let last = remoteLastHeartbeatAt else { return NSNull() }
|
|
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) {
|
|
skipControlMasterCleanupAfterDetachedRemoteTransfer = false
|
|
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 shouldCleanupControlMaster =
|
|
clearConfiguration
|
|
&& !isDetachingCloseTransaction
|
|
&& pendingDetachedSurfaces.isEmpty
|
|
&& !skipControlMasterCleanupAfterDetachedRemoteTransfer
|
|
let configurationForCleanup = shouldCleanupControlMaster ? remoteConfiguration : nil
|
|
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
|
|
skipControlMasterCleanupAfterDetachedRemoteTransfer = false
|
|
}
|
|
applyRemoteProxyEndpointUpdate(nil)
|
|
applyBrowserRemoteWorkspaceStatusToPanels()
|
|
recomputeListeningPorts()
|
|
if let configurationForCleanup {
|
|
Self.requestSSHControlMasterCleanupIfNeeded(configuration: configurationForCleanup)
|
|
}
|
|
}
|
|
|
|
private func clearRemoteConfigurationIfWorkspaceBecameLocal() {
|
|
guard !isDetachingCloseTransaction, 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) {
|
|
skipControlMasterCleanupAfterDetachedRemoteTransfer = false
|
|
pendingRemoteTerminalChildExitSurfaceIds.remove(panelId)
|
|
transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId)
|
|
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
|
|
guard !isDetachingCloseTransaction else { return }
|
|
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)
|
|
}
|
|
}
|
|
|
|
private func cleanupTransferredRemoteConnectionIfNeeded(surfaceId: UUID, relayPort: Int?) -> Bool {
|
|
guard let relayPort,
|
|
relayPort > 0,
|
|
let cleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId[surfaceId],
|
|
cleanupConfiguration.relayPort == relayPort else {
|
|
return false
|
|
}
|
|
transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId)
|
|
Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration)
|
|
return true
|
|
}
|
|
|
|
func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) {
|
|
if cleanupTransferredRemoteConnectionIfNeeded(surfaceId: surfaceId, relayPort: relayPort) {
|
|
return
|
|
}
|
|
guard let relayPort,
|
|
relayPort > 0,
|
|
remoteConfiguration?.relayPort == relayPort else {
|
|
return
|
|
}
|
|
pendingRemoteTerminalChildExitSurfaceIds.insert(surfaceId)
|
|
untrackRemoteTerminalSurface(surfaceId)
|
|
}
|
|
|
|
func teardownRemoteConnection() {
|
|
disconnectRemoteConnection(clearConfiguration: true)
|
|
}
|
|
|
|
private static func requestSSHControlMasterCleanupIfNeeded(configuration: WorkspaceRemoteConfiguration) {
|
|
guard let arguments = sshControlMasterCleanupArguments(configuration: configuration) else { return }
|
|
if let override = runSSHControlMasterCommandOverrideForTesting {
|
|
override(arguments)
|
|
return
|
|
}
|
|
|
|
sshControlMasterCleanupQueue.async {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
|
process.arguments = arguments
|
|
process.standardInput = FileHandle.nullDevice
|
|
process.standardOutput = FileHandle.nullDevice
|
|
process.standardError = FileHandle.nullDevice
|
|
let exitSemaphore = DispatchSemaphore(value: 0)
|
|
process.terminationHandler = { _ in
|
|
exitSemaphore.signal()
|
|
}
|
|
|
|
do {
|
|
try process.run()
|
|
if exitSemaphore.wait(timeout: .now() + 5) == .timedOut {
|
|
if process.isRunning {
|
|
process.terminate()
|
|
}
|
|
_ = exitSemaphore.wait(timeout: .now() + 1)
|
|
}
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func sshControlMasterCleanupArguments(configuration: WorkspaceRemoteConfiguration) -> [String]? {
|
|
let sshOptions = normalizedSSHControlCleanupOptions(configuration.sshOptions)
|
|
var arguments: [String] = [
|
|
"-o", "BatchMode=yes",
|
|
"-o", "ControlMaster=no",
|
|
]
|
|
if let port = configuration.port {
|
|
arguments += ["-p", String(port)]
|
|
}
|
|
if let identityFile = configuration.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!identityFile.isEmpty {
|
|
arguments += ["-i", identityFile]
|
|
}
|
|
for option in sshOptions {
|
|
arguments += ["-o", option]
|
|
}
|
|
arguments += ["-O", "exit", configuration.destination]
|
|
return arguments
|
|
}
|
|
|
|
private static func normalizedSSHControlCleanupOptions(_ options: [String]) -> [String] {
|
|
let disallowedKeys: Set<String> = ["controlmaster", "controlpersist"]
|
|
return options.compactMap { option in
|
|
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
guard let key = sshOptionKeyForControlCleanup(trimmed) else { return nil }
|
|
return disallowedKeys.contains(key) ? nil : trimmed
|
|
}
|
|
}
|
|
|
|
private static func sshOptionKeyForControlCleanup(_ 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()
|
|
}
|
|
|
|
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.liveSurfaceForGhosttyAccess(
|
|
reason: "workspace.rememberConfigInheritanceSource"
|
|
),
|
|
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? {
|
|
var staleRootedFontFallback: Float?
|
|
|
|
// 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
|
|
) {
|
|
let rootedFontFallback = terminalInheritanceFontPointsByPanelId[terminalPanel.id]
|
|
guard let sourceSurface = terminalPanel.surface.liveSurfaceForGhosttyAccess(
|
|
reason: "workspace.inheritedTerminalConfig"
|
|
) else {
|
|
if staleRootedFontFallback == nil,
|
|
let rootedFontFallback,
|
|
rootedFontFallback > 0 {
|
|
staleRootedFontFallback = rootedFontFallback
|
|
}
|
|
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 = staleRootedFontFallback ?? lastTerminalConfigInheritanceFontPoints {
|
|
var config = ghostty_surface_config_new()
|
|
config.font_size = fallbackFontPoints
|
|
#if DEBUG
|
|
let fallbackSource = staleRootedFontFallback != nil ? "quarantinedRootedFont" : "lastKnownFont"
|
|
dlog(
|
|
"zoom.inherit fallback=\(fallbackSource) 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
|
|
)
|
|
configureTerminalPanel(newPanel)
|
|
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
|
|
)
|
|
configureTerminalPanel(newPanel)
|
|
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)
|
|
pendingRemoteTerminalChildExitSurfaceIds.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 }
|
|
let shouldSkipControlMasterCleanupAfterDetach =
|
|
activeRemoteTerminalSurfaceIds.contains(panelId)
|
|
&& activeRemoteTerminalSurfaceIds.count == 1
|
|
#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
|
|
}
|
|
|
|
var detached = pendingDetachedSurfaces.removeValue(forKey: tabId)
|
|
if shouldSkipControlMasterCleanupAfterDetach, let detachedTransfer = detached, detachedTransfer.isRemoteTerminal {
|
|
skipControlMasterCleanupAfterDetachedRemoteTransfer = true
|
|
if detachedTransfer.remoteCleanupConfiguration == nil {
|
|
detached = detachedTransfer.withRemoteCleanupConfiguration(remoteConfiguration)
|
|
}
|
|
}
|
|
#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 ttyName = detached.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty {
|
|
surfaceTTYNames[detached.panelId] = ttyName
|
|
} else {
|
|
surfaceTTYNames.removeValue(forKey: detached.panelId)
|
|
}
|
|
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)
|
|
surfaceTTYNames.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
|
|
let didAdoptWorkspaceRemoteTracking =
|
|
detached.isRemoteTerminal
|
|
&& detached.remoteRelayPort == remoteConfiguration?.relayPort
|
|
if didAdoptWorkspaceRemoteTracking {
|
|
trackRemoteTerminalSurface(detached.panelId)
|
|
}
|
|
if let cleanupConfiguration = detached.remoteCleanupConfiguration {
|
|
if didAdoptWorkspaceRemoteTracking {
|
|
transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId)
|
|
} else {
|
|
transferredRemoteCleanupConfigurationsByPanelId[detached.panelId] = cleanupConfiguration
|
|
}
|
|
} else {
|
|
transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: 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) {
|
|
let previousFocusedPanelId = focusedPanelId
|
|
|
|
// Unfocus the currently-focused panel before navigating.
|
|
if let prevPanelId = previousFocusedPanelId, 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) {
|
|
requestAttentionFlash(panelId: panelId, reason: .navigation)
|
|
}
|
|
|
|
func triggerNotificationFocusFlash(
|
|
panelId: UUID,
|
|
requiresSplit: Bool = false,
|
|
shouldFocus: Bool = true
|
|
) {
|
|
guard terminalPanel(for: panelId) != nil else { return }
|
|
if shouldFocus {
|
|
focusPanel(panelId)
|
|
}
|
|
let isSplit = bonsplitController.allPaneIds.count > 1 || panels.count > 1
|
|
if requiresSplit && !isSplit {
|
|
return
|
|
}
|
|
requestAttentionFlash(panelId: panelId, reason: .notificationArrival)
|
|
}
|
|
|
|
func triggerNotificationDismissFlash(panelId: UUID) {
|
|
guard terminalPanel(for: panelId) != nil else { return }
|
|
requestAttentionFlash(panelId: panelId, reason: .notificationDismiss)
|
|
}
|
|
|
|
func triggerDebugFlash(panelId: UUID) {
|
|
guard panels[panelId] != nil else { return }
|
|
focusPanel(panelId)
|
|
requestAttentionFlash(panelId: panelId, reason: .debug)
|
|
}
|
|
|
|
// 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
|
|
)
|
|
configureTerminalPanel(newPanel)
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Hide browser portals for tabs that are no longer selected in the given pane.
|
|
private func hideBrowserPortalsForDeselectedTabs(inPane pane: PaneID, selectedTabId: TabID) {
|
|
for tab in bonsplitController.tabs(inPane: pane) {
|
|
guard tab.id != selectedTabId else { continue }
|
|
guard let panelId = panelIdFromSurfaceId(tab.id),
|
|
let browserPanel = panels[panelId] as? BrowserPanel else { continue }
|
|
browserPanel.hideBrowserPortalView(source: "tabDeselected")
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// Explicitly hide browser portals for deselected tabs in this pane.
|
|
// Bonsplit's keepAllAlive mode hides non-selected tabs via SwiftUI .opacity(0),
|
|
// but portal-hosted WKWebViews render at the window level in AppKit and are not
|
|
// affected by SwiftUI opacity. Without an explicit hide, the deselected browser's
|
|
// portal layer can remain visible above the newly selected tab.
|
|
hideBrowserPortalsForDeselectedTabs(inPane: focusedPane, selectedTabId: selectedTabId)
|
|
|
|
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]
|
|
let transferredRemoteCleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: 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],
|
|
ttyName: surfaceTTYNames[panelId],
|
|
cachedTitle: cachedTitle,
|
|
customTitle: panelCustomTitles[panelId],
|
|
manuallyUnread: manualUnreadPanelIds.contains(panelId),
|
|
isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId),
|
|
remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId)
|
|
? remoteConfiguration?.relayPort
|
|
: nil,
|
|
remoteCleanupConfiguration: transferredRemoteCleanupConfiguration
|
|
)
|
|
} else {
|
|
if let closedBrowserRestoreSnapshot {
|
|
onClosedBrowserPanel?(closedBrowserRestoreSnapshot)
|
|
}
|
|
panel?.close()
|
|
}
|
|
|
|
panels.removeValue(forKey: panelId)
|
|
untrackRemoteTerminalSurface(panelId)
|
|
pendingRemoteTerminalChildExitSurfaceIds.remove(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()
|
|
if !isDetaching, let transferredRemoteCleanupConfiguration {
|
|
Self.requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration)
|
|
}
|
|
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)
|
|
pendingRemoteTerminalChildExitSurfaceIds.remove(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
|
|
)
|
|
configureTerminalPanel(replacementPanel)
|
|
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
|
|
)
|
|
configureTerminalPanel(newPanel)
|
|
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) {
|
|
tmuxLayoutSnapshot = snapshot
|
|
scheduleTerminalGeometryReconcile()
|
|
if !isDetachingCloseTransaction {
|
|
scheduleFocusReconcile()
|
|
}
|
|
}
|
|
|
|
// No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups.
|
|
}
|