Skip to content
34 changes: 34 additions & 0 deletions Sources/CapsuleApp/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public struct AppEnvironment {
public var browserModel: ContainerBrowserModel
public var lifecycleModel: ContainerLifecycleModel
public var statsModel: ContainerStatsModel
public var imageBrowserModel: ImageBrowserModel
public var imageActionsModel: ImageActionsModel
public var taskCenter: TaskCenter
public var registriesModel: RegistriesModel
public var actions: ShellActions
public var updater: any UpdaterController
public var terminalSurfaceProvider: any TerminalSurfaceProviding
Expand All @@ -40,6 +44,10 @@ public struct AppEnvironment {
browserModel: ContainerBrowserModel,
lifecycleModel: ContainerLifecycleModel,
statsModel: ContainerStatsModel,
imageBrowserModel: ImageBrowserModel,
imageActionsModel: ImageActionsModel,
taskCenter: TaskCenter,
registriesModel: RegistriesModel,
actions: ShellActions,
updater: any UpdaterController,
terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider()
Expand All @@ -50,6 +58,10 @@ public struct AppEnvironment {
self.browserModel = browserModel
self.lifecycleModel = lifecycleModel
self.statsModel = statsModel
self.imageBrowserModel = imageBrowserModel
self.imageActionsModel = imageActionsModel
self.taskCenter = taskCenter
self.registriesModel = registriesModel
self.actions = actions
self.updater = updater
self.terminalSurfaceProvider = terminalSurfaceProvider
Expand All @@ -73,6 +85,24 @@ public struct AppEnvironment {
onActivity: { line in shell.appendActivity(line) }
)
let statsModel = ContainerStatsModel(backend: backend)
let imageBrowserModel = ImageBrowserModel(
backend: backend,
normalize: { ErrorNormalizer.normalize($0) },
onActivity: { line in shell.appendActivity(line) }
)
let taskCenter = TaskCenter(normalize: { ErrorNormalizer.normalize($0) })
let registriesModel = RegistriesModel(
backend: backend,
normalize: { ErrorNormalizer.normalize($0) },
onActivity: { line in shell.appendActivity(line) }
)
let imageActionsModel = ImageActionsModel(
backend: backend,
normalize: { ErrorNormalizer.normalize($0) },
onActivity: { line in shell.appendActivity(line) },
reloadList: { await imageBrowserModel.refresh() },
taskCenter: taskCenter
)
let lifecycleModel = ContainerLifecycleModel(
backend: backend,
normalize: { ErrorNormalizer.normalize($0) },
Expand Down Expand Up @@ -102,6 +132,10 @@ public struct AppEnvironment {
browserModel: browserModel,
lifecycleModel: lifecycleModel,
statsModel: statsModel,
imageBrowserModel: imageBrowserModel,
imageActionsModel: imageActionsModel,
taskCenter: taskCenter,
registriesModel: registriesModel,
actions: actions,
updater: NoopUpdaterController(),
terminalSurfaceProvider: terminalSurfaceProvider
Expand Down
15 changes: 15 additions & 0 deletions Sources/CapsuleApp/CapsuleScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public struct CapsuleScene: Scene {
@State private var browserModel: ContainerBrowserModel
@State private var lifecycleModel: ContainerLifecycleModel
@State private var statsModel: ContainerStatsModel
@State private var imageBrowserModel: ImageBrowserModel
@State private var imageActionsModel: ImageActionsModel
@State private var taskCenter: TaskCenter
@State private var registriesModel: RegistriesModel
private let actions: ShellActions
private let updater: any UpdaterController
private let terminalSurfaceProvider: any TerminalSurfaceProviding
Expand All @@ -37,6 +41,10 @@ public struct CapsuleScene: Scene {
self._browserModel = State(initialValue: environment.browserModel)
self._lifecycleModel = State(initialValue: environment.lifecycleModel)
self._statsModel = State(initialValue: environment.statsModel)
self._imageBrowserModel = State(initialValue: environment.imageBrowserModel)
self._imageActionsModel = State(initialValue: environment.imageActionsModel)
self._taskCenter = State(initialValue: environment.taskCenter)
self._registriesModel = State(initialValue: environment.registriesModel)
self.actions = environment.actions
self.updater = environment.updater
self.terminalSurfaceProvider = environment.terminalSurfaceProvider
Expand All @@ -51,6 +59,9 @@ public struct CapsuleScene: Scene {
browserModel: browserModel,
lifecycleModel: lifecycleModel,
statsModel: statsModel,
imageBrowserModel: imageBrowserModel,
imageActionsModel: imageActionsModel,
taskCenter: taskCenter,
actions: actions,
terminalSurfaceProvider: terminalSurfaceProvider
)
Expand All @@ -63,5 +74,9 @@ public struct CapsuleScene: Scene {
actions: actions
)
}

Settings {
PreferencesView(registriesModel: registriesModel)
}
}
}
13 changes: 11 additions & 2 deletions Sources/CapsuleBackend/BackendValueTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,25 @@ public struct ContainerSummary: Sendable, Equatable, Identifiable, Codable {
}
}

/// A backend's lightweight view of an image.
/// A backend's lightweight view of an image. `digest` is the full content digest
/// (`sha256:…`) the UI uses for unambiguous, digest-centric copy actions; `createdAt` is
/// the raw ISO-8601 string the CLI emits (the domain parses it into a `Date`).
public struct ImageSummary: Sendable, Equatable, Identifiable, Codable {
public var id: String
public var reference: String
public var sizeBytes: Int64
public var digest: String
public var createdAt: String?

public init(id: String, reference: String, sizeBytes: Int64) {
public init(
id: String, reference: String, sizeBytes: Int64,
digest: String = "", createdAt: String? = nil
) {
self.id = id
self.reference = reference
self.sizeBytes = sizeBytes
self.digest = digest
self.createdAt = createdAt
}
}

Expand Down
36 changes: 34 additions & 2 deletions Sources/CapsuleBackend/ContainerBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,24 @@ public protocol ContainerBackend: Sendable {
func inspectImage(reference: String) async throws -> Parsed<ImageSummary>
func removeImage(reference: String) async throws

/// Pulls an image, streaming progress line-by-line.
func pullImage(reference: String) -> AsyncThrowingStream<OutputLine, Error>
/// Pulls an image (optionally constrained to a platform), streaming progress line-by-line.
func pullImage(reference: String, platform: String?) -> AsyncThrowingStream<OutputLine, Error>

/// Pushes an image to its registry, streaming progress line-by-line.
func pushImage(reference: String, platform: String?) -> AsyncThrowingStream<OutputLine, Error>

/// Saves one or more images as an OCI-compatible tar archive at `url`.
func saveImage(references: [String], to url: URL, platform: String?) async throws

/// Loads images from an OCI-compatible tar archive at `url`.
func loadImage(from url: URL) async throws

/// Creates a new reference (`target`) for an existing image (`source`).
func tagImage(source: String, target: String) async throws

/// Removes dangling images (or all unused when `all` is true); returns the CLI's
/// best-effort reclaimed summary.
func pruneImages(all: Bool) async throws -> PruneResult

// MARK: Volumes / networks / registries / machines / builder

Expand All @@ -92,6 +108,17 @@ public protocol ContainerBackend: Sendable {
func listMachines() async throws -> [MachineSummary]
func builderStatus() async throws -> BuilderStatus

/// Logs in to a registry. The password is delivered out-of-band (never on argv) so it
/// cannot leak through process listings, logs, or transcripts.
func registryLogin(server: String, username: String?, password: String?) async throws

/// Logs out from a registry, dropping its stored credential.
func registryLogout(server: String) async throws

/// Validates credentials against a registry without persisting a login in Capsule's
/// model. (apple/container has no dry-run verb, so the adapter performs a real login.)
func registryTest(server: String, username: String?, password: String?) async throws

// MARK: Escape hatches

/// Runs an arbitrary argument vector and returns its raw result (never throwing on a
Expand All @@ -112,4 +139,9 @@ extension ContainerBackend {
public func stopContainer(id: String) async throws {
try await stopContainer(id: id, options: .default)
}

/// Convenience: pull for the host's default platform.
public func pullImage(reference: String) -> AsyncThrowingStream<OutputLine, Error> {
pullImage(reference: reference, platform: nil)
}
}
95 changes: 91 additions & 4 deletions Sources/CapsuleBackend/MockBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ public final class MockBackend: ContainerBackend, @unchecked Sendable {
public private(set) var lastKillSignal: String?
/// The URL passed to the most recent `exportContainer` call.
public private(set) var lastExportURL: URL?
/// The source/target of the most recent `tagImage` call.
public private(set) var lastTag: (source: String, target: String)?
/// The URL passed to the most recent `saveImage` call.
public private(set) var lastSavedURL: URL?
/// The URL passed to the most recent `loadImage` call.
public private(set) var lastLoadedURL: URL?
/// The `all` flag passed to the most recent `pruneImages` call.
public private(set) var prunedAll: Bool?
/// The server/username/password of the most recent `registryLogin` call.
public private(set) var lastLogin: (server: String, username: String?, password: String?)?
/// The server/username/password of the most recent `registryTest` call.
public private(set) var lastTest: (server: String, username: String?, password: String?)?
/// The server passed to the most recent `registryLogout` call.
public private(set) var lastLogout: String?
/// How many times `containerStats` has been invoked (for stream-teardown tests).
public private(set) var statsCallCount = 0
/// How many `followLogs` streams have terminated (incl. via cancellation).
Expand Down Expand Up @@ -214,7 +228,8 @@ public final class MockBackend: ContainerBackend, @unchecked Sendable {

public func inspectImage(reference: String) async throws -> Parsed<ImageSummary> {
try withState { state in
let match = state.images.first { $0.reference == reference }
// The CLI accepts a reference or an id; dangling images are addressed by id.
let match = state.images.first { $0.reference == reference || $0.id == reference }
return Parsed(value: match, raw: match.map { "\($0)" } ?? "")
}
}
Expand All @@ -225,17 +240,84 @@ public final class MockBackend: ContainerBackend, @unchecked Sendable {
}
}

public func pullImage(reference: String) -> AsyncThrowingStream<OutputLine, Error> {
public func pullImage(
reference: String, platform: String?
) -> AsyncThrowingStream<OutputLine, Error> {
seededStream()
}

public func pushImage(
reference: String, platform: String?
) -> AsyncThrowingStream<OutputLine, Error> {
seededStream()
}

public func saveImage(references: [String], to url: URL, platform: String?) async throws {
try withState { state in state.lastSavedURL = url }
}

public func loadImage(from url: URL) async throws {
try withState { state in state.lastLoadedURL = url }
}

public func tagImage(source: String, target: String) async throws {
try withState { state in
state.lastTag = (source: source, target: target)
if let original = state.images.first(where: { $0.reference == source }) {
state.images.append(
ImageSummary(
id: original.id, reference: target, sizeBytes: original.sizeBytes,
digest: original.digest, createdAt: original.createdAt))
}
}
}

public func pruneImages(all: Bool) async throws -> PruneResult {
try withState { state in
state.prunedAll = all
let removed: Int
if all {
removed = state.images.count
state.images.removeAll()
} else {
let dangling = state.images.filter { $0.reference.contains("<none>") }
removed = dangling.count
state.images.removeAll { $0.reference.contains("<none>") }
}
return PruneResult(reclaimedDescription: "Reclaimed \(removed) image(s).", raw: "")
}
}

// MARK: - Volumes / networks / registries / machines / builder

public func listVolumes() async throws -> [VolumeSummary] { try withState { $0.volumes } }
public func listNetworks() async throws -> [NetworkSummary] { try withState { $0.networks } }
public func listRegistries() async throws -> [RegistrySummary] {
try withState { $0.registries }
}

public func registryLogin(server: String, username: String?, password: String?) async throws {
try withState { state in
state.lastLogin = (server: server, username: username, password: password)
if !state.registries.contains(where: { $0.server == server }) {
state.registries.append(RegistrySummary(server: server))
}
}
}

public func registryLogout(server: String) async throws {
try withState { state in
state.lastLogout = server
state.registries.removeAll { $0.server == server }
}
}

public func registryTest(server: String, username: String?, password: String?) async throws {
// A test validates credentials but, unlike login, does not persist a registry entry.
try withState { state in
state.lastTest = (server: server, username: username, password: password)
}
}
public func listMachines() async throws -> [MachineSummary] { try withState { $0.machines } }
public func builderStatus() async throws -> BuilderStatus { try withState { $0.builder } }

Expand Down Expand Up @@ -303,9 +385,14 @@ extension MockBackend {
]

public static let sampleImages: [ImageSummary] = [
ImageSummary(id: "28bd5fe8", reference: "docker.io/library/alpine:latest", sizeBytes: 9218),
ImageSummary(
id: "9c7a4f10", reference: "docker.io/library/postgres:16", sizeBytes: 138_412_032),
id: "28bd5fe8", reference: "docker.io/library/alpine:latest", sizeBytes: 9218,
digest: "sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b",
createdAt: "2026-06-16T00:00:15Z"),
ImageSummary(
id: "9c7a4f10", reference: "docker.io/library/postgres:16", sizeBytes: 138_412_032,
digest: "sha256:9c7a4f10e6d2b3a1c5f8e0d7b6a4938271605f4e3d2c1b0a9f8e7d6c5b4a3920",
createdAt: "2026-06-10T08:30:00Z"),
]

public static let sampleNetworks: [NetworkSummary] = [
Expand Down
43 changes: 41 additions & 2 deletions Sources/CapsuleCLIBackend/CLICommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,32 @@ public enum CLICommand {
ArgumentBuilder("image", "inspect").adding(reference).arguments
}

public static func pullImage(reference: String) -> [String] {
ArgumentBuilder("image", "pull").adding(reference).arguments
public static func pullImage(reference: String, platform: String?) -> [String] {
ArgumentBuilder("image", "pull").flag("--platform", platform).adding(reference).arguments
}

public static func pushImage(reference: String, platform: String?) -> [String] {
ArgumentBuilder("image", "push").flag("--platform", platform).adding(reference).arguments
}

public static func saveImage(references: [String], to url: URL, platform: String?) -> [String] {
ArgumentBuilder("image", "save")
.flag("--output", url.path)
.flag("--platform", platform)
.adding(contentsOf: references)
.arguments
}

public static func loadImage(from url: URL) -> [String] {
ArgumentBuilder("image", "load").flag("--input", url.path).arguments
}

public static func tagImage(source: String, target: String) -> [String] {
ArgumentBuilder("image", "tag").adding(source, target).arguments
}

public static func pruneImages(all: Bool) -> [String] {
ArgumentBuilder("image", "prune").option("--all", enabled: all).arguments
}

public static func removeImage(reference: String) -> [String] {
Expand All @@ -117,6 +141,21 @@ public enum CLICommand {
ArgumentBuilder("registry", "list").flag("--format", "json").arguments
}

/// The login argv carries `--password-stdin` and (optionally) the username, but never
/// the password itself — the secret is written to the child's stdin so it cannot leak
/// via `ps`, the debug log, an error's `command:` field, or any task transcript.
public static func registryLogin(server: String, username: String?) -> [String] {
ArgumentBuilder("registry", "login")
.flag("--username", username)
.option("--password-stdin", enabled: true)
.adding(server)
.arguments
}

public static func registryLogout(server: String) -> [String] {
ArgumentBuilder("registry", "logout").adding(server).arguments
}

public static func listMachines() -> [String] {
ArgumentBuilder("machine", "list").flag("--format", "json").arguments
}
Expand Down
Loading
Loading