diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1f2959b..1f236df 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -36,6 +36,39 @@ jobs: key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- + - name: Checkout arcbox proto definitions + uses: actions/checkout@v4 + with: + repository: arcboxlabs/arcbox + path: arcbox + sparse-checkout: rpc/arcbox-protocol/proto + sparse-checkout-cone-mode: false + + - name: Verify protobuf generated code is up-to-date + run: | + # Install protoc via Homebrew + brew install protobuf + + # Recreate the sibling arcbox path as a symlink so generate.sh --local + # can find proto at the expected relative path even if cache restore + # previously created ../arcbox as a real directory. + rm -rf "$GITHUB_WORKSPACE/../arcbox" + ln -s "$GITHUB_WORKSPACE/arcbox" "$GITHUB_WORKSPACE/../arcbox" + + # Generate Swift code from local proto definitions + cd Packages/ArcBoxClient + ./generate.sh --local + + # Fail if generated files differ from checked-in versions (including untracked files) + if ! git diff --exit-code Sources/ArcBoxClient/Generated/; then + echo "::error::Protobuf generated Swift files are out of date. Run 'cd Packages/ArcBoxClient && ./generate.sh' and commit the result." + exit 1 + fi + if [ -n "$(git ls-files --others --exclude-standard Sources/ArcBoxClient/Generated/)" ]; then + echo "::error::Untracked generated files detected. Run 'cd Packages/ArcBoxClient && ./generate.sh' and commit all generated files." + exit 1 + fi + - name: Resolve Swift packages run: xcodebuild -resolvePackageDependencies -project ArcBox.xcodeproj -scheme ArcBox diff --git a/ArcBox/ArcBoxApp.swift b/ArcBox/ArcBoxApp.swift index 220559e..201cbf3 100644 --- a/ArcBox/ArcBoxApp.swift +++ b/ArcBox/ArcBoxApp.swift @@ -35,17 +35,24 @@ class AppDelegate: NSObject, NSApplicationDelegate { @main struct ArcBoxDesktopApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + // Lightweight init — no network calls until view appears @State private var appVM = AppViewModel() + // Lightweight init — no network calls until view appears @State private var daemonManager = DaemonManager() @State private var arcboxClient: ArcBoxClient? @State private var dockerClient: DockerClient? + // Lightweight init — no network calls until view appears @State private var eventMonitor = DockerEventMonitor() @State private var startupOrchestrator: StartupOrchestrator? // Shared ViewModels used by both main window and menu bar + // Lightweight init — no network calls until view appears @State private var containersVM = ContainersViewModel() + // Lightweight init — no network calls until view appears @State private var imagesVM = ImagesViewModel() + // Lightweight init — no network calls until view appears @State private var networksVM = NetworksViewModel() + // Lightweight init — no network calls until view appears @State private var volumesVM = VolumesViewModel() private let updaterDelegate = UpdaterDelegate() @@ -140,6 +147,7 @@ struct ArcBoxDesktopApp: App { // All ListViews use .task(id: docker != nil) to trigger their initial data load. // Creating DockerClient earlier (e.g., in initClientsAndReturn) causes those tasks // to fire before the Docker socket is ready, resulting in empty lists. (ABXD-76 / #169) + .onOpenURL { url in handleDeepLink(url) } .onChange(of: daemonManager.state) { _, newState in if newState.isRunning { if dockerClient == nil { @@ -197,7 +205,7 @@ struct ArcBoxDesktopApp: App { try await client.runConnections() Log.startup.info("runConnections ended") } catch { - Log.startup.error("runConnections failed: \(error.localizedDescription, privacy: .public)") + Log.startup.error("runConnections failed: \(error.localizedDescription, privacy: .private)") } } arcboxClient = client @@ -205,6 +213,19 @@ struct ArcBoxDesktopApp: App { appDelegate.connectionTask = task return client } + + /// Handle incoming `arcbox://` deep links. + /// TODO(ABXD-62): Register URL scheme in Info.plist or project build settings + /// (INFOPLIST_KEY_LSApplicationCategoryType / CFBundleURLTypes) once scheme routing is finalized. + private func handleDeepLink(_ url: URL) { + Log.startup.info("Received deep link: \(url.absoluteString, privacy: .private)") + guard url.scheme == "arcbox" else { + Log.startup.warning("Ignoring unrecognized URL scheme: \(url.scheme ?? "nil", privacy: .private)") + return + } + // TODO(ABXD-62): Route deep link to appropriate view based on host/path. + // e.g. arcbox://containers/, arcbox://settings, etc. + } } // MARK: - Environment Keys diff --git a/ArcBox/Components/EmptyStateView.swift b/ArcBox/Components/EmptyStateView.swift new file mode 100644 index 0000000..c189231 --- /dev/null +++ b/ArcBox/Components/EmptyStateView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// Reusable empty state component with icon, title, and an optional custom content area. +struct EmptyStateView: View { + let icon: String + let title: String + let content: Content + + init( + icon: String, + title: String, + @ViewBuilder content: () -> Content + ) { + self.icon = icon + self.title = title + self.content = content() + } + + var body: some View { + VStack(spacing: 16) { + Spacer() + + ZStack { + Circle() + .fill(AppColors.surfaceElevated) + .frame(width: 64, height: 64) + Image(systemName: icon) + .font(.system(size: 26)) + .foregroundStyle(AppColors.textMuted) + } + + Text(title) + .font(.system(size: 13)) + .foregroundStyle(AppColors.textSecondary) + + content + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(AppColors.surfaceElevated) + ) + + Spacer() + } + .frame(maxWidth: .infinity) + .padding(24) + } +} + +/// Convenience initializer for empty states without extra content. +extension EmptyStateView where Content == EmptyView { + init(icon: String, title: String) { + self.icon = icon + self.title = title + self.content = EmptyView() + } +} diff --git a/ArcBox/Components/StatusBadge.swift b/ArcBox/Components/StatusBadge.swift index 6ecbaa6..3e5b0bf 100644 --- a/ArcBox/Components/StatusBadge.swift +++ b/ArcBox/Components/StatusBadge.swift @@ -14,5 +14,7 @@ struct StatusBadge: View { .font(.system(size: 13)) .foregroundStyle(color) } + .accessibilityElement(children: .combine) + .accessibilityLabel(label) } } diff --git a/ArcBox/Logging.swift b/ArcBox/Logging.swift index 8045604..7b8adab 100644 --- a/ArcBox/Logging.swift +++ b/ArcBox/Logging.swift @@ -10,6 +10,7 @@ nonisolated enum Log { static let image = Logger(subsystem: subsystem, category: "image") static let volume = Logger(subsystem: subsystem, category: "volume") static let network = Logger(subsystem: subsystem, category: "network") + static let machine = Logger(subsystem: subsystem, category: "machine") static let pods = Logger(subsystem: subsystem, category: "pods") static let services = Logger(subsystem: subsystem, category: "services") } diff --git a/ArcBox/Models/DockerTerminalSession.swift b/ArcBox/Models/DockerTerminalSession.swift index 51a8dfd..4fa7f78 100644 --- a/ArcBox/Models/DockerTerminalSession.swift +++ b/ArcBox/Models/DockerTerminalSession.swift @@ -1,5 +1,6 @@ import ArcBoxClient import Foundation +import os import SwiftTerm /// Manages an interactive docker exec session using PTY + Process. @@ -9,6 +10,12 @@ import SwiftTerm @MainActor @Observable class DockerTerminalSession { + private enum Defaults { + static let cols = 80 + static let rows = 24 + static let bufferSize = 8192 + } + enum State: Equatable { case idle case connecting @@ -20,7 +27,12 @@ class DockerTerminalSession { var state: State = .idle @ObservationIgnored private var process: Process? - @ObservationIgnored private var masterFD: Int32 = -1 + /// ABXD-17: File descriptor protected by a lock to prevent close races. + /// `teardownProcess()` atomically swaps the FD to -1 under the lock, + /// then closes the old FD outside the lock. Readers (`send`, `resize`) + /// take the lock to read a snapshot, guaranteeing they never operate on + /// a closed or reused FD. + @ObservationIgnored private let masterFDLock = OSAllocatedUnfairLock(initialState: -1) @ObservationIgnored private var readTask: Task? @ObservationIgnored private weak var terminalView: TerminalView? /// Monotonically increasing counter to distinguish sessions. @@ -82,12 +94,12 @@ class DockerTerminalSession { state = .error("Failed to create PTY") return } - masterFD = master + masterFDLock.withLock { $0 = master } // Set initial terminal size from SwiftTerm (use sensible defaults if not yet laid out) let terminal = terminalView.getTerminal() - let cols = max(terminal.cols, 80) - let rows = max(terminal.rows, 24) + let cols = max(terminal.cols, Defaults.cols) + let rows = max(terminal.rows, Defaults.rows) var winSize = winsize() winSize.ws_col = UInt16(cols) winSize.ws_row = UInt16(rows) @@ -111,12 +123,11 @@ class DockerTerminalSession { // Start reading from PTY master readTask = Task.detached { [weak self] in - let bufferSize = 8192 - let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + let buffer = UnsafeMutablePointer.allocate(capacity: Defaults.bufferSize) defer { buffer.deallocate() } while !Task.isCancelled { - let bytesRead = read(masterForRead, buffer, bufferSize) + let bytesRead = read(masterForRead, buffer, Defaults.bufferSize) if bytesRead <= 0 { break } let data = Array(UnsafeBufferPointer(start: buffer, count: bytesRead)) await MainActor.run { [weak self] in @@ -151,27 +162,29 @@ class DockerTerminalSession { } catch { close(slave) close(master) - masterFD = -1 + masterFDLock.withLock { $0 = -1 } state = .error(error.localizedDescription) } } /// Send data from the terminal to the docker exec process stdin. func send(_ data: Data) { - guard masterFD >= 0 else { return } + let fd = masterFDLock.withLock { $0 } + guard fd >= 0 else { return } data.withUnsafeBytes { rawBuffer in guard let ptr = rawBuffer.baseAddress else { return } - _ = write(masterFD, ptr, rawBuffer.count) + _ = write(fd, ptr, rawBuffer.count) } } /// Update the PTY window size (called when terminal view resizes). func resize(cols: Int, rows: Int) { - guard masterFD >= 0, cols > 0, rows > 0 else { return } + let fd = masterFDLock.withLock { $0 } + guard fd >= 0, cols > 0, rows > 0 else { return } var winSize = winsize() winSize.ws_col = UInt16(cols) winSize.ws_row = UInt16(rows) - _ = ioctl(masterFD, TIOCSWINSZ, &winSize) + _ = ioctl(fd, TIOCSWINSZ, &winSize) } /// Disconnect and clean up the session. @@ -191,9 +204,16 @@ class DockerTerminalSession { // Capture references before nilling them out let dyingProcess = process - let oldMasterFD = masterFD process = nil - masterFD = -1 + + // ABXD-17: Atomically swap the FD to -1 under the lock so that + // concurrent `send()` / `resize()` calls see -1 immediately and + // never operate on the FD after it has been closed. + let oldMasterFD = masterFDLock.withLock { fd -> Int32 in + let prev = fd + fd = -1 + return prev + } // Move kill + close + dealloc entirely off the main thread. // Foundation's Process deallocation uses Mach ports that can diff --git a/ArcBox/Models/Utilities.swift b/ArcBox/Models/Utilities.swift index d92ec0d..a54ad65 100644 --- a/ArcBox/Models/Utilities.swift +++ b/ArcBox/Models/Utilities.swift @@ -1,5 +1,20 @@ import Foundation +/// Parse an ISO 8601 date string with automatic fallback from fractional seconds +/// to plain `.withInternetDateTime`. Returns `.distantPast` when the input is nil +/// or unparseable. +func parseISO8601Date(_ string: String?) -> Date { + guard let string else { return .distantPast } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let parsed = formatter.date(from: string) { + return parsed + } + // Retry without fractional seconds + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) ?? .distantPast +} + /// Shared utility for relative time display func relativeTime(from date: Date) -> String { let interval = Date().timeIntervalSince(date) diff --git a/ArcBox/Services/DockerEventMonitor.swift b/ArcBox/Services/DockerEventMonitor.swift index 0ef653c..eb84fd0 100644 --- a/ArcBox/Services/DockerEventMonitor.swift +++ b/ArcBox/Services/DockerEventMonitor.swift @@ -69,7 +69,7 @@ final class DockerEventMonitor { } } catch { if Task.isCancelled || isStopped { break } - Log.docker.warning("Event stream error, reconnecting in 2s: \(error.localizedDescription, privacy: .public)") + Log.docker.warning("Event stream error, reconnecting in 2s: \(error.localizedDescription, privacy: .private)") } guard !Task.isCancelled, !isStopped else { break } diff --git a/ArcBox/ViewModels/ContainersViewModel.swift b/ArcBox/ViewModels/ContainersViewModel.swift index fd4a8ec..45b1307 100644 --- a/ArcBox/ViewModels/ContainersViewModel.swift +++ b/ArcBox/ViewModels/ContainersViewModel.swift @@ -261,7 +261,7 @@ class ContainersViewModel { // (image, url, succeeded) — cache empty url as "no icon available" return (image, url, true) } catch { - Log.container.debug("Icon fetch failed for \(image, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.debug("Icon fetch failed for \(image, privacy: .private): \(error.localizedDescription, privacy: .private)") // Mark as failed so we don't cache the negative result return (image, nil, false) } @@ -313,7 +313,7 @@ class ContainersViewModel { await loadContainerDetails(selectedID, client: client) } } catch { - Log.container.error("Error loading containers via gRPC: \(error.localizedDescription, privacy: .public)") + Log.container.error("Error loading containers via gRPC: \(error.localizedDescription, privacy: .private)") SentrySDK.capture(error: error) { scope in scope.setTag(value: "list_grpc", key: "container_op") } @@ -330,7 +330,7 @@ class ContainersViewModel { _ = try await client.containers.start(request, options: ArcBoxClient.defaultCallOptions) setContainerRunningState(id, isRunning: true) } catch { - Log.container.error("Error starting container \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Error starting container \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") lastError = error.localizedDescription } setTransitioning(id, false) @@ -347,7 +347,7 @@ class ContainersViewModel { _ = try await client.containers.stop(request, options: ArcBoxClient.defaultCallOptions) setContainerRunningState(id, isRunning: false) } catch { - Log.container.error("Error stopping container \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Error stopping container \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") lastError = error.localizedDescription } setTransitioning(id, false) @@ -406,23 +406,23 @@ class ContainersViewModel { switch created.body { case .json(let body): let id = body.Id - Log.container.info("Created container \(id, privacy: .public)") + Log.container.info("Created container \(id, privacy: .private)") await loadContainersFromDocker(docker: docker) return id } case .badRequest(let err): - Log.container.error("Bad request creating container: \(String(describing: err), privacy: .public)") + Log.container.error("Bad request creating container: \(String(describing: err), privacy: .private)") case .notFound(let err): - Log.container.error("Image not found: \(String(describing: err), privacy: .public)") + Log.container.error("Image not found: \(String(describing: err), privacy: .private)") case .conflict(let err): - Log.container.error("Container name conflict: \(String(describing: err), privacy: .public)") + Log.container.error("Container name conflict: \(String(describing: err), privacy: .private)") case .internalServerError(let err): - Log.container.error("Server error creating container: \(String(describing: err), privacy: .public)") + Log.container.error("Server error creating container: \(String(describing: err), privacy: .private)") case .undocumented(let statusCode, _): Log.container.error("Unexpected status \(statusCode, privacy: .public) creating container") } } catch { - Log.container.error("Error creating container: \(String(describing: error), privacy: .public)") + Log.container.error("Error creating container: \(String(describing: error), privacy: .private)") } return nil } @@ -437,7 +437,7 @@ class ContainersViewModel { _ = try await client.containers.remove(request, options: ArcBoxClient.defaultCallOptions) removeContainerLocally(id) } catch { - Log.container.error("Error removing container \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Error removing container \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") lastError = error.localizedDescription } await loadContainers(client: client) @@ -465,7 +465,7 @@ class ContainersViewModel { } ) } catch { - Log.container.error("Error inspecting container \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Error inspecting container \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") } } @@ -492,14 +492,14 @@ class ContainersViewModel { viewModels[i].isTransitioning = true } containers = viewModels - Log.container.info("Loaded \(self.containers.count, privacy: .public) containers") + Log.container.info("Loaded \(self.containers.count, privacy: .public) containers via Docker") applyExpandedGroups(from: containers) await fetchIcons(client: iconClient) if let selectedID, containers.contains(where: { $0.id == selectedID }) { await loadContainerDetailsFromDocker(selectedID, docker: docker) } } catch { - Log.container.error("Error loading containers: \(error.localizedDescription, privacy: .public)") + Log.container.error("Error loading containers: \(error.localizedDescription, privacy: .private)") SentrySDK.capture(error: error) { scope in scope.setTag(value: "list_docker", key: "container_op") } @@ -515,7 +515,7 @@ class ContainersViewModel { _ = try await docker.api.ContainerStart(path: .init(id: id)) setContainerRunningState(id, isRunning: true) } catch { - Log.container.error("Error starting container \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Error starting container \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") lastError = error.localizedDescription } setTransitioning(id, false) @@ -530,7 +530,7 @@ class ContainersViewModel { _ = try await docker.api.ContainerStop(path: .init(id: id)) setContainerRunningState(id, isRunning: false) } catch { - Log.container.error("Error stopping container \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Error stopping container \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") lastError = error.localizedDescription } setTransitioning(id, false) @@ -545,7 +545,7 @@ class ContainersViewModel { removeContainerLocally(id) NotificationCenter.default.post(name: .dockerDataChanged, object: nil) } catch { - Log.container.error("Error removing container \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Error removing container \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") lastError = error.localizedDescription } await loadContainersFromDocker(docker: docker) @@ -576,10 +576,10 @@ class ContainersViewModel { rootfsMountPath: Self.normalized(snapshot.rootfsMountPath) ) Log.container.debug( - "Inspect snapshot for \(id, privacy: .public): domain=\(Self.normalized(snapshot.domainname) ?? "-", privacy: .public), ip=\(Self.normalized(snapshot.ipAddress) ?? "-", privacy: .public), mounts=\(mounts.count, privacy: .public), rootfs=\(Self.normalized(snapshot.rootfsMountPath) ?? "-", privacy: .public)" + "Inspect snapshot for \(id, privacy: .private): domain=\(Self.normalized(snapshot.domainname) ?? "-", privacy: .private), ip=\(Self.normalized(snapshot.ipAddress) ?? "-", privacy: .private), mounts=\(mounts.count, privacy: .public), rootfs=\(Self.normalized(snapshot.rootfsMountPath) ?? "-", privacy: .private)" ) } catch { - Log.container.error("Inspect snapshot failed for \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Inspect snapshot failed for \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") do { // Fallback to generated inspect model if raw path fails unexpectedly. let response = try await docker.api.ContainerInspect(path: .init(id: id)) @@ -603,10 +603,10 @@ class ContainersViewModel { mounts: mounts ) Log.container.debug( - "Inspect fallback for \(id, privacy: .public): domain=\(Self.normalized(details.Config?.Domainname) ?? "-", privacy: .public), ip=\(Self.normalized(details.NetworkSettings?.IPAddress) ?? "-", privacy: .public), mounts=\(mounts.count, privacy: .public)" + "Inspect fallback for \(id, privacy: .private): domain=\(Self.normalized(details.Config?.Domainname) ?? "-", privacy: .private), ip=\(Self.normalized(details.NetworkSettings?.IPAddress) ?? "-", privacy: .private), mounts=\(mounts.count, privacy: .public)" ) } catch { - Log.container.error("Inspect fallback failed for \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Inspect fallback failed for \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") } } } @@ -626,7 +626,7 @@ class ContainersViewModel { _ = try await docker.api.ContainerStart(path: .init(id: id)) await self?.setContainerRunningState(id, isRunning: true) } catch { - Log.container.error("Error starting container \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Error starting container \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") } } } @@ -648,7 +648,7 @@ class ContainersViewModel { _ = try await docker.api.ContainerStop(path: .init(id: id)) await self?.setContainerRunningState(id, isRunning: false) } catch { - Log.container.error("Error stopping container \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Error stopping container \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") } } } @@ -667,7 +667,7 @@ class ContainersViewModel { _ = try await docker.api.ContainerDelete(path: .init(id: id), query: .init(force: true)) await self?.removeContainerLocally(id) } catch { - Log.container.error("Error removing container \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.container.error("Error removing container \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") } } } diff --git a/ArcBox/ViewModels/ImagesViewModel.swift b/ArcBox/ViewModels/ImagesViewModel.swift index 251330f..f05dc5d 100644 --- a/ArcBox/ViewModels/ImagesViewModel.swift +++ b/ArcBox/ViewModels/ImagesViewModel.swift @@ -104,7 +104,7 @@ class ImagesViewModel { let url = response.url.isEmpty ? nil : response.url return (repo, url, true) } catch { - Log.image.debug("Icon fetch failed for \(repo, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.image.debug("Icon fetch failed for \(repo, privacy: .private): \(error.localizedDescription, privacy: .private)") return (repo, nil, false) } } @@ -141,7 +141,7 @@ class ImagesViewModel { Log.image.info("Loaded \(self.images.count, privacy: .public) images") await fetchIcons(client: iconClient) } catch { - Log.image.error("Error loading images: \(error.localizedDescription, privacy: .public)") + Log.image.error("Error loading images: \(error.localizedDescription, privacy: .private)") } } @@ -178,11 +178,11 @@ class ImagesViewModel { query: .init(fromImage: parsed.fromImage, tag: parsed.tag, platform: platform) ) _ = try response.ok - Log.image.info("Pulled image \(reference, privacy: .public)") + Log.image.info("Pulled image \(reference, privacy: .private)") await loadImages(docker: docker) return true } catch { - Log.image.error("Error pulling image \(reference, privacy: .public): \(String(describing: error), privacy: .public)") + Log.image.error("Error pulling image \(reference, privacy: .private): \(String(describing: error), privacy: .private)") return false } } @@ -196,11 +196,11 @@ class ImagesViewModel { body: .application_x_hyphen_tar(HTTPBody(data)) ) _ = try response.ok - Log.image.info("Imported image from \(tarURL.lastPathComponent, privacy: .public)") + Log.image.info("Imported image from \(tarURL.lastPathComponent, privacy: .private)") await loadImages(docker: docker) return true } catch { - Log.image.error("Error importing image: \(String(describing: error), privacy: .public)") + Log.image.error("Error importing image: \(String(describing: error), privacy: .private)") return false } } @@ -212,9 +212,9 @@ class ImagesViewModel { do { let response = try await docker.api.ImageDelete(path: .init(name: dockerId), query: .init(force: true)) _ = try response.ok - Log.image.info("Removed image \(dockerId, privacy: .public)") + Log.image.info("Removed image \(dockerId, privacy: .private)") } catch { - Log.image.error("Error removing image \(dockerId, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.image.error("Error removing image \(dockerId, privacy: .private): \(error.localizedDescription, privacy: .private)") lastError = error.localizedDescription } await loadImages(docker: docker) diff --git a/ArcBox/ViewModels/KubernetesState.swift b/ArcBox/ViewModels/KubernetesState.swift index 5c871c4..3778baf 100644 --- a/ArcBox/ViewModels/KubernetesState.swift +++ b/ArcBox/ViewModels/KubernetesState.swift @@ -55,7 +55,7 @@ class KubernetesState { } } catch { startError = error.localizedDescription - Log.pods.error("Error starting Kubernetes: \(error.localizedDescription, privacy: .public)") + Log.pods.error("Error starting Kubernetes: \(error.localizedDescription, privacy: .private)") self.enabled = false } isStarting = false diff --git a/ArcBox/ViewModels/MachinesViewModel.swift b/ArcBox/ViewModels/MachinesViewModel.swift index e8ff860..67bef9e 100644 --- a/ArcBox/ViewModels/MachinesViewModel.swift +++ b/ArcBox/ViewModels/MachinesViewModel.swift @@ -43,10 +43,16 @@ class MachinesViewModel { selectedID = id } - // Mock actions - func startMachine(_ id: String) {} - func stopMachine(_ id: String) {} - func deleteMachine(_ id: String) {} + // TODO: Implement when machine lifecycle is connected to gRPC + func startMachine(_ id: String) { + Log.machine.warning("Not implemented: \(#function) for \(id, privacy: .private)") + } + func stopMachine(_ id: String) { + Log.machine.warning("Not implemented: \(#function) for \(id, privacy: .private)") + } + func deleteMachine(_ id: String) { + Log.machine.warning("Not implemented: \(#function) for \(id, privacy: .private)") + } func loadSampleData() { machines = SampleData.machines diff --git a/ArcBox/ViewModels/NetworksViewModel.swift b/ArcBox/ViewModels/NetworksViewModel.swift index 7ef3550..cb160d4 100644 --- a/ArcBox/ViewModels/NetworksViewModel.swift +++ b/ArcBox/ViewModels/NetworksViewModel.swift @@ -72,7 +72,7 @@ class NetworksViewModel { networks = networkList.compactMap(NetworkViewModel.init(fromDocker:)) Log.network.info("Loaded \(self.networks.count, privacy: .public) networks") } catch { - Log.network.error("Error loading networks: \(error.localizedDescription, privacy: .public)") + Log.network.error("Error loading networks: \(error.localizedDescription, privacy: .private)") } } @@ -83,9 +83,9 @@ class NetworksViewModel { do { let response = try await docker.api.NetworkDelete(path: .init(id: id)) _ = try response.noContent - Log.network.info("Removed network \(id, privacy: .public)") + Log.network.info("Removed network \(id, privacy: .private)") } catch { - Log.network.error("Error removing network \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.network.error("Error removing network \(id, privacy: .private): \(error.localizedDescription, privacy: .private)") lastError = error.localizedDescription } await loadNetworks(docker: docker) @@ -111,7 +111,7 @@ class NetworksViewModel { let output = try await docker.api.NetworkCreate(body: .json(payload)) switch output { case .created: - Log.network.info("Created network \(trimmedName, privacy: .public)") + Log.network.info("Created network \(trimmedName, privacy: .private)") await loadNetworks(docker: docker) return nil case let .badRequest(response): @@ -126,7 +126,7 @@ class NetworksViewModel { return "Unexpected response status: \(status)." } } catch { - Log.network.error("Error creating network \(trimmedName, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.network.error("Error creating network \(trimmedName, privacy: .private): \(error.localizedDescription, privacy: .private)") return error.localizedDescription } } @@ -155,20 +155,7 @@ extension NetworkViewModel { init?(fromDocker network: Components.Schemas.Network) { guard let id = network.Id, let name = network.Name else { return nil } - let createdAt: Date - if let created = network.Created { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let parsed = formatter.date(from: created) { - createdAt = parsed - } else { - // Retry without fractional seconds - formatter.formatOptions = [.withInternetDateTime] - createdAt = formatter.date(from: created) ?? .distantPast - } - } else { - createdAt = .distantPast - } + let createdAt = parseISO8601Date(network.Created) self.init( id: id, diff --git a/ArcBox/ViewModels/PodsViewModel.swift b/ArcBox/ViewModels/PodsViewModel.swift index 0f27d89..c8ff41b 100644 --- a/ArcBox/ViewModels/PodsViewModel.swift +++ b/ArcBox/ViewModels/PodsViewModel.swift @@ -60,7 +60,7 @@ class PodsViewModel { self.pods = podList.items.compactMap { Self.mapPod($0) } return true } catch { - Log.pods.error("Error loading pods: \(error.localizedDescription, privacy: .public)") + Log.pods.error("Error loading pods: \(error.localizedDescription, privacy: .private)") self.pods = [] self.k8sClient = nil return false diff --git a/ArcBox/ViewModels/ServicesViewModel.swift b/ArcBox/ViewModels/ServicesViewModel.swift index 3ce5c6d..687cda9 100644 --- a/ArcBox/ViewModels/ServicesViewModel.swift +++ b/ArcBox/ViewModels/ServicesViewModel.swift @@ -57,7 +57,7 @@ class ServicesViewModel { self.services = serviceList.items.compactMap { Self.mapService($0) } return true } catch { - Log.services.error("Error loading services: \(error.localizedDescription, privacy: .public)") + Log.services.error("Error loading services: \(error.localizedDescription, privacy: .private)") self.services = [] self.k8sClient = nil return false diff --git a/ArcBox/ViewModels/VolumesViewModel.swift b/ArcBox/ViewModels/VolumesViewModel.swift index 1fac1ec..675bea8 100644 --- a/ArcBox/ViewModels/VolumesViewModel.swift +++ b/ArcBox/ViewModels/VolumesViewModel.swift @@ -93,7 +93,7 @@ class VolumesViewModel { volumes = (dfResponse.Volumes ?? []).map { VolumeViewModel(fromDocker: $0) } Log.volume.info("Loaded \(self.volumes.count, privacy: .public) volumes") } catch { - Log.volume.error("Error loading volumes: \(error.localizedDescription, privacy: .public)") + Log.volume.error("Error loading volumes: \(error.localizedDescription, privacy: .private)") } } @@ -105,11 +105,11 @@ class VolumesViewModel { body: .json(.init(Name: name.isEmpty ? nil : name)) ) let vol = try response.created.body.json - Log.volume.info("Created volume \(vol.Name, privacy: .public)") + Log.volume.info("Created volume \(vol.Name, privacy: .private)") await loadVolumes(docker: docker) return true } catch { - Log.volume.error("Error creating volume: \(String(describing: error), privacy: .public)") + Log.volume.error("Error creating volume: \(String(describing: error), privacy: .private)") return false } } @@ -139,7 +139,7 @@ class VolumesViewModel { ) volName = try response.created.body.json.Name } catch { - Log.volume.error("Error creating volume for import: \(String(describing: error), privacy: .public)") + Log.volume.error("Error creating volume for import: \(String(describing: error), privacy: .private)") return false } @@ -157,7 +157,7 @@ class VolumesViewModel { do { try await ensureImageExists("busybox:latest", docker: docker) } catch { - Log.volume.error("Error pulling busybox for import: \(String(describing: error), privacy: .public)") + Log.volume.error("Error pulling busybox for import: \(String(describing: error), privacy: .private)") return false } @@ -179,7 +179,7 @@ class VolumesViewModel { ) tempID = try response.created.body.json.Id } catch { - Log.volume.error("Error creating temp container for import: \(String(describing: error), privacy: .public)") + Log.volume.error("Error creating temp container for import: \(String(describing: error), privacy: .private)") return false } @@ -198,9 +198,9 @@ class VolumesViewModel { body: .application_x_hyphen_tar(body) ) _ = try response.ok - Log.volume.info("Imported tar into volume \(volName, privacy: .public)") + Log.volume.info("Imported tar into volume \(volName, privacy: .private)") } catch { - Log.volume.error("Error importing tar into volume: \(String(describing: error), privacy: .public)") + Log.volume.error("Error importing tar into volume: \(String(describing: error), privacy: .private)") return false } @@ -216,9 +216,9 @@ class VolumesViewModel { do { let response = try await docker.api.VolumeDelete(path: .init(name: name), query: .init(force: true)) _ = try response.noContent - Log.volume.info("Removed volume \(name, privacy: .public)") + Log.volume.info("Removed volume \(name, privacy: .private)") } catch { - Log.volume.error("Error removing volume \(name, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.volume.error("Error removing volume \(name, privacy: .private): \(error.localizedDescription, privacy: .private)") lastError = error.localizedDescription } await loadVolumes(docker: docker) @@ -231,20 +231,7 @@ class VolumesViewModel { extension VolumeViewModel { /// Create a VolumeViewModel from a Docker Engine API Volume. init(fromDocker volume: Components.Schemas.Volume) { - let createdAt: Date - if let created = volume.CreatedAt { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let parsed = formatter.date(from: created) { - createdAt = parsed - } else { - // Retry without fractional seconds - formatter.formatOptions = [.withInternetDateTime] - createdAt = formatter.date(from: created) ?? .distantPast - } - } else { - createdAt = .distantPast - } + let createdAt = parseISO8601Date(volume.CreatedAt) let sizeBytes: UInt64? if let size = volume.UsageData?.Size, size >= 0 { diff --git a/ArcBox/Views/Containers/ContainerEmptyState.swift b/ArcBox/Views/Containers/ContainerEmptyState.swift index e8f166a..5305821 100644 --- a/ArcBox/Views/Containers/ContainerEmptyState.swift +++ b/ArcBox/Views/Containers/ContainerEmptyState.swift @@ -3,22 +3,7 @@ import SwiftUI /// "No containers yet" + quick start commands struct ContainerEmptyState: View { var body: some View { - VStack(spacing: 16) { - Spacer() - - ZStack { - Circle() - .fill(AppColors.surfaceElevated) - .frame(width: 64, height: 64) - Image(systemName: "cube") - .font(.system(size: 26)) - .foregroundStyle(AppColors.textMuted) - } - - Text("No containers yet") - .font(.system(size: 13)) - .foregroundStyle(AppColors.textSecondary) - + EmptyStateView(icon: "cube", title: "No containers yet") { VStack(alignment: .leading, spacing: 8) { Text("Quick start:") .font(.system(size: 11)) @@ -37,15 +22,6 @@ struct ContainerEmptyState: View { description: "Start compose project" ) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(AppColors.surfaceElevated) - ) - - Spacer() } - .frame(maxWidth: .infinity) - .padding(24) } } diff --git a/ArcBox/Views/Containers/ContainerRowView.swift b/ArcBox/Views/Containers/ContainerRowView.swift index 2334717..ec2d403 100644 --- a/ArcBox/Views/Containers/ContainerRowView.swift +++ b/ArcBox/Views/Containers/ContainerRowView.swift @@ -139,12 +139,14 @@ struct ContainerRowView: View { action: onStartStop, color: isSelected ? AppColors.onAccent : AppColors.textSecondary ) + .accessibilityLabel("Start/Stop container") } IconButton( symbol: "trash.fill", action: { showDeleteConfirm = true }, color: isSelected ? AppColors.onAccent : AppColors.textSecondary ) + .accessibilityLabel("Delete container") } } } @@ -162,6 +164,8 @@ struct ContainerRowView: View { .foregroundStyle(isSelected ? AppColors.onAccent : AppColors.text) .padding(.horizontal, 12) .contentShape(Rectangle()) + .accessibilityElement(children: .contain) + .accessibilityLabel("\(container.name), \(container.state.label)") .onTapGesture(perform: onSelect) .onHover { hovering in isHovered = hovering diff --git a/ArcBox/Views/Containers/ContainersListView.swift b/ArcBox/Views/Containers/ContainersListView.swift index f79b5c2..34145f0 100644 --- a/ArcBox/Views/Containers/ContainersListView.swift +++ b/ArcBox/Views/Containers/ContainersListView.swift @@ -84,6 +84,7 @@ struct ContainersListView: View { Button(action: { vm.showNewContainerSheet = true }) { Image(systemName: "plus") } + .accessibilityLabel("New container") .keyboardShortcut("n", modifiers: .command) } } diff --git a/ArcBox/Views/ContentView.swift b/ArcBox/Views/ContentView.swift index 5c9f4ba..14524be 100644 --- a/ArcBox/Views/ContentView.swift +++ b/ArcBox/Views/ContentView.swift @@ -10,7 +10,7 @@ struct ContentView: View { @Environment(ImagesViewModel.self) private var imagesVM @Environment(NetworksViewModel.self) private var networksVM - // Feature ViewModels – local to main window + // Feature ViewModels -- local to main window @State private var k8sState = KubernetesState() @State private var podsVM = PodsViewModel() @State private var servicesVM = ServicesViewModel() @@ -24,36 +24,8 @@ struct ContentView: View { @Bindable var vm = appVM NavigationSplitView { - List(selection: $vm.currentNav) { - Section("Docker") { - ForEach(NavItem.Section.docker.items) { item in - Label(item.label, systemImage: item.sfSymbol) - .tag(item) - } - } - Section("Kubernetes") { - ForEach(NavItem.Section.kubernetes.items) { item in - Label(item.label, systemImage: item.sfSymbol) - .tag(item) - } - } - Section("Linux") { - ForEach(NavItem.Section.linux.items) { item in - Label(item.label, systemImage: item.sfSymbol) - .tag(item) - } - } - Section("Sandbox") { - ForEach(NavItem.Section.sandbox.items) { item in - Label(item.label, systemImage: item.sfSymbol) - .tag(item) - } - } - } - .listStyle(.sidebar) - .navigationSplitViewColumnWidth(180) + sidebar } content: { - // Sandbox section: collapse content column, full view goes to detail if isSandboxSection { Color.clear .navigationSplitViewColumnWidth(0) @@ -64,7 +36,7 @@ struct ContentView: View { .navigationSplitViewColumnWidth(min: 150, ideal: 320, max: 600) } } detail: { - detailColumn + detailPanel .background(AppColors.sidebar) } .onChange(of: appVM.currentNav) { _, newNav in @@ -78,6 +50,41 @@ struct ContentView: View { } } + // MARK: - Sidebar + + private var sidebar: some View { + @Bindable var vm = appVM + + return List(selection: $vm.currentNav) { + Section("Docker") { + ForEach(NavItem.Section.docker.items) { item in + Label(item.label, systemImage: item.sfSymbol) + .tag(item) + } + } + Section("Kubernetes") { + ForEach(NavItem.Section.kubernetes.items) { item in + Label(item.label, systemImage: item.sfSymbol) + .tag(item) + } + } + Section("Linux") { + ForEach(NavItem.Section.linux.items) { item in + Label(item.label, systemImage: item.sfSymbol) + .tag(item) + } + } + Section("Sandbox") { + ForEach(NavItem.Section.sandbox.items) { item in + Label(item.label, systemImage: item.sfSymbol) + .tag(item) + } + } + } + .listStyle(.sidebar) + .navigationSplitViewColumnWidth(180) + } + private var isSandboxSection: Bool { appVM.currentNav == .sandboxes || appVM.currentNav == .templates } @@ -119,10 +126,10 @@ struct ContentView: View { } } - // MARK: - Detail column + // MARK: - Detail panel @ViewBuilder - private var detailColumn: some View { + private var detailPanel: some View { switch appVM.currentNav { case .containers: ContainerDetailView() diff --git a/ArcBox/Views/Images/ImageEmptyState.swift b/ArcBox/Views/Images/ImageEmptyState.swift index d6f2e67..9df13df 100644 --- a/ArcBox/Views/Images/ImageEmptyState.swift +++ b/ArcBox/Views/Images/ImageEmptyState.swift @@ -2,22 +2,7 @@ import SwiftUI struct ImageEmptyState: View { var body: some View { - VStack(spacing: 16) { - Spacer() - - ZStack { - Circle() - .fill(AppColors.surfaceElevated) - .frame(width: 64, height: 64) - Image(systemName: "circle.circle") - .font(.system(size: 26)) - .foregroundStyle(AppColors.textMuted) - } - - Text("No images yet") - .font(.system(size: 13)) - .foregroundStyle(AppColors.textSecondary) - + EmptyStateView(icon: "circle.circle", title: "No images yet") { VStack(alignment: .leading, spacing: 8) { Text("Pull an image:") .font(.system(size: 11)) @@ -36,15 +21,6 @@ struct ImageEmptyState: View { description: "Redis with Alpine Linux" ) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(AppColors.surfaceElevated) - ) - - Spacer() } - .frame(maxWidth: .infinity) - .padding(24) } } diff --git a/ArcBox/Views/Images/ImageRowView.swift b/ArcBox/Views/Images/ImageRowView.swift index 64f71a0..350f198 100644 --- a/ArcBox/Views/Images/ImageRowView.swift +++ b/ArcBox/Views/Images/ImageRowView.swift @@ -87,6 +87,8 @@ struct ImageRowView: View { .foregroundStyle(isSelected ? AppColors.onAccent : AppColors.text) .padding(.horizontal, 12) .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(image.repository):\(image.tag), \(image.sizeDisplay)") .onTapGesture(perform: onSelect) .onHover { hovering in isHovered = hovering } .contextMenu { diff --git a/ArcBox/Views/Images/ImagesListView.swift b/ArcBox/Views/Images/ImagesListView.swift index 33ff432..2680e82 100644 --- a/ArcBox/Views/Images/ImagesListView.swift +++ b/ArcBox/Views/Images/ImagesListView.swift @@ -49,6 +49,7 @@ struct ImagesListView: View { Button(action: { vm.showPullImageSheet = true }) { Image(systemName: "plus") } + .accessibilityLabel("Pull image") .keyboardShortcut("n", modifiers: .command) } } diff --git a/ArcBox/Views/Kubernetes/PodRowView.swift b/ArcBox/Views/Kubernetes/PodRowView.swift index e251246..c7bcfec 100644 --- a/ArcBox/Views/Kubernetes/PodRowView.swift +++ b/ArcBox/Views/Kubernetes/PodRowView.swift @@ -52,6 +52,8 @@ struct PodRowView: View { .foregroundStyle(isSelected ? AppColors.onAccent : AppColors.text) .padding(.horizontal, 8) .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(pod.name), \(pod.phase.rawValue)") .onTapGesture(perform: onSelect) .onHover { hovering in isHovered = hovering } .contextMenu { diff --git a/ArcBox/Views/Kubernetes/ServiceRowView.swift b/ArcBox/Views/Kubernetes/ServiceRowView.swift index bd19e71..e948e04 100644 --- a/ArcBox/Views/Kubernetes/ServiceRowView.swift +++ b/ArcBox/Views/Kubernetes/ServiceRowView.swift @@ -47,6 +47,8 @@ struct ServiceRowView: View { .foregroundStyle(isSelected ? AppColors.onAccent : AppColors.text) .padding(.horizontal, 8) .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(service.name), \(service.type.rawValue)") .onTapGesture(perform: onSelect) .onHover { hovering in isHovered = hovering } .contextMenu { diff --git a/ArcBox/Views/Machines/MachineEmptyState.swift b/ArcBox/Views/Machines/MachineEmptyState.swift index bcb9d73..9fca2e7 100644 --- a/ArcBox/Views/Machines/MachineEmptyState.swift +++ b/ArcBox/Views/Machines/MachineEmptyState.swift @@ -2,13 +2,7 @@ import SwiftUI struct MachineEmptyState: View { var body: some View { - VStack(spacing: 16) { - Spacer() - - Text("No Linux machines yet") - .font(.system(size: 13)) - .foregroundStyle(AppColors.textSecondary) - + EmptyStateView(icon: "desktopcomputer", title: "No Linux machines yet") { VStack(alignment: .leading, spacing: 12) { Text("Create a new machine to run a full Linux environment:") .font(.system(size: 11)) @@ -27,15 +21,6 @@ struct MachineEmptyState: View { .foregroundStyle(AppColors.textSecondary) .padding(.top, 8) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(AppColors.surfaceElevated) - ) - - Spacer() } - .frame(maxWidth: .infinity) - .padding(24) } } diff --git a/ArcBox/Views/Machines/MachinesView.swift b/ArcBox/Views/Machines/MachinesView.swift index 828d606..1c3dd61 100644 --- a/ArcBox/Views/Machines/MachinesView.swift +++ b/ArcBox/Views/Machines/MachinesView.swift @@ -61,6 +61,7 @@ struct MachinesView: View { Button(action: {}) { Image(systemName: "plus") } + .accessibilityLabel("New machine") } } .onAppear { diff --git a/ArcBox/Views/Networks/NetworkEmptyState.swift b/ArcBox/Views/Networks/NetworkEmptyState.swift index 7b2c511..2911e8a 100644 --- a/ArcBox/Views/Networks/NetworkEmptyState.swift +++ b/ArcBox/Views/Networks/NetworkEmptyState.swift @@ -2,22 +2,10 @@ import SwiftUI struct NetworkEmptyState: View { var body: some View { - VStack(spacing: 16) { - Spacer() - - ZStack { - Circle() - .fill(AppColors.surfaceElevated) - .frame(width: 64, height: 64) - Image(systemName: "point.3.filled.connected.trianglepath.dotted") - .font(.system(size: 26)) - .foregroundStyle(AppColors.textMuted) - } - - Text("No networks yet") - .font(.system(size: 13)) - .foregroundStyle(AppColors.textSecondary) - + EmptyStateView( + icon: "point.3.filled.connected.trianglepath.dotted", + title: "No networks yet" + ) { VStack(alignment: .leading, spacing: 8) { Text("Create a network:") .font(.system(size: 11)) @@ -32,15 +20,6 @@ struct NetworkEmptyState: View { description: "Create overlay network" ) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(AppColors.surfaceElevated) - ) - - Spacer() } - .frame(maxWidth: .infinity) - .padding(24) } } diff --git a/ArcBox/Views/Networks/NetworkRowView.swift b/ArcBox/Views/Networks/NetworkRowView.swift index e4b20b1..8e7a0eb 100644 --- a/ArcBox/Views/Networks/NetworkRowView.swift +++ b/ArcBox/Views/Networks/NetworkRowView.swift @@ -58,6 +58,8 @@ struct NetworkRowView: View { .foregroundStyle(isSelected ? AppColors.onAccent : AppColors.text) .padding(.horizontal, 12) .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(network.name), \(network.driverDisplay)") .onTapGesture(perform: onSelect) .onHover { hovering in isHovered = hovering } .contextMenu { diff --git a/ArcBox/Views/Networks/NetworksListView.swift b/ArcBox/Views/Networks/NetworksListView.swift index 393caa3..4c52cd7 100644 --- a/ArcBox/Views/Networks/NetworksListView.swift +++ b/ArcBox/Views/Networks/NetworksListView.swift @@ -48,6 +48,7 @@ struct NetworksListView: View { Button(action: { vm.showNewNetworkSheet = true }) { Image(systemName: "plus") } + .accessibilityLabel("New network") .keyboardShortcut("n", modifiers: .command) } } diff --git a/ArcBox/Views/Networks/Tabs/NetworkContainersTab.swift b/ArcBox/Views/Networks/Tabs/NetworkContainersTab.swift index e0e2808..f9ea04f 100644 --- a/ArcBox/Views/Networks/Tabs/NetworkContainersTab.swift +++ b/ArcBox/Views/Networks/Tabs/NetworkContainersTab.swift @@ -87,7 +87,7 @@ struct NetworkContainersSection: View { } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } catch { - Log.network.error("Error loading containers for network \(network.id, privacy: .public): \(error.localizedDescription, privacy: .public)") + Log.network.error("Error loading containers for network \(network.id, privacy: .private): \(error.localizedDescription, privacy: .private)") containers = [] } } diff --git a/ArcBox/Views/Sandboxes/SandboxEmptyState.swift b/ArcBox/Views/Sandboxes/SandboxEmptyState.swift index cea7f51..329f464 100644 --- a/ArcBox/Views/Sandboxes/SandboxEmptyState.swift +++ b/ArcBox/Views/Sandboxes/SandboxEmptyState.swift @@ -2,13 +2,7 @@ import SwiftUI struct SandboxEmptyState: View { var body: some View { - VStack(spacing: 16) { - Spacer() - - Text("No sandboxes yet") - .font(.system(size: 13)) - .foregroundStyle(AppColors.textSecondary) - + EmptyStateView(icon: "shippingbox", title: "No sandboxes yet") { VStack(alignment: .leading, spacing: 8) { Text("Create a sandbox:") .font(.system(size: 11)) @@ -23,15 +17,6 @@ struct SandboxEmptyState: View { description: "Create from specific template" ) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(AppColors.surfaceElevated) - ) - - Spacer() } - .frame(maxWidth: .infinity) - .padding(24) } } diff --git a/ArcBox/Views/Templates/TemplateEmptyState.swift b/ArcBox/Views/Templates/TemplateEmptyState.swift index 6f838fc..d41e42f 100644 --- a/ArcBox/Views/Templates/TemplateEmptyState.swift +++ b/ArcBox/Views/Templates/TemplateEmptyState.swift @@ -2,13 +2,7 @@ import SwiftUI struct TemplateEmptyState: View { var body: some View { - VStack(spacing: 16) { - Spacer() - - Text("No templates yet") - .font(.system(size: 13)) - .foregroundStyle(AppColors.textSecondary) - + EmptyStateView(icon: "doc.text", title: "No templates yet") { VStack(alignment: .leading, spacing: 8) { Text("Create a template:") .font(.system(size: 11)) @@ -23,15 +17,6 @@ struct TemplateEmptyState: View { description: "Build template from Dockerfile" ) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(AppColors.surfaceElevated) - ) - - Spacer() } - .frame(maxWidth: .infinity) - .padding(24) } } diff --git a/ArcBox/Views/Volumes/VolumeEmptyState.swift b/ArcBox/Views/Volumes/VolumeEmptyState.swift index eb9e624..d8a9fab 100644 --- a/ArcBox/Views/Volumes/VolumeEmptyState.swift +++ b/ArcBox/Views/Volumes/VolumeEmptyState.swift @@ -2,22 +2,7 @@ import SwiftUI struct VolumeEmptyState: View { var body: some View { - VStack(spacing: 16) { - Spacer() - - ZStack { - Circle() - .fill(AppColors.surfaceElevated) - .frame(width: 64, height: 64) - Image(systemName: "internaldrive") - .font(.system(size: 26)) - .foregroundStyle(AppColors.textMuted) - } - - Text("No volumes yet") - .font(.system(size: 13)) - .foregroundStyle(AppColors.textSecondary) - + EmptyStateView(icon: "internaldrive", title: "No volumes yet") { VStack(alignment: .leading, spacing: 8) { Text("Create a volume:") .font(.system(size: 11)) @@ -32,15 +17,6 @@ struct VolumeEmptyState: View { description: "Mount volume to container" ) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(AppColors.surfaceElevated) - ) - - Spacer() } - .frame(maxWidth: .infinity) - .padding(24) } } diff --git a/ArcBox/Views/Volumes/VolumeRowView.swift b/ArcBox/Views/Volumes/VolumeRowView.swift index ab68deb..fb0d66c 100644 --- a/ArcBox/Views/Volumes/VolumeRowView.swift +++ b/ArcBox/Views/Volumes/VolumeRowView.swift @@ -58,6 +58,8 @@ struct VolumeRowView: View { .foregroundStyle(isSelected ? AppColors.onAccent : AppColors.text) .padding(.horizontal, 12) .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(volume.name), \(volume.sizeDisplay)") .onTapGesture(perform: onSelect) .onHover { hovering in isHovered = hovering } .contextMenu { diff --git a/ArcBox/Views/Volumes/VolumesListView.swift b/ArcBox/Views/Volumes/VolumesListView.swift index f1e5a6f..67a88ef 100644 --- a/ArcBox/Views/Volumes/VolumesListView.swift +++ b/ArcBox/Views/Volumes/VolumesListView.swift @@ -48,6 +48,7 @@ struct VolumesListView: View { Button(action: { vm.showNewVolumeSheet = true }) { Image(systemName: "plus") } + .accessibilityLabel("New volume") .keyboardShortcut("n", modifiers: .command) } } diff --git a/Packages/ArcBoxClient/Package.swift b/Packages/ArcBoxClient/Package.swift index f7823a2..a65fcfe 100644 --- a/Packages/ArcBoxClient/Package.swift +++ b/Packages/ArcBoxClient/Package.swift @@ -11,9 +11,9 @@ let package = Package( .library(name: "ArcBoxClient", targets: ["ArcBoxClient"]), ], dependencies: [ - .package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", from: "1.0.0"), - .package(url: "https://github.com/grpc/grpc-swift-protobuf.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.28.1"), + .package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", from: "1.2.0"), + .package(url: "https://github.com/grpc/grpc-swift-protobuf.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.35.0"), .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "9.7.0"), ], targets: [ diff --git a/Packages/ArcBoxClient/Sources/ArcBoxClient/DaemonManager.swift b/Packages/ArcBoxClient/Sources/ArcBoxClient/DaemonManager.swift index 1eddc68..87f1496 100644 --- a/Packages/ArcBoxClient/Sources/ArcBoxClient/DaemonManager.swift +++ b/Packages/ArcBoxClient/Sources/ArcBoxClient/DaemonManager.swift @@ -74,6 +74,13 @@ public final class DaemonManager { private var watchTask: Task? + /// Guards `enableDaemon()` against concurrent (re-entrant) calls. + /// Even though `@MainActor` serializes synchronous access, `await` + /// suspension points allow a second call to interleave. This flag + /// is checked at entry and cleared at exit to ensure only one + /// enable operation is in flight at a time. + private var isEnabling: Bool = false + public init() {} // MARK: - Helper Lifecycle @@ -128,7 +135,7 @@ public final class DaemonManager { if let appleScript = NSAppleScript(source: script) { appleScript.executeAndReturnError(&error) if let error { - ClientLog.daemon.warning("Helper install failed: \(error, privacy: .public)") + ClientLog.daemon.warning("Helper install failed: \(error, privacy: .private)") return false } return true @@ -147,6 +154,17 @@ public final class DaemonManager { /// Register the daemon with launchd. Does not wait for reachability — /// that is handled by ``connectAndWatch(client:)``. public func enableDaemon() async { + // ABXD-22: Prevent concurrent enable operations. Even though we + // are @MainActor, the `await` suspension points (unregister/register) + // allow a second SwiftUI .task call to interleave and start a + // duplicate enable cycle. + guard !isEnabling else { + ClientLog.daemon.info("enableDaemon() already in progress, skipping duplicate call") + return + } + isEnabling = true + defer { isEnabling = false } + errorMessage = nil state = .starting @@ -155,6 +173,15 @@ public final class DaemonManager { try? FileManager.default.createDirectory( atPath: "\(home)/.arcbox/run", withIntermediateDirectories: true) + // ABXD-54: Verify daemon binary code signature before registration. + // Run off MainActor to avoid blocking UI during codesign --verify. + // Compute the path here to avoid capturing non-Sendable self. + let daemonPath = Bundle.main.bundleURL + .appendingPathComponent("Contents/Helpers/com.arcboxlabs.desktop.daemon").path + await Task.detached { + Self.verifyDaemonSignature(at: daemonPath) + }.value + let status = daemonService.status ClientLog.daemon.info("SMAppService status: \(String(describing: status), privacy: .public)") @@ -178,7 +205,7 @@ public final class DaemonManager { ClientLog.daemon.info("Service registered successfully") state = .registered } catch { - ClientLog.daemon.error("Failed to register: \(error.localizedDescription, privacy: .public)") + ClientLog.daemon.error("Failed to register: \(error.localizedDescription, privacy: .private)") errorMessage = error.localizedDescription state = .error("Failed to register daemon: \(error.localizedDescription)") } @@ -202,7 +229,7 @@ public final class DaemonManager { ClientLog.daemon.info("Service registered successfully") state = .registered } catch { - ClientLog.daemon.error("Failed to register: \(error.localizedDescription, privacy: .public)") + ClientLog.daemon.error("Failed to register: \(error.localizedDescription, privacy: .private)") errorMessage = error.localizedDescription state = .error("Failed to register daemon: \(error.localizedDescription)") } @@ -234,7 +261,7 @@ public final class DaemonManager { ClientLog.daemon.info("Force re-register completed") state = .registered } catch { - ClientLog.daemon.error("Force re-register failed: \(error.localizedDescription, privacy: .public)") + ClientLog.daemon.error("Force re-register failed: \(error.localizedDescription, privacy: .private)") errorMessage = error.localizedDescription state = .error("Force re-register failed: \(error.localizedDescription)") } @@ -293,7 +320,7 @@ public final class DaemonManager { } } } catch { - ClientLog.daemon.warning("WatchSetupStatus stream error: \(error.localizedDescription, privacy: .public)") + ClientLog.daemon.warning("WatchSetupStatus stream error: \(error.localizedDescription, privacy: .private)") } continuation.finish() } @@ -343,6 +370,52 @@ public final class DaemonManager { watchTask = nil } + // MARK: - Binary Verification + + /// Verify the daemon binary's code signature before launching. + /// + /// Runs `codesign --verify --strict` on the daemon binary at `path`. + /// Logs a warning on failure but does **not** block startup — dev + /// builds may use ad-hoc signatures that fail strict verification. + /// + /// Static so it can be called from a detached task without capturing self. + private static func verifyDaemonSignature(at daemonPath: String) { + guard FileManager.default.fileExists(atPath: daemonPath) else { + ClientLog.daemon.warning("Daemon binary not found at expected path, skipping signature check") + return + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = ["--verify", "--strict", daemonPath] + process.standardOutput = FileHandle.nullDevice + // Redirect stderr to null to avoid pipe-buffer deadlock. + // codesign output is not actionable beyond the exit code. + process.standardError = FileHandle.nullDevice + + do { + try process.run() + // Bounded wait: terminate if codesign hangs for more than 10 seconds. + let deadline = DispatchTime.now() + .seconds(10) + let done = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in done.signal() } + if done.wait(timeout: deadline) == .timedOut { + process.terminate() + ClientLog.daemon.warning("Daemon signature verification timed out, killed codesign process") + return + } + if process.terminationStatus != 0 { + ClientLog.daemon.warning( + "Daemon binary signature verification failed (status \(process.terminationStatus))") + } else { + ClientLog.daemon.info("Daemon binary signature verified OK") + } + } catch { + ClientLog.daemon.warning( + "Could not run codesign verification: \(error.localizedDescription, privacy: .private)") + } + } + // MARK: - Internal /// Apply a setup status update. Called from MainActor-isolated stream handlers. diff --git a/Packages/ArcBoxClient/Sources/ArcBoxClient/Generated/agent.grpc.swift b/Packages/ArcBoxClient/Sources/ArcBoxClient/Generated/agent.grpc.swift index 0586a77..25193a1 100644 --- a/Packages/ArcBoxClient/Sources/ArcBoxClient/Generated/agent.grpc.swift +++ b/Packages/ArcBoxClient/Sources/ArcBoxClient/Generated/agent.grpc.swift @@ -132,6 +132,18 @@ public enum Arcbox_V1_AgentService { method: "GetKubeconfig" ) } + /// Namespace for "Shutdown" metadata. + public enum Shutdown { + /// Request type for "Shutdown". + public typealias Input = Arcbox_V1_ShutdownRequest + /// Response type for "Shutdown". + public typealias Output = Arcbox_V1_ShutdownResponse + /// Descriptor for "Shutdown". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.v1.AgentService"), + method: "Shutdown" + ) + } /// Descriptors for all methods in the "arcbox.v1.AgentService" service. public static let descriptors: [GRPCCore.MethodDescriptor] = [ Ping.descriptor, @@ -142,7 +154,8 @@ public enum Arcbox_V1_AgentService { StopKubernetes.descriptor, DeleteKubernetes.descriptor, GetKubernetesStatus.descriptor, - GetKubeconfig.descriptor + GetKubeconfig.descriptor, + Shutdown.descriptor ] } } @@ -333,6 +346,25 @@ extension Arcbox_V1_AgentService { request: GRPCCore.StreamingServerRequest, context: GRPCCore.ServerContext ) async throws -> GRPCCore.StreamingServerResponse + + /// Handle the "Shutdown" method. + /// + /// > Source IDL Documentation: + /// > + /// > Requests graceful guest shutdown. The agent responds immediately, + /// > then performs orderly process teardown and powers off the VM. + /// + /// - Parameters: + /// - request: A streaming request of `Arcbox_V1_ShutdownRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_V1_ShutdownResponse` messages. + func shutdown( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse } /// Service protocol for the "arcbox.v1.AgentService" service. @@ -508,6 +540,25 @@ extension Arcbox_V1_AgentService { request: GRPCCore.ServerRequest, context: GRPCCore.ServerContext ) async throws -> GRPCCore.ServerResponse + + /// Handle the "Shutdown" method. + /// + /// > Source IDL Documentation: + /// > + /// > Requests graceful guest shutdown. The agent responds immediately, + /// > then performs orderly process teardown and powers off the VM. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_V1_ShutdownRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Arcbox_V1_ShutdownResponse` message. + func shutdown( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse } /// Simple service protocol for the "arcbox.v1.AgentService" service. @@ -681,6 +732,25 @@ extension Arcbox_V1_AgentService { request: Arcbox_V1_KubernetesKubeconfigRequest, context: GRPCCore.ServerContext ) async throws -> Arcbox_V1_KubernetesKubeconfigResponse + + /// Handle the "Shutdown" method. + /// + /// > Source IDL Documentation: + /// > + /// > Requests graceful guest shutdown. The agent responds immediately, + /// > then performs orderly process teardown and powers off the VM. + /// + /// - Parameters: + /// - request: A `Arcbox_V1_ShutdownRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Arcbox_V1_ShutdownResponse` to respond with. + func shutdown( + request: Arcbox_V1_ShutdownRequest, + context: GRPCCore.ServerContext + ) async throws -> Arcbox_V1_ShutdownResponse } } @@ -787,6 +857,17 @@ extension Arcbox_V1_AgentService.StreamingServiceProtocol { ) } ) + router.registerHandler( + forMethod: Arcbox_V1_AgentService.Method.Shutdown.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.shutdown( + request: request, + context: context + ) + } + ) } } @@ -891,6 +972,17 @@ extension Arcbox_V1_AgentService.ServiceProtocol { ) return GRPCCore.StreamingServerResponse(single: response) } + + public func shutdown( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.shutdown( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } } // Default implementation of methods from 'ServiceProtocol'. @@ -1012,6 +1104,19 @@ extension Arcbox_V1_AgentService.SimpleServiceProtocol { metadata: [:] ) } + + public func shutdown( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.shutdown( + request: request.message, + context: context + ), + metadata: [:] + ) + } } // MARK: arcbox.v1.AgentService (client) @@ -1233,6 +1338,30 @@ extension Arcbox_V1_AgentService { options: GRPCCore.CallOptions, onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result ) async throws -> Result where Result: Sendable + + /// Call the "Shutdown" method. + /// + /// > Source IDL Documentation: + /// > + /// > Requests graceful guest shutdown. The agent responds immediately, + /// > then performs orderly process teardown and powers off the VM. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_V1_ShutdownRequest` message. + /// - serializer: A serializer for `Arcbox_V1_ShutdownRequest` messages. + /// - deserializer: A deserializer for `Arcbox_V1_ShutdownResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func shutdown( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable } /// Generated client for the "arcbox.v1.AgentService" service. @@ -1560,6 +1689,41 @@ extension Arcbox_V1_AgentService { onResponse: handleResponse ) } + + /// Call the "Shutdown" method. + /// + /// > Source IDL Documentation: + /// > + /// > Requests graceful guest shutdown. The agent responds immediately, + /// > then performs orderly process teardown and powers off the VM. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_V1_ShutdownRequest` message. + /// - serializer: A serializer for `Arcbox_V1_ShutdownRequest` messages. + /// - deserializer: A deserializer for `Arcbox_V1_ShutdownResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func shutdown( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Arcbox_V1_AgentService.Method.Shutdown.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } } } @@ -1826,6 +1990,36 @@ extension Arcbox_V1_AgentService.ClientProtocol { onResponse: handleResponse ) } + + /// Call the "Shutdown" method. + /// + /// > Source IDL Documentation: + /// > + /// > Requests graceful guest shutdown. The agent responds immediately, + /// > then performs orderly process teardown and powers off the VM. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_V1_ShutdownRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func shutdown( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.shutdown( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } } // Helpers providing sugared APIs for 'ClientProtocol' methods. @@ -2127,4 +2321,38 @@ extension Arcbox_V1_AgentService.ClientProtocol { onResponse: handleResponse ) } + + /// Call the "Shutdown" method. + /// + /// > Source IDL Documentation: + /// > + /// > Requests graceful guest shutdown. The agent responds immediately, + /// > then performs orderly process teardown and powers off the VM. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func shutdown( + _ message: Arcbox_V1_ShutdownRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.shutdown( + request: request, + options: options, + onResponse: handleResponse + ) + } } \ No newline at end of file diff --git a/Packages/ArcBoxClient/Sources/ArcBoxClient/Generated/agent.pb.swift b/Packages/ArcBoxClient/Sources/ArcBoxClient/Generated/agent.pb.swift index 4a1938d..9ff9f8e 100644 --- a/Packages/ArcBoxClient/Sources/ArcBoxClient/Generated/agent.pb.swift +++ b/Packages/ArcBoxClient/Sources/ArcBoxClient/Generated/agent.pb.swift @@ -382,6 +382,35 @@ public struct Arcbox_V1_KubernetesKubeconfigResponse: Sendable { public init() {} } +/// Graceful shutdown request from host. +public struct Arcbox_V1_ShutdownRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Grace period in seconds for processes to exit after SIGTERM. + /// 0 means use the agent's default (currently 25 seconds). + public var timeoutSeconds: UInt32 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +/// Shutdown acknowledgement. Sent before the agent begins teardown. +public struct Arcbox_V1_ShutdownResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Always true when the agent accepts the request. + public var accepted: Bool = false + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "arcbox.v1" @@ -1079,3 +1108,63 @@ extension Arcbox_V1_KubernetesKubeconfigResponse: SwiftProtobuf.Message, SwiftPr return true } } + +extension Arcbox_V1_ShutdownRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ShutdownRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}timeout_seconds\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self.timeoutSeconds) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.timeoutSeconds != 0 { + try visitor.visitSingularUInt32Field(value: self.timeoutSeconds, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_V1_ShutdownRequest, rhs: Arcbox_V1_ShutdownRequest) -> Bool { + if lhs.timeoutSeconds != rhs.timeoutSeconds {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Arcbox_V1_ShutdownResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ShutdownResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}accepted\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularBoolField(value: &self.accepted) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.accepted != false { + try visitor.visitSingularBoolField(value: self.accepted, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_V1_ShutdownResponse, rhs: Arcbox_V1_ShutdownResponse) -> Bool { + if lhs.accepted != rhs.accepted {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/Packages/ArcBoxClient/Sources/ArcBoxClient/StartupOrchestrator.swift b/Packages/ArcBoxClient/Sources/ArcBoxClient/StartupOrchestrator.swift index f362c6d..f33857b 100644 --- a/Packages/ArcBoxClient/Sources/ArcBoxClient/StartupOrchestrator.swift +++ b/Packages/ArcBoxClient/Sources/ArcBoxClient/StartupOrchestrator.swift @@ -261,7 +261,7 @@ public final class StartupOrchestrator { let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startTime) * 1000) let message = error.localizedDescription ClientLog.startup.error( - "\(step.label, privacy: .public) failed after \(elapsedMs, privacy: .public)ms: \(message, privacy: .public)") + "\(step.label, privacy: .public) failed after \(elapsedMs, privacy: .public)ms: \(message, privacy: .private)") SentrySDK.capture(error: error) { scope in scope.setTag(value: step.label, key: "startup_step") } diff --git a/Packages/DockerClient/Package.swift b/Packages/DockerClient/Package.swift index 0bba7b4..1200061 100644 --- a/Packages/DockerClient/Package.swift +++ b/Packages/DockerClient/Package.swift @@ -11,9 +11,9 @@ let package = Package( .library(name: "DockerClient", targets: ["DockerClient"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), - .package(url: "https://github.com/swift-server/swift-openapi-async-http-client", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.10.0"), + .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.10.0"), + .package(url: "https://github.com/swift-server/swift-openapi-async-http-client", from: "1.3.0"), ], targets: [ .target( diff --git a/Packages/DockerClient/Sources/DockerClient/DockerClient.swift b/Packages/DockerClient/Sources/DockerClient/DockerClient.swift index 5f0b774..5bd4b4a 100644 --- a/Packages/DockerClient/Sources/DockerClient/DockerClient.swift +++ b/Packages/DockerClient/Sources/DockerClient/DockerClient.swift @@ -101,7 +101,7 @@ struct UnixSocketTransport: ClientTransport { ) async throws -> (HTTPResponse, HTTPBody?) { // Retry transient connection errors (socket not ready, connection reset). let maxRetries = 2 - var lastError: Error? + var lastError: Error = URLError(.unknown) for attempt in 0...maxRetries { if attempt > 0 { try? await Task.sleep(for: .milliseconds(500 * attempt)) @@ -116,7 +116,7 @@ struct UnixSocketTransport: ClientTransport { guard isTransient, attempt < maxRetries else { break } } } - throw lastError! + throw lastError } private func sendOnce( diff --git a/Packages/K8sClient/Sources/K8sClient/K8sClient.swift b/Packages/K8sClient/Sources/K8sClient/K8sClient.swift index 9df021f..0d2a519 100644 --- a/Packages/K8sClient/Sources/K8sClient/K8sClient.swift +++ b/Packages/K8sClient/Sources/K8sClient/K8sClient.swift @@ -47,10 +47,14 @@ public final class K8sClient: Sendable { // MARK: - Watch (TODO: implement streaming watch with reconnection) // + // Design documented (ABXD-43). Current 10s polling is sufficient for the desktop UI; + // Watch API is a future optimization when real-time updates justify the complexity. + // // Future: implement watch using chunked HTTP response with: // - resourceVersion tracking from list metadata // - Automatic reconnection with exponential backoff // - ADDED/MODIFIED/DELETED event types + // - 410 Gone handling: re-list to obtain a fresh resourceVersion // See: https://kubernetes.io/docs/reference/using-api/api-concepts/#efficient-detection-of-changes // MARK: - Private