From 18358e2ee85399e4671391970bc13ccb53bd3784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Wed, 21 Oct 2026 12:14:03 -0400 Subject: [PATCH 1/6] feat(storage): choose where the container runtime keeps its data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings > Storage gets a Location section. Move storage… copies the runtime store to a folder the user picks, checks the copy's size, and replaces the default path with a symlink to the copy. The original is kept as a backup until the user deletes it. - StorageLocation moves and restores the store with a verified copy (ditto). Failures put the original back and remove the partial copy. - The move stops the daemon and restarts it on the new path with CONTAINER_APP_ROOT set. If the restart fails, the original is restored. - LaunchdManager.start accepts the environment for the daemon. - The symlink is followed by the CLI and the daemon, so no shell variable is needed. Logs are not moved yet. --- Sources/AppleDockerApp/AppState.swift | 48 ++++ .../AppleDockerApp/Views/SettingsView.swift | 119 ++++++++++ Sources/ContainerBackend/LaunchdManager.swift | 7 +- .../ContainerBackend/StorageLocation.swift | 222 ++++++++++++++++++ .../StorageLocationTests.swift | 146 ++++++++++++ 5 files changed, 539 insertions(+), 3 deletions(-) create mode 100644 Sources/ContainerBackend/StorageLocation.swift create mode 100644 Tests/ContainerBackendTests/StorageLocationTests.swift diff --git a/Sources/AppleDockerApp/AppState.swift b/Sources/AppleDockerApp/AppState.swift index 96bbbfc..f5d416a 100644 --- a/Sources/AppleDockerApp/AppState.swift +++ b/Sources/AppleDockerApp/AppState.swift @@ -75,6 +75,8 @@ 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 /// Whether a CLI install is in flight. var isInstalling = false /// Whether an app install into /Applications is in flight. @@ -692,6 +694,52 @@ 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 + do { + try await service.daemon.stop() + let result: StorageMoveResult + do { + result = try location.move(toParent: parent) + } catch { + try? await service.daemon.start() + throw error + } + do { + try await service.daemon.start(environment: [StorageLocation.environmentName: result.newRoot.path]) + } catch { + try? location.restoreBackup() + try? await service.daemon.start() + throw error + } + reconnect() + await refresh() + errorMessage = nil + } catch { + 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) diff --git a/Sources/AppleDockerApp/Views/SettingsView.swift b/Sources/AppleDockerApp/Views/SettingsView.swift index 59ce329..454ed21 100644 --- a/Sources/AppleDockerApp/Views/SettingsView.swift +++ b/Sources/AppleDockerApp/Views/SettingsView.swift @@ -3,6 +3,7 @@ //===----------------------------------------------------------------------===// import SwiftUI +import AppKit import ServiceManagement import ContainerBackend @@ -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 { @@ -293,9 +299,122 @@ 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) + Text("Moving storage. The daemon restarts afterwards.") + .foregroundStyle(.secondary) + } + } + } + .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") diff --git a/Sources/ContainerBackend/LaunchdManager.swift b/Sources/ContainerBackend/LaunchdManager.swift index 1472e9a..8276b1d 100644 --- a/Sources/ContainerBackend/LaunchdManager.swift +++ b/Sources/ContainerBackend/LaunchdManager.swift @@ -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))" diff --git a/Sources/ContainerBackend/StorageLocation.swift b/Sources/ContainerBackend/StorageLocation.swift new file mode 100644 index 0000000..d43df8e --- /dev/null +++ b/Sources/ContainerBackend/StorageLocation.swift @@ -0,0 +1,222 @@ +//===----------------------------------------------------------------------===// +// StorageLocation — where the container runtime keeps images, layers, +// containers, volumes and the build cache. +// +// The runtime uses one root directory (CONTAINER_APP_ROOT, default +// ~/Library/Application Support/com.apple.container). To move it, the data is +// copied to a new parent folder, verified, and the default path is replaced by +// a symlink to the copy. The original is kept as ".bak" until the caller +// deletes it. The CLI and the daemon both follow the symlink, so no environment +// variable is needed for the CLI. +// +// This type only moves files. Stopping and starting the daemon is the caller's +// job, and must happen before `move` runs. +//===----------------------------------------------------------------------===// + +import Foundation + +public enum StorageLocationError: LocalizedError, Equatable { + case alreadyRedirected(String) + case destinationMissing(String) + case destinationExists(String) + case notEnoughSpace(needed: UInt64, available: UInt64) + case backupExists(String) + case copyFailed(String) + case sizeMismatch(expected: UInt64, actual: UInt64) + + public var errorDescription: String? { + switch self { + case .alreadyRedirected(let target): + "Storage already points to \(target). Restore the default location before moving it again." + case .destinationMissing(let path): + "The destination folder does not exist: \(path). If it is on an external disk, connect it first." + case .destinationExists(let path): + "Something already exists at \(path). Choose another folder." + case .notEnoughSpace(let needed, let available): + "Not enough free space at the destination: \(needed) bytes needed, \(available) available." + case .backupExists(let path): + "A backup already exists at \(path). Delete it before moving the storage again." + case .copyFailed(let detail): + "Copying the storage failed: \(detail)" + case .sizeMismatch(let expected, let actual): + "The copy is incomplete: expected \(expected) bytes, found \(actual)." + } + } +} + +/// The result of a successful move. +public struct StorageMoveResult: Sendable, Equatable { + /// Where the data lives now (the target of the symlink). + public let newRoot: URL + /// The original folder, kept until the caller removes it. + public let backup: URL +} + +public struct StorageLocation: Sendable { + /// The path the runtime and the CLI use: the real folder, or a symlink to it. + public let link: URL + + public init(link: URL) { + self.link = link + } + + /// The default runtime root. + public static var standard: StorageLocation { + StorageLocation(link: defaultRoot) + } + + /// `~/Library/Application Support/com.apple.container`. + public static var defaultRoot: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("com.apple.container", isDirectory: true) + } + + /// True when the default path is a symlink to another folder. Asks the file + /// manager directly: `URL.resourceValues` caches its answer on the URL instance. + public var isRedirected: Bool { + (try? FileManager.default.destinationOfSymbolicLink(atPath: link.path)) != nil + } + + /// The folder that actually holds the data. + public var resolvedRoot: URL { + link.resolvingSymlinksInPath() + } + + /// Size of each top-level folder of the store, sorted by size, largest first. + public func sizes() -> [(name: String, bytes: UInt64)] { + let fm = FileManager.default + let entries = (try? fm.contentsOfDirectory(at: resolvedRoot, includingPropertiesForKeys: nil)) ?? [] + return entries + .map { (name: $0.lastPathComponent, bytes: Self.directorySize($0)) } + .sorted { $0.bytes > $1.bytes } + } + + /// The backup folder that `move` leaves behind, if one exists. + public var backup: URL? { + let url = link.deletingLastPathComponent().appendingPathComponent(link.lastPathComponent + ".bak", isDirectory: true) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } + + /// Copies the store into `parent/`, verifies the copy, and swaps the + /// default path for a symlink to it. On any failure the original is put + /// back and the partial copy is removed. + public func move( + toParent parent: URL, + copy: (URL, URL) throws -> Void = StorageLocation.dittoCopy + ) throws -> StorageMoveResult { + let fm = FileManager.default + if isRedirected { + throw StorageLocationError.alreadyRedirected(resolvedRoot.path) + } + guard fm.fileExists(atPath: parent.path) else { + throw StorageLocationError.destinationMissing(parent.path) + } + let destination = parent.appendingPathComponent(link.lastPathComponent, isDirectory: true) + guard !fm.fileExists(atPath: destination.path) else { + throw StorageLocationError.destinationExists(destination.path) + } + let backupURL = link.deletingLastPathComponent() + .appendingPathComponent(link.lastPathComponent + ".bak", isDirectory: true) + guard !fm.fileExists(atPath: backupURL.path) else { + throw StorageLocationError.backupExists(backupURL.path) + } + + let source = link + let needed = Self.directorySize(source) + let available = Self.availableCapacity(at: parent) + // A safety margin: the copy also needs room for metadata. + let required = needed + needed / 10 + guard available >= required else { + throw StorageLocationError.notEnoughSpace(needed: required, available: available) + } + + do { + try copy(source, destination) + } catch { + try? fm.removeItem(at: destination) + throw StorageLocationError.copyFailed(error.localizedDescription) + } + + let copied = Self.directorySize(destination) + guard copied == needed else { + try? fm.removeItem(at: destination) + throw StorageLocationError.sizeMismatch(expected: needed, actual: copied) + } + + do { + try fm.moveItem(at: source, to: backupURL) + } catch { + try? fm.removeItem(at: destination) + throw StorageLocationError.copyFailed("could not set the original aside: \(error.localizedDescription)") + } + do { + try fm.createSymbolicLink(at: link, withDestinationURL: destination) + } catch { + // Put the original back so the runtime still finds its data. + try? fm.moveItem(at: backupURL, to: source) + try? fm.removeItem(at: destination) + throw StorageLocationError.copyFailed("could not create the link: \(error.localizedDescription)") + } + return StorageMoveResult(newRoot: destination, backup: backupURL) + } + + /// Puts the original back: removes the symlink and renames the backup to the + /// default path. Does nothing when the store is not redirected. + public func restoreBackup() throws { + guard isRedirected, let backup else { return } + try FileManager.default.removeItem(at: link) + try FileManager.default.moveItem(at: backup, to: link) + } + + /// The environment variable the runtime reads for its root folder. + public static let environmentName = "CONTAINER_APP_ROOT" + + /// Deletes the backup left by `move`. Only call this after the moved storage + /// has been checked. + public func deleteBackup() throws { + guard let backup else { return } + try FileManager.default.removeItem(at: backup) + } + + /// Copies a folder with `ditto`, which keeps sparse files and attributes. + public static func dittoCopy(from source: URL, to destination: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + process.arguments = [source.path, destination.path] + let errors = Pipe() + process.standardError = errors + try process.run() + let stderr = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw StorageLocationError.copyFailed(stderr.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + /// Total size of the files under `url`, in bytes. + static func directorySize(_ url: URL) -> UInt64 { + let fm = FileManager.default + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return 0 } + if !isDirectory.boolValue { + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 + return UInt64(size) + } + guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey]) else { + return 0 + } + var total: UInt64 = 0 + for case let file as URL in enumerator { + let values = try? file.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + if values?.isRegularFile == true { + total += UInt64(values?.fileSize ?? 0) + } + } + return total + } + + static func availableCapacity(at url: URL) -> UInt64 { + let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + return UInt64(values?.volumeAvailableCapacityForImportantUsage ?? 0) + } +} diff --git a/Tests/ContainerBackendTests/StorageLocationTests.swift b/Tests/ContainerBackendTests/StorageLocationTests.swift new file mode 100644 index 0000000..4c3808d --- /dev/null +++ b/Tests/ContainerBackendTests/StorageLocationTests.swift @@ -0,0 +1,146 @@ +//===----------------------------------------------------------------------===// +// StorageLocationTests — moving the runtime store, with temporary folders only. +//===----------------------------------------------------------------------===// + +import XCTest +@testable import ContainerBackend + +final class StorageLocationTests: XCTestCase { + private var root: URL! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("storage-test-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + /// A store at `root/home/com.apple.container` with a few files. + private func makeStore() throws -> StorageLocation { + let link = root.appendingPathComponent("home/com.apple.container", isDirectory: true) + try FileManager.default.createDirectory(at: link.appendingPathComponent("content"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: link.appendingPathComponent("snapshots"), withIntermediateDirectories: true) + try Data(repeating: 1, count: 4_000).write(to: link.appendingPathComponent("content/blob")) + try Data(repeating: 2, count: 2_000).write(to: link.appendingPathComponent("snapshots/layer")) + return StorageLocation(link: link) + } + + private func makeDestinationParent() throws -> URL { + let parent = root.appendingPathComponent("external", isDirectory: true) + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + return parent + } + + func testMoveCopiesVerifiesAndLeavesSymlinkAndBackup() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + + let result = try store.move(toParent: parent) + + XCTAssertTrue(store.isRedirected) + XCTAssertEqual(store.resolvedRoot.standardizedFileURL, result.newRoot.standardizedFileURL) + XCTAssertEqual( + try Data(contentsOf: store.link.appendingPathComponent("content/blob")).count, 4_000 + ) + XCTAssertNotNil(store.backup) + XCTAssertTrue(FileManager.default.fileExists(atPath: result.backup.path)) + } + + func testDeleteBackupRemovesOnlyTheBackup() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + _ = try store.move(toParent: parent) + + try store.deleteBackup() + + XCTAssertNil(store.backup) + XCTAssertTrue(store.isRedirected) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.link.appendingPathComponent("content/blob").path)) + } + + func testFailedCopyRestoresTheOriginal() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + struct Boom: Error {} + + XCTAssertThrowsError(try store.move(toParent: parent, copy: { _, _ in throw Boom() })) { error in + guard case StorageLocationError.copyFailed = error else { + return XCTFail("expected copyFailed, got \(error)") + } + } + + XCTAssertFalse(store.isRedirected) + XCTAssertNil(store.backup) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.link.appendingPathComponent("content/blob").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: parent.appendingPathComponent("com.apple.container").path)) + } + + func testIncompleteCopyIsRejectedAndCleanedUp() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + + // A copy that drops one file. + let partial: (URL, URL) throws -> Void = { source, destination in + try FileManager.default.createDirectory(at: destination.appendingPathComponent("content"), withIntermediateDirectories: true) + try Data(repeating: 1, count: 10).write(to: destination.appendingPathComponent("content/blob")) + } + + XCTAssertThrowsError(try store.move(toParent: parent, copy: partial)) { error in + guard case StorageLocationError.sizeMismatch = error else { + return XCTFail("expected sizeMismatch, got \(error)") + } + } + XCTAssertFalse(store.isRedirected) + XCTAssertFalse(FileManager.default.fileExists(atPath: parent.appendingPathComponent("com.apple.container").path)) + } + + func testMissingDestinationIsRefused() throws { + let store = try makeStore() + let missing = root.appendingPathComponent("not-mounted", isDirectory: true) + + XCTAssertThrowsError(try store.move(toParent: missing)) { error in + guard case StorageLocationError.destinationMissing = error else { + return XCTFail("expected destinationMissing, got \(error)") + } + } + XCTAssertFalse(store.isRedirected) + } + + func testAlreadyRedirectedIsRefused() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + _ = try store.move(toParent: parent) + let second = root.appendingPathComponent("second", isDirectory: true) + try FileManager.default.createDirectory(at: second, withIntermediateDirectories: true) + + XCTAssertThrowsError(try store.move(toParent: second)) { error in + guard case StorageLocationError.alreadyRedirected = error else { + return XCTFail("expected alreadyRedirected, got \(error)") + } + } + } + + func testRestoreBackupPutsTheOriginalBack() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + _ = try store.move(toParent: parent) + + try store.restoreBackup() + + XCTAssertFalse(store.isRedirected) + XCTAssertNil(store.backup) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.link.appendingPathComponent("content/blob").path)) + } + + func testSizesListFoldersLargestFirst() throws { + let store = try makeStore() + + let sizes = store.sizes() + + XCTAssertEqual(sizes.map(\.name), ["content", "snapshots"]) + XCTAssertEqual(sizes.first?.bytes, 4_000) + } +} From 8df1f639a3c0ece7ad323af1a5e840fc4ecce775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Wed, 21 Oct 2026 12:39:45 -0400 Subject: [PATCH 2/6] fix(storage): show move progress and errors, and report allocated size - The Location section shows what the move is doing (stopping, copying, starting) and the result or the error. Before, a failed or skipped move showed nothing. - Folder sizes use allocated blocks. Logical sizes counted sparse disk images at their full length (4.41 TB for a store that uses about 500 MB). The copy check still compares logical sizes on both sides. --- Sources/AppleDockerApp/AppState.swift | 7 ++++ .../AppleDockerApp/Views/SettingsView.swift | 8 +++-- .../ContainerBackend/StorageLocation.swift | 36 +++++++++++++------ .../StorageLocationTests.swift | 10 +++--- 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/Sources/AppleDockerApp/AppState.swift b/Sources/AppleDockerApp/AppState.swift index f5d416a..f328b4d 100644 --- a/Sources/AppleDockerApp/AppState.swift +++ b/Sources/AppleDockerApp/AppState.swift @@ -77,6 +77,8 @@ final class AppState { 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. @@ -706,8 +708,10 @@ final class AppState { 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 { result = try location.move(toParent: parent) @@ -715,6 +719,7 @@ final class AppState { 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 { @@ -725,7 +730,9 @@ final class AppState { 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 } } diff --git a/Sources/AppleDockerApp/Views/SettingsView.swift b/Sources/AppleDockerApp/Views/SettingsView.swift index 454ed21..2bd190d 100644 --- a/Sources/AppleDockerApp/Views/SettingsView.swift +++ b/Sources/AppleDockerApp/Views/SettingsView.swift @@ -345,10 +345,14 @@ struct SettingsView: View { if state.isMovingStorage { ProgressView() .controlSize(.small) - Text("Moving storage. The daemon restarts afterwards.") - .foregroundStyle(.secondary) } } + 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 diff --git a/Sources/ContainerBackend/StorageLocation.swift b/Sources/ContainerBackend/StorageLocation.swift index d43df8e..8d4e233 100644 --- a/Sources/ContainerBackend/StorageLocation.swift +++ b/Sources/ContainerBackend/StorageLocation.swift @@ -82,12 +82,13 @@ public struct StorageLocation: Sendable { link.resolvingSymlinksInPath() } - /// Size of each top-level folder of the store, sorted by size, largest first. + /// Space used by each top-level folder of the store (allocated blocks, so + /// sparse disk images count only what they occupy), largest first. public func sizes() -> [(name: String, bytes: UInt64)] { let fm = FileManager.default let entries = (try? fm.contentsOfDirectory(at: resolvedRoot, includingPropertiesForKeys: nil)) ?? [] return entries - .map { (name: $0.lastPathComponent, bytes: Self.directorySize($0)) } + .map { (name: $0.lastPathComponent, bytes: Self.allocatedSize($0)) } .sorted { $0.bytes > $1.bytes } } @@ -193,24 +194,39 @@ public struct StorageLocation: Sendable { } } - /// Total size of the files under `url`, in bytes. + /// Space occupied on disk by the files under `url`. + static func allocatedSize(_ url: URL) -> UInt64 { + totalSize(url, keys: [.totalFileAllocatedSizeKey, .isRegularFileKey]) { values in + UInt64(values.totalFileAllocatedSize ?? 0) + } + } + + /// Logical size of the files under `url`, in bytes. Used to check a copy, + /// since both sides of the comparison are counted the same way. static func directorySize(_ url: URL) -> UInt64 { + totalSize(url, keys: [.fileSizeKey, .isRegularFileKey]) { values in + UInt64(values.fileSize ?? 0) + } + } + + private static func totalSize( + _ url: URL, + keys: Set, + bytes: (URLResourceValues) -> UInt64 + ) -> UInt64 { let fm = FileManager.default var isDirectory: ObjCBool = false guard fm.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return 0 } if !isDirectory.boolValue { - let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 - return UInt64(size) + return bytes((try? url.resourceValues(forKeys: keys)) ?? URLResourceValues()) } - guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey]) else { + guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: Array(keys)) else { return 0 } var total: UInt64 = 0 for case let file as URL in enumerator { - let values = try? file.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) - if values?.isRegularFile == true { - total += UInt64(values?.fileSize ?? 0) - } + guard let values = try? file.resourceValues(forKeys: keys), values.isRegularFile == true else { continue } + total += bytes(values) } return total } diff --git a/Tests/ContainerBackendTests/StorageLocationTests.swift b/Tests/ContainerBackendTests/StorageLocationTests.swift index 4c3808d..609835f 100644 --- a/Tests/ContainerBackendTests/StorageLocationTests.swift +++ b/Tests/ContainerBackendTests/StorageLocationTests.swift @@ -135,12 +135,14 @@ final class StorageLocationTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: store.link.appendingPathComponent("content/blob").path)) } - func testSizesListFoldersLargestFirst() throws { + func testSizesReportAllocatedSpacePerFolder() throws { let store = try makeStore() - let sizes = store.sizes() + let sizes = Dictionary(uniqueKeysWithValues: store.sizes().map { ($0.name, $0.bytes) }) - XCTAssertEqual(sizes.map(\.name), ["content", "snapshots"]) - XCTAssertEqual(sizes.first?.bytes, 4_000) + // Allocated space is at least the logical size, and comes in whole blocks. + XCTAssertGreaterThanOrEqual(sizes["content"] ?? 0, 4_000) + XCTAssertGreaterThanOrEqual(sizes["snapshots"] ?? 0, 2_000) + XCTAssertEqual(Set(sizes.keys), ["content", "snapshots"]) } } From 4d214de752e947b437508657779359fe352592fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Wed, 21 Oct 2026 12:44:15 -0400 Subject: [PATCH 3/6] fix(storage): check free space against allocated bytes, not logical size Sparse disk images report their full length as their size. The space check used that length, so a store of about 520 MB was reported as needing 4.8 TB. ditto keeps sparse files sparse, so the copy needs only the allocated bytes. The copy is still verified by logical size on both sides. --- .../ContainerBackend/StorageLocation.swift | 24 +++++++++++++------ .../StorageLocationTests.swift | 17 +++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/Sources/ContainerBackend/StorageLocation.swift b/Sources/ContainerBackend/StorageLocation.swift index 8d4e233..85d3cd3 100644 --- a/Sources/ContainerBackend/StorageLocation.swift +++ b/Sources/ContainerBackend/StorageLocation.swift @@ -24,6 +24,10 @@ public enum StorageLocationError: LocalizedError, Equatable { case copyFailed(String) case sizeMismatch(expected: UInt64, actual: UInt64) + static func format(_ bytes: UInt64) -> String { + ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) + } + public var errorDescription: String? { switch self { case .alreadyRedirected(let target): @@ -33,7 +37,7 @@ public enum StorageLocationError: LocalizedError, Equatable { case .destinationExists(let path): "Something already exists at \(path). Choose another folder." case .notEnoughSpace(let needed, let available): - "Not enough free space at the destination: \(needed) bytes needed, \(available) available." + "Not enough free space at the destination: \(Self.format(needed)) needed, \(Self.format(available)) available." case .backupExists(let path): "A backup already exists at \(path). Delete it before moving the storage again." case .copyFailed(let detail): @@ -103,7 +107,8 @@ public struct StorageLocation: Sendable { /// back and the partial copy is removed. public func move( toParent parent: URL, - copy: (URL, URL) throws -> Void = StorageLocation.dittoCopy + copy: (URL, URL) throws -> Void = StorageLocation.dittoCopy, + availableCapacity: (URL) -> UInt64 = StorageLocation.availableCapacity ) throws -> StorageMoveResult { let fm = FileManager.default if isRedirected { @@ -123,8 +128,10 @@ public struct StorageLocation: Sendable { } let source = link - let needed = Self.directorySize(source) - let available = Self.availableCapacity(at: parent) + // Sparse disk images copy as sparse files (ditto keeps them sparse), so + // the room needed is the allocated size, not the logical one. + let needed = Self.allocatedSize(source) + let available = availableCapacity(parent) // A safety margin: the copy also needs room for metadata. let required = needed + needed / 10 guard available >= required else { @@ -138,10 +145,12 @@ public struct StorageLocation: Sendable { throw StorageLocationError.copyFailed(error.localizedDescription) } + // Compare logical sizes on both sides: the copy preserves them exactly. + let expected = Self.directorySize(source) let copied = Self.directorySize(destination) - guard copied == needed else { + guard copied == expected else { try? fm.removeItem(at: destination) - throw StorageLocationError.sizeMismatch(expected: needed, actual: copied) + throw StorageLocationError.sizeMismatch(expected: expected, actual: copied) } do { @@ -231,7 +240,8 @@ public struct StorageLocation: Sendable { return total } - static func availableCapacity(at url: URL) -> UInt64 { + /// Free space on the volume that holds `url`, in bytes. + public static func availableCapacity(at url: URL) -> UInt64 { let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) return UInt64(values?.volumeAvailableCapacityForImportantUsage ?? 0) } diff --git a/Tests/ContainerBackendTests/StorageLocationTests.swift b/Tests/ContainerBackendTests/StorageLocationTests.swift index 609835f..b84b30f 100644 --- a/Tests/ContainerBackendTests/StorageLocationTests.swift +++ b/Tests/ContainerBackendTests/StorageLocationTests.swift @@ -97,6 +97,23 @@ final class StorageLocationTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: parent.appendingPathComponent("com.apple.container").path)) } + func testSparseImagesNeedOnlyTheirAllocatedSpace() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + // A sparse 10 GB image that occupies almost nothing on disk. + let image = store.link.appendingPathComponent("snapshots/sparse.img") + FileManager.default.createFile(atPath: image.path, contents: nil) + let handle = try FileHandle(forWritingTo: image) + try handle.truncate(atOffset: 10_000_000_000) + try handle.close() + + // Only 1 MB free: enough for the allocated bytes, far less than the logical size. + let result = try store.move(toParent: parent, availableCapacity: { _ in 1_000_000 }) + + XCTAssertTrue(store.isRedirected) + XCTAssertTrue(FileManager.default.fileExists(atPath: result.newRoot.appendingPathComponent("snapshots/sparse.img").path)) + } + func testMissingDestinationIsRefused() throws { let store = try makeStore() let missing = root.appendingPathComponent("not-mounted", isDirectory: true) From 818b4e5c4de635e07d0d39ce22cb9066e50156e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Wed, 21 Oct 2026 12:56:59 -0400 Subject: [PATCH 4/6] fix(storage): copy with rsync --sparse under an allocation limit, off the main thread - The copy uses rsync --sparse, which keeps holes in sparse disk images. ditto wrote a 550 GB sparse image out in full and filled the destination. - The copy is watched: if the space it occupies passes 1.5x the source's allocated bytes plus 64 MB, rsync is stopped and the move rolls back. - The copy result is checked against the same limit after it finishes. - The move runs on a background task, so the window stays responsive during a long copy. --- Sources/AppleDockerApp/AppState.swift | 5 +- .../ContainerBackend/StorageLocation.swift | 46 +++++++++++++++---- .../StorageLocationTests.swift | 4 +- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/Sources/AppleDockerApp/AppState.swift b/Sources/AppleDockerApp/AppState.swift index f328b4d..5378d0c 100644 --- a/Sources/AppleDockerApp/AppState.swift +++ b/Sources/AppleDockerApp/AppState.swift @@ -714,7 +714,10 @@ final class AppState { storageMessage = "Copying the storage to \(parent.path)…" let result: StorageMoveResult do { - result = try location.move(toParent: parent) + // 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 diff --git a/Sources/ContainerBackend/StorageLocation.swift b/Sources/ContainerBackend/StorageLocation.swift index 85d3cd3..525dc2d 100644 --- a/Sources/ContainerBackend/StorageLocation.swift +++ b/Sources/ContainerBackend/StorageLocation.swift @@ -102,12 +102,16 @@ public struct StorageLocation: Sendable { return FileManager.default.fileExists(atPath: url.path) ? url : nil } + /// Copies `source` to `destination`. `allocationLimit` is the most space the + /// copy may occupy on disk; a copy that exceeds it must stop with an error. + public typealias CopyFunction = (_ source: URL, _ destination: URL, _ allocationLimit: UInt64) throws -> Void + /// Copies the store into `parent/`, verifies the copy, and swaps the /// default path for a symlink to it. On any failure the original is put /// back and the partial copy is removed. public func move( toParent parent: URL, - copy: (URL, URL) throws -> Void = StorageLocation.dittoCopy, + copy: CopyFunction = StorageLocation.sparseCopy, availableCapacity: (URL) -> UInt64 = StorageLocation.availableCapacity ) throws -> StorageMoveResult { let fm = FileManager.default @@ -128,8 +132,8 @@ public struct StorageLocation: Sendable { } let source = link - // Sparse disk images copy as sparse files (ditto keeps them sparse), so - // the room needed is the allocated size, not the logical one. + // Sparse disk images copy as sparse files, so the room needed is the + // allocated size, not the logical one. let needed = Self.allocatedSize(source) let available = availableCapacity(parent) // A safety margin: the copy also needs room for metadata. @@ -137,13 +141,19 @@ public struct StorageLocation: Sendable { guard available >= required else { throw StorageLocationError.notEnoughSpace(needed: required, available: available) } + // The copy may use up to 1.5x the source's allocated space, plus 64 MB. + let allocationLimit = needed + needed / 2 + 64_000_000 do { - try copy(source, destination) + try copy(source, destination, allocationLimit) } catch { try? fm.removeItem(at: destination) throw StorageLocationError.copyFailed(error.localizedDescription) } + guard Self.allocatedSize(destination) <= allocationLimit else { + try? fm.removeItem(at: destination) + throw StorageLocationError.copyFailed("the copy occupies more space than expected") + } // Compare logical sizes on both sides: the copy preserves them exactly. let expected = Self.directorySize(source) @@ -188,16 +198,34 @@ public struct StorageLocation: Sendable { try FileManager.default.removeItem(at: backup) } - /// Copies a folder with `ditto`, which keeps sparse files and attributes. - public static func dittoCopy(from source: URL, to destination: URL) throws { + /// Copies a folder with `rsync --sparse`, which keeps holes in sparse files. + /// The copy is watched while it runs: if the space it occupies passes + /// `allocationLimit`, rsync is stopped and an error is thrown, so a copy + /// that expands sparse files cannot fill the disk. + public static func sparseCopy(from source: URL, to destination: URL, allocationLimit: UInt64) throws { + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") - process.arguments = [source.path, destination.path] + process.executableURL = URL(fileURLWithPath: "/usr/bin/rsync") + process.arguments = ["-a", "--sparse", source.path + "/", destination.path + "/"] let errors = Pipe() process.standardError = errors + process.standardOutput = FileHandle.nullDevice try process.run() - let stderr = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + + var exceeded = false + while process.isRunning { + Thread.sleep(forTimeInterval: 1) + if allocatedSize(destination) > allocationLimit { + exceeded = true + process.terminate() + break + } + } process.waitUntilExit() + let stderr = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + if exceeded { + throw StorageLocationError.copyFailed("the copy grew past the expected size and was stopped") + } guard process.terminationStatus == 0 else { throw StorageLocationError.copyFailed(stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } diff --git a/Tests/ContainerBackendTests/StorageLocationTests.swift b/Tests/ContainerBackendTests/StorageLocationTests.swift index b84b30f..dde9516 100644 --- a/Tests/ContainerBackendTests/StorageLocationTests.swift +++ b/Tests/ContainerBackendTests/StorageLocationTests.swift @@ -66,7 +66,7 @@ final class StorageLocationTests: XCTestCase { let parent = try makeDestinationParent() struct Boom: Error {} - XCTAssertThrowsError(try store.move(toParent: parent, copy: { _, _ in throw Boom() })) { error in + XCTAssertThrowsError(try store.move(toParent: parent, copy: { _, _, _ in throw Boom() })) { error in guard case StorageLocationError.copyFailed = error else { return XCTFail("expected copyFailed, got \(error)") } @@ -88,7 +88,7 @@ final class StorageLocationTests: XCTestCase { try Data(repeating: 1, count: 10).write(to: destination.appendingPathComponent("content/blob")) } - XCTAssertThrowsError(try store.move(toParent: parent, copy: partial)) { error in + XCTAssertThrowsError(try store.move(toParent: parent, copy: { source, destination, _ in try partial(source, destination) })) { error in guard case StorageLocationError.sizeMismatch = error else { return XCTFail("expected sizeMismatch, got \(error)") } From 7e28875b484b62e451a4712c58ebfb1ca2e1e29a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Wed, 21 Oct 2026 13:26:34 -0400 Subject: [PATCH 5/6] fix(storage): copy with tar, which skips holes in sparse images rsync reads every byte of a sparse file to find its holes. The store has nine snapshot images of 513 GB each, holding about 40 MB of data apiece, so the copy ran for half an hour and copied 211 MB of 649 MB. bsdtar finds holes with SEEK_HOLE and does not read them. A real 513 GB snapshot copies in seconds with the same allocated size as the source. The copy is still watched against the allocation limit, and stopping it also stops the tar writing to that destination. --- .../ContainerBackend/StorageLocation.swift | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/Sources/ContainerBackend/StorageLocation.swift b/Sources/ContainerBackend/StorageLocation.swift index 525dc2d..bf5cb57 100644 --- a/Sources/ContainerBackend/StorageLocation.swift +++ b/Sources/ContainerBackend/StorageLocation.swift @@ -198,15 +198,21 @@ public struct StorageLocation: Sendable { try FileManager.default.removeItem(at: backup) } - /// Copies a folder with `rsync --sparse`, which keeps holes in sparse files. - /// The copy is watched while it runs: if the space it occupies passes - /// `allocationLimit`, rsync is stopped and an error is thrown, so a copy - /// that expands sparse files cannot fill the disk. + /// Copies a folder with `tar | tar`. bsdtar finds the holes in sparse files + /// with SEEK_HOLE and does not read them, so a 550 GB sparse image with 40 MB + /// of data copies in seconds. The copy is watched while it runs: if the space + /// it occupies passes `allocationLimit`, the copy is stopped and an error is + /// thrown, so a copy that expands sparse files cannot fill the disk. public static func sparseCopy(from source: URL, to destination: URL, allocationLimit: UInt64) throws { try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/rsync") - process.arguments = ["-a", "--sparse", source.path + "/", destination.path + "/"] + process.executableURL = URL(fileURLWithPath: "/bin/sh") + // Paths are passed as arguments, never pasted into the script. + process.arguments = [ + "-c", + "set -o pipefail; /usr/bin/tar -cf - -C \"$1\" . | /usr/bin/tar -xf - -C \"$2\"", + "sparse-copy", source.path, destination.path, + ] let errors = Pipe() process.standardError = errors process.standardOutput = FileHandle.nullDevice @@ -217,7 +223,7 @@ public struct StorageLocation: Sendable { Thread.sleep(forTimeInterval: 1) if allocatedSize(destination) > allocationLimit { exceeded = true - process.terminate() + stopCopy(destinationPath: destination.path, process: process) break } } @@ -231,6 +237,17 @@ public struct StorageLocation: Sendable { } } + /// Stops a copy: the shell, and the tar writing to this destination (the + /// destination path is unique to this move, so the match is exact). + private static func stopCopy(destinationPath: String, process: Process) { + process.terminate() + let killer = Process() + killer.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + killer.arguments = ["-f", "-- -C \(destinationPath)"] + try? killer.run() + killer.waitUntilExit() + } + /// Space occupied on disk by the files under `url`. static func allocatedSize(_ url: URL) -> UInt64 { totalSize(url, keys: [.totalFileAllocatedSizeKey, .isRegularFileKey]) { values in From 22b33d0f2d3029123889bffb4dc6a8c3bae73885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Wed, 21 Oct 2026 13:38:02 -0400 Subject: [PATCH 6/6] fix(storage): remove the copy when a move is rolled back When the daemon failed to start on the new location, the original was put back but the copy stayed on the target disk. The next move then refused with "something already exists". The unused copy is now removed in the rollback. --- Sources/AppleDockerApp/AppState.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/AppleDockerApp/AppState.swift b/Sources/AppleDockerApp/AppState.swift index 5378d0c..03f2028 100644 --- a/Sources/AppleDockerApp/AppState.swift +++ b/Sources/AppleDockerApp/AppState.swift @@ -726,7 +726,10 @@ final class AppState { 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 }