diff --git a/CLI/CMUXCLI+ThemeSupport.swift b/CLI/CMUXCLI+ThemeSupport.swift index 95e38ee94ee0..9d0ea2404232 100644 --- a/CLI/CMUXCLI+ThemeSupport.swift +++ b/CLI/CMUXCLI+ThemeSupport.swift @@ -99,6 +99,13 @@ extension CMUXCLI { appendIfExisting(URL(fileURLWithPath: "/Applications/Ghostty.app/Contents/Resources/ghostty/themes", isDirectory: true)) appendIfExisting(URL(fileURLWithPath: NSString(string: "~/.config/ghostty/themes").expandingTildeInPath, isDirectory: true)) + for appSupportDirectory in CmuxApplicationSupportDirectories.userDirectories(environment: processEnv) { + appendIfExisting( + appSupportDirectory + .appendingPathComponent(Self.cmuxThemeOverrideBundleIdentifier, isDirectory: true) + .appendingPathComponent("themes", isDirectory: true) + ) + } appendIfExisting( URL( fileURLWithPath: NSString( @@ -131,8 +138,18 @@ extension CMUXCLI { configURL("~/.config/ghostty/config"), configURL("~/.config/ghostty/config.ghostty"), ] + var seen = Set(urls.map { $0.standardizedFileURL.path }) - if let appSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + func append(_ url: URL) { + let standardized = url.standardizedFileURL + if seen.insert(standardized.path).inserted { + urls.append(standardized) + } + } + + for appSupportDirectory in CmuxApplicationSupportDirectories.userDirectories( + environment: ProcessInfo.processInfo.environment + ) { let ghosttyDirectory = appSupportDirectory.appendingPathComponent( "com.mitchellh.ghostty", isDirectory: true @@ -140,25 +157,21 @@ extension CMUXCLI { let legacyGhosttyConfigURL = ghosttyDirectory.appendingPathComponent("config", isDirectory: false) let currentGhosttyConfigURL = ghosttyDirectory.appendingPathComponent("config.ghostty", isDirectory: false) - urls.append(currentGhosttyConfigURL) + append(currentGhosttyConfigURL) if shouldLoadLegacyGhosttyConfig( newConfigURL: currentGhosttyConfigURL, legacyConfigURL: legacyGhosttyConfigURL, fileManager: fileManager ) { - urls.append(legacyGhosttyConfigURL) + append(legacyGhosttyConfigURL) } let cmuxDirectory = appSupportDirectory.appendingPathComponent( Self.cmuxThemeOverrideBundleIdentifier, isDirectory: true ) - urls.append(cmuxDirectory.appendingPathComponent("config", isDirectory: false)) - urls.append(cmuxDirectory.appendingPathComponent("config.ghostty", isDirectory: false)) - } else { - urls.append(configURL("~/Library/Application Support/com.mitchellh.ghostty/config.ghostty")) - urls.append(configURL("~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config")) - urls.append(configURL("~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config.ghostty")) + append(cmuxDirectory.appendingPathComponent("config", isDirectory: false)) + append(cmuxDirectory.appendingPathComponent("config.ghostty", isDirectory: false)) } return urls diff --git a/Sources/CmuxApplicationSupportDirectories.swift b/Sources/CmuxApplicationSupportDirectories.swift new file mode 100644 index 000000000000..85829a53bf17 --- /dev/null +++ b/Sources/CmuxApplicationSupportDirectories.swift @@ -0,0 +1,38 @@ +import Foundation + +enum CmuxApplicationSupportDirectories { + static func userDirectories( + environment: [String: String], + fileManager: FileManager = .default + ) -> [URL] { + var urls: [URL] = [] + var seen: Set = [] + + func append(_ url: URL?) { + guard let url else { return } + let standardized = url.standardizedFileURL + if seen.insert(standardized.path).inserted { + urls.append(standardized) + } + } + + append(fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first) + + if let fixedHome = environment["CFFIXED_USER_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !fixedHome.isEmpty { + append( + URL(fileURLWithPath: fixedHome, isDirectory: true) + .appendingPathComponent("Library/Application Support", isDirectory: true) + ) + } + + append( + URL( + fileURLWithPath: NSString(string: "~/Library/Application Support").expandingTildeInPath, + isDirectory: true + ) + ) + + return urls + } +} diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index e09c56bb3fbf..93380595922b 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -683,6 +683,15 @@ struct GhosttyConfig { // 4) Common system/user fallback locations. appendUniquePath("/Applications/Ghostty.app/Contents/Resources/ghostty/themes/\(themeName)") appendUniquePath("~/.config/ghostty/themes/\(themeName)") + for appSupportDirectory in CmuxApplicationSupportDirectories.userDirectories(environment: environment) { + appendUniquePath( + appSupportDirectory + .appendingPathComponent(CmuxGhosttyConfigPathResolver.releaseBundleIdentifier, isDirectory: true) + .appendingPathComponent("themes", isDirectory: true) + .appendingPathComponent(themeName, isDirectory: false) + .path + ) + } appendUniquePath("~/Library/Application Support/com.mitchellh.ghostty/themes/\(themeName)") return paths diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index be0c7c887814..05fe516d9a0c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -85,6 +85,8 @@ D0B10016A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */; }; A5001450A5001450A5001450 /* FilePreviewNativeBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001451A5001451A5001451 /* FilePreviewNativeBackground.swift */; }; D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */; }; + D35110010000000000000001 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; + D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000002 /* SidebarDropPlanner.swift */; }; D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; }; D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */; }; @@ -843,6 +845,7 @@ A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = ""; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; + D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxApplicationSupportDirectories.swift; sourceTree = ""; }; A5001661 /* RestorableAgentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentSession.swift; sourceTree = ""; }; C0DEF0C10000000000000002 /* AgentForkSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentForkSupport.swift; sourceTree = ""; }; C0DEF0C20000000000000002 /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = ""; }; @@ -1375,6 +1378,7 @@ C0DEF0C10000000000000002 /* AgentForkSupport.swift */, C0DEF0C20000000000000002 /* SemanticVersion.swift */, A5001641 /* RemoteRelayZshBootstrap.swift */, + D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */, A5001651 /* CmuxConfig.swift */, C10D00040000000000000004 /* CmuxSurfaceTabBarBuiltInAction.swift */, C0DEF0A10000000000000002 /* CmuxConfigUI.swift */, @@ -1937,6 +1941,7 @@ A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */, A5F10013A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift in Sources */, C0DEF0B10000000000000001 /* JSONCParser.swift in Sources */, + D35110010000000000000001 /* CmuxApplicationSupportDirectories.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, A5001003 /* TabManager.swift in Sources */, C3677003000000000000001 /* TabManager+CompatibilityTypes.swift in Sources */, @@ -2162,6 +2167,7 @@ B9000064A1B2C3D4E5F60719 /* CMUXCLI+Events.swift in Sources */, B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */, C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */, + D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */, B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 67e8f6256599..8e8abd144068 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -94,6 +94,76 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertTrue(paths.contains("\(pathB)/ghostty/themes/Solarized Light")) } + func testThemeSearchPathsIncludeCmuxUserThemesDirectory() { + let paths = GhosttyConfig.themeSearchPaths( + forThemeName: "Zag Light", + environment: [:], + bundleResourceURL: nil + ) + + XCTAssertTrue( + paths.contains( + "\(NSHomeDirectory())/Library/Application Support/com.cmuxterm.app/themes/Zag Light" + ) + ) + } + + func testThemeSearchPathsIncludeCmuxUserThemesDirectoryFromFixedHome() { + let fixedHome = "/tmp/cmux-fixed-home-\(UUID().uuidString)" + let paths = GhosttyConfig.themeSearchPaths( + forThemeName: "Zag Light", + environment: ["CFFIXED_USER_HOME": fixedHome], + bundleResourceURL: nil + ) + + XCTAssertTrue( + paths.contains( + "\(fixedHome)/Library/Application Support/com.cmuxterm.app/themes/Zag Light" + ) + ) + } + + func testThemesListIncludesCmuxUserThemesDirectory() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-user-theme-list-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let themesDirectory = root + .appendingPathComponent("Library/Application Support/com.cmuxterm.app/themes", isDirectory: true) + try fileManager.createDirectory(at: themesDirectory, withIntermediateDirectories: true) + try "background = #ffffff\nforeground = #1f2328\n".write( + to: themesDirectory.appendingPathComponent("Zag Light", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + let configURL = themesDirectory + .deletingLastPathComponent() + .appendingPathComponent("config.ghostty", isDirectory: false) + try "theme = Zag Light\n".write(to: configURL, atomically: true, encoding: .utf8) + + let result = runCLI( + try bundledCLIPath(), + arguments: ["--json", "themes", "list"], + environment: ["CFFIXED_USER_HOME": root.path], + timeout: 10 + ) + + XCTAssertFalse(result.timedOut, result.output) + XCTAssertEqual(result.status, 0, result.output) + + let payload = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.output.utf8)) as? [String: Any] + ) + let themes = try XCTUnwrap(payload["themes"] as? [[String: Any]]) + XCTAssertTrue(themes.contains { ($0["name"] as? String) == "Zag Light" }, result.output) + let current = try XCTUnwrap(payload["current"] as? [String: Any]) + XCTAssertEqual(current["light"] as? String, "Zag Light") + XCTAssertEqual(current["dark"] as? String, "Zag Light") + XCTAssertEqual(current["source_path"] as? String, configURL.path) + } + func testCmuxDefaultThemeConfigContentsSkipsInvalidUTF8Candidate() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -597,6 +667,81 @@ final class GhosttyConfigTests: XCTestCase { ) } + private struct CLIResult { + let status: Int32 + let output: String + let timedOut: Bool + } + + private func bundledCLIPath() throws -> String { + let fileManager = FileManager.default + let appBundleURL = Bundle(for: Self.self) + .bundleURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let enumerator = fileManager.enumerator( + at: appBundleURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + + while let item = enumerator?.nextObject() as? URL { + guard item.lastPathComponent == "cmux", + item.path.contains(".app/Contents/Resources/bin/cmux") else { + continue + } + return item.path + } + + throw XCTSkip("Bundled cmux CLI not found in \(appBundleURL.path)") + } + + private func runCLI( + _ cliPath: String, + arguments: [String], + environment overrides: [String: String], + timeout: TimeInterval + ) -> CLIResult { + let process = Process() + let outputPipe = Pipe() + process.executableURL = URL(fileURLWithPath: cliPath) + process.arguments = arguments + var environment = ProcessInfo.processInfo.environment + for (key, value) in overrides { + environment[key] = value + } + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = outputPipe + process.standardError = outputPipe + + do { + try process.run() + } catch { + return CLIResult(status: -1, output: String(describing: error), timedOut: false) + } + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + + let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut + if timedOut { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + } + + let output = String( + data: outputPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + return CLIResult(status: process.terminationStatus, output: output, timedOut: timedOut) + } + } final class WorkspaceChromeThemeTests: XCTestCase {