* fix(workspace): defer layout follow-up flush to avoid re-entrant displayIfNeeded crash beginEventDrivenLayoutFollowUp() ended with a synchronous call to attemptEventDrivenLayoutFollowUp(), which calls flushWorkspaceWindowLayouts() → window.contentView?.displayIfNeeded(). This is fine when invoked from user-event handlers, but splitTabBar(_:didChangeGeometry:) fires from inside SwiftUI's .onChange(of: geometry) during an active AppKit display/layout pass. Calling displayIfNeeded() re-entrantly during that pass caused AppKit to increment the per-window Update Constraints pass counter on every display cycle. Once the counter exceeded the view-count limit AppKit threw an NSGenericException and crashed: 'The window has been marked as needing another Update Constraints in Window pass, but it has already had more Update Constraints in Window passes than there are views in the window.' Fix: replace the direct attemptEventDrivenLayoutFollowUp() call with scheduleLayoutFollowUpAttempt(), which defers via asyncAfter(.now() + 0). When layoutFollowUpStalledAttemptCount == 0 the backoff delay is zero, so there is no meaningful latency increase — the flush simply runs at the start of the next run loop iteration, after the current layout pass has fully unwound. The NSWindow.didUpdateNotification observer and the existing timeout still drive retries, so convergence is unaffected. Made-with: Bunny * fix(workspace): supersede stale layout follow-up retry on reset scheduleLayoutFollowUpAttempt() is a no-op when layoutFollowUpAttemptScheduled is true, so a pending retry with a long backoff delay would survive a beginEventDrivenLayoutFollowUp() call even though that call resets layoutFollowUpStalledAttemptCount to 0. The stale closure would then fire after its original delay rather than immediately. Adds a layoutFollowUpAttemptVersion counter. beginEventDrivenLayoutFollowUp() increments the version and clears layoutFollowUpAttemptScheduled, allowing a fresh asyncAfter(0) attempt to be enqueued. Pending closures capture the version at scheduling time and exit early if it no longer matches. clearLayoutFollowUp() also increments the version to cancel any in-flight closure during teardown. Made-with: Bunny
10811 lines
430 KiB
Swift
10811 lines
430 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import AppKit
|
|
import Bonsplit
|
|
import Combine
|
|
import CryptoKit
|
|
import Darwin
|
|
import Network
|
|
import CoreText
|
|
|
|
struct CmuxSurfaceConfigTemplate {
|
|
var fontSize: Float32 = 0
|
|
var workingDirectory: String?
|
|
var command: String?
|
|
var environmentVariables: [String: String] = [:]
|
|
var initialInput: String?
|
|
var waitAfterCommand: Bool = false
|
|
|
|
init() {}
|
|
|
|
init(cConfig: ghostty_surface_config_s) {
|
|
fontSize = cConfig.font_size
|
|
if let workingDirectory = cConfig.working_directory {
|
|
self.workingDirectory = String(cString: workingDirectory, encoding: .utf8)
|
|
}
|
|
if let command = cConfig.command {
|
|
self.command = String(cString: command, encoding: .utf8)
|
|
}
|
|
if let initialInput = cConfig.initial_input {
|
|
self.initialInput = String(cString: initialInput, encoding: .utf8)
|
|
}
|
|
if cConfig.env_var_count > 0, let envVars = cConfig.env_vars {
|
|
for index in 0..<Int(cConfig.env_var_count) {
|
|
let envVar = envVars[index]
|
|
if let key = String(cString: envVar.key, encoding: .utf8),
|
|
let value = String(cString: envVar.value, encoding: .utf8) {
|
|
environmentVariables[key] = value
|
|
}
|
|
}
|
|
}
|
|
waitAfterCommand = cConfig.wait_after_command
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
) -> CmuxSurfaceConfigTemplate {
|
|
let inherited = ghostty_surface_inherited_config(sourceSurface, context)
|
|
var config = CmuxSurfaceConfigTemplate(cConfig: 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.fontSize = 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.fontSize)
|
|
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)
|
|
}
|
|
|
|
/// Fetch the live manifest JSON from the release, returning nil on any failure.
|
|
private static func fetchRemoteManifestLocked(releaseURL: String, version: String) -> WorkspaceRemoteDaemonManifest? {
|
|
guard let manifestURL = URL(string: "\(releaseURL)/cmuxd-remote-manifest.json") else { return nil }
|
|
let request = NSMutableURLRequest(url: manifestURL)
|
|
request.timeoutInterval = 15
|
|
request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent")
|
|
let session = URLSession(configuration: .ephemeral)
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
var resultData: Data?
|
|
session.dataTask(with: request as URLRequest) { data, response, error in
|
|
defer { semaphore.signal() }
|
|
guard error == nil,
|
|
let httpResponse = response as? HTTPURLResponse,
|
|
(200...299).contains(httpResponse.statusCode) else { return }
|
|
resultData = data
|
|
}.resume()
|
|
_ = semaphore.wait(timeout: .now() + 20.0)
|
|
session.finishTasksAndInvalidate()
|
|
guard let data = resultData else { return nil }
|
|
return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data)
|
|
}
|
|
|
|
private func downloadRemoteDaemonBinaryLocked(entry: WorkspaceRemoteDaemonManifest.Entry, version: String, releaseURL: String? = nil) 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)
|
|
if downloadedSHA != entry.sha256.lowercased() {
|
|
// The embedded manifest's checksum doesn't match the downloaded binary.
|
|
// This can happen when a newer nightly overwrites the shared release
|
|
// asset after this build's manifest was embedded. As a fallback, fetch
|
|
// the live manifest from the release and verify against that.
|
|
if let releaseURL,
|
|
let liveManifest = Self.fetchRemoteManifestLocked(releaseURL: releaseURL, version: version),
|
|
let liveEntry = liveManifest.entry(goOS: entry.goOS, goArch: entry.goArch),
|
|
downloadedSHA == liveEntry.sha256.lowercased() {
|
|
debugLog("remote.download.checksum-fallback: embedded manifest checksum stale, live manifest matched for \(entry.assetName)")
|
|
} 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, releaseURL: manifest.releaseURL)
|
|
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: CmuxSurfaceConfigTemplate? = 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 layoutFollowUpAttemptVersion: Int = 0
|
|
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: CmuxSurfaceConfigTemplate?
|
|
) {
|
|
guard let fontPoints = configTemplate?.fontSize, fontPoints > 0 else { return }
|
|
terminalInheritanceFontPointsByPanelId[panelId] = fontPoints
|
|
lastTerminalConfigInheritanceFontPoints = fontPoints
|
|
}
|
|
|
|
private func resolvedTerminalInheritanceFontPoints(
|
|
for terminalPanel: TerminalPanel,
|
|
sourceSurface: ghostty_surface_t,
|
|
inheritedConfig: CmuxSurfaceConfigTemplate
|
|
) -> 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.fontSize > 0 {
|
|
return inheritedConfig.fontSize
|
|
}
|
|
return runtimePoints
|
|
}
|
|
|
|
private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) {
|
|
lastTerminalConfigInheritancePanelId = terminalPanel.id
|
|
if let sourceSurface = terminalPanel.surface.surface,
|
|
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) {
|
|
let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id]
|
|
if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 {
|
|
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints
|
|
}
|
|
lastTerminalConfigInheritanceFontPoints =
|
|
terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints
|
|
}
|
|
}
|
|
|
|
func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? {
|
|
guard let panelId = lastTerminalConfigInheritancePanelId else { return nil }
|
|
return terminalPanel(for: panelId)
|
|
}
|
|
|
|
func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? {
|
|
lastTerminalConfigInheritanceFontPoints
|
|
}
|
|
|
|
/// Candidate terminal panels used as the source when creating inherited Ghostty config.
|
|
/// Preference order:
|
|
/// 1) explicitly preferred terminal panel (when the caller has one),
|
|
/// 2) selected terminal in the target pane,
|
|
/// 3) currently focused terminal in the workspace,
|
|
/// 4) last remembered terminal source,
|
|
/// 5) first terminal tab in the target pane,
|
|
/// 6) deterministic workspace fallback.
|
|
private func terminalPanelConfigInheritanceCandidates(
|
|
preferredPanelId: UUID? = nil,
|
|
inPane preferredPaneId: PaneID? = nil
|
|
) -> [TerminalPanel] {
|
|
var candidates: [TerminalPanel] = []
|
|
var seen: Set<UUID> = []
|
|
|
|
func appendCandidate(_ panel: TerminalPanel?) {
|
|
guard let panel, seen.insert(panel.id).inserted else { return }
|
|
candidates.append(panel)
|
|
}
|
|
|
|
if let preferredPanelId,
|
|
let terminalPanel = terminalPanel(for: preferredPanelId) {
|
|
appendCandidate(terminalPanel)
|
|
}
|
|
|
|
if let preferredPaneId,
|
|
let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id,
|
|
let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId),
|
|
let selectedTerminalPanel = terminalPanel(for: selectedPanelId) {
|
|
appendCandidate(selectedTerminalPanel)
|
|
}
|
|
|
|
if let focusedTerminalPanel {
|
|
appendCandidate(focusedTerminalPanel)
|
|
}
|
|
|
|
if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() {
|
|
appendCandidate(rememberedTerminalPanel)
|
|
}
|
|
|
|
if let preferredPaneId {
|
|
for tab in bonsplitController.tabs(inPane: preferredPaneId) {
|
|
guard let panelId = panelIdFromSurfaceId(tab.id),
|
|
let terminalPanel = terminalPanel(for: panelId) else { continue }
|
|
appendCandidate(terminalPanel)
|
|
}
|
|
}
|
|
|
|
for terminalPanel in panels.values
|
|
.compactMap({ $0 as? TerminalPanel })
|
|
.sorted(by: { $0.id.uuidString < $1.id.uuidString }) {
|
|
appendCandidate(terminalPanel)
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
/// Picks the first terminal panel candidate used as the inheritance source.
|
|
func terminalPanelForConfigInheritance(
|
|
preferredPanelId: UUID? = nil,
|
|
inPane preferredPaneId: PaneID? = nil
|
|
) -> TerminalPanel? {
|
|
terminalPanelConfigInheritanceCandidates(
|
|
preferredPanelId: preferredPanelId,
|
|
inPane: preferredPaneId
|
|
).first
|
|
}
|
|
|
|
private func inheritedTerminalConfig(
|
|
preferredPanelId: UUID? = nil,
|
|
inPane preferredPaneId: PaneID? = nil
|
|
) -> CmuxSurfaceConfigTemplate? {
|
|
// Walk candidates in priority order and use the first panel that still exposes
|
|
// a runtime surface pointer.
|
|
for terminalPanel in terminalPanelConfigInheritanceCandidates(
|
|
preferredPanelId: preferredPanelId,
|
|
inPane: preferredPaneId
|
|
) {
|
|
// Pin the panel and its TerminalSurface wrapper for the duration of
|
|
// this iteration. The raw ghostty_surface_t extracted below is owned
|
|
// by `surface` (the TerminalSurface) — ARC must not release it while
|
|
// ghostty_surface_inherited_config or cmuxCurrentSurfaceFontSizePoints
|
|
// is still reading through the pointer.
|
|
let surface = terminalPanel.surface
|
|
guard let sourceSurface = surface.surface else { continue }
|
|
var config = cmuxInheritedSurfaceConfig(
|
|
sourceSurface: sourceSurface,
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT
|
|
)
|
|
if let rootedFontPoints = resolvedTerminalInheritanceFontPoints(
|
|
for: terminalPanel,
|
|
sourceSurface: sourceSurface,
|
|
inheritedConfig: config
|
|
), rootedFontPoints > 0 {
|
|
config.fontSize = rootedFontPoints
|
|
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints
|
|
}
|
|
// Prevent ARC from releasing panel/surface before the C calls above complete.
|
|
withExtendedLifetime((terminalPanel, surface)) {}
|
|
rememberTerminalConfigInheritanceSource(terminalPanel)
|
|
if config.fontSize > 0 {
|
|
lastTerminalConfigInheritanceFontPoints = config.fontSize
|
|
}
|
|
return config
|
|
}
|
|
|
|
if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints {
|
|
var config = CmuxSurfaceConfigTemplate()
|
|
config.fontSize = fallbackFontPoints
|
|
#if DEBUG
|
|
dlog(
|
|
"zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))"
|
|
)
|
|
#endif
|
|
return config
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Create a new split with a terminal panel
|
|
@discardableResult
|
|
func newTerminalSplit(
|
|
from panelId: UUID,
|
|
orientation: SplitOrientation,
|
|
insertFirst: Bool = false,
|
|
focus: Bool = true
|
|
) -> TerminalPanel? {
|
|
// Find the pane containing the source panel
|
|
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
|
|
var sourcePaneId: PaneID?
|
|
for paneId in bonsplitController.allPaneIds {
|
|
let tabs = bonsplitController.tabs(inPane: paneId)
|
|
if tabs.contains(where: { $0.id == sourceTabId }) {
|
|
sourcePaneId = paneId
|
|
break
|
|
}
|
|
}
|
|
|
|
guard let paneId = sourcePaneId else { return nil }
|
|
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
|
|
let remoteTerminalStartupCommand = remoteTerminalStartupCommand()
|
|
|
|
// Inherit working directory: prefer the source panel's reported cwd,
|
|
// then its requested startup cwd if shell integration has not reported
|
|
// back yet, and finally fall back to the workspace's current directory.
|
|
let splitWorkingDirectory: String? = {
|
|
if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!panelDirectory.isEmpty {
|
|
return panelDirectory
|
|
}
|
|
if let requestedWorkingDirectory = terminalPanel(for: panelId)?
|
|
.requestedWorkingDirectory?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!requestedWorkingDirectory.isEmpty {
|
|
return requestedWorkingDirectory
|
|
}
|
|
let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return workspaceDirectory.isEmpty ? nil : workspaceDirectory
|
|
}()
|
|
#if DEBUG
|
|
dlog(
|
|
"split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")"
|
|
)
|
|
#endif
|
|
|
|
// Create the new terminal panel.
|
|
let newPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: inheritedConfig,
|
|
workingDirectory: splitWorkingDirectory,
|
|
portOrdinal: portOrdinal,
|
|
initialCommand: remoteTerminalStartupCommand
|
|
)
|
|
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
|
|
// Invalidate any pending retry whose delay was computed from a stale stall count.
|
|
// Incrementing the version causes old closures to exit early; clearing the flag
|
|
// allows scheduleLayoutFollowUpAttempt() below to enqueue a fresh asyncAfter(0).
|
|
layoutFollowUpAttemptVersion &+= 1
|
|
layoutFollowUpAttemptScheduled = false
|
|
|
|
if layoutFollowUpTimeoutWorkItem == nil {
|
|
installLayoutFollowUpObservers()
|
|
}
|
|
refreshLayoutFollowUpTimeout()
|
|
// Use async scheduling instead of a synchronous call here. beginEventDrivenLayoutFollowUp
|
|
// is often invoked from splitTabBar(_:didChangeGeometry:), which fires from inside
|
|
// SwiftUI's .onChange(of: geometry) during an active layout pass. Calling
|
|
// attemptEventDrivenLayoutFollowUp() synchronously in that context causes
|
|
// flushWorkspaceWindowLayouts() → displayIfNeeded() to be called re-entrantly,
|
|
// incrementing AppKit's per-window constraint-pass counter on every display cycle
|
|
// until it exceeds the limit and crashes with NSGenericException.
|
|
// scheduleLayoutFollowUpAttempt() defers via asyncAfter(0) so the flush always
|
|
// happens after the current layout pass completes.
|
|
scheduleLayoutFollowUpAttempt()
|
|
}
|
|
|
|
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
|
|
layoutFollowUpAttemptVersion &+= 1
|
|
layoutFollowUpAttemptScheduled = false
|
|
layoutFollowUpStalledAttemptCount = 0
|
|
}
|
|
|
|
private func scheduleLayoutFollowUpAttempt() {
|
|
guard layoutFollowUpTimeoutWorkItem != nil else { return }
|
|
guard !layoutFollowUpAttemptScheduled else { return }
|
|
|
|
layoutFollowUpAttemptScheduled = true
|
|
let delay = layoutFollowUpBackoffDelay()
|
|
let version = layoutFollowUpAttemptVersion
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
guard let self else { return }
|
|
guard self.layoutFollowUpAttemptVersion == version 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.
|
|
}
|