mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-09 22:36:01 +05:45
315 lines
11 KiB
Swift
315 lines
11 KiB
Swift
/*
|
|
* Copyright 2026 Google LLC
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import Dispatch
|
|
import Foundation
|
|
import Logging
|
|
|
|
/// Utility for launching and capturing output from child shell processes.
|
|
public enum ProcessRunner {
|
|
private static let logger = Logger(label: "zip-builder.process")
|
|
|
|
/// Characters that can appear unquoted in a POSIX shell token without triggering word splitting,
|
|
/// variable expansion, globbing, or redirection.
|
|
///
|
|
/// Built from `CharacterSet.alphanumerics` (`a-z`, `A-Z`, `0-9`, and Unicode letters/digits)
|
|
/// unioned with the specific shell-inert punctuation and symbol characters commonly used in
|
|
/// CLI flags, file paths, key-value build settings, and Xcode destinations (`-_./:=@%+,`).
|
|
private static let shellSafeCharacters = CharacterSet.alphanumerics
|
|
.union(CharacterSet(charactersIn: "-_./:=@%+,"))
|
|
|
|
/// The outcome of an executed process including exit code, standard output, and standard error.
|
|
public struct ProcessResult: Sendable {
|
|
/// The termination exit status of the process (0 indicates success).
|
|
public let exitCode: Int32
|
|
/// UTF-8 decoded standard output stream captured from the process.
|
|
public let standardOutput: String
|
|
/// UTF-8 decoded standard error stream captured from the process.
|
|
public let standardError: String
|
|
|
|
/// Returns `true` if the process terminated normally with exit code 0.
|
|
public var isSuccess: Bool {
|
|
exitCode == 0
|
|
}
|
|
}
|
|
|
|
/// Formats an executable, arguments, environment overrides, and working directory into a
|
|
/// copy-pasteable shell command string.
|
|
///
|
|
/// - Parameters:
|
|
/// - executable: Executable binary or tool name.
|
|
/// - arguments: Command line arguments passed to the executable.
|
|
/// - workingDirectory: Optional directory to execute the command within.
|
|
/// - environment: Optional environment variable overrides.
|
|
/// - Returns: A fully quoted, copy-pasteable command line string.
|
|
public static func formatCommandLine(executable: String,
|
|
arguments: [String] = [],
|
|
workingDirectory: URL? = nil,
|
|
environment: [String: String] = [:]) -> String {
|
|
func shellEscape(_ str: String) -> String {
|
|
guard !str.isEmpty else {
|
|
return "''"
|
|
}
|
|
|
|
if str.unicodeScalars.allSatisfy(shellSafeCharacters.contains) {
|
|
return str
|
|
}
|
|
|
|
let escaped = str.replacingOccurrences(of: "'", with: "'\\''")
|
|
return "'\(escaped)'"
|
|
}
|
|
|
|
var components: [String] = []
|
|
|
|
for (k, v) in environment.sorted(by: { $0.key < $1.key }) {
|
|
components.append("\(k)=\(shellEscape(v))")
|
|
}
|
|
|
|
components.append(shellEscape(executable))
|
|
components.append(contentsOf: arguments.map(shellEscape))
|
|
|
|
let command = components.joined(separator: " ")
|
|
if let workingDirectory {
|
|
return "cd \(shellEscape(workingDirectory.path)) && \(command)"
|
|
}
|
|
|
|
return command
|
|
}
|
|
|
|
/// Executes an external command synchronously via `/usr/bin/env` and captures its streams.
|
|
///
|
|
/// - Parameters:
|
|
/// - executable: Executable binary or tool name (e.g. `xcodebuild`, `ditto`, `codesign`).
|
|
/// - arguments: Command line arguments passed to the executable.
|
|
/// - workingDirectory: Optional directory to execute the command within.
|
|
/// - environment: Optional environment variable overrides.
|
|
/// - logOutput: When `true`, streams output directly to the logger.
|
|
/// - Returns: `ProcessResult` containing termination status and output.
|
|
/// - Throws: An error if the process fails to launch.
|
|
@discardableResult
|
|
public static func run(_ executable: String,
|
|
arguments: [String] = [],
|
|
workingDirectory: URL? = nil,
|
|
environment: [String: String] = [:],
|
|
logOutput: Bool = false) throws -> ProcessResult {
|
|
let process = Process()
|
|
process.executableURL = URL(filePath: "/usr/bin/env")
|
|
process.arguments = [executable] + arguments
|
|
if let workingDirectory {
|
|
process.currentDirectoryURL = workingDirectory
|
|
}
|
|
|
|
var env = ProcessInfo.processInfo.environment
|
|
process.environment = env.combineWith(environment)
|
|
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
process.standardOutput = stdoutPipe
|
|
process.standardError = stderrPipe
|
|
|
|
nonisolated(unsafe) var stdoutData = Data()
|
|
nonisolated(unsafe) var stderrData = Data()
|
|
|
|
let group = DispatchGroup()
|
|
let stdoutQueue = DispatchQueue(label: "zip-builder.process.stdout")
|
|
let stderrQueue = DispatchQueue(label: "zip-builder.process.stderr")
|
|
|
|
group.enter()
|
|
stdoutQueue.async {
|
|
stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
|
group.leave()
|
|
}
|
|
|
|
group.enter()
|
|
stderrQueue.async {
|
|
stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
|
group.leave()
|
|
}
|
|
|
|
let commandLine = formatCommandLine(
|
|
executable: executable,
|
|
arguments: arguments,
|
|
workingDirectory: workingDirectory,
|
|
environment: environment
|
|
)
|
|
|
|
logger.debug(
|
|
"Executing command: \(commandLine)",
|
|
metadata: [
|
|
"command": "\(commandLine)",
|
|
"executable": "\(executable)",
|
|
]
|
|
)
|
|
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
group.wait()
|
|
|
|
let stdout = String(data: stdoutData, encoding: .utf8) ?? ""
|
|
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
|
|
|
|
logger.debug(
|
|
"Command finished",
|
|
metadata: [
|
|
"executable": "\(executable)",
|
|
"exitCode": "\(process.terminationStatus)",
|
|
]
|
|
)
|
|
|
|
if logOutput {
|
|
if !stdout.isEmpty {
|
|
logger.info("Process stdout", metadata: ["output": "\(stdout)"])
|
|
}
|
|
if !stderr.isEmpty {
|
|
logger.error("Process stderr", metadata: ["error": "\(stderr)"])
|
|
}
|
|
} else if process.terminationStatus != 0 && !stderr.isEmpty {
|
|
logger.debug("Process stderr", metadata: ["error": "\(stderr)"])
|
|
}
|
|
|
|
return ProcessResult(
|
|
exitCode: process.terminationStatus,
|
|
standardOutput: stdout,
|
|
standardError: stderr
|
|
)
|
|
}
|
|
|
|
/// Executes an external command asynchronously without blocking the calling cooperative thread.
|
|
///
|
|
/// - Parameters:
|
|
/// - executable: Executable binary or tool name (e.g. `xcodebuild`, `ditto`, `codesign`).
|
|
/// - arguments: Command line arguments passed to the executable.
|
|
/// - workingDirectory: Optional directory to execute the command within.
|
|
/// - environment: Optional environment variable overrides.
|
|
/// - logOutput: When `true`, streams output directly to the logger.
|
|
/// - Returns: `ProcessResult` containing termination status and output.
|
|
/// - Throws: An error if the process fails to launch.
|
|
@discardableResult
|
|
public static func runAsync(_ executable: String,
|
|
arguments: [String] = [],
|
|
workingDirectory: URL? = nil,
|
|
environment: [String: String] = [:],
|
|
logOutput: Bool = false) async throws -> ProcessResult {
|
|
let process = Process()
|
|
process.executableURL = URL(filePath: "/usr/bin/env")
|
|
process.arguments = [executable] + arguments
|
|
if let workingDirectory {
|
|
process.currentDirectoryURL = workingDirectory
|
|
}
|
|
|
|
var env = ProcessInfo.processInfo.environment
|
|
process.environment = env.combineWith(environment)
|
|
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
process.standardOutput = stdoutPipe
|
|
process.standardError = stderrPipe
|
|
|
|
let commandLine = formatCommandLine(
|
|
executable: executable,
|
|
arguments: arguments,
|
|
workingDirectory: workingDirectory,
|
|
environment: environment
|
|
)
|
|
|
|
logger.debug(
|
|
"Executing command (async): \(commandLine)",
|
|
metadata: [
|
|
"command": "\(commandLine)",
|
|
"executable": "\(executable)",
|
|
]
|
|
)
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
nonisolated(unsafe) var stdoutData = Data()
|
|
nonisolated(unsafe) var stderrData = Data()
|
|
|
|
let group = DispatchGroup()
|
|
let stdoutQueue = DispatchQueue(label: "zip-builder.process.stdout.async")
|
|
let stderrQueue = DispatchQueue(label: "zip-builder.process.stderr.async")
|
|
|
|
group.enter()
|
|
stdoutQueue.async {
|
|
stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
|
group.leave()
|
|
}
|
|
|
|
group.enter()
|
|
stderrQueue.async {
|
|
stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
|
group.leave()
|
|
}
|
|
|
|
group.enter()
|
|
process.terminationHandler = { _ in
|
|
group.leave()
|
|
}
|
|
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
try? stdoutPipe.fileHandleForWriting.close()
|
|
try? stderrPipe.fileHandleForWriting.close()
|
|
process.terminationHandler = nil
|
|
group.leave()
|
|
continuation.resume(throwing: error)
|
|
return
|
|
}
|
|
|
|
group.notify(queue: DispatchQueue.global()) {
|
|
let stdout = String(data: stdoutData, encoding: .utf8) ?? ""
|
|
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
|
|
let status = process.terminationStatus
|
|
|
|
logger.debug(
|
|
"Command finished (async)",
|
|
metadata: [
|
|
"executable": "\(executable)",
|
|
"exitCode": "\(status)",
|
|
]
|
|
)
|
|
|
|
if logOutput {
|
|
if !stdout.isEmpty {
|
|
logger.info("Process stdout", metadata: ["output": "\(stdout)"])
|
|
}
|
|
if !stderr.isEmpty {
|
|
logger.error("Process stderr", metadata: ["error": "\(stderr)"])
|
|
}
|
|
} else if status != 0 && !stderr.isEmpty {
|
|
logger.debug("Process stderr", metadata: ["error": "\(stderr)"])
|
|
}
|
|
|
|
continuation.resume(returning: ProcessResult(
|
|
exitCode: status,
|
|
standardOutput: stdout,
|
|
standardError: stderr
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Dictionary where Key: Hashable {
|
|
/// Merges `other` into `self` and returns the mutated dictionary.
|
|
mutating func combineWith(_ other: Dictionary) -> Self {
|
|
for (k, v) in other {
|
|
self[k] = v
|
|
}
|
|
return self
|
|
}
|
|
}
|