1229 lines
45 KiB
Swift
1229 lines
45 KiB
Swift
import Foundation
|
|
#if canImport(Darwin)
|
|
import Darwin
|
|
#elseif canImport(Glibc)
|
|
import Glibc
|
|
#elseif canImport(Musl)
|
|
import Musl
|
|
#endif
|
|
#if os(macOS)
|
|
import Security
|
|
#endif
|
|
|
|
public enum PathPurpose: Hashable, Sendable {
|
|
case rpc
|
|
case tty
|
|
case nodeTooling
|
|
}
|
|
|
|
public struct PathDebugSnapshot: Equatable, Sendable {
|
|
public let codexBinary: String?
|
|
public let claudeBinary: String?
|
|
public let geminiBinary: String?
|
|
public let effectivePATH: String
|
|
public let loginShellPATH: String?
|
|
|
|
public static let empty = PathDebugSnapshot(
|
|
codexBinary: nil,
|
|
claudeBinary: nil,
|
|
geminiBinary: nil,
|
|
effectivePATH: "",
|
|
loginShellPATH: nil)
|
|
|
|
public init(
|
|
codexBinary: String?,
|
|
claudeBinary: String?,
|
|
geminiBinary: String? = nil,
|
|
effectivePATH: String,
|
|
loginShellPATH: String?)
|
|
{
|
|
self.codexBinary = codexBinary
|
|
self.claudeBinary = claudeBinary
|
|
self.geminiBinary = geminiBinary
|
|
self.effectivePATH = effectivePATH
|
|
self.loginShellPATH = loginShellPATH
|
|
}
|
|
}
|
|
|
|
public enum BinaryLocator {
|
|
/// Test-only override so parallel Gemini suites can point at fake binaries
|
|
/// without mutating process-wide `GEMINI_CLI_PATH`.
|
|
@TaskLocal public static var geminiBinaryPathOverrideForTesting: String?
|
|
|
|
public static func resolveClaudeBinary(
|
|
env: [String: String] = ProcessInfo.processInfo.environment,
|
|
loginPATH: [String]? = LoginShellPathCache.shared.current,
|
|
commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV,
|
|
aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator
|
|
.resolveAlias,
|
|
fileManager: FileManager = .default,
|
|
home: String = NSHomeDirectory()) -> String?
|
|
{
|
|
self.resolveBinary(
|
|
name: "claude",
|
|
overrideKey: "CLAUDE_CLI_PATH",
|
|
env: env,
|
|
loginPATH: loginPATH,
|
|
commandV: commandV,
|
|
aliasResolver: aliasResolver,
|
|
wellKnownPaths: self.claudeWellKnownPaths(home: home),
|
|
fileManager: fileManager,
|
|
home: home)
|
|
}
|
|
|
|
/// Well-known installation paths for the Claude CLI binary.
|
|
/// Covers Anthropic's native installer (`~/.local/bin`), the `claude migrate-installer`
|
|
/// self-updating location (`~/.claude/local`), the legacy per-user installer
|
|
/// (`~/.claude/bin`), Homebrew, and the macOS Terminal installer (cmux.app).
|
|
static func claudeWellKnownPaths(home: String) -> [String] {
|
|
[
|
|
"\(home)/.local/bin/claude",
|
|
"\(home)/.claude/local/claude",
|
|
"\(home)/.claude/bin/claude",
|
|
"/opt/homebrew/bin/claude",
|
|
"/usr/local/bin/claude",
|
|
"/Applications/cmux.app/Contents/Resources/bin/claude",
|
|
]
|
|
}
|
|
|
|
public static func resolveAntigravityBinary(
|
|
env: [String: String] = ProcessInfo.processInfo.environment,
|
|
loginPATH: [String]? = LoginShellPathCache.shared.current,
|
|
commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV,
|
|
aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator
|
|
.resolveAlias,
|
|
fileManager: FileManager = .default,
|
|
home: String = NSHomeDirectory()) -> String?
|
|
{
|
|
self.resolveBinary(
|
|
name: "agy",
|
|
overrideKey: "ANTIGRAVITY_CLI_PATH",
|
|
env: env,
|
|
loginPATH: loginPATH,
|
|
commandV: commandV,
|
|
aliasResolver: aliasResolver,
|
|
wellKnownPaths: [
|
|
"\(home)/.local/bin/agy",
|
|
"/opt/homebrew/bin/agy",
|
|
"/usr/local/bin/agy",
|
|
],
|
|
fileManager: fileManager,
|
|
home: home)
|
|
}
|
|
|
|
public static func resolveCodexBinary(
|
|
env: [String: String] = ProcessInfo.processInfo.environment,
|
|
loginPATH: [String]? = LoginShellPathCache.shared.current,
|
|
commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV,
|
|
aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator
|
|
.resolveAlias,
|
|
launchCandidateFilter: (String, FileManager) -> Bool = CodexLaunchPreflight.isLaunchCandidateAllowed,
|
|
fileManager: FileManager = .default,
|
|
home: String = NSHomeDirectory()) -> String?
|
|
{
|
|
self.resolveBinary(
|
|
name: "codex",
|
|
overrideKey: "CODEX_CLI_PATH",
|
|
env: env,
|
|
loginPATH: loginPATH,
|
|
commandV: commandV,
|
|
aliasResolver: aliasResolver,
|
|
wellKnownPaths: self.codexWellKnownPaths(home: home),
|
|
launchCandidateFilter: launchCandidateFilter,
|
|
fileManager: fileManager,
|
|
home: home)
|
|
}
|
|
|
|
/// Well-known installation paths for the signed Codex CLI bundled with current and legacy desktop apps.
|
|
/// Keep these after PATH lookups, but use them as a safe fallback when a PATH shim is blocked.
|
|
static func codexWellKnownPaths(home: String) -> [String] {
|
|
#if os(macOS)
|
|
[
|
|
"\(home)/Applications/ChatGPT.app/Contents/Resources/codex",
|
|
"\(home)/Applications/Codex.app/Contents/Resources/codex",
|
|
"/Applications/ChatGPT.app/Contents/Resources/codex",
|
|
"/Applications/Codex.app/Contents/Resources/codex",
|
|
]
|
|
#else
|
|
[]
|
|
#endif
|
|
}
|
|
|
|
public static func resolveGeminiBinary(
|
|
env: [String: String] = ProcessInfo.processInfo.environment,
|
|
loginPATH: [String]? = LoginShellPathCache.shared.current,
|
|
commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV,
|
|
aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator
|
|
.resolveAlias,
|
|
fileManager: FileManager = .default,
|
|
home: String = NSHomeDirectory()) -> String?
|
|
{
|
|
if let override = self.geminiBinaryPathOverrideForTesting,
|
|
fileManager.isExecutableFile(atPath: override)
|
|
{
|
|
return override
|
|
}
|
|
return self.resolveBinary(
|
|
name: "gemini",
|
|
overrideKey: "GEMINI_CLI_PATH",
|
|
env: env,
|
|
loginPATH: loginPATH,
|
|
commandV: commandV,
|
|
aliasResolver: aliasResolver,
|
|
fileManager: fileManager,
|
|
home: home)
|
|
}
|
|
|
|
public static func resolveGrokBinary(
|
|
env: [String: String] = ProcessInfo.processInfo.environment,
|
|
loginPATH: [String]? = LoginShellPathCache.shared.current,
|
|
commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV,
|
|
aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator
|
|
.resolveAlias,
|
|
fileManager: FileManager = .default,
|
|
home: String = NSHomeDirectory()) -> String?
|
|
{
|
|
self.resolveBinary(
|
|
name: "grok",
|
|
overrideKey: "GROK_CLI_PATH",
|
|
env: env,
|
|
loginPATH: loginPATH,
|
|
commandV: commandV,
|
|
aliasResolver: aliasResolver,
|
|
wellKnownPaths: self.grokWellKnownPaths(home: home),
|
|
fileManager: fileManager,
|
|
home: home)
|
|
}
|
|
|
|
public static func resolveAmpBinary(
|
|
env: [String: String] = ProcessInfo.processInfo.environment,
|
|
loginPATH: [String]? = LoginShellPathCache.shared.current,
|
|
commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV,
|
|
aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator
|
|
.resolveAlias,
|
|
fileManager: FileManager = .default,
|
|
home: String = NSHomeDirectory()) -> String?
|
|
{
|
|
self.resolveBinary(
|
|
name: "amp",
|
|
overrideKey: "AMP_CLI_PATH",
|
|
env: env,
|
|
loginPATH: loginPATH,
|
|
commandV: commandV,
|
|
aliasResolver: aliasResolver,
|
|
wellKnownPaths: self.ampWellKnownPaths(home: home),
|
|
fileManager: fileManager,
|
|
home: home)
|
|
}
|
|
|
|
static func ampWellKnownPaths(home: String) -> [String] {
|
|
[
|
|
"\(home)/.local/bin/amp",
|
|
"\(home)/.amp/bin/amp",
|
|
"/opt/homebrew/bin/amp",
|
|
"/usr/local/bin/amp",
|
|
]
|
|
}
|
|
|
|
/// Well-known install locations for the Grok Build CLI binary.
|
|
/// Covers the installer's default (`~/.grok/bin/grok`) and the symlinks it sometimes
|
|
/// creates into `~/.local/bin` and `/usr/local/bin`.
|
|
static func grokWellKnownPaths(home: String) -> [String] {
|
|
[
|
|
"\(home)/.grok/bin/grok",
|
|
"\(home)/.local/bin/grok",
|
|
"/usr/local/bin/grok",
|
|
"/opt/homebrew/bin/grok",
|
|
]
|
|
}
|
|
|
|
public static func resolveAWSBinary(
|
|
env: [String: String] = ProcessInfo.processInfo.environment,
|
|
loginPATH: [String]? = LoginShellPathCache.shared.current,
|
|
commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV,
|
|
aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator
|
|
.resolveAlias,
|
|
fileManager: FileManager = .default,
|
|
home: String = NSHomeDirectory()) -> String?
|
|
{
|
|
self.resolveBinary(
|
|
name: "aws",
|
|
overrideKey: "AWS_CLI_PATH",
|
|
env: env,
|
|
loginPATH: loginPATH,
|
|
commandV: commandV,
|
|
aliasResolver: aliasResolver,
|
|
wellKnownPaths: self.awsWellKnownPaths(home: home),
|
|
fileManager: fileManager,
|
|
home: home)
|
|
}
|
|
|
|
/// Well-known install locations for the AWS CLI v2 (`aws`).
|
|
/// Covers Homebrew (Apple Silicon + Intel) and the per-user pip/uv install path.
|
|
static func awsWellKnownPaths(home: String) -> [String] {
|
|
[
|
|
"/opt/homebrew/bin/aws",
|
|
"/usr/local/bin/aws",
|
|
"\(home)/.local/bin/aws",
|
|
]
|
|
}
|
|
|
|
public static func resolveAuggieBinary(
|
|
env: [String: String] = ProcessInfo.processInfo.environment,
|
|
loginPATH: [String]? = LoginShellPathCache.shared.current,
|
|
commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV,
|
|
aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator
|
|
.resolveAlias,
|
|
fileManager: FileManager = .default,
|
|
home: String = NSHomeDirectory()) -> String?
|
|
{
|
|
self.resolveBinary(
|
|
name: "auggie",
|
|
overrideKey: "AUGGIE_CLI_PATH",
|
|
env: env,
|
|
loginPATH: loginPATH,
|
|
commandV: commandV,
|
|
aliasResolver: aliasResolver,
|
|
fileManager: fileManager,
|
|
home: home)
|
|
}
|
|
|
|
// swiftlint:disable function_parameter_count
|
|
private static func resolveBinary(
|
|
name: String,
|
|
overrideKey: String,
|
|
env: [String: String],
|
|
loginPATH: [String]?,
|
|
commandV: (String, String?, TimeInterval, FileManager) -> String?,
|
|
aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String?,
|
|
wellKnownPaths: [String] = [],
|
|
launchCandidateFilter: (String, FileManager) -> Bool = { _, _ in true },
|
|
fileManager: FileManager,
|
|
home: String) -> String?
|
|
{
|
|
// swiftlint:enable function_parameter_count
|
|
// 1) Explicit override
|
|
if let override = env[overrideKey], fileManager.isExecutableFile(atPath: override) {
|
|
return override
|
|
}
|
|
|
|
// 2) Login-shell PATH (captured once per launch)
|
|
if let loginPATH,
|
|
let pathHit = self.find(
|
|
name,
|
|
in: loginPATH,
|
|
fileManager: fileManager,
|
|
launchCandidateFilter: launchCandidateFilter)
|
|
{
|
|
return pathHit
|
|
}
|
|
|
|
// 3) Existing PATH
|
|
if let existingPATH = env["PATH"],
|
|
let pathHit = self.find(
|
|
name,
|
|
in: existingPATH.split(separator: ":").map(String.init),
|
|
fileManager: fileManager,
|
|
launchCandidateFilter: launchCandidateFilter)
|
|
{
|
|
return pathHit
|
|
}
|
|
|
|
// 4) Well-known installation paths (e.g. Homebrew, cmux.app bundle, ~/.claude/bin).
|
|
// Prefer these before shell probing to avoid running interactive shell init for common installs.
|
|
for candidate in wellKnownPaths
|
|
where fileManager.isExecutableFile(atPath: candidate) && launchCandidateFilter(candidate, fileManager)
|
|
{
|
|
return candidate
|
|
}
|
|
|
|
// 5) Interactive login shell lookup (captures nvm/fnm/mise paths from .zshrc/.bashrc)
|
|
if let shellHit = commandV(name, env["SHELL"], 2.0, fileManager),
|
|
fileManager.isExecutableFile(atPath: shellHit),
|
|
launchCandidateFilter(shellHit, fileManager)
|
|
{
|
|
return shellHit
|
|
}
|
|
|
|
// 5b) Alias fallback (login shell); only attempt after all standard lookups fail.
|
|
if let aliasHit = aliasResolver(name, env["SHELL"], 2.0, fileManager, home),
|
|
fileManager.isExecutableFile(atPath: aliasHit),
|
|
launchCandidateFilter(aliasHit, fileManager)
|
|
{
|
|
return aliasHit
|
|
}
|
|
|
|
// 6) Minimal fallback
|
|
let fallback = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]
|
|
if let pathHit = self.find(
|
|
name,
|
|
in: fallback,
|
|
fileManager: fileManager,
|
|
launchCandidateFilter: launchCandidateFilter)
|
|
{
|
|
return pathHit
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func find(
|
|
_ binary: String,
|
|
in paths: [String],
|
|
fileManager: FileManager,
|
|
launchCandidateFilter: (String, FileManager) -> Bool = { _, _ in true }) -> String?
|
|
{
|
|
for path in paths where !path.isEmpty {
|
|
let candidate = "\(path.hasSuffix("/") ? String(path.dropLast()) : path)/\(binary)"
|
|
if fileManager.isExecutableFile(atPath: candidate), launchCandidateFilter(candidate, fileManager) {
|
|
return candidate
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public enum CodexLaunchPreflight {
|
|
struct GatekeeperAssessment {
|
|
let output: String
|
|
let exitStatus: Int32
|
|
}
|
|
|
|
public static func isLaunchCandidateAllowed(path: String, fileManager: FileManager = .default) -> Bool {
|
|
#if os(macOS)
|
|
self.isLaunchCandidateAllowed(
|
|
path: path,
|
|
fileManager: fileManager,
|
|
hasExtendedAttribute: self.hasExtendedAttribute,
|
|
spctlAssessment: { self.spctlAssessment(path: $0) },
|
|
appSignatureIsTrusted: self.isExpectedOpenAIAppSignature,
|
|
isMachOExecutable: self.isMachOExecutable)
|
|
#else
|
|
_ = path
|
|
_ = fileManager
|
|
return true
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS)
|
|
// Keep each security boundary injectable so preflight tests never inspect or launch host binaries.
|
|
// swiftlint:disable:next function_parameter_count
|
|
static func isLaunchCandidateAllowed(
|
|
path: String,
|
|
fileManager: FileManager,
|
|
hasExtendedAttribute: (String, String) -> Bool,
|
|
spctlAssessment: (String) -> GatekeeperAssessment?,
|
|
appSignatureIsTrusted: (String) -> Bool,
|
|
isMachOExecutable: (String) -> Bool) -> Bool
|
|
{
|
|
let realPath = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
|
|
let sourceAppBundlePath = self.containingAppBundlePath(for: path)
|
|
let resolvedAppBundlePath = self.containingAppBundlePath(for: realPath)
|
|
let appBundlePath: String?
|
|
if let sourceAppBundlePath {
|
|
let resolvedSourceBundle = URL(fileURLWithPath: sourceAppBundlePath).resolvingSymlinksInPath().path
|
|
guard resolvedAppBundlePath == resolvedSourceBundle else { return false }
|
|
appBundlePath = resolvedSourceBundle
|
|
} else {
|
|
appBundlePath = resolvedAppBundlePath
|
|
}
|
|
let appBundlePaths = [sourceAppBundlePath, appBundlePath].compactMap(\.self)
|
|
let pathsToCheck = [path, realPath] + appBundlePaths + self
|
|
.nativeCodexExecutableCandidates(
|
|
for: realPath,
|
|
fileManager: fileManager)
|
|
|
|
for candidate in Set(pathsToCheck) where hasExtendedAttribute(candidate, "com.apple.malware") {
|
|
return false
|
|
}
|
|
|
|
let hasQuarantine = Set(pathsToCheck).contains { hasExtendedAttribute($0, "com.apple.quarantine") }
|
|
if let appBundlePath {
|
|
guard appSignatureIsTrusted(appBundlePath),
|
|
let assessment = spctlAssessment(appBundlePath),
|
|
assessment.exitStatus == 0,
|
|
self.isAcceptedAssessment(assessment.output, path: appBundlePath),
|
|
!self.isExplicitlyBlockedAssessment(assessment.output, path: appBundlePath)
|
|
else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
guard let native = pathsToCheck.first(where: isMachOExecutable) else {
|
|
return !hasQuarantine
|
|
}
|
|
|
|
guard let assessment = spctlAssessment(native)
|
|
else {
|
|
return !hasQuarantine
|
|
}
|
|
|
|
return !self.isExplicitlyBlockedAssessment(assessment.output, path: native)
|
|
}
|
|
|
|
private static func containingAppBundlePath(for path: String) -> String? {
|
|
var candidate = URL(fileURLWithPath: path).standardizedFileURL
|
|
while candidate.path != "/" {
|
|
if candidate.pathExtension.caseInsensitiveCompare("app") == .orderedSame {
|
|
return candidate.path
|
|
}
|
|
let parent = candidate.deletingLastPathComponent()
|
|
guard parent.path != candidate.path else { return nil }
|
|
candidate = parent
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func nativeCodexExecutableCandidates(for path: String, fileManager: FileManager) -> [String] {
|
|
let url = URL(fileURLWithPath: path)
|
|
guard url.lastPathComponent == "codex.js" else { return [] }
|
|
|
|
let packageRoot = url.deletingLastPathComponent().deletingLastPathComponent()
|
|
return self.npmNativeCodexCandidates(packageRoot: packageRoot)
|
|
.map(\.path)
|
|
.filter { fileManager.isExecutableFile(atPath: $0) }
|
|
}
|
|
|
|
private static func npmNativeCodexCandidates(packageRoot: URL) -> [URL] {
|
|
guard let target = self.darwinCodexTarget else { return [] }
|
|
let optionalPackage = packageRoot
|
|
.appendingPathComponent("node_modules")
|
|
.appendingPathComponent("@openai")
|
|
.appendingPathComponent(target.packageName)
|
|
|
|
return [
|
|
optionalPackage,
|
|
packageRoot,
|
|
].map {
|
|
$0.appendingPathComponent("vendor")
|
|
.appendingPathComponent(target.triple)
|
|
.appendingPathComponent("codex")
|
|
.appendingPathComponent("codex")
|
|
}
|
|
}
|
|
|
|
private static var darwinCodexTarget: (packageName: String, triple: String)? {
|
|
#if arch(arm64)
|
|
("codex-darwin-arm64", "aarch64-apple-darwin")
|
|
#elseif arch(x86_64)
|
|
("codex-darwin-x64", "x86_64-apple-darwin")
|
|
#else
|
|
nil
|
|
#endif
|
|
}
|
|
|
|
private static func hasExtendedAttribute(path: String, name: String) -> Bool {
|
|
path.withCString { pathPointer in
|
|
name.withCString { namePointer in
|
|
getxattr(pathPointer, namePointer, nil, 0, 0, 0) >= 0
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func isMachOExecutable(path: String) -> Bool {
|
|
guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { return false }
|
|
defer { try? handle.close() }
|
|
|
|
guard let data = try? handle.read(upToCount: 4), data.count == 4 else { return false }
|
|
let bytes = [UInt8](data)
|
|
return bytes == [0xFE, 0xED, 0xFA, 0xCE] ||
|
|
bytes == [0xCE, 0xFA, 0xED, 0xFE] ||
|
|
bytes == [0xFE, 0xED, 0xFA, 0xCF] ||
|
|
bytes == [0xCF, 0xFA, 0xED, 0xFE] ||
|
|
bytes == [0xCA, 0xFE, 0xBA, 0xBE] ||
|
|
bytes == [0xCA, 0xFE, 0xBA, 0xBF]
|
|
}
|
|
|
|
private static func spctlAssessment(path: String, timeout: TimeInterval = 2.0) -> GatekeeperAssessment? {
|
|
let spctlPath = "/usr/sbin/spctl"
|
|
guard FileManager.default.isExecutableFile(atPath: spctlPath) else { return nil }
|
|
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: spctlPath)
|
|
process.arguments = ["--assess", "--type", "execute", "--verbose=4", path]
|
|
|
|
let output = Pipe()
|
|
process.standardOutput = output
|
|
process.standardError = output
|
|
|
|
let finished = DispatchSemaphore(value: 0)
|
|
process.terminationHandler = { _ in finished.signal() }
|
|
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
return nil
|
|
}
|
|
|
|
if finished.wait(timeout: .now() + timeout) != .success {
|
|
process.terminate()
|
|
return nil
|
|
}
|
|
|
|
let data = output.fileHandleForReading.readDataToEndOfFile()
|
|
guard let text = String(data: data, encoding: .utf8) else { return nil }
|
|
return GatekeeperAssessment(output: text, exitStatus: process.terminationStatus)
|
|
}
|
|
|
|
private static func isExpectedOpenAIAppSignature(path: String) -> Bool {
|
|
let requirementText =
|
|
"identifier \"com.openai.codex\" and anchor apple generic " +
|
|
"and certificate leaf[subject.OU] = \"2DC432GLL2\""
|
|
var staticCode: SecStaticCode?
|
|
guard SecStaticCodeCreateWithPath(
|
|
URL(fileURLWithPath: path) as CFURL,
|
|
SecCSFlags(),
|
|
&staticCode) == errSecSuccess,
|
|
let staticCode
|
|
else {
|
|
return false
|
|
}
|
|
|
|
var requirement: SecRequirement?
|
|
guard SecRequirementCreateWithString(
|
|
requirementText as CFString,
|
|
SecCSFlags(),
|
|
&requirement) == errSecSuccess
|
|
else {
|
|
return false
|
|
}
|
|
|
|
// Pin the publisher and bundle identity here; the Gatekeeper assessment below performs full bundle validation.
|
|
let validationFlags = SecCSFlags(rawValue: kSecCSBasicValidateOnly)
|
|
return SecStaticCodeCheckValidity(staticCode, validationFlags, requirement) == errSecSuccess
|
|
}
|
|
|
|
private static func isAcceptedAssessment(_ assessment: String, path: String) -> Bool {
|
|
self.assessmentDiagnosticText(assessment, path: path)
|
|
.split(whereSeparator: \.isNewline)
|
|
.first?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.localizedCaseInsensitiveCompare("accepted") == .orderedSame
|
|
}
|
|
|
|
private static func isExplicitlyBlockedAssessment(_ assessment: String, path: String) -> Bool {
|
|
let lower = self.assessmentDiagnosticText(assessment, path: path).lowercased()
|
|
if lower.contains("denied") ||
|
|
lower.contains("cssmerr_tp_cert_revoked") ||
|
|
lower.contains("revoked") ||
|
|
lower.contains("malware") ||
|
|
lower.contains("quarantine")
|
|
{
|
|
return true
|
|
}
|
|
if lower.contains("rejected") {
|
|
return !lower.contains("code is valid but does not seem to be an app")
|
|
}
|
|
return false
|
|
}
|
|
|
|
private static func assessmentDiagnosticText(_ assessment: String, path: String) -> String {
|
|
assessment
|
|
.split(whereSeparator: \.isNewline)
|
|
.enumerated()
|
|
.compactMap { offset, line -> String? in
|
|
var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if offset == 0, text.hasPrefix("\(path):") {
|
|
text = String(text.dropFirst(path.count + 1))
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
let lower = text.lowercased()
|
|
guard !lower.hasPrefix("source="), !lower.hasPrefix("origin=") else {
|
|
return nil
|
|
}
|
|
return text
|
|
}
|
|
.joined(separator: "\n")
|
|
}
|
|
#endif
|
|
}
|
|
|
|
public enum ShellCommandLocator {
|
|
#if canImport(Darwin)
|
|
private static let shellSpawnFlags = Int16(POSIX_SPAWN_SETSID)
|
|
#else
|
|
private static let shellSpawnFlags: Int16 = 0x80 // glibc/musl POSIX_SPAWN_SETSID.
|
|
#endif
|
|
|
|
static func test_runShellCommand(
|
|
shell: String,
|
|
arguments: [String],
|
|
timeout: TimeInterval) -> Data?
|
|
{
|
|
self.runShellCommand(shell: shell, arguments: arguments, timeout: timeout)
|
|
}
|
|
|
|
static var test_shellSpawnFlags: Int16 {
|
|
self.shellSpawnFlags
|
|
}
|
|
|
|
public static func commandV(
|
|
_ tool: String,
|
|
_ shell: String?,
|
|
_ timeout: TimeInterval,
|
|
_ fileManager: FileManager) -> String?
|
|
{
|
|
let text = self.runShellCapture(shell, timeout, "command -v \(tool)")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let text, !text.isEmpty else { return nil }
|
|
|
|
let lines = text.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
for line in lines.reversed() where line.hasPrefix("/") {
|
|
let path = line
|
|
if fileManager.isExecutableFile(atPath: path) {
|
|
return path
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
public static func resolveAlias(
|
|
_ tool: String,
|
|
_ shell: String?,
|
|
_ timeout: TimeInterval,
|
|
_ fileManager: FileManager,
|
|
_ home: String) -> String?
|
|
{
|
|
let command = "alias \(tool) 2>/dev/null; type -a \(tool) 2>/dev/null"
|
|
guard let text = self.runShellCapture(shell, timeout, command) else { return nil }
|
|
let lines = text.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
|
|
if let aliasPath = self.parseAliasPath(lines, tool: tool, home: home, fileManager: fileManager) {
|
|
return aliasPath
|
|
}
|
|
|
|
for line in lines {
|
|
if let path = self.extractPathCandidate(line: line, tool: tool, home: home),
|
|
fileManager.isExecutableFile(atPath: path)
|
|
{
|
|
return path
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Thread-safe buffer for collecting pipe output from a readability handler.
|
|
private final class CapturedData: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var data = Data()
|
|
|
|
func append(_ other: Data) {
|
|
self.lock.lock()
|
|
self.data.append(other)
|
|
self.lock.unlock()
|
|
}
|
|
|
|
func drain() -> Data {
|
|
self.lock.lock()
|
|
let result = self.data
|
|
self.lock.unlock()
|
|
return result
|
|
}
|
|
}
|
|
|
|
/// Idempotent one-shot flag — `fire()` returns true exactly once.
|
|
/// Used to make `DispatchGroup.leave()` safe to attempt from multiple paths.
|
|
private final class OnceFlag: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var fired = false
|
|
|
|
func fire() -> Bool {
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
if self.fired {
|
|
return false
|
|
}
|
|
self.fired = true
|
|
return true
|
|
}
|
|
}
|
|
|
|
// swiftlint:disable cyclomatic_complexity function_body_length
|
|
/// Runs a shell command, draining both stdout and stderr concurrently so that
|
|
/// verbose shell init scripts (oh-my-zsh, nvm, pyenv, etc.) cannot deadlock on
|
|
/// a full pipe buffer. The child is launched via `posix_spawn` with
|
|
/// `POSIX_SPAWN_SETSID` so it cannot take ownership of the caller's controlling
|
|
/// terminal. The new session also makes the child its own process-group leader
|
|
/// before `exec`, which guarantees that subsequent `kill(-pgid, ...)` calls reach
|
|
/// background helpers spawned by shell init, both after timeout and normal exit.
|
|
fileprivate static func runShellCommand(
|
|
shell: String,
|
|
arguments: [String],
|
|
timeout: TimeInterval) -> Data?
|
|
{
|
|
// Pipes for stdout/stderr. stdin is redirected from /dev/null in the child
|
|
// via posix_spawn_file_actions_addopen below.
|
|
var stdoutFds: (read: Int32, write: Int32) = (-1, -1)
|
|
var stderrFds: (read: Int32, write: Int32) = (-1, -1)
|
|
guard withUnsafeMutablePointer(to: &stdoutFds, {
|
|
$0.withMemoryRebound(to: Int32.self, capacity: 2) { pipe($0) == 0 }
|
|
}) else { return nil }
|
|
guard withUnsafeMutablePointer(to: &stderrFds, {
|
|
$0.withMemoryRebound(to: Int32.self, capacity: 2) { pipe($0) == 0 }
|
|
}) else {
|
|
close(stdoutFds.read); close(stdoutFds.write)
|
|
return nil
|
|
}
|
|
|
|
// Build file actions: redirect stdin from /dev/null, dup pipe write ends to
|
|
// fds 1 and 2, and close every pipe fd in the child. The init pattern
|
|
// differs between platforms because the typedef is an opaque pointer on
|
|
// Darwin and a struct on Linux C modules.
|
|
#if canImport(Darwin)
|
|
var fileActions: posix_spawn_file_actions_t?
|
|
#else
|
|
var fileActions = posix_spawn_file_actions_t()
|
|
#endif
|
|
guard posix_spawn_file_actions_init(&fileActions) == 0 else {
|
|
close(stdoutFds.read); close(stdoutFds.write)
|
|
close(stderrFds.read); close(stderrFds.write)
|
|
return nil
|
|
}
|
|
defer { posix_spawn_file_actions_destroy(&fileActions) }
|
|
posix_spawn_file_actions_addopen(&fileActions, 0, "/dev/null", O_RDONLY, 0)
|
|
posix_spawn_file_actions_adddup2(&fileActions, stdoutFds.write, 1)
|
|
posix_spawn_file_actions_adddup2(&fileActions, stderrFds.write, 2)
|
|
posix_spawn_file_actions_addclose(&fileActions, stdoutFds.read)
|
|
posix_spawn_file_actions_addclose(&fileActions, stdoutFds.write)
|
|
posix_spawn_file_actions_addclose(&fileActions, stderrFds.read)
|
|
posix_spawn_file_actions_addclose(&fileActions, stderrFds.write)
|
|
|
|
// Build attributes: detach the child into a new session before exec. This
|
|
// prevents interactive shell startup from changing the caller's foreground
|
|
// process group while retaining a stable process group for cleanup.
|
|
#if canImport(Darwin)
|
|
var attr: posix_spawnattr_t?
|
|
#else
|
|
var attr = posix_spawnattr_t()
|
|
#endif
|
|
guard posix_spawnattr_init(&attr) == 0 else {
|
|
close(stdoutFds.read); close(stdoutFds.write)
|
|
close(stderrFds.read); close(stderrFds.write)
|
|
return nil
|
|
}
|
|
defer { posix_spawnattr_destroy(&attr) }
|
|
guard posix_spawnattr_setflags(&attr, self.shellSpawnFlags) == 0 else {
|
|
close(stdoutFds.read); close(stdoutFds.write)
|
|
close(stderrFds.read); close(stderrFds.write)
|
|
return nil
|
|
}
|
|
|
|
// Build argv (argv[0] is conventionally the executable path).
|
|
var cArgs: [UnsafeMutablePointer<CChar>?] = []
|
|
cArgs.append(strdup(shell))
|
|
for arg in arguments {
|
|
cArgs.append(strdup(arg))
|
|
}
|
|
cArgs.append(nil)
|
|
defer {
|
|
for p in cArgs {
|
|
if let p {
|
|
free(p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inherit the parent environment. Build a NULL-terminated `KEY=VALUE`
|
|
// array since `extern char **environ` isn't directly visible from Swift.
|
|
var cEnv: [UnsafeMutablePointer<CChar>?] = []
|
|
for (key, value) in ProcessInfo.processInfo.environment {
|
|
cEnv.append(strdup("\(key)=\(value)"))
|
|
}
|
|
cEnv.append(nil)
|
|
defer {
|
|
for p in cEnv {
|
|
if let p {
|
|
free(p)
|
|
}
|
|
}
|
|
}
|
|
|
|
var pid: pid_t = 0
|
|
let spawnResult = shell.withCString { execPath in
|
|
posix_spawn(&pid, execPath, &fileActions, &attr, cArgs, cEnv)
|
|
}
|
|
|
|
// Close the write ends in the parent so EOF will arrive on the read ends
|
|
// once every descendant in the process group also closes them.
|
|
close(stdoutFds.write)
|
|
close(stderrFds.write)
|
|
|
|
guard spawnResult == 0 else {
|
|
close(stdoutFds.read); close(stderrFds.read)
|
|
return nil
|
|
}
|
|
|
|
// POSIX_SPAWN_SETSID guarantees the child's session ID and pgid equal its pid.
|
|
let pgid: pid_t = pid
|
|
|
|
// Track EOF on each pipe so we can wait for full drain instead of sleeping.
|
|
// The readability handler fires with empty data when every writer end is
|
|
// closed (i.e. the child *and* any inheriting background helpers are gone).
|
|
let drainGroup = DispatchGroup()
|
|
drainGroup.enter()
|
|
drainGroup.enter()
|
|
let stdoutDone = OnceFlag()
|
|
let stderrDone = OnceFlag()
|
|
|
|
let stdoutCollector = CapturedData()
|
|
let stdoutHandle = FileHandle(fileDescriptor: stdoutFds.read, closeOnDealloc: true)
|
|
stdoutHandle.readabilityHandler = { handle in
|
|
let data = handle.availableData
|
|
if data.isEmpty {
|
|
handle.readabilityHandler = nil
|
|
if stdoutDone.fire() {
|
|
drainGroup.leave()
|
|
}
|
|
} else {
|
|
stdoutCollector.append(data)
|
|
}
|
|
}
|
|
|
|
let stderrHandle = FileHandle(fileDescriptor: stderrFds.read, closeOnDealloc: true)
|
|
stderrHandle.readabilityHandler = { handle in
|
|
let data = handle.availableData
|
|
if data.isEmpty {
|
|
handle.readabilityHandler = nil
|
|
if stderrDone.fire() {
|
|
drainGroup.leave()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reap the child on a background queue and signal a semaphore on exit.
|
|
let exitSemaphore = DispatchSemaphore(value: 0)
|
|
let waitPid = pid
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
var status: Int32 = 0
|
|
while waitpid(waitPid, &status, 0) == -1, errno == EINTR {
|
|
// retry
|
|
}
|
|
exitSemaphore.signal()
|
|
}
|
|
|
|
let finishedInTime = exitSemaphore.wait(timeout: .now() + timeout) == .success
|
|
|
|
if !finishedInTime {
|
|
kill(-pgid, SIGTERM)
|
|
kill(pid, SIGTERM)
|
|
if exitSemaphore.wait(timeout: .now() + 0.4) != .success {
|
|
kill(-pgid, SIGKILL)
|
|
kill(pid, SIGKILL)
|
|
_ = exitSemaphore.wait(timeout: .now() + 1.0)
|
|
}
|
|
stdoutHandle.readabilityHandler = nil
|
|
stderrHandle.readabilityHandler = nil
|
|
if stdoutDone.fire() {
|
|
drainGroup.leave()
|
|
}
|
|
if stderrDone.fire() {
|
|
drainGroup.leave()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Normal completion — clean up any background children spawned by shell init.
|
|
// Without this, helpers that inherited stdout/stderr keep the pipe write ends
|
|
// open and we never see EOF on the read ends.
|
|
kill(-pgid, SIGTERM)
|
|
|
|
// Wait for both pipes to deliver EOF so no buffered bytes are lost.
|
|
// Bounded so a stuck handler can't hang the caller indefinitely.
|
|
if drainGroup.wait(timeout: .now() + 0.4) != .success {
|
|
kill(-pgid, SIGKILL)
|
|
}
|
|
if drainGroup.wait(timeout: .now() + 0.6) != .success {
|
|
stdoutHandle.readabilityHandler = nil
|
|
stderrHandle.readabilityHandler = nil
|
|
if stdoutDone.fire() {
|
|
drainGroup.leave()
|
|
}
|
|
if stderrDone.fire() {
|
|
drainGroup.leave()
|
|
}
|
|
}
|
|
return stdoutCollector.drain()
|
|
}
|
|
|
|
// swiftlint:enable cyclomatic_complexity function_body_length
|
|
|
|
private static func runShellCapture(_ shell: String?, _ timeout: TimeInterval, _ command: String) -> String? {
|
|
let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh"
|
|
let isCI = ["1", "true"].contains(ProcessInfo.processInfo.environment["CI"]?.lowercased())
|
|
// Interactive login shell to pick up PATH mutations from shell init (nvm/fnm/mise).
|
|
// CI runners can have shell init hooks that emit missing CLI errors; avoid them in CI.
|
|
let args = isCI ? ["-c", command] : ["-l", "-i", "-c", command]
|
|
guard let data = runShellCommand(shell: shellPath, arguments: args, timeout: timeout) else {
|
|
return nil
|
|
}
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
private static func parseAliasPath(
|
|
_ lines: [String],
|
|
tool: String,
|
|
home: String,
|
|
fileManager: FileManager) -> String?
|
|
{
|
|
for line in lines {
|
|
if line.hasPrefix("alias \(tool)=") {
|
|
let value = line.replacingOccurrences(of: "alias \(tool)=", with: "")
|
|
if let path = self.extractAliasExpansion(value, home: home),
|
|
fileManager.isExecutableFile(atPath: path)
|
|
{
|
|
return path
|
|
}
|
|
}
|
|
if line.lowercased().contains("aliased to") {
|
|
if let range = line.range(of: "aliased to") {
|
|
let value = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let path = self.extractAliasExpansion(String(value), home: home),
|
|
fileManager.isExecutableFile(atPath: path)
|
|
{
|
|
return path
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func extractAliasExpansion(_ raw: String, home: String) -> String? {
|
|
let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: " \t\"'`"))
|
|
guard !trimmed.isEmpty else { return nil }
|
|
let parts = trimmed.split(separator: " ").map(String.init)
|
|
guard let first = parts.first else { return nil }
|
|
return self.expandPath(first, home: home)
|
|
}
|
|
|
|
private static func extractPathCandidate(line: String, tool: String, home: String) -> String? {
|
|
let tokens = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
|
for token in tokens {
|
|
let candidate = self.expandPath(token, home: home)
|
|
if candidate.hasPrefix("/"),
|
|
URL(fileURLWithPath: candidate).lastPathComponent == tool
|
|
{
|
|
return candidate
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func expandPath(_ raw: String, home: String) -> String {
|
|
if raw == "~" {
|
|
return home
|
|
}
|
|
if raw.hasPrefix("~/") {
|
|
return home + String(raw.dropFirst())
|
|
}
|
|
return raw
|
|
}
|
|
}
|
|
|
|
public enum PathBuilder {
|
|
public static func effectivePATH(
|
|
purposes _: Set<PathPurpose>,
|
|
env: [String: String] = ProcessInfo.processInfo.environment,
|
|
loginPATH: [String]? = LoginShellPathCache.shared.current,
|
|
home _: String = NSHomeDirectory()) -> String
|
|
{
|
|
var parts: [String] = []
|
|
|
|
if let loginPATH, !loginPATH.isEmpty {
|
|
parts.append(contentsOf: loginPATH)
|
|
}
|
|
|
|
if let existing = env["PATH"], !existing.isEmpty {
|
|
parts.append(contentsOf: existing.split(separator: ":").map(String.init))
|
|
}
|
|
|
|
if parts.isEmpty {
|
|
parts.append(contentsOf: ["/usr/bin", "/bin", "/usr/sbin", "/sbin"])
|
|
}
|
|
|
|
var seen = Set<String>()
|
|
let deduped = parts.compactMap { part -> String? in
|
|
guard !part.isEmpty else { return nil }
|
|
if seen.insert(part).inserted {
|
|
return part
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return deduped.joined(separator: ":")
|
|
}
|
|
|
|
public static func debugSnapshot(
|
|
purposes: Set<PathPurpose>,
|
|
env: [String: String] = ProcessInfo.processInfo.environment,
|
|
home: String = NSHomeDirectory()) -> PathDebugSnapshot
|
|
{
|
|
let login = LoginShellPathCache.shared.current
|
|
let effective = self.effectivePATH(
|
|
purposes: purposes,
|
|
env: env,
|
|
loginPATH: login,
|
|
home: home)
|
|
let codex = BinaryLocator.resolveCodexBinary(env: env, loginPATH: login, home: home)
|
|
let claude = BinaryLocator.resolveClaudeBinary(env: env, loginPATH: login, home: home)
|
|
let gemini = BinaryLocator.resolveGeminiBinary(env: env, loginPATH: login, home: home)
|
|
let loginString = login?.joined(separator: ":")
|
|
return PathDebugSnapshot(
|
|
codexBinary: codex,
|
|
claudeBinary: claude,
|
|
geminiBinary: gemini,
|
|
effectivePATH: effective,
|
|
loginShellPATH: loginString)
|
|
}
|
|
|
|
public static func debugSnapshotAsync(
|
|
purposes: Set<PathPurpose>,
|
|
env: [String: String] = ProcessInfo.processInfo.environment,
|
|
home: String = NSHomeDirectory()) async -> PathDebugSnapshot
|
|
{
|
|
await Task.detached(priority: .userInitiated) {
|
|
self.debugSnapshot(purposes: purposes, env: env, home: home)
|
|
}.value
|
|
}
|
|
}
|
|
|
|
enum LoginShellPathCapturer {
|
|
static let defaultTimeout: TimeInterval = 6.0
|
|
|
|
static func capture(
|
|
shell: String? = ProcessInfo.processInfo.environment["SHELL"],
|
|
timeout: TimeInterval = Self.defaultTimeout) -> [String]?
|
|
{
|
|
let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh"
|
|
let isCI = ["1", "true"].contains(ProcessInfo.processInfo.environment["CI"]?.lowercased())
|
|
let marker = "__CODEXBAR_PATH__"
|
|
// Skip interactive login shells in CI to avoid noisy init hooks.
|
|
let args = isCI
|
|
? ["-c", "printf '\(marker)%s\(marker)' \"$PATH\""]
|
|
: ["-l", "-i", "-c", "printf '\(marker)%s\(marker)' \"$PATH\""]
|
|
guard let data = ShellCommandLocator.runShellCommand(
|
|
shell: shellPath,
|
|
arguments: args,
|
|
timeout: timeout),
|
|
let raw = String(data: data, encoding: .utf8),
|
|
!raw.isEmpty
|
|
else { return nil }
|
|
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let extracted = if let start = trimmed.range(of: marker),
|
|
let end = trimmed.range(of: marker, options: .backwards),
|
|
start.upperBound <= end.lowerBound
|
|
{
|
|
String(trimmed[start.upperBound..<end.lowerBound])
|
|
} else {
|
|
trimmed
|
|
}
|
|
|
|
let value = extracted.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !value.isEmpty else { return nil }
|
|
return value.split(separator: ":").map(String.init)
|
|
}
|
|
}
|
|
|
|
public final class LoginShellPathCache: @unchecked Sendable {
|
|
public static let shared = LoginShellPathCache()
|
|
|
|
private let lock = NSLock()
|
|
private let capture: @Sendable (String?, TimeInterval) -> [String]?
|
|
private var captured: [String]?
|
|
private var isCapturing = false
|
|
private var callbacks: [([String]?) -> Void] = []
|
|
|
|
init(capture: @escaping @Sendable (String?, TimeInterval) -> [String]? = LoginShellPathCapturer.capture) {
|
|
self.capture = capture
|
|
}
|
|
|
|
public var current: [String]? {
|
|
self.lock.lock()
|
|
let value = self.captured
|
|
self.lock.unlock()
|
|
return value
|
|
}
|
|
|
|
public func captureOnce(
|
|
shell: String? = ProcessInfo.processInfo.environment["SHELL"],
|
|
timeout: TimeInterval = 6.0,
|
|
onFinish: (([String]?) -> Void)? = nil)
|
|
{
|
|
self.lock.lock()
|
|
if let captured {
|
|
self.lock.unlock()
|
|
onFinish?(captured)
|
|
return
|
|
}
|
|
|
|
if let onFinish {
|
|
self.callbacks.append(onFinish)
|
|
}
|
|
|
|
if self.isCapturing {
|
|
self.lock.unlock()
|
|
return
|
|
}
|
|
|
|
self.isCapturing = true
|
|
self.lock.unlock()
|
|
|
|
let capture = self.capture
|
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
let result = capture(shell, timeout)
|
|
guard let self else { return }
|
|
|
|
self.lock.lock()
|
|
self.captured = result
|
|
self.isCapturing = false
|
|
let callbacks = self.callbacks
|
|
self.callbacks.removeAll()
|
|
self.lock.unlock()
|
|
|
|
callbacks.forEach { $0(result) }
|
|
}
|
|
}
|
|
|
|
public func currentOrCapture(
|
|
shell: String? = ProcessInfo.processInfo.environment["SHELL"],
|
|
timeout: TimeInterval = 6.0) -> [String]?
|
|
{
|
|
self.lock.lock()
|
|
if let captured {
|
|
self.lock.unlock()
|
|
return captured
|
|
}
|
|
|
|
if self.isCapturing {
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
var callbackResult: [String]?
|
|
self.callbacks.append { result in
|
|
callbackResult = result
|
|
semaphore.signal()
|
|
}
|
|
self.lock.unlock()
|
|
let deadline = DispatchTime.now() + timeout
|
|
_ = semaphore.wait(timeout: deadline)
|
|
return callbackResult ?? self.current
|
|
}
|
|
|
|
self.isCapturing = true
|
|
self.lock.unlock()
|
|
|
|
let result = self.capture(shell, timeout)
|
|
self.lock.lock()
|
|
self.captured = result
|
|
self.isCapturing = false
|
|
let callbacks = self.callbacks
|
|
self.callbacks.removeAll()
|
|
self.lock.unlock()
|
|
|
|
callbacks.forEach { $0(result) }
|
|
return result
|
|
}
|
|
}
|