cmux/Sources/CmuxConfig.swift
Lawrence Chen 23253e6ddf
Validate workspace color during cmux.json decode (#2112)
Reject invalid color values at parse time with a clear error message
instead of silently ignoring them. Colors are normalized to #RRGGBB
via WorkspaceTabColorSettings.normalizedHex during decode.

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
2026-03-24 22:58:27 -07:00

617 lines
20 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?
init(name: String? = nil, cwd: String? = nil, color: String? = nil, layout: CmuxLayoutNode? = nil) {
self.name = name
self.cwd = cwd
self.color = color
self.layout = layout
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
cwd = try container.decodeIfPresent(String.self, forKey: .cwd)
layout = try container.decodeIfPresent(CmuxLayoutNode.self, forKey: .layout)
if let rawColor = try container.decodeIfPresent(String.self, forKey: .color) {
guard let normalized = WorkspaceTabColorSettings.normalizedHex(rawColor) else {
throw DecodingError.dataCorruptedError(
forKey: .color,
in: container,
debugDescription: "Invalid color \"\(rawColor)\". Expected 6-digit hex format: #RRGGBB"
)
}
color = normalized
} else {
color = nil
}
}
}
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)
}
}