diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a5573209..8dc13908 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,11 @@ import Foundation import Darwin +#if canImport(LocalAuthentication) +import LocalAuthentication +#endif +#if canImport(Security) +import Security +#endif #if canImport(Sentry) import Sentry #endif @@ -415,17 +421,22 @@ enum CLIIDFormat: String { } private enum SocketPasswordResolver { + private static let service = "com.cmuxterm.app.socket-control" + private static let account = "local-socket-password" private static let directoryName = "cmux" private static let fileName = "socket-control-password" - static func resolve(explicit: String?) -> String? { + static func resolve(explicit: String?, socketPath: String) -> String? { if let explicit = normalized(explicit) { return explicit } if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]) { return env } - return loadFromFile() + if let filePassword = loadFromFile() { + return filePassword + } + return loadFromKeychain(socketPath: socketPath) } private static func normalized(_ value: String?) -> String? { @@ -449,6 +460,83 @@ private enum SocketPasswordResolver { } return normalized(value) } + + private static func keychainServices(socketPath: String) -> [String] { + guard let scope = keychainScope(socketPath: socketPath) else { + return [service] + } + return ["\(service).\(scope)"] + } + + private static func keychainScope(socketPath: String) -> String? { + if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) { + let scoped = sanitizeScope(tag) + if !scoped.isEmpty { + return scoped + } + } + + let candidate = URL(fileURLWithPath: socketPath).lastPathComponent + let prefixes = ["cmux-debug-", "cmux-"] + for prefix in prefixes { + guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue } + let start = candidate.index(candidate.startIndex, offsetBy: prefix.count) + let end = candidate.index(candidate.endIndex, offsetBy: -".sock".count) + guard start < end else { continue } + let rawScope = String(candidate[start.. String { + let lowered = raw.lowercased() + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) + let mappedScalars = lowered.unicodeScalars.map { scalar -> Character in + allowed.contains(scalar) ? Character(scalar) : "." + } + var normalizedScope = String(mappedScalars) + normalizedScope = normalizedScope.replacingOccurrences( + of: "\\.+", + with: ".", + options: .regularExpression + ) + normalizedScope = normalizedScope.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalizedScope + } + + private static func loadFromKeychain(socketPath: String) -> String? { + for service in keychainServices(socketPath: socketPath) { + let authContext = LAContext() + authContext.interactionNotAllowed = true + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + // Never trigger keychain UI from CLI commands; fail fast instead. + kSecUseAuthenticationContext as String: authContext, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound || status == errSecInteractionNotAllowed || status == errSecAuthFailed { + continue + } + guard status == errSecSuccess else { + continue + } + guard let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { + continue + } + return password + } + return nil + } } final class SocketClient { @@ -469,6 +557,10 @@ final class SocketClient { self.path = path } + var socketPath: String { + path + } + func connect() throws { if socketFD >= 0 { return } @@ -613,8 +705,56 @@ final class SocketClient { struct CMUXCLI { let args: [String] + private static let debugLastSocketHintPath = "/tmp/cmux-last-socket-path" + + private static func normalizedEnvValue(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func pathIsSocket(_ path: String) -> Bool { + var st = stat() + guard lstat(path, &st) == 0 else { return false } + return (st.st_mode & S_IFMT) == S_IFSOCK + } + + private static func debugSocketPathFromHintFile() -> String? { +#if DEBUG + guard let raw = try? String(contentsOfFile: debugLastSocketHintPath, encoding: .utf8) else { + return nil + } + guard let hinted = normalizedEnvValue(raw), + hinted.hasPrefix("/tmp/cmux-debug"), + hinted.hasSuffix(".sock"), + pathIsSocket(hinted) else { + return nil + } + return hinted +#else + return nil +#endif + } + + private static func defaultSocketPath(environment: [String: String]) -> String { + if let explicit = normalizedEnvValue(environment["CMUX_SOCKET_PATH"]) { + return explicit + } +#if DEBUG + if let hinted = debugSocketPathFromHintFile() { + return hinted + } + return "/tmp/cmux-debug.sock" +#else + return "/tmp/cmux.sock" +#endif + } + func run() throws { - var socketPath = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"] ?? "/tmp/cmux.sock" + let environment = ProcessInfo.processInfo.environment + var socketPath = Self.defaultSocketPath(environment: environment) var jsonOutput = false var idFormatArg: String? = nil var windowId: String? = nil @@ -715,7 +855,7 @@ struct CMUXCLI { } defer { client.close() } - if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) { + if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg, socketPath: socketPath) { let authResponse = try client.send(command: "auth \(socketPassword)") if authResponse.hasPrefix("ERROR:"), !authResponse.contains("Unknown command 'auth'") { @@ -1321,109 +1461,6 @@ struct CMUXCLI { throw error } - case "set-status": - let (icon, r1) = parseOption(commandArgs, name: "--icon") - let (color, r2) = parseOption(r1, name: "--color") - let (wsFlag, r3) = parseOption(r2, name: "--workspace") - guard r3.count >= 2 else { - throw CLIError(message: "set-status requires and ") - } - let key = r3[0] - let value = r3.dropFirst().joined(separator: " ") - guard !value.isEmpty else { - throw CLIError(message: "set-status requires a non-empty value") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "set_status \(key) \(socketQuote(value))" - if let icon { socketCmd += " --icon=\(socketQuote(icon))" } - if let color { socketCmd += " --color=\(socketQuote(color))" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-status": - let (wsFlag, csRemaining) = parseOption(commandArgs, name: "--workspace") - guard let key = csRemaining.first else { - throw CLIError(message: "clear-status requires a ") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_status \(key) --tab=\(wsId)", client: client) - print(response) - - case "list-status": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("list_status --tab=\(wsId)", client: client) - print(response) - - case "set-progress": - let (label, spR1) = parseOption(commandArgs, name: "--label") - let (wsFlag, spR2) = parseOption(spR1, name: "--workspace") - guard let valueStr = spR2.first else { - throw CLIError(message: "set-progress requires a progress value (0.0-1.0)") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "set_progress \(valueStr)" - if let label { socketCmd += " --label=\(socketQuote(label))" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-progress": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_progress --tab=\(wsId)", client: client) - print(response) - - case "log": - let (level, r1) = parseOption(commandArgs, name: "--level") - let (source, r2) = parseOption(r1, name: "--source") - let (wsFlag, r3) = parseOption(r2, name: "--workspace") - // Strip leading "--" separator if present - let positional = r3.first == "--" ? Array(r3.dropFirst()) : r3 - let message = positional.joined(separator: " ") - guard !message.isEmpty else { - throw CLIError(message: "log requires a message") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "log" - if let level { socketCmd += " --level=\(level)" } - if let source { socketCmd += " --source=\(socketQuote(source))" } - socketCmd += " --tab=\(wsId) -- \(socketQuote(message))" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-log": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_log --tab=\(wsId)", client: client) - print(response) - - case "list-log": - let (limitStr, r1) = parseOption(commandArgs, name: "--limit") - let (wsFlag, _) = parseOption(r1, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "list_log" - if let limitStr { socketCmd += " --limit=\(limitStr)" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "sidebar-state": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("sidebar_state --tab=\(wsId)", client: client) - print(response) - case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } let response = try sendV1Command("set_app_focus \(value)", client: client) @@ -1510,14 +1547,6 @@ struct CMUXCLI { } } - private func sendV1Command(_ command: String, client: SocketClient) throws -> String { - let response = try client.send(command: command) - if response.hasPrefix("ERROR:") { - throw CLIError(message: response) - } - return response - } - private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat { _ = jsonOutput if let parsed = try CLIIDFormat.parse(raw) { @@ -1526,6 +1555,14 @@ struct CMUXCLI { return .refs } + private func sendV1Command(_ command: String, client: SocketClient) throws -> String { + let response = try client.send(command: command) + if response.hasPrefix("ERROR:") { + throw CLIError(message: response) + } + return response + } + private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any { switch object { case let dict as [String: Any]: @@ -2124,6 +2161,54 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) } + private func runRenameTab( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace") + let (tabOpt, rem1) = parseOption(rem0, name: "--tab") + let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface") + let (titleOpt, rem3) = parseOption(rem2, name: "--title") + + if rem3.contains("--action") { + throw CLIError(message: "rename-tab does not accept --action (it always performs rename)") + } + if let unknown = rem3.first(where: { $0.hasPrefix("--") && $0 != "--" }) { + throw CLIError(message: "rename-tab: unknown flag '\(unknown)'") + } + + let inferredTitle = rem3 + .dropFirst(rem3.first == "--" ? 1 : 0) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))? + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard let title, !title.isEmpty else { + throw CLIError(message: "rename-tab requires a title") + } + + var forwarded: [String] = ["--action", "rename", "--title", title] + if let workspaceOpt { + forwarded += ["--workspace", workspaceOpt] + } + if let tabOpt { + forwarded += ["--tab", tabOpt] + } else if let surfaceOpt { + forwarded += ["--surface", surfaceOpt] + } + + try runTabAction( + commandArgs: forwarded, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowOverride + ) + } private struct SSHCommandOptions { let destination: String let port: Int? @@ -2131,6 +2216,13 @@ struct CMUXCLI { let workspaceName: String? let sshOptions: [String] let extraArguments: [String] + let localSocketPath: String + let remoteRelayPort: Int + } + + private func generateRemoteRelayPort() -> Int { + // Random port in the ephemeral range (49152-65535) + Int.random(in: 49152...65535) } private func runSSH( @@ -2139,7 +2231,10 @@ struct CMUXCLI { jsonOutput: Bool, idFormat: CLIIDFormat ) throws { - let sshOptions = try parseSSHCommandOptions(commandArgs) + // Use the socket path from this invocation (supports --socket overrides). + let localSocketPath = client.socketPath + let remoteRelayPort = generateRemoteRelayPort() + let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) let sshCommand = buildSSHCommandText(sshOptions) let shellFeaturesValue = scopedGhosttyShellFeaturesValue() let sshStartupCommand = buildSSHStartupCommand(sshCommand: sshCommand, shellFeatures: shellFeaturesValue) @@ -2152,6 +2247,8 @@ struct CMUXCLI { guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else { throw CLIError(message: "workspace.create did not return workspace_id") } + let workspaceWindowId = (workspaceCreate["window_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) let remoteSSHOptions = sshOptionsWithControlSocketDefaults(sshOptions.sshOptions) let configuredPayload: [String: Any] @@ -2178,11 +2275,17 @@ struct CMUXCLI { if !remoteSSHOptions.isEmpty { configureParams["ssh_options"] = remoteSSHOptions } + if sshOptions.remoteRelayPort > 0 { + configureParams["relay_port"] = sshOptions.remoteRelayPort + configureParams["local_socket_path"] = sshOptions.localSocketPath + } configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) - _ = try client.sendV2(method: "workspace.select", params: [ - "workspace_id": workspaceId, - ]) + var selectParams: [String: Any] = ["workspace_id": workspaceId] + if let workspaceWindowId, !workspaceWindowId.isEmpty { + selectParams["window_id"] = workspaceWindowId + } + _ = try client.sendV2(method: "workspace.select", params: selectParams) } catch { _ = try? client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) throw error @@ -2195,6 +2298,7 @@ struct CMUXCLI { payload["ssh_env_overrides"] = [ "GHOSTTY_SHELL_FEATURES": shellFeaturesValue, ] + payload["remote_relay_port"] = remoteRelayPort if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { @@ -2205,7 +2309,7 @@ struct CMUXCLI { } } - private func parseSSHCommandOptions(_ commandArgs: [String]) throws -> SSHCommandOptions { + private func parseSSHCommandOptions(_ commandArgs: [String], localSocketPath: String = "", remoteRelayPort: Int = 0) throws -> SSHCommandOptions { var destination: String? var port: Int? var identityFile: String? @@ -2290,7 +2394,9 @@ struct CMUXCLI { identityFile: identityFile, workspaceName: workspaceName, sshOptions: sshOptions, - extraArguments: extraArguments + extraArguments: extraArguments, + localSocketPath: localSocketPath, + remoteRelayPort: remoteRelayPort ) } @@ -2309,8 +2415,35 @@ struct CMUXCLI { for option in effectiveSSHOptions { parts += ["-o", option] } - parts.append(options.destination) - parts.append(contentsOf: options.extraArguments) + + if options.extraArguments.isEmpty { + // No explicit remote command provided: keep destination-only argv so Ghostty's + // ssh-terminfo bootstrap can safely append its own remote install command. + // Use RemoteCommand for session-local PATH bootstrap to make `cmux` available. + if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { + parts.append("-tt") + } + if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") { + var startupExports = [ + "export PATH=\"$HOME/.cmux/bin:$PATH\"", + ] + if options.remoteRelayPort > 0 { + // Pin this shell to the relay allocated for this workspace so parallel + // SSH sessions (including from different cmux versions) don't race on + // shared ~/.cmux/socket_addr. + startupExports.append("export CMUX_SOCKET_PATH=127.0.0.1:\(options.remoteRelayPort)") + } + startupExports.append("exec \"${SHELL:-/bin/zsh}\" -l") + parts += [ + "-o", + "RemoteCommand=\(startupExports.joined(separator: "; "))", + ] + } + parts.append(options.destination) + } else { + parts.append(options.destination) + parts.append(contentsOf: options.extraArguments) + } return parts.map(shellQuote).joined(separator: " ") } @@ -2415,54 +2548,6 @@ fi return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } - private func runRenameTab( - commandArgs: [String], - client: SocketClient, - jsonOutput: Bool, - idFormat: CLIIDFormat, - windowOverride: String? - ) throws { - let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace") - let (tabOpt, rem1) = parseOption(rem0, name: "--tab") - let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface") - let (titleOpt, rem3) = parseOption(rem2, name: "--title") - - if rem3.contains("--action") { - throw CLIError(message: "rename-tab does not accept --action (it always performs rename)") - } - if let unknown = rem3.first(where: { $0.hasPrefix("--") && $0 != "--" }) { - throw CLIError(message: "rename-tab: unknown flag '\(unknown)'") - } - - let inferredTitle = rem3 - .dropFirst(rem3.first == "--" ? 1 : 0) - .joined(separator: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))? - .trimmingCharacters(in: .whitespacesAndNewlines) - - guard let title, !title.isEmpty else { - throw CLIError(message: "rename-tab requires a title") - } - - var forwarded: [String] = ["--action", "rename", "--title", title] - if let workspaceOpt { - forwarded += ["--workspace", workspaceOpt] - } - if let tabOpt { - forwarded += ["--tab", tabOpt] - } else if let surfaceOpt { - forwarded += ["--surface", surfaceOpt] - } - - try runTabAction( - commandArgs: forwarded, - client: client, - jsonOutput: jsonOutput, - idFormat: idFormat, - windowOverride: windowOverride - ) - } private func runBrowserCommand( commandArgs: [String], client: SocketClient, @@ -2565,7 +2650,6 @@ fi return lines.joined(separator: "\n") } - func nonFlagArgs(_ values: [String]) -> [String] { values.filter { !$0.hasPrefix("-") } } @@ -2733,13 +2817,7 @@ fi throw CLIError(message: "browser eval requires a script") } let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed]) - let fallback: String - if let value = payload["value"] { - fallback = displayBrowserValue(value) - } else { - fallback = "OK" - } - output(payload, fallback: fallback) + output(payload, fallback: "OK") return } @@ -3350,8 +3428,7 @@ fi throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid]) - let fallback = displayBrowserLogItems(payload["entries"]) ?? "OK" - output(payload, fallback: fallback) + output(payload, fallback: "OK") return } @@ -3365,8 +3442,7 @@ fi throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)") } let payload = try client.sendV2(method: "browser.errors.list", params: params) - let fallback = displayBrowserLogItems(payload["errors"]) ?? "OK" - output(payload, fallback: fallback) + output(payload, fallback: "OK") return } @@ -3890,7 +3966,7 @@ fi new-terminal-right | new-browser-right reload | duplicate pin | unpin - mark-read | mark-unread + mark-unread Flags: --action Action name (required if not positional) @@ -3909,18 +3985,21 @@ fi return """ Usage: cmux rename-tab [--workspace ] [--tab ] [--surface ] [--] - Rename a tab (surface). Defaults to the focused tab, using: - 1) explicit --tab/--surface - 2) $CMUX_TAB_ID / $CMUX_SURFACE_ID - 3) focused tab in the resolved workspace context + Compatibility alias for tab-action rename. + + Resolution order for target tab: + 1) --tab + 2) --surface + 3) $CMUX_TAB_ID / $CMUX_SURFACE_ID + 4) currently focused tab (optionally within --workspace) Flags: --workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID) - --tab <id|ref> Target tab (accepts tab:<n> or surface:<n>) + --tab <id|ref> Tab target (supports tab:<n> or surface:<n>) --surface <id|ref> Alias for --tab - --title <text> New title (or pass trailing title) + --title <text> Explicit title (or use trailing positional title) - Example: + Examples: cmux rename-tab "build logs" cmux rename-tab --tab tab:3 "staging server" cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" @@ -4759,20 +4838,6 @@ fi return true } - /// Escape and quote a string for safe embedding in a v1 socket command. - /// The socket tokenizer treats `\` and `"` as special inside quoted strings, - /// so both must be escaped before wrapping in double quotes. Newlines and - /// carriage returns must also be escaped since the socket protocol uses - /// newline as the message terminator. - private func socketQuote(_ s: String) -> String { - let escaped = s - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - return "\"\(escaped)\"" - } - private func parseOption(_ args: [String], name: String) -> (String?, [String]) { var remaining: [String] = [] var value: String? @@ -5897,7 +5962,7 @@ fi let subtitle = sanitizeNotificationField(completion.subtitle) let body = sanitizeNotificationField(completion.body) let payload = "\(title)|\(subtitle)|\(body)" - let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) + let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") print(response) } else { print("OK") @@ -5938,7 +6003,7 @@ fi ) } - let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) + let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") _ = try? setClaudeStatus( client: client, workspaceId: workspaceId, @@ -6173,8 +6238,7 @@ fi ] let session = firstString(in: object, keys: ["session_id", "sessionId"]) let message = messageCandidates.compactMap { $0 }.first ?? "Claude needs your input" - let dedupedMessage = dedupeBranchContextLines(message) - let normalizedMessage = normalizedSingleLine(dedupedMessage) + let normalizedMessage = normalizedSingleLine(message) let signal = signalParts.compactMap { $0 }.joined(separator: " ") var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage) @@ -6207,42 +6271,6 @@ fi return ("Attention", body) } - private func dedupeBranchContextLines(_ value: String) -> String { - let lines = value.components(separatedBy: .newlines) - guard lines.count > 1 else { return value } - - var lastIndexByPath: [String: Int] = [:] - for (index, line) in lines.enumerated() { - guard let path = branchContextPath(from: line) else { continue } - lastIndexByPath[path] = index - } - guard !lastIndexByPath.isEmpty else { return value } - - let deduped = lines.enumerated().compactMap { index, line -> String? in - guard let path = branchContextPath(from: line) else { return line } - return lastIndexByPath[path] == index ? line : nil - } - return deduped.joined(separator: "\n") - } - - private func branchContextPath(from line: String) -> String? { - let parts = line.split(separator: "•", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2 else { return nil } - - let branch = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) - let path = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - guard !branch.isEmpty, !path.isEmpty else { return nil } - - let looksLikePath = path.hasPrefix("/") || path.hasPrefix("~") || path.hasPrefix(".") || path.contains("/") - guard looksLikePath else { return nil } - - let trimmedQuotes = path.trimmingCharacters(in: CharacterSet(charactersIn: "`'\"")) - let expanded = NSString(string: trimmedQuotes).expandingTildeInPath - let standardized = NSString(string: expanded).standardizingPath - let normalized = standardized.trimmingCharacters(in: .whitespacesAndNewlines) - return normalized.isEmpty ? nil : normalized - } - private func firstString(in object: [String: Any], keys: [String]) -> String? { for key in keys { guard let value = object[key] else { continue } @@ -6477,8 +6505,6 @@ fi candidates.append(current.appendingPathComponent("Info.plist")) } - // Local dev fallback: resolve version from the repo's app Info.plist - // when running a standalone cmux-cli binary from build/Debug. let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") let repoInfo = current.appendingPathComponent("Resources/Info.plist") if fileManager.fileExists(atPath: projectMarker.path), @@ -6551,8 +6577,8 @@ fi --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings. Commands: - version ping + version capabilities identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] list-windows @@ -6598,18 +6624,6 @@ fi list-notifications clear-notifications claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>] - - # sidebar metadata commands - set-status <key> <value> [--icon <name>] [--color <#hex>] [--workspace <id|ref>] - clear-status <key> [--workspace <id|ref>] - list-status [--workspace <id|ref>] - set-progress <0.0-1.0> [--label <text>] [--workspace <id|ref>] - clear-progress [--workspace <id|ref>] - log [--level <level>] [--source <name>] [--workspace <id|ref>] [--] <message> - clear-log [--workspace <id|ref>] - list-log [--limit <n>] [--workspace <id|ref>] - sidebar-state [--workspace <id|ref>] - set-app-focus <active|inactive|clear> simulate-app-active @@ -6673,7 +6687,9 @@ fi ALL commands (send, list-panels, new-split, notify, etc.). CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab. CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface. - CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock). + CMUX_SOCKET_PATH Override the default Unix socket path. + Debug CLI defaults: /tmp/cmux-last-socket-path -> /tmp/cmux-debug.sock. + Release CLI default: /tmp/cmux.sock. CMUX_CLI_SENTRY_DISABLED Set to 1 to disable CLI Sentry socket diagnostics. """ diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 50f26918..30620e15 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6465,6 +6465,26 @@ private struct TabItemView: View { ) } + private var copyableSidebarSSHError: String? { + let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { + let target = tab.remoteDisplayTarget ?? "unknown" + return "SSH error (\(target)): \(trimmedDetail)" + } + if let statusValue = tab.statusEntries["remote.error"]?.value + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusValue.isEmpty { + return statusValue + } + return nil + } + + private func copyTextToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + var body: some View { let latestNotificationSubtitle = latestNotificationText let orderedPanelIds: [UUID]? = (sidebarShowBranchDirectory || sidebarShowPullRequest) @@ -6525,13 +6545,6 @@ private struct TabItemView: View { .foregroundColor(activeSecondaryColor(0.8)) } - if tab.isRemoteWorkspace { - Image(systemName: remoteStateIcon(tab.remoteConnectionState)) - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(remoteStateColor(tab.remoteConnectionState, isActive: isActive)) - .help(remoteStateHelpText) - } - Text(tab.title) .font(.system(size: 12.5, weight: titleFontWeight)) .foregroundColor(activePrimaryTextColor) @@ -6924,6 +6937,12 @@ private struct TabItemView: View { } } + if let copyableSidebarSSHError { + Button("Copy SSH Error") { + copyTextToPasteboard(copyableSidebarSSHError) + } + } + Divider() Button("Move Up") { @@ -7208,7 +7227,6 @@ private struct TabItemView: View { return "SSH disconnected from \(target)" } } - private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { guard let app = AppDelegate.shared else { return } let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } @@ -7414,45 +7432,6 @@ private struct TabItemView: View { } } - private func remoteStateIcon(_ state: WorkspaceRemoteConnectionState) -> String { - switch state { - case .connected: - return "network" - case .connecting: - return "network.badge.shield.half.filled" - case .error: - return "network.slash" - case .disconnected: - return "network.slash" - } - } - - private func remoteStateColor(_ state: WorkspaceRemoteConnectionState, isActive: Bool) -> Color { - if isActive { - switch state { - case .connected: - return .white.opacity(0.9) - case .connecting: - return .white.opacity(0.85) - case .error: - return .white.opacity(0.9) - case .disconnected: - return .white.opacity(0.65) - } - } - - switch state { - case .connected: - return .green - case .connecting: - return .blue - case .error: - return .red - case .disconnected: - return .secondary - } - } - private func shortenPath(_ path: String, home: String) -> String { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return path } diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 200104df..a98c5338 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -24,9 +24,9 @@ struct TerminalPanelView: View { portalZPriority: portalPriority, showsInactiveOverlay: isSplit && !isFocused, showsUnreadNotificationRing: hasUnreadNotification, + searchState: panel.searchState, inactiveOverlayColor: appearance.unfocusedOverlayNSColor, inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, - searchState: panel.searchState, reattachToken: panel.viewReattachToken, onFocus: { _ in onFocus() }, onTriggerFlash: onTriggerFlash diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 37f98a65..0fa39b0a 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -105,47 +105,6 @@ enum SidebarActiveTabIndicatorSettings { } } -enum WorkspacePlacementSettings { - static let placementKey = "newWorkspacePlacement" - static let defaultPlacement: NewWorkspacePlacement = .afterCurrent - - static func current(defaults: UserDefaults = .standard) -> NewWorkspacePlacement { - guard let raw = defaults.string(forKey: placementKey), - let placement = NewWorkspacePlacement(rawValue: raw) else { - return defaultPlacement - } - return placement - } - - static func insertionIndex( - placement: NewWorkspacePlacement, - selectedIndex: Int?, - selectedIsPinned: Bool, - pinnedCount: Int, - totalCount: Int - ) -> Int { - let clampedTotalCount = max(0, totalCount) - let clampedPinnedCount = max(0, min(pinnedCount, clampedTotalCount)) - - switch placement { - case .top: - // Keep pinned workspaces grouped at the top by inserting ahead of unpinned items. - return clampedPinnedCount - case .end: - return clampedTotalCount - case .afterCurrent: - guard let selectedIndex, clampedTotalCount > 0 else { - return clampedTotalCount - } - let clampedSelectedIndex = max(0, min(selectedIndex, clampedTotalCount - 1)) - if selectedIsPinned { - return clampedPinnedCount - } - return min(clampedSelectedIndex + 1, clampedTotalCount) - } - } -} - struct WorkspaceTabColorEntry: Equatable, Identifiable { let name: String let hex: String @@ -353,6 +312,47 @@ enum WorkspaceTabColorSettings { } } +enum WorkspacePlacementSettings { + static let placementKey = "newWorkspacePlacement" + static let defaultPlacement: NewWorkspacePlacement = .afterCurrent + + static func current(defaults: UserDefaults = .standard) -> NewWorkspacePlacement { + guard let raw = defaults.string(forKey: placementKey), + let placement = NewWorkspacePlacement(rawValue: raw) else { + return defaultPlacement + } + return placement + } + + static func insertionIndex( + placement: NewWorkspacePlacement, + selectedIndex: Int?, + selectedIsPinned: Bool, + pinnedCount: Int, + totalCount: Int + ) -> Int { + let clampedTotalCount = max(0, totalCount) + let clampedPinnedCount = max(0, min(pinnedCount, clampedTotalCount)) + + switch placement { + case .top: + // Keep pinned workspaces grouped at the top by inserting ahead of unpinned items. + return clampedPinnedCount + case .end: + return clampedTotalCount + case .afterCurrent: + guard let selectedIndex, clampedTotalCount > 0 else { + return clampedTotalCount + } + let clampedSelectedIndex = max(0, min(selectedIndex, clampedTotalCount - 1)) + if selectedIsPinned { + return clampedPinnedCount + } + return min(clampedSelectedIndex + 1, clampedTotalCount) + } + } +} + /// Coalesces repeated main-thread signals into one callback after a short delay. /// Useful for notification storms where only the latest update matters. final class NotificationBurstCoalescer { @@ -558,12 +558,9 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback( @MainActor class TabManager: ObservableObject { - /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). - /// Used to apply title updates to the correct window instead of NSApp.keyWindow. - weak var window: NSWindow? - @Published var tabs: [Workspace] = [] @Published private(set) var isWorkspaceCycleHot: Bool = false + weak var window: NSWindow? /// Global monotonically increasing counter for CMUX_PORT ordinal assignment. /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). @@ -571,9 +568,6 @@ class TabManager: ObservableObject { @Published var selectedTabId: UUID? { didSet { guard selectedTabId != oldValue else { return } - sentryBreadcrumb("workspace.switch", data: [ - "tabCount": tabs.count - ]) let previousTabId = oldValue if let previousTabId, let previousPanelId = focusedPanelId(for: previousTabId) { @@ -812,9 +806,6 @@ class TabManager: ObservableObject { if let focusedTerminal = workspace.focusedTerminalPanel { return focusedTerminal } - if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() { - return rememberedTerminal - } if let focusedPaneId = workspace.bonsplitController.focusedPaneId, let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) { return paneTerminal @@ -822,19 +813,71 @@ class TabManager: ObservableObject { return workspace.terminalPanelForConfigInheritance() } - private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? { - if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface { - return cmuxInheritedSurfaceConfig( - sourceSurface: sourceSurface, - context: GHOSTTY_SURFACE_CONTEXT_TAB + func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { + let workspaceSnapshots = tabs + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + .map { $0.sessionSnapshot(includeScrollback: includeScrollback) } + let selectedWorkspaceIndex = selectedTabId.flatMap { selectedId in + tabs.firstIndex(where: { $0.id == selectedId }) + } + return SessionTabManagerSnapshot( + selectedWorkspaceIndex: selectedWorkspaceIndex, + workspaces: workspaceSnapshots + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) { + for tab in tabs { + unwireClosedBrowserTracking(for: tab) + } + + tabs.removeAll(keepingCapacity: false) + lastFocusedPanelByTab.removeAll() + pendingPanelTitleUpdates.removeAll() + tabHistory.removeAll() + historyIndex = -1 + isNavigatingHistory = false + pendingWorkspaceUnfocusTarget = nil + workspaceCycleCooldownTask?.cancel() + workspaceCycleCooldownTask = nil + isWorkspaceCycleHot = false + selectionSideEffectsGeneration &+= 1 + recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) + + let workspaceSnapshots = snapshot.workspaces + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + for workspaceSnapshot in workspaceSnapshots { + let ordinal = Self.nextPortOrdinal + Self.nextPortOrdinal += 1 + let workspace = Workspace( + title: workspaceSnapshot.processTitle, + workingDirectory: workspaceSnapshot.currentDirectory, + portOrdinal: ordinal + ) + workspace.restoreSessionSnapshot(workspaceSnapshot) + wireClosedBrowserTracking(for: workspace) + tabs.append(workspace) + } + + if tabs.isEmpty { + _ = addWorkspace(select: false) + } + + selectedTabId = nil + if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex, + tabs.indices.contains(selectedWorkspaceIndex) { + selectedTabId = tabs[selectedWorkspaceIndex].id + } else { + selectedTabId = tabs.first?.id + } + + if let selectedTabId { + NotificationCenter.default.post( + name: .ghosttyDidFocusTab, + object: nil, + userInfo: [GhosttyNotificationKey.tabId: selectedTabId] ) } - if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() { - var config = ghostty_surface_config_new() - config.font_size = fallbackFontPoints - return config - } - return nil } private func normalizedWorkingDirectory(_ directory: String?) -> String? { @@ -933,11 +976,6 @@ class TabManager: ObservableObject { setCustomTitle(tabId: tabId, title: nil) } - func setTabColor(tabId: UUID, color: String?) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } - tab.setCustomColor(color) - } - func togglePin(tabId: UUID) { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } let tab = tabs[index] @@ -950,6 +988,11 @@ class TabManager: ObservableObject { reorderTabForPinnedState(tab) } + func setTabColor(tabId: UUID, color: String?) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + tab.setCustomColor(color) + } + private func reorderTabForPinnedState(_ tab: Workspace) { guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return } tabs.remove(at: index) @@ -979,7 +1022,6 @@ class TabManager: ObservableObject { func closeWorkspace(_ workspace: Workspace) { guard tabs.count > 1 else { return } - sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) workspace.teardownRemoteConnection() @@ -1313,24 +1355,11 @@ class TabManager: ObservableObject { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } -#if DEBUG - dlog( - "surface.close.runtime tab=\(tabId.uuidString.prefix(5)) " + - "surface=\(surfaceId.uuidString.prefix(5)) panelsBefore=\(tab.panels.count)" - ) -#endif - // Keep AppKit first responder in sync with workspace focus before routing the close. // If split reparenting caused a temporary model/view mismatch, fallback close logic in // Workspace.closePanel uses focused selection to resolve the correct tab deterministically. reconcileFocusedPanelFromFirstResponderForKeyboard() - let closed = tab.closePanel(surfaceId, force: true) -#if DEBUG - dlog( - "surface.close.runtime.done tab=\(tabId.uuidString.prefix(5)) " + - "surface=\(surfaceId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) panelsAfter=\(tab.panels.count)" - ) -#endif + _ = tab.closePanel(surfaceId, force: true) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId) } @@ -1339,33 +1368,6 @@ class TabManager: ObservableObject { /// This should never prompt: the process is already gone, and Ghostty emits the /// `SHOW_CHILD_EXITED` action specifically so the host app can decide what to do. func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } - guard tab.panels[surfaceId] != nil else { return } - -#if DEBUG - dlog( - "surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " + - "surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count)" - ) -#endif - - // Child-exit on the last panel should collapse the workspace, matching explicit close - // semantics (and close the window when it was the last workspace). - if tab.panels.count <= 1 { - if tabs.count <= 1 { - if let app = AppDelegate.shared { - app.notificationStore?.clearNotifications(forTabId: tabId) - app.closeMainWindowContainingTabId(tabId) - } else { - // Headless/test fallback when no AppDelegate window context exists. - closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) - } - } else { - closeWorkspace(tab) - } - return - } - closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) } @@ -1631,8 +1633,8 @@ class TabManager: ObservableObject { private func updateWindowTitle(for tab: Workspace?) { let title = windowTitle(for: tab) - guard let targetWindow = window else { return } - targetWindow.title = title + let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first + targetWindow?.title = title } private func windowTitle(for tab: Workspace?) -> String { @@ -1646,11 +1648,7 @@ class TabManager: ObservableObject { } func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } - if let surfaceId, tab.panels[surfaceId] != nil { - // Keep selected-surface intent stable across selectedTabId didSet async restore. - lastFocusedPanelByTab[tabId] = surfaceId - } + guard tabs.contains(where: { $0.id == tabId }) else { return } selectedTabId = tabId NotificationCenter.default.post( name: .ghosttyDidFocusTab, @@ -1658,15 +1656,10 @@ class TabManager: ObservableObject { userInfo: [GhosttyNotificationKey.tabId: tabId] ) - DispatchQueue.main.async { [weak self] in - guard let self else { return } + DispatchQueue.main.async { NSApp.activate(ignoringOtherApps: true) NSApp.unhide(nil) - if let app = AppDelegate.shared, - let windowId = app.windowId(for: self), - let window = app.mainWindow(for: windowId) { - window.makeKeyAndOrderFront(nil) - } else if let window = NSApp.keyWindow ?? NSApp.windows.first { + if let window = NSApp.keyWindow ?? NSApp.windows.first { window.makeKeyAndOrderFront(nil) } } @@ -1674,7 +1667,7 @@ class TabManager: ObservableObject { if let surfaceId { if !suppressFlash { focusSurface(tabId: tabId, surfaceId: surfaceId) - } else { + } else if let tab = tabs.first(where: { $0.id == tabId }) { tab.focusPanel(surfaceId) } } @@ -1988,13 +1981,12 @@ class TabManager: ObservableObject { /// Create a new split in the specified direction /// Returns the new panel's ID (which is also the surface ID for terminals) - func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { + func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } return tab.newTerminalSplit( from: surfaceId, orientation: direction.orientation, - insertFirst: direction.insertFirst, - focus: focus + insertFirst: direction.insertFirst )?.id } @@ -2096,16 +2088,14 @@ class TabManager: ObservableObject { fromPanelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, - url: URL? = nil, - focus: Bool = true + url: URL? = nil ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } return tab.newBrowserSplit( from: fromPanelId, orientation: orientation, insertFirst: insertFirst, - url: url, - focus: focus + url: url )?.id } @@ -2198,8 +2188,6 @@ class TabManager: ObservableObject { ) } - /// Reopen the most recently closed browser panel (Cmd+Shift+T). - /// No-op when no browser panel restore snapshot is available. @discardableResult func reopenMostRecentlyClosedBrowserPanel() -> Bool { while let snapshot = recentlyClosedBrowsers.pop() { @@ -3078,10 +3066,6 @@ class TabManager: ObservableObject { let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1" let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input") .trimmingCharacters(in: .whitespacesAndNewlines) - let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d" - let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d" - let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger - let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr") .trimmingCharacters(in: .whitespacesAndNewlines) let expectedPanelsAfter = max( @@ -3355,11 +3339,8 @@ class TabManager: ObservableObject { return } - let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift - ? [.control, .shift] - : [.control] - let shouldWaitForSurface = !useEarlyTrigger - + // Wait for the target panel to be fully attached after split churn. + let readyDeadline = Date().addingTimeInterval(2.0) var attachedBeforeTrigger = false var hasSurfaceBeforeTrigger = false if shouldWaitForSurface { @@ -3378,9 +3359,12 @@ class TabManager: ObservableObject { } try? await Task.sleep(nanoseconds: 50_000_000) } - } else if let panel = tab.terminalPanel(for: exitPanelId) { attachedBeforeTrigger = panel.hostedView.window != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil + if attachedBeforeTrigger, hasSurfaceBeforeTrigger { + break + } + try? await Task.sleep(nanoseconds: 50_000_000) } write([ "exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0", @@ -3396,7 +3380,7 @@ class TabManager: ObservableObject { return } // Exercise the real key path (ghostty_surface_key for Ctrl+D). - if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { + if panel.hostedView.sendSyntheticCtrlDForUITest() { write(["autoTriggerSentCtrlDKey1": "1"]) } else { write([ @@ -3408,20 +3392,13 @@ class TabManager: ObservableObject { // In strict mode, never mask routing bugs with fallback writes. if strictKeyOnly { - let strictModeLabel: String = { - if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" } - if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" } - if triggerUsesShift { return "strict_ctrl_shift_d" } - return "strict_ctrl_d" - }() - write(["autoTriggerMode": strictModeLabel]) + write(["autoTriggerMode": "strict_ctrl_d"]) return } // Non-strict mode keeps one additional Ctrl+D retry for startup timing variance. try? await Task.sleep(nanoseconds: 450_000_000) - if tab.panels[exitPanelId] != nil, - panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { + if tab.panels[exitPanelId] != nil, panel.hostedView.sendSyntheticCtrlDForUITest() { write(["autoTriggerSentCtrlDKey2": "1"]) } } @@ -3554,7 +3531,6 @@ extension TabManager { } } } - // MARK: - Direction Types for Backwards Compatibility /// Split direction for backwards compatibility with old API @@ -3594,11 +3570,12 @@ extension Notification.Name { static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface") - static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView") static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar") static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection") static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") + static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") + static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink") } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index b63c1a16..bc075ac8 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -143,6 +143,28 @@ class TerminalController { return socketCommandFocusAllowanceStack.last ?? false } + private func socketCommandAllowsInAppFocusMutations() -> Bool { + Self.allowsInAppFocusMutationsForActiveSocketCommand() + } + + private func v2FocusAllowed(requested: Bool = true) -> Bool { + requested && socketCommandAllowsInAppFocusMutations() + } + + private func v2MaybeFocusWindow(for tabManager: TabManager) { + guard socketCommandAllowsInAppFocusMutations(), + let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { + guard socketCommandAllowsInAppFocusMutations() else { return } + if tabManager.selectedTabId != workspace.id { + tabManager.selectWorkspace(workspace) + } + } + private static func socketCommandAllowsInAppFocusMutations(commandKey: String, isV2: Bool) -> Bool { if isV2 { return focusIntentV2Methods.contains(commandKey) @@ -167,27 +189,26 @@ class TerminalController { return body() } - private func socketCommandAllowsInAppFocusMutations() -> Bool { - Self.allowsInAppFocusMutationsForActiveSocketCommand() - } - - private func v2FocusAllowed(requested: Bool = true) -> Bool { - requested && socketCommandAllowsInAppFocusMutations() - } - - private func v2MaybeFocusWindow(for tabManager: TabManager) { - guard socketCommandAllowsInAppFocusMutations(), - let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - - private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { - guard socketCommandAllowsInAppFocusMutations() else { return } - if tabManager.selectedTabId != workspace.id { - tabManager.selectWorkspace(workspace) +#if DEBUG + static func debugSocketCommandPolicySnapshot( + commandKey: String, + isV2: Bool + ) -> (insideSuppressed: Bool, insideAllowsFocus: Bool, outsideSuppressed: Bool, outsideAllowsFocus: Bool) { + var insideSuppressed = false + var insideAllowsFocus = false + _ = Self.shared.withSocketCommandPolicy(commandKey: commandKey, isV2: isV2) { + insideSuppressed = Self.shouldSuppressSocketCommandActivation() + insideAllowsFocus = Self.socketCommandAllowsInAppFocusMutations() + return 0 } + return ( + insideSuppressed: insideSuppressed, + insideAllowsFocus: insideAllowsFocus, + outsideSuppressed: Self.shouldSuppressSocketCommandActivation(), + outsideAllowsFocus: Self.socketCommandAllowsInAppFocusMutations() + ) } +#endif nonisolated static func shouldReplaceStatusEntry( current: SidebarStatusEntry?, @@ -254,33 +275,6 @@ class TerminalController { return currentSorted != nextSorted } - private struct SocketSurfaceKey: Hashable { - let workspaceId: UUID - let panelId: UUID - } - - private final class SocketFastPathState: @unchecked Sendable { - private let queue = DispatchQueue(label: "com.cmux.socket-fast-path") - private var lastReportedDirectories: [SocketSurfaceKey: String] = [:] - private let maxTrackedDirectories = 4096 - - func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool { - let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId) - return queue.sync { - if lastReportedDirectories[key] == directory { - return false - } - if lastReportedDirectories.count >= maxTrackedDirectories { - lastReportedDirectories.removeAll(keepingCapacity: true) - } - lastReportedDirectories[key] = directory - return true - } - } - } - - private static let socketFastPathState = SocketFastPathState() - nonisolated static func explicitSocketScope( options: [String: String] ) -> (workspaceId: UUID, panelId: UUID)? { @@ -304,6 +298,36 @@ class TerminalController { return trimmed } + nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), + url.isFileURL, + !url.path.isEmpty { + return url.path + } + return trimmed.hasPrefix("/") ? trimmed : nil + } + + nonisolated static func shouldRemoveExportedScreenFile( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let standardizedFile = fileURL.standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return standardizedFile.path.hasPrefix(temporary.path + "/") + } + + nonisolated static func shouldRemoveExportedScreenDirectory( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let directory = fileURL.deletingLastPathComponent().standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return directory.path.hasPrefix(temporary.path + "/") + } + /// Update which window's TabManager receives socket commands. /// This is used when the user switches between multiple terminal windows. func setActiveTabManager(_ tabManager: TabManager?) { @@ -489,14 +513,7 @@ class TerminalController { guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } let validSurfaceIds = Set(workspace.panels.keys) guard validSurfaceIds.contains(panelId) else { return } - let nextPorts = Array(Set(ports)).sorted() - let currentPorts = workspace.surfaceListeningPorts[panelId] ?? [] - guard currentPorts != nextPorts else { return } - if nextPorts.isEmpty { - workspace.surfaceListeningPorts.removeValue(forKey: panelId) - } else { - workspace.surfaceListeningPorts[panelId] = nextPorts - } + workspace.surfaceListeningPorts[panelId] = ports.isEmpty ? nil : ports workspace.recomputeListeningPorts() } } @@ -728,7 +745,7 @@ class TerminalController { defer { close(socket) } // In cmuxOnly mode, verify the connecting process is a descendant of cmux. - // Other modes allow external clients and apply separate auth controls. + // In allowAll mode (env-var only), skip the ancestry check. if accessMode == .cmuxOnly { // Use pre-captured peer PID if available (captured in accept loop before // the peer can disconnect), falling back to live lookup. @@ -799,11 +816,7 @@ class TerminalController { let cmd = parts[0].lowercased() let args = parts.count > 1 ? parts[1] : "" - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - - let response = withSocketCommandPolicy(commandKey: cmd, isV2: false) { + return withSocketCommandPolicy(commandKey: cmd, isV2: false) { switch cmd { case "ping": return "PONG" @@ -1118,25 +1131,13 @@ class TerminalController { case "refresh_surfaces": return refreshSurfaces() - case "surface_health": - return surfaceHealth(args) + case "surface_health": + return surfaceHealth(args) - default: - return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + default: + return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + } } - } - - #if DEBUG - if cmd == "new_workspace" || cmd == "send" || cmd == "send_surface" { - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - let status = response.hasPrefix("OK") ? "ok" : "err" - dlog( - "socket.v1 cmd=\(cmd) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - } - #endif - - return response } // MARK: - V2 JSON Socket Protocol @@ -1171,11 +1172,7 @@ class TerminalController { v2MainSync { self.v2RefreshKnownRefs() } - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - - let response = withSocketCommandPolicy(commandKey: method, isV2: true) { + return withSocketCommandPolicy(commandKey: method, isV2: true) { switch method { case "system.ping": return v2Ok(id: id, result: ["pong": true]) @@ -1551,22 +1548,10 @@ class TerminalController { return v2Result(id: id, self.v2DebugScreenshot(params: params)) #endif - default: - return v2Error(id: id, code: "method_not_found", message: "Unknown method") + default: + return v2Error(id: id, code: "method_not_found", message: "Unknown method") + } } - } - - #if DEBUG - if method == "workspace.create" || method == "surface.send_text" { - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - let status = response.contains("\"ok\":true") ? "ok" : "err" - dlog( - "socket.v2 method=\(method) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - } - #endif - - return response } private func v2Capabilities() -> [String: Any] { @@ -2390,9 +2375,8 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return .err(code: "internal_error", message: "Failed to create window", data: nil) } - // Keep active routing stable unless this command is explicitly focus-intent. - if socketCommandAllowsInAppFocusMutations(), - let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + // The new window should become key, but setActiveTabManager defensively. + if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return .ok([ @@ -2467,9 +2451,6 @@ class TerminalController { var newId: UUID? let shouldFocus = v2FocusAllowed() - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif v2MainSync { let ws = tabManager.addWorkspace( workingDirectory: workingDirectory, @@ -2479,12 +2460,6 @@ class TerminalController { ) newId = ws.id } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - dlog( - "socket.workspace.create focus=\(shouldFocus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - #endif guard let newId else { return .err(code: "internal_error", message: "Failed to create workspace", data: nil) @@ -2508,8 +2483,12 @@ class TerminalController { var success = false v2MainSync { if let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + // If this workspace belongs to another window, bring it forward so focus is visible. + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectWorkspace(ws) success = true } } @@ -2595,7 +2574,7 @@ class TerminalController { guard let windowId = v2UUID(params, "window_id") else { return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil) v2MainSync { @@ -2715,7 +2694,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.selectNextTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -2737,7 +2719,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.selectPreviousTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -2759,7 +2744,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil) v2MainSync { guard let before = tabManager.selectedTabId else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.navigateBack() guard let after = tabManager.selectedTabId, after != before else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -2811,6 +2799,8 @@ class TerminalController { let identityFile = v2RawString(params, "identity_file")?.trimmingCharacters(in: .whitespacesAndNewlines) let sshOptions = v2StringArray(params, "ssh_options") ?? [] let autoConnect = v2Bool(params, "auto_connect") ?? true + let relayPort = v2Int(params, "relay_port") + let localSocketPath = v2RawString(params, "local_socket_path") var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ "workspace_id": workspaceId.uuidString, @@ -2829,7 +2819,9 @@ class TerminalController { port: sshPort, identityFile: identityFile?.isEmpty == true ? nil : identityFile, sshOptions: sshOptions, - localProxyPort: localProxyPort + localProxyPort: localProxyPort, + relayPort: relayPort, + localSocketPath: localSocketPath ) workspace.configureRemoteConnection(config, autoConnect: autoConnect) @@ -3126,7 +3118,7 @@ class TerminalController { "close_left", "close_right", "close_others", "new_terminal_right", "new_browser_right", "reload", "duplicate", - "pin", "unpin", "mark_read", "mark_unread" + "pin", "unpin", "mark_unread" ] var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [ @@ -3139,7 +3131,6 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - let allowFocusMutation = v2FocusAllowed() let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") ?? workspace.focusedPanelId guard let surfaceId else { @@ -3241,10 +3232,6 @@ class TerminalController { workspace.setPanelPinned(panelId: surfaceId, pinned: false) finish(["pinned": false]) - case "mark_read", "mark_as_read": - workspace.markPanelRead(surfaceId) - finish() - case "mark_unread", "mark_as_unread": workspace.markPanelUnread(surfaceId) finish() @@ -3269,7 +3256,7 @@ class TerminalController { guard let newPanel = workspace.newBrowserSurface( inPane: paneId, url: browserPanel.currentURL, - focus: allowFocusMutation + focus: true ) else { result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) return @@ -3290,7 +3277,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: allowFocusMutation) else { + guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -3317,7 +3304,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: allowFocusMutation) else { + guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -3428,7 +3415,7 @@ class TerminalController { "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, "type": panel.panelType.rawValue, - "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, + "title": panel.displayTitle, "focused": panel.id == focusedSurfaceId, "pane_id": v2OrNull(paneUUID?.uuidString), "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), @@ -3504,8 +3491,15 @@ class TerminalController { return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + // Make sure the workspace is selected so focus effects apply to the visible UI. + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } guard ws.panels[surfaceId] != nil else { result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) @@ -3533,8 +3527,13 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } let targetSurfaceId: UUID? = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let targetSurfaceId else { @@ -3546,12 +3545,7 @@ class TerminalController { return } - if let newId = tabManager.newSplit( - tabId: ws.id, - surfaceId: targetSurfaceId, - direction: direction, - focus: v2FocusAllowed() - ) { + if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) { let paneUUID = ws.paneId(forPanelId: newId)?.id let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ @@ -3724,7 +3718,7 @@ class TerminalController { let beforeSurfaceId = v2UUID(params, "before_surface_id") let afterSurfaceId = v2UUID(params, "after_surface_id") let explicitIndex = v2Int(params, "index") - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true let anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) if anchorCount > 1 { @@ -3835,15 +3829,16 @@ class TerminalController { ?? sourceWorkspace.bonsplitController.focusedPaneId ?? sourceWorkspace.bonsplitController.allPaneIds.first if let rollbackPane { - _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: focus) + _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: true) } result = .err(code: "internal_error", message: "Failed to attach surface to destination", data: nil) return } if focus { - v2MaybeFocusWindow(for: targetTabManager) - v2MaybeSelectWorkspace(targetTabManager, workspace: targetWorkspace) + _ = app.focusMainWindow(windowId: targetWindowId) + setActiveTabManager(targetTabManager) + targetTabManager.selectWorkspace(targetWorkspace) } result = .ok([ @@ -4013,38 +4008,24 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - #if DEBUG - let sendStart = ProcessInfo.processInfo.systemUptime - #endif - let queued: Bool - if let surface = terminalPanel.surface.surface { - sendSocketText(text, surface: surface) - // Ensure we present a new frame after injecting input so snapshot-based tests (and - // socket-driven agents) can observe the updated terminal without requiring a focus - // change to trigger a draw. - terminalPanel.surface.forceRefresh() - queued = false - } else { - // Avoid blocking the main actor waiting for view/surface attachment. - terminalPanel.sendText(text) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - queued = true + guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else { + result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString]) + return } - #if DEBUG - let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 - dlog( - "socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))" - ) - #endif - result = .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "queued": queued, - "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager)) - ]) + + for char in text { + if char.unicodeScalars.count == 1, + let scalar = char.unicodeScalars.first, + handleControlScalar(scalar, surface: surface) { + continue + } + sendTextEvent(surface: surface, text: String(char)) + } + // Ensure we present a new frame after injecting input so snapshot-based tests (and + // socket-driven agents) can observe the updated terminal without requiring a focus + // change to trigger a draw. + terminalPanel.surface.forceRefresh() + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } return result } @@ -4072,7 +4053,7 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else { result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString]) return } @@ -4281,154 +4262,6 @@ class TerminalController { return "OK \(base64)" } - private struct PasteboardItemSnapshot { - let representations: [(type: NSPasteboard.PasteboardType, data: Data)] - } - - nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { - guard let raw else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if let url = URL(string: trimmed), - url.isFileURL, - !url.path.isEmpty { - return url.path - } - return trimmed.hasPrefix("/") ? trimmed : nil - } - - nonisolated static func shouldRemoveExportedScreenFile( - fileURL: URL, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> Bool { - let standardizedFile = fileURL.standardizedFileURL - let temporary = temporaryDirectory.standardizedFileURL - return standardizedFile.path.hasPrefix(temporary.path + "/") - } - - nonisolated static func shouldRemoveExportedScreenDirectory( - fileURL: URL, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> Bool { - let directory = fileURL.deletingLastPathComponent().standardizedFileURL - let temporary = temporaryDirectory.standardizedFileURL - return directory.path.hasPrefix(temporary.path + "/") - } - - private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { - guard let items = pasteboard.pasteboardItems else { return [] } - return items.map { item in - let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in - guard let data = item.data(forType: type) else { return nil } - return (type: type, data: data) - } - return PasteboardItemSnapshot(representations: representations) - } - } - - private func restorePasteboardItems( - _ snapshots: [PasteboardItemSnapshot], - to pasteboard: NSPasteboard - ) { - _ = pasteboard.clearContents() - guard !snapshots.isEmpty else { return } - - let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in - guard !snapshot.representations.isEmpty else { return nil } - let item = NSPasteboardItem() - for representation in snapshot.representations { - item.setData(representation.data, forType: representation.type) - } - return item - } - guard !restoredItems.isEmpty else { return } - _ = pasteboard.writeObjects(restoredItems) - } - - private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? { - if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], - let firstURL = urls.first, - firstURL.isFileURL { - return firstURL.path - } - if let value = pasteboard.string(forType: .string) { - return value - } - return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text")) - } - - private func readTerminalTextFromVTExportForSnapshot( - terminalPanel: TerminalPanel, - lineLimit: Int? - ) -> String? { - // read_text strips style state; VT export keeps ANSI escape sequences. - let pasteboard = NSPasteboard.general - let snapshot = snapshotPasteboardItems(pasteboard) - defer { - restorePasteboardItems(snapshot, to: pasteboard) - } - - let initialChangeCount = pasteboard.changeCount - guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else { - return nil - } - guard pasteboard.changeCount != initialChangeCount else { - return nil - } - guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else { - return nil - } - - let fileURL = URL(fileURLWithPath: exportedPath) - defer { - if Self.shouldRemoveExportedScreenFile(fileURL: fileURL) { - try? FileManager.default.removeItem(at: fileURL) - if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) { - try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) - } - } - } - - guard let data = try? Data(contentsOf: fileURL), - var output = String(data: data, encoding: .utf8) else { - return nil - } - if let lineLimit { - output = tailTerminalLines(output, maxLines: lineLimit) - } - return output - } - - func readTerminalTextForSnapshot( - terminalPanel: TerminalPanel, - includeScrollback: Bool = false, - lineLimit: Int? = nil - ) -> String? { - if includeScrollback, - let vtOutput = readTerminalTextFromVTExportForSnapshot( - terminalPanel: terminalPanel, - lineLimit: lineLimit - ) { - return vtOutput - } - - let response = readTerminalTextBase64( - terminalPanel: terminalPanel, - includeScrollback: includeScrollback, - lineLimit: lineLimit - ) - guard response.hasPrefix("OK ") else { return nil } - let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) - if base64.isEmpty { - return "" - } - guard let data = Data(base64Encoded: base64), - let decoded = String(data: data, encoding: .utf8) else { - return nil - } - return decoded - } - private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -4441,9 +4274,14 @@ class TerminalController { return } - // Only explicit focus-intent commands may mutate selection state. - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + // Ensure the flash is visible in the active UI. + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let surfaceId else { @@ -4524,8 +4362,13 @@ class TerminalController { result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } ws.bonsplitController.focusPane(paneId) let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "pane_id": paneId.id.uuidString, "pane_ref": v2Ref(kind: .pane, uuid: paneId.id)]) @@ -4845,7 +4688,7 @@ class TerminalController { if sourcePaneUUID == targetPaneUUID { return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil) v2MainSync { @@ -4928,7 +4771,7 @@ class TerminalController { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2Bool(params, "focus") ?? true var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil) v2MainSync { @@ -4969,7 +4812,7 @@ class TerminalController { return } - let destinationWorkspace = tabManager.addWorkspace(select: focus) + let destinationWorkspace = tabManager.addWorkspace() guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId ?? destinationWorkspace.bonsplitController.allPaneIds.first else { if let sourcePaneForRollback { @@ -4977,7 +4820,7 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: focus + focus: true ) } result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) @@ -4990,12 +4833,16 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: focus + focus: true ) } result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) return } + + if !focus { + tabManager.selectWorkspace(sourceWorkspace) + } let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), @@ -5601,8 +5448,13 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } let sourceSurfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let sourceSurfaceId else { @@ -5620,16 +5472,11 @@ class TerminalController { var placementStrategy = "split_right" let createdPanel: BrowserPanel? if let targetPane = ws.preferredBrowserTargetPane(fromPanelId: sourceSurfaceId) { - createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: v2FocusAllowed()) + createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: true) createdSplit = false placementStrategy = "reuse_right_sibling" } else { - createdPanel = ws.newBrowserSplit( - from: sourceSurfaceId, - orientation: .horizontal, - url: url, - focus: v2FocusAllowed() - ) + createdPanel = ws.newBrowserSplit(from: sourceSurfaceId, orientation: .horizontal, url: url) } guard let browserPanelId = createdPanel?.id else { @@ -6884,8 +6731,13 @@ class TerminalController { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager), let browserPanel = ws.browserPanel(for: surfaceId) else { return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } // Prevent omnibar auto-focus from immediately stealing first responder back. browserPanel.suppressOmnibarAutofocus(for: 1.0) @@ -7922,7 +7774,7 @@ class TerminalController { "id": panel.id.uuidString, "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, - "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, + "title": panel.displayTitle, "url": panel.currentURL?.absoluteString ?? "", "focused": panel.id == ws.focusedPanelId, "pane_id": v2OrNull(ws.paneId(forPanelId: panel.id)?.id.uuidString), @@ -7967,7 +7819,7 @@ class TerminalController { return } - guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: v2FocusAllowed()) else { + guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: true) else { result = .err(code: "internal_error", message: "Failed to create browser tab", data: nil) return } @@ -9059,7 +8911,6 @@ class TerminalController { Available commands: ping - Check if server is running - auth <password> - Authenticate this connection (required in password mode) list_workspaces - List all workspaces with IDs new_workspace - Create a new workspace select_workspace <id|index> - Select workspace by ID or index (0-based) @@ -9173,37 +9024,6 @@ class TerminalController { } #if DEBUG - private func debugShortcutName(for action: KeyboardShortcutSettings.Action) -> String { - let snakeCase = action.rawValue.replacingOccurrences( - of: "([a-z0-9])([A-Z])", - with: "$1_$2", - options: .regularExpression - ) - return snakeCase.lowercased() - } - - private func debugShortcutAction(named rawName: String) -> KeyboardShortcutSettings.Action? { - let normalized = rawName - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "-", with: "_") - - for action in KeyboardShortcutSettings.Action.allCases { - let snakeCaseName = debugShortcutName(for: action) - if normalized == snakeCaseName || normalized == snakeCaseName.replacingOccurrences(of: "_", with: "") { - return action - } - } - return nil - } - - private func debugShortcutSupportedNames() -> String { - KeyboardShortcutSettings.Action.allCases - .map(debugShortcutName(for:)) - .sorted() - .joined(separator: ", ") - } - private func setShortcut(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) @@ -9211,15 +9031,29 @@ class TerminalController { return "ERROR: Usage: set_shortcut <name> <combo|clear>" } - let name = parts[0] + let name = parts[0].lowercased() let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - guard let action = debugShortcutAction(named: name) else { - return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())" + let defaultsKey: String? + switch name { + case "focus_left", "focusleft": + defaultsKey = KeyboardShortcutSettings.focusLeftKey + case "focus_right", "focusright": + defaultsKey = KeyboardShortcutSettings.focusRightKey + case "focus_up", "focusup": + defaultsKey = KeyboardShortcutSettings.focusUpKey + case "focus_down", "focusdown": + defaultsKey = KeyboardShortcutSettings.focusDownKey + default: + defaultsKey = nil + } + + guard let defaultsKey else { + return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down" } if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { - UserDefaults.standard.removeObject(forKey: action.defaultsKey) + UserDefaults.standard.removeObject(forKey: defaultsKey) return "OK" } @@ -9237,7 +9071,7 @@ class TerminalController { guard let data = try? JSONEncoder().encode(shortcut) else { return "ERROR: Failed to encode shortcut" } - UserDefaults.standard.set(data, forKey: action.defaultsKey) + UserDefaults.standard.set(data, forKey: defaultsKey) return "OK" } @@ -9256,24 +9090,17 @@ class TerminalController { var result = "ERROR: Failed to create event" DispatchQueue.main.sync { - // Prefer the current active-tab-manager window so shortcut simulation stays - // scoped to the intended window even when NSApp.keyWindow is stale. - let targetWindow: NSWindow? = { - if let activeTabManager = self.tabManager, - let windowId = AppDelegate.shared?.windowId(for: activeTabManager), - let window = AppDelegate.shared?.mainWindow(for: windowId) { - return window - } - return NSApp.keyWindow - ?? NSApp.mainWindow - ?? NSApp.windows.first(where: { $0.isVisible }) - ?? NSApp.windows.first - }() + // Tests can run while the app is activating (no keyWindow yet). Prefer a visible + // window to keep input simulation deterministic in debug builds. + let targetWindow = NSApp.keyWindow + ?? NSApp.mainWindow + ?? NSApp.windows.first(where: { $0.isVisible }) + ?? NSApp.windows.first if let targetWindow { NSApp.activate(ignoringOtherApps: true) targetWindow.makeKeyAndOrderFront(nil) } - let windowNumber = targetWindow?.windowNumber ?? 0 + let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0 guard let keyDownEvent = NSEvent.keyEvent( with: .keyDown, location: .zero, @@ -9352,20 +9179,20 @@ class TerminalController { // Socket commands are line-based; allow callers to express control chars with backslash escapes. let text = unescapeSocketText(raw) - var result = "ERROR: No window" - DispatchQueue.main.sync { - // Like simulate_shortcut, prefer a visible window so debug automation doesn't - // fail during key window transitions. - guard let window = NSApp.keyWindow - ?? NSApp.mainWindow - ?? NSApp.windows.first(where: { $0.isVisible }) - ?? NSApp.windows.first else { return } - NSApp.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - guard let fr = window.firstResponder else { - result = "ERROR: No first responder" - return - } + var result = "ERROR: No window" + DispatchQueue.main.sync { + // Like simulate_shortcut, prefer a visible window so debug automation doesn't + // fail during key window transitions. + guard let window = NSApp.keyWindow + ?? NSApp.mainWindow + ?? NSApp.windows.first(where: { $0.isVisible }) + ?? NSApp.windows.first else { return } + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + guard let fr = window.firstResponder else { + result = "ERROR: No first responder" + return + } if let client = fr as? NSTextInputClient { client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) @@ -9373,22 +9200,7 @@ class TerminalController { return } - // If workspace handoff temporarily leaves a non-terminal first responder, - // route debug typing to the selected terminal's focused panel directly. - if let tabManager, - let tabId = tabManager.selectedTabId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }), - let panelId = tab.focusedPanelId, - let terminalPanel = tab.terminalPanel(for: panelId), - !terminalPanel.hostedView.isSurfaceViewFirstResponder() { - // Match Enter semantics expected by tests/debug tooling when bypassing AppKit. - let directText = text.replacingOccurrences(of: "\n", with: "\r") - terminalPanel.surface.sendText(directText) - result = "OK" - return - } - - // Fall back to the responder-chain insertText action. + // Fall back to the responder chain insertText action. (fr as? NSResponder)?.insertText(text) result = "OK" } @@ -9981,10 +9793,6 @@ class TerminalController { let charactersIgnoringModifiers: String switch keyToken.lowercased() { - case "esc", "escape": - storedKey = "\u{1b}" - keyCode = UInt16(kVK_Escape) - charactersIgnoringModifiers = storedKey case "left": storedKey = "←" keyCode = 123 @@ -10005,10 +9813,6 @@ class TerminalController { storedKey = "\r" keyCode = UInt16(kVK_Return) charactersIgnoringModifiers = storedKey - case "backspace", "delete", "del": - storedKey = "\u{7f}" - keyCode = UInt16(kVK_Delete) - charactersIgnoringModifiers = storedKey default: let key = keyToken.lowercased() guard let code = keyCodeForShortcutKey(key) else { return nil } @@ -10147,8 +9951,7 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return "ERROR: Failed to create window" } - if socketCommandAllowsInAppFocusMutations(), - let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return "OK \(windowId.uuidString)" @@ -10168,7 +9971,6 @@ class TerminalController { guard let windowId = UUID(uuidString: parts[1]) else { return "ERROR: Invalid window id" } var ok = false - let focus = socketCommandAllowsInAppFocusMutations() v2MainSync { guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId), let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowId), @@ -10176,11 +9978,9 @@ class TerminalController { ok = false return } - dstTM.attachWorkspace(ws, select: focus) - if focus { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(dstTM) - } + dstTM.attachWorkspace(ws, select: true) + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(dstTM) ok = true } @@ -10206,19 +10006,10 @@ class TerminalController { var newTabId: UUID? let focus = socketCommandAllowsInAppFocusMutations() - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif DispatchQueue.main.sync { let workspace = tabManager.addTab(select: focus) newTabId = workspace.id } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - dlog( - "socket.new_workspace focus=\(focus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - #endif return "OK \(newTabId?.uuidString ?? "unknown")" } @@ -10262,12 +10053,7 @@ class TerminalController { return } - if let newPanelId = tabManager.newSplit( - tabId: tabId, - surfaceId: targetSurface, - direction: direction, - focus: socketCommandAllowsInAppFocusMutations() - ) { + if let newPanelId = tabManager.newSplit(tabId: tabId, surfaceId: targetSurface, direction: direction) { result = "OK \(newPanelId.uuidString)" } } @@ -11243,29 +11029,6 @@ class TerminalController { } } - private func sendSocketText(_ text: String, surface: ghostty_surface_t) { - let chunks = Self.socketTextChunks(text) - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - for chunk in chunks { - switch chunk { - case .text(let value): - sendTextEvent(surface: surface, text: value) - case .control(let scalar): - _ = handleControlScalar(scalar, surface: surface) - } - } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - if elapsedMs >= 8 || chunks.count > 1 { - dlog( - "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" - ) - } - #endif - } - private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool { switch scalar.value { case 0x0A, 0x0D: @@ -11368,6 +11131,15 @@ class TerminalController { return } + guard let surface = resolveTerminalSurface( + from: terminalPanel.id.uuidString, + tabManager: tabManager, + waitUpTo: 2.0 + ) else { + error = "ERROR: Surface not ready" + return + } + // Unescape common escape sequences // Note: \n is converted to \r for terminal (Enter key sends \r) let unescaped = text @@ -11375,11 +11147,13 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - if let surface = terminalPanel.surface.surface { - sendSocketText(unescaped, surface: surface) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + for char in unescaped { + if char.unicodeScalars.count == 1, + let scalar = char.unicodeScalars.first, + handleControlScalar(scalar, surface: surface) { + continue + } + sendTextEvent(surface: surface, text: String(char)) } success = true } @@ -11397,18 +11171,20 @@ class TerminalController { var success = false DispatchQueue.main.sync { - guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return } + guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return } let unescaped = text .replacingOccurrences(of: "\\n", with: "\r") .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - if let surface = terminalPanel.surface.surface { - sendSocketText(unescaped, surface: surface) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + for char in unescaped { + if char.unicodeScalars.count == 1, + let scalar = char.unicodeScalars.first, + handleControlScalar(scalar, surface: surface) { + continue + } + sendTextEvent(surface: surface, text: String(char)) } success = true } @@ -11429,7 +11205,11 @@ class TerminalController { return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = resolveTerminalSurface( + from: terminalPanel.id.uuidString, + tabManager: tabManager, + waitUpTo: 2.0 + ) else { error = "ERROR: Surface not ready" return } @@ -11451,11 +11231,11 @@ class TerminalController { var success = false var error: String? DispatchQueue.main.sync { - guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { + guard resolveTerminalPanel(from: target, tabManager: tabManager) != nil else { error = "ERROR: Surface not found" return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = resolveTerminalSurface(from: target, tabManager: tabManager, waitUpTo: 2.0) else { error = "ERROR: Surface not ready" return } @@ -11473,7 +11253,6 @@ class TerminalController { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let url: URL? = trimmed.isEmpty ? nil : URL(string: trimmed) - let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create browser panel" DispatchQueue.main.sync { @@ -11483,12 +11262,7 @@ class TerminalController { return } - if let browserPanelId = tab.newBrowserSplit( - from: focusedPanelId, - orientation: .horizontal, - url: url, - focus: shouldFocus - )?.id { + if let browserPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: .horizontal, url: url)?.id { result = "OK \(browserPanelId.uuidString)" } } @@ -11890,7 +11664,6 @@ class TerminalController { let orientation = direction.orientation let insertFirst = direction.insertFirst - let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create pane" DispatchQueue.main.sync { @@ -11902,20 +11675,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSplit( - from: focusedPanelId, - orientation: orientation, - insertFirst: insertFirst, - url: url, - focus: shouldFocus - )?.id + newPanelId = tab.newBrowserSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, url: url)?.id } else { - newPanelId = tab.newTerminalSplit( - from: focusedPanelId, - orientation: orientation, - insertFirst: insertFirst, - focus: shouldFocus - )?.id + newPanelId = tab.newTerminalSplit(from: focusedPanelId, orientation: orientation, insertFirst: insertFirst)?.id } if let id = newPanelId { @@ -12492,9 +12254,6 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - guard Self.shouldReplaceProgress(current: tab.progress, value: clamped, label: label) else { - return - } tab.progress = SidebarProgressState(value: clamped, label: label) } return result @@ -12507,9 +12266,7 @@ class TerminalController { result = "ERROR: Tab not found" return } - if tab.progress != nil { - tab.progress = nil - } + tab.progress = nil } return result } @@ -12517,7 +12274,7 @@ class TerminalController { private func reportGitBranch(_ args: String) -> String { let parsed = parseOptions(args) guard let branch = parsed.positional.first else { - return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]" + return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X]" } let isDirty = parsed.options["status"]?.lowercased() == "dirty" @@ -12544,35 +12301,7 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let panelArg = parsed.options["panel"] ?? parsed.options["surface"] - let surfaceId: UUID - if let panelArg { - if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]" - return - } - guard let parsedId = UUID(uuidString: panelArg) else { - result = "ERROR: Invalid panel id '\(panelArg)'" - return - } - surfaceId = parsedId - } else { - guard let focused = tab.focusedPanelId else { - result = "ERROR: Missing panel id (no focused surface)" - return - } - surfaceId = focused - } - - guard validSurfaceIds.contains(surfaceId) else { - result = "ERROR: Panel not found '\(surfaceId.uuidString)'" - return - } - - tab.updatePanelGitBranch(panelId: surfaceId, branch: branch, isDirty: isDirty) + tab.gitBranch = SidebarGitBranchState(branch: branch, isDirty: isDirty) } return result } @@ -12596,42 +12325,13 @@ class TerminalController { } return "OK" } - var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { - result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + result = "ERROR: Tab not found" return } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let panelArg = parsed.options["panel"] ?? parsed.options["surface"] - let surfaceId: UUID - if let panelArg { - if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: clear_git_branch [--tab=X] [--panel=Y]" - return - } - guard let parsedId = UUID(uuidString: panelArg) else { - result = "ERROR: Invalid panel id '\(panelArg)'" - return - } - surfaceId = parsedId - } else { - guard let focused = tab.focusedPanelId else { - result = "ERROR: Missing panel id (no focused surface)" - return - } - surfaceId = focused - } - - guard validSurfaceIds.contains(surfaceId) else { - result = "ERROR: Panel not found '\(surfaceId.uuidString)'" - return - } - - tab.clearPanelGitBranch(panelId: surfaceId) + tab.gitBranch = nil } return result } @@ -12774,7 +12474,6 @@ class TerminalController { } ports.append(port) } - let normalizedPorts = Array(Set(ports)).sorted() var result = "OK" DispatchQueue.main.sync { @@ -12811,43 +12510,20 @@ class TerminalController { return } - guard Self.shouldReplacePorts(current: tab.surfaceListeningPorts[surfaceId], next: normalizedPorts) else { - return - } - - tab.surfaceListeningPorts[surfaceId] = normalizedPorts + tab.surfaceListeningPorts[surfaceId] = ports tab.recomputeListeningPorts() } return result } private func reportPwd(_ args: String) -> String { + guard let tabManager else { return "ERROR: TabManager not available" } let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { return "ERROR: Missing path — usage: report_pwd <path> [--tab=X] [--panel=Y]" } - let directory = Self.normalizeReportedDirectory(parsed.positional.joined(separator: " ")) - - // Shell integration provides explicit UUID handles for cwd updates. - // Keep this hot path off-main and drop no-op reports before scheduling UI work. - if let scope = Self.explicitSocketScope(options: parsed.options) { - guard Self.socketFastPathState.shouldPublishDirectory( - workspaceId: scope.workspaceId, - panelId: scope.panelId, - directory: directory - ) else { - return "OK" - } - DispatchQueue.main.async { - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return } - tabManager.updateSurfaceDirectory(tabId: scope.workspaceId, surfaceId: scope.panelId, directory: directory) - } - return "OK" - } - - guard let tabManager else { return "ERROR: TabManager not available" } - + let directory = parsed.positional.joined(separator: " ") var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -12914,15 +12590,11 @@ class TerminalController { result = "ERROR: Panel not found '\(surfaceId.uuidString)'" return } - if tab.surfaceListeningPorts.removeValue(forKey: surfaceId) != nil { - tab.recomputeListeningPorts() - } + tab.surfaceListeningPorts.removeValue(forKey: surfaceId) } else { - if !tab.surfaceListeningPorts.isEmpty { - tab.surfaceListeningPorts.removeAll() - tab.recomputeListeningPorts() - } + tab.surfaceListeningPorts.removeAll() } + tab.recomputeListeningPorts() } return result } @@ -12933,17 +12605,6 @@ class TerminalController { return "ERROR: Missing tty name — usage: report_tty <tty_name> [--tab=X] [--panel=Y]" } - // Shell integration always provides explicit UUID handles. - // Handle that common path off-main to avoid sync-hopping on every report. - if let scope = Self.explicitSocketScope(options: parsed.options) { - PortScanner.shared.registerTTY( - workspaceId: scope.workspaceId, - panelId: scope.panelId, - ttyName: ttyName - ) - return "OK" - } - var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -12977,7 +12638,6 @@ class TerminalController { return } - guard tab.surfaceTTYNames[surfaceId] != ttyName else { return } tab.surfaceTTYNames[surfaceId] = ttyName PortScanner.shared.registerTTY(workspaceId: tab.id, panelId: surfaceId, ttyName: ttyName) } @@ -12985,22 +12645,15 @@ class TerminalController { } private func portsKick(_ args: String) -> String { - let parsed = parseOptions(args) - - // Shell integration always provides explicit UUID handles. - // Handle that common path off-main to keep prompt hooks from blocking UI work. - if let scope = Self.explicitSocketScope(options: parsed.options) { - PortScanner.shared.kick(workspaceId: scope.workspaceId, panelId: scope.panelId) - return "OK" - } - var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { + let parsed = parseOptions(args) result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } + let parsed = parseOptions(args) let panelArg = parsed.options["panel"] ?? parsed.options["surface"] let surfaceId: UUID if let panelArg { @@ -13232,7 +12885,6 @@ class TerminalController { var panelType: PanelType = .terminal var paneArg: String? = nil var url: URL? = nil - let shouldFocus = socketCommandAllowsInAppFocusMutations() let parts = args.split(separator: " ") for part in parts { @@ -13277,9 +12929,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: shouldFocus)?.id + newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: true)?.id } else { - newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: shouldFocus)?.id + newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: true)?.id } if let id = newPanelId { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index b38387fa..ca15be68 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -106,7 +106,1586 @@ private struct SessionPaneRestoreEntry { let snapshot: SessionPaneLayoutSnapshot } -extension Workspace { +private final class WorkspaceRemoteSessionController { + private struct ForwardEntry { + let process: Process + let stderrPipe: Pipe + } + + private struct CommandResult { + let status: Int32 + let stdout: String + let stderr: String + } + + private struct RemotePlatform { + let goOS: String + let goArch: String + } + + 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 weak var workspace: Workspace? + private let configuration: WorkspaceRemoteConfiguration + + private var isStopping = false + private var probeProcess: Process? + private var probeStdoutPipe: Pipe? + private var probeStderrPipe: Pipe? + private var probeStdoutBuffer = "" + private var probeStderrBuffer = "" + + private var desiredRemotePorts: Set<Int> = [] + private var forwardEntries: [Int: ForwardEntry] = [:] + private var portConflicts: Set<Int> = [] + private var daemonReady = false + private var daemonBootstrapVersion: String? + private var daemonRemotePath: String? + private var reconnectRetryCount = 0 + private var reconnectWorkItem: DispatchWorkItem? + private var reverseRelayProcess: Process? + private var reverseRelayStderrPipe: Pipe? + + init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration) { + self.workspace = workspace + self.configuration = configuration + } + + func start() { + queue.async { [weak self] in + guard let self else { return } + guard !self.isStopping else { return } + self.beginConnectionAttemptLocked() + } + } + + func stop() { + queue.async { [weak self] in + self?.stopAllLocked() + } + } + + private func stopAllLocked() { + isStopping = true + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + + if let probeProcess { + probeStdoutPipe?.fileHandleForReading.readabilityHandler = nil + probeStderrPipe?.fileHandleForReading.readabilityHandler = nil + if probeProcess.isRunning { + probeProcess.terminate() + } + } + probeProcess = nil + probeStdoutPipe = nil + probeStderrPipe = nil + probeStdoutBuffer = "" + probeStderrBuffer = "" + + if let reverseRelayProcess { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + if reverseRelayProcess.isRunning { + reverseRelayProcess.terminate() + } + } + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + + for (_, entry) in forwardEntries { + entry.stderrPipe.fileHandleForReading.readabilityHandler = nil + if entry.process.isRunning { + entry.process.terminate() + } + } + forwardEntries.removeAll() + desiredRemotePorts.removeAll() + portConflicts.removeAll() + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + } + + private func beginConnectionAttemptLocked() { + guard !isStopping else { return } + + 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() + 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 + ) + startReverseRelayLocked() + startProbeLocked() + } catch { + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + let nextRetry = scheduleProbeRestartLocked(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 startProbeLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + + probeStdoutBuffer = "" + probeStderrBuffer = "" + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = probeArguments() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + return + } + self?.queue.async { + self?.consumeProbeStdoutData(data) + } + } + + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + return + } + self?.queue.async { + self?.consumeProbeStderrData(data) + } + } + + process.terminationHandler = { [weak self] terminated in + self?.queue.async { + self?.handleProbeTermination(terminated) + } + } + + do { + try process.run() + probeProcess = process + probeStdoutPipe = stdoutPipe + probeStderrPipe = stderrPipe + } catch { + let nextRetry = scheduleProbeRestartLocked(delay: 3.0) + let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 3.0) + publishState(.error, detail: "Failed to start SSH probe: \(error.localizedDescription)\(retrySuffix)") + } + } + + private func handleProbeTermination(_ process: Process) { + probeStdoutPipe?.fileHandleForReading.readabilityHandler = nil + probeStderrPipe?.fileHandleForReading.readabilityHandler = nil + probeProcess = nil + probeStdoutPipe = nil + probeStderrPipe = nil + + guard !isStopping else { return } + + for (_, entry) in forwardEntries { + entry.stderrPipe.fileHandleForReading.readabilityHandler = nil + if entry.process.isRunning { + entry.process.terminate() + } + } + forwardEntries.removeAll() + publishPortsSnapshotLocked() + + let statusCode = process.terminationStatus + let rawDetail = Self.bestErrorLine(stderr: probeStderrBuffer, stdout: probeStdoutBuffer) + let detail = rawDetail ?? "SSH probe exited with status \(statusCode)" + let nextRetry = scheduleProbeRestartLocked(delay: 3.0) + let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 3.0) + publishState(.error, detail: "SSH probe to \(configuration.displayTarget) failed: \(detail)\(retrySuffix)") + } + + @discardableResult + private func scheduleProbeRestartLocked(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.probeProcess == nil else { return } + self.beginConnectionAttemptLocked() + } + reconnectWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + return retryNumber + } + + private func consumeProbeStdoutData(_ data: Data) { + guard let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty else { return } + probeStdoutBuffer.append(chunk) + + while let newline = probeStdoutBuffer.firstIndex(of: "\n") { + let line = String(probeStdoutBuffer[..<newline]) + probeStdoutBuffer.removeSubrange(...newline) + handleProbePortsLine(line) + } + } + + private func consumeProbeStderrData(_ data: Data) { + guard let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty else { return } + probeStderrBuffer.append(chunk) + if probeStderrBuffer.count > 8192 { + probeStderrBuffer.removeFirst(probeStderrBuffer.count - 8192) + } + } + + private func handleProbePortsLine(_ line: String) { + guard !isStopping else { return } + + var ports = Set(Self.parseRemotePorts(line: line)) + if let relayPort = configuration.relayPort { + ports.remove(relayPort) + } + // Filter ephemeral ports (49152-65535) — these are SSH reverse relay ports + // from this or other workspaces, not user services worth forwarding. + ports = ports.filter { $0 < 49152 } + desiredRemotePorts = ports + portConflicts = portConflicts.intersection(desiredRemotePorts) + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + publishState(.connected, detail: "Connected to \(configuration.displayTarget)") + reconcileForwardsLocked() + } + + private func reconcileForwardsLocked() { + guard !isStopping else { return } + + for (port, entry) in forwardEntries where !desiredRemotePorts.contains(port) { + entry.stderrPipe.fileHandleForReading.readabilityHandler = nil + if entry.process.isRunning { + entry.process.terminate() + } + forwardEntries.removeValue(forKey: port) + } + + for port in desiredRemotePorts.sorted() where forwardEntries[port] == nil { + guard Self.isLoopbackPortAvailable(port: port) else { + // Port is already bound locally. If it's reachable (e.g. another + // workspace is forwarding it), don't flag it as a conflict. + if Self.isLoopbackPortReachable(port: port) { + portConflicts.remove(port) + } else { + portConflicts.insert(port) + } + continue + } + if startForwardLocked(port: port) { + portConflicts.remove(port) + } else { + portConflicts.insert(port) + } + } + + publishPortsSnapshotLocked() + } + + @discardableResult + private func startForwardLocked(port: Int) -> Bool { + guard !isStopping else { return false } + + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = forwardArguments(port: port) + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + 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.probeStderrBuffer.append(chunk) + if self.probeStderrBuffer.count > 8192 { + self.probeStderrBuffer.removeFirst(self.probeStderrBuffer.count - 8192) + } + } + } + } + + process.terminationHandler = { [weak self] terminated in + self?.queue.async { + self?.handleForwardTermination(port: port, process: terminated) + } + } + + do { + try process.run() + forwardEntries[port] = ForwardEntry(process: process, stderrPipe: stderrPipe) + return true + } catch { + publishState(.error, detail: "Failed to forward local :\(port) to \(configuration.displayTarget): \(error.localizedDescription)") + return false + } + } + + private func handleForwardTermination(port: Int, process: Process) { + if let current = forwardEntries[port], current.process === process { + current.stderrPipe.fileHandleForReading.readabilityHandler = nil + forwardEntries.removeValue(forKey: port) + } + + guard !isStopping else { return } + publishPortsSnapshotLocked() + + guard desiredRemotePorts.contains(port) else { return } + let rawDetail = Self.bestErrorLine(stderr: probeStderrBuffer) + if process.terminationReason != .exit || process.terminationStatus != 0 { + let detail = rawDetail ?? "process exited with status \(process.terminationStatus)" + publishState(.error, detail: "SSH port-forward :\(port) dropped for \(configuration.displayTarget): \(detail)") + } + guard Self.isLoopbackPortAvailable(port: port) else { + portConflicts.insert(port) + publishPortsSnapshotLocked() + return + } + + queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in + guard let self else { return } + guard !self.isStopping else { return } + guard self.desiredRemotePorts.contains(port) else { return } + guard self.forwardEntries[port] == nil else { return } + if self.startForwardLocked(port: port) { + self.portConflicts.remove(port) + } else { + self.portConflicts.insert(port) + } + self.publishPortsSnapshotLocked() + } + } + + /// Spawns a background SSH process that reverse-forwards a remote TCP port to the local cmux Unix socket. + /// This process is a direct child of the cmux app, so it passes the `isDescendant()` ancestry check. + @discardableResult + private func startReverseRelayLocked() -> Bool { + guard !isStopping else { return false } + guard let relayPort = configuration.relayPort, relayPort > 0, + let localSocketPath = configuration.localSocketPath, !localSocketPath.isEmpty else { + return false + } + + // Kill any existing relay process managed by this session + if let existing = reverseRelayProcess { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + if existing.isRunning { existing.terminate() } + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + } + + // Kill orphaned relay SSH processes from previous app sessions that reverse-forward + // to the same socket path (they survive pkill because they're reparented to launchd). + Self.killOrphanedRelayProcesses( + relayPort: relayPort, + socketPath: localSocketPath, + destination: configuration.destination + ) + + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + + // Build arguments: -N (no remote command), -o ControlPath=none (avoid ControlMaster delegation), + // then common SSH args, then -R reverse forward, then destination. + // ExitOnForwardFailure=no because user's ~/.ssh/config may have RemoteForward entries + // that conflict with already-bound ports — we don't want those to kill our relay. + var args: [String] = ["-N"] + args += sshCommonArguments(batchMode: true) + args += ["-R", "127.0.0.1:\(relayPort):\(localSocketPath)"] + args += [configuration.destination] + process.arguments = args + + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + 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.probeStderrBuffer.append(chunk) + if self.probeStderrBuffer.count > 8192 { + self.probeStderrBuffer.removeFirst(self.probeStderrBuffer.count - 8192) + } + } + } + } + + process.terminationHandler = { [weak self] terminated in + self?.queue.async { + self?.handleReverseRelayTermination(process: terminated) + } + } + + do { + try process.run() + reverseRelayProcess = process + reverseRelayStderrPipe = stderrPipe + NSLog("[cmux] reverse relay started: -R 127.0.0.1:%d:%@ → %@", relayPort, localSocketPath, configuration.destination) + + // Write socket_addr after a delay to give the SSH -R forward time to establish. + // The Go CLI retry loop re-reads this file, so it will pick up the port once ready. + queue.asyncAfter(deadline: .now() + 3.0) { [weak self] in + guard let self, !self.isStopping else { return } + guard self.reverseRelayProcess?.isRunning == true else { return } + self.writeRemoteSocketAddrLocked() + self.writeRemoteRelayDaemonMappingLocked() + } + + return true + } catch { + NSLog("[cmux] failed to start reverse relay: %@", error.localizedDescription) + return false + } + } + + private func handleReverseRelayTermination(process: Process) { + if reverseRelayProcess === process { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + } + + guard !isStopping else { return } + guard configuration.relayPort != nil else { return } + + // Auto-restart after 2 seconds if we're still active + queue.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self else { return } + guard !self.isStopping else { return } + guard self.reverseRelayProcess == nil else { return } + self.startReverseRelayLocked() + } + } + + private func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { + DispatchQueue.main.async { [weak workspace] in + guard let workspace 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 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 } + workspace.applyRemoteDaemonStatusUpdate( + status, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + private func publishPortsSnapshotLocked() { + let detected = desiredRemotePorts.sorted() + let forwarded = forwardEntries.keys.sorted() + let conflicts = portConflicts.sorted() + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + workspace.applyRemotePortsSnapshot( + detected: detected, + forwarded: forwarded, + conflicts: conflicts, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + private func probeArguments() -> [String] { + let remoteScript = Self.probeScript() + let remoteCommand = "sh -lc \(Self.shellSingleQuoted(remoteScript))" + return sshCommonArguments(batchMode: true) + [configuration.destination, remoteCommand] + } + + private func forwardArguments(port: Int) -> [String] { + let localBind = "127.0.0.1:\(port):127.0.0.1:\(port)" + return ["-N"] + sshCommonArguments(batchMode: true) + ["-L", localBind, configuration.destination] + } + + private func sshCommonArguments(batchMode: Bool) -> [String] { + var args: [String] = [ + "-o", "ConnectTimeout=6", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=2", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ExitOnForwardFailure=no", + "-o", "ControlPath=none", + ] + if batchMode { + args += ["-o", "BatchMode=yes"] + } + 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 configuration.sshOptions { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + args += ["-o", trimmed] + } + return args + } + + private func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/ssh", + arguments: arguments, + stdin: stdin, + timeout: timeout + ) + } + + private func scpExec(arguments: [String], timeout: TimeInterval = 30) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/scp", + arguments: arguments, + stdin: nil, + timeout: timeout + ) + } + + private func runProcess( + executable: String, + arguments: [String], + environment: [String: String]? = nil, + currentDirectory: URL? = nil, + stdin: Data?, + timeout: TimeInterval + ) throws -> CommandResult { + 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 + } + + do { + try process.run() + } catch { + throw NSError(domain: "cmux.remote.process", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)", + ]) + } + + if let stdin, let pipe = process.standardInput as? Pipe { + pipe.fileHandleForWriting.write(stdin) + try? pipe.fileHandleForWriting.close() + } + + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { + process.terminate() + throw NSError(domain: "cmux.remote.process", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s", + ]) + } + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stdout = String(data: stdoutData, encoding: .utf8) ?? "" + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) + } + + private func bootstrapDaemonLocked() throws -> DaemonHello { + let platform = try resolveRemotePlatformLocked() + let version = Self.remoteDaemonVersion() + let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) + + if try !remoteDaemonExistsLocked(remotePath: remotePath) { + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + } + + createRemoteCLISymlinkLocked(daemonRemotePath: remotePath) + + return try helloRemoteDaemonLocked(remotePath: remotePath) + } + + /// Installs a stable `cmux` wrapper on the remote and updates the default daemon target. + /// The wrapper resolves daemon path using relay-port metadata, allowing multiple local + /// cmux versions to coexist on the same remote host without clobbering each other. + /// Tries `/usr/local/bin` first (already in PATH, no rc changes needed), falls back to + /// `~/.cmux/bin`. Non-fatal: logs on failure but does not throw. + private func createRemoteCLISymlinkLocked(daemonRemotePath: String) { + let script = """ + mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay" + ln -sf "$HOME/\(daemonRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current" + cat > "$HOME/.cmux/bin/cmux" <<'CMUX_REMOTE_WRAPPER' + #!/bin/sh + set -eu + + if [ -n "${CMUX_SOCKET_PATH:-}" ]; then + _cmux_port="${CMUX_SOCKET_PATH##*:}" + case "$_cmux_port" in + ''|*[!0-9]*) + ;; + *) + _cmux_map="$HOME/.cmux/relay/${_cmux_port}.daemon_path" + if [ -r "$_cmux_map" ]; then + _cmux_daemon="$(cat "$_cmux_map" 2>/dev/null || true)" + if [ -n "$_cmux_daemon" ] && [ -x "$_cmux_daemon" ]; then + exec "$_cmux_daemon" cli "$@" + fi + fi + ;; + esac + fi + + if [ -x "$HOME/.cmux/bin/cmuxd-remote-current" ]; then + exec "$HOME/.cmux/bin/cmuxd-remote-current" cli "$@" + fi + + echo "cmux: remote daemon not installed; reconnect from local cmux." >&2 + exit 127 + CMUX_REMOTE_WRAPPER + chmod 755 "$HOME/.cmux/bin/cmux" + ln -sf "$HOME/.cmux/bin/cmux" /usr/local/bin/cmux 2>/dev/null \ + || sudo -n ln -sf "$HOME/.cmux/bin/cmux" /usr/local/bin/cmux 2>/dev/null \ + || true + """ + let command = "sh -lc \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + NSLog("[cmux] warning: failed to create remote CLI symlink (exit %d): %@", + result.status, + Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error") + } + } catch { + NSLog("[cmux] warning: failed to create remote CLI symlink: %@", error.localizedDescription) + } + } + + /// Writes `~/.cmux/socket_addr` on the remote with the relay TCP address. + /// The Go CLI relay reads this file as a fallback when CMUX_SOCKET_PATH is not set. + private func writeRemoteSocketAddrLocked() { + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + let addr = "127.0.0.1:\(relayPort)" + let script = "mkdir -p \"$HOME/.cmux\" && printf '%s' '\(addr)' > \"$HOME/.cmux/socket_addr\"" + let command = "sh -lc \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + NSLog("[cmux] warning: failed to write remote socket_addr (exit %d): %@", + result.status, + Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error") + } + } catch { + NSLog("[cmux] warning: failed to write remote socket_addr: %@", error.localizedDescription) + } + } + + /// Writes relay-port -> daemon binary mapping used by the remote `cmux` wrapper. + /// This keeps CLI dispatch stable when multiple local cmux versions target the same host. + private func writeRemoteRelayDaemonMappingLocked() { + guard let relayPort = configuration.relayPort, relayPort > 0, + let daemonRemotePath, !daemonRemotePath.isEmpty else { return } + let script = """ + mkdir -p "$HOME/.cmux/relay" && \ + printf '%s' "$HOME/\(daemonRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path" + """ + let command = "sh -lc \(Self.shellSingleQuoted(script))" + do { + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + if result.status != 0 { + NSLog("[cmux] warning: failed to write remote relay daemon mapping (exit %d): %@", + result.status, + Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error") + } + } catch { + NSLog("[cmux] warning: failed to write remote relay daemon mapping: %@", error.localizedDescription) + } + } + + private func resolveRemotePlatformLocked() throws -> RemotePlatform { + let script = "uname -s; uname -m" + let command = "sh -lc \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 10) + 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: 10, userInfo: [ + NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", + ]) + } + + let lines = result.stdout + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard lines.count >= 2 else { + throw NSError(domain: "cmux.remote.daemon", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "remote platform probe returned invalid output", + ]) + } + + guard let goOS = Self.mapUnameOS(lines[0]), + let goArch = Self.mapUnameArch(lines[1]) else { + throw NSError(domain: "cmux.remote.daemon", code: 12, userInfo: [ + NSLocalizedDescriptionKey: "unsupported remote platform \(lines[0])/\(lines[1])", + ]) + } + + return RemotePlatform(goOS: goOS, goArch: goArch) + } + + private func remoteDaemonExistsLocked(remotePath: String) throws -> Bool { + let script = "if [ -x \(Self.shellSingleQuoted(remotePath)) ]; then echo yes; else echo no; fi" + let command = "sh -lc \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + guard result.status == 0 else { return false } + return result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "yes" + } + + private func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { + guard let repoRoot = Self.findRepoRoot() else { + throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "cannot locate cmux repo root for daemon build", + ]) + } + 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 to build cmuxd-remote", + ]) + } + + let cacheRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-remote-daemon-build", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + let output = cacheRoot.appendingPathComponent("cmuxd-remote", isDirectory: false) + + 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", "-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", + ]) + } + return output + } + + private func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws { + let remoteDirectory = (remotePath as NSString).deletingLastPathComponent + let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" + + let mkdirScript = "mkdir -p \(Self.shellSingleQuoted(remoteDirectory))" + let mkdirCommand = "sh -lc \(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)", + ]) + } + + var scpArgs: [String] = ["-q", "-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 configuration.sshOptions { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + scpArgs += ["-o", trimmed] + } + 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 -lc \(Self.shellSingleQuoted(finalizeScript))" + let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12) + guard finalizeResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)" + throw NSError(domain: "cmux.remote.daemon", code: 32, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)", + ]) + } + } + + private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello { + let request = #"{"id":1,"method":"hello","params":{}}"# + let script = "printf '%s\\n' \(Self.shellSingleQuoted(request)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -lc \(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 static func parseRemotePorts(line: String) -> [Int] { + let tokens = line.split(whereSeparator: \.isWhitespace) + let values = tokens.compactMap { Int($0) } + let filtered = values.filter { $0 >= 1024 && $0 <= 65535 } + let unique = Set(filtered) + if unique.count <= 40 { + return unique.sorted() + } + return Array(unique.sorted().prefix(40)) + } + + private static func probeScript() -> String { + """ + set -eu + # Force an initial emission so the controller can transition out of + # "connecting" even when no ports are detected. + CMUX_LAST="__cmux_init__" + while true; do + if command -v ss >/dev/null 2>&1; then + PORTS="$(ss -ltnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | sort -n -u | tr '\\n' ' ')" + elif command -v netstat >/dev/null 2>&1; then + PORTS="$(netstat -lnt 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | sort -n -u | tr '\\n' ' ')" + else + PORTS="" + fi + if [ "$PORTS" != "$CMUX_LAST" ]; then + echo "$PORTS" + CMUX_LAST="$PORTS" + fi + sleep 2 + done + """ + } + + private static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + 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) + if let bundleVersion, !bundleVersion.isEmpty { + return bundleVersion + } + return "dev" + } + + private static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { + ".cmux/bin/cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote" + } + + 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 + } + + 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)" + } + + /// Kills orphaned SSH relay processes from previous app sessions. + /// These processes survive app restarts because `pkill` doesn't trigger graceful cleanup. + private static func killOrphanedRelayProcesses(relayPort: Int, socketPath: String, destination: String) { + guard relayPort > 0 else { return } + let pipe = Pipe() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + let socketPathPattern = NSRegularExpression.escapedPattern(for: socketPath) + let destinationPattern = NSRegularExpression.escapedPattern(for: destination) + let relayPattern = "ssh.*-R[[:space:]]*127\\.0\\.0\\.1:\(relayPort):\(socketPathPattern).*\(destinationPattern)" + process.arguments = ["-f", relayPattern] + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + process.waitUntilExit() + } catch { + // Best-effort cleanup; ignore failures + } + } + + private static func isLoopbackPortAvailable(port: Int) -> Bool { + guard port > 0 && port <= 65535 else { return false } + + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + 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(UInt16(port).bigEndian) + 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)) + } + } + return bindResult == 0 + } + + /// Check if a port on 127.0.0.1 is already accepting connections (e.g. forwarded by another workspace). + private static func isLoopbackPortReachable(port: Int) -> Bool { + guard port > 0 && port <= 65535 else { return false } + + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + + 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(UInt16(port).bigEndian) + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + connect(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_in>.size)) + } + } + return result == 0 + } +} + +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 SidebarPullRequestStatus: String { + case open + case merged + case closed +} + +struct SidebarPullRequestState: Equatable { + let number: Int + let label: String + let url: URL + let status: SidebarPullRequestStatus +} + +enum SidebarBranchOrdering { + struct BranchEntry: Equatable { + let name: String + let isDirty: Bool + } + + struct BranchDirectoryEntry: Equatable { + let branch: String? + let isDirty: Bool + let directory: String? + } + + static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { + switch tree { + case .pane(let pane): + return [pane.id] + case .split(let split): + 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 normalizedReviewURLKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + 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 + } + } + + 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?, + fallbackBranch: SidebarGitBranchState? + ) -> [BranchDirectoryEntry] { + struct EntryKey: Hashable { + let directory: String? + let branch: String? + } + + struct MutableEntry { + var branch: String? + var isDirty: Bool + var directory: String? + } + + func normalized(_ text: String?) -> String? { + guard let text else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func canonicalDirectoryKey(_ directory: String?) -> String? { + guard let directory = normalized(directory) else { return nil } + let expanded = NSString(string: directory).expandingTildeInPath + let standardized = NSString(string: expanded).standardizingPath + let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned + } + + 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] ?? defaultDirectory) + guard branch != nil || directory != nil else { continue } + + let panelDirty = panelBranch != nil + ? (panelBranches[panelId]?.isDirty ?? false) + : defaultBranchDirty + + let key: EntryKey + if let directoryKey = canonicalDirectoryKey(directory) { + key = EntryKey(directory: directoryKey, branch: nil) + } else { + key = EntryKey(directory: nil, branch: branch) + } + + if entries[key] == nil { + order.append(key) + entries[key] = MutableEntry( + branch: branch, + isDirty: panelDirty, + directory: directory + ) + } else { + if panelDirty { + entries[key]?.isDirty = true + } + if let branch { + entries[key]?.branch = branch + } + if let directory { + entries[key]?.directory = directory + } + } + } + + if order.isEmpty, let fallbackBranch { + let branch = normalized(fallbackBranch.branch) + let directory = normalized(defaultDirectory) + if branch != nil || directory != nil { + return [BranchDirectoryEntry( + branch: branch, + isDirty: fallbackBranch.isDirty, + directory: directory + )] + } + } + + return order.compactMap { key in + guard let entry = entries[key] else { return nil } + return BranchDirectoryEntry( + branch: entry.branch, + isDirty: entry.isDirty, + directory: entry.directory + ) + } + } +} + +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 localSocketPath: String? + + var displayTarget: String { + guard let port else { return destination } + return "\(destination):\(port)" + } +} + +struct ClosedBrowserPanelRestoreSnapshot { + let workspaceId: UUID + let url: URL? + 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? + @Published var currentDirectory: String + + /// 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 + var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? + + nonisolated static func resolvedSnapshotTerminalScrollback( + capturedScrollback: String?, + fallbackScrollback: String? + ) -> String? { + if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { + return captured + } + return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) + } + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { let tree = bonsplitController.treeSnapshot() let layout = sessionLayoutSnapshot(from: tree) @@ -311,17 +1890,10 @@ extension Workspace { let browserSnapshot: SessionBrowserPanelSnapshot? switch panel.panelType { case .terminal: - guard let terminalPanel = panel as? TerminalPanel else { return nil } - let capturedScrollback = includeScrollback - ? TerminalController.shared.readTerminalTextForSnapshot( - terminalPanel: terminalPanel, - includeScrollback: true, - lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal - ) - : nil + guard let _ = panel as? TerminalPanel else { return nil } let resolvedScrollback = terminalSnapshotScrollback( panelId: panelId, - capturedScrollback: capturedScrollback, + capturedScrollback: nil, includeScrollback: includeScrollback ) terminalSnapshot = SessionTerminalPanelSnapshot( @@ -359,16 +1931,6 @@ extension Workspace { ) } - nonisolated static func resolvedSnapshotTerminalScrollback( - capturedScrollback: String?, - fallbackScrollback: String? - ) -> String? { - if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { - return captured - } - return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) - } - private func terminalSnapshotScrollback( panelId: UUID, capturedScrollback: String?, @@ -3191,26 +4753,18 @@ final class Workspace: Identifiable, ObservableObject { return panel } - enum FocusPanelTrigger { - case standard - case terminalFirstResponder - } - /// Published directory for each panel @Published var panelDirectories: [UUID: String] = [:] @Published var panelTitles: [UUID: String] = [:] @Published private(set) var panelCustomTitles: [UUID: String] = [:] @Published private(set) var pinnedPanelIds: Set<UUID> = [] @Published private(set) var manualUnreadPanelIds: Set<UUID> = [] - private var manualUnreadMarkedAt: [UUID: Date] = [:] - nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 - nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 + @Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:] @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]] = [:] @@ -3293,33 +4847,16 @@ final class Workspace: Identifiable, ObservableObject { ) } - func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { - applyGhosttyChrome(backgroundColor: config.backgroundColor, reason: reason) + func applyGhosttyChrome(from config: GhosttyConfig) { + applyGhosttyChrome(backgroundColor: config.backgroundColor) } - func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { - let currentChromeColors = bonsplitController.configuration.appearance.chromeColors - let nextChromeColors = Self.resolvedChromeColors(from: backgroundColor) - let isNoOp = currentChromeColors.backgroundHex == nextChromeColors.backgroundHex && - currentChromeColors.borderHex == nextChromeColors.borderHex - - if GhosttyApp.shared.backgroundLogEnabled { - let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" - let nextBackgroundHex = nextChromeColors.backgroundHex ?? "nil" - GhosttyApp.shared.logBackground( - "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextBackgroundHex) currentBorder=\(currentChromeColors.borderHex ?? "nil") nextBorder=\(nextChromeColors.borderHex ?? "nil") noop=\(isNoOp)" - ) - } - - if isNoOp { + func applyGhosttyChrome(backgroundColor: NSColor) { + let nextHex = backgroundColor.hexString() + if bonsplitController.configuration.appearance.chromeColors.backgroundHex == nextHex { return } - bonsplitController.configuration.appearance.chromeColors = nextChromeColors - if GhosttyApp.shared.backgroundLogEnabled { - GhosttyApp.shared.logBackground( - "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")" - ) - } + bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex } init( @@ -3335,6 +4872,7 @@ final class Workspace: Identifiable, ObservableObject { self.processTitle = title self.title = title self.customTitle = nil + self.customColor = nil let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty @@ -3344,9 +4882,7 @@ final class Workspace: Identifiable, ObservableObject { // 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) + let appearance = Self.bonsplitAppearance(from: GhosttyConfig.load()) let config = BonsplitConfiguration( allowSplits: true, allowCloseTabs: true, @@ -3368,7 +4904,6 @@ final class Workspace: Identifiable, ObservableObject { let terminalPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, portOrdinal: portOrdinal, initialCommand: initialTerminalCommand, @@ -3376,7 +4911,6 @@ final class Workspace: Identifiable, ObservableObject { ) 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? @@ -3396,10 +4930,6 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.closeTab(welcomeTabId) } - bonsplitController.onExternalTabDrop = { [weak self] request in - self?.handleExternalTabDrop(request) ?? false - } - // Set ourselves as delegate bonsplitController.delegate = self @@ -3429,10 +4959,8 @@ final class Workspace: Identifiable, ObservableObject { } func refreshSplitButtonTooltips() { - let tooltips = Self.currentSplitButtonTooltips() var configuration = bonsplitController.configuration - guard configuration.appearance.splitButtonTooltips != tooltips else { return } - configuration.appearance.splitButtonTooltips = tooltips + configuration.appearance.splitButtonTooltips = Self.currentSplitButtonTooltips() bonsplitController.configuration = configuration } @@ -3453,9 +4981,6 @@ final class Workspace: Identifiable, ObservableObject { /// 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 var pendingTabSelection: (tabId: TabID, pane: PaneID)? @@ -3463,11 +4988,8 @@ final class Workspace: Identifiable, ObservableObject { 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 geometryReconcileScheduled = false - private var geometryReconcileNeedsRerun = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 @@ -3495,15 +5017,6 @@ final class Workspace: Identifiable, ObservableObject { private var detachingTabIds: Set<TabID> = [] private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] - private var activeDetachCloseTransactions: Int = 0 - private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } - -#if DEBUG - private func debugElapsedMs(since start: TimeInterval) -> String { - let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 - return String(format: "%.2f", ms) - } -#endif func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { surfaceIdToPanelId[surfaceId] @@ -3619,16 +5132,17 @@ final class Workspace: Identifiable, ObservableObject { private func syncUnreadBadgeStateForPanel(_ panelId: UUID) { guard let tabId = surfaceIdFromPanelId(panelId) else { return } - let shouldShowUnread = Self.shouldShowUnreadIndicator( - hasUnreadNotification: hasUnreadNotification(panelId: panelId), - isManuallyUnread: manualUnreadPanelIds.contains(panelId) - ) + let shouldShowUnread = manualUnreadPanelIds.contains(panelId) || hasUnreadNotification(panelId: panelId) if let existing = bonsplitController.tab(tabId), existing.showsNotificationBadge == shouldShowUnread { return } bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread) } + static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool { + hasUnreadNotification || isManuallyUnread + } + private func normalizePinnedTabs(in paneId: PaneID) { guard !isNormalizingPinnedTabOrder else { return } isNormalizingPinnedTabOrder = true @@ -3721,7 +5235,6 @@ final class Workspace: Identifiable, ObservableObject { func markPanelUnread(_ panelId: UUID) { guard panels[panelId] != nil else { return } guard manualUnreadPanelIds.insert(panelId).inserted else { return } - manualUnreadMarkedAt[panelId] = Date() syncUnreadBadgeStateForPanel(panelId) } @@ -3732,34 +5245,10 @@ final class Workspace: Identifiable, ObservableObject { } func clearManualUnread(panelId: UUID) { - let didRemoveUnread = manualUnreadPanelIds.remove(panelId) != nil - manualUnreadMarkedAt.removeValue(forKey: panelId) - guard didRemoveUnread else { return } + guard manualUnreadPanelIds.remove(panelId) != nil 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 { @@ -3801,7 +5290,7 @@ final class Workspace: Identifiable, ObservableObject { panelDirectories[panelId] = trimmed } // Update current directory if this is the focused panel - if panelId == focusedPanelId, currentDirectory != trimmed { + if panelId == focusedPanelId { currentDirectory = trimmed } } @@ -3893,7 +5382,6 @@ final class Workspace: Identifiable, ObservableObject { 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) } panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } @@ -3957,6 +5445,10 @@ final class Workspace: Identifiable, ObservableObject { sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) } + var isRemoteWorkspace: Bool { + remoteConfiguration != nil + } + func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] { SidebarBranchOrdering.orderedUniquePullRequests( orderedPanelIds: orderedPanelIds, @@ -3985,9 +5477,7 @@ final class Workspace: Identifiable, ObservableObject { } } - var isRemoteWorkspace: Bool { - remoteConfiguration != nil - } + // MARK: - Panel Operations var remoteDisplayTarget: String? { remoteConfiguration?.displayTarget @@ -4263,67 +5753,6 @@ final class Workspace: Identifiable, ObservableObject { } } - // MARK: - Panel Operations - - private func seedTerminalInheritanceFontPoints( - panelId: UUID, - configTemplate: ghostty_surface_config_s? - ) { - guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return } - terminalInheritanceFontPointsByPanelId[panelId] = fontPoints - lastTerminalConfigInheritanceFontPoints = fontPoints - } - - private func resolvedTerminalInheritanceFontPoints( - for terminalPanel: TerminalPanel, - sourceSurface: ghostty_surface_t, - inheritedConfig: ghostty_surface_config_s - ) -> Float? { - let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) - if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 { - if let runtimePoints, abs(runtimePoints - rooted) > 0.05 { - // Runtime zoom changed after lineage was seeded (manual zoom on descendant); - // treat runtime as the new root for future descendants. - return runtimePoints - } - return rooted - } - if inheritedConfig.font_size > 0 { - return inheritedConfig.font_size - } - return runtimePoints - } - - private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { - lastTerminalConfigInheritancePanelId = terminalPanel.id - if let sourceSurface = terminalPanel.surface.surface, - let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) { - let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] - if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { - terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints - } - lastTerminalConfigInheritanceFontPoints = - terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints - } - } - - func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? { - guard let panelId = lastTerminalConfigInheritancePanelId else { return nil } - return terminalPanel(for: panelId) - } - - func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? { - lastTerminalConfigInheritanceFontPoints - } - - /// Candidate terminal panels used as the source when creating inherited Ghostty config. - /// Preference order: - /// 1) explicitly preferred terminal panel (when the caller has one), - /// 2) selected terminal in the target pane, - /// 3) currently focused terminal in the workspace, - /// 4) last remembered terminal source, - /// 5) first terminal tab in the target pane, - /// 6) deterministic workspace fallback. private func terminalPanelConfigInheritanceCandidates( preferredPanelId: UUID? = nil, inPane preferredPaneId: PaneID? = nil @@ -4332,29 +5761,16 @@ final class Workspace: Identifiable, ObservableObject { var seen: Set<UUID> = [] func appendCandidate(_ panel: TerminalPanel?) { - guard let panel, seen.insert(panel.id).inserted else { return } + guard let panel else { return } + guard seen.insert(panel.id).inserted else { return } candidates.append(panel) } - if let preferredPanelId, - let terminalPanel = terminalPanel(for: preferredPanelId) { - appendCandidate(terminalPanel) + if let preferredPanelId, let preferredTerminal = terminalPanel(for: preferredPanelId) { + appendCandidate(preferredTerminal) } - 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) - } + appendCandidate(focusedTerminalPanel) if let preferredPaneId { for tab in bonsplitController.tabs(inPane: preferredPaneId) { @@ -4373,7 +5789,6 @@ final class Workspace: Identifiable, ObservableObject { return candidates } - /// Picks the first terminal panel candidate used as the inheritance source. func terminalPanelForConfigInheritance( preferredPanelId: UUID? = nil, inPane preferredPaneId: PaneID? = nil @@ -4384,49 +5799,7 @@ final class Workspace: Identifiable, ObservableObject { ).first } - private func inheritedTerminalConfig( - preferredPanelId: UUID? = nil, - inPane preferredPaneId: PaneID? = nil - ) -> ghostty_surface_config_s? { - // Walk candidates in priority order and use the first panel with a live surface. - // This avoids returning nil when the top candidate exists but is not attached yet. - for terminalPanel in terminalPanelConfigInheritanceCandidates( - preferredPanelId: preferredPanelId, - inPane: preferredPaneId - ) { - guard let sourceSurface = terminalPanel.surface.surface else { continue } - var config = cmuxInheritedSurfaceConfig( - sourceSurface: sourceSurface, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT - ) - if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( - for: terminalPanel, - sourceSurface: sourceSurface, - inheritedConfig: config - ), rootedFontPoints > 0 { - config.font_size = rootedFontPoints - terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints - } - rememberTerminalConfigInheritanceSource(terminalPanel) - if config.font_size > 0 { - lastTerminalConfigInheritanceFontPoints = config.font_size - } - return config - } - - if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { - var config = ghostty_surface_config_new() - config.font_size = fallbackFontPoints -#if DEBUG - dlog( - "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" - ) -#endif - return config - } - - return nil - } + // MARK: - Panel Operations /// Create a new split with a terminal panel @discardableResult @@ -4436,6 +5809,22 @@ final class Workspace: Identifiable, ObservableObject { insertFirst: Bool = false, focus: Bool = true ) -> TerminalPanel? { + // Get inherited config from the source terminal when possible. + // If the split is initiated from a non-terminal panel (for example browser), + // fall back to any terminal in the workspace. + let inheritedConfig: ghostty_surface_config_s? = { + if let sourceTerminal = terminalPanel(for: panelId), + let existing = sourceTerminal.surface.surface { + return ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) + } + if let fallbackSurface = panels.values + .compactMap({ ($0 as? TerminalPanel)?.surface.surface }) + .first { + return ghostty_surface_inherited_config(fallbackSurface, GHOSTTY_SURFACE_CONTEXT_SPLIT) + } + return nil + }() + // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? @@ -4448,7 +5837,6 @@ final class Workspace: Identifiable, ObservableObject { } guard let paneId = sourcePaneId else { return nil } - let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) // Create the new terminal panel. let newPanel = TerminalPanel( @@ -4459,7 +5847,6 @@ final class Workspace: Identifiable, ObservableObject { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle - 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). @@ -4473,44 +5860,43 @@ final class Workspace: Identifiable, ObservableObject { 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 + // 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) - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) - return nil - } + // 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) + return nil + } #if DEBUG - dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") + 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() + // 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 + ) } - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: newPanel.id, - previousHostedView: previousHostedView - ) - } - return newPanel - } + 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), @@ -4525,7 +5911,16 @@ final class Workspace: Identifiable, ObservableObject { ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + // Get an existing terminal panel to inherit config from + let inheritedConfig: ghostty_surface_config_s? = { + for panel in panels.values { + if let terminalPanel = panel as? TerminalPanel, + let surface = terminalPanel.surface.surface { + return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) + } + } + return nil + }() // Create new terminal panel let newPanel = TerminalPanel( @@ -4538,7 +5933,6 @@ final class Workspace: Identifiable, ObservableObject { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit guard let newTabId = bonsplitController.createTab( @@ -4551,7 +5945,6 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -4612,32 +6005,32 @@ final class Workspace: Identifiable, ObservableObject { 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 - } + // 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 + } - // 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 - ) - } + // 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()) @@ -5081,41 +6474,17 @@ final class Workspace: Identifiable, ObservableObject { func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } guard panels[panelId] != nil else { return nil } -#if DEBUG - let detachStart = ProcessInfo.processInfo.systemUptime - dlog( - "split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + - "tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " + - "pendingDetached=\(pendingDetachedSurfaces.count)" - ) -#endif detachingTabIds.insert(tabId) forceCloseTabIds.insert(tabId) - activeDetachCloseTransactions += 1 - defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) } guard bonsplitController.closeTab(tabId) else { detachingTabIds.remove(tabId) pendingDetachedSurfaces.removeValue(forKey: tabId) forceCloseTabIds.remove(tabId) -#if DEBUG - dlog( - "split.detach.fail ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + - "tab=\(tabId.uuid.uuidString.prefix(5)) reason=closeTabRejected elapsedMs=\(debugElapsedMs(since: detachStart))" - ) -#endif return nil } - let detached = pendingDetachedSurfaces.removeValue(forKey: tabId) -#if DEBUG - dlog( - "split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + - "tab=\(tabId.uuid.uuidString.prefix(5)) transfer=\(detached != nil ? 1 : 0) " + - "elapsedMs=\(debugElapsedMs(since: detachStart))" - ) -#endif - return detached + return pendingDetachedSurfaces.removeValue(forKey: tabId) } @discardableResult @@ -5125,31 +6494,8 @@ final class Workspace: Identifiable, ObservableObject { 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 - } + guard bonsplitController.allPaneIds.contains(paneId) else { return nil } + guard panels[detached.panelId] == nil else { return nil } panels[detached.panelId] = detached.panel if let terminalPanel = detached.panel as? TerminalPanel { @@ -5177,10 +6523,8 @@ final class Workspace: Identifiable, ObservableObject { } 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( @@ -5200,14 +6544,7 @@ final class Workspace: Identifiable, ObservableObject { 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 } @@ -5229,14 +6566,6 @@ final class Workspace: Identifiable, ObservableObject { } 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 @@ -5257,9 +6586,6 @@ final class Workspace: Identifiable, ObservableObject { 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, @@ -5328,19 +6654,51 @@ final class Workspace: Identifiable, ObservableObject { terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) } - func focusPanel( - _ panelId: UUID, - previousHostedView: GhosttySurfaceScrollView? = nil, - trigger: FocusPanelTrigger = .standard - ) { + 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 markExplicitFocusIntent(on panelId: UUID) { + guard let pending = pendingNonFocusSplitFocusReassert, + pending.splitPanelId == panelId else { + return + } + pendingNonFocusSplitFocusReassert = nil + } + + func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) { 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)" - ) + dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)") + FocusLogStore.shared.append("Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane)") #endif guard let tabId = surfaceIdFromPanelId(panelId) else { return } let currentlyFocusedPanelId = focusedPanelId @@ -5363,15 +6721,6 @@ final class Workspace: Identifiable, ObservableObject { return bonsplitController.focusedPaneId == targetPaneId && bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId }() - let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged -#if DEBUG - if shouldSuppressReentrantRefocus { - dlog( - "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + - "reason=firstResponderAlreadyConverged" - ) - } -#endif if let targetPaneId, !selectionAlreadyConverged { bonsplitController.focusPane(targetPaneId) @@ -5383,11 +6732,11 @@ final class Workspace: Identifiable, ObservableObject { // Also focus the underlying panel if let panel = panels[panelId] { - if (currentlyFocusedPanelId != panelId || !selectionAlreadyConverged) && !shouldSuppressReentrantRefocus { + if currentlyFocusedPanelId != panelId || !selectionAlreadyConverged { panel.focus() } - if !shouldSuppressReentrantRefocus, let terminalPanel = panel as? TerminalPanel { + if let terminalPanel = panel as? TerminalPanel { // Avoid re-entrant focus loops when focus was initiated by AppKit first-responder // (becomeFirstResponder -> onFocus -> focusPanel). if !terminalPanel.hostedView.isSurfaceViewFirstResponder() { @@ -5395,47 +6744,9 @@ final class Workspace: Identifiable, ObservableObject { } } } - if let targetPaneId, !shouldSuppressReentrantRefocus { + if let targetPaneId { applyTabSelection(tabId: tabId, inPane: targetPaneId) } - - if let browserPanel = panels[panelId] as? BrowserPanel { - maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) - } - } - - 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) { @@ -5541,7 +6852,14 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Flash/Notification Support func triggerFocusFlash(panelId: UUID) { - panels[panelId]?.triggerFlash() + if let terminalPanel = terminalPanel(for: panelId) { + terminalPanel.triggerFlash() + return + } + if let browserPanel = browserPanel(for: panelId) { + browserPanel.triggerFlash() + return + } } func triggerNotificationFocusFlash( @@ -5582,19 +6900,14 @@ final class Workspace: Identifiable, ObservableObject { /// 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, + configTemplate: nil, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit if let newTabId = bonsplitController.createTab( @@ -5679,7 +6992,7 @@ final class Workspace: Identifiable, ObservableObject { /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. private func scheduleFocusReconcile() { #if DEBUG - if isDetachingCloseTransaction { + if !detachingTabIds.isEmpty { debugFocusReconcileScheduledDuringDetachCount += 1 } #endif @@ -5753,7 +7066,6 @@ final class Workspace: Identifiable, ObservableObject { geometryReconcileScheduled = false geometryReconcileNeedsRerun = false } - private func scheduleTerminalGeometryReconcile() { guard !geometryReconcileScheduled else { geometryReconcileNeedsRerun = true @@ -5865,147 +7177,6 @@ final class Workspace: Identifiable, ObservableObject { 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 @@ -6053,7 +7224,6 @@ extension Workspace: BonsplitDelegate { } private func applyTabSelectionNow(tabId: TabID, inPane pane: PaneID) { - let previousFocusedPanelId = focusedPanelId if bonsplitController.allPaneIds.contains(pane) { if bonsplitController.focusedPaneId != pane { bonsplitController.focusPane(pane) @@ -6084,11 +7254,6 @@ extension Workspace: BonsplitDelegate { let panel = panels[panelId] else { return } - - if shouldTreatCurrentEventAsExplicitFocusIntent() { - markExplicitFocusIntent(on: panelId) - } - syncPinnedStateForTab(selectedTabId, panelId: panelId) syncUnreadBadgeStateForPanel(panelId) @@ -6098,34 +7263,7 @@ extension Workspace: BonsplitDelegate { } panel.focus() - let focusIntentAllowsBrowserOmnibarAutofocus = - shouldTreatCurrentEventAsExplicitFocusIntent() || - TerminalController.socketCommandAllowsInAppFocusMutations() - if let browserPanel = panel as? BrowserPanel, - 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) - } - } - } + 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. @@ -6137,8 +7275,7 @@ extension Workspace: BonsplitDelegate { if let dir = panelDirectories[panelId] { currentDirectory = dir } - gitBranch = panelGitBranches[panelId] - pullRequest = panelPullRequests[panelId] + refreshFocusedGitBranchState() // Post notification NotificationCenter.default.post( @@ -6151,57 +7288,16 @@ extension Workspace: BonsplitDelegate { ) } - 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 refreshFocusedGitBranchState() { + if let focusedPanelId { + gitBranch = panelGitBranches[focusedPanelId] + pullRequest = panelPullRequests[focusedPanelId] + } else { + gitBranch = nil + pullRequest = nil } } - 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) @@ -6283,17 +7379,15 @@ extension Workspace: BonsplitDelegate { 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 + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() - if !isDetaching { - scheduleFocusReconcile() - } + scheduleFocusReconcile() return } @@ -6301,23 +7395,23 @@ extension Workspace: BonsplitDelegate { NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") #endif + let isDetaching = detachingTabIds.remove(tabId) != nil let panel = panels[panelId] if isDetaching, let panel { let browserPanel = panel as? BrowserPanel - let cachedTitle = panelTitles[panelId] - let transferFallbackTitle = cachedTitle ?? panel.displayTitle + let cachedTitle = panelTitles[panelId] ?? panel.displayTitle pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, - title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle), + title: resolvedPanelTitle(panelId: panelId, fallback: cachedTitle), icon: panel.displayIcon, iconImageData: browserPanel?.faviconPNGData, kind: surfaceKind(for: panel), isLoading: browserPanel?.isLoading ?? false, isPinned: pinnedPanelIds.contains(panelId), directory: panelDirectories[panelId], - cachedTitle: cachedTitle, + cachedTitle: panelTitles[panelId], customTitle: panelCustomTitles[panelId], manuallyUnread: manualUnreadPanelIds.contains(panelId) ) @@ -6331,31 +7425,24 @@ extension Workspace: BonsplitDelegate { panels.removeValue(forKey: 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) + panelGitBranches.removeValue(forKey: panelId) panelSubscriptions.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 - } - // 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. + // Keep the workspace invariant: always retain at least one real panel. + // This prevents runtime close callbacks from ever collapsing into a tabless workspace. if panels.isEmpty { if isDetaching { - scheduleTerminalGeometryReconcile() + gitBranch = nil return } - let replacement = createReplacementTerminalPanel() if let replacementTabId = surfaceIdFromPanelId(replacement.id), let replacementPane = bonsplitController.allPaneIds.first { @@ -6363,6 +7450,7 @@ extension Workspace: BonsplitDelegate { bonsplitController.selectTab(replacementTabId) applyTabSelection(tabId: replacementTabId, inPane: replacementPane) } + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() scheduleFocusReconcile() return @@ -6376,20 +7464,14 @@ extension Workspace: BonsplitDelegate { // 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) } + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() - if !isDetaching { - scheduleFocusReconcile() - } + scheduleFocusReconcile() } func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { @@ -6398,56 +7480,18 @@ extension Workspace: BonsplitDelegate { 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" + let movedPanel = panelIdFromSurfaceId(tab.id)?.uuidString.prefix(5) ?? "unknown" dlog( - "split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " + + "split.moveTab 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() - } + scheduleFocusReconcile() } func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { @@ -6468,43 +7512,35 @@ extension Workspace: BonsplitDelegate { } 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) - 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) - 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) } + _ = paneId + let liveTabIds: Set<TabID> = Set( + controller.allPaneIds.flatMap { controller.tabs(inPane: $0).map(\.id) } + ) + let staleMappings = surfaceIdToPanelId.filter { !liveTabIds.contains($0.key) } + for (staleTabId, stalePanelId) in staleMappings { + panels[stalePanelId]?.close() + panels.removeValue(forKey: stalePanelId) + surfaceIdToPanelId.removeValue(forKey: staleTabId) + panelDirectories.removeValue(forKey: stalePanelId) + panelTitles.removeValue(forKey: stalePanelId) + panelCustomTitles.removeValue(forKey: stalePanelId) + pinnedPanelIds.remove(stalePanelId) + manualUnreadPanelIds.remove(stalePanelId) + panelGitBranches.removeValue(forKey: stalePanelId) + panelPullRequests.removeValue(forKey: stalePanelId) + panelSubscriptions.removeValue(forKey: stalePanelId) + surfaceTTYNames.removeValue(forKey: stalePanelId) + surfaceListeningPorts.removeValue(forKey: stalePanelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: stalePanelId) + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: stalePanelId) + } + if !staleMappings.isEmpty { recomputeListeningPorts() - - if let focusedPane = bonsplitController.focusedPaneId, - let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { - applyTabSelection(tabId: focusedTabId, inPane: focusedPane) - } else if shouldScheduleFocusReconcile { - scheduleFocusReconcile() - } } + refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() - if shouldScheduleFocusReconcile { - scheduleFocusReconcile() - } + scheduleFocusReconcile() } func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { @@ -6515,11 +7551,9 @@ extension Workspace: BonsplitDelegate { if let panelId = panelIdFromSurfaceId(tab.id), let terminalPanel = terminalPanel(for: panelId), terminalPanel.needsConfirmClose() { - pendingPaneClosePanelIds.removeValue(forKey: pane.id) return false } } - pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) } return true } @@ -6589,7 +7623,15 @@ extension Workspace: BonsplitDelegate { // 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 inheritedConfig: ghostty_surface_config_s? = { + for panel in panels.values { + if let terminalPanel = panel as? TerminalPanel, + let surface = terminalPanel.surface.surface { + return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) + } + } + return nil + }() let replacementPanel = TerminalPanel( workspaceId: id, @@ -6599,7 +7641,6 @@ extension Workspace: BonsplitDelegate { ) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) surfaceIdToPanelId[replacementTab.id] = replacementPanel.id bonsplitController.updateTab( @@ -6652,10 +7693,11 @@ extension Workspace: BonsplitDelegate { ) #endif - let inheritedConfig = inheritedTerminalConfig( - preferredPanelId: sourcePanelId, - inPane: originalPane - ) + let inheritedConfig: ghostty_surface_config_s? = if let existing = sourcePanel.surface.surface { + ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) + } else { + nil + } let newPanel = TerminalPanel( workspaceId: id, @@ -6665,7 +7707,6 @@ extension Workspace: BonsplitDelegate { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle - seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, @@ -6677,7 +7718,6 @@ extension Workspace: BonsplitDelegate { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) - terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return } @@ -6727,7 +7767,8 @@ extension Workspace: BonsplitDelegate { case .closeOthers: closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) case .move: - promptMovePanel(tabId: tab.id) + // TODO: Wire this to a move target picker. + return case .newTerminalToRight: createTerminalToRight(of: tab.id, inPane: pane) case .newBrowserToRight: @@ -6745,6 +7786,7 @@ extension Workspace: BonsplitDelegate { case .markAsRead: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } clearManualUnread(panelId: panelId) + AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) case .markAsUnread: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } markPanelUnread(panelId) @@ -6759,9 +7801,7 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { _ = snapshot scheduleTerminalGeometryReconcile() - if !isDetachingCloseTransaction { - scheduleFocusReconcile() - } + scheduleFocusReconcile() } // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups. diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 0d3cc451..927b7910 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -53,51 +53,12 @@ struct WorkspaceContentView: View { }() BonsplitView(controller: workspace.bonsplitController) { tab, paneId in - // Content for each tab in bonsplit - let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) - if let panel = workspace.panel(for: tab.id) { - let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id - let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id - let isVisibleInUI = Self.panelVisibleInUI( - isWorkspaceVisible: isWorkspaceVisible, - isSelectedInPane: isSelectedInPane, - isFocused: isFocused - ) - let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( - hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), - isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) - ) - PanelContentView( - panel: panel, - isFocused: isFocused, - isSelectedInPane: isSelectedInPane, - isVisibleInUI: isVisibleInUI, - portalPriority: workspacePortalPriority, - isSplit: isSplit, - appearance: appearance, - hasUnreadNotification: hasUnreadNotification, - onFocus: { - // Keep bonsplit focus in sync with the AppKit first responder for the - // active workspace. This prevents divergence between the blue focused-tab - // indicator and where keyboard input/flash-focus actually lands. - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id, trigger: .terminalFirstResponder) - }, - onRequestPanelFocus: { - guard isWorkspaceInputActive else { return } - guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id) - }, - onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) } - ) - .onTapGesture { - workspace.bonsplitController.focusPane(paneId) - } - } else { - // Fallback for tabs without panels (shouldn't happen normally) - EmptyPanelView(workspace: workspace, paneId: paneId) - } + panelView( + tab: tab, + paneId: paneId, + isSplit: isSplit, + appearance: appearance + ) } emptyPane: { paneId in // Empty pane content EmptyPanelView(workspace: workspace, paneId: paneId) @@ -143,6 +104,55 @@ struct WorkspaceContentView: View { } } + @ViewBuilder + private func panelView( + tab: Bonsplit.Tab, + paneId: PaneID, + isSplit: Bool, + appearance: PanelAppearance + ) -> some View { + let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) + if let panel = workspace.panel(for: tab.id) { + let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id + let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id + let isVisibleInUI = Self.panelVisibleInUI( + isWorkspaceVisible: isWorkspaceVisible, + isSelectedInPane: isSelectedInPane, + isFocused: isFocused + ) + let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( + hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), + isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) + ) + PanelContentView( + panel: panel, + isFocused: isFocused, + isSelectedInPane: isSelectedInPane, + isVisibleInUI: isVisibleInUI, + portalPriority: workspacePortalPriority, + isSplit: isSplit, + appearance: appearance, + hasUnreadNotification: hasUnreadNotification, + onFocus: { + guard isWorkspaceInputActive else { return } + guard workspace.panels[panel.id] != nil else { return } + workspace.focusPanel(panel.id) + }, + onRequestPanelFocus: { + guard isWorkspaceInputActive else { return } + guard workspace.panels[panel.id] != nil else { return } + workspace.focusPanel(panel.id) + }, + onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) } + ) + .onTapGesture { + workspace.bonsplitController.focusPane(paneId) + } + } else { + EmptyPanelView(workspace: workspace, paneId: paneId) + } + } + private func syncBonsplitNotificationBadges() { let unreadFromNotifications: Set<UUID> = Set( notificationStore.notifications @@ -243,7 +253,8 @@ struct WorkspaceContentView: View { ) let chromeReason = "refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)" - workspace.applyGhosttyChrome(from: next, reason: chromeReason) + _ = chromeReason + workspace.applyGhosttyChrome(from: next) if let terminalPanel = workspace.focusedTerminalPanel { terminalPanel.applyWindowBackgroundIfActive() logTheme( diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 08063767..dfd6d7cc 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3065,6 +3065,74 @@ final class UpdateChannelSettingsTests: XCTestCase { } } +final class SidebarRemoteErrorCopySupportTests: XCTestCase { + func testMenuLabelIsNilWhenThereAreNoErrors() { + XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) + XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: [])) + } + + func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to start reverse relay" + ) + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + "SSH error (devbox:22): failed to start reverse relay" + ) + } + + func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox-a:22", + detail: "connection timed out" + ), + SidebarRemoteErrorCopyEntry( + workspaceTitle: "beta", + target: "devbox-b:22", + detail: "permission denied" + ), + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + """ + 1. alpha (devbox-a:22): connection timed out + 2. beta (devbox-b:22): permission denied + """ + ) + } + + func testParsedTargetAndDetailParsesCanonicalStatusValue() { + let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( + from: "SSH error (devbox:22): failed to bootstrap daemon" + ) + XCTAssertEqual(parsed?.target, "devbox:22") + XCTAssertEqual(parsed?.detail, "failed to bootstrap daemon") + } + + func testParsedTargetAndDetailUsesFallbackTargetWhenStatusOmitsTarget() { + let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( + from: "SSH error: connection refused", + fallbackTarget: "fallback-host" + ) + XCTAssertEqual(parsed?.target, "fallback-host") + XCTAssertEqual(parsed?.detail, "connection refused") + } + + func testParsedTargetAndDetailIgnoresNonSSHStatusValues() { + XCTAssertNil(SidebarRemoteErrorCopySupport.parsedTargetAndDetail(from: "All good")) + } +} + final class WorkspaceReorderTests: XCTestCase { @MainActor func testReorderWorkspaceMovesWorkspaceToRequestedIndex() { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift new file mode 100644 index 00000000..af954ee2 --- /dev/null +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -0,0 +1,49 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TabManagerSessionSnapshotTests: XCTestCase { + func testSessionSnapshotSerializesWorkspacesAndRestoreRebuildsSelection() { + let manager = TabManager() + guard let firstWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial workspace") + return + } + firstWorkspace.setCustomTitle("First") + + let secondWorkspace = manager.addWorkspace(select: true) + secondWorkspace.setCustomTitle("Second") + XCTAssertEqual(manager.tabs.count, 2) + XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + XCTAssertEqual(snapshot.workspaces.count, 2) + XCTAssertEqual(snapshot.selectedWorkspaceIndex, 1) + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + XCTAssertEqual(restored.tabs.count, 2) + XCTAssertEqual(restored.selectedTabId, restored.tabs[1].id) + XCTAssertEqual(restored.tabs[0].customTitle, "First") + XCTAssertEqual(restored.tabs[1].customTitle, "Second") + } + + func testRestoreSessionSnapshotWithNoWorkspacesKeepsSingleFallbackWorkspace() { + let manager = TabManager() + let emptySnapshot = SessionTabManagerSnapshot( + selectedWorkspaceIndex: nil, + workspaces: [] + ) + + manager.restoreSessionSnapshot(emptySnapshot) + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertNotNil(manager.selectedTabId) + } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift new file mode 100644 index 00000000..ccf3f116 --- /dev/null +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -0,0 +1,214 @@ +import XCTest +import AppKit +import Darwin + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TerminalControllerSocketSecurityTests: XCTestCase { + private func makeSocketPath(_ name: String) -> String { + FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-socket-security-\(name)-\(UUID().uuidString).sock") + .path + } + + override func setUp() { + super.setUp() + TerminalController.shared.stop() + } + + override func tearDown() { + TerminalController.shared.stop() + super.tearDown() + } + + func testSocketPermissionsFollowAccessMode() throws { + let tabManager = TabManager() + + let allowAllPath = makeSocketPath("allow-all") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: allowAllPath, + accessMode: .allowAll + ) + try waitForSocket(at: allowAllPath) + XCTAssertEqual(try socketMode(at: allowAllPath), 0o666) + + TerminalController.shared.stop() + + let restrictedPath = makeSocketPath("cmux-only") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: restrictedPath, + accessMode: .cmuxOnly + ) + try waitForSocket(at: restrictedPath) + XCTAssertEqual(try socketMode(at: restrictedPath), 0o600) + } + + func testPasswordModeRejectsUnauthenticatedCommands() throws { + let socketPath = makeSocketPath("password-mode") + let tabManager = TabManager() + + TerminalController.shared.start( + tabManager: tabManager, + socketPath: socketPath, + accessMode: .password + ) + try waitForSocket(at: socketPath) + + let pingOnly = try sendCommands(["ping"], to: socketPath) + XCTAssertEqual(pingOnly.count, 1) + XCTAssertTrue(pingOnly[0].hasPrefix("ERROR:")) + XCTAssertFalse(pingOnly[0].localizedCaseInsensitiveContains("PONG")) + + let wrongAuthThenPing = try sendCommands( + ["auth not-the-password", "ping"], + to: socketPath + ) + XCTAssertEqual(wrongAuthThenPing.count, 2) + XCTAssertTrue(wrongAuthThenPing[0].hasPrefix("ERROR:")) + XCTAssertTrue(wrongAuthThenPing[1].hasPrefix("ERROR:")) + } + + func testSocketCommandPolicyDistinguishesFocusIntent() throws { +#if DEBUG + let nonFocus = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "ping", + isV2: false + ) + XCTAssertTrue(nonFocus.insideSuppressed) + XCTAssertFalse(nonFocus.insideAllowsFocus) + XCTAssertFalse(nonFocus.outsideSuppressed) + XCTAssertFalse(nonFocus.outsideAllowsFocus) + + let focusV1 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "focus_window", + isV2: false + ) + XCTAssertTrue(focusV1.insideSuppressed) + XCTAssertTrue(focusV1.insideAllowsFocus) + XCTAssertFalse(focusV1.outsideSuppressed) + + let focusV2 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "workspace.select", + isV2: true + ) + XCTAssertTrue(focusV2.insideSuppressed) + XCTAssertTrue(focusV2.insideAllowsFocus) + XCTAssertFalse(focusV2.outsideSuppressed) +#else + throw XCTSkip("Socket command policy snapshot helper is debug-only.") +#endif + } + + private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: path) { + return + } + usleep(20_000) + } + XCTFail("Timed out waiting for socket at \(path)") + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) + } + + private func socketMode(at path: String) throws -> UInt16 { + var fileInfo = stat() + guard lstat(path, &fileInfo) == 0 else { + throw posixError("lstat(\(path))") + } + return UInt16(fileInfo.st_mode & 0o777) + } + + private func sendCommands(_ commands: [String], to socketPath: String) throws -> [String] { + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw posixError("socket(AF_UNIX)") + } + defer { Darwin.close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let bytes = Array(socketPath.utf8) + let maxPathLen = MemoryLayout.size(ofValue: addr.sun_path) + guard bytes.count < maxPathLen else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG)) + } + + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let cPath = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + cPath.initialize(repeating: 0, count: maxPathLen) + for (index, byte) in bytes.enumerated() { + cPath[index] = CChar(bitPattern: byte) + } + } + + let addrLen = socklen_t(MemoryLayout<sa_family_t>.size + bytes.count + 1) + let connectResult = withUnsafePointer(to: &addr) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, addrLen) + } + } + guard connectResult == 0 else { + throw posixError("connect(\(socketPath))") + } + + var responses: [String] = [] + for command in commands { + try writeLine(command, to: fd) + responses.append(try readLine(from: fd)) + } + return responses + } + + private func writeLine(_ command: String, to fd: Int32) throws { + let payload = Array((command + "\n").utf8) + var offset = 0 + while offset < payload.count { + let wrote = payload.withUnsafeBytes { raw in + Darwin.write(fd, raw.baseAddress!.advanced(by: offset), payload.count - offset) + } + guard wrote >= 0 else { + throw posixError("write(\(command))") + } + offset += wrote + } + } + + private func readLine(from fd: Int32) throws -> String { + var buffer = [UInt8](repeating: 0, count: 1) + var data = Data() + + while true { + let count = Darwin.read(fd, &buffer, 1) + guard count >= 0 else { + throw posixError("read") + } + if count == 0 { break } + if buffer[0] == 0x0A { break } + data.append(buffer[0]) + } + + guard let line = String(data: data, encoding: .utf8) else { + throw NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Invalid UTF-8 response from socket" + ]) + } + return line + } + + private func posixError(_ operation: String) -> NSError { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "\(operation) failed: \(String(cString: strerror(errno)))"] + ) + } +} diff --git a/daemon/remote/README.md b/daemon/remote/README.md index fe4951a2..a510a19e 100644 --- a/daemon/remote/README.md +++ b/daemon/remote/README.md @@ -1,12 +1,17 @@ # cmuxd-remote (Go) -Go remote daemon for `cmux ssh` bootstrap and capability negotiation. +Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and CLI relay. + +## Commands -Current commands: 1. `cmuxd-remote version` 2. `cmuxd-remote serve --stdio` +3. `cmuxd-remote cli <command> [args...]` — relay cmux commands to the local app over the reverse TCP forward + +When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the binary auto-dispatches to the `cli` subcommand. This is busybox-style argv[0] detection. + +## RPC methods (newline-delimited JSON over stdio) -Current RPC methods (newline-delimited JSON): 1. `hello` 2. `ping` 3. `proxy.open` @@ -31,3 +36,20 @@ Current integration in cmux: 2. Out-of-range values and invalid types return `invalid_params`. 3. `local_proxy_port` is an internal deterministic test hook used by bind-conflict regressions. 4. SSH option precedence checks are case-insensitive; user overrides for `StrictHostKeyChecking` and control-socket keys prevent default injection. + +## CLI relay + +The `cli` subcommand (or `cmux` wrapper/symlink) connects to the local cmux app's socket through an SSH reverse TCP forward and relays commands. It supports both v1 text protocol and v2 JSON-RPC commands. + +Socket discovery order: +1. `--socket <path>` flag +2. `CMUX_SOCKET_PATH` environment variable +3. `~/.cmux/socket_addr` file (written by the app after the reverse relay establishes) + +For TCP addresses, the CLI retries for up to 15 seconds on connection refused, re-reading `~/.cmux/socket_addr` on each attempt to pick up updated relay ports. + +Integration additions for the relay path: + +1. Bootstrap installs `~/.cmux/bin/cmux` wrapper and keeps a default daemon target (`~/.cmux/bin/cmuxd-remote-current`). +2. A background `ssh -N -R` process reverse-forwards a TCP port to the local cmux Unix socket. The relay address is written to `~/.cmux/socket_addr` on the remote. +3. Relay startup writes `~/.cmux/relay/<port>.daemon_path` so the wrapper can route each shell to the correct daemon binary when multiple local cmux instances/versions coexist. diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go new file mode 100644 index 00000000..fad8b4d9 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -0,0 +1,568 @@ +package main + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +// protocolVersion indicates whether a command uses the v1 text or v2 JSON-RPC protocol. +type protocolVersion int + +const ( + protoV1 protocolVersion = iota + protoV2 +) + +// commandSpec describes a single CLI command and how to relay it. +type commandSpec struct { + name string // CLI command name (e.g. "ping", "new-window") + proto protocolVersion // v1 text or v2 JSON-RPC + v1Cmd string // v1: literal command string sent over the socket + v2Method string // v2: JSON-RPC method name + // flagKeys lists parameter keys this command accepts. + // They are extracted from --key flags and added to params. + flagKeys []string + // noParams means the command takes no parameters at all. + noParams bool +} + +var commands = []commandSpec{ + // V1 text protocol commands + {name: "ping", proto: protoV1, v1Cmd: "ping", noParams: true}, + {name: "new-window", proto: protoV1, v1Cmd: "new_window", noParams: true}, + {name: "current-window", proto: protoV1, v1Cmd: "current_window", noParams: true}, + {name: "close-window", proto: protoV1, v1Cmd: "close_window", flagKeys: []string{"window"}}, + {name: "focus-window", proto: protoV1, v1Cmd: "focus_window", flagKeys: []string{"window"}}, + {name: "list-windows", proto: protoV1, v1Cmd: "list_windows", noParams: true}, + + // V2 JSON-RPC commands + {name: "capabilities", proto: protoV2, v2Method: "system.capabilities", noParams: true}, + {name: "list-workspaces", proto: protoV2, v2Method: "workspace.list", noParams: true}, + {name: "new-workspace", proto: protoV2, v2Method: "workspace.create", flagKeys: []string{"command", "working-directory", "name"}}, + {name: "close-workspace", proto: protoV2, v2Method: "workspace.close", flagKeys: []string{"workspace"}}, + {name: "select-workspace", proto: protoV2, v2Method: "workspace.select", flagKeys: []string{"workspace"}}, + {name: "current-workspace", proto: protoV2, v2Method: "workspace.current", noParams: true}, + {name: "list-panels", proto: protoV2, v2Method: "panel.list", flagKeys: []string{"workspace"}}, + {name: "focus-panel", proto: protoV2, v2Method: "panel.focus", flagKeys: []string{"panel", "workspace"}}, + {name: "list-panes", proto: protoV2, v2Method: "pane.list", flagKeys: []string{"workspace"}}, + {name: "list-pane-surfaces", proto: protoV2, v2Method: "pane.surfaces", flagKeys: []string{"pane"}}, + {name: "new-pane", proto: protoV2, v2Method: "pane.create", flagKeys: []string{"workspace"}}, + {name: "new-surface", proto: protoV2, v2Method: "surface.create", flagKeys: []string{"workspace", "pane"}}, + {name: "new-split", proto: protoV2, v2Method: "surface.split", flagKeys: []string{"surface", "direction"}}, + {name: "close-surface", proto: protoV2, v2Method: "surface.close", flagKeys: []string{"surface"}}, + {name: "send", proto: protoV2, v2Method: "surface.send_text", flagKeys: []string{"surface", "text"}}, + {name: "send-key", proto: protoV2, v2Method: "surface.send_key", flagKeys: []string{"surface", "key"}}, + {name: "notify", proto: protoV2, v2Method: "notification.create", flagKeys: []string{"title", "body", "workspace"}}, + {name: "refresh-surfaces", proto: protoV2, v2Method: "surface.refresh", noParams: true}, +} + +var commandIndex map[string]*commandSpec + +func init() { + commandIndex = make(map[string]*commandSpec, len(commands)) + for i := range commands { + commandIndex[commands[i].name] = &commands[i] + } +} + +// runCLI is the entry point for the "cli" subcommand (or busybox "cmux" invocation). +func runCLI(args []string) int { + socketPath := os.Getenv("CMUX_SOCKET_PATH") + + // Parse global flags + var jsonOutput bool + var remaining []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--socket": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "cmux: --socket requires a path") + return 2 + } + socketPath = args[i+1] + i++ + case "--json": + jsonOutput = true + case "--help", "-h": + cliUsage() + return 0 + default: + remaining = append(remaining, args[i:]...) + goto doneFlags + } + } +doneFlags: + + if len(remaining) == 0 { + cliUsage() + return 2 + } + cmdName := remaining[0] + cmdArgs := remaining[1:] + if cmdName == "help" { + cliUsage() + return 0 + } + + // refreshAddr is set when the address came from socket_addr file (not env/flag), + // allowing retry loops to pick up updated relay ports. + var refreshAddr func() string + if socketPath == "" { + socketPath = readSocketAddrFile() + refreshAddr = readSocketAddrFile + } + if socketPath == "" { + fmt.Fprintln(os.Stderr, "cmux: CMUX_SOCKET_PATH not set and --socket not provided") + return 1 + } + + // Special case: "rpc" passthrough + if cmdName == "rpc" { + return runRPC(socketPath, cmdArgs, jsonOutput, refreshAddr) + } + + // Browser subcommand delegation + if cmdName == "browser" { + return runBrowserRelay(socketPath, cmdArgs, jsonOutput, refreshAddr) + } + + spec, ok := commandIndex[cmdName] + if !ok { + fmt.Fprintf(os.Stderr, "cmux: unknown command %q\n", cmdName) + return 2 + } + + switch spec.proto { + case protoV1: + return execV1(socketPath, spec, cmdArgs, refreshAddr) + case protoV2: + return execV2(socketPath, spec, cmdArgs, jsonOutput, refreshAddr) + default: + fmt.Fprintf(os.Stderr, "cmux: internal error: unknown protocol for %q\n", cmdName) + return 1 + } +} + +// execV1 sends a v1 text command over the socket. +func execV1(socketPath string, spec *commandSpec, args []string, refreshAddr func() string) int { + cmd := spec.v1Cmd + + if !spec.noParams { + parsed := parseFlags(args, spec.flagKeys) + for _, key := range spec.flagKeys { + if val, ok := parsed.flags[key]; ok { + cmd += " " + val + } + } + } + + resp, err := socketRoundTrip(socketPath, cmd, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + fmt.Print(resp) + if !strings.HasSuffix(resp, "\n") { + fmt.Println() + } + return 0 +} + +// execV2 sends a v2 JSON-RPC request over the socket. +func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool, refreshAddr func() string) int { + params := make(map[string]any) + + if !spec.noParams { + parsed := parseFlags(args, spec.flagKeys) + // Map flag keys to JSON param keys (e.g. "workspace" → "workspace_id" where appropriate) + for _, key := range spec.flagKeys { + if val, ok := parsed.flags[key]; ok { + paramKey := flagToParamKey(key) + params[paramKey] = val + } + } + + // First positional arg is used as initial_command if --command wasn't given + if _, ok := params["initial_command"]; !ok && len(parsed.positional) > 0 { + params["initial_command"] = parsed.positional[0] + } + + // Fall back to env vars for common IDs + if _, ok := params["workspace_id"]; !ok { + if envWs := os.Getenv("CMUX_WORKSPACE_ID"); envWs != "" { + params["workspace_id"] = envWs + } + } + if _, ok := params["surface_id"]; !ok { + if envSf := os.Getenv("CMUX_SURFACE_ID"); envSf != "" { + params["surface_id"] = envSf + } + } + } + + resp, err := socketRoundTripV2(socketPath, spec.v2Method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + + if jsonOutput { + fmt.Println(resp) + } else { + fmt.Println("OK") + } + return 0 +} + +// runRPC sends an arbitrary JSON-RPC method with optional JSON params. +func runRPC(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "cmux rpc: requires a method name") + return 2 + } + method := args[0] + var params map[string]any + if len(args) > 1 { + if err := json.Unmarshal([]byte(args[1]), ¶ms); err != nil { + fmt.Fprintf(os.Stderr, "cmux rpc: invalid JSON params: %v\n", err) + return 2 + } + } + + resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + fmt.Println(resp) + return 0 +} + +// runBrowserRelay handles "cmux browser <subcommand>" by mapping to browser.* v2 methods. +func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "cmux browser: requires a subcommand (open, navigate, back, forward, reload, get-url)") + return 2 + } + + sub := args[0] + subArgs := args[1:] + + var method string + var flagKeys []string + switch sub { + case "open", "open-split", "new": + method = "browser.open" + flagKeys = []string{"url", "workspace", "surface"} + case "navigate": + method = "browser.navigate" + flagKeys = []string{"url", "surface"} + case "back": + method = "browser.back" + flagKeys = []string{"surface"} + case "forward": + method = "browser.forward" + flagKeys = []string{"surface"} + case "reload": + method = "browser.reload" + flagKeys = []string{"surface"} + case "get-url": + method = "browser.get_url" + flagKeys = []string{"surface"} + default: + fmt.Fprintf(os.Stderr, "cmux browser: unknown subcommand %q\n", sub) + return 2 + } + + params := make(map[string]any) + parsed := parseFlags(subArgs, flagKeys) + for _, key := range flagKeys { + if val, ok := parsed.flags[key]; ok { + paramKey := flagToParamKey(key) + params[paramKey] = val + } + } + + resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + if jsonOutput { + fmt.Println(resp) + } else { + fmt.Println("OK") + } + return 0 +} + +// flagToParamKey maps a CLI flag name to its JSON-RPC param key. +func flagToParamKey(key string) string { + switch key { + case "workspace": + return "workspace_id" + case "surface": + return "surface_id" + case "panel": + return "panel_id" + case "pane": + return "pane_id" + case "window": + return "window_id" + case "command": + return "initial_command" + case "name": + return "title" + case "working-directory": + return "working_directory" + default: + return key + } +} + +// parsedFlags holds the results of flag parsing. +type parsedFlags struct { + flags map[string]string // --key value pairs + positional []string // non-flag arguments +} + +// parseFlags extracts --key value pairs from args for the given allowed keys. +// Non-flag arguments are collected in positional. +func parseFlags(args []string, keys []string) parsedFlags { + allowed := make(map[string]bool, len(keys)) + for _, k := range keys { + allowed[k] = true + } + + result := parsedFlags{flags: make(map[string]string)} + for i := 0; i < len(args); i++ { + if !strings.HasPrefix(args[i], "--") { + result.positional = append(result.positional, args[i]) + continue + } + key := strings.TrimPrefix(args[i], "--") + if !allowed[key] { + continue + } + if i+1 < len(args) { + result.flags[key] = args[i+1] + i++ + } + } + return result +} + +// readSocketAddrFile reads the socket address from ~/.cmux/socket_addr as a fallback +// when CMUX_SOCKET_PATH is not set. Written by the cmux app after the relay establishes. +func readSocketAddrFile() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(home, ".cmux", "socket_addr")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// dialSocket connects to the cmux socket. If addr contains a colon and doesn't +// start with '/', it's treated as a TCP address (host:port); otherwise Unix socket. +// For TCP connections, it retries briefly to allow the SSH reverse forward to establish. +// refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files. +func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { + if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") { + return dialTCPRetry(addr, 15*time.Second, refreshAddr) + } + return net.Dial("unix", addr) +} + +// dialTCPRetry attempts a TCP connection, retrying on "connection refused" for up to timeout. +// This handles the case where the SSH reverse relay hasn't finished establishing yet. +// If refreshAddr is non-nil, it's called on each retry to pick up updated addresses +// (e.g. when socket_addr is rewritten by a new relay process). +func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, error) { + deadline := time.Now().Add(timeout) + interval := 250 * time.Millisecond + printed := false + for { + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err == nil { + return conn, nil + } + if time.Now().After(deadline) { + return nil, err + } + // Only retry on connection refused (relay not ready yet) + if !isConnectionRefused(err) { + return nil, err + } + if !printed { + fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr) + printed = true + } + time.Sleep(interval) + // Re-read socket_addr in case the relay port has changed + if refreshAddr != nil { + if newAddr := refreshAddr(); newAddr != "" && newAddr != addr { + addr = newAddr + fmt.Fprintf(os.Stderr, "cmux: relay address updated to %s\n", addr) + } + } + } +} + +func isConnectionRefused(err error) bool { + if opErr, ok := err.(*net.OpError); ok { + return strings.Contains(opErr.Err.Error(), "connection refused") + } + return strings.Contains(err.Error(), "connection refused") +} + +// socketRoundTrip sends a raw text line and reads a raw text response (v1). +func socketRoundTrip(socketPath, command string, refreshAddr func() string) (string, error) { + conn, err := dialSocket(socketPath, refreshAddr) + if err != nil { + return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err) + } + defer conn.Close() + + if _, err := fmt.Fprintf(conn, "%s\n", command); err != nil { + return "", fmt.Errorf("failed to send command: %w", err) + } + + // V1 handlers may return multiple lines (e.g. list_windows). Read until + // the stream goes idle briefly after seeing at least one newline. + reader := bufio.NewReader(conn) + var response strings.Builder + sawNewline := false + + for { + readTimeout := 15 * time.Second + if sawNewline { + readTimeout = 120 * time.Millisecond + } + _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) + + chunk, err := reader.ReadString('\n') + if chunk != "" { + response.WriteString(chunk) + if strings.Contains(chunk, "\n") { + sawNewline = true + } + } + + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + if sawNewline { + break + } + return "", fmt.Errorf("failed to read response: timeout waiting for response") + } + if errors.Is(err, io.EOF) { + break + } + return "", fmt.Errorf("failed to read response: %w", err) + } + } + + return strings.TrimRight(response.String(), "\n"), nil +} + +// socketRoundTripV2 sends a JSON-RPC request and returns the result JSON. +func socketRoundTripV2(socketPath, method string, params map[string]any, refreshAddr func() string) (string, error) { + conn, err := dialSocket(socketPath, refreshAddr) + if err != nil { + return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err) + } + defer conn.Close() + + id := randomHex(8) + req := map[string]any{ + "id": id, + "method": method, + } + if params != nil { + req["params"] = params + } else { + req["params"] = map[string]any{} + } + + payload, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + if _, err := conn.Write(append(payload, '\n')); err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Parse the response to check for errors + var resp map[string]any + if err := json.Unmarshal([]byte(line), &resp); err != nil { + return strings.TrimRight(line, "\n"), nil + } + + if ok, _ := resp["ok"].(bool); !ok { + if errObj, _ := resp["error"].(map[string]any); errObj != nil { + code, _ := errObj["code"].(string) + msg, _ := errObj["message"].(string) + return "", fmt.Errorf("server error [%s]: %s", code, msg) + } + return "", fmt.Errorf("server returned error response") + } + + // Return the result portion as JSON + if result, ok := resp["result"]; ok { + resultJSON, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + return string(resultJSON), nil + } + + return "{}", nil +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func cliUsage() { + fmt.Fprintln(os.Stderr, "Usage: cmux [--socket <path>] [--json] <command> [args...]") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Commands:") + fmt.Fprintln(os.Stderr, " ping Check connectivity") + fmt.Fprintln(os.Stderr, " capabilities List server capabilities") + fmt.Fprintln(os.Stderr, " list-workspaces List all workspaces") + fmt.Fprintln(os.Stderr, " new-window Create a new window") + fmt.Fprintln(os.Stderr, " new-workspace Create a new workspace") + fmt.Fprintln(os.Stderr, " new-surface Create a new surface") + fmt.Fprintln(os.Stderr, " new-split Split an existing surface") + fmt.Fprintln(os.Stderr, " close-surface Close a surface") + fmt.Fprintln(os.Stderr, " close-workspace Close a workspace") + fmt.Fprintln(os.Stderr, " select-workspace Select a workspace") + fmt.Fprintln(os.Stderr, " send Send text to a surface") + fmt.Fprintln(os.Stderr, " send-key Send a key to a surface") + fmt.Fprintln(os.Stderr, " notify Create a notification") + fmt.Fprintln(os.Stderr, " browser <sub> Browser commands (open, navigate, back, forward, reload, get-url)") + fmt.Fprintln(os.Stderr, " rpc <method> [json-params] Send arbitrary JSON-RPC") +} diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go new file mode 100644 index 00000000..924c4e00 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -0,0 +1,482 @@ +package main + +import ( + "encoding/json" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// startMockSocket creates a Unix socket that accepts one connection, +// reads a line, and responds with the given canned response. +func startMockSocket(t *testing.T, response string) string { + t.Helper() + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _ = n // consume request + conn.Write([]byte(response + "\n")) + conn.Close() + } + }() + + return sockPath +} + +// startMockV2Socket creates a Unix socket that echoes the received request's method +// back as a successful JSON-RPC response with the method name in the result. +func startMockV2Socket(t *testing.T) string { + t.Helper() + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + if n > 0 { + var req map[string]any + if err := json.Unmarshal(buf[:n], &req); err == nil { + resp := map[string]any{ + "id": req["id"], + "ok": true, + "result": map[string]any{"method": req["method"], "params": req["params"]}, + } + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + } else { + conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) + } + } + conn.Close() + } + }() + + return sockPath +} + +// startMockTCPSocket creates a TCP listener that responds with a canned response. +func startMockTCPSocket(t *testing.T, response string) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _ = n + conn.Write([]byte(response + "\n")) + conn.Close() + } + }() + + return ln.Addr().String() +} + +func TestDialTCPRetrySuccess(t *testing.T) { + // Get a free port, then close the listener so connection is refused initially. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + + // Start a listener after a delay so the retry logic finds it. + go func() { + time.Sleep(400 * time.Millisecond) + ln2, err := net.Listen("tcp", addr) + if err != nil { + return + } + defer ln2.Close() + conn, err := ln2.Accept() + if err != nil { + return + } + conn.Close() + }() + + conn, err := dialTCPRetry(addr, 3*time.Second, nil) + if err != nil { + t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err) + } + conn.Close() +} + +func TestDialTCPRetryTimeout(t *testing.T) { + // Get a free port and close it — nothing will ever listen. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + + start := time.Now() + _, err = dialTCPRetry(addr, 600*time.Millisecond, nil) + elapsed := time.Since(start) + if err == nil { + t.Fatal("dialTCPRetry should fail when nothing is listening") + } + if elapsed < 500*time.Millisecond { + t.Fatalf("should have retried for ~600ms, only took %v", elapsed) + } +} + +func TestCLIPingV1(t *testing.T) { + sockPath := startMockSocket(t, "pong") + code := runCLI([]string{"--socket", sockPath, "ping"}) + if code != 0 { + t.Fatalf("ping should return 0, got %d", code) + } +} + +func TestCLIPingV1OverTCP(t *testing.T) { + addr := startMockTCPSocket(t, "pong") + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over TCP should return 0, got %d", code) + } +} + +func TestDialSocketDetection(t *testing.T) { + // Unix socket paths should attempt Unix dial + for _, path := range []string{"/tmp/cmux-nonexistent-test-99999.sock", "/var/run/cmux-nonexistent.sock"} { + conn, err := dialSocket(path, nil) + if conn != nil { + conn.Close() + } + // We expect a connection error (not found), not a panic + if err == nil { + t.Fatalf("dialSocket(%q) should fail for non-existent path", path) + } + } + + // TCP addresses should attempt TCP dial + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + go func() { + conn, _ := ln.Accept() + if conn != nil { + conn.Close() + } + }() + + conn, err := dialSocket(ln.Addr().String(), nil) + if err != nil { + t.Fatalf("dialSocket(%q) should succeed for TCP: %v", ln.Addr().String(), err) + } + conn.Close() +} + +func TestCLINewWindowV1(t *testing.T) { + sockPath := startMockSocket(t, "OK window_id=abc123") + code := runCLI([]string{"--socket", sockPath, "new-window"}) + if code != 0 { + t.Fatalf("new-window should return 0, got %d", code) + } +} + +func TestSocketRoundTripReadsFullMultilineV1Response(t *testing.T) { + addr := startMockTCPSocket(t, "window:alpha\nwindow:beta\nwindow:gamma") + resp, err := socketRoundTrip(addr, "list_windows", nil) + if err != nil { + t.Fatalf("socketRoundTrip should succeed, got error: %v", err) + } + want := "window:alpha\nwindow:beta\nwindow:gamma" + if resp != want { + t.Fatalf("socketRoundTrip truncated v1 response: got %q want %q", resp, want) + } +} + +func TestCLICloseWindowV1(t *testing.T) { + // Verify that the flag value is appended to the v1 command + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var received string + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + received = strings.TrimSpace(string(buf[:n])) + conn.Write([]byte("OK\n")) + conn.Close() + }() + + code := runCLI([]string{"--socket", sockPath, "close-window", "--window", "win-42"}) + if code != 0 { + t.Fatalf("close-window should return 0, got %d", code) + } + if received != "close_window win-42" { + t.Fatalf("expected 'close_window win-42', got %q", received) + } +} + +func TestCLIListWorkspacesV2(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "--json", "list-workspaces"}) + if code != 0 { + t.Fatalf("list-workspaces should return 0, got %d", code) + } +} + +func TestCLIRPCPassthrough(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "rpc", "system.capabilities"}) + if code != 0 { + t.Fatalf("rpc should return 0, got %d", code) + } +} + +func TestCLIRPCWithParams(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "rpc", "workspace.create", `{"title":"test"}`}) + if code != 0 { + t.Fatalf("rpc with params should return 0, got %d", code) + } +} + +func TestCLIUnknownCommand(t *testing.T) { + code := runCLI([]string{"--socket", "/dev/null", "does-not-exist"}) + if code != 2 { + t.Fatalf("unknown command should return 2, got %d", code) + } +} + +func TestCLINoSocket(t *testing.T) { + // Without CMUX_SOCKET_PATH set, should fail + os.Unsetenv("CMUX_SOCKET_PATH") + code := runCLI([]string{"ping"}) + if code != 1 { + t.Fatalf("missing socket should return 1, got %d", code) + } +} + +func TestCLISocketEnvVar(t *testing.T) { + sockPath := startMockSocket(t, "pong") + os.Setenv("CMUX_SOCKET_PATH", sockPath) + defer os.Unsetenv("CMUX_SOCKET_PATH") + + code := runCLI([]string{"ping"}) + if code != 0 { + t.Fatalf("ping with env socket should return 0, got %d", code) + } +} + +func TestCLIV2FlagMapping(t *testing.T) { + // Verify that --workspace gets mapped to workspace_id in params + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var receivedParams map[string]any + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + var req map[string]any + json.Unmarshal(buf[:n], &req) + receivedParams, _ = req["params"].(map[string]any) + resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + conn.Close() + }() + + code := runCLI([]string{"--socket", sockPath, "--json", "close-workspace", "--workspace", "ws-abc"}) + if code != 0 { + t.Fatalf("close-workspace should return 0, got %d", code) + } + if receivedParams["workspace_id"] != "ws-abc" { + t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams) + } +} + +func TestBusyboxArgv0Detection(t *testing.T) { + // Verify that when argv[0] base is "cmux", we enter CLI mode + base := filepath.Base("cmux") + if base != "cmux" { + t.Fatalf("expected base 'cmux', got %q", base) + } + base2 := filepath.Base("/home/user/.cmux/bin/cmux") + if base2 != "cmux" { + t.Fatalf("expected base 'cmux', got %q", base2) + } + base3 := filepath.Base("cmuxd-remote") + if base3 == "cmux" { + t.Fatalf("cmuxd-remote should not match cmux") + } +} + +func TestCLIBrowserSubcommand(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "--url", "https://example.com"}) + if code != 0 { + t.Fatalf("browser open should return 0, got %d", code) + } +} + +func TestCLINoArgs(t *testing.T) { + code := runCLI([]string{}) + if code != 2 { + t.Fatalf("no args should return 2, got %d", code) + } +} + +func TestCLIHelpFlag(t *testing.T) { + code := runCLI([]string{"--help"}) + if code != 0 { + t.Fatalf("--help should return 0, got %d", code) + } +} + +func TestCLIHelpCommand(t *testing.T) { + code := runCLI([]string{"help"}) + if code != 0 { + t.Fatalf("help should return 0, got %d", code) + } +} + +func TestFlagToParamKey(t *testing.T) { + tests := []struct { + input, expected string + }{ + {"workspace", "workspace_id"}, + {"surface", "surface_id"}, + {"panel", "panel_id"}, + {"pane", "pane_id"}, + {"window", "window_id"}, + {"command", "initial_command"}, + {"name", "title"}, + {"working-directory", "working_directory"}, + {"title", "title"}, + {"url", "url"}, + {"direction", "direction"}, + } + for _, tc := range tests { + got := flagToParamKey(tc.input) + if got != tc.expected { + t.Errorf("flagToParamKey(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} + +func TestParseFlags(t *testing.T) { + args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2", "--unknown", "val"} + result := parseFlags(args, []string{"workspace", "surface"}) + if result.flags["workspace"] != "ws-1" { + t.Errorf("expected workspace=ws-1, got %q", result.flags["workspace"]) + } + if result.flags["surface"] != "sf-2" { + t.Errorf("expected surface=sf-2, got %q", result.flags["surface"]) + } + if _, ok := result.flags["unknown"]; ok { + t.Errorf("unknown flag should not be parsed") + } + if len(result.positional) == 0 || result.positional[0] != "positional-cmd" { + t.Errorf("expected first positional=positional-cmd, got %v", result.positional) + } +} + +func TestCLIEnvVarDefaults(t *testing.T) { + // Test that CMUX_WORKSPACE_ID and CMUX_SURFACE_ID are used as defaults + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + var receivedParams map[string]any + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + var req map[string]any + json.Unmarshal(buf[:n], &req) + receivedParams, _ = req["params"].(map[string]any) + resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + conn.Close() + }() + + os.Setenv("CMUX_WORKSPACE_ID", "env-ws-id") + os.Setenv("CMUX_SURFACE_ID", "env-sf-id") + defer os.Unsetenv("CMUX_WORKSPACE_ID") + defer os.Unsetenv("CMUX_SURFACE_ID") + + code := runCLI([]string{"--socket", sockPath, "--json", "close-surface"}) + if code != 0 { + t.Fatalf("close-surface should return 0, got %d", code) + } + if receivedParams["workspace_id"] != "env-ws-id" { + t.Errorf("expected workspace_id from env, got %v", receivedParams["workspace_id"]) + } + if receivedParams["surface_id"] != "env-sf-id" { + t.Errorf("expected surface_id from env, got %v", receivedParams["surface_id"]) + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index d5eee852..15f8efa3 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -11,6 +11,7 @@ import ( "io" "net" "os" + "path/filepath" "sort" "strconv" "sync" @@ -62,6 +63,11 @@ type sessionState struct { const maxRPCFrameBytes = 4 * 1024 * 1024 func main() { + // Busybox-style: if invoked as "cmux" (via symlink), act as CLI relay. + base := filepath.Base(os.Args[0]) + if base == "cmux" { + os.Exit(runCLI(os.Args[1:])) + } os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } @@ -91,6 +97,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 1 } return 0 + case "cli": + return runCLI(args[1:]) default: usage(stderr) return 2 @@ -101,6 +109,7 @@ func usage(w io.Writer) { _, _ = fmt.Fprintln(w, "Usage:") _, _ = fmt.Fprintln(w, " cmuxd-remote version") _, _ = fmt.Fprintln(w, " cmuxd-remote serve --stdio") + _, _ = fmt.Fprintln(w, " cmuxd-remote cli <command> [args...]") } func runStdioServer(stdin io.Reader, stdout io.Writer) error { diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 6010f794..2c88e9bf 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -1,8 +1,9 @@ # Remote SSH Living Spec -Last updated: February 21, 2026 -Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 +Last updated: February 23, 2026 +Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 Primary PR: https://github.com/manaflow-ai/cmux/pull/239 +CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374 This document is the working source of truth for: 1. what is implemented now @@ -37,7 +38,21 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` transport-level proxy failures now escalate from broker retry to full daemon re-bootstrap/reconnect in the session controller. - `DONE` SOCKS handshake parsing now preserves pipelined post-connect payload bytes instead of dropping request-prefix bytes. - `DONE` `workspace.remote.configure.local_proxy_port` exists as an internal deterministic test hook for bind-conflict regression coverage. -- `DONE` bootstrap/proxy failures surface actionable details. +- `DONE` bootstrap/probe failures surface actionable details. +- `DONE` bootstrap installs `~/.cmux/bin/cmux` wrapper (also tries `/usr/local/bin/cmux`) so `cmux` is available in PATH on the remote. + +### 3.5 CLI Relay (Running cmux Commands From Remote) +- `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages. +- `DONE` busybox-style argv[0] detection: when invoked as `cmux` via wrapper/symlink, auto-dispatches to CLI relay. +- `DONE` background `ssh -N -R 127.0.0.1:PORT:/local/cmux.sock` process reverse-forwards a TCP port to the local cmux socket. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled. +- `DONE` relay process uses `ControlPath=none` (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=no` (inherited forwards from user ssh config failing should not kill the relay). +- `DONE` relay address written to `~/.cmux/socket_addr` on the remote with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward. +- `DONE` Go CLI re-reads `~/.cmux/socket_addr` on each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file. +- `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:<relay_port>` so parallel sessions pin to their own relay instead of racing on shared socket_addr. +- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.daemon_path`; remote `cmux` wrapper uses this to select the right daemon binary per session, including mixed local cmux versions. +- `DONE` ephemeral port range (49152-65535) filtered from probe results to exclude relay ports from other workspaces. +- `DONE` multi-workspace port conflict detection uses TCP connect check (`isLoopbackPortReachable`) so ports already forwarded by another workspace are silently skipped instead of flagged as conflicts. +- `DONE` orphaned relay SSH processes from previous app sessions are cleaned up before starting a new relay. ### 3.3 Error Surfacing - `DONE` remote errors are surfaced in sidebar status + logs + notifications. @@ -107,6 +122,7 @@ Recompute effective size on: | M-002 | Remote bootstrap/upload/start + hello handshake | DONE | Includes daemon capability handshake + status surfacing | | M-003 | Reconnect/disconnect UX + API + improved error surfacing | DONE | Includes retry count in surfaced errors | | M-004 | Docker e2e for bootstrap/reconnect shell niceties | DONE | Docker suites validate proxy-path bootstrap and reconnect behavior | +| M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper | | M-005 | Remove automatic remote port mirroring path | DONE | `WorkspaceRemoteSessionController` now uses one shared daemon-backed proxy endpoint | | M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint | | M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/read` implemented | @@ -126,7 +142,19 @@ Recompute effective size on: | T-004 | reconnect API success/error paths | DONE | | T-005 | retry count visible in daemon error detail | DONE | -### 7.2 Browser Proxy (Target) +### 7.2 CLI Relay + +| ID | Scenario | Status | +|---|---|---| +| C-001 | `cmux ping` from remote session | DONE | +| C-002 | `cmux list-workspaces --json` from remote | DONE | +| C-003 | `cmux new-workspace` from remote | DONE | +| C-004 | `cmux rpc system.capabilities` passthrough | DONE | +| C-005 | TCP retry handles relay not yet established | DONE | +| C-006 | multi-workspace port conflict silent skip | DONE | +| C-007 | ephemeral port filtering excludes relay ports | DONE | + +### 7.3 Browser Proxy (Target) | ID | Scenario | Status | |---|---|---| @@ -138,7 +166,7 @@ Recompute effective size on: | W-006 | proxy transport failure triggers daemon re-bootstrap and recovers after host recreation | DONE | | W-007 | SOCKS greeting/connect + immediate pipelined payload in same write remains intact | DONE | -### 7.3 Resize +### 7.4 Resize | ID | Scenario | Status | |---|---|---| diff --git a/scripts/reload.sh b/scripts/reload.sh index 0b8e4644..bf078811 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -418,6 +418,7 @@ if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then elif [[ -n "${TAG_SLUG:-}" ]]; then "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH" else + echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true "${OPEN_CLEAN_ENV[@]}" open "$APP_PATH" fi diff --git a/tests/fixtures/ssh-remote/sshd_config b/tests/fixtures/ssh-remote/sshd_config index dba37c52..9885b799 100644 --- a/tests/fixtures/ssh-remote/sshd_config +++ b/tests/fixtures/ssh-remote/sshd_config @@ -20,6 +20,8 @@ AcceptEnv TERM_PROGRAM TERM_PROGRAM_VERSION COLORTERM X11Forwarding no AllowTcpForwarding yes +AllowStreamLocalForwarding yes +StreamLocalBindUnlink yes GatewayPorts no PermitTunnel no ClientAliveInterval 30 diff --git a/tests/test_cli_version_flag.py b/tests/test_cli_version_flag.py index b48419f2..00499ce0 100644 --- a/tests/test_cli_version_flag.py +++ b/tests/test_cli_version_flag.py @@ -32,13 +32,17 @@ def resolve_cmux_cli() -> str: raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") -def run(cli_path: str, *args: str) -> tuple[int, str, str]: - proc = subprocess.run( - [cli_path, *args], - text=True, - capture_output=True, - check=False, - ) +def run(cli_path: str, *args: str, timeout: float = 5.0) -> tuple[int, str, str]: + try: + proc = subprocess.run( + [cli_path, *args], + text=True, + capture_output=True, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 124, "", f"timed out after {timeout:.1f}s" return proc.returncode, proc.stdout.strip(), proc.stderr.strip() diff --git a/tests/test_sidebar_copy_ssh_error_context_menu.py b/tests/test_sidebar_copy_ssh_error_context_menu.py new file mode 100644 index 00000000..52b3a6f3 --- /dev/null +++ b/tests/test_sidebar_copy_ssh_error_context_menu.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Regression test: sidebar context menu shows Copy SSH Error only when an SSH error exists.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + content_view_path = repo_root / "Sources" / "ContentView.swift" + if not content_view_path.exists(): + print(f"FAIL: missing expected file: {content_view_path}") + return 1 + + content = content_view_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + "private var copyableSidebarSSHError: String?", + "Missing sidebar SSH error extraction helper", + failures, + ) + require( + content, + 'tab.statusEntries["remote.error"]?.value', + "Missing remote.error status fallback for copyable SSH error text", + failures, + ) + require( + content, + "if let copyableSidebarSSHError {", + "Copy SSH Error menu entry is no longer conditionally gated", + failures, + ) + require( + content, + 'Button("Copy SSH Error")', + "Missing Copy SSH Error context menu button", + failures, + ) + require( + content, + "copyTextToPasteboard(copyableSidebarSSHError)", + "Copy SSH Error button no longer writes the resolved error text", + failures, + ) + + if failures: + print("FAIL: sidebar copy SSH error context-menu regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: sidebar Copy SSH Error context menu wiring is intact") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_socket_password_keychain_scope.py b/tests/test_socket_password_keychain_scope.py new file mode 100644 index 00000000..2392d8c7 --- /dev/null +++ b/tests/test_socket_password_keychain_scope.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Regression test: socket password keychain entries are scoped per debug instance.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def reject(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + cli_path = repo_root / "CLI" / "cmux.swift" + settings_path = repo_root / "Sources" / "SocketControlSettings.swift" + + missing = [str(path) for path in (cli_path, settings_path) if not path.exists()] + if missing: + print("FAIL: missing expected files:") + for path in missing: + print(f"- {path}") + return 1 + + cli = cli_path.read_text(encoding="utf-8") + settings = settings_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + cli, + "static func resolve(explicit: String?, socketPath: String) -> String?", + "CLI resolver must accept socketPath to determine scoped keychain service", + failures, + ) + require( + cli, + "private static func keychainServices(socketPath: String) -> [String]", + "CLI must derive keychain services from socket context", + failures, + ) + require( + cli, + 'return ["\\(service).\\(scope)"]', + "CLI should use only the scoped keychain service when scope is present", + failures, + ) + require( + cli, + "URL(fileURLWithPath: socketPath).lastPathComponent", + "CLI scope detection should parse the socket file name", + failures, + ) + require( + cli, + "kSecUseAuthenticationContext as String: authContext", + "CLI keychain lookup must fail fast without interactive keychain prompts", + failures, + ) + require( + cli, + "SocketPasswordResolver.resolve(explicit: socketPasswordArg, socketPath: socketPath)", + "CLI run path must pass socketPath into password resolution", + failures, + ) + + require( + settings, + "private static func keychainScope(environment: [String: String]) -> String?", + "App keychain store should compute a scoped keychain namespace", + failures, + ) + require( + settings, + "environment[SocketControlSettings.launchTagEnvKey]", + "App keychain scope should prioritize CMUX_TAG", + failures, + ) + require( + settings, + "URL(fileURLWithPath: socketPath).lastPathComponent", + "App keychain scope should parse the socket file name", + failures, + ) + require( + settings, + "private static func keychainService(environment: [String: String]) -> String", + "App keychain service should be derived from environment scope", + failures, + ) + require( + settings, + 'return "\\(service).\\(scope)"', + "App keychain service should append the scoped suffix", + failures, + ) + require( + settings, + "kSecAttrService as String: keychainService(environment: environment)", + "App keychain queries should use mode-specific scoped service", + failures, + ) + require( + settings, + "return try? loadPassword(environment: environment)", + "configuredPassword should read keychain from matching scoped service", + failures, + ) + + reject( + settings, + "private static var baseQuery: [String: Any]", + "Legacy global baseQuery should not remain as a static unscoped property", + failures, + ) + + if failures: + print("FAIL: keychain scope regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: socket password keychain service is scoped by tagged debug instance") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_cli_global_flags_and_v1_error_contract.py b/tests_v2/test_cli_global_flags_and_v1_error_contract.py new file mode 100644 index 00000000..e09741fd --- /dev/null +++ b/tests_v2/test_cli_global_flags_and_v1_error_contract.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Regression: global CLI flags still parse and v1 ERROR responses fail with non-zero exit.""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +LAST_SOCKET_HINT_PATH = Path("/tmp/cmux-last-socket-path") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + + +def _merged_output(proc: subprocess.CompletedProcess[str]) -> str: + return f"{proc.stdout}\n{proc.stderr}".strip() + + +def main() -> int: + cli = _find_cli_binary() + + # Global --version should be handled before socket command dispatch. + version_proc = _run([cli, "--version"]) + version_out = _merged_output(version_proc).lower() + _must(version_proc.returncode == 0, f"--version should succeed: {version_proc.returncode} {version_out!r}") + _must("cmux" in version_out, f"--version output should mention cmux: {version_out!r}") + + # Debug builds should auto-resolve the active debug socket via /tmp/cmux-last-socket-path + # when CMUX_SOCKET_PATH is not set. + hint_backup: str | None = None + hint_had_file = LAST_SOCKET_HINT_PATH.exists() + if hint_had_file: + hint_backup = LAST_SOCKET_HINT_PATH.read_text(encoding="utf-8") + try: + LAST_SOCKET_HINT_PATH.write_text(f"{SOCKET_PATH}\n", encoding="utf-8") + auto_env = dict(os.environ) + auto_env.pop("CMUX_SOCKET_PATH", None) + auto_ping = _run([cli, "ping"], env=auto_env) + auto_ping_out = _merged_output(auto_ping).lower() + _must(auto_ping.returncode == 0, f"debug auto socket resolution should succeed: {auto_ping.returncode} {auto_ping_out!r}") + _must("pong" in auto_ping_out, f"debug auto socket resolution should return pong: {auto_ping_out!r}") + finally: + try: + if hint_had_file: + LAST_SOCKET_HINT_PATH.write_text(hint_backup or "", encoding="utf-8") + else: + LAST_SOCKET_HINT_PATH.unlink(missing_ok=True) + except OSError: + pass + + # Global --password should parse as a flag (not a command name) and still allow non-password sockets. + ping_proc = _run([cli, "--socket", SOCKET_PATH, "--password", "ignored-in-cmuxonly", "ping"]) + ping_out = _merged_output(ping_proc).lower() + _must(ping_proc.returncode == 0, f"ping with --password should succeed: {ping_proc.returncode} {ping_out!r}") + _must("pong" in ping_out, f"ping should still return pong: {ping_out!r}") + + # V1 errors must produce non-zero exit codes for automation correctness. + bad_focus = _run([cli, "--socket", SOCKET_PATH, "focus-window", "--window", "window:999999"]) + bad_out = _merged_output(bad_focus).lower() + _must(bad_focus.returncode != 0, f"focus-window with invalid target should fail non-zero: {bad_out!r}") + _must("error" in bad_out, f"focus-window failure should surface an error: {bad_out!r}") + + print("PASS: global flags parse correctly and v1 ERROR responses fail the CLI process") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_rename_tab_cli_parity.py b/tests_v2/test_rename_tab_cli_parity.py index a60055fa..e7ea1b94 100644 --- a/tests_v2/test_rename_tab_cli_parity.py +++ b/tests_v2/test_rename_tab_cli_parity.py @@ -55,14 +55,6 @@ def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) -> return proc.stdout.strip() -def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str: - payload = c._call("surface.list", {"workspace_id": workspace_id}) or {} - for row in payload.get("surfaces") or []: - if str(row.get("id") or "") == surface_id: - return str(row.get("title") or "") - raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}") - - def main() -> int: cli = _find_cli_binary() stamp = int(time.time() * 1000) @@ -82,7 +74,7 @@ def main() -> int: _must(bool(surface_id), f"surface.current returned no surface_id: {current}") socket_title = f"socket rename {stamp}" - c._call( + socket_payload = c._call( "tab.action", { "workspace_id": ws_id, @@ -91,14 +83,20 @@ def main() -> int: "title": socket_title, }, ) - _must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title") + _must( + str((socket_payload or {}).get("title") or "") == socket_title, + f"tab.action rename response missing requested title: {socket_payload}", + ) cli_title = f"cli rename {stamp}" - _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) - _must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title") + cli_out = _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) + _must( + "action=rename" in cli_out.lower() and "tab=" in cli_out.lower(), + f"rename-tab --tab should route to tab.action rename summary, got: {cli_out!r}", + ) env_title = f"env rename {stamp}" - _run_cli( + env_out = _run_cli( cli, ["rename-tab", env_title], env={ @@ -106,7 +104,10 @@ def main() -> int: "CMUX_TAB_ID": surface_id, }, ) - _must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title") + _must( + "action=rename" in env_out.lower() and "tab=" in env_out.lower(), + f"rename-tab via CMUX_TAB_ID should route to tab.action rename summary, got: {env_out!r}", + ) invalid = subprocess.run( [cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id], diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index f5cb21c7..0cae2698 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -143,6 +143,9 @@ def main() -> int: selected_workspace_id == workspace_id, f"cmux ssh should select the newly created workspace: expected {workspace_id}, got {selected_workspace_id}", ) + remote_relay_port = payload.get("remote_relay_port") + _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") + remote_socket_addr = f"127.0.0.1:{int(remote_relay_port)}" ssh_command = str(payload.get("ssh_command") or "") _must(bool(ssh_command), f"cmux ssh output missing ssh_command: {payload}") _must( @@ -164,6 +167,14 @@ def main() -> int: _must("-o ControlMaster=auto" in ssh_command, f"ssh command should opt into connection reuse: {ssh_command!r}") _must("-o ControlPersist=600" in ssh_command, f"ssh command should keep master alive for reuse: {ssh_command!r}") _must("ControlPath=/tmp/cmux-ssh-" in ssh_command, f"ssh command should use shared control path template: {ssh_command!r}") + _must( + ( + f"RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; " + f"export CMUX_SOCKET_PATH={remote_socket_addr}; " + "exec \"${SHELL:-/bin/zsh}\" -l" + ) in ssh_command, + f"cmux ssh should use -o RemoteCommand for PATH/bootstrap env pinning (not positional command): {ssh_command!r}", + ) listed_row = None deadline = time.time() + 8.0 diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py new file mode 100644 index 00000000..53e01a95 --- /dev/null +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Docker integration: verify cmux CLI commands work over SSH via reverse socket forwarding.""" + +from __future__ import annotations + +import glob +import json +import os +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +# Keep the fixture's extra HTTP server below 1024 so there are no eligible +# (>1023) ports to auto-forward. This guards the "connecting forever" regression. +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "81")) + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + # Ensure --socket is what drives the relay path during tests. + env.pop("CMUX_SOCKET_PATH", None) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", "--id-format", "both", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + last = text.split(":")[-1] + return int(last) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=5", + "-p", str(host_port), + "-i", str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _wait_for_remote_ready(client, workspace_id: str, timeout: float = 45.0) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + state = str(remote.get("state") or "") + daemon_state = str(daemon.get("state") or "") + if state == "connected" and daemon_state == "ready": + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote daemon did not become ready: {last_status}") + + +def _assert_remote_ping(host: str, host_port: int, key_path: Path, remote_socket_addr: str, *, label: str) -> None: + ping_result = _ssh_run( + host, host_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping", + check=False, + ) + _must( + ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(), + f"{label} cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}", + ) + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-cli-relay-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-cli-relay-{secrets.token_hex(4)}" + workspace_id = "" + workspace_id_2 = "" + + try: + # Generate SSH key pair + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + # Build and start Docker container + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", "run", "-d", "--rm", + "--name", container_name, + "-e", f"AUTHORIZED_KEY={pubkey}", + "-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", + "-p", "127.0.0.1::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = "root@127.0.0.1" + _wait_for_ssh(host, host_ssh_port, key_path) + + with cmux(SOCKET_PATH) as client: + # Create SSH workspace (this sets up the reverse socket forward) + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-cli-relay", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + remote_relay_port = payload.get("remote_relay_port") + _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") + remote_relay_port = int(remote_relay_port) + _must(49152 <= remote_relay_port <= 65535, f"remote_relay_port should be in ephemeral range: {remote_relay_port}") + remote_socket_addr = f"127.0.0.1:{remote_relay_port}" + startup_cmd = str(payload.get("ssh_startup_command") or "") + _must( + 'PATH="$HOME/.cmux/bin:$PATH"' in startup_cmd, + f"ssh startup command should prepend ~/.cmux/bin for remote cmux CLI: {startup_cmd!r}", + ) + _must( + f"CMUX_SOCKET_PATH={remote_socket_addr}" in startup_cmd, + f"ssh startup command should pin CMUX_SOCKET_PATH to workspace relay: {startup_cmd!r}", + ) + workspace_window_id = payload.get("window_id") + current_params = {"window_id": workspace_window_id} if isinstance(workspace_window_id, str) and workspace_window_id else {} + current = client._call("workspace.current", current_params) or {} + current_workspace_id = str(current.get("workspace_id") or "") + _must( + current_workspace_id == workspace_id, + f"cmux ssh should focus created workspace: current={current_workspace_id!r} created={workspace_id!r}", + ) + + # Wait for daemon to be ready + first_status = _wait_for_remote_ready(client, workspace_id) + first_remote = first_status.get("remote") or {} + # Regression: should transition to connected even with no eligible + # (>1023, non-ephemeral) remote ports. + _must( + not (first_remote.get("detected_ports") or []), + f"expected no eligible detected ports in fixture: {first_status}", + ) + _must( + not (first_remote.get("forwarded_ports") or []), + f"expected no forwarded ports when none are eligible: {first_status}", + ) + + # Verify remote cmux wrapper + relay-specific daemon mapping were installed. + wrapper_check = None + wrapper_deadline = time.time() + 10.0 + while time.time() < wrapper_deadline: + wrapper_check = _ssh_run( + host, host_ssh_port, key_path, + f"test -x \"$HOME/.cmux/bin/cmux\" && test -f \"$HOME/.cmux/bin/cmux\" && " + f"map=\"$HOME/.cmux/relay/{remote_relay_port}.daemon_path\" && " + "daemon=\"$(cat \"$map\" 2>/dev/null || true)\" && " + "test -n \"$daemon\" && test -x \"$daemon\" && echo wrapper-ok", + check=False, + ) + if "wrapper-ok" in (wrapper_check.stdout or ""): + break + time.sleep(0.4) + _must( + wrapper_check is not None and "wrapper-ok" in (wrapper_check.stdout or ""), + f"Expected remote cmux wrapper+relay mapping to exist: {wrapper_check.stdout if wrapper_check else ''} {wrapper_check.stderr if wrapper_check else ''}", + ) + + # Start a second SSH workspace to the same destination and verify both + # relays remain healthy (regression: same-host workspaces killed each other). + payload_2 = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-cli-relay-2", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id_2 = str(payload_2.get("workspace_id") or "") + workspace_ref_2 = str(payload_2.get("workspace_ref") or "") + if not workspace_id_2 and workspace_ref_2.startswith("workspace:"): + listed_2 = client._call("workspace.list", {}) or {} + for row in listed_2.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref_2: + workspace_id_2 = str(row.get("id") or "") + break + _must(bool(workspace_id_2), f"second cmux ssh output missing workspace_id: {payload_2}") + + remote_relay_port_2 = payload_2.get("remote_relay_port") + _must(remote_relay_port_2 is not None, f"second cmux ssh output missing remote_relay_port: {payload_2}") + remote_relay_port_2 = int(remote_relay_port_2) + _must(49152 <= remote_relay_port_2 <= 65535, f"second remote_relay_port out of range: {remote_relay_port_2}") + _must( + remote_relay_port_2 != remote_relay_port, + f"relay ports should differ per workspace: {remote_relay_port_2} vs {remote_relay_port}", + ) + remote_socket_addr_2 = f"127.0.0.1:{remote_relay_port_2}" + startup_cmd_2 = str(payload_2.get("ssh_startup_command") or "") + _must( + f"CMUX_SOCKET_PATH={remote_socket_addr_2}" in startup_cmd_2, + f"second ssh startup command should pin CMUX_SOCKET_PATH to second relay: {startup_cmd_2!r}", + ) + _ = _wait_for_remote_ready(client, workspace_id_2) + + stability_deadline = time.time() + 8.0 + while time.time() < stability_deadline: + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="first relay") + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr_2, label="second relay") + time.sleep(0.5) + + # Test 1: cmux ping (v1) + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="cmux") + + # Test 2: cmux list-workspaces --json (v2) + list_ws_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux --json list-workspaces", + check=False, + ) + _must( + list_ws_result.returncode == 0, + f"cmux list-workspaces failed: rc={list_ws_result.returncode} stderr={list_ws_result.stderr!r}", + ) + try: + ws_data = json.loads(list_ws_result.stdout.strip()) + _must(isinstance(ws_data, dict), f"list-workspaces should return JSON object: {list_ws_result.stdout!r}") + except json.JSONDecodeError: + raise cmuxError(f"list-workspaces returned invalid JSON: {list_ws_result.stdout!r}") + + # Test 3: cmux new-window (v1) + new_win_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux new-window", + check=False, + ) + _must( + new_win_result.returncode == 0, + f"cmux new-window failed: rc={new_win_result.returncode} stderr={new_win_result.stderr!r}", + ) + + # Test 4: cmux rpc system.capabilities (v2 passthrough) + rpc_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux rpc system.capabilities", + check=False, + ) + _must( + rpc_result.returncode == 0, + f"cmux rpc system.capabilities failed: rc={rpc_result.returncode} stderr={rpc_result.stderr!r}", + ) + try: + caps_data = json.loads(rpc_result.stdout.strip()) + _must(isinstance(caps_data, dict), f"rpc capabilities should return JSON: {rpc_result.stdout!r}") + except json.JSONDecodeError: + raise cmuxError(f"rpc system.capabilities returned invalid JSON: {rpc_result.stdout!r}") + + # Cleanup + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + if workspace_id_2: + try: + client.close_workspace(workspace_id_2) + except Exception: + pass + workspace_id_2 = "" + + print("PASS: cmux CLI commands relay correctly over SSH reverse socket forwarding") + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + if workspace_id_2: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id_2) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_shell_integration.py b/tests_v2/test_ssh_remote_shell_integration.py index baab25f6..e1bf935d 100755 --- a/tests_v2/test_ssh_remote_shell_integration.py +++ b/tests_v2/test_ssh_remote_shell_integration.py @@ -405,6 +405,11 @@ def main() -> int: surfaces = client.list_surfaces(workspace_id) _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") surface_id = surfaces[0][1] + terminal_text = client.read_terminal_text(surface_id) + _must( + "Reconstructed via infocmp" not in terminal_text, + "ssh-terminfo bootstrap should not leak raw infocmp output into the interactive shell", + ) try: term_value = _read_probe_payload(client, surface_id, "printf '%s' \"$TERM\"") diff --git a/tests_v2/test_workspace_create_initial_env.py b/tests_v2/test_workspace_create_initial_env.py new file mode 100644 index 00000000..33b56c2e --- /dev/null +++ b/tests_v2/test_workspace_create_initial_env.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Regression: workspace.create must apply initial_env to the initial terminal.""" + +import os +import sys +import time +import base64 +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for_text(c: cmux, workspace_id: str, needle: str, timeout_s: float = 8.0) -> str: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + payload = c._call( + "surface.read_text", + {"workspace_id": workspace_id}, + ) or {} + if "text" in payload: + last_text = str(payload.get("text") or "") + else: + b64 = str(payload.get("base64") or "") + raw = base64.b64decode(b64) if b64 else b"" + last_text = raw.decode("utf-8", errors="replace") + if needle in last_text: + return last_text + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in panel text: {last_text!r}") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + baseline_workspace = c.current_workspace() + created_workspace = "" + try: + token = f"tok_{int(time.time() * 1000)}" + payload = c._call( + "workspace.create", + { + "initial_env": {"CMUX_INITIAL_ENV_TOKEN": token}, + }, + ) or {} + created_workspace = str(payload.get("workspace_id") or "") + _must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}") + _must(c.current_workspace() == baseline_workspace, "workspace.create should not steal workspace focus") + + # Terminal surfaces in background workspaces may not be attached/render-ready yet. + # Select it before reading text so the initial command output is available. + c.select_workspace(created_workspace) + listed = c._call("surface.list", {"workspace_id": created_workspace}) or {} + rows = list(listed.get("surfaces") or []) + _must(bool(rows), "Expected at least one surface in the created workspace") + terminal_row = next((row for row in rows if str(row.get("type") or "") == "terminal"), None) + _must(terminal_row is not None, f"Expected a terminal surface in workspace.create result: {rows}") + + c.send("printf 'CMUX_ENV_CHECK=%s\\n' \"$CMUX_INITIAL_ENV_TOKEN\"\\n") + text = _wait_for_text(c, created_workspace, f"CMUX_ENV_CHECK={token}") + _must( + f"CMUX_ENV_CHECK={token}" in text, + f"initial_env token missing from terminal output: {text!r}", + ) + c.select_workspace(baseline_workspace) + finally: + if created_workspace: + try: + c.close_workspace(created_workspace) + except Exception: + pass + + print("PASS: workspace.create applies initial_env to initial terminal") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())