diff --git a/App/AppState.swift b/App/AppState.swift index c1aa479..824fe73 100644 --- a/App/AppState.swift +++ b/App/AppState.swift @@ -15,10 +15,13 @@ final class AppState { private(set) var inFlight: Set = [] var lastError: String? + private(set) var images: [ContainerImage] = [] + private let containerEngine: ContainerEngine private let composeEngine: ComposeEngine private let serviceHealth: ServiceHealthChecking private let creator: ContainerCreating + private let imageEngine: ImageEngine private let store: RegistryStore private var registry: ProjectRegistry private var pollTask: Task? @@ -49,12 +52,14 @@ final class AppState { composeEngine: ComposeEngine = CLIComposeEngine(binaryPath: AppState.storedPath("composeBinaryPath")), serviceHealth: ServiceHealthChecking = CLIServiceHealth(binaryPath: AppState.storedPath("containerBinaryPath")), creator: ContainerCreating = CLIContainerCreator(binaryPath: AppState.storedPath("containerBinaryPath")), + imageEngine: ImageEngine = SDKImageEngine(binaryPath: AppState.storedPath("containerBinaryPath")), store: RegistryStore = RegistryStore() ) { self.containerEngine = containerEngine self.composeEngine = composeEngine self.serviceHealth = serviceHealth self.creator = creator + self.imageEngine = imageEngine self.store = store self.registry = store.load() startPolling() @@ -253,6 +258,34 @@ final class AppState { func clearError() { lastError = nil } + // MARK: - Images + + func loadImages() async { + do { images = try await imageEngine.list() } + catch { lastError = describe(error) } + } + + /// Pull an image; returns true on success. Refreshes the image list. + func pullImage(_ reference: String) async -> Bool { + do { + try await imageEngine.pull(reference: reference) + await loadImages() + return true + } catch { + lastError = describe(error) + return false + } + } + + func deleteImage(_ reference: String) async { + do { + try await imageEngine.delete(reference: reference) + await loadImages() + } catch { + lastError = describe(error) + } + } + // MARK: - Helpers private func act(_ id: String, optimistic: ContainerStatus, _ op: @escaping () async throws -> Void) async { diff --git a/App/ConsaiApp.swift b/App/ConsaiApp.swift index ecd82e9..f1547be 100644 --- a/App/ConsaiApp.swift +++ b/App/ConsaiApp.swift @@ -52,6 +52,11 @@ struct ConsaiApp: App { } .menuBarExtraStyle(.window) + Window("Images", id: "images") { + ImagesWindow().environment(appState) + } + .defaultSize(width: 560, height: 420) + Window("Consai Settings", id: "settings") { SettingsWindow().environment(appState) .preferredColorScheme(.dark).tint(Theme.jade) diff --git a/App/Shots/ShotRenderer.swift b/App/Shots/ShotRenderer.swift index 404a58b..d5e5d30 100644 --- a/App/Shots/ShotRenderer.swift +++ b/App/Shots/ShotRenderer.swift @@ -54,6 +54,8 @@ enum ShotRenderer { try? await Task.sleep(for: .seconds(2.5)) await state.refresh() await shoot(PanelView().environment(state), width: 360, height: 600, name: "live-panel", dir: dir) + await state.loadImages() + await shoot(ImagesWindow().environment(state), width: 560, height: 420, name: "live-images", dir: dir) FileHandle.standardError.write(Data("rendered live panel to \(dir.path)\n".utf8)) } diff --git a/App/Views/ImagesWindow.swift b/App/Views/ImagesWindow.swift new file mode 100644 index 0000000..71826ef --- /dev/null +++ b/App/Views/ImagesWindow.swift @@ -0,0 +1,86 @@ +import SwiftUI +import ConsaiCore +import AppKit + +/// Browse local images, pull by reference, delete. +struct ImagesWindow: View { + @Environment(AppState.self) private var appState + @State private var pullRef = "" + @State private var pulling = false + @State private var deleting: String? + + var body: some View { + VStack(spacing: 0) { + pullBar + Rectangle().fill(Theme.hairline).frame(height: 0.5) + list + } + .frame(minWidth: 520, minHeight: 360) + .background(Theme.bg) + .preferredColorScheme(.dark).tint(Theme.jade) + .navigationTitle("Images") + .onAppear { + NSApp.activate(ignoringOtherApps: true) + Task { await appState.loadImages() } + } + } + + private var pullBar: some View { + HStack(spacing: 8) { + Image(systemName: "arrow.down.circle").foregroundStyle(Theme.dim) + TextField("docker.io/library/nginx:latest", text: $pullRef) + .textFieldStyle(.roundedBorder) + .onSubmit(pull) + Button(action: pull) { + if pulling { ProgressView().controlSize(.small) } else { Text("Pull") } + } + .disabled(pullRef.trimmingCharacters(in: .whitespaces).isEmpty || pulling) + Button { Task { await appState.loadImages() } } label: { Image(systemName: "arrow.clockwise") } + .buttonStyle(.borderless).foregroundStyle(Theme.dim).help("Refresh") + } + .padding(12) + } + + @ViewBuilder + private var list: some View { + if appState.images.isEmpty { + EmptyState(symbol: "shippingbox", title: "No images", + subtitle: "Pull an image by reference above.") + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(appState.images) { image in + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 1) { + Text(image.reference).font(Theme.ui(13, .medium)).foregroundStyle(Theme.text) + .textSelection(.enabled).lineLimit(1).truncationMode(.middle) + Text(image.shortDigest).font(Theme.mono(10)).foregroundStyle(Theme.dim2) + } + Spacer(minLength: 8) + if deleting == image.reference { + ProgressView().controlSize(.small) + } else { + Button { delete(image.reference) } label: { Image(systemName: "trash") } + .buttonStyle(.plain).foregroundStyle(Theme.dim).help("Delete") + } + } + .padding(.horizontal, 14).padding(.vertical, 8) + Rectangle().fill(Theme.hairline).frame(height: 0.5) + } + } + } + } + } + + private func pull() { + let ref = pullRef.trimmingCharacters(in: .whitespaces) + guard !ref.isEmpty else { return } + pulling = true + Task { if await appState.pullImage(ref) { pullRef = "" }; pulling = false } + } + + private func delete(_ reference: String) { + deleting = reference + Task { await appState.deleteImage(reference); deleting = nil } + } +} diff --git a/App/Views/PanelView.swift b/App/Views/PanelView.swift index 7941863..6fc78fc 100644 --- a/App/Views/PanelView.swift +++ b/App/Views/PanelView.swift @@ -110,6 +110,7 @@ struct PanelView: View { if let file = ComposeFilePicker.pick() { Task { await appState.composeUp(file: file) } } } } + footerButton("photo.stack", "Images") { openWindow(id: "images") } Spacer() footerButton("arrow.clockwise", nil) { Task { await appState.refresh() } } footerButton("gearshape", "Tend") { openWindow(id: "settings") } diff --git a/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift b/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift index b843404..6e09cac 100644 --- a/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift +++ b/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift @@ -24,6 +24,14 @@ public protocol ComposeEngine: Sendable { func down(file: URL) async throws } +/// Manages local OCI images. `list` uses the SDK; `pull`/`delete` shell out to the CLI +/// (the SDK pull requires a `ContainerSystemConfig` the CLI resolves for us). +public protocol ImageEngine: Sendable { + func list() async throws -> [ContainerImage] + func pull(reference: String) async throws + func delete(reference: String) async throws +} + /// Checks/controls the `container` system service. /// The concrete `CLIServiceHealth` is implemented in Wave 1. public protocol ServiceHealthChecking: Sendable { diff --git a/ConsaiCore/Sources/ConsaiCore/Engines/SDKImageEngine.swift b/ConsaiCore/Sources/ConsaiCore/Engines/SDKImageEngine.swift new file mode 100644 index 0000000..d1c64d6 --- /dev/null +++ b/ConsaiCore/Sources/ConsaiCore/Engines/SDKImageEngine.swift @@ -0,0 +1,49 @@ +import Foundation +import ContainerAPIClient + +/// `ImageEngine`: lists images via the apple/container SDK, and pulls/deletes via the +/// `container image …` CLI (pull over the SDK needs a `ContainerSystemConfig` the CLI +/// resolves itself). Pull can be slow, so the runner gets a long timeout. +public struct SDKImageEngine: ImageEngine { + private let binaryURL: URL? + private let runner: ProcessRunning + + init(binaryURL: URL?, runner: ProcessRunning) { + self.binaryURL = binaryURL + self.runner = runner + } + + public init(binaryPath: String? = nil, runner: ProcessRunning = SystemProcessRunner(timeout: 600)) { + self.init(binaryURL: ContainerBinary.resolve(explicit: binaryPath), runner: runner) + } + + public func list() async throws -> [ContainerImage] { + do { + return try await ClientImage.list() + .map { ContainerImage(reference: $0.reference, digest: $0.digest) } + .sorted { $0.reference < $1.reference } + } catch { + throw ConsaiError.sdk(String(describing: error)) + } + } + + public func pull(reference: String) async throws { + try await run(Self.pullArguments(reference: reference)) + } + + public func delete(reference: String) async throws { + try await run(Self.deleteArguments(reference: reference)) + } + + private func run(_ arguments: [String]) async throws { + guard let binaryURL else { throw ConsaiError.sdk("`container` CLI not found") } + let result = try await runner.run(executable: binaryURL.path, arguments: arguments, cwd: nil) + if result.exitCode != 0 { + throw ConsaiError.processFailed(stderr: result.stderr.isEmpty ? result.stdout : result.stderr) + } + } + + // Pure arg-building for tests. + static func pullArguments(reference: String) -> [String] { ["image", "pull", reference] } + static func deleteArguments(reference: String) -> [String] { ["image", "delete", reference] } +} diff --git a/ConsaiCore/Sources/ConsaiCore/Models/ContainerImage.swift b/ConsaiCore/Sources/ConsaiCore/Models/ContainerImage.swift new file mode 100644 index 0000000..11d3b9e --- /dev/null +++ b/ConsaiCore/Sources/ConsaiCore/Models/ContainerImage.swift @@ -0,0 +1,19 @@ +import Foundation + +/// An OCI image present locally. +public struct ContainerImage: Identifiable, Hashable, Sendable { + public var id: String { reference } + public let reference: String + public let digest: String + + public init(reference: String, digest: String) { + self.reference = reference + self.digest = digest + } + + /// Short digest for display (`sha256:abcd…` → `abcd1234`). + public var shortDigest: String { + let hex = digest.contains(":") ? String(digest.split(separator: ":").last ?? "") : digest + return String(hex.prefix(12)) + } +} diff --git a/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift b/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift index 29bec3d..0e0de2d 100644 --- a/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift +++ b/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift @@ -99,6 +99,20 @@ final class SpyProcessRunner: ProcessRunning, @unchecked Sendable { } } +@Suite struct SDKImageEngineTests { + @Test func buildsImageCommandArgs() { + #expect(SDKImageEngine.pullArguments(reference: "docker.io/library/nginx:latest") + == ["image", "pull", "docker.io/library/nginx:latest"]) + #expect(SDKImageEngine.deleteArguments(reference: "alpine:latest") + == ["image", "delete", "alpine:latest"]) + } + + @Test func shortDigestTrimsAlgoAndLength() { + let img = ContainerImage(reference: "nginx:latest", digest: "sha256:abcdef0123456789aaaa") + #expect(img.shortDigest == "abcdef012345") + } +} + @Suite struct CLIServiceHealthTests { @Test func parsesNegativeSignalsAsStopped() { #expect(CLIServiceHealth.parseStatus(exitCode: 0, output: "apiserver is not running") == .stopped)