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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ final class AppModel {
/// True while a service start/stop initiated from the menu bar is in flight.
private(set) var isMutatingService = false

/// Normalized logged-in registry hosts. nil = unknown (list failed / no CLI).
private(set) var loggedInRegistries: Set<String>?

@ObservationIgnored private var monitorTask: Task<Void, Never>?

/// User override for the binary path (persisted). Empty means "auto-detect".
Expand Down Expand Up @@ -88,6 +91,7 @@ final class AppModel {
var systemService: SystemService? { cli.map(SystemService.init) }
var volumeService: VolumeService? { cli.map(VolumeService.init) }
var networkService: NetworkService? { cli.map(NetworkService.init) }
var registryService: RegistryService? { cli.map(RegistryService.init) }

/// Docker Hub image search talks to the network directly (the CLI has no
/// registry-search command), so it's available regardless of the backend.
Expand Down Expand Up @@ -311,4 +315,11 @@ final class AppModel {
func select(_ item: SidebarItem) {
withAnimation(Theme.Motion.smooth) { selection = item }
}

/// Refreshes the logged-in registry set from `registry list --quiet`.
/// Any failure degrades to nil (unknown) so the pull hint never falsely nags.
func refreshRegistries() async {
guard let registryService else { loggedInRegistries = nil; return }
loggedInRegistries = try? await registryService.loggedInHosts()
}
}
63 changes: 63 additions & 0 deletions Core/Models/RegistryLogin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

/// One stored registry credential, as listed by `container registry list`.
///
/// The shipping CLI emits `name`/`id` for the host and `creationDate`/
/// `modificationDate` for the timestamps (verified against `registry list
/// --format json`). Decoding stays tolerant of the older `hostname`/`host`/
/// `server`, `created`/`modified`, and `user` spellings so a key rename degrades
/// gracefully instead of crashing the list.
struct RegistryLogin: Identifiable, Sendable, Equatable {
var hostname: String
var username: String?
var created: Date?
var modified: Date?
var id: String { hostname }
}

extension RegistryLogin: Decodable {
private enum CodingKeys: String, CodingKey {
case hostname, name, id, host, server
case username, user
case created, creationDate
case modified, modificationDate
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
// First key that's present wins (a long `??` chain trips the type-checker).
func first(_ keys: CodingKeys...) throws -> String? {
for key in keys where try c.decodeIfPresent(String.self, forKey: key) != nil {
return try c.decodeIfPresent(String.self, forKey: key)
}
return nil
}
// Real CLI keys (`name`/`id`) first, then the older guessed spellings.
guard let host = try first(.hostname, .name, .id, .host, .server), !host.isEmpty else {
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath,
debugDescription: "RegistryLogin has no name/id/hostname key"))
}
hostname = host
username = try first(.username, .user)
created = try c.decodeIfPresent(Date.self, forKey: .creationDate)
?? c.decodeIfPresent(Date.self, forKey: .created)
modified = try c.decodeIfPresent(Date.self, forKey: .modificationDate)
?? c.decodeIfPresent(Date.self, forKey: .modified)
}
}

/// Canonicalizes registry hostnames so Docker Hub's aliases collapse to one key.
enum RegistryHost {
static let canonicalDockerHub = "docker.io"
static let dockerHubAliases: Set<String> = [
"docker.io", "index.docker.io", "registry-1.docker.io", "registry.hub.docker.com",
]
static func normalize(_ raw: String?) -> String {
guard let raw, !raw.trimmingCharacters(in: .whitespaces).isEmpty else { return canonicalDockerHub }
let lower = raw.trimmingCharacters(in: .whitespaces).lowercased()
return dockerHubAliases.contains(lower) ? canonicalDockerHub : lower
}
static func forReference(_ reference: String) -> String {
normalize(ImageReference.parse(reference).registry)
}
static func isDockerHub(_ normalizedHost: String) -> Bool { normalizedHost == canonicalDockerHub }
}
57 changes: 57 additions & 0 deletions Core/Services/RegistryService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation

/// TLS scheme for `registry login`. `.auto` omits the flag (CLI default).
enum RegistryScheme: String, Sendable, CaseIterable {
case auto, https, http
var flagValue: String? { self == .auto ? nil : rawValue }
}

/// Registry credential management (`container registry …`).
struct RegistryService: Sendable {
let cli: ContainerCLI
init(cli: ContainerCLI) { self.cli = cli }

// MARK: Argument builders (pure, unit-tested)
// NOTE: the password is NEVER an argument — it is piped via stdin.
static func loginArguments(server: String, username: String?, scheme: RegistryScheme = .auto) -> [String] {
var args = ["registry", "login"]
if let s = scheme.flagValue { args += ["--scheme", s] }
if let username, !username.isEmpty { args += ["--username", username] }
args.append("--password-stdin")
args.append(server)
return args
}
static func logoutArguments(server: String) -> [String] { ["registry", "logout", server] }
static func listArguments() -> [String] { ["registry", "list", "--format", "json"] }
static func listQuietArguments() -> [String] { ["registry", "list", "--quiet"] }

// MARK: Operations
/// Logs in, piping the password/token to stdin (`--password-stdin`).
/// The password is passed only as stdin `Data`, never in `arguments`.
func login(server: String, username: String, password: String, scheme: RegistryScheme = .auto) async throws {
let args = Self.loginArguments(server: server, username: username, scheme: scheme)
_ = try await cli.output(args, stdin: Data(password.utf8))
}
@discardableResult
func logout(server: String) async throws -> String {
try await cli.text(Self.logoutArguments(server: server))
}
func list() async throws -> [RegistryLogin] {
try await cli.decode([RegistryLogin].self, from: Self.listArguments())
}
/// Hostnames exactly as the CLI stored them — used for display and for
/// `logout(server:)`, which must pass the verbatim stored name (no alias
/// folding or lowercasing, or the logout won't match the credential).
func loggedInHostsRaw() async throws -> [String] {
let out = try await cli.text(Self.listQuietArguments())
return out.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}

/// Normalized set of logged-in hosts (Docker Hub aliases folded) — the
/// authoritative membership check for the pull hint.
func loggedInHosts() async throws -> Set<String> {
Set(try await loggedInHostsRaw().map(RegistryHost.normalize))
}
}
3 changes: 3 additions & 0 deletions Features/Explore/ExploreScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ struct ExploreScreen: View {
}
.sheet(item: $pullTarget) { target in
if let imageService = app.imageService {
// Re-inject AppModel: PullImageView reads it (registry-aware hint),
// and sheet content doesn't inherit it reliably across the boundary.
PullImageView(service: imageService, initialReference: target.reference) {}
.environment(app)
}
}
}
Expand Down
1 change: 1 addition & 0 deletions Features/Images/ImagesScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ struct ImagesScreen: View {
}
.sheet(isPresented: $showPull) {
PullImageView(service: model.service) { await model.load() }
.environment(app)
}
.sheet(isPresented: $showBuild) {
BuildImageView(service: model.service) { await model.load() }
Expand Down
29 changes: 29 additions & 0 deletions Features/Images/PullImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct PullImageView: View {
var onComplete: () async -> Void

@Environment(\.dismiss) private var dismiss
@Environment(AppModel.self) private var app

@State private var reference: String
@State private var lines: [String] = []
Expand All @@ -17,6 +18,7 @@ struct PullImageView: View {
@State private var finished = false
@State private var errorMessage: String?
@State private var showConsole = false
@State private var showLogin = false

init(service: ImageService, initialReference: String = "", onComplete: @escaping () async -> Void) {
self.service = service
Expand All @@ -30,6 +32,13 @@ struct PullImageView: View {
Divider()
VStack(alignment: .leading, spacing: 14) {
inputRow
if let host = loginHintHost {
InlineBanner(kind: .info,
title: "Not signed in to \(host)",
message: "You can still pull public images. Sign in to pull private repositories.",
actionTitle: "Log in",
action: { showLogin = true })
}
progressCard
consoleDisclosure
}
Expand All @@ -40,6 +49,26 @@ struct PullImageView: View {
.frame(width: 600)
.fixedSize(horizontal: false, vertical: true) // hug content; no dead space
.animation(Theme.Motion.spring, value: showConsole)
.sheet(isPresented: $showLogin) {
if let service = app.registryService {
RegistryLoginView(service: service, initialServer: loginHintHost ?? "") {
await app.refreshRegistries()
}
}
}
.task { await app.refreshRegistries() }
}

/// The normalized registry host to nag about, or nil if the reference is
/// empty, targets Docker Hub, or the logged-in set isn't known yet.
private var loginHintHost: String? {
let trimmed = reference.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return nil }
let host = RegistryHost.forReference(trimmed)
guard !RegistryHost.isDockerHub(host) else { return nil }
guard let known = app.loggedInRegistries else { return nil }
guard !known.contains(host) else { return nil }
return host
}

// MARK: - Header / footer
Expand Down
109 changes: 109 additions & 0 deletions Features/Registries/RegistriesSection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import SwiftUI

/// Embeddable card listing registry logins (`container registry list`), with
/// log in / log out actions. Used on the System screen.
struct RegistriesSection: View {
@Environment(AppModel.self) private var app

@State private var logins: [RegistryLogin] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showLogin = false
@State private var logoutBusy: Set<String> = []

var body: some View {
VStack(alignment: .leading, spacing: 9) {
HStack(spacing: 10) {
SectionLabel(title: "Registries")
Spacer()
PillButton(style: .accent) {
showLogin = true
} label: {
Label("Log in", systemImage: "person.badge.key.fill")
}
.disabled(app.registryService == nil)
}

if isLoading {
ProgressView().controlSize(.small)
} else if logins.isEmpty {
// Only assert "signed in to nothing" when the list actually
// succeeded — an error below means we don't really know.
if errorMessage == nil {
Text("Not signed in to any registries.")
.font(Theme.Typography.callout)
.foregroundStyle(.secondary)
}
} else {
VStack(spacing: 8) {
ForEach(logins) { login in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(login.hostname).font(Theme.Typography.headline)
Text(login.username ?? "—")
.font(Theme.Typography.caption)
.foregroundStyle(.secondary)
}
Spacer()
PillButton(style: .destructive) {
Task { await logout(login.hostname) }
} label: {
if logoutBusy.contains(login.hostname) {
ProgressView().controlSize(.small)
} else {
Label("Log out", systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
.card(padding: 14)
}
}
}

if let errorMessage {
Text(errorMessage)
.font(Theme.Typography.caption)
.foregroundStyle(.red)
}
}
.sheet(isPresented: $showLogin) {
if let service = app.registryService {
RegistryLoginView(service: service, initialServer: "") { await reload() }
}
}
.task { await reload() }
.onChange(of: app.refreshTick) { Task { await reload() } }
}

private func reload() async {
guard let service = app.registryService else { logins = []; return }
isLoading = logins.isEmpty
errorMessage = nil // a successful refresh must clear any prior error
defer { isLoading = false }
do { logins = try await service.list() }
catch CLIError.decodingFailed {
// Decode of the rich JSON failed — fall back to the verbatim hostnames
// from `--quiet`. These must NOT be normalized: logout() sends the name
// straight to the CLI, which stored it exactly as typed.
let hosts = (try? await service.loggedInHostsRaw()) ?? []
logins = hosts.sorted().map { RegistryLogin(hostname: $0, username: nil, created: nil, modified: nil) }
}
catch let e as CLIError { errorMessage = e.localizedDescription }
catch { errorMessage = error.localizedDescription }
await app.refreshRegistries()
}

private func logout(_ host: String) async {
guard let service = app.registryService else { return }
logoutBusy.insert(host)
do {
_ = try await service.logout(server: host)
} catch let e as CLIError {
errorMessage = e.localizedDescription
} catch {
errorMessage = error.localizedDescription
}
logoutBusy.remove(host)
await reload()
}
}
Loading
Loading