Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- Whole-codebase restructuring pass (internal, no behavior change): the remote-daemon stack moved out of `Workspace.swift`, browser data-import out of `BrowserPanel.swift`, v2 browser automation out of `TerminalController.swift`, UI-test harnesses out of `AppDelegate.swift`, and `TabManager`/`GhosttyNSView`/`ContentView` split into per-concern files — the largest source files shrank by 3,000–5,000 lines each, cutting incremental build times. The copy-pasted v1 telemetry-handler skeleton, agent-wrapper commands (Go and Swift), and boilerplate settings accessors were each collapsed onto single shared implementations.

### Fixed
- `programa.json`/`cmux.json` command configs now accept `//` and `/* */` comments and trailing commas, so a hand-edited config with a note like `// dev commands` no longer fails to load with a cryptic parse error.
- Release signing now proceeds inside-out without `--deep`, so the bundled `programa` and `ghostty` tools no longer inherit the app's camera, microphone, automation, JIT, or library-validation entitlements; the signed artifact is gated before notarization.
- Debug, Release, and Staging reload entrypoints now prepare GhosttyKit before building; Staging uses the canonical `Programa STAGING` name and `com.darkroom.programa.staging` identity.
- CI now retries only genuine SwiftPM resolution failures and always propagates XCTest failures, including deterministic failures reported as “0 unexpected.”
Expand Down
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
A5001652 /* ProgramaConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* ProgramaConfigExecutor.swift */; };
NRPA00001 /* FileWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00002 /* FileWatcher.swift */; };
A5001654 /* ProgramaDirectoryTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* ProgramaDirectoryTrust.swift */; };
A5001660 /* JSONCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* JSONCParser.swift */; };
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
B9000002A1B2C3D4E5F60719 /* programa.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* programa.swift */; };
Expand Down Expand Up @@ -425,6 +426,7 @@
A5001653 /* ProgramaConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfigExecutor.swift; sourceTree = "<group>"; };
NRPA00002 /* FileWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileWatcher.swift; sourceTree = "<group>"; };
A5001655 /* ProgramaDirectoryTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaDirectoryTrust.swift; sourceTree = "<group>"; };
A5001661 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = "<group>"; };
A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = "<group>"; };
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -751,6 +753,7 @@
A5001653 /* ProgramaConfigExecutor.swift */,
NRPA00002 /* FileWatcher.swift */,
A5001655 /* ProgramaDirectoryTrust.swift */,
A5001661 /* JSONCParser.swift */,
);
path = Sources;
sourceTree = "<group>";
Expand Down Expand Up @@ -1135,6 +1138,7 @@
A5001652 /* ProgramaConfigExecutor.swift in Sources */,
NRPA00001 /* FileWatcher.swift in Sources */,
A5001654 /* ProgramaDirectoryTrust.swift in Sources */,
A5001660 /* JSONCParser.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
215 changes: 215 additions & 0 deletions Sources/JSONCParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import Foundation

/// Preprocesses JSONC (JSON with Comments) text into plain JSON `Data` that
/// `JSONDecoder`/`JSONSerialization` can parse.
///
/// Supports the two JSONC conveniences users actually reach for in hand-edited
/// config files: `//` and `/* */` comments, and trailing commas before a
/// closing `}` or `]`. Both are stripped with a string-literal-aware scanner
/// so a comment marker or trailing comma inside a quoted string is left alone.
///
/// Shared by `ProgramaConfigStore` (`~/.config/programa/programa.json` and
/// project-local `programa.json`/`cmux.json`) and `ProgramaSettingsFileStore`
/// (`~/.config/programa/settings.json`) so both config surfaces accept the
/// same JSONC dialect.
enum JSONCParser {
static func preprocess(data: Data) throws -> Data {
let source = try sourceString(from: data)
let withoutBOM = source.hasPrefix("\u{feff}") ? String(source.dropFirst()) : source
let stripped = try stripComments(from: withoutBOM)
let normalized = stripTrailingCommas(from: stripped)
return Data(normalized.utf8)
}

private static func sourceString(from data: Data) throws -> String {
if let encoding = detectedJSONEncoding(for: data),
let source = String(data: data, encoding: encoding) {
return source
}
if let source = String(data: data, encoding: .utf8) {
return source
}

var convertedString: NSString?
var usedLossyConversion = ObjCBool(false)
let encoding = NSString.stringEncoding(
for: data,
encodingOptions: [
.suggestedEncodingsKey: [
String.Encoding.utf8.rawValue,
String.Encoding.utf16BigEndian.rawValue,
String.Encoding.utf16LittleEndian.rawValue,
String.Encoding.utf32BigEndian.rawValue,
String.Encoding.utf32LittleEndian.rawValue,
],
.useOnlySuggestedEncodingsKey: true,
.allowLossyKey: false,
],
convertedString: &convertedString,
usedLossyConversion: &usedLossyConversion
)

if let convertedString, !usedLossyConversion.boolValue {
return convertedString as String
}
if encoding != 0, !usedLossyConversion.boolValue {
let stringEncoding = String.Encoding(rawValue: encoding)
if let source = String(data: data, encoding: stringEncoding) {
return source
}
}
throw JSONCError.invalidTextEncoding
}

private static func detectedJSONEncoding(for data: Data) -> String.Encoding? {
let bytes = Array(data.prefix(4))
if bytes.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return .utf32BigEndian }
if bytes.starts(with: [0xFF, 0xFE, 0x00, 0x00]) { return .utf32LittleEndian }
if bytes.starts(with: [0xFE, 0xFF]) { return .utf16BigEndian }
if bytes.starts(with: [0xFF, 0xFE]) { return .utf16LittleEndian }
if bytes.starts(with: [0xEF, 0xBB, 0xBF]) { return .utf8 }
guard bytes.count >= 4 else { return nil }

switch (bytes[0] == 0, bytes[1] == 0, bytes[2] == 0, bytes[3] == 0) {
case (true, true, true, false):
return .utf32BigEndian
case (false, true, true, true):
return .utf32LittleEndian
case (true, false, true, false):
return .utf16BigEndian
case (false, true, false, true):
return .utf16LittleEndian
default:
return nil
}
}

private static func stripComments(from source: String) throws -> String {
var result = ""
var index = source.startIndex
var inString = false
var isEscaped = false

while index < source.endIndex {
let character = source[index]

if inString {
result.append(character)
if isEscaped {
isEscaped = false
} else if character == "\\" {
isEscaped = true
} else if character == "\"" {
inString = false
}
index = source.index(after: index)
continue
}

if character == "\"" {
inString = true
result.append(character)
index = source.index(after: index)
continue
}

if character == "/" {
let nextIndex = source.index(after: index)
if nextIndex < source.endIndex {
let next = source[nextIndex]
if next == "/" {
index = source.index(after: nextIndex)
while index < source.endIndex && source[index] != "\n" {
index = source.index(after: index)
}
continue
}
if next == "*" {
index = source.index(after: nextIndex)
var didClose = false
while index < source.endIndex {
let current = source[index]
let followingIndex = source.index(after: index)
if current == "*" && followingIndex < source.endIndex && source[followingIndex] == "/" {
index = source.index(after: followingIndex)
didClose = true
break
}
index = followingIndex
}
guard didClose else {
throw JSONCError.unterminatedBlockComment
}
continue
}
}
}

result.append(character)
index = source.index(after: index)
}

return result
}

private static func stripTrailingCommas(from source: String) -> String {
var result = ""
var index = source.startIndex
var inString = false
var isEscaped = false

while index < source.endIndex {
let character = source[index]

if inString {
result.append(character)
if isEscaped {
isEscaped = false
} else if character == "\\" {
isEscaped = true
} else if character == "\"" {
inString = false
}
index = source.index(after: index)
continue
}

if character == "\"" {
inString = true
result.append(character)
index = source.index(after: index)
continue
}

if character == "," {
var lookahead = source.index(after: index)
while lookahead < source.endIndex && source[lookahead].isWhitespace {
lookahead = source.index(after: lookahead)
}
if lookahead < source.endIndex && (source[lookahead] == "}" || source[lookahead] == "]") {
index = source.index(after: index)
continue
}
}

result.append(character)
index = source.index(after: index)
}

return result
}

enum JSONCError: LocalizedError {
case invalidTextEncoding
case unterminatedBlockComment

var errorDescription: String? {
switch self {
case .invalidTextEncoding:
return "config file text encoding is not supported"
case .unterminatedBlockComment:
return "unterminated block comment"
}
}
}
}
11 changes: 10 additions & 1 deletion Sources/ProgramaConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -401,8 +401,17 @@ final class ProgramaConfigStore: ObservableObject {
!data.isEmpty else {
return nil
}

let sanitized: Data
do {
sanitized = try JSONCParser.preprocess(data: data)
} catch {
NSLog("[ProgramaConfig] JSONC preprocessing error at %@: %@", path, String(describing: error))
return nil
}

do {
return try JSONDecoder().decode(ProgramaConfigFile.self, from: data)
return try JSONDecoder().decode(ProgramaConfigFile.self, from: sanitized)
} catch {
NSLog("[ProgramaConfig] parse error at %@: %@", path, String(describing: error))
return nil
Expand Down
Loading
Loading