import Foundation public enum ResourceKind: String, CaseIterable, Identifiable, Sendable { case monitor, containers, images, networks, volumes, projects public var id: String { rawValue } public var title: String { rawValue.capitalized } public var symbol: String { switch self { case .monitor: "waveform.path.ecg"; case .containers: "shippingbox"; case .images: "square.stack.3d.up"; case .networks: "network"; case .volumes: "externaldrive"; case .projects: "folder" } } public var isContainer: Bool { self == .containers || self == .projects || self == .monitor } public var prefix: [String] { isContainer ? [] : [String(rawValue.dropLast())] } public var listArguments: [String] { isContainer ? ["list", "--all", "--format", "json"] : prefix + ["list", "--format", "json"] } } public struct Resource: Identifiable, Sendable, Hashable { public var id: String public var name: String public var state: String public var image: String public var project: String public var service: String public var ports: String public var raw: String public var driver = "—" public var format = "—" public var source = "—" public var created = "—" public var capacity = "—" public var storageKind = "Named volume" public var usedBy = "—" public var readOnlyStorage = false public var cpuFraction: Double? public var memoryBytes: Double? public var rxBytes: Double? public var txBytes: Double? public var cpu = "—" public var memory = "—" public var network = "—" public var disk = "—" } public enum ServiceError: LocalizedError { case message(String) public var errorDescription: String? { if case .message(let s) = self { return s }; return nil } } private final class Capture: @unchecked Sendable { private let lock = NSLock() private var data = Data() func append(_ bytes: Data) { lock.lock(); defer { lock.unlock() }; let remaining = 4 * 1024 * 1024 - data.count; if remaining > 0 { data.append(bytes.prefix(remaining)) } } var text: String { lock.lock(); defer { lock.unlock() }; return String(decoding: data, as: UTF8.self) } } public struct ContainerService: Sendable { public var binary: String public var demo: Bool public init(binary: String = ContainerService.defaultBinary, demo: Bool = false) { self.binary = binary; self.demo = demo } public static var defaultBinary: String { let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) + ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] return paths.map { $0 + "/container" }.first { FileManager.default.isExecutableFile(atPath: $0) } ?? "/opt/homebrew/bin/container" } public func run(_ args: [String], timeout: TimeInterval = 30) async throws -> String { try await Task.detached { try execute(args, timeout: timeout) }.value } public func execute(_ args: [String], timeout: TimeInterval = 30) throws -> String { if demo { let verb = ["image", "network", "volume"].contains(args.first ?? "") ? args.dropFirst().first ?? "" : args.first ?? "" guard ["list", "inspect", "logs", "stats", "exec"].contains(verb) else { throw ServiceError.message("Demo mode is read-only. Connect to the live runtime to control containers.") } return try demoOutput(args) } guard FileManager.default.isExecutableFile(atPath: binary) else { throw ServiceError.message("Apple container CLI was not found at \(binary). Install it or choose its executable in Settings.") } let process = Process(); process.executableURL = URL(fileURLWithPath: binary); process.arguments = args let out = Pipe(), err = Pipe(); process.standardOutput = out; process.standardError = err let stdout = Capture(), stderr = Capture(); let group = DispatchGroup() let ended = DispatchSemaphore(value: 0); process.terminationHandler = { _ in ended.signal() } try process.run() for (handle, capture) in [(out.fileHandleForReading, stdout), (err.fileHandleForReading, stderr)] { group.enter(); DispatchQueue.global().async { defer { group.leave() }; while true { let data = handle.availableData; if data.isEmpty { break }; capture.append(data) } } } let timedOut = ended.wait(timeout: .now() + timeout) == .timedOut if timedOut { process.terminate(); if ended.wait(timeout: .now() + 2) == .timedOut { kill(process.processIdentifier, SIGKILL); process.waitUntilExit() } } group.wait() if timedOut { throw ServiceError.message("container \(args.joined(separator: " ")) timed out.") } guard process.terminationStatus == 0 else { throw ServiceError.message("container \(args.joined(separator: " ")) failed:\n\(stderr.text)") } return stdout.text } public static func parse(_ data: String, kind: ResourceKind, projectHint: String = "") throws -> [Resource] { guard let bytes = data.data(using: .utf8), let rows = try JSONSerialization.jsonObject(with: bytes) as? [[String: Any]] else { throw ServiceError.message("Unexpected container JSON: expected a resource array.") } return try rows.map { row in let cfg = row["configuration"] as? [String: Any] ?? row let status = row["status"] as? [String: Any] ?? [:] let image = cfg["image"] as? [String: Any] ?? [:] let id = kind.isContainer ? (cfg["id"] ?? row["id"]) : (cfg["name"] ?? row["reference"] ?? row["name"] ?? cfg["id"] ?? row["id"]) guard let id = id as? String, !id.isEmpty else { throw ServiceError.message("Unsupported \(kind.title) JSON: missing identifier.") } let labels = cfg["labels"] as? [String: String] ?? [:] var project = "", service = "" for prefix in ["com.docker.compose.", "io.container-compose.", "com.container-compose.", "container-compose.", "io.accompose.", "com.accompose.", "accompose."] { if let p = labels[prefix + "project"] { project = p; service = labels[prefix + "service"] ?? ""; break } } if project.isEmpty, !projectHint.isEmpty, id.hasPrefix(projectHint + "-"), id.hasSuffix("-1") { project = projectHint; service = String(id.dropFirst(projectHint.count + 1).dropLast(2)) } var resource = Resource(id: id, name: id, state: status["state"] as? String ?? row["status"] as? String ?? "—", image: image["reference"] as? String ?? image["name"] as? String ?? (cfg["descriptor"] as? [String: Any])?["digest"] as? String ?? "—", project: project, service: service, ports: pretty(cfg["publishedPorts"] ?? []), raw: pretty(row)) if kind == .volumes { resource.driver = cfg["driver"] as? String ?? "—" resource.format = cfg["format"] as? String ?? "—" resource.source = cfg["source"] as? String ?? "—" resource.created = cfg["creationDate"] as? String ?? cfg["createdAt"] as? String ?? "—" resource.capacity = Self.bytes(cfg["sizeInBytes"]) resource.storageKind = labels["com.apple.container.resource.anonymous"] == nil ? "Named volume" : "Anonymous volume" } return resource }.sorted { $0.name < $1.name } } public func list(_ kind: ResourceKind, project: String = "") async throws -> [Resource] { try Self.parse(await run(kind.listArguments), kind: kind, projectHint: project) } public func withStats(_ resources: [Resource]) async throws -> [Resource] { guard resources.contains(where: { $0.state == "running" }) else { return resources } let output = try await run(["stats", "--no-stream", "--format", "json"]) guard let rows = try JSONSerialization.jsonObject(with: Data(output.utf8)) as? [[String: Any]] else { throw ServiceError.message("Unsupported stats JSON") } return resources.map { resource in var r = resource; if let s = rows.first(where: { $0["id"] as? String == r.id }) { if let cpu = s["cpuUtilization"] as? Double { r.cpuFraction = cpu; r.cpu = String(format: "%.2f%%", cpu * 100) }; r.memoryBytes = s["memoryUsageBytes"] as? Double; r.rxBytes = s["networkRxBytes"] as? Double; r.txBytes = s["networkTxBytes"] as? Double; r.disk = Self.bytes(s["blockReadBytes"]) + " / " + Self.bytes(s["blockWriteBytes"]); r.memory = Self.bytes(s["memoryUsageBytes"]); r.network = Self.bytes(s["networkRxBytes"]) + " / " + Self.bytes(s["networkTxBytes"]) }; return r } } public static func bytes(_ value: Any?) -> String { guard let n = value as? NSNumber else { return "—" }; return ByteCountFormatter.string(fromByteCount: n.int64Value, countStyle: .binary) } public static func detailArguments(kind: ResourceKind, id: String, mode: String) -> [String] { if kind.isContainer { switch mode { case "Logs": return ["logs", "-n", "200", id]; case "Stats": return ["stats", "--no-stream", "--format", "json", id]; case "Processes": return ["exec", id, "ps", "aux"]; default: break } } return kind.prefix + ["inspect", id] } public static func actionArguments(kind: ResourceKind, action: String, id: String) -> [String] { kind.prefix + [action] + (action == "prune" ? [] : [id]) } public func waitForState(id: String, expected: String, timeout: TimeInterval = 15) async throws -> Resource { let deadline = Date().addingTimeInterval(timeout) while true { let output = try await run(["inspect", id], timeout: min(5, timeout)) if let resource = try Self.parse(output, kind: .containers).first(where: { $0.id == id }), resource.state == expected { return resource } guard Date() < deadline else { throw ServiceError.message("The command returned, but \(id) did not reach \(expected) within \(Int(timeout)) seconds. Check Inspect for its current state.") } try await Task.sleep(for: .milliseconds(250)) } } public func action(kind: ResourceKind, action: String, resource: Resource?) async throws -> String { guard !demo else { throw ServiceError.message("Demo mode is read-only. Connect to the live runtime to control containers.") } guard resource?.readOnlyStorage != true else { throw ServiceError.message("Host mounts are inspect-only and cannot be deleted as volumes.") } if action == "restart" { guard let r = resource else { throw ServiceError.message("Select a container") }; if r.state == "running" { _ = try await run(["stop", r.id]) }; return try await run(["start", r.id]) } guard action == "prune" || resource != nil else { throw ServiceError.message("Select a resource") } return try await run(Self.actionArguments(kind: kind, action: action, id: resource?.id ?? "")) } public static func pretty(_ value: Any) -> String { guard JSONSerialization.isValidJSONObject(value), let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]), let s = String(data: data, encoding: .utf8) else { return String(describing: value) }; return s } public static func formatted(_ string: String) -> String { guard let data = string.data(using: .utf8), let value = try? JSONSerialization.jsonObject(with: data) else { return string }; return pretty(value) } private func demoOutput(_ args: [String]) throws -> String { if args.first == "list" { return Self.demoContainers } if args.prefix(2) == ["image", "list"] { return "[{\"configuration\":{\"name\":\"nginx:alpine\"}}]" } if args.prefix(2) == ["network", "list"] { return "[{\"id\":\"default\",\"configuration\":{\"name\":\"default\"}}]" } if args.prefix(2) == ["volume", "list"] { return "[{\"configuration\":{\"name\":\"demo-data\",\"driver\":\"local\",\"format\":\"ext4\",\"source\":\"/demo/demo-data.img\",\"creationDate\":\"2026-11-08T12:00:00Z\",\"sizeInBytes\":1073741824}}]" } if args.first == "stats" { return Self.demoStats(at: Date()) } if args.first == "logs" { return "12:00:01 Server ready on :80\n12:00:02 GET /health 200\nDemo mode · no runtime commands executed" } if args.first == "inspect" { return Self.demoContainers } return "Demo: \(args.joined(separator: " ")) (simulated; fixture state unchanged)" } public static func demoStats(at date: Date) -> String { let t = date.timeIntervalSince1970, wave = sin(t / 13) let row: [String: Any] = ["id": "demo-web-1", "cpuUtilization": 0.08 + 0.05 * wave, "memoryUsageBytes": (42 + 4 * wave) * 1_048_576, "networkRxBytes": t * 12 * 1024 - 8 * 1024 * 13 * cos(t / 13), "networkTxBytes": t * 7 * 1024 - 4 * 1024 * 13 * cos(t / 13)] return pretty([row]) } public static let demoContainers = """ [{"configuration":{"id":"demo-web-1","image":{"reference":"nginx:alpine"},"publishedPorts":[{"hostPort":8080,"containerPort":80}],"labels":{"io.container-compose.project":"demo","io.container-compose.service":"web"}},"status":{"state":"running"}},{"configuration":{"id":"demo-db-1","image":{"reference":"postgres:17"},"labels":{"io.container-compose.project":"demo","io.container-compose.service":"db"}},"status":{"state":"stopped"}}] """ }