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
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
|
|
@ -28,12 +40,20 @@ xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -des
|
|||
|
||||
## 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
|
||||
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
|
||||
|
||||
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 */; };
|
||||
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
|
||||
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 */; };
|
||||
A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.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 */; };
|
||||
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
|
||||
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 */; };
|
||||
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
|
||||
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
|
||||
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
|
@ -52,6 +56,17 @@
|
|||
name = "Embed Frameworks";
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
|
@ -62,6 +77,13 @@
|
|||
remoteGlobalIDString = A5001050 /* GhosttyTabs */;
|
||||
remoteInfo = GhosttyTabs;
|
||||
};
|
||||
B900000DA1B2C3D4E5F60719 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A5001070 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */;
|
||||
remoteInfo = "cmuxterm-cli";
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
|
|
@ -76,6 +98,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
|
@ -99,7 +122,10 @@
|
|||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -119,6 +145,13 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B900000CA1B2C3D4E5F60719 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
|
|
@ -140,11 +173,32 @@
|
|||
};
|
||||
/* 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 */
|
||||
A5001040 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A5001041 /* Sources */,
|
||||
B9000003A1B2C3D4E5F60719 /* CLI */,
|
||||
087C454FFF74443AB06942C3 /* Resources */,
|
||||
A5001101 /* Assets.xcassets */,
|
||||
A5001016 /* GhosttyKit.xcframework */,
|
||||
|
|
@ -164,6 +218,7 @@
|
|||
A5001014 /* GhosttyConfig.swift */,
|
||||
A5001015 /* GhosttyTerminalView.swift */,
|
||||
A5001019 /* TerminalController.swift */,
|
||||
A5001225 /* SocketControlSettings.swift */,
|
||||
A5001090 /* AppDelegate.swift */,
|
||||
A5001091 /* NotificationsPage.swift */,
|
||||
A5001092 /* TerminalNotificationStore.swift */,
|
||||
|
|
@ -188,6 +243,14 @@
|
|||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B9000003A1B2C3D4E5F60719 /* CLI */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */,
|
||||
);
|
||||
path = CLI;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
087C454FFF74443AB06942C3 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -200,6 +263,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
A5001000 /* cmuxterm.app */,
|
||||
B9000004A1B2C3D4E5F60719 /* cmuxterm */,
|
||||
7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
|
|
@ -208,6 +272,7 @@
|
|||
3196C9C2D01F054C1D3385DD /* GhosttyTabsUITests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */,
|
||||
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */,
|
||||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
|
||||
);
|
||||
|
|
@ -223,12 +288,15 @@
|
|||
buildPhases = (
|
||||
A5001051 /* Sources */,
|
||||
A5001030 /* Frameworks */,
|
||||
A5001300A1B2C3D4E5F60719 /* Copy Ghostty Resources */,
|
||||
A5001102 /* Resources */,
|
||||
A5001020 /* Embed Frameworks */,
|
||||
B900000AA1B2C3D4E5F60719 /* Copy CLI */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
B900000EA1B2C3D4E5F60719 /* PBXTargetDependency */,
|
||||
);
|
||||
packageProductDependencies = (
|
||||
A5001231 /* Sparkle */,
|
||||
|
|
@ -238,6 +306,22 @@
|
|||
productReference = A5001000 /* cmuxterm.app */;
|
||||
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 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "GhosttyTabsUITests" */;
|
||||
|
|
@ -283,6 +367,7 @@
|
|||
projectRoot = "";
|
||||
targets = (
|
||||
A5001050 /* GhosttyTabs */,
|
||||
B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */,
|
||||
CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */,
|
||||
);
|
||||
};
|
||||
|
|
@ -299,6 +384,7 @@
|
|||
A5001004 /* GhosttyConfig.swift in Sources */,
|
||||
A5001005 /* GhosttyTerminalView.swift in Sources */,
|
||||
A5001007 /* TerminalController.swift in Sources */,
|
||||
A5001226 /* SocketControlSettings.swift in Sources */,
|
||||
A5001093 /* AppDelegate.swift in Sources */,
|
||||
A5001094 /* NotificationsPage.swift in Sources */,
|
||||
A5001095 /* TerminalNotificationStore.swift in Sources */,
|
||||
|
|
@ -326,11 +412,20 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */,
|
||||
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */,
|
||||
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B9000006A1B2C3D4E5F60719 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B9000002A1B2C3D4E5F60719 /* cmuxterm.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
|
|
@ -339,6 +434,11 @@
|
|||
target = A5001050 /* GhosttyTabs */;
|
||||
targetProxy = 738BF3D3196765B250928A93 /* PBXContainerItemProxy */;
|
||||
};
|
||||
B900000EA1B2C3D4E5F60719 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = B9000005A1B2C3D4E5F60719 /* cmuxterm-cli */;
|
||||
targetProxy = B900000DA1B2C3D4E5F60719 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
|
|
@ -494,6 +594,30 @@
|
|||
};
|
||||
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 */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
|
|
@ -567,6 +691,15 @@
|
|||
defaultConfigurationIsVisible = 0;
|
||||
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" */ = {
|
||||
isa = XCConfigurationList;
|
||||
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
|
||||
- **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
|
||||
- **Auto-updates** — Stay current with Sparkle-powered updates
|
||||
|
||||
## Why cmuxterm?
|
||||
|
||||
|
|
|
|||
|
|
@ -43,10 +43,18 @@ struct GhosttyConfig {
|
|||
static func load() -> GhosttyConfig {
|
||||
var config = GhosttyConfig()
|
||||
|
||||
// Load user config
|
||||
let configPath = NSString(string: "~/Library/Application Support/com.mitchellh.ghostty/config").expandingTildeInPath
|
||||
if let contents = try? String(contentsOfFile: configPath, encoding: .utf8) {
|
||||
config.parse(contents)
|
||||
// Match Ghostty's default load order on macOS.
|
||||
let configPaths = [
|
||||
"~/.config/ghostty/config",
|
||||
"~/.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
|
||||
|
|
@ -137,11 +145,15 @@ struct GhosttyConfig {
|
|||
}
|
||||
|
||||
mutating func loadTheme(_ name: String) {
|
||||
// Try to load from Ghostty app resources
|
||||
let bundleThemePath = Bundle.main.resourceURL?
|
||||
.appendingPathComponent("ghostty/themes/\(name)")
|
||||
.path
|
||||
|
||||
let themePaths = [
|
||||
bundleThemePath,
|
||||
"/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 {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import SwiftUI
|
|||
import AppKit
|
||||
import Metal
|
||||
import QuartzCore
|
||||
import Darwin
|
||||
|
||||
private enum GhosttyPasteboardHelper {
|
||||
private static let selectionPasteboard = NSPasteboard(
|
||||
|
|
@ -353,6 +354,7 @@ class GhosttyApp {
|
|||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
title: command,
|
||||
subtitle: "",
|
||||
body: body
|
||||
)
|
||||
}
|
||||
|
|
@ -509,6 +511,7 @@ class GhosttyApp {
|
|||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
title: command,
|
||||
subtitle: "",
|
||||
body: body
|
||||
)
|
||||
}
|
||||
|
|
@ -637,8 +640,63 @@ class TerminalSurface: Identifiable {
|
|||
surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque()
|
||||
surfaceConfig.scale_factor = scale
|
||||
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 {
|
||||
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 {
|
||||
focusedSurface?.applyWindowBackgroundIfActive()
|
||||
}
|
||||
let isAppFocused = AppFocusState.isAppFocused()
|
||||
guard isSelectedTab && isAppFocused else { return }
|
||||
let isAppActive = AppFocusState.isAppActive()
|
||||
guard isSelectedTab && isAppActive else { return }
|
||||
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
|
||||
if notificationStore.hasUnreadNotification(forTabId: self.id, surfaceId: id) {
|
||||
triggerNotificationFocusFlash(surfaceId: id, requiresSplit: false, shouldFocus: false)
|
||||
|
|
@ -465,7 +465,7 @@ class TabManager: ObservableObject {
|
|||
let shouldSuppressFlash = suppressFocusFlash
|
||||
suppressFocusFlash = false
|
||||
guard !shouldSuppressFlash else { return }
|
||||
guard AppFocusState.isAppFocused() else { return }
|
||||
guard AppFocusState.isAppActive() else { return }
|
||||
guard let surfaceId = focusedSurfaceId(for: tabId) else { return }
|
||||
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
|
||||
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return }
|
||||
|
|
|
|||
|
|
@ -6,16 +6,28 @@ import Foundation
|
|||
class TerminalController {
|
||||
static let shared = TerminalController()
|
||||
|
||||
private let socketPath = "/tmp/cmux.sock"
|
||||
private var socketPath = "/tmp/cmuxterm.sock"
|
||||
private var serverSocket: Int32 = -1
|
||||
private var isRunning = false
|
||||
private var clientHandlers: [Int32: Thread] = [:]
|
||||
private weak var tabManager: TabManager?
|
||||
private var accessMode: SocketControlMode = .full
|
||||
|
||||
private init() {}
|
||||
|
||||
func start(tabManager: TabManager) {
|
||||
func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) {
|
||||
self.tabManager = tabManager
|
||||
self.accessMode = accessMode
|
||||
|
||||
if isRunning {
|
||||
if self.socketPath == socketPath {
|
||||
self.accessMode = accessMode
|
||||
return
|
||||
}
|
||||
stop()
|
||||
}
|
||||
|
||||
self.socketPath = socketPath
|
||||
|
||||
// Remove existing socket file
|
||||
unlink(socketPath)
|
||||
|
|
@ -133,6 +145,9 @@ class TerminalController {
|
|||
|
||||
let cmd = parts[0].lowercased()
|
||||
let args = parts.count > 1 ? parts[1] : ""
|
||||
if !isCommandAllowed(cmd) {
|
||||
return "ERROR: Command disabled by socket access mode"
|
||||
}
|
||||
|
||||
switch cmd {
|
||||
case "ping":
|
||||
|
|
@ -180,6 +195,9 @@ class TerminalController {
|
|||
case "notify_surface":
|
||||
return notifySurface(args)
|
||||
|
||||
case "notify_target":
|
||||
return notifyTarget(args)
|
||||
|
||||
case "list_notifications":
|
||||
return listNotifications()
|
||||
|
||||
|
|
@ -227,8 +245,9 @@ class TerminalController {
|
|||
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_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_surface <id|idx> <title>|<body> - Create a notification for a surface
|
||||
notify <title>|<subtitle>|<body> - Create a notification for the focused 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
|
||||
clear_notifications - Clear all notifications
|
||||
set_app_focus <active|inactive|clear> - Override app focus state
|
||||
|
|
@ -246,6 +265,26 @@ class TerminalController {
|
|||
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 {
|
||||
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
|
|
@ -350,11 +389,12 @@ class TerminalController {
|
|||
return
|
||||
}
|
||||
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
|
||||
let (title, body) = parseNotificationPayload(args)
|
||||
let (title, subtitle, body) = parseNotificationPayload(args)
|
||||
TerminalNotificationStore.shared.addNotification(
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
body: body
|
||||
)
|
||||
}
|
||||
|
|
@ -381,11 +421,47 @@ class TerminalController {
|
|||
result = "ERROR: Surface not found"
|
||||
return
|
||||
}
|
||||
let (title, body) = parseNotificationPayload(payload)
|
||||
let (title, subtitle, body) = parseNotificationPayload(payload)
|
||||
TerminalNotificationStore.shared.addNotification(
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
body: body
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func notifyTarget(_ args: String) -> String {
|
||||
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
||||
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "ERROR: Usage: notify_target <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
|
||||
)
|
||||
}
|
||||
|
|
@ -398,7 +474,7 @@ class TerminalController {
|
|||
let lines = TerminalNotificationStore.shared.notifications.enumerated().map { index, notification in
|
||||
let surfaceText = notification.surfaceId?.uuidString ?? "none"
|
||||
let readText = notification.isRead ? "read" : "unread"
|
||||
return "\(index):\(notification.id.uuidString)|\(notification.tabId.uuidString)|\(surfaceText)|\(readText)|\(notification.title)|\(notification.body)"
|
||||
return "\(index):\(notification.id.uuidString)|\(notification.tabId.uuidString)|\(surfaceText)|\(readText)|\(notification.title)|\(notification.subtitle)|\(notification.body)"
|
||||
}
|
||||
result = lines.joined(separator: "\n")
|
||||
}
|
||||
|
|
@ -559,13 +635,16 @@ class TerminalController {
|
|||
return nil
|
||||
}
|
||||
|
||||
private func parseNotificationPayload(_ args: String) -> (String, String) {
|
||||
private func parseNotificationPayload(_ args: String) -> (String, String, String) {
|
||||
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return ("Notification", "") }
|
||||
let parts = trimmed.split(separator: "|", maxSplits: 1).map(String.init)
|
||||
guard !trimmed.isEmpty else { return ("Notification", "", "") }
|
||||
let parts = trimmed.split(separator: "|", maxSplits: 2).map(String.init)
|
||||
let title = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let body = parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
return (title.isEmpty ? "Notification" : title, body)
|
||||
let subtitle = parts.count > 2 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
let body = parts.count > 2
|
||||
? parts[2].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: (parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : "")
|
||||
return (title.isEmpty ? "Notification" : title, subtitle, body)
|
||||
}
|
||||
|
||||
private func closeTab(_ tabId: String) -> String {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ import UserNotifications
|
|||
enum AppFocusState {
|
||||
static var overrideIsFocused: Bool?
|
||||
|
||||
static func isAppActive() -> Bool {
|
||||
if let overrideIsFocused {
|
||||
return overrideIsFocused
|
||||
}
|
||||
return NSApp.isActive
|
||||
}
|
||||
|
||||
static func isAppFocused() -> Bool {
|
||||
if let overrideIsFocused {
|
||||
return overrideIsFocused
|
||||
|
|
@ -18,6 +25,7 @@ struct TerminalNotification: Identifiable, Hashable {
|
|||
let tabId: UUID
|
||||
let surfaceId: UUID?
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let body: String
|
||||
let createdAt: Date
|
||||
var isRead: Bool
|
||||
|
|
@ -56,7 +64,7 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
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)
|
||||
|
||||
let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId
|
||||
|
|
@ -73,6 +81,7 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
body: body,
|
||||
createdAt: Date(),
|
||||
isRead: false
|
||||
|
|
@ -168,8 +177,8 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
||||
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
||||
?? "cmuxterm"
|
||||
content.title = appName
|
||||
content.subtitle = notification.title
|
||||
content.title = notification.title.isEmpty ? appName : notification.title
|
||||
content.subtitle = notification.subtitle
|
||||
content.body = notification.body
|
||||
content.sound = UNNotificationSound.default
|
||||
content.categoryIdentifier = Self.categoryIdentifier
|
||||
|
|
|
|||
|
|
@ -8,17 +8,25 @@ struct WindowAccessor: NSViewRepresentable {
|
|||
Coordinator()
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
NSView()
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
DispatchQueue.main.async { [weak nsView] in
|
||||
guard let window = nsView?.window else { return }
|
||||
func makeNSView(context: Context) -> WindowObservingView {
|
||||
let view = WindowObservingView()
|
||||
view.onWindow = { window in
|
||||
guard context.coordinator.lastWindow !== window else { return }
|
||||
context.coordinator.lastWindow = 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?
|
||||
}
|
||||
}
|
||||
|
||||
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 SwiftUI
|
||||
import Darwin
|
||||
|
||||
@main
|
||||
struct cmuxApp: App {
|
||||
|
|
@ -12,6 +13,7 @@ struct cmuxApp: App {
|
|||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
init() {
|
||||
configureGhosttyEnvironment()
|
||||
// Start the terminal controller for programmatic control
|
||||
// This runs after TabManager is created via @StateObject
|
||||
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 {
|
||||
WindowGroup {
|
||||
ContentView(updateViewModel: appDelegate.updateViewModel)
|
||||
|
|
@ -50,6 +103,7 @@ struct cmuxApp: App {
|
|||
Settings {
|
||||
SettingsRootView()
|
||||
}
|
||||
.defaultSize(width: 460, height: 280)
|
||||
.windowResizability(.contentMinSize)
|
||||
.commands {
|
||||
CommandGroup(replacing: .appInfo) {
|
||||
|
|
@ -327,6 +381,7 @@ struct SettingsView: View {
|
|||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(20)
|
||||
.padding(.top, 4)
|
||||
.frame(minWidth: 360, minHeight: 280)
|
||||
}
|
||||
}
|
||||
|
|
@ -341,12 +396,21 @@ private struct SettingsRootView: View {
|
|||
|
||||
private func configureSettingsWindow(_ window: NSWindow) {
|
||||
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
|
||||
window.title = ""
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titlebarAppearsTransparent = false
|
||||
window.styleMask.remove(.fullSizeContentView)
|
||||
window.styleMask.insert(.resizable)
|
||||
window.contentMinSize = NSSize(width: 360, height: 280)
|
||||
if window.frame.width > 520 {
|
||||
window.setContentSize(NSSize(width: 460, height: max(280, window.contentView?.frame.height ?? 280)))
|
||||
if window.toolbar == nil {
|
||||
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
|
||||
|
|
|
|||
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 select
|
||||
import os
|
||||
from typing import Optional, List, Tuple
|
||||
import time
|
||||
import errno
|
||||
from typing import Optional, List, Tuple, Union
|
||||
|
||||
|
||||
class cmuxError(Exception):
|
||||
|
|
@ -39,10 +41,21 @@ class cmuxError(Exception):
|
|||
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:
|
||||
"""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):
|
||||
self.socket_path = socket_path or self.DEFAULT_SOCKET_PATH
|
||||
|
|
@ -54,19 +67,30 @@ class cmux:
|
|||
if self._socket is not None:
|
||||
return
|
||||
|
||||
if not os.path.exists(self.socket_path):
|
||||
raise cmuxError(
|
||||
f"Socket not found at {self.socket_path}. "
|
||||
"Is cmux running?"
|
||||
)
|
||||
start = time.time()
|
||||
while not os.path.exists(self.socket_path):
|
||||
if time.time() - start >= 2.0:
|
||||
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)
|
||||
try:
|
||||
self._socket.connect(self.socket_path)
|
||||
self._socket.settimeout(5.0)
|
||||
except socket.error as e:
|
||||
self._socket = None
|
||||
raise cmuxError(f"Failed to connect: {e}")
|
||||
last_error: Optional[socket.error] = None
|
||||
while True:
|
||||
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
self._socket.connect(self.socket_path)
|
||||
self._socket.settimeout(5.0)
|
||||
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:
|
||||
"""Close the connection"""
|
||||
|
|
@ -91,20 +115,26 @@ class cmux:
|
|||
self._socket.sendall((command + "\n").encode())
|
||||
data = self._recv_buffer
|
||||
self._recv_buffer = ""
|
||||
saw_newline = "\n" in data
|
||||
start = time.time()
|
||||
while True:
|
||||
if "\n" not in data:
|
||||
chunk = self._socket.recv(8192)
|
||||
if not chunk:
|
||||
if saw_newline:
|
||||
ready, _, _ = select.select([self._socket], [], [], 0.1)
|
||||
if not ready:
|
||||
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
|
||||
ready, _, _ = select.select([self._socket], [], [], 0.01)
|
||||
if not ready:
|
||||
break
|
||||
chunk = self._socket.recv(8192)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk.decode()
|
||||
if "\n" in data:
|
||||
saw_newline = True
|
||||
if data.endswith("\n"):
|
||||
data = data[:-1]
|
||||
return data
|
||||
|
|
@ -159,13 +189,13 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
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"""
|
||||
response = self._send_command(f"select_tab {tab}")
|
||||
if not response.startswith("OK"):
|
||||
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.
|
||||
If tab is None, uses the current tab.
|
||||
|
|
@ -187,7 +217,7 @@ class cmux:
|
|||
surfaces.append((index, surface_id, selected))
|
||||
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."""
|
||||
response = self._send_command(f"focus_surface {surface}")
|
||||
if not response.startswith("OK"):
|
||||
|
|
@ -216,7 +246,7 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
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."""
|
||||
escaped = text.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
||||
response = self._send_command(f"send_surface {surface} {escaped}")
|
||||
|
|
@ -236,7 +266,7 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
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."""
|
||||
response = self._send_command(f"send_key_surface {surface} {key}")
|
||||
if not response.startswith("OK"):
|
||||
|
|
@ -258,16 +288,22 @@ class cmux:
|
|||
"""Get help text from server"""
|
||||
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."""
|
||||
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}")
|
||||
if not response.startswith("OK"):
|
||||
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."""
|
||||
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}")
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
|
@ -275,7 +311,7 @@ class cmux:
|
|||
def list_notifications(self) -> list[dict]:
|
||||
"""
|
||||
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")
|
||||
if response == "No notifications":
|
||||
|
|
@ -286,16 +322,17 @@ class cmux:
|
|||
if not line.strip():
|
||||
continue
|
||||
_, payload = line.split(":", 1)
|
||||
parts = payload.split("|", 5)
|
||||
if len(parts) < 6:
|
||||
parts = payload.split("|", 6)
|
||||
if len(parts) < 7:
|
||||
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({
|
||||
"id": notif_id,
|
||||
"tab_id": tab_id,
|
||||
"surface_id": None if surface_id == "none" else surface_id,
|
||||
"is_read": read_text == "read",
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"body": body,
|
||||
})
|
||||
return items
|
||||
|
|
@ -306,7 +343,7 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
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."""
|
||||
if active is None:
|
||||
value = "clear"
|
||||
|
|
@ -322,7 +359,7 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
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."""
|
||||
if surface is None:
|
||||
command = f"focus_notification {tab}"
|
||||
|
|
@ -332,7 +369,7 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
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."""
|
||||
response = self._send_command(f"flash_count {surface}")
|
||||
if response.startswith("OK "):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import sys
|
|||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Add the directory containing cmux.py to the path
|
||||
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
|
||||
|
||||
|
||||
def find_config_with_keybind() -> Path | None:
|
||||
def find_config_with_keybind() -> Optional[Path]:
|
||||
home = Path.home()
|
||||
candidates = [
|
||||
home / "Library/Application Support/com.mitchellh.ghostty/config.ghostty",
|
||||
|
|
|
|||
|
|
@ -65,15 +65,24 @@ def test_ctrl_c(client: cmux) -> TestResult:
|
|||
|
||||
# Start a long sleep
|
||||
client.send("sleep 30\n")
|
||||
time.sleep(0.3)
|
||||
time.sleep(0.8)
|
||||
|
||||
# Send Ctrl+C to interrupt
|
||||
client.send_ctrl_c()
|
||||
time.sleep(0.3)
|
||||
time.sleep(0.8)
|
||||
|
||||
# If Ctrl+C worked, shell should accept new command
|
||||
client.send(f"touch {marker}\n")
|
||||
time.sleep(0.5)
|
||||
for attempt in range(3):
|
||||
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():
|
||||
result.success("Ctrl+C interrupted sleep, shell responsive")
|
||||
|
|
@ -104,15 +113,18 @@ def test_ctrl_d(client: cmux) -> TestResult:
|
|||
|
||||
# Run cat (waits for input)
|
||||
client.send("cat\n")
|
||||
time.sleep(0.3)
|
||||
time.sleep(0.6)
|
||||
|
||||
# Send Ctrl+D (EOF)
|
||||
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
|
||||
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():
|
||||
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
|
||||
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
|
||||
client.send_ctrl_c()
|
||||
time.sleep(0.5)
|
||||
time.sleep(0.6)
|
||||
|
||||
# If Ctrl+C worked, shell should accept new command
|
||||
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():
|
||||
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 sys
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
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]
|
||||
|
||||
|
||||
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."""
|
||||
command = f"printf '{sequence}'\\n"
|
||||
if surface is None:
|
||||
|
|
|
|||
|
|
@ -202,13 +202,27 @@ print("TIMEOUT", flush=True)
|
|||
)
|
||||
|
||||
try:
|
||||
# Wait for process to start
|
||||
time.sleep(0.2)
|
||||
# Wait for process to start and emit the ready line
|
||||
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
|
||||
proc.send_signal(signal.SIGINT)
|
||||
|
||||
stdout, stderr = proc.communicate(timeout=2)
|
||||
stdout = output + stdout
|
||||
|
||||
if b"SIGINT_RECEIVED" in stdout:
|
||||
print(" ✅ PASSED: Direct SIGINT works")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue