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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 22 additions & 1 deletion ArcBox/ArcBoxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -197,14 +205,27 @@ 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
appDelegate.arcboxClient = client
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/<id>, arcbox://settings, etc.
}
}

// MARK: - Environment Keys
Expand Down
57 changes: 57 additions & 0 deletions ArcBox/Components/EmptyStateView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import SwiftUI

/// Reusable empty state component with icon, title, and an optional custom content area.
struct EmptyStateView<Content: View>: 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()
}
}
2 changes: 2 additions & 0 deletions ArcBox/Components/StatusBadge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ struct StatusBadge: View {
.font(.system(size: 13))
.foregroundStyle(color)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(label)
}
}
1 change: 1 addition & 0 deletions ArcBox/Logging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
48 changes: 34 additions & 14 deletions ArcBox/Models/DockerTerminalSession.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ArcBoxClient
import Foundation
import os
import SwiftTerm

/// Manages an interactive docker exec session using PTY + Process.
Expand All @@ -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
Expand All @@ -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<Int32>(initialState: -1)
@ObservationIgnored private var readTask: Task<Void, Never>?
@ObservationIgnored private weak var terminalView: TerminalView?
/// Monotonically increasing counter to distinguish sessions.
Expand Down Expand Up @@ -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)
Expand All @@ -111,12 +123,11 @@ class DockerTerminalSession {

// Start reading from PTY master
readTask = Task.detached { [weak self] in
let bufferSize = 8192
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
let buffer = UnsafeMutablePointer<UInt8>.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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions ArcBox/Models/Utilities.swift
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion ArcBox/Services/DockerEventMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading
Loading