* 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>
590 lines
19 KiB
Swift
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)
|
|
}
|
|
}
|