diff --git a/Sources/CapsuleApp/AppEnvironment.swift b/Sources/CapsuleApp/AppEnvironment.swift index d2b0a4e..e46577d 100644 --- a/Sources/CapsuleApp/AppEnvironment.swift +++ b/Sources/CapsuleApp/AppEnvironment.swift @@ -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 @@ -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() @@ -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 @@ -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) }, @@ -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 diff --git a/Sources/CapsuleApp/CapsuleScene.swift b/Sources/CapsuleApp/CapsuleScene.swift index 14c1641..0f30559 100644 --- a/Sources/CapsuleApp/CapsuleScene.swift +++ b/Sources/CapsuleApp/CapsuleScene.swift @@ -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 @@ -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 @@ -51,6 +59,9 @@ public struct CapsuleScene: Scene { browserModel: browserModel, lifecycleModel: lifecycleModel, statsModel: statsModel, + imageBrowserModel: imageBrowserModel, + imageActionsModel: imageActionsModel, + taskCenter: taskCenter, actions: actions, terminalSurfaceProvider: terminalSurfaceProvider ) @@ -63,5 +74,9 @@ public struct CapsuleScene: Scene { actions: actions ) } + + Settings { + PreferencesView(registriesModel: registriesModel) + } } } diff --git a/Sources/CapsuleBackend/BackendValueTypes.swift b/Sources/CapsuleBackend/BackendValueTypes.swift index 4581ce6..977b86a 100644 --- a/Sources/CapsuleBackend/BackendValueTypes.swift +++ b/Sources/CapsuleBackend/BackendValueTypes.swift @@ -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 } } diff --git a/Sources/CapsuleBackend/ContainerBackend.swift b/Sources/CapsuleBackend/ContainerBackend.swift index 1407a33..d56d22a 100644 --- a/Sources/CapsuleBackend/ContainerBackend.swift +++ b/Sources/CapsuleBackend/ContainerBackend.swift @@ -81,8 +81,24 @@ public protocol ContainerBackend: Sendable { func inspectImage(reference: String) async throws -> Parsed func removeImage(reference: String) async throws - /// Pulls an image, streaming progress line-by-line. - func pullImage(reference: String) -> AsyncThrowingStream + /// Pulls an image (optionally constrained to a platform), streaming progress line-by-line. + func pullImage(reference: String, platform: String?) -> AsyncThrowingStream + + /// Pushes an image to its registry, streaming progress line-by-line. + func pushImage(reference: String, platform: String?) -> AsyncThrowingStream + + /// 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 @@ -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 @@ -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 { + pullImage(reference: reference, platform: nil) + } } diff --git a/Sources/CapsuleBackend/MockBackend.swift b/Sources/CapsuleBackend/MockBackend.swift index b9d60d9..eb53cc3 100644 --- a/Sources/CapsuleBackend/MockBackend.swift +++ b/Sources/CapsuleBackend/MockBackend.swift @@ -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). @@ -214,7 +228,8 @@ public final class MockBackend: ContainerBackend, @unchecked Sendable { public func inspectImage(reference: String) async throws -> Parsed { 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)" } ?? "") } } @@ -225,10 +240,54 @@ public final class MockBackend: ContainerBackend, @unchecked Sendable { } } - public func pullImage(reference: String) -> AsyncThrowingStream { + public func pullImage( + reference: String, platform: String? + ) -> AsyncThrowingStream { seededStream() } + public func pushImage( + reference: String, platform: String? + ) -> AsyncThrowingStream { + 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("") } + removed = dangling.count + state.images.removeAll { $0.reference.contains("") } + } + return PruneResult(reclaimedDescription: "Reclaimed \(removed) image(s).", raw: "") + } + } + // MARK: - Volumes / networks / registries / machines / builder public func listVolumes() async throws -> [VolumeSummary] { try withState { $0.volumes } } @@ -236,6 +295,29 @@ public final class MockBackend: ContainerBackend, @unchecked Sendable { 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 } } @@ -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] = [ diff --git a/Sources/CapsuleCLIBackend/CLICommand.swift b/Sources/CapsuleCLIBackend/CLICommand.swift index 89505c9..9a0350a 100644 --- a/Sources/CapsuleCLIBackend/CLICommand.swift +++ b/Sources/CapsuleCLIBackend/CLICommand.swift @@ -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] { @@ -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 } diff --git a/Sources/CapsuleCLIBackend/CLIContainerBackend.swift b/Sources/CapsuleCLIBackend/CLIContainerBackend.swift index df1c444..b0789e7 100644 --- a/Sources/CapsuleCLIBackend/CLIContainerBackend.swift +++ b/Sources/CapsuleCLIBackend/CLIContainerBackend.swift @@ -161,8 +161,42 @@ public struct CLIContainerBackend: ContainerBackend { _ = try await runChecked(CLICommand.removeImage(reference: reference)) } - public func pullImage(reference: String) -> AsyncThrowingStream { - runner.stream(CLICommand.pullImage(reference: reference), environment: [:]) + public func pullImage( + reference: String, platform: String? + ) -> AsyncThrowingStream { + runner.stream( + CLICommand.pullImage(reference: reference, platform: platform), environment: [:]) + } + + public func pushImage( + reference: String, platform: String? + ) -> AsyncThrowingStream { + runner.stream( + CLICommand.pushImage(reference: reference, platform: platform), environment: [:]) + } + + public func saveImage(references: [String], to url: URL, platform: String?) async throws { + _ = try await runChecked( + CLICommand.saveImage(references: references, to: url, platform: platform)) + } + + public func loadImage(from url: URL) async throws { + _ = try await runChecked(CLICommand.loadImage(from: url)) + } + + public func tagImage(source: String, target: String) async throws { + _ = try await runChecked(CLICommand.tagImage(source: source, target: target)) + } + + public func pruneImages(all: Bool) async throws -> PruneResult { + // Like `pruneContainers`: prune exits 0 and prints a human "Reclaimed …" line, so a + // non-empty stderr is not a failure — only a non-zero exit is a real error. + let result = try await runner.run(CLICommand.pruneImages(all: all), environment: [:]) + guard result.isSuccess else { + throw BackendError.nonZeroExit( + command: "container image prune", code: result.exitCode, stderr: result.stderr) + } + return OutputParser.parsePruneResult(stdout: result.stdout, stderr: result.stderr) } // MARK: - Volumes / networks / registries / machines / builder @@ -182,6 +216,32 @@ public struct CLIContainerBackend: ContainerBackend { return try OutputParser.parseRegistries(Data(output.stdout.utf8)) } + public func registryLogin(server: String, username: String?, password: String?) async throws { + try await runLogin(server: server, username: username, password: password) + } + + public func registryLogout(server: String) async throws { + _ = try await runChecked(CLICommand.registryLogout(server: server)) + } + + public func registryTest(server: String, username: String?, password: String?) async throws { + // apple/container has no dry-run verb; a real login validates the credentials. + try await runLogin(server: server, username: username, password: password) + } + + /// Runs `registry login`, feeding the password through stdin (`--password-stdin`). The + /// password is never placed on argv, so it cannot leak via `ps`, the debug log line, or + /// the error's `command:` field. Only the argv (sans secret) is logged. + private func runLogin(server: String, username: String?, password: String?) async throws { + let argv = CLICommand.registryLogin(server: server, username: username) + Log.backend.debug("container \(argv.joined(separator: " "), privacy: .public)") + let result = try await runner.run(argv, environment: [:], standardInput: password ?? "") + guard result.isSuccess else { + throw BackendError.nonZeroExit( + command: commandDescription(argv), code: result.exitCode, stderr: result.stderr) + } + } + public func listMachines() async throws -> [MachineSummary] { let output = try await runChecked(CLICommand.listMachines()) return try OutputParser.parseMachines(Data(output.stdout.utf8)) diff --git a/Sources/CapsuleCLIBackend/CLIProcessRunner.swift b/Sources/CapsuleCLIBackend/CLIProcessRunner.swift index 8eeb4b1..7af6f41 100644 --- a/Sources/CapsuleCLIBackend/CLIProcessRunner.swift +++ b/Sources/CapsuleCLIBackend/CLIProcessRunner.swift @@ -28,7 +28,8 @@ public struct CLIProcessRunner: Sendable { /// are drained concurrently so a chatty stream on one pipe cannot deadlock the other. public func run( _ arguments: [String], - environment: [String: String] = [:] + environment: [String: String] = [:], + standardInput: String? = nil ) async throws -> CommandResult { let process = makeProcess(arguments: arguments, environment: environment) let outPipe = Pipe() @@ -36,6 +37,12 @@ public struct CLIProcessRunner: Sendable { process.standardOutput = outPipe process.standardError = errPipe + // A secret-bearing input (e.g. a registry password for `--password-stdin`) is fed + // through stdin so it never appears on argv. The pipe is closed after writing so the + // child sees EOF and proceeds. + let inPipe: Pipe? = standardInput == nil ? nil : Pipe() + process.standardInput = inPipe ?? FileHandle.nullDevice + // Propagate Swift task cancellation to the spawned child so a cancelled caller // (e.g. a stats poll whose stream was torn down) doesn't wait for a long-running // command to finish. terminate() fires the terminationHandler, which resolves the @@ -51,6 +58,11 @@ public struct CLIProcessRunner: Sendable { throwing: BackendError.executableNotFound(executableURL.path)) return } + if let standardInput, let inPipe { + let handle = inPipe.fileHandleForWriting + handle.write(Data(standardInput.utf8)) + try? handle.close() + } // Drain both pipes concurrently on background queues. DispatchQueue.global().async { collector.set(stdout: outPipe.fileHandleForReading.readDataToEndOfFile()) diff --git a/Sources/CapsuleCLIBackend/OutputParser.swift b/Sources/CapsuleCLIBackend/OutputParser.swift index 534ed0b..5827cfc 100644 --- a/Sources/CapsuleCLIBackend/OutputParser.swift +++ b/Sources/CapsuleCLIBackend/OutputParser.swift @@ -28,7 +28,9 @@ public enum OutputParser { ImageSummary( id: record.id, reference: record.configuration.name, - sizeBytes: record.configuration.descriptor.size + sizeBytes: record.configuration.descriptor.size, + digest: record.configuration.descriptor.digest, + createdAt: record.configuration.creationDate ) } } diff --git a/Sources/CapsuleCLIBackend/ProcessRunning.swift b/Sources/CapsuleCLIBackend/ProcessRunning.swift index b05707e..2ce47cd 100644 --- a/Sources/CapsuleCLIBackend/ProcessRunning.swift +++ b/Sources/CapsuleCLIBackend/ProcessRunning.swift @@ -12,11 +12,24 @@ import CapsuleBackend import Foundation protocol ProcessRunning: Sendable { - func run(_ arguments: [String], environment: [String: String]) async throws -> CommandResult + func run( + _ arguments: [String], + environment: [String: String], + standardInput: String? + ) async throws -> CommandResult func stream( _ arguments: [String], environment: [String: String] ) -> AsyncThrowingStream } +extension ProcessRunning { + /// Convenience for the common case with no stdin payload. + func run( + _ arguments: [String], environment: [String: String] + ) async throws -> CommandResult { + try await run(arguments, environment: environment, standardInput: nil) + } +} + extension CLIProcessRunner: ProcessRunning {} diff --git a/Sources/CapsuleDomain/Confirmation.swift b/Sources/CapsuleDomain/Confirmation.swift index 4a67c2b..7b468e9 100644 --- a/Sources/CapsuleDomain/Confirmation.swift +++ b/Sources/CapsuleDomain/Confirmation.swift @@ -15,6 +15,8 @@ public enum ConfirmationKind: Sendable, Equatable { case kill case delete(force: Bool) case exportNotStopped + // Images (Milestone 6) + case deleteImage } /// A request to confirm a destructive operation, as pure data the UI renders generically. @@ -70,4 +72,20 @@ public struct ConfirmationRequest: Sendable, Equatable, Identifiable { + "Stopping it first is recommended.", confirmTitle: "Export Anyway", targetIDs: [id], kind: .exportNotStopped) } + + // MARK: Images (Milestone 6) + + /// Deleting an image always confirms; the operation is permanent and may be refused if + /// the image is still referenced by a container. + public static func deleteImage(ids: [String]) -> ConfirmationRequest? { + let count = ids.count + guard count > 0 else { return nil } + let noun = count == 1 ? "this image" : "\(count) images" + return ConfirmationRequest( + title: count == 1 ? "Delete image?" : "Delete \(count) images?", + message: "Deleting \(noun) is permanent. An image still referenced by a container " + + "can't be removed.", + confirmTitle: "Delete", targetIDs: ids, kind: .deleteImage) + } + } diff --git a/Sources/CapsuleDomain/Image.swift b/Sources/CapsuleDomain/Image.swift index 4ec6c16..3ef7908 100644 --- a/Sources/CapsuleDomain/Image.swift +++ b/Sources/CapsuleDomain/Image.swift @@ -4,27 +4,89 @@ // // Copyright © 2026 Capsule. All rights reserved. // +// NOTE: This module must remain free of UI and of `Foundation.Process`. The domain's +// model of a container image — decoupled from the backend wire format, with the reference +// parsed into repository/tag and the digest surfaced for unambiguous copy actions. import CapsuleBackend import Foundation -/// The domain's model of a container image, decoupled from the backend wire format so -/// that backend value types never leak into the UI. +/// The domain's model of a container image. public struct Image: Sendable, Equatable, Identifiable { - public var id: String public var reference: String + public var repository: String + public var tag: String? + /// The full content digest (`sha256:…`), used verbatim for digest-centric copy actions. + public var digest: String + /// The backend's own image identifier (the CLI's `id`), accepted by `inspect`/`delete`/ + /// `tag`. Used to address dangling images, whose `:` reference is not usable. + public var imageID: String public var sizeBytes: Int64 + public var createdAt: Date? - public init(id: String, reference: String, sizeBytes: Int64) { - self.id = id + public init( + reference: String, repository: String, tag: String?, digest: String, + imageID: String, sizeBytes: Int64, createdAt: Date? = nil + ) { self.reference = reference + self.repository = repository + self.tag = tag + self.digest = digest + self.imageID = imageID self.sizeBytes = sizeBytes + self.createdAt = createdAt } + + /// A row id that doubles as the CLI identifier: the reference for tagged images, and the + /// backend id for dangling images (whose `:` reference can't address them). + public var id: String { isDangling ? imageID : reference } + + /// An untagged / unreferenced image (``), the prune target. + public var isDangling: Bool { reference.isEmpty || reference.contains("") } + + /// The leading 12 hex characters of the digest (after any `sha256:` prefix), for + /// compact display alongside the full, copyable digest. + public var shortDigest: String { + let hex = digest.hasPrefix("sha256:") ? String(digest.dropFirst(7)) : digest + return String(hex.prefix(12)) + } + + /// A user-facing name: the reference, or "" for a dangling image. + public var displayName: String { isDangling ? "" : reference } } extension Image { - /// Maps a backend summary into the domain model. + /// Maps a backend summary into the domain model, parsing the reference and date. public init(summary: ImageSummary) { - self.init(id: summary.id, reference: summary.reference, sizeBytes: summary.sizeBytes) + let (repository, tag) = Image.parse(reference: summary.reference) + self.init( + reference: summary.reference, + repository: repository, + tag: tag, + digest: summary.digest, + imageID: summary.id, + sizeBytes: summary.sizeBytes, + createdAt: summary.createdAt.flatMap(Container.parseDate) + ) + } + + /// Splits an image reference into `(repository, tag?)`. + /// + /// A digest-pinned reference (`name@sha256:…`) has no tag. A tag is only the text after + /// the last `:` *within the final path segment*, so a registry port (`localhost:5000/…`) + /// is never mistaken for a tag. + static func parse(reference: String) -> (repository: String, tag: String?) { + // Digest-pinned: everything before `@` is the repository, no tag. + if let at = reference.firstIndex(of: "@") { + return (String(reference[..:` — that placeholder is not a real tag. + return (String(reference[.." ? nil : tag) } } diff --git a/Sources/CapsuleDomain/ImageActionsModel.swift b/Sources/CapsuleDomain/ImageActionsModel.swift new file mode 100644 index 0000000..7188e68 --- /dev/null +++ b/Sources/CapsuleDomain/ImageActionsModel.swift @@ -0,0 +1,197 @@ +// +// ImageActionsModel.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// NOTE: This module must remain free of UI and of `Foundation.Process`. Owns the +// non-streaming image operations — tag, delete (single/bulk), and prune — mirroring +// `ContainerLifecycleModel`. Streaming transfers (pull/push/save/load) live in +// `TaskCenter`; the read surface lives in `ImageBrowserModel`. + +import CapsuleBackend +import Foundation +import Observation + +@MainActor +@Observable +public final class ImageActionsModel { + public private(set) var busy: Set = [] + public var notice: LifecycleNotice? + /// A pending destructive confirmation the UI should present, or nil. + public var confirmation: ConfirmationRequest? + + private let backend: any ContainerBackend + private let normalize: @Sendable (any Error) -> CapsuleError + private let onActivity: @MainActor (String) -> Void + private let reloadList: @MainActor () async -> Void + private let taskCenter: TaskCenter + + public init( + backend: any ContainerBackend, + normalize: @escaping @Sendable (any Error) -> CapsuleError = SystemStatusModel + .defaultNormalize, + onActivity: @escaping @MainActor (String) -> Void = { _ in }, + reloadList: @escaping @MainActor () async -> Void = {}, + taskCenter: TaskCenter? = nil + ) { + self.backend = backend + self.normalize = normalize + self.onActivity = onActivity + self.reloadList = reloadList + self.taskCenter = taskCenter ?? TaskCenter(normalize: normalize) + } + + // MARK: - Tag + + /// Creates a new reference (`target`) for an existing image (`source`). Returns whether + /// it succeeded, so a sheet can dismiss only on success. + @discardableResult + public func tag(source: String, target: String) async -> Bool { + busy.insert(source) + defer { busy.remove(source) } + do { + try await backend.tagImage(source: source, target: target) + await reloadList() + onActivity("Tagged “\(source)” as “\(target)”.") + return true + } catch { + notice = LifecycleNotice(detail: normalize(error).detail) + return false + } + } + + // MARK: - Delete + + public func delete(reference: String) async { + busy.insert(reference) + defer { busy.remove(reference) } + if let detail = await performDelete(reference) { + notice = LifecycleNotice(detail: detail) + } + } + + /// Bulk delete continues past failures and aggregates every dependency conflict into a + /// single notice, so a multi-select delete never hides a referenced image behind another. + public func deleteAll(references: [String]) async { + var failures: [String] = [] + for reference in references { + busy.insert(reference) + let detail = await performDelete(reference) + busy.remove(reference) + if let detail { failures.append("\(reference): \(detail.explanation)") } + } + guard !failures.isEmpty else { return } + notice = LifecycleNotice( + detail: ErrorDetail( + title: failures.count == 1 + ? "Couldn’t delete an image" : "Couldn’t delete \(failures.count) images", + explanation: failures.joined(separator: "\n"))) + } + + /// Deletes one image, returning the failure detail (or nil on success / benign not-found). + private func performDelete(_ reference: String) async -> ErrorDetail? { + do { + try await backend.removeImage(reference: reference) + await reloadList() + onActivity("Deleted image “\(reference)”.") + return nil + } catch { + if isBenignAlreadyRemoved(error) { + await reloadList() + onActivity("Image “\(reference)” was already removed.") + return nil + } + return normalize(error).detail + } + } + + // MARK: - Prune + + /// The images a prune would remove, for the Clean Up sheet's preview. By default that is + /// the dangling (untagged) images; with `all`, every image not referenced by a + /// container. + public func computePruneTargets(all: Bool) async -> [Image] { + let images = ((try? await backend.listImages()) ?? []).map(Image.init(summary:)) + guard all else { return images.filter(\.isDangling) } + let containers = (try? await backend.listContainers(all: true)) ?? [] + let referenced = Set(containers.map(\.image)) + return images.filter { !referenced.contains($0.reference) } + } + + @discardableResult + public func prune(all: Bool) async -> PruneSummary { + do { + let result = try await backend.pruneImages(all: all) + await reloadList() + let message = result.reclaimedDescription ?? "Cleanup complete." + onActivity(message) + return PruneSummary(message: message) + } catch { + notice = LifecycleNotice(detail: normalize(error).detail) + return PruneSummary(message: "Cleanup failed.") + } + } + + // MARK: - Transfers (registered as Activity tasks) + + /// Pulls an image, streaming progress into a task; refreshes the list on success. + @discardableResult + public func pull(reference: String, platform: String?) -> OperationTask { + onActivity("Pulling “\(reference)”…") + return taskCenter.runStreaming( + kind: .pull, title: "Pull \(reference)", + onSuccess: { [reloadList] in await reloadList() } + ) { [backend] in + backend.pullImage(reference: reference, platform: platform) + } + } + + /// Pushes an image to its registry, streaming progress into a task. + @discardableResult + public func push(reference: String, platform: String?) -> OperationTask { + onActivity("Pushing “\(reference)”…") + return taskCenter.runStreaming(kind: .push, title: "Push \(reference)") { [backend] in + backend.pushImage(reference: reference, platform: platform) + } + } + + /// Saves one or more images to a tar archive as a task. + @discardableResult + public func save(references: [String], to url: URL, platform: String?) -> OperationTask { + taskCenter.runAsync( + kind: .save, title: "Save \(references.joined(separator: ", "))" + ) { [backend] in + try await backend.saveImage(references: references, to: url, platform: platform) + } + } + + /// Loads images from a tar archive as a task; refreshes the list on success. + @discardableResult + public func load(from url: URL) -> OperationTask { + taskCenter.runAsync( + kind: .load, title: "Load \(url.lastPathComponent)", + onSuccess: { [reloadList] in await reloadList() } + ) { [backend] in + try await backend.loadImage(from: url) + } + } + + /// Re-runs a finished transfer task (the sheets/Activity pane Retry affordance). + public func retryTask(_ task: OperationTask) { + taskCenter.retry(task) + } + + // MARK: - Helpers + + /// Delete is idempotent: a `notFound` means the image is already gone — a benign + /// success. Daemon outages are never benign. + private func isBenignAlreadyRemoved(_ error: any Error) -> Bool { + guard case let BackendError.nonZeroExit(_, _, stderr) = error else { return false } + let s = stderr.lowercased() + let gone = s.contains("notfound") || s.contains("not found") + let daemon = + s.contains("xpc") || s.contains("launchd") || s.contains("connection refused") + return gone && !daemon + } +} diff --git a/Sources/CapsuleDomain/ImageBrowserModel.swift b/Sources/CapsuleDomain/ImageBrowserModel.swift new file mode 100644 index 0000000..630d1b0 --- /dev/null +++ b/Sources/CapsuleDomain/ImageBrowserModel.swift @@ -0,0 +1,166 @@ +// +// ImageBrowserModel.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// NOTE: This module must remain free of UI and of `Foundation.Process`. The images read +// surface, mirroring `ContainerBrowserModel`: a loaded list with a live query (search + +// sort + a dangling filter), a multi-selection, and raw-retaining inspect. Image actions +// (tag/delete/prune) live in `ImageActionsModel`; transfers live in `TaskCenter`. + +import CapsuleBackend +import Foundation +import Observation + +/// The load state of the image list, kept separate from `rows` so the UI can distinguish +/// "service unreachable" from "no images" from "no matches". +public enum ImageLoadState: Sendable, Equatable { + case idle + case loading + case loaded + case unavailable(ErrorDetail) +} + +/// How the image list is ordered. +public enum ImageSort: String, Sendable, CaseIterable, Identifiable { + case name + case size + case created + + public var id: String { rawValue } + + public var title: String { + switch self { + case .name: return "Name" + case .size: return "Size" + case .created: return "Created" + } + } +} + +/// An image inspection: the decoded domain value (nil if the payload drifted) paired with +/// the exact raw JSON, so the inspector can always show *something*. +public struct ImageInspection: Sendable, Equatable { + public var value: Image? + public var rawJSON: String + + public init(value: Image?, rawJSON: String) { + self.value = value + self.rawJSON = rawJSON + } +} + +@MainActor +@Observable +public final class ImageBrowserModel { + public private(set) var allImages: [Image] = [] + public private(set) var loadState: ImageLoadState = .idle + + public var searchText: String = "" + public var sort: ImageSort = .name + public var showDanglingOnly: Bool = false + public var selection: Set = [] + + private let backend: any ContainerBackend + private let normalize: @Sendable (any Error) -> CapsuleError + private let onActivity: @MainActor (String) -> Void + + public init( + backend: any ContainerBackend, + normalize: @escaping @Sendable (any Error) -> CapsuleError = SystemStatusModel + .defaultNormalize, + onActivity: @escaping @MainActor (String) -> Void = { _ in } + ) { + self.backend = backend + self.normalize = normalize + self.onActivity = onActivity + } + + // MARK: Derived views + + /// Images passing the dangling filter and search term, ordered by the active sort. + public var rows: [Image] { + allImages + .filter { !showDanglingOnly || $0.isDangling } + .filter { matchesSearch($0) } + .sorted(by: ordering) + } + + public var selectedImages: [Image] { + allImages.filter { selection.contains($0.id) } + } + + /// The service is up but there are genuinely no images (distinct from a down service + /// and from a filter that matched nothing). + public var isEmptyButHealthy: Bool { + loadState == .loaded && allImages.isEmpty + } + + /// There are images, but the active filter/search matched none. + public var noMatches: Bool { + loadState == .loaded && !allImages.isEmpty && rows.isEmpty + } + + private func matchesSearch(_ image: Image) -> Bool { + let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty else { return true } + return image.reference.localizedCaseInsensitiveContains(term) + || image.repository.localizedCaseInsensitiveContains(term) + || (image.tag?.localizedCaseInsensitiveContains(term) ?? false) + || image.digest.localizedCaseInsensitiveContains(term) + } + + /// Name sorts ascending; size sorts largest-first; created sorts newest-first with + /// undated images last. Ties break on the reference for a stable order. + private func ordering(_ lhs: Image, _ rhs: Image) -> Bool { + switch sort { + case .name: + return lhs.reference.localizedCaseInsensitiveCompare(rhs.reference) == .orderedAscending + case .size: + if lhs.sizeBytes != rhs.sizeBytes { return lhs.sizeBytes > rhs.sizeBytes } + return lhs.reference.localizedCaseInsensitiveCompare(rhs.reference) == .orderedAscending + case .created: + switch (lhs.createdAt, rhs.createdAt) { + case let (l?, r?) where l != r: return l > r + case (nil, .some): return false + case (.some, nil): return true + default: + return lhs.reference.localizedCaseInsensitiveCompare(rhs.reference) + == .orderedAscending + } + } + } + + // MARK: Loading + + public func refresh() async { + loadState = .loading + do { + let summaries = try await backend.listImages() + allImages = summaries.map(Image.init(summary:)) + selection = selection.intersection(Set(allImages.map(\.id))) + loadState = .loaded + onActivity("Loaded \(allImages.count) image(s).") + } catch { + allImages = [] + let detail = normalize(error).detail + onActivity("Failed to load images: \(detail.title)") + loadState = .unavailable(detail) + } + } + + /// Inspects one image, mapping the backend's raw-retaining `Parsed` into the domain + /// `ImageInspection`. Never throws: a failure yields an empty raw payload. + public func inspect(reference: String) async -> ImageInspection { + do { + let parsed = try await backend.inspectImage(reference: reference) + return ImageInspection( + value: parsed.value.map(Image.init(summary:)), + rawJSON: parsed.raw + ) + } catch { + return ImageInspection(value: nil, rawJSON: "") + } + } +} diff --git a/Sources/CapsuleDomain/RegistriesModel.swift b/Sources/CapsuleDomain/RegistriesModel.swift new file mode 100644 index 0000000..2053265 --- /dev/null +++ b/Sources/CapsuleDomain/RegistriesModel.swift @@ -0,0 +1,111 @@ +// +// RegistriesModel.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// NOTE: This module must remain free of UI and of `Foundation.Process`. Registry credential +// management for the Registries preferences pane: list, login, logout, and a credential +// test. The password flows straight through to the backend (delivered via stdin there) and +// is never retained or logged — only the fact that a login happened is recorded. + +import CapsuleBackend +import Foundation +import Observation + +/// The domain's view of a registry login (apple/container exposes only the server locally). +public struct Registry: Sendable, Equatable, Identifiable { + public var server: String + public var id: String { server } + + public init(server: String) { self.server = server } +} + +/// The load state of the registry list, distinguishing a down service from no logins. +public enum RegistryLoadState: Sendable, Equatable { + case idle + case loading + case loaded + case unavailable(ErrorDetail) +} + +/// The outcome of a credential test. +public enum RegistryTestResult: Sendable, Equatable { + case success + case failure(ErrorDetail) +} + +@MainActor +@Observable +public final class RegistriesModel { + public private(set) var registries: [Registry] = [] + public private(set) var loadState: RegistryLoadState = .idle + public var notice: LifecycleNotice? + + private let backend: any ContainerBackend + private let normalize: @Sendable (any Error) -> CapsuleError + private let onActivity: @MainActor (String) -> Void + + public init( + backend: any ContainerBackend, + normalize: @escaping @Sendable (any Error) -> CapsuleError = SystemStatusModel + .defaultNormalize, + onActivity: @escaping @MainActor (String) -> Void = { _ in } + ) { + self.backend = backend + self.normalize = normalize + self.onActivity = onActivity + } + + public func refresh() async { + loadState = .loading + do { + let summaries = try await backend.listRegistries() + registries = summaries.map { Registry(server: $0.server) } + loadState = .loaded + } catch { + registries = [] + loadState = .unavailable(normalize(error).detail) + } + } + + /// Logs in to a registry. Returns whether it succeeded. The password is forwarded to the + /// backend and then discarded; it is never stored or written to the activity feed. + @discardableResult + public func login(server: String, username: String?, password: String?) async -> Bool { + do { + try await backend.registryLogin(server: server, username: username, password: password) + await refresh() + onActivity("Logged in to \(server).") + return true + } catch { + notice = LifecycleNotice(detail: normalize(error).detail) + return false + } + } + + public func logout(server: String) async { + do { + try await backend.registryLogout(server: server) + await refresh() + onActivity("Logged out of \(server).") + } catch { + notice = LifecycleNotice(detail: normalize(error).detail) + } + } + + /// Validates credentials against a registry (apple/container has no dry-run, so this is a + /// real login). The password is never retained or logged. + public func test( + server: String, username: String?, password: String? + ) async + -> RegistryTestResult + { + do { + try await backend.registryTest(server: server, username: username, password: password) + return .success + } catch { + return .failure(normalize(error).detail) + } + } +} diff --git a/Sources/CapsuleDomain/TaskCenter.swift b/Sources/CapsuleDomain/TaskCenter.swift new file mode 100644 index 0000000..419ff92 --- /dev/null +++ b/Sources/CapsuleDomain/TaskCenter.swift @@ -0,0 +1,198 @@ +// +// TaskCenter.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// NOTE: This module must remain free of UI and of `Foundation.Process`. The backing for +// the Activity pane's Tasks/Progress tabs: long image operations (pull/push/save/load) +// register an ``OperationTask`` that accumulates a transcript, records success/failure, and +// can be retried. Milestone 7 will expand this into the full activity surface. + +import CapsuleBackend +import Foundation +import Observation + +/// The kind of long-running operation a task represents. +public enum OperationKind: String, Sendable, CaseIterable { + case pull + case push + case save + case load + + public var title: String { + switch self { + case .pull: return "Pull" + case .push: return "Push" + case .save: return "Save" + case .load: return "Load" + } + } + + public var symbolName: String { + switch self { + case .pull: return "arrow.down.circle" + case .push: return "arrow.up.circle" + case .save: return "square.and.arrow.down" + case .load: return "square.and.arrow.up" + } + } +} + +/// One long-running operation: its live state, the accumulated transcript (UI-safe +/// ``LogLine``s, so the UI never sees a backend type), and a stable id. +@MainActor +@Observable +public final class OperationTask: Identifiable { + public let id: String + public let title: String + public let kind: OperationKind + public internal(set) var state: TaskState = .running(progress: nil) + public internal(set) var transcript: [LogLine] = [] + + private var nextLineID = 0 + /// The Swift task currently driving this operation; `wait()` awaits it (used by tests + /// and any caller that needs to act on completion). + fileprivate var driver: Task? + + init(id: String, title: String, kind: OperationKind) { + self.id = id + self.title = title + self.kind = kind + } + + /// The transcript joined into a single copyable string. + public var transcriptText: String { + transcript.map(\.text).joined(separator: "\n") + } + + fileprivate func append(source: OutputLine.Source, text: String) { + nextLineID += 1 + transcript.append(LogLine(id: nextLineID, source: source, text: text)) + } + + fileprivate func resetForRetry() { + transcript = [] + nextLineID = 0 + state = .running(progress: nil) + } + + /// Awaits the operation's completion. + public func wait() async { + await driver?.value + } +} + +@MainActor +@Observable +public final class TaskCenter { + public private(set) var tasks: [OperationTask] = [] + + private let normalize: @Sendable (any Error) -> CapsuleError + private var streams: [String: @Sendable () -> AsyncThrowingStream] = [:] + private var operations: [String: @Sendable () async throws -> Void] = [:] + private var successHandlers: [String: @MainActor () async -> Void] = [:] + private var counter = 0 + + public init( + normalize: @escaping @Sendable (any Error) -> CapsuleError = SystemStatusModel + .defaultNormalize + ) { + self.normalize = normalize + } + + /// The tasks still queued or running — the Progress tab's source. + public var activeTasks: [OperationTask] { tasks.filter { $0.state.isActive } } + + /// Registers and starts a streaming operation (pull/push). Each yielded line is appended + /// to the transcript; completion flips the state to succeeded/failed. + @discardableResult + public func runStreaming( + kind: OperationKind, + title: String, + onSuccess: (@MainActor () async -> Void)? = nil, + _ stream: @escaping @Sendable () -> AsyncThrowingStream + ) -> OperationTask { + let task = makeTask(kind: kind, title: title) + streams[task.id] = stream + successHandlers[task.id] = onSuccess + tasks.append(task) + drive(task) + return task + } + + /// Registers and starts a non-streaming operation (save/load). A thrown error is + /// captured in the transcript and the state flips to failed. + @discardableResult + public func runAsync( + kind: OperationKind, + title: String, + onSuccess: (@MainActor () async -> Void)? = nil, + _ operation: @escaping @Sendable () async throws -> Void + ) -> OperationTask { + let task = makeTask(kind: kind, title: title) + operations[task.id] = operation + successHandlers[task.id] = onSuccess + tasks.append(task) + drive(task) + return task + } + + /// Re-runs a finished (typically failed) task using its original operation. + public func retry(_ task: OperationTask) { + task.resetForRetry() + drive(task) + } + + /// Drops every finished task (and its retained operation), leaving active ones. + public func clearFinished() { + let removed = tasks.filter { !$0.state.isActive }.map(\.id) + for id in removed { + streams[id] = nil + operations[id] = nil + successHandlers[id] = nil + } + tasks.removeAll { !$0.state.isActive } + } + + // MARK: - Internals + + private func makeTask(kind: OperationKind, title: String) -> OperationTask { + counter += 1 + return OperationTask(id: "task-\(counter)", title: title, kind: kind) + } + + private func drive(_ task: OperationTask) { + if let stream = streams[task.id] { + task.driver = Task { @MainActor [weak self] in + task.state = .running(progress: nil) + do { + for try await line in stream() { + task.append(source: line.source, text: line.text) + } + await self?.successHandlers[task.id]?() + task.state = .succeeded + } catch { + self?.recordFailure(error, on: task) + } + } + } else if let operation = operations[task.id] { + task.driver = Task { @MainActor [weak self] in + task.state = .running(progress: nil) + do { + try await operation() + await self?.successHandlers[task.id]?() + task.state = .succeeded + } catch { + self?.recordFailure(error, on: task) + } + } + } + } + + private func recordFailure(_ error: any Error, on task: OperationTask) { + let detail = normalize(error).detail + task.append(source: .stderr, text: detail.explanation) + task.state = .failed(detail.diagnosticInfo) + } +} diff --git a/Sources/CapsuleUI/ActivityPaneView.swift b/Sources/CapsuleUI/ActivityPaneView.swift index 52ac906..5bff906 100644 --- a/Sources/CapsuleUI/ActivityPaneView.swift +++ b/Sources/CapsuleUI/ActivityPaneView.swift @@ -18,6 +18,8 @@ struct ActivityPaneView: View { @Bindable var shell: ShellState /// Recent activity lines (newest last) surfaced by the system model. let activityLog: [String] + /// The long-operation tasks (pull/push/save/load) shown in the Tasks/Progress tabs. + var taskCenter: TaskCenter? /// The active read-only attach session, if any (takes over the pane content). var attachSession: AttachSession? var terminalAvailable: Bool = false @@ -173,14 +175,65 @@ struct ActivityPaneView: View { case .logs: logsList case .tasks: - placeholder("No running tasks", systemImage: "checklist") + tasksList case .progress: - placeholder("No active transfers", systemImage: "chart.bar") + progressList case .terminal: placeholder("No terminal session", systemImage: "terminal") } } + @ViewBuilder + private var tasksList: some View { + let tasks = taskCenter?.tasks ?? [] + if tasks.isEmpty { + placeholder("No tasks yet", systemImage: "checklist") + } else { + VStack(spacing: 0) { + HStack { + Spacer() + Button("Clear Finished") { taskCenter?.clearFinished() } + .buttonStyle(.borderless) + .font(.caption) + .disabled(!tasks.contains { !$0.state.isActive }) + } + .padding(.horizontal, 10) + .padding(.top, 6) + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(tasks) { task in + TaskTranscriptView(task: task, onRetry: { taskCenter?.retry(task) }) + Divider() + } + } + .padding(10) + } + } + } + } + + @ViewBuilder + private var progressList: some View { + let active = taskCenter?.activeTasks ?? [] + if active.isEmpty { + placeholder("No active transfers", systemImage: "chart.bar") + } else { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(active) { task in + HStack(spacing: 10) { + Label(task.title, systemImage: task.kind.symbolName) + .font(.caption) + Spacer() + ProgressView().controlSize(.small) + } + } + } + .padding(10) + } + } + } + private var logsList: some View { ScrollView { VStack(alignment: .leading, spacing: 2) { diff --git a/Sources/CapsuleUI/AppShellView.swift b/Sources/CapsuleUI/AppShellView.swift index 4a68935..f1ee0e3 100644 --- a/Sources/CapsuleUI/AppShellView.swift +++ b/Sources/CapsuleUI/AppShellView.swift @@ -19,6 +19,9 @@ public struct AppShellView: View { @Bindable var browserModel: ContainerBrowserModel @Bindable var lifecycleModel: ContainerLifecycleModel let statsModel: ContainerStatsModel + @Bindable var imageBrowserModel: ImageBrowserModel + @Bindable var imageActionsModel: ImageActionsModel + @Bindable var taskCenter: TaskCenter let actions: ShellActions let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -29,6 +32,9 @@ public struct AppShellView: View { browserModel: ContainerBrowserModel, lifecycleModel: ContainerLifecycleModel, statsModel: ContainerStatsModel, + imageBrowserModel: ImageBrowserModel, + imageActionsModel: ImageActionsModel, + taskCenter: TaskCenter, actions: ShellActions, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() ) { @@ -38,6 +44,9 @@ public struct AppShellView: View { self.browserModel = browserModel self.lifecycleModel = lifecycleModel self.statsModel = statsModel + self.imageBrowserModel = imageBrowserModel + self.imageActionsModel = imageActionsModel + self.taskCenter = taskCenter self.actions = actions self.terminalSurfaceProvider = terminalSurfaceProvider } @@ -80,13 +89,25 @@ public struct AppShellView: View { .padding(.top, 6) } + if let notice = imageActionsModel.notice { + LifecycleNoticeView( + notice: notice, + onAction: { _ in imageActionsModel.notice = nil }, + onForceStop: { _ in imageActionsModel.notice = nil }, + onDismiss: { imageActionsModel.notice = nil } + ) + .padding(.top, 6) + } + ContentColumnView( section: shell.selection, health: systemModel.health, actions: actions, browserModel: browserModel, lifecycleModel: lifecycleModel, - statsModel: statsModel + statsModel: statsModel, + imageBrowserModel: imageBrowserModel, + imageActionsModel: imageActionsModel ) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -94,6 +115,7 @@ public struct AppShellView: View { ActivityPaneView( shell: shell, activityLog: shell.activityLog, + taskCenter: taskCenter, attachSession: lifecycleModel.attachSession, terminalAvailable: lifecycleModel.isTerminalAvailable, terminalSurfaceProvider: terminalSurfaceProvider, @@ -106,9 +128,12 @@ public struct AppShellView: View { } .inspector(isPresented: $shell.inspectorPresented) { Group { - if shell.selection == .containers { + switch shell.selection { + case .containers: ContainerInspectorView(model: browserModel, stats: statsModel) - } else { + case .images: + ImageInspectorView(model: imageBrowserModel) + default: InspectorView(section: shell.selection) } } diff --git a/Sources/CapsuleUI/ContainerListView.swift b/Sources/CapsuleUI/ContainerListView.swift index 57e6bf4..6264f11 100644 --- a/Sources/CapsuleUI/ContainerListView.swift +++ b/Sources/CapsuleUI/ContainerListView.swift @@ -310,6 +310,8 @@ struct ContainerListView: View { let name = model.allContainers.first { $0.id == id }?.name ?? id presentExportPanel(id: id, name: name) } + case .deleteImage: + break // image confirmations are routed from the images surface, not here } } diff --git a/Sources/CapsuleUI/ContentColumnView.swift b/Sources/CapsuleUI/ContentColumnView.swift index 005f861..74fbd98 100644 --- a/Sources/CapsuleUI/ContentColumnView.swift +++ b/Sources/CapsuleUI/ContentColumnView.swift @@ -18,6 +18,8 @@ struct ContentColumnView: View { let browserModel: ContainerBrowserModel let lifecycleModel: ContainerLifecycleModel let statsModel: ContainerStatsModel + let imageBrowserModel: ImageBrowserModel + let imageActionsModel: ImageActionsModel private var onRecover: (RecoveryAction) -> Void { actions.recover } @@ -38,9 +40,12 @@ struct ContentColumnView: View { /// sections keep the friendly placeholder until their milestones land. @ViewBuilder private var runningContent: some View { - if section == .containers { + switch section { + case .containers: ContainerListView(model: browserModel, lifecycle: lifecycleModel, stats: statsModel) - } else { + case .images: + ImageListView(model: imageBrowserModel, actions: imageActionsModel) + default: resourcePlaceholder } } diff --git a/Sources/CapsuleUI/ImageInspectorView.swift b/Sources/CapsuleUI/ImageInspectorView.swift new file mode 100644 index 0000000..5db3cd4 --- /dev/null +++ b/Sources/CapsuleUI/ImageInspectorView.swift @@ -0,0 +1,152 @@ +// +// ImageInspectorView.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The images inspector: a friendly Summary tab (repository, tag, full digest with a copy +// button, size, created) plus a Raw JSON tab fed by `image inspect`. The raw payload is +// always shown even when decoding drifts, and is copyable. Digest-centric copy actions +// live here so a user is never ambiguous about which image they acted on. AppKit +// (NSPasteboard) is permitted in the UI layer. + +import AppKit +import CapsuleDomain +import SwiftUI + +struct ImageInspectorView: View { + let model: ImageBrowserModel + + @State private var rawJSON = "" + @State private var isLoadingRaw = false + + init(model: ImageBrowserModel) { + self.model = model + } + + /// The single selected image, when exactly one row is selected. + private var solo: CapsuleDomain.Image? { + guard model.selection.count == 1, let id = model.selection.first else { return nil } + return model.selectedImages.first { $0.id == id } + } + + var body: some View { + TabView { + summaryTab + .tabItem { Label("Summary", systemImage: "info.circle") } + rawTab + .tabItem { Label("Raw JSON", systemImage: "curlybraces") } + } + .task(id: model.selection) { await loadRaw() } + } + + // MARK: Summary + + @ViewBuilder + private var summaryTab: some View { + if model.selection.isEmpty { + ContentUnavailableView( + "No Selection", systemImage: "square.stack.3d.up", + description: Text("Select an image to see its details.")) + } else if let image = solo { + Form { + Section("Image") { + LabeledContent("Repository", value: image.repository) + LabeledContent("Tag", value: image.tag ?? "—") + LabeledContent("Digest") { + HStack(spacing: 6) { + Text(image.shortDigest) + .font(.system(.body, design: .monospaced)) + Button { + Pasteboard.copy(image.digest) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help("Copy full digest (\(image.digest))") + } + } + LabeledContent("Size") { + Text(image.sizeBytes, format: .byteCount(style: .file)) + } + if let created = image.createdAt { + LabeledContent("Created") { Text(created, format: .dateTime) } + } + if image.isDangling { + LabeledContent("State", value: "Dangling (untagged)") + } + } + + Section { + Button { + Pasteboard.copy(image.digest) + } label: { + Label("Copy Digest", systemImage: "number") + } + Button { + Pasteboard.copy(image.reference) + } label: { + Label("Copy Reference", systemImage: "doc.on.doc") + } + .disabled(image.isDangling) + } + } + .formStyle(.grouped) + } else { + ContentUnavailableView( + "\(model.selection.count) Images Selected", + systemImage: "square.stack.3d.up", + description: Text("Select a single image to see its details.")) + } + } + + // MARK: Raw JSON + + @ViewBuilder + private var rawTab: some View { + if solo == nil { + ContentUnavailableView( + "No Selection", systemImage: "curlybraces", + description: Text("Select a single image to inspect its raw JSON.")) + } else { + VStack(spacing: 0) { + HStack { + Spacer() + Button { + Pasteboard.copy(rawJSON) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .disabled(rawJSON.isEmpty) + } + .padding(8) + + Divider() + + ScrollView([.vertical, .horizontal]) { + Text(rawJSON.isEmpty ? "No raw payload available." : rawJSON) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .overlay { + if isLoadingRaw { ProgressView() } + } + } + } + } + + // MARK: Actions + + private func loadRaw() async { + guard let image = solo else { + rawJSON = "" + return + } + isLoadingRaw = true + let inspection = await model.inspect(reference: image.id) + rawJSON = JSONPrettyPrinter.prettyPrint(inspection.rawJSON) + isLoadingRaw = false + } +} diff --git a/Sources/CapsuleUI/ImageListView.swift b/Sources/CapsuleUI/ImageListView.swift new file mode 100644 index 0000000..8d27698 --- /dev/null +++ b/Sources/CapsuleUI/ImageListView.swift @@ -0,0 +1,269 @@ +// +// ImageListView.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The images content column: a Table backed by ImageBrowserModel (read surface) with +// search, sort, and a dangling filter, plus digest-centric copy actions. Image operations +// (pull/push/save/load/tag/delete/prune) are layered on by ImageActionsModel + TaskCenter +// in later milestone phases. + +import AppKit +import CapsuleDomain +import SwiftUI + +/// Within this file `Image` is the domain model, not `SwiftUI.Image` (this view never uses +/// the latter). +private typealias Image = CapsuleDomain.Image + +struct ImageListView: View { + @Bindable var model: ImageBrowserModel + let actions: ImageActionsModel + + @State private var activeSheet: ImageSheet? + + init(model: ImageBrowserModel, actions: ImageActionsModel) { + self.model = model + self.actions = actions + } + + var body: some View { + content + .searchable(text: $model.searchText, prompt: "Search images") + .toolbar { toolbarContent } + .task { await model.refresh() } + .sheet(item: $activeSheet) { sheet in + switch sheet { + case let .tag(reference, digest): + TagImageSheet( + sourceReference: reference, sourceDigest: digest, + onTag: { target in + activeSheet = nil + Task { await actions.tag(source: reference, target: target) } + }, + onCancel: { activeSheet = nil }) + case .pull: + PullImageSheet( + onPull: { ref, plat in actions.pull(reference: ref, platform: plat) }, + onRetry: { actions.retryTask($0) }, + onClose: { activeSheet = nil }) + case let .push(reference, digest): + PushImageSheet( + initialReference: reference, initialDigest: digest, + onPush: { ref, plat in actions.push(reference: ref, platform: plat) }, + onRetry: { actions.retryTask($0) }, + onClose: { activeSheet = nil }) + case .load: + LoadImageSheet( + onLoad: { url in actions.load(from: url) }, + onRetry: { actions.retryTask($0) }, + onClose: { activeSheet = nil }) + case let .confirm(request): + ConfirmationSheet( + request: request, + onConfirm: { req in + activeSheet = nil + performConfirmed(req) + }, onCancel: { activeSheet = nil }) + case .prune: + ImagePruneSheet(actions: actions, onClose: { activeSheet = nil }) + } + } + } + + @ViewBuilder + private var content: some View { + switch model.loadState { + case .idle, .loading: + ProgressView("Loading images…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .unavailable(let detail): + ContentUnavailableView { + Label(detail.title, systemImage: "exclamationmark.triangle") + } description: { + Text(detail.explanation) + } + case .loaded: + if model.isEmptyButHealthy { + ContentUnavailableView { + Label("No images yet", systemImage: "square.stack.3d.up") + } description: { + Text("Images you pull or build will appear here.") + } + } else { + table + } + } + } + + private var table: some View { + Table(model.rows, selection: $model.selection) { + TableColumn("") { image in + if image.isDangling { + Circle().fill(.orange).frame(width: 8, height: 8) + .help("Dangling (untagged) image") + } else { + Circle().fill(.secondary).frame(width: 8, height: 8).opacity(0.4) + } + } + .width(18) + + TableColumn("Repository") { Text($0.repository) } + TableColumn("Tag") { image in + Text(image.tag ?? "—").foregroundStyle(.secondary) + } + TableColumn("Digest") { image in + Text(image.shortDigest) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + } + TableColumn("Size") { image in + Text(image.sizeBytes, format: .byteCount(style: .file)) + .foregroundStyle(.secondary) + } + TableColumn("Created") { image in + if let created = image.createdAt { + Text(created, format: .relative(presentation: .named)) + .foregroundStyle(.secondary) + } else { + Text("—").foregroundStyle(.secondary) + } + } + } + .contextMenu(forSelectionType: Image.ID.self) { ids in + rowMenu(for: ids) + } + .onDeleteCommand { requestDelete(ids: model.selection) } + .overlay { + if model.noMatches { ContentUnavailableView.search } + } + } + + @ViewBuilder + private func rowMenu(for ids: Set) -> some View { + let targets = images(for: ids) + if let single = targets.first, targets.count == 1 { + Button("Copy Digest") { Pasteboard.copy(single.digest) } + Button("Copy Reference") { Pasteboard.copy(single.reference) } + .disabled(single.isDangling) + Divider() + Button("Tag…") { + activeSheet = .tag(reference: single.id, digest: single.digest) + } + Button("Push…") { + activeSheet = .push(reference: single.reference, digest: single.digest) + } + .disabled(single.isDangling) + } + Button("Save…") { requestSave(targets) } + .disabled(targets.isEmpty) + Divider() + Button("Delete…", role: .destructive) { requestDelete(ids: ids) } + .disabled(targets.isEmpty) + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItemGroup { + Button { + activeSheet = .pull + } label: { + Label("Pull", systemImage: "arrow.down.circle") + } + .help("Pull an image from a registry") + + Button { + activeSheet = .load + } label: { + Label("Load", systemImage: "square.and.arrow.up") + } + .help("Load images from a tar archive") + } + + ToolbarItem(placement: .principal) { + Picker("Sort", selection: $model.sort) { + ForEach(ImageSort.allCases) { sort in + Text(sort.title).tag(sort) + } + } + .pickerStyle(.segmented) + .help("Sort images") + } + + ToolbarItemGroup { + Toggle(isOn: $model.showDanglingOnly) { + Label("Dangling only", systemImage: "questionmark.diamond") + } + .help("Show only untagged (dangling) images") + + Button { + activeSheet = .prune + } label: { + Label("Clean Up", systemImage: "trash") + } + .help("Remove dangling or unused images") + } + } + + // MARK: - Destructive actions + + /// Deleting an image always confirms; the delete itself surfaces a dependency conflict + /// (image still referenced by a container) as a notice. + private func requestDelete(ids: Set) { + let targets = images(for: ids) + guard !targets.isEmpty else { return } + if let request = ConfirmationRequest.deleteImage(ids: targets.map(\.id)) { + activeSheet = .confirm(request) + } + } + + private func performConfirmed(_ request: ConfirmationRequest) { + switch request.kind { + case .deleteImage: + Task { await actions.deleteAll(references: request.targetIDs) } + default: + break // other kinds are not raised by the images surface + } + } + + /// Saves the selected image(s) to a single tar archive via a Save panel. + private func requestSave(_ targets: [Image]) { + guard !targets.isEmpty else { return } + let references = targets.map(\.id) + let panel = NSSavePanel() + let suggested = targets.count == 1 ? targets[0].repository : "images" + panel.nameFieldStringValue = + "\((suggested as NSString).lastPathComponent).tar" + panel.canCreateDirectories = true + panel.title = "Save Image\(targets.count == 1 ? "" : "s")" + if panel.runModal() == .OK, let url = panel.url { + actions.save(references: references, to: url, platform: nil) + } + } + + private func images(for ids: Set) -> [Image] { + model.allImages.filter { ids.contains($0.id) } + } +} + +/// Which image sheet is presented. +enum ImageSheet: Identifiable { + case tag(reference: String, digest: String) + case pull + case push(reference: String, digest: String) + case load + case confirm(ConfirmationRequest) + case prune + + var id: String { + switch self { + case let .tag(reference, _): return "tag-\(reference)" + case .pull: return "pull" + case let .push(reference, _): return "push-\(reference)" + case .load: return "load" + case let .confirm(request): return "confirm-\(request.id)" + case .prune: return "prune" + } + } +} diff --git a/Sources/CapsuleUI/ImagePruneSheet.swift b/Sources/CapsuleUI/ImagePruneSheet.swift new file mode 100644 index 0000000..0df1c1e --- /dev/null +++ b/Sources/CapsuleUI/ImagePruneSheet.swift @@ -0,0 +1,105 @@ +// +// ImagePruneSheet.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The image Clean Up sheet: a scope toggle (dangling-only vs all unused) drives a live +// preview of exactly which images would be reclaimed, and reports the actual reclaimed +// result after running. Honest that the freed-space amount is only known afterwards. + +import CapsuleDomain +import SwiftUI + +struct ImagePruneSheet: View { + let actions: ImageActionsModel + let onClose: () -> Void + + @State private var scope: PruneScope = .dangling + @State private var targets: [CapsuleDomain.Image] = [] + @State private var isLoading = true + @State private var isPruning = false + @State private var resultMessage: String? + + private enum PruneScope: String, CaseIterable, Identifiable { + case dangling + case allUnused + var id: String { rawValue } + var title: String { self == .dangling ? "Dangling only" : "All unused" } + var isAll: Bool { self == .allUnused } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label("Clean Up Images", systemImage: "trash") + .font(.headline) + + if resultMessage == nil { + Picker("Scope", selection: $scope) { + ForEach(PruneScope.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .disabled(isPruning) + } + + if let resultMessage { + Text(resultMessage).font(.callout) + } else if isLoading { + ProgressView("Finding images…") + } else if targets.isEmpty { + Text("No \(scope == .dangling ? "dangling" : "unused") images to remove.") + .foregroundStyle(.secondary) + } else { + Text("\(targets.count) image(s) will be removed:") + .font(.callout) + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(targets) { image in + Text("• \(image.displayName) \(image.shortDigest)") + .font(.system(.callout, design: .monospaced)) + } + } + } + .frame(maxHeight: 160) + Text( + scope.isAll + ? "This preview is best-effort; the runtime decides the final set. Freed " + + "space is shown after cleanup." + : "Freed space can't be estimated in advance; the amount reclaimed is " + + "shown after." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + Button(resultMessage == nil ? "Cancel" : "Done", role: .cancel, action: onClose) + .keyboardShortcut(.cancelAction) + Spacer() + if resultMessage == nil { + Button("Clean Up", role: .destructive) { + Task { + isPruning = true + let summary = await actions.prune(all: scope.isAll) + resultMessage = summary.message + isPruning = false + } + } + .buttonStyle(.borderedProminent) + // Dangling detection is reliable, so block on an empty preview there. The + // all-unused preview is heuristic, so never block a prune the CLI would run. + .disabled(isLoading || isPruning || (scope == .dangling && targets.isEmpty)) + } + } + } + .padding(20) + .frame(width: 460) + .task(id: scope) { await reloadTargets() } + } + + private func reloadTargets() async { + isLoading = true + targets = await actions.computePruneTargets(all: scope.isAll) + isLoading = false + } +} diff --git a/Sources/CapsuleUI/LoadImageSheet.swift b/Sources/CapsuleUI/LoadImageSheet.swift new file mode 100644 index 0000000..57d0f28 --- /dev/null +++ b/Sources/CapsuleUI/LoadImageSheet.swift @@ -0,0 +1,105 @@ +// +// LoadImageSheet.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// Import images from an OCI-compatible tar archive: pick a file or drag one in. The archive +// type is validated before the backend is invoked, and the live transcript stays visible so +// a malformed archive surfaces a clear error. + +import CapsuleDomain +import SwiftUI +import UniformTypeIdentifiers + +struct LoadImageSheet: View { + let onLoad: (URL) -> OperationTask + let onRetry: (OperationTask) -> Void + let onClose: () -> Void + + @State private var selectedURL: URL? + @State private var validationError: String? + @State private var isTargeted = false + @State private var importing = false + @State private var task: OperationTask? + + /// Archive shapes the backend accepts: a tar (optionally gzipped) or an OCI directory. + private static let allowedExtensions: Set = ["tar", "gz", "tgz"] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label("Load Image", systemImage: "square.and.arrow.up") + .font(.headline) + + if let task { + TaskTranscriptView(task: task, onRetry: { onRetry(task) }) + HStack { + Spacer() + Button("Done", action: onClose).keyboardShortcut(.defaultAction) + } + } else { + dropZone + + if let selectedURL { + LabeledContent("Selected", value: selectedURL.lastPathComponent) + } + if let validationError { + Label(validationError, systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(.orange) + } + + HStack { + Button("Cancel", role: .cancel, action: onClose) + .keyboardShortcut(.cancelAction) + Button("Choose File…") { importing = true } + Spacer() + Button("Load") { + if let url = selectedURL { task = onLoad(url) } + } + .buttonStyle(.borderedProminent) + .disabled(selectedURL == nil) + } + } + } + .padding(20) + .frame(width: 480) + .fileImporter( + isPresented: $importing, allowedContentTypes: [.data, .directory], + allowsMultipleSelection: false + ) { result in + if case let .success(urls) = result, let url = urls.first { validate(url) } + } + } + + private var dropZone: some View { + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + style: StrokeStyle(lineWidth: 1.5, dash: [6]) + ) + .foregroundStyle(isTargeted ? Color.accentColor : .secondary) + .frame(height: 80) + .overlay { + Label("Drag a .tar archive here", systemImage: "tray.and.arrow.down") + .foregroundStyle(.secondary) + } + .onDrop(of: [.fileURL], isTargeted: $isTargeted) { providers in + guard let provider = providers.first else { return false } + _ = provider.loadObject(ofClass: URL.self) { url, _ in + if let url { Task { @MainActor in validate(url) } } + } + return true + } + } + + /// Validates the archive type before the backend is ever invoked. + private func validate(_ url: URL) { + let ext = url.pathExtension.lowercased() + if url.hasDirectoryPath || Self.allowedExtensions.contains(ext) { + selectedURL = url + validationError = nil + } else { + selectedURL = nil + validationError = "“\(url.lastPathComponent)” isn’t a .tar archive or OCI directory." + } + } +} diff --git a/Sources/CapsuleUI/Pasteboard.swift b/Sources/CapsuleUI/Pasteboard.swift new file mode 100644 index 0000000..ca68f0c --- /dev/null +++ b/Sources/CapsuleUI/Pasteboard.swift @@ -0,0 +1,19 @@ +// +// Pasteboard.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// A tiny wrapper over `NSPasteboard` for the copy actions in the image surface. AppKit is +// permitted in the UI layer (the arch guard only forbids backend imports there). + +import AppKit + +enum Pasteboard { + /// Replaces the general pasteboard's contents with `string`. + static func copy(_ string: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(string, forType: .string) + } +} diff --git a/Sources/CapsuleUI/PreferencesView.swift b/Sources/CapsuleUI/PreferencesView.swift new file mode 100644 index 0000000..6a120f2 --- /dev/null +++ b/Sources/CapsuleUI/PreferencesView.swift @@ -0,0 +1,27 @@ +// +// PreferencesView.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The app's Preferences window (⌘,). A tabbed container; today it hosts the Registries +// pane, with room for future settings panes. + +import CapsuleDomain +import SwiftUI + +public struct PreferencesView: View { + private let registriesModel: RegistriesModel + + public init(registriesModel: RegistriesModel) { + self.registriesModel = registriesModel + } + + public var body: some View { + TabView { + RegistriesView(model: registriesModel) + .tabItem { Label("Registries", systemImage: "person.badge.key") } + } + .frame(width: 520, height: 420) + } +} diff --git a/Sources/CapsuleUI/PullImageSheet.swift b/Sources/CapsuleUI/PullImageSheet.swift new file mode 100644 index 0000000..d497901 --- /dev/null +++ b/Sources/CapsuleUI/PullImageSheet.swift @@ -0,0 +1,66 @@ +// +// PullImageSheet.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// Pull an image by reference, with an optional platform constraint. Once started, the live +// transcript stays in the sheet so registry/auth/platform errors are visible without +// leaving the dialog; the task also lives on in the Activity pane. + +import CapsuleDomain +import SwiftUI + +struct PullImageSheet: View { + let onPull: (String, String?) -> OperationTask + let onRetry: (OperationTask) -> Void + let onClose: () -> Void + + @State private var reference = "" + @State private var platform = "" + @State private var task: OperationTask? + + private var trimmedReference: String { + reference.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label("Pull Image", systemImage: "arrow.down.circle") + .font(.headline) + + if let task { + TaskTranscriptView(task: task, onRetry: { onRetry(task) }) + HStack { + Spacer() + Button("Done", action: onClose).keyboardShortcut(.defaultAction) + } + } else { + Form { + TextField( + "Reference", text: $reference, + prompt: Text("e.g. docker.io/library/alpine:latest")) + TextField( + "Platform (optional)", text: $platform, + prompt: Text("e.g. linux/arm64")) + } + .formStyle(.grouped) + + HStack { + Button("Cancel", role: .cancel, action: onClose) + .keyboardShortcut(.cancelAction) + Spacer() + Button("Pull") { + let plat = platform.trimmingCharacters(in: .whitespacesAndNewlines) + task = onPull(trimmedReference, plat.isEmpty ? nil : plat) + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(trimmedReference.isEmpty) + } + } + } + .padding(20) + .frame(width: 480) + } +} diff --git a/Sources/CapsuleUI/PushImageSheet.swift b/Sources/CapsuleUI/PushImageSheet.swift new file mode 100644 index 0000000..ea11a5f --- /dev/null +++ b/Sources/CapsuleUI/PushImageSheet.swift @@ -0,0 +1,105 @@ +// +// PushImageSheet.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// Push an image to its registry. The destination is shown prominently and a confirmation +// step guards against an accidental push to the wrong repository. The live transcript stays +// in the sheet so auth/registry errors are visible. + +import CapsuleDomain +import SwiftUI + +struct PushImageSheet: View { + let initialReference: String + let initialDigest: String + let onPush: (String, String?) -> OperationTask + let onRetry: (OperationTask) -> Void + let onClose: () -> Void + + @State private var reference: String + @State private var platform = "" + @State private var confirming = false + @State private var task: OperationTask? + + init( + initialReference: String, + initialDigest: String, + onPush: @escaping (String, String?) -> OperationTask, + onRetry: @escaping (OperationTask) -> Void, + onClose: @escaping () -> Void + ) { + self.initialReference = initialReference + self.initialDigest = initialDigest + self.onPush = onPush + self.onRetry = onRetry + self.onClose = onClose + _reference = State(initialValue: initialReference) + } + + private var trimmedReference: String { + reference.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// The registry host the push targets. A reference with no `/` is a Docker Hub image + /// (`alpine:latest`), so its leading segment is the repo, never the host — only treat the + /// first path segment as a host when it actually looks like one. + private var destination: String { + guard let first = trimmedReference.split(separator: "/").first, + trimmedReference.contains("/") + else { return "docker.io" } + let host = String(first) + return host.contains(".") || host.contains(":") || host == "localhost" ? host : "docker.io" + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label("Push Image", systemImage: "arrow.up.circle") + .font(.headline) + + if let task { + TaskTranscriptView(task: task, onRetry: { onRetry(task) }) + HStack { + Spacer() + Button("Done", action: onClose).keyboardShortcut(.defaultAction) + } + } else { + Form { + LabeledContent("Digest") { + Text(initialDigest).font(.system(.callout, design: .monospaced)) + .lineLimit(1).truncationMode(.middle) + } + TextField("Reference (tag) to push", text: $reference) + TextField( + "Platform (optional)", text: $platform, prompt: Text("e.g. linux/amd64")) + LabeledContent("Destination", value: destination) + } + .formStyle(.grouped) + + HStack { + Button("Cancel", role: .cancel, action: onClose) + .keyboardShortcut(.cancelAction) + Spacer() + Button("Push…") { confirming = true } + .buttonStyle(.borderedProminent) + .disabled(trimmedReference.isEmpty) + } + } + } + .padding(20) + .frame(width: 480) + .confirmationDialog( + "Push “\(trimmedReference)” to \(destination)?", + isPresented: $confirming, titleVisibility: .visible + ) { + Button("Push to \(destination)") { + let plat = platform.trimmingCharacters(in: .whitespacesAndNewlines) + task = onPush(trimmedReference, plat.isEmpty ? nil : plat) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Make sure the destination repository is correct before continuing.") + } + } +} diff --git a/Sources/CapsuleUI/RegistriesView.swift b/Sources/CapsuleUI/RegistriesView.swift new file mode 100644 index 0000000..3473a3b --- /dev/null +++ b/Sources/CapsuleUI/RegistriesView.swift @@ -0,0 +1,118 @@ +// +// RegistriesView.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The Registries preferences pane: lists registry logins, adds one via a login sheet that +// never echoes secrets, and removes one with a confirmation. Backed by `RegistriesModel`. + +import CapsuleDomain +import SwiftUI + +struct RegistriesView: View { + @Bindable var model: RegistriesModel + + @State private var showingLogin = false + @State private var pendingLogout: Registry? + + init(model: RegistriesModel) { + self.model = model + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Registry Logins") + .font(.headline) + Text( + "Credentials are stored by the container runtime. Capsule never displays or " + + "logs your password." + ) + .font(.caption) + .foregroundStyle(.secondary) + + content + + if let notice = model.notice { + Label(notice.detail.explanation, systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(.orange) + } + + HStack { + Button { + showingLogin = true + } label: { + Label("Add Registry…", systemImage: "plus") + } + Spacer() + } + } + .padding(20) + .frame(minWidth: 460, minHeight: 320, alignment: .topLeading) + .task { await model.refresh() } + .sheet(isPresented: $showingLogin) { + RegistryLoginSheet( + onLogin: { server, user, pass in + let ok = await model.login(server: server, username: user, password: pass) + return ok ? nil : model.notice?.detail + }, + onTest: { server, user, pass in + await model.test(server: server, username: user, password: pass) + }, + onClose: { showingLogin = false }) + } + .confirmationDialog( + "Remove the login for \(pendingLogout?.server ?? "")?", + isPresented: Binding( + get: { pendingLogout != nil }, set: { if !$0 { pendingLogout = nil } }), + titleVisibility: .visible + ) { + Button("Log Out", role: .destructive) { + if let registry = pendingLogout { + pendingLogout = nil + Task { await model.logout(server: registry.server) } + } + } + Button("Cancel", role: .cancel) { pendingLogout = nil } + } message: { + Text("Pushes and pulls that rely on this registry will need new credentials.") + } + } + + @ViewBuilder + private var content: some View { + switch model.loadState { + case .idle, .loading: + ProgressView().frame(maxWidth: .infinity) + case .unavailable(let detail): + ContentUnavailableView { + Label(detail.title, systemImage: "exclamationmark.triangle") + } description: { + Text(detail.explanation) + } + case .loaded: + if model.registries.isEmpty { + ContentUnavailableView( + "No registry logins", systemImage: "person.badge.key", + description: Text("Add a registry to push and pull private images.")) + } else { + List { + ForEach(model.registries) { registry in + HStack { + Label(registry.server, systemImage: "server.rack") + Spacer() + Button(role: .destructive) { + pendingLogout = registry + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .help("Log out of \(registry.server)") + } + } + } + .frame(minHeight: 160) + } + } + } +} diff --git a/Sources/CapsuleUI/RegistryLoginSheet.swift b/Sources/CapsuleUI/RegistryLoginSheet.swift new file mode 100644 index 0000000..3e5591f --- /dev/null +++ b/Sources/CapsuleUI/RegistryLoginSheet.swift @@ -0,0 +1,109 @@ +// +// RegistryLoginSheet.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// Log in to a registry. The password is entered in a `SecureField` and is never echoed, +// stored, or logged. A Test action validates credentials before committing, and any +// auth/registry failure is shown verbatim so the user can act on it. + +import CapsuleDomain +import SwiftUI + +struct RegistryLoginSheet: View { + /// Returns nil on success, or the failure detail to display. + let onLogin: (String, String?, String?) async -> ErrorDetail? + let onTest: (String, String?, String?) async -> RegistryTestResult + let onClose: () -> Void + + @State private var server = "" + @State private var username = "" + @State private var password = "" + @State private var busy = false + @State private var testResult: RegistryTestResult? + @State private var failure: ErrorDetail? + + private var trimmedServer: String { + server.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var credentials: (String?, String?) { + ( + username.isEmpty ? nil : username, + password.isEmpty ? nil : password + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label("Registry Login", systemImage: "person.badge.key") + .font(.headline) + + Form { + TextField("Server", text: $server, prompt: Text("e.g. ghcr.io")) + TextField("Username (optional)", text: $username) + SecureField("Password", text: $password) + } + .formStyle(.grouped) + + if let testResult { + switch testResult { + case .success: + Label("Credentials are valid.", systemImage: "checkmark.seal") + .font(.callout).foregroundStyle(.green) + case let .failure(detail): + failureLabel(detail) + } + } + if let failure { + failureLabel(failure) + } + + HStack { + Button("Cancel", role: .cancel, action: onClose) + .keyboardShortcut(.cancelAction) + Button("Test") { + let (user, pass) = credentials + Task { + busy = true + failure = nil + testResult = await onTest(trimmedServer, user, pass) + busy = false + } + } + .disabled(trimmedServer.isEmpty || busy) + Spacer() + Button("Log In") { + let (user, pass) = credentials + Task { + busy = true + testResult = nil + if let detail = await onLogin(trimmedServer, user, pass) { + failure = detail + } else { + onClose() + } + busy = false + } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(trimmedServer.isEmpty || busy) + } + } + .padding(20) + .frame(width: 460) + } + + private func failureLabel(_ detail: ErrorDetail) -> some View { + VStack(alignment: .leading, spacing: 2) { + Label(detail.title, systemImage: "exclamationmark.triangle") + .font(.callout).foregroundStyle(.orange) + Text(detail.explanation) + .font(.caption).foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/Sources/CapsuleUI/RootView.swift b/Sources/CapsuleUI/RootView.swift index 429ed76..fc3782e 100644 --- a/Sources/CapsuleUI/RootView.swift +++ b/Sources/CapsuleUI/RootView.swift @@ -21,6 +21,9 @@ public struct RootView: View { private let browserModel: ContainerBrowserModel private let lifecycleModel: ContainerLifecycleModel private let statsModel: ContainerStatsModel + private let imageBrowserModel: ImageBrowserModel + private let imageActionsModel: ImageActionsModel + private let taskCenter: TaskCenter private let actions: ShellActions private let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -33,6 +36,9 @@ public struct RootView: View { browserModel: ContainerBrowserModel, lifecycleModel: ContainerLifecycleModel, statsModel: ContainerStatsModel, + imageBrowserModel: ImageBrowserModel, + imageActionsModel: ImageActionsModel, + taskCenter: TaskCenter, actions: ShellActions, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() ) { @@ -42,6 +48,9 @@ public struct RootView: View { self.browserModel = browserModel self.lifecycleModel = lifecycleModel self.statsModel = statsModel + self.imageBrowserModel = imageBrowserModel + self.imageActionsModel = imageActionsModel + self.taskCenter = taskCenter self.actions = actions self.terminalSurfaceProvider = terminalSurfaceProvider } @@ -54,6 +63,9 @@ public struct RootView: View { browserModel: browserModel, lifecycleModel: lifecycleModel, statsModel: statsModel, + imageBrowserModel: imageBrowserModel, + imageActionsModel: imageActionsModel, + taskCenter: taskCenter, actions: actions, terminalSurfaceProvider: terminalSurfaceProvider ) diff --git a/Sources/CapsuleUI/TagImageSheet.swift b/Sources/CapsuleUI/TagImageSheet.swift new file mode 100644 index 0000000..37c87e5 --- /dev/null +++ b/Sources/CapsuleUI/TagImageSheet.swift @@ -0,0 +1,54 @@ +// +// TagImageSheet.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// Creates a new reference for an existing image. The source — including its digest — stays +// visible so the user always knows exactly which image they are retagging. + +import SwiftUI + +struct TagImageSheet: View { + let sourceReference: String + let sourceDigest: String + let onTag: (String) -> Void + let onCancel: () -> Void + + @State private var target = "" + + private var trimmedTarget: String { + target.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label("Tag Image", systemImage: "tag") + .font(.headline) + + Form { + LabeledContent("Source", value: sourceReference) + LabeledContent("Digest") { + Text(sourceDigest).font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(1).truncationMode(.middle) + } + TextField("New reference", text: $target, prompt: Text("e.g. ghcr.io/me/app:1.0")) + .textFieldStyle(.roundedBorder) + } + .formStyle(.grouped) + + HStack { + Button("Cancel", role: .cancel, action: onCancel) + .keyboardShortcut(.cancelAction) + Spacer() + Button("Create Tag") { onTag(trimmedTarget) } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(trimmedTarget.isEmpty) + } + } + .padding(20) + .frame(width: 460) + } +} diff --git a/Sources/CapsuleUI/TaskTranscriptView.swift b/Sources/CapsuleUI/TaskTranscriptView.swift new file mode 100644 index 0000000..8d4a226 --- /dev/null +++ b/Sources/CapsuleUI/TaskTranscriptView.swift @@ -0,0 +1,83 @@ +// +// TaskTranscriptView.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// A single long-operation task: its state, a scrollable raw transcript (kept visible on +// failure so registry/auth/platform errors are never hidden), and a Retry on failure. +// Reused by the Activity pane's Tasks tab and the transfer sheets. + +import CapsuleDomain +import SwiftUI + +struct TaskTranscriptView: View { + let task: OperationTask + var onRetry: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + stateIcon + Text(task.title).font(.callout.weight(.medium)) + Spacer() + if task.transcript.isEmpty == false { + Button { + Pasteboard.copy(task.transcriptText) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help("Copy transcript") + } + if isFailed, let onRetry { + Button("Retry", action: onRetry) + } + } + + if !task.transcript.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 1) { + ForEach(task.transcript) { line in + Text(line.text) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(color(for: line)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + .padding(6) + } + .frame(maxHeight: 160) + .background(CapsuleColors.activitySurface) + } + } + } + + private var isFailed: Bool { + if case .failed = task.state { return true } + return false + } + + /// stdout is primary; stderr is dimmed while running/succeeding (many CLIs emit progress + /// there) and only turns red once the task has actually failed, so a successful pull + /// never looks alarming. + private func color(for line: LogLine) -> Color { + guard line.stream == .error else { return .primary } + return isFailed ? .red : .secondary + } + + @ViewBuilder + private var stateIcon: some View { + switch task.state { + case .idle, .queued: + Image(systemName: "clock").foregroundStyle(.secondary) + case .running: + ProgressView().controlSize(.small) + case .succeeded: + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + case .failed: + Image(systemName: "xmark.circle.fill").foregroundStyle(.red) + } + } +} diff --git a/Tests/CapsuleUnitTests/CLICommandTests.swift b/Tests/CapsuleUnitTests/CLICommandTests.swift index 24a434b..fec11f1 100644 --- a/Tests/CapsuleUnitTests/CLICommandTests.swift +++ b/Tests/CapsuleUnitTests/CLICommandTests.swift @@ -65,10 +65,61 @@ final class CLICommandTests: XCTestCase { CLICommand.inspectImage(reference: "alpine"), ["image", "inspect", "alpine"] ) - XCTAssertEqual(CLICommand.pullImage(reference: "alpine"), ["image", "pull", "alpine"]) + XCTAssertEqual( + CLICommand.pullImage(reference: "alpine", platform: nil), ["image", "pull", "alpine"]) XCTAssertEqual(CLICommand.removeImage(reference: "alpine"), ["image", "delete", "alpine"]) } + func testImageTransferAndMaintenanceCommands() { + XCTAssertEqual( + CLICommand.pullImage(reference: "alpine", platform: "linux/arm64"), + ["image", "pull", "--platform", "linux/arm64", "alpine"]) + XCTAssertEqual( + CLICommand.pushImage(reference: "ghcr.io/me/app:1", platform: nil), + ["image", "push", "ghcr.io/me/app:1"]) + XCTAssertEqual( + CLICommand.pushImage(reference: "ghcr.io/me/app:1", platform: "linux/amd64"), + ["image", "push", "--platform", "linux/amd64", "ghcr.io/me/app:1"]) + XCTAssertEqual( + CLICommand.saveImage( + references: ["alpine:latest"], to: URL(fileURLWithPath: "/tmp/a.tar"), + platform: nil), + ["image", "save", "--output", "/tmp/a.tar", "alpine:latest"]) + XCTAssertEqual( + CLICommand.saveImage( + references: ["a", "b"], to: URL(fileURLWithPath: "/tmp/a.tar"), + platform: "linux/amd64"), + ["image", "save", "--output", "/tmp/a.tar", "--platform", "linux/amd64", "a", "b"]) + XCTAssertEqual( + CLICommand.loadImage(from: URL(fileURLWithPath: "/tmp/a.tar")), + ["image", "load", "--input", "/tmp/a.tar"]) + XCTAssertEqual( + CLICommand.tagImage(source: "alpine:latest", target: "ghcr.io/me/alpine:1"), + ["image", "tag", "alpine:latest", "ghcr.io/me/alpine:1"]) + XCTAssertEqual(CLICommand.pruneImages(all: false), ["image", "prune"]) + XCTAssertEqual(CLICommand.pruneImages(all: true), ["image", "prune", "--all"]) + } + + /// The login argv must carry `--password-stdin` and the username, but NEVER the + /// password — the secret is delivered through the child's stdin, so it cannot leak + /// via `ps`, the debug log line, an error's `command:`, or any task transcript. + func testRegistryLoginNeverPutsSecretOnArgv() { + let argv = CLICommand.registryLogin(server: "ghcr.io", username: "me") + XCTAssertEqual( + argv, ["registry", "login", "--username", "me", "--password-stdin", "ghcr.io"]) + XCTAssertFalse( + argv.contains { + $0.localizedCaseInsensitiveContains("password") && $0 != "--password-stdin" + }, + "no literal password may appear in argv") + + XCTAssertEqual( + CLICommand.registryLogin(server: "ghcr.io", username: nil), + ["registry", "login", "--password-stdin", "ghcr.io"]) + XCTAssertEqual( + CLICommand.registryLogout(server: "ghcr.io"), ["registry", "logout", "ghcr.io"]) + } + func testOtherFamilies() { XCTAssertEqual(CLICommand.listVolumes(), ["volume", "list", "--format", "json"]) XCTAssertEqual(CLICommand.listNetworks(), ["network", "list", "--format", "json"]) diff --git a/Tests/CapsuleUnitTests/CLIContainerBackendTests.swift b/Tests/CapsuleUnitTests/CLIContainerBackendTests.swift index 3d53be2..d345baf 100644 --- a/Tests/CapsuleUnitTests/CLIContainerBackendTests.swift +++ b/Tests/CapsuleUnitTests/CLIContainerBackendTests.swift @@ -288,4 +288,76 @@ final class CLIContainerBackendTests: XCTestCase { XCTAssertEqual(stub.lastCall, ["image", "delete", "alpine"]) } + + func testImageMaintenanceCommandsIssueCorrectArgv() async throws { + let stub = StubProcessRunner() + let backend = makeBackend(stub) + + try await backend.saveImage( + references: ["alpine:latest"], to: URL(fileURLWithPath: "/tmp/a.tar"), platform: nil) + XCTAssertEqual(stub.lastCall, ["image", "save", "--output", "/tmp/a.tar", "alpine:latest"]) + + try await backend.loadImage(from: URL(fileURLWithPath: "/tmp/a.tar")) + XCTAssertEqual(stub.lastCall, ["image", "load", "--input", "/tmp/a.tar"]) + + try await backend.tagImage(source: "alpine:latest", target: "ghcr.io/me/alpine:1") + XCTAssertEqual(stub.lastCall, ["image", "tag", "alpine:latest", "ghcr.io/me/alpine:1"]) + + stub.result = CommandResult(exitCode: 0, stdout: "Reclaimed 5 MB in disk space", stderr: "") + let pruned = try await backend.pruneImages(all: true) + XCTAssertEqual(stub.lastCall, ["image", "prune", "--all"]) + XCTAssertEqual(pruned.reclaimedDescription, "Reclaimed 5 MB in disk space") + } + + func testPushImageStreamsAndBuildsArgv() async throws { + let stub = StubProcessRunner() + stub.streamLines = [OutputLine(source: .stdout, text: "Pushing")] + + var received: [String] = [] + for try await line in makeBackend(stub).pushImage( + reference: "ghcr.io/me/app:1", platform: "linux/amd64") + { + received.append(line.text) + } + + XCTAssertEqual(received, ["Pushing"]) + XCTAssertEqual( + stub.lastCall, ["image", "push", "--platform", "linux/amd64", "ghcr.io/me/app:1"]) + } + + // MARK: - Registry credential safety + + /// The defining secret-safety guarantee: the password reaches the CLI through stdin, + /// and NEVER appears anywhere in the argument vector. + func testRegistryLoginFeedsSecretThroughStdinNotArgv() async throws { + let stub = StubProcessRunner() + + try await makeBackend(stub).registryLogin( + server: "ghcr.io", username: "me", password: "sup3r-s3cret") + + XCTAssertEqual( + stub.lastCall, ["registry", "login", "--username", "me", "--password-stdin", "ghcr.io"]) + XCTAssertFalse( + stub.lastCall?.contains("sup3r-s3cret") ?? false, + "the password must never appear on argv") + XCTAssertEqual( + stub.lastStandardInput, "sup3r-s3cret", "the password is delivered via stdin") + } + + func testRegistryTestAlsoUsesStdinAndDoesNotLeakOnArgv() async throws { + let stub = StubProcessRunner() + + try await makeBackend(stub).registryTest( + server: "ghcr.io", username: nil, password: "another-secret") + + XCTAssertEqual(stub.lastCall, ["registry", "login", "--password-stdin", "ghcr.io"]) + XCTAssertFalse(stub.lastCall?.contains("another-secret") ?? false) + XCTAssertEqual(stub.lastStandardInput, "another-secret") + } + + func testRegistryLogoutBuildsArgv() async throws { + let stub = StubProcessRunner() + try await makeBackend(stub).registryLogout(server: "ghcr.io") + XCTAssertEqual(stub.lastCall, ["registry", "logout", "ghcr.io"]) + } } diff --git a/Tests/CapsuleUnitTests/ConfirmationTests.swift b/Tests/CapsuleUnitTests/ConfirmationTests.swift index c58dcd8..cf774a9 100644 --- a/Tests/CapsuleUnitTests/ConfirmationTests.swift +++ b/Tests/CapsuleUnitTests/ConfirmationTests.swift @@ -29,4 +29,18 @@ final class ConfirmationTests: XCTestCase { XCTAssertEqual(r.kind, .exportNotStopped) XCTAssertEqual(r.targetIDs, ["a"]) } + + // MARK: - Images (M6) + + func testDeleteImageAlwaysConfirmsSingleAndBulk() { + let single = ConfirmationRequest.deleteImage(ids: ["alpine:latest"]) + XCTAssertEqual(single?.kind, .deleteImage) + XCTAssertEqual(single?.targetIDs, ["alpine:latest"]) + XCTAssertTrue(single?.title.localizedCaseInsensitiveContains("image") ?? false) + + let bulk = ConfirmationRequest.deleteImage(ids: ["a:1", "b:1"]) + XCTAssertTrue(bulk?.title.contains("2") ?? false) + + XCTAssertNil(ConfirmationRequest.deleteImage(ids: []), "nothing selected → no sheet") + } } diff --git a/Tests/CapsuleUnitTests/DomainModelTests.swift b/Tests/CapsuleUnitTests/DomainModelTests.swift index a7f25cf..34f755d 100644 --- a/Tests/CapsuleUnitTests/DomainModelTests.swift +++ b/Tests/CapsuleUnitTests/DomainModelTests.swift @@ -47,11 +47,16 @@ final class DomainModelTests: XCTestCase { } func testImageMapsBackendSummary() { - let summary = ImageSummary(id: "sha256:abc", reference: "nginx:latest", sizeBytes: 4096) + let summary = ImageSummary( + id: "sha256:abc", reference: "nginx:latest", sizeBytes: 4096, digest: "sha256:abc") let image = Image(summary: summary) - XCTAssertEqual(image.id, "sha256:abc") + // A tagged image is identified by its (unique) reference; the digest is retained. + XCTAssertEqual(image.id, "nginx:latest") XCTAssertEqual(image.reference, "nginx:latest") + XCTAssertEqual(image.repository, "nginx") + XCTAssertEqual(image.tag, "latest") + XCTAssertEqual(image.digest, "sha256:abc") XCTAssertEqual(image.sizeBytes, 4096) } diff --git a/Tests/CapsuleUnitTests/ImageActionsModelTests.swift b/Tests/CapsuleUnitTests/ImageActionsModelTests.swift new file mode 100644 index 0000000..c93dbd3 --- /dev/null +++ b/Tests/CapsuleUnitTests/ImageActionsModelTests.swift @@ -0,0 +1,229 @@ +// +// ImageActionsModelTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The non-streaming image operations: tag, delete (single/bulk, idempotent not-found, and +// dependency-conflict surfacing), and prune (target preview + result). + +import CapsuleBackend +import CapsuleDiagnostics +import XCTest + +@testable import CapsuleDomain + +@MainActor +final class ImageActionsModelTests: XCTestCase { + private func img(_ ref: String, digest: String = "sha256:a") -> ImageSummary { + ImageSummary(id: digest, reference: ref, sizeBytes: 1, digest: digest) + } + + func testTagSucceedsReloadsAndLogs() async { + let backend = MockBackend(images: [img("alpine:latest")]) + var reloads = 0 + let model = ImageActionsModel(backend: backend, reloadList: { reloads += 1 }) + + let ok = await model.tag(source: "alpine:latest", target: "alpine:pinned") + + XCTAssertTrue(ok) + XCTAssertEqual(backend.lastTag?.target, "alpine:pinned") + XCTAssertEqual(reloads, 1) + XCTAssertNil(model.notice) + } + + func testTagFailureSetsNoticeAndReturnsFalse() async { + let backend = MockBackend(images: [img("alpine:latest")]) + backend.failure = BackendError.nonZeroExit( + command: "container image tag", code: 1, stderr: "invalid reference") + let model = ImageActionsModel(backend: backend) + + let ok = await model.tag(source: "alpine:latest", target: "BAD REF") + + XCTAssertFalse(ok) + XCTAssertNotNil(model.notice) + } + + func testDeleteRemovesAndReloads() async { + let backend = MockBackend(images: [img("alpine:latest")]) + var reloads = 0 + let model = ImageActionsModel(backend: backend, reloadList: { reloads += 1 }) + + await model.delete(reference: "alpine:latest") + + XCTAssertEqual(reloads, 1) + XCTAssertNil(model.notice) + } + + func testDeleteOfAlreadyAbsentImageIsBenign() async { + let backend = MockBackend(images: []) + backend.failure = BackendError.nonZeroExit( + command: "container image delete", code: 1, stderr: "Error: image not found") + var reloads = 0 + let model = ImageActionsModel(backend: backend, reloadList: { reloads += 1 }) + + await model.delete(reference: "ghost:1") + + XCTAssertNil(model.notice, "a not-found delete is a benign success") + XCTAssertEqual(reloads, 1) + } + + func testDeleteSurfacesDependencyConflictRawStderr() async { + let backend = MockBackend(images: [img("alpine:latest")]) + backend.failure = BackendError.nonZeroExit( + command: "container image delete", code: 1, + stderr: "Error: image is referenced by container \"web\"") + let model = ImageActionsModel( + backend: backend, normalize: { ErrorNormalizer.normalize($0) }) + + await model.delete(reference: "alpine:latest") + + let notice = try? XCTUnwrap(model.notice) + XCTAssertTrue( + notice?.detail.explanation.contains("referenced by container") ?? false, + "the dependency conflict must be visible verbatim") + } + + func testDeleteAllContinuesPastFailures() async { + let backend = MockBackend(images: [img("a:1"), img("b:1", digest: "sha256:b")]) + let model = ImageActionsModel(backend: backend) + + await model.deleteAll(references: ["a:1", "b:1"]) + + let remaining = try? await backend.listImages() + XCTAssertTrue(remaining?.isEmpty ?? false) + } + + func testDeleteAllAggregatesEveryDependencyConflict() async { + let backend = MockBackend(images: [img("a:1"), img("b:1", digest: "sha256:b")]) + backend.failure = BackendError.nonZeroExit( + command: "container image delete", code: 1, stderr: "image is referenced by container") + let model = ImageActionsModel(backend: backend) + + await model.deleteAll(references: ["a:1", "b:1"]) + + let explanation = model.notice?.detail.explanation ?? "" + XCTAssertTrue(explanation.contains("a:1")) + XCTAssertTrue(explanation.contains("b:1"), "every conflict is surfaced, not just the last") + } + + func testComputePruneTargetsReturnsDanglingByDefault() async { + let backend = MockBackend(images: [ + img("alpine:latest", digest: "sha256:1"), + img(":", digest: "sha256:2"), + ]) + let model = ImageActionsModel(backend: backend) + + let targets = await model.computePruneTargets(all: false) + + XCTAssertEqual(targets.map(\.id), ["sha256:2"]) + } + + func testComputePruneTargetsAllReturnsImagesNoContainerReferences() async { + let backend = MockBackend( + containers: [ + ContainerSummary(id: "c", name: "web", image: "alpine:latest", state: "running") + ], + images: [ + img("alpine:latest", digest: "sha256:1"), + img("postgres:16", digest: "sha256:2"), + ]) + let model = ImageActionsModel(backend: backend) + + let targets = await model.computePruneTargets(all: true) + + XCTAssertEqual( + targets.map(\.reference), ["postgres:16"], + "only images unreferenced by any container are prune candidates") + } + + func testPruneReturnsSummaryAndReloads() async { + let backend = MockBackend(images: [img(":", digest: "sha256:2")]) + var reloads = 0 + let model = ImageActionsModel(backend: backend, reloadList: { reloads += 1 }) + + let summary = await model.prune(all: false) + + XCTAssertFalse(summary.message.isEmpty) + XCTAssertEqual(backend.prunedAll, false) + XCTAssertEqual(reloads, 1) + } + + func testPruneFailureSetsNotice() async { + let backend = MockBackend() + backend.failure = BackendError.nonZeroExit( + command: "container image prune", code: 1, stderr: "boom") + let model = ImageActionsModel(backend: backend) + + _ = await model.prune(all: true) + + XCTAssertNotNil(model.notice) + } + + // MARK: - Transfers (register tasks in the TaskCenter) + + func testPullRegistersAStreamingTaskAndRefreshesOnSuccess() async { + let backend = MockBackend( + images: [img("alpine:latest")], + logLines: [ + OutputLine(source: .stdout, text: "Pulling"), + OutputLine(source: .stdout, text: "Done"), + ]) + let center = TaskCenter() + var reloads = 0 + let model = ImageActionsModel( + backend: backend, reloadList: { reloads += 1 }, taskCenter: center) + + let task = model.pull(reference: "alpine:latest", platform: nil) + await task.wait() + + XCTAssertEqual(task.kind, .pull) + XCTAssertEqual(task.state, .succeeded) + XCTAssertEqual(task.transcript.map(\.text), ["Pulling", "Done"]) + XCTAssertEqual(reloads, 1, "a successful pull refreshes the image list") + XCTAssertEqual(center.tasks.count, 1) + } + + func testPushRegistersAStreamingTask() async { + let backend = MockBackend(logLines: [OutputLine(source: .stdout, text: "Pushing")]) + let center = TaskCenter() + let model = ImageActionsModel(backend: backend, taskCenter: center) + + let task = model.push(reference: "ghcr.io/me/app:1", platform: nil) + await task.wait() + + XCTAssertEqual(task.kind, .push) + XCTAssertEqual(task.state, .succeeded) + } + + func testSaveRegistersANonStreamingTaskAndRecordsURL() async { + let backend = MockBackend(images: [img("alpine:latest")]) + let center = TaskCenter() + let model = ImageActionsModel(backend: backend, taskCenter: center) + let url = URL(fileURLWithPath: "/tmp/out.tar") + + let task = model.save(references: ["alpine:latest"], to: url, platform: nil) + await task.wait() + + XCTAssertEqual(task.kind, .save) + XCTAssertEqual(task.state, .succeeded) + XCTAssertEqual(backend.lastSavedURL, url) + } + + func testLoadRegistersANonStreamingTaskAndRefreshesOnSuccess() async { + let backend = MockBackend(images: []) + let center = TaskCenter() + var reloads = 0 + let model = ImageActionsModel( + backend: backend, reloadList: { reloads += 1 }, taskCenter: center) + let url = URL(fileURLWithPath: "/tmp/in.tar") + + let task = model.load(from: url) + await task.wait() + + XCTAssertEqual(task.kind, .load) + XCTAssertEqual(task.state, .succeeded) + XCTAssertEqual(backend.lastLoadedURL, url) + XCTAssertEqual(reloads, 1) + } +} diff --git a/Tests/CapsuleUnitTests/ImageBrowserModelTests.swift b/Tests/CapsuleUnitTests/ImageBrowserModelTests.swift new file mode 100644 index 0000000..f58d256 --- /dev/null +++ b/Tests/CapsuleUnitTests/ImageBrowserModelTests.swift @@ -0,0 +1,144 @@ +// +// ImageBrowserModelTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The images read surface: loading (distinguishing a down service from a genuinely empty +// list), search, sort, the dangling filter, selection, and raw-retaining inspect. + +import CapsuleBackend +import XCTest + +@testable import CapsuleDomain + +@MainActor +final class ImageBrowserModelTests: XCTestCase { + private func image( + _ ref: String, size: Int64 = 100, created: String? = nil, digest: String = "sha256:a" + ) + -> ImageSummary + { + ImageSummary( + id: digest, reference: ref, sizeBytes: size, digest: digest, createdAt: created) + } + + func testRefreshLoadsAndMapsImages() async { + let backend = MockBackend(images: [ + image("alpine:latest"), image("postgres:16", digest: "sha256:b"), + ]) + let model = ImageBrowserModel(backend: backend) + + await model.refresh() + + XCTAssertEqual(model.loadState, .loaded) + XCTAssertEqual(model.allImages.count, 2) + XCTAssertEqual(Set(model.allImages.map(\.reference)), ["alpine:latest", "postgres:16"]) + } + + func testUnavailableIsDistinctFromEmpty() async { + let backend = MockBackend(images: []) + backend.failure = BackendError.nonZeroExit( + command: "container image list", code: 1, stderr: "Connection refused") + let model = ImageBrowserModel(backend: backend) + + await model.refresh() + + guard case .unavailable = model.loadState else { + return XCTFail("a daemon failure must surface as .unavailable, not an empty list") + } + XCTAssertFalse(model.isEmptyButHealthy) + } + + func testEmptyButHealthyWhenServiceUpButNoImages() async { + let model = ImageBrowserModel(backend: MockBackend(images: [])) + await model.refresh() + XCTAssertEqual(model.loadState, .loaded) + XCTAssertTrue(model.isEmptyButHealthy) + } + + func testSearchMatchesReferenceAndDigest() async { + let backend = MockBackend(images: [ + image("alpine:latest", digest: "sha256:aaa111"), + image("postgres:16", digest: "sha256:bbb222"), + ]) + let model = ImageBrowserModel(backend: backend) + await model.refresh() + + model.searchText = "postgres" + XCTAssertEqual(model.rows.map(\.reference), ["postgres:16"]) + + model.searchText = "aaa111" + XCTAssertEqual(model.rows.map(\.reference), ["alpine:latest"], "digest is searchable") + } + + func testSortBySizeDescendingThenName() async { + let backend = MockBackend(images: [ + image("a:1", size: 10, digest: "sha256:1"), + image("b:1", size: 30, digest: "sha256:2"), + image("c:1", size: 20, digest: "sha256:3"), + ]) + let model = ImageBrowserModel(backend: backend) + await model.refresh() + + model.sort = .size + XCTAssertEqual(model.rows.map(\.reference), ["b:1", "c:1", "a:1"]) + + model.sort = .name + XCTAssertEqual(model.rows.map(\.reference), ["a:1", "b:1", "c:1"]) + } + + func testSortByCreatedNewestFirstWithUndatedLast() async { + let backend = MockBackend(images: [ + image("old:1", created: "2026-01-01T00:00:00Z", digest: "sha256:1"), + image("new:1", created: "2026-06-01T00:00:00Z", digest: "sha256:2"), + image("undated:1", created: nil, digest: "sha256:3"), + ]) + let model = ImageBrowserModel(backend: backend) + await model.refresh() + + model.sort = .created + XCTAssertEqual(model.rows.map(\.reference), ["new:1", "old:1", "undated:1"]) + } + + func testDanglingOnlyFilter() async { + let backend = MockBackend(images: [ + image("alpine:latest", digest: "sha256:1"), + image(":", digest: "sha256:2"), + ]) + let model = ImageBrowserModel(backend: backend) + await model.refresh() + + model.showDanglingOnly = true + XCTAssertEqual(model.rows.map(\.id), ["sha256:2"]) + } + + func testNoMatchesWhenSearchExcludesEverything() async { + let model = ImageBrowserModel(backend: MockBackend(images: [image("alpine:latest")])) + await model.refresh() + model.searchText = "zzz-not-here" + XCTAssertTrue(model.noMatches) + XCTAssertFalse(model.isEmptyButHealthy) + } + + func testSelectionIsIntersectedWithLoadedRowsOnRefresh() async { + let backend = MockBackend(images: [ + image("alpine:latest"), image("postgres:16", digest: "sha256:b"), + ]) + let model = ImageBrowserModel(backend: backend) + await model.refresh() + model.selection = ["alpine:latest", "ghost:1"] + + await model.refresh() + + XCTAssertEqual(model.selection, ["alpine:latest"], "stale ids are dropped") + } + + func testInspectReturnsDecodedValueAndRawPayload() async { + let backend = MockBackend(images: [image("alpine:latest")]) + let model = ImageBrowserModel(backend: backend) + let inspection = await model.inspect(reference: "alpine:latest") + XCTAssertEqual(inspection.value?.reference, "alpine:latest") + XCTAssertFalse(inspection.rawJSON.isEmpty) + } +} diff --git a/Tests/CapsuleUnitTests/ImageTests.swift b/Tests/CapsuleUnitTests/ImageTests.swift new file mode 100644 index 0000000..2cd0c03 --- /dev/null +++ b/Tests/CapsuleUnitTests/ImageTests.swift @@ -0,0 +1,85 @@ +// +// ImageTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The domain image model parses a backend reference into repository/tag, exposes the +// full + short digest for unambiguous copy actions, and recognizes dangling images. + +import CapsuleBackend +import XCTest + +@testable import CapsuleDomain + +final class ImageTests: XCTestCase { + func testParsesStandardReferenceIntoRepositoryAndTag() { + let image = Image(summary: summary(reference: "docker.io/library/alpine:latest")) + XCTAssertEqual(image.repository, "docker.io/library/alpine") + XCTAssertEqual(image.tag, "latest") + XCTAssertFalse(image.isDangling) + } + + func testParsesRegistryPortWithoutMistakingItForATag() { + let image = Image(summary: summary(reference: "localhost:5000/myimage:1.0")) + XCTAssertEqual(image.repository, "localhost:5000/myimage") + XCTAssertEqual(image.tag, "1.0") + } + + func testReferenceWithoutTagHasNilTag() { + let image = Image(summary: summary(reference: "localhost:5000/myimage")) + XCTAssertEqual(image.repository, "localhost:5000/myimage") + XCTAssertNil(image.tag) + } + + func testDigestPinnedReferenceHasNoTagAndIsNotDangling() { + let image = Image(summary: summary(reference: "alpine@sha256:abc123")) + XCTAssertEqual(image.repository, "alpine") + XCTAssertNil(image.tag) + XCTAssertFalse(image.isDangling) + } + + func testDanglingReferenceIsRecognized() { + let image = Image(summary: summary(reference: ":", digest: "sha256:deadbeef")) + XCTAssertTrue(image.isDangling) + XCTAssertNil(image.tag) + XCTAssertEqual( + image.id, "sha256:deadbeef", "a dangling row is identified by its backend id") + } + + func testNonDanglingIdIsTheReference() { + let image = Image(summary: summary(reference: "alpine:latest")) + XCTAssertEqual(image.id, "alpine:latest") + } + + func testShortDigestTakesTwelveHexAfterTheAlgorithmPrefix() { + let image = Image( + summary: summary( + reference: "alpine:latest", + digest: "sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b")) + XCTAssertEqual(image.shortDigest, "28bd5fe8b56d") + } + + func testInitFromSummaryParsesCreationDate() { + let image = Image( + summary: summary(reference: "alpine:latest", createdAt: "2026-06-16T00:00:15Z")) + XCTAssertNotNil(image.createdAt) + } + + func testDanglingImageIsAddressedByItsBackendID() { + let image = Image( + summary: ImageSummary( + id: "deadbeefcafe", reference: ":", sizeBytes: 1, + digest: "sha256:deadbeefcafe0000")) + XCTAssertEqual(image.imageID, "deadbeefcafe") + XCTAssertEqual( + image.id, "deadbeefcafe", "a dangling image is addressed by its backend id, not ") + } + + private func summary( + reference: String, digest: String = "sha256:abc", createdAt: String? = nil + ) -> ImageSummary { + ImageSummary( + id: digest, reference: reference, sizeBytes: 1234, digest: digest, createdAt: createdAt) + } +} diff --git a/Tests/CapsuleUnitTests/MockBackendTests.swift b/Tests/CapsuleUnitTests/MockBackendTests.swift index 065d65f..49eb16c 100644 --- a/Tests/CapsuleUnitTests/MockBackendTests.swift +++ b/Tests/CapsuleUnitTests/MockBackendTests.swift @@ -211,6 +211,113 @@ final class MockBackendTests: XCTestCase { XCTAssertEqual(status, .stopped) } + // MARK: - Images (M6) + + func testSeededImagesCarryDigestAndCreationDate() async throws { + let images = try await MockBackend().listImages() + XCTAssertTrue(images.allSatisfy { $0.digest.hasPrefix("sha256:") }) + XCTAssertTrue(images.allSatisfy { $0.createdAt != nil }) + } + + func testTagAddsAReferenceAndRecordsArguments() async throws { + let backend = MockBackend() + try await backend.tagImage( + source: "docker.io/library/alpine:latest", target: "alpine:pinned") + XCTAssertEqual(backend.lastTag?.source, "docker.io/library/alpine:latest") + XCTAssertEqual(backend.lastTag?.target, "alpine:pinned") + let refs = try await backend.listImages().map(\.reference) + XCTAssertTrue(refs.contains("alpine:pinned")) + } + + func testRemoveImageDropsItFromTheList() async throws { + let backend = MockBackend() + try await backend.removeImage(reference: "docker.io/library/alpine:latest") + let refs = try await backend.listImages().map(\.reference) + XCTAssertFalse(refs.contains("docker.io/library/alpine:latest")) + } + + func testPruneImagesRemovesDanglingAndReportsReclaimed() async throws { + let backend = MockBackend(images: [ + ImageSummary(id: "1", reference: ":", sizeBytes: 10, digest: "sha256:1"), + ImageSummary(id: "2", reference: "alpine:latest", sizeBytes: 20, digest: "sha256:2"), + ]) + let result = try await backend.pruneImages(all: false) + XCTAssertEqual(backend.prunedAll, false) + let refs = try await backend.listImages().map(\.reference) + XCTAssertEqual(refs, ["alpine:latest"], "only the dangling image is removed") + XCTAssertNotNil(result.reclaimedDescription) + } + + func testPruneImagesAllRemovesEverythingUnused() async throws { + let backend = MockBackend(images: [ + ImageSummary(id: "1", reference: ":", sizeBytes: 10, digest: "sha256:1"), + ImageSummary(id: "2", reference: "alpine:latest", sizeBytes: 20, digest: "sha256:2"), + ]) + _ = try await backend.pruneImages(all: true) + XCTAssertEqual(backend.prunedAll, true) + let remaining = try await backend.listImages() + XCTAssertTrue(remaining.isEmpty) + } + + func testSaveAndLoadRecordTheirURLs() async throws { + let backend = MockBackend() + let out = URL(fileURLWithPath: "/tmp/a.tar") + try await backend.saveImage(references: ["alpine:latest"], to: out, platform: nil) + XCTAssertEqual(backend.lastSavedURL, out) + let inp = URL(fileURLWithPath: "/tmp/b.tar") + try await backend.loadImage(from: inp) + XCTAssertEqual(backend.lastLoadedURL, inp) + } + + func testPushImageStreamsLines() async throws { + let backend = MockBackend(logLines: [OutputLine(source: .stdout, text: "pushing")]) + var received: [String] = [] + for try await line in backend.pushImage(reference: "ghcr.io/me/app:1", platform: nil) { + received.append(line.text) + } + XCTAssertEqual(received, ["pushing"]) + } + + // MARK: - Registries (M6) + + func testLoginRecordsServerUsernameAndPasswordWithoutLeakingToArgv() async throws { + let backend = MockBackend() + try await backend.registryLogin(server: "ghcr.io", username: "me", password: "s3cret") + XCTAssertEqual(backend.lastLogin?.server, "ghcr.io") + XCTAssertEqual(backend.lastLogin?.username, "me") + XCTAssertEqual(backend.lastLogin?.password, "s3cret") + let servers = try await backend.listRegistries().map(\.server) + XCTAssertTrue(servers.contains("ghcr.io")) + } + + func testLogoutRemovesTheRegistry() async throws { + let backend = MockBackend(registries: [RegistrySummary(server: "ghcr.io")]) + try await backend.registryLogout(server: "ghcr.io") + XCTAssertEqual(backend.lastLogout, "ghcr.io") + let remaining = try await backend.listRegistries() + XCTAssertTrue(remaining.isEmpty) + } + + func testRegistryTestDoesNotPersistButRecordsTheAttempt() async throws { + let backend = MockBackend() + try await backend.registryTest(server: "ghcr.io", username: "me", password: "s3cret") + XCTAssertEqual(backend.lastTest?.server, "ghcr.io") + let registries = try await backend.listRegistries() + XCTAssertTrue(registries.isEmpty, "a test must not add a persistent login in the mock") + } + + func testImageOperationsHonourInjectedFailure() async throws { + let backend = MockBackend() + backend.failure = BackendError.nonZeroExit( + command: "container image tag", code: 1, stderr: "boom") + do { + try await backend.tagImage(source: "a", target: "b") + XCTFail("expected the injected failure to throw") + } catch let BackendError.nonZeroExit(_, code, _) { + XCTAssertEqual(code, 1) + } + } + func testInjectedFailurePropagates() async throws { let backend = MockBackend() backend.failure = BackendError.nonZeroExit( diff --git a/Tests/CapsuleUnitTests/OutputParserTests.swift b/Tests/CapsuleUnitTests/OutputParserTests.swift index 2b16059..3945315 100644 --- a/Tests/CapsuleUnitTests/OutputParserTests.swift +++ b/Tests/CapsuleUnitTests/OutputParserTests.swift @@ -26,6 +26,11 @@ final class OutputParserTests: XCTestCase { "28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" ) XCTAssertEqual(alpine.sizeBytes, 9218) + XCTAssertEqual( + alpine.digest, + "sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b", + "the full descriptor digest is carried for digest-centric copy actions") + XCTAssertEqual(alpine.createdAt, "2026-06-16T00:00:15Z") } func testParsesEmptyImageList() throws { diff --git a/Tests/CapsuleUnitTests/RegistriesModelTests.swift b/Tests/CapsuleUnitTests/RegistriesModelTests.swift new file mode 100644 index 0000000..dd0c6f3 --- /dev/null +++ b/Tests/CapsuleUnitTests/RegistriesModelTests.swift @@ -0,0 +1,109 @@ +// +// RegistriesModelTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// Registry credential management: list, login, logout, and a credential test — proving the +// password reaches the backend but never the activity feed. + +import CapsuleBackend +import XCTest + +@testable import CapsuleDomain + +@MainActor +final class RegistriesModelTests: XCTestCase { + func testRefreshLoadsRegistries() async { + let backend = MockBackend(registries: [RegistrySummary(server: "ghcr.io")]) + let model = RegistriesModel(backend: backend) + + await model.refresh() + + XCTAssertEqual(model.loadState, .loaded) + XCTAssertEqual(model.registries.map(\.server), ["ghcr.io"]) + } + + func testRefreshFailureIsUnavailableNotEmpty() async { + let backend = MockBackend(registries: []) + backend.failure = BackendError.nonZeroExit( + command: "container registry list", code: 1, stderr: "Connection refused") + let model = RegistriesModel(backend: backend) + + await model.refresh() + + guard case .unavailable = model.loadState else { + return XCTFail("a daemon failure must be .unavailable, not an empty list") + } + } + + func testLoginPassesCredentialsToBackendAndReloads() async { + let backend = MockBackend() + let model = RegistriesModel(backend: backend) + + let ok = await model.login(server: "ghcr.io", username: "me", password: "s3cret") + + XCTAssertTrue(ok) + XCTAssertEqual(backend.lastLogin?.server, "ghcr.io") + XCTAssertEqual(backend.lastLogin?.username, "me") + XCTAssertEqual(backend.lastLogin?.password, "s3cret") + XCTAssertTrue(model.registries.contains { $0.server == "ghcr.io" }) + } + + func testLoginNeverWritesThePasswordToActivity() async { + let backend = MockBackend() + var activity: [String] = [] + let model = RegistriesModel(backend: backend, onActivity: { activity.append($0) }) + + _ = await model.login(server: "ghcr.io", username: "me", password: "top-secret-pw") + + XCTAssertFalse( + activity.contains { $0.contains("top-secret-pw") }, + "the password must never appear in the activity feed") + XCTAssertFalse(activity.isEmpty, "a login still logs that it happened") + } + + func testLoginFailureSetsNoticeAndReturnsFalse() async { + let backend = MockBackend() + backend.failure = BackendError.nonZeroExit( + command: "container registry login", code: 1, stderr: "unauthorized") + let model = RegistriesModel(backend: backend) + + let ok = await model.login(server: "ghcr.io", username: "me", password: "wrong") + + XCTAssertFalse(ok) + XCTAssertNotNil(model.notice) + } + + func testLogoutRemovesTheRegistry() async { + let backend = MockBackend(registries: [RegistrySummary(server: "ghcr.io")]) + let model = RegistriesModel(backend: backend) + await model.refresh() + + await model.logout(server: "ghcr.io") + + XCTAssertEqual(backend.lastLogout, "ghcr.io") + XCTAssertFalse(model.registries.contains { $0.server == "ghcr.io" }) + } + + func testTestSucceedsWithoutPersistingARegistry() async { + let backend = MockBackend() + let model = RegistriesModel(backend: backend) + + let result = await model.test(server: "ghcr.io", username: "me", password: "s3cret") + + XCTAssertEqual(result, .success) + XCTAssertEqual(backend.lastTest?.server, "ghcr.io") + } + + func testTestFailureReturnsADetail() async { + let backend = MockBackend() + backend.failure = BackendError.nonZeroExit( + command: "container registry login", code: 1, stderr: "bad credentials") + let model = RegistriesModel(backend: backend) + + let result = await model.test(server: "ghcr.io", username: "me", password: "bad") + + guard case .failure = result else { return XCTFail("expected .failure") } + } +} diff --git a/Tests/CapsuleUnitTests/StubProcessRunner.swift b/Tests/CapsuleUnitTests/StubProcessRunner.swift index 5ee7712..821cfb7 100644 --- a/Tests/CapsuleUnitTests/StubProcessRunner.swift +++ b/Tests/CapsuleUnitTests/StubProcessRunner.swift @@ -25,15 +25,21 @@ final class StubProcessRunner: ProcessRunning, @unchecked Sendable { var streamExit: Int32 = 0 private var calls: [[String]] = [] + /// The stdin payload of the most recent `run` (nil when none was supplied) — lets a + /// test prove a secret was delivered out-of-band rather than on argv. + private(set) var lastStandardInput: String? var lastCall: [String]? { withLock { calls.last } } - func run(_ arguments: [String], environment: [String: String]) async throws -> CommandResult { + func run( + _ arguments: [String], environment: [String: String], standardInput: String? + ) async throws -> CommandResult { let (provider, fixed) = withLock { () -> ((@Sendable ([String]) -> CommandResult)?, CommandResult) in calls.append(arguments) + lastStandardInput = standardInput return (resultProvider, result) } return provider?(arguments) ?? fixed diff --git a/Tests/CapsuleUnitTests/TaskCenterTests.swift b/Tests/CapsuleUnitTests/TaskCenterTests.swift new file mode 100644 index 0000000..ec56e7e --- /dev/null +++ b/Tests/CapsuleUnitTests/TaskCenterTests.swift @@ -0,0 +1,117 @@ +// +// TaskCenterTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The activity task center backing long image operations (pull/push/save/load): it +// accumulates a transcript, records success/failure, and re-runs a failed task on retry. + +import CapsuleBackend +import XCTest + +@testable import CapsuleDomain + +/// A mutable, Sendable flag so a test stream can change behaviour between the first run and +/// a retry. +private final class Flip: @unchecked Sendable { + var shouldFail: Bool + init(_ shouldFail: Bool) { self.shouldFail = shouldFail } +} + +@MainActor +final class TaskCenterTests: XCTestCase { + func testStreamingTaskAccumulatesTranscriptAndSucceeds() async { + let center = TaskCenter() + let task = center.runStreaming(kind: .pull, title: "Pull alpine") { + AsyncThrowingStream { continuation in + continuation.yield(OutputLine(source: .stdout, text: "Pulling")) + continuation.yield(OutputLine(source: .stdout, text: "Done")) + continuation.finish() + } + } + + await task.wait() + + XCTAssertEqual(task.state, .succeeded) + XCTAssertEqual(task.transcript.map(\.text), ["Pulling", "Done"]) + XCTAssertEqual(center.tasks.count, 1) + XCTAssertTrue(center.activeTasks.isEmpty) + } + + func testStreamingTaskFailureRecordsFailedStateAndKeepsTranscript() async { + let center = TaskCenter() + let task = center.runStreaming(kind: .push, title: "Push app") { + AsyncThrowingStream { continuation in + continuation.yield(OutputLine(source: .stdout, text: "Uploading")) + continuation.finish( + throwing: BackendError.nonZeroExit( + command: "container image push", code: 1, stderr: "unauthorized")) + } + } + + await task.wait() + + guard case .failed = task.state else { + return XCTFail("expected .failed, got \(task.state)") + } + XCTAssertTrue(task.transcript.contains { $0.text == "Uploading" }) + XCTAssertTrue( + task.transcript.contains { $0.text.contains("unauthorized") }, + "the failure transcript must keep the raw error visible") + } + + func testRetryReRunsAFailedTaskToSuccess() async { + let center = TaskCenter() + let flip = Flip(true) + let task = center.runStreaming(kind: .pull, title: "Pull alpine") { + AsyncThrowingStream { continuation in + if flip.shouldFail { + continuation.finish( + throwing: BackendError.nonZeroExit( + command: "container image pull", code: 1, stderr: "network error")) + } else { + continuation.yield(OutputLine(source: .stdout, text: "Pulled")) + continuation.finish() + } + } + } + await task.wait() + guard case .failed = task.state else { return XCTFail("expected first run to fail") } + + flip.shouldFail = false + center.retry(task) + await task.wait() + + XCTAssertEqual(task.state, .succeeded) + XCTAssertEqual(task.transcript.map(\.text), ["Pulled"], "the transcript resets on retry") + } + + func testNonStreamingTaskSucceeds() async { + let center = TaskCenter() + let task = center.runAsync(kind: .save, title: "Save alpine") {} + await task.wait() + XCTAssertEqual(task.state, .succeeded) + } + + func testNonStreamingTaskFailureRecordsError() async { + let center = TaskCenter() + let task = center.runAsync(kind: .load, title: "Load archive") { + throw BackendError.nonZeroExit( + command: "container image load", code: 1, stderr: "invalid archive") + } + await task.wait() + guard case .failed = task.state else { return XCTFail("expected .failed") } + XCTAssertTrue(task.transcript.contains { $0.text.contains("invalid archive") }) + } + + func testClearFinishedRemovesOnlyCompletedTasks() async { + let center = TaskCenter() + let done = center.runAsync(kind: .save, title: "Save") {} + await done.wait() + + center.clearFinished() + + XCTAssertTrue(center.tasks.isEmpty) + } +} diff --git a/docs/superpowers/plans/2026-06-28-milestone6-images-registries.md b/docs/superpowers/plans/2026-06-28-milestone6-images-registries.md new file mode 100644 index 0000000..de5bcc1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-milestone6-images-registries.md @@ -0,0 +1,63 @@ +# Milestone 6 · Images browser and registries — Implementation plan + +Design: `docs/superpowers/specs/2026-06-28-milestone6-images-registries-design.md`. +Method: TDD (red → green → refactor), one phase per logical layer, commit per phase. +All behaviour tested against `MockBackend`. Build/test command: `swift test` (SwiftPM). + +## Phase 0 — Backend port + value types +1. (red) `CLICommandTests`: argv for `pushImage`, `saveImage`, `loadImage`, `tagImage`, + `pruneImages(all:)`, `removeImage(all:force:)`, `pullImage(platform:)`, + `registryLogin(server:username:)` (asserts `--password-stdin`, **no password in argv**), + `registryLogout`. `OutputParserTests`: `parseImages` populates `digest`+`createdAt`. +2. (green) Enrich `ImageSummary` (`digest`, `createdAt`). Add `CLICommand` factories. + Add `CLIProcessRunner.run(_:environment:standardInput:)`. Implement new methods in + `CLIContainerBackend`. Add port methods to `ContainerBackend` (+ default-arg + convenience extensions). Implement in `MockBackend` with last-call tracking + seeded + digest/createdAt. +3. (green) `MockBackendTests`: new methods mutate/record; `failure` injection. + +## Phase 1 — Images read surface +1. (red) `ImageTests` (reference→repo/tag/digest/dangling/shortDigest, summary map), + `ImageBrowserModelTests` (loaded vs unavailable vs empty-but-healthy vs no-matches; + search; sort name/size/created; dangling filter; selection intersection; inspect). +2. (green) Enrich `Image`; add `ImageBrowserModel`, `ImageSort`, `ImageInspection`, + `ImageLoadState`. +3. (green, UI) `ImageListView` (Table + searchable + sort/dangling toolbar + context menu + with Copy Digest/Reference), `ImageInspectorView` (Summary + Raw JSON + digest copy); + route `.images` in `ContentColumnView` + `AppShellView`. Wire models through + `AppEnvironment`/`CapsuleScene`/`RootView`. + +## Phase 2 — Image operations +1. (red) `ImageActionsModelTests` (tag; delete single/bulk; idempotent not-found; + dependency-conflict → notice with raw stderr; prune targets + result), + `ConfirmationTests` (image delete single/bulk/force, prune all, push destination). +2. (green) `ImageActionsModel`; extend `ConfirmationKind` (`.deleteImage(force:)`, + `.pruneImages(all:)`) + `ConfirmationRequest` factories incl. `pushImage`. +3. (green, UI) `TagImageSheet`, `ImagePruneSheet`, delete/prune via `ConfirmationSheet`; + wire into `ImageListView` context menu + toolbar. + +## Phase 3 — Long operations as tasks +1. (red) `TaskCenterTests` (start/append/finish; failed→retry re-runs; active-progress set). +2. (green) `OperationTask` + `TaskCenter`; pull/push stream into transcript, save/load + non-stream; expose start closures from `AppEnvironment`. +3. (green, UI) Replace Activity pane Tasks/Progress placeholders with real lists (state + glyph, transcript disclosure, Retry). `PullImageSheet`, `PushImageSheet` (confirm + destination), `LoadImageSheet` (file picker + drag/drop + archive-type validation), + Save via `NSSavePanel`. Wire into `ImageListView`. + +## Phase 4 — Registries preferences +1. (red) `RegistriesModelTests` (list/login/logout/test; backend called with right + server/username; password passed via the password parameter; failure → notice; + logout confirmation policy). Secret-never-in-argv already covered in Phase 0 + `CLICommandTests`. +2. (green) `RegistriesModel`. +3. (green, UI) `Settings` scene + `PreferencesView` + `RegistriesView` + + `RegistryLoginSheet` (`SecureField`, Test, raw transcript on failure, remove confirm). + Wire `registriesModel` through `AppEnvironment` + `CapsuleScene`. + +## Phase 5 — Verify, review, finish +1. `swift build` + `swift test` green; `ArchitectureGuardTests` parity; swift-format. +2. Adversarial code-review subagent; address findings (re-verify after each fix). +3. Interactive GUI smoke if feasible (mock-backed previews / live app). +4. Per-phase commits already landed; open PR per `finishing-a-development-branch`. + diff --git a/docs/superpowers/specs/2026-06-28-milestone6-images-registries-design.md b/docs/superpowers/specs/2026-06-28-milestone6-images-registries-design.md new file mode 100644 index 0000000..aac3b8f --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-milestone6-images-registries-design.md @@ -0,0 +1,238 @@ +# Milestone 6 · Images browser and registries — Design + +Status: approved-to-build (autonomous; `/goal` directive). Date: 2026-06-28. +Branch: `milestone-6-images-registries`. + +## 1. Goal & scope + +Build the image-management surface and registry credential handling, mirroring the +container surface delivered in Milestone 5 (5A read / 5B+5C actions). Concretely: + +- An **Images browser** from `container image list --format json` showing references + (repo:tag), digests, and sizes, with search + sort + a dangling filter. The empty + state must distinguish "no images" from "daemon failure" (same pattern as + `ContainerBrowserModel.loadState`). +- An **Image inspector** with a friendly Summary tab and a Raw JSON tab, plus + **digest-centric copy actions** (copy full digest, copy reference) to avoid ambiguity. +- **Image operations** as sheets/actions: `pull`, `push`, `save`, `load`, `tag`, + `delete`/`rm` (single + bulk, surfacing dependency conflicts), `prune` (with a preview + of what will be reclaimed). +- A **Registries** preferences pane backed by `registry login`/`logout`/`list`: a login + sheet that **never echoes secrets**, a credential-test action, and confirmation before + removing a login. Secrets are fed to the CLI via **`--password-stdin`** so they never + appear on argv, in logs, in the activity feed, or in any task transcript. +- Long operations (`pull`/`push`/`save`/`load`) register **tasks** with state, a raw + transcript, and retry. The Activity pane's Tasks/Progress tabs are placeholders today; + M6 gives them a real, minimal backing model that Milestone 7 can expand. + +Out of scope (YAGNI): saved scopes for images (containers have them; images get +search+sort+dangling-filter instead), Keychain integration beyond what the CLI already +does (apple/container stores its own credentials; we do not touch Keychain directly), and +per-layer/variant drill-down in the inspector (the Raw JSON tab covers it). + +## 2. CLI facts (verified against `container` v1.0.0 `--help` on this machine) + +``` +image list --format json|table|yaml|toml [-q] [-v] +image inspect ... (JSON by default) +image pull [--platform os/arch] [--progress auto|none|ansi|plain|color] +image push [--platform os/arch] [--progress ...] +image save [-o/--output ] [--platform os/arch] ... +image load [-i/--input ] [--force] +image tag +image prune [--all] (default: dangling only; --all: all unused) +image delete [--all] [--force] ... (--force = ignore not-found; NOT force-unref) +registry login [--scheme ...] [--password-stdin] [-u/--username ] +registry logout +registry list [--format json] [-q] +``` + +Live `image list --format json` row shape (captured): + +```json +{ "id": "28bd5fe8…(hex digest)", + "configuration": { + "name": "docker.io/library/alpine:latest", + "creationDate": "2026-06-16T00:00:15Z", + "descriptor": { "digest": "sha256:28bd5fe8…", "mediaType": "…index.v1+json", "size": 9218 } }, + "variants": [ … ] } +``` + +`registry list --format json` returns `[]` on a fresh machine. There is **no +`registry test`** subcommand: the credential-test action is implemented as a real +`registry login` attempt (the registry authenticates the credentials; success ⇒ valid). +We document this conflation honestly in the UI ("Test logs in to verify the credentials"). + +`image delete --force` only *ignores not-found errors*; it does **not** force-remove an +image that a container still references. So deleting a referenced image fails with a +dependency error on stderr — which we surface verbatim in the notice (acceptance: +"show dependency conflicts when images are still referenced"). + +## 3. Architecture & layering + +Obeys the existing arch-guard rules (`ArchitectureGuardTests`): + +- `CapsuleBackend` — extend the `ContainerBackend` port + value types. No deps. +- `CapsuleDomain` (imports `CapsuleBackend` only) — new `@Observable` models + value types. +- `CapsuleUI` (imports `CapsuleDomain` only) — new SwiftUI views/sheets. No backend types + in any UI signature. +- `CapsuleCLIBackend` (imports `CapsuleBackend`, `CapsuleDiagnostics`) — CLI adapter. +- `CapsuleApp` — composition root wires everything, adds the Settings scene. + +### 3.1 Backend port additions (`ContainerBackend`) + +```swift +// Images (existing: listImages, inspectImage, removeImage, pullImage) +func pushImage(reference: String, platform: String?) -> AsyncThrowingStream +func saveImage(references: [String], to url: URL, platform: String?) async throws +func loadImage(from url: URL) async throws +func tagImage(source: String, target: String) async throws +func pruneImages(all: Bool) async throws -> PruneResult +// pullImage gains a platform overload (keep the no-arg one via default-arg extension) +func pullImage(reference: String, platform: String?) -> AsyncThrowingStream + +// Registries (existing: listRegistries) +func registryLogin(server: String, username: String?, password: String?) async throws +func registryLogout(server: String) async throws +func registryTest(server: String, username: String?, password: String?) async throws +``` + +`registryLogin`/`registryTest` pass `--password-stdin` and write the password to the +child's stdin (new `CLIProcessRunner.run(_:environment:standardInput:)`). The password is +**never** placed in `arguments`, so it cannot leak through `commandDescription`, the +`Log.backend.debug` argv line, the error's `command:` field, or any transcript. + +### 3.2 Value-type enrichment (`CapsuleBackend`) + +`ImageSummary` gains `digest: String` (full `sha256:…` from `descriptor.digest`) and +`createdAt: String?` (the raw ISO string). `OutputParser.parseImages` populates them from +`CLIImageRecord` (already decodes `descriptor.digest` + `creationDate`). Defaulted inits +keep existing callers/tests compiling. + +`RegistrySummary` stays minimal (`server`). apple/container exposes no auth-state or +last-used metadata locally, so we do not invent it; the pane shows the server list the CLI +reports. (Documented; "surfaced where available locally" = nothing extra is available.) + +### 3.3 Domain models (`CapsuleDomain`) + +- **`Image`** (existing, enriched): add `repository`, `tag`, `digest`, `shortDigest` + (12 hex chars after `sha256:`), `createdAt: Date?`, `isDangling` (tag/ref is ``). + `init(summary:)` parses `reference` into repo/tag. +- **`ImageBrowserModel`** (new, mirrors `ContainerBrowserModel`): `allImages`, + `loadState: ImageLoadState` (`idle/loading/loaded/unavailable(ErrorDetail)`), + `searchText`, `sort: ImageSort` (name/size/created), `showDanglingOnly`, `selection`, + derived `rows`, `isEmptyButHealthy`, `noMatches`, `refresh()`, `inspect(reference:) + -> ImageInspection`. +- **`ImageActionsModel`** (new, mirrors `ContainerLifecycleModel` for non-streaming ops): + `busy`, `notice: LifecycleNotice?`, `confirmation: ConfirmationRequest?`; methods + `tag(source:target:)`, `delete(reference:)`/`deleteAll`, `computePruneTargets()`, + `prune(all:)`. Streaming ops (pull/push/save/load) go through the task center (below). +- **`TaskCenter`** (new, `@Observable`) + **`OperationTask`** value/observable: the real + backing for the Activity pane's Tasks/Progress tabs. `OperationTask` holds `id`, + `title`, `kind` (pull/push/save/load), `state: TaskState`, `transcript: [OutputLine]`, + and a `retry: () -> Void` closure. `TaskCenter` exposes `tasks`, `start(...)`, + `append(line:to:)`, `finish(_:state:)`, `retry(_:)`, and `activeProgressTasks`. + - pull/push stream `OutputLine`s → appended to the transcript; state is + `.running(progress: nil)` (indeterminate — `--progress plain` emits lines, not a clean + %), flipping to `.succeeded`/`.failed(DiagnosticInfo)` on stream end/throw. + - save/load are non-streaming → `.running(progress: nil)` then succeeded/failed; their + transcript captures the normalized error on failure. + +`ConfirmationKind` gains `.deleteImage(force:)` and `.pruneImages(all:)`; `push` gets its +own confirmation factory (`ConfirmationRequest.pushImage(reference:destination:)`) so a +push always confirms its destination repo (acceptance: "confirm destination repo"). + +### 3.4 UI (`CapsuleUI`) + +- **Routing**: `ContentColumnView.runningContent` adds `case .images → ImageListView(...)`. + `AppShellView` inspector adds `if selection == .images → ImageInspectorView(...)`. +- **`ImageListView`** (mirrors `ContainerListView`): `Table` of rows (status dot for + dangling, Reference, Tag, Digest (short, monospaced), Size, Created relative), a + searchable field, a sort Picker + a "Dangling only" toggle in the toolbar, a Pull button, + a Clean Up (prune) button, a context menu (Push…, Save…, Tag…, Copy Digest, Copy + Reference, Delete…), bulk delete via `onDeleteCommand`. Sheets via one + `ImageSheet` enum (`pull`, `push`, `save` handled by NSSavePanel, `load`, `tag`, + `confirm`, `prune`). +- **Sheets**: `PullImageSheet` (reference paste field + platform field + a live transcript + area fed by the task), `PushImageSheet` (reference + destination confirm), `TagImageSheet` + (source shown read-only with its digest + a target field), `LoadImageSheet` (file picker + + drag/drop with archive-type validation: `.tar`/`.tar.gz`/OCI dir), `ImagePruneSheet` + (preview of dangling [or all-unused] candidates + reclaimed result), reuse + `ConfirmationSheet` for delete/push/prune confirmations. Save uses `NSSavePanel` + (`.tar`), like container export. +- **`ImageInspectorView`** (mirrors `ContainerInspectorView`): Summary (repository, tag, + full digest with a copy button, size, created) + Raw JSON tab (copyable). Digest-centric + copy actions live here and in the list context menu. +- **Activity pane**: replace the Tasks/Progress placeholders with real lists bound to + `TaskCenter`: Tasks shows each `OperationTask` with a state glyph, a disclosure for its + transcript, and a Retry button on failure; Progress shows active transfers with a + progress view (indeterminate) + inline transcript tail. +- **Registries preferences**: a new `Settings` scene → `PreferencesView` with a Registries + pane. `RegistriesView` lists logins (server), a "+" presents `RegistryLoginSheet` + (server, username, `SecureField` password — never rendered/echoed, a Test button, a Log + In button; raw transcript shown on auth failure), and a "−"/remove that confirms via + `ConfirmationSheet` before `registry logout`. + +### 3.5 Composition (`CapsuleApp`) + +`AppEnvironment` gains `imageBrowserModel`, `imageActionsModel`, `taskCenter`, +`registriesModel`; `live()` wires them to the CLI backend with `ErrorNormalizer.normalize` +and `shell.appendActivity`. Pull/push/save/load actions are created so the UI fires them +without naming backend types; they register an `OperationTask` and stream into it. +`CapsuleScene` adds a `Settings { PreferencesView(registriesModel:) }` scene; +`CapsuleCommands` adds nothing new (the standard Settings menu item opens it; ⌘,). + +## 4. Error handling + +- All backend failures normalize through `ErrorNormalizer.normalize` → `CapsuleError` → + `ErrorDetail`, exactly as containers do. Raw transcripts (stdout+stderr) stay visible: + pull/push/save/load keep their full transcript in the task; registry login shows the raw + failure in the sheet; delete surfaces the raw dependency-conflict stderr in the notice. +- Benign cases: `image delete` of an already-absent image with `--force` is idempotent + (mirror `isBenignAlreadyRemoved`). Tag/login/logout surface failures directly. +- Secret safety is an invariant, asserted by tests: the argv built for login/test never + contains the password; the transcript/diagnostic of a failed login never contains it. + +## 5. Testing (all against `MockBackend`) + +New `MockBackend` support: seeded images carry digest/createdAt; new methods mutate state +and record last-call params (`lastTaggedSource/Target`, `lastSavedURL`, `lastLoadedURL`, +`lastLogin: (server,username,password)`, `lastLogout`, `prunedAll`), with `failure` +injection. Streaming push/save/load reuse the seeded-stream pattern. + +Unit tests (mirroring existing suites): +- `ImageTests` — reference→repo/tag/digest/dangling parsing; summary mapping. +- `ImageBrowserModelTests` — load/empty-vs-unavailable, search, sort, dangling filter, + selection intersection, inspect. +- `ImageActionsModelTests` — tag, delete (incl. idempotent not-found + dependency-conflict + notice), bulk delete, prune targets + result. +- `TaskCenterTests` — start/append/finish, failed→retry re-runs, progress-active set. +- `RegistriesModelTests` — list, login (calls backend with right server/user; password via + the password arg, not argv), logout, test; **secret-never-in-argv** assertion via a new + `CLICommandTests` case (`registryLogin` argv excludes the password, includes + `--password-stdin`). +- `CLICommandTests` — argv for every new command (push/save/load/tag/prune/delete-all, + registry login/logout) matches the verified `--help` flags. +- `OutputParserTests` — `parseImages` populates digest + createdAt from the real captured + JSON; lenient on drift. +- `ConfirmationTests` — image delete (single/bulk/force), prune(all), push-destination. +- `ArchitectureGuardTests` — unchanged rules keep passing (no new violations). +- `CompositionTests`/`AppEnvironmentActionsTests` — env exposes the new models; a pull + action registers a task and streams into it. + +Acceptance maps 1:1 to the goal: list with tags/digests ✓; pull/push/save/load/tag/delete/ +prune with correct prompts + visible raw transcripts on failure ✓; registry +login/logout/list without printing secrets + a test action ✓; pushes confirm destination ✓; +all tested against `MockBackend` ✓. + +## 6. Decisions log (autonomous; documented for async review) + +1. **No saved scopes for images** — search + sort + dangling-filter instead (YAGNI). +2. **No Keychain code** — apple/container owns credential storage; we drive its CLI only. +3. **`registry test` = a real `registry login`** (no dry-run verb exists); labelled honestly. +4. **Progress is indeterminate** for pull/push (the CLI's plain progress isn't a clean %); + the transcript is the source of truth. Milestone 7 can parse finer progress. +5. **Secrets via `--password-stdin`**, never argv — enforced by a unit test. +6. **One milestone, one branch**, phased commits (not split into 6A/6B/6C). + +