Add cmuxterm CLI and socket control modes
This commit is contained in:
parent
c5d6065664
commit
a0bf5dfc84
22 changed files with 1446 additions and 92 deletions
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -2,12 +2,24 @@
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
|
After making code changes, always run the reload script to launch the Debug app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/reload.sh
|
||||||
|
```
|
||||||
|
|
||||||
After making code changes, always run the build:
|
After making code changes, always run the build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
|
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When rebuilding GhosttyKit.xcframework, always use Release optimizations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ghostty && zig build -Demit-xcframework=true -Doptimize=ReleaseFast
|
||||||
|
```
|
||||||
|
|
||||||
`reload` = kill and launch the Debug app only:
|
`reload` = kill and launch the Debug app only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -28,12 +40,20 @@ xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -des
|
||||||
|
|
||||||
## E2E mac UI tests
|
## E2E mac UI tests
|
||||||
|
|
||||||
Run UI tests on the UTM macOS VM (never on the host machine):
|
Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:GhosttyTabsUITests/UpdatePillUITests test'
|
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:GhosttyTabsUITests/UpdatePillUITests test'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Basic tests
|
||||||
|
|
||||||
|
Run basic automated tests on the UTM macOS VM (never on the host machine):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build && pkill -x "cmuxterm DEV" || true && APP=$(find /Users/cmux/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmuxterm DEV.app" -print -quit) && open "$APP" && for i in {1..20}; do [ -S /tmp/cmuxterm.sock ] && break; sleep 0.5; done && python3 tests/test_update_timing.py && python3 tests/test_signals_auto.py && python3 tests/test_ctrl_socket.py && python3 tests/test_notifications.py'
|
||||||
|
```
|
||||||
|
|
||||||
## Release
|
## Release
|
||||||
|
|
||||||
Tagging a version triggers the GitHub Actions release workflow and uploads the notarized zip.
|
Tagging a version triggers the GitHub Actions release workflow and uploads the notarized zip.
|
||||||
|
|
|
||||||
546
CLI/cmuxterm.swift
Normal file
546
CLI/cmuxterm.swift
Normal file
|
|
@ -0,0 +1,546 @@
|
||||||
|
import Foundation
|
||||||
|
import Darwin
|
||||||
|
|
||||||
|
struct CLIError: Error, CustomStringConvertible {
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
var description: String { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TabInfo {
|
||||||
|
let index: Int
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let selected: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PanelInfo {
|
||||||
|
let index: Int
|
||||||
|
let id: String
|
||||||
|
let focused: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NotificationInfo {
|
||||||
|
let id: String
|
||||||
|
let tabId: String
|
||||||
|
let panelId: String?
|
||||||
|
let isRead: Bool
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let body: String
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SocketClient {
|
||||||
|
private let path: String
|
||||||
|
private var socketFD: Int32 = -1
|
||||||
|
|
||||||
|
init(path: String) {
|
||||||
|
self.path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect() throws {
|
||||||
|
if socketFD >= 0 { return }
|
||||||
|
socketFD = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||||
|
if socketFD < 0 {
|
||||||
|
throw CLIError(message: "Failed to create socket")
|
||||||
|
}
|
||||||
|
|
||||||
|
var addr = sockaddr_un()
|
||||||
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
|
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
|
||||||
|
path.withCString { ptr in
|
||||||
|
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
|
||||||
|
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
|
||||||
|
strncpy(buf, ptr, maxLength - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = withUnsafePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
||||||
|
Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result != 0 {
|
||||||
|
Darwin.close(socketFD)
|
||||||
|
socketFD = -1
|
||||||
|
throw CLIError(message: "Failed to connect to socket at \(path)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
if socketFD >= 0 {
|
||||||
|
Darwin.close(socketFD)
|
||||||
|
socketFD = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(command: String) throws -> String {
|
||||||
|
guard socketFD >= 0 else { throw CLIError(message: "Not connected") }
|
||||||
|
let payload = command + "\n"
|
||||||
|
try payload.withCString { ptr in
|
||||||
|
let sent = Darwin.write(socketFD, ptr, strlen(ptr))
|
||||||
|
if sent < 0 {
|
||||||
|
throw CLIError(message: "Failed to write to socket")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = Data()
|
||||||
|
var sawNewline = false
|
||||||
|
let start = Date()
|
||||||
|
|
||||||
|
while true {
|
||||||
|
var pollFD = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0)
|
||||||
|
let ready = poll(&pollFD, 1, 100)
|
||||||
|
if ready < 0 {
|
||||||
|
throw CLIError(message: "Socket read error")
|
||||||
|
}
|
||||||
|
if ready == 0 {
|
||||||
|
if sawNewline {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if Date().timeIntervalSince(start) > 5.0 {
|
||||||
|
throw CLIError(message: "Command timed out")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = [UInt8](repeating: 0, count: 8192)
|
||||||
|
let count = Darwin.read(socketFD, &buffer, buffer.count)
|
||||||
|
if count <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
data.append(buffer, count: count)
|
||||||
|
if data.contains(UInt8(0x0A)) {
|
||||||
|
sawNewline = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard var response = String(data: data, encoding: .utf8) else {
|
||||||
|
throw CLIError(message: "Invalid UTF-8 response")
|
||||||
|
}
|
||||||
|
if response.hasSuffix("\n") {
|
||||||
|
response.removeLast()
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CMUXCLI {
|
||||||
|
let args: [String]
|
||||||
|
|
||||||
|
func run() throws {
|
||||||
|
var socketPath = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"] ?? "/tmp/cmuxterm.sock"
|
||||||
|
var jsonOutput = false
|
||||||
|
|
||||||
|
var index = 1
|
||||||
|
while index < args.count {
|
||||||
|
let arg = args[index]
|
||||||
|
if arg == "--socket" {
|
||||||
|
guard index + 1 < args.count else {
|
||||||
|
throw CLIError(message: "--socket requires a path")
|
||||||
|
}
|
||||||
|
socketPath = args[index + 1]
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if arg == "--json" {
|
||||||
|
jsonOutput = true
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if arg == "-h" || arg == "--help" {
|
||||||
|
print(usage())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
guard index < args.count else {
|
||||||
|
print(usage())
|
||||||
|
throw CLIError(message: "Missing command")
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = args[index]
|
||||||
|
let commandArgs = Array(args[(index + 1)...])
|
||||||
|
|
||||||
|
let client = SocketClient(path: socketPath)
|
||||||
|
try client.connect()
|
||||||
|
defer { client.close() }
|
||||||
|
|
||||||
|
switch command {
|
||||||
|
case "ping":
|
||||||
|
let response = try client.send(command: "ping")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "list-tabs":
|
||||||
|
let response = try client.send(command: "list_tabs")
|
||||||
|
if jsonOutput {
|
||||||
|
let tabs = parseTabs(response)
|
||||||
|
let payload = tabs.map { [
|
||||||
|
"index": $0.index,
|
||||||
|
"id": $0.id,
|
||||||
|
"title": $0.title,
|
||||||
|
"selected": $0.selected
|
||||||
|
] }
|
||||||
|
print(jsonString(payload))
|
||||||
|
} else {
|
||||||
|
print(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "new-tab":
|
||||||
|
let response = try client.send(command: "new_tab")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "new-split":
|
||||||
|
guard let direction = commandArgs.first else {
|
||||||
|
throw CLIError(message: "new-split requires a direction")
|
||||||
|
}
|
||||||
|
let response = try client.send(command: "new_split \(direction)")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "list-panels":
|
||||||
|
let (tabArg, _) = parseOption(commandArgs, name: "--tab")
|
||||||
|
let response = try client.send(command: "list_surfaces \(tabArg ?? "")".trimmingCharacters(in: .whitespaces))
|
||||||
|
if jsonOutput {
|
||||||
|
let panels = parsePanels(response)
|
||||||
|
let payload = panels.map { [
|
||||||
|
"index": $0.index,
|
||||||
|
"id": $0.id,
|
||||||
|
"focused": $0.focused
|
||||||
|
] }
|
||||||
|
print(jsonString(payload))
|
||||||
|
} else {
|
||||||
|
print(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "focus-panel":
|
||||||
|
guard let panel = optionValue(commandArgs, name: "--panel") else {
|
||||||
|
throw CLIError(message: "focus-panel requires --panel")
|
||||||
|
}
|
||||||
|
let response = try client.send(command: "focus_surface \(panel)")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "close-tab":
|
||||||
|
guard let tab = optionValue(commandArgs, name: "--tab") else {
|
||||||
|
throw CLIError(message: "close-tab requires --tab")
|
||||||
|
}
|
||||||
|
let tabId = try resolveTabId(tab, client: client)
|
||||||
|
let response = try client.send(command: "close_tab \(tabId)")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "select-tab":
|
||||||
|
guard let tab = optionValue(commandArgs, name: "--tab") else {
|
||||||
|
throw CLIError(message: "select-tab requires --tab")
|
||||||
|
}
|
||||||
|
let response = try client.send(command: "select_tab \(tab)")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "current-tab":
|
||||||
|
let response = try client.send(command: "current_tab")
|
||||||
|
if jsonOutput {
|
||||||
|
print(jsonString(["tab_id": response]))
|
||||||
|
} else {
|
||||||
|
print(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "send":
|
||||||
|
let text = commandArgs.joined(separator: " ")
|
||||||
|
guard !text.isEmpty else { throw CLIError(message: "send requires text") }
|
||||||
|
let escaped = escapeText(text)
|
||||||
|
let response = try client.send(command: "send \(escaped)")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "send-key":
|
||||||
|
guard let key = commandArgs.first else { throw CLIError(message: "send-key requires a key") }
|
||||||
|
let response = try client.send(command: "send_key \(key)")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "send-panel":
|
||||||
|
guard let panel = optionValue(commandArgs, name: "--panel") else {
|
||||||
|
throw CLIError(message: "send-panel requires --panel")
|
||||||
|
}
|
||||||
|
let text = remainingArgs(commandArgs, removing: ["--panel", panel]).joined(separator: " ")
|
||||||
|
guard !text.isEmpty else { throw CLIError(message: "send-panel requires text") }
|
||||||
|
let escaped = escapeText(text)
|
||||||
|
let response = try client.send(command: "send_surface \(panel) \(escaped)")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "send-key-panel":
|
||||||
|
guard let panel = optionValue(commandArgs, name: "--panel") else {
|
||||||
|
throw CLIError(message: "send-key-panel requires --panel")
|
||||||
|
}
|
||||||
|
let key = remainingArgs(commandArgs, removing: ["--panel", panel]).first ?? ""
|
||||||
|
guard !key.isEmpty else { throw CLIError(message: "send-key-panel requires a key") }
|
||||||
|
let response = try client.send(command: "send_key_surface \(panel) \(key)")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "notify":
|
||||||
|
let title = optionValue(commandArgs, name: "--title") ?? "Notification"
|
||||||
|
let subtitle = optionValue(commandArgs, name: "--subtitle") ?? ""
|
||||||
|
let body = optionValue(commandArgs, name: "--body") ?? ""
|
||||||
|
|
||||||
|
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||||
|
let panelArg = optionValue(commandArgs, name: "--panel") ?? ProcessInfo.processInfo.environment["CMUX_PANEL_ID"]
|
||||||
|
|
||||||
|
let targetTab = try resolveTabId(tabArg, client: client)
|
||||||
|
let targetPanel = try resolvePanelId(panelArg, tabId: targetTab, client: client)
|
||||||
|
|
||||||
|
let payload = "\(title)|\(subtitle)|\(body)"
|
||||||
|
let response = try client.send(command: "notify_target \(targetTab) \(targetPanel) \(payload)")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "list-notifications":
|
||||||
|
let response = try client.send(command: "list_notifications")
|
||||||
|
if jsonOutput {
|
||||||
|
let notifications = parseNotifications(response)
|
||||||
|
let payload = notifications.map { item in
|
||||||
|
var dict: [String: Any] = [
|
||||||
|
"id": item.id,
|
||||||
|
"tab_id": item.tabId,
|
||||||
|
"is_read": item.isRead,
|
||||||
|
"title": item.title,
|
||||||
|
"subtitle": item.subtitle,
|
||||||
|
"body": item.body
|
||||||
|
]
|
||||||
|
dict["panel_id"] = item.panelId ?? NSNull()
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
print(jsonString(payload))
|
||||||
|
} else {
|
||||||
|
print(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "clear-notifications":
|
||||||
|
let response = try client.send(command: "clear_notifications")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "set-app-focus":
|
||||||
|
guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") }
|
||||||
|
let response = try client.send(command: "set_app_focus \(value)")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "simulate-app-active":
|
||||||
|
let response = try client.send(command: "simulate_app_active")
|
||||||
|
print(response)
|
||||||
|
|
||||||
|
case "help":
|
||||||
|
print(usage())
|
||||||
|
|
||||||
|
default:
|
||||||
|
print(usage())
|
||||||
|
throw CLIError(message: "Unknown command: \(command)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseTabs(_ response: String) -> [TabInfo] {
|
||||||
|
guard response != "No tabs" else { return [] }
|
||||||
|
return response
|
||||||
|
.split(separator: "\n")
|
||||||
|
.compactMap { line in
|
||||||
|
let raw = String(line)
|
||||||
|
let selected = raw.hasPrefix("*")
|
||||||
|
let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "* "))
|
||||||
|
let parts = cleaned.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true)
|
||||||
|
guard parts.count >= 2 else { return nil }
|
||||||
|
let indexText = parts[0].replacingOccurrences(of: ":", with: "")
|
||||||
|
guard let index = Int(indexText) else { return nil }
|
||||||
|
let id = String(parts[1])
|
||||||
|
let title = parts.count > 2 ? String(parts[2]) : ""
|
||||||
|
return TabInfo(index: index, id: id, title: title, selected: selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parsePanels(_ response: String) -> [PanelInfo] {
|
||||||
|
guard response != "No surfaces" else { return [] }
|
||||||
|
return response
|
||||||
|
.split(separator: "\n")
|
||||||
|
.compactMap { line in
|
||||||
|
let raw = String(line)
|
||||||
|
let focused = raw.hasPrefix("*")
|
||||||
|
let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "* "))
|
||||||
|
let parts = cleaned.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
|
||||||
|
guard parts.count >= 2 else { return nil }
|
||||||
|
let indexText = parts[0].replacingOccurrences(of: ":", with: "")
|
||||||
|
guard let index = Int(indexText) else { return nil }
|
||||||
|
let id = String(parts[1])
|
||||||
|
return PanelInfo(index: index, id: id, focused: focused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseNotifications(_ response: String) -> [NotificationInfo] {
|
||||||
|
guard response != "No notifications" else { return [] }
|
||||||
|
return response
|
||||||
|
.split(separator: "\n")
|
||||||
|
.compactMap { line in
|
||||||
|
let raw = String(line)
|
||||||
|
let parts = raw.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||||
|
guard parts.count == 2 else { return nil }
|
||||||
|
let payload = parts[1].split(separator: "|", maxSplits: 6, omittingEmptySubsequences: false)
|
||||||
|
guard payload.count >= 7 else { return nil }
|
||||||
|
let notifId = String(payload[0])
|
||||||
|
let tabId = String(payload[1])
|
||||||
|
let panelRaw = String(payload[2])
|
||||||
|
let panelId = panelRaw == "none" ? nil : panelRaw
|
||||||
|
let readText = String(payload[3])
|
||||||
|
let title = String(payload[4])
|
||||||
|
let subtitle = String(payload[5])
|
||||||
|
let body = String(payload[6])
|
||||||
|
return NotificationInfo(
|
||||||
|
id: notifId,
|
||||||
|
tabId: tabId,
|
||||||
|
panelId: panelId,
|
||||||
|
isRead: readText == "read",
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
body: body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveTabId(_ raw: String?, client: SocketClient) throws -> String {
|
||||||
|
if let raw, isUUID(raw) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
if let raw, let index = Int(raw) {
|
||||||
|
let response = try client.send(command: "list_tabs")
|
||||||
|
let tabs = parseTabs(response)
|
||||||
|
if let match = tabs.first(where: { $0.index == index }) {
|
||||||
|
return match.id
|
||||||
|
}
|
||||||
|
throw CLIError(message: "Tab index not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = try client.send(command: "current_tab")
|
||||||
|
if response.hasPrefix("ERROR") {
|
||||||
|
throw CLIError(message: response)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvePanelId(_ raw: String?, tabId: String, client: SocketClient) throws -> String {
|
||||||
|
if let raw, isUUID(raw) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = try client.send(command: "list_surfaces \(tabId)")
|
||||||
|
if response.hasPrefix("ERROR") {
|
||||||
|
throw CLIError(message: response)
|
||||||
|
}
|
||||||
|
let panels = parsePanels(response)
|
||||||
|
|
||||||
|
if let raw, let index = Int(raw) {
|
||||||
|
if let match = panels.first(where: { $0.index == index }) {
|
||||||
|
return match.id
|
||||||
|
}
|
||||||
|
throw CLIError(message: "Panel index not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let focused = panels.first(where: { $0.focused }) {
|
||||||
|
return focused.id
|
||||||
|
}
|
||||||
|
|
||||||
|
throw CLIError(message: "Unable to resolve panel ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseOption(_ args: [String], name: String) -> (String?, [String]) {
|
||||||
|
var remaining: [String] = []
|
||||||
|
var value: String?
|
||||||
|
var skipNext = false
|
||||||
|
for (idx, arg) in args.enumerated() {
|
||||||
|
if skipNext {
|
||||||
|
skipNext = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if arg == name, idx + 1 < args.count {
|
||||||
|
value = args[idx + 1]
|
||||||
|
skipNext = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remaining.append(arg)
|
||||||
|
}
|
||||||
|
return (value, remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func optionValue(_ args: [String], name: String) -> String? {
|
||||||
|
guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil }
|
||||||
|
return args[index + 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func remainingArgs(_ args: [String], removing tokens: [String]) -> [String] {
|
||||||
|
var remaining = args
|
||||||
|
for token in tokens {
|
||||||
|
if let index = remaining.firstIndex(of: token) {
|
||||||
|
remaining.remove(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
private func escapeText(_ text: String) -> String {
|
||||||
|
return text
|
||||||
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||||
|
.replacingOccurrences(of: "\n", with: "\\n")
|
||||||
|
.replacingOccurrences(of: "\r", with: "\\r")
|
||||||
|
.replacingOccurrences(of: "\t", with: "\\t")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isUUID(_ value: String) -> Bool {
|
||||||
|
return UUID(uuidString: value) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func jsonString(_ object: Any) -> String {
|
||||||
|
guard JSONSerialization.isValidJSONObject(object),
|
||||||
|
let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
|
||||||
|
let output = String(data: data, encoding: .utf8) else {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private func usage() -> String {
|
||||||
|
return """
|
||||||
|
cmuxterm - control cmuxterm via Unix socket
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cmuxterm [--socket PATH] [--json] <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
ping
|
||||||
|
list-tabs
|
||||||
|
new-tab
|
||||||
|
new-split <left|right|up|down>
|
||||||
|
list-panels [--tab <id|index>]
|
||||||
|
focus-panel --panel <id|index>
|
||||||
|
close-tab --tab <id|index>
|
||||||
|
select-tab --tab <id|index>
|
||||||
|
current-tab
|
||||||
|
send <text>
|
||||||
|
send-key <key>
|
||||||
|
send-panel --panel <id|index> <text>
|
||||||
|
send-key-panel --panel <id|index> <key>
|
||||||
|
notify --title <text> [--subtitle <text>] [--body <text>] [--tab <id|index>] [--panel <id|index>]
|
||||||
|
list-notifications
|
||||||
|
clear-notifications
|
||||||
|
set-app-focus <active|inactive|clear>
|
||||||
|
simulate-app-active
|
||||||
|
help
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
CMUX_TAB_ID, CMUX_PANEL_ID, CMUX_SOCKET_PATH
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct CMUXTermMain {
|
||||||
|
static func main() {
|
||||||
|
let cli = CMUXCLI(args: CommandLine.arguments)
|
||||||
|
do {
|
||||||
|
try cli.run()
|
||||||
|
} catch {
|
||||||
|
FileHandle.standardError.write(Data("Error: \(error)\n".utf8))
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
|
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
|
||||||
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
|
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
|
||||||
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
|
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
|
||||||
|
A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; };
|
||||||
A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; };
|
A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; };
|
||||||
A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; };
|
A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; };
|
||||||
A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; };
|
A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; };
|
||||||
|
|
@ -36,7 +37,10 @@
|
||||||
A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; };
|
A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; };
|
||||||
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
|
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
|
||||||
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
|
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
|
||||||
|
B9000002A1B2C3D4E5F60719 /* cmuxterm.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */; };
|
||||||
|
B900000BA1B2C3D4E5F60719 /* cmuxterm in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmuxterm */; };
|
||||||
84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; };
|
84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; };
|
||||||
|
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
|
||||||
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
|
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
|
||||||
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
|
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
@ -52,6 +56,17 @@
|
||||||
name = "Embed Frameworks";
|
name = "Embed Frameworks";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
B900000AA1B2C3D4E5F60719 /* Copy CLI */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "bin";
|
||||||
|
dstSubfolderSpec = 7;
|
||||||
|
files = (
|
||||||
|
B900000BA1B2C3D4E5F60719 /* cmuxterm in Copy CLI */,
|
||||||
|
);
|
||||||
|
name = "Copy CLI";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
|
@ -62,6 +77,13 @@
|
||||||
remoteGlobalIDString = A5001050 /* GhosttyTabs */;
|
remoteGlobalIDString = A5001050 /* GhosttyTabs */;
|
||||||
remoteInfo = GhosttyTabs;
|
remoteInfo = GhosttyTabs;
|
||||||
};
|
};
|
||||||
|
B900000DA1B2C3D4E5F60719 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = A5001070 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */;
|
||||||
|
remoteInfo = "cmuxterm-cli";
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
|
@ -76,6 +98,7 @@
|
||||||
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
|
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
|
||||||
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
|
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
|
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
|
||||||
|
A5001225 /* SocketControlSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlSettings.swift; sourceTree = "<group>"; };
|
||||||
A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = "<group>"; };
|
A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = "<group>"; };
|
||||||
A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; };
|
A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -99,7 +122,10 @@
|
||||||
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
|
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
|
||||||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
|
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
|
||||||
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
|
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
|
||||||
|
B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxterm.swift; sourceTree = "<group>"; };
|
||||||
|
B9000004A1B2C3D4E5F60719 /* cmuxterm */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmuxterm; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSocketUITests.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
|
@ -119,6 +145,13 @@
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
B900000CA1B2C3D4E5F60719 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
|
@ -140,11 +173,32 @@
|
||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
A5001300A1B2C3D4E5F60719 /* Copy Ghostty Resources */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ghostty\"\nSRC=\"${SRCROOT}/ghostty/zig-out/share/ghostty\"\nFALLBACK=\"${SRCROOT}/Resources/ghostty\"\nif [ -d \"$SRC\" ]; then\n mkdir -p \"$DEST\"\n rsync -a --delete \"$SRC/\" \"$DEST/\"\nelif [ -d \"$FALLBACK\" ]; then\n mkdir -p \"$DEST\"\n rsync -a \"$FALLBACK/\" \"$DEST/\"\nfi\n";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
A5001040 = {
|
A5001040 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A5001041 /* Sources */,
|
A5001041 /* Sources */,
|
||||||
|
B9000003A1B2C3D4E5F60719 /* CLI */,
|
||||||
087C454FFF74443AB06942C3 /* Resources */,
|
087C454FFF74443AB06942C3 /* Resources */,
|
||||||
A5001101 /* Assets.xcassets */,
|
A5001101 /* Assets.xcassets */,
|
||||||
A5001016 /* GhosttyKit.xcframework */,
|
A5001016 /* GhosttyKit.xcframework */,
|
||||||
|
|
@ -164,6 +218,7 @@
|
||||||
A5001014 /* GhosttyConfig.swift */,
|
A5001014 /* GhosttyConfig.swift */,
|
||||||
A5001015 /* GhosttyTerminalView.swift */,
|
A5001015 /* GhosttyTerminalView.swift */,
|
||||||
A5001019 /* TerminalController.swift */,
|
A5001019 /* TerminalController.swift */,
|
||||||
|
A5001225 /* SocketControlSettings.swift */,
|
||||||
A5001090 /* AppDelegate.swift */,
|
A5001090 /* AppDelegate.swift */,
|
||||||
A5001091 /* NotificationsPage.swift */,
|
A5001091 /* NotificationsPage.swift */,
|
||||||
A5001092 /* TerminalNotificationStore.swift */,
|
A5001092 /* TerminalNotificationStore.swift */,
|
||||||
|
|
@ -188,6 +243,14 @@
|
||||||
path = Sources;
|
path = Sources;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
B9000003A1B2C3D4E5F60719 /* CLI */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */,
|
||||||
|
);
|
||||||
|
path = CLI;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
087C454FFF74443AB06942C3 /* Resources */ = {
|
087C454FFF74443AB06942C3 /* Resources */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -200,6 +263,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A5001000 /* cmuxterm.app */,
|
A5001000 /* cmuxterm.app */,
|
||||||
|
B9000004A1B2C3D4E5F60719 /* cmuxterm */,
|
||||||
7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */,
|
7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
|
|
@ -208,6 +272,7 @@
|
||||||
3196C9C2D01F054C1D3385DD /* GhosttyTabsUITests */ = {
|
3196C9C2D01F054C1D3385DD /* GhosttyTabsUITests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */,
|
||||||
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */,
|
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */,
|
||||||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
|
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
|
||||||
);
|
);
|
||||||
|
|
@ -223,12 +288,15 @@
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
A5001051 /* Sources */,
|
A5001051 /* Sources */,
|
||||||
A5001030 /* Frameworks */,
|
A5001030 /* Frameworks */,
|
||||||
|
A5001300A1B2C3D4E5F60719 /* Copy Ghostty Resources */,
|
||||||
A5001102 /* Resources */,
|
A5001102 /* Resources */,
|
||||||
A5001020 /* Embed Frameworks */,
|
A5001020 /* Embed Frameworks */,
|
||||||
|
B900000AA1B2C3D4E5F60719 /* Copy CLI */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
B900000EA1B2C3D4E5F60719 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
A5001231 /* Sparkle */,
|
A5001231 /* Sparkle */,
|
||||||
|
|
@ -238,6 +306,22 @@
|
||||||
productReference = A5001000 /* cmuxterm.app */;
|
productReference = A5001000 /* cmuxterm.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = B9000007A1B2C3D4E5F60719 /* Build configuration list for PBXNativeTarget "cmuxterm-cli" */;
|
||||||
|
buildPhases = (
|
||||||
|
B9000006A1B2C3D4E5F60719 /* Sources */,
|
||||||
|
B900000CA1B2C3D4E5F60719 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = "cmuxterm-cli";
|
||||||
|
productName = cmuxterm;
|
||||||
|
productReference = B9000004A1B2C3D4E5F60719 /* cmuxterm */;
|
||||||
|
productType = "com.apple.product-type.tool";
|
||||||
|
};
|
||||||
CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */ = {
|
CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "GhosttyTabsUITests" */;
|
buildConfigurationList = AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "GhosttyTabsUITests" */;
|
||||||
|
|
@ -283,6 +367,7 @@
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
A5001050 /* GhosttyTabs */,
|
A5001050 /* GhosttyTabs */,
|
||||||
|
B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */,
|
||||||
CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */,
|
CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -299,6 +384,7 @@
|
||||||
A5001004 /* GhosttyConfig.swift in Sources */,
|
A5001004 /* GhosttyConfig.swift in Sources */,
|
||||||
A5001005 /* GhosttyTerminalView.swift in Sources */,
|
A5001005 /* GhosttyTerminalView.swift in Sources */,
|
||||||
A5001007 /* TerminalController.swift in Sources */,
|
A5001007 /* TerminalController.swift in Sources */,
|
||||||
|
A5001226 /* SocketControlSettings.swift in Sources */,
|
||||||
A5001093 /* AppDelegate.swift in Sources */,
|
A5001093 /* AppDelegate.swift in Sources */,
|
||||||
A5001094 /* NotificationsPage.swift in Sources */,
|
A5001094 /* NotificationsPage.swift in Sources */,
|
||||||
A5001095 /* TerminalNotificationStore.swift in Sources */,
|
A5001095 /* TerminalNotificationStore.swift in Sources */,
|
||||||
|
|
@ -326,11 +412,20 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */,
|
||||||
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */,
|
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */,
|
||||||
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
|
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
B9000006A1B2C3D4E5F60719 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
B9000002A1B2C3D4E5F60719 /* cmuxterm.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
|
|
@ -339,6 +434,11 @@
|
||||||
target = A5001050 /* GhosttyTabs */;
|
target = A5001050 /* GhosttyTabs */;
|
||||||
targetProxy = 738BF3D3196765B250928A93 /* PBXContainerItemProxy */;
|
targetProxy = 738BF3D3196765B250928A93 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
B900000EA1B2C3D4E5F60719 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */;
|
||||||
|
targetProxy = B900000DA1B2C3D4E5F60719 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
|
|
@ -494,6 +594,30 @@
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
B9000008A1B2C3D4E5F60719 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
PRODUCT_NAME = cmuxterm;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
B9000009A1B2C3D4E5F60719 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
PRODUCT_NAME = cmuxterm;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
C117776A77E71D1432F570D7 /* Debug */ = {
|
C117776A77E71D1432F570D7 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
|
@ -567,6 +691,15 @@
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
B9000007A1B2C3D4E5F60719 /* Build configuration list for PBXNativeTarget "cmuxterm-cli" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
B9000008A1B2C3D4E5F60719 /* Debug */,
|
||||||
|
B9000009A1B2C3D4E5F60719 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
A5001071 /* Build configuration list for PBXProject "GhosttyTabs" */ = {
|
A5001071 /* Build configuration list for PBXProject "GhosttyTabs" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|
|
||||||
112
GhosttyTabsUITests/AutomationSocketUITests.swift
Normal file
112
GhosttyTabsUITests/AutomationSocketUITests.swift
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class AutomationSocketUITests: XCTestCase {
|
||||||
|
private var socketPath = ""
|
||||||
|
private let defaultsDomain = "com.cmuxterm.app.debug"
|
||||||
|
private let modeKey = "socketControlMode"
|
||||||
|
private let legacyKey = "socketControlEnabled"
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
continueAfterFailure = false
|
||||||
|
socketPath = "/tmp/cmuxterm-debug-\(UUID().uuidString).sock"
|
||||||
|
resetSocketDefaults()
|
||||||
|
removeSocketFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSocketToggleDisablesAndEnables() {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments += ["-\(modeKey)", "notifications"]
|
||||||
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
|
app.launch()
|
||||||
|
app.activate()
|
||||||
|
|
||||||
|
guard let resolvedPath = resolveSocketPath(timeout: 5.0) else {
|
||||||
|
XCTFail("Expected control socket to exist")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socketPath = resolvedPath
|
||||||
|
XCTAssertTrue(waitForSocket(exists: true, timeout: 2.0))
|
||||||
|
app.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSocketDisabledWhenSettingOff() {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments += ["-\(modeKey)", "off"]
|
||||||
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
|
app.launch()
|
||||||
|
app.activate()
|
||||||
|
|
||||||
|
XCTAssertTrue(waitForSocket(exists: false, timeout: 3.0))
|
||||||
|
app.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if FileManager.default.fileExists(atPath: socketPath) == exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||||
|
}
|
||||||
|
return FileManager.default.fileExists(atPath: socketPath) == exists
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSocketPath(timeout: TimeInterval) -> String? {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if FileManager.default.fileExists(atPath: socketPath) {
|
||||||
|
return socketPath
|
||||||
|
}
|
||||||
|
if let found = findSocketInTmp() {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||||
|
}
|
||||||
|
if FileManager.default.fileExists(atPath: socketPath) {
|
||||||
|
return socketPath
|
||||||
|
}
|
||||||
|
return findSocketInTmp()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findSocketInTmp() -> String? {
|
||||||
|
let tmpPath = "/tmp"
|
||||||
|
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: tmpPath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let matches = entries.filter { $0.hasPrefix("cmuxterm") && $0.hasSuffix(".sock") }
|
||||||
|
if let debug = matches.first(where: { $0.contains("debug") }) {
|
||||||
|
return (tmpPath as NSString).appendingPathComponent(debug)
|
||||||
|
}
|
||||||
|
if let first = matches.first {
|
||||||
|
return (tmpPath as NSString).appendingPathComponent(first)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetSocketDefaults() {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults")
|
||||||
|
process.arguments = ["delete", defaultsDomain, modeKey]
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let legacy = Process()
|
||||||
|
legacy.executableURL = URL(fileURLWithPath: "/usr/bin/defaults")
|
||||||
|
legacy.arguments = ["delete", defaultsDomain, legacyKey]
|
||||||
|
do {
|
||||||
|
try legacy.run()
|
||||||
|
legacy.waitUntilExit()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeSocketFile() {
|
||||||
|
try? FileManager.default.removeItem(atPath: socketPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,6 @@
|
||||||
- **Vertical tabs** — See all your terminals at a glance in a sidebar
|
- **Vertical tabs** — See all your terminals at a glance in a sidebar
|
||||||
- **Notification panel** — Tabs flash when AI agents (Claude Code, Codex) need your attention
|
- **Notification panel** — Tabs flash when AI agents (Claude Code, Codex) need your attention
|
||||||
- **Built on libghostty** — Native macOS performance with Ghostty's GPU-accelerated rendering
|
- **Built on libghostty** — Native macOS performance with Ghostty's GPU-accelerated rendering
|
||||||
- **Auto-updates** — Stay current with Sparkle-powered updates
|
|
||||||
|
|
||||||
## Why cmuxterm?
|
## Why cmuxterm?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,18 @@ struct GhosttyConfig {
|
||||||
static func load() -> GhosttyConfig {
|
static func load() -> GhosttyConfig {
|
||||||
var config = GhosttyConfig()
|
var config = GhosttyConfig()
|
||||||
|
|
||||||
// Load user config
|
// Match Ghostty's default load order on macOS.
|
||||||
let configPath = NSString(string: "~/Library/Application Support/com.mitchellh.ghostty/config").expandingTildeInPath
|
let configPaths = [
|
||||||
if let contents = try? String(contentsOfFile: configPath, encoding: .utf8) {
|
"~/.config/ghostty/config",
|
||||||
config.parse(contents)
|
"~/.config/ghostty/config.ghostty",
|
||||||
|
"~/Library/Application Support/com.mitchellh.ghostty/config",
|
||||||
|
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
|
||||||
|
].map { NSString(string: $0).expandingTildeInPath }
|
||||||
|
|
||||||
|
for path in configPaths {
|
||||||
|
if let contents = readConfigFile(at: path) {
|
||||||
|
config.parse(contents)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load theme if specified
|
// Load theme if specified
|
||||||
|
|
@ -137,11 +145,15 @@ struct GhosttyConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func loadTheme(_ name: String) {
|
mutating func loadTheme(_ name: String) {
|
||||||
// Try to load from Ghostty app resources
|
let bundleThemePath = Bundle.main.resourceURL?
|
||||||
|
.appendingPathComponent("ghostty/themes/\(name)")
|
||||||
|
.path
|
||||||
|
|
||||||
let themePaths = [
|
let themePaths = [
|
||||||
|
bundleThemePath,
|
||||||
"/Applications/Ghostty.app/Contents/Resources/ghostty/themes/\(name)",
|
"/Applications/Ghostty.app/Contents/Resources/ghostty/themes/\(name)",
|
||||||
NSString(string: "~/.config/ghostty/themes/\(name)").expandingTildeInPath
|
NSString(string: "~/.config/ghostty/themes/\(name)").expandingTildeInPath,
|
||||||
]
|
].compactMap { $0 }
|
||||||
|
|
||||||
for path in themePaths {
|
for path in themePaths {
|
||||||
if let contents = try? String(contentsOfFile: path, encoding: .utf8) {
|
if let contents = try? String(contentsOfFile: path, encoding: .utf8) {
|
||||||
|
|
@ -150,6 +162,22 @@ struct GhosttyConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func readConfigFile(at path: String) -> String? {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
guard fileManager.fileExists(atPath: path) else { return nil }
|
||||||
|
|
||||||
|
if let attributes = try? fileManager.attributesOfItem(atPath: path) {
|
||||||
|
if let type = attributes[.type] as? FileAttributeType, type != .typeRegular {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let size = attributes[.size] as? NSNumber, size.intValue == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try? String(contentsOfFile: path, encoding: .utf8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NSColor {
|
extension NSColor {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import SwiftUI
|
||||||
import AppKit
|
import AppKit
|
||||||
import Metal
|
import Metal
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
import Darwin
|
||||||
|
|
||||||
private enum GhosttyPasteboardHelper {
|
private enum GhosttyPasteboardHelper {
|
||||||
private static let selectionPasteboard = NSPasteboard(
|
private static let selectionPasteboard = NSPasteboard(
|
||||||
|
|
@ -353,6 +354,7 @@ class GhosttyApp {
|
||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
surfaceId: surfaceId,
|
surfaceId: surfaceId,
|
||||||
title: command,
|
title: command,
|
||||||
|
subtitle: "",
|
||||||
body: body
|
body: body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -509,6 +511,7 @@ class GhosttyApp {
|
||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
surfaceId: surfaceId,
|
surfaceId: surfaceId,
|
||||||
title: command,
|
title: command,
|
||||||
|
subtitle: "",
|
||||||
body: body
|
body: body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -637,8 +640,63 @@ class TerminalSurface: Identifiable {
|
||||||
surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque()
|
surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque()
|
||||||
surfaceConfig.scale_factor = scale
|
surfaceConfig.scale_factor = scale
|
||||||
surfaceConfig.context = surfaceContext
|
surfaceConfig.context = surfaceContext
|
||||||
|
var envVars: [ghostty_env_var_s] = []
|
||||||
|
var envStorage: [(UnsafeMutablePointer<CChar>, UnsafeMutablePointer<CChar>)] = []
|
||||||
|
defer {
|
||||||
|
for (key, value) in envStorage {
|
||||||
|
free(key)
|
||||||
|
free(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
surface = ghostty_surface_new(app, &surfaceConfig)
|
var env: [String: String] = [:]
|
||||||
|
if surfaceConfig.env_var_count > 0, let existingEnv = surfaceConfig.env_vars {
|
||||||
|
let count = Int(surfaceConfig.env_var_count)
|
||||||
|
if count > 0 {
|
||||||
|
for i in 0..<count {
|
||||||
|
let item = existingEnv[i]
|
||||||
|
if let key = String(cString: item.key, encoding: .utf8),
|
||||||
|
let value = String(cString: item.value, encoding: .utf8) {
|
||||||
|
env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
env["CMUX_PANEL_ID"] = id.uuidString
|
||||||
|
env["CMUX_TAB_ID"] = tabId.uuidString
|
||||||
|
env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath()
|
||||||
|
|
||||||
|
if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path {
|
||||||
|
let currentPath = env["PATH"]
|
||||||
|
?? ProcessInfo.processInfo.environment["PATH"]
|
||||||
|
?? ""
|
||||||
|
if !currentPath.split(separator: ":").contains(Substring(cliBinPath)) {
|
||||||
|
let separator = currentPath.isEmpty ? "" : ":"
|
||||||
|
env["PATH"] = "\(cliBinPath)\(separator)\(currentPath)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !env.isEmpty {
|
||||||
|
envVars.reserveCapacity(env.count)
|
||||||
|
envStorage.reserveCapacity(env.count)
|
||||||
|
for (key, value) in env {
|
||||||
|
guard let keyPtr = strdup(key), let valuePtr = strdup(value) else { continue }
|
||||||
|
envStorage.append((keyPtr, valuePtr))
|
||||||
|
envVars.append(ghostty_env_var_s(key: keyPtr, value: valuePtr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !envVars.isEmpty {
|
||||||
|
let envVarsCount = envVars.count
|
||||||
|
envVars.withUnsafeMutableBufferPointer { buffer in
|
||||||
|
surfaceConfig.env_vars = buffer.baseAddress
|
||||||
|
surfaceConfig.env_var_count = envVarsCount
|
||||||
|
surface = ghostty_surface_new(app, &surfaceConfig)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
surface = ghostty_surface_new(app, &surfaceConfig)
|
||||||
|
}
|
||||||
|
|
||||||
if surface == nil {
|
if surface == nil {
|
||||||
print("Failed to create ghostty surface")
|
print("Failed to create ghostty surface")
|
||||||
|
|
|
||||||
95
Sources/SocketControlSettings.swift
Normal file
95
Sources/SocketControlSettings.swift
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SocketControlMode: String, CaseIterable, Identifiable {
|
||||||
|
case off
|
||||||
|
case notifications
|
||||||
|
case full
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .off:
|
||||||
|
return "Off"
|
||||||
|
case .notifications:
|
||||||
|
return "Notifications only"
|
||||||
|
case .full:
|
||||||
|
return "Full control"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .off:
|
||||||
|
return "Disable the local control socket."
|
||||||
|
case .notifications:
|
||||||
|
return "Allow only notification commands over the local socket."
|
||||||
|
case .full:
|
||||||
|
return "Allow all socket commands, including tab and input control."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SocketControlSettings {
|
||||||
|
static let appStorageKey = "socketControlMode"
|
||||||
|
static let legacyEnabledKey = "socketControlEnabled"
|
||||||
|
|
||||||
|
static var defaultMode: SocketControlMode {
|
||||||
|
#if DEBUG
|
||||||
|
return .full
|
||||||
|
#else
|
||||||
|
return .notifications
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static func socketPath() -> String {
|
||||||
|
if let override = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"], !override.isEmpty {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
return "/tmp/cmuxterm-debug.sock"
|
||||||
|
#else
|
||||||
|
return "/tmp/cmuxterm.sock"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static func envOverrideEnabled() -> Bool? {
|
||||||
|
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func envOverrideMode() -> SocketControlMode? {
|
||||||
|
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_MODE"], !raw.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return SocketControlMode(rawValue: raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func effectiveMode(userMode: SocketControlMode) -> SocketControlMode {
|
||||||
|
if let overrideEnabled = envOverrideEnabled() {
|
||||||
|
if !overrideEnabled {
|
||||||
|
return .off
|
||||||
|
}
|
||||||
|
if let overrideMode = envOverrideMode() {
|
||||||
|
return overrideMode
|
||||||
|
}
|
||||||
|
return userMode == .off ? .notifications : userMode
|
||||||
|
}
|
||||||
|
|
||||||
|
if let overrideMode = envOverrideMode() {
|
||||||
|
return overrideMode
|
||||||
|
}
|
||||||
|
|
||||||
|
return userMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,8 +45,8 @@ class Tab: Identifiable, ObservableObject {
|
||||||
if isSelectedTab {
|
if isSelectedTab {
|
||||||
focusedSurface?.applyWindowBackgroundIfActive()
|
focusedSurface?.applyWindowBackgroundIfActive()
|
||||||
}
|
}
|
||||||
let isAppFocused = AppFocusState.isAppFocused()
|
let isAppActive = AppFocusState.isAppActive()
|
||||||
guard isSelectedTab && isAppFocused else { return }
|
guard isSelectedTab && isAppActive else { return }
|
||||||
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
|
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
|
||||||
if notificationStore.hasUnreadNotification(forTabId: self.id, surfaceId: id) {
|
if notificationStore.hasUnreadNotification(forTabId: self.id, surfaceId: id) {
|
||||||
triggerNotificationFocusFlash(surfaceId: id, requiresSplit: false, shouldFocus: false)
|
triggerNotificationFocusFlash(surfaceId: id, requiresSplit: false, shouldFocus: false)
|
||||||
|
|
@ -465,7 +465,7 @@ class TabManager: ObservableObject {
|
||||||
let shouldSuppressFlash = suppressFocusFlash
|
let shouldSuppressFlash = suppressFocusFlash
|
||||||
suppressFocusFlash = false
|
suppressFocusFlash = false
|
||||||
guard !shouldSuppressFlash else { return }
|
guard !shouldSuppressFlash else { return }
|
||||||
guard AppFocusState.isAppFocused() else { return }
|
guard AppFocusState.isAppActive() else { return }
|
||||||
guard let surfaceId = focusedSurfaceId(for: tabId) else { return }
|
guard let surfaceId = focusedSurfaceId(for: tabId) else { return }
|
||||||
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
|
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
|
||||||
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return }
|
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return }
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,28 @@ import Foundation
|
||||||
class TerminalController {
|
class TerminalController {
|
||||||
static let shared = TerminalController()
|
static let shared = TerminalController()
|
||||||
|
|
||||||
private let socketPath = "/tmp/cmux.sock"
|
private var socketPath = "/tmp/cmuxterm.sock"
|
||||||
private var serverSocket: Int32 = -1
|
private var serverSocket: Int32 = -1
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
private var clientHandlers: [Int32: Thread] = [:]
|
private var clientHandlers: [Int32: Thread] = [:]
|
||||||
private weak var tabManager: TabManager?
|
private weak var tabManager: TabManager?
|
||||||
|
private var accessMode: SocketControlMode = .full
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func start(tabManager: TabManager) {
|
func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) {
|
||||||
self.tabManager = tabManager
|
self.tabManager = tabManager
|
||||||
|
self.accessMode = accessMode
|
||||||
|
|
||||||
|
if isRunning {
|
||||||
|
if self.socketPath == socketPath {
|
||||||
|
self.accessMode = accessMode
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.socketPath = socketPath
|
||||||
|
|
||||||
// Remove existing socket file
|
// Remove existing socket file
|
||||||
unlink(socketPath)
|
unlink(socketPath)
|
||||||
|
|
@ -133,6 +145,9 @@ class TerminalController {
|
||||||
|
|
||||||
let cmd = parts[0].lowercased()
|
let cmd = parts[0].lowercased()
|
||||||
let args = parts.count > 1 ? parts[1] : ""
|
let args = parts.count > 1 ? parts[1] : ""
|
||||||
|
if !isCommandAllowed(cmd) {
|
||||||
|
return "ERROR: Command disabled by socket access mode"
|
||||||
|
}
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "ping":
|
case "ping":
|
||||||
|
|
@ -180,6 +195,9 @@ class TerminalController {
|
||||||
case "notify_surface":
|
case "notify_surface":
|
||||||
return notifySurface(args)
|
return notifySurface(args)
|
||||||
|
|
||||||
|
case "notify_target":
|
||||||
|
return notifyTarget(args)
|
||||||
|
|
||||||
case "list_notifications":
|
case "list_notifications":
|
||||||
return listNotifications()
|
return listNotifications()
|
||||||
|
|
||||||
|
|
@ -227,8 +245,9 @@ class TerminalController {
|
||||||
send_key <key> - Send special key (ctrl-c, ctrl-d, enter, tab, escape)
|
send_key <key> - Send special key (ctrl-c, ctrl-d, enter, tab, escape)
|
||||||
send_surface <id|idx> <text> - Send text to a surface in current tab
|
send_surface <id|idx> <text> - Send text to a surface in current tab
|
||||||
send_key_surface <id|idx> <key> - Send special key to a surface in current tab
|
send_key_surface <id|idx> <key> - Send special key to a surface in current tab
|
||||||
notify <title>|<body> - Create a notification for the focused surface
|
notify <title>|<subtitle>|<body> - Create a notification for the focused surface
|
||||||
notify_surface <id|idx> <title>|<body> - Create a notification for a surface
|
notify_surface <id|idx> <title>|<subtitle>|<body> - Create a notification for a surface
|
||||||
|
notify_target <tabId> <panelId> <title>|<subtitle>|<body> - Notify a specific panel
|
||||||
list_notifications - List all notifications
|
list_notifications - List all notifications
|
||||||
clear_notifications - Clear all notifications
|
clear_notifications - Clear all notifications
|
||||||
set_app_focus <active|inactive|clear> - Override app focus state
|
set_app_focus <active|inactive|clear> - Override app focus state
|
||||||
|
|
@ -246,6 +265,26 @@ class TerminalController {
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isCommandAllowed(_ command: String) -> Bool {
|
||||||
|
switch accessMode {
|
||||||
|
case .full:
|
||||||
|
return true
|
||||||
|
case .notifications:
|
||||||
|
let allowed: Set<String> = [
|
||||||
|
"ping",
|
||||||
|
"help",
|
||||||
|
"notify",
|
||||||
|
"notify_surface",
|
||||||
|
"notify_target",
|
||||||
|
"list_notifications",
|
||||||
|
"clear_notifications"
|
||||||
|
]
|
||||||
|
return allowed.contains(command)
|
||||||
|
case .off:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func listTabs() -> String {
|
private func listTabs() -> String {
|
||||||
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
||||||
|
|
||||||
|
|
@ -350,11 +389,12 @@ class TerminalController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
|
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
|
||||||
let (title, body) = parseNotificationPayload(args)
|
let (title, subtitle, body) = parseNotificationPayload(args)
|
||||||
TerminalNotificationStore.shared.addNotification(
|
TerminalNotificationStore.shared.addNotification(
|
||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
surfaceId: surfaceId,
|
surfaceId: surfaceId,
|
||||||
title: title,
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
body: body
|
body: body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -381,11 +421,47 @@ class TerminalController {
|
||||||
result = "ERROR: Surface not found"
|
result = "ERROR: Surface not found"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let (title, body) = parseNotificationPayload(payload)
|
let (title, subtitle, body) = parseNotificationPayload(payload)
|
||||||
TerminalNotificationStore.shared.addNotification(
|
TerminalNotificationStore.shared.addNotification(
|
||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
surfaceId: surfaceId,
|
surfaceId: surfaceId,
|
||||||
title: title,
|
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 <tabId> <panelId> <title>|<subtitle>|<body>" }
|
||||||
|
|
||||||
|
let parts = trimmed.split(separator: " ", maxSplits: 2).map(String.init)
|
||||||
|
guard parts.count >= 2 else { return "ERROR: Usage: notify_target <tabId> <panelId> <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.surface(for: 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
|
body: body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -398,7 +474,7 @@ class TerminalController {
|
||||||
let lines = TerminalNotificationStore.shared.notifications.enumerated().map { index, notification in
|
let lines = TerminalNotificationStore.shared.notifications.enumerated().map { index, notification in
|
||||||
let surfaceText = notification.surfaceId?.uuidString ?? "none"
|
let surfaceText = notification.surfaceId?.uuidString ?? "none"
|
||||||
let readText = notification.isRead ? "read" : "unread"
|
let readText = notification.isRead ? "read" : "unread"
|
||||||
return "\(index):\(notification.id.uuidString)|\(notification.tabId.uuidString)|\(surfaceText)|\(readText)|\(notification.title)|\(notification.body)"
|
return "\(index):\(notification.id.uuidString)|\(notification.tabId.uuidString)|\(surfaceText)|\(readText)|\(notification.title)|\(notification.subtitle)|\(notification.body)"
|
||||||
}
|
}
|
||||||
result = lines.joined(separator: "\n")
|
result = lines.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
@ -559,13 +635,16 @@ class TerminalController {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func parseNotificationPayload(_ args: String) -> (String, String) {
|
private func parseNotificationPayload(_ args: String) -> (String, String, String) {
|
||||||
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return ("Notification", "") }
|
guard !trimmed.isEmpty else { return ("Notification", "", "") }
|
||||||
let parts = trimmed.split(separator: "|", maxSplits: 1).map(String.init)
|
let parts = trimmed.split(separator: "|", maxSplits: 2).map(String.init)
|
||||||
let title = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let body = parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
let subtitle = parts.count > 2 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||||
return (title.isEmpty ? "Notification" : title, body)
|
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 closeTab(_ tabId: String) -> String {
|
private func closeTab(_ tabId: String) -> String {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ import UserNotifications
|
||||||
enum AppFocusState {
|
enum AppFocusState {
|
||||||
static var overrideIsFocused: Bool?
|
static var overrideIsFocused: Bool?
|
||||||
|
|
||||||
|
static func isAppActive() -> Bool {
|
||||||
|
if let overrideIsFocused {
|
||||||
|
return overrideIsFocused
|
||||||
|
}
|
||||||
|
return NSApp.isActive
|
||||||
|
}
|
||||||
|
|
||||||
static func isAppFocused() -> Bool {
|
static func isAppFocused() -> Bool {
|
||||||
if let overrideIsFocused {
|
if let overrideIsFocused {
|
||||||
return overrideIsFocused
|
return overrideIsFocused
|
||||||
|
|
@ -18,6 +25,7 @@ struct TerminalNotification: Identifiable, Hashable {
|
||||||
let tabId: UUID
|
let tabId: UUID
|
||||||
let surfaceId: UUID?
|
let surfaceId: UUID?
|
||||||
let title: String
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
let body: String
|
let body: String
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
var isRead: Bool
|
var isRead: Bool
|
||||||
|
|
@ -56,7 +64,7 @@ final class TerminalNotificationStore: ObservableObject {
|
||||||
return notifications.first(where: { $0.tabId == tabId })
|
return notifications.first(where: { $0.tabId == tabId })
|
||||||
}
|
}
|
||||||
|
|
||||||
func addNotification(tabId: UUID, surfaceId: UUID?, title: String, body: String) {
|
func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String) {
|
||||||
clearNotifications(forTabId: tabId, surfaceId: surfaceId)
|
clearNotifications(forTabId: tabId, surfaceId: surfaceId)
|
||||||
|
|
||||||
let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId
|
let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId
|
||||||
|
|
@ -73,6 +81,7 @@ final class TerminalNotificationStore: ObservableObject {
|
||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
surfaceId: surfaceId,
|
surfaceId: surfaceId,
|
||||||
title: title,
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
body: body,
|
body: body,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
isRead: false
|
isRead: false
|
||||||
|
|
@ -168,8 +177,8 @@ final class TerminalNotificationStore: ObservableObject {
|
||||||
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
||||||
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
||||||
?? "cmuxterm"
|
?? "cmuxterm"
|
||||||
content.title = appName
|
content.title = notification.title.isEmpty ? appName : notification.title
|
||||||
content.subtitle = notification.title
|
content.subtitle = notification.subtitle
|
||||||
content.body = notification.body
|
content.body = notification.body
|
||||||
content.sound = UNNotificationSound.default
|
content.sound = UNNotificationSound.default
|
||||||
content.categoryIdentifier = Self.categoryIdentifier
|
content.categoryIdentifier = Self.categoryIdentifier
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,25 @@ struct WindowAccessor: NSViewRepresentable {
|
||||||
Coordinator()
|
Coordinator()
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSView {
|
func makeNSView(context: Context) -> WindowObservingView {
|
||||||
NSView()
|
let view = WindowObservingView()
|
||||||
}
|
view.onWindow = { window in
|
||||||
|
|
||||||
func updateNSView(_ nsView: NSView, context: Context) {
|
|
||||||
DispatchQueue.main.async { [weak nsView] in
|
|
||||||
guard let window = nsView?.window else { return }
|
|
||||||
guard context.coordinator.lastWindow !== window else { return }
|
guard context.coordinator.lastWindow !== window else { return }
|
||||||
context.coordinator.lastWindow = window
|
context.coordinator.lastWindow = window
|
||||||
onWindow(window)
|
onWindow(window)
|
||||||
}
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: WindowObservingView, context: Context) {
|
||||||
|
nsView.onWindow = { window in
|
||||||
|
guard context.coordinator.lastWindow !== window else { return }
|
||||||
|
context.coordinator.lastWindow = window
|
||||||
|
onWindow(window)
|
||||||
|
}
|
||||||
|
if let window = nsView.window {
|
||||||
|
nsView.onWindow?(window)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,3 +35,21 @@ extension WindowAccessor {
|
||||||
weak var lastWindow: NSWindow?
|
weak var lastWindow: NSWindow?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class WindowObservingView: NSView {
|
||||||
|
var onWindow: ((NSWindow) -> Void)?
|
||||||
|
|
||||||
|
override func viewWillMove(toWindow newWindow: NSWindow?) {
|
||||||
|
super.viewWillMove(toWindow: newWindow)
|
||||||
|
if let newWindow {
|
||||||
|
onWindow?(newWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidMoveToWindow() {
|
||||||
|
super.viewDidMoveToWindow()
|
||||||
|
if let window {
|
||||||
|
onWindow?(window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Darwin
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct cmuxApp: App {
|
struct cmuxApp: App {
|
||||||
|
|
@ -12,6 +13,7 @@ struct cmuxApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
configureGhosttyEnvironment()
|
||||||
// Start the terminal controller for programmatic control
|
// Start the terminal controller for programmatic control
|
||||||
// This runs after TabManager is created via @StateObject
|
// This runs after TabManager is created via @StateObject
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
|
|
@ -22,6 +24,57 @@ struct cmuxApp: App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configureGhosttyEnvironment() {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty"
|
||||||
|
let bundledGhosttyURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty")
|
||||||
|
var resolvedResourcesDir: String?
|
||||||
|
|
||||||
|
if getenv("GHOSTTY_RESOURCES_DIR") == nil {
|
||||||
|
if let bundledGhosttyURL,
|
||||||
|
fileManager.fileExists(atPath: bundledGhosttyURL.path),
|
||||||
|
fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) {
|
||||||
|
resolvedResourcesDir = bundledGhosttyURL.path
|
||||||
|
} else if fileManager.fileExists(atPath: ghosttyAppResources) {
|
||||||
|
resolvedResourcesDir = ghosttyAppResources
|
||||||
|
} else if let bundledGhosttyURL, fileManager.fileExists(atPath: bundledGhosttyURL.path) {
|
||||||
|
resolvedResourcesDir = bundledGhosttyURL.path
|
||||||
|
}
|
||||||
|
|
||||||
|
if let resolvedResourcesDir {
|
||||||
|
setenv("GHOSTTY_RESOURCES_DIR", resolvedResourcesDir, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if getenv("TERM") == nil {
|
||||||
|
setenv("TERM", "xterm-ghostty", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if getenv("TERM_PROGRAM") == nil {
|
||||||
|
setenv("TERM_PROGRAM", "ghostty", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap({ String(cString: $0) }) {
|
||||||
|
let resourcesURL = URL(fileURLWithPath: resourcesDir)
|
||||||
|
let resourcesParent = resourcesURL.deletingLastPathComponent()
|
||||||
|
let dataDir = resourcesParent.path
|
||||||
|
let manDir = resourcesParent.appendingPathComponent("man").path
|
||||||
|
|
||||||
|
appendEnvPathIfMissing("XDG_DATA_DIRS", path: dataDir)
|
||||||
|
appendEnvPathIfMissing("MANPATH", path: manDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendEnvPathIfMissing(_ key: String, path: String) {
|
||||||
|
if path.isEmpty { return }
|
||||||
|
let current = getenv(key).flatMap { String(cString: $0) } ?? ""
|
||||||
|
if current.split(separator: ":").contains(Substring(path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let updated = current.isEmpty ? path : "\(current):\(path)"
|
||||||
|
setenv(key, updated, 1)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView(updateViewModel: appDelegate.updateViewModel)
|
ContentView(updateViewModel: appDelegate.updateViewModel)
|
||||||
|
|
@ -50,6 +103,7 @@ struct cmuxApp: App {
|
||||||
Settings {
|
Settings {
|
||||||
SettingsRootView()
|
SettingsRootView()
|
||||||
}
|
}
|
||||||
|
.defaultSize(width: 460, height: 280)
|
||||||
.windowResizability(.contentMinSize)
|
.windowResizability(.contentMinSize)
|
||||||
.commands {
|
.commands {
|
||||||
CommandGroup(replacing: .appInfo) {
|
CommandGroup(replacing: .appInfo) {
|
||||||
|
|
@ -327,6 +381,7 @@ struct SettingsView: View {
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
|
.padding(.top, 4)
|
||||||
.frame(minWidth: 360, minHeight: 280)
|
.frame(minWidth: 360, minHeight: 280)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -341,12 +396,21 @@ private struct SettingsRootView: View {
|
||||||
|
|
||||||
private func configureSettingsWindow(_ window: NSWindow) {
|
private func configureSettingsWindow(_ window: NSWindow) {
|
||||||
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
|
||||||
|
window.title = ""
|
||||||
window.titleVisibility = .hidden
|
window.titleVisibility = .hidden
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = false
|
||||||
|
window.styleMask.remove(.fullSizeContentView)
|
||||||
window.styleMask.insert(.resizable)
|
window.styleMask.insert(.resizable)
|
||||||
window.contentMinSize = NSSize(width: 360, height: 280)
|
window.contentMinSize = NSSize(width: 360, height: 280)
|
||||||
if window.frame.width > 520 {
|
if window.toolbar == nil {
|
||||||
window.setContentSize(NSSize(width: 460, height: max(280, window.contentView?.frame.height ?? 280)))
|
let toolbar = NSToolbar(identifier: NSToolbar.Identifier("cmux.settings.toolbar"))
|
||||||
|
toolbar.displayMode = .iconOnly
|
||||||
|
toolbar.sizeMode = .regular
|
||||||
|
toolbar.allowsUserCustomization = false
|
||||||
|
toolbar.autosavesConfiguration = false
|
||||||
|
toolbar.showsBaselineSeparator = false
|
||||||
|
window.toolbar = toolbar
|
||||||
|
window.toolbarStyle = .unified
|
||||||
}
|
}
|
||||||
|
|
||||||
let accessories = window.titlebarAccessoryViewControllers
|
let accessories = window.titlebarAccessoryViewControllers
|
||||||
|
|
|
||||||
2
ghostty
2
ghostty
|
|
@ -1 +1 @@
|
||||||
Subproject commit ef19290456c4a2368f7e24527cb617e6581adb79
|
Subproject commit c8c28df2e5c39048358fa04197c4b4ebf9b3cd33
|
||||||
36
scripts/notify_probe.sh
Executable file
36
scripts/notify_probe.sh
Executable file
|
|
@ -0,0 +1,36 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Probe common desktop-notification escape sequences.
|
||||||
|
# NOTE: cmux suppresses notifications when the app + surface are focused,
|
||||||
|
# so switch to another app/window while this runs.
|
||||||
|
|
||||||
|
esc=$'\033'
|
||||||
|
bel=$'\007'
|
||||||
|
st="${esc}\\"
|
||||||
|
|
||||||
|
send_seq() {
|
||||||
|
local label="$1"
|
||||||
|
local seq="$2"
|
||||||
|
printf '\n[%s]\n' "$label"
|
||||||
|
printf '%b' "$seq"
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep_between() {
|
||||||
|
# Ghostty rate limits notifications (~1/sec) and suppresses identical
|
||||||
|
# content within a short window, so keep spacing + unique content.
|
||||||
|
sleep 1.2
|
||||||
|
}
|
||||||
|
|
||||||
|
send_seq "OSC 9 (iTerm2) body-only, BEL terminator" "${esc}]9;cmux OSC 9 BEL $RANDOM${bel}"
|
||||||
|
sleep_between
|
||||||
|
|
||||||
|
send_seq "OSC 9 (iTerm2) body-only, ST terminator" "${esc}]9;cmux OSC 9 ST $RANDOM${st}"
|
||||||
|
sleep_between
|
||||||
|
|
||||||
|
send_seq "OSC 777 (rxvt) notify, BEL terminator" "${esc}]777;notify;cmux OSC 777 BEL $RANDOM;body ${RANDOM}${bel}"
|
||||||
|
sleep_between
|
||||||
|
|
||||||
|
send_seq "OSC 777 (rxvt) notify, ST terminator" "${esc}]777;notify;cmux OSC 777 ST $RANDOM;body ${RANDOM}${st}"
|
||||||
|
|
||||||
|
printf '\nDone.\n'
|
||||||
113
tests/cmux.py
113
tests/cmux.py
|
|
@ -31,7 +31,9 @@ Usage:
|
||||||
import socket
|
import socket
|
||||||
import select
|
import select
|
||||||
import os
|
import os
|
||||||
from typing import Optional, List, Tuple
|
import time
|
||||||
|
import errno
|
||||||
|
from typing import Optional, List, Tuple, Union
|
||||||
|
|
||||||
|
|
||||||
class cmuxError(Exception):
|
class cmuxError(Exception):
|
||||||
|
|
@ -39,10 +41,21 @@ class cmuxError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _default_socket_path() -> str:
|
||||||
|
override = os.environ.get("CMUX_SOCKET_PATH")
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
candidates = ["/tmp/cmuxterm-debug.sock", "/tmp/cmuxterm.sock"]
|
||||||
|
for path in candidates:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
class cmux:
|
class cmux:
|
||||||
"""Client for controlling cmux via Unix socket"""
|
"""Client for controlling cmux via Unix socket"""
|
||||||
|
|
||||||
DEFAULT_SOCKET_PATH = "/tmp/cmux.sock"
|
DEFAULT_SOCKET_PATH = _default_socket_path()
|
||||||
|
|
||||||
def __init__(self, socket_path: str = None):
|
def __init__(self, socket_path: str = None):
|
||||||
self.socket_path = socket_path or self.DEFAULT_SOCKET_PATH
|
self.socket_path = socket_path or self.DEFAULT_SOCKET_PATH
|
||||||
|
|
@ -54,19 +67,30 @@ class cmux:
|
||||||
if self._socket is not None:
|
if self._socket is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not os.path.exists(self.socket_path):
|
start = time.time()
|
||||||
raise cmuxError(
|
while not os.path.exists(self.socket_path):
|
||||||
f"Socket not found at {self.socket_path}. "
|
if time.time() - start >= 2.0:
|
||||||
"Is cmux running?"
|
raise cmuxError(
|
||||||
)
|
f"Socket not found at {self.socket_path}. "
|
||||||
|
"Is cmux running?"
|
||||||
|
)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
last_error: Optional[socket.error] = None
|
||||||
try:
|
while True:
|
||||||
self._socket.connect(self.socket_path)
|
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
self._socket.settimeout(5.0)
|
try:
|
||||||
except socket.error as e:
|
self._socket.connect(self.socket_path)
|
||||||
self._socket = None
|
self._socket.settimeout(5.0)
|
||||||
raise cmuxError(f"Failed to connect: {e}")
|
return
|
||||||
|
except socket.error as e:
|
||||||
|
last_error = e
|
||||||
|
self._socket.close()
|
||||||
|
self._socket = None
|
||||||
|
if e.errno in (errno.ECONNREFUSED, errno.ENOENT) and time.time() - start < 2.0:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
raise cmuxError(f"Failed to connect: {e}")
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Close the connection"""
|
"""Close the connection"""
|
||||||
|
|
@ -91,20 +115,26 @@ class cmux:
|
||||||
self._socket.sendall((command + "\n").encode())
|
self._socket.sendall((command + "\n").encode())
|
||||||
data = self._recv_buffer
|
data = self._recv_buffer
|
||||||
self._recv_buffer = ""
|
self._recv_buffer = ""
|
||||||
|
saw_newline = "\n" in data
|
||||||
|
start = time.time()
|
||||||
while True:
|
while True:
|
||||||
if "\n" not in data:
|
if saw_newline:
|
||||||
chunk = self._socket.recv(8192)
|
ready, _, _ = select.select([self._socket], [], [], 0.1)
|
||||||
if not chunk:
|
if not ready:
|
||||||
break
|
break
|
||||||
data += chunk.decode()
|
try:
|
||||||
|
chunk = self._socket.recv(8192)
|
||||||
|
except socket.timeout:
|
||||||
|
if saw_newline:
|
||||||
|
break
|
||||||
|
if time.time() - start >= 5.0:
|
||||||
|
raise cmuxError("Command timed out")
|
||||||
continue
|
continue
|
||||||
ready, _, _ = select.select([self._socket], [], [], 0.01)
|
|
||||||
if not ready:
|
|
||||||
break
|
|
||||||
chunk = self._socket.recv(8192)
|
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
data += chunk.decode()
|
data += chunk.decode()
|
||||||
|
if "\n" in data:
|
||||||
|
saw_newline = True
|
||||||
if data.endswith("\n"):
|
if data.endswith("\n"):
|
||||||
data = data[:-1]
|
data = data[:-1]
|
||||||
return data
|
return data
|
||||||
|
|
@ -159,13 +189,13 @@ class cmux:
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
raise cmuxError(response)
|
raise cmuxError(response)
|
||||||
|
|
||||||
def select_tab(self, tab: str | int) -> None:
|
def select_tab(self, tab: Union[str, int]) -> None:
|
||||||
"""Select a tab by ID or index"""
|
"""Select a tab by ID or index"""
|
||||||
response = self._send_command(f"select_tab {tab}")
|
response = self._send_command(f"select_tab {tab}")
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
raise cmuxError(response)
|
raise cmuxError(response)
|
||||||
|
|
||||||
def list_surfaces(self, tab: str | int | None = None) -> List[Tuple[int, str, bool]]:
|
def list_surfaces(self, tab: Union[str, int, None] = None) -> List[Tuple[int, str, bool]]:
|
||||||
"""
|
"""
|
||||||
List surfaces for a tab. Returns list of (index, id, is_focused) tuples.
|
List surfaces for a tab. Returns list of (index, id, is_focused) tuples.
|
||||||
If tab is None, uses the current tab.
|
If tab is None, uses the current tab.
|
||||||
|
|
@ -187,7 +217,7 @@ class cmux:
|
||||||
surfaces.append((index, surface_id, selected))
|
surfaces.append((index, surface_id, selected))
|
||||||
return surfaces
|
return surfaces
|
||||||
|
|
||||||
def focus_surface(self, surface: str | int) -> None:
|
def focus_surface(self, surface: Union[str, int]) -> None:
|
||||||
"""Focus a surface by ID or index in the current tab."""
|
"""Focus a surface by ID or index in the current tab."""
|
||||||
response = self._send_command(f"focus_surface {surface}")
|
response = self._send_command(f"focus_surface {surface}")
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
|
|
@ -216,7 +246,7 @@ class cmux:
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
raise cmuxError(response)
|
raise cmuxError(response)
|
||||||
|
|
||||||
def send_surface(self, surface: str | int, text: str) -> None:
|
def send_surface(self, surface: Union[str, int], text: str) -> None:
|
||||||
"""Send text to a specific surface by ID or index in the current tab."""
|
"""Send text to a specific surface by ID or index in the current tab."""
|
||||||
escaped = text.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
escaped = text.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
||||||
response = self._send_command(f"send_surface {surface} {escaped}")
|
response = self._send_command(f"send_surface {surface} {escaped}")
|
||||||
|
|
@ -236,7 +266,7 @@ class cmux:
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
raise cmuxError(response)
|
raise cmuxError(response)
|
||||||
|
|
||||||
def send_key_surface(self, surface: str | int, key: str) -> None:
|
def send_key_surface(self, surface: Union[str, int], key: str) -> None:
|
||||||
"""Send a special key to a specific surface by ID or index in the current tab."""
|
"""Send a special key to a specific surface by ID or index in the current tab."""
|
||||||
response = self._send_command(f"send_key_surface {surface} {key}")
|
response = self._send_command(f"send_key_surface {surface} {key}")
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
|
|
@ -258,16 +288,22 @@ class cmux:
|
||||||
"""Get help text from server"""
|
"""Get help text from server"""
|
||||||
return self._send_command("help")
|
return self._send_command("help")
|
||||||
|
|
||||||
def notify(self, title: str, body: str = "") -> None:
|
def notify(self, title: str, subtitle: str = "", body: str = "") -> None:
|
||||||
"""Create a notification for the focused surface."""
|
"""Create a notification for the focused surface."""
|
||||||
payload = f"{title}|{body}" if body else title
|
if subtitle or body:
|
||||||
|
payload = f"{title}|{subtitle}|{body}"
|
||||||
|
else:
|
||||||
|
payload = title
|
||||||
response = self._send_command(f"notify {payload}")
|
response = self._send_command(f"notify {payload}")
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
raise cmuxError(response)
|
raise cmuxError(response)
|
||||||
|
|
||||||
def notify_surface(self, surface: str | int, title: str, body: str = "") -> None:
|
def notify_surface(self, surface: Union[str, int], title: str, subtitle: str = "", body: str = "") -> None:
|
||||||
"""Create a notification for a specific surface by ID or index."""
|
"""Create a notification for a specific surface by ID or index."""
|
||||||
payload = f"{title}|{body}" if body else title
|
if subtitle or body:
|
||||||
|
payload = f"{title}|{subtitle}|{body}"
|
||||||
|
else:
|
||||||
|
payload = title
|
||||||
response = self._send_command(f"notify_surface {surface} {payload}")
|
response = self._send_command(f"notify_surface {surface} {payload}")
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
raise cmuxError(response)
|
raise cmuxError(response)
|
||||||
|
|
@ -275,7 +311,7 @@ class cmux:
|
||||||
def list_notifications(self) -> list[dict]:
|
def list_notifications(self) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
List notifications.
|
List notifications.
|
||||||
Returns list of dicts with keys: id, tab_id, surface_id, is_read, title, body.
|
Returns list of dicts with keys: id, tab_id, surface_id, is_read, title, subtitle, body.
|
||||||
"""
|
"""
|
||||||
response = self._send_command("list_notifications")
|
response = self._send_command("list_notifications")
|
||||||
if response == "No notifications":
|
if response == "No notifications":
|
||||||
|
|
@ -286,16 +322,17 @@ class cmux:
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
_, payload = line.split(":", 1)
|
_, payload = line.split(":", 1)
|
||||||
parts = payload.split("|", 5)
|
parts = payload.split("|", 6)
|
||||||
if len(parts) < 6:
|
if len(parts) < 7:
|
||||||
continue
|
continue
|
||||||
notif_id, tab_id, surface_id, read_text, title, body = parts
|
notif_id, tab_id, surface_id, read_text, title, subtitle, body = parts
|
||||||
items.append({
|
items.append({
|
||||||
"id": notif_id,
|
"id": notif_id,
|
||||||
"tab_id": tab_id,
|
"tab_id": tab_id,
|
||||||
"surface_id": None if surface_id == "none" else surface_id,
|
"surface_id": None if surface_id == "none" else surface_id,
|
||||||
"is_read": read_text == "read",
|
"is_read": read_text == "read",
|
||||||
"title": title,
|
"title": title,
|
||||||
|
"subtitle": subtitle,
|
||||||
"body": body,
|
"body": body,
|
||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
|
|
@ -306,7 +343,7 @@ class cmux:
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
raise cmuxError(response)
|
raise cmuxError(response)
|
||||||
|
|
||||||
def set_app_focus(self, active: bool | None) -> None:
|
def set_app_focus(self, active: Union[bool, None]) -> None:
|
||||||
"""Override app focus state. Use None to clear override."""
|
"""Override app focus state. Use None to clear override."""
|
||||||
if active is None:
|
if active is None:
|
||||||
value = "clear"
|
value = "clear"
|
||||||
|
|
@ -322,7 +359,7 @@ class cmux:
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
raise cmuxError(response)
|
raise cmuxError(response)
|
||||||
|
|
||||||
def focus_notification(self, tab: str | int, surface: str | int | None = None) -> None:
|
def focus_notification(self, tab: Union[str, int], surface: Union[str, int, None] = None) -> None:
|
||||||
"""Focus tab/surface using the notification flow."""
|
"""Focus tab/surface using the notification flow."""
|
||||||
if surface is None:
|
if surface is None:
|
||||||
command = f"focus_notification {tab}"
|
command = f"focus_notification {tab}"
|
||||||
|
|
@ -332,7 +369,7 @@ class cmux:
|
||||||
if not response.startswith("OK"):
|
if not response.startswith("OK"):
|
||||||
raise cmuxError(response)
|
raise cmuxError(response)
|
||||||
|
|
||||||
def flash_count(self, surface: str | int) -> int:
|
def flash_count(self, surface: Union[str, int]) -> int:
|
||||||
"""Get flash count for a surface by ID or index."""
|
"""Get flash count for a surface by ID or index."""
|
||||||
response = self._send_command(f"flash_count {surface}")
|
response = self._send_command(f"flash_count {surface}")
|
||||||
if response.startswith("OK "):
|
if response.startswith("OK "):
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import sys
|
||||||
import time
|
import time
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
# Add the directory containing cmux.py to the path
|
# Add the directory containing cmux.py to the path
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
@ -35,7 +36,7 @@ def has_ctrl_enter_keybind(config_text: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def find_config_with_keybind() -> Path | None:
|
def find_config_with_keybind() -> Optional[Path]:
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
candidates = [
|
candidates = [
|
||||||
home / "Library/Application Support/com.mitchellh.ghostty/config.ghostty",
|
home / "Library/Application Support/com.mitchellh.ghostty/config.ghostty",
|
||||||
|
|
|
||||||
|
|
@ -65,15 +65,24 @@ def test_ctrl_c(client: cmux) -> TestResult:
|
||||||
|
|
||||||
# Start a long sleep
|
# Start a long sleep
|
||||||
client.send("sleep 30\n")
|
client.send("sleep 30\n")
|
||||||
time.sleep(0.3)
|
time.sleep(0.8)
|
||||||
|
|
||||||
# Send Ctrl+C to interrupt
|
# Send Ctrl+C to interrupt
|
||||||
client.send_ctrl_c()
|
client.send_ctrl_c()
|
||||||
time.sleep(0.3)
|
time.sleep(0.8)
|
||||||
|
|
||||||
# If Ctrl+C worked, shell should accept new command
|
# If Ctrl+C worked, shell should accept new command
|
||||||
client.send(f"touch {marker}\n")
|
for attempt in range(3):
|
||||||
time.sleep(0.5)
|
client.send(f"touch {marker}\n")
|
||||||
|
for _ in range(10):
|
||||||
|
if marker.exists():
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
if marker.exists():
|
||||||
|
break
|
||||||
|
# try another Ctrl+C in case the process swallowed the signal
|
||||||
|
client.send_ctrl_c()
|
||||||
|
time.sleep(0.6)
|
||||||
|
|
||||||
if marker.exists():
|
if marker.exists():
|
||||||
result.success("Ctrl+C interrupted sleep, shell responsive")
|
result.success("Ctrl+C interrupted sleep, shell responsive")
|
||||||
|
|
@ -104,15 +113,18 @@ def test_ctrl_d(client: cmux) -> TestResult:
|
||||||
|
|
||||||
# Run cat (waits for input)
|
# Run cat (waits for input)
|
||||||
client.send("cat\n")
|
client.send("cat\n")
|
||||||
time.sleep(0.3)
|
time.sleep(0.6)
|
||||||
|
|
||||||
# Send Ctrl+D (EOF)
|
# Send Ctrl+D (EOF)
|
||||||
client.send_ctrl_d()
|
client.send_ctrl_d()
|
||||||
time.sleep(0.3)
|
time.sleep(0.4)
|
||||||
|
|
||||||
# If Ctrl+D worked, cat should exit and we can run another command
|
# If Ctrl+D worked, cat should exit and we can run another command
|
||||||
client.send(f"touch {marker}\n")
|
client.send(f"touch {marker}\n")
|
||||||
time.sleep(0.5)
|
for _ in range(10):
|
||||||
|
if marker.exists():
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
if marker.exists():
|
if marker.exists():
|
||||||
result.success("Ctrl+D sent EOF, cat exited")
|
result.success("Ctrl+D sent EOF, cat exited")
|
||||||
|
|
@ -140,15 +152,18 @@ def test_ctrl_c_python(client: cmux) -> TestResult:
|
||||||
|
|
||||||
# Start Python that loops forever
|
# Start Python that loops forever
|
||||||
client.send("python3 -c 'import time; [time.sleep(1) for _ in iter(int, 1)]'\n")
|
client.send("python3 -c 'import time; [time.sleep(1) for _ in iter(int, 1)]'\n")
|
||||||
time.sleep(1.0) # Give Python time to start
|
time.sleep(1.2) # Give Python time to start
|
||||||
|
|
||||||
# Send Ctrl+C
|
# Send Ctrl+C
|
||||||
client.send_ctrl_c()
|
client.send_ctrl_c()
|
||||||
time.sleep(0.5)
|
time.sleep(0.6)
|
||||||
|
|
||||||
# If Ctrl+C worked, shell should accept new command
|
# If Ctrl+C worked, shell should accept new command
|
||||||
client.send(f"touch {marker}\n")
|
client.send(f"touch {marker}\n")
|
||||||
time.sleep(0.5)
|
for _ in range(10):
|
||||||
|
if marker.exists():
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
if marker.exists():
|
if marker.exists():
|
||||||
result.success("Ctrl+C interrupted Python process")
|
result.success("Ctrl+C interrupted Python process")
|
||||||
|
|
|
||||||
81
tests/test_focus_notification_dismiss.py
Executable file
81
tests/test_focus_notification_dismiss.py
Executable file
|
|
@ -0,0 +1,81 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
E2E: focusing a panel clears its notification and triggers a flash.
|
||||||
|
|
||||||
|
Note: This uses the socket focus command (no assistive access needed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from cmux import cmux, cmuxError
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_notification(client: cmux, surface_id: str, is_read: bool, timeout: float = 2.0) -> bool:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
items = client.list_notifications()
|
||||||
|
for item in items:
|
||||||
|
if item["surface_id"] == surface_id and item["is_read"] == is_read:
|
||||||
|
return True
|
||||||
|
time.sleep(0.05)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def surface_id_for_index(client: cmux, index: int) -> str:
|
||||||
|
surfaces = client.list_surfaces()
|
||||||
|
for entry in surfaces:
|
||||||
|
if entry[0] == index:
|
||||||
|
return entry[1]
|
||||||
|
raise RuntimeError(f"Surface index {index} not found")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_two_surfaces(client: cmux) -> None:
|
||||||
|
surfaces = client.list_surfaces()
|
||||||
|
if len(surfaces) < 2:
|
||||||
|
client.new_split("right")
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
with cmux() as client:
|
||||||
|
client.set_app_focus(None)
|
||||||
|
ensure_two_surfaces(client)
|
||||||
|
client.focus_surface(0)
|
||||||
|
|
||||||
|
surface_id = surface_id_for_index(client, 1)
|
||||||
|
client.clear_notifications()
|
||||||
|
client.reset_flash_counts()
|
||||||
|
initial_flash = client.flash_count(1)
|
||||||
|
|
||||||
|
client.notify_surface(1, "Focus Test", "panel", "body")
|
||||||
|
if not wait_for_notification(client, surface_id, is_read=False, timeout=2.0):
|
||||||
|
print("FAIL: Notification did not appear as unread")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
client.focus_surface(1)
|
||||||
|
client.send("x")
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
|
||||||
|
print("FAIL: Notification did not become read after focus")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
final_flash = client.flash_count(1)
|
||||||
|
if final_flash <= initial_flash:
|
||||||
|
print(f"FAIL: Flash count did not increment (before={initial_flash}, after={final_flash})")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("PASS: Focus clears notification and flashes panel")
|
||||||
|
return 0
|
||||||
|
except (cmuxError, RuntimeError) as exc:
|
||||||
|
print(f"FAIL: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -12,6 +12,7 @@ Requirements:
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
@ -60,7 +61,7 @@ def focused_surface_index(client: cmux) -> int:
|
||||||
return focused[0]
|
return focused[0]
|
||||||
|
|
||||||
|
|
||||||
def send_osc(client: cmux, sequence: str, surface: int | None = None) -> None:
|
def send_osc(client: cmux, sequence: str, surface: Optional[int] = None) -> None:
|
||||||
"""Send an OSC sequence by printing it in the shell."""
|
"""Send an OSC sequence by printing it in the shell."""
|
||||||
command = f"printf '{sequence}'\\n"
|
command = f"printf '{sequence}'\\n"
|
||||||
if surface is None:
|
if surface is None:
|
||||||
|
|
|
||||||
|
|
@ -202,13 +202,27 @@ print("TIMEOUT", flush=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Wait for process to start
|
# Wait for process to start and emit the ready line
|
||||||
time.sleep(0.2)
|
output = b""
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < 2.0:
|
||||||
|
if select.select([proc.stdout], [], [], 0.1)[0]:
|
||||||
|
chunk = os.read(proc.stdout.fileno(), 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
output += chunk
|
||||||
|
if b"WAITING" in output:
|
||||||
|
break
|
||||||
|
|
||||||
|
if b"WAITING" not in output:
|
||||||
|
print(f" ❌ FAILED: Process not ready. Output: {output}")
|
||||||
|
return False
|
||||||
|
|
||||||
# Send SIGINT directly
|
# Send SIGINT directly
|
||||||
proc.send_signal(signal.SIGINT)
|
proc.send_signal(signal.SIGINT)
|
||||||
|
|
||||||
stdout, stderr = proc.communicate(timeout=2)
|
stdout, stderr = proc.communicate(timeout=2)
|
||||||
|
stdout = output + stdout
|
||||||
|
|
||||||
if b"SIGINT_RECEIVED" in stdout:
|
if b"SIGINT_RECEIVED" in stdout:
|
||||||
print(" ✅ PASSED: Direct SIGINT works")
|
print(" ✅ PASSED: Direct SIGINT works")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue