cmux/Sources/CmuxConfig.swift
Pratik Pakhale b9c656b90c
feat: cmux.json for custom commands (#2011)
* Pre-launch app for browser UI test on headless CI runners

XCUIApplication.launch() blocks ~60s then fails on headless WarpBuild
runners because foreground activation requires a GUI login session.

Apply the same pre-launch strategy used for the display resolution test:
- CI shell launches the app with env vars before running xcodebuild
- Test detects pre-launched app via manifest, uses activate() instead of
  launch() to avoid killing and relaunching the app
- Falls back to clicking the window for focus via accessibility framework

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Revert "Pre-launch app for browser UI test on headless CI runners"

This reverts commit a540e2fd99aaa1395b91a8d50caa797cdd7551b8.

* feat: cmux.json for custom commands

* tests: add cmux  json tests

* fix: pr review feedback: validation, translations, input handling, and palette improvements

  - Fix Danish ("Overfladedef inition") and Norwegian ("rotmapp") translation typos
  - Add empty-string check for baseCwd fallback in command palette handlers
  - Coalesce \r\n into single Return keypress in sendInput
  - Redact command text from timeout log to prevent secret leakage
  - Add decode-time validation: reject hybrid/empty commands, ambiguous layout
    nodes, wrong split children count, and empty pane surfaces
  - Namespace custom command IDs with "cmux.config.command." prefix
  - Forward command description to palette subtitle when available
  - Update tests for new validation rules and ID prefix

* fix: address PR review feedback — per-window config isolation, blank validation, ancestor walk,
  palette sanitization

* fix: fallback to current dir cmux.json watching if no any cmux.json found in full acesor walk

* ci: trigger CI for fork PR

* Add directory trust for cmux.json command confirmation

The confirm dialog now shows the actual command text and has an "Always
trust commands from this folder" checkbox. When checked, future confirm
commands from that directory skip the dialog.

Trust is scoped to the git repo root if the cmux.json is inside a repo,
so trusting once covers all subdirectories. Non-git directories are
trusted by exact path. Global config is always trusted.

Trusted directories are persisted in ~/Library/Application Support/cmux/
trusted-directories.json.

* Add trusted directories section to Settings

Shows all trusted directories with per-directory revoke buttons and a
Clear All option. Placed in a "Custom Commands" section between
Automation and Browser in Settings.

* Replace trusted directories list with editable textarea

One path per line, with a Save button that activates on changes.
Users can add, remove, or edit paths directly.

* Auto-save trusted directories on edit, remove Save button

Matches the behavior of other textarea settings (browser host
whitelist, external URL patterns) which auto-save via @AppStorage.

* Sanitize command text in confirm dialog against BiDi attacks

Strip zero-width and BiDi override characters from the command preview
so the dialog shows exactly what will be executed.

---------

Co-authored-by: austinpower1258 <austinwang115@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
2026-03-24 22:28:46 -07:00

590 lines
19 KiB
Swift

import Bonsplit
import Combine
import Foundation
struct CmuxConfigFile: Codable, Sendable {
var commands: [CmuxCommandDefinition]
}
struct CmuxCommandDefinition: Codable, Sendable, Identifiable {
var name: String
var description: String?
var keywords: [String]?
var restart: CmuxRestartBehavior?
var workspace: CmuxWorkspaceDefinition?
var command: String?
var confirm: Bool?
var id: String {
"cmux.config.command." + (name.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? name)
}
init(
name: String,
description: String? = nil,
keywords: [String]? = nil,
restart: CmuxRestartBehavior? = nil,
workspace: CmuxWorkspaceDefinition? = nil,
command: String? = nil,
confirm: Bool? = nil
) {
self.name = name
self.description = description
self.keywords = keywords
self.restart = restart
self.workspace = workspace
self.command = command
self.confirm = confirm
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
description = try container.decodeIfPresent(String.self, forKey: .description)
keywords = try container.decodeIfPresent([String].self, forKey: .keywords)
restart = try container.decodeIfPresent(CmuxRestartBehavior.self, forKey: .restart)
workspace = try container.decodeIfPresent(CmuxWorkspaceDefinition.self, forKey: .workspace)
command = try container.decodeIfPresent(String.self, forKey: .command)
confirm = try container.decodeIfPresent(Bool.self, forKey: .confirm)
if name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Command name must not be blank"
)
)
}
if let cmd = command,
cmd.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Command '\(name)' must not define a blank 'command'"
)
)
}
if workspace != nil && command != nil {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Command '\(name)' must not define both 'workspace' and 'command'"
)
)
}
if workspace == nil && command == nil {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Command '\(name)' must define either 'workspace' or 'command'"
)
)
}
}
}
enum CmuxRestartBehavior: String, Codable, Sendable {
case recreate
case ignore
case confirm
}
struct CmuxWorkspaceDefinition: Codable, Sendable {
var name: String?
var cwd: String?
var color: String?
var layout: CmuxLayoutNode?
}
indirect enum CmuxLayoutNode: Codable, Sendable {
case pane(CmuxPaneDefinition)
case split(CmuxSplitDefinition)
private enum CodingKeys: String, CodingKey {
case pane
case direction
case split
case children
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let hasPane = container.contains(.pane)
let hasDirection = container.contains(.direction)
if hasPane && hasDirection {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "CmuxLayoutNode must not contain both 'pane' and 'direction' keys"
)
)
}
if hasPane {
let pane = try container.decode(CmuxPaneDefinition.self, forKey: .pane)
self = .pane(pane)
} else if hasDirection {
let splitDef = try CmuxSplitDefinition(from: decoder)
self = .split(splitDef)
} else {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "CmuxLayoutNode must contain either a 'pane' key or a 'direction' key"
)
)
}
}
func encode(to encoder: Encoder) throws {
switch self {
case .pane(let pane):
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(pane, forKey: .pane)
case .split(let split):
try split.encode(to: encoder)
}
}
}
struct CmuxSplitDefinition: Codable, Sendable {
var direction: CmuxSplitDirection
var split: Double?
var children: [CmuxLayoutNode]
init(direction: CmuxSplitDirection, split: Double? = nil, children: [CmuxLayoutNode]) {
self.direction = direction
self.split = split
self.children = children
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
direction = try container.decode(CmuxSplitDirection.self, forKey: .direction)
split = try container.decodeIfPresent(Double.self, forKey: .split)
children = try container.decode([CmuxLayoutNode].self, forKey: .children)
if children.count != 2 {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Split node requires exactly 2 children, got \(children.count)"
)
)
}
}
var clampedSplitPosition: Double {
let value = split ?? 0.5
return min(0.9, max(0.1, value))
}
var splitOrientation: SplitOrientation {
switch direction {
case .horizontal: return .horizontal
case .vertical: return .vertical
}
}
}
enum CmuxSplitDirection: String, Codable, Sendable {
case horizontal
case vertical
}
struct CmuxPaneDefinition: Codable, Sendable {
var surfaces: [CmuxSurfaceDefinition]
init(surfaces: [CmuxSurfaceDefinition]) {
self.surfaces = surfaces
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
surfaces = try container.decode([CmuxSurfaceDefinition].self, forKey: .surfaces)
if surfaces.isEmpty {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Pane node must contain at least one surface"
)
)
}
}
}
struct CmuxSurfaceDefinition: Codable, Sendable {
var type: CmuxSurfaceType
var name: String?
var command: String?
var cwd: String?
var env: [String: String]?
var url: String?
var focus: Bool?
}
enum CmuxSurfaceType: String, Codable, Sendable {
case terminal
case browser
}
@MainActor
final class CmuxConfigStore: ObservableObject {
@Published private(set) var loadedCommands: [CmuxCommandDefinition] = []
@Published private(set) var configRevision: UInt64 = 0
/// Which config file each command came from, keyed by command id.
private(set) var commandSourcePaths: [String: String] = [:]
private(set) var localConfigPath: String?
let globalConfigPath: String = {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return (home as NSString).appendingPathComponent(".config/cmux/cmux.json")
}()
private var cancellables = Set<AnyCancellable>()
private var localFileWatchSource: DispatchSourceFileSystemObject?
private var localFileDescriptor: Int32 = -1
private var globalFileWatchSource: DispatchSourceFileSystemObject?
private var globalFileDescriptor: Int32 = -1
private let watchQueue = DispatchQueue(label: "com.cmux.config-file-watch")
private static let maxReattachAttempts = 5
private static let reattachDelay: TimeInterval = 0.5
init() {
startGlobalFileWatcher()
}
deinit {
localFileWatchSource?.cancel()
globalFileWatchSource?.cancel()
}
// MARK: - Public API
func wireDirectoryTracking(tabManager: TabManager) {
cancellables.removeAll()
tabManager.$selectedTabId
.compactMap { [weak tabManager] tabId -> Workspace? in
guard let tabId, let tabManager else { return nil }
return tabManager.tabs.first(where: { $0.id == tabId })
}
.removeDuplicates(by: { $0.id == $1.id })
.map { workspace -> AnyPublisher<String, Never> in
workspace.$currentDirectory.eraseToAnyPublisher()
}
.switchToLatest()
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] directory in
self?.updateLocalConfigPath(directory)
}
.store(in: &cancellables)
if let directory = tabManager.selectedWorkspace?.currentDirectory {
updateLocalConfigPath(directory)
}
}
private func updateLocalConfigPath(_ directory: String?) {
let newPath: String?
if let directory, !directory.isEmpty {
newPath = findCmuxConfig(startingFrom: directory)
?? (directory as NSString).appendingPathComponent("cmux.json")
} else {
newPath = nil
}
guard newPath != localConfigPath else { return }
stopLocalFileWatcher()
localConfigPath = newPath
if newPath != nil {
startLocalFileWatcher()
}
loadAll()
}
private func findCmuxConfig(startingFrom directory: String) -> String? {
var current = directory
let fs = FileManager.default
while true {
let candidate = (current as NSString).appendingPathComponent("cmux.json")
if fs.fileExists(atPath: candidate) {
return candidate
}
let parent = (current as NSString).deletingLastPathComponent
if parent == current { break }
current = parent
}
return nil
}
func loadAll() {
var commands: [CmuxCommandDefinition] = []
var seenNames = Set<String>()
var sourcePaths: [String: String] = [:]
// Local config takes precedence
if let localPath = localConfigPath {
if let localConfig = parseConfig(at: localPath) {
for command in localConfig.commands {
if !seenNames.contains(command.name) {
commands.append(command)
seenNames.insert(command.name)
sourcePaths[command.id] = localPath
}
}
}
}
// Global config fills in the rest
if let globalConfig = parseConfig(at: globalConfigPath) {
for command in globalConfig.commands {
if !seenNames.contains(command.name) {
commands.append(command)
seenNames.insert(command.name)
sourcePaths[command.id] = globalConfigPath
}
}
}
loadedCommands = commands
commandSourcePaths = sourcePaths
configRevision &+= 1
}
// MARK: - Parsing
private func parseConfig(at path: String) -> CmuxConfigFile? {
guard FileManager.default.fileExists(atPath: path),
let data = FileManager.default.contents(atPath: path),
!data.isEmpty else {
return nil
}
do {
return try JSONDecoder().decode(CmuxConfigFile.self, from: data)
} catch {
NSLog("[CmuxConfig] parse error at %@: %@", path, String(describing: error))
return nil
}
}
// MARK: - File watching (local)
private func startLocalFileWatcher() {
guard let path = localConfigPath else { return }
let fd = open(path, O_EVTONLY)
guard fd >= 0 else {
// File doesn't exist yet watch the directory instead
startLocalDirectoryWatcher()
return
}
localFileDescriptor = fd
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .delete, .rename, .extend],
queue: watchQueue
)
source.setEventHandler { [weak self] in
guard let self else { return }
let flags = source.data
if flags.contains(.delete) || flags.contains(.rename) {
DispatchQueue.main.async {
self.stopLocalFileWatcher()
self.loadAll()
self.scheduleLocalReattach(attempt: 1)
}
} else {
DispatchQueue.main.async {
self.loadAll()
}
}
}
source.setCancelHandler {
Darwin.close(fd)
}
source.resume()
localFileWatchSource = source
}
private func startLocalDirectoryWatcher() {
guard let path = localConfigPath else { return }
let dirPath = (path as NSString).deletingLastPathComponent
let fd = open(dirPath, O_EVTONLY)
guard fd >= 0 else { return }
localFileDescriptor = fd
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .link, .rename],
queue: watchQueue
)
source.setEventHandler { [weak self] in
guard let self else { return }
DispatchQueue.main.async {
guard let configPath = self.localConfigPath,
FileManager.default.fileExists(atPath: configPath) else { return }
// File appeared switch to file-level watching
self.stopLocalFileWatcher()
self.loadAll()
self.startLocalFileWatcher()
}
}
source.setCancelHandler {
Darwin.close(fd)
}
source.resume()
localFileWatchSource = source
}
private func scheduleLocalReattach(attempt: Int) {
guard attempt <= Self.maxReattachAttempts else { return }
watchQueue.asyncAfter(deadline: .now() + Self.reattachDelay) { [weak self] in
guard let self else { return }
DispatchQueue.main.async {
guard let path = self.localConfigPath else { return }
if FileManager.default.fileExists(atPath: path) {
self.loadAll()
self.startLocalFileWatcher()
} else {
self.startLocalDirectoryWatcher()
}
}
}
}
private func stopLocalFileWatcher() {
if let source = localFileWatchSource {
source.cancel()
localFileWatchSource = nil
}
localFileDescriptor = -1
}
// MARK: - File watching (global)
private func startGlobalFileWatcher() {
let fd = open(globalConfigPath, O_EVTONLY)
guard fd >= 0 else {
startGlobalDirectoryWatcher()
return
}
globalFileDescriptor = fd
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .delete, .rename, .extend],
queue: watchQueue
)
source.setEventHandler { [weak self] in
guard let self else { return }
let flags = source.data
if flags.contains(.delete) || flags.contains(.rename) {
DispatchQueue.main.async {
self.stopGlobalFileWatcher()
self.loadAll()
self.scheduleGlobalReattach(attempt: 1)
}
} else {
DispatchQueue.main.async {
self.loadAll()
}
}
}
source.setCancelHandler {
Darwin.close(fd)
}
source.resume()
globalFileWatchSource = source
}
private func scheduleGlobalReattach(attempt: Int) {
guard attempt <= Self.maxReattachAttempts else {
startGlobalDirectoryWatcher()
return
}
watchQueue.asyncAfter(deadline: .now() + Self.reattachDelay) { [weak self] in
guard let self else { return }
DispatchQueue.main.async {
if FileManager.default.fileExists(atPath: self.globalConfigPath) {
self.loadAll()
self.startGlobalFileWatcher()
} else {
self.scheduleGlobalReattach(attempt: attempt + 1)
}
}
}
}
private func startGlobalDirectoryWatcher() {
let dirPath = (globalConfigPath as NSString).deletingLastPathComponent
let fm = FileManager.default
if !fm.fileExists(atPath: dirPath) {
try? fm.createDirectory(atPath: dirPath, withIntermediateDirectories: true)
}
let fd = open(dirPath, O_EVTONLY)
guard fd >= 0 else { return }
globalFileDescriptor = fd
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .link, .rename],
queue: watchQueue
)
source.setEventHandler { [weak self] in
guard let self else { return }
DispatchQueue.main.async {
guard FileManager.default.fileExists(atPath: self.globalConfigPath) else { return }
self.stopGlobalFileWatcher()
self.loadAll()
self.startGlobalFileWatcher()
}
}
source.setCancelHandler {
Darwin.close(fd)
}
source.resume()
globalFileWatchSource = source
}
private func stopGlobalFileWatcher() {
if let source = globalFileWatchSource {
source.cancel()
globalFileWatchSource = nil
}
globalFileDescriptor = -1
}
}
extension CmuxConfigStore {
static func resolveCwd(_ cwd: String?, relativeTo baseCwd: String) -> String {
guard let cwd, !cwd.isEmpty, cwd != "." else {
return baseCwd
}
if cwd.hasPrefix("~/") || cwd == "~" {
let home = FileManager.default.homeDirectoryForCurrentUser.path
if cwd == "~" { return home }
return (home as NSString).appendingPathComponent(String(cwd.dropFirst(2)))
}
if cwd.hasPrefix("/") {
return cwd
}
return (baseCwd as NSString).appendingPathComponent(cwd)
}
}