Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ final class AppState {
private(set) var inFlight: Set<String> = []
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<Void, Never>?
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions App/ConsaiApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions App/Shots/ShotRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
86 changes: 86 additions & 0 deletions App/Views/ImagesWindow.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
1 change: 1 addition & 0 deletions App/Views/PanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down
8 changes: 8 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Engines/SDKImageEngine.swift
Original file line number Diff line number Diff line change
@@ -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] }
}
19 changes: 19 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Models/ContainerImage.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
14 changes: 14 additions & 0 deletions ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading