Skip to content
Open
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
61 changes: 61 additions & 0 deletions Sources/AppleDockerApp/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ final class AppState {
var diskUsage = SystemDiskUsage()
/// Whether a cleanup operation is in flight (disables the cleanup buttons).
var isCleaning = false
/// Whether the runtime store is being moved.
var isMovingStorage = false
/// Progress or result of the last storage move, shown in Settings > Storage.
var storageMessage: String?
/// Whether a CLI install is in flight.
var isInstalling = false
/// Whether an app install into /Applications is in flight.
Expand Down Expand Up @@ -692,6 +696,63 @@ final class AppState {
}
}

/// Moves the runtime store into `parent`. The daemon is stopped for the copy
/// and started again on the new location. If the move fails, the daemon is
/// started on the original location; if the start fails after the move, the
/// original is restored.
func moveStorage(toParent parent: URL) async {
guard !isMovingStorage else { return }
isMovingStorage = true
defer { isMovingStorage = false }
let opID = beginOperation("moveStorage", section: .settings)
defer { endOperation(opID) }

let location = StorageLocation.standard
storageMessage = "Stopping the runtime…"
do {
try await service.daemon.stop()
storageMessage = "Copying the storage to \(parent.path)…"
let result: StorageMoveResult
do {
// The copy can take minutes; keep it off the main thread so the window stays responsive.
result = try await Task.detached(priority: .userInitiated) {
try location.move(toParent: parent)
}.value
} catch {
try? await service.daemon.start()
throw error
}
storageMessage = "Starting the runtime on the new location…"
do {
try await service.daemon.start(environment: [StorageLocation.environmentName: result.newRoot.path])
} catch {
// Back to the original: put it back, drop the unused copy, and
// start the daemon on the original location.
try? location.restoreBackup()
try? FileManager.default.removeItem(at: result.newRoot)
try? await service.daemon.start()
throw error
}
reconnect()
await refresh()
errorMessage = nil
storageMessage = "Storage moved to \(result.newRoot.path). The original is kept as a backup."
} catch {
storageMessage = "Move failed: \(error.localizedDescription)"
errorMessage = error.localizedDescription
}
}

/// Deletes the backup left by `moveStorage`, once the moved store is in use.
func deleteStorageBackup() {
do {
try StorageLocation.standard.deleteBackup()
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}

/// Delete the buildkit builder container (frees the build cache).
func deleteBuildkitBuilder() async {
let opID = beginOperation("deleteBuildkitBuilder", section: .images)
Expand Down
123 changes: 123 additions & 0 deletions Sources/AppleDockerApp/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//===----------------------------------------------------------------------===//

import SwiftUI
import AppKit
import ServiceManagement
import ContainerBackend

Expand All @@ -21,6 +22,11 @@ struct SettingsView: View {
@State private var confirmFull = false
@State private var confirmInstall = false
@State private var confirmInstallApp = false
@State private var storageSizes: [(name: String, bytes: UInt64)] = []
@State private var storageRedirected = false
@State private var storageHasBackup = false
@State private var pendingStorageParent: URL?
@State private var confirmDeleteBackup = false
@State private var confirmRuntimeUpgrade = false

var body: some View {
Expand Down Expand Up @@ -293,9 +299,126 @@ struct SettingsView: View {
}
}

/// Where the runtime keeps its data, with a move action.
private var storageLocation: some View {
Section("Location") {
HStack {
Text("Current")
Spacer()
Text(StorageLocation.standard.resolvedRoot.path)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
if storageRedirected {
Text("This folder is a link to another disk.")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(storageSizes, id: \.name) { folder in
HStack {
Text(folder.name)
.foregroundStyle(.secondary)
Spacer()
Text(Self.formatBytes(folder.bytes))
.foregroundStyle(.secondary)
}
.font(.caption)
}
if storageHasBackup {
HStack {
Text("The original data is kept as a backup until you delete it.")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Delete backup…", role: .destructive) {
confirmDeleteBackup = true
}
.disabled(state.isMovingStorage)
}
}
HStack {
Button("Move storage…") {
chooseStorageFolder()
}
.disabled(state.isMovingStorage)
if state.isMovingStorage {
ProgressView()
.controlSize(.small)
}
}
if let message = state.storageMessage {
Text(message)
.font(.caption)
.foregroundStyle(message.hasPrefix("Move failed") ? .red : .secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.onAppear(perform: reloadStorageInfo)
.onChange(of: state.isMovingStorage) { _, moving in
if !moving { reloadStorageInfo() }
}
.confirmationDialog(
"Move the container storage?",
isPresented: Binding(
get: { pendingStorageParent != nil },
set: { if !$0 { pendingStorageParent = nil } }
),
titleVisibility: .visible
) {
Button("Move storage") {
guard let parent = pendingStorageParent else { return }
pendingStorageParent = nil
Task { @MainActor in
await state.moveStorage(toParent: parent)
}
}
Button("Cancel", role: .cancel) {
pendingStorageParent = nil
}
} message: {
Text("Images, containers, volumes and the build cache are copied to \(pendingStorageParent?.path ?? "the chosen folder"). Running containers are stopped during the move. The original stays as a backup until you delete it.")
}
.confirmationDialog(
"Delete the backup?",
isPresented: $confirmDeleteBackup,
titleVisibility: .visible
) {
Button("Delete backup", role: .destructive) {
state.deleteStorageBackup()
reloadStorageInfo()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Only do this after checking that images and containers work from the new location.")
}
}

private func reloadStorageInfo() {
let location = StorageLocation.standard
storageRedirected = location.isRedirected
storageHasBackup = location.backup != nil
storageSizes = location.sizes()
}

/// Lets the user pick a parent folder, then asks for confirmation.
private func chooseStorageFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "Choose"
panel.message = "Choose the folder where the container storage should live."
if panel.runModal() == .OK, let url = panel.url {
pendingStorageParent = url
}
}

private var storage: some View {
ScrollView {
Form {
storageLocation
Section("Disk usage") {
HStack {
Text("Total")
Expand Down
7 changes: 4 additions & 3 deletions Sources/ContainerBackend/LaunchdManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ public struct LaunchdManager: Sendable {
}

/// Start the container services. Idempotent: starting an already-running
/// daemon is a no-op.
public func start() async throws {
let result = try await runner.run(["system", "start"])
/// daemon is a no-op. `environment` is passed to `container system start`,
/// which records it for the daemon (for example CONTAINER_APP_ROOT).
public func start(environment: [String: String] = [:]) async throws {
let result = try await runner.run(["system", "start"], environment: environment)
guard result.succeeded else {
throw BackendError.operationFailed(
"failed to start container services: \(result.stderr.trimmingCharacters(in: .whitespacesAndNewlines))"
Expand Down
Loading
Loading