diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index eb99dcbfe9..1184fc5a5c 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -48,16 +48,14 @@ jobs: - name: Select Xcode run: | set -euo pipefail - if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then + if compgen -G "/Applications/Xcode_*.app" >/dev/null 2>&1; then + XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -n 1)" + XCODE_DIR="$XCODE_APP/Contents/Developer" + elif [ -d "/Applications/Xcode.app/Contents/Developer" ]; then XCODE_DIR="/Applications/Xcode.app/Contents/Developer" else - XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)" - if [ -n "$XCODE_APP" ]; then - XCODE_DIR="$XCODE_APP/Contents/Developer" - else - echo "No Xcode.app found under /Applications" >&2 - exit 1 - fi + echo "No Xcode.app found under /Applications" >&2 + exit 1 fi echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" @@ -217,9 +215,7 @@ jobs: clang -framework Foundation -framework CoreGraphics \ -o "$HELPER_PATH" scripts/create-virtual-display.m - cat >"$MANIFEST_PATH" <"$MANIFEST_PATH" fi # Start recording right before the test (after build/resolve). diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a6794e5cad..c6dbb2467b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1769,15 +1769,18 @@ func startOrFocusTerminalSearch( return true } + terminalSurface.requestGhosttySearchActivation(.startSearch) if terminalSurface.performBindingAction("start_search") { - DispatchQueue.main.async { [weak terminalSurface] in - guard let terminalSurface, terminalSurface.searchState == nil else { return } - terminalSurface.searchState = TerminalSurface.SearchState() - searchFocusNotifier(terminalSurface) + DispatchQueue.main.async { + cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded( + terminalSurface, + searchFocusNotifier: searchFocusNotifier + ) } return true } + terminalSurface.clearGhosttySearchActivationRequest() terminalSurface.searchState = TerminalSurface.SearchState() searchFocusNotifier(terminalSurface) return true @@ -2076,6 +2079,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var bonsplitTabDragUITestRecorder: DispatchSourceTimer? private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false + private var didSetupAutomationSocketStressUITest = false private var didSetupDisplayResolutionUITestDiagnostics = false private var displayResolutionUITestObservers: [NSObjectProtocol] = [] private struct UITestRenderDiagnosticsSnapshot { @@ -2459,6 +2463,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return object } + private func updateUITestDiagnosticsIfNeeded(_ updates: [String: String]) { + let env = ProcessInfo.processInfo.environment + guard let path = env["CMUX_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } + + var payload = loadUITestDiagnostics(at: path) + for (key, value) in updates { + payload[key] = value + } + + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + } + private func appendUITestSocketDiagnosticsIfNeeded( _ payload: inout [String: String], environment env: [String: String] @@ -2679,6 +2696,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setupGotoSplitUITestIfNeeded() setupBonsplitTabDragUITestIfNeeded() setupMultiWindowNotificationsUITestIfNeeded() + setupAutomationSocketStressUITestIfNeeded(tabManager: tabManager) setupDisplayResolutionUITestDiagnosticsIfNeeded() // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. @@ -2733,6 +2751,232 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func setupAutomationSocketStressUITestIfNeeded(tabManager: TabManager) { + guard !didSetupAutomationSocketStressUITest else { return } + didSetupAutomationSocketStressUITest = true + + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_AUTOMATION_SOCKET_STRESS"] == "1" else { return } + + updateUITestDiagnosticsIfNeeded([ + "automationSocketStressStatus": "waiting", + "automationSocketStressDone": "0", + "automationSocketStressIterationsCompleted": "0", + "automationSocketStressTrace": "scheduled", + ]) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.runAutomationSocketStressUITestAttempt(tabManager: tabManager, remainingAttempts: 40) + } + } + + private func runAutomationSocketStressUITestAttempt(tabManager: TabManager, remainingAttempts: Int) { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_AUTOMATION_SOCKET_STRESS"] == "1" else { return } + + guard let config = socketListenerConfigurationIfEnabled() else { + finishAutomationSocketStressAttempt( + tabManager: tabManager, + remainingAttempts: remainingAttempts, + status: "waiting", + trace: ["socket_disabled"] + ) + return + } + + let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) + let pingResponse = health.isHealthy + ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) + : nil + + guard health.isHealthy, pingResponse == "PONG" else { + finishAutomationSocketStressAttempt( + tabManager: tabManager, + remainingAttempts: remainingAttempts, + status: "waiting", + trace: [ + "socket=\(socketPath)", + "isHealthy=\(health.isHealthy ? "1" : "0")", + "ping=\(pingResponse ?? "")", + ] + ) + return + } + + let workspaceListResponse = TerminalController.probeSocketCommand( + "list_workspaces", + at: socketPath, + timeout: 1.0 + ) + guard let workspaceId = automationSocketStressPrimaryId(from: workspaceListResponse) else { + finishAutomationSocketStressAttempt( + tabManager: tabManager, + remainingAttempts: remainingAttempts, + status: "waiting", + trace: [ + "socket=\(socketPath)", + "ping=PONG", + "workspaces=\(workspaceListResponse ?? "")", + ] + ) + return + } + + let surfaceListResponse = TerminalController.probeSocketCommand( + "list_surfaces \(workspaceId)", + at: socketPath, + timeout: 1.0 + ) + guard let surfaceId = automationSocketStressPrimaryId(from: surfaceListResponse) else { + finishAutomationSocketStressAttempt( + tabManager: tabManager, + remainingAttempts: remainingAttempts, + status: "waiting", + trace: [ + "socket=\(socketPath)", + "ping=PONG", + "workspace=\(workspaceId)", + "workspaces=\(workspaceListResponse ?? "")", + "surfaces=\(surfaceListResponse ?? "")", + ] + ) + return + } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.performAutomationSocketStressLoop( + socketPath: socketPath, + workspaceId: workspaceId, + surfaceId: surfaceId, + baselineListResponse: surfaceListResponse + ) + } + } + + private func finishAutomationSocketStressAttempt( + tabManager: TabManager, + remainingAttempts: Int, + status: String, + trace: [String] + ) { + updateUITestDiagnosticsIfNeeded([ + "automationSocketStressStatus": status, + "automationSocketStressDone": "0", + "automationSocketStressIterationsCompleted": "0", + "automationSocketStressTrace": trace.joined(separator: " | "), + ]) + + guard remainingAttempts > 1 else { + updateUITestDiagnosticsIfNeeded([ + "automationSocketStressStatus": "failed", + "automationSocketStressDone": "1", + "automationSocketStressIterationsCompleted": "0", + "automationSocketStressTrace": trace.joined(separator: " | "), + ]) + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.runAutomationSocketStressUITestAttempt( + tabManager: tabManager, + remainingAttempts: remainingAttempts - 1 + ) + } + } + + private func performAutomationSocketStressLoop( + socketPath: String, + workspaceId: String, + surfaceId: String, + baselineListResponse: String? + ) { + var trace: [String] = [ + "socket=\(socketPath)", + "workspace=\(workspaceId)", + "surface=\(surfaceId)", + "baseline.list=\(baselineListResponse ?? "")", + ] + + for iteration in 1...8 { + let pingBefore = TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) + let sendResponse = TerminalController.probeSocketCommand( + "send_key_surface \(surfaceId) enter", + at: socketPath, + timeout: 2.0 + ) + let pingAfter = TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) + let listResponse = TerminalController.probeSocketCommand( + "list_surfaces \(workspaceId)", + at: socketPath, + timeout: 2.0 + ) + + trace.append( + "iteration\(iteration)=pingBefore:\(pingBefore ?? ""),send:\(sendResponse ?? ""),pingAfter:\(pingAfter ?? ""),list:\(listResponse ?? "")" + ) + + guard pingBefore == "PONG", + sendResponse == "OK", + pingAfter == "PONG", + automationSocketStressListResponse(listResponse, containsSurface: surfaceId) else { + updateUITestDiagnosticsIfNeeded([ + "automationSocketStressStatus": "failed", + "automationSocketStressDone": "1", + "automationSocketStressIterationsCompleted": String(iteration - 1), + "automationSocketStressTrace": trace.joined(separator: " | "), + ]) + return + } + } + + updateUITestDiagnosticsIfNeeded([ + "automationSocketStressStatus": "passed", + "automationSocketStressDone": "1", + "automationSocketStressIterationsCompleted": "8", + "automationSocketStressTrace": trace.joined(separator: " | "), + ]) + } + + private func automationSocketStressListResponse(_ response: String?, containsSurface surfaceId: String) -> Bool { + guard let response, !response.isEmpty, response != "No surfaces" else { return false } + return response.contains(surfaceId) + } + + private func automationSocketStressPrimaryId(from response: String?) -> String? { + let entries = automationSocketStressListEntries(from: response) + return entries.first(where: \.isSelected)?.id ?? entries.first?.id + } + + private func automationSocketStressListEntries(from response: String?) -> [(id: String, isSelected: Bool)] { + guard let response, + !response.isEmpty, + !response.hasPrefix("ERROR:"), + response != "No workspaces", + response != "No surfaces" else { + return [] + } + + return response + .split(separator: "\n", omittingEmptySubsequences: true) + .compactMap { rawLine in + var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { return nil } + + let isSelected = line.hasPrefix("*") + if line.hasPrefix("* ") || line.hasPrefix(" ") { + line = String(line.dropFirst(2)) + } + + let parts = line.split(whereSeparator: \.isWhitespace) + guard parts.count >= 2 else { return nil } + + let id = String(parts[1]) + guard UUID(uuidString: id) != nil else { return nil } + return (id: id, isSelected: isSelected) + } + } + private func setupDisplayResolutionUITestDiagnosticsIfNeeded() { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 15d5172317..517d5a94e0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -320,6 +320,44 @@ struct TitlebarLayerBackground: NSViewRepresentable { } } +func cmuxMainWindowTitlebarOpacity( + backgroundOpacity: Double +) -> CGFloat { + GhosttyBackgroundTheme.compositedChromeOpacity(backgroundOpacity) +} + +func cmuxResolveGhosttyChromeConfig( + loadedConfig: GhosttyConfig, + runtimeBackgroundColor: NSColor, + runtimeBackgroundOpacity: Double +) -> GhosttyConfig { + let runtimeOpacity = GhosttyBackgroundTheme.clampedOpacity(runtimeBackgroundOpacity) + let loadedOpacity = GhosttyBackgroundTheme.clampedOpacity(loadedConfig.backgroundOpacity) + + // Newer Ghostty builds can drive the window background fully clear at runtime for + // translucent terminals. That clear backing color should not replace cmux chrome. + guard runtimeOpacity > 0.001 || loadedOpacity <= 0.001 else { + return loadedConfig + } + + var resolved = loadedConfig + resolved.backgroundColor = runtimeBackgroundColor + resolved.backgroundOpacity = runtimeBackgroundOpacity + return resolved +} + +func cmuxResolveGhosttyChromeConfig( + loadConfig: () -> GhosttyConfig = { GhosttyConfig.load() }, + runtimeBackgroundColor: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }, + runtimeBackgroundOpacity: () -> Double = { GhosttyApp.shared.defaultBackgroundOpacity } +) -> GhosttyConfig { + cmuxResolveGhosttyChromeConfig( + loadedConfig: loadConfig(), + runtimeBackgroundColor: runtimeBackgroundColor(), + runtimeBackgroundOpacity: runtimeBackgroundOpacity() + ) +} + final class SidebarState: ObservableObject { @Published var isVisible: Bool @Published var persistedWidth: CGFloat @@ -1457,6 +1495,7 @@ struct ContentView: View { @State private var workspaceHandoffFallbackTask: Task? @State private var didApplyUITestSidebarSelection = false @State private var titlebarThemeGeneration: UInt64 = 0 + @State private var titlebarThemeConfig = cmuxResolveGhosttyChromeConfig() @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem? @@ -2183,8 +2222,7 @@ struct ContentView: View { @State private var titlebarLeadingInset: CGFloat = 12 private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } private var fakeTitlebarTextColor: Color { - _ = titlebarThemeGeneration - let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor + let ghosttyBackground = titlebarThemeConfig.backgroundColor return ghosttyBackground.isLightColor ? Color.black.opacity(0.78) : Color.white.opacity(0.82) @@ -2242,14 +2280,11 @@ struct ContentView: View { .frame(maxWidth: .infinity) .contentShape(Rectangle()) .background({ - // The terminal area has two stacked semi-transparent layers: the Bonsplit - // container chrome background plus Ghostty's own Metal-rendered background. - // Compute the effective composited opacity so the titlebar matches visually. - let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) - let effective = alpha >= 0.999 ? alpha : 1.0 - pow(1.0 - alpha, 2) return TitlebarLayerBackground( - backgroundColor: GhosttyApp.shared.defaultBackgroundColor, - opacity: effective + backgroundColor: titlebarThemeConfig.backgroundColor, + opacity: cmuxMainWindowTitlebarOpacity( + backgroundOpacity: titlebarThemeConfig.backgroundOpacity + ) ) }()) .overlay(alignment: .bottom) { @@ -2408,6 +2443,7 @@ struct ContentView: View { syncSidebarSelectedWorkspaceIds() applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs) updateTitlebarText() + titlebarThemeConfig = cmuxResolveGhosttyChromeConfig() // Startup recovery (#399): if session restore or a race condition leaves the // view in a broken state (empty tabs, no selection, unmounted workspaces), @@ -2529,9 +2565,10 @@ struct ContentView: View { }) view = AnyView(view.onChange(of: titlebarThemeGeneration) { oldValue, newValue in + titlebarThemeConfig = cmuxResolveGhosttyChromeConfig() guard GhosttyApp.shared.backgroundLogEnabled else { return } GhosttyApp.shared.logBackground( - "titlebar theme refresh applied oldGeneration=\(oldValue) generation=\(newValue) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" + "titlebar theme refresh applied oldGeneration=\(oldValue) generation=\(newValue) titlebarBg=\(titlebarThemeConfig.backgroundColor.hexString()) titlebarOpacity=\(String(format: "%.3f", titlebarThemeConfig.backgroundOpacity)) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" ) }) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 13f78f1257..e4ddc15406 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -7,6 +7,44 @@ struct GhosttyConfig { case dark } + enum BackgroundBlur: Hashable { + case disabled + case enabled + case macosGlassRegular + case macosGlassClear + + var isGlassStyle: Bool { + switch self { + case .macosGlassRegular, .macosGlassClear: + return true + case .disabled, .enabled: + return false + } + } + + static func parse(_ rawValue: String) -> Self? { + let normalized = rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + switch normalized { + case "false", "0", "off", "disabled": + return .disabled + case "true", "1", "on": + return .enabled + case "macos-glass-regular": + return .macosGlassRegular + case "macos-glass-clear": + return .macosGlassClear + default: + if Double(normalized) != nil { + return .enabled + } + return nil + } + } + } + private static let cmuxReleaseBundleIdentifier = "com.cmuxterm.app" private static let loadCacheLock = NSLock() private static var cachedConfigsByColorScheme: [ColorSchemePreference: GhosttyConfig] = [:] @@ -23,6 +61,7 @@ struct GhosttyConfig { // Colors (from theme or config) var backgroundColor: NSColor = NSColor(hex: "#272822")! var backgroundOpacity: Double = 1.0 + var backgroundBlur: BackgroundBlur = .disabled var foregroundColor: NSColor = NSColor(hex: "#fdfff1")! var cursorColor: NSColor = NSColor(hex: "#c0c1b5")! var cursorTextColor: NSColor = NSColor(hex: "#8d8e82")! @@ -258,6 +297,10 @@ struct GhosttyConfig { if let opacity = Double(value) { backgroundOpacity = opacity } + case "background-blur": + if let blur = BackgroundBlur.parse(value) { + backgroundBlur = blur + } case "foreground": if let color = NSColor(hex: value) { foregroundColor = color diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 17fe87ab73..7741dd0193 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -27,6 +27,68 @@ private func cmuxTransparentWindowBaseColor() -> NSColor { // avoids visual artifacts that can happen with a fully clear window background. NSColor.white.withAlphaComponent(0.001) } + +enum CmuxPendingGhosttySearchRequest: Equatable { + case startSearch + case searchSelection +} + +enum CmuxGhosttyStartSearchResolution: Equatable { + case ignore + case focusExisting + case updateExistingNeedle(String) + case createEmpty + case createWithNeedle(String) +} + +func cmuxDecodeGhosttyOptionalCString(_ pointer: UnsafePointer?) -> String? { + guard let pointer else { return nil } + + // Guard obviously invalid low addresses so Ghostty callback corruption cannot crash us + // while handling an empty/open-search notification. + let address = UInt(bitPattern: pointer) + guard address >= 0x1000 else { return nil } + + return String(validatingUTF8: pointer) +} + +func cmuxResolveGhosttyStartSearch( + existingSearchState: Bool, + pendingRequest: CmuxPendingGhosttySearchRequest?, + needle: String? +) -> CmuxGhosttyStartSearchResolution { + if existingSearchState { + if let needle, !needle.isEmpty { + return .updateExistingNeedle(needle) + } + return .focusExisting + } + + switch pendingRequest { + case .startSearch: + return .createEmpty + case .searchSelection: + if let needle, !needle.isEmpty { + return .createWithNeedle(needle) + } + return .ignore + case nil: + return .ignore + } +} + +func cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded( + _ terminalSurface: TerminalSurface, + searchFocusNotifier: @escaping (TerminalSurface) -> Void = { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0) + } +) { + guard terminalSurface.pendingGhosttySearchActivationRequest == .startSearch else { return } + terminalSurface.clearGhosttySearchActivationRequest() + guard terminalSurface.searchState == nil else { return } + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) +} #endif #if DEBUG @@ -2131,15 +2193,41 @@ class GhosttyApp { return true case GHOSTTY_ACTION_START_SEARCH: guard let terminalSurface = surfaceView.terminalSurface else { return true } - let needle = action.action.start_search.needle.flatMap { String(cString: $0) } + let pendingRequest = terminalSurface.consumeGhosttySearchActivationRequest() + let shouldDecodeNeedle = + terminalSurface.searchState != nil || pendingRequest != .startSearch + let needle = shouldDecodeNeedle + ? cmuxDecodeGhosttyOptionalCString(action.action.start_search.needle) + : nil DispatchQueue.main.async { - if let searchState = terminalSurface.searchState { - if let needle, !needle.isEmpty { + let resolution = cmuxResolveGhosttyStartSearch( + existingSearchState: terminalSurface.searchState != nil, + pendingRequest: pendingRequest, + needle: needle + ) + + switch resolution { + case .ignore: +#if DEBUG + dlog( + "find.startSearch ignored tab=\(terminalSurface.tabId.uuidString.prefix(5)) " + + "surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "pending=\(String(describing: pendingRequest))" + ) +#endif + return + case .focusExisting: + break + case .updateExistingNeedle(let needle): + if let searchState = terminalSurface.searchState { searchState.needle = needle } - } else { - terminalSurface.searchState = TerminalSurface.SearchState(needle: needle ?? "") + case .createEmpty: + terminalSurface.searchState = TerminalSurface.SearchState() + case .createWithNeedle(let needle): + terminalSurface.searchState = TerminalSurface.SearchState(needle: needle) } + NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) } return true @@ -2590,6 +2678,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private var portalLifecycleState: PortalLifecycleState = .live private var portalLifecycleGeneration: UInt64 = 1 private var activePortalHostLease: PortalHostLease? + private var pendingGhosttySearchRequest: CmuxPendingGhosttySearchRequest? @Published var searchState: SearchState? = nil { didSet { if let searchState { @@ -3629,6 +3718,24 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + func requestGhosttySearchActivation(_ request: CmuxPendingGhosttySearchRequest) { + pendingGhosttySearchRequest = request + } + + var pendingGhosttySearchActivationRequest: CmuxPendingGhosttySearchRequest? { + pendingGhosttySearchRequest + } + + func consumeGhosttySearchActivationRequest() -> CmuxPendingGhosttySearchRequest? { + let request = pendingGhosttySearchRequest + pendingGhosttySearchRequest = nil + return request + } + + func clearGhosttySearchActivationRequest() { + pendingGhosttySearchRequest = nil + } + @discardableResult func toggleKeyboardCopyMode() -> Bool { let handled = surfaceView.toggleKeyboardCopyMode() @@ -4536,7 +4643,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { _ = performBindingAction("jump_to_prompt:\(delta * count)") refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .startSearch: - _ = performBindingAction("start_search") + if let terminalSurface, terminalSurface.searchState != nil { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) + break + } + terminalSurface?.requestGhosttySearchActivation(.startSearch) + if performBindingAction("start_search") { + if let terminalSurface { + DispatchQueue.main.async { + cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(terminalSurface) + } + } + } else { + terminalSurface?.clearGhosttySearchActivationRequest() + } case .searchNext: performBindingAction("navigate_search:next", repeatCount: count) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 1fa0570a8d..911107a698 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -43,10 +43,19 @@ enum GhosttyBackgroundTheme { CGFloat(max(0.0, min(1.0, opacity))) } + static func compositedChromeOpacity(_ opacity: Double) -> CGFloat { + let alpha = clampedOpacity(opacity) + return alpha >= 0.999 ? alpha : 1.0 - pow(1.0 - alpha, 2) + } + static func color(backgroundColor: NSColor, opacity: Double) -> NSColor { backgroundColor.withAlphaComponent(clampedOpacity(opacity)) } + static func compositedChromeColor(backgroundColor: NSColor, opacity: Double) -> NSColor { + backgroundColor.withAlphaComponent(compositedChromeOpacity(opacity)) + } + static func color( from notification: Notification?, fallbackColor: NSColor, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 6df6abaf3a..d8d749b84a 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -981,15 +981,6 @@ class TabManager: ObservableObject { } func startSearch() { - if let panel = selectedTerminalPanel { - if panel.searchState == nil { - panel.searchState = TerminalSurface.SearchState() - } - NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) - NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) - _ = panel.performBindingAction("start_search") - return - } if let panel = selectedTerminalPanel { let hadExistingSearch = panel.searchState != nil let handled = startOrFocusTerminalSearch(panel.surface) @@ -1010,12 +1001,13 @@ class TabManager: ObservableObject { func searchSelection() { guard let panel = selectedTerminalPanel else { return } - if panel.searchState == nil { - panel.searchState = TerminalSurface.SearchState() +#if DEBUG + dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))") +#endif + panel.surface.requestGhosttySearchActivation(.searchSelection) + if !panel.performBindingAction("search_selection") { + panel.surface.clearGhosttySearchActivationRequest() } - NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) - NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) - _ = panel.performBindingAction("search_selection") } func findNext() { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b2c0ca68d2..43aa01828e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5149,7 +5149,7 @@ final class Workspace: Identifiable, ObservableObject { } static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String { - let themedColor = GhosttyBackgroundTheme.color( + let themedColor = GhosttyBackgroundTheme.compositedChromeColor( backgroundColor: backgroundColor, opacity: backgroundOpacity ) diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index a74efffaff..7d6772ea82 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -205,27 +205,41 @@ struct WorkspaceContentView: View { defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }, defaultBackgroundOpacity: () -> Double = { GhosttyApp.shared.defaultBackgroundOpacity } ) -> GhosttyConfig { - var next = loadConfig() - let loadedBackgroundHex = next.backgroundColor.hexString() - let defaultBackgroundHex: String - let resolvedBackground: NSColor + let loadedConfig = loadConfig() + let loadedBackgroundHex = loadedConfig.backgroundColor.hexString() + let loadedOpacity = loadedConfig.backgroundOpacity + let next: GhosttyConfig + let runtimeBackgroundHex: String + let runtimeOpacity: Double + let chromeSource: String if let backgroundOverride { - resolvedBackground = backgroundOverride - defaultBackgroundHex = "skipped" + var overrideConfig = loadedConfig + overrideConfig.backgroundColor = backgroundOverride + next = overrideConfig + runtimeBackgroundHex = backgroundOverride.hexString() + runtimeOpacity = overrideConfig.backgroundOpacity + chromeSource = "override" } else { - let fallback = defaultBackground() - resolvedBackground = fallback - defaultBackgroundHex = fallback.hexString() + let runtimeBackground = defaultBackground() + let resolved = cmuxResolveGhosttyChromeConfig( + loadedConfig: loadedConfig, + runtimeBackgroundColor: runtimeBackground, + runtimeBackgroundOpacity: defaultBackgroundOpacity() + ) + next = resolved + runtimeBackgroundHex = runtimeBackground.hexString() + runtimeOpacity = defaultBackgroundOpacity() + chromeSource = + resolved.backgroundColor.hexString() == loadedBackgroundHex && + abs(resolved.backgroundOpacity - loadedOpacity) <= 0.0001 + ? "loaded" + : "runtime" } - next.backgroundColor = resolvedBackground - // Use the runtime opacity from the Ghostty engine, which may differ from the - // file-level value parsed by GhosttyConfig.load(). - next.backgroundOpacity = defaultBackgroundOpacity() if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( - "theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) opacity=\(String(format: "%.3f", next.backgroundOpacity)) theme=\(next.theme ?? "nil")" + "theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) loadedOpacity=\(String(format: "%.3f", loadedOpacity)) overrideBg=\(backgroundOverride?.hexString() ?? "nil") runtimeBg=\(runtimeBackgroundHex) runtimeOpacity=\(String(format: "%.3f", runtimeOpacity)) finalBg=\(next.backgroundColor.hexString()) finalOpacity=\(String(format: "%.3f", next.backgroundOpacity)) chromeSource=\(chromeSource) theme=\(next.theme ?? "nil")" ) } return next @@ -248,7 +262,8 @@ struct WorkspaceContentView: View { let payloadLabel = notificationPayloadHex ?? "nil" let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString() let opacityChanged = abs(config.backgroundOpacity - next.backgroundOpacity) > 0.0001 - let shouldRequestTitlebarRefresh = backgroundChanged || opacityChanged || reason == "onAppear" + let blurChanged = config.backgroundBlur != next.backgroundBlur + let shouldRequestTitlebarRefresh = backgroundChanged || opacityChanged || blurChanged || reason == "onAppear" logTheme( "theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")" ) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 57895a69de..7f777dc6af 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -141,6 +141,20 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertEqual(config.backgroundOpacity, 0.42, accuracy: 0.0001) } + func testParseBackgroundBlurReadsMacOSGlassValue() { + var config = GhosttyConfig() + config.parse("background-blur = macos-glass-regular") + XCTAssertEqual(config.backgroundBlur, .macosGlassRegular) + XCTAssertTrue(config.backgroundBlur.isGlassStyle) + } + + func testParseBackgroundBlurTreatsNumericRadiusAsEnabled() { + var config = GhosttyConfig() + config.parse("background-blur = 24") + XCTAssertEqual(config.backgroundBlur, .enabled) + XCTAssertFalse(config.backgroundBlur.isGlassStyle) + } + func testLoadThemeResolvesBuiltinAliasFromGhosttyResourcesDir() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-ghostty-themes-\(UUID().uuidString)") @@ -695,6 +709,56 @@ final class WorkspaceAppearanceConfigResolutionTests: XCTestCase { } } +final class MainWindowTitlebarOpacityTests: XCTestCase { + func testUsesCompositedOpacityForTranslucentBackground() { + XCTAssertEqual( + cmuxMainWindowTitlebarOpacity(backgroundOpacity: 0.57), + 1.0 - pow(1.0 - 0.57, 2), + accuracy: 0.0001 + ) + } + + func testKeepsOpaqueBackgroundOpaque() { + XCTAssertEqual( + cmuxMainWindowTitlebarOpacity(backgroundOpacity: 1.0), + 1.0, + accuracy: 0.0001 + ) + } +} + +final class GhosttyChromeConfigResolutionTests: XCTestCase { + func testPrefersLoadedThemeWhenRuntimeBackgroundBecomesClear() { + var loaded = GhosttyConfig() + loaded.backgroundColor = NSColor(hex: "#272822")! + loaded.backgroundOpacity = 0.8 + + let resolved = cmuxResolveGhosttyChromeConfig( + loadedConfig: loaded, + runtimeBackgroundColor: NSColor(hex: "#000004")!, + runtimeBackgroundOpacity: 0.0 + ) + + XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822") + XCTAssertEqual(resolved.backgroundOpacity, 0.8, accuracy: 0.0001) + } + + func testUsesRuntimeBackgroundWhenRuntimeOpacityIsVisible() { + var loaded = GhosttyConfig() + loaded.backgroundColor = NSColor(hex: "#272822")! + loaded.backgroundOpacity = 0.8 + + let resolved = cmuxResolveGhosttyChromeConfig( + loadedConfig: loaded, + runtimeBackgroundColor: NSColor(hex: "#112233")!, + runtimeBackgroundOpacity: 0.42 + ) + + XCTAssertEqual(resolved.backgroundColor.hexString(), "#112233") + XCTAssertEqual(resolved.backgroundOpacity, 0.42, accuracy: 0.0001) + } +} + @MainActor final class WorkspaceChromeColorTests: XCTestCase { func testBonsplitChromeHexIncludesAlphaWhenTranslucent() { @@ -706,7 +770,7 @@ final class WorkspaceChromeColorTests: XCTestCase { ) let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 0.5) - XCTAssertEqual(hex, "#1122337F") + XCTAssertEqual(hex, "#112233BF") } func testBonsplitChromeHexOmitsAlphaWhenOpaque() { diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 9faffe0a32..c9d6e34105 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -1440,6 +1440,97 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) } + func testPendingGhosttyStartSearchFallbackCreatesSearchStateWhenCallbackDoesNotArrive() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + surface.requestGhosttySearchActivation(.startSearch) + + var focusNotificationCount = 0 + cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(surface) { _ in + focusNotificationCount += 1 + } + + XCTAssertNotNil(surface.searchState) + XCTAssertNil(surface.consumeGhosttySearchActivationRequest()) + XCTAssertEqual( + focusNotificationCount, + 1, + "Explicit Find requests should still open a search state if Ghostty never calls back" + ) + } + + func testGhosttyStartSearchIgnoresEmptyUnsolicitedRequests() { + XCTAssertEqual( + cmuxResolveGhosttyStartSearch( + existingSearchState: false, + pendingRequest: nil, + needle: nil + ), + .ignore + ) + XCTAssertEqual( + cmuxResolveGhosttyStartSearch( + existingSearchState: false, + pendingRequest: nil, + needle: "" + ), + .ignore + ) + XCTAssertEqual( + cmuxResolveGhosttyStartSearch( + existingSearchState: false, + pendingRequest: nil, + needle: "needle" + ), + .ignore + ) + } + + func testGhosttyStartSearchCreatesEmptyStateForExplicitOpenRequest() { + XCTAssertEqual( + cmuxResolveGhosttyStartSearch( + existingSearchState: false, + pendingRequest: .startSearch, + needle: nil + ), + .createEmpty + ) + } + + func testGhosttyStartSearchCreatesNeedleStateForSelectionRequest() { + XCTAssertEqual( + cmuxResolveGhosttyStartSearch( + existingSearchState: false, + pendingRequest: .searchSelection, + needle: "term" + ), + .createWithNeedle("term") + ) + } + + func testGhosttyStartSearchUpdatesExistingStateWhenNeedleArrives() { + XCTAssertEqual( + cmuxResolveGhosttyStartSearch( + existingSearchState: true, + pendingRequest: nil, + needle: "term" + ), + .updateExistingNeedle("term") + ) + XCTAssertEqual( + cmuxResolveGhosttyStartSearch( + existingSearchState: true, + pendingRequest: nil, + needle: nil + ), + .focusExisting + ) + } + func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() { _ = NSApplication.shared diff --git a/cmuxUITests/AutomationSocketUITests.swift b/cmuxUITests/AutomationSocketUITests.swift index ee2c189e67..edf03f42bd 100644 --- a/cmuxUITests/AutomationSocketUITests.swift +++ b/cmuxUITests/AutomationSocketUITests.swift @@ -2,23 +2,43 @@ import XCTest import Foundation final class AutomationSocketUITests: XCTestCase { + private struct CmuxCommandResult { + let terminationStatus: Int32 + let stdout: String + let stderr: String + } + + private struct SocketSurface { + let id: String + } + private var socketPath = "" + private var diagnosticsPath = "" + private var ensureTerminalSurfaceFailure = "" + private var lastPingResponse = "" private let defaultsDomain = "com.cmuxterm.app.debug" private let modeKey = "socketControlMode" private let legacyKey = "socketControlEnabled" - private let launchTag = "ui-tests-automation-socket" override func setUp() { super.setUp() continueAfterFailure = false - socketPath = "/tmp/cmux-debug-\(UUID().uuidString).sock" + socketPath = "/tmp/cmux-ui-test-automation-socket-\(UUID().uuidString).sock" + diagnosticsPath = "/tmp/cmux-ui-test-diagnostics-\(UUID().uuidString).json" + ensureTerminalSurfaceFailure = "" + lastPingResponse = "" resetSocketDefaults() removeSocketFile() + try? FileManager.default.removeItem(atPath: diagnosticsPath) + + let cleanup = XCUIApplication() + cleanup.terminate() + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } func testSocketToggleDisablesAndEnables() { let app = configuredApp(mode: "cmuxOnly") - app.launch() + launchAndActivate(app) XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: 12.0), "Expected app to launch for socket toggle test. state=\(app.state.rawValue)" @@ -35,7 +55,7 @@ final class AutomationSocketUITests: XCTestCase { func testSocketDisabledWhenSettingOff() { let app = configuredApp(mode: "off") - app.launch() + launchAndActivate(app) XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: 12.0), "Expected app to launch for socket off test. state=\(app.state.rawValue)" @@ -45,17 +65,75 @@ final class AutomationSocketUITests: XCTestCase { app.terminate() } + func testSurfaceListStillRespondsAfterRepeatedSendKey() { + let app = configuredApp(mode: "allowAll") + app.launchEnvironment["CMUX_UI_TEST_AUTOMATION_SOCKET_STRESS"] = "1" + launchAndActivate(app) + defer { + if app.state != .notRunning { + app.terminate() + } + } + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for repeated send-key socket test. state=\(app.state.rawValue)" + ) + + guard let resolvedPath = resolveSocketPath(timeout: 5.0) else { + XCTFail("Expected control socket to exist for repeated send-key socket test.") + return + } + socketPath = resolvedPath + guard let socketDiagnostics = waitForSocketReadyDiagnostics(timeout: 12.0) else { + XCTFail( + "Expected control socket diagnostics to report ready at \(socketPath). " + + "lastPing=\(lastPingResponse.isEmpty ? "" : lastPingResponse) " + + "diagnostics=\(loadDiagnostics() ?? [:])" + ) + return + } + XCTAssertEqual( + socketDiagnostics["socketPingResponse"], + "PONG", + "Expected app-side socket sanity ping to succeed before repeated send-key test. " + + "diagnostics=\(socketDiagnostics)" + ) + XCTAssertTrue( + waitForAutomationSocketStress(timeout: 20.0), + "Expected automation socket stress harness to finish. diagnostics=\(loadDiagnostics() ?? [:])" + ) + + let finalDiagnostics = loadDiagnostics() ?? [:] + XCTAssertEqual( + finalDiagnostics["automationSocketStressStatus"], + "passed", + "Expected repeated send_key socket stress to pass. diagnostics=\(finalDiagnostics)" + ) + XCTAssertEqual( + finalDiagnostics["automationSocketStressIterationsCompleted"], + "8", + "Expected repeated send_key socket stress to complete all iterations. diagnostics=\(finalDiagnostics)" + ) + } + private func configuredApp(mode: String) -> XCUIApplication { let app = XCUIApplication() app.launchArguments += ["-\(modeKey)", mode] app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1" + app.launchEnvironment["CMUX_SOCKET_MODE"] = mode app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" - // Debug launches require a tag outside reload.sh; provide one in UITests so CI - // does not fail with "Application ... does not have a process ID". - app.launchEnvironment["CMUX_TAG"] = launchTag + app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath return app } + private func launchAndActivate(_ app: XCUIApplication) { + app.launch() + app.activate() + } + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { if app.wait(for: .runningForeground, timeout: timeout) { return true @@ -68,51 +146,579 @@ final class AutomationSocketUITests: XCTestCase { return false } - private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool { + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { let expectation = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in - FileManager.default.fileExists(atPath: self.socketPath) == exists + predicate() }, object: NSObject() ) return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } - private func resolveSocketPath(timeout: TimeInterval) -> String? { - var resolvedPath: String? + private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool { let expectation = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in - if FileManager.default.fileExists(atPath: self.socketPath) { - resolvedPath = self.socketPath - return true - } - if let found = self.findSocketInTmp() { - resolvedPath = found - return true - } - return false + FileManager.default.fileExists(atPath: self.socketPath) == exists }, object: NSObject() ) - if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed { - return resolvedPath + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } + + private func waitForSocketPong(timeout: TimeInterval) -> String? { + var lastResponse: String? + _ = waitForCondition(timeout: timeout) { + lastResponse = self.socketCommand("ping", responseTimeout: 1.5) + self.lastPingResponse = lastResponse ?? "" + return lastResponse == "PONG" + } + let finalResponse = lastResponse == "PONG" ? "PONG" : (socketCommand("ping", responseTimeout: 1.5) ?? lastResponse) + lastPingResponse = finalResponse ?? "" + return finalResponse + } + + private func waitForSocketReadyDiagnostics(timeout: TimeInterval) -> [String: String]? { + var lastDiagnostics: [String: String]? + let isReady = waitForCondition(timeout: timeout) { + guard let diagnostics = self.loadDiagnostics() else { return false } + lastDiagnostics = diagnostics + if let expectedPath = diagnostics["socketExpectedPath"], !expectedPath.isEmpty, expectedPath != self.socketPath { + return false + } + return diagnostics["socketReady"] == "1" + } + return isReady ? lastDiagnostics : loadDiagnostics() + } + + private func resolveSocketPath(timeout: TimeInterval) -> String? { + guard waitForSocket(exists: true, timeout: timeout) else { + return nil + } + return socketPath + } + + private func waitForAutomationSocketStress(timeout: TimeInterval) -> Bool { + waitForCondition(timeout: timeout) { + guard let diagnostics = self.loadDiagnostics() else { return false } + return diagnostics["automationSocketStressDone"] == "1" + } + } + + private func currentWorkspaceId() -> String? { + guard let response = socketCommand("current_workspace", responseTimeout: 4.0)? + .trimmingCharacters(in: .whitespacesAndNewlines), + UUID(uuidString: response) != nil else { + return nil + } + return response + } + + private func listSurfaces(workspaceId: String?) -> [SocketSurface]? { + let command: String + if let workspaceId, !workspaceId.isEmpty { + command = "list_surfaces \(workspaceId)" + } else { + command = "list_surfaces" + } + guard let response = socketCommand(command, responseTimeout: 4.0) else { + return nil + } + if response == "No surfaces" { + return [] + } + return parseSocketList(response).map { SocketSurface(id: $0.id) } + } + + private func parseSocketList(_ response: String) -> [(id: String, isSelected: Bool)] { + response + .split(separator: "\n", omittingEmptySubsequences: true) + .compactMap { rawLine in + var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { return nil } + let isSelected = line.hasPrefix("*") + if line.hasPrefix("* ") || line.hasPrefix(" ") { + line = String(line.dropFirst(2)) + } + let parts = line.split(whereSeparator: \.isWhitespace) + guard parts.count >= 2 else { return nil } + let id = String(parts[1]) + guard UUID(uuidString: id) != nil else { return nil } + return (id: id, isSelected: isSelected) + } + } + + private func okUUID(from response: String?) -> String? { + guard let response else { return nil } + let parts = response.split(whereSeparator: \.isWhitespace) + guard parts.count >= 2, parts[0] == "OK" else { return nil } + let id = String(parts[1]) + guard UUID(uuidString: id) != nil else { return nil } + return id + } + + private func socketCommand(_ command: String, responseTimeout: TimeInterval = 2.0) -> String? { + if let response = ControlSocketClient(path: socketPath, responseTimeout: responseTimeout).sendLine(command) { + return response + } + return socketCommandViaNetcat(command, responseTimeout: responseTimeout) + } + + private func socketCommandViaNetcat(_ command: String, responseTimeout: TimeInterval = 2.0) -> String? { + let netcatPath = "/usr/bin/nc" + guard FileManager.default.isExecutableFile(atPath: netcatPath) else { return nil } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + let timeoutSeconds = max(1, Int(ceil(responseTimeout))) + let script = + "printf '%s\\n' \(shellSingleQuote(command)) | " + + "\(netcatPath) -U \(shellSingleQuote(socketPath)) -w \(timeoutSeconds) 2>/dev/null" + process.arguments = ["-lc", script] + + let outputPipe = Pipe() + process.standardOutput = outputPipe + + do { + try process.run() + } catch { + return nil + } + + process.waitUntilExit() + + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: outputData, encoding: .utf8) else { return nil } + if let firstLine = output.split(separator: "\n", maxSplits: 1).first { + return String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines) } - return resolvedPath + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func shellSingleQuote(_ value: String) -> String { + if value.isEmpty { return "''" } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } - private func findSocketInTmp() -> String? { - let tmpPath = "/tmp" - guard let entries = try? FileManager.default.contentsOfDirectory(atPath: tmpPath) else { + private func ensureTerminalSurface(timeout: TimeInterval) -> (workspaceId: String, surfaceId: String)? { + ensureTerminalSurfaceFailure = "" + var traceParts: [String] = [ + "ping=\(socketCommand("ping", responseTimeout: 1.5) ?? "")", + "current-window=\(socketCommand("current_window", responseTimeout: 4.0) ?? "")", + "current-workspace=\(socketCommand("current_workspace", responseTimeout: 4.0) ?? "")", + "list-workspaces.initial=\(socketCommand("list_workspaces", responseTimeout: 4.0) ?? "")", + "list-surfaces.initial=\(socketCommand("list_surfaces", responseTimeout: 4.0) ?? "")", + ] + + let foundExistingSurface = waitForCondition(timeout: min(timeout, 6.0)) { + self.terminalSurface() != nil + } + traceParts.append("existing-surface-ready=\(foundExistingSurface ? "1" : "0")") + if let target = terminalSurface() { + ensureTerminalSurfaceFailure = traceParts.joined(separator: " | ") + return target + } + + let workspaceCreateResult = socketCommand("new_workspace", responseTimeout: 4.0) + traceParts.append("new-workspace=\(workspaceCreateResult ?? "")") + guard let workspaceId = okUUID(from: workspaceCreateResult) else { + ensureTerminalSurfaceFailure = traceParts.joined(separator: " | ") return nil } - let matches = entries.filter { $0.hasPrefix("cmux") && $0.hasSuffix(".sock") } - if let debug = matches.first(where: { $0.contains("debug") }) { - return (tmpPath as NSString).appendingPathComponent(debug) + + let workspaceSelectResult = socketCommand("select_workspace \(workspaceId)", responseTimeout: 4.0) + traceParts.append("select-workspace=\(workspaceSelectResult ?? "")") + guard workspaceSelectResult == "OK" else { + ensureTerminalSurfaceFailure = traceParts.joined(separator: " | ") + return nil } - if let first = matches.first { - return (tmpPath as NSString).appendingPathComponent(first) + + let ready = waitForCondition(timeout: timeout) { + self.terminalSurface(workspaceId: workspaceId) != nil + } + traceParts.append("list-workspaces.created=\(self.socketCommand("list_workspaces", responseTimeout: 4.0) ?? "")") + traceParts.append("list-surfaces.created=\(self.socketCommand("list_surfaces \(workspaceId)", responseTimeout: 4.0) ?? "")") + ensureTerminalSurfaceFailure = traceParts.joined(separator: " | ") + guard ready else { return nil } + return terminalSurface(workspaceId: workspaceId) + } + + private func terminalSurface(workspaceId: String? = nil) -> (workspaceId: String, surfaceId: String)? { + guard let resolvedWorkspaceId = workspaceId ?? currentWorkspaceId(), + let surfaces = listSurfaces(workspaceId: resolvedWorkspaceId), + let surface = surfaces.first else { + return nil + } + return (workspaceId: resolvedWorkspaceId, surfaceId: surface.id) + } + + private func resolvedWindowId(from payload: [String: Any]?) -> String? { + guard let windows = payload?["windows"] as? [[String: Any]], !windows.isEmpty else { + return nil + } + let preferred = windows.first(where: { ($0["key"] as? Bool) == true }) ?? + windows.first(where: { ($0["visible"] as? Bool) == true }) ?? + windows.first + guard let windowId = preferred?["id"] as? String, !windowId.isEmpty else { + return nil + } + return windowId + } + + private func workspaceList(windowId: String?) -> (command: CmuxCommandResult, payload: [String: Any]?)? { + guard let windowId, !windowId.isEmpty else { return nil } + return runCmuxJSON( + arguments: [ + "--window", + windowId, + "list-workspaces", + ], + responseTimeoutSeconds: 4.0 + ) + } + + private func panelList( + windowId: String?, + workspaceId: String? + ) -> (command: CmuxCommandResult, payload: [String: Any]?)? { + guard let windowId, !windowId.isEmpty else { return nil } + var arguments = [ + "--window", + windowId, + "list-panels", + ] + if let workspaceId, !workspaceId.isEmpty { + arguments.append(contentsOf: ["--workspace", workspaceId]) + } + return runCmuxJSON(arguments: arguments, responseTimeoutSeconds: 4.0) + } + + private func runCmuxJSON( + arguments: [String], + responseTimeoutSeconds: Double = 3.0 + ) -> (command: CmuxCommandResult, payload: [String: Any]?)? { + let command = runCmuxCommand( + arguments: ["--json", "--id-format", "uuids"] + arguments, + responseTimeoutSeconds: responseTimeoutSeconds + ) + let raw = command.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { + return (command: command, payload: nil) + } + guard let data = raw.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return (command: command, payload: nil) + } + return (command: command, payload: payload) + } + + private func runCmuxCommand( + arguments: [String], + responseTimeoutSeconds: Double = 3.0 + ) -> CmuxCommandResult { + var args = ["--socket", socketPath] + args.append(contentsOf: arguments) + + var environment = ProcessInfo.processInfo.environment + environment["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"] = String(responseTimeoutSeconds) + + let cliPaths = resolveCmuxCLIPaths() + if cliPaths.isEmpty { + return CmuxCommandResult( + terminationStatus: -1, + stdout: "", + stderr: "Failed to locate bundled cmux CLI" + ) + } + + var lastPermissionFailure: CmuxCommandResult? + for cliPath in cliPaths { + let result = executeCmuxCommand( + executablePath: cliPath, + arguments: args, + environment: environment + ) + if result.terminationStatus == 0 { + return result + } + if isSocketPermissionFailure(result.stderr) { + lastPermissionFailure = result + continue + } + return result + } + + let fallbackResult = executeCmuxCommand( + executablePath: "/usr/bin/env", + arguments: ["cmux"] + args, + environment: environment + ) + if fallbackResult.terminationStatus == 0 || lastPermissionFailure == nil { + return fallbackResult + } + return lastPermissionFailure ?? fallbackResult + } + + private func describeCommandResult(_ result: (command: CmuxCommandResult, payload: [String: Any]?)?) -> String { + guard let result else { return "" } + let stdout = result.command.stdout.isEmpty ? "" : result.command.stdout + let stderr = result.command.stderr.isEmpty ? "" : result.command.stderr + return "status=\(result.command.terminationStatus) stdout=\(stdout) stderr=\(stderr)" + } + + private func resolveCmuxCLIPaths() -> [String] { + let fileManager = FileManager.default + let env = ProcessInfo.processInfo.environment + var candidates: [String] = [] + var productDirectories: [String] = [] + + for key in ["CMUX_UI_TEST_CLI_PATH", "CMUXTERM_CLI"] { + if let value = env[key], !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(value) + } + } + + if let builtProductsDir = env["BUILT_PRODUCTS_DIR"], !builtProductsDir.isEmpty { + productDirectories.append(builtProductsDir) + } + + if let hostPath = env["TEST_HOST"], !hostPath.isEmpty { + let hostURL = URL(fileURLWithPath: hostPath) + let productsDir = hostURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + productDirectories.append(productsDir) + } + + productDirectories.append(contentsOf: inferredBuildProductsDirectories()) + for productsDir in uniquePaths(productDirectories) { + appendCLIPathCandidates(fromProductsDirectory: productsDir, to: &candidates) + } + + var resolvedPaths: [String] = [] + for path in uniquePaths(candidates) { + guard fileManager.isExecutableFile(atPath: path) else { continue } + resolvedPaths.append(URL(fileURLWithPath: path).resolvingSymlinksInPath().path) + } + return uniquePaths(resolvedPaths) + } + + private func inferredBuildProductsDirectories() -> [String] { + let bundleURLs = [ + Bundle.main.bundleURL, + Bundle(for: Self.self).bundleURL, + ] + + return bundleURLs.compactMap { bundleURL in + let standardizedPath = bundleURL.standardizedFileURL.path + let components = standardizedPath.split(separator: "/") + guard let productsIndex = components.firstIndex(of: "Products"), + productsIndex + 1 < components.count else { + return nil + } + let prefixComponents = components.prefix(productsIndex + 2) + return "/" + prefixComponents.joined(separator: "/") + } + } + + private func appendCLIPathCandidates(fromProductsDirectory productsDir: String, to candidates: inout [String]) { + candidates.append("\(productsDir)/cmux DEV.app/Contents/Resources/bin/cmux") + candidates.append("\(productsDir)/cmux.app/Contents/Resources/bin/cmux") + candidates.append("\(productsDir)/cmux") + + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: productsDir) else { + return + } + + for entry in entries.sorted() where entry.hasSuffix(".app") { + let cliPath = URL(fileURLWithPath: productsDir) + .appendingPathComponent(entry) + .appendingPathComponent("Contents/Resources/bin/cmux") + .path + candidates.append(cliPath) + } + for entry in entries.sorted() where entry == "cmux" { + let cliPath = URL(fileURLWithPath: productsDir) + .appendingPathComponent(entry) + .path + candidates.append(cliPath) + } + } + + private func executeCmuxCommand( + executablePath: String, + arguments: [String], + environment: [String: String] + ) -> CmuxCommandResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + process.waitUntilExit() + } catch { + return CmuxCommandResult( + terminationStatus: -1, + stdout: "", + stderr: "Failed to run cmux command: \(error.localizedDescription) (cliPath=\(executablePath))" + ) + } + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stdout = String(data: stdoutData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let rawStderr = String(data: stderrData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let stderr = rawStderr.isEmpty ? "" : "\(rawStderr) (cliPath=\(executablePath))" + return CmuxCommandResult( + terminationStatus: process.terminationStatus, + stdout: stdout, + stderr: stderr + ) + } + + private func isSocketPermissionFailure(_ stderr: String?) -> Bool { + guard let stderr, !stderr.isEmpty else { return false } + return stderr.localizedCaseInsensitiveContains("failed to connect to socket") && + stderr.localizedCaseInsensitiveContains("operation not permitted") + } + + private func uniquePaths(_ paths: [String]) -> [String] { + var unique: [String] = [] + var seen = Set() + for path in paths { + if seen.insert(path).inserted { + unique.append(path) + } + } + return unique + } + + private final class ControlSocketClient { + private let path: String + private let responseTimeout: TimeInterval + + init(path: String, responseTimeout: TimeInterval) { + self.path = path + self.responseTimeout = responseTimeout + } + + func sendLine(_ line: String) -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + +#if os(macOS) + var noSigPipe: Int32 = 1 + _ = withUnsafePointer(to: &noSigPipe) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_NOSIGPIPE, + ptr, + socklen_t(MemoryLayout.size) + ) + } +#endif + var socketTimeout = timeval( + tv_sec: Int(responseTimeout.rounded(.down)), + tv_usec: Int32(((responseTimeout - floor(responseTimeout)) * 1_000_000).rounded()) + ) + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_SNDTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.size) + addr.sun_family = sa_family_t(AF_UNIX) + + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + let bytes = Array(path.utf8CString) + guard bytes.count <= maxLen else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + memset(raw, 0, maxLen) + for index in 0...offset(of: \.sun_path) ?? 0 + let addrLen = socklen_t(pathOffset + bytes.count) +#if os(macOS) + addr.sun_len = UInt8(min(Int(addrLen), 255)) +#endif + + let connected = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + connect(fd, sa, addrLen) + } + } + guard connected == 0 else { return nil } + + let payload = line + "\n" + let wrote: Bool = payload.withCString { cstr in + var remaining = strlen(cstr) + var pointer = UnsafeRawPointer(cstr) + while remaining > 0 { + let written = write(fd, pointer, remaining) + if written <= 0 { return false } + remaining -= written + pointer = pointer.advanced(by: written) + } + return true + } + guard wrote else { return nil } + + var buffer = [UInt8](repeating: 0, count: 4096) + var accumulator = "" + while true { + let count = read(fd, &buffer, buffer.count) + if count < 0 { + let code = errno + if code == EAGAIN || code == EWOULDBLOCK { + break + } + return nil + } + if count <= 0 { break } + if let chunk = String(bytes: buffer[0.. [String: String]? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: diagnosticsPath)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return nil + } + return object + } } diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 49de9988fa..2d18649edd 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,11 +12,11 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 12, 2026. +Fork rebased onto upstream `main` at `a2b2b883e` as of March 15, 2026. ### 1) OSC 99 (kitty) notification parser -- Commit: `a2252e7a9` (Add OSC 99 notification parser) +- Commit: `5510f9a36` (Add OSC 99 notification parser) - Files: - `src/terminal/osc.zig` - `src/terminal/osc/parsers.zig` @@ -26,7 +26,7 @@ Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 12, 20 ### 2) macOS display link restart on display changes -- Commit: `c07e6c5a5` (macos: restart display link after display ID change) +- Commit: `65ccdafdf` (macos: restart display link after display ID change) - Files: - `src/renderer/generic.zig` - Summary: @@ -35,7 +35,7 @@ Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 12, 20 ### 3) Keyboard copy mode selection C API -- Commit: `a50579bd5` (Add C API for keyboard copy mode selection) +- Commit: `553acd246` (Add C API for keyboard copy mode selection) - Files: - `src/Surface.zig` - `src/apprt/embedded.zig` @@ -50,8 +50,8 @@ applied earlier than the section 3 copy-mode commit, but they are kept together touch the same stale-frame mitigation path and tend to conflict in the same files during rebases. - Commits: - - `769bbf7a9` (macos: reduce transient blank/scaled frames during resize) - - `9efcdfdf8` (macos: keep top-left gravity for stale-frame replay) + - `aa026c50f` (macos: reduce transient blank/scaled frames during resize) + - `e63d8af3a` (macos: keep top-left gravity for stale-frame replay) - Files: - `pkg/macos/animation.zig` - `src/Surface.zig` @@ -63,34 +63,27 @@ touch the same stale-frame mitigation path and tend to conflict in the same file - Replays the last rendered frame during resize and keeps its geometry anchored correctly. - Reduces transient blank or scaled frames while a macOS window is being resized. -### 5) zsh prompt redraw markers use OSC 133 P +### 5) zsh Pure-style prompt redraw markers -- Commit: `8ade43ce5` (zsh: use OSC 133 P for prompt redraws) -- Files: - - `src/shell-integration/zsh/ghostty-integration` -- Summary: - - Emits one `OSC 133;A` fresh-prompt mark for real prompt transitions. - - Uses `OSC 133;P` markers for prompt redraws so async zsh themes do not look like extra prompt lines. - -### 6) zsh Pure-style multiline prompt redraws - -- Commits: - - `0cf559581` (zsh: fix Pure-style multiline prompt redraws) - - `312c7b23a` (zsh: avoid extra Pure continuation markers) - - `404a3f175` (Fix Pure prompt redraw markers) +- Commit: `bc6c0f70a` (Fix Pure prompt redraw markers) - Files: - `src/shell-integration/zsh/ghostty-integration` - Summary: + - Emits one `OSC 133;A` fresh-prompt mark for real prompt transitions and uses `OSC 133;P` markers for redraws. - Handles multiline prompts that use `\n%{\r%}` to return to column 0 before the visible prompt line. - - Keeps redraw-safe prompt-start markers for async themes. - Avoids inserting an explicit continuation marker after Pure's hidden carriage return, because Ghostty already tracks the newline as prompt continuation and the extra marker duplicates the preprompt row. - - Restores that prompt-marker behavior on top of the current Ghostty `main` base after the older redraw fix drifted out during later submodule updates. + - Keeps the current upstream prompt-preservation flow intact while restoring the Pure-specific redraw behavior cmux needs. -The fork branch HEAD is now the section 6 zsh redraw follow-up commit. +### 6) cmux theme picker helper hooks -### 7) cmux theme picker helper hooks - -- Commit: `0c52c987b` (Add cmux theme picker helper hooks) +- Commits: + - `24f7ae74d` (Add cmux theme picker helper hooks) + - `475f4f3ce` (Fix cmux theme picker preview writes) + - `4c95f358d` (Improve cmux theme picker footer contrast) + - `835d81a40` (Respect system theme in cmux picker) + - `8cc1303d8` (Skip theme detection in cmux picker) + - `ad72b3358` (Match Ghostty theme picker startup) + - `51ba49a4b` (Harden cmux theme override writes) - Files: - `build.zig` - `src/cli/list_themes.zig` @@ -99,8 +92,9 @@ The fork branch HEAD is now the section 6 zsh redraw follow-up commit. - Adds a `zig build cli-helper` step so cmux can bundle Ghostty's CLI helper binary on macOS. - Lets `+list-themes` switch into a cmux-managed mode via env vars, writing the cmux theme override file and posting the existing cmux reload notification for live app-wide preview. - Fixes the helper-only `app-runtime=none` stdout path so the Ghostty CLI binary builds with the current Zig toolchain. + - Aligns preview startup, system-theme handling, footer contrast, and override-file writes with the current upstream picker UI. -The fork branch HEAD is now the section 7 cmux theme picker helper commit. +The fork branch HEAD is now the section 6 theme-picker hardening commit. ## Upstreamed fork changes @@ -124,6 +118,8 @@ These files change frequently upstream; be careful when rebasing the fork: - Prompt marker handling is easy to regress when upstream adjusts zsh redraw behavior. Keep the `OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes. Pure-style `\n%{\r%}` prompt newlines should not get an extra explicit continuation marker after the hidden CR. + - Current upstream also preserves a clean prompt around async redraws, so keep that behavior while + applying the Pure-specific newline guard. - `src/cli/list_themes.zig` - cmux now relies on the upstream picker UI plus local env-driven hooks for live preview and restore. diff --git a/ghostty b/ghostty index bc9be90a21..51ba49a4bb 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42 +Subproject commit 51ba49a4bb74c2f3030b8a2878f4162d2624d4ca diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 1e45a32adf..b230bc0540 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -8,3 +8,4 @@ c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606b 312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30 404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42 6b83b66768e8bba871a3753ae8ffbaabd03370b306c429cd86c9cdcc8db82589 +51ba49a4bb74c2f3030b8a2878f4162d2624d4ca c9fd0b627a3599ab0fc94b75e1651d0a7a8476ff90abb9bb81d99616f1ee994d