13549 lines
564 KiB
Swift
13549 lines
564 KiB
Swift
import AppKit
|
|
import Carbon.HIToolbox
|
|
import Foundation
|
|
import Bonsplit
|
|
import WebKit
|
|
|
|
/// Unix socket-based controller for programmatic terminal control
|
|
/// Allows automated testing and external control of terminal tabs
|
|
@MainActor
|
|
class TerminalController {
|
|
struct SocketListenerHealth: Sendable {
|
|
let isRunning: Bool
|
|
let acceptLoopAlive: Bool
|
|
let socketPathMatches: Bool
|
|
let socketPathExists: Bool
|
|
|
|
var failureSignals: [String] {
|
|
var signals: [String] = []
|
|
if !isRunning { signals.append("not_running") }
|
|
if !acceptLoopAlive { signals.append("accept_loop_dead") }
|
|
if !socketPathMatches { signals.append("socket_path_mismatch") }
|
|
if !socketPathExists { signals.append("socket_missing") }
|
|
return signals
|
|
}
|
|
|
|
var isHealthy: Bool {
|
|
failureSignals.isEmpty
|
|
}
|
|
}
|
|
|
|
static let shared = TerminalController()
|
|
|
|
private nonisolated(unsafe) var socketPath = "/tmp/cmux.sock"
|
|
private nonisolated(unsafe) var serverSocket: Int32 = -1
|
|
private nonisolated(unsafe) var isRunning = false
|
|
private nonisolated(unsafe) var acceptLoopAlive = false
|
|
private nonisolated(unsafe) var activeAcceptLoopGeneration: UInt64 = 0
|
|
private nonisolated(unsafe) var nextAcceptLoopGeneration: UInt64 = 0
|
|
private nonisolated(unsafe) var pendingAcceptLoopRearmGeneration: UInt64?
|
|
private nonisolated(unsafe) var listenerStartInProgress = false
|
|
private nonisolated let listenerStateLock = NSLock()
|
|
private var clientHandlers: [Int32: Thread] = [:]
|
|
private var tabManager: TabManager?
|
|
private var accessMode: SocketControlMode = .cmuxOnly
|
|
private let myPid = getpid()
|
|
private nonisolated(unsafe) static var socketCommandPolicyDepth: Int = 0
|
|
private nonisolated(unsafe) static var socketCommandFocusAllowanceStack: [Bool] = []
|
|
private nonisolated static let socketCommandPolicyLock = NSLock()
|
|
private nonisolated static let socketListenBacklog: Int32 = 128
|
|
private nonisolated static let acceptFailureBaseBackoffMs = 10
|
|
private nonisolated static let acceptFailureMaxBackoffMs = 5_000
|
|
private nonisolated static let acceptFailureMinimumRearmDelayMs = 100
|
|
private nonisolated static let acceptFailureRearmThreshold = 50
|
|
|
|
private struct ListenerStateSnapshot {
|
|
let socketPath: String
|
|
let serverSocket: Int32
|
|
let isRunning: Bool
|
|
let acceptLoopAlive: Bool
|
|
let activeGeneration: UInt64
|
|
let pendingRearmGeneration: UInt64?
|
|
}
|
|
|
|
private static let focusIntentV1Commands: Set<String> = [
|
|
"focus_window",
|
|
"select_workspace",
|
|
"focus_surface",
|
|
"focus_pane",
|
|
"focus_surface_by_panel",
|
|
"focus_webview",
|
|
"focus_notification",
|
|
"activate_app"
|
|
]
|
|
|
|
private static let focusIntentV2Methods: Set<String> = [
|
|
"window.focus",
|
|
"workspace.select",
|
|
"workspace.next",
|
|
"workspace.previous",
|
|
"workspace.last",
|
|
"surface.focus",
|
|
"pane.focus",
|
|
"pane.last",
|
|
"browser.focus_webview",
|
|
"browser.focus",
|
|
"browser.tab.switch",
|
|
"debug.command_palette.toggle",
|
|
"debug.notification.focus",
|
|
"debug.app.activate"
|
|
]
|
|
|
|
private enum V2HandleKind: String, CaseIterable {
|
|
case window
|
|
case workspace
|
|
case pane
|
|
case surface
|
|
}
|
|
|
|
private var v2NextHandleOrdinal: [V2HandleKind: Int] = [
|
|
.window: 1,
|
|
.workspace: 1,
|
|
.pane: 1,
|
|
.surface: 1,
|
|
]
|
|
private var v2RefByUUID: [V2HandleKind: [UUID: String]] = [
|
|
.window: [:],
|
|
.workspace: [:],
|
|
.pane: [:],
|
|
.surface: [:],
|
|
]
|
|
private var v2UUIDByRef: [V2HandleKind: [String: UUID]] = [
|
|
.window: [:],
|
|
.workspace: [:],
|
|
.pane: [:],
|
|
.surface: [:],
|
|
]
|
|
|
|
private struct V2BrowserElementRefEntry {
|
|
let surfaceId: UUID
|
|
let selector: String
|
|
}
|
|
|
|
private struct V2BrowserPendingDialog {
|
|
let type: String
|
|
let message: String
|
|
let defaultText: String?
|
|
let responder: (_ accept: Bool, _ text: String?) -> Void
|
|
}
|
|
|
|
private final class V2BrowserUndefinedSentinel {}
|
|
|
|
private static let v2BrowserEvalEnvelopeTypeKey = "__cmux_t"
|
|
private static let v2BrowserEvalEnvelopeValueKey = "__cmux_v"
|
|
private static let v2BrowserEvalEnvelopeTypeUndefined = "undefined"
|
|
private static let v2BrowserEvalEnvelopeTypeValue = "value"
|
|
|
|
private var v2BrowserNextElementOrdinal: Int = 1
|
|
private var v2BrowserElementRefs: [String: V2BrowserElementRefEntry] = [:]
|
|
private var v2BrowserFrameSelectorBySurface: [UUID: String] = [:]
|
|
private var v2BrowserInitScriptsBySurface: [UUID: [String]] = [:]
|
|
private var v2BrowserInitStylesBySurface: [UUID: [String]] = [:]
|
|
private var v2BrowserDialogQueueBySurface: [UUID: [V2BrowserPendingDialog]] = [:]
|
|
private var v2BrowserDownloadEventsBySurface: [UUID: [[String: Any]]] = [:]
|
|
private var v2BrowserUnsupportedNetworkRequestsBySurface: [UUID: [[String: Any]]] = [:]
|
|
private let v2BrowserUndefinedSentinel = V2BrowserUndefinedSentinel()
|
|
|
|
private init() {}
|
|
|
|
private nonisolated func withListenerState<T>(_ body: () -> T) -> T {
|
|
listenerStateLock.lock()
|
|
defer { listenerStateLock.unlock() }
|
|
return body()
|
|
}
|
|
|
|
private nonisolated func listenerStateSnapshot() -> ListenerStateSnapshot {
|
|
withListenerState {
|
|
ListenerStateSnapshot(
|
|
socketPath: socketPath,
|
|
serverSocket: serverSocket,
|
|
isRunning: isRunning,
|
|
acceptLoopAlive: acceptLoopAlive,
|
|
activeGeneration: activeAcceptLoopGeneration,
|
|
pendingRearmGeneration: pendingAcceptLoopRearmGeneration
|
|
)
|
|
}
|
|
}
|
|
|
|
private nonisolated func shouldContinueAcceptLoop(generation: UInt64) -> Bool {
|
|
withListenerState {
|
|
isRunning && generation == activeAcceptLoopGeneration
|
|
}
|
|
}
|
|
|
|
nonisolated static func shouldSuppressSocketCommandActivation() -> Bool {
|
|
socketCommandPolicyLock.lock()
|
|
defer { socketCommandPolicyLock.unlock() }
|
|
return socketCommandPolicyDepth > 0
|
|
}
|
|
|
|
nonisolated static func socketCommandAllowsInAppFocusMutations() -> Bool {
|
|
allowsInAppFocusMutationsForActiveSocketCommand()
|
|
}
|
|
|
|
private nonisolated static func allowsInAppFocusMutationsForActiveSocketCommand() -> Bool {
|
|
socketCommandPolicyLock.lock()
|
|
defer { socketCommandPolicyLock.unlock() }
|
|
return socketCommandFocusAllowanceStack.last ?? false
|
|
}
|
|
|
|
private static func socketCommandAllowsInAppFocusMutations(commandKey: String, isV2: Bool) -> Bool {
|
|
if isV2 {
|
|
return focusIntentV2Methods.contains(commandKey)
|
|
}
|
|
return focusIntentV1Commands.contains(commandKey)
|
|
}
|
|
|
|
private func withSocketCommandPolicy<T>(commandKey: String, isV2: Bool, _ body: () -> T) -> T {
|
|
let allowsFocusMutation = Self.socketCommandAllowsInAppFocusMutations(commandKey: commandKey, isV2: isV2)
|
|
Self.socketCommandPolicyLock.lock()
|
|
Self.socketCommandPolicyDepth += 1
|
|
Self.socketCommandFocusAllowanceStack.append(allowsFocusMutation)
|
|
Self.socketCommandPolicyLock.unlock()
|
|
defer {
|
|
Self.socketCommandPolicyLock.lock()
|
|
if !Self.socketCommandFocusAllowanceStack.isEmpty {
|
|
_ = Self.socketCommandFocusAllowanceStack.popLast()
|
|
}
|
|
Self.socketCommandPolicyDepth = max(0, Self.socketCommandPolicyDepth - 1)
|
|
Self.socketCommandPolicyLock.unlock()
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
nonisolated static func shouldReplaceStatusEntry(
|
|
current: SidebarStatusEntry?,
|
|
key: String,
|
|
value: String,
|
|
icon: String?,
|
|
color: String?,
|
|
url: URL?,
|
|
priority: Int,
|
|
format: SidebarMetadataFormat
|
|
) -> Bool {
|
|
guard let current else { return true }
|
|
return current.key != key ||
|
|
current.value != value ||
|
|
current.icon != icon ||
|
|
current.color != color ||
|
|
current.url != url ||
|
|
current.priority != priority ||
|
|
current.format != format
|
|
}
|
|
|
|
nonisolated static func shouldReplaceMetadataBlock(
|
|
current: SidebarMetadataBlock?,
|
|
key: String,
|
|
markdown: String,
|
|
priority: Int
|
|
) -> Bool {
|
|
guard let current else { return true }
|
|
return current.key != key || current.markdown != markdown || current.priority != priority
|
|
}
|
|
|
|
nonisolated static func shouldReplaceProgress(
|
|
current: SidebarProgressState?,
|
|
value: Double,
|
|
label: String?
|
|
) -> Bool {
|
|
guard let current else { return true }
|
|
return current.value != value || current.label != label
|
|
}
|
|
|
|
nonisolated static func shouldReplaceGitBranch(
|
|
current: SidebarGitBranchState?,
|
|
branch: String,
|
|
isDirty: Bool
|
|
) -> Bool {
|
|
guard let current else { return true }
|
|
return current.branch != branch || current.isDirty != isDirty
|
|
}
|
|
|
|
nonisolated static func shouldReplacePullRequest(
|
|
current: SidebarPullRequestState?,
|
|
number: Int,
|
|
label: String,
|
|
url: URL,
|
|
status: SidebarPullRequestStatus
|
|
) -> Bool {
|
|
guard let current else { return true }
|
|
return current.number != number || current.label != label || current.url != url || current.status != status
|
|
}
|
|
|
|
nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool {
|
|
let currentSorted = Array(Set(current ?? [])).sorted()
|
|
let nextSorted = Array(Set(next)).sorted()
|
|
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)? {
|
|
guard let tabRaw = options["tab"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!tabRaw.isEmpty,
|
|
let panelRaw = (options["panel"] ?? options["surface"])?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!panelRaw.isEmpty,
|
|
let workspaceId = UUID(uuidString: tabRaw),
|
|
let panelId = UUID(uuidString: panelRaw) else {
|
|
return nil
|
|
}
|
|
return (workspaceId, panelId)
|
|
}
|
|
|
|
nonisolated static func normalizeReportedDirectory(_ directory: String) -> String {
|
|
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return directory }
|
|
if trimmed.hasPrefix("file://"), let url = URL(string: trimmed), !url.path.isEmpty {
|
|
return url.path
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
/// Update which window's TabManager receives socket commands.
|
|
/// This is used when the user switches between multiple terminal windows.
|
|
func setActiveTabManager(_ tabManager: TabManager?) {
|
|
self.tabManager = tabManager
|
|
}
|
|
|
|
// MARK: - Process Ancestry Check
|
|
|
|
/// Get the peer PID of a connected Unix domain socket using LOCAL_PEERPID.
|
|
private nonisolated func getPeerPid(_ socket: Int32) -> pid_t? {
|
|
var pid: pid_t = 0
|
|
var pidSize = socklen_t(MemoryLayout<pid_t>.size)
|
|
let result = getsockopt(socket, SOL_LOCAL, LOCAL_PEERPID, &pid, &pidSize)
|
|
if result != 0 || pid <= 0 {
|
|
return nil
|
|
}
|
|
return pid
|
|
}
|
|
|
|
/// Check if the peer has the same UID as this process using LOCAL_PEERCRED.
|
|
/// This works even after the peer has disconnected (unlike LOCAL_PEERPID).
|
|
private func peerHasSameUID(_ socket: Int32) -> Bool {
|
|
var cred = xucred()
|
|
var credLen = socklen_t(MemoryLayout<xucred>.size)
|
|
let result = getsockopt(socket, SOL_LOCAL, LOCAL_PEERCRED, &cred, &credLen)
|
|
guard result == 0 else { return false }
|
|
return cred.cr_uid == getuid()
|
|
}
|
|
|
|
/// Check if `pid` is a descendant of this process by walking the process tree.
|
|
func isDescendant(_ pid: pid_t) -> Bool {
|
|
var current = pid
|
|
// Walk up to 128 levels to avoid infinite loops from kernel bugs
|
|
for _ in 0..<128 {
|
|
if current == myPid {
|
|
return true
|
|
}
|
|
if current <= 1 {
|
|
return false
|
|
}
|
|
let parent = parentPid(of: current)
|
|
if parent == current || parent < 0 {
|
|
return false
|
|
}
|
|
current = parent
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Get the parent PID of a process using sysctl.
|
|
private func parentPid(of pid: pid_t) -> pid_t {
|
|
var info = kinfo_proc()
|
|
var size = MemoryLayout<kinfo_proc>.size
|
|
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
|
|
guard sysctl(&mib, 4, &info, &size, nil, 0) == 0 else {
|
|
return -1
|
|
}
|
|
return info.kp_eproc.e_ppid
|
|
}
|
|
|
|
private nonisolated func socketListenerEventData(
|
|
stage: String,
|
|
errnoCode: Int32? = nil,
|
|
extra: [String: Any] = [:]
|
|
) -> [String: Any] {
|
|
let snapshot = listenerStateSnapshot()
|
|
var data: [String: Any] = [
|
|
"stage": stage,
|
|
"path": snapshot.socketPath,
|
|
"isRunning": snapshot.isRunning ? 1 : 0,
|
|
"acceptLoopAlive": snapshot.acceptLoopAlive ? 1 : 0,
|
|
"serverSocket": Int(snapshot.serverSocket),
|
|
"activeGeneration": snapshot.activeGeneration
|
|
]
|
|
if let errnoCode {
|
|
data["errno"] = Int(errnoCode)
|
|
data["errnoDescription"] = String(cString: strerror(errnoCode))
|
|
}
|
|
for (key, value) in extra {
|
|
data[key] = value
|
|
}
|
|
return data
|
|
}
|
|
|
|
private nonisolated func reportSocketListenerFailure(
|
|
message: String,
|
|
stage: String,
|
|
errnoCode: Int32? = nil,
|
|
extra: [String: Any] = [:]
|
|
) {
|
|
let data = socketListenerEventData(stage: stage, errnoCode: errnoCode, extra: extra)
|
|
sentryBreadcrumb(message, category: "socket", data: data)
|
|
sentryCaptureError(message, category: "socket", data: data, contextKey: "socket_listener")
|
|
}
|
|
|
|
nonisolated static func acceptErrorClassification(errnoCode: Int32) -> String {
|
|
switch errnoCode {
|
|
case EINTR, ECONNABORTED, EAGAIN, EWOULDBLOCK:
|
|
return "immediate_retry"
|
|
case EMFILE, ENFILE, ENOBUFS, ENOMEM:
|
|
return "resource_pressure"
|
|
case EBADF, EINVAL, ENOTSOCK:
|
|
return "fatal"
|
|
default:
|
|
return "retry_with_backoff"
|
|
}
|
|
}
|
|
|
|
nonisolated static func shouldRearmListenerForAcceptError(errnoCode: Int32) -> Bool {
|
|
acceptErrorClassification(errnoCode: errnoCode) == "fatal"
|
|
}
|
|
|
|
nonisolated static func shouldRetryAcceptImmediately(errnoCode: Int32) -> Bool {
|
|
acceptErrorClassification(errnoCode: errnoCode) == "immediate_retry"
|
|
}
|
|
|
|
nonisolated static func shouldRearmForConsecutiveAcceptFailures(consecutiveFailures: Int) -> Bool {
|
|
consecutiveFailures >= acceptFailureRearmThreshold
|
|
}
|
|
|
|
nonisolated static func acceptFailureBackoffMilliseconds(consecutiveFailures: Int) -> Int {
|
|
guard consecutiveFailures > 0 else { return 0 }
|
|
var delay = acceptFailureBaseBackoffMs
|
|
var remaining = consecutiveFailures - 1
|
|
while remaining > 0 {
|
|
if delay >= acceptFailureMaxBackoffMs {
|
|
return acceptFailureMaxBackoffMs
|
|
}
|
|
delay = min(delay * 2, acceptFailureMaxBackoffMs)
|
|
remaining -= 1
|
|
}
|
|
return delay
|
|
}
|
|
|
|
nonisolated static func acceptFailureRearmDelayMilliseconds(consecutiveFailures: Int) -> Int {
|
|
max(
|
|
acceptFailureBackoffMilliseconds(consecutiveFailures: consecutiveFailures),
|
|
acceptFailureMinimumRearmDelayMs
|
|
)
|
|
}
|
|
|
|
nonisolated static func shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: Int) -> Bool {
|
|
guard consecutiveFailures > 0 else { return false }
|
|
if consecutiveFailures <= 3 {
|
|
return true
|
|
}
|
|
return (consecutiveFailures & (consecutiveFailures - 1)) == 0
|
|
}
|
|
|
|
nonisolated static func shouldUnlinkSocketPathAfterAcceptLoopCleanup(
|
|
pathMatches: Bool,
|
|
isRunning: Bool,
|
|
activeGeneration: UInt64,
|
|
listenerStartInProgress: Bool
|
|
) -> Bool {
|
|
guard pathMatches else { return false }
|
|
guard !listenerStartInProgress else { return false }
|
|
return !isRunning && activeGeneration == 0
|
|
}
|
|
|
|
func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) {
|
|
self.tabManager = tabManager
|
|
self.accessMode = accessMode
|
|
|
|
let existing = withListenerState {
|
|
(isRunning: isRunning, socketPath: self.socketPath, acceptLoopAlive: acceptLoopAlive)
|
|
}
|
|
|
|
if existing.isRunning && existing.socketPath == socketPath && existing.acceptLoopAlive {
|
|
self.accessMode = accessMode
|
|
applySocketPermissions()
|
|
return
|
|
}
|
|
|
|
if existing.isRunning {
|
|
stop()
|
|
}
|
|
|
|
withListenerState {
|
|
self.socketPath = socketPath
|
|
listenerStartInProgress = true
|
|
}
|
|
var listenerActivated = false
|
|
defer {
|
|
if !listenerActivated {
|
|
withListenerState {
|
|
listenerStartInProgress = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove existing socket file
|
|
unlink(socketPath)
|
|
|
|
// Create socket
|
|
let newServerSocket = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
guard newServerSocket >= 0 else {
|
|
let errnoCode = errno
|
|
print("TerminalController: Failed to create socket")
|
|
reportSocketListenerFailure(
|
|
message: "socket.listener.start.failed",
|
|
stage: "create_socket",
|
|
errnoCode: errnoCode
|
|
)
|
|
return
|
|
}
|
|
|
|
// Bind to path
|
|
var addr = sockaddr_un()
|
|
addr.sun_family = sa_family_t(AF_UNIX)
|
|
socketPath.withCString { ptr in
|
|
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
|
|
let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
|
|
strcpy(pathBuf, ptr)
|
|
}
|
|
}
|
|
|
|
let bindResult = withUnsafePointer(to: &addr) { ptr in
|
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
|
bind(newServerSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
|
}
|
|
}
|
|
|
|
guard bindResult >= 0 else {
|
|
let errnoCode = errno
|
|
print("TerminalController: Failed to bind socket")
|
|
close(newServerSocket)
|
|
reportSocketListenerFailure(
|
|
message: "socket.listener.start.failed",
|
|
stage: "bind",
|
|
errnoCode: errnoCode
|
|
)
|
|
return
|
|
}
|
|
|
|
applySocketPermissions()
|
|
|
|
// Listen
|
|
guard listen(newServerSocket, Self.socketListenBacklog) >= 0 else {
|
|
let errnoCode = errno
|
|
print("TerminalController: Failed to listen on socket")
|
|
close(newServerSocket)
|
|
reportSocketListenerFailure(
|
|
message: "socket.listener.start.failed",
|
|
stage: "listen",
|
|
errnoCode: errnoCode
|
|
)
|
|
return
|
|
}
|
|
|
|
let generation = withListenerState {
|
|
isRunning = true
|
|
pendingAcceptLoopRearmGeneration = nil
|
|
nextAcceptLoopGeneration &+= 1
|
|
let generation = nextAcceptLoopGeneration
|
|
activeAcceptLoopGeneration = generation
|
|
serverSocket = newServerSocket
|
|
listenerStartInProgress = false
|
|
return generation
|
|
}
|
|
listenerActivated = true
|
|
let listenerSocket = newServerSocket
|
|
print("TerminalController: Listening on \(socketPath)")
|
|
sentryBreadcrumb(
|
|
"socket.listener.listening",
|
|
category: "socket",
|
|
data: [
|
|
"path": socketPath,
|
|
"mode": accessMode.rawValue,
|
|
"generation": generation,
|
|
"backlog": Self.socketListenBacklog
|
|
]
|
|
)
|
|
|
|
// Wire batched port scanner results back to workspace state.
|
|
PortScanner.shared.onPortsUpdated = { [weak self] workspaceId, panelId, ports in
|
|
MainActor.assumeIsolated {
|
|
guard let self, let tabManager = self.tabManager else { return }
|
|
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.recomputeListeningPorts()
|
|
}
|
|
}
|
|
|
|
// Accept connections in background thread
|
|
Thread.detachNewThread { [weak self] in
|
|
self?.acceptLoop(listenerSocket: listenerSocket, generation: generation)
|
|
}
|
|
}
|
|
|
|
nonisolated func socketListenerHealth(expectedSocketPath: String) -> SocketListenerHealth {
|
|
let snapshot = listenerStateSnapshot()
|
|
let pathMatches = snapshot.socketPath == expectedSocketPath
|
|
|
|
var st = stat()
|
|
let exists = lstat(expectedSocketPath, &st) == 0 && (st.st_mode & S_IFMT) == S_IFSOCK
|
|
|
|
return SocketListenerHealth(
|
|
isRunning: snapshot.isRunning,
|
|
acceptLoopAlive: snapshot.acceptLoopAlive,
|
|
socketPathMatches: pathMatches,
|
|
socketPathExists: exists
|
|
)
|
|
}
|
|
|
|
nonisolated static func probeSocketCommand(
|
|
_ command: String,
|
|
at socketPath: String,
|
|
timeout: TimeInterval
|
|
) -> String? {
|
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
guard fd >= 0 else { return nil }
|
|
defer { close(fd) }
|
|
|
|
#if os(macOS)
|
|
var noSigPipe: Int32 = 1
|
|
_ = withUnsafePointer(to: &noSigPipe) { ptr in
|
|
setsockopt(
|
|
fd,
|
|
SOL_SOCKET,
|
|
SO_NOSIGPIPE,
|
|
ptr,
|
|
socklen_t(MemoryLayout<Int32>.size)
|
|
)
|
|
}
|
|
#endif
|
|
|
|
var addr = sockaddr_un()
|
|
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
|
|
addr.sun_family = sa_family_t(AF_UNIX)
|
|
|
|
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
|
let pathBytes = Array(socketPath.utf8CString)
|
|
guard pathBytes.count <= maxLen else { return nil }
|
|
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
|
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self)
|
|
memset(raw, 0, maxLen)
|
|
for index in 0..<pathBytes.count {
|
|
raw[index] = pathBytes[index]
|
|
}
|
|
}
|
|
|
|
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
|
|
let addrLen = socklen_t(pathOffset + pathBytes.count)
|
|
#if os(macOS)
|
|
addr.sun_len = UInt8(min(Int(addrLen), 255))
|
|
#endif
|
|
|
|
let connectResult = withUnsafePointer(to: &addr) { ptr in
|
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
|
connect(fd, sockaddrPtr, addrLen)
|
|
}
|
|
}
|
|
guard connectResult == 0 else { return nil }
|
|
|
|
let payload = command + "\n"
|
|
let wroteAll = payload.withCString { cString in
|
|
var remaining = strlen(cString)
|
|
var pointer = UnsafeRawPointer(cString)
|
|
while remaining > 0 {
|
|
let written = write(fd, pointer, remaining)
|
|
if written <= 0 { return false }
|
|
remaining -= written
|
|
pointer = pointer.advanced(by: written)
|
|
}
|
|
return true
|
|
}
|
|
guard wroteAll else { return nil }
|
|
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
var buffer = [UInt8](repeating: 0, count: 4096)
|
|
var response = ""
|
|
|
|
while Date() < deadline {
|
|
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
|
let ready = poll(&pollDescriptor, 1, 100)
|
|
if ready < 0 {
|
|
return nil
|
|
}
|
|
if ready == 0 {
|
|
continue
|
|
}
|
|
|
|
let count = read(fd, &buffer, buffer.count)
|
|
if count <= 0 {
|
|
break
|
|
}
|
|
if let chunk = String(bytes: buffer[0..<count], encoding: .utf8) {
|
|
response.append(chunk)
|
|
if let newlineIndex = response.firstIndex(of: "\n") {
|
|
return String(response[..<newlineIndex])
|
|
}
|
|
}
|
|
}
|
|
|
|
let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
nonisolated func stop() {
|
|
let (socketToClose, socketPathToUnlink) = withListenerState {
|
|
isRunning = false
|
|
acceptLoopAlive = false
|
|
pendingAcceptLoopRearmGeneration = nil
|
|
listenerStartInProgress = false
|
|
nextAcceptLoopGeneration &+= 1
|
|
activeAcceptLoopGeneration = 0
|
|
let socketToClose = serverSocket
|
|
serverSocket = -1
|
|
return (socketToClose, socketPath)
|
|
}
|
|
if socketToClose >= 0 {
|
|
close(socketToClose)
|
|
}
|
|
unlink(socketPathToUnlink)
|
|
}
|
|
|
|
private nonisolated func unlinkSocketPathIfListenerStillInactive(_ path: String) {
|
|
let shouldUnlink = withListenerState {
|
|
Self.shouldUnlinkSocketPathAfterAcceptLoopCleanup(
|
|
pathMatches: socketPath == path,
|
|
isRunning: isRunning,
|
|
activeGeneration: activeAcceptLoopGeneration,
|
|
listenerStartInProgress: listenerStartInProgress
|
|
)
|
|
}
|
|
if shouldUnlink {
|
|
unlink(path)
|
|
}
|
|
}
|
|
|
|
private func applySocketPermissions() {
|
|
let permissions = mode_t(accessMode.socketFilePermissions)
|
|
let currentSocketPath = withListenerState { socketPath }
|
|
if chmod(currentSocketPath, permissions) != 0 {
|
|
let errnoCode = errno
|
|
print(
|
|
"TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(currentSocketPath)"
|
|
)
|
|
sentryBreadcrumb(
|
|
"socket.listener.permissions.failed",
|
|
category: "socket",
|
|
data: socketListenerEventData(
|
|
stage: "chmod",
|
|
errnoCode: errnoCode,
|
|
extra: ["permissions": String(permissions, radix: 8)]
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func writeSocketResponse(_ response: String, to socket: Int32) {
|
|
let payload = response + "\n"
|
|
payload.withCString { ptr in
|
|
_ = write(socket, ptr, strlen(ptr))
|
|
}
|
|
}
|
|
|
|
private func passwordAuthRequiredResponse(for command: String) -> String {
|
|
let message = "Authentication required. Send auth <password> first."
|
|
guard command.hasPrefix("{"),
|
|
let data = command.data(using: .utf8),
|
|
let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else {
|
|
return "ERROR: Authentication required — send auth <password> first"
|
|
}
|
|
let id = dict["id"]
|
|
return v2Error(id: id, code: "auth_required", message: message)
|
|
}
|
|
|
|
private func passwordLoginV1ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? {
|
|
let lowered = command.lowercased()
|
|
guard lowered == "auth" || lowered.hasPrefix("auth ") else {
|
|
return nil
|
|
}
|
|
guard SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) else {
|
|
return "ERROR: Password mode is enabled but no socket password is configured in Settings."
|
|
}
|
|
|
|
let provided: String
|
|
if lowered == "auth" {
|
|
provided = ""
|
|
} else {
|
|
provided = String(command.dropFirst(5))
|
|
}
|
|
guard !provided.isEmpty else {
|
|
return "ERROR: Missing password. Usage: auth <password>"
|
|
}
|
|
guard SocketControlPasswordStore.verify(password: provided, allowLazyKeychainFallback: true) else {
|
|
return "ERROR: Invalid password"
|
|
}
|
|
authenticated = true
|
|
return "OK: Authenticated"
|
|
}
|
|
|
|
private func passwordLoginV2ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? {
|
|
guard command.hasPrefix("{"),
|
|
let data = command.data(using: .utf8),
|
|
let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else {
|
|
return nil
|
|
}
|
|
let id = dict["id"]
|
|
let method = (dict["method"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard method == "auth.login" else {
|
|
return nil
|
|
}
|
|
|
|
guard let params = dict["params"] as? [String: Any],
|
|
let provided = params["password"] as? String else {
|
|
return v2Error(id: id, code: "invalid_params", message: "auth.login requires params.password")
|
|
}
|
|
|
|
guard SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) else {
|
|
return v2Error(
|
|
id: id,
|
|
code: "auth_unconfigured",
|
|
message: "Password mode is enabled but no socket password is configured in Settings."
|
|
)
|
|
}
|
|
|
|
guard SocketControlPasswordStore.verify(password: provided, allowLazyKeychainFallback: true) else {
|
|
return v2Error(id: id, code: "auth_failed", message: "Invalid password")
|
|
}
|
|
authenticated = true
|
|
return v2Ok(id: id, result: ["authenticated": true])
|
|
}
|
|
|
|
private func authResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? {
|
|
guard accessMode.requiresPasswordAuth else {
|
|
return nil
|
|
}
|
|
if let v2Response = passwordLoginV2ResponseIfNeeded(for: command, authenticated: &authenticated) {
|
|
return v2Response
|
|
}
|
|
if let v1Response = passwordLoginV1ResponseIfNeeded(for: command, authenticated: &authenticated) {
|
|
return v1Response
|
|
}
|
|
if !authenticated {
|
|
return passwordAuthRequiredResponse(for: command)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private nonisolated func acceptLoop(listenerSocket: Int32, generation: UInt64) {
|
|
let armedAcceptLoop = withListenerState {
|
|
guard generation == activeAcceptLoopGeneration else { return false }
|
|
acceptLoopAlive = true
|
|
return true
|
|
}
|
|
guard armedAcceptLoop else {
|
|
return
|
|
}
|
|
|
|
sentryBreadcrumb(
|
|
"socket.listener.accept_loop.started",
|
|
category: "socket",
|
|
data: socketListenerEventData(
|
|
stage: "accept_loop_start",
|
|
extra: [
|
|
"generation": generation,
|
|
"listenerSocket": Int(listenerSocket)
|
|
]
|
|
)
|
|
)
|
|
|
|
var exitReason = "stopped"
|
|
var lastAcceptErrno: Int32?
|
|
var lastAcceptErrnoClass = "none"
|
|
var rearmRequested = false
|
|
|
|
defer {
|
|
let cleanup = withListenerState {
|
|
guard generation == activeAcceptLoopGeneration else {
|
|
return (shouldCaptureExit: false, socketToClose: Int32(-1), pathToUnlink: nil as String?)
|
|
}
|
|
|
|
if isRunning && exitReason == "stopped" {
|
|
exitReason = "unexpected_loop_exit"
|
|
}
|
|
let shouldCaptureExit = exitReason != "stopped"
|
|
|
|
acceptLoopAlive = false
|
|
isRunning = false
|
|
activeAcceptLoopGeneration = 0
|
|
|
|
var socketToClose: Int32 = -1
|
|
var pathToUnlink: String?
|
|
if serverSocket == listenerSocket {
|
|
socketToClose = serverSocket
|
|
serverSocket = -1
|
|
if shouldCaptureExit {
|
|
pathToUnlink = socketPath
|
|
}
|
|
}
|
|
return (shouldCaptureExit, socketToClose, pathToUnlink)
|
|
}
|
|
|
|
if cleanup.socketToClose >= 0 {
|
|
close(cleanup.socketToClose)
|
|
}
|
|
if let pathToUnlink = cleanup.pathToUnlink {
|
|
unlinkSocketPathIfListenerStillInactive(pathToUnlink)
|
|
}
|
|
|
|
if cleanup.shouldCaptureExit {
|
|
let data = socketListenerEventData(
|
|
stage: "accept_loop_exit",
|
|
errnoCode: lastAcceptErrno,
|
|
extra: [
|
|
"reason": exitReason,
|
|
"generation": generation,
|
|
"errnoClass": lastAcceptErrnoClass,
|
|
"rearmRequested": rearmRequested ? 1 : 0
|
|
]
|
|
)
|
|
sentryBreadcrumb("socket.listener.accept_loop.exited", category: "socket", data: data)
|
|
sentryCaptureError(
|
|
"socket.listener.accept_loop.exited",
|
|
category: "socket",
|
|
data: data,
|
|
contextKey: "socket_listener"
|
|
)
|
|
}
|
|
}
|
|
|
|
var consecutiveFailures = 0
|
|
|
|
while shouldContinueAcceptLoop(generation: generation) {
|
|
var clientAddr = sockaddr_un()
|
|
var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
|
|
|
|
let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { ptr in
|
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
|
accept(listenerSocket, sockaddrPtr, &clientAddrLen)
|
|
}
|
|
}
|
|
|
|
guard clientSocket >= 0 else {
|
|
if !shouldContinueAcceptLoop(generation: generation) {
|
|
exitReason = "stopped"
|
|
break
|
|
}
|
|
|
|
let errnoCode = errno
|
|
lastAcceptErrno = errnoCode
|
|
let errnoClass = Self.acceptErrorClassification(errnoCode: errnoCode)
|
|
lastAcceptErrnoClass = errnoClass
|
|
|
|
if Self.shouldRetryAcceptImmediately(errnoCode: errnoCode) {
|
|
continue
|
|
}
|
|
|
|
consecutiveFailures += 1
|
|
let backoffMs = Self.acceptFailureBackoffMilliseconds(
|
|
consecutiveFailures: consecutiveFailures
|
|
)
|
|
let rearmDelayMs = Self.acceptFailureRearmDelayMilliseconds(
|
|
consecutiveFailures: consecutiveFailures
|
|
)
|
|
|
|
if Self.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: consecutiveFailures) {
|
|
sentryBreadcrumb(
|
|
"socket.listener.accept.failed",
|
|
category: "socket",
|
|
data: socketListenerEventData(
|
|
stage: "accept",
|
|
errnoCode: errnoCode,
|
|
extra: [
|
|
"consecutiveFailures": consecutiveFailures,
|
|
"generation": generation,
|
|
"errnoClass": errnoClass,
|
|
"backoffMs": backoffMs
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
let shouldRearmForFatalErrno = Self.shouldRearmListenerForAcceptError(errnoCode: errnoCode)
|
|
let shouldRearmForPersistentFailures = Self.shouldRearmForConsecutiveAcceptFailures(
|
|
consecutiveFailures: consecutiveFailures
|
|
)
|
|
|
|
if shouldRearmForFatalErrno || shouldRearmForPersistentFailures {
|
|
exitReason = shouldRearmForFatalErrno
|
|
? "fatal_accept_error"
|
|
: "persistent_accept_failures"
|
|
rearmRequested = true
|
|
withListenerState {
|
|
pendingAcceptLoopRearmGeneration = generation
|
|
}
|
|
scheduleListenerRearm(
|
|
generation: generation,
|
|
errnoCode: errnoCode,
|
|
consecutiveFailures: consecutiveFailures,
|
|
delayMs: rearmDelayMs
|
|
)
|
|
break
|
|
}
|
|
|
|
if backoffMs > 0 {
|
|
usleep(useconds_t(backoffMs * 1_000))
|
|
}
|
|
continue
|
|
}
|
|
|
|
consecutiveFailures = 0
|
|
|
|
// Capture peer PID immediately — before the client can disconnect.
|
|
// ncat --send-only closes the connection right after writing, so by
|
|
// the time a new thread starts the peer may already be gone.
|
|
let peerPid = getPeerPid(clientSocket)
|
|
|
|
// Handle client in new thread
|
|
Thread.detachNewThread { [weak self] in
|
|
self?.handleClient(clientSocket, peerPid: peerPid)
|
|
}
|
|
}
|
|
}
|
|
|
|
private nonisolated func scheduleListenerRearm(
|
|
generation: UInt64,
|
|
errnoCode: Int32,
|
|
consecutiveFailures: Int,
|
|
delayMs: Int
|
|
) {
|
|
let deadline = DispatchTime.now() + .milliseconds(delayMs)
|
|
DispatchQueue.main.asyncAfter(deadline: deadline) { [weak self] in
|
|
guard let self else { return }
|
|
guard let tabManager = self.tabManager else { return }
|
|
guard let restartPath = self.withListenerState({ () -> String? in
|
|
guard self.pendingAcceptLoopRearmGeneration == generation else { return nil }
|
|
self.pendingAcceptLoopRearmGeneration = nil
|
|
return self.socketPath
|
|
}) else { return }
|
|
|
|
let restartMode = self.accessMode
|
|
|
|
sentryBreadcrumb(
|
|
"socket.listener.rearm.requested",
|
|
category: "socket",
|
|
data: self.socketListenerEventData(
|
|
stage: "accept_rearm",
|
|
errnoCode: errnoCode,
|
|
extra: [
|
|
"generation": generation,
|
|
"consecutiveFailures": consecutiveFailures,
|
|
"rearmDelayMs": delayMs
|
|
]
|
|
)
|
|
)
|
|
|
|
self.stop()
|
|
self.start(tabManager: tabManager, socketPath: restartPath, accessMode: restartMode)
|
|
}
|
|
}
|
|
|
|
private func handleClient(_ socket: Int32, peerPid: pid_t? = nil) {
|
|
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.
|
|
if accessMode == .cmuxOnly {
|
|
// Use pre-captured peer PID if available (captured in accept loop before
|
|
// the peer can disconnect), falling back to live lookup.
|
|
let pid = peerPid ?? getPeerPid(socket)
|
|
if let pid {
|
|
guard isDescendant(pid) else {
|
|
let msg = "ERROR: Access denied — only processes started inside cmux can connect\n"
|
|
msg.withCString { ptr in _ = write(socket, ptr, strlen(ptr)) }
|
|
return
|
|
}
|
|
}
|
|
// If pid is nil, LOCAL_PEERPID failed (peer disconnected before we
|
|
// could read it — common with ncat --send-only). We still verify the
|
|
// peer runs as the same user via LOCAL_PEERCRED. This is the same
|
|
// security boundary as the socket file permissions (0600), so it does
|
|
// not widen the attack surface. We also require that the peer actually
|
|
// sent data (checked in the read loop below) — a connect-only probe
|
|
// with no data is harmless.
|
|
if pid == nil {
|
|
guard peerHasSameUID(socket) else {
|
|
let msg = "ERROR: Unable to verify client process\n"
|
|
msg.withCString { ptr in _ = write(socket, ptr, strlen(ptr)) }
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
var buffer = [UInt8](repeating: 0, count: 4096)
|
|
var pending = ""
|
|
var authenticated = false
|
|
|
|
while withListenerState({ isRunning }) {
|
|
let bytesRead = read(socket, &buffer, buffer.count - 1)
|
|
guard bytesRead > 0 else { break }
|
|
|
|
let chunk = String(bytes: buffer[0..<bytesRead], encoding: .utf8) ?? ""
|
|
pending.append(chunk)
|
|
|
|
while let newlineIndex = pending.firstIndex(of: "\n") {
|
|
let line = String(pending[..<newlineIndex])
|
|
pending = String(pending[pending.index(after: newlineIndex)...])
|
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { continue }
|
|
|
|
if let authResponse = authResponseIfNeeded(for: trimmed, authenticated: &authenticated) {
|
|
writeSocketResponse(authResponse, to: socket)
|
|
continue
|
|
}
|
|
|
|
let response = processCommand(trimmed)
|
|
writeSocketResponse(response, to: socket)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func processCommand(_ command: String) -> String {
|
|
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "ERROR: Empty command" }
|
|
|
|
// v2 protocol: newline-delimited JSON.
|
|
if trimmed.hasPrefix("{") {
|
|
return processV2Command(trimmed)
|
|
}
|
|
|
|
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard !parts.isEmpty else { return "ERROR: Empty command" }
|
|
|
|
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) {
|
|
switch cmd {
|
|
case "ping":
|
|
return "PONG"
|
|
|
|
case "auth":
|
|
return "OK: Authentication not required"
|
|
|
|
case "list_windows":
|
|
return listWindows()
|
|
|
|
case "current_window":
|
|
return currentWindow()
|
|
|
|
case "focus_window":
|
|
return focusWindow(args)
|
|
|
|
case "new_window":
|
|
return newWindow()
|
|
|
|
case "close_window":
|
|
return closeWindow(args)
|
|
|
|
case "move_workspace_to_window":
|
|
return moveWorkspaceToWindow(args)
|
|
|
|
case "list_workspaces":
|
|
return listWorkspaces()
|
|
|
|
case "new_workspace":
|
|
return newWorkspace()
|
|
|
|
case "new_split":
|
|
return newSplit(args)
|
|
|
|
case "list_surfaces":
|
|
return listSurfaces(args)
|
|
|
|
case "focus_surface":
|
|
return focusSurface(args)
|
|
|
|
case "close_workspace":
|
|
return closeWorkspace(args)
|
|
|
|
case "select_workspace":
|
|
return selectWorkspace(args)
|
|
|
|
case "current_workspace":
|
|
return currentWorkspace()
|
|
|
|
case "send":
|
|
return sendInput(args)
|
|
|
|
case "send_key":
|
|
return sendKey(args)
|
|
|
|
case "send_surface":
|
|
return sendInputToSurface(args)
|
|
|
|
case "send_key_surface":
|
|
return sendKeyToSurface(args)
|
|
|
|
case "notify":
|
|
return notifyCurrent(args)
|
|
|
|
case "notify_surface":
|
|
return notifySurface(args)
|
|
|
|
case "notify_target":
|
|
return notifyTarget(args)
|
|
|
|
case "list_notifications":
|
|
return listNotifications()
|
|
|
|
case "clear_notifications":
|
|
return clearNotifications(args)
|
|
|
|
case "set_app_focus":
|
|
return setAppFocusOverride(args)
|
|
|
|
case "simulate_app_active":
|
|
return simulateAppDidBecomeActive()
|
|
|
|
case "set_status":
|
|
return setStatus(args)
|
|
|
|
case "report_meta":
|
|
return reportMeta(args)
|
|
|
|
case "report_meta_block":
|
|
return reportMetaBlock(args)
|
|
|
|
case "clear_status":
|
|
return clearStatus(args)
|
|
|
|
case "clear_meta":
|
|
return clearMeta(args)
|
|
|
|
case "clear_meta_block":
|
|
return clearMetaBlock(args)
|
|
|
|
case "list_status":
|
|
return listStatus(args)
|
|
|
|
case "list_meta":
|
|
return listMeta(args)
|
|
|
|
case "list_meta_blocks":
|
|
return listMetaBlocks(args)
|
|
|
|
case "log":
|
|
return appendLog(args)
|
|
|
|
case "clear_log":
|
|
return clearLog(args)
|
|
|
|
case "list_log":
|
|
return listLog(args)
|
|
|
|
case "set_progress":
|
|
return setProgress(args)
|
|
|
|
case "clear_progress":
|
|
return clearProgress(args)
|
|
|
|
case "report_git_branch":
|
|
return reportGitBranch(args)
|
|
|
|
case "clear_git_branch":
|
|
return clearGitBranch(args)
|
|
|
|
case "report_pr":
|
|
return reportPullRequest(args)
|
|
|
|
case "report_review":
|
|
return reportPullRequest(args)
|
|
|
|
case "clear_pr":
|
|
return clearPullRequest(args)
|
|
|
|
case "report_ports":
|
|
return reportPorts(args)
|
|
|
|
case "clear_ports":
|
|
return clearPorts(args)
|
|
|
|
case "report_tty":
|
|
return reportTTY(args)
|
|
|
|
case "ports_kick":
|
|
return portsKick(args)
|
|
|
|
case "report_pwd":
|
|
return reportPwd(args)
|
|
|
|
case "sidebar_state":
|
|
return sidebarState(args)
|
|
|
|
case "reset_sidebar":
|
|
return resetSidebar(args)
|
|
|
|
case "read_screen":
|
|
return readScreenText(args)
|
|
|
|
|
|
#if DEBUG
|
|
case "send_workspace":
|
|
return sendInputToWorkspace(args)
|
|
|
|
case "set_shortcut":
|
|
return setShortcut(args)
|
|
|
|
case "simulate_shortcut":
|
|
return simulateShortcut(args)
|
|
|
|
case "simulate_type":
|
|
return simulateType(args)
|
|
|
|
case "simulate_file_drop":
|
|
return simulateFileDrop(args)
|
|
|
|
case "seed_drag_pasteboard_fileurl":
|
|
return seedDragPasteboardFileURL()
|
|
|
|
case "seed_drag_pasteboard_tabtransfer":
|
|
return seedDragPasteboardTabTransfer()
|
|
|
|
case "seed_drag_pasteboard_sidebar_reorder":
|
|
return seedDragPasteboardSidebarReorder()
|
|
|
|
case "seed_drag_pasteboard_types":
|
|
return seedDragPasteboardTypes(args)
|
|
|
|
case "clear_drag_pasteboard":
|
|
return clearDragPasteboard()
|
|
|
|
case "drop_hit_test":
|
|
return dropHitTest(args)
|
|
|
|
case "drag_hit_chain":
|
|
return dragHitChain(args)
|
|
|
|
case "overlay_hit_gate":
|
|
return overlayHitGate(args)
|
|
|
|
case "overlay_drop_gate":
|
|
return overlayDropGate(args)
|
|
|
|
case "portal_hit_gate":
|
|
return portalHitGate(args)
|
|
|
|
case "sidebar_overlay_gate":
|
|
return sidebarOverlayGate(args)
|
|
|
|
case "terminal_drop_overlay_probe":
|
|
return terminalDropOverlayProbe(args)
|
|
|
|
case "activate_app":
|
|
return activateApp()
|
|
|
|
case "is_terminal_focused":
|
|
return isTerminalFocused(args)
|
|
|
|
case "read_terminal_text":
|
|
return readTerminalText(args)
|
|
|
|
case "render_stats":
|
|
return renderStats(args)
|
|
|
|
case "layout_debug":
|
|
return layoutDebug()
|
|
|
|
case "bonsplit_underflow_count":
|
|
return bonsplitUnderflowCount()
|
|
|
|
case "reset_bonsplit_underflow_count":
|
|
return resetBonsplitUnderflowCount()
|
|
|
|
case "empty_panel_count":
|
|
return emptyPanelCount()
|
|
|
|
case "reset_empty_panel_count":
|
|
return resetEmptyPanelCount()
|
|
|
|
case "focus_notification":
|
|
return focusFromNotification(args)
|
|
|
|
case "flash_count":
|
|
return flashCount(args)
|
|
|
|
case "reset_flash_counts":
|
|
return resetFlashCounts()
|
|
|
|
case "panel_snapshot":
|
|
return panelSnapshot(args)
|
|
|
|
case "panel_snapshot_reset":
|
|
return panelSnapshotReset(args)
|
|
|
|
case "screenshot":
|
|
return captureScreenshot(args)
|
|
#endif
|
|
|
|
case "help":
|
|
return helpText()
|
|
|
|
// Browser panel commands
|
|
case "open_browser":
|
|
return openBrowser(args)
|
|
|
|
case "navigate":
|
|
return navigateBrowser(args)
|
|
|
|
case "browser_back":
|
|
return browserBack(args)
|
|
|
|
case "browser_forward":
|
|
return browserForward(args)
|
|
|
|
case "browser_reload":
|
|
return browserReload(args)
|
|
|
|
case "get_url":
|
|
return getUrl(args)
|
|
|
|
case "focus_webview":
|
|
return focusWebView(args)
|
|
|
|
case "is_webview_focused":
|
|
return isWebViewFocused(args)
|
|
|
|
case "list_panes":
|
|
return listPanes()
|
|
|
|
case "list_pane_surfaces":
|
|
return listPaneSurfaces(args)
|
|
|
|
case "focus_pane":
|
|
return focusPane(args)
|
|
|
|
case "focus_surface_by_panel":
|
|
return focusSurfaceByPanel(args)
|
|
|
|
case "drag_surface_to_split":
|
|
return dragSurfaceToSplit(args)
|
|
|
|
case "new_pane":
|
|
return newPane(args)
|
|
|
|
case "new_surface":
|
|
return newSurface(args)
|
|
|
|
case "close_surface":
|
|
return closeSurface(args)
|
|
|
|
case "refresh_surfaces":
|
|
return refreshSurfaces()
|
|
|
|
case "surface_health":
|
|
return surfaceHealth(args)
|
|
|
|
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
|
|
|
|
private func processV2Command(_ jsonLine: String) -> String {
|
|
// v1 access-mode gating applies to v2 as well. We can't know which v2 method maps
|
|
// to which v1 command without parsing, so parse first and then apply allow-list.
|
|
|
|
guard let data = jsonLine.data(using: .utf8) else {
|
|
return v2Encode(["ok": false, "error": ["code": "invalid_utf8", "message": "Invalid UTF-8"]])
|
|
}
|
|
|
|
let object: Any
|
|
do {
|
|
object = try JSONSerialization.jsonObject(with: data, options: [])
|
|
} catch {
|
|
return v2Encode(["ok": false, "error": ["code": "parse_error", "message": "Invalid JSON"]])
|
|
}
|
|
|
|
guard let dict = object as? [String: Any] else {
|
|
return v2Encode(["ok": false, "error": ["code": "invalid_request", "message": "Expected JSON object"]])
|
|
}
|
|
|
|
let id: Any? = dict["id"]
|
|
let method = (dict["method"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let params = dict["params"] as? [String: Any] ?? [:]
|
|
|
|
guard !method.isEmpty else {
|
|
return v2Error(id: id, code: "invalid_request", message: "Missing method")
|
|
}
|
|
|
|
v2MainSync { self.v2RefreshKnownRefs() }
|
|
|
|
|
|
#if DEBUG
|
|
let startedAt = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
|
|
let response = withSocketCommandPolicy(commandKey: method, isV2: true) {
|
|
switch method {
|
|
case "system.ping":
|
|
return v2Ok(id: id, result: ["pong": true])
|
|
case "system.capabilities":
|
|
return v2Ok(id: id, result: v2Capabilities())
|
|
|
|
case "system.identify":
|
|
return v2Ok(id: id, result: v2Identify(params: params))
|
|
case "system.tree":
|
|
return v2Result(id: id, self.v2SystemTree(params: params))
|
|
case "auth.login":
|
|
return v2Ok(
|
|
id: id,
|
|
result: [
|
|
"authenticated": true,
|
|
"required": accessMode.requiresPasswordAuth
|
|
]
|
|
)
|
|
|
|
// Windows
|
|
case "window.list":
|
|
return v2Result(id: id, self.v2WindowList(params: params))
|
|
case "window.current":
|
|
return v2Result(id: id, self.v2WindowCurrent(params: params))
|
|
case "window.focus":
|
|
return v2Result(id: id, self.v2WindowFocus(params: params))
|
|
case "window.create":
|
|
return v2Result(id: id, self.v2WindowCreate(params: params))
|
|
case "window.close":
|
|
return v2Result(id: id, self.v2WindowClose(params: params))
|
|
|
|
// Workspaces
|
|
case "workspace.list":
|
|
return v2Result(id: id, self.v2WorkspaceList(params: params))
|
|
case "workspace.create":
|
|
return v2Result(id: id, self.v2WorkspaceCreate(params: params))
|
|
case "workspace.select":
|
|
return v2Result(id: id, self.v2WorkspaceSelect(params: params))
|
|
case "workspace.current":
|
|
return v2Result(id: id, self.v2WorkspaceCurrent(params: params))
|
|
case "workspace.close":
|
|
return v2Result(id: id, self.v2WorkspaceClose(params: params))
|
|
case "workspace.move_to_window":
|
|
return v2Result(id: id, self.v2WorkspaceMoveToWindow(params: params))
|
|
case "workspace.reorder":
|
|
return v2Result(id: id, self.v2WorkspaceReorder(params: params))
|
|
case "workspace.rename":
|
|
return v2Result(id: id, self.v2WorkspaceRename(params: params))
|
|
case "workspace.action":
|
|
return v2Result(id: id, self.v2WorkspaceAction(params: params))
|
|
case "workspace.next":
|
|
return v2Result(id: id, self.v2WorkspaceNext(params: params))
|
|
case "workspace.previous":
|
|
return v2Result(id: id, self.v2WorkspacePrevious(params: params))
|
|
case "workspace.last":
|
|
return v2Result(id: id, self.v2WorkspaceLast(params: params))
|
|
|
|
|
|
// Surfaces / input
|
|
case "surface.list":
|
|
return v2Result(id: id, self.v2SurfaceList(params: params))
|
|
case "surface.current":
|
|
return v2Result(id: id, self.v2SurfaceCurrent(params: params))
|
|
case "surface.focus":
|
|
return v2Result(id: id, self.v2SurfaceFocus(params: params))
|
|
case "surface.split":
|
|
return v2Result(id: id, self.v2SurfaceSplit(params: params))
|
|
case "surface.create":
|
|
return v2Result(id: id, self.v2SurfaceCreate(params: params))
|
|
case "surface.close":
|
|
return v2Result(id: id, self.v2SurfaceClose(params: params))
|
|
case "surface.move":
|
|
return v2Result(id: id, self.v2SurfaceMove(params: params))
|
|
case "surface.reorder":
|
|
return v2Result(id: id, self.v2SurfaceReorder(params: params))
|
|
case "surface.action":
|
|
return v2Result(id: id, self.v2TabAction(params: params))
|
|
case "tab.action":
|
|
return v2Result(id: id, self.v2TabAction(params: params))
|
|
case "surface.drag_to_split":
|
|
return v2Result(id: id, self.v2SurfaceDragToSplit(params: params))
|
|
case "surface.refresh":
|
|
return v2Result(id: id, self.v2SurfaceRefresh(params: params))
|
|
case "surface.health":
|
|
return v2Result(id: id, self.v2SurfaceHealth(params: params))
|
|
case "surface.send_text":
|
|
return v2Result(id: id, self.v2SurfaceSendText(params: params))
|
|
case "surface.send_key":
|
|
return v2Result(id: id, self.v2SurfaceSendKey(params: params))
|
|
case "surface.clear_history":
|
|
return v2Result(id: id, self.v2SurfaceClearHistory(params: params))
|
|
case "surface.trigger_flash":
|
|
return v2Result(id: id, self.v2SurfaceTriggerFlash(params: params))
|
|
|
|
// Panes
|
|
case "pane.list":
|
|
return v2Result(id: id, self.v2PaneList(params: params))
|
|
case "pane.focus":
|
|
return v2Result(id: id, self.v2PaneFocus(params: params))
|
|
case "pane.surfaces":
|
|
return v2Result(id: id, self.v2PaneSurfaces(params: params))
|
|
case "pane.create":
|
|
return v2Result(id: id, self.v2PaneCreate(params: params))
|
|
case "pane.resize":
|
|
return v2Result(id: id, self.v2PaneResize(params: params))
|
|
case "pane.swap":
|
|
return v2Result(id: id, self.v2PaneSwap(params: params))
|
|
case "pane.break":
|
|
return v2Result(id: id, self.v2PaneBreak(params: params))
|
|
case "pane.join":
|
|
return v2Result(id: id, self.v2PaneJoin(params: params))
|
|
case "pane.last":
|
|
return v2Result(id: id, self.v2PaneLast(params: params))
|
|
|
|
// Notifications
|
|
case "notification.create":
|
|
return v2Result(id: id, self.v2NotificationCreate(params: params))
|
|
case "notification.create_for_surface":
|
|
return v2Result(id: id, self.v2NotificationCreateForSurface(params: params))
|
|
case "notification.create_for_target":
|
|
return v2Result(id: id, self.v2NotificationCreateForTarget(params: params))
|
|
case "notification.list":
|
|
return v2Ok(id: id, result: self.v2NotificationList())
|
|
case "notification.clear":
|
|
return v2Result(id: id, self.v2NotificationClear())
|
|
|
|
// App focus
|
|
case "app.focus_override.set":
|
|
return v2Result(id: id, self.v2AppFocusOverride(params: params))
|
|
case "app.simulate_active":
|
|
return v2Result(id: id, self.v2AppSimulateActive())
|
|
|
|
// Browser
|
|
case "browser.open_split":
|
|
return v2Result(id: id, self.v2BrowserOpenSplit(params: params))
|
|
case "browser.navigate":
|
|
return v2Result(id: id, self.v2BrowserNavigate(params: params))
|
|
case "browser.back":
|
|
return v2Result(id: id, self.v2BrowserBack(params: params))
|
|
case "browser.forward":
|
|
return v2Result(id: id, self.v2BrowserForward(params: params))
|
|
case "browser.reload":
|
|
return v2Result(id: id, self.v2BrowserReload(params: params))
|
|
case "browser.url.get":
|
|
return v2Result(id: id, self.v2BrowserGetURL(params: params))
|
|
case "browser.focus_webview":
|
|
return v2Result(id: id, self.v2BrowserFocusWebView(params: params))
|
|
case "browser.is_webview_focused":
|
|
return v2Result(id: id, self.v2BrowserIsWebViewFocused(params: params))
|
|
case "browser.snapshot":
|
|
return v2Result(id: id, self.v2BrowserSnapshot(params: params))
|
|
case "browser.eval":
|
|
return v2Result(id: id, self.v2BrowserEval(params: params))
|
|
case "browser.wait":
|
|
return v2Result(id: id, self.v2BrowserWait(params: params))
|
|
case "browser.click":
|
|
return v2Result(id: id, self.v2BrowserClick(params: params))
|
|
case "browser.dblclick":
|
|
return v2Result(id: id, self.v2BrowserDblClick(params: params))
|
|
case "browser.hover":
|
|
return v2Result(id: id, self.v2BrowserHover(params: params))
|
|
case "browser.focus":
|
|
return v2Result(id: id, self.v2BrowserFocusElement(params: params))
|
|
case "browser.type":
|
|
return v2Result(id: id, self.v2BrowserType(params: params))
|
|
case "browser.fill":
|
|
return v2Result(id: id, self.v2BrowserFill(params: params))
|
|
case "browser.press":
|
|
return v2Result(id: id, self.v2BrowserPress(params: params))
|
|
case "browser.keydown":
|
|
return v2Result(id: id, self.v2BrowserKeyDown(params: params))
|
|
case "browser.keyup":
|
|
return v2Result(id: id, self.v2BrowserKeyUp(params: params))
|
|
case "browser.check":
|
|
return v2Result(id: id, self.v2BrowserCheck(params: params, checked: true))
|
|
case "browser.uncheck":
|
|
return v2Result(id: id, self.v2BrowserCheck(params: params, checked: false))
|
|
case "browser.select":
|
|
return v2Result(id: id, self.v2BrowserSelect(params: params))
|
|
case "browser.scroll":
|
|
return v2Result(id: id, self.v2BrowserScroll(params: params))
|
|
case "browser.scroll_into_view":
|
|
return v2Result(id: id, self.v2BrowserScrollIntoView(params: params))
|
|
case "browser.screenshot":
|
|
return v2Result(id: id, self.v2BrowserScreenshot(params: params))
|
|
case "browser.get.text":
|
|
return v2Result(id: id, self.v2BrowserGetText(params: params))
|
|
case "browser.get.html":
|
|
return v2Result(id: id, self.v2BrowserGetHTML(params: params))
|
|
case "browser.get.value":
|
|
return v2Result(id: id, self.v2BrowserGetValue(params: params))
|
|
case "browser.get.attr":
|
|
return v2Result(id: id, self.v2BrowserGetAttr(params: params))
|
|
case "browser.get.title":
|
|
return v2Result(id: id, self.v2BrowserGetTitle(params: params))
|
|
case "browser.get.count":
|
|
return v2Result(id: id, self.v2BrowserGetCount(params: params))
|
|
case "browser.get.box":
|
|
return v2Result(id: id, self.v2BrowserGetBox(params: params))
|
|
case "browser.get.styles":
|
|
return v2Result(id: id, self.v2BrowserGetStyles(params: params))
|
|
case "browser.is.visible":
|
|
return v2Result(id: id, self.v2BrowserIsVisible(params: params))
|
|
case "browser.is.enabled":
|
|
return v2Result(id: id, self.v2BrowserIsEnabled(params: params))
|
|
case "browser.is.checked":
|
|
return v2Result(id: id, self.v2BrowserIsChecked(params: params))
|
|
case "browser.find.role":
|
|
return v2Result(id: id, self.v2BrowserFindRole(params: params))
|
|
case "browser.find.text":
|
|
return v2Result(id: id, self.v2BrowserFindText(params: params))
|
|
case "browser.find.label":
|
|
return v2Result(id: id, self.v2BrowserFindLabel(params: params))
|
|
case "browser.find.placeholder":
|
|
return v2Result(id: id, self.v2BrowserFindPlaceholder(params: params))
|
|
case "browser.find.alt":
|
|
return v2Result(id: id, self.v2BrowserFindAlt(params: params))
|
|
case "browser.find.title":
|
|
return v2Result(id: id, self.v2BrowserFindTitle(params: params))
|
|
case "browser.find.testid":
|
|
return v2Result(id: id, self.v2BrowserFindTestId(params: params))
|
|
case "browser.find.first":
|
|
return v2Result(id: id, self.v2BrowserFindFirst(params: params))
|
|
case "browser.find.last":
|
|
return v2Result(id: id, self.v2BrowserFindLast(params: params))
|
|
case "browser.find.nth":
|
|
return v2Result(id: id, self.v2BrowserFindNth(params: params))
|
|
case "browser.frame.select":
|
|
return v2Result(id: id, self.v2BrowserFrameSelect(params: params))
|
|
case "browser.frame.main":
|
|
return v2Result(id: id, self.v2BrowserFrameMain(params: params))
|
|
case "browser.dialog.accept":
|
|
return v2Result(id: id, self.v2BrowserDialogRespond(params: params, accept: true))
|
|
case "browser.dialog.dismiss":
|
|
return v2Result(id: id, self.v2BrowserDialogRespond(params: params, accept: false))
|
|
case "browser.download.wait":
|
|
return v2Result(id: id, self.v2BrowserDownloadWait(params: params))
|
|
case "browser.cookies.get":
|
|
return v2Result(id: id, self.v2BrowserCookiesGet(params: params))
|
|
case "browser.cookies.set":
|
|
return v2Result(id: id, self.v2BrowserCookiesSet(params: params))
|
|
case "browser.cookies.clear":
|
|
return v2Result(id: id, self.v2BrowserCookiesClear(params: params))
|
|
case "browser.storage.get":
|
|
return v2Result(id: id, self.v2BrowserStorageGet(params: params))
|
|
case "browser.storage.set":
|
|
return v2Result(id: id, self.v2BrowserStorageSet(params: params))
|
|
case "browser.storage.clear":
|
|
return v2Result(id: id, self.v2BrowserStorageClear(params: params))
|
|
case "browser.tab.new":
|
|
return v2Result(id: id, self.v2BrowserTabNew(params: params))
|
|
case "browser.tab.list":
|
|
return v2Result(id: id, self.v2BrowserTabList(params: params))
|
|
case "browser.tab.switch":
|
|
return v2Result(id: id, self.v2BrowserTabSwitch(params: params))
|
|
case "browser.tab.close":
|
|
return v2Result(id: id, self.v2BrowserTabClose(params: params))
|
|
case "browser.console.list":
|
|
return v2Result(id: id, self.v2BrowserConsoleList(params: params))
|
|
case "browser.console.clear":
|
|
return v2Result(id: id, self.v2BrowserConsoleClear(params: params))
|
|
case "browser.errors.list":
|
|
return v2Result(id: id, self.v2BrowserErrorsList(params: params))
|
|
case "browser.highlight":
|
|
return v2Result(id: id, self.v2BrowserHighlight(params: params))
|
|
case "browser.state.save":
|
|
return v2Result(id: id, self.v2BrowserStateSave(params: params))
|
|
case "browser.state.load":
|
|
return v2Result(id: id, self.v2BrowserStateLoad(params: params))
|
|
case "browser.addinitscript":
|
|
return v2Result(id: id, self.v2BrowserAddInitScript(params: params))
|
|
case "browser.addscript":
|
|
return v2Result(id: id, self.v2BrowserAddScript(params: params))
|
|
case "browser.addstyle":
|
|
return v2Result(id: id, self.v2BrowserAddStyle(params: params))
|
|
case "browser.viewport.set":
|
|
return v2Result(id: id, self.v2BrowserViewportSet(params: params))
|
|
case "browser.geolocation.set":
|
|
return v2Result(id: id, self.v2BrowserGeolocationSet(params: params))
|
|
case "browser.offline.set":
|
|
return v2Result(id: id, self.v2BrowserOfflineSet(params: params))
|
|
case "browser.trace.start":
|
|
return v2Result(id: id, self.v2BrowserTraceStart(params: params))
|
|
case "browser.trace.stop":
|
|
return v2Result(id: id, self.v2BrowserTraceStop(params: params))
|
|
case "browser.network.route":
|
|
return v2Result(id: id, self.v2BrowserNetworkRoute(params: params))
|
|
case "browser.network.unroute":
|
|
return v2Result(id: id, self.v2BrowserNetworkUnroute(params: params))
|
|
case "browser.network.requests":
|
|
return v2Result(id: id, self.v2BrowserNetworkRequests(params: params))
|
|
case "browser.screencast.start":
|
|
return v2Result(id: id, self.v2BrowserScreencastStart(params: params))
|
|
case "browser.screencast.stop":
|
|
return v2Result(id: id, self.v2BrowserScreencastStop(params: params))
|
|
case "browser.input_mouse":
|
|
return v2Result(id: id, self.v2BrowserInputMouse(params: params))
|
|
case "browser.input_keyboard":
|
|
return v2Result(id: id, self.v2BrowserInputKeyboard(params: params))
|
|
case "browser.input_touch":
|
|
return v2Result(id: id, self.v2BrowserInputTouch(params: params))
|
|
|
|
// Markdown
|
|
case "markdown.open":
|
|
return v2Result(id: id, self.v2MarkdownOpen(params: params))
|
|
|
|
case "surface.read_text":
|
|
return v2Result(id: id, self.v2SurfaceReadText(params: params))
|
|
|
|
|
|
#if DEBUG
|
|
// Debug / test-only
|
|
case "debug.shortcut.set":
|
|
return v2Result(id: id, self.v2DebugShortcutSet(params: params))
|
|
case "debug.shortcut.simulate":
|
|
return v2Result(id: id, self.v2DebugShortcutSimulate(params: params))
|
|
case "debug.type":
|
|
return v2Result(id: id, self.v2DebugType(params: params))
|
|
case "debug.app.activate":
|
|
return v2Result(id: id, self.v2DebugActivateApp())
|
|
case "debug.command_palette.toggle":
|
|
return v2Result(id: id, self.v2DebugToggleCommandPalette(params: params))
|
|
case "debug.command_palette.rename_tab.open":
|
|
return v2Result(id: id, self.v2DebugOpenCommandPaletteRenameTabInput(params: params))
|
|
case "debug.command_palette.visible":
|
|
return v2Result(id: id, self.v2DebugCommandPaletteVisible(params: params))
|
|
case "debug.command_palette.selection":
|
|
return v2Result(id: id, self.v2DebugCommandPaletteSelection(params: params))
|
|
case "debug.command_palette.results":
|
|
return v2Result(id: id, self.v2DebugCommandPaletteResults(params: params))
|
|
case "debug.command_palette.rename_input.interact":
|
|
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputInteraction(params: params))
|
|
case "debug.command_palette.rename_input.delete_backward":
|
|
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params))
|
|
case "debug.command_palette.rename_input.selection":
|
|
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params))
|
|
case "debug.command_palette.rename_input.select_all":
|
|
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params))
|
|
case "debug.browser.address_bar_focused":
|
|
return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params))
|
|
case "debug.sidebar.visible":
|
|
return v2Result(id: id, self.v2DebugSidebarVisible(params: params))
|
|
case "debug.terminal.is_focused":
|
|
return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params))
|
|
case "debug.terminal.read_text":
|
|
return v2Result(id: id, self.v2DebugReadTerminalText(params: params))
|
|
case "debug.terminal.render_stats":
|
|
return v2Result(id: id, self.v2DebugRenderStats(params: params))
|
|
case "debug.layout":
|
|
return v2Result(id: id, self.v2DebugLayout())
|
|
case "debug.portal.stats":
|
|
return v2Result(id: id, self.v2DebugPortalStats())
|
|
case "debug.bonsplit_underflow.count":
|
|
return v2Result(id: id, self.v2DebugBonsplitUnderflowCount())
|
|
case "debug.bonsplit_underflow.reset":
|
|
return v2Result(id: id, self.v2DebugResetBonsplitUnderflowCount())
|
|
case "debug.empty_panel.count":
|
|
return v2Result(id: id, self.v2DebugEmptyPanelCount())
|
|
case "debug.empty_panel.reset":
|
|
return v2Result(id: id, self.v2DebugResetEmptyPanelCount())
|
|
case "debug.notification.focus":
|
|
return v2Result(id: id, self.v2DebugFocusNotification(params: params))
|
|
case "debug.flash.count":
|
|
return v2Result(id: id, self.v2DebugFlashCount(params: params))
|
|
case "debug.flash.reset":
|
|
return v2Result(id: id, self.v2DebugResetFlashCounts())
|
|
case "debug.panel_snapshot":
|
|
return v2Result(id: id, self.v2DebugPanelSnapshot(params: params))
|
|
case "debug.panel_snapshot.reset":
|
|
return v2Result(id: id, self.v2DebugPanelSnapshotReset(params: params))
|
|
case "debug.window.screenshot":
|
|
return v2Result(id: id, self.v2DebugScreenshot(params: params))
|
|
#endif
|
|
|
|
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] {
|
|
var methods: [String] = [
|
|
"system.ping",
|
|
"system.capabilities",
|
|
"system.identify",
|
|
"system.tree",
|
|
"auth.login",
|
|
"window.list",
|
|
"window.current",
|
|
"window.focus",
|
|
"window.create",
|
|
"window.close",
|
|
"workspace.list",
|
|
"workspace.create",
|
|
"workspace.select",
|
|
"workspace.current",
|
|
"workspace.close",
|
|
"workspace.move_to_window",
|
|
"workspace.reorder",
|
|
"workspace.rename",
|
|
"workspace.action",
|
|
"workspace.next",
|
|
"workspace.previous",
|
|
"workspace.last",
|
|
"surface.list",
|
|
"surface.current",
|
|
"surface.focus",
|
|
"surface.split",
|
|
"surface.create",
|
|
"surface.close",
|
|
"surface.drag_to_split",
|
|
"surface.move",
|
|
"surface.reorder",
|
|
"surface.action",
|
|
"tab.action",
|
|
"surface.refresh",
|
|
"surface.health",
|
|
"surface.send_text",
|
|
"surface.send_key",
|
|
"surface.read_text",
|
|
"surface.clear_history",
|
|
"surface.trigger_flash",
|
|
"pane.list",
|
|
"pane.focus",
|
|
"pane.surfaces",
|
|
"pane.create",
|
|
"pane.resize",
|
|
"pane.swap",
|
|
"pane.break",
|
|
"pane.join",
|
|
"pane.last",
|
|
"notification.create",
|
|
"notification.create_for_surface",
|
|
"notification.create_for_target",
|
|
"notification.list",
|
|
"notification.clear",
|
|
"app.focus_override.set",
|
|
"app.simulate_active",
|
|
"markdown.open",
|
|
"browser.open_split",
|
|
"browser.navigate",
|
|
"browser.back",
|
|
"browser.forward",
|
|
"browser.reload",
|
|
"browser.url.get",
|
|
"browser.snapshot",
|
|
"browser.eval",
|
|
"browser.wait",
|
|
"browser.click",
|
|
"browser.dblclick",
|
|
"browser.hover",
|
|
"browser.focus",
|
|
"browser.type",
|
|
"browser.fill",
|
|
"browser.press",
|
|
"browser.keydown",
|
|
"browser.keyup",
|
|
"browser.check",
|
|
"browser.uncheck",
|
|
"browser.select",
|
|
"browser.scroll",
|
|
"browser.scroll_into_view",
|
|
"browser.screenshot",
|
|
"browser.get.text",
|
|
"browser.get.html",
|
|
"browser.get.value",
|
|
"browser.get.attr",
|
|
"browser.get.title",
|
|
"browser.get.count",
|
|
"browser.get.box",
|
|
"browser.get.styles",
|
|
"browser.is.visible",
|
|
"browser.is.enabled",
|
|
"browser.is.checked",
|
|
"browser.focus_webview",
|
|
"browser.is_webview_focused",
|
|
"browser.find.role",
|
|
"browser.find.text",
|
|
"browser.find.label",
|
|
"browser.find.placeholder",
|
|
"browser.find.alt",
|
|
"browser.find.title",
|
|
"browser.find.testid",
|
|
"browser.find.first",
|
|
"browser.find.last",
|
|
"browser.find.nth",
|
|
"browser.frame.select",
|
|
"browser.frame.main",
|
|
"browser.dialog.accept",
|
|
"browser.dialog.dismiss",
|
|
"browser.download.wait",
|
|
"browser.cookies.get",
|
|
"browser.cookies.set",
|
|
"browser.cookies.clear",
|
|
"browser.storage.get",
|
|
"browser.storage.set",
|
|
"browser.storage.clear",
|
|
"browser.tab.new",
|
|
"browser.tab.list",
|
|
"browser.tab.switch",
|
|
"browser.tab.close",
|
|
"browser.console.list",
|
|
"browser.console.clear",
|
|
"browser.errors.list",
|
|
"browser.highlight",
|
|
"browser.state.save",
|
|
"browser.state.load",
|
|
"browser.addinitscript",
|
|
"browser.addscript",
|
|
"browser.addstyle",
|
|
"browser.viewport.set",
|
|
"browser.geolocation.set",
|
|
"browser.offline.set",
|
|
"browser.trace.start",
|
|
"browser.trace.stop",
|
|
"browser.network.route",
|
|
"browser.network.unroute",
|
|
"browser.network.requests",
|
|
"browser.screencast.start",
|
|
"browser.screencast.stop",
|
|
"browser.input_mouse",
|
|
"browser.input_keyboard",
|
|
"browser.input_touch",
|
|
]
|
|
#if DEBUG
|
|
methods.append(contentsOf: [
|
|
"debug.shortcut.set",
|
|
"debug.shortcut.simulate",
|
|
"debug.type",
|
|
"debug.app.activate",
|
|
"debug.command_palette.toggle",
|
|
"debug.command_palette.rename_tab.open",
|
|
"debug.command_palette.visible",
|
|
"debug.command_palette.selection",
|
|
"debug.command_palette.results",
|
|
"debug.command_palette.rename_input.interact",
|
|
"debug.command_palette.rename_input.delete_backward",
|
|
"debug.command_palette.rename_input.selection",
|
|
"debug.command_palette.rename_input.select_all",
|
|
"debug.browser.address_bar_focused",
|
|
"debug.sidebar.visible",
|
|
"debug.terminal.is_focused",
|
|
"debug.terminal.read_text",
|
|
"debug.terminal.render_stats",
|
|
"debug.layout",
|
|
"debug.portal.stats",
|
|
"debug.bonsplit_underflow.count",
|
|
"debug.bonsplit_underflow.reset",
|
|
"debug.empty_panel.count",
|
|
"debug.empty_panel.reset",
|
|
"debug.notification.focus",
|
|
"debug.flash.count",
|
|
"debug.flash.reset",
|
|
"debug.panel_snapshot",
|
|
"debug.panel_snapshot.reset",
|
|
"debug.window.screenshot",
|
|
])
|
|
#endif
|
|
|
|
return [
|
|
"protocol": "cmux-socket",
|
|
"version": 2,
|
|
"socket_path": socketPath,
|
|
"access_mode": accessMode.rawValue,
|
|
"methods": methods.sorted()
|
|
]
|
|
}
|
|
|
|
private func v2Identify(params: [String: Any]) -> [String: Any] {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return [
|
|
"socket_path": socketPath,
|
|
"focused": NSNull(),
|
|
"caller": NSNull()
|
|
]
|
|
}
|
|
|
|
var focused: [String: Any] = [:]
|
|
v2MainSync {
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
if let wsId = tabManager.selectedTabId,
|
|
let ws = tabManager.tabs.first(where: { $0.id == wsId }) {
|
|
let paneUUID = ws.bonsplitController.focusedPaneId?.id
|
|
let surfaceUUID = ws.focusedPanelId
|
|
focused = [
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"workspace_id": wsId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId),
|
|
"pane_id": v2OrNull(paneUUID?.uuidString),
|
|
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
|
|
"surface_id": v2OrNull(surfaceUUID?.uuidString),
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceUUID),
|
|
"tab_id": v2OrNull(surfaceUUID?.uuidString),
|
|
"tab_ref": v2TabRef(uuid: surfaceUUID),
|
|
"surface_type": v2OrNull(surfaceUUID.flatMap { ws.panels[$0]?.panelType.rawValue }),
|
|
"is_browser_surface": v2OrNull(surfaceUUID.flatMap { ws.panels[$0]?.panelType == .browser })
|
|
]
|
|
} else {
|
|
focused = [
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
]
|
|
}
|
|
}
|
|
|
|
// Optionally validate a caller-provided location (useful for agents calling from inside a surface).
|
|
var resolvedCaller: [String: Any]? = nil
|
|
if let callerObj = params["caller"] as? [String: Any],
|
|
let wsId = v2UUIDAny(callerObj["workspace_id"]) {
|
|
let surfaceId = v2UUIDAny(callerObj["surface_id"]) ?? v2UUIDAny(callerObj["tab_id"])
|
|
v2MainSync {
|
|
let callerTabManager = AppDelegate.shared?.tabManagerFor(tabId: wsId) ?? tabManager
|
|
if let ws = callerTabManager.tabs.first(where: { $0.id == wsId }) {
|
|
let callerWindowId = v2ResolveWindowId(tabManager: callerTabManager)
|
|
var payload: [String: Any] = [
|
|
"window_id": v2OrNull(callerWindowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: callerWindowId),
|
|
"workspace_id": wsId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
|
|
]
|
|
|
|
if let surfaceId, ws.panels[surfaceId] != nil {
|
|
let paneUUID = ws.paneId(forPanelId: surfaceId)?.id
|
|
payload["surface_id"] = surfaceId.uuidString
|
|
payload["surface_ref"] = v2Ref(kind: .surface, uuid: surfaceId)
|
|
payload["tab_id"] = surfaceId.uuidString
|
|
payload["tab_ref"] = v2TabRef(uuid: surfaceId)
|
|
payload["surface_type"] = v2OrNull(ws.panels[surfaceId]?.panelType.rawValue)
|
|
payload["is_browser_surface"] = v2OrNull(ws.panels[surfaceId]?.panelType == .browser)
|
|
payload["pane_id"] = v2OrNull(paneUUID?.uuidString)
|
|
payload["pane_ref"] = v2Ref(kind: .pane, uuid: paneUUID)
|
|
} else {
|
|
payload["surface_id"] = NSNull()
|
|
payload["surface_ref"] = NSNull()
|
|
payload["tab_id"] = NSNull()
|
|
payload["tab_ref"] = NSNull()
|
|
payload["surface_type"] = NSNull()
|
|
payload["is_browser_surface"] = NSNull()
|
|
payload["pane_id"] = NSNull()
|
|
payload["pane_ref"] = NSNull()
|
|
}
|
|
resolvedCaller = payload
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
"socket_path": socketPath,
|
|
"focused": focused.isEmpty ? NSNull() : focused,
|
|
"caller": v2OrNull(resolvedCaller)
|
|
]
|
|
}
|
|
|
|
private func v2SystemTree(params: [String: Any]) -> V2CallResult {
|
|
let workspaceFilter = v2UUID(params, "workspace_id")
|
|
if params["workspace_id"] != nil && workspaceFilter == nil {
|
|
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
|
|
}
|
|
let includeAllWindows = v2Bool(params, "all_windows") ?? false
|
|
|
|
var identifyParams: [String: Any] = [:]
|
|
if let caller = params["caller"] as? [String: Any], !caller.isEmpty {
|
|
identifyParams["caller"] = caller
|
|
}
|
|
let identifyPayload = v2Identify(params: identifyParams)
|
|
let focused = identifyPayload["focused"] as? [String: Any] ?? [:]
|
|
let caller = identifyPayload["caller"] as? [String: Any] ?? [:]
|
|
let focusedWindowId = v2UUIDAny(focused["window_id"]) ?? v2UUIDAny(focused["window_ref"])
|
|
|
|
var windowNodes: [[String: Any]] = []
|
|
var workspaceFound = (workspaceFilter == nil)
|
|
|
|
v2MainSync {
|
|
guard let app = AppDelegate.shared else { return }
|
|
let summaries = app.listMainWindowSummaries()
|
|
let defaultWindowId = focusedWindowId ?? summaries.first?.windowId
|
|
|
|
for (windowIndex, summary) in summaries.enumerated() {
|
|
guard let manager = app.tabManagerFor(windowId: summary.windowId) else { continue }
|
|
|
|
if let workspaceFilter {
|
|
guard let workspaceIndex = manager.tabs.firstIndex(where: { $0.id == workspaceFilter }) else {
|
|
continue
|
|
}
|
|
let workspace = manager.tabs[workspaceIndex]
|
|
let workspaceNode = v2TreeWorkspaceNode(
|
|
workspace: workspace,
|
|
index: workspaceIndex,
|
|
selected: workspace.id == manager.selectedTabId
|
|
)
|
|
windowNodes = [
|
|
v2TreeWindowNode(
|
|
summary: summary,
|
|
index: windowIndex,
|
|
workspaceNodes: [workspaceNode]
|
|
)
|
|
]
|
|
workspaceFound = true
|
|
break
|
|
}
|
|
|
|
if !includeAllWindows && summary.windowId != defaultWindowId {
|
|
continue
|
|
}
|
|
|
|
let workspaceNodesForWindow = manager.tabs.enumerated().map { workspaceIndex, workspace in
|
|
v2TreeWorkspaceNode(
|
|
workspace: workspace,
|
|
index: workspaceIndex,
|
|
selected: workspace.id == manager.selectedTabId
|
|
)
|
|
}
|
|
|
|
windowNodes.append(
|
|
v2TreeWindowNode(
|
|
summary: summary,
|
|
index: windowIndex,
|
|
workspaceNodes: workspaceNodesForWindow
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
if let workspaceFilter, !workspaceFound {
|
|
return .err(
|
|
code: "not_found",
|
|
message: "Workspace not found",
|
|
data: [
|
|
"workspace_id": workspaceFilter.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceFilter)
|
|
]
|
|
)
|
|
}
|
|
|
|
return .ok([
|
|
"active": focused.isEmpty ? (NSNull() as Any) : focused,
|
|
"caller": caller.isEmpty ? (NSNull() as Any) : caller,
|
|
"windows": windowNodes
|
|
])
|
|
}
|
|
|
|
private func v2TreeWindowNode(
|
|
summary: AppDelegate.MainWindowSummary,
|
|
index: Int,
|
|
workspaceNodes: [[String: Any]]
|
|
) -> [String: Any] {
|
|
return [
|
|
"id": summary.windowId.uuidString,
|
|
"ref": v2Ref(kind: .window, uuid: summary.windowId),
|
|
"index": index,
|
|
"key": summary.isKeyWindow,
|
|
"visible": summary.isVisible,
|
|
"workspace_count": workspaceNodes.count,
|
|
"selected_workspace_id": v2OrNull(summary.selectedWorkspaceId?.uuidString),
|
|
"selected_workspace_ref": v2Ref(kind: .workspace, uuid: summary.selectedWorkspaceId),
|
|
"workspaces": workspaceNodes
|
|
]
|
|
}
|
|
|
|
private func v2TreeWorkspaceNode(
|
|
workspace: Workspace,
|
|
index: Int,
|
|
selected: Bool
|
|
) -> [String: Any] {
|
|
var paneByPanelId: [UUID: UUID] = [:]
|
|
var indexInPaneByPanelId: [UUID: Int] = [:]
|
|
var selectedInPaneByPanelId: [UUID: Bool] = [:]
|
|
|
|
let paneIds = workspace.bonsplitController.allPaneIds
|
|
for paneId in paneIds {
|
|
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
|
|
let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId)
|
|
for (tabIndex, tab) in tabs.enumerated() {
|
|
guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue }
|
|
paneByPanelId[panelId] = paneId.id
|
|
indexInPaneByPanelId[panelId] = tabIndex
|
|
selectedInPaneByPanelId[panelId] = (tab.id == selectedTab?.id)
|
|
}
|
|
}
|
|
|
|
var surfacesByPane: [UUID: [[String: Any]]] = [:]
|
|
let focusedSurfaceId = workspace.focusedPanelId
|
|
for (surfaceIndex, panel) in orderedPanels(in: workspace).enumerated() {
|
|
let paneUUID = paneByPanelId[panel.id]
|
|
let selectedInPane = selectedInPaneByPanelId[panel.id] ?? false
|
|
|
|
var item: [String: Any] = [
|
|
"id": panel.id.uuidString,
|
|
"ref": v2Ref(kind: .surface, uuid: panel.id),
|
|
"index": surfaceIndex,
|
|
"type": panel.panelType.rawValue,
|
|
"title": workspace.panelTitle(panelId: panel.id) ?? panel.displayTitle,
|
|
"focused": panel.id == focusedSurfaceId,
|
|
"selected": selectedInPane,
|
|
"selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id]),
|
|
"pane_id": v2OrNull(paneUUID?.uuidString),
|
|
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
|
|
"index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id])
|
|
]
|
|
|
|
if panel.panelType == .browser, let browserPanel = panel as? BrowserPanel {
|
|
item["url"] = browserPanel.currentURL?.absoluteString ?? ""
|
|
} else {
|
|
item["url"] = NSNull()
|
|
}
|
|
if let paneUUID {
|
|
surfacesByPane[paneUUID, default: []].append(item)
|
|
}
|
|
}
|
|
|
|
for paneUUID in surfacesByPane.keys {
|
|
surfacesByPane[paneUUID]?.sort {
|
|
let lhs = ($0["index_in_pane"] as? Int) ?? ($0["index"] as? Int) ?? Int.max
|
|
let rhs = ($1["index_in_pane"] as? Int) ?? ($1["index"] as? Int) ?? Int.max
|
|
return lhs < rhs
|
|
}
|
|
}
|
|
|
|
let focusedPaneId = workspace.bonsplitController.focusedPaneId
|
|
let panes: [[String: Any]] = paneIds.enumerated().map { paneIndex, paneId in
|
|
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
|
|
let surfaceUUIDs: [UUID] = tabs.compactMap { workspace.panelIdFromSurfaceId($0.id) }
|
|
let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId)
|
|
let selectedSurfaceUUID = selectedTab.flatMap { workspace.panelIdFromSurfaceId($0.id) }
|
|
|
|
return [
|
|
"id": paneId.id.uuidString,
|
|
"ref": v2Ref(kind: .pane, uuid: paneId.id),
|
|
"index": paneIndex,
|
|
"focused": paneId == focusedPaneId,
|
|
"surface_ids": surfaceUUIDs.map { $0.uuidString },
|
|
"surface_refs": surfaceUUIDs.map { v2Ref(kind: .surface, uuid: $0) },
|
|
"selected_surface_id": v2OrNull(selectedSurfaceUUID?.uuidString),
|
|
"selected_surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceUUID),
|
|
"surface_count": surfaceUUIDs.count,
|
|
"surfaces": surfacesByPane[paneId.id] ?? []
|
|
]
|
|
}
|
|
|
|
return [
|
|
"id": workspace.id.uuidString,
|
|
"ref": v2Ref(kind: .workspace, uuid: workspace.id),
|
|
"index": index,
|
|
"title": workspace.title,
|
|
"selected": selected,
|
|
"pinned": workspace.isPinned,
|
|
"panes": panes
|
|
]
|
|
}
|
|
|
|
// MARK: - V2 Helpers (encoding + result plumbing)
|
|
// MARK: - V2 Helpers (encoding + result plumbing)
|
|
|
|
private func v2OrNull(_ value: Any?) -> Any {
|
|
// Avoid relying on `?? NSNull()` inference (Swift toolchains can disagree).
|
|
if let value { return value }
|
|
return NSNull()
|
|
}
|
|
|
|
private func v2MainSync<T>(_ body: () -> T) -> T {
|
|
if Thread.isMainThread {
|
|
return body()
|
|
}
|
|
return DispatchQueue.main.sync(execute: body)
|
|
}
|
|
|
|
private func v2Ok(id: Any?, result: Any) -> String {
|
|
return v2Encode([
|
|
"id": v2OrNull(id),
|
|
"ok": true,
|
|
"result": result
|
|
])
|
|
}
|
|
|
|
private func v2Error(id: Any?, code: String, message: String, data: Any? = nil) -> String {
|
|
var err: [String: Any] = ["code": code, "message": message]
|
|
if let data {
|
|
err["data"] = data
|
|
}
|
|
return v2Encode([
|
|
"id": v2OrNull(id),
|
|
"ok": false,
|
|
"error": err
|
|
])
|
|
}
|
|
|
|
private enum V2CallResult {
|
|
case ok(Any)
|
|
case err(code: String, message: String, data: Any?)
|
|
}
|
|
|
|
private func v2Result(id: Any?, _ res: V2CallResult) -> String {
|
|
switch res {
|
|
case .ok(let payload):
|
|
return v2Ok(id: id, result: payload)
|
|
case .err(let code, let message, let data):
|
|
return v2Error(id: id, code: code, message: message, data: data)
|
|
}
|
|
}
|
|
|
|
private func v2Encode(_ object: Any) -> String {
|
|
guard JSONSerialization.isValidJSONObject(object),
|
|
let data = try? JSONSerialization.data(withJSONObject: object, options: []),
|
|
var s = String(data: data, encoding: .utf8) else {
|
|
return "{\"ok\":false,\"error\":{\"code\":\"encode_error\",\"message\":\"Failed to encode JSON\"}}"
|
|
}
|
|
|
|
// Ensure single-line responses for the line-oriented socket protocol.
|
|
s = s.replacingOccurrences(of: "\n", with: "\\n")
|
|
return s
|
|
}
|
|
|
|
private func v2EnsureHandleRef(kind: V2HandleKind, uuid: UUID) -> String {
|
|
if let existing = v2RefByUUID[kind]?[uuid] {
|
|
return existing
|
|
}
|
|
let next = v2NextHandleOrdinal[kind] ?? 1
|
|
let ref = "\(kind.rawValue):\(next)"
|
|
var byUUID = v2RefByUUID[kind] ?? [:]
|
|
var byRef = v2UUIDByRef[kind] ?? [:]
|
|
byUUID[uuid] = ref
|
|
byRef[ref] = uuid
|
|
v2RefByUUID[kind] = byUUID
|
|
v2UUIDByRef[kind] = byRef
|
|
v2NextHandleOrdinal[kind] = next + 1
|
|
return ref
|
|
}
|
|
|
|
private func v2ResolveHandleRef(_ handle: String) -> UUID? {
|
|
for kind in V2HandleKind.allCases {
|
|
if let id = v2UUIDByRef[kind]?[handle] {
|
|
return id
|
|
}
|
|
}
|
|
// Tab refs are aliases for surface refs in tab-facing APIs.
|
|
let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if trimmed.hasPrefix("tab:"),
|
|
let ordinal = Int(trimmed.replacingOccurrences(of: "tab:", with: "")),
|
|
let id = v2UUIDByRef[.surface]?["surface:\(ordinal)"] {
|
|
return id
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func v2Ref(kind: V2HandleKind, uuid: UUID?) -> Any {
|
|
guard let uuid else { return NSNull() }
|
|
return v2EnsureHandleRef(kind: kind, uuid: uuid)
|
|
}
|
|
|
|
private func v2TabRef(uuid: UUID?) -> Any {
|
|
guard let uuid else { return NSNull() }
|
|
let surfaceRef = v2EnsureHandleRef(kind: .surface, uuid: uuid)
|
|
return surfaceRef.replacingOccurrences(of: "surface:", with: "tab:")
|
|
}
|
|
|
|
private func v2RefreshKnownRefs() {
|
|
guard let app = AppDelegate.shared else { return }
|
|
|
|
let windows = app.listMainWindowSummaries()
|
|
for item in windows {
|
|
_ = v2EnsureHandleRef(kind: .window, uuid: item.windowId)
|
|
if let tm = app.tabManagerFor(windowId: item.windowId) {
|
|
for ws in tm.tabs {
|
|
_ = v2EnsureHandleRef(kind: .workspace, uuid: ws.id)
|
|
for paneId in ws.bonsplitController.allPaneIds {
|
|
_ = v2EnsureHandleRef(kind: .pane, uuid: paneId.id)
|
|
}
|
|
for panelId in ws.panels.keys {
|
|
_ = v2EnsureHandleRef(kind: .surface, uuid: panelId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - V2 Param Parsing
|
|
|
|
private func v2String(_ params: [String: Any], _ key: String) -> String? {
|
|
guard let raw = params[key] as? String else { return nil }
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private func v2ActionKey(_ params: [String: Any], _ key: String = "action") -> String? {
|
|
guard let action = v2String(params, key) else { return nil }
|
|
return action.lowercased().replacingOccurrences(of: "-", with: "_")
|
|
}
|
|
|
|
private func v2RawString(_ params: [String: Any], _ key: String) -> String? {
|
|
params[key] as? String
|
|
}
|
|
|
|
private func v2UUID(_ params: [String: Any], _ key: String) -> UUID? {
|
|
guard let s = v2String(params, key) else { return nil }
|
|
if let uuid = UUID(uuidString: s) {
|
|
return uuid
|
|
}
|
|
return v2ResolveHandleRef(s)
|
|
}
|
|
|
|
private func v2UUIDAny(_ raw: Any?) -> UUID? {
|
|
guard let s = raw as? String else { return nil }
|
|
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
if let uuid = UUID(uuidString: trimmed) {
|
|
return uuid
|
|
}
|
|
return v2ResolveHandleRef(trimmed)
|
|
}
|
|
private func v2Bool(_ params: [String: Any], _ key: String) -> Bool? {
|
|
if let b = params[key] as? Bool { return b }
|
|
if let n = params[key] as? NSNumber { return n.boolValue }
|
|
if let s = params[key] as? String {
|
|
switch s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func v2LocatePane(_ paneUUID: UUID) -> (windowId: UUID, tabManager: TabManager, workspace: Workspace, paneId: PaneID)? {
|
|
guard let app = AppDelegate.shared else { return nil }
|
|
let windows = app.listMainWindowSummaries()
|
|
for item in windows {
|
|
guard let tm = app.tabManagerFor(windowId: item.windowId) else { continue }
|
|
for ws in tm.tabs {
|
|
if let paneId = ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) {
|
|
return (item.windowId, tm, ws, paneId)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
private func v2Int(_ params: [String: Any], _ key: String) -> Int? {
|
|
if let i = params[key] as? Int { return i }
|
|
if let n = params[key] as? NSNumber { return n.intValue }
|
|
if let s = params[key] as? String { return Int(s) }
|
|
return nil
|
|
}
|
|
|
|
private func v2PanelType(_ params: [String: Any], _ key: String) -> PanelType? {
|
|
guard let s = v2String(params, key) else { return nil }
|
|
return PanelType(rawValue: s.lowercased())
|
|
}
|
|
|
|
// MARK: - V2 Context Resolution
|
|
|
|
private func v2ResolveTabManager(params: [String: Any]) -> TabManager? {
|
|
// Prefer explicit window_id routing. Fall back to global lookup by workspace_id/surface_id/tab_id,
|
|
// and finally to the active window's TabManager.
|
|
if let windowId = v2UUID(params, "window_id") {
|
|
return v2MainSync { AppDelegate.shared?.tabManagerFor(windowId: windowId) }
|
|
}
|
|
if let wsId = v2UUID(params, "workspace_id") {
|
|
if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(tabId: wsId) }) {
|
|
return tm
|
|
}
|
|
}
|
|
if let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") {
|
|
if let tm = v2MainSync({ AppDelegate.shared?.locateSurface(surfaceId: surfaceId)?.tabManager }) {
|
|
return tm
|
|
}
|
|
}
|
|
return tabManager
|
|
}
|
|
|
|
private func v2ResolveWindowId(tabManager: TabManager?) -> UUID? {
|
|
guard let tabManager else { return nil }
|
|
return v2MainSync { AppDelegate.shared?.windowId(for: tabManager) }
|
|
}
|
|
|
|
// MARK: - V2 Window Methods
|
|
|
|
private func v2WindowList(params _: [String: Any]) -> V2CallResult {
|
|
let windows = v2MainSync { AppDelegate.shared?.listMainWindowSummaries() } ?? []
|
|
let payload: [[String: Any]] = windows.enumerated().map { index, item in
|
|
return [
|
|
"id": item.windowId.uuidString,
|
|
"ref": v2Ref(kind: .window, uuid: item.windowId),
|
|
"index": index,
|
|
"key": item.isKeyWindow,
|
|
"visible": item.isVisible,
|
|
"workspace_count": item.workspaceCount,
|
|
"selected_workspace_id": v2OrNull(item.selectedWorkspaceId?.uuidString),
|
|
"selected_workspace_ref": v2Ref(kind: .workspace, uuid: item.selectedWorkspaceId)
|
|
]
|
|
}
|
|
return .ok(["windows": payload])
|
|
}
|
|
|
|
private func v2WindowCurrent(params _: [String: Any]) -> V2CallResult {
|
|
guard let tabManager else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let windowId = v2ResolveWindowId(tabManager: tabManager) else {
|
|
return .err(code: "not_found", message: "Current window not found", data: nil)
|
|
}
|
|
return .ok([
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
}
|
|
|
|
private func v2WindowFocus(params: [String: Any]) -> V2CallResult {
|
|
guard let windowId = v2UUID(params, "window_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
|
}
|
|
let ok = v2MainSync { AppDelegate.shared?.focusMainWindow(windowId: windowId) ?? false }
|
|
return ok
|
|
? .ok([
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
: .err(code: "not_found", message: "Window not found", data: [
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
}
|
|
|
|
private func v2WindowCreate(params _: [String: Any]) -> V2CallResult {
|
|
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) }) {
|
|
setActiveTabManager(tm)
|
|
}
|
|
return .ok([
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
}
|
|
|
|
private func v2WindowClose(params: [String: Any]) -> V2CallResult {
|
|
guard let windowId = v2UUID(params, "window_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
|
}
|
|
let ok = v2MainSync { AppDelegate.shared?.closeMainWindow(windowId: windowId) ?? false }
|
|
return ok
|
|
? .ok([
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
: .err(code: "not_found", message: "Window not found", data: [
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
}
|
|
|
|
// MARK: - V2 Workspace Methods
|
|
|
|
private func v2WorkspaceList(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var workspaces: [[String: Any]] = []
|
|
v2MainSync {
|
|
workspaces = tabManager.tabs.enumerated().map { index, ws in
|
|
return [
|
|
"id": ws.id.uuidString,
|
|
"ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"index": index,
|
|
"title": ws.title,
|
|
"selected": ws.id == tabManager.selectedTabId,
|
|
"pinned": ws.isPinned
|
|
]
|
|
}
|
|
}
|
|
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
return .ok([
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"workspaces": workspaces
|
|
])
|
|
}
|
|
private func v2WorkspaceCreate(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
let cwd: String?
|
|
if let raw = params["cwd"] {
|
|
guard let str = raw as? String else {
|
|
return .err(code: "invalid_params", message: "cwd must be a string", data: nil)
|
|
}
|
|
cwd = str
|
|
} else {
|
|
cwd = nil
|
|
}
|
|
|
|
var newId: UUID?
|
|
let shouldFocus = v2FocusAllowed()
|
|
#if DEBUG
|
|
let startedAt = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
v2MainSync {
|
|
let ws = tabManager.addWorkspace(workingDirectory: cwd, select: shouldFocus)
|
|
if !shouldFocus, let terminalPanel = ws.focusedTerminalPanel {
|
|
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
|
}
|
|
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)
|
|
}
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
return .ok([
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"workspace_id": newId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: newId)
|
|
])
|
|
}
|
|
private func v2WorkspaceSelect(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let wsId = v2UUID(params, "workspace_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
|
|
}
|
|
|
|
var success = false
|
|
v2MainSync {
|
|
if let ws = tabManager.tabs.first(where: { $0.id == wsId }) {
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
v2MaybeSelectWorkspace(tabManager, workspace: ws)
|
|
success = true
|
|
}
|
|
}
|
|
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
return success
|
|
? .ok([
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"workspace_id": wsId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
|
|
])
|
|
: .err(code: "not_found", message: "Workspace not found", data: [
|
|
"workspace_id": wsId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
|
|
])
|
|
}
|
|
private func v2WorkspaceCurrent(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
var wsId: UUID?
|
|
v2MainSync {
|
|
wsId = tabManager.selectedTabId
|
|
}
|
|
guard let wsId else {
|
|
return .err(code: "not_found", message: "No workspace selected", data: nil)
|
|
}
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
return .ok([
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"workspace_id": wsId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
|
|
])
|
|
}
|
|
private func v2WorkspaceClose(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let wsId = v2UUID(params, "workspace_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
|
|
}
|
|
|
|
var found = false
|
|
v2MainSync {
|
|
if let ws = tabManager.tabs.first(where: { $0.id == wsId }) {
|
|
tabManager.closeWorkspace(ws)
|
|
found = true
|
|
}
|
|
}
|
|
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
return found
|
|
? .ok([
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"workspace_id": wsId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
|
|
])
|
|
: .err(code: "not_found", message: "Workspace not found", data: [
|
|
"workspace_id": wsId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
|
|
])
|
|
}
|
|
private func v2WorkspaceMoveToWindow(params: [String: Any]) -> V2CallResult {
|
|
guard let wsId = v2UUID(params, "workspace_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
|
|
}
|
|
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)
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil)
|
|
v2MainSync {
|
|
guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString])
|
|
return
|
|
}
|
|
guard let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowId) else {
|
|
result = .err(code: "not_found", message: "Window not found", data: ["window_id": windowId.uuidString])
|
|
return
|
|
}
|
|
guard let ws = srcTM.detachWorkspace(tabId: wsId) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString])
|
|
return
|
|
}
|
|
|
|
dstTM.attachWorkspace(ws, select: focus)
|
|
if focus {
|
|
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
|
|
setActiveTabManager(dstTM)
|
|
}
|
|
result = .ok([
|
|
"workspace_id": wsId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId),
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
private func v2WorkspaceReorder(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let workspaceId = v2UUID(params, "workspace_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
|
|
}
|
|
|
|
let index = v2Int(params, "index")
|
|
let beforeId = v2UUID(params, "before_workspace_id")
|
|
let afterId = v2UUID(params, "after_workspace_id")
|
|
|
|
let targetCount = (index != nil ? 1 : 0) + (beforeId != nil ? 1 : 0) + (afterId != nil ? 1 : 0)
|
|
if targetCount != 1 {
|
|
return .err(
|
|
code: "invalid_params",
|
|
message: "Specify exactly one target: index, before_workspace_id, or after_workspace_id",
|
|
data: nil
|
|
)
|
|
}
|
|
|
|
var moved = false
|
|
var newIndex: Int?
|
|
v2MainSync {
|
|
if let index {
|
|
moved = tabManager.reorderWorkspace(tabId: workspaceId, toIndex: index)
|
|
} else {
|
|
moved = tabManager.reorderWorkspace(tabId: workspaceId, before: beforeId, after: afterId)
|
|
}
|
|
newIndex = tabManager.tabs.firstIndex(where: { $0.id == workspaceId })
|
|
}
|
|
|
|
guard moved else {
|
|
return .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": workspaceId.uuidString])
|
|
}
|
|
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
return .ok([
|
|
"workspace_id": workspaceId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"index": v2OrNull(newIndex)
|
|
])
|
|
}
|
|
private func v2WorkspaceRename(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let workspaceId = v2UUID(params, "workspace_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
|
|
}
|
|
guard let titleRaw = v2String(params, "title"),
|
|
!titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
|
|
}
|
|
|
|
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
var renamed = false
|
|
v2MainSync {
|
|
guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return }
|
|
tabManager.setCustomTitle(tabId: workspaceId, title: title)
|
|
renamed = true
|
|
}
|
|
|
|
guard renamed else {
|
|
return .err(code: "not_found", message: "Workspace not found", data: [
|
|
"workspace_id": workspaceId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId)
|
|
])
|
|
}
|
|
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
return .ok([
|
|
"workspace_id": workspaceId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"title": title
|
|
])
|
|
}
|
|
private func v2WorkspaceNext(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil)
|
|
v2MainSync {
|
|
guard tabManager.selectedTabId != nil else { return }
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
tabManager.selectNextTab()
|
|
guard let workspaceId = tabManager.selectedTabId else { return }
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
result = .ok([
|
|
"workspace_id": workspaceId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2WorkspacePrevious(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil)
|
|
v2MainSync {
|
|
guard tabManager.selectedTabId != nil else { return }
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
tabManager.selectPreviousTab()
|
|
guard let workspaceId = tabManager.selectedTabId else { return }
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
result = .ok([
|
|
"workspace_id": workspaceId.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2WorkspaceLast(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
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)
|
|
tabManager.navigateBack()
|
|
guard let after = tabManager.selectedTabId, after != before else { return }
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
result = .ok([
|
|
"workspace_id": after.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: after),
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let action = v2ActionKey(params) else {
|
|
return .err(code: "invalid_params", message: "Missing action", data: nil)
|
|
}
|
|
|
|
let supportedActions = [
|
|
"pin", "unpin", "rename", "clear_name",
|
|
"move_up", "move_down", "move_top",
|
|
"close_others", "close_above", "close_below",
|
|
"mark_read", "mark_unread"
|
|
]
|
|
|
|
var result: V2CallResult = .err(code: "invalid_params", message: "Unknown workspace action", data: [
|
|
"action": action,
|
|
"supported_actions": supportedActions
|
|
])
|
|
|
|
v2MainSync {
|
|
let requestedWorkspaceId = v2UUID(params, "workspace_id") ?? tabManager.selectedTabId
|
|
guard let workspaceId = requestedWorkspaceId,
|
|
let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
|
|
@MainActor
|
|
func closeWorkspaces(_ workspaces: [Workspace]) -> Int {
|
|
var closed = 0
|
|
for candidate in workspaces where candidate.id != workspace.id {
|
|
let existedBefore = tabManager.tabs.contains(where: { $0.id == candidate.id })
|
|
guard existedBefore else { continue }
|
|
tabManager.closeWorkspace(candidate)
|
|
if !tabManager.tabs.contains(where: { $0.id == candidate.id }) {
|
|
closed += 1
|
|
}
|
|
}
|
|
return closed
|
|
}
|
|
|
|
@MainActor
|
|
func finish(_ extras: [String: Any] = [:]) {
|
|
var payload: [String: Any] = [
|
|
"action": action,
|
|
"workspace_id": workspace.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
]
|
|
for (key, value) in extras {
|
|
payload[key] = value
|
|
}
|
|
result = .ok(payload)
|
|
}
|
|
|
|
switch action {
|
|
case "pin":
|
|
tabManager.setPinned(workspace, pinned: true)
|
|
finish(["pinned": true])
|
|
|
|
case "unpin":
|
|
tabManager.setPinned(workspace, pinned: false)
|
|
finish(["pinned": false])
|
|
|
|
case "rename":
|
|
guard let titleRaw = v2String(params, "title"),
|
|
!titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
result = .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
|
|
return
|
|
}
|
|
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
tabManager.setCustomTitle(tabId: workspace.id, title: title)
|
|
finish(["title": title])
|
|
|
|
case "clear_name":
|
|
tabManager.clearCustomTitle(tabId: workspace.id)
|
|
finish(["title": workspace.title])
|
|
|
|
case "move_up":
|
|
guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
_ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: max(currentIndex - 1, 0))
|
|
finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))])
|
|
|
|
case "move_down":
|
|
guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
_ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: min(currentIndex + 1, tabManager.tabs.count - 1))
|
|
finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))])
|
|
|
|
case "move_top":
|
|
tabManager.moveTabToTop(workspace.id)
|
|
finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))])
|
|
|
|
case "close_others":
|
|
let candidates = tabManager.tabs.filter { $0.id != workspace.id && !$0.isPinned }
|
|
let closed = closeWorkspaces(candidates)
|
|
finish(["closed": closed])
|
|
|
|
case "close_above":
|
|
guard let index = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
let candidates = Array(tabManager.tabs.prefix(index)).filter { !$0.isPinned }
|
|
let closed = closeWorkspaces(candidates)
|
|
finish(["closed": closed])
|
|
|
|
case "close_below":
|
|
guard let index = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
let candidates: [Workspace]
|
|
if index + 1 < tabManager.tabs.count {
|
|
candidates = Array(tabManager.tabs.suffix(from: index + 1)).filter { !$0.isPinned }
|
|
} else {
|
|
candidates = []
|
|
}
|
|
let closed = closeWorkspaces(candidates)
|
|
finish(["closed": closed])
|
|
|
|
case "mark_read":
|
|
AppDelegate.shared?.notificationStore?.markRead(forTabId: workspace.id)
|
|
finish()
|
|
|
|
case "mark_unread":
|
|
AppDelegate.shared?.notificationStore?.markUnread(forTabId: workspace.id)
|
|
finish()
|
|
|
|
default:
|
|
result = .err(code: "invalid_params", message: "Unknown workspace action", data: [
|
|
"action": action,
|
|
"supported_actions": supportedActions
|
|
])
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private func v2TabAction(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let action = v2ActionKey(params) else {
|
|
return .err(code: "invalid_params", message: "Missing action", data: nil)
|
|
}
|
|
|
|
let supportedActions = [
|
|
"rename", "clear_name",
|
|
"close_left", "close_right", "close_others",
|
|
"new_terminal_right", "new_browser_right",
|
|
"reload", "duplicate",
|
|
"pin", "unpin", "mark_read", "mark_unread"
|
|
]
|
|
|
|
var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [
|
|
"action": action,
|
|
"supported_actions": supportedActions
|
|
])
|
|
|
|
v2MainSync {
|
|
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
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 {
|
|
result = .err(code: "not_found", message: "No focused tab", data: nil)
|
|
return
|
|
}
|
|
guard workspace.panels[surfaceId] != nil else {
|
|
result = .err(code: "not_found", message: "Tab not found", data: [
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
|
|
"tab_id": surfaceId.uuidString,
|
|
"tab_ref": v2TabRef(uuid: surfaceId)
|
|
])
|
|
return
|
|
}
|
|
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
|
|
@MainActor
|
|
func finish(_ extras: [String: Any] = [:]) {
|
|
var payload: [String: Any] = [
|
|
"action": action,
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"workspace_id": workspace.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
|
|
"tab_id": surfaceId.uuidString,
|
|
"tab_ref": v2TabRef(uuid: surfaceId)
|
|
]
|
|
if let paneId = workspace.paneId(forPanelId: surfaceId)?.id {
|
|
payload["pane_id"] = paneId.uuidString
|
|
payload["pane_ref"] = v2Ref(kind: .pane, uuid: paneId)
|
|
} else {
|
|
payload["pane_id"] = NSNull()
|
|
payload["pane_ref"] = NSNull()
|
|
}
|
|
for (key, value) in extras {
|
|
payload[key] = value
|
|
}
|
|
result = .ok(payload)
|
|
}
|
|
|
|
@MainActor
|
|
func insertionIndexToRight(anchorTabId: TabID, inPane paneId: PaneID) -> Int {
|
|
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
|
|
guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count }
|
|
let pinnedCount = tabs.reduce(into: 0) { count, tab in
|
|
if let panelId = workspace.panelIdFromSurfaceId(tab.id),
|
|
workspace.isPanelPinned(panelId) {
|
|
count += 1
|
|
}
|
|
}
|
|
let rawTarget = min(anchorIndex + 1, tabs.count)
|
|
return max(rawTarget, pinnedCount)
|
|
}
|
|
|
|
@MainActor
|
|
func closeTabs(_ tabIds: [TabID]) -> (closed: Int, skippedPinned: Int) {
|
|
var closed = 0
|
|
var skippedPinned = 0
|
|
for tabId in tabIds {
|
|
guard let panelId = workspace.panelIdFromSurfaceId(tabId) else { continue }
|
|
if workspace.isPanelPinned(panelId) {
|
|
skippedPinned += 1
|
|
continue
|
|
}
|
|
if workspace.panels.count <= 1 {
|
|
break
|
|
}
|
|
if workspace.closePanel(panelId, force: true) {
|
|
closed += 1
|
|
}
|
|
}
|
|
return (closed, skippedPinned)
|
|
}
|
|
|
|
switch action {
|
|
case "rename":
|
|
guard let titleRaw = v2String(params, "title"),
|
|
!titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
result = .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
|
|
return
|
|
}
|
|
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
workspace.setPanelCustomTitle(panelId: surfaceId, title: title)
|
|
finish(["title": title])
|
|
|
|
case "clear_name":
|
|
workspace.setPanelCustomTitle(panelId: surfaceId, title: nil)
|
|
finish()
|
|
|
|
case "pin":
|
|
workspace.setPanelPinned(panelId: surfaceId, pinned: true)
|
|
finish(["pinned": true])
|
|
|
|
case "unpin":
|
|
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()
|
|
|
|
case "reload", "reload_tab":
|
|
guard let browserPanel = workspace.browserPanel(for: surfaceId) else {
|
|
result = .err(code: "invalid_state", message: "Reload is only available for browser tabs", data: nil)
|
|
return
|
|
}
|
|
browserPanel.reload()
|
|
finish()
|
|
|
|
case "duplicate", "duplicate_tab":
|
|
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
|
let paneId = workspace.paneId(forPanelId: surfaceId),
|
|
let browserPanel = workspace.browserPanel(for: surfaceId) else {
|
|
result = .err(code: "invalid_state", message: "Duplicate is only available for browser tabs", data: nil)
|
|
return
|
|
}
|
|
|
|
let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId)
|
|
guard let newPanel = workspace.newBrowserSurface(
|
|
inPane: paneId,
|
|
url: browserPanel.currentURL,
|
|
focus: allowFocusMutation
|
|
) else {
|
|
result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil)
|
|
return
|
|
}
|
|
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
|
finish([
|
|
"created_surface_id": newPanel.id.uuidString,
|
|
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id),
|
|
"created_tab_id": newPanel.id.uuidString,
|
|
"created_tab_ref": v2TabRef(uuid: newPanel.id)
|
|
])
|
|
|
|
case "new_terminal_right", "new_terminal_to_right", "new_terminal_tab_to_right":
|
|
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
|
let paneId = workspace.paneId(forPanelId: surfaceId) else {
|
|
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
|
|
return
|
|
}
|
|
|
|
let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId)
|
|
guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: allowFocusMutation) else {
|
|
result = .err(code: "internal_error", message: "Failed to create tab", data: nil)
|
|
return
|
|
}
|
|
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
|
finish([
|
|
"created_surface_id": newPanel.id.uuidString,
|
|
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id),
|
|
"created_tab_id": newPanel.id.uuidString,
|
|
"created_tab_ref": v2TabRef(uuid: newPanel.id)
|
|
])
|
|
|
|
case "new_browser_right", "new_browser_to_right", "new_browser_tab_to_right":
|
|
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
|
let paneId = workspace.paneId(forPanelId: surfaceId) else {
|
|
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
|
|
return
|
|
}
|
|
|
|
let urlRaw = v2String(params, "url")
|
|
let url = urlRaw.flatMap { URL(string: $0) }
|
|
if urlRaw != nil && url == nil {
|
|
result = .err(code: "invalid_params", message: "Invalid URL", data: ["url": v2OrNull(urlRaw)])
|
|
return
|
|
}
|
|
|
|
let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId)
|
|
guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: allowFocusMutation) else {
|
|
result = .err(code: "internal_error", message: "Failed to create tab", data: nil)
|
|
return
|
|
}
|
|
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
|
finish([
|
|
"created_surface_id": newPanel.id.uuidString,
|
|
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id),
|
|
"created_tab_id": newPanel.id.uuidString,
|
|
"created_tab_ref": v2TabRef(uuid: newPanel.id)
|
|
])
|
|
|
|
case "close_left", "close_to_left":
|
|
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
|
let paneId = workspace.paneId(forPanelId: surfaceId) else {
|
|
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
|
|
return
|
|
}
|
|
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
|
|
guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else {
|
|
result = .err(code: "not_found", message: "Tab not found in pane", data: nil)
|
|
return
|
|
}
|
|
let targetIds = Array(tabs.prefix(index).map(\.id))
|
|
let closeResult = closeTabs(targetIds)
|
|
finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned])
|
|
|
|
case "close_right", "close_to_right":
|
|
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
|
let paneId = workspace.paneId(forPanelId: surfaceId) else {
|
|
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
|
|
return
|
|
}
|
|
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
|
|
guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else {
|
|
result = .err(code: "not_found", message: "Tab not found in pane", data: nil)
|
|
return
|
|
}
|
|
let targetIds = (index + 1 < tabs.count) ? Array(tabs.suffix(from: index + 1).map(\.id)) : []
|
|
let closeResult = closeTabs(targetIds)
|
|
finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned])
|
|
|
|
case "close_others", "close_other_tabs":
|
|
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
|
let paneId = workspace.paneId(forPanelId: surfaceId) else {
|
|
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
|
|
return
|
|
}
|
|
let targetIds = workspace.bonsplitController.tabs(inPane: paneId)
|
|
.map(\.id)
|
|
.filter { $0 != anchorTabId }
|
|
let closeResult = closeTabs(targetIds)
|
|
finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned])
|
|
|
|
default:
|
|
result = .err(code: "invalid_params", message: "Unknown tab action", data: [
|
|
"action": action,
|
|
"supported_actions": supportedActions
|
|
])
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// MARK: - V2 Surface Methods
|
|
|
|
private func v2ResolveWorkspace(params: [String: Any], tabManager: TabManager) -> Workspace? {
|
|
if let wsId = v2UUID(params, "workspace_id") {
|
|
return tabManager.tabs.first(where: { $0.id == wsId })
|
|
}
|
|
if let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") {
|
|
return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil })
|
|
}
|
|
guard let wsId = tabManager.selectedTabId else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == wsId })
|
|
}
|
|
|
|
private func v2SurfaceList(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var payload: [String: Any]?
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
|
|
|
|
// Map panel_id -> pane_id and index/selection within that pane.
|
|
var paneByPanelId: [UUID: UUID] = [:]
|
|
var indexInPaneByPanelId: [UUID: Int] = [:]
|
|
var selectedInPaneByPanelId: [UUID: Bool] = [:]
|
|
for paneId in ws.bonsplitController.allPaneIds {
|
|
let tabs = ws.bonsplitController.tabs(inPane: paneId)
|
|
let selected = ws.bonsplitController.selectedTab(inPane: paneId)
|
|
for (idx, tab) in tabs.enumerated() {
|
|
guard let panelId = ws.panelIdFromSurfaceId(tab.id) else { continue }
|
|
paneByPanelId[panelId] = paneId.id
|
|
indexInPaneByPanelId[panelId] = idx
|
|
selectedInPaneByPanelId[panelId] = (tab.id == selected?.id)
|
|
}
|
|
}
|
|
|
|
let focusedSurfaceId = ws.focusedPanelId
|
|
let panels = orderedPanels(in: ws)
|
|
let surfaces: [[String: Any]] = panels.enumerated().map { index, panel in
|
|
let paneUUID = paneByPanelId[panel.id]
|
|
var item: [String: Any] = [
|
|
"id": panel.id.uuidString,
|
|
"ref": v2Ref(kind: .surface, uuid: panel.id),
|
|
"index": index,
|
|
"type": panel.panelType.rawValue,
|
|
"title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle,
|
|
"focused": panel.id == focusedSurfaceId,
|
|
"pane_id": v2OrNull(paneUUID?.uuidString),
|
|
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
|
|
"index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]),
|
|
"selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id])
|
|
]
|
|
return item
|
|
}
|
|
|
|
payload = [
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"surfaces": surfaces
|
|
]
|
|
}
|
|
|
|
guard let payload else {
|
|
return .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
}
|
|
var out = payload
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
out["window_id"] = v2OrNull(windowId?.uuidString)
|
|
out["window_ref"] = v2Ref(kind: .window, uuid: windowId)
|
|
return .ok(out)
|
|
}
|
|
|
|
private func v2SurfaceCurrent(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var payload: [String: Any]?
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
|
|
|
|
// Focus can be transiently nil during startup/reparenting; fall back to first
|
|
// ordered panel so callers always get a usable current surface.
|
|
let surfaceId = ws.focusedPanelId ?? orderedPanels(in: ws).first?.id
|
|
let paneId = surfaceId.flatMap { ws.paneId(forPanelId: $0)?.id }
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
|
|
payload = [
|
|
"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": v2OrNull(paneId?.uuidString),
|
|
"pane_ref": v2Ref(kind: .pane, uuid: paneId),
|
|
"surface_id": v2OrNull(surfaceId?.uuidString),
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
|
|
"surface_type": v2OrNull(surfaceId.flatMap { ws.panels[$0]?.panelType.rawValue })
|
|
]
|
|
}
|
|
|
|
guard let payload else {
|
|
return .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
}
|
|
return .ok(payload)
|
|
}
|
|
|
|
private func v2SurfaceFocus(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let surfaceId = v2UUID(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
v2MaybeSelectWorkspace(tabManager, workspace: ws)
|
|
|
|
guard ws.panels[surfaceId] != nil else {
|
|
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
|
|
ws.focusPanel(surfaceId)
|
|
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
|
|
}
|
|
|
|
private func v2SurfaceSplit(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let directionStr = v2String(params, "direction"),
|
|
let direction = parseSplitDirection(directionStr) else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid direction (left|right|up|down)", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create split", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
v2MaybeSelectWorkspace(tabManager, workspace: ws)
|
|
|
|
let targetSurfaceId: UUID? = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
|
guard let targetSurfaceId else {
|
|
result = .err(code: "not_found", message: "No focused surface", data: nil)
|
|
return
|
|
}
|
|
guard ws.panels[targetSurfaceId] != nil else {
|
|
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": targetSurfaceId.uuidString])
|
|
return
|
|
}
|
|
|
|
if let newId = tabManager.newSplit(
|
|
tabId: ws.id,
|
|
surfaceId: targetSurfaceId,
|
|
direction: direction,
|
|
focus: v2FocusAllowed()
|
|
) {
|
|
let paneUUID = ws.paneId(forPanelId: newId)?.id
|
|
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": v2OrNull(paneUUID?.uuidString),
|
|
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
|
|
"surface_id": newId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: newId),
|
|
"type": v2OrNull(ws.panels[newId]?.panelType.rawValue)
|
|
])
|
|
} else {
|
|
result = .err(code: "internal_error", message: "Failed to create split", data: nil)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
private func v2SurfaceCreate(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
let panelType = v2PanelType(params, "type") ?? .terminal
|
|
let urlStr = v2String(params, "url")
|
|
let url = urlStr.flatMap { URL(string: $0) }
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create surface", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
v2MaybeSelectWorkspace(tabManager, workspace: ws)
|
|
|
|
let paneUUID = v2UUID(params, "pane_id")
|
|
let paneId: PaneID? = {
|
|
if let paneUUID {
|
|
return ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID })
|
|
}
|
|
return ws.bonsplitController.focusedPaneId
|
|
}()
|
|
|
|
guard let paneId else {
|
|
result = .err(code: "not_found", message: "Pane not found", data: nil)
|
|
return
|
|
}
|
|
|
|
let newPanelId: UUID?
|
|
if panelType == .browser {
|
|
newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: v2FocusAllowed())?.id
|
|
} else {
|
|
newPanelId = ws.newTerminalSurface(inPane: paneId, focus: v2FocusAllowed())?.id
|
|
}
|
|
|
|
guard let newPanelId else {
|
|
result = .err(code: "internal_error", message: "Failed to create surface", data: nil)
|
|
return
|
|
}
|
|
|
|
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),
|
|
"surface_id": newPanelId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: newPanelId),
|
|
"type": panelType.rawValue
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2SurfaceClose(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to close surface", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
|
|
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
|
guard let surfaceId else {
|
|
result = .err(code: "not_found", message: "No focused surface", data: nil)
|
|
return
|
|
}
|
|
|
|
guard ws.panels[surfaceId] != nil else {
|
|
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
|
|
if ws.panels.count <= 1 {
|
|
result = .err(code: "invalid_state", message: "Cannot close the last surface", data: nil)
|
|
return
|
|
}
|
|
|
|
// Socket API must be non-interactive: bypass close-confirmation gating.
|
|
ws.closePanel(surfaceId, force: true)
|
|
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
|
|
}
|
|
|
|
private func v2SurfaceDragToSplit(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let surfaceId = v2UUID(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
|
|
}
|
|
guard let directionStr = v2String(params, "direction"),
|
|
let direction = parseSplitDirection(directionStr) else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid direction (left|right|up|down)", data: nil)
|
|
}
|
|
|
|
let orientation: SplitOrientation = direction.isHorizontal ? .horizontal : .vertical
|
|
let insertFirst = (direction == .left || direction == .up)
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to move surface", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
guard let bonsplitTabId = ws.surfaceIdFromPanelId(surfaceId) else {
|
|
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
guard let newPaneId = ws.bonsplitController.splitPane(
|
|
orientation: orientation,
|
|
movingTab: bonsplitTabId,
|
|
insertFirst: insertFirst
|
|
) else {
|
|
result = .err(code: "internal_error", message: "Failed to split pane", data: nil)
|
|
return
|
|
}
|
|
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),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
|
|
"pane_id": newPaneId.id.uuidString,
|
|
"pane_ref": v2Ref(kind: .pane, uuid: newPaneId.id)
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2SurfaceMove(params: [String: Any]) -> V2CallResult {
|
|
guard let surfaceId = v2UUID(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
|
|
}
|
|
|
|
let requestedPaneUUID = v2UUID(params, "pane_id")
|
|
let requestedWorkspaceUUID = v2UUID(params, "workspace_id")
|
|
let requestedWindowUUID = v2UUID(params, "window_id")
|
|
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 anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0)
|
|
if anchorCount > 1 {
|
|
return .err(code: "invalid_params", message: "Specify at most one of before_surface_id or after_surface_id", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to move surface", data: nil)
|
|
v2MainSync {
|
|
guard let app = AppDelegate.shared else {
|
|
result = .err(code: "unavailable", message: "AppDelegate not available", data: nil)
|
|
return
|
|
}
|
|
|
|
guard let source = app.locateSurface(surfaceId: surfaceId),
|
|
let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else {
|
|
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
|
|
let sourcePane = sourceWorkspace.paneId(forPanelId: surfaceId)
|
|
let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId)
|
|
|
|
var targetWindowId = source.windowId
|
|
var targetTabManager = source.tabManager
|
|
var targetWorkspace = sourceWorkspace
|
|
var targetPane = sourcePane ?? sourceWorkspace.bonsplitController.focusedPaneId ?? sourceWorkspace.bonsplitController.allPaneIds.first
|
|
var targetIndex = explicitIndex
|
|
|
|
if let anchorSurfaceId = beforeSurfaceId ?? afterSurfaceId {
|
|
guard let anchor = app.locateSurface(surfaceId: anchorSurfaceId),
|
|
let anchorWorkspace = anchor.tabManager.tabs.first(where: { $0.id == anchor.workspaceId }),
|
|
let anchorPane = anchorWorkspace.paneId(forPanelId: anchorSurfaceId),
|
|
let anchorIndex = anchorWorkspace.indexInPane(forPanelId: anchorSurfaceId) else {
|
|
result = .err(code: "not_found", message: "Anchor surface not found", data: ["surface_id": anchorSurfaceId.uuidString])
|
|
return
|
|
}
|
|
targetWindowId = anchor.windowId
|
|
targetTabManager = anchor.tabManager
|
|
targetWorkspace = anchorWorkspace
|
|
targetPane = anchorPane
|
|
targetIndex = (beforeSurfaceId != nil) ? anchorIndex : (anchorIndex + 1)
|
|
} else if let paneUUID = requestedPaneUUID {
|
|
guard let located = v2LocatePane(paneUUID) else {
|
|
result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString])
|
|
return
|
|
}
|
|
targetWindowId = located.windowId
|
|
targetTabManager = located.tabManager
|
|
targetWorkspace = located.workspace
|
|
targetPane = located.paneId
|
|
} else if let workspaceUUID = requestedWorkspaceUUID {
|
|
guard let tm = app.tabManagerFor(tabId: workspaceUUID),
|
|
let ws = tm.tabs.first(where: { $0.id == workspaceUUID }) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": workspaceUUID.uuidString])
|
|
return
|
|
}
|
|
targetTabManager = tm
|
|
targetWorkspace = ws
|
|
targetWindowId = app.windowId(for: tm) ?? targetWindowId
|
|
targetPane = ws.bonsplitController.focusedPaneId ?? ws.bonsplitController.allPaneIds.first
|
|
} else if let windowUUID = requestedWindowUUID {
|
|
guard let tm = app.tabManagerFor(windowId: windowUUID) else {
|
|
result = .err(code: "not_found", message: "Window not found", data: ["window_id": windowUUID.uuidString])
|
|
return
|
|
}
|
|
targetWindowId = windowUUID
|
|
targetTabManager = tm
|
|
guard let selectedWorkspaceId = tm.selectedTabId,
|
|
let ws = tm.tabs.first(where: { $0.id == selectedWorkspaceId }) else {
|
|
result = .err(code: "not_found", message: "Target window has no selected workspace", data: ["window_id": windowUUID.uuidString])
|
|
return
|
|
}
|
|
targetWorkspace = ws
|
|
targetPane = ws.bonsplitController.focusedPaneId ?? ws.bonsplitController.allPaneIds.first
|
|
}
|
|
|
|
guard let destinationPane = targetPane else {
|
|
result = .err(code: "not_found", message: "No destination pane", data: nil)
|
|
return
|
|
}
|
|
|
|
if targetWorkspace.id == sourceWorkspace.id {
|
|
guard sourceWorkspace.moveSurface(panelId: surfaceId, toPane: destinationPane, atIndex: targetIndex, focus: focus) else {
|
|
result = .err(code: "internal_error", message: "Failed to move surface", data: nil)
|
|
return
|
|
}
|
|
result = .ok([
|
|
"window_id": targetWindowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: targetWindowId),
|
|
"workspace_id": targetWorkspace.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: targetWorkspace.id),
|
|
"pane_id": destinationPane.id.uuidString,
|
|
"pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
|
|
])
|
|
return
|
|
}
|
|
|
|
guard let transfer = sourceWorkspace.detachSurface(panelId: surfaceId) else {
|
|
result = .err(code: "internal_error", message: "Failed to detach surface", data: nil)
|
|
return
|
|
}
|
|
|
|
if targetWorkspace.attachDetachedSurface(transfer, inPane: destinationPane, atIndex: targetIndex, focus: focus) == nil {
|
|
// Roll back to source workspace if attach fails.
|
|
let rollbackPane = sourcePane.flatMap { sp in sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0 == sp }) }
|
|
?? sourceWorkspace.bonsplitController.focusedPaneId
|
|
?? sourceWorkspace.bonsplitController.allPaneIds.first
|
|
if let rollbackPane {
|
|
_ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: focus)
|
|
}
|
|
result = .err(code: "internal_error", message: "Failed to attach surface to destination", data: nil)
|
|
return
|
|
}
|
|
|
|
if focus {
|
|
v2MaybeFocusWindow(for: targetTabManager)
|
|
v2MaybeSelectWorkspace(targetTabManager, workspace: targetWorkspace)
|
|
}
|
|
|
|
result = .ok([
|
|
"window_id": targetWindowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: targetWindowId),
|
|
"workspace_id": targetWorkspace.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: targetWorkspace.id),
|
|
"pane_id": destinationPane.id.uuidString,
|
|
"pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
|
|
])
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private func v2SurfaceReorder(params: [String: Any]) -> V2CallResult {
|
|
guard let surfaceId = v2UUID(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
|
|
}
|
|
|
|
let index = v2Int(params, "index")
|
|
let beforeSurfaceId = v2UUID(params, "before_surface_id")
|
|
let afterSurfaceId = v2UUID(params, "after_surface_id")
|
|
let targetCount = (index != nil ? 1 : 0) + (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0)
|
|
if targetCount != 1 {
|
|
return .err(code: "invalid_params", message: "Specify exactly one of index, before_surface_id, or after_surface_id", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to reorder surface", data: nil)
|
|
v2MainSync {
|
|
guard let app = AppDelegate.shared,
|
|
let located = app.locateSurface(surfaceId: surfaceId),
|
|
let ws = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }),
|
|
let sourcePane = ws.paneId(forPanelId: surfaceId) else {
|
|
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
|
|
let targetIndex: Int
|
|
if let index {
|
|
targetIndex = index
|
|
} else if let beforeSurfaceId {
|
|
guard let anchorPane = ws.paneId(forPanelId: beforeSurfaceId),
|
|
anchorPane == sourcePane,
|
|
let anchorIndex = ws.indexInPane(forPanelId: beforeSurfaceId) else {
|
|
result = .err(code: "invalid_params", message: "Anchor surface must be in the same pane", data: nil)
|
|
return
|
|
}
|
|
targetIndex = anchorIndex
|
|
} else if let afterSurfaceId {
|
|
guard let anchorPane = ws.paneId(forPanelId: afterSurfaceId),
|
|
anchorPane == sourcePane,
|
|
let anchorIndex = ws.indexInPane(forPanelId: afterSurfaceId) else {
|
|
result = .err(code: "invalid_params", message: "Anchor surface must be in the same pane", data: nil)
|
|
return
|
|
}
|
|
targetIndex = anchorIndex + 1
|
|
} else {
|
|
result = .err(code: "invalid_params", message: "Missing reorder target", data: nil)
|
|
return
|
|
}
|
|
|
|
guard ws.reorderSurface(panelId: surfaceId, toIndex: targetIndex) else {
|
|
result = .err(code: "internal_error", message: "Failed to reorder surface", data: nil)
|
|
return
|
|
}
|
|
|
|
result = .ok([
|
|
"window_id": located.windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: located.windowId),
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"pane_id": sourcePane.id.uuidString,
|
|
"pane_ref": v2Ref(kind: .pane, uuid: sourcePane.id),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
|
|
])
|
|
}
|
|
|
|
return result
|
|
}
|
|
private func v2SurfaceRefresh(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
var result: V2CallResult = .ok(["refreshed": 0])
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
var refreshedCount = 0
|
|
for panel in ws.panels.values {
|
|
if let terminalPanel = panel as? TerminalPanel {
|
|
terminalPanel.surface.forceRefresh()
|
|
refreshedCount += 1
|
|
}
|
|
}
|
|
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), "refreshed": refreshedCount])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2SurfaceHealth(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var payload: [String: Any]?
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
|
|
let panels = orderedPanels(in: ws)
|
|
let items: [[String: Any]] = panels.enumerated().map { index, panel in
|
|
var inWindow: Any = NSNull()
|
|
if let tp = panel as? TerminalPanel {
|
|
inWindow = tp.surface.isViewInWindow
|
|
} else if let bp = panel as? BrowserPanel {
|
|
inWindow = bp.webView.window != nil
|
|
}
|
|
return [
|
|
"index": index,
|
|
"id": panel.id.uuidString,
|
|
"ref": v2Ref(kind: .surface, uuid: panel.id),
|
|
"type": panel.panelType.rawValue,
|
|
"in_window": inWindow
|
|
]
|
|
}
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
payload = [
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"surfaces": items,
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
]
|
|
}
|
|
|
|
guard let payload else {
|
|
return .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
}
|
|
return .ok(payload)
|
|
}
|
|
|
|
private func v2SurfaceSendText(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let text = params["text"] as? String else {
|
|
return .err(code: "invalid_params", message: "Missing text", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to send text", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
|
guard let surfaceId else {
|
|
result = .err(code: "not_found", message: "No focused surface", data: nil)
|
|
return
|
|
}
|
|
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
|
|
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
|
|
}
|
|
#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))
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2SurfaceSendKey(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let key = v2String(params, "key") else {
|
|
return .err(code: "invalid_params", message: "Missing key", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to send key", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
|
guard let surfaceId else {
|
|
result = .err(code: "not_found", message: "No focused surface", data: nil)
|
|
return
|
|
}
|
|
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
|
|
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
guard let surface = terminalPanel.surface.surface else {
|
|
result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
guard sendNamedKey(surface, keyName: key) else {
|
|
result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key])
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
private func v2SurfaceClearHistory(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear history", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
|
guard let surfaceId else {
|
|
result = .err(code: "not_found", message: "No focused surface", data: nil)
|
|
return
|
|
}
|
|
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
|
|
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
|
|
guard terminalPanel.performBindingAction("clear_screen") else {
|
|
result = .err(code: "not_supported", message: "clear_screen binding action is unavailable", data: nil)
|
|
return
|
|
}
|
|
|
|
terminalPanel.surface.forceRefresh()
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
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(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private func v2SurfaceReadText(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var includeScrollback = v2Bool(params, "scrollback") ?? false
|
|
let lineLimit = v2Int(params, "lines")
|
|
if let lineLimit, lineLimit <= 0 {
|
|
return .err(code: "invalid_params", message: "lines must be greater than 0", data: nil)
|
|
}
|
|
if lineLimit != nil {
|
|
includeScrollback = true
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to read terminal text", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
|
|
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
|
guard let surfaceId else {
|
|
result = .err(code: "not_found", message: "No focused surface", data: nil)
|
|
return
|
|
}
|
|
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
|
|
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
|
|
let response = readTerminalTextBase64(
|
|
terminalPanel: terminalPanel,
|
|
includeScrollback: includeScrollback,
|
|
lineLimit: lineLimit
|
|
)
|
|
guard response.hasPrefix("OK ") else {
|
|
result = .err(code: "internal_error", message: response, data: nil)
|
|
return
|
|
}
|
|
let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let decoded = Data(base64Encoded: base64).flatMap { String(data: $0, encoding: .utf8) }
|
|
guard let text = decoded ?? (base64.isEmpty ? "" : nil) else {
|
|
result = .err(code: "internal_error", message: "Failed to decode terminal text", data: nil)
|
|
return
|
|
}
|
|
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
result = .ok([
|
|
"text": text,
|
|
"base64": base64,
|
|
"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(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func readTerminalTextBase64(terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String {
|
|
guard let surface = terminalPanel.surface.surface else { return "ERROR: Terminal surface not found" }
|
|
|
|
let pointTag: ghostty_point_tag_e = includeScrollback ? GHOSTTY_POINT_SCREEN : GHOSTTY_POINT_VIEWPORT
|
|
let topLeft = ghostty_point_s(
|
|
tag: pointTag,
|
|
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
|
|
x: 0,
|
|
y: 0
|
|
)
|
|
let bottomRight = ghostty_point_s(
|
|
tag: pointTag,
|
|
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
|
|
x: 0,
|
|
y: 0
|
|
)
|
|
let selection = ghostty_selection_s(
|
|
top_left: topLeft,
|
|
bottom_right: bottomRight,
|
|
rectangle: true
|
|
)
|
|
var text = ghostty_text_s()
|
|
|
|
guard ghostty_surface_read_text(surface, selection, &text) else {
|
|
return "ERROR: Failed to read terminal text"
|
|
}
|
|
defer {
|
|
ghostty_surface_free_text(surface, &text)
|
|
}
|
|
|
|
let rawData: Data
|
|
if let ptr = text.text, text.text_len > 0 {
|
|
rawData = Data(bytes: ptr, count: Int(text.text_len))
|
|
} else {
|
|
rawData = Data()
|
|
}
|
|
|
|
var output = String(decoding: rawData, as: UTF8.self)
|
|
if let lineLimit {
|
|
output = tailTerminalLines(output, maxLines: lineLimit)
|
|
}
|
|
|
|
let base64 = output.data(using: .utf8)?.base64EncodedString() ?? ""
|
|
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)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to trigger flash", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
|
|
// Only explicit focus-intent commands may mutate selection state.
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
v2MaybeSelectWorkspace(tabManager, workspace: ws)
|
|
|
|
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
|
guard let surfaceId else {
|
|
result = .err(code: "not_found", message: "No focused surface", data: nil)
|
|
return
|
|
}
|
|
guard ws.panels[surfaceId] != nil else {
|
|
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
|
|
ws.triggerFocusFlash(panelId: surfaceId)
|
|
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
|
|
}
|
|
|
|
// MARK: - V2 Pane Methods
|
|
|
|
private func v2PaneList(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var payload: [String: Any]?
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
|
|
|
|
let focusedPaneId = ws.bonsplitController.focusedPaneId
|
|
let panes: [[String: Any]] = ws.bonsplitController.allPaneIds.enumerated().map { index, paneId in
|
|
let tabs = ws.bonsplitController.tabs(inPane: paneId)
|
|
let surfaceUUIDs: [UUID] = tabs.compactMap { ws.panelIdFromSurfaceId($0.id) }
|
|
let selectedTab = ws.bonsplitController.selectedTab(inPane: paneId)
|
|
let selectedSurfaceUUID = selectedTab.flatMap { ws.panelIdFromSurfaceId($0.id) }
|
|
return [
|
|
"id": paneId.id.uuidString,
|
|
"ref": v2Ref(kind: .pane, uuid: paneId.id),
|
|
"index": index,
|
|
"focused": paneId == focusedPaneId,
|
|
"surface_ids": surfaceUUIDs.map { $0.uuidString },
|
|
"surface_refs": surfaceUUIDs.map { v2Ref(kind: .surface, uuid: $0) },
|
|
"selected_surface_id": v2OrNull(selectedSurfaceUUID?.uuidString),
|
|
"selected_surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceUUID),
|
|
"surface_count": surfaceUUIDs.count
|
|
]
|
|
}
|
|
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
payload = [
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"panes": panes,
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
]
|
|
}
|
|
|
|
guard let payload else {
|
|
return .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
}
|
|
return .ok(payload)
|
|
}
|
|
private func v2PaneFocus(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let paneUUID = v2UUID(params, "pane_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString])
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
guard let paneId = ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) else {
|
|
result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString])
|
|
return
|
|
}
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
v2MaybeSelectWorkspace(tabManager, workspace: 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)])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2PaneSurfaces(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var payload: [String: Any]?
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
|
|
|
|
let paneUUID = v2UUID(params, "pane_id")
|
|
let paneId: PaneID? = {
|
|
if let paneUUID {
|
|
return ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID })
|
|
}
|
|
return ws.bonsplitController.focusedPaneId
|
|
}()
|
|
guard let paneId else { return }
|
|
|
|
let selectedTab = ws.bonsplitController.selectedTab(inPane: paneId)
|
|
let tabs = ws.bonsplitController.tabs(inPane: paneId)
|
|
|
|
let surfaces: [[String: Any]] = tabs.enumerated().map { index, tab in
|
|
let panelId = ws.panelIdFromSurfaceId(tab.id)
|
|
let panel = panelId.flatMap { ws.panels[$0] }
|
|
return [
|
|
"id": v2OrNull(panelId?.uuidString),
|
|
"ref": v2Ref(kind: .surface, uuid: panelId),
|
|
"index": index,
|
|
"title": tab.title,
|
|
"type": v2OrNull(panel?.panelType.rawValue),
|
|
"selected": tab.id == selectedTab?.id
|
|
]
|
|
}
|
|
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
payload = [
|
|
"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),
|
|
"surfaces": surfaces,
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
|
]
|
|
}
|
|
|
|
guard let payload else {
|
|
return .err(code: "not_found", message: "Pane or workspace not found", data: nil)
|
|
}
|
|
return .ok(payload)
|
|
}
|
|
private func v2PaneCreate(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let directionStr = v2String(params, "direction"),
|
|
let direction = parseSplitDirection(directionStr) else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid direction (left|right|up|down)", data: nil)
|
|
}
|
|
|
|
let panelType = v2PanelType(params, "type") ?? .terminal
|
|
let urlStr = v2String(params, "url")
|
|
let url = urlStr.flatMap { URL(string: $0) }
|
|
|
|
let orientation = direction.orientation
|
|
let insertFirst = direction.insertFirst
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create pane", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
v2MaybeSelectWorkspace(tabManager, workspace: ws)
|
|
guard let focusedPanelId = ws.focusedPanelId else {
|
|
result = .err(code: "not_found", message: "No focused surface to split", data: nil)
|
|
return
|
|
}
|
|
|
|
let newPanelId: UUID?
|
|
if panelType == .browser {
|
|
newPanelId = ws.newBrowserSplit(
|
|
from: focusedPanelId,
|
|
orientation: orientation,
|
|
insertFirst: insertFirst,
|
|
url: url,
|
|
focus: v2FocusAllowed()
|
|
)?.id
|
|
} else {
|
|
newPanelId = ws.newTerminalSplit(
|
|
from: focusedPanelId,
|
|
orientation: orientation,
|
|
insertFirst: insertFirst,
|
|
focus: v2FocusAllowed()
|
|
)?.id
|
|
}
|
|
|
|
guard let newPanelId else {
|
|
result = .err(code: "internal_error", message: "Failed to create pane", data: nil)
|
|
return
|
|
}
|
|
let paneUUID = ws.paneId(forPanelId: newPanelId)?.id
|
|
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": v2OrNull(paneUUID?.uuidString),
|
|
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
|
|
"surface_id": newPanelId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: newPanelId),
|
|
"type": panelType.rawValue
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private enum V2PaneResizeDirection: String {
|
|
case left
|
|
case right
|
|
case up
|
|
case down
|
|
|
|
var splitOrientation: String {
|
|
switch self {
|
|
case .left, .right:
|
|
return "horizontal"
|
|
case .up, .down:
|
|
return "vertical"
|
|
}
|
|
}
|
|
|
|
/// A split controls the target pane's right/bottom edge when target is first child,
|
|
/// and left/top edge when target is second child.
|
|
var requiresPaneInFirstChild: Bool {
|
|
switch self {
|
|
case .right, .down:
|
|
return true
|
|
case .left, .up:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Positive value moves divider toward second child (right/down).
|
|
var dividerDeltaSign: CGFloat {
|
|
requiresPaneInFirstChild ? 1 : -1
|
|
}
|
|
}
|
|
|
|
private struct V2PaneResizeCandidate {
|
|
let splitId: UUID
|
|
let orientation: String
|
|
let paneInFirstChild: Bool
|
|
let dividerPosition: CGFloat
|
|
let axisPixels: CGFloat
|
|
}
|
|
|
|
private struct V2PaneResizeTrace {
|
|
let containsTarget: Bool
|
|
let bounds: CGRect
|
|
}
|
|
|
|
private func v2PaneResizeCollectCandidates(
|
|
node: ExternalTreeNode,
|
|
targetPaneId: String,
|
|
candidates: inout [V2PaneResizeCandidate]
|
|
) -> V2PaneResizeTrace {
|
|
switch node {
|
|
case .pane(let pane):
|
|
let bounds = CGRect(
|
|
x: pane.frame.x,
|
|
y: pane.frame.y,
|
|
width: pane.frame.width,
|
|
height: pane.frame.height
|
|
)
|
|
return V2PaneResizeTrace(containsTarget: pane.id == targetPaneId, bounds: bounds)
|
|
|
|
case .split(let split):
|
|
let first = v2PaneResizeCollectCandidates(
|
|
node: split.first,
|
|
targetPaneId: targetPaneId,
|
|
candidates: &candidates
|
|
)
|
|
let second = v2PaneResizeCollectCandidates(
|
|
node: split.second,
|
|
targetPaneId: targetPaneId,
|
|
candidates: &candidates
|
|
)
|
|
|
|
let combinedBounds = first.bounds.union(second.bounds)
|
|
let containsTarget = first.containsTarget || second.containsTarget
|
|
|
|
if containsTarget,
|
|
let splitUUID = UUID(uuidString: split.id) {
|
|
let orientation = split.orientation.lowercased()
|
|
let axisPixels: CGFloat = orientation == "horizontal"
|
|
? combinedBounds.width
|
|
: combinedBounds.height
|
|
candidates.append(V2PaneResizeCandidate(
|
|
splitId: splitUUID,
|
|
orientation: orientation,
|
|
paneInFirstChild: first.containsTarget,
|
|
dividerPosition: CGFloat(split.dividerPosition),
|
|
axisPixels: max(axisPixels, 1)
|
|
))
|
|
}
|
|
|
|
return V2PaneResizeTrace(containsTarget: containsTarget, bounds: combinedBounds)
|
|
}
|
|
}
|
|
|
|
private func v2PaneResize(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
let directionRaw = (v2String(params, "direction") ?? "").lowercased()
|
|
let amount = v2Int(params, "amount") ?? 1
|
|
guard let direction = V2PaneResizeDirection(rawValue: directionRaw), amount > 0 else {
|
|
return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to resize pane", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
|
|
let paneUUID = v2UUID(params, "pane_id") ?? ws.bonsplitController.focusedPaneId?.id
|
|
guard let paneUUID else {
|
|
result = .err(code: "not_found", message: "No focused pane", data: nil)
|
|
return
|
|
}
|
|
guard ws.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else {
|
|
result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString])
|
|
return
|
|
}
|
|
|
|
let tree = ws.bonsplitController.treeSnapshot()
|
|
var candidates: [V2PaneResizeCandidate] = []
|
|
let trace = v2PaneResizeCollectCandidates(
|
|
node: tree,
|
|
targetPaneId: paneUUID.uuidString,
|
|
candidates: &candidates
|
|
)
|
|
guard trace.containsTarget else {
|
|
result = .err(code: "not_found", message: "Pane not found in split tree", data: ["pane_id": paneUUID.uuidString])
|
|
return
|
|
}
|
|
|
|
let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation }
|
|
guard !orientationMatches.isEmpty else {
|
|
result = .err(
|
|
code: "invalid_state",
|
|
message: "No \(direction.splitOrientation) split ancestor for pane",
|
|
data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue]
|
|
)
|
|
return
|
|
}
|
|
|
|
guard let candidate = orientationMatches.first(where: { $0.paneInFirstChild == direction.requiresPaneInFirstChild }) else {
|
|
result = .err(
|
|
code: "invalid_state",
|
|
message: "Pane has no adjacent border in direction \(direction.rawValue)",
|
|
data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue]
|
|
)
|
|
return
|
|
}
|
|
|
|
let delta = CGFloat(amount) / candidate.axisPixels
|
|
let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta)
|
|
let clamped = min(max(requested, 0.1), 0.9)
|
|
guard ws.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) else {
|
|
result = .err(
|
|
code: "internal_error",
|
|
message: "Failed to set split divider position",
|
|
data: ["split_id": candidate.splitId.uuidString]
|
|
)
|
|
return
|
|
}
|
|
|
|
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": paneUUID.uuidString,
|
|
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
|
|
"split_id": candidate.splitId.uuidString,
|
|
"direction": direction.rawValue,
|
|
"amount": amount,
|
|
"old_divider_position": candidate.dividerPosition,
|
|
"new_divider_position": clamped
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2PaneSwap(params: [String: Any]) -> V2CallResult {
|
|
guard let sourcePaneUUID = v2UUID(params, "pane_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil)
|
|
}
|
|
guard let targetPaneUUID = v2UUID(params, "target_pane_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil)
|
|
}
|
|
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)
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil)
|
|
v2MainSync {
|
|
guard let located = v2LocatePane(sourcePaneUUID) else {
|
|
result = .err(code: "not_found", message: "Source pane not found", data: ["pane_id": sourcePaneUUID.uuidString])
|
|
return
|
|
}
|
|
guard let targetPane = located.workspace.bonsplitController.allPaneIds.first(where: { $0.id == targetPaneUUID }) else {
|
|
result = .err(code: "not_found", message: "Target pane not found in source workspace", data: ["target_pane_id": targetPaneUUID.uuidString])
|
|
return
|
|
}
|
|
let workspace = located.workspace
|
|
let sourcePane = located.paneId
|
|
|
|
guard let selectedSourceTab = workspace.bonsplitController.selectedTab(inPane: sourcePane),
|
|
let selectedTargetTab = workspace.bonsplitController.selectedTab(inPane: targetPane),
|
|
let sourceSurfaceId = workspace.panelIdFromSurfaceId(selectedSourceTab.id),
|
|
let targetSurfaceId = workspace.panelIdFromSurfaceId(selectedTargetTab.id) else {
|
|
result = .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil)
|
|
return
|
|
}
|
|
|
|
// Keep pane identities stable during swap when one side has a single surface.
|
|
var sourcePlaceholder: UUID?
|
|
var targetPlaceholder: UUID?
|
|
if workspace.bonsplitController.tabs(inPane: sourcePane).count <= 1 {
|
|
sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false)?.id
|
|
if sourcePlaceholder == nil {
|
|
result = .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil)
|
|
return
|
|
}
|
|
}
|
|
if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 {
|
|
targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id
|
|
if targetPlaceholder == nil {
|
|
result = .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
guard workspace.moveSurface(panelId: sourceSurfaceId, toPane: targetPane, focus: false) else {
|
|
result = .err(code: "internal_error", message: "Failed moving source surface into target pane", data: nil)
|
|
return
|
|
}
|
|
guard workspace.moveSurface(panelId: targetSurfaceId, toPane: sourcePane, focus: false) else {
|
|
result = .err(code: "internal_error", message: "Failed moving target surface into source pane", data: nil)
|
|
return
|
|
}
|
|
|
|
if let sourcePlaceholder {
|
|
_ = workspace.closePanel(sourcePlaceholder, force: true)
|
|
}
|
|
if let targetPlaceholder {
|
|
_ = workspace.closePanel(targetPlaceholder, force: true)
|
|
}
|
|
|
|
if focus {
|
|
workspace.bonsplitController.focusPane(targetPane)
|
|
}
|
|
let windowId = located.windowId
|
|
result = .ok([
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"workspace_id": workspace.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
|
|
"pane_id": sourcePane.id.uuidString,
|
|
"pane_ref": v2Ref(kind: .pane, uuid: sourcePane.id),
|
|
"target_pane_id": targetPane.id.uuidString,
|
|
"target_pane_ref": v2Ref(kind: .pane, uuid: targetPane.id),
|
|
"source_surface_id": sourceSurfaceId.uuidString,
|
|
"source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId),
|
|
"target_surface_id": targetSurfaceId.uuidString,
|
|
"target_surface_ref": v2Ref(kind: .surface, uuid: targetSurfaceId)
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2PaneBreak(params: [String: Any]) -> V2CallResult {
|
|
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)
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil)
|
|
v2MainSync {
|
|
guard let sourceWorkspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
|
|
let sourcePaneUUID = v2UUID(params, "pane_id")
|
|
let sourcePane: PaneID? = {
|
|
if let sourcePaneUUID {
|
|
return sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0.id == sourcePaneUUID })
|
|
}
|
|
return sourceWorkspace.bonsplitController.focusedPaneId
|
|
}()
|
|
|
|
let surfaceId: UUID? = {
|
|
if let explicitSurface = v2UUID(params, "surface_id") { return explicitSurface }
|
|
if let sourcePane,
|
|
let selected = sourceWorkspace.bonsplitController.selectedTab(inPane: sourcePane) {
|
|
return sourceWorkspace.panelIdFromSurfaceId(selected.id)
|
|
}
|
|
return sourceWorkspace.focusedPanelId
|
|
}()
|
|
guard let surfaceId else {
|
|
result = .err(code: "not_found", message: "No source surface to break", data: nil)
|
|
return
|
|
}
|
|
guard sourceWorkspace.panels[surfaceId] != nil else {
|
|
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId)
|
|
let sourcePaneForRollback = sourceWorkspace.paneId(forPanelId: surfaceId)
|
|
|
|
guard let detached = sourceWorkspace.detachSurface(panelId: surfaceId) else {
|
|
result = .err(code: "internal_error", message: "Failed to detach source surface", data: nil)
|
|
return
|
|
}
|
|
|
|
let destinationWorkspace = tabManager.addWorkspace(select: focus)
|
|
guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId
|
|
?? destinationWorkspace.bonsplitController.allPaneIds.first else {
|
|
if let sourcePaneForRollback {
|
|
_ = sourceWorkspace.attachDetachedSurface(
|
|
detached,
|
|
inPane: sourcePaneForRollback,
|
|
atIndex: sourceIndex,
|
|
focus: focus
|
|
)
|
|
}
|
|
result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil)
|
|
return
|
|
}
|
|
|
|
guard destinationWorkspace.attachDetachedSurface(detached, inPane: destinationPane, focus: focus) != nil else {
|
|
if let sourcePaneForRollback {
|
|
_ = sourceWorkspace.attachDetachedSurface(
|
|
detached,
|
|
inPane: sourcePaneForRollback,
|
|
atIndex: sourceIndex,
|
|
focus: focus
|
|
)
|
|
}
|
|
result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil)
|
|
return
|
|
}
|
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
|
result = .ok([
|
|
"window_id": v2OrNull(windowId?.uuidString),
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"workspace_id": destinationWorkspace.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: destinationWorkspace.id),
|
|
"pane_id": destinationPane.id.uuidString,
|
|
"pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2PaneJoin(params: [String: Any]) -> V2CallResult {
|
|
guard let targetPaneUUID = v2UUID(params, "target_pane_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil)
|
|
}
|
|
|
|
var surfaceId = v2UUID(params, "surface_id")
|
|
if surfaceId == nil, let sourcePaneUUID = v2UUID(params, "pane_id") {
|
|
guard let sourceLocated = v2LocatePane(sourcePaneUUID),
|
|
let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId),
|
|
let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else {
|
|
return .err(code: "not_found", message: "Unable to resolve selected surface in source pane", data: [
|
|
"pane_id": sourcePaneUUID.uuidString
|
|
])
|
|
}
|
|
surfaceId = selectedSurface
|
|
}
|
|
guard let surfaceId else {
|
|
return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil)
|
|
}
|
|
|
|
var moveParams: [String: Any] = [
|
|
"surface_id": surfaceId.uuidString,
|
|
"pane_id": targetPaneUUID.uuidString
|
|
]
|
|
if let focus = v2Bool(params, "focus") {
|
|
moveParams["focus"] = focus
|
|
}
|
|
return v2SurfaceMove(params: moveParams)
|
|
}
|
|
|
|
private func v2PaneLast(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "not_found", message: "No alternate pane available", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
guard let focused = ws.bonsplitController.focusedPaneId else {
|
|
result = .err(code: "not_found", message: "No focused pane", data: nil)
|
|
return
|
|
}
|
|
guard let target = ws.bonsplitController.allPaneIds.first(where: { $0.id != focused.id }) else {
|
|
result = .err(code: "not_found", message: "No alternate pane available", data: nil)
|
|
return
|
|
}
|
|
|
|
ws.bonsplitController.focusPane(target)
|
|
let selectedSurfaceId = ws.bonsplitController.selectedTab(inPane: target).flatMap { ws.panelIdFromSurfaceId($0.id) }
|
|
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": target.id.uuidString,
|
|
"pane_ref": v2Ref(kind: .pane, uuid: target.id),
|
|
"surface_id": v2OrNull(selectedSurfaceId?.uuidString),
|
|
"surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceId)
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - V2 Notification Methods
|
|
|
|
private func v2NotificationCreate(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
let title = (params["title"] as? String) ?? "Notification"
|
|
let subtitle = (params["subtitle"] as? String) ?? ""
|
|
let body = (params["body"] as? String) ?? ""
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
let surfaceId = ws.focusedPanelId
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: ws.id,
|
|
surfaceId: surfaceId,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
body: body
|
|
)
|
|
result = .ok(["workspace_id": ws.id.uuidString, "surface_id": v2OrNull(surfaceId?.uuidString)])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2NotificationCreateForSurface(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let surfaceId = v2UUID(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
|
|
}
|
|
|
|
let title = (params["title"] as? String) ?? "Notification"
|
|
let subtitle = (params["subtitle"] as? String) ?? ""
|
|
let body = (params["body"] as? String) ?? ""
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
guard ws.panels[surfaceId] != nil else {
|
|
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: ws.id,
|
|
surfaceId: surfaceId,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
body: body
|
|
)
|
|
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
|
|
}
|
|
|
|
private func v2NotificationCreateForTarget(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let wsId = v2UUID(params, "workspace_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
|
|
}
|
|
guard let surfaceId = v2UUID(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
|
|
}
|
|
|
|
let title = (params["title"] as? String) ?? "Notification"
|
|
let subtitle = (params["subtitle"] as? String) ?? ""
|
|
let body = (params["body"] as? String) ?? ""
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil)
|
|
v2MainSync {
|
|
guard let ws = tabManager.tabs.first(where: { $0.id == wsId }) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString])
|
|
return
|
|
}
|
|
guard ws.panels[surfaceId] != nil else {
|
|
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: ws.id,
|
|
surfaceId: surfaceId,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
body: body
|
|
)
|
|
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
|
|
}
|
|
|
|
private func v2NotificationList() -> [String: Any] {
|
|
var items: [[String: Any]] = []
|
|
DispatchQueue.main.sync {
|
|
items = TerminalNotificationStore.shared.notifications.map { n in
|
|
return [
|
|
"id": n.id.uuidString,
|
|
"workspace_id": n.tabId.uuidString,
|
|
"surface_id": v2OrNull(n.surfaceId?.uuidString),
|
|
"is_read": n.isRead,
|
|
"title": n.title,
|
|
"subtitle": n.subtitle,
|
|
"body": n.body
|
|
]
|
|
}
|
|
}
|
|
return ["notifications": items]
|
|
}
|
|
|
|
private func v2NotificationClear() -> V2CallResult {
|
|
DispatchQueue.main.sync {
|
|
TerminalNotificationStore.shared.clearAll()
|
|
}
|
|
return .ok([:])
|
|
}
|
|
|
|
// MARK: - V2 App Focus Methods
|
|
|
|
private func v2AppFocusOverride(params: [String: Any]) -> V2CallResult {
|
|
// Accept either:
|
|
// - state: "active" | "inactive" | "clear"
|
|
// - focused: true/false/null
|
|
if let state = v2String(params, "state")?.lowercased() {
|
|
switch state {
|
|
case "active":
|
|
AppFocusState.overrideIsFocused = true
|
|
case "inactive":
|
|
AppFocusState.overrideIsFocused = false
|
|
case "clear", "none":
|
|
AppFocusState.overrideIsFocused = nil
|
|
default:
|
|
return .err(code: "invalid_params", message: "Invalid state (active|inactive|clear)", data: ["state": state])
|
|
}
|
|
} else if params.keys.contains("focused") {
|
|
if let focused = v2Bool(params, "focused") {
|
|
AppFocusState.overrideIsFocused = focused
|
|
} else {
|
|
AppFocusState.overrideIsFocused = nil
|
|
}
|
|
} else {
|
|
return .err(code: "invalid_params", message: "Missing state or focused", data: nil)
|
|
}
|
|
|
|
let overrideVal: Any = v2OrNull(AppFocusState.overrideIsFocused.map { $0 as Any })
|
|
return .ok(["override": overrideVal])
|
|
}
|
|
|
|
private func v2AppSimulateActive() -> V2CallResult {
|
|
v2MainSync {
|
|
AppDelegate.shared?.applicationDidBecomeActive(
|
|
Notification(name: NSApplication.didBecomeActiveNotification)
|
|
)
|
|
}
|
|
return .ok([:])
|
|
}
|
|
|
|
// MARK: - V2 Browser Methods
|
|
|
|
private func v2BrowserWithPanel(
|
|
params: [String: Any],
|
|
_ body: (_ tabManager: TabManager, _ workspace: Workspace, _ surfaceId: UUID, _ browserPanel: BrowserPanel) -> V2CallResult
|
|
) -> V2CallResult {
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Browser operation failed", data: nil)
|
|
v2MainSync {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
result = .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
return
|
|
}
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
|
guard let surfaceId else {
|
|
result = .err(code: "not_found", message: "No focused browser surface", data: nil)
|
|
return
|
|
}
|
|
guard let browserPanel = ws.browserPanel(for: surfaceId) else {
|
|
result = .err(code: "invalid_params", message: "Surface is not a browser", data: ["surface_id": surfaceId.uuidString])
|
|
return
|
|
}
|
|
result = body(tabManager, ws, surfaceId, browserPanel)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2JSONLiteral(_ value: Any) -> String {
|
|
if let data = try? JSONSerialization.data(withJSONObject: [value], options: []),
|
|
let text = String(data: data, encoding: .utf8),
|
|
text.count >= 2 {
|
|
return String(text.dropFirst().dropLast())
|
|
}
|
|
if let s = value as? String {
|
|
return "\"\(s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\""))\""
|
|
}
|
|
return "null"
|
|
}
|
|
|
|
private func v2NormalizeJSValue(_ value: Any?) -> Any {
|
|
guard let value else { return NSNull() }
|
|
if value is V2BrowserUndefinedSentinel {
|
|
return [
|
|
Self.v2BrowserEvalEnvelopeTypeKey: Self.v2BrowserEvalEnvelopeTypeUndefined,
|
|
Self.v2BrowserEvalEnvelopeValueKey: NSNull()
|
|
]
|
|
}
|
|
if value is NSNull { return NSNull() }
|
|
if let v = value as? String { return v }
|
|
if let v = value as? NSNumber { return v }
|
|
if let v = value as? Bool { return v }
|
|
if let dict = value as? [String: Any] {
|
|
var out: [String: Any] = [:]
|
|
for (k, v) in dict {
|
|
out[k] = v2NormalizeJSValue(v)
|
|
}
|
|
return out
|
|
}
|
|
if let arr = value as? [Any] {
|
|
return arr.map { v2NormalizeJSValue($0) }
|
|
}
|
|
return String(describing: value)
|
|
}
|
|
|
|
private enum V2JavaScriptResult {
|
|
case success(Any?)
|
|
case failure(String)
|
|
}
|
|
|
|
private func v2RunJavaScript(
|
|
_ webView: WKWebView,
|
|
script: String,
|
|
timeout: TimeInterval = 5.0,
|
|
preferAsync: Bool = false
|
|
) -> V2JavaScriptResult {
|
|
var done = false
|
|
var resultValue: Any?
|
|
var resultError: String?
|
|
|
|
if preferAsync, #available(macOS 11.0, *) {
|
|
webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
|
|
switch result {
|
|
case .success(let value):
|
|
resultValue = value
|
|
case .failure(let error):
|
|
resultError = error.localizedDescription
|
|
}
|
|
done = true
|
|
}
|
|
} else {
|
|
webView.evaluateJavaScript(script) { value, error in
|
|
if let error {
|
|
resultError = error.localizedDescription
|
|
} else {
|
|
resultValue = value
|
|
}
|
|
done = true
|
|
}
|
|
}
|
|
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while !done && Date() < deadline {
|
|
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
|
}
|
|
|
|
if !done {
|
|
return .failure("Timed out waiting for JavaScript result")
|
|
}
|
|
if let resultError {
|
|
return .failure(resultError)
|
|
}
|
|
return .success(resultValue)
|
|
}
|
|
|
|
private func v2BrowserSelector(_ params: [String: Any]) -> String? {
|
|
v2String(params, "selector")
|
|
?? v2String(params, "sel")
|
|
?? v2String(params, "element_ref")
|
|
?? v2String(params, "ref")
|
|
}
|
|
|
|
private func v2BrowserNotSupported(_ method: String, details: String) -> V2CallResult {
|
|
.err(code: "not_supported", message: "\(method) is not supported on WKWebView", data: ["details": details])
|
|
}
|
|
|
|
private func v2BrowserAllocateElementRef(surfaceId: UUID, selector: String) -> String {
|
|
let ref = "@e\(v2BrowserNextElementOrdinal)"
|
|
v2BrowserNextElementOrdinal += 1
|
|
v2BrowserElementRefs[ref] = V2BrowserElementRefEntry(surfaceId: surfaceId, selector: selector)
|
|
return ref
|
|
}
|
|
|
|
private func v2BrowserResolveSelector(_ rawSelector: String, surfaceId: UUID) -> String? {
|
|
let trimmed = rawSelector.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
let refKey: String? = {
|
|
if trimmed.hasPrefix("@e") { return trimmed }
|
|
if trimmed.hasPrefix("e"), Int(trimmed.dropFirst()) != nil { return "@\(trimmed)" }
|
|
return nil
|
|
}()
|
|
|
|
if let refKey {
|
|
guard let entry = v2BrowserElementRefs[refKey], entry.surfaceId == surfaceId else { return nil }
|
|
return entry.selector
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
private func v2BrowserCurrentFrameSelector(surfaceId: UUID) -> String? {
|
|
v2BrowserFrameSelectorBySurface[surfaceId]
|
|
}
|
|
|
|
private func v2RunBrowserJavaScript(
|
|
_ webView: WKWebView,
|
|
surfaceId: UUID,
|
|
script: String,
|
|
timeout: TimeInterval = 5.0
|
|
) -> V2JavaScriptResult {
|
|
let scriptLiteral = v2JSONLiteral(script)
|
|
let framePrelude: String
|
|
if let frameSelector = v2BrowserCurrentFrameSelector(surfaceId: surfaceId) {
|
|
let selectorLiteral = v2JSONLiteral(frameSelector)
|
|
framePrelude = """
|
|
let __cmuxDoc = document;
|
|
try {
|
|
const __cmuxFrame = document.querySelector(\(selectorLiteral));
|
|
if (__cmuxFrame && __cmuxFrame.contentDocument) {
|
|
__cmuxDoc = __cmuxFrame.contentDocument;
|
|
}
|
|
} catch (_) {}
|
|
"""
|
|
} else {
|
|
framePrelude = "const __cmuxDoc = document;"
|
|
}
|
|
|
|
let asyncFunctionBody = """
|
|
\(framePrelude)
|
|
|
|
const __cmuxMaybeAwait = async (__r) => {
|
|
if (__r !== null && (typeof __r === 'object' || typeof __r === 'function') && typeof __r.then === 'function') {
|
|
return await __r;
|
|
}
|
|
return __r;
|
|
};
|
|
|
|
const __cmuxEvalInFrame = async function() {
|
|
const document = __cmuxDoc;
|
|
const __r = eval(\(scriptLiteral));
|
|
const __value = await __cmuxMaybeAwait(__r);
|
|
return {
|
|
__cmux_t: (typeof __value === 'undefined') ? 'undefined' : 'value',
|
|
__cmux_v: __value
|
|
};
|
|
};
|
|
|
|
return await __cmuxEvalInFrame();
|
|
"""
|
|
|
|
let rawResult: V2JavaScriptResult
|
|
if #available(macOS 11.0, *) {
|
|
rawResult = v2RunJavaScript(webView, script: asyncFunctionBody, timeout: timeout, preferAsync: true)
|
|
} else {
|
|
let evaluateFallback = """
|
|
(async () => {
|
|
\(asyncFunctionBody)
|
|
})()
|
|
"""
|
|
rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout)
|
|
}
|
|
|
|
switch rawResult {
|
|
case .failure(let message):
|
|
return .failure(message)
|
|
case .success(let value):
|
|
guard let dict = value as? [String: Any],
|
|
let type = dict[Self.v2BrowserEvalEnvelopeTypeKey] as? String else {
|
|
return .success(value)
|
|
}
|
|
|
|
switch type {
|
|
case Self.v2BrowserEvalEnvelopeTypeUndefined:
|
|
return .success(v2BrowserUndefinedSentinel)
|
|
case Self.v2BrowserEvalEnvelopeTypeValue:
|
|
return .success(dict[Self.v2BrowserEvalEnvelopeValueKey])
|
|
default:
|
|
return .success(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserRecordUnsupportedRequest(surfaceId: UUID, request: [String: Any]) {
|
|
var logs = v2BrowserUnsupportedNetworkRequestsBySurface[surfaceId] ?? []
|
|
logs.append(request)
|
|
if logs.count > 256 {
|
|
logs.removeFirst(logs.count - 256)
|
|
}
|
|
v2BrowserUnsupportedNetworkRequestsBySurface[surfaceId] = logs
|
|
}
|
|
|
|
private func v2BrowserPendingDialogs(surfaceId: UUID) -> [[String: Any]] {
|
|
let queue = v2BrowserDialogQueueBySurface[surfaceId] ?? []
|
|
return queue.enumerated().map { index, d in
|
|
[
|
|
"index": index,
|
|
"type": d.type,
|
|
"message": d.message,
|
|
"default_text": v2OrNull(d.defaultText)
|
|
]
|
|
}
|
|
}
|
|
|
|
func enqueueBrowserDialog(
|
|
surfaceId: UUID,
|
|
type: String,
|
|
message: String,
|
|
defaultText: String?,
|
|
responder: @escaping (_ accept: Bool, _ text: String?) -> Void
|
|
) {
|
|
var queue = v2BrowserDialogQueueBySurface[surfaceId] ?? []
|
|
queue.append(V2BrowserPendingDialog(type: type, message: message, defaultText: defaultText, responder: responder))
|
|
if queue.count > 16 {
|
|
// Keep bounded memory while preserving FIFO semantics for newest entries.
|
|
queue.removeFirst(queue.count - 16)
|
|
}
|
|
v2BrowserDialogQueueBySurface[surfaceId] = queue
|
|
}
|
|
|
|
private func v2BrowserPopDialog(surfaceId: UUID) -> V2BrowserPendingDialog? {
|
|
var queue = v2BrowserDialogQueueBySurface[surfaceId] ?? []
|
|
guard !queue.isEmpty else { return nil }
|
|
let first = queue.removeFirst()
|
|
v2BrowserDialogQueueBySurface[surfaceId] = queue
|
|
return first
|
|
}
|
|
|
|
private func v2BrowserEnsureInitScriptsApplied(surfaceId: UUID, browserPanel: BrowserPanel) {
|
|
let scripts = v2BrowserInitScriptsBySurface[surfaceId] ?? []
|
|
let styles = v2BrowserInitStylesBySurface[surfaceId] ?? []
|
|
guard !scripts.isEmpty || !styles.isEmpty else { return }
|
|
|
|
let injector = """
|
|
(() => {
|
|
window.__cmuxInitScriptsApplied = window.__cmuxInitScriptsApplied || { scripts: [], styles: [] };
|
|
return true;
|
|
})()
|
|
"""
|
|
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: injector)
|
|
|
|
for script in scripts {
|
|
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script)
|
|
}
|
|
for css in styles {
|
|
let cssLiteral = v2JSONLiteral(css)
|
|
let styleScript = """
|
|
(() => {
|
|
const id = 'cmux-init-style-' + btoa(unescape(encodeURIComponent(\(cssLiteral)))).replace(/=+$/g, '');
|
|
if (document.getElementById(id)) return true;
|
|
const el = document.createElement('style');
|
|
el.id = id;
|
|
el.textContent = String(\(cssLiteral));
|
|
(document.head || document.documentElement || document.body).appendChild(el);
|
|
return true;
|
|
})()
|
|
"""
|
|
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: styleScript)
|
|
}
|
|
}
|
|
|
|
private func v2BrowserWaitForCondition(
|
|
_ conditionScript: String,
|
|
webView: WKWebView,
|
|
surfaceId: UUID? = nil,
|
|
timeout: TimeInterval = 5.0,
|
|
pollInterval: TimeInterval = 0.05
|
|
) -> Bool {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
let wrapped = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()"
|
|
let jsResult: V2JavaScriptResult
|
|
if let surfaceId {
|
|
jsResult = v2RunBrowserJavaScript(webView, surfaceId: surfaceId, script: wrapped, timeout: max(0.5, pollInterval + 0.25))
|
|
} else {
|
|
jsResult = v2RunJavaScript(webView, script: wrapped, timeout: max(0.5, pollInterval + 0.25))
|
|
}
|
|
if case let .success(value) = jsResult,
|
|
let ok = value as? Bool,
|
|
ok {
|
|
return true
|
|
}
|
|
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(pollInterval))
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func v2PNGData(from image: NSImage) -> Data? {
|
|
guard let tiff = image.tiffRepresentation,
|
|
let rep = NSBitmapImageRep(data: tiff) else { return nil }
|
|
return rep.representation(using: .png, properties: [:])
|
|
}
|
|
|
|
// MARK: - Markdown
|
|
|
|
private func v2MarkdownOpen(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let rawPath = v2String(params, "path") else {
|
|
return .err(code: "invalid_params", message: "Missing 'path' parameter", data: nil)
|
|
}
|
|
|
|
// Resolve the path (expand ~ and standardize)
|
|
let expandedPath = NSString(string: rawPath).expandingTildeInPath
|
|
let filePath = NSString(string: expandedPath).standardizingPath
|
|
|
|
// Reject paths that aren't absolute after resolution
|
|
guard filePath.hasPrefix("/") else {
|
|
return .err(code: "invalid_params", message: "Path must be absolute: \(filePath)", data: ["path": filePath])
|
|
}
|
|
|
|
// Validate the file exists and is a regular file (not a directory)
|
|
var isDir: ObjCBool = false
|
|
guard FileManager.default.fileExists(atPath: filePath, isDirectory: &isDir) else {
|
|
return .err(code: "not_found", message: "File not found: \(filePath)", data: ["path": filePath])
|
|
}
|
|
guard !isDir.boolValue else {
|
|
return .err(code: "invalid_params", message: "Path is a directory, not a file: \(filePath)", data: ["path": filePath])
|
|
}
|
|
guard FileManager.default.isReadableFile(atPath: filePath) else {
|
|
return .err(code: "permission_denied", message: "File not readable: \(filePath)", data: ["path": filePath])
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create markdown panel", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
v2MaybeSelectWorkspace(tabManager, workspace: ws)
|
|
|
|
let sourceSurfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
|
guard let sourceSurfaceId else {
|
|
result = .err(code: "not_found", message: "No focused surface to split", data: nil)
|
|
return
|
|
}
|
|
guard ws.panels[sourceSurfaceId] != nil else {
|
|
result = .err(code: "not_found", message: "Source surface not found", data: ["surface_id": sourceSurfaceId.uuidString])
|
|
return
|
|
}
|
|
|
|
let sourcePaneUUID = ws.paneId(forPanelId: sourceSurfaceId)?.id
|
|
|
|
let createdPanel = ws.newMarkdownSplit(
|
|
from: sourceSurfaceId,
|
|
orientation: .horizontal,
|
|
filePath: filePath,
|
|
focus: v2FocusAllowed()
|
|
)
|
|
|
|
guard let markdownPanelId = createdPanel?.id else {
|
|
result = .err(code: "internal_error", message: "Failed to create markdown panel", data: nil)
|
|
return
|
|
}
|
|
|
|
let targetPaneUUID = ws.paneId(forPanelId: markdownPanelId)?.id
|
|
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": v2OrNull(targetPaneUUID?.uuidString),
|
|
"pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID),
|
|
"surface_id": markdownPanelId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: markdownPanelId),
|
|
"source_surface_id": sourceSurfaceId.uuidString,
|
|
"source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId),
|
|
"source_pane_id": v2OrNull(sourcePaneUUID?.uuidString),
|
|
"source_pane_ref": v2Ref(kind: .pane, uuid: sourcePaneUUID),
|
|
"target_pane_id": v2OrNull(targetPaneUUID?.uuidString),
|
|
"target_pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID),
|
|
"path": filePath
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - Browser
|
|
|
|
private func v2BrowserOpenSplit(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
let urlStr = v2String(params, "url")
|
|
let url = urlStr.flatMap { URL(string: $0) }
|
|
let respectExternalOpenRules = v2Bool(params, "respect_external_open_rules") ?? false
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create browser", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
if let url,
|
|
respectExternalOpenRules,
|
|
BrowserLinkOpenSettings.shouldOpenExternally(url) {
|
|
guard NSWorkspace.shared.open(url) else {
|
|
result = .err(
|
|
code: "external_open_failed",
|
|
message: "Failed to open URL externally",
|
|
data: ["url": url.absoluteString]
|
|
)
|
|
return
|
|
}
|
|
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": v2OrNull(nil),
|
|
"pane_ref": v2Ref(kind: .pane, uuid: nil),
|
|
"surface_id": v2OrNull(nil),
|
|
"surface_ref": v2Ref(kind: .surface, uuid: nil),
|
|
"created_split": false,
|
|
"placement_strategy": "external",
|
|
"opened_externally": true,
|
|
"url": url.absoluteString
|
|
])
|
|
return
|
|
}
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
v2MaybeSelectWorkspace(tabManager, workspace: ws)
|
|
|
|
let sourceSurfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
|
guard let sourceSurfaceId else {
|
|
result = .err(code: "not_found", message: "No focused surface to split", data: nil)
|
|
return
|
|
}
|
|
guard ws.panels[sourceSurfaceId] != nil else {
|
|
result = .err(code: "not_found", message: "Source surface not found", data: ["surface_id": sourceSurfaceId.uuidString])
|
|
return
|
|
}
|
|
|
|
let sourcePaneUUID = ws.paneId(forPanelId: sourceSurfaceId)?.id
|
|
|
|
var createdSplit = true
|
|
var placementStrategy = "split_right"
|
|
let createdPanel: BrowserPanel?
|
|
if let targetPane = ws.preferredBrowserTargetPane(fromPanelId: sourceSurfaceId) {
|
|
createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: v2FocusAllowed())
|
|
createdSplit = false
|
|
placementStrategy = "reuse_right_sibling"
|
|
} else {
|
|
createdPanel = ws.newBrowserSplit(
|
|
from: sourceSurfaceId,
|
|
orientation: .horizontal,
|
|
url: url,
|
|
focus: v2FocusAllowed()
|
|
)
|
|
}
|
|
|
|
guard let browserPanelId = createdPanel?.id else {
|
|
result = .err(code: "internal_error", message: "Failed to create browser", data: nil)
|
|
return
|
|
}
|
|
|
|
let targetPaneUUID = ws.paneId(forPanelId: browserPanelId)?.id
|
|
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": v2OrNull(targetPaneUUID?.uuidString),
|
|
"pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID),
|
|
"surface_id": browserPanelId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: browserPanelId),
|
|
"source_surface_id": sourceSurfaceId.uuidString,
|
|
"source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId),
|
|
"source_pane_id": v2OrNull(sourcePaneUUID?.uuidString),
|
|
"source_pane_ref": v2Ref(kind: .pane, uuid: sourcePaneUUID),
|
|
"target_pane_id": v2OrNull(targetPaneUUID?.uuidString),
|
|
"target_pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID),
|
|
"created_split": createdSplit,
|
|
"placement_strategy": placementStrategy
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2BrowserNavigate(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let surfaceId = v2UUID(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
|
|
}
|
|
guard let url = v2String(params, "url") else {
|
|
return .err(code: "invalid_params", message: "Missing url", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "not_found", message: "Surface not found or not a browser", data: ["surface_id": surfaceId.uuidString])
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager),
|
|
let browserPanel = ws.browserPanel(for: surfaceId) else { return }
|
|
browserPanel.navigateSmart(url)
|
|
var payload: [String: Any] = [
|
|
"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))
|
|
]
|
|
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
|
|
result = .ok(payload)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2BrowserBack(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserNavSimple(params: params, action: "back")
|
|
}
|
|
|
|
private func v2BrowserForward(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserNavSimple(params: params, action: "forward")
|
|
}
|
|
|
|
private func v2BrowserReload(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserNavSimple(params: params, action: "reload")
|
|
}
|
|
|
|
private func v2BrowserNotFoundDiagnostics(
|
|
surfaceId: UUID,
|
|
browserPanel: BrowserPanel,
|
|
selector: String
|
|
) -> [String: Any] {
|
|
let selectorLiteral = v2JSONLiteral(selector)
|
|
let script = """
|
|
(() => {
|
|
const __selector = \(selectorLiteral);
|
|
const __normalize = (s) => String(s || '').replace(/\\s+/g, ' ').trim();
|
|
const __isVisible = (el) => {
|
|
try {
|
|
if (!el) return false;
|
|
const style = getComputedStyle(el);
|
|
const rect = el.getBoundingClientRect();
|
|
if (!style || !rect) return false;
|
|
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
if (parseFloat(style.opacity || '1') <= 0.01) return false;
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
};
|
|
const __describe = (el) => {
|
|
const tag = String(el.tagName || '').toLowerCase();
|
|
const id = __normalize(el.id || '');
|
|
const klass = __normalize(el.className || '').split(/\\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
let out = tag || 'element';
|
|
if (id) out += '#' + id;
|
|
if (klass) out += '.' + klass;
|
|
return out;
|
|
};
|
|
try {
|
|
const __nodes = Array.from(document.querySelectorAll(__selector));
|
|
const __visible = __nodes.filter(__isVisible);
|
|
const __sample = __nodes.slice(0, 6).map((el, idx) => ({
|
|
index: idx,
|
|
descriptor: __describe(el),
|
|
role: __normalize(el.getAttribute('role') || ''),
|
|
visible: __isVisible(el),
|
|
text: __normalize(el.innerText || el.textContent || '').slice(0, 120)
|
|
}));
|
|
const __snapshotExcerpt = __sample.map((row) => {
|
|
const suffix = row.text ? ` \"${row.text}\"` : '';
|
|
return `- ${row.descriptor}${suffix}`;
|
|
}).join('\\n');
|
|
return {
|
|
ok: true,
|
|
selector: __selector,
|
|
count: __nodes.length,
|
|
visible_count: __visible.length,
|
|
sample: __sample,
|
|
snapshot_excerpt: __snapshotExcerpt,
|
|
title: __normalize(document.title || ''),
|
|
url: String(location.href || ''),
|
|
body_excerpt: document.body ? __normalize(document.body.innerText || '').slice(0, 400) : ''
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
selector: __selector,
|
|
error: 'invalid_selector',
|
|
details: String((err && err.message) || err || '')
|
|
};
|
|
}
|
|
})()
|
|
"""
|
|
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 4.0) {
|
|
case .failure(let message):
|
|
return [
|
|
"selector": selector,
|
|
"diagnostics_error": message
|
|
]
|
|
case .success(let value):
|
|
guard let dict = value as? [String: Any] else {
|
|
return ["selector": selector]
|
|
}
|
|
var out: [String: Any] = ["selector": selector]
|
|
if let count = dict["count"] { out["match_count"] = count }
|
|
if let visibleCount = dict["visible_count"] { out["visible_match_count"] = visibleCount }
|
|
if let sample = dict["sample"] { out["sample"] = v2NormalizeJSValue(sample) }
|
|
if let excerpt = dict["snapshot_excerpt"] { out["snapshot_excerpt"] = excerpt }
|
|
if let body = dict["body_excerpt"] { out["body_excerpt"] = body }
|
|
if let title = dict["title"] { out["title"] = title }
|
|
if let url = dict["url"] { out["url"] = url }
|
|
if let err = dict["error"] { out["diagnostics_code"] = err }
|
|
if let details = dict["details"] { out["diagnostics_details"] = details }
|
|
return out
|
|
}
|
|
}
|
|
|
|
private func v2BrowserElementNotFoundResult(
|
|
actionName: String,
|
|
selector: String,
|
|
attempts: Int,
|
|
surfaceId: UUID,
|
|
browserPanel: BrowserPanel
|
|
) -> V2CallResult {
|
|
var data = v2BrowserNotFoundDiagnostics(surfaceId: surfaceId, browserPanel: browserPanel, selector: selector)
|
|
data["action"] = actionName
|
|
data["retry_attempts"] = attempts
|
|
data["hint"] = "Run 'browser snapshot' to refresh refs, then retry with a more specific selector."
|
|
|
|
let count = (data["match_count"] as? Int) ?? (data["match_count"] as? NSNumber)?.intValue ?? 0
|
|
let visibleCount = (data["visible_match_count"] as? Int) ?? (data["visible_match_count"] as? NSNumber)?.intValue ?? 0
|
|
|
|
let message: String
|
|
if count > 0 && visibleCount == 0 {
|
|
message = "Element \"\(selector)\" is present but not visible."
|
|
} else if count > 1 {
|
|
message = "Selector \"\(selector)\" matched multiple elements."
|
|
} else {
|
|
message = "Element \"\(selector)\" not found or not visible. Run 'browser snapshot' to see current page elements."
|
|
}
|
|
|
|
return .err(code: "not_found", message: message, data: data)
|
|
}
|
|
|
|
private func v2BrowserAppendPostSnapshot(
|
|
params: [String: Any],
|
|
surfaceId: UUID,
|
|
payload: inout [String: Any]
|
|
) {
|
|
guard v2Bool(params, "snapshot_after") ?? false else { return }
|
|
|
|
var snapshotParams: [String: Any] = [
|
|
"surface_id": surfaceId.uuidString,
|
|
"interactive": v2Bool(params, "snapshot_interactive") ?? true,
|
|
"cursor": v2Bool(params, "snapshot_cursor") ?? false,
|
|
"compact": v2Bool(params, "snapshot_compact") ?? true,
|
|
"max_depth": max(0, v2Int(params, "snapshot_max_depth") ?? 10)
|
|
]
|
|
if let selector = v2String(params, "snapshot_selector"),
|
|
!selector.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
snapshotParams["selector"] = selector
|
|
}
|
|
|
|
switch v2BrowserSnapshot(params: snapshotParams) {
|
|
case .ok(let snapshotAny):
|
|
guard let snapshot = snapshotAny as? [String: Any] else {
|
|
payload["post_action_snapshot_error"] = [
|
|
"code": "internal_error",
|
|
"message": "Invalid snapshot payload"
|
|
]
|
|
return
|
|
}
|
|
if let value = snapshot["snapshot"] {
|
|
payload["post_action_snapshot"] = value
|
|
}
|
|
if let value = snapshot["refs"] {
|
|
payload["post_action_refs"] = value
|
|
}
|
|
if let value = snapshot["title"] {
|
|
payload["post_action_title"] = value
|
|
}
|
|
if let value = snapshot["url"] {
|
|
payload["post_action_url"] = value
|
|
}
|
|
case .err(code: let code, message: let message, data: let data):
|
|
var err: [String: Any] = [
|
|
"code": code,
|
|
"message": message,
|
|
]
|
|
err["data"] = v2OrNull(data)
|
|
payload["post_action_snapshot_error"] = err
|
|
}
|
|
}
|
|
|
|
private func v2BrowserSelectorAction(
|
|
params: [String: Any],
|
|
actionName: String,
|
|
scriptBuilder: (_ selectorLiteral: String) -> String
|
|
) -> V2CallResult {
|
|
guard let selectorRaw = v2BrowserSelector(params) else {
|
|
return .err(code: "invalid_params", message: "Missing selector", data: nil)
|
|
}
|
|
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
|
|
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
|
|
}
|
|
let script = scriptBuilder(v2JSONLiteral(selector))
|
|
let retryAttempts = max(1, v2Int(params, "retry_attempts") ?? 3)
|
|
|
|
for attempt in 1...retryAttempts {
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: ["action": actionName, "selector": selector])
|
|
case .success(let value):
|
|
if let dict = value as? [String: Any],
|
|
let ok = dict["ok"] as? Bool,
|
|
ok {
|
|
var payload: [String: Any] = [
|
|
"workspace_id": ws.id.uuidString,
|
|
"surface_id": surfaceId.uuidString,
|
|
"action": actionName,
|
|
"attempts": attempt
|
|
]
|
|
payload["workspace_ref"] = v2Ref(kind: .workspace, uuid: ws.id)
|
|
payload["surface_ref"] = v2Ref(kind: .surface, uuid: surfaceId)
|
|
if let resultValue = dict["value"] {
|
|
payload["value"] = v2NormalizeJSValue(resultValue)
|
|
}
|
|
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
|
|
return .ok(payload)
|
|
}
|
|
|
|
let errorText = (value as? [String: Any])?["error"] as? String
|
|
if errorText == "not_found", attempt < retryAttempts {
|
|
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.08))
|
|
continue
|
|
}
|
|
if errorText == "not_found" {
|
|
return v2BrowserElementNotFoundResult(
|
|
actionName: actionName,
|
|
selector: selector,
|
|
attempts: retryAttempts,
|
|
surfaceId: surfaceId,
|
|
browserPanel: browserPanel
|
|
)
|
|
}
|
|
|
|
return .err(code: "js_error", message: "Browser action failed", data: ["action": actionName, "selector": selector])
|
|
}
|
|
}
|
|
|
|
return v2BrowserElementNotFoundResult(
|
|
actionName: actionName,
|
|
selector: selector,
|
|
attempts: retryAttempts,
|
|
surfaceId: surfaceId,
|
|
browserPanel: browserPanel
|
|
)
|
|
}
|
|
}
|
|
|
|
private func v2BrowserEval(params: [String: Any]) -> V2CallResult {
|
|
guard let script = v2String(params, "script") else {
|
|
return .err(code: "invalid_params", message: "Missing script", data: nil)
|
|
}
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
return .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),
|
|
"value": v2NormalizeJSValue(value)
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserSnapshot(params: [String: Any]) -> V2CallResult {
|
|
let interactiveOnly = v2Bool(params, "interactive") ?? false
|
|
let includeCursor = v2Bool(params, "cursor") ?? false
|
|
let compact = v2Bool(params, "compact") ?? false
|
|
let maxDepth = max(0, v2Int(params, "max_depth") ?? v2Int(params, "maxDepth") ?? 12)
|
|
let scopeSelector = v2String(params, "selector")
|
|
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let interactiveLiteral = interactiveOnly ? "true" : "false"
|
|
let cursorLiteral = includeCursor ? "true" : "false"
|
|
let compactLiteral = compact ? "true" : "false"
|
|
let scopeLiteral = scopeSelector.map(v2JSONLiteral) ?? "null"
|
|
|
|
let script = """
|
|
(() => {
|
|
const __interactiveOnly = \(interactiveLiteral);
|
|
const __includeCursor = \(cursorLiteral);
|
|
const __compact = \(compactLiteral);
|
|
const __maxDepth = \(maxDepth);
|
|
const __scopeSelector = \(scopeLiteral);
|
|
|
|
const __normalize = (s) => String(s || '').replace(/\\s+/g, ' ').trim();
|
|
const __interactiveRoles = new Set(['button','link','textbox','checkbox','radio','combobox','listbox','menuitem','menuitemcheckbox','menuitemradio','option','searchbox','slider','spinbutton','switch','tab','treeitem']);
|
|
const __contentRoles = new Set(['heading','cell','gridcell','columnheader','rowheader','listitem','article','region','main','navigation']);
|
|
const __structuralRoles = new Set(['generic','group','list','table','row','rowgroup','grid','treegrid','menu','menubar','toolbar','tablist','tree','directory','document','application','presentation','none']);
|
|
|
|
const __isVisible = (el) => {
|
|
try {
|
|
if (!el) return false;
|
|
const style = getComputedStyle(el);
|
|
const rect = el.getBoundingClientRect();
|
|
if (!style || !rect) return false;
|
|
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
if (parseFloat(style.opacity || '1') <= 0.01) return false;
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const __implicitRole = (el) => {
|
|
const tag = String(el.tagName || '').toLowerCase();
|
|
if (tag === 'button') return 'button';
|
|
if (tag === 'a' && el.hasAttribute('href')) return 'link';
|
|
if (tag === 'input') {
|
|
const type = String(el.getAttribute('type') || 'text').toLowerCase();
|
|
if (type === 'checkbox') return 'checkbox';
|
|
if (type === 'radio') return 'radio';
|
|
if (type === 'submit' || type === 'button' || type === 'reset') return 'button';
|
|
return 'textbox';
|
|
}
|
|
if (tag === 'textarea') return 'textbox';
|
|
if (tag === 'select') return 'combobox';
|
|
if (tag === 'summary') return 'button';
|
|
if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') return 'heading';
|
|
if (tag === 'li') return 'listitem';
|
|
return null;
|
|
};
|
|
|
|
const __nameFor = (el) => {
|
|
const aria = __normalize(el.getAttribute('aria-label') || '');
|
|
if (aria) return aria;
|
|
const labelledBy = __normalize(el.getAttribute('aria-labelledby') || '');
|
|
if (labelledBy) {
|
|
const text = labelledBy.split(/\\s+/).map((id) => document.getElementById(id)).filter(Boolean).map((n) => __normalize(n.textContent || '')).join(' ').trim();
|
|
if (text) return text;
|
|
}
|
|
if (el.tagName && String(el.tagName).toLowerCase() === 'input') {
|
|
const placeholder = __normalize(el.getAttribute('placeholder') || '');
|
|
if (placeholder) return placeholder;
|
|
const value = __normalize(el.value || '');
|
|
if (value) return value;
|
|
}
|
|
const title = __normalize(el.getAttribute('title') || '');
|
|
if (title) return title;
|
|
const text = __normalize(el.innerText || el.textContent || '');
|
|
if (text) return text.slice(0, 120);
|
|
return '';
|
|
};
|
|
|
|
const __cssPath = (el) => {
|
|
if (!el || el.nodeType !== 1) return null;
|
|
if (el.id) return '#' + CSS.escape(el.id);
|
|
const parts = [];
|
|
let cur = el;
|
|
while (cur && cur.nodeType === 1) {
|
|
let part = String(cur.tagName || '').toLowerCase();
|
|
if (!part) break;
|
|
if (cur.id) {
|
|
part += '#' + CSS.escape(cur.id);
|
|
parts.unshift(part);
|
|
break;
|
|
}
|
|
const tag = part;
|
|
const parent = cur.parentElement;
|
|
if (parent) {
|
|
const siblings = Array.from(parent.children).filter((n) => String(n.tagName || '').toLowerCase() === tag);
|
|
if (siblings.length > 1) {
|
|
const index = siblings.indexOf(cur) + 1;
|
|
part += `:nth-of-type(${index})`;
|
|
}
|
|
}
|
|
parts.unshift(part);
|
|
cur = cur.parentElement;
|
|
if (parts.length >= 6) break;
|
|
}
|
|
return parts.join(' > ');
|
|
};
|
|
|
|
const __root = (() => {
|
|
if (__scopeSelector) {
|
|
return document.querySelector(__scopeSelector) || document.body || document.documentElement;
|
|
}
|
|
return document.body || document.documentElement;
|
|
})();
|
|
|
|
const __entries = [];
|
|
const __seen = new Set();
|
|
const __appendEntry = (el, depth, forcedRole) => {
|
|
if (!__isVisible(el)) return;
|
|
const explicitRole = __normalize(el.getAttribute('role') || '').toLowerCase();
|
|
const role = forcedRole || explicitRole || __implicitRole(el) || '';
|
|
if (!role) return;
|
|
|
|
if (__interactiveOnly && !__interactiveRoles.has(role)) return;
|
|
if (!__interactiveOnly) {
|
|
const includeRole = __interactiveRoles.has(role) || __contentRoles.has(role);
|
|
if (!includeRole) return;
|
|
if (__compact && __structuralRoles.has(role)) {
|
|
const name = __nameFor(el);
|
|
if (!name) return;
|
|
}
|
|
}
|
|
|
|
const selector = __cssPath(el);
|
|
if (!selector || __seen.has(selector)) return;
|
|
__seen.add(selector);
|
|
__entries.push({
|
|
selector,
|
|
role,
|
|
name: __nameFor(el),
|
|
depth
|
|
});
|
|
};
|
|
|
|
const __walk = (node, depth) => {
|
|
if (!node || depth > __maxDepth || node.nodeType !== 1) return;
|
|
const el = node;
|
|
__appendEntry(el, depth, null);
|
|
for (const child of Array.from(el.children || [])) {
|
|
__walk(child, depth + 1);
|
|
}
|
|
};
|
|
|
|
if (__root) {
|
|
__walk(__root, 0);
|
|
}
|
|
|
|
if (__includeCursor && __root) {
|
|
const all = Array.from(__root.querySelectorAll('*'));
|
|
for (const el of all) {
|
|
if (!__isVisible(el)) continue;
|
|
const style = getComputedStyle(el);
|
|
const hasOnClick = typeof el.onclick === 'function' || el.hasAttribute('onclick');
|
|
const hasCursorPointer = style.cursor === 'pointer';
|
|
const tabIndex = el.getAttribute('tabindex');
|
|
const hasTabIndex = tabIndex != null && String(tabIndex) !== '-1';
|
|
if (!hasOnClick && !hasCursorPointer && !hasTabIndex) continue;
|
|
__appendEntry(el, 0, 'generic');
|
|
if (__entries.length >= 256) break;
|
|
}
|
|
}
|
|
|
|
const body = document.body;
|
|
const root = document.documentElement;
|
|
return {
|
|
title: __normalize(document.title || ''),
|
|
url: String(location.href || ''),
|
|
ready_state: String(document.readyState || ''),
|
|
text: body ? String(body.innerText || '') : '',
|
|
html: root ? String(root.outerHTML || '') : '',
|
|
entries: __entries
|
|
};
|
|
})()
|
|
"""
|
|
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
guard let dict = value as? [String: Any] else {
|
|
return .err(code: "js_error", message: "Invalid snapshot payload", data: nil)
|
|
}
|
|
|
|
let title = (dict["title"] as? String) ?? ""
|
|
let url = (dict["url"] as? String) ?? ""
|
|
let readyState = (dict["ready_state"] as? String) ?? ""
|
|
let text = (dict["text"] as? String) ?? ""
|
|
let html = (dict["html"] as? String) ?? ""
|
|
let entries = (dict["entries"] as? [[String: Any]]) ?? []
|
|
|
|
var refs: [String: [String: Any]] = [:]
|
|
var treeLines: [String] = []
|
|
var seenSelectors: Set<String> = []
|
|
|
|
for entry in entries {
|
|
guard let selector = entry["selector"] as? String,
|
|
!selector.isEmpty,
|
|
!seenSelectors.contains(selector) else {
|
|
continue
|
|
}
|
|
seenSelectors.insert(selector)
|
|
|
|
let roleRaw = (entry["role"] as? String) ?? "generic"
|
|
let role = roleRaw.isEmpty ? "generic" : roleRaw
|
|
let name = ((entry["name"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let depth = max(0, (entry["depth"] as? Int) ?? ((entry["depth"] as? NSNumber)?.intValue ?? 0))
|
|
|
|
let refToken = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: selector)
|
|
let shortRef = refToken.hasPrefix("@") ? String(refToken.dropFirst()) : refToken
|
|
|
|
var refInfo: [String: Any] = ["role": role]
|
|
if !name.isEmpty {
|
|
refInfo["name"] = name
|
|
}
|
|
refs[shortRef] = refInfo
|
|
|
|
let indent = String(repeating: " ", count: depth)
|
|
var line = "\(indent)- \(role)"
|
|
if !name.isEmpty {
|
|
let cleanName = name.replacingOccurrences(of: "\"", with: "'")
|
|
line += " \"\(cleanName)\""
|
|
}
|
|
line += " [ref=\(shortRef)]"
|
|
treeLines.append(line)
|
|
}
|
|
|
|
let titleForTree = title.isEmpty ? "page" : title.replacingOccurrences(of: "\"", with: "'")
|
|
var snapshotLines = ["- document \"\(titleForTree)\""]
|
|
if !treeLines.isEmpty {
|
|
snapshotLines.append(contentsOf: treeLines)
|
|
} else {
|
|
let excerpt = text
|
|
.replacingOccurrences(of: "\n", with: " ")
|
|
.replacingOccurrences(of: "\t", with: " ")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !excerpt.isEmpty {
|
|
let clipped = String(excerpt.prefix(240)).replacingOccurrences(of: "\"", with: "'")
|
|
snapshotLines.append("- text \"\(clipped)\"")
|
|
} else {
|
|
snapshotLines.append("- (empty)")
|
|
}
|
|
}
|
|
let snapshotText = snapshotLines.joined(separator: "\n")
|
|
|
|
var payload: [String: Any] = [
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
|
|
"snapshot": snapshotText,
|
|
"title": title,
|
|
"url": url,
|
|
"ready_state": readyState,
|
|
"page": [
|
|
"title": title,
|
|
"url": url,
|
|
"ready_state": readyState,
|
|
"text": text,
|
|
"html": html
|
|
]
|
|
]
|
|
if !refs.isEmpty {
|
|
payload["refs"] = refs
|
|
}
|
|
return .ok(payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserWait(params: [String: Any]) -> V2CallResult {
|
|
let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? 5_000)
|
|
let timeout = Double(timeoutMs) / 1000.0
|
|
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let conditionScript: String = {
|
|
if let selector = v2BrowserSelector(params) {
|
|
let literal = v2JSONLiteral(selector)
|
|
return "document.querySelector(\(literal)) !== null"
|
|
}
|
|
if let urlContains = v2String(params, "url_contains") {
|
|
let literal = v2JSONLiteral(urlContains)
|
|
return "String(location.href || '').includes(\(literal))"
|
|
}
|
|
if let textContains = v2String(params, "text_contains") {
|
|
let literal = v2JSONLiteral(textContains)
|
|
return "(document.body && String(document.body.innerText || '').includes(\(literal)))"
|
|
}
|
|
if let loadState = v2String(params, "load_state") {
|
|
let literal = v2JSONLiteral(loadState.lowercased())
|
|
return "String(document.readyState || '').toLowerCase() === \(literal)"
|
|
}
|
|
if let fn = v2String(params, "function") {
|
|
return "(() => { return !!(\(fn)); })()"
|
|
}
|
|
return "document.readyState === 'complete'"
|
|
}()
|
|
|
|
let ok = v2BrowserWaitForCondition(conditionScript, webView: browserPanel.webView, surfaceId: surfaceId, timeout: timeout)
|
|
if !ok {
|
|
return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs])
|
|
}
|
|
return .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),
|
|
"waited": true
|
|
])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserClick(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "click") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
if (typeof el.click === 'function') {
|
|
el.click();
|
|
} else {
|
|
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window, detail: 1 }));
|
|
}
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserDblClick(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "dblclick") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window, detail: 2 }));
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserHover(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "hover") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
|
|
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, cancelable: true, view: window }));
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserFocusElement(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "focus") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
if (typeof el.focus === 'function') el.focus();
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserType(params: [String: Any]) -> V2CallResult {
|
|
guard let text = v2String(params, "text") else {
|
|
return .err(code: "invalid_params", message: "Missing text", data: nil)
|
|
}
|
|
return v2BrowserSelectorAction(params: params, actionName: "type") { selectorLiteral in
|
|
let textLiteral = v2JSONLiteral(text)
|
|
return """
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
if (typeof el.focus === 'function') el.focus();
|
|
const chunk = String(\(textLiteral));
|
|
if ('value' in el) {
|
|
el.value = (el.value || '') + chunk;
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
} else {
|
|
el.textContent = (el.textContent || '') + chunk;
|
|
}
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserFill(params: [String: Any]) -> V2CallResult {
|
|
// `fill` must allow empty strings so callers can clear existing input values.
|
|
guard let text = v2RawString(params, "text") ?? v2RawString(params, "value") else {
|
|
return .err(code: "invalid_params", message: "Missing text/value", data: nil)
|
|
}
|
|
return v2BrowserSelectorAction(params: params, actionName: "fill") { selectorLiteral in
|
|
let textLiteral = v2JSONLiteral(text)
|
|
return """
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
if (typeof el.focus === 'function') el.focus();
|
|
const value = String(\(textLiteral));
|
|
if ('value' in el) {
|
|
el.value = value;
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
} else {
|
|
el.textContent = value;
|
|
}
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserPress(params: [String: Any]) -> V2CallResult {
|
|
guard let key = v2String(params, "key") else {
|
|
return .err(code: "invalid_params", message: "Missing key", data: nil)
|
|
}
|
|
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let keyLiteral = v2JSONLiteral(key)
|
|
let script = """
|
|
(() => {
|
|
const target = document.activeElement || document.body || document.documentElement;
|
|
if (!target) return { ok: false, error: 'not_found' };
|
|
const k = String(\(keyLiteral));
|
|
target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
|
|
target.dispatchEvent(new KeyboardEvent('keypress', { key: k, bubbles: true, cancelable: true }));
|
|
target.dispatchEvent(new KeyboardEvent('keyup', { key: k, bubbles: true, cancelable: true }));
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success:
|
|
var payload: [String: Any] = [
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
|
|
]
|
|
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
|
|
return .ok(payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserKeyDown(params: [String: Any]) -> V2CallResult {
|
|
guard let key = v2String(params, "key") else {
|
|
return .err(code: "invalid_params", message: "Missing key", data: nil)
|
|
}
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let keyLiteral = v2JSONLiteral(key)
|
|
let script = """
|
|
(() => {
|
|
const target = document.activeElement || document.body || document.documentElement;
|
|
if (!target) return { ok: false, error: 'not_found' };
|
|
const k = String(\(keyLiteral));
|
|
target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success:
|
|
var payload: [String: Any] = [
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
|
|
]
|
|
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
|
|
return .ok(payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserKeyUp(params: [String: Any]) -> V2CallResult {
|
|
guard let key = v2String(params, "key") else {
|
|
return .err(code: "invalid_params", message: "Missing key", data: nil)
|
|
}
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let keyLiteral = v2JSONLiteral(key)
|
|
let script = """
|
|
(() => {
|
|
const target = document.activeElement || document.body || document.documentElement;
|
|
if (!target) return { ok: false, error: 'not_found' };
|
|
const k = String(\(keyLiteral));
|
|
target.dispatchEvent(new KeyboardEvent('keyup', { key: k, bubbles: true, cancelable: true }));
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success:
|
|
var payload: [String: Any] = [
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
|
|
]
|
|
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
|
|
return .ok(payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserCheck(params: [String: Any], checked: Bool) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: checked ? "check" : "uncheck") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
if (!('checked' in el)) return { ok: false, error: 'not_checkable' };
|
|
el.checked = \(checked ? "true" : "false");
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserSelect(params: [String: Any]) -> V2CallResult {
|
|
let selectedValue = v2String(params, "value") ?? v2String(params, "text")
|
|
guard let selectedValue else {
|
|
return .err(code: "invalid_params", message: "Missing value", data: nil)
|
|
}
|
|
return v2BrowserSelectorAction(params: params, actionName: "select") { selectorLiteral in
|
|
let valueLiteral = v2JSONLiteral(selectedValue)
|
|
return """
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
if (!('value' in el)) return { ok: false, error: 'not_select' };
|
|
el.value = String(\(valueLiteral));
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserScroll(params: [String: Any]) -> V2CallResult {
|
|
let dx = v2Int(params, "dx") ?? 0
|
|
let dy = v2Int(params, "dy") ?? 0
|
|
let selectorRaw = v2BrowserSelector(params)
|
|
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let selector = selectorRaw.flatMap { v2BrowserResolveSelector($0, surfaceId: surfaceId) }
|
|
if selectorRaw != nil && selector == nil {
|
|
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw ?? ""])
|
|
}
|
|
|
|
let script: String
|
|
if let selector {
|
|
let selectorLiteral = v2JSONLiteral(selector)
|
|
script = """
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
if (typeof el.scrollBy === 'function') {
|
|
el.scrollBy({ left: \(dx), top: \(dy), behavior: 'instant' });
|
|
} else {
|
|
el.scrollLeft += \(dx);
|
|
el.scrollTop += \(dy);
|
|
}
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
} else {
|
|
script = "window.scrollBy({ left: \(dx), top: \(dy), behavior: 'instant' }); ({ ok: true })"
|
|
}
|
|
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
if let dict = value as? [String: Any],
|
|
let ok = dict["ok"] as? Bool,
|
|
!ok,
|
|
let errorText = dict["error"] as? String,
|
|
errorText == "not_found" {
|
|
if let selector {
|
|
return v2BrowserElementNotFoundResult(
|
|
actionName: "scroll",
|
|
selector: selector,
|
|
attempts: 1,
|
|
surfaceId: surfaceId,
|
|
browserPanel: browserPanel
|
|
)
|
|
}
|
|
return .err(code: "not_found", message: "Element not found", data: ["selector": selector ?? ""])
|
|
}
|
|
var payload: [String: Any] = [
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
|
|
]
|
|
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
|
|
return .ok(payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserScrollIntoView(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "scroll_into_view") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserScreenshot(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
var done = false
|
|
var imageData: Data?
|
|
browserPanel.takeSnapshot { image in
|
|
imageData = image.flatMap { self.v2PNGData(from: $0) }
|
|
done = true
|
|
}
|
|
|
|
let deadline = Date().addingTimeInterval(5.0)
|
|
while !done && Date() < deadline {
|
|
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
|
}
|
|
|
|
guard done else {
|
|
return .err(code: "timeout", message: "Timed out waiting for snapshot", data: nil)
|
|
}
|
|
guard let imageData else {
|
|
return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil)
|
|
}
|
|
|
|
return .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),
|
|
"png_base64": imageData.base64EncodedString()
|
|
])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserGetText(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "get.text") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
return { ok: true, value: String(el.innerText || el.textContent || '') };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserGetHTML(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "get.html") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
return { ok: true, value: String(el.outerHTML || '') };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserGetValue(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "get.value") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
const value = ('value' in el) ? el.value : (el.textContent || '');
|
|
return { ok: true, value: String(value || '') };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserGetAttr(params: [String: Any]) -> V2CallResult {
|
|
guard let attr = v2String(params, "attr") ?? v2String(params, "name") else {
|
|
return .err(code: "invalid_params", message: "Missing attr/name", data: nil)
|
|
}
|
|
return v2BrowserSelectorAction(params: params, actionName: "get.attr") { selectorLiteral in
|
|
let attrLiteral = v2JSONLiteral(attr)
|
|
return """
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
return { ok: true, value: el.getAttribute(String(\(attrLiteral))) };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserGetTitle(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
.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),
|
|
"title": browserPanel.pageTitle
|
|
])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserGetCount(params: [String: Any]) -> V2CallResult {
|
|
guard let selectorRaw = v2BrowserSelector(params) else {
|
|
return .err(code: "invalid_params", message: "Missing selector", data: nil)
|
|
}
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
|
|
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
|
|
}
|
|
let selectorLiteral = v2JSONLiteral(selector)
|
|
let script = "document.querySelectorAll(\(selectorLiteral)).length"
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
let count = (value as? NSNumber)?.intValue ?? 0
|
|
return .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),
|
|
"count": count
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserGetBox(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "get.box") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
const r = el.getBoundingClientRect();
|
|
return { ok: true, value: { x: r.x, y: r.y, width: r.width, height: r.height, top: r.top, left: r.left, right: r.right, bottom: r.bottom } };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserGetStyles(params: [String: Any]) -> V2CallResult {
|
|
let property = v2String(params, "property")
|
|
return v2BrowserSelectorAction(params: params, actionName: "get.styles") { selectorLiteral in
|
|
if let property {
|
|
let propLiteral = v2JSONLiteral(property)
|
|
return """
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
const style = getComputedStyle(el);
|
|
return { ok: true, value: style.getPropertyValue(String(\(propLiteral))) };
|
|
})()
|
|
"""
|
|
}
|
|
return """
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
const style = getComputedStyle(el);
|
|
return { ok: true, value: {
|
|
display: style.display,
|
|
visibility: style.visibility,
|
|
opacity: style.opacity,
|
|
color: style.color,
|
|
background: style.background,
|
|
width: style.width,
|
|
height: style.height
|
|
} };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserIsVisible(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "is.visible") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
const style = getComputedStyle(el);
|
|
const rect = el.getBoundingClientRect();
|
|
const visible = style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity || '1') > 0 && rect.width > 0 && rect.height > 0;
|
|
return { ok: true, value: visible };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserIsEnabled(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "is.enabled") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
const enabled = !el.disabled;
|
|
return { ok: true, value: !!enabled };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserIsChecked(params: [String: Any]) -> V2CallResult {
|
|
v2BrowserSelectorAction(params: params, actionName: "is.checked") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
const checked = ('checked' in el) ? !!el.checked : false;
|
|
return { ok: true, value: checked };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
|
|
private func v2BrowserNavSimple(params: [String: Any], action: String) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let surfaceId = v2UUID(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "not_found", message: "Surface not found or not a browser", data: ["surface_id": surfaceId.uuidString])
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager),
|
|
let browserPanel = ws.browserPanel(for: surfaceId) else { return }
|
|
switch action {
|
|
case "back":
|
|
browserPanel.goBack()
|
|
case "forward":
|
|
browserPanel.goForward()
|
|
case "reload":
|
|
browserPanel.reload()
|
|
default:
|
|
break
|
|
}
|
|
var payload: [String: Any] = [
|
|
"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))
|
|
]
|
|
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
|
|
result = .ok(payload)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2BrowserGetURL(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let surfaceId = v2UUID(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "not_found", message: "Surface not found or not a browser", data: ["surface_id": surfaceId.uuidString])
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager),
|
|
let browserPanel = ws.browserPanel(for: surfaceId) else { return }
|
|
result = .ok([
|
|
"workspace_id": ws.id.uuidString,
|
|
"surface_id": surfaceId.uuidString,
|
|
"url": browserPanel.currentURL?.absoluteString ?? ""
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2BrowserFocusWebView(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let surfaceId = v2UUID(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "not_found", message: "Surface not found or not a browser", data: ["surface_id": surfaceId.uuidString])
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager),
|
|
let browserPanel = ws.browserPanel(for: surfaceId) else { return }
|
|
|
|
v2MaybeFocusWindow(for: tabManager)
|
|
v2MaybeSelectWorkspace(tabManager, workspace: ws)
|
|
|
|
// Prevent omnibar auto-focus from immediately stealing first responder back.
|
|
browserPanel.suppressOmnibarAutofocus(for: 1.0)
|
|
|
|
let webView = browserPanel.webView
|
|
guard let window = webView.window else {
|
|
result = .err(code: "invalid_state", message: "WebView is not in a window", data: nil)
|
|
return
|
|
}
|
|
guard !webView.isHiddenOrHasHiddenAncestor else {
|
|
result = .err(code: "invalid_state", message: "WebView is hidden", data: nil)
|
|
return
|
|
}
|
|
|
|
window.makeFirstResponder(webView)
|
|
if let fr = window.firstResponder as? NSView, fr.isDescendant(of: webView) {
|
|
result = .ok(["focused": true])
|
|
} else {
|
|
result = .err(code: "internal_error", message: "Focus did not move into web view", data: nil)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2BrowserIsWebViewFocused(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
guard let surfaceId = v2UUID(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
|
|
}
|
|
|
|
var focused = false
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager),
|
|
let browserPanel = ws.browserPanel(for: surfaceId) else { return }
|
|
let webView = browserPanel.webView
|
|
guard let window = webView.window,
|
|
let fr = window.firstResponder as? NSView else {
|
|
focused = false
|
|
return
|
|
}
|
|
focused = fr.isDescendant(of: webView)
|
|
}
|
|
return .ok(["focused": focused])
|
|
}
|
|
|
|
private func v2BrowserFindWithScript(
|
|
params: [String: Any],
|
|
actionName: String,
|
|
finderBody: String,
|
|
metadata: [String: Any] = [:]
|
|
) -> V2CallResult {
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let script = """
|
|
(() => {
|
|
const __cmuxCssPath = (el) => {
|
|
if (!el || el.nodeType !== 1) return null;
|
|
if (el.id) return '#' + CSS.escape(el.id);
|
|
const parts = [];
|
|
let cur = el;
|
|
while (cur && cur.nodeType === 1) {
|
|
let part = String(cur.tagName || '').toLowerCase();
|
|
if (!part) break;
|
|
if (cur.id) {
|
|
part += '#' + CSS.escape(cur.id);
|
|
parts.unshift(part);
|
|
break;
|
|
}
|
|
const tag = part;
|
|
let siblings = cur.parentElement ? Array.from(cur.parentElement.children).filter((n) => String(n.tagName || '').toLowerCase() === tag) : [];
|
|
if (siblings.length > 1) {
|
|
const pos = siblings.indexOf(cur) + 1;
|
|
part += `:nth-of-type(${pos})`;
|
|
}
|
|
parts.unshift(part);
|
|
cur = cur.parentElement;
|
|
}
|
|
return parts.join(' > ');
|
|
};
|
|
|
|
const __cmuxFound = (() => {
|
|
\(finderBody)
|
|
})();
|
|
if (!__cmuxFound) return { ok: false, error: 'not_found' };
|
|
const selector = __cmuxCssPath(__cmuxFound);
|
|
if (!selector) return { ok: false, error: 'not_found' };
|
|
return {
|
|
ok: true,
|
|
selector,
|
|
tag: String(__cmuxFound.tagName || '').toLowerCase(),
|
|
text: String(__cmuxFound.textContent || '').trim()
|
|
};
|
|
})()
|
|
"""
|
|
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: ["action": actionName])
|
|
case .success(let value):
|
|
guard let dict = value as? [String: Any],
|
|
let ok = dict["ok"] as? Bool,
|
|
ok,
|
|
let selector = dict["selector"] as? String,
|
|
!selector.isEmpty else {
|
|
return .err(code: "not_found", message: "Element not found", data: metadata)
|
|
}
|
|
|
|
let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: selector)
|
|
var payload: [String: Any] = [
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"surface_id": surfaceId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
|
|
"action": actionName,
|
|
"selector": selector,
|
|
"element_ref": ref,
|
|
"ref": ref
|
|
]
|
|
for (k, v) in metadata {
|
|
payload[k] = v
|
|
}
|
|
if let tag = dict["tag"] as? String {
|
|
payload["tag"] = tag
|
|
}
|
|
if let text = dict["text"] as? String {
|
|
payload["text"] = text
|
|
}
|
|
return .ok(payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserFindRole(params: [String: Any]) -> V2CallResult {
|
|
guard let role = (v2String(params, "role") ?? v2String(params, "value"))?.lowercased() else {
|
|
return .err(code: "invalid_params", message: "Missing role", data: nil)
|
|
}
|
|
let name = v2String(params, "name")?.lowercased()
|
|
let exact = v2Bool(params, "exact") ?? false
|
|
let roleLiteral = v2JSONLiteral(role)
|
|
let nameLiteral = name.map(v2JSONLiteral) ?? "null"
|
|
let exactLiteral = exact ? "true" : "false"
|
|
|
|
let finder = """
|
|
const __targetRole = String(\(roleLiteral)).toLowerCase();
|
|
const __targetName = \(nameLiteral);
|
|
const __exact = \(exactLiteral);
|
|
const __implicitRole = (el) => {
|
|
const tag = String(el.tagName || '').toLowerCase();
|
|
if (tag === 'button') return 'button';
|
|
if (tag === 'a' && el.hasAttribute('href')) return 'link';
|
|
if (tag === 'input') {
|
|
const type = String(el.getAttribute('type') || 'text').toLowerCase();
|
|
if (type === 'checkbox') return 'checkbox';
|
|
if (type === 'radio') return 'radio';
|
|
if (type === 'submit' || type === 'button') return 'button';
|
|
return 'textbox';
|
|
}
|
|
if (tag === 'textarea') return 'textbox';
|
|
if (tag === 'select') return 'combobox';
|
|
return null;
|
|
};
|
|
const __nameFor = (el) => {
|
|
const aria = String(el.getAttribute('aria-label') || '').trim();
|
|
if (aria) return aria.toLowerCase();
|
|
const labelledBy = String(el.getAttribute('aria-labelledby') || '').trim();
|
|
if (labelledBy) {
|
|
const text = labelledBy.split(/\\s+/).map((id) => document.getElementById(id)).filter(Boolean).map((n) => String(n.textContent || '').trim()).join(' ').trim();
|
|
if (text) return text.toLowerCase();
|
|
}
|
|
const txt = String(el.innerText || el.textContent || '').trim();
|
|
if (txt) return txt.toLowerCase();
|
|
if ('value' in el) {
|
|
const v = String(el.value || '').trim();
|
|
if (v) return v.toLowerCase();
|
|
}
|
|
return '';
|
|
};
|
|
const __nodes = Array.from(document.querySelectorAll('*'));
|
|
return __nodes.find((el) => {
|
|
const explicit = String(el.getAttribute('role') || '').toLowerCase();
|
|
const resolved = explicit || __implicitRole(el) || '';
|
|
if (resolved !== __targetRole) return false;
|
|
if (__targetName == null) return true;
|
|
const currentName = __nameFor(el);
|
|
return __exact ? (currentName === __targetName) : currentName.includes(__targetName);
|
|
}) || null;
|
|
"""
|
|
|
|
return v2BrowserFindWithScript(
|
|
params: params,
|
|
actionName: "find.role",
|
|
finderBody: finder,
|
|
metadata: [
|
|
"role": role,
|
|
"name": v2OrNull(name),
|
|
"exact": exact
|
|
]
|
|
)
|
|
}
|
|
|
|
private func v2BrowserFindText(params: [String: Any]) -> V2CallResult {
|
|
guard let text = (v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else {
|
|
return .err(code: "invalid_params", message: "Missing text", data: nil)
|
|
}
|
|
let exact = v2Bool(params, "exact") ?? false
|
|
let textLiteral = v2JSONLiteral(text)
|
|
let exactLiteral = exact ? "true" : "false"
|
|
|
|
let finder = """
|
|
const __target = String(\(textLiteral));
|
|
const __exact = \(exactLiteral);
|
|
const __norm = (s) => String(s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
|
const __nodes = Array.from(document.querySelectorAll('body *'));
|
|
return __nodes.find((el) => {
|
|
const v = __norm(el.innerText || el.textContent || '');
|
|
if (!v) return false;
|
|
return __exact ? (v === __target) : v.includes(__target);
|
|
}) || null;
|
|
"""
|
|
|
|
return v2BrowserFindWithScript(
|
|
params: params,
|
|
actionName: "find.text",
|
|
finderBody: finder,
|
|
metadata: ["text": text, "exact": exact]
|
|
)
|
|
}
|
|
|
|
private func v2BrowserFindLabel(params: [String: Any]) -> V2CallResult {
|
|
guard let label = (v2String(params, "label") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else {
|
|
return .err(code: "invalid_params", message: "Missing label", data: nil)
|
|
}
|
|
let exact = v2Bool(params, "exact") ?? false
|
|
let labelLiteral = v2JSONLiteral(label)
|
|
let exactLiteral = exact ? "true" : "false"
|
|
|
|
let finder = """
|
|
const __target = String(\(labelLiteral));
|
|
const __exact = \(exactLiteral);
|
|
const __norm = (s) => String(s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
|
const __labels = Array.from(document.querySelectorAll('label'));
|
|
const __label = __labels.find((el) => {
|
|
const v = __norm(el.innerText || el.textContent || '');
|
|
return __exact ? (v === __target) : v.includes(__target);
|
|
});
|
|
if (!__label) return null;
|
|
const htmlFor = String(__label.getAttribute('for') || '').trim();
|
|
if (htmlFor) {
|
|
return document.getElementById(htmlFor);
|
|
}
|
|
return __label.querySelector('input,textarea,select,button,[contenteditable="true"]');
|
|
"""
|
|
|
|
return v2BrowserFindWithScript(
|
|
params: params,
|
|
actionName: "find.label",
|
|
finderBody: finder,
|
|
metadata: ["label": label, "exact": exact]
|
|
)
|
|
}
|
|
|
|
private func v2BrowserFindPlaceholder(params: [String: Any]) -> V2CallResult {
|
|
guard let placeholder = (v2String(params, "placeholder") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else {
|
|
return .err(code: "invalid_params", message: "Missing placeholder", data: nil)
|
|
}
|
|
let exact = v2Bool(params, "exact") ?? false
|
|
let placeholderLiteral = v2JSONLiteral(placeholder)
|
|
let exactLiteral = exact ? "true" : "false"
|
|
|
|
let finder = """
|
|
const __target = String(\(placeholderLiteral));
|
|
const __exact = \(exactLiteral);
|
|
const __nodes = Array.from(document.querySelectorAll('[placeholder]'));
|
|
return __nodes.find((el) => {
|
|
const p = String(el.getAttribute('placeholder') || '').trim().toLowerCase();
|
|
if (!p) return false;
|
|
return __exact ? (p === __target) : p.includes(__target);
|
|
}) || null;
|
|
"""
|
|
|
|
return v2BrowserFindWithScript(
|
|
params: params,
|
|
actionName: "find.placeholder",
|
|
finderBody: finder,
|
|
metadata: ["placeholder": placeholder, "exact": exact]
|
|
)
|
|
}
|
|
|
|
private func v2BrowserFindAlt(params: [String: Any]) -> V2CallResult {
|
|
guard let alt = (v2String(params, "alt") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else {
|
|
return .err(code: "invalid_params", message: "Missing alt text", data: nil)
|
|
}
|
|
let exact = v2Bool(params, "exact") ?? false
|
|
let altLiteral = v2JSONLiteral(alt)
|
|
let exactLiteral = exact ? "true" : "false"
|
|
|
|
let finder = """
|
|
const __target = String(\(altLiteral));
|
|
const __exact = \(exactLiteral);
|
|
const __nodes = Array.from(document.querySelectorAll('[alt]'));
|
|
return __nodes.find((el) => {
|
|
const a = String(el.getAttribute('alt') || '').trim().toLowerCase();
|
|
if (!a) return false;
|
|
return __exact ? (a === __target) : a.includes(__target);
|
|
}) || null;
|
|
"""
|
|
|
|
return v2BrowserFindWithScript(
|
|
params: params,
|
|
actionName: "find.alt",
|
|
finderBody: finder,
|
|
metadata: ["alt": alt, "exact": exact]
|
|
)
|
|
}
|
|
|
|
private func v2BrowserFindTitle(params: [String: Any]) -> V2CallResult {
|
|
guard let title = (v2String(params, "title") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else {
|
|
return .err(code: "invalid_params", message: "Missing title", data: nil)
|
|
}
|
|
let exact = v2Bool(params, "exact") ?? false
|
|
let titleLiteral = v2JSONLiteral(title)
|
|
let exactLiteral = exact ? "true" : "false"
|
|
|
|
let finder = """
|
|
const __target = String(\(titleLiteral));
|
|
const __exact = \(exactLiteral);
|
|
const __nodes = Array.from(document.querySelectorAll('[title]'));
|
|
return __nodes.find((el) => {
|
|
const t = String(el.getAttribute('title') || '').trim().toLowerCase();
|
|
if (!t) return false;
|
|
return __exact ? (t === __target) : t.includes(__target);
|
|
}) || null;
|
|
"""
|
|
|
|
return v2BrowserFindWithScript(
|
|
params: params,
|
|
actionName: "find.title",
|
|
finderBody: finder,
|
|
metadata: ["title": title, "exact": exact]
|
|
)
|
|
}
|
|
|
|
private func v2BrowserFindTestId(params: [String: Any]) -> V2CallResult {
|
|
guard let testId = v2String(params, "testid") ?? v2String(params, "test_id") ?? v2String(params, "value") else {
|
|
return .err(code: "invalid_params", message: "Missing testid", data: nil)
|
|
}
|
|
let testIdLiteral = v2JSONLiteral(testId)
|
|
|
|
let finder = """
|
|
const __target = String(\(testIdLiteral));
|
|
const __selectors = ['[data-testid]', '[data-test-id]', '[data-test]'];
|
|
for (const sel of __selectors) {
|
|
const nodes = Array.from(document.querySelectorAll(sel));
|
|
const found = nodes.find((el) => {
|
|
return String(el.getAttribute('data-testid') || el.getAttribute('data-test-id') || el.getAttribute('data-test') || '') === __target;
|
|
});
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
"""
|
|
|
|
return v2BrowserFindWithScript(
|
|
params: params,
|
|
actionName: "find.testid",
|
|
finderBody: finder,
|
|
metadata: ["testid": testId]
|
|
)
|
|
}
|
|
|
|
private func v2BrowserFindFirst(params: [String: Any]) -> V2CallResult {
|
|
guard let selectorRaw = v2BrowserSelector(params) else {
|
|
return .err(code: "invalid_params", message: "Missing selector", data: nil)
|
|
}
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
|
|
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
|
|
}
|
|
let selectorLiteral = v2JSONLiteral(selector)
|
|
let script = """
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
return { ok: true, selector: \(selectorLiteral), text: String(el.textContent || '').trim() };
|
|
})()
|
|
"""
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
guard let dict = value as? [String: Any],
|
|
let ok = dict["ok"] as? Bool,
|
|
ok else {
|
|
return .err(code: "not_found", message: "Element not found", data: ["selector": selector])
|
|
}
|
|
let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: selector)
|
|
return .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),
|
|
"selector": selector,
|
|
"element_ref": ref,
|
|
"ref": ref,
|
|
"text": v2OrNull(dict["text"])
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserFindLast(params: [String: Any]) -> V2CallResult {
|
|
guard let selectorRaw = v2BrowserSelector(params) else {
|
|
return .err(code: "invalid_params", message: "Missing selector", data: nil)
|
|
}
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
|
|
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
|
|
}
|
|
let selectorLiteral = v2JSONLiteral(selector)
|
|
let script = """
|
|
(() => {
|
|
const list = document.querySelectorAll(\(selectorLiteral));
|
|
if (!list || list.length === 0) return { ok: false, error: 'not_found' };
|
|
const idx = list.length - 1;
|
|
const el = list[idx];
|
|
const finalSelector = `${\(selectorLiteral)}:nth-of-type(${idx + 1})`;
|
|
return { ok: true, selector: finalSelector, text: String(el.textContent || '').trim() };
|
|
})()
|
|
"""
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
guard let dict = value as? [String: Any],
|
|
let ok = dict["ok"] as? Bool,
|
|
ok,
|
|
let finalSelector = dict["selector"] as? String,
|
|
!finalSelector.isEmpty else {
|
|
return .err(code: "not_found", message: "Element not found", data: ["selector": selector])
|
|
}
|
|
let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: finalSelector)
|
|
return .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),
|
|
"selector": finalSelector,
|
|
"element_ref": ref,
|
|
"ref": ref,
|
|
"text": v2OrNull(dict["text"])
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserFindNth(params: [String: Any]) -> V2CallResult {
|
|
guard let selectorRaw = v2BrowserSelector(params) else {
|
|
return .err(code: "invalid_params", message: "Missing selector", data: nil)
|
|
}
|
|
guard let index = v2Int(params, "index") ?? v2Int(params, "nth") else {
|
|
return .err(code: "invalid_params", message: "Missing index", data: nil)
|
|
}
|
|
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
|
|
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
|
|
}
|
|
let selectorLiteral = v2JSONLiteral(selector)
|
|
let script = """
|
|
(() => {
|
|
const list = Array.from(document.querySelectorAll(\(selectorLiteral)));
|
|
if (!list.length) return { ok: false, error: 'not_found' };
|
|
let idx = \(index);
|
|
if (idx < 0) idx = list.length + idx;
|
|
if (idx < 0 || idx >= list.length) return { ok: false, error: 'not_found' };
|
|
const el = list[idx];
|
|
const nth = idx + 1;
|
|
const finalSelector = `${\(selectorLiteral)}:nth-of-type(${nth})`;
|
|
return { ok: true, selector: finalSelector, index: idx, text: String(el.textContent || '').trim() };
|
|
})()
|
|
"""
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
guard let dict = value as? [String: Any],
|
|
let ok = dict["ok"] as? Bool,
|
|
ok,
|
|
let finalSelector = dict["selector"] as? String,
|
|
!finalSelector.isEmpty else {
|
|
return .err(code: "not_found", message: "Element not found", data: ["selector": selector, "index": index])
|
|
}
|
|
let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: finalSelector)
|
|
return .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),
|
|
"selector": finalSelector,
|
|
"element_ref": ref,
|
|
"ref": ref,
|
|
"index": v2OrNull(dict["index"]),
|
|
"text": v2OrNull(dict["text"])
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserFrameSelect(params: [String: Any]) -> V2CallResult {
|
|
guard let selectorRaw = v2BrowserSelector(params) else {
|
|
return .err(code: "invalid_params", message: "Missing selector", data: nil)
|
|
}
|
|
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
|
|
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
|
|
}
|
|
let selectorLiteral = v2JSONLiteral(selector)
|
|
let script = """
|
|
(() => {
|
|
const frame = document.querySelector(\(selectorLiteral));
|
|
if (!frame) return { ok: false, error: 'not_found' };
|
|
if (!('contentDocument' in frame)) return { ok: false, error: 'not_frame' };
|
|
try {
|
|
const sameOrigin = !!frame.contentDocument;
|
|
if (!sameOrigin) return { ok: false, error: 'cross_origin' };
|
|
} catch (_) {
|
|
return { ok: false, error: 'cross_origin' };
|
|
}
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
if let dict = value as? [String: Any],
|
|
let ok = dict["ok"] as? Bool,
|
|
ok {
|
|
v2BrowserFrameSelectorBySurface[surfaceId] = selector
|
|
return .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),
|
|
"frame_selector": selector
|
|
])
|
|
}
|
|
if let dict = value as? [String: Any],
|
|
let errorText = dict["error"] as? String,
|
|
errorText == "cross_origin" {
|
|
return .err(code: "not_supported", message: "Cross-origin iframe control is not supported", data: ["selector": selector])
|
|
}
|
|
return .err(code: "not_found", message: "Frame not found", data: ["selector": selector])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserFrameMain(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, _ in
|
|
v2BrowserFrameSelectorBySurface.removeValue(forKey: surfaceId)
|
|
return .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),
|
|
"frame_selector": NSNull()
|
|
])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserEnsureTelemetryHooks(surfaceId _: UUID, browserPanel: BrowserPanel) {
|
|
_ = v2RunJavaScript(
|
|
browserPanel.webView,
|
|
script: BrowserPanel.telemetryHookBootstrapScriptSource,
|
|
timeout: 5.0
|
|
)
|
|
}
|
|
|
|
private func v2BrowserEnsureDialogHooks(browserPanel: BrowserPanel) {
|
|
_ = v2RunJavaScript(
|
|
browserPanel.webView,
|
|
script: BrowserPanel.dialogTelemetryHookBootstrapScriptSource,
|
|
timeout: 5.0
|
|
)
|
|
}
|
|
|
|
private func v2BrowserDialogRespond(params: [String: Any], accept: Bool) -> V2CallResult {
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
v2BrowserEnsureTelemetryHooks(surfaceId: surfaceId, browserPanel: browserPanel)
|
|
v2BrowserEnsureDialogHooks(browserPanel: browserPanel)
|
|
let text = v2String(params, "text") ?? v2String(params, "prompt_text")
|
|
let acceptLiteral = accept ? "true" : "false"
|
|
let textLiteral = text.map(v2JSONLiteral) ?? "null"
|
|
let script = """
|
|
(() => {
|
|
const q = window.__cmuxDialogQueue || [];
|
|
if (!q.length) return { ok: false, error: 'not_found' };
|
|
const entry = q.shift();
|
|
if (entry.type === 'confirm') {
|
|
window.__cmuxDialogDefaults = window.__cmuxDialogDefaults || { confirm: false, prompt: null };
|
|
window.__cmuxDialogDefaults.confirm = \(acceptLiteral);
|
|
}
|
|
if (entry.type === 'prompt') {
|
|
window.__cmuxDialogDefaults = window.__cmuxDialogDefaults || { confirm: false, prompt: null };
|
|
if (\(acceptLiteral)) {
|
|
window.__cmuxDialogDefaults.prompt = \(textLiteral);
|
|
} else {
|
|
window.__cmuxDialogDefaults.prompt = null;
|
|
}
|
|
}
|
|
return { ok: true, dialog: entry, remaining: q.length };
|
|
})()
|
|
"""
|
|
|
|
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
guard let dict = value as? [String: Any],
|
|
let ok = dict["ok"] as? Bool,
|
|
ok else {
|
|
let pending = v2BrowserPendingDialogs(surfaceId: surfaceId)
|
|
return .err(code: "not_found", message: "No pending dialog", data: ["pending": pending])
|
|
}
|
|
|
|
return .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),
|
|
"accepted": accept,
|
|
"dialog": v2NormalizeJSValue(dict["dialog"]),
|
|
"remaining": v2OrNull(dict["remaining"])
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserDownloadWait(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, _ in
|
|
let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? v2Int(params, "timeout") ?? 10_000)
|
|
let timeout = Double(timeoutMs) / 1000.0
|
|
let path = v2String(params, "path")
|
|
|
|
if let path {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
let fm = FileManager.default
|
|
while Date() < deadline {
|
|
if fm.fileExists(atPath: path),
|
|
let attrs = try? fm.attributesOfItem(atPath: path),
|
|
let size = attrs[.size] as? NSNumber,
|
|
size.intValue > 0 {
|
|
return .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),
|
|
"path": path,
|
|
"downloaded": true
|
|
])
|
|
}
|
|
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
|
|
}
|
|
return .err(code: "timeout", message: "Timed out waiting for download file", data: ["path": path, "timeout_ms": timeoutMs])
|
|
}
|
|
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
let entries = v2BrowserDownloadEventsBySurface[surfaceId] ?? []
|
|
if let first = entries.first {
|
|
var remaining = entries
|
|
remaining.removeFirst()
|
|
v2BrowserDownloadEventsBySurface[surfaceId] = remaining
|
|
return .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),
|
|
"download": first
|
|
])
|
|
}
|
|
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
|
|
}
|
|
return .err(code: "timeout", message: "No download event observed", data: ["timeout_ms": timeoutMs])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserCookieDict(_ cookie: HTTPCookie) -> [String: Any] {
|
|
var out: [String: Any] = [
|
|
"name": cookie.name,
|
|
"value": cookie.value,
|
|
"domain": cookie.domain,
|
|
"path": cookie.path,
|
|
"secure": cookie.isSecure,
|
|
"session_only": cookie.isSessionOnly
|
|
]
|
|
if let expiresDate = cookie.expiresDate {
|
|
out["expires"] = Int(expiresDate.timeIntervalSince1970)
|
|
} else {
|
|
out["expires"] = NSNull()
|
|
}
|
|
return out
|
|
}
|
|
|
|
private func v2BrowserCookieStoreAll(_ store: WKHTTPCookieStore, timeout: TimeInterval = 3.0) -> [HTTPCookie]? {
|
|
var done = false
|
|
var cookies: [HTTPCookie] = []
|
|
store.getAllCookies { items in
|
|
cookies = items
|
|
done = true
|
|
}
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while !done && Date() < deadline {
|
|
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
|
}
|
|
return done ? cookies : nil
|
|
}
|
|
|
|
private func v2BrowserCookieStoreSet(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool {
|
|
var done = false
|
|
store.setCookie(cookie) {
|
|
done = true
|
|
}
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while !done && Date() < deadline {
|
|
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
|
}
|
|
return done
|
|
}
|
|
|
|
private func v2BrowserCookieStoreDelete(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool {
|
|
var done = false
|
|
store.delete(cookie) {
|
|
done = true
|
|
}
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while !done && Date() < deadline {
|
|
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
|
}
|
|
return done
|
|
}
|
|
|
|
private func v2BrowserCookieFromObject(_ raw: [String: Any], fallbackURL: URL?) -> HTTPCookie? {
|
|
var props: [HTTPCookiePropertyKey: Any] = [:]
|
|
if let name = raw["name"] as? String {
|
|
props[.name] = name
|
|
}
|
|
if let value = raw["value"] as? String {
|
|
props[.value] = value
|
|
}
|
|
|
|
if let urlStr = raw["url"] as? String, let url = URL(string: urlStr) {
|
|
props[.originURL] = url
|
|
} else if let fallbackURL {
|
|
props[.originURL] = fallbackURL
|
|
}
|
|
|
|
if let domain = raw["domain"] as? String {
|
|
props[.domain] = domain
|
|
} else if let host = fallbackURL?.host {
|
|
props[.domain] = host
|
|
}
|
|
|
|
if let path = raw["path"] as? String {
|
|
props[.path] = path
|
|
} else {
|
|
props[.path] = "/"
|
|
}
|
|
|
|
if let secure = raw["secure"] as? Bool, secure {
|
|
props[.secure] = "TRUE"
|
|
}
|
|
if let expires = raw["expires"] as? TimeInterval {
|
|
props[.expires] = Date(timeIntervalSince1970: expires)
|
|
} else if let expiresInt = raw["expires"] as? Int {
|
|
props[.expires] = Date(timeIntervalSince1970: TimeInterval(expiresInt))
|
|
}
|
|
|
|
return HTTPCookie(properties: props)
|
|
}
|
|
|
|
private func v2BrowserCookiesGet(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let store = browserPanel.webView.configuration.websiteDataStore.httpCookieStore
|
|
guard var cookies = v2BrowserCookieStoreAll(store) else {
|
|
return .err(code: "timeout", message: "Timed out reading cookies", data: nil)
|
|
}
|
|
|
|
if let name = v2String(params, "name") {
|
|
cookies = cookies.filter { $0.name == name }
|
|
}
|
|
if let domain = v2String(params, "domain") {
|
|
cookies = cookies.filter { $0.domain.contains(domain) }
|
|
}
|
|
if let path = v2String(params, "path") {
|
|
cookies = cookies.filter { $0.path == path }
|
|
}
|
|
|
|
return .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),
|
|
"cookies": cookies.map(v2BrowserCookieDict)
|
|
])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserCookiesSet(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let store = browserPanel.webView.configuration.websiteDataStore.httpCookieStore
|
|
let fallbackURL = browserPanel.currentURL
|
|
|
|
var cookieObjects: [[String: Any]] = []
|
|
if let rows = params["cookies"] as? [[String: Any]] {
|
|
cookieObjects = rows
|
|
} else {
|
|
var single: [String: Any] = [:]
|
|
if let name = v2String(params, "name") { single["name"] = name }
|
|
if let value = v2String(params, "value") { single["value"] = value }
|
|
if let url = v2String(params, "url") { single["url"] = url }
|
|
if let domain = v2String(params, "domain") { single["domain"] = domain }
|
|
if let path = v2String(params, "path") { single["path"] = path }
|
|
if let secure = v2Bool(params, "secure") { single["secure"] = secure }
|
|
if let expires = v2Int(params, "expires") { single["expires"] = expires }
|
|
if !single.isEmpty {
|
|
cookieObjects = [single]
|
|
}
|
|
}
|
|
|
|
guard !cookieObjects.isEmpty else {
|
|
return .err(code: "invalid_params", message: "Missing cookies payload", data: nil)
|
|
}
|
|
|
|
var setCount = 0
|
|
for raw in cookieObjects {
|
|
guard let cookie = v2BrowserCookieFromObject(raw, fallbackURL: fallbackURL) else {
|
|
return .err(code: "invalid_params", message: "Invalid cookie payload", data: ["cookie": raw])
|
|
}
|
|
if v2BrowserCookieStoreSet(store, cookie: cookie) {
|
|
setCount += 1
|
|
} else {
|
|
return .err(code: "timeout", message: "Timed out setting cookie", data: ["name": cookie.name])
|
|
}
|
|
}
|
|
|
|
return .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),
|
|
"set": setCount
|
|
])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserCookiesClear(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let store = browserPanel.webView.configuration.websiteDataStore.httpCookieStore
|
|
guard let cookies = v2BrowserCookieStoreAll(store) else {
|
|
return .err(code: "timeout", message: "Timed out reading cookies", data: nil)
|
|
}
|
|
|
|
let name = v2String(params, "name")
|
|
let domain = v2String(params, "domain")
|
|
let clearAll = params["all"] == nil && name == nil && domain == nil
|
|
let targets = cookies.filter { cookie in
|
|
if clearAll { return true }
|
|
if let name, cookie.name != name { return false }
|
|
if let domain, !cookie.domain.contains(domain) { return false }
|
|
return true
|
|
}
|
|
|
|
var removed = 0
|
|
for cookie in targets {
|
|
if v2BrowserCookieStoreDelete(store, cookie: cookie) {
|
|
removed += 1
|
|
}
|
|
}
|
|
|
|
return .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),
|
|
"cleared": removed
|
|
])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserStorageType(_ params: [String: Any]) -> String {
|
|
let type = (v2String(params, "storage") ?? v2String(params, "type") ?? "local").lowercased()
|
|
return (type == "session") ? "session" : "local"
|
|
}
|
|
|
|
private func v2BrowserStorageGet(params: [String: Any]) -> V2CallResult {
|
|
let storageType = v2BrowserStorageType(params)
|
|
let key = v2String(params, "key")
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let typeLiteral = v2JSONLiteral(storageType)
|
|
let keyLiteral = key.map(v2JSONLiteral) ?? "null"
|
|
let script = """
|
|
(() => {
|
|
const type = String(\(typeLiteral));
|
|
const key = \(keyLiteral);
|
|
const st = type === 'session' ? window.sessionStorage : window.localStorage;
|
|
if (!st) return { ok: false, error: 'not_available' };
|
|
if (key == null) {
|
|
const out = {};
|
|
for (let i = 0; i < st.length; i++) {
|
|
const k = st.key(i);
|
|
out[k] = st.getItem(k);
|
|
}
|
|
return { ok: true, value: out };
|
|
}
|
|
return { ok: true, value: st.getItem(String(key)) };
|
|
})()
|
|
"""
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
guard let dict = value as? [String: Any],
|
|
let ok = dict["ok"] as? Bool,
|
|
ok else {
|
|
return .err(code: "invalid_state", message: "Storage unavailable", data: ["type": storageType])
|
|
}
|
|
return .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),
|
|
"type": storageType,
|
|
"key": v2OrNull(key),
|
|
"value": v2NormalizeJSValue(dict["value"])
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserStorageSet(params: [String: Any]) -> V2CallResult {
|
|
let storageType = v2BrowserStorageType(params)
|
|
guard let key = v2String(params, "key") else {
|
|
return .err(code: "invalid_params", message: "Missing key", data: nil)
|
|
}
|
|
guard let value = params["value"] else {
|
|
return .err(code: "invalid_params", message: "Missing value", data: nil)
|
|
}
|
|
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let typeLiteral = v2JSONLiteral(storageType)
|
|
let keyLiteral = v2JSONLiteral(key)
|
|
let valueLiteral = v2JSONLiteral(v2NormalizeJSValue(value))
|
|
let script = """
|
|
(() => {
|
|
const type = String(\(typeLiteral));
|
|
const key = String(\(keyLiteral));
|
|
const value = \(valueLiteral);
|
|
const st = type === 'session' ? window.sessionStorage : window.localStorage;
|
|
if (!st) return { ok: false, error: 'not_available' };
|
|
st.setItem(key, value == null ? '' : String(value));
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
guard let dict = value as? [String: Any],
|
|
let ok = dict["ok"] as? Bool,
|
|
ok else {
|
|
return .err(code: "invalid_state", message: "Storage unavailable", data: ["type": storageType])
|
|
}
|
|
return .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),
|
|
"type": storageType,
|
|
"key": key
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserStorageClear(params: [String: Any]) -> V2CallResult {
|
|
let storageType = v2BrowserStorageType(params)
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let typeLiteral = v2JSONLiteral(storageType)
|
|
let script = """
|
|
(() => {
|
|
const type = String(\(typeLiteral));
|
|
const st = type === 'session' ? window.sessionStorage : window.localStorage;
|
|
if (!st) return { ok: false, error: 'not_available' };
|
|
st.clear();
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
guard let dict = value as? [String: Any],
|
|
let ok = dict["ok"] as? Bool,
|
|
ok else {
|
|
return .err(code: "invalid_state", message: "Storage unavailable", data: ["type": storageType])
|
|
}
|
|
return .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),
|
|
"type": storageType,
|
|
"cleared": true
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserTabList(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var payload: [String: Any]?
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
|
|
let browserPanels = orderedPanels(in: ws).compactMap { panel -> BrowserPanel? in
|
|
panel as? BrowserPanel
|
|
}
|
|
let tabs: [[String: Any]] = browserPanels.enumerated().map { index, panel in
|
|
[
|
|
"id": panel.id.uuidString,
|
|
"ref": v2Ref(kind: .surface, uuid: panel.id),
|
|
"index": index,
|
|
"title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle,
|
|
"url": panel.currentURL?.absoluteString ?? "",
|
|
"focused": panel.id == ws.focusedPanelId,
|
|
"pane_id": v2OrNull(ws.paneId(forPanelId: panel.id)?.id.uuidString),
|
|
"pane_ref": v2Ref(kind: .pane, uuid: ws.paneId(forPanelId: panel.id)?.id)
|
|
]
|
|
}
|
|
payload = [
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"surface_id": v2OrNull(ws.focusedPanelId?.uuidString),
|
|
"surface_ref": v2Ref(kind: .surface, uuid: ws.focusedPanelId),
|
|
"tabs": tabs
|
|
]
|
|
}
|
|
|
|
guard let payload else {
|
|
return .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
}
|
|
return .ok(payload)
|
|
}
|
|
|
|
private func v2BrowserTabNew(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
let url = v2String(params, "url").flatMap(URL.init(string:))
|
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create browser tab", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
let paneUUID = v2UUID(params, "pane_id")
|
|
?? v2UUID(params, "target_pane_id")
|
|
?? (v2UUID(params, "surface_id").flatMap { ws.paneId(forPanelId: $0)?.id })
|
|
?? ws.paneId(forPanelId: ws.focusedPanelId ?? UUID())?.id
|
|
?? ws.bonsplitController.focusedPaneId?.id
|
|
guard let paneUUID,
|
|
let pane = ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) else {
|
|
result = .err(code: "not_found", message: "Target pane not found", data: nil)
|
|
return
|
|
}
|
|
|
|
guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: v2FocusAllowed()) else {
|
|
result = .err(code: "internal_error", message: "Failed to create browser tab", data: nil)
|
|
return
|
|
}
|
|
result = .ok([
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"pane_id": pane.id.uuidString,
|
|
"pane_ref": v2Ref(kind: .pane, uuid: pane.id),
|
|
"surface_id": panel.id.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: panel.id),
|
|
"url": panel.currentURL?.absoluteString ?? ""
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2BrowserTabSwitch(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "not_found", message: "Browser tab not found", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
|
|
let browserIds = orderedPanels(in: ws).compactMap { panel -> UUID? in
|
|
(panel as? BrowserPanel)?.id
|
|
}
|
|
|
|
let targetId: UUID? = {
|
|
if let explicit = v2UUID(params, "target_surface_id") ?? v2UUID(params, "tab_id") {
|
|
return explicit
|
|
}
|
|
if let idx = v2Int(params, "index"), idx >= 0, idx < browserIds.count {
|
|
return browserIds[idx]
|
|
}
|
|
return v2UUID(params, "surface_id")
|
|
}()
|
|
|
|
guard let targetId, browserIds.contains(targetId) else {
|
|
result = .err(code: "not_found", message: "Browser tab not found", data: nil)
|
|
return
|
|
}
|
|
|
|
ws.focusPanel(targetId)
|
|
result = .ok([
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"surface_id": targetId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: targetId)
|
|
])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2BrowserTabClose(params: [String: Any]) -> V2CallResult {
|
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "not_found", message: "Browser tab not found", data: nil)
|
|
v2MainSync {
|
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
|
return
|
|
}
|
|
|
|
let browserIds = orderedPanels(in: ws).compactMap { panel -> UUID? in
|
|
(panel as? BrowserPanel)?.id
|
|
}
|
|
guard !browserIds.isEmpty else {
|
|
result = .err(code: "not_found", message: "No browser tabs", data: nil)
|
|
return
|
|
}
|
|
|
|
let targetId: UUID? = {
|
|
if let explicit = v2UUID(params, "target_surface_id") ?? v2UUID(params, "tab_id") {
|
|
return explicit
|
|
}
|
|
if let idx = v2Int(params, "index"), idx >= 0, idx < browserIds.count {
|
|
return browserIds[idx]
|
|
}
|
|
if let sid = v2UUID(params, "surface_id") {
|
|
return sid
|
|
}
|
|
return ws.focusedPanelId
|
|
}()
|
|
|
|
guard let targetId, browserIds.contains(targetId) else {
|
|
result = .err(code: "not_found", message: "Browser tab not found", data: nil)
|
|
return
|
|
}
|
|
|
|
if ws.panels.count <= 1 {
|
|
result = .err(code: "invalid_state", message: "Cannot close the last surface", data: nil)
|
|
return
|
|
}
|
|
|
|
let ok = ws.closePanel(targetId, force: true)
|
|
result = ok
|
|
? .ok([
|
|
"workspace_id": ws.id.uuidString,
|
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
|
"surface_id": targetId.uuidString,
|
|
"surface_ref": v2Ref(kind: .surface, uuid: targetId)
|
|
])
|
|
: .err(code: "internal_error", message: "Failed to close browser tab", data: ["surface_id": targetId.uuidString])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2BrowserConsoleList(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
v2BrowserEnsureTelemetryHooks(surfaceId: surfaceId, browserPanel: browserPanel)
|
|
let clear = v2Bool(params, "clear") ?? false
|
|
let clearLiteral = clear ? "true" : "false"
|
|
let script = """
|
|
(() => {
|
|
const items = Array.isArray(window.__cmuxConsoleLog) ? window.__cmuxConsoleLog.slice() : [];
|
|
if (\(clearLiteral)) {
|
|
window.__cmuxConsoleLog = [];
|
|
}
|
|
return { ok: true, items };
|
|
})()
|
|
"""
|
|
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
let dict = value as? [String: Any]
|
|
let items = (dict?["items"] as? [Any]) ?? []
|
|
return .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),
|
|
"entries": items.map(v2NormalizeJSValue),
|
|
"count": items.count
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserConsoleClear(params: [String: Any]) -> V2CallResult {
|
|
var withClear = params
|
|
withClear["clear"] = true
|
|
return v2BrowserConsoleList(params: withClear)
|
|
}
|
|
|
|
private func v2BrowserErrorsList(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
v2BrowserEnsureTelemetryHooks(surfaceId: surfaceId, browserPanel: browserPanel)
|
|
let clear = v2Bool(params, "clear") ?? false
|
|
let clearLiteral = clear ? "true" : "false"
|
|
let script = """
|
|
(() => {
|
|
const items = Array.isArray(window.__cmuxErrorLog) ? window.__cmuxErrorLog.slice() : [];
|
|
if (\(clearLiteral)) {
|
|
window.__cmuxErrorLog = [];
|
|
}
|
|
return { ok: true, items };
|
|
})()
|
|
"""
|
|
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
let dict = value as? [String: Any]
|
|
let items = (dict?["items"] as? [Any]) ?? []
|
|
return .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),
|
|
"errors": items.map(v2NormalizeJSValue),
|
|
"count": items.count
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserHighlight(params: [String: Any]) -> V2CallResult {
|
|
return v2BrowserSelectorAction(params: params, actionName: "highlight") { selectorLiteral in
|
|
"""
|
|
(() => {
|
|
const el = document.querySelector(\(selectorLiteral));
|
|
if (!el) return { ok: false, error: 'not_found' };
|
|
const prev = el.style.outline;
|
|
const prevOffset = el.style.outlineOffset;
|
|
el.style.outline = '3px solid #ff9f0a';
|
|
el.style.outlineOffset = '2px';
|
|
setTimeout(() => {
|
|
el.style.outline = prev;
|
|
el.style.outlineOffset = prevOffset;
|
|
}, 1200);
|
|
return { ok: true };
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
private func v2BrowserStateSave(params: [String: Any]) -> V2CallResult {
|
|
guard let path = v2String(params, "path") else {
|
|
return .err(code: "invalid_params", message: "Missing path", data: nil)
|
|
}
|
|
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
let storageScript = """
|
|
(() => {
|
|
const readStorage = (st) => {
|
|
const out = {};
|
|
if (!st) return out;
|
|
for (let i = 0; i < st.length; i++) {
|
|
const k = st.key(i);
|
|
out[k] = st.getItem(k);
|
|
}
|
|
return out;
|
|
};
|
|
return {
|
|
local: readStorage(window.localStorage),
|
|
session: readStorage(window.sessionStorage)
|
|
};
|
|
})()
|
|
"""
|
|
|
|
let storageValue: Any
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: storageScript, timeout: 10.0) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
storageValue = v2NormalizeJSValue(value)
|
|
}
|
|
|
|
let store = browserPanel.webView.configuration.websiteDataStore.httpCookieStore
|
|
let cookies = (v2BrowserCookieStoreAll(store) ?? []).map(v2BrowserCookieDict)
|
|
|
|
let state: [String: Any] = [
|
|
"url": browserPanel.currentURL?.absoluteString ?? "",
|
|
"cookies": cookies,
|
|
"storage": storageValue,
|
|
"frame_selector": v2OrNull(v2BrowserFrameSelectorBySurface[surfaceId])
|
|
]
|
|
|
|
do {
|
|
let data = try JSONSerialization.data(withJSONObject: state, options: [.prettyPrinted, .sortedKeys])
|
|
try data.write(to: URL(fileURLWithPath: path), options: .atomic)
|
|
} catch {
|
|
return .err(code: "internal_error", message: "Failed to write state file", data: ["path": path, "error": error.localizedDescription])
|
|
}
|
|
|
|
return .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),
|
|
"path": path,
|
|
"cookies": cookies.count
|
|
])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserStateLoad(params: [String: Any]) -> V2CallResult {
|
|
guard let path = v2String(params, "path") else {
|
|
return .err(code: "invalid_params", message: "Missing path", data: nil)
|
|
}
|
|
|
|
let url = URL(fileURLWithPath: path)
|
|
let raw: [String: Any]
|
|
do {
|
|
let data = try Data(contentsOf: url)
|
|
guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
return .err(code: "invalid_params", message: "State file must contain a JSON object", data: ["path": path])
|
|
}
|
|
raw = obj
|
|
} catch {
|
|
return .err(code: "not_found", message: "Failed to read state file", data: ["path": path, "error": error.localizedDescription])
|
|
}
|
|
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
if let frameSelector = raw["frame_selector"] as? String, !frameSelector.isEmpty {
|
|
v2BrowserFrameSelectorBySurface[surfaceId] = frameSelector
|
|
} else {
|
|
v2BrowserFrameSelectorBySurface.removeValue(forKey: surfaceId)
|
|
}
|
|
|
|
if let urlStr = raw["url"] as? String,
|
|
!urlStr.isEmpty,
|
|
let parsed = URL(string: urlStr) {
|
|
browserPanel.navigate(to: parsed)
|
|
}
|
|
|
|
if let cookieRows = raw["cookies"] as? [[String: Any]] {
|
|
let store = browserPanel.webView.configuration.websiteDataStore.httpCookieStore
|
|
for row in cookieRows {
|
|
if let cookie = v2BrowserCookieFromObject(row, fallbackURL: browserPanel.currentURL) {
|
|
_ = v2BrowserCookieStoreSet(store, cookie: cookie)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let storage = raw["storage"] as? [String: Any] {
|
|
let storageLiteral = v2JSONLiteral(storage)
|
|
let script = """
|
|
(() => {
|
|
const payload = \(storageLiteral);
|
|
const apply = (st, data) => {
|
|
if (!st || !data || typeof data !== 'object') return;
|
|
st.clear();
|
|
for (const [k, v] of Object.entries(data)) {
|
|
st.setItem(String(k), v == null ? '' : String(v));
|
|
}
|
|
};
|
|
apply(window.localStorage, payload.local);
|
|
apply(window.sessionStorage, payload.session);
|
|
return true;
|
|
})()
|
|
"""
|
|
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0)
|
|
}
|
|
|
|
return .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),
|
|
"path": path,
|
|
"loaded": true
|
|
])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserAddInitScript(params: [String: Any]) -> V2CallResult {
|
|
guard let script = v2String(params, "script") ?? v2String(params, "content") else {
|
|
return .err(code: "invalid_params", message: "Missing script", data: nil)
|
|
}
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
var scripts = v2BrowserInitScriptsBySurface[surfaceId] ?? []
|
|
scripts.append(script)
|
|
v2BrowserInitScriptsBySurface[surfaceId] = scripts
|
|
|
|
let userScript = WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: false)
|
|
browserPanel.webView.configuration.userContentController.addUserScript(userScript)
|
|
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0)
|
|
|
|
return .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),
|
|
"scripts": scripts.count
|
|
])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserAddScript(params: [String: Any]) -> V2CallResult {
|
|
guard let script = v2String(params, "script") ?? v2String(params, "content") else {
|
|
return .err(code: "invalid_params", message: "Missing script", data: nil)
|
|
}
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0) {
|
|
case .failure(let message):
|
|
return .err(code: "js_error", message: message, data: nil)
|
|
case .success(let value):
|
|
return .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),
|
|
"value": v2NormalizeJSValue(value)
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func v2BrowserAddStyle(params: [String: Any]) -> V2CallResult {
|
|
guard let css = v2String(params, "css") ?? v2String(params, "style") ?? v2String(params, "content") else {
|
|
return .err(code: "invalid_params", message: "Missing css/style content", data: nil)
|
|
}
|
|
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
|
var styles = v2BrowserInitStylesBySurface[surfaceId] ?? []
|
|
styles.append(css)
|
|
v2BrowserInitStylesBySurface[surfaceId] = styles
|
|
|
|
let cssLiteral = v2JSONLiteral(css)
|
|
let source = """
|
|
(() => {
|
|
const el = document.createElement('style');
|
|
el.textContent = String(\(cssLiteral));
|
|
(document.head || document.documentElement || document.body).appendChild(el);
|
|
return true;
|
|
})()
|
|
"""
|
|
|
|
let userScript = WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: false)
|
|
browserPanel.webView.configuration.userContentController.addUserScript(userScript)
|
|
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: source, timeout: 10.0)
|
|
|
|
return .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),
|
|
"styles": styles.count
|
|
])
|
|
}
|
|
}
|
|
|
|
private func v2BrowserViewportSet(params _: [String: Any]) -> V2CallResult {
|
|
v2BrowserNotSupported("browser.viewport.set", details: "WKWebView does not provide a per-tab programmable viewport emulation API equivalent to CDP")
|
|
}
|
|
|
|
private func v2BrowserGeolocationSet(params _: [String: Any]) -> V2CallResult {
|
|
v2BrowserNotSupported("browser.geolocation.set", details: "WKWebView does not expose per-tab geolocation spoofing hooks equivalent to Playwright/CDP")
|
|
}
|
|
|
|
private func v2BrowserOfflineSet(params _: [String: Any]) -> V2CallResult {
|
|
v2BrowserNotSupported("browser.offline.set", details: "WKWebView does not expose reliable per-tab offline emulation")
|
|
}
|
|
|
|
private func v2BrowserTraceStart(params _: [String: Any]) -> V2CallResult {
|
|
v2BrowserNotSupported("browser.trace.start", details: "Playwright trace artifacts are not available on WKWebView")
|
|
}
|
|
|
|
private func v2BrowserTraceStop(params _: [String: Any]) -> V2CallResult {
|
|
v2BrowserNotSupported("browser.trace.stop", details: "Playwright trace artifacts are not available on WKWebView")
|
|
}
|
|
|
|
private func v2BrowserNetworkRoute(params: [String: Any]) -> V2CallResult {
|
|
if let surfaceId = v2UUID(params, "surface_id") {
|
|
v2BrowserRecordUnsupportedRequest(surfaceId: surfaceId, request: ["action": "route", "params": params])
|
|
}
|
|
return v2BrowserNotSupported("browser.network.route", details: "WKWebView does not provide CDP-style request interception/mocking")
|
|
}
|
|
|
|
private func v2BrowserNetworkUnroute(params: [String: Any]) -> V2CallResult {
|
|
if let surfaceId = v2UUID(params, "surface_id") {
|
|
v2BrowserRecordUnsupportedRequest(surfaceId: surfaceId, request: ["action": "unroute", "params": params])
|
|
}
|
|
return v2BrowserNotSupported("browser.network.unroute", details: "WKWebView does not provide CDP-style request interception/mocking")
|
|
}
|
|
|
|
private func v2BrowserNetworkRequests(params: [String: Any]) -> V2CallResult {
|
|
if let surfaceId = v2UUID(params, "surface_id") {
|
|
let items = v2BrowserUnsupportedNetworkRequestsBySurface[surfaceId] ?? []
|
|
return .err(code: "not_supported", message: "browser.network.requests is not supported on WKWebView", data: [
|
|
"details": "Request interception logs are unavailable without CDP network hooks",
|
|
"recorded_requests": items
|
|
])
|
|
}
|
|
return v2BrowserNotSupported("browser.network.requests", details: "Request interception logs are unavailable without CDP network hooks")
|
|
}
|
|
|
|
private func v2BrowserScreencastStart(params _: [String: Any]) -> V2CallResult {
|
|
v2BrowserNotSupported("browser.screencast.start", details: "WKWebView does not expose CDP screencast streaming")
|
|
}
|
|
|
|
private func v2BrowserScreencastStop(params _: [String: Any]) -> V2CallResult {
|
|
v2BrowserNotSupported("browser.screencast.stop", details: "WKWebView does not expose CDP screencast streaming")
|
|
}
|
|
|
|
private func v2BrowserInputMouse(params _: [String: Any]) -> V2CallResult {
|
|
v2BrowserNotSupported("browser.input_mouse", details: "Raw CDP mouse injection is unavailable; use browser.click/hover/scroll")
|
|
}
|
|
|
|
private func v2BrowserInputKeyboard(params _: [String: Any]) -> V2CallResult {
|
|
v2BrowserNotSupported("browser.input_keyboard", details: "Raw CDP keyboard injection is unavailable; use browser.press/keydown/keyup")
|
|
}
|
|
|
|
private func v2BrowserInputTouch(params _: [String: Any]) -> V2CallResult {
|
|
v2BrowserNotSupported("browser.input_touch", details: "Raw CDP touch injection is unavailable on WKWebView")
|
|
}
|
|
|
|
#if DEBUG
|
|
// MARK: - V2 Debug / Test-only Methods
|
|
|
|
private func v2DebugShortcutSet(params: [String: Any]) -> V2CallResult {
|
|
guard let name = v2String(params, "name"),
|
|
let combo = v2String(params, "combo") else {
|
|
return .err(code: "invalid_params", message: "Missing name/combo", data: nil)
|
|
}
|
|
let resp = setShortcut("\(name) \(combo)")
|
|
return resp == "OK"
|
|
? .ok([:])
|
|
: .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
|
|
private func v2DebugShortcutSimulate(params: [String: Any]) -> V2CallResult {
|
|
guard let combo = v2String(params, "combo") else {
|
|
return .err(code: "invalid_params", message: "Missing combo", data: nil)
|
|
}
|
|
let resp = simulateShortcut(combo)
|
|
return resp == "OK"
|
|
? .ok([:])
|
|
: .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
|
|
private func v2DebugType(params: [String: Any]) -> V2CallResult {
|
|
guard let text = params["text"] as? String else {
|
|
return .err(code: "invalid_params", message: "Missing text", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .err(code: "internal_error", message: "No window", data: nil)
|
|
DispatchQueue.main.sync {
|
|
guard let window = NSApp.keyWindow
|
|
?? NSApp.mainWindow
|
|
?? NSApp.windows.first(where: { $0.isVisible })
|
|
?? NSApp.windows.first else {
|
|
result = .err(code: "not_found", message: "No window", data: nil)
|
|
return
|
|
}
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
window.makeKeyAndOrderFront(nil)
|
|
guard let fr = window.firstResponder else {
|
|
result = .err(code: "not_found", message: "No first responder", data: nil)
|
|
return
|
|
}
|
|
if let client = fr as? NSTextInputClient {
|
|
client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
result = .ok([:])
|
|
return
|
|
}
|
|
(fr as? NSResponder)?.insertText(text)
|
|
result = .ok([:])
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2DebugActivateApp() -> V2CallResult {
|
|
let resp = activateApp()
|
|
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
|
|
private func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult {
|
|
let requestedWindowId = v2UUID(params, "window_id")
|
|
var result: V2CallResult = .ok([:])
|
|
DispatchQueue.main.sync {
|
|
let targetWindow: NSWindow?
|
|
if let requestedWindowId {
|
|
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
|
result = .err(
|
|
code: "not_found",
|
|
message: "Window not found",
|
|
data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)]
|
|
)
|
|
return
|
|
}
|
|
targetWindow = window
|
|
} else {
|
|
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
|
}
|
|
NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult {
|
|
let requestedWindowId = v2UUID(params, "window_id")
|
|
var result: V2CallResult = .ok([:])
|
|
DispatchQueue.main.sync {
|
|
let targetWindow: NSWindow?
|
|
if let requestedWindowId {
|
|
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
|
result = .err(
|
|
code: "not_found",
|
|
message: "Window not found",
|
|
data: [
|
|
"window_id": requestedWindowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
|
|
]
|
|
)
|
|
return
|
|
}
|
|
targetWindow = window
|
|
} else {
|
|
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
|
}
|
|
NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult {
|
|
guard let windowId = v2UUID(params, "window_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
|
}
|
|
var visible = false
|
|
DispatchQueue.main.sync {
|
|
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
|
|
}
|
|
return .ok([
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"visible": visible
|
|
])
|
|
}
|
|
|
|
private func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult {
|
|
guard let windowId = v2UUID(params, "window_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
|
}
|
|
var visible = false
|
|
var selectedIndex = 0
|
|
DispatchQueue.main.sync {
|
|
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
|
|
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
|
|
}
|
|
return .ok([
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"visible": visible,
|
|
"selected_index": max(0, selectedIndex)
|
|
])
|
|
}
|
|
|
|
private func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult {
|
|
guard let windowId = v2UUID(params, "window_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
|
}
|
|
let requestedLimit = params["limit"] as? Int
|
|
let limit = max(1, min(100, requestedLimit ?? 20))
|
|
|
|
var visible = false
|
|
var selectedIndex = 0
|
|
var snapshot = CommandPaletteDebugSnapshot.empty
|
|
|
|
DispatchQueue.main.sync {
|
|
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
|
|
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
|
|
snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty
|
|
}
|
|
|
|
let rows = Array(snapshot.results.prefix(limit)).map { row in
|
|
[
|
|
"command_id": row.commandId,
|
|
"title": row.title,
|
|
"shortcut_hint": v2OrNull(row.shortcutHint),
|
|
"trailing_label": v2OrNull(row.trailingLabel),
|
|
"score": row.score
|
|
] as [String: Any]
|
|
}
|
|
|
|
return .ok([
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"visible": visible,
|
|
"selected_index": max(0, selectedIndex),
|
|
"query": snapshot.query,
|
|
"mode": snapshot.mode,
|
|
"results": rows
|
|
])
|
|
}
|
|
|
|
private func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult {
|
|
let requestedWindowId = v2UUID(params, "window_id")
|
|
var result: V2CallResult = .ok([:])
|
|
DispatchQueue.main.sync {
|
|
let targetWindow: NSWindow?
|
|
if let requestedWindowId {
|
|
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
|
result = .err(
|
|
code: "not_found",
|
|
message: "Window not found",
|
|
data: [
|
|
"window_id": requestedWindowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
|
|
]
|
|
)
|
|
return
|
|
}
|
|
targetWindow = window
|
|
} else {
|
|
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
|
}
|
|
NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult {
|
|
let requestedWindowId = v2UUID(params, "window_id")
|
|
var result: V2CallResult = .ok([:])
|
|
DispatchQueue.main.sync {
|
|
let targetWindow: NSWindow?
|
|
if let requestedWindowId {
|
|
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
|
result = .err(
|
|
code: "not_found",
|
|
message: "Window not found",
|
|
data: [
|
|
"window_id": requestedWindowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
|
|
]
|
|
)
|
|
return
|
|
}
|
|
targetWindow = window
|
|
} else {
|
|
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
|
}
|
|
NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult {
|
|
guard let windowId = v2UUID(params, "window_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
|
}
|
|
|
|
var result: V2CallResult = .ok([
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"focused": false,
|
|
"selection_location": 0,
|
|
"selection_length": 0,
|
|
"text_length": 0
|
|
])
|
|
|
|
DispatchQueue.main.sync {
|
|
guard let window = AppDelegate.shared?.mainWindow(for: windowId) else {
|
|
result = .err(
|
|
code: "not_found",
|
|
message: "Window not found",
|
|
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
|
|
)
|
|
return
|
|
}
|
|
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else {
|
|
return
|
|
}
|
|
let selectedRange = editor.selectedRange()
|
|
let textLength = (editor.string as NSString).length
|
|
result = .ok([
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"focused": true,
|
|
"selection_location": max(0, selectedRange.location),
|
|
"selection_length": max(0, selectedRange.length),
|
|
"text_length": max(0, textLength)
|
|
])
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult {
|
|
if let rawEnabled = params["enabled"] {
|
|
guard let enabled = rawEnabled as? Bool else {
|
|
return .err(
|
|
code: "invalid_params",
|
|
message: "enabled must be a bool",
|
|
data: ["enabled": rawEnabled]
|
|
)
|
|
}
|
|
DispatchQueue.main.sync {
|
|
UserDefaults.standard.set(
|
|
enabled,
|
|
forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey
|
|
)
|
|
}
|
|
}
|
|
|
|
var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
|
DispatchQueue.main.sync {
|
|
enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled()
|
|
}
|
|
|
|
return .ok([
|
|
"enabled": enabled
|
|
])
|
|
}
|
|
|
|
private func v2DebugBrowserAddressBarFocused(params: [String: Any]) -> V2CallResult {
|
|
let requestedSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "panel_id")
|
|
var focusedSurfaceId: UUID?
|
|
DispatchQueue.main.sync {
|
|
focusedSurfaceId = AppDelegate.shared?.focusedBrowserAddressBarPanelId()
|
|
}
|
|
|
|
var payload: [String: Any] = [
|
|
"focused_surface_id": v2OrNull(focusedSurfaceId?.uuidString),
|
|
"focused_surface_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId),
|
|
"focused_panel_id": v2OrNull(focusedSurfaceId?.uuidString),
|
|
"focused_panel_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId),
|
|
"focused": focusedSurfaceId != nil
|
|
]
|
|
|
|
if let requestedSurfaceId {
|
|
payload["surface_id"] = requestedSurfaceId.uuidString
|
|
payload["surface_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId)
|
|
payload["panel_id"] = requestedSurfaceId.uuidString
|
|
payload["panel_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId)
|
|
payload["focused"] = (focusedSurfaceId == requestedSurfaceId)
|
|
}
|
|
|
|
return .ok(payload)
|
|
}
|
|
|
|
private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult {
|
|
guard let windowId = v2UUID(params, "window_id") else {
|
|
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
|
}
|
|
var visibility: Bool?
|
|
DispatchQueue.main.sync {
|
|
visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId)
|
|
}
|
|
guard let visible = visibility else {
|
|
return .err(
|
|
code: "not_found",
|
|
message: "Window not found",
|
|
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
|
|
)
|
|
}
|
|
return .ok([
|
|
"window_id": windowId.uuidString,
|
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
|
"visible": visible
|
|
])
|
|
}
|
|
|
|
private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult {
|
|
guard let surfaceId = v2String(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
|
|
}
|
|
let resp = isTerminalFocused(surfaceId)
|
|
if resp.hasPrefix("ERROR") {
|
|
return .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
return .ok(["focused": resp.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true"])
|
|
}
|
|
|
|
private func v2DebugReadTerminalText(params: [String: Any]) -> V2CallResult {
|
|
let surfaceArg = v2String(params, "surface_id") ?? ""
|
|
let resp = readTerminalText(surfaceArg)
|
|
guard resp.hasPrefix("OK ") else {
|
|
return .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
let b64 = String(resp.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return .ok(["base64": b64])
|
|
}
|
|
|
|
private func v2DebugRenderStats(params: [String: Any]) -> V2CallResult {
|
|
let surfaceArg = v2String(params, "surface_id") ?? ""
|
|
let resp = renderStats(surfaceArg)
|
|
guard resp.hasPrefix("OK ") else {
|
|
return .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
let jsonStr = String(resp.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let data = jsonStr.data(using: .utf8),
|
|
let obj = try? JSONSerialization.jsonObject(with: data, options: []) else {
|
|
return .err(code: "internal_error", message: "render_stats JSON decode failed", data: ["payload": String(jsonStr.prefix(200))])
|
|
}
|
|
return .ok(["stats": obj])
|
|
}
|
|
|
|
private func v2DebugLayout() -> V2CallResult {
|
|
let resp = layoutDebug()
|
|
guard resp.hasPrefix("OK ") else {
|
|
return .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
let jsonStr = String(resp.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let data = jsonStr.data(using: .utf8),
|
|
let obj = try? JSONSerialization.jsonObject(with: data, options: []) else {
|
|
return .err(code: "internal_error", message: "layout_debug JSON decode failed", data: ["payload": String(jsonStr.prefix(200))])
|
|
}
|
|
return .ok(["layout": obj])
|
|
}
|
|
|
|
private func v2DebugPortalStats() -> V2CallResult {
|
|
let payload: [String: Any] = v2MainSync {
|
|
TerminalWindowPortalRegistry.debugPortalStats()
|
|
}
|
|
return .ok(payload)
|
|
}
|
|
|
|
private func v2DebugBonsplitUnderflowCount() -> V2CallResult {
|
|
let resp = bonsplitUnderflowCount()
|
|
guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) }
|
|
let n = Int(resp.split(separator: " ").last ?? "0") ?? 0
|
|
return .ok(["count": n])
|
|
}
|
|
|
|
private func v2DebugResetBonsplitUnderflowCount() -> V2CallResult {
|
|
let resp = resetBonsplitUnderflowCount()
|
|
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
|
|
private func v2DebugEmptyPanelCount() -> V2CallResult {
|
|
let resp = emptyPanelCount()
|
|
guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) }
|
|
let n = Int(resp.split(separator: " ").last ?? "0") ?? 0
|
|
return .ok(["count": n])
|
|
}
|
|
|
|
private func v2DebugResetEmptyPanelCount() -> V2CallResult {
|
|
let resp = resetEmptyPanelCount()
|
|
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
|
|
private func v2DebugFocusNotification(params: [String: Any]) -> V2CallResult {
|
|
guard let wsId = v2String(params, "workspace_id") else {
|
|
return .err(code: "invalid_params", message: "Missing workspace_id", data: nil)
|
|
}
|
|
let surfaceId = v2String(params, "surface_id")
|
|
let args = surfaceId != nil ? "\(wsId) \(surfaceId!)" : wsId
|
|
let resp = focusFromNotification(args)
|
|
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
|
|
private func v2DebugFlashCount(params: [String: Any]) -> V2CallResult {
|
|
guard let surfaceId = v2String(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
|
|
}
|
|
let resp = flashCount(surfaceId)
|
|
guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) }
|
|
let n = Int(resp.split(separator: " ").last ?? "0") ?? 0
|
|
return .ok(["count": n])
|
|
}
|
|
|
|
private func v2DebugResetFlashCounts() -> V2CallResult {
|
|
let resp = resetFlashCounts()
|
|
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
|
|
private func v2DebugPanelSnapshot(params: [String: Any]) -> V2CallResult {
|
|
guard let surfaceId = v2String(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
|
|
}
|
|
let label = v2String(params, "label") ?? ""
|
|
let args = label.isEmpty ? surfaceId : "\(surfaceId) \(label)"
|
|
let resp = panelSnapshot(args)
|
|
guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) }
|
|
let payload = String(resp.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let parts = payload.split(separator: " ", maxSplits: 4).map(String.init)
|
|
guard parts.count == 5 else {
|
|
return .err(code: "internal_error", message: "panel_snapshot parse failed", data: ["payload": payload])
|
|
}
|
|
return .ok([
|
|
"surface_id": parts[0],
|
|
"changed_pixels": Int(parts[1]) ?? -1,
|
|
"width": Int(parts[2]) ?? 0,
|
|
"height": Int(parts[3]) ?? 0,
|
|
"path": parts[4]
|
|
])
|
|
}
|
|
|
|
private func v2DebugPanelSnapshotReset(params: [String: Any]) -> V2CallResult {
|
|
guard let surfaceId = v2String(params, "surface_id") else {
|
|
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
|
|
}
|
|
let resp = panelSnapshotReset(surfaceId)
|
|
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
|
|
private func v2DebugScreenshot(params: [String: Any]) -> V2CallResult {
|
|
let label = v2String(params, "label") ?? ""
|
|
let resp = captureScreenshot(label)
|
|
guard resp.hasPrefix("OK ") else {
|
|
return .err(code: "internal_error", message: resp, data: nil)
|
|
}
|
|
let payload = String(resp.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let parts = payload.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard parts.count == 2 else {
|
|
return .err(code: "internal_error", message: "screenshot parse failed", data: ["payload": payload])
|
|
}
|
|
return .ok([
|
|
"screenshot_id": parts[0],
|
|
"path": parts[1]
|
|
])
|
|
}
|
|
#endif
|
|
|
|
private struct ReadScreenOptions {
|
|
let surfaceArg: String
|
|
let includeScrollback: Bool
|
|
let lineLimit: Int?
|
|
}
|
|
|
|
private struct ReadScreenParseError: Error {
|
|
let message: String
|
|
}
|
|
|
|
private func parseReadScreenArgs(_ args: String) -> Result<ReadScreenOptions, ReadScreenParseError> {
|
|
let tokens = args
|
|
.split(whereSeparator: { $0.isWhitespace })
|
|
.map(String.init)
|
|
var surfaceArg: String?
|
|
var includeScrollback = false
|
|
var lineLimit: Int?
|
|
var idx = 0
|
|
|
|
while idx < tokens.count {
|
|
let token = tokens[idx]
|
|
switch token {
|
|
case "--scrollback":
|
|
includeScrollback = true
|
|
idx += 1
|
|
case "--lines":
|
|
guard idx + 1 < tokens.count, let parsed = Int(tokens[idx + 1]), parsed > 0 else {
|
|
return .failure(ReadScreenParseError(message: "ERROR: --lines must be greater than 0"))
|
|
}
|
|
lineLimit = parsed
|
|
includeScrollback = true
|
|
idx += 2
|
|
default:
|
|
guard surfaceArg == nil else {
|
|
return .failure(ReadScreenParseError(message: "ERROR: Usage: read_screen [id|idx] [--scrollback] [--lines <n>]"))
|
|
}
|
|
surfaceArg = token
|
|
idx += 1
|
|
}
|
|
}
|
|
|
|
return .success(
|
|
ReadScreenOptions(
|
|
surfaceArg: surfaceArg ?? "",
|
|
includeScrollback: includeScrollback,
|
|
lineLimit: lineLimit
|
|
)
|
|
)
|
|
}
|
|
|
|
private func tailTerminalLines(_ text: String, maxLines: Int) -> String {
|
|
guard maxLines > 0 else { return "" }
|
|
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
|
|
guard lines.count > maxLines else { return text }
|
|
return lines.suffix(maxLines).joined(separator: "\n")
|
|
}
|
|
|
|
private func readTerminalTextBase64(surfaceArg: String, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let trimmedSurfaceArg = surfaceArg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
var result = "ERROR: No tab selected"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
let panelId: UUID?
|
|
if trimmedSurfaceArg.isEmpty {
|
|
panelId = tab.focusedPanelId
|
|
} else {
|
|
panelId = resolveSurfaceId(from: trimmedSurfaceArg, tab: tab)
|
|
}
|
|
|
|
guard let panelId,
|
|
let terminalPanel = tab.terminalPanel(for: panelId) else {
|
|
result = "ERROR: Terminal surface not found"
|
|
return
|
|
}
|
|
|
|
result = readTerminalTextBase64(
|
|
terminalPanel: terminalPanel,
|
|
includeScrollback: includeScrollback,
|
|
lineLimit: lineLimit
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func readScreenText(_ args: String) -> String {
|
|
let options: ReadScreenOptions
|
|
switch parseReadScreenArgs(args) {
|
|
case .success(let parsed):
|
|
options = parsed
|
|
case .failure(let error):
|
|
return error.message
|
|
}
|
|
|
|
let response = readTerminalTextBase64(
|
|
surfaceArg: options.surfaceArg,
|
|
includeScrollback: options.includeScrollback,
|
|
lineLimit: options.lineLimit
|
|
)
|
|
guard response.hasPrefix("OK ") else { return response }
|
|
|
|
let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if payload.isEmpty {
|
|
return ""
|
|
}
|
|
|
|
guard let data = Data(base64Encoded: payload) else {
|
|
return "ERROR: Failed to decode terminal text"
|
|
}
|
|
return String(decoding: data, as: UTF8.self)
|
|
}
|
|
|
|
private func helpText() -> String {
|
|
var text = """
|
|
Hierarchy: Workspace (sidebar tab) > Pane (split region) > Surface (nested tab) > Panel (terminal/browser)
|
|
|
|
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)
|
|
current_workspace - Get current workspace ID
|
|
close_workspace <id> - Close workspace by ID
|
|
|
|
Split & surface commands:
|
|
new_split <direction> [panel] - Split panel (left/right/up/down)
|
|
drag_surface_to_split <id|idx> <direction> - Move surface into a new split (drag-to-edge)
|
|
new_pane [--type=terminal|browser] [--direction=left|right|up|down] [--url=...]
|
|
new_surface [--type=terminal|browser] [--pane=<pane-id|index>] [--url=...]
|
|
list_surfaces [workspace] - List surfaces for workspace (current if omitted)
|
|
list_panes - List all panes with IDs
|
|
list_pane_surfaces [--pane=<pane-id|index>] - List surfaces in pane
|
|
focus_surface <id|idx> - Focus surface by ID or index
|
|
focus_pane <pane-id|index> - Focus a pane
|
|
focus_surface_by_panel <panel_id> - Focus surface by panel ID
|
|
close_surface [id|idx] - Close surface (collapse split)
|
|
refresh_surfaces - Force refresh all terminals
|
|
surface_health [workspace] - Check view health of all surfaces
|
|
|
|
Input commands:
|
|
send <text> - Send text to current terminal
|
|
send_key <key> - Send special key (ctrl-c, ctrl-d, enter, tab, escape)
|
|
send_surface <id|idx> <text> - Send text to a specific terminal
|
|
send_key_surface <id|idx> <key> - Send special key to a specific terminal
|
|
read_screen [id|idx] [--scrollback] [--lines N] - Read terminal text (plain text)
|
|
|
|
Notification commands:
|
|
notify <title>|<subtitle>|<body> - Notify focused panel
|
|
notify_surface <id|idx> <payload> - Notify a specific surface
|
|
notify_target <workspace_id> <surface_id> <payload> - Notify by workspace+surface
|
|
list_notifications - List all notifications
|
|
clear_notifications [--tab=X] - Clear notifications (all or per-tab)
|
|
set_app_focus <active|inactive|clear> - Override app focus state
|
|
simulate_app_active - Trigger app active handler
|
|
set_status <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry
|
|
report_meta <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry
|
|
report_meta_block <key> [--priority=N] [--tab=X] -- <markdown> - Set freeform sidebar markdown block
|
|
clear_status <key> [--tab=X] - Remove a status entry
|
|
clear_meta <key> [--tab=X] - Remove sidebar metadata entry
|
|
clear_meta_block <key> [--tab=X] - Remove sidebar markdown block
|
|
list_status [--tab=X] - List all status entries
|
|
list_meta [--tab=X] - List sidebar metadata entries
|
|
list_meta_blocks [--tab=X] - List sidebar markdown blocks
|
|
log [--level=X] [--source=X] [--tab=X] -- <message> - Append a log entry
|
|
clear_log [--tab=X] - Clear log entries
|
|
list_log [--limit=N] [--tab=X] - List log entries
|
|
set_progress <0.0-1.0> [--label=X] [--tab=X] - Set progress bar
|
|
clear_progress [--tab=X] - Clear progress bar
|
|
report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch
|
|
clear_git_branch [--tab=X] [--panel=Y] - Clear git branch
|
|
report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request / review item
|
|
report_review <number> <url> [--label=MR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Alias for provider-specific review item
|
|
clear_pr [--tab=X] [--panel=Y] - Clear pull request
|
|
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
|
|
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
|
|
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
|
|
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
|
|
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
|
|
sidebar_state [--tab=X] - Dump sidebar metadata
|
|
reset_sidebar [--tab=X] - Clear sidebar metadata
|
|
|
|
Browser commands:
|
|
open_browser [url] - Create browser panel with optional URL
|
|
navigate <panel_id> <url> - Navigate browser to URL
|
|
browser_back <panel_id> - Go back in browser history
|
|
browser_forward <panel_id> - Go forward in browser history
|
|
browser_reload <panel_id> - Reload browser page
|
|
get_url <panel_id> - Get current URL of browser panel
|
|
focus_webview <panel_id> - Move keyboard focus into the WKWebView (for tests)
|
|
is_webview_focused <panel_id> - Return true/false if WKWebView is first responder
|
|
|
|
help - Show this help
|
|
"""
|
|
#if DEBUG
|
|
text += """
|
|
|
|
focus_notification <workspace|idx> [surface|idx] - Focus via notification flow
|
|
flash_count <id|idx> - Read flash count for a panel
|
|
reset_flash_counts - Reset flash counters
|
|
screenshot [label] - Capture window screenshot
|
|
set_shortcut <name> <combo|clear> - Set a keyboard shortcut (test-only)
|
|
simulate_shortcut <combo> - Simulate a keyDown shortcut (test-only)
|
|
simulate_type <text> - Insert text into the current first responder (test-only)
|
|
simulate_file_drop <id|idx> <path[|path...]> - Simulate dropping file path(s) on terminal (test-only)
|
|
seed_drag_pasteboard_fileurl - Seed NSDrag pasteboard with public.file-url (test-only)
|
|
seed_drag_pasteboard_tabtransfer - Seed NSDrag pasteboard with tab transfer type (test-only)
|
|
seed_drag_pasteboard_sidebar_reorder - Seed NSDrag pasteboard with sidebar reorder type (test-only)
|
|
seed_drag_pasteboard_types <types> - Seed NSDrag pasteboard with comma/space-separated types (fileurl, tabtransfer, sidebarreorder, or raw UTI)
|
|
clear_drag_pasteboard - Clear NSDrag pasteboard (test-only)
|
|
drop_hit_test <x 0-1> <y 0-1> - Hit-test file-drop overlay at normalised coords (test-only)
|
|
drag_hit_chain <x 0-1> <y 0-1> - Return hit-view chain at normalised coords (test-only)
|
|
overlay_hit_gate <event|none> - Return true/false if file-drop overlay would capture hit-testing for event type (test-only)
|
|
overlay_drop_gate [external|local] - Return true/false if file-drop overlay would capture drag destination routing (test-only)
|
|
portal_hit_gate <event|none> - Return true/false if terminal portal should pass hit-testing to SwiftUI drag targets (test-only)
|
|
sidebar_overlay_gate [active|inactive] - Return true/false if sidebar outside-drop overlay would capture (test-only)
|
|
terminal_drop_overlay_probe [deferred|direct] - Trigger focused terminal drop-overlay show path and report animation counts (test-only)
|
|
activate_app - Bring app + main window to front (test-only)
|
|
send_workspace <workspace_id> <text> - Send text to a workspace's focused terminal (test-only)
|
|
is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only)
|
|
read_terminal_text [id|idx] - Read visible terminal text (base64, test-only)
|
|
render_stats [id|idx] - Read terminal render stats (draw counters, test-only)
|
|
layout_debug - Dump bonsplit layout + selected panel bounds (test-only)
|
|
bonsplit_underflow_count - Count bonsplit arranged-subview underflow events (test-only)
|
|
reset_bonsplit_underflow_count - Reset bonsplit underflow counter (test-only)
|
|
empty_panel_count - Count EmptyPanelView appearances (test-only)
|
|
reset_empty_panel_count - Reset EmptyPanelView appearance count (test-only)
|
|
"""
|
|
#endif
|
|
return text
|
|
}
|
|
|
|
#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)
|
|
guard parts.count == 2 else {
|
|
return "ERROR: Usage: set_shortcut <name> <combo|clear>"
|
|
}
|
|
|
|
let name = parts[0]
|
|
let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
guard let action = debugShortcutAction(named: name) else {
|
|
return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())"
|
|
}
|
|
|
|
if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" {
|
|
UserDefaults.standard.removeObject(forKey: action.defaultsKey)
|
|
return "OK"
|
|
}
|
|
|
|
guard let parsed = parseShortcutCombo(combo) else {
|
|
return "ERROR: Invalid combo. Example: cmd+ctrl+h"
|
|
}
|
|
|
|
let shortcut = StoredShortcut(
|
|
key: parsed.storedKey,
|
|
command: parsed.modifierFlags.contains(.command),
|
|
shift: parsed.modifierFlags.contains(.shift),
|
|
option: parsed.modifierFlags.contains(.option),
|
|
control: parsed.modifierFlags.contains(.control)
|
|
)
|
|
guard let data = try? JSONEncoder().encode(shortcut) else {
|
|
return "ERROR: Failed to encode shortcut"
|
|
}
|
|
UserDefaults.standard.set(data, forKey: action.defaultsKey)
|
|
return "OK"
|
|
}
|
|
|
|
private func simulateShortcut(_ args: String) -> String {
|
|
let combo = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !combo.isEmpty else {
|
|
return "ERROR: Usage: simulate_shortcut <combo>"
|
|
}
|
|
guard let parsed = parseShortcutCombo(combo) else {
|
|
return "ERROR: Invalid combo. Example: cmd+ctrl+h"
|
|
}
|
|
|
|
// Stamp at socket-handler arrival so event.timestamp includes any wait
|
|
// before the main-thread event dispatch.
|
|
let requestTimestamp = ProcessInfo.processInfo.systemUptime
|
|
|
|
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
|
|
}()
|
|
if let targetWindow {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
targetWindow.makeKeyAndOrderFront(nil)
|
|
}
|
|
let windowNumber = targetWindow?.windowNumber ?? 0
|
|
guard let keyDownEvent = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: parsed.modifierFlags,
|
|
timestamp: requestTimestamp,
|
|
windowNumber: windowNumber,
|
|
context: nil,
|
|
characters: parsed.characters,
|
|
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
|
|
isARepeat: false,
|
|
keyCode: parsed.keyCode
|
|
) else {
|
|
result = "ERROR: NSEvent.keyEvent returned nil"
|
|
return
|
|
}
|
|
let keyUpEvent = NSEvent.keyEvent(
|
|
with: .keyUp,
|
|
location: .zero,
|
|
modifierFlags: parsed.modifierFlags,
|
|
timestamp: requestTimestamp + 0.0001,
|
|
windowNumber: windowNumber,
|
|
context: nil,
|
|
characters: parsed.characters,
|
|
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
|
|
isARepeat: false,
|
|
keyCode: parsed.keyCode
|
|
)
|
|
// Socket-driven shortcut simulation should reuse the exact same matching logic as the
|
|
// app-level shortcut monitor (so tests are hermetic), while still falling back to the
|
|
// normal responder chain for plain typing.
|
|
if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) {
|
|
result = "OK"
|
|
return
|
|
}
|
|
NSApp.sendEvent(keyDownEvent)
|
|
if let keyUpEvent {
|
|
NSApp.sendEvent(keyUpEvent)
|
|
}
|
|
result = "OK"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func activateApp() -> String {
|
|
DispatchQueue.main.sync {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
NSApp.unhide(nil)
|
|
let hasMainTerminalWindow = NSApp.windows.contains { window in
|
|
guard let raw = window.identifier?.rawValue else { return false }
|
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
|
}
|
|
|
|
if !hasMainTerminalWindow {
|
|
AppDelegate.shared?.openNewMainWindow(nil)
|
|
}
|
|
|
|
if let window = NSApp.mainWindow
|
|
?? NSApp.keyWindow
|
|
?? NSApp.windows.first(where: { win in
|
|
guard let raw = win.identifier?.rawValue else { return false }
|
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
|
})
|
|
?? NSApp.windows.first {
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
private func simulateType(_ args: String) -> String {
|
|
let raw = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !raw.isEmpty else {
|
|
return "ERROR: Usage: simulate_type <text>"
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
if let client = fr as? NSTextInputClient {
|
|
client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
result = "OK"
|
|
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.
|
|
(fr as? NSResponder)?.insertText(text)
|
|
result = "OK"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func simulateFileDrop(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard parts.count == 2 else {
|
|
return "ERROR: Usage: simulate_file_drop <id|idx> <path[|path...]>"
|
|
}
|
|
|
|
let target = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let rawPaths = parts[1]
|
|
let paths = rawPaths
|
|
.split(separator: "|")
|
|
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
guard !paths.isEmpty else {
|
|
return "ERROR: Usage: simulate_file_drop <id|idx> <path[|path...]>"
|
|
}
|
|
|
|
var result = "ERROR: Surface not found"
|
|
DispatchQueue.main.sync {
|
|
guard let panel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return }
|
|
result = panel.hostedView.debugSimulateFileDrop(paths: paths)
|
|
? "OK"
|
|
: "ERROR: Failed to simulate drop"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func seedDragPasteboardFileURL() -> String {
|
|
return seedDragPasteboardTypes("fileurl")
|
|
}
|
|
|
|
private func seedDragPasteboardTabTransfer() -> String {
|
|
return seedDragPasteboardTypes("tabtransfer")
|
|
}
|
|
|
|
private func seedDragPasteboardSidebarReorder() -> String {
|
|
return seedDragPasteboardTypes("sidebarreorder")
|
|
}
|
|
|
|
private func seedDragPasteboardTypes(_ args: String) -> String {
|
|
let raw = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !raw.isEmpty else {
|
|
return "ERROR: Usage: seed_drag_pasteboard_types <type[,type...]>"
|
|
}
|
|
|
|
let tokens = raw
|
|
.split(whereSeparator: { $0 == "," || $0.isWhitespace })
|
|
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
guard !tokens.isEmpty else {
|
|
return "ERROR: Usage: seed_drag_pasteboard_types <type[,type...]>"
|
|
}
|
|
|
|
var types: [NSPasteboard.PasteboardType] = []
|
|
for token in tokens {
|
|
guard let mapped = dragPasteboardType(from: token) else {
|
|
return "ERROR: Unknown drag type '\(token)'"
|
|
}
|
|
if !types.contains(mapped) {
|
|
types.append(mapped)
|
|
}
|
|
}
|
|
|
|
DispatchQueue.main.sync {
|
|
_ = NSPasteboard(name: .drag).declareTypes(types, owner: nil)
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
private func clearDragPasteboard() -> String {
|
|
DispatchQueue.main.sync {
|
|
_ = NSPasteboard(name: .drag).clearContents()
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
private func overlayHitGate(_ args: String) -> String {
|
|
let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !token.isEmpty else {
|
|
return "ERROR: Usage: overlay_hit_gate <leftMouseDragged|rightMouseDragged|otherMouseDragged|mouseMoved|mouseEntered|mouseExited|flagsChanged|cursorUpdate|appKitDefined|systemDefined|applicationDefined|periodic|leftMouseDown|leftMouseUp|rightMouseDown|rightMouseUp|otherMouseDown|otherMouseUp|scrollWheel|none>"
|
|
}
|
|
|
|
let parsedEvent = parseOverlayEventType(token)
|
|
guard parsedEvent.isKnown else {
|
|
return "ERROR: Unknown event type '\(args.trimmingCharacters(in: .whitespacesAndNewlines))'"
|
|
}
|
|
let eventType = parsedEvent.eventType
|
|
|
|
var shouldCapture = false
|
|
DispatchQueue.main.sync {
|
|
let pb = NSPasteboard(name: .drag)
|
|
shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay(
|
|
pasteboardTypes: pb.types,
|
|
eventType: eventType
|
|
)
|
|
}
|
|
|
|
return shouldCapture ? "true" : "false"
|
|
}
|
|
|
|
private func overlayDropGate(_ args: String) -> String {
|
|
let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let hasLocalDraggingSource: Bool
|
|
switch token {
|
|
case "", "external":
|
|
hasLocalDraggingSource = false
|
|
case "local":
|
|
hasLocalDraggingSource = true
|
|
default:
|
|
return "ERROR: Usage: overlay_drop_gate [external|local]"
|
|
}
|
|
|
|
var shouldCapture = false
|
|
DispatchQueue.main.sync {
|
|
let pb = NSPasteboard(name: .drag)
|
|
shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
|
|
pasteboardTypes: pb.types,
|
|
hasLocalDraggingSource: hasLocalDraggingSource
|
|
)
|
|
}
|
|
return shouldCapture ? "true" : "false"
|
|
}
|
|
|
|
private func portalHitGate(_ args: String) -> String {
|
|
let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !token.isEmpty else {
|
|
return "ERROR: Usage: portal_hit_gate <leftMouseDragged|rightMouseDragged|otherMouseDragged|mouseMoved|mouseEntered|mouseExited|flagsChanged|cursorUpdate|appKitDefined|systemDefined|applicationDefined|periodic|leftMouseDown|leftMouseUp|rightMouseDown|rightMouseUp|otherMouseDown|otherMouseUp|scrollWheel|none>"
|
|
}
|
|
let parsedEvent = parseOverlayEventType(token)
|
|
guard parsedEvent.isKnown else {
|
|
return "ERROR: Unknown event type '\(args.trimmingCharacters(in: .whitespacesAndNewlines))'"
|
|
}
|
|
let eventType = parsedEvent.eventType
|
|
|
|
var shouldPassThrough = false
|
|
DispatchQueue.main.sync {
|
|
let pb = NSPasteboard(name: .drag)
|
|
shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting(
|
|
pasteboardTypes: pb.types,
|
|
eventType: eventType
|
|
)
|
|
}
|
|
return shouldPassThrough ? "true" : "false"
|
|
}
|
|
|
|
private func sidebarOverlayGate(_ args: String) -> String {
|
|
let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let hasSidebarDragState: Bool
|
|
switch token {
|
|
case "", "active":
|
|
hasSidebarDragState = true
|
|
case "inactive":
|
|
hasSidebarDragState = false
|
|
default:
|
|
return "ERROR: Usage: sidebar_overlay_gate [active|inactive]"
|
|
}
|
|
|
|
var shouldCapture = false
|
|
DispatchQueue.main.sync {
|
|
let pb = NSPasteboard(name: .drag)
|
|
shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay(
|
|
hasSidebarDragState: hasSidebarDragState,
|
|
pasteboardTypes: pb.types
|
|
)
|
|
}
|
|
return shouldCapture ? "true" : "false"
|
|
}
|
|
|
|
private func terminalDropOverlayProbe(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let useDeferredPath: Bool
|
|
switch token {
|
|
case "", "deferred":
|
|
useDeferredPath = true
|
|
case "direct":
|
|
useDeferredPath = false
|
|
default:
|
|
return "ERROR: Usage: terminal_drop_overlay_probe [deferred|direct]"
|
|
}
|
|
|
|
var result = "ERROR: No selected workspace"
|
|
DispatchQueue.main.sync {
|
|
guard let selectedId = tabManager.selectedTabId,
|
|
let workspace = tabManager.tabs.first(where: { $0.id == selectedId }) else {
|
|
return
|
|
}
|
|
|
|
let terminalPanel = workspace.focusedTerminalPanel
|
|
?? orderedPanels(in: workspace).compactMap { $0 as? TerminalPanel }.first
|
|
guard let terminalPanel else {
|
|
result = "ERROR: No terminal panel available"
|
|
return
|
|
}
|
|
|
|
let probe = terminalPanel.hostedView.debugProbeDropOverlayAnimation(
|
|
useDeferredPath: useDeferredPath
|
|
)
|
|
let animated = probe.after > probe.before
|
|
let mode = useDeferredPath ? "deferred" : "direct"
|
|
result = String(
|
|
format: "OK mode=%@ animated=%d before=%d after=%d bounds=%.1fx%.1f",
|
|
mode,
|
|
animated ? 1 : 0,
|
|
probe.before,
|
|
probe.after,
|
|
probe.bounds.width,
|
|
probe.bounds.height
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func parseOverlayEventType(_ token: String) -> (isKnown: Bool, eventType: NSEvent.EventType?) {
|
|
switch token {
|
|
case "leftmousedragged":
|
|
return (true, .leftMouseDragged)
|
|
case "rightmousedragged":
|
|
return (true, .rightMouseDragged)
|
|
case "othermousedragged":
|
|
return (true, .otherMouseDragged)
|
|
case "mousemove", "mousemoved":
|
|
return (true, .mouseMoved)
|
|
case "mouseentered":
|
|
return (true, .mouseEntered)
|
|
case "mouseexited":
|
|
return (true, .mouseExited)
|
|
case "flagschanged":
|
|
return (true, .flagsChanged)
|
|
case "cursorupdate":
|
|
return (true, .cursorUpdate)
|
|
case "appkitdefined":
|
|
return (true, .appKitDefined)
|
|
case "systemdefined":
|
|
return (true, .systemDefined)
|
|
case "applicationdefined":
|
|
return (true, .applicationDefined)
|
|
case "periodic":
|
|
return (true, .periodic)
|
|
case "leftmousedown":
|
|
return (true, .leftMouseDown)
|
|
case "leftmouseup":
|
|
return (true, .leftMouseUp)
|
|
case "rightmousedown":
|
|
return (true, .rightMouseDown)
|
|
case "rightmouseup":
|
|
return (true, .rightMouseUp)
|
|
case "othermousedown":
|
|
return (true, .otherMouseDown)
|
|
case "othermouseup":
|
|
return (true, .otherMouseUp)
|
|
case "scrollwheel":
|
|
return (true, .scrollWheel)
|
|
case "none":
|
|
return (true, nil)
|
|
default:
|
|
return (false, nil)
|
|
}
|
|
}
|
|
|
|
private func dragPasteboardType(from token: String) -> NSPasteboard.PasteboardType? {
|
|
let normalized = token.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
switch normalized {
|
|
case "fileurl", "file-url", "public.file-url":
|
|
return .fileURL
|
|
case "tabtransfer", "tab-transfer", "com.splittabbar.tabtransfer":
|
|
return DragOverlayRoutingPolicy.bonsplitTabTransferType
|
|
case "sidebarreorder", "sidebar-reorder", "sidebar_tab_reorder",
|
|
"com.cmux.sidebar-tab-reorder":
|
|
return DragOverlayRoutingPolicy.sidebarTabReorderType
|
|
default:
|
|
// Allow explicit UTI strings for ad-hoc debug probes.
|
|
guard token.contains(".") else { return nil }
|
|
return NSPasteboard.PasteboardType(token)
|
|
}
|
|
}
|
|
|
|
/// Hit-tests the file-drop overlay's coordinate-to-terminal mapping.
|
|
/// Takes normalised (0-1) x,y within the content area where (0,0) is the
|
|
/// top-left corner and (1,1) is the bottom-right corner. Returns the
|
|
/// surface UUID of the terminal under that point, or "none".
|
|
private func dropHitTest(_ args: String) -> String {
|
|
let parts = args.split(separator: " ").map(String.init)
|
|
guard parts.count == 2,
|
|
let nx = Double(parts[0]), let ny = Double(parts[1]),
|
|
(0...1).contains(nx), (0...1).contains(ny) else {
|
|
return "ERROR: Usage: drop_hit_test <x 0-1> <y 0-1>"
|
|
}
|
|
|
|
var result = "ERROR: No window"
|
|
DispatchQueue.main.sync {
|
|
guard let window = NSApp.mainWindow
|
|
?? NSApp.keyWindow
|
|
?? NSApp.windows.first(where: { win in
|
|
guard let raw = win.identifier?.rawValue else { return false }
|
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
|
}),
|
|
let contentView = window.contentView,
|
|
let themeFrame = contentView.superview else { return }
|
|
|
|
// Convert normalized top-left coordinates into a window point.
|
|
let pointInTheme = NSPoint(
|
|
x: contentView.frame.minX + (contentView.bounds.width * nx),
|
|
y: contentView.frame.maxY - (contentView.bounds.height * ny)
|
|
)
|
|
let windowPoint = themeFrame.convert(pointInTheme, to: nil)
|
|
|
|
if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView,
|
|
let terminal = overlay.terminalUnderPoint(windowPoint),
|
|
let surfaceId = terminal.terminalSurface?.id {
|
|
result = surfaceId.uuidString.uppercased()
|
|
return
|
|
}
|
|
|
|
result = "none"
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Return the hit-test chain at normalized (0-1) coordinates in the main window's
|
|
/// content area. Used by regression tests to detect root-level drag destinations
|
|
/// shadowing pane-local Bonsplit drop targets.
|
|
private func dragHitChain(_ args: String) -> String {
|
|
let parts = args.split(separator: " ").map(String.init)
|
|
guard parts.count == 2,
|
|
let nx = Double(parts[0]), let ny = Double(parts[1]),
|
|
(0...1).contains(nx), (0...1).contains(ny) else {
|
|
return "ERROR: Usage: drag_hit_chain <x 0-1> <y 0-1>"
|
|
}
|
|
|
|
var result = "ERROR: No window"
|
|
DispatchQueue.main.sync {
|
|
guard let window = NSApp.mainWindow
|
|
?? NSApp.keyWindow
|
|
?? NSApp.windows.first(where: { win in
|
|
guard let raw = win.identifier?.rawValue else { return false }
|
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
|
}),
|
|
let contentView = window.contentView,
|
|
let themeFrame = contentView.superview else { return }
|
|
|
|
let pointInTheme = NSPoint(
|
|
x: contentView.frame.minX + (contentView.bounds.width * nx),
|
|
y: contentView.frame.maxY - (contentView.bounds.height * ny)
|
|
)
|
|
|
|
let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView
|
|
if let overlay { overlay.isHidden = true }
|
|
defer { overlay?.isHidden = false }
|
|
|
|
guard let hit = themeFrame.hitTest(pointInTheme) else {
|
|
result = "none"
|
|
return
|
|
}
|
|
|
|
var chain: [String] = []
|
|
var current: NSView? = hit
|
|
var depth = 0
|
|
while let view = current, depth < 8 {
|
|
chain.append(debugDragHitViewDescriptor(view))
|
|
current = view.superview
|
|
depth += 1
|
|
}
|
|
result = chain.joined(separator: "->")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func debugDragHitViewDescriptor(_ view: NSView) -> String {
|
|
let className = String(describing: type(of: view))
|
|
let pointer = String(describing: Unmanaged.passUnretained(view).toOpaque())
|
|
let types = view.registeredDraggedTypes
|
|
let renderedTypes: String
|
|
if types.isEmpty {
|
|
renderedTypes = "-"
|
|
} else {
|
|
let raw = types.map(\.rawValue)
|
|
renderedTypes = raw.count <= 4
|
|
? raw.joined(separator: ",")
|
|
: raw.prefix(4).joined(separator: ",") + ",+\(raw.count - 4)"
|
|
}
|
|
return "\(className)@\(pointer){dragTypes=\(renderedTypes)}"
|
|
}
|
|
|
|
private func unescapeSocketText(_ input: String) -> String {
|
|
var out = ""
|
|
var escaping = false
|
|
for ch in input {
|
|
if escaping {
|
|
switch ch {
|
|
case "n":
|
|
out.append("\n")
|
|
case "r":
|
|
out.append("\r")
|
|
case "t":
|
|
out.append("\t")
|
|
case "\\":
|
|
out.append("\\")
|
|
default:
|
|
out.append("\\")
|
|
out.append(ch)
|
|
}
|
|
escaping = false
|
|
} else if ch == "\\" {
|
|
escaping = true
|
|
} else {
|
|
out.append(ch)
|
|
}
|
|
}
|
|
if escaping {
|
|
out.append("\\")
|
|
}
|
|
return out
|
|
}
|
|
|
|
private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
|
|
var r = start
|
|
var hops = 0
|
|
while let cur = r, hops < 64 {
|
|
if cur === target { return true }
|
|
r = cur.nextResponder
|
|
hops += 1
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func isTerminalFocused(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !panelArg.isEmpty else { return "ERROR: Usage: is_terminal_focused <panel_id|idx>" }
|
|
|
|
var result = "false"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
result = "false"
|
|
return
|
|
}
|
|
|
|
guard let panelId = resolveSurfaceId(from: panelArg, tab: tab),
|
|
let terminalPanel = tab.terminalPanel(for: panelId) else {
|
|
result = "false"
|
|
return
|
|
}
|
|
result = terminalPanel.hostedView.isSurfaceViewFirstResponder() ? "true" : "false"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func readTerminalText(_ args: String) -> String {
|
|
readTerminalTextBase64(surfaceArg: args)
|
|
}
|
|
|
|
private struct RenderStatsResponse: Codable {
|
|
let panelId: String
|
|
let drawCount: Int
|
|
let lastDrawTime: Double
|
|
let metalDrawableCount: Int
|
|
let metalLastDrawableTime: Double
|
|
let presentCount: Int
|
|
let lastPresentTime: Double
|
|
let layerClass: String
|
|
let layerContentsKey: String
|
|
let inWindow: Bool
|
|
let windowIsKey: Bool
|
|
let windowOcclusionVisible: Bool
|
|
let appIsActive: Bool
|
|
let isActive: Bool
|
|
let desiredFocus: Bool
|
|
let isFirstResponder: Bool
|
|
}
|
|
|
|
private func renderStats(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
var result = "ERROR: No tab selected"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
let panelId: UUID?
|
|
if panelArg.isEmpty {
|
|
panelId = tab.focusedPanelId
|
|
} else {
|
|
panelId = resolveSurfaceId(from: panelArg, tab: tab)
|
|
}
|
|
|
|
guard let panelId,
|
|
let terminalPanel = tab.terminalPanel(for: panelId) else {
|
|
result = "ERROR: Terminal surface not found"
|
|
return
|
|
}
|
|
|
|
let stats = terminalPanel.hostedView.debugRenderStats()
|
|
let payload = RenderStatsResponse(
|
|
panelId: panelId.uuidString,
|
|
drawCount: stats.drawCount,
|
|
lastDrawTime: stats.lastDrawTime,
|
|
metalDrawableCount: stats.metalDrawableCount,
|
|
metalLastDrawableTime: stats.metalLastDrawableTime,
|
|
presentCount: stats.presentCount,
|
|
lastPresentTime: stats.lastPresentTime,
|
|
layerClass: stats.layerClass,
|
|
layerContentsKey: stats.layerContentsKey,
|
|
inWindow: stats.inWindow,
|
|
windowIsKey: stats.windowIsKey,
|
|
windowOcclusionVisible: stats.windowOcclusionVisible,
|
|
appIsActive: stats.appIsActive,
|
|
isActive: stats.isActive,
|
|
desiredFocus: stats.desiredFocus,
|
|
isFirstResponder: stats.isFirstResponder
|
|
)
|
|
|
|
let encoder = JSONEncoder()
|
|
guard let data = try? encoder.encode(payload),
|
|
let json = String(data: data, encoding: .utf8) else {
|
|
result = "ERROR: Failed to encode render_stats"
|
|
return
|
|
}
|
|
|
|
result = "OK \(json)"
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private struct ParsedShortcutCombo {
|
|
let storedKey: String
|
|
let keyCode: UInt16
|
|
let modifierFlags: NSEvent.ModifierFlags
|
|
let characters: String
|
|
let charactersIgnoringModifiers: String
|
|
}
|
|
|
|
private func parseShortcutCombo(_ combo: String) -> ParsedShortcutCombo? {
|
|
let raw = combo.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !raw.isEmpty else { return nil }
|
|
|
|
let parts = raw
|
|
.split(separator: "+")
|
|
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
guard !parts.isEmpty else { return nil }
|
|
|
|
var flags: NSEvent.ModifierFlags = []
|
|
var keyToken: String?
|
|
|
|
for part in parts {
|
|
let lower = part.lowercased()
|
|
switch lower {
|
|
case "cmd", "command", "super":
|
|
flags.insert(.command)
|
|
case "ctrl", "control":
|
|
flags.insert(.control)
|
|
case "opt", "option", "alt":
|
|
flags.insert(.option)
|
|
case "shift":
|
|
flags.insert(.shift)
|
|
default:
|
|
// Treat as the key component.
|
|
if keyToken == nil {
|
|
keyToken = part
|
|
} else {
|
|
// Multiple non-modifier tokens is ambiguous.
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
guard var keyToken else { return nil }
|
|
keyToken = keyToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !keyToken.isEmpty else { return nil }
|
|
|
|
// Normalize a few named keys.
|
|
let storedKey: String
|
|
let keyCode: UInt16
|
|
let charactersIgnoringModifiers: String
|
|
|
|
switch keyToken.lowercased() {
|
|
case "esc", "escape":
|
|
storedKey = "\u{1b}"
|
|
keyCode = UInt16(kVK_Escape)
|
|
charactersIgnoringModifiers = storedKey
|
|
case "left":
|
|
storedKey = "←"
|
|
keyCode = 123
|
|
charactersIgnoringModifiers = storedKey
|
|
case "right":
|
|
storedKey = "→"
|
|
keyCode = 124
|
|
charactersIgnoringModifiers = storedKey
|
|
case "down":
|
|
storedKey = "↓"
|
|
keyCode = 125
|
|
charactersIgnoringModifiers = storedKey
|
|
case "up":
|
|
storedKey = "↑"
|
|
keyCode = 126
|
|
charactersIgnoringModifiers = storedKey
|
|
case "enter", "return":
|
|
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 }
|
|
storedKey = key
|
|
keyCode = code
|
|
|
|
// Replicate a common system behavior: Ctrl+letter yields a control character in
|
|
// charactersIgnoringModifiers (e.g. Ctrl+H => backspace). This is important for
|
|
// testing keyCode fallback matching.
|
|
if flags.contains(.control),
|
|
key.count == 1,
|
|
let scalar = key.unicodeScalars.first,
|
|
scalar.isASCII,
|
|
scalar.value >= 97, scalar.value <= 122 { // a-z
|
|
let upper = scalar.value - 32
|
|
let controlValue = upper - 64 // 'A' => 1
|
|
charactersIgnoringModifiers = String(UnicodeScalar(controlValue)!)
|
|
} else {
|
|
charactersIgnoringModifiers = storedKey
|
|
}
|
|
}
|
|
|
|
// For our shortcut matcher, characters aren't important beyond exercising edge cases.
|
|
let chars = charactersIgnoringModifiers
|
|
|
|
return ParsedShortcutCombo(
|
|
storedKey: storedKey,
|
|
keyCode: keyCode,
|
|
modifierFlags: flags,
|
|
characters: chars,
|
|
charactersIgnoringModifiers: charactersIgnoringModifiers
|
|
)
|
|
}
|
|
|
|
private func keyCodeForShortcutKey(_ key: String) -> UInt16? {
|
|
// Matches macOS ANSI key codes for common printable keys and a few named specials.
|
|
switch key {
|
|
case "a": return 0 // kVK_ANSI_A
|
|
case "s": return 1 // kVK_ANSI_S
|
|
case "d": return 2 // kVK_ANSI_D
|
|
case "f": return 3 // kVK_ANSI_F
|
|
case "h": return 4 // kVK_ANSI_H
|
|
case "g": return 5 // kVK_ANSI_G
|
|
case "z": return 6 // kVK_ANSI_Z
|
|
case "x": return 7 // kVK_ANSI_X
|
|
case "c": return 8 // kVK_ANSI_C
|
|
case "v": return 9 // kVK_ANSI_V
|
|
case "b": return 11 // kVK_ANSI_B
|
|
case "q": return 12 // kVK_ANSI_Q
|
|
case "w": return 13 // kVK_ANSI_W
|
|
case "e": return 14 // kVK_ANSI_E
|
|
case "r": return 15 // kVK_ANSI_R
|
|
case "y": return 16 // kVK_ANSI_Y
|
|
case "t": return 17 // kVK_ANSI_T
|
|
case "1": return 18 // kVK_ANSI_1
|
|
case "2": return 19 // kVK_ANSI_2
|
|
case "3": return 20 // kVK_ANSI_3
|
|
case "4": return 21 // kVK_ANSI_4
|
|
case "6": return 22 // kVK_ANSI_6
|
|
case "5": return 23 // kVK_ANSI_5
|
|
case "=": return 24 // kVK_ANSI_Equal
|
|
case "9": return 25 // kVK_ANSI_9
|
|
case "7": return 26 // kVK_ANSI_7
|
|
case "-": return 27 // kVK_ANSI_Minus
|
|
case "8": return 28 // kVK_ANSI_8
|
|
case "0": return 29 // kVK_ANSI_0
|
|
case "]": return 30 // kVK_ANSI_RightBracket
|
|
case "o": return 31 // kVK_ANSI_O
|
|
case "u": return 32 // kVK_ANSI_U
|
|
case "[": return 33 // kVK_ANSI_LeftBracket
|
|
case "i": return 34 // kVK_ANSI_I
|
|
case "p": return 35 // kVK_ANSI_P
|
|
case "l": return 37 // kVK_ANSI_L
|
|
case "j": return 38 // kVK_ANSI_J
|
|
case "'": return 39 // kVK_ANSI_Quote
|
|
case "k": return 40 // kVK_ANSI_K
|
|
case ";": return 41 // kVK_ANSI_Semicolon
|
|
case "\\": return 42 // kVK_ANSI_Backslash
|
|
case ",": return 43 // kVK_ANSI_Comma
|
|
case "/": return 44 // kVK_ANSI_Slash
|
|
case "n": return 45 // kVK_ANSI_N
|
|
case "m": return 46 // kVK_ANSI_M
|
|
case ".": return 47 // kVK_ANSI_Period
|
|
case "`": return 50 // kVK_ANSI_Grave
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if !DEBUG
|
|
private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
|
|
var responder = start
|
|
var hops = 0
|
|
while let current = responder, hops < 64 {
|
|
if current === target { return true }
|
|
responder = current.nextResponder
|
|
hops += 1
|
|
}
|
|
return false
|
|
}
|
|
#endif
|
|
|
|
private func listWindows() -> String {
|
|
let summaries = v2MainSync { AppDelegate.shared?.listMainWindowSummaries() } ?? []
|
|
guard !summaries.isEmpty else { return "No windows" }
|
|
|
|
let lines = summaries.enumerated().map { idx, item in
|
|
let selected = item.isKeyWindow ? "*" : " "
|
|
let selectedWs = item.selectedWorkspaceId?.uuidString ?? "none"
|
|
return "\(selected) \(idx): \(item.windowId.uuidString) selected_workspace=\(selectedWs) workspaces=\(item.workspaceCount)"
|
|
}
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
private func currentWindow() -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
guard let windowId = v2ResolveWindowId(tabManager: tabManager) else { return "ERROR: No active window" }
|
|
return windowId.uuidString
|
|
}
|
|
|
|
private func focusWindow(_ arg: String) -> String {
|
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let windowId = UUID(uuidString: trimmed) else { return "ERROR: Invalid window id" }
|
|
|
|
let ok = v2MainSync { AppDelegate.shared?.focusMainWindow(windowId: windowId) ?? false }
|
|
guard ok else { return "ERROR: Window not found" }
|
|
|
|
if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) {
|
|
setActiveTabManager(tm)
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
private func newWindow() -> String {
|
|
guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else {
|
|
return "ERROR: Failed to create window"
|
|
}
|
|
if socketCommandAllowsInAppFocusMutations(),
|
|
let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) {
|
|
setActiveTabManager(tm)
|
|
}
|
|
return "OK \(windowId.uuidString)"
|
|
}
|
|
|
|
private func closeWindow(_ arg: String) -> String {
|
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let windowId = UUID(uuidString: trimmed) else { return "ERROR: Invalid window id" }
|
|
let ok = v2MainSync { AppDelegate.shared?.closeMainWindow(windowId: windowId) ?? false }
|
|
return ok ? "OK" : "ERROR: Window not found"
|
|
}
|
|
|
|
private func moveWorkspaceToWindow(_ args: String) -> String {
|
|
let parts = args.split(separator: " ").map(String.init)
|
|
guard parts.count >= 2 else { return "ERROR: Usage move_workspace_to_window <workspace_id> <window_id>" }
|
|
guard let wsId = UUID(uuidString: parts[0]) else { return "ERROR: Invalid workspace id" }
|
|
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),
|
|
let ws = srcTM.detachWorkspace(tabId: wsId) else {
|
|
ok = false
|
|
return
|
|
}
|
|
dstTM.attachWorkspace(ws, select: focus)
|
|
if focus {
|
|
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
|
|
setActiveTabManager(dstTM)
|
|
}
|
|
ok = true
|
|
}
|
|
|
|
return ok ? "OK" : "ERROR: Move failed"
|
|
}
|
|
|
|
private func listWorkspaces() -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result: String = ""
|
|
DispatchQueue.main.sync {
|
|
let tabs = tabManager.tabs.enumerated().map { (index, tab) in
|
|
let selected = tab.id == tabManager.selectedTabId ? "*" : " "
|
|
return "\(selected) \(index): \(tab.id.uuidString) \(tab.title)"
|
|
}
|
|
result = tabs.joined(separator: "\n")
|
|
}
|
|
return result.isEmpty ? "No workspaces" : result
|
|
}
|
|
|
|
private func newWorkspace() -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var newTabId: UUID?
|
|
let focus = socketCommandAllowsInAppFocusMutations()
|
|
#if DEBUG
|
|
let startedAt = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
DispatchQueue.main.sync {
|
|
let workspace = tabManager.addTab(select: focus)
|
|
if !focus, let terminalPanel = workspace.focusedTerminalPanel {
|
|
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
|
}
|
|
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")"
|
|
}
|
|
|
|
private func newSplit(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard !parts.isEmpty else {
|
|
return "ERROR: Invalid direction. Use left, right, up, or down."
|
|
}
|
|
|
|
let directionArg = parts[0]
|
|
let panelArg = parts.count > 1 ? parts[1] : ""
|
|
|
|
guard let direction = parseSplitDirection(directionArg) else {
|
|
return "ERROR: Invalid direction. Use left, right, up, or down."
|
|
}
|
|
|
|
var result = "ERROR: Failed to create split"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
// If panel arg provided, resolve it; otherwise use focused panel
|
|
let surfaceId: UUID?
|
|
if !panelArg.isEmpty {
|
|
surfaceId = resolveSurfaceId(from: panelArg, tab: tab)
|
|
if surfaceId == nil {
|
|
result = "ERROR: Panel not found"
|
|
return
|
|
}
|
|
} else {
|
|
surfaceId = tab.focusedPanelId
|
|
}
|
|
|
|
guard let targetSurface = surfaceId else {
|
|
result = "ERROR: No surface to split"
|
|
return
|
|
}
|
|
|
|
if let newPanelId = tabManager.newSplit(
|
|
tabId: tabId,
|
|
surfaceId: targetSurface,
|
|
direction: direction,
|
|
focus: socketCommandAllowsInAppFocusMutations()
|
|
) {
|
|
result = "OK \(newPanelId.uuidString)"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func listSurfaces(_ tabArg: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
let panels = orderedPanels(in: tab)
|
|
let focusedId = tab.focusedPanelId
|
|
let lines = panels.enumerated().map { index, panel in
|
|
let selected = panel.id == focusedId ? "*" : " "
|
|
return "\(selected) \(index): \(panel.id.uuidString)"
|
|
}
|
|
result = lines.isEmpty ? "No surfaces" : lines.joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func focusSurface(_ arg: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "ERROR: Missing panel id or index" }
|
|
|
|
var success = false
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
if let uuid = UUID(uuidString: trimmed),
|
|
tab.panels[uuid] != nil {
|
|
guard tab.surfaceIdFromPanelId(uuid) != nil else { return }
|
|
tabManager.focusSurface(tabId: tab.id, surfaceId: uuid)
|
|
success = true
|
|
return
|
|
}
|
|
|
|
if let index = Int(trimmed), index >= 0 {
|
|
let panels = orderedPanels(in: tab)
|
|
guard index < panels.count else { return }
|
|
guard tab.surfaceIdFromPanelId(panels[index].id) != nil else { return }
|
|
tabManager.focusSurface(tabId: tab.id, surfaceId: panels[index].id)
|
|
success = true
|
|
}
|
|
}
|
|
|
|
return success ? "OK" : "ERROR: Panel not found"
|
|
}
|
|
|
|
private func notifyCurrent(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId else {
|
|
result = "ERROR: No tab selected"
|
|
return
|
|
}
|
|
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
|
|
let (title, subtitle, body) = parseNotificationPayload(args)
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
body: body
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func notifySurface(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" }
|
|
|
|
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
|
|
let surfaceArg = parts[0]
|
|
let payload = parts.count > 1 ? parts[1] : ""
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
result = "ERROR: No tab selected"
|
|
return
|
|
}
|
|
guard let surfaceId = resolveSurfaceId(from: surfaceArg, tab: tab) else {
|
|
result = "ERROR: Surface not found"
|
|
return
|
|
}
|
|
let (title, subtitle, body) = parseNotificationPayload(payload)
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
body: body
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func notifyTarget(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "ERROR: Usage: notify_target <workspace_id> <surface_id> <title>|<subtitle>|<body>" }
|
|
|
|
let parts = trimmed.split(separator: " ", maxSplits: 2).map(String.init)
|
|
guard parts.count >= 2 else { return "ERROR: Usage: notify_target <workspace_id> <surface_id> <title>|<subtitle>|<body>" }
|
|
|
|
let tabArg = parts[0]
|
|
let panelArg = parts[1]
|
|
let payload = parts.count > 2 ? parts[2] : ""
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
guard let panelId = UUID(uuidString: panelArg),
|
|
tab.panels[panelId] != nil else {
|
|
result = "ERROR: Panel not found"
|
|
return
|
|
}
|
|
let (title, subtitle, body) = parseNotificationPayload(payload)
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: tab.id,
|
|
surfaceId: panelId,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
body: body
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func listNotifications() -> String {
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
let lines = TerminalNotificationStore.shared.notifications.enumerated().map { index, notification in
|
|
let surfaceText = notification.surfaceId?.uuidString ?? "none"
|
|
let readText = notification.isRead ? "read" : "unread"
|
|
return "\(index):\(notification.id.uuidString)|\(notification.tabId.uuidString)|\(surfaceText)|\(readText)|\(notification.title)|\(notification.subtitle)|\(notification.body)"
|
|
}
|
|
result = lines.joined(separator: "\n")
|
|
}
|
|
return result.isEmpty ? "No notifications" : result
|
|
}
|
|
|
|
private func clearNotifications(_ args: String) -> String {
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty {
|
|
DispatchQueue.main.sync {
|
|
TerminalNotificationStore.shared.clearAll()
|
|
}
|
|
return "OK"
|
|
}
|
|
let parsed = parseOptions(trimmed)
|
|
guard let tabOption = parsed.options["tab"],
|
|
!tabOption.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
return "ERROR: Usage: clear_notifications [--tab=X]"
|
|
}
|
|
var tabId: UUID?
|
|
DispatchQueue.main.sync {
|
|
if let tab = resolveTabForReport(trimmed) {
|
|
tabId = tab.id
|
|
}
|
|
}
|
|
guard let tabId else {
|
|
return "ERROR: Tab not found"
|
|
}
|
|
DispatchQueue.main.sync {
|
|
TerminalNotificationStore.shared.clearNotifications(forTabId: tabId)
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
private func setAppFocusOverride(_ arg: String) -> String {
|
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
switch trimmed {
|
|
case "active", "1", "true":
|
|
AppFocusState.overrideIsFocused = true
|
|
return "OK"
|
|
case "inactive", "0", "false":
|
|
AppFocusState.overrideIsFocused = false
|
|
return "OK"
|
|
case "clear", "none", "":
|
|
AppFocusState.overrideIsFocused = nil
|
|
return "OK"
|
|
default:
|
|
return "ERROR: Expected active, inactive, or clear"
|
|
}
|
|
}
|
|
|
|
private func simulateAppDidBecomeActive() -> String {
|
|
DispatchQueue.main.sync {
|
|
AppDelegate.shared?.applicationDidBecomeActive(
|
|
Notification(name: NSApplication.didBecomeActiveNotification)
|
|
)
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
#if DEBUG
|
|
private func focusFromNotification(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
|
|
let tabArg = parts.first ?? ""
|
|
let surfaceArg = parts.count > 1 ? parts[1] : ""
|
|
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
let surfaceId = surfaceArg.isEmpty ? nil : resolveSurfaceId(from: surfaceArg, tab: tab)
|
|
if !surfaceArg.isEmpty && surfaceId == nil {
|
|
result = "ERROR: Surface not found"
|
|
return
|
|
}
|
|
tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func flashCount(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" }
|
|
|
|
var result = "ERROR: Surface not found"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
result = "ERROR: No tab selected"
|
|
return
|
|
}
|
|
guard let surfaceId = resolveSurfaceId(from: trimmed, tab: tab) else {
|
|
result = "ERROR: Surface not found"
|
|
return
|
|
}
|
|
let count = GhosttySurfaceScrollView.flashCount(for: surfaceId)
|
|
result = "OK \(count)"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func resetFlashCounts() -> String {
|
|
DispatchQueue.main.sync {
|
|
GhosttySurfaceScrollView.resetFlashCounts()
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
#if DEBUG
|
|
private struct PanelSnapshotState: Sendable {
|
|
let width: Int
|
|
let height: Int
|
|
let bytesPerRow: Int
|
|
let rgba: Data
|
|
}
|
|
|
|
/// Most tests run single-threaded but socket handlers can be invoked concurrently.
|
|
/// Keep snapshot bookkeeping simple and thread-safe.
|
|
private static let panelSnapshotLock = NSLock()
|
|
private static var panelSnapshots: [UUID: PanelSnapshotState] = [:]
|
|
|
|
private func panelSnapshotReset(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !panelArg.isEmpty else { return "ERROR: Usage: panel_snapshot_reset <panel_id|idx>" }
|
|
|
|
var result = "ERROR: No tab selected"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
guard let panelId = resolveSurfaceId(from: panelArg, tab: tab) else {
|
|
result = "ERROR: Surface not found"
|
|
return
|
|
}
|
|
Self.panelSnapshotLock.lock()
|
|
Self.panelSnapshots.removeValue(forKey: panelId)
|
|
Self.panelSnapshotLock.unlock()
|
|
result = "OK"
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private static func makePanelSnapshot(from cgImage: CGImage) -> PanelSnapshotState? {
|
|
let width = cgImage.width
|
|
let height = cgImage.height
|
|
guard width > 0, height > 0 else { return nil }
|
|
|
|
let bytesPerPixel = 4
|
|
let bytesPerRow = width * bytesPerPixel
|
|
var data = Data(count: bytesPerRow * height)
|
|
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
|
|
let ok: Bool = data.withUnsafeMutableBytes { rawBuf in
|
|
guard let base = rawBuf.baseAddress else { return false }
|
|
guard let ctx = CGContext(
|
|
data: base,
|
|
width: width,
|
|
height: height,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: bytesPerRow,
|
|
space: colorSpace,
|
|
bitmapInfo: bitmapInfo
|
|
) else { return false }
|
|
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
return true
|
|
}
|
|
guard ok else { return nil }
|
|
|
|
return PanelSnapshotState(width: width, height: height, bytesPerRow: bytesPerRow, rgba: data)
|
|
}
|
|
|
|
private static func countChangedPixels(previous: PanelSnapshotState, current: PanelSnapshotState) -> Int {
|
|
// Any mismatch means we can't sensibly diff; treat as a fresh snapshot.
|
|
guard previous.width == current.width,
|
|
previous.height == current.height,
|
|
previous.bytesPerRow == current.bytesPerRow else {
|
|
return -1
|
|
}
|
|
|
|
let threshold = 8 // ignore tiny per-channel jitter
|
|
var changed = 0
|
|
|
|
previous.rgba.withUnsafeBytes { prevRaw in
|
|
current.rgba.withUnsafeBytes { curRaw in
|
|
guard let prev = prevRaw.bindMemory(to: UInt8.self).baseAddress,
|
|
let cur = curRaw.bindMemory(to: UInt8.self).baseAddress else {
|
|
return
|
|
}
|
|
|
|
let count = min(prevRaw.count, curRaw.count)
|
|
var i = 0
|
|
while i + 3 < count {
|
|
let dr = abs(Int(prev[i]) - Int(cur[i]))
|
|
let dg = abs(Int(prev[i + 1]) - Int(cur[i + 1]))
|
|
let db = abs(Int(prev[i + 2]) - Int(cur[i + 2]))
|
|
// Skip alpha channel at i+3.
|
|
if dr + dg + db > threshold {
|
|
changed += 1
|
|
}
|
|
i += 4
|
|
}
|
|
}
|
|
}
|
|
|
|
return changed
|
|
}
|
|
|
|
private func panelSnapshot(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "ERROR: Usage: panel_snapshot <panel_id|idx> [label]" }
|
|
|
|
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
|
|
let panelArg = parts.first ?? ""
|
|
let label = parts.count > 1 ? parts[1] : ""
|
|
|
|
// Generate unique ID for this snapshot/screenshot
|
|
let timestamp = ISO8601DateFormatter().string(from: Date())
|
|
.replacingOccurrences(of: ":", with: "-")
|
|
.replacingOccurrences(of: "+", with: "_")
|
|
let shortId = UUID().uuidString.prefix(8)
|
|
let snapshotId = "\(timestamp)_\(shortId)"
|
|
|
|
let outputDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-screenshots")
|
|
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
|
|
let filename = label.isEmpty ? "\(snapshotId).png" : "\(label)_\(snapshotId).png"
|
|
let outputPath = outputDir.appendingPathComponent(filename)
|
|
|
|
var result = "ERROR: No tab selected"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
guard let panelId = resolveSurfaceId(from: panelArg, tab: tab),
|
|
let terminalPanel = tab.terminalPanel(for: panelId) else {
|
|
result = "ERROR: Terminal surface not found"
|
|
return
|
|
}
|
|
|
|
// Capture the terminal's IOSurface directly, avoiding Screen Recording permissions.
|
|
let view = terminalPanel.hostedView
|
|
var cgImage = view.debugCopyIOSurfaceCGImage()
|
|
if cgImage == nil {
|
|
// If the surface is mid-attach we may not have contents yet. Nudge a draw and retry once.
|
|
terminalPanel.surface.forceRefresh()
|
|
cgImage = view.debugCopyIOSurfaceCGImage()
|
|
}
|
|
guard let cgImage else {
|
|
result = "ERROR: Failed to capture panel image"
|
|
return
|
|
}
|
|
|
|
guard let current = Self.makePanelSnapshot(from: cgImage) else {
|
|
result = "ERROR: Failed to read panel pixels"
|
|
return
|
|
}
|
|
|
|
var changedPixels = -1
|
|
Self.panelSnapshotLock.lock()
|
|
if let previous = Self.panelSnapshots[panelId] {
|
|
changedPixels = Self.countChangedPixels(previous: previous, current: current)
|
|
}
|
|
Self.panelSnapshots[panelId] = current
|
|
Self.panelSnapshotLock.unlock()
|
|
|
|
// Save PNG for postmortem debugging.
|
|
let bitmap = NSBitmapImageRep(cgImage: cgImage)
|
|
guard let pngData = bitmap.representation(using: .png, properties: [:]) else {
|
|
result = "ERROR: Failed to encode PNG"
|
|
return
|
|
}
|
|
|
|
do {
|
|
try pngData.write(to: outputPath)
|
|
} catch {
|
|
result = "ERROR: Failed to write file: \(error.localizedDescription)"
|
|
return
|
|
}
|
|
|
|
result = "OK \(panelId.uuidString) \(changedPixels) \(current.width) \(current.height) \(outputPath.path)"
|
|
}
|
|
|
|
return result
|
|
}
|
|
#endif
|
|
|
|
private struct LayoutDebugSelectedPanel: Codable, Sendable {
|
|
let paneId: String
|
|
let paneFrame: PixelRect?
|
|
let selectedTabId: String?
|
|
let panelId: String?
|
|
let panelType: String?
|
|
let inWindow: Bool?
|
|
let hidden: Bool?
|
|
let viewFrame: PixelRect?
|
|
let splitViews: [LayoutDebugSplitView]?
|
|
}
|
|
|
|
private struct LayoutDebugSplitView: Codable, Sendable {
|
|
let isVertical: Bool
|
|
let dividerThickness: Double
|
|
let bounds: PixelRect
|
|
let frame: PixelRect?
|
|
let arrangedSubviewFrames: [PixelRect]
|
|
let normalizedDividerPosition: Double?
|
|
}
|
|
|
|
private struct LayoutDebugResponse: Codable, Sendable {
|
|
let layout: LayoutSnapshot
|
|
let selectedPanels: [LayoutDebugSelectedPanel]
|
|
let mainWindowNumber: Int?
|
|
let keyWindowNumber: Int?
|
|
}
|
|
|
|
private func layoutDebug() -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result = "ERROR: No tab selected"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
let layout = tab.bonsplitController.layoutSnapshot()
|
|
var paneFrames: [String: PixelRect] = [:]
|
|
for pane in layout.panes {
|
|
paneFrames[pane.paneId] = pane.frame
|
|
}
|
|
|
|
func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
|
|
if view.isHidden { return true }
|
|
var current = view.superview
|
|
while let v = current {
|
|
if v.isHidden { return true }
|
|
current = v.superview
|
|
}
|
|
return false
|
|
}
|
|
|
|
func windowFrame(for view: NSView) -> CGRect? {
|
|
guard view.window != nil else { return nil }
|
|
// Prefer the view's frame as laid out by its superview. Some AppKit views
|
|
// (notably scroll views) can temporarily report stale bounds during reparenting.
|
|
if let superview = view.superview {
|
|
return superview.convert(view.frame, to: nil)
|
|
}
|
|
return view.convert(view.bounds, to: nil)
|
|
}
|
|
|
|
func splitViewInfos(for view: NSView) -> [LayoutDebugSplitView] {
|
|
var infos: [LayoutDebugSplitView] = []
|
|
var current: NSView? = view
|
|
var depth = 0
|
|
while let v = current, depth < 12 {
|
|
if let sv = v as? NSSplitView {
|
|
// The split view can be mid-update during bonsplit structural changes; force a layout
|
|
// pass so our debug snapshot reflects the real state.
|
|
sv.layoutSubtreeIfNeeded()
|
|
let isVertical = sv.isVertical
|
|
let dividerThickness = Double(sv.dividerThickness)
|
|
let bounds = PixelRect(from: sv.bounds)
|
|
let frame = windowFrame(for: sv).map { PixelRect(from: $0) }
|
|
let arranged = sv.arrangedSubviews
|
|
let arrangedFrames = arranged.compactMap { windowFrame(for: $0).map { PixelRect(from: $0) } }
|
|
|
|
// Approximate divider position from the first arranged subview's size.
|
|
let totalSize: CGFloat = isVertical ? sv.bounds.width : sv.bounds.height
|
|
let availableSize = max(totalSize - sv.dividerThickness, 0)
|
|
var normalized: Double? = nil
|
|
if availableSize > 0, let first = arranged.first {
|
|
let dividerPos = isVertical ? first.frame.width : first.frame.height
|
|
normalized = Double(dividerPos / availableSize)
|
|
}
|
|
|
|
infos.append(LayoutDebugSplitView(
|
|
isVertical: isVertical,
|
|
dividerThickness: dividerThickness,
|
|
bounds: bounds,
|
|
frame: frame,
|
|
arrangedSubviewFrames: arrangedFrames,
|
|
normalizedDividerPosition: normalized
|
|
))
|
|
}
|
|
current = v.superview
|
|
depth += 1
|
|
}
|
|
return infos
|
|
}
|
|
|
|
let selectedPanels: [LayoutDebugSelectedPanel] = tab.bonsplitController.allPaneIds.map { paneId in
|
|
let paneIdStr = paneId.id.uuidString
|
|
let paneFrame = paneFrames[paneIdStr]
|
|
let selectedTabId = layout.panes.first(where: { $0.paneId == paneIdStr })?.selectedTabId
|
|
|
|
guard let selectedTab = tab.bonsplitController.selectedTab(inPane: paneId) else {
|
|
return LayoutDebugSelectedPanel(
|
|
paneId: paneIdStr,
|
|
paneFrame: paneFrame,
|
|
selectedTabId: selectedTabId,
|
|
panelId: nil,
|
|
panelType: nil,
|
|
inWindow: nil,
|
|
hidden: nil,
|
|
viewFrame: nil,
|
|
splitViews: nil
|
|
)
|
|
}
|
|
|
|
guard let panelId = tab.panelIdFromSurfaceId(selectedTab.id),
|
|
let panel = tab.panels[panelId] else {
|
|
return LayoutDebugSelectedPanel(
|
|
paneId: paneIdStr,
|
|
paneFrame: paneFrame,
|
|
selectedTabId: selectedTabId,
|
|
panelId: nil,
|
|
panelType: nil,
|
|
inWindow: nil,
|
|
hidden: nil,
|
|
viewFrame: nil,
|
|
splitViews: nil
|
|
)
|
|
}
|
|
|
|
if let tp = panel as? TerminalPanel {
|
|
let viewRect = windowFrame(for: tp.hostedView).map { PixelRect(from: $0) }
|
|
let splitViews = splitViewInfos(for: tp.hostedView)
|
|
return LayoutDebugSelectedPanel(
|
|
paneId: paneIdStr,
|
|
paneFrame: paneFrame,
|
|
selectedTabId: selectedTabId,
|
|
panelId: panelId.uuidString,
|
|
panelType: tp.panelType.rawValue,
|
|
inWindow: tp.surface.isViewInWindow,
|
|
hidden: isHiddenOrAncestorHidden(tp.hostedView),
|
|
viewFrame: viewRect,
|
|
splitViews: splitViews
|
|
)
|
|
}
|
|
|
|
if let bp = panel as? BrowserPanel {
|
|
let viewRect = windowFrame(for: bp.webView).map { PixelRect(from: $0) }
|
|
let splitViews = splitViewInfos(for: bp.webView)
|
|
return LayoutDebugSelectedPanel(
|
|
paneId: paneIdStr,
|
|
paneFrame: paneFrame,
|
|
selectedTabId: selectedTabId,
|
|
panelId: panelId.uuidString,
|
|
panelType: bp.panelType.rawValue,
|
|
inWindow: bp.webView.window != nil,
|
|
hidden: isHiddenOrAncestorHidden(bp.webView),
|
|
viewFrame: viewRect,
|
|
splitViews: splitViews
|
|
)
|
|
}
|
|
|
|
return LayoutDebugSelectedPanel(
|
|
paneId: paneIdStr,
|
|
paneFrame: paneFrame,
|
|
selectedTabId: selectedTabId,
|
|
panelId: panelId.uuidString,
|
|
panelType: panel.panelType.rawValue,
|
|
inWindow: nil,
|
|
hidden: nil,
|
|
viewFrame: nil,
|
|
splitViews: nil
|
|
)
|
|
}
|
|
|
|
let payload = LayoutDebugResponse(
|
|
layout: layout,
|
|
selectedPanels: selectedPanels,
|
|
mainWindowNumber: NSApp.mainWindow?.windowNumber,
|
|
keyWindowNumber: NSApp.keyWindow?.windowNumber
|
|
)
|
|
|
|
let encoder = JSONEncoder()
|
|
guard let data = try? encoder.encode(payload),
|
|
let json = String(data: data, encoding: .utf8) else {
|
|
result = "ERROR: Failed to encode layout_debug"
|
|
return
|
|
}
|
|
|
|
result = "OK \(json)"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func emptyPanelCount() -> String {
|
|
var result = "OK 0"
|
|
DispatchQueue.main.sync {
|
|
result = "OK \(DebugUIEventCounters.emptyPanelAppearCount)"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func resetEmptyPanelCount() -> String {
|
|
DispatchQueue.main.sync {
|
|
DebugUIEventCounters.resetEmptyPanelAppearCount()
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
private func bonsplitUnderflowCount() -> String {
|
|
var result = "OK 0"
|
|
DispatchQueue.main.sync {
|
|
#if DEBUG
|
|
result = "OK \(BonsplitDebugCounters.arrangedSubviewUnderflowCount)"
|
|
#else
|
|
result = "OK 0"
|
|
#endif
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func resetBonsplitUnderflowCount() -> String {
|
|
DispatchQueue.main.sync {
|
|
#if DEBUG
|
|
BonsplitDebugCounters.reset()
|
|
#endif
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
private func captureScreenshot(_ args: String) -> String {
|
|
// Parse optional label from args
|
|
let label = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
// Generate unique ID for this screenshot
|
|
let timestamp = ISO8601DateFormatter().string(from: Date())
|
|
.replacingOccurrences(of: ":", with: "-")
|
|
.replacingOccurrences(of: "+", with: "_")
|
|
let shortId = UUID().uuidString.prefix(8)
|
|
let screenshotId = "\(timestamp)_\(shortId)"
|
|
|
|
// Determine output path
|
|
let outputDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-screenshots")
|
|
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
|
|
|
|
let filename = label.isEmpty ? "\(screenshotId).png" : "\(label)_\(screenshotId).png"
|
|
let outputPath = outputDir.appendingPathComponent(filename)
|
|
|
|
// Capture the main window on main thread
|
|
var captureError: String?
|
|
DispatchQueue.main.sync {
|
|
guard let window = NSApp.mainWindow ?? NSApp.windows.first else {
|
|
captureError = "No window available"
|
|
return
|
|
}
|
|
|
|
// Get window's CGWindowID
|
|
let windowNumber = CGWindowID(window.windowNumber)
|
|
|
|
// Capture the window using CGWindowListCreateImage
|
|
guard let cgImage = CGWindowListCreateImage(
|
|
.null, // Capture just the window bounds
|
|
.optionIncludingWindow,
|
|
windowNumber,
|
|
[.boundsIgnoreFraming, .nominalResolution]
|
|
) else {
|
|
captureError = "Failed to capture window image"
|
|
return
|
|
}
|
|
|
|
// Convert to NSBitmapImageRep and save as PNG
|
|
let bitmap = NSBitmapImageRep(cgImage: cgImage)
|
|
guard let pngData = bitmap.representation(using: .png, properties: [:]) else {
|
|
captureError = "Failed to create PNG data"
|
|
return
|
|
}
|
|
|
|
do {
|
|
try pngData.write(to: outputPath)
|
|
} catch {
|
|
captureError = "Failed to write file: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
if let error = captureError {
|
|
return "ERROR: \(error)"
|
|
}
|
|
|
|
// Return OK with screenshot ID and path for easy reference
|
|
return "OK \(screenshotId) \(outputPath.path)"
|
|
}
|
|
#endif
|
|
|
|
private func parseSplitDirection(_ value: String) -> SplitDirection? {
|
|
switch value.lowercased() {
|
|
case "left", "l":
|
|
return .left
|
|
case "right", "r":
|
|
return .right
|
|
case "up", "u":
|
|
return .up
|
|
case "down", "d":
|
|
return .down
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func resolveTab(from arg: String, tabManager: TabManager) -> Tab? {
|
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty {
|
|
guard let selected = tabManager.selectedTabId else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == selected })
|
|
}
|
|
|
|
if let uuid = UUID(uuidString: trimmed) {
|
|
return tabManager.tabs.first(where: { $0.id == uuid })
|
|
}
|
|
|
|
if let index = Int(trimmed), index >= 0, index < tabManager.tabs.count {
|
|
return tabManager.tabs[index]
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func orderedPanels(in tab: Workspace) -> [any Panel] {
|
|
// Use bonsplit's tab ordering as the source of truth. This avoids relying on
|
|
// Dictionary iteration order, and prevents indexing into panels that aren't
|
|
// actually present in bonsplit anymore.
|
|
let orderedTabIds = tab.bonsplitController.allTabIds
|
|
var result: [any Panel] = []
|
|
var seen = Set<UUID>()
|
|
|
|
for tabId in orderedTabIds {
|
|
guard let panelId = tab.panelIdFromSurfaceId(tabId),
|
|
let panel = tab.panels[panelId] else { continue }
|
|
result.append(panel)
|
|
seen.insert(panelId)
|
|
}
|
|
|
|
// Defensive: include any orphaned panels in a stable order at the end.
|
|
let orphans = tab.panels.values
|
|
.filter { !seen.contains($0.id) }
|
|
.sorted { $0.id.uuidString < $1.id.uuidString }
|
|
result.append(contentsOf: orphans)
|
|
|
|
return result
|
|
}
|
|
|
|
private func resolveTerminalPanel(from arg: String, tabManager: TabManager) -> TerminalPanel? {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return nil
|
|
}
|
|
|
|
if let uuid = UUID(uuidString: arg) {
|
|
return tab.terminalPanel(for: uuid)
|
|
}
|
|
|
|
if let index = Int(arg), index >= 0 {
|
|
let panels = orderedPanels(in: tab)
|
|
guard index < panels.count else { return nil }
|
|
return panels[index] as? TerminalPanel
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func resolveTerminalSurface(from arg: String, tabManager: TabManager, waitUpTo timeout: TimeInterval = 0.6) -> ghostty_surface_t? {
|
|
guard let terminalPanel = resolveTerminalPanel(from: arg, tabManager: tabManager) else { return nil }
|
|
return waitForTerminalSurface(terminalPanel, waitUpTo: timeout)
|
|
}
|
|
|
|
private func waitForTerminalSurface(_ terminalPanel: TerminalPanel, waitUpTo timeout: TimeInterval = 0.6) -> ghostty_surface_t? {
|
|
if let surface = terminalPanel.surface.surface { return surface }
|
|
|
|
// This can be transient during bonsplit tree restructuring when the SwiftUI
|
|
// view is temporarily detached and then reattached (surface creation is
|
|
// gated on view/window/bounds). Pump the runloop briefly to allow pending
|
|
// attach retries to execute.
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while terminalPanel.surface.surface == nil && Date() < deadline {
|
|
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
|
}
|
|
|
|
return terminalPanel.surface.surface
|
|
}
|
|
|
|
private func resolveSurface(from arg: String, tabManager: TabManager) -> ghostty_surface_t? {
|
|
// Backwards compatibility: resolve a terminal surface by panel UUID or a stable index.
|
|
// Use a slightly longer wait to reduce flakiness during bonsplit/layout restructures.
|
|
return resolveTerminalSurface(from: arg, tabManager: tabManager, waitUpTo: 2.0)
|
|
}
|
|
|
|
private func resolveSurfaceId(from arg: String, tab: Workspace) -> UUID? {
|
|
if let uuid = UUID(uuidString: arg), tab.panels[uuid] != nil {
|
|
return uuid
|
|
}
|
|
|
|
if let index = Int(arg), index >= 0 {
|
|
let panels = orderedPanels(in: tab)
|
|
guard index < panels.count else { return nil }
|
|
return panels[index].id
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func parseNotificationPayload(_ args: String) -> (String, String, String) {
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return ("Notification", "", "") }
|
|
let parts = trimmed.split(separator: "|", maxSplits: 2, omittingEmptySubsequences: false).map(String.init)
|
|
let title = parts.count > 0 ? parts[0].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
|
let subtitle = parts.count > 2 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
|
let body = parts.count > 2
|
|
? parts[2].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
: (parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : "")
|
|
return (title.isEmpty ? "Notification" : title, subtitle, body)
|
|
}
|
|
|
|
private func closeWorkspace(_ tabId: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
guard let uuid = UUID(uuidString: tabId) else { return "ERROR: Invalid tab ID" }
|
|
|
|
var success = false
|
|
DispatchQueue.main.sync {
|
|
if let tab = tabManager.tabs.first(where: { $0.id == uuid }) {
|
|
tabManager.closeTab(tab)
|
|
success = true
|
|
}
|
|
}
|
|
return success ? "OK" : "ERROR: Tab not found"
|
|
}
|
|
|
|
private func selectWorkspace(_ arg: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var success = false
|
|
DispatchQueue.main.sync {
|
|
// Try as UUID first
|
|
if let uuid = UUID(uuidString: arg) {
|
|
if let tab = tabManager.tabs.first(where: { $0.id == uuid }) {
|
|
tabManager.selectTab(tab)
|
|
success = true
|
|
}
|
|
}
|
|
// Try as index
|
|
else if let index = Int(arg), index >= 0, index < tabManager.tabs.count {
|
|
tabManager.selectTab(at: index)
|
|
success = true
|
|
}
|
|
}
|
|
return success ? "OK" : "ERROR: Tab not found"
|
|
}
|
|
|
|
private func currentWorkspace() -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result: String = ""
|
|
DispatchQueue.main.sync {
|
|
if let id = tabManager.selectedTabId {
|
|
result = id.uuidString
|
|
}
|
|
}
|
|
return result.isEmpty ? "ERROR: No tab selected" : result
|
|
}
|
|
|
|
private func sendKeyEvent(
|
|
surface: ghostty_surface_t,
|
|
keycode: UInt32,
|
|
mods: ghostty_input_mods_e = GHOSTTY_MODS_NONE,
|
|
text: String? = nil
|
|
) {
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = GHOSTTY_ACTION_PRESS
|
|
keyEvent.keycode = keycode
|
|
keyEvent.mods = mods
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.unshifted_codepoint = 0
|
|
keyEvent.composing = false
|
|
if let text {
|
|
text.withCString { ptr in
|
|
keyEvent.text = ptr
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
} else {
|
|
keyEvent.text = nil
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
}
|
|
|
|
private func sendTextEvent(surface: ghostty_surface_t, text: String) {
|
|
sendKeyEvent(surface: surface, keycode: 0, text: text)
|
|
}
|
|
|
|
enum SocketTextChunk: Equatable {
|
|
case text(String)
|
|
case control(UnicodeScalar)
|
|
}
|
|
|
|
nonisolated static func socketTextChunks(_ text: String) -> [SocketTextChunk] {
|
|
guard !text.isEmpty else { return [] }
|
|
|
|
var chunks: [SocketTextChunk] = []
|
|
chunks.reserveCapacity(8)
|
|
var bufferedText = ""
|
|
bufferedText.reserveCapacity(text.count)
|
|
|
|
func flushBufferedText() {
|
|
guard !bufferedText.isEmpty else { return }
|
|
chunks.append(.text(bufferedText))
|
|
bufferedText.removeAll(keepingCapacity: true)
|
|
}
|
|
|
|
for scalar in text.unicodeScalars {
|
|
if isSocketControlScalar(scalar) {
|
|
flushBufferedText()
|
|
chunks.append(.control(scalar))
|
|
} else {
|
|
bufferedText.unicodeScalars.append(scalar)
|
|
}
|
|
}
|
|
flushBufferedText()
|
|
return chunks
|
|
}
|
|
|
|
private nonisolated static func isSocketControlScalar(_ scalar: UnicodeScalar) -> Bool {
|
|
switch scalar.value {
|
|
case 0x0A, 0x0D, 0x09, 0x1B, 0x7F:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
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:
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Return))
|
|
return true
|
|
case 0x09:
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab))
|
|
return true
|
|
case 0x1B:
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Escape))
|
|
return true
|
|
case 0x7F:
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Delete))
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func keycodeForLetter(_ letter: Character) -> UInt32? {
|
|
switch String(letter).lowercased() {
|
|
case "a": return UInt32(kVK_ANSI_A)
|
|
case "b": return UInt32(kVK_ANSI_B)
|
|
case "c": return UInt32(kVK_ANSI_C)
|
|
case "d": return UInt32(kVK_ANSI_D)
|
|
case "e": return UInt32(kVK_ANSI_E)
|
|
case "f": return UInt32(kVK_ANSI_F)
|
|
case "g": return UInt32(kVK_ANSI_G)
|
|
case "h": return UInt32(kVK_ANSI_H)
|
|
case "i": return UInt32(kVK_ANSI_I)
|
|
case "j": return UInt32(kVK_ANSI_J)
|
|
case "k": return UInt32(kVK_ANSI_K)
|
|
case "l": return UInt32(kVK_ANSI_L)
|
|
case "m": return UInt32(kVK_ANSI_M)
|
|
case "n": return UInt32(kVK_ANSI_N)
|
|
case "o": return UInt32(kVK_ANSI_O)
|
|
case "p": return UInt32(kVK_ANSI_P)
|
|
case "q": return UInt32(kVK_ANSI_Q)
|
|
case "r": return UInt32(kVK_ANSI_R)
|
|
case "s": return UInt32(kVK_ANSI_S)
|
|
case "t": return UInt32(kVK_ANSI_T)
|
|
case "u": return UInt32(kVK_ANSI_U)
|
|
case "v": return UInt32(kVK_ANSI_V)
|
|
case "w": return UInt32(kVK_ANSI_W)
|
|
case "x": return UInt32(kVK_ANSI_X)
|
|
case "y": return UInt32(kVK_ANSI_Y)
|
|
case "z": return UInt32(kVK_ANSI_Z)
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
private func sendNamedKey(_ surface: ghostty_surface_t, keyName: String) -> Bool {
|
|
switch keyName.lowercased() {
|
|
case "ctrl-c", "ctrl+c", "sigint":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_C), mods: GHOSTTY_MODS_CTRL)
|
|
return true
|
|
case "ctrl-d", "ctrl+d", "eof":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_D), mods: GHOSTTY_MODS_CTRL)
|
|
return true
|
|
case "ctrl-z", "ctrl+z", "sigtstp":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_Z), mods: GHOSTTY_MODS_CTRL)
|
|
return true
|
|
case "ctrl-\\", "ctrl+\\", "sigquit":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_Backslash), mods: GHOSTTY_MODS_CTRL)
|
|
return true
|
|
case "enter", "return":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Return))
|
|
return true
|
|
case "tab":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab))
|
|
return true
|
|
case "escape", "esc":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Escape))
|
|
return true
|
|
case "backspace":
|
|
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Delete))
|
|
return true
|
|
default:
|
|
if keyName.lowercased().hasPrefix("ctrl-") || keyName.lowercased().hasPrefix("ctrl+") {
|
|
let letter = keyName.dropFirst(5)
|
|
if letter.count == 1, let char = letter.first, let keycode = keycodeForLetter(char) {
|
|
sendKeyEvent(surface: surface, keycode: keycode, mods: GHOSTTY_MODS_CTRL)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func sendInput(_ text: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var success = false
|
|
var error: String?
|
|
DispatchQueue.main.sync {
|
|
guard let selectedId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == selectedId }),
|
|
let terminalPanel = tab.focusedTerminalPanel else {
|
|
error = "ERROR: No focused terminal"
|
|
return
|
|
}
|
|
|
|
// Unescape common escape sequences
|
|
// Note: \n is converted to \r for terminal (Enter key sends \r)
|
|
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()
|
|
}
|
|
success = true
|
|
}
|
|
if let error { return error }
|
|
return success ? "OK" : "ERROR: Failed to send input"
|
|
}
|
|
|
|
private func sendInputToWorkspace(_ args: String) -> String {
|
|
guard let tabManager else { return "ERROR: TabManager not available" }
|
|
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard parts.count == 2 else { return "ERROR: Usage: send_workspace <workspace_id> <text>" }
|
|
|
|
let workspaceArg = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let text = parts[1]
|
|
guard let workspaceId = UUID(uuidString: workspaceArg) else {
|
|
return "ERROR: Invalid workspace ID"
|
|
}
|
|
|
|
var success = false
|
|
var error: String?
|
|
DispatchQueue.main.sync {
|
|
guard let targetManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId)
|
|
?? (tabManager.tabs.contains(where: { $0.id == workspaceId }) ? tabManager : nil) else {
|
|
error = "ERROR: Workspace not found"
|
|
return
|
|
}
|
|
guard let tab = targetManager.tabs.first(where: { $0.id == workspaceId }),
|
|
let terminalPanel = tab.focusedTerminalPanel else {
|
|
error = "ERROR: No focused terminal in workspace"
|
|
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()
|
|
}
|
|
success = true
|
|
}
|
|
|
|
if let error { return error }
|
|
return success ? "OK" : "ERROR: Failed to send input"
|
|
}
|
|
|
|
private func sendInputToSurface(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard parts.count == 2 else { return "ERROR: Usage: send_surface <id|idx> <text>" }
|
|
|
|
let target = parts[0]
|
|
let text = parts[1]
|
|
|
|
var success = false
|
|
DispatchQueue.main.sync {
|
|
guard let terminalPanel = resolveTerminalPanel(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()
|
|
}
|
|
success = true
|
|
}
|
|
|
|
return success ? "OK" : "ERROR: Failed to send input"
|
|
}
|
|
|
|
private func sendKey(_ keyName: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var success = false
|
|
var error: String?
|
|
DispatchQueue.main.sync {
|
|
guard let selectedId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == selectedId }),
|
|
let terminalPanel = tab.focusedTerminalPanel else {
|
|
error = "ERROR: No focused terminal"
|
|
return
|
|
}
|
|
|
|
guard let surface = terminalPanel.surface.surface else {
|
|
error = "ERROR: Surface not ready"
|
|
return
|
|
}
|
|
|
|
success = sendNamedKey(surface, keyName: keyName)
|
|
}
|
|
if let error { return error }
|
|
return success ? "OK" : "ERROR: Unknown key '\(keyName)'"
|
|
}
|
|
|
|
private func sendKeyToSurface(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard parts.count == 2 else { return "ERROR: Usage: send_key_surface <id|idx> <key>" }
|
|
|
|
let target = parts[0]
|
|
let keyName = parts[1]
|
|
|
|
var success = false
|
|
var error: String?
|
|
DispatchQueue.main.sync {
|
|
guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else {
|
|
error = "ERROR: Surface not found"
|
|
return
|
|
}
|
|
guard let surface = terminalPanel.surface.surface else {
|
|
error = "ERROR: Surface not ready"
|
|
return
|
|
}
|
|
success = sendNamedKey(surface, keyName: keyName)
|
|
}
|
|
|
|
if let error { return error }
|
|
return success ? "OK" : "ERROR: Unknown key '\(keyName)'"
|
|
}
|
|
|
|
// MARK: - Browser Panel Commands
|
|
|
|
private func openBrowser(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
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 {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
|
let focusedPanelId = tab.focusedPanelId else {
|
|
return
|
|
}
|
|
|
|
if let browserPanelId = tab.newBrowserSplit(
|
|
from: focusedPanelId,
|
|
orientation: .horizontal,
|
|
url: url,
|
|
focus: shouldFocus
|
|
)?.id {
|
|
result = "OK \(browserPanelId.uuidString)"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func navigateBrowser(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
|
|
guard parts.count == 2 else { return "ERROR: Usage: navigate <panel_id> <url>" }
|
|
|
|
let panelArg = parts[0]
|
|
let urlStr = parts[1]
|
|
|
|
var result = "ERROR: Panel not found or not a browser"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
|
let panelId = UUID(uuidString: panelArg),
|
|
let browserPanel = tab.browserPanel(for: panelId) else {
|
|
return
|
|
}
|
|
|
|
browserPanel.navigateSmart(urlStr)
|
|
result = "OK"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func browserBack(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !panelArg.isEmpty else { return "ERROR: Usage: browser_back <panel_id>" }
|
|
|
|
var result = "ERROR: Panel not found or not a browser"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
|
let panelId = UUID(uuidString: panelArg),
|
|
let browserPanel = tab.browserPanel(for: panelId) else {
|
|
return
|
|
}
|
|
|
|
browserPanel.goBack()
|
|
result = "OK"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func browserForward(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !panelArg.isEmpty else { return "ERROR: Usage: browser_forward <panel_id>" }
|
|
|
|
var result = "ERROR: Panel not found or not a browser"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
|
let panelId = UUID(uuidString: panelArg),
|
|
let browserPanel = tab.browserPanel(for: panelId) else {
|
|
return
|
|
}
|
|
|
|
browserPanel.goForward()
|
|
result = "OK"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func browserReload(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !panelArg.isEmpty else { return "ERROR: Usage: browser_reload <panel_id>" }
|
|
|
|
var result = "ERROR: Panel not found or not a browser"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
|
let panelId = UUID(uuidString: panelArg),
|
|
let browserPanel = tab.browserPanel(for: panelId) else {
|
|
return
|
|
}
|
|
|
|
browserPanel.reload()
|
|
result = "OK"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func getUrl(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !panelArg.isEmpty else { return "ERROR: Usage: get_url <panel_id>" }
|
|
|
|
var result = "ERROR: Panel not found or not a browser"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
|
let panelId = UUID(uuidString: panelArg),
|
|
let browserPanel = tab.browserPanel(for: panelId) else {
|
|
return
|
|
}
|
|
|
|
result = browserPanel.currentURL?.absoluteString ?? ""
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func focusWebView(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !panelArg.isEmpty else { return "ERROR: Usage: focus_webview <panel_id>" }
|
|
|
|
var result = "ERROR: Panel not found or not a browser"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
|
let panelId = UUID(uuidString: panelArg),
|
|
let browserPanel = tab.browserPanel(for: panelId) else {
|
|
return
|
|
}
|
|
|
|
// Programmatic WebView focus should win over stale omnibar focus state, especially
|
|
// after workspace switches where the blank-page omnibar auto-focus can re-trigger.
|
|
browserPanel.endSuppressWebViewFocusForAddressBar()
|
|
browserPanel.clearWebViewFocusSuppression()
|
|
NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panelId)
|
|
|
|
// Prevent omnibar auto-focus from immediately stealing first responder back.
|
|
browserPanel.suppressOmnibarAutofocus(for: 1.5)
|
|
|
|
let webView = browserPanel.webView
|
|
guard let window = webView.window else {
|
|
result = "ERROR: WebView is not in a window"
|
|
return
|
|
}
|
|
guard !webView.isHiddenOrHasHiddenAncestor else {
|
|
result = "ERROR: WebView is hidden"
|
|
return
|
|
}
|
|
|
|
window.makeFirstResponder(webView)
|
|
if Self.responderChainContains(window.firstResponder, target: webView) {
|
|
// Some focus churn paths (workspace handoff / omnibar blur) can race this call.
|
|
// Reassert on the next runloop if another responder steals focus immediately.
|
|
DispatchQueue.main.async { [weak window, weak webView] in
|
|
guard let window, let webView else { return }
|
|
guard webView.window === window else { return }
|
|
if !Self.responderChainContains(window.firstResponder, target: webView) {
|
|
window.makeFirstResponder(webView)
|
|
}
|
|
}
|
|
result = "OK"
|
|
} else {
|
|
result = "ERROR: Focus did not move into web view"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func isWebViewFocused(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !panelArg.isEmpty else { return "ERROR: Usage: is_webview_focused <panel_id>" }
|
|
|
|
var result = "ERROR: Panel not found or not a browser"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
|
let panelId = UUID(uuidString: panelArg),
|
|
let browserPanel = tab.browserPanel(for: panelId) else {
|
|
return
|
|
}
|
|
|
|
let webView = browserPanel.webView
|
|
guard let window = webView.window else {
|
|
result = "false"
|
|
return
|
|
}
|
|
result = Self.responderChainContains(window.firstResponder, target: webView) ? "true" : "false"
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - Bonsplit Pane Commands
|
|
|
|
private func listPanes() -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
result = "ERROR: No tab selected"
|
|
return
|
|
}
|
|
|
|
let paneIds = tab.bonsplitController.allPaneIds
|
|
let focusedPaneId = tab.bonsplitController.focusedPaneId
|
|
|
|
let lines = paneIds.enumerated().map { index, paneId in
|
|
let selected = paneId == focusedPaneId ? "*" : " "
|
|
let tabCount = tab.bonsplitController.tabs(inPane: paneId).count
|
|
return "\(selected) \(index): \(paneId) [\(tabCount) tabs]"
|
|
}
|
|
result = lines.isEmpty ? "No panes" : lines.joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func listPaneSurfaces(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
result = "ERROR: No tab selected"
|
|
return
|
|
}
|
|
|
|
// Parse --pane=<pane-id|index> argument (UUID preferred).
|
|
var paneArg: String?
|
|
for part in args.split(separator: " ") {
|
|
if part.hasPrefix("--pane=") {
|
|
paneArg = String(part.dropFirst(7))
|
|
break
|
|
}
|
|
}
|
|
|
|
let paneIds = tab.bonsplitController.allPaneIds
|
|
var targetPaneId: PaneID? = tab.bonsplitController.focusedPaneId
|
|
if let paneArg {
|
|
if let uuid = UUID(uuidString: paneArg),
|
|
let paneId = paneIds.first(where: { $0.id == uuid }) {
|
|
targetPaneId = paneId
|
|
} else if let index = Int(paneArg), index >= 0, index < paneIds.count {
|
|
targetPaneId = paneIds[index]
|
|
} else {
|
|
result = "ERROR: Pane not found"
|
|
return
|
|
}
|
|
}
|
|
|
|
guard let paneId = targetPaneId else {
|
|
result = "ERROR: No pane to list tabs from"
|
|
return
|
|
}
|
|
|
|
let tabs = tab.bonsplitController.tabs(inPane: paneId)
|
|
let selectedTab = tab.bonsplitController.selectedTab(inPane: paneId)
|
|
|
|
let lines = tabs.enumerated().map { index, bonsplitTab in
|
|
let selected = bonsplitTab.id == selectedTab?.id ? "*" : " "
|
|
let panelId = tab.panelIdFromSurfaceId(bonsplitTab.id)
|
|
let panelIdStr = panelId?.uuidString ?? "unknown"
|
|
return "\(selected) \(index): \(bonsplitTab.title) [panel:\(panelIdStr)]"
|
|
}
|
|
result = lines.isEmpty ? "No tabs in pane" : lines.joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func focusPane(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let paneArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !paneArg.isEmpty else { return "ERROR: Usage: focus_pane <pane_id>" }
|
|
|
|
var result = "ERROR: Pane not found"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
let paneIds = tab.bonsplitController.allPaneIds
|
|
|
|
// Try UUID first, then fall back to index
|
|
if let uuid = UUID(uuidString: paneArg),
|
|
let paneId = paneIds.first(where: { $0.id == uuid }) {
|
|
tab.bonsplitController.focusPane(paneId)
|
|
result = "OK"
|
|
} else if let index = Int(paneArg), index >= 0, index < paneIds.count {
|
|
tab.bonsplitController.focusPane(paneIds[index])
|
|
result = "OK"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func focusSurfaceByPanel(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let tabArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !tabArg.isEmpty else { return "ERROR: Usage: focus_surface_by_panel <panel_id>" }
|
|
|
|
var result = "ERROR: Panel not found"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
// Focus by panel UUID (our stable surface handle). This must also move AppKit
|
|
// first responder into the terminal view to ensure typing routes correctly.
|
|
if let panelUUID = UUID(uuidString: tabArg),
|
|
tab.panels[panelUUID] != nil,
|
|
tab.surfaceIdFromPanelId(panelUUID) != nil {
|
|
tabManager.focusSurface(tabId: tab.id, surfaceId: panelUUID)
|
|
result = "OK"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func dragSurfaceToSplit(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let parts = trimmed.split(separator: " ").map(String.init)
|
|
guard parts.count >= 2 else { return "ERROR: Usage: drag_surface_to_split <id|idx> <left|right|up|down>" }
|
|
|
|
let surfaceArg = parts[0]
|
|
let directionArg = parts[1]
|
|
guard let direction = parseSplitDirection(directionArg) else {
|
|
return "ERROR: Invalid direction. Use left, right, up, or down."
|
|
}
|
|
|
|
let orientation: SplitOrientation = direction.isHorizontal ? .horizontal : .vertical
|
|
let insertFirst = (direction == .left || direction == .up)
|
|
|
|
var result = "ERROR: Failed to move surface"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
result = "ERROR: No tab selected"
|
|
return
|
|
}
|
|
|
|
guard let panelId = resolveSurfaceId(from: surfaceArg, tab: tab),
|
|
let bonsplitTabId = tab.surfaceIdFromPanelId(panelId) else {
|
|
result = "ERROR: Surface not found"
|
|
return
|
|
}
|
|
|
|
guard let newPaneId = tab.bonsplitController.splitPane(
|
|
orientation: orientation,
|
|
movingTab: bonsplitTabId,
|
|
insertFirst: insertFirst
|
|
) else {
|
|
result = "ERROR: Failed to split pane"
|
|
return
|
|
}
|
|
|
|
result = "OK \(newPaneId.id.uuidString)"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func newPane(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
// Parse arguments: --type=terminal|browser --direction=left|right|up|down --url=...
|
|
var panelType: PanelType = .terminal
|
|
var direction: SplitDirection = .right
|
|
var url: URL? = nil
|
|
var invalidDirection = false
|
|
|
|
let parts = args.split(separator: " ")
|
|
for part in parts {
|
|
let partStr = String(part)
|
|
if partStr.hasPrefix("--type=") {
|
|
let typeStr = String(partStr.dropFirst(7))
|
|
panelType = typeStr == "browser" ? .browser : .terminal
|
|
} else if partStr.hasPrefix("--direction=") {
|
|
let dirStr = String(partStr.dropFirst(12))
|
|
if let parsed = parseSplitDirection(dirStr) {
|
|
direction = parsed
|
|
} else {
|
|
invalidDirection = true
|
|
}
|
|
} else if partStr.hasPrefix("--url=") {
|
|
let urlStr = String(partStr.dropFirst(6))
|
|
url = URL(string: urlStr)
|
|
}
|
|
}
|
|
|
|
if invalidDirection {
|
|
return "ERROR: Invalid direction. Use left, right, up, or down."
|
|
}
|
|
|
|
let orientation = direction.orientation
|
|
let insertFirst = direction.insertFirst
|
|
let shouldFocus = socketCommandAllowsInAppFocusMutations()
|
|
|
|
var result = "ERROR: Failed to create pane"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
|
let focusedPanelId = tab.focusedPanelId else {
|
|
return
|
|
}
|
|
|
|
let newPanelId: UUID?
|
|
if panelType == .browser {
|
|
newPanelId = tab.newBrowserSplit(
|
|
from: focusedPanelId,
|
|
orientation: orientation,
|
|
insertFirst: insertFirst,
|
|
url: url,
|
|
focus: shouldFocus
|
|
)?.id
|
|
} else {
|
|
newPanelId = tab.newTerminalSplit(
|
|
from: focusedPanelId,
|
|
orientation: orientation,
|
|
insertFirst: insertFirst,
|
|
focus: shouldFocus
|
|
)?.id
|
|
}
|
|
|
|
if let id = newPanelId {
|
|
result = "OK \(id.uuidString)"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - Option Parsing (sidebar metadata commands)
|
|
|
|
private func tokenizeArgs(_ args: String) -> [String] {
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return [] }
|
|
|
|
var tokens: [String] = []
|
|
var current = ""
|
|
var inQuote = false
|
|
var quoteChar: Character = "\""
|
|
var cursor = trimmed.startIndex
|
|
|
|
while cursor < trimmed.endIndex {
|
|
let char = trimmed[cursor]
|
|
if inQuote {
|
|
if char == quoteChar {
|
|
inQuote = false
|
|
cursor = trimmed.index(after: cursor)
|
|
continue
|
|
}
|
|
if char == "\\" {
|
|
let nextIndex = trimmed.index(after: cursor)
|
|
if nextIndex < trimmed.endIndex {
|
|
let next = trimmed[nextIndex]
|
|
switch next {
|
|
case "n":
|
|
current.append("\n")
|
|
cursor = trimmed.index(after: nextIndex)
|
|
continue
|
|
case "r":
|
|
current.append("\r")
|
|
cursor = trimmed.index(after: nextIndex)
|
|
continue
|
|
case "t":
|
|
current.append("\t")
|
|
cursor = trimmed.index(after: nextIndex)
|
|
continue
|
|
case "\"", "'", "\\":
|
|
current.append(next)
|
|
cursor = trimmed.index(after: nextIndex)
|
|
continue
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
current.append(char)
|
|
cursor = trimmed.index(after: cursor)
|
|
continue
|
|
}
|
|
|
|
if char == "'" || char == "\"" {
|
|
inQuote = true
|
|
quoteChar = char
|
|
cursor = trimmed.index(after: cursor)
|
|
continue
|
|
}
|
|
|
|
if char.isWhitespace {
|
|
if !current.isEmpty {
|
|
tokens.append(current)
|
|
current = ""
|
|
}
|
|
cursor = trimmed.index(after: cursor)
|
|
continue
|
|
}
|
|
|
|
current.append(char)
|
|
cursor = trimmed.index(after: cursor)
|
|
}
|
|
|
|
if !current.isEmpty {
|
|
tokens.append(current)
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
private func parseOptions(_ args: String) -> (positional: [String], options: [String: String]) {
|
|
let tokens = tokenizeArgs(args)
|
|
guard !tokens.isEmpty else { return ([], [:]) }
|
|
|
|
var positional: [String] = []
|
|
var options: [String: String] = [:]
|
|
var stopParsingOptions = false
|
|
var i = 0
|
|
while i < tokens.count {
|
|
let token = tokens[i]
|
|
if stopParsingOptions {
|
|
positional.append(token)
|
|
} else if token == "--" {
|
|
stopParsingOptions = true
|
|
} else if token.hasPrefix("--") {
|
|
if let eqIndex = token.firstIndex(of: "=") {
|
|
let key = String(token[token.index(token.startIndex, offsetBy: 2)..<eqIndex])
|
|
let value = String(token[token.index(after: eqIndex)...])
|
|
options[key] = value
|
|
} else {
|
|
let key = String(token.dropFirst(2))
|
|
if i + 1 < tokens.count && !tokens[i + 1].hasPrefix("--") {
|
|
options[key] = tokens[i + 1]
|
|
i += 1
|
|
} else {
|
|
options[key] = ""
|
|
}
|
|
}
|
|
} else {
|
|
positional.append(token)
|
|
}
|
|
i += 1
|
|
}
|
|
return (positional, options)
|
|
}
|
|
|
|
private func parseOptionsNoStop(_ args: String) -> (positional: [String], options: [String: String]) {
|
|
let tokens = tokenizeArgs(args)
|
|
guard !tokens.isEmpty else { return ([], [:]) }
|
|
|
|
var positional: [String] = []
|
|
var options: [String: String] = [:]
|
|
var i = 0
|
|
while i < tokens.count {
|
|
let token = tokens[i]
|
|
if token == "--" {
|
|
i += 1
|
|
continue
|
|
}
|
|
if token.hasPrefix("--") {
|
|
if let eqIndex = token.firstIndex(of: "=") {
|
|
let key = String(token[token.index(token.startIndex, offsetBy: 2)..<eqIndex])
|
|
let value = String(token[token.index(after: eqIndex)...])
|
|
options[key] = value
|
|
} else {
|
|
let key = String(token.dropFirst(2))
|
|
if i + 1 < tokens.count && !tokens[i + 1].hasPrefix("--") {
|
|
options[key] = tokens[i + 1]
|
|
i += 1
|
|
} else {
|
|
options[key] = ""
|
|
}
|
|
}
|
|
} else {
|
|
positional.append(token)
|
|
}
|
|
i += 1
|
|
}
|
|
return (positional, options)
|
|
}
|
|
|
|
private func resolveTabForReport(_ args: String) -> Tab? {
|
|
guard let tabManager else { return nil }
|
|
let parsed = parseOptions(args)
|
|
if let tabArg = parsed.options["tab"], !tabArg.isEmpty {
|
|
if let tab = resolveTab(from: tabArg, tabManager: tabManager) {
|
|
return tab
|
|
}
|
|
// The tab may belong to a different window — search all contexts.
|
|
if let uuid = UUID(uuidString: tabArg.trimmingCharacters(in: .whitespacesAndNewlines)),
|
|
let otherManager = AppDelegate.shared?.tabManagerFor(tabId: uuid) {
|
|
return otherManager.tabs.first(where: { $0.id == uuid })
|
|
}
|
|
return nil
|
|
}
|
|
guard let selectedId = tabManager.selectedTabId else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == selectedId })
|
|
}
|
|
|
|
private func resolveTabIdForSidebarMutation(
|
|
reportArgs: String,
|
|
options: [String: String]
|
|
) -> (tabId: UUID?, error: String?) {
|
|
var tabId: UUID?
|
|
DispatchQueue.main.sync {
|
|
if let tab = resolveTabForReport(reportArgs) {
|
|
tabId = tab.id
|
|
}
|
|
}
|
|
if let tabId {
|
|
return (tabId, nil)
|
|
}
|
|
let error = options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
|
return (nil, error)
|
|
}
|
|
|
|
private func tabForSidebarMutation(id: UUID) -> Tab? {
|
|
if let tab = tabManager?.tabs.first(where: { $0.id == id }) {
|
|
return tab
|
|
}
|
|
if let otherManager = AppDelegate.shared?.tabManagerFor(tabId: id) {
|
|
return otherManager.tabs.first(where: { $0.id == id })
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? {
|
|
switch raw.lowercased() {
|
|
case "plain":
|
|
return .plain
|
|
case "markdown", "md":
|
|
return .markdown
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func normalizedOptionValue(_ value: String?) -> String? {
|
|
guard let value else { return nil }
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private func upsertSidebarMetadata(_ args: String, missingError: String) -> String {
|
|
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
|
let parsed = parseOptionsNoStop(args)
|
|
guard parsed.positional.count >= 2 else { return missingError }
|
|
|
|
let key = parsed.positional[0]
|
|
let value = parsed.positional[1...].joined(separator: " ")
|
|
let icon = normalizedOptionValue(parsed.options["icon"])
|
|
let color = normalizedOptionValue(parsed.options["color"])
|
|
|
|
let formatRaw = normalizedOptionValue(parsed.options["format"]) ?? SidebarMetadataFormat.plain.rawValue
|
|
guard let format = parseSidebarMetadataFormat(formatRaw) else {
|
|
return "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown"
|
|
}
|
|
|
|
let priority: Int
|
|
if let rawPriority = normalizedOptionValue(parsed.options["priority"]) {
|
|
guard let parsedPriority = Int(rawPriority) else {
|
|
return "ERROR: Invalid metadata priority '\(rawPriority)' — must be an integer"
|
|
}
|
|
priority = max(-9999, min(9999, parsedPriority))
|
|
} else {
|
|
priority = 0
|
|
}
|
|
|
|
let parsedURL: URL?
|
|
if let rawURL = normalizedOptionValue(parsed.options["url"] ?? parsed.options["link"]) {
|
|
guard let candidate = URL(string: rawURL),
|
|
let scheme = candidate.scheme?.lowercased(),
|
|
scheme == "http" || scheme == "https" else {
|
|
return "ERROR: Invalid metadata URL '\(rawURL)' — expected http(s) URL"
|
|
}
|
|
parsedURL = candidate
|
|
} else {
|
|
parsedURL = nil
|
|
}
|
|
|
|
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options)
|
|
guard let targetTabId = tabResolution.tabId else {
|
|
return tabResolution.error ?? "ERROR: No tab selected"
|
|
}
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
|
|
guard Self.shouldReplaceStatusEntry(
|
|
current: tab.statusEntries[key],
|
|
key: key,
|
|
value: value,
|
|
icon: icon,
|
|
color: color,
|
|
url: parsedURL,
|
|
priority: priority,
|
|
format: format
|
|
) else {
|
|
return
|
|
}
|
|
tab.statusEntries[key] = SidebarStatusEntry(
|
|
key: key,
|
|
value: value,
|
|
icon: icon,
|
|
color: color,
|
|
url: parsedURL,
|
|
priority: priority,
|
|
format: format,
|
|
timestamp: Date()
|
|
)
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
private func clearSidebarMetadata(_ args: String, usage: String) -> String {
|
|
let parsed = parseOptions(args)
|
|
guard let key = parsed.positional.first, parsed.positional.count == 1 else {
|
|
return "ERROR: Missing metadata key — usage: \(usage)"
|
|
}
|
|
|
|
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"
|
|
return
|
|
}
|
|
if tab.statusEntries.removeValue(forKey: key) == nil {
|
|
result = "OK (key not found)"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String {
|
|
var line = "\(entry.key)=\(entry.value)"
|
|
if let icon = entry.icon { line += " icon=\(icon)" }
|
|
if let color = entry.color { line += " color=\(color)" }
|
|
if let url = entry.url { line += " url=\(url.absoluteString)" }
|
|
if entry.priority != 0 { line += " priority=\(entry.priority)" }
|
|
if entry.format != .plain { line += " format=\(entry.format.rawValue)" }
|
|
return line
|
|
}
|
|
|
|
private func listSidebarMetadata(_ args: String, emptyMessage: String) -> String {
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
let entries = tab.sidebarStatusEntriesInDisplayOrder()
|
|
if entries.isEmpty {
|
|
result = emptyMessage
|
|
return
|
|
}
|
|
result = entries.map(sidebarMetadataLine).joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func setStatus(_ args: String) -> String {
|
|
upsertSidebarMetadata(
|
|
args,
|
|
missingError: "ERROR: Missing status key or value — usage: set_status <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]"
|
|
)
|
|
}
|
|
|
|
private func reportMeta(_ args: String) -> String {
|
|
upsertSidebarMetadata(
|
|
args,
|
|
missingError: "ERROR: Missing metadata key or value — usage: report_meta <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]"
|
|
)
|
|
}
|
|
|
|
private func clearStatus(_ args: String) -> String {
|
|
clearSidebarMetadata(args, usage: "clear_status <key> [--tab=X]")
|
|
}
|
|
|
|
private func clearMeta(_ args: String) -> String {
|
|
clearSidebarMetadata(args, usage: "clear_meta <key> [--tab=X]")
|
|
}
|
|
|
|
private func listStatus(_ args: String) -> String {
|
|
listSidebarMetadata(args, emptyMessage: "No status entries")
|
|
}
|
|
|
|
private func listMeta(_ args: String) -> String {
|
|
listSidebarMetadata(args, emptyMessage: "No metadata entries")
|
|
}
|
|
|
|
private func splitMetadataBlockArgs(_ args: String) -> (optionsPart: String, markdownPart: String?) {
|
|
guard let separatorRange = args.range(of: " -- ") else {
|
|
return (args, nil)
|
|
}
|
|
let optionsPart = String(args[..<separatorRange.lowerBound])
|
|
let markdownPart = String(args[separatorRange.upperBound...])
|
|
return (optionsPart, markdownPart)
|
|
}
|
|
|
|
private func sidebarMetadataBlockLine(_ block: SidebarMetadataBlock) -> String {
|
|
var line = "\(block.key)=\(block.markdown.replacingOccurrences(of: "\n", with: "\\n"))"
|
|
if block.priority != 0 { line += " priority=\(block.priority)" }
|
|
return line
|
|
}
|
|
|
|
private func reportMetaBlock(_ args: String) -> String {
|
|
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
|
|
|
let parts = splitMetadataBlockArgs(args)
|
|
let parsed = parseOptionsNoStop(parts.optionsPart)
|
|
guard let key = parsed.positional.first, !key.isEmpty else {
|
|
return "ERROR: Missing metadata block key — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
|
|
}
|
|
|
|
let markdown: String
|
|
if let raw = parts.markdownPart {
|
|
markdown = raw
|
|
} else if parsed.positional.count >= 2 {
|
|
markdown = parsed.positional.dropFirst().joined(separator: " ")
|
|
} else {
|
|
return "ERROR: Missing metadata markdown — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
|
|
}
|
|
|
|
let normalizedMarkdown = markdown
|
|
.replacingOccurrences(of: "\\r\\n", with: "\n")
|
|
.replacingOccurrences(of: "\\n", with: "\n")
|
|
.replacingOccurrences(of: "\\t", with: "\t")
|
|
|
|
let trimmedMarkdown = normalizedMarkdown.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedMarkdown.isEmpty else {
|
|
return "ERROR: Missing metadata markdown — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
|
|
}
|
|
|
|
let priority: Int
|
|
if let rawPriority = normalizedOptionValue(parsed.options["priority"]) {
|
|
guard let parsedPriority = Int(rawPriority) else {
|
|
return "ERROR: Invalid metadata block priority '\(rawPriority)' — must be an integer"
|
|
}
|
|
priority = max(-9999, min(9999, parsedPriority))
|
|
} else {
|
|
priority = 0
|
|
}
|
|
|
|
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: parts.optionsPart, options: parsed.options)
|
|
guard let targetTabId = tabResolution.tabId else {
|
|
return tabResolution.error ?? "ERROR: No tab selected"
|
|
}
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
|
|
guard Self.shouldReplaceMetadataBlock(
|
|
current: tab.metadataBlocks[key],
|
|
key: key,
|
|
markdown: normalizedMarkdown,
|
|
priority: priority
|
|
) else {
|
|
return
|
|
}
|
|
tab.metadataBlocks[key] = SidebarMetadataBlock(
|
|
key: key,
|
|
markdown: normalizedMarkdown,
|
|
priority: priority,
|
|
timestamp: Date()
|
|
)
|
|
}
|
|
return "OK"
|
|
}
|
|
|
|
private func clearMetaBlock(_ args: String) -> String {
|
|
let parsed = parseOptions(args)
|
|
guard let key = parsed.positional.first, parsed.positional.count == 1 else {
|
|
return "ERROR: Missing metadata block key — usage: clear_meta_block <key> [--tab=X]"
|
|
}
|
|
|
|
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"
|
|
return
|
|
}
|
|
if tab.metadataBlocks.removeValue(forKey: key) == nil {
|
|
result = "OK (key not found)"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func listMetaBlocks(_ args: String) -> String {
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
let blocks = tab.sidebarMetadataBlocksInDisplayOrder()
|
|
if blocks.isEmpty {
|
|
result = "No metadata blocks"
|
|
return
|
|
}
|
|
result = blocks.map(sidebarMetadataBlockLine).joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func appendLog(_ args: String) -> String {
|
|
let parsed = parseOptions(args)
|
|
guard !parsed.positional.isEmpty else {
|
|
return "ERROR: Missing message — usage: log [--level=X] [--source=X] [--tab=X] -- <message>"
|
|
}
|
|
let message = parsed.positional.joined(separator: " ")
|
|
let levelStr = parsed.options["level"] ?? "info"
|
|
guard let level = SidebarLogLevel(rawValue: levelStr) else {
|
|
return "ERROR: Unknown log level '\(levelStr)' — use: info, progress, success, warning, error"
|
|
}
|
|
let source = parsed.options["source"]
|
|
|
|
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"
|
|
return
|
|
}
|
|
tab.logEntries.append(SidebarLogEntry(message: message, level: level, source: source, timestamp: Date()))
|
|
let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50
|
|
let limit = max(1, min(500, configuredLimit))
|
|
if tab.logEntries.count > limit {
|
|
tab.logEntries.removeFirst(tab.logEntries.count - limit)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func clearLog(_ args: String) -> String {
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
tab.logEntries.removeAll()
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func listLog(_ args: String) -> String {
|
|
let parsed = parseOptions(args)
|
|
var limit: Int?
|
|
if let limitStr = parsed.options["limit"] {
|
|
if limitStr.isEmpty {
|
|
return "ERROR: Missing limit value — usage: list_log [--limit=N] [--tab=X]"
|
|
}
|
|
guard let parsedLimit = Int(limitStr), parsedLimit >= 0 else {
|
|
return "ERROR: Invalid limit '\(limitStr)' — must be >= 0"
|
|
}
|
|
limit = parsedLimit
|
|
}
|
|
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
|
return
|
|
}
|
|
if tab.logEntries.isEmpty {
|
|
result = "No log entries"
|
|
return
|
|
}
|
|
let entries: [SidebarLogEntry]
|
|
if let limit {
|
|
entries = Array(tab.logEntries.suffix(limit))
|
|
} else {
|
|
entries = tab.logEntries
|
|
}
|
|
result = entries.map { entry in
|
|
var line = "[\(entry.level.rawValue)] \(entry.message)"
|
|
if let source = entry.source, !source.isEmpty {
|
|
line = "[\(source)] \(line)"
|
|
}
|
|
return line
|
|
}.joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func setProgress(_ args: String) -> String {
|
|
let parsed = parseOptions(args)
|
|
guard let first = parsed.positional.first else {
|
|
return "ERROR: Missing progress value — usage: set_progress <0.0-1.0> [--label=X] [--tab=X]"
|
|
}
|
|
guard let value = Double(first), value.isFinite else {
|
|
return "ERROR: Invalid progress value '\(first)' — must be 0.0 to 1.0"
|
|
}
|
|
let clamped = min(1.0, max(0.0, value))
|
|
let label = parsed.options["label"]
|
|
|
|
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"
|
|
return
|
|
}
|
|
guard Self.shouldReplaceProgress(current: tab.progress, value: clamped, label: label) else {
|
|
return
|
|
}
|
|
tab.progress = SidebarProgressState(value: clamped, label: label)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func clearProgress(_ args: String) -> String {
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
if tab.progress != nil {
|
|
tab.progress = nil
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
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]"
|
|
}
|
|
let isDirty = parsed.options["status"]?.lowercased() == "dirty"
|
|
|
|
// Shell integration always includes explicit workspace/panel IDs.
|
|
// Keep this telemetry path off-main so wake/main-thread stalls don't
|
|
// block socket handlers and starve subsequent branch updates.
|
|
if let scope = Self.explicitSocketScope(options: parsed.options) {
|
|
DispatchQueue.main.async {
|
|
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId),
|
|
let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else {
|
|
return
|
|
}
|
|
let validSurfaceIds = Set(tab.panels.keys)
|
|
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
|
guard validSurfaceIds.contains(scope.panelId) else { return }
|
|
tab.updatePanelGitBranch(panelId: scope.panelId, branch: branch, isDirty: isDirty)
|
|
}
|
|
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"
|
|
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)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func clearGitBranch(_ args: String) -> String {
|
|
let parsed = parseOptions(args)
|
|
|
|
// Shell integration always includes explicit workspace/panel IDs.
|
|
// Keep this telemetry path off-main so wake/main-thread stalls don't
|
|
// block socket handlers and starve subsequent branch updates.
|
|
if let scope = Self.explicitSocketScope(options: parsed.options) {
|
|
DispatchQueue.main.async {
|
|
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId),
|
|
let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else {
|
|
return
|
|
}
|
|
let validSurfaceIds = Set(tab.panels.keys)
|
|
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
|
guard validSurfaceIds.contains(scope.panelId) else { return }
|
|
tab.clearPanelGitBranch(panelId: scope.panelId)
|
|
}
|
|
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"
|
|
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)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func reportPullRequest(_ args: String) -> String {
|
|
let parsed = parseOptions(args)
|
|
guard parsed.positional.count >= 2 else {
|
|
return "ERROR: Missing pull request number or URL — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
|
|
}
|
|
|
|
let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let numberToken = rawNumber.hasPrefix("#") ? String(rawNumber.dropFirst()) : rawNumber
|
|
guard let number = Int(numberToken), number > 0 else {
|
|
return "ERROR: Invalid pull request number '\(rawNumber)'"
|
|
}
|
|
|
|
let rawURL = parsed.positional[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let url = URL(string: rawURL),
|
|
let scheme = url.scheme?.lowercased(),
|
|
scheme == "http" || scheme == "https" else {
|
|
return "ERROR: Invalid pull request URL '\(rawURL)'"
|
|
}
|
|
|
|
let statusRaw = (parsed.options["state"] ?? "open").lowercased()
|
|
guard let status = SidebarPullRequestStatus(rawValue: statusRaw) else {
|
|
return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed"
|
|
}
|
|
|
|
let labelRaw = normalizedOptionValue(parsed.options["label"]) ?? "PR"
|
|
guard !labelRaw.isEmpty else {
|
|
return "ERROR: Invalid review label — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
|
|
}
|
|
let label = String(labelRaw.prefix(16))
|
|
|
|
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"
|
|
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_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--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
|
|
}
|
|
|
|
guard Self.shouldReplacePullRequest(
|
|
current: tab.panelPullRequests[surfaceId],
|
|
number: number,
|
|
label: label,
|
|
url: url,
|
|
status: status
|
|
) else {
|
|
return
|
|
}
|
|
|
|
tab.updatePanelPullRequest(
|
|
panelId: surfaceId,
|
|
number: number,
|
|
label: label,
|
|
url: url,
|
|
status: status
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func clearPullRequest(_ args: String) -> String {
|
|
let parsed = parseOptions(args)
|
|
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"
|
|
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_pr [--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.clearPanelPullRequest(panelId: surfaceId)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func reportPorts(_ args: String) -> String {
|
|
let parsed = parseOptions(args)
|
|
guard !parsed.positional.isEmpty else {
|
|
return "ERROR: Missing ports — usage: report_ports <port1> [port2...] [--tab=X] [--panel=Y]"
|
|
}
|
|
var ports: [Int] = []
|
|
for portStr in parsed.positional {
|
|
guard let port = Int(portStr), port > 0, port <= 65535 else {
|
|
return "ERROR: Invalid port '\(portStr)' — must be 1-65535"
|
|
}
|
|
ports.append(port)
|
|
}
|
|
let normalizedPorts = Array(Set(ports)).sorted()
|
|
|
|
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"
|
|
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_ports <port1> [port2...] [--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
|
|
}
|
|
|
|
guard Self.shouldReplacePorts(current: tab.surfaceListeningPorts[surfaceId], next: normalizedPorts) else {
|
|
return
|
|
}
|
|
|
|
tab.surfaceListeningPorts[surfaceId] = normalizedPorts
|
|
tab.recomputeListeningPorts()
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func reportPwd(_ args: String) -> String {
|
|
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" }
|
|
|
|
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"
|
|
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_pwd <path> [--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
|
|
}
|
|
|
|
tabManager.updateSurfaceDirectory(tabId: tab.id, surfaceId: surfaceId, directory: directory)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func clearPorts(_ args: String) -> String {
|
|
let parsed = parseOptions(args)
|
|
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"
|
|
return
|
|
}
|
|
|
|
let validSurfaceIds = Set(tab.panels.keys)
|
|
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
|
|
|
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
|
if let panelArg {
|
|
if panelArg.isEmpty {
|
|
result = "ERROR: Missing panel id — usage: clear_ports [--tab=X] [--panel=Y]"
|
|
return
|
|
}
|
|
guard let surfaceId = UUID(uuidString: panelArg) else {
|
|
result = "ERROR: Invalid panel id '\(panelArg)'"
|
|
return
|
|
}
|
|
guard validSurfaceIds.contains(surfaceId) else {
|
|
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
|
return
|
|
}
|
|
if tab.surfaceListeningPorts.removeValue(forKey: surfaceId) != nil {
|
|
tab.recomputeListeningPorts()
|
|
}
|
|
} else {
|
|
if !tab.surfaceListeningPorts.isEmpty {
|
|
tab.surfaceListeningPorts.removeAll()
|
|
tab.recomputeListeningPorts()
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func reportTTY(_ args: String) -> String {
|
|
let parsed = parseOptions(args)
|
|
guard let ttyName = parsed.positional.first, !ttyName.isEmpty else {
|
|
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 {
|
|
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
|
return
|
|
}
|
|
|
|
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
|
let surfaceId: UUID
|
|
if let panelArg {
|
|
if panelArg.isEmpty {
|
|
result = "ERROR: Missing panel id — usage: report_tty <tty_name> [--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
|
|
}
|
|
|
|
let validSurfaceIds = Set(tab.panels.keys)
|
|
guard validSurfaceIds.contains(surfaceId) else {
|
|
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
|
return
|
|
}
|
|
|
|
guard tab.surfaceTTYNames[surfaceId] != ttyName else { return }
|
|
tab.surfaceTTYNames[surfaceId] = ttyName
|
|
PortScanner.shared.registerTTY(workspaceId: tab.id, panelId: surfaceId, ttyName: ttyName)
|
|
}
|
|
return result
|
|
}
|
|
|
|
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 {
|
|
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
|
return
|
|
}
|
|
|
|
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
|
let surfaceId: UUID
|
|
if let panelArg {
|
|
if panelArg.isEmpty {
|
|
result = "ERROR: Missing panel id — usage: ports_kick [--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
|
|
}
|
|
|
|
PortScanner.shared.kick(workspaceId: tab.id, panelId: surfaceId)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func sidebarState(_ args: String) -> String {
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
|
|
var lines: [String] = []
|
|
lines.append("tab=\(tab.id.uuidString)")
|
|
lines.append("cwd=\(tab.currentDirectory)")
|
|
|
|
if let focused = tab.focusedPanelId,
|
|
let focusedDir = tab.panelDirectories[focused] {
|
|
lines.append("focused_cwd=\(focusedDir)")
|
|
lines.append("focused_panel=\(focused.uuidString)")
|
|
} else {
|
|
lines.append("focused_cwd=unknown")
|
|
lines.append("focused_panel=unknown")
|
|
}
|
|
|
|
if let git = tab.gitBranch {
|
|
lines.append("git_branch=\(git.branch)\(git.isDirty ? " dirty" : " clean")")
|
|
} else {
|
|
lines.append("git_branch=none")
|
|
}
|
|
|
|
if let pr = tab.pullRequest {
|
|
lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)")
|
|
lines.append("pr_label=\(pr.label)")
|
|
} else {
|
|
lines.append("pr=none")
|
|
lines.append("pr_label=none")
|
|
}
|
|
|
|
if tab.listeningPorts.isEmpty {
|
|
lines.append("ports=none")
|
|
} else {
|
|
lines.append("ports=\(tab.listeningPorts.map(String.init).joined(separator: ","))")
|
|
}
|
|
|
|
if let progress = tab.progress {
|
|
let label = progress.label ?? ""
|
|
lines.append("progress=\(String(format: "%.2f", progress.value)) \(label)".trimmingCharacters(in: .whitespaces))
|
|
} else {
|
|
lines.append("progress=none")
|
|
}
|
|
|
|
let statusEntries = tab.sidebarStatusEntriesInDisplayOrder()
|
|
lines.append("status_count=\(statusEntries.count)")
|
|
for entry in statusEntries {
|
|
lines.append(" \(sidebarMetadataLine(entry))")
|
|
}
|
|
|
|
let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder()
|
|
lines.append("meta_block_count=\(metadataBlocks.count)")
|
|
for block in metadataBlocks {
|
|
lines.append(" \(sidebarMetadataBlockLine(block))")
|
|
}
|
|
|
|
lines.append("log_count=\(tab.logEntries.count)")
|
|
for entry in tab.logEntries.suffix(5) {
|
|
lines.append(" [\(entry.level.rawValue)] \(entry.message)")
|
|
}
|
|
|
|
result = lines.joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func resetSidebar(_ args: String) -> String {
|
|
var result = "OK"
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTabForReport(args) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
tab.statusEntries.removeAll()
|
|
tab.logEntries.removeAll()
|
|
tab.progress = nil
|
|
tab.gitBranch = nil
|
|
tab.panelGitBranches.removeAll()
|
|
tab.pullRequest = nil
|
|
tab.panelPullRequests.removeAll()
|
|
tab.surfaceListeningPorts.removeAll()
|
|
tab.listeningPorts.removeAll()
|
|
tab.metadataBlocks.removeAll()
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func refreshSurfaces() -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
var refreshedCount = 0
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
// Force-refresh all terminal panels in current tab
|
|
// (resets cached metrics so the Metal layer drawable resizes correctly)
|
|
for panel in tab.panels.values {
|
|
if let terminalPanel = panel as? TerminalPanel {
|
|
terminalPanel.surface.forceRefresh()
|
|
refreshedCount += 1
|
|
}
|
|
}
|
|
}
|
|
return "OK Refreshed \(refreshedCount) surfaces"
|
|
}
|
|
|
|
private func viewDepth(of view: NSView, maxDepth: Int = 128) -> Int {
|
|
var depth = 0
|
|
var current: NSView? = view
|
|
while let v = current, depth < maxDepth {
|
|
current = v.superview
|
|
depth += 1
|
|
}
|
|
return depth
|
|
}
|
|
|
|
private func isPortalHosted(_ view: NSView) -> Bool {
|
|
var current: NSView? = view
|
|
while let v = current {
|
|
if v is WindowTerminalHostView { return true }
|
|
current = v.superview
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func surfaceHealth(_ tabArg: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
var result = ""
|
|
DispatchQueue.main.sync {
|
|
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
|
|
result = "ERROR: Tab not found"
|
|
return
|
|
}
|
|
let panels = orderedPanels(in: tab)
|
|
let lines = panels.enumerated().map { index, panel -> String in
|
|
let panelId = panel.id.uuidString
|
|
let type = panel.panelType.rawValue
|
|
if let tp = panel as? TerminalPanel {
|
|
let inWindow = tp.surface.isViewInWindow
|
|
let portalHosted = isPortalHosted(tp.hostedView)
|
|
let depth = viewDepth(of: tp.hostedView)
|
|
return "\(index): \(panelId) type=\(type) in_window=\(inWindow) portal=\(portalHosted) view_depth=\(depth)"
|
|
} else if let bp = panel as? BrowserPanel {
|
|
let inWindow = bp.webView.window != nil
|
|
return "\(index): \(panelId) type=\(type) in_window=\(inWindow)"
|
|
} else {
|
|
return "\(index): \(panelId) type=\(type) in_window=unknown"
|
|
}
|
|
}
|
|
result = lines.isEmpty ? "No surfaces" : lines.joined(separator: "\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func closeSurface(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
var result = "ERROR: Failed to close surface"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
// Resolve surface ID from argument or use focused
|
|
let surfaceId: UUID?
|
|
if trimmed.isEmpty {
|
|
surfaceId = tab.focusedPanelId
|
|
} else {
|
|
surfaceId = resolveSurfaceId(from: trimmed, tab: tab)
|
|
}
|
|
|
|
guard let targetSurfaceId = surfaceId else {
|
|
result = "ERROR: Surface not found"
|
|
return
|
|
}
|
|
|
|
// Don't close if it's the only surface
|
|
if tab.panels.count <= 1 {
|
|
result = "ERROR: Cannot close the last surface"
|
|
return
|
|
}
|
|
|
|
// Socket commands must be non-interactive: bypass close-confirmation gating.
|
|
tab.closePanel(targetSurfaceId, force: true)
|
|
result = "OK"
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func newSurface(_ args: String) -> String {
|
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
|
|
|
// Parse arguments: --type=terminal|browser --pane=<pane_id> --url=...
|
|
var panelType: PanelType = .terminal
|
|
var paneArg: String? = nil
|
|
var url: URL? = nil
|
|
let shouldFocus = socketCommandAllowsInAppFocusMutations()
|
|
|
|
let parts = args.split(separator: " ")
|
|
for part in parts {
|
|
let partStr = String(part)
|
|
if partStr.hasPrefix("--type=") {
|
|
let typeStr = String(partStr.dropFirst(7))
|
|
panelType = typeStr == "browser" ? .browser : .terminal
|
|
} else if partStr.hasPrefix("--pane=") {
|
|
paneArg = String(partStr.dropFirst(7))
|
|
} else if partStr.hasPrefix("--url=") {
|
|
let urlStr = String(partStr.dropFirst(6))
|
|
url = URL(string: urlStr)
|
|
}
|
|
}
|
|
|
|
var result = "ERROR: Failed to create tab"
|
|
DispatchQueue.main.sync {
|
|
guard let tabId = tabManager.selectedTabId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
|
return
|
|
}
|
|
|
|
// Get target pane
|
|
let paneId: PaneID?
|
|
let paneIds = tab.bonsplitController.allPaneIds
|
|
if let paneArg {
|
|
if let uuid = UUID(uuidString: paneArg) {
|
|
paneId = paneIds.first(where: { $0.id == uuid })
|
|
} else if let idx = Int(paneArg), idx >= 0, idx < paneIds.count {
|
|
paneId = paneIds[idx]
|
|
} else {
|
|
paneId = nil
|
|
}
|
|
} else {
|
|
paneId = tab.bonsplitController.focusedPaneId
|
|
}
|
|
|
|
guard let targetPaneId = paneId else {
|
|
result = "ERROR: Pane not found"
|
|
return
|
|
}
|
|
|
|
let newPanelId: UUID?
|
|
if panelType == .browser {
|
|
newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: shouldFocus)?.id
|
|
} else {
|
|
newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: shouldFocus)?.id
|
|
}
|
|
|
|
if let id = newPanelId {
|
|
result = "OK \(id.uuidString)"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
deinit {
|
|
stop()
|
|
}
|
|
}
|