* Skip quit confirmation for tagged DEV builds Tagged DEV builds are ephemeral dev iterations, so the "Quit cmux?" dialog just adds friction. Check SocketControlSettings.launchTag() in both applicationShouldTerminate and handleQuitShortcutWarning to bypass the confirmation when a tag is present. Closes https://github.com/manaflow-ai/cmux/issues/2286 * Use bundle ID instead of env var for tagged DEV detection CMUX_TAG env var is only set when reload.sh --launch opens the app. When the user cmd-clicks the app path, it launches via Finder without the env var, so launchTag() returns nil and the quit dialog still shows. Switch to checking the bundle identifier (com.cmuxterm.app.debug.<tag>) which is baked into the built app and available regardless of how it was launched. Add SocketControlSettings.isTaggedDevBuild() helper. --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
680 lines
25 KiB
Swift
680 lines
25 KiB
Swift
import Darwin
|
|
import Foundation
|
|
#if canImport(Security)
|
|
import Security
|
|
#endif
|
|
|
|
enum SocketControlMode: String, CaseIterable, Identifiable {
|
|
case off
|
|
case cmuxOnly
|
|
case automation
|
|
case password
|
|
/// Full open access (all local users/processes) with no ancestry or password gate.
|
|
case allowAll
|
|
|
|
var id: String { rawValue }
|
|
|
|
static var uiCases: [SocketControlMode] { [.off, .cmuxOnly, .automation, .password, .allowAll] }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .off:
|
|
return String(localized: "socketControl.off.name", defaultValue: "Off")
|
|
case .cmuxOnly:
|
|
return String(localized: "socketControl.cmuxOnly.name", defaultValue: "cmux processes only")
|
|
case .automation:
|
|
return String(localized: "socketControl.automation.name", defaultValue: "Automation mode")
|
|
case .password:
|
|
return String(localized: "socketControl.password.name", defaultValue: "Password mode")
|
|
case .allowAll:
|
|
return String(localized: "socketControl.allowAll.name", defaultValue: "Full open access")
|
|
}
|
|
}
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .off:
|
|
return String(localized: "socketControl.off.description", defaultValue: "Disable the local control socket.")
|
|
case .cmuxOnly:
|
|
return String(localized: "socketControl.cmuxOnly.description", defaultValue: "Only processes started inside cmux terminals can send commands.")
|
|
case .automation:
|
|
return String(localized: "socketControl.automation.description", defaultValue: "Allow external local automation clients from this macOS user (no ancestry check).")
|
|
case .password:
|
|
return String(localized: "socketControl.password.description", defaultValue: "Require socket authentication with a password stored in a local file.")
|
|
case .allowAll:
|
|
return String(localized: "socketControl.allowAll.description", defaultValue: "Allow any local process and user to connect with no auth. Unsafe.")
|
|
}
|
|
}
|
|
|
|
var socketFilePermissions: UInt16 {
|
|
switch self {
|
|
case .allowAll:
|
|
return 0o666
|
|
case .off, .cmuxOnly, .automation, .password:
|
|
return 0o600
|
|
}
|
|
}
|
|
|
|
var requiresPasswordAuth: Bool {
|
|
self == .password
|
|
}
|
|
}
|
|
|
|
enum SocketControlPasswordStore {
|
|
static let directoryName = "cmux"
|
|
static let fileName = "socket-control-password"
|
|
private static let keychainMigrationDefaultsKey = "socketControlPasswordMigrationVersion"
|
|
private static let keychainMigrationVersion = 1
|
|
private static let legacyKeychainService = "com.cmuxterm.app.socket-control"
|
|
private static let legacyKeychainAccount = "local-socket-password"
|
|
private struct LazyKeychainFallbackCache {
|
|
var hasLoaded = false
|
|
var password: String?
|
|
}
|
|
private static let lazyKeychainFallbackLock = NSLock()
|
|
private static var lazyKeychainFallbackCache = LazyKeychainFallbackCache()
|
|
|
|
static func configuredPassword(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
fileURL: URL? = nil,
|
|
allowLazyKeychainFallback: Bool = false,
|
|
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
|
|
) -> String? {
|
|
if let envPassword = normalized(environment[SocketControlSettings.socketPasswordEnvKey]) {
|
|
return envPassword
|
|
}
|
|
let filePassword: String?
|
|
do {
|
|
filePassword = try loadPassword(fileURL: fileURL)
|
|
} catch {
|
|
filePassword = nil
|
|
}
|
|
if let filePassword {
|
|
return filePassword
|
|
}
|
|
guard allowLazyKeychainFallback else {
|
|
return nil
|
|
}
|
|
return cachedLazyKeychainFallbackPassword(loadKeychainPassword: loadKeychainPassword)
|
|
}
|
|
|
|
static func hasConfiguredPassword(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
fileURL: URL? = nil,
|
|
allowLazyKeychainFallback: Bool = false,
|
|
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
|
|
) -> Bool {
|
|
guard let configured = configuredPassword(
|
|
environment: environment,
|
|
fileURL: fileURL,
|
|
allowLazyKeychainFallback: allowLazyKeychainFallback,
|
|
loadKeychainPassword: loadKeychainPassword
|
|
) else { return false }
|
|
return !configured.isEmpty
|
|
}
|
|
|
|
static func verify(
|
|
password candidate: String,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
fileURL: URL? = nil,
|
|
allowLazyKeychainFallback: Bool = false,
|
|
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
|
|
) -> Bool {
|
|
guard let expected = configuredPassword(
|
|
environment: environment,
|
|
fileURL: fileURL,
|
|
allowLazyKeychainFallback: allowLazyKeychainFallback,
|
|
loadKeychainPassword: loadKeychainPassword
|
|
), !expected.isEmpty else {
|
|
return false
|
|
}
|
|
return expected == candidate
|
|
}
|
|
|
|
static func migrateLegacyKeychainPasswordIfNeeded(
|
|
defaults: UserDefaults = .standard,
|
|
fileURL: URL? = nil,
|
|
loadLegacyPassword: () -> String? = { loadLegacyPasswordFromKeychain() },
|
|
deleteLegacyPassword: () -> Bool = { deleteLegacyPasswordFromKeychain() }
|
|
) {
|
|
guard defaults.integer(forKey: keychainMigrationDefaultsKey) < keychainMigrationVersion else {
|
|
return
|
|
}
|
|
|
|
guard let legacyPassword = normalized(loadLegacyPassword()) else {
|
|
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
|
|
return
|
|
}
|
|
|
|
do {
|
|
if try loadPassword(fileURL: fileURL) == nil {
|
|
try savePassword(legacyPassword, fileURL: fileURL)
|
|
}
|
|
guard deleteLegacyPassword() else {
|
|
return
|
|
}
|
|
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
|
|
} catch {
|
|
// Leave migration unset so it retries on next launch.
|
|
}
|
|
}
|
|
|
|
static func loadPassword(fileURL: URL? = nil) throws -> String? {
|
|
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
|
|
return nil
|
|
}
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
return nil
|
|
}
|
|
let data = try Data(contentsOf: fileURL)
|
|
guard let password = String(data: data, encoding: .utf8) else {
|
|
return nil
|
|
}
|
|
return normalized(password)
|
|
}
|
|
|
|
static func savePassword(_ password: String, fileURL: URL? = nil) throws {
|
|
let normalized = password.trimmingCharacters(in: .newlines)
|
|
if normalized.isEmpty {
|
|
try clearPassword(fileURL: fileURL)
|
|
return
|
|
}
|
|
|
|
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
|
|
throw NSError(
|
|
domain: NSCocoaErrorDomain,
|
|
code: NSFileNoSuchFileError,
|
|
userInfo: [NSLocalizedDescriptionKey: String(localized: "socketControl.error.passwordFilePath", defaultValue: "Unable to resolve socket password file path.")]
|
|
)
|
|
}
|
|
let directory = fileURL.deletingLastPathComponent()
|
|
try FileManager.default.createDirectory(
|
|
at: directory,
|
|
withIntermediateDirectories: true,
|
|
attributes: [.posixPermissions: 0o700]
|
|
)
|
|
let data = Data(normalized.utf8)
|
|
try data.write(to: fileURL, options: .atomic)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
|
|
}
|
|
|
|
static func clearPassword(fileURL: URL? = nil) throws {
|
|
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
|
|
return
|
|
}
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
return
|
|
}
|
|
try FileManager.default.removeItem(at: fileURL)
|
|
}
|
|
|
|
static func resetLazyKeychainFallbackCacheForTests() {
|
|
lazyKeychainFallbackLock.lock()
|
|
lazyKeychainFallbackCache = LazyKeychainFallbackCache()
|
|
lazyKeychainFallbackLock.unlock()
|
|
}
|
|
|
|
static func defaultPasswordFileURL(
|
|
appSupportDirectory: URL? = nil,
|
|
fileManager: FileManager = .default
|
|
) -> URL? {
|
|
let resolvedAppSupport: URL
|
|
if let appSupportDirectory {
|
|
resolvedAppSupport = appSupportDirectory
|
|
} else if let discovered = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
|
resolvedAppSupport = discovered
|
|
} else {
|
|
return nil
|
|
}
|
|
return resolvedAppSupport
|
|
.appendingPathComponent(directoryName, isDirectory: true)
|
|
.appendingPathComponent(fileName, isDirectory: false)
|
|
}
|
|
|
|
private static func loadLegacyPasswordFromKeychain() -> String? {
|
|
#if canImport(Security)
|
|
let query: [CFString: Any] = [
|
|
kSecClass: kSecClassGenericPassword,
|
|
kSecAttrService: legacyKeychainService,
|
|
kSecAttrAccount: legacyKeychainAccount,
|
|
kSecReturnData: true,
|
|
kSecMatchLimit: kSecMatchLimitOne,
|
|
]
|
|
|
|
var result: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
guard status == errSecSuccess, let data = result as? Data else {
|
|
return nil
|
|
}
|
|
return String(data: data, encoding: .utf8)
|
|
#else
|
|
return nil
|
|
#endif
|
|
}
|
|
|
|
private static func deleteLegacyPasswordFromKeychain() -> Bool {
|
|
#if canImport(Security)
|
|
let query: [CFString: Any] = [
|
|
kSecClass: kSecClassGenericPassword,
|
|
kSecAttrService: legacyKeychainService,
|
|
kSecAttrAccount: legacyKeychainAccount,
|
|
]
|
|
let status = SecItemDelete(query as CFDictionary)
|
|
return status == errSecSuccess || status == errSecItemNotFound
|
|
#else
|
|
return false
|
|
#endif
|
|
}
|
|
|
|
private static func cachedLazyKeychainFallbackPassword(
|
|
loadKeychainPassword: () -> String?
|
|
) -> String? {
|
|
lazyKeychainFallbackLock.lock()
|
|
defer { lazyKeychainFallbackLock.unlock() }
|
|
if lazyKeychainFallbackCache.hasLoaded {
|
|
return lazyKeychainFallbackCache.password
|
|
}
|
|
lazyKeychainFallbackCache.hasLoaded = true
|
|
lazyKeychainFallbackCache.password = normalized(loadKeychainPassword())
|
|
return lazyKeychainFallbackCache.password
|
|
}
|
|
|
|
private static func normalized(_ value: String?) -> String? {
|
|
guard let value else { return nil }
|
|
let trimmed = value.trimmingCharacters(in: .newlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
}
|
|
|
|
struct SocketControlSettings {
|
|
static let appStorageKey = "socketControlMode"
|
|
static let legacyEnabledKey = "socketControlEnabled"
|
|
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
|
|
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
|
|
static let launchTagEnvKey = "CMUX_TAG"
|
|
static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug"
|
|
private static let socketDirectoryName = "cmux"
|
|
private static let stableSocketFileName = "cmux.sock"
|
|
private static let lastSocketPathFileName = "last-socket-path"
|
|
static let legacyStableDefaultSocketPath = "/tmp/cmux.sock"
|
|
static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path"
|
|
|
|
static var stableDefaultSocketPath: String {
|
|
stableSocketFileURL()?.path ?? legacyStableDefaultSocketPath
|
|
}
|
|
|
|
static var lastSocketPathFile: String {
|
|
lastSocketPathFileURL()?.path ?? legacyLastSocketPathFile
|
|
}
|
|
|
|
enum StableDefaultSocketPathEntry: Equatable {
|
|
case missing
|
|
case socket(ownerUserID: uid_t)
|
|
case other(ownerUserID: uid_t)
|
|
case inaccessible(errnoCode: Int32)
|
|
}
|
|
|
|
private static func normalizeMode(_ raw: String) -> String {
|
|
raw
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
.replacingOccurrences(of: "_", with: "")
|
|
.replacingOccurrences(of: "-", with: "")
|
|
}
|
|
|
|
private static func parseMode(_ raw: String) -> SocketControlMode? {
|
|
switch normalizeMode(raw) {
|
|
case "off":
|
|
return .off
|
|
case "cmuxonly":
|
|
return .cmuxOnly
|
|
case "automation":
|
|
return .automation
|
|
case "password":
|
|
return .password
|
|
case "allowall", "openaccess", "fullopenaccess":
|
|
return .allowAll
|
|
// Legacy values from the old socket mode model.
|
|
case "notifications":
|
|
return .automation
|
|
case "full":
|
|
return .allowAll
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Map persisted values to the current enum values.
|
|
static func migrateMode(_ raw: String) -> SocketControlMode {
|
|
parseMode(raw) ?? defaultMode
|
|
}
|
|
|
|
static var defaultMode: SocketControlMode {
|
|
return .cmuxOnly
|
|
}
|
|
|
|
private static var isDebugBuild: Bool {
|
|
#if DEBUG
|
|
true
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
|
|
static func launchTag(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
|
) -> String? {
|
|
guard let raw = environment[launchTagEnvKey] else { return nil }
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
static func shouldBlockUntaggedDebugLaunch(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
|
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
|
|
) -> Bool {
|
|
guard isDebugBuild else { return false }
|
|
if isRunningUnderXCTest(environment: environment) {
|
|
return false
|
|
}
|
|
// XCUITest launches the app as a separate process without XCTest env vars,
|
|
// so isRunningUnderXCTest() misses it. Check for any CMUX_UI_TEST_ env var.
|
|
if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) {
|
|
return false
|
|
}
|
|
|
|
guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!bundleIdentifier.isEmpty else {
|
|
return false
|
|
}
|
|
|
|
if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") {
|
|
return false
|
|
}
|
|
|
|
guard bundleIdentifier == baseDebugBundleIdentifier else {
|
|
return false
|
|
}
|
|
|
|
return launchTag(environment: environment) == nil
|
|
}
|
|
|
|
static func isRunningUnderXCTest(environment: [String: String]) -> Bool {
|
|
let indicators = [
|
|
"XCTestConfigurationFilePath",
|
|
"XCTestBundlePath",
|
|
"XCTestSessionIdentifier",
|
|
"XCInjectBundle",
|
|
"XCInjectBundleInto",
|
|
]
|
|
if indicators.contains(where: { key in
|
|
guard let value = environment[key] else { return false }
|
|
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}) {
|
|
return true
|
|
}
|
|
if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
static func socketPath(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
|
isDebugBuild: Bool = SocketControlSettings.isDebugBuild,
|
|
currentUserID: uid_t = getuid(),
|
|
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
|
|
) -> String {
|
|
let fallback = defaultSocketPath(
|
|
bundleIdentifier: bundleIdentifier,
|
|
isDebugBuild: isDebugBuild,
|
|
currentUserID: currentUserID,
|
|
probeStableDefaultPathEntry: probeStableDefaultPathEntry
|
|
)
|
|
|
|
if let taggedDebugPath = taggedDebugSocketPath(
|
|
bundleIdentifier: bundleIdentifier,
|
|
environment: environment
|
|
) {
|
|
if isTruthy(environment[allowSocketPathOverrideKey]),
|
|
let override = environment["CMUX_SOCKET_PATH"],
|
|
!override.isEmpty {
|
|
return override
|
|
}
|
|
return taggedDebugPath
|
|
}
|
|
|
|
guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else {
|
|
return fallback
|
|
}
|
|
|
|
if shouldHonorSocketPathOverride(
|
|
environment: environment,
|
|
bundleIdentifier: bundleIdentifier,
|
|
isDebugBuild: isDebugBuild
|
|
) {
|
|
return override
|
|
}
|
|
|
|
return fallback
|
|
}
|
|
|
|
static func defaultSocketPath(
|
|
bundleIdentifier: String?,
|
|
isDebugBuild: Bool,
|
|
currentUserID: uid_t = getuid(),
|
|
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
|
|
) -> String {
|
|
if let taggedDebugPath = taggedDebugSocketPath(bundleIdentifier: bundleIdentifier, environment: [:]) {
|
|
return taggedDebugPath
|
|
}
|
|
if bundleIdentifier == "com.cmuxterm.app.nightly" {
|
|
return "/tmp/cmux-nightly.sock"
|
|
}
|
|
if isDebugLikeBundleIdentifier(bundleIdentifier) || isDebugBuild {
|
|
return "/tmp/cmux-debug.sock"
|
|
}
|
|
if isStagingBundleIdentifier(bundleIdentifier) {
|
|
return "/tmp/cmux-staging.sock"
|
|
}
|
|
return resolvedStableDefaultSocketPath(
|
|
currentUserID: currentUserID,
|
|
probeStableDefaultPathEntry: probeStableDefaultPathEntry
|
|
)
|
|
}
|
|
|
|
static func userScopedStableSocketPath(currentUserID: uid_t = getuid()) -> String {
|
|
stableSocketDirectoryURL()?
|
|
.appendingPathComponent("cmux-\(currentUserID).sock", isDirectory: false)
|
|
.path ?? "/tmp/cmux-\(currentUserID).sock"
|
|
}
|
|
|
|
static func resolvedStableDefaultSocketPath(
|
|
currentUserID: uid_t = getuid(),
|
|
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
|
|
) -> String {
|
|
switch probeStableDefaultPathEntry(stableDefaultSocketPath) {
|
|
case .missing:
|
|
return stableDefaultSocketPath
|
|
case .socket(let ownerUserID) where ownerUserID == currentUserID:
|
|
return stableDefaultSocketPath
|
|
case .socket, .other, .inaccessible:
|
|
return userScopedStableSocketPath(currentUserID: currentUserID)
|
|
}
|
|
}
|
|
|
|
static func recordLastSocketPath(_ path: String, filePath: String = lastSocketPathFile) {
|
|
let payload = Data((path + "\n").utf8)
|
|
writeSocketPathMarker(payload, to: filePath)
|
|
if filePath != legacyLastSocketPathFile {
|
|
writeSocketPathMarker(payload, to: legacyLastSocketPathFile)
|
|
}
|
|
}
|
|
|
|
static func shouldHonorSocketPathOverride(
|
|
environment: [String: String],
|
|
bundleIdentifier: String?,
|
|
isDebugBuild: Bool
|
|
) -> Bool {
|
|
if isTruthy(environment[allowSocketPathOverrideKey]) {
|
|
return true
|
|
}
|
|
if isDebugLikeBundleIdentifier(bundleIdentifier) || isStagingBundleIdentifier(bundleIdentifier) {
|
|
return true
|
|
}
|
|
return isDebugBuild
|
|
}
|
|
|
|
static func isDebugLikeBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
|
|
guard let bundleIdentifier else { return false }
|
|
return bundleIdentifier == "com.cmuxterm.app.debug"
|
|
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.")
|
|
}
|
|
|
|
/// Tagged DEV builds have bundle IDs like `com.cmuxterm.app.debug.<tag>`.
|
|
static func isTaggedDevBuild(bundleIdentifier: String? = Bundle.main.bundleIdentifier) -> Bool {
|
|
guard let bundleIdentifier else { return false }
|
|
return bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).")
|
|
}
|
|
|
|
static func taggedDebugSocketPath(
|
|
bundleIdentifier: String?,
|
|
environment: [String: String]
|
|
) -> String? {
|
|
let bundleId = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if bundleId.hasPrefix("\(baseDebugBundleIdentifier).") {
|
|
let suffix = String(bundleId.dropFirst(baseDebugBundleIdentifier.count + 1))
|
|
let slug = suffix
|
|
.replacingOccurrences(of: ".", with: "-")
|
|
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
|
if !slug.isEmpty {
|
|
return "/tmp/cmux-debug-\(slug).sock"
|
|
}
|
|
}
|
|
|
|
let tag = launchTag(environment: environment)?
|
|
.lowercased()
|
|
.replacingOccurrences(of: ".", with: "-")
|
|
.replacingOccurrences(of: "_", with: "-")
|
|
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: "-")
|
|
|
|
guard bundleId == baseDebugBundleIdentifier,
|
|
let tag,
|
|
!tag.isEmpty else {
|
|
return nil
|
|
}
|
|
return "/tmp/cmux-debug-\(tag).sock"
|
|
}
|
|
|
|
static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
|
|
guard let bundleIdentifier else { return false }
|
|
return bundleIdentifier == "com.cmuxterm.app.staging"
|
|
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.")
|
|
}
|
|
|
|
static func stableSocketDirectoryURL(fileManager: FileManager = .default) -> URL? {
|
|
guard let appSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
|
return nil
|
|
}
|
|
return appSupportDirectory.appendingPathComponent(socketDirectoryName, isDirectory: true)
|
|
}
|
|
|
|
static func stableSocketFileURL(fileManager: FileManager = .default) -> URL? {
|
|
stableSocketDirectoryURL(fileManager: fileManager)?
|
|
.appendingPathComponent(stableSocketFileName, isDirectory: false)
|
|
}
|
|
|
|
static func lastSocketPathFileURL(fileManager: FileManager = .default) -> URL? {
|
|
stableSocketDirectoryURL(fileManager: fileManager)?
|
|
.appendingPathComponent(lastSocketPathFileName, isDirectory: false)
|
|
}
|
|
|
|
private static func writeSocketPathMarker(_ payload: Data, to filePath: String) {
|
|
let fileURL = URL(fileURLWithPath: filePath)
|
|
let parentURL = fileURL.deletingLastPathComponent()
|
|
try? FileManager.default.createDirectory(
|
|
at: parentURL,
|
|
withIntermediateDirectories: true,
|
|
attributes: [.posixPermissions: 0o700]
|
|
)
|
|
try? payload.write(to: fileURL, options: .atomic)
|
|
}
|
|
|
|
private static func inspectStableDefaultSocketPathEntry(_ path: String) -> StableDefaultSocketPathEntry {
|
|
var st = stat()
|
|
guard lstat(path, &st) == 0 else {
|
|
let errnoCode = errno
|
|
if errnoCode == ENOENT {
|
|
return .missing
|
|
}
|
|
return .inaccessible(errnoCode: errnoCode)
|
|
}
|
|
|
|
let fileType = st.st_mode & mode_t(S_IFMT)
|
|
if fileType == mode_t(S_IFSOCK) {
|
|
return .socket(ownerUserID: st.st_uid)
|
|
}
|
|
return .other(ownerUserID: st.st_uid)
|
|
}
|
|
|
|
static func isTruthy(_ raw: String?) -> Bool {
|
|
guard let raw else { return false }
|
|
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
static func envOverrideEnabled(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
|
) -> Bool? {
|
|
guard let raw = environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
static func envOverrideMode(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
|
) -> SocketControlMode? {
|
|
guard let raw = environment["CMUX_SOCKET_MODE"], !raw.isEmpty else {
|
|
return nil
|
|
}
|
|
return parseMode(raw)
|
|
}
|
|
|
|
static func effectiveMode(
|
|
userMode: SocketControlMode,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
|
) -> SocketControlMode {
|
|
if let overrideEnabled = envOverrideEnabled(environment: environment) {
|
|
if !overrideEnabled {
|
|
return .off
|
|
}
|
|
if let overrideMode = envOverrideMode(environment: environment) {
|
|
return overrideMode
|
|
}
|
|
return userMode == .off ? .cmuxOnly : userMode
|
|
}
|
|
|
|
if let overrideMode = envOverrideMode(environment: environment) {
|
|
return overrideMode
|
|
}
|
|
|
|
return userMode
|
|
}
|
|
}
|