mirror of
https://github.com/aculix/knotchy.git
synced 2026-10-27 07:15:53 +00:00
Initial release
This commit is contained in:
commit
4b67728cce
108 files changed
+13310
No files matched your search
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
public struct CLIRunner: Sendable {
|
||||
public struct Output: Sendable {
|
||||
public var status: Int32
|
||||
public var text: String
|
||||
|
||||
public var lastLine: String {
|
||||
text.split(whereSeparator: \.isNewline).last.map(String.init) ?? "exit status \(status)"
|
||||
}
|
||||
}
|
||||
|
||||
public enum Failure: Error, Equatable {
|
||||
case notInstalled
|
||||
case timedOut([String])
|
||||
case launchFailed(String)
|
||||
}
|
||||
|
||||
// Fixed paths: apps launched from the Services menu get a minimal PATH.
|
||||
public static let searchPaths = ["/usr/local/bin/container", "/opt/homebrew/bin/container"]
|
||||
public let searchPaths: [String]
|
||||
|
||||
public init(searchPaths: [String] = CLIRunner.searchPaths) {
|
||||
self.searchPaths = searchPaths
|
||||
}
|
||||
|
||||
/// Looked up on every call, so a runtime installed while the app runs is found.
|
||||
public var executable: String? {
|
||||
searchPaths.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
|
||||
public func run(_ args: [String], timeout: TimeInterval,
|
||||
line: (@Sendable (String) -> Void)? = nil) async throws -> Output {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
continuation.resume(with: Result { try self.runBlocking(args, timeout: timeout, line: line) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static let outputLimit = 1024 * 1024
|
||||
|
||||
private static let builds = Mutex(Set<Int32>())
|
||||
|
||||
/// Builds would outlive the app. Other commands are left to the quit path, which waits for them.
|
||||
public static func stopBuilds() {
|
||||
for pid in builds.withLock({ $0 }) { kill(pid, SIGTERM) }
|
||||
}
|
||||
|
||||
public func runBlocking(_ args: [String], timeout: TimeInterval,
|
||||
line: (@Sendable (String) -> Void)? = nil) throws -> Output {
|
||||
guard let executable else { throw Failure.notInstalled }
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = args
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
// Only the tail is kept: a noisy build can print for minutes.
|
||||
let limit = Self.outputLimit
|
||||
let collected = Mutex(Data())
|
||||
let pending = Mutex(Data())
|
||||
pipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let chunk = handle.availableData
|
||||
guard !chunk.isEmpty else { return }
|
||||
collected.withLock { buffer in
|
||||
buffer.append(chunk)
|
||||
if buffer.count > limit { buffer.removeFirst(buffer.count - limit) }
|
||||
}
|
||||
guard let line else { return }
|
||||
let lines: [String] = pending.withLock { buffer in
|
||||
buffer.append(chunk)
|
||||
var out: [String] = []
|
||||
while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
out.append(String(decoding: buffer[buffer.startIndex..<newline], as: UTF8.self))
|
||||
buffer.removeSubrange(buffer.startIndex...newline)
|
||||
}
|
||||
if buffer.count > 16 * 1024 { // A line that never ends.
|
||||
out.append(String(decoding: buffer.prefix(16 * 1024), as: UTF8.self))
|
||||
buffer.removeAll()
|
||||
}
|
||||
return out
|
||||
}
|
||||
for text in lines where !text.trimmingCharacters(in: .whitespaces).isEmpty { line(text) }
|
||||
}
|
||||
|
||||
let finished = DispatchSemaphore(value: 0)
|
||||
process.terminationHandler = { _ in finished.signal() }
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
pipe.fileHandleForReading.readabilityHandler = nil
|
||||
throw Failure.launchFailed(error.localizedDescription)
|
||||
}
|
||||
let pid = process.processIdentifier
|
||||
let isBuild = args.first == "build"
|
||||
if isBuild { Self.builds.withLock { _ = $0.insert(pid) } }
|
||||
defer { if isBuild { Self.builds.withLock { _ = $0.remove(pid) } } }
|
||||
if finished.wait(timeout: .now() + timeout) == .timedOut {
|
||||
process.terminate()
|
||||
if finished.wait(timeout: .now() + 1) == .timedOut {
|
||||
kill(process.processIdentifier, SIGKILL)
|
||||
finished.wait()
|
||||
}
|
||||
pipe.fileHandleForReading.readabilityHandler = nil
|
||||
throw Failure.timedOut(args)
|
||||
}
|
||||
pipe.fileHandleForReading.readabilityHandler = nil
|
||||
let rest = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let data = collected.withLock { ($0 + rest).suffix(limit) }
|
||||
return Output(status: process.terminationStatus, text: String(decoding: data, as: UTF8.self))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user