├── assets
└── overview.png
├── .gitignore
├── Orchard
├── Assets.xcassets
│ ├── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── Orchard.entitlements
├── Views
│ ├── Features
│ │ ├── Registries
│ │ │ ├── RegistriesDetailHeader.swift
│ │ │ └── DetailRegistries.swift
│ │ ├── Configuration
│ │ │ ├── ConfigurationDetailHeader.swift
│ │ │ └── ConfigurationDetail.swift
│ │ ├── Mounts
│ │ │ ├── MountDetailHeader.swift
│ │ │ └── ListMounts.swift
│ │ ├── DNS
│ │ │ ├── DNSDetailHeader.swift
│ │ │ ├── DetailDNS.swift
│ │ │ ├── AddDNS.swift
│ │ │ └── ListDNS.swift
│ │ ├── Networks
│ │ │ ├── NetworkDetailHeader.swift
│ │ │ ├── README.md
│ │ │ ├── DetailNetwork.swift
│ │ │ ├── ListNetworks.swift
│ │ │ └── AddNetwork.swift
│ │ ├── Images
│ │ │ ├── ImageDetailHeader.swift
│ │ │ └── ListImages.swift
│ │ ├── Containers
│ │ │ ├── ListContainers.swift
│ │ │ └── ContainerDetailHeader.swift
│ │ ├── Logs
│ │ │ └── ViewLogs.swift
│ │ └── Stats
│ │ │ └── StatsView.swift
│ ├── States
│ │ ├── NotRunning.swift
│ │ ├── NewerVersion.swift
│ │ └── VersionIncompatibility.swift
│ ├── Layout
│ │ ├── Sidebar
│ │ │ ├── SidebarTabSelection.swift
│ │ │ ├── SidebarTabs.swift
│ │ │ └── Sidebar.swift
│ │ ├── DetailContent.swift
│ │ ├── MainInterface.swift
│ │ └── Content.swift
│ └── Components
│ │ ├── DetailViewButton.swift
│ │ ├── ListItemRow.swift
│ │ ├── ContainerTable.swift
│ │ ├── StatsTableView.swift
│ │ └── ItemNavigatorPopover.swift
├── Info.plist
└── OrchardApp.swift
├── Orchard.xcodeproj
└── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ ├── WorkspaceSettings.xcsettings
│ └── swiftpm
│ └── Package.resolved
├── OrchardTests
└── OrchardTests.swift
├── OrchardUITests
├── OrchardUITestsLaunchTests.swift
└── OrchardUITests.swift
├── README.md
├── CHANGELOG.md
├── .github
└── workflows
│ └── release.yml
└── scripts
└── release.sh
/assets/overview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrew-waters/orchard/HEAD/assets/overview.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | scratch
2 | .DS_Store
3 | Orchard.xcodeproj/project.xcworkspace/xcuserdata/*
4 | Orchard.xcodeproj/xcuserdata/*
5 |
--------------------------------------------------------------------------------
/Orchard/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Orchard.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Orchard/Orchard.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/Orchard/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Orchard.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/OrchardTests/OrchardTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OrchardTests.swift
3 | // OrchardTests
4 | //
5 | // Created by Andrew Waters on 16/06/2025.
6 | //
7 |
8 | import Testing
9 |
10 | struct OrchardTests {
11 |
12 | @Test func example() async throws {
13 | // Write your test here and use APIs like `#expect(...)` to check expected conditions.
14 | }
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/Orchard.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "19173093ad1c8b01f9138471625844ff2974d4af9c11c4e7fa54808c7cd74596",
3 | "pins" : [
4 | {
5 | "identity" : "swift-exec",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/samuelmeuli/swift-exec",
8 | "state" : {
9 | "branch" : "master",
10 | "revision" : "ca84cfe4d769722d15cac1d87cfa508b08115504"
11 | }
12 | }
13 | ],
14 | "version" : 3
15 | }
16 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/Registries/RegistriesDetailHeader.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Registries Detail Header
4 | struct RegistriesDetailHeader: View {
5 | var body: some View {
6 | HStack {
7 | VStack(alignment: .leading, spacing: 2) {
8 | Text("Registries")
9 | .font(.title2)
10 | .fontWeight(.semibold)
11 | }
12 | Spacer()
13 |
14 | // Action buttons
15 | HStack(spacing: 8) {
16 | // Add registries-specific actions here if needed in the future
17 | }
18 | }
19 | .padding(.horizontal, 16)
20 | .padding(.top, 20)
21 | .padding(.bottom, 12)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/Configuration/ConfigurationDetailHeader.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Configuration Detail Header
4 | struct ConfigurationDetailHeader: View {
5 | var body: some View {
6 | HStack {
7 | VStack(alignment: .leading, spacing: 2) {
8 | Text("Configuration")
9 | .font(.title2)
10 | .fontWeight(.semibold)
11 | }
12 | Spacer()
13 |
14 | // Action buttons
15 | HStack(spacing: 8) {
16 | // Add configuration-specific actions here if needed in the future
17 | }
18 | }
19 | .padding(.horizontal, 16)
20 | .padding(.top, 20)
21 | .padding(.bottom, 12)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/OrchardUITests/OrchardUITestsLaunchTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OrchardUITestsLaunchTests.swift
3 | // OrchardUITests
4 | //
5 | // Created by Andrew Waters on 16/06/2025.
6 | //
7 |
8 | import XCTest
9 |
10 | final class OrchardUITestsLaunchTests: XCTestCase {
11 |
12 | override class var runsForEachTargetApplicationUIConfiguration: Bool {
13 | true
14 | }
15 |
16 | override func setUpWithError() throws {
17 | continueAfterFailure = false
18 | }
19 |
20 | @MainActor
21 | func testLaunch() throws {
22 | let app = XCUIApplication()
23 | app.launch()
24 |
25 | // Insert steps here to perform after app launch but before taking a screenshot,
26 | // such as logging into a test account or navigating somewhere in the app
27 |
28 | let attachment = XCTAttachment(screenshot: app.screenshot())
29 | attachment.name = "Launch Screen"
30 | attachment.lifetime = .keepAlways
31 | add(attachment)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Orchard/Views/States/NotRunning.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct NotRunningView: View {
4 | @EnvironmentObject var containerService: ContainerService
5 |
6 | var body: some View {
7 | VStack(spacing: 20) {
8 | PowerButton(
9 | isLoading: containerService.isSystemLoading,
10 | action: {
11 | Task { @MainActor in
12 | await containerService.startSystem()
13 | }
14 | }
15 | )
16 |
17 | Text("Container is not currently runnning")
18 | .font(.title2)
19 | .fontWeight(.medium)
20 | }
21 | .frame(maxWidth: .infinity, maxHeight: .infinity)
22 | .padding()
23 | .task {
24 | await containerService.checkSystemStatus()
25 | await containerService.loadContainers(showLoading: true)
26 | await containerService.loadImages()
27 | await containerService.loadBuilders()
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/Mounts/MountDetailHeader.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Mount Detail Header
4 | struct MountDetailHeader: View {
5 | let mount: ContainerMount
6 |
7 | private var mountName: String {
8 | URL(fileURLWithPath: mount.mount.source).lastPathComponent
9 | }
10 |
11 | var body: some View {
12 | HStack {
13 | VStack(alignment: .leading, spacing: 2) {
14 | Text(mountName)
15 | .font(.title2)
16 | .fontWeight(.semibold)
17 | }
18 | Spacer()
19 |
20 | // Action buttons
21 | HStack(spacing: 12) {
22 | Button(action: {
23 | NSWorkspace.shared.open(URL(fileURLWithPath: mount.mount.source))
24 | }) {
25 | Label("Open in Finder", systemImage: "folder")
26 | }
27 | .buttonStyle(.bordered)
28 | }
29 | }
30 | .padding(.horizontal, 16)
31 | .padding(.top, 20)
32 | .padding(.bottom, 12)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Orchard/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIconFile
10 |
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | APPL
19 | CFBundleShortVersionString
20 | 1.7.1
21 | CFBundleVersion
22 | 1
23 | LSMinimumSystemVersion
24 | 12.0
25 | NSAppleEventsUsageDescription
26 | This app needs permission to control the container service.
27 | NSHumanReadableCopyright
28 |
29 | NSPrincipalClass
30 | NSApplication
31 |
32 |
33 |
--------------------------------------------------------------------------------
/OrchardUITests/OrchardUITests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OrchardUITests.swift
3 | // OrchardUITests
4 | //
5 | // Created by Andrew Waters on 16/06/2025.
6 | //
7 |
8 | import XCTest
9 |
10 | final class OrchardUITests: XCTestCase {
11 |
12 | override func setUpWithError() throws {
13 | // Put setup code here. This method is called before the invocation of each test method in the class.
14 |
15 | // In UI tests it is usually best to stop immediately when a failure occurs.
16 | continueAfterFailure = false
17 |
18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
19 | }
20 |
21 | override func tearDownWithError() throws {
22 | // Put teardown code here. This method is called after the invocation of each test method in the class.
23 | }
24 |
25 | @MainActor
26 | func testExample() throws {
27 | // UI tests must launch the application that they test.
28 | let app = XCUIApplication()
29 | app.launch()
30 |
31 | // Use XCTAssert and related functions to verify your tests produce the correct results.
32 | }
33 |
34 | @MainActor
35 | func testLaunchPerformance() throws {
36 | // This measures how long it takes to launch your application.
37 | measure(metrics: [XCTApplicationLaunchMetric()]) {
38 | XCUIApplication().launch()
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Orchard/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images": [
3 | {
4 | "filename": "icon_16x16.png",
5 | "idiom": "mac",
6 | "scale": "1x",
7 | "size": "16x16"
8 | },
9 | {
10 | "filename": "icon_32x32.png",
11 | "idiom": "mac",
12 | "scale": "2x",
13 | "size": "16x16"
14 | },
15 | {
16 | "filename": "icon_32x32.png",
17 | "idiom": "mac",
18 | "scale": "1x",
19 | "size": "32x32"
20 | },
21 | {
22 | "filename": "icon_64x64.png",
23 | "idiom": "mac",
24 | "scale": "2x",
25 | "size": "32x32"
26 | },
27 | {
28 | "filename": "icon_128x128.png",
29 | "idiom": "mac",
30 | "scale": "1x",
31 | "size": "128x128"
32 | },
33 | {
34 | "filename": "icon_256x256.png",
35 | "idiom": "mac",
36 | "scale": "2x",
37 | "size": "128x128"
38 | },
39 | {
40 | "filename": "icon_256x256.png",
41 | "idiom": "mac",
42 | "scale": "1x",
43 | "size": "256x256"
44 | },
45 | {
46 | "filename": "icon_512x512.png",
47 | "idiom": "mac",
48 | "scale": "2x",
49 | "size": "256x256"
50 | },
51 | {
52 | "filename": "icon_512x512.png",
53 | "idiom": "mac",
54 | "scale": "1x",
55 | "size": "512x512"
56 | },
57 | {
58 | "filename": "icon_1024x1024.png",
59 | "idiom": "mac",
60 | "scale": "2x",
61 | "size": "512x512"
62 | }
63 | ],
64 | "info": {
65 | "author": "xcode",
66 | "version": 1
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/Orchard/Views/Layout/Sidebar/SidebarTabSelection.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum TabSelection: String, CaseIterable {
4 | case containers = "containers"
5 | case images = "images"
6 | case mounts = "mounts"
7 | case dns = "dns"
8 | case networks = "networks"
9 | case registries = "registries"
10 | case systemLogs = "systemLogs"
11 | case stats = "stats"
12 | case configuration = "configuration"
13 |
14 | var icon: String {
15 | switch self {
16 | case .containers:
17 | return "cube"
18 | case .images:
19 | return "cube.transparent"
20 | case .mounts:
21 | return "externaldrive"
22 | case .dns:
23 | return "network"
24 | case .networks:
25 | return "arrow.down.left.arrow.up.right"
26 | case .registries:
27 | return "server.rack"
28 | case .systemLogs:
29 | return "doc.text.below.ecg"
30 | case .stats:
31 | return "water.waves"
32 | case .configuration:
33 | return "gearshape"
34 | }
35 | }
36 |
37 | var title: String {
38 | switch self {
39 | case .containers:
40 | return "Containers"
41 | case .images:
42 | return "Images"
43 | case .mounts:
44 | return "Mounts"
45 | case .dns:
46 | return "DNS"
47 | case .networks:
48 | return "Networks"
49 | case .registries:
50 | return "Registries"
51 | case .systemLogs:
52 | return "System Logs"
53 | case .stats:
54 | return "Stats"
55 | case .configuration:
56 | return "Configuration"
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/DNS/DNSDetailHeader.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - DNS Detail Header
4 | struct DNSDetailHeader: View {
5 | let domain: String
6 | @EnvironmentObject var containerService: ContainerService
7 |
8 | var body: some View {
9 | HStack {
10 | VStack(alignment: .leading, spacing: 2) {
11 | Text(domain)
12 | .font(.title2)
13 | .fontWeight(.semibold)
14 | }
15 | Spacer()
16 |
17 | // Action buttons
18 | HStack(spacing: 12) {
19 | let dnsDomain = containerService.dnsDomains.first(where: { $0.domain == domain })
20 |
21 | Button("Make Default") {
22 | DispatchQueue.main.async {
23 | Task {
24 | await containerService.setDefaultDNSDomain(domain)
25 | }
26 | }
27 | }
28 | .buttonStyle(.bordered)
29 | .disabled(dnsDomain?.isDefault == true)
30 |
31 | Button("Delete", role: .destructive) {
32 | confirmDNSDomainDeletion(domain: domain)
33 | }
34 | .buttonStyle(BorderedProminentButtonStyle())
35 | .tint(.red)
36 | }
37 | }
38 | .padding(.horizontal, 16)
39 | .padding(.top, 20)
40 | .padding(.bottom, 12)
41 | }
42 |
43 | private func confirmDNSDomainDeletion(domain: String) {
44 | let alert = NSAlert()
45 | alert.messageText = "Delete DNS Domain"
46 | alert.informativeText = "Are you sure you want to delete '\(domain)'? This requires administrator privileges."
47 | alert.alertStyle = .warning
48 | alert.addButton(withTitle: "Delete")
49 | alert.addButton(withTitle: "Cancel")
50 |
51 | if alert.runModal() == .alertFirstButtonReturn {
52 | Task { await containerService.deleteDNSDomain(domain) }
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/Networks/NetworkDetailHeader.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Network Detail Header
4 | struct NetworkDetailHeader: View {
5 | let network: ContainerNetwork
6 | @EnvironmentObject var containerService: ContainerService
7 |
8 | private var connectedContainers: [Container] {
9 | containerService.containers.filter { container in
10 | container.networks.contains { containerNetwork in
11 | containerNetwork.network == network.id
12 | }
13 | }
14 | }
15 |
16 | var body: some View {
17 | HStack {
18 | VStack(alignment: .leading, spacing: 2) {
19 | Text(network.id)
20 | .font(.title2)
21 | .fontWeight(.semibold)
22 | }
23 | Spacer()
24 |
25 | // Action buttons
26 | HStack(spacing: 12) {
27 | let isDisabled = network.id == "default" || !connectedContainers.isEmpty
28 |
29 | if isDisabled {
30 | Button("Delete", role: .destructive) {
31 | confirmNetworkDeletion(networkId: network.id)
32 | }
33 | .buttonStyle(BorderedButtonStyle())
34 | .disabled(true)
35 | } else {
36 | Button("Delete", role: .destructive) {
37 | confirmNetworkDeletion(networkId: network.id)
38 | }
39 | .buttonStyle(BorderedProminentButtonStyle())
40 | }
41 | }
42 | }
43 | .padding(.horizontal, 16)
44 | .padding(.top, 20)
45 | .padding(.bottom, 12)
46 | }
47 |
48 | private func confirmNetworkDeletion(networkId: String) {
49 | let alert = NSAlert()
50 | alert.messageText = "Delete Network"
51 | alert.informativeText = "Are you sure you want to delete '\(networkId)'?"
52 | alert.alertStyle = .warning
53 | alert.addButton(withTitle: "Delete")
54 | alert.addButton(withTitle: "Cancel")
55 |
56 | if alert.runModal() == .alertFirstButtonReturn {
57 | Task { await containerService.deleteNetwork(networkId) }
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/DNS/DetailDNS.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DNSDetailView: View {
4 | @EnvironmentObject var containerService: ContainerService
5 | let domain: String
6 | @Binding var selectedTab: TabSelection
7 | @Binding var selectedContainer: String?
8 |
9 | var body: some View {
10 | if let dnsDomain = containerService.dnsDomains.first(where: { $0.domain == domain }) {
11 | VStack(spacing: 0) {
12 | DNSDetailHeader(domain: domain)
13 |
14 | ScrollView {
15 | VStack(alignment: .leading, spacing: 20) {
16 | // Containers using this domain
17 | VStack(alignment: .leading, spacing: 12) {
18 | Text("Containers using this domain")
19 | .font(.headline)
20 |
21 | let containersUsingDomain = containerService.containers.filter { container in
22 | // Check if container's DNS domain matches
23 | if let containerDomain = container.configuration.dns.domain {
24 | return containerDomain == dnsDomain.domain
25 | }
26 | // Also check search domains as fallback
27 | return container.configuration.dns.searchDomains.contains(dnsDomain.domain)
28 | }
29 |
30 | ContainerTable(
31 | containers: containersUsingDomain,
32 | selectedTab: $selectedTab,
33 | selectedContainer: $selectedContainer,
34 | emptyStateMessage: "No containers are using this domain"
35 | )
36 | }
37 |
38 |
39 |
40 | Spacer(minLength: 20)
41 | }
42 | .padding()
43 | }
44 | }
45 | } else {
46 | Text("Domain not found")
47 | .foregroundColor(.secondary)
48 | .frame(maxWidth: .infinity, maxHeight: .infinity)
49 | }
50 | }
51 |
52 |
53 | }
54 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Orchard
2 |
3 | Orchard is a native (swift) application to manage containers on macOS using the new [Containerization framework](https://github.com/apple/containerization).
4 |
5 | It has been based on years of experience with Docker Desktop, but dedicated to the new containerization option.
6 |
7 | The ambition of the project is to allow it easy for developers to switch from Docker Desktop to Containers. Orchard gives you a desktop experience that complements the `container ...` command-line interface.
8 |
9 | 
10 |
11 | ## Highlight of Containerization
12 |
13 | - Made by Apple: Native support, incredible performance and the engineering resources to make it work.
14 | - Sub second startup times
15 | - Kernel isolation by design
16 | - Easier networking - no more port mapping (every container gets its own IP address), networks out of the box
17 |
18 | ## Requirements
19 |
20 | > `container` relies on the new features and enhancements present in macOS 26. Additionally, you need to install a specific version of container - [follow the instructions here](https://github.com/apple/container?tab=readme-ov-file#install-or-upgrade) if you have not already upgraded.
21 |
22 | https://github.com/apple/container?tab=readme-ov-file#requirements
23 |
24 | ## Versioning
25 |
26 | Since the container project is releasing frequently with breaking changes, starting from `v1.6.0` the releases of this project will track the compatibility with container and mirror them with the difference being `v1` - this is because previous releases used that and it will be impossible to automtically update existing instances.
27 |
28 | So, `v1.6.0` supports container `0.6.0` - all changes with that container version will be incremented so the next update will be `v1.6.1`.
29 |
30 | When container `0.7.0` is released, the first support will be added in `v1.7.0` of this app.
31 |
32 | ## Installation
33 |
34 | You can build from source or download a prebuilt package.
35 |
36 | ### Prebuilt
37 |
38 | 1. Download the latest release from [GitHub Releases](https://github.com/andrew-waters/orchard/releases)
39 | 2. Open the `.dmg` file and drag Orchard to your Applications folder
40 | 3. Launch Orchard - you may need to go to **System Settings > Privacy & Security** and click "Open Anyway" to allow the app to run
41 |
42 | ### Build from Source
43 |
44 | ```bash
45 | git clone https://github.com/andrew-waters/orchard.git
46 | cd orchard
47 | open Orchard.xcodeproj
48 | ```
49 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/Registries/DetailRegistries.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct RegistriesDetailView: View {
4 | var body: some View {
5 | VStack(spacing: 0) {
6 | RegistriesDetailHeader()
7 |
8 | ScrollView {
9 | VStack(spacing: 20) {
10 | HStack {
11 | Spacer()
12 | VStack(alignment: .leading, spacing: 16) {
13 | Text("Registries cannot be listed due to limitations with container itself. To add them, you'll need to open a terminal and run the container commands. Copy your registry password to your clipboard and run:")
14 | .foregroundColor(.secondary)
15 | .multilineTextAlignment(.leading)
16 |
17 | VStack(alignment: .leading, spacing: 8) {
18 | HStack {
19 | Text("pbpaste | container registry login REGISTRY_URL --username YOUR_USERNAME --password-stdin")
20 | .foregroundColor(.secondary)
21 | .multilineTextAlignment(.leading)
22 | .font(.system(.body, design: .monospaced))
23 | .textSelection(.enabled)
24 |
25 | Spacer()
26 |
27 | Button(action: {
28 | let pasteboard = NSPasteboard.general
29 | pasteboard.clearContents()
30 | pasteboard.setString("pbpaste | container registry login REGISTRY_URL --username YOUR_USERNAME --password-stdin", forType: .string)
31 | }) {
32 | SwiftUI.Image(systemName: "doc.on.clipboard")
33 | .font(.caption)
34 | }
35 | .buttonStyle(.borderless)
36 | .help("Copy command to clipboard")
37 | }
38 | }
39 | }
40 | Spacer()
41 | }
42 | .frame(minHeight: 200)
43 | .padding()
44 |
45 | Spacer(minLength: 20)
46 | }
47 | .padding()
48 | }
49 | }
50 | .frame(maxWidth: .infinity, maxHeight: .infinity)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Orchard/Views/Components/DetailViewButton.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Reusable Detail View Button Component
4 |
5 | struct DetailViewButton: View {
6 | let icon: String
7 | let accessibilityText: String
8 | let action: () -> Void
9 | let isDisabled: Bool
10 | let isLoading: Bool
11 |
12 | init(
13 | icon: String,
14 | accessibilityText: String,
15 | action: @escaping () -> Void,
16 | isDisabled: Bool = false,
17 | isLoading: Bool = false
18 | ) {
19 | self.icon = icon
20 | self.accessibilityText = accessibilityText
21 | self.action = action
22 | self.isDisabled = isDisabled
23 | self.isLoading = isLoading
24 | }
25 |
26 | @State private var glowIntensity: Double = 0.0
27 |
28 | var body: some View {
29 | Button(action: isLoading ? {} : action) {
30 | HStack(spacing: 6) {
31 | SwiftUI.Image(systemName: icon)
32 | .font(.system(size: 16, weight: .regular))
33 | .foregroundColor(buttonColor)
34 | .shadow(
35 | color: isLoading ? .blue.opacity(glowIntensity) : .clear,
36 | radius: isLoading ? 8 : 0
37 | )
38 | .scaleEffect(isLoading ? 1.1 : 1.0)
39 | }
40 | .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 6))
41 | }
42 | .buttonStyle(.plain)
43 | .help(accessibilityText)
44 | .disabled(isDisabled || isLoading)
45 | .onHover { hovering in
46 | if hovering {
47 | let cursor: NSCursor = (isDisabled || isLoading) ? .arrow : .pointingHand
48 | cursor.push()
49 | } else {
50 | NSCursor.pop()
51 | }
52 | }
53 | .onAppear {
54 | if isLoading {
55 | startGlowAnimation()
56 | }
57 | }
58 | .onChange(of: isLoading) { _, newValue in
59 | if newValue {
60 | startGlowAnimation()
61 | } else {
62 | stopGlowAnimation()
63 | }
64 | }
65 | }
66 |
67 | private var buttonColor: Color {
68 | if isLoading {
69 | return .blue
70 | } else if isDisabled {
71 | return .primary.opacity(0.5)
72 | } else {
73 | return .primary
74 | }
75 | }
76 |
77 | private func startGlowAnimation() {
78 | withAnimation(
79 | .easeInOut(duration: 0.8)
80 | .repeatForever(autoreverses: true)
81 | ) {
82 | glowIntensity = 0.8
83 | }
84 | }
85 |
86 | private func stopGlowAnimation() {
87 | withAnimation(.easeOut(duration: 0.3)) {
88 | glowIntensity = 0.0
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/Orchard/Views/States/NewerVersion.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct NewerVersionView: View {
4 | @EnvironmentObject var containerService: ContainerService
5 |
6 | var body: some View {
7 | VStack(spacing: 30) {
8 | SwiftUI.Image(systemName: "exclamationmark.triangle")
9 | .font(.system(size: 60))
10 | .foregroundColor(.orange)
11 |
12 | VStack(spacing: 16) {
13 | Text("Container's Version is not yet supported")
14 | .font(.title)
15 | .fontWeight(.semibold)
16 |
17 | if let installedVersion = containerService.parsedContainerVersion {
18 | Text("We require Apple Container version \(containerService.supportedContainerVersion), but you are running version \(installedVersion)")
19 | .padding(.horizontal)
20 | .multilineTextAlignment(.center)
21 | } else if let rawVersion = containerService.containerVersion {
22 | Text("Detected version: \(rawVersion)")
23 | .font(.subheadline)
24 | .foregroundColor(.secondary)
25 | .padding(.horizontal)
26 | .multilineTextAlignment(.center)
27 |
28 | Text("We require Apple Container version \(containerService.supportedContainerVersion)")
29 | .font(.subheadline)
30 | .foregroundColor(.secondary)
31 | } else {
32 | Text("We require Apple Container version \(containerService.supportedContainerVersion)")
33 | .font(.subheadline)
34 | .foregroundColor(.secondary)
35 | }
36 |
37 | Text("Please check whether an Orchard update is available.")
38 | .font(.body)
39 | .foregroundColor(.primary)
40 | .multilineTextAlignment(.center)
41 | .padding(.horizontal, 40)
42 | }
43 |
44 | HStack(spacing: 16) {
45 | Button("Check latest Orchard releases") {
46 | if let url = URL(string: "https://github.com/andrew-waters/orchard/releases") {
47 | NSWorkspace.shared.open(url)
48 | }
49 | }
50 | .buttonStyle(.borderedProminent)
51 |
52 | Button("Proceed Anyway") {
53 | Task { @MainActor in
54 | await containerService.checkSystemStatusIgnoreVersion()
55 | }
56 | }
57 | .buttonStyle(.bordered)
58 | }
59 | }
60 | .frame(maxWidth: .infinity, maxHeight: .infinity)
61 | .padding()
62 | .task {
63 | await containerService.checkSystemStatus()
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/Orchard/Views/States/VersionIncompatibility.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct VersionIncompatibilityView: View {
4 | @EnvironmentObject var containerService: ContainerService
5 |
6 | var body: some View {
7 | VStack(spacing: 30) {
8 | SwiftUI.Image(systemName: "exclamationmark.triangle")
9 | .font(.system(size: 60))
10 | .foregroundColor(.orange)
11 |
12 | VStack(spacing: 16) {
13 | Text("Unsupported Container Version")
14 | .font(.title)
15 | .fontWeight(.semibold)
16 |
17 | if let installedVersion = containerService.parsedContainerVersion {
18 | Text("We require Apple Container version \(containerService.supportedContainerVersion), but you are running version \(installedVersion)")
19 | .padding(.horizontal)
20 | .multilineTextAlignment(.center)
21 | } else if let rawVersion = containerService.containerVersion {
22 | Text("Detected version: \(rawVersion)")
23 | .font(.subheadline)
24 | .foregroundColor(.secondary)
25 | .padding(.horizontal)
26 | .multilineTextAlignment(.center)
27 |
28 | Text("We require Apple Container version \(containerService.supportedContainerVersion)")
29 | .font(.subheadline)
30 | .foregroundColor(.secondary)
31 | } else {
32 | Text("We require Apple Container version \(containerService.supportedContainerVersion)")
33 | .font(.subheadline)
34 | .foregroundColor(.secondary)
35 | }
36 |
37 | Text("Please update your Container installation to continue using this application.")
38 | .font(.body)
39 | .foregroundColor(.primary)
40 | .multilineTextAlignment(.center)
41 | .padding(.horizontal, 40)
42 | }
43 |
44 | HStack(spacing: 16) {
45 | Button("View upgrade instructions") {
46 | if let url = URL(string: "https://github.com/apple/container?tab=readme-ov-file#install-or-upgrade") {
47 | NSWorkspace.shared.open(url)
48 | }
49 | }
50 | .buttonStyle(.borderedProminent)
51 |
52 | Button("Check Again") {
53 | Task { @MainActor in
54 | await containerService.checkSystemStatus()
55 | }
56 | }
57 | .buttonStyle(.bordered)
58 | }
59 | }
60 | .frame(maxWidth: .infinity, maxHeight: .infinity)
61 | .padding()
62 | .task {
63 | await containerService.checkSystemStatus()
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/Images/ImageDetailHeader.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Image Detail Header
4 | struct ImageDetailHeader: View {
5 | let image: ContainerImage
6 | @EnvironmentObject var containerService: ContainerService
7 | @State private var showRunContainer = false
8 | @State private var showDeleteConfirmation = false
9 | @State private var isDeleting = false
10 | @State private var isRunning = false
11 |
12 | private var imageName: String {
13 | let components = image.reference.split(separator: "/")
14 | if let lastComponent = components.last {
15 | return String(lastComponent.split(separator: ":").first ?? lastComponent)
16 | }
17 | return image.reference
18 | }
19 |
20 | private var containersUsingImage: [Container] {
21 | containerService.containers.filter { container in
22 | container.configuration.image.reference == image.reference
23 | }
24 | }
25 |
26 | private func deleteImage() {
27 | isDeleting = true
28 | Task {
29 | await containerService.deleteImage(image.reference)
30 | await MainActor.run {
31 | isDeleting = false
32 | }
33 | }
34 | }
35 |
36 | var body: some View {
37 | HStack {
38 | VStack(alignment: .leading, spacing: 2) {
39 | Text(imageName)
40 | .font(.title2)
41 | .fontWeight(.semibold)
42 | }
43 | Spacer()
44 |
45 | // Action buttons
46 | HStack(spacing: 12) {
47 | // Run Container button
48 | Button("Launch image") {
49 | showRunContainer = true
50 | }
51 | .buttonStyle(BorderedButtonStyle())
52 |
53 | // Delete Image button - only show if no containers are using it
54 | if containersUsingImage.isEmpty {
55 | Button("Delete", role: .destructive) {
56 | showDeleteConfirmation = true
57 | }
58 | .buttonStyle(BorderedProminentButtonStyle())
59 | .tint(.red)
60 | } else {
61 | Button("Delete", role: .destructive) {
62 | showDeleteConfirmation = true
63 | }
64 | .buttonStyle(BorderedButtonStyle())
65 | .disabled(true)
66 | }
67 | }
68 | }
69 | .padding(.horizontal, 16)
70 | .padding(.top, 20)
71 | .padding(.bottom, 12)
72 | .sheet(isPresented: $showRunContainer) {
73 | RunContainerView(imageName: image.reference)
74 | .environmentObject(containerService)
75 | }
76 | .alert("Delete Image?", isPresented: $showDeleteConfirmation) {
77 | Button("Cancel", role: .cancel) { }
78 | Button("Delete", role: .destructive) {
79 | deleteImage()
80 | }
81 | } message: {
82 | Text("Are you sure you want to delete '\(imageName)'? This action cannot be undone.")
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/Mounts/ListMounts.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct MountsListView: View {
4 | @EnvironmentObject var containerService: ContainerService
5 | @Binding var selectedMount: String?
6 | @Binding var lastSelectedMount: String?
7 | @Binding var searchText: String
8 | @Binding var showOnlyMountsInUse: Bool
9 | @FocusState var listFocusedTab: TabSelection?
10 |
11 | var body: some View {
12 | VStack(spacing: 0) {
13 | // Mounts list
14 | List(selection: $selectedMount) {
15 | ForEach(filteredMounts, id: \.id) { mount in
16 | ListItemRow(
17 | icon: "externaldrive",
18 | iconColor: isMountUsedByRunningContainer(mount) ? .green : .secondary,
19 | primaryText: mount.mount.destination,
20 | secondaryLeftText: mount.mount.source,
21 | isSelected: selectedMount == mount.id
22 | )
23 | .contextMenu {
24 | Button("Copy Source Path") {
25 | NSPasteboard.general.clearContents()
26 | NSPasteboard.general.setString(mount.mount.source, forType: .string)
27 | }
28 |
29 | Button("Copy Destination Path") {
30 | NSPasteboard.general.clearContents()
31 | NSPasteboard.general.setString(mount.mount.destination, forType: .string)
32 | }
33 | }
34 | .tag(mount.id)
35 | }
36 | }
37 | .listStyle(PlainListStyle())
38 | .animation(.easeInOut(duration: 0.3), value: containerService.allMounts)
39 | .focused($listFocusedTab, equals: .mounts)
40 | .onChange(of: selectedMount) { _, newValue in
41 | lastSelectedMount = newValue
42 | }
43 |
44 |
45 | }
46 | }
47 |
48 | private var filteredMounts: [ContainerMount] {
49 | var filtered = containerService.allMounts
50 |
51 | // Apply "in use" filter
52 | if showOnlyMountsInUse {
53 | filtered = filtered.filter { mount in
54 | // Only show mounts used by running containers
55 | mount.containerIds.contains { containerID in
56 | containerService.containers.first { $0.configuration.id == containerID }?.status.lowercased() == "running"
57 | }
58 | }
59 | }
60 |
61 | // Apply search filter
62 | if !searchText.isEmpty {
63 | filtered = filtered.filter { mount in
64 | mount.mount.source.localizedCaseInsensitiveContains(searchText)
65 | || mount.mount.destination.localizedCaseInsensitiveContains(searchText)
66 | || mount.mountType.localizedCaseInsensitiveContains(searchText)
67 | }
68 | }
69 |
70 | return filtered
71 | }
72 |
73 | private func isMountUsedByRunningContainer(_ mount: ContainerMount) -> Bool {
74 | return mount.containerIds.contains { containerID in
75 | containerService.containers.first { $0.configuration.id == containerID }?.status.lowercased() == "running"
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/Orchard/Views/Components/ListItemRow.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ListItemRow: View {
4 | let icon: String
5 | let iconColor: Color
6 | let primaryText: String
7 | let secondaryLeftText: String?
8 | let secondaryRightText: String?
9 | let isSelected: Bool
10 |
11 | init(
12 | icon: String,
13 | iconColor: Color,
14 | primaryText: String,
15 | secondaryLeftText: String? = nil,
16 | secondaryRightText: String? = nil,
17 | isSelected: Bool = false
18 | ) {
19 | self.icon = icon
20 | self.iconColor = iconColor
21 | self.primaryText = primaryText
22 | self.secondaryLeftText = secondaryLeftText
23 | self.secondaryRightText = secondaryRightText
24 | self.isSelected = isSelected
25 | }
26 |
27 | var body: some View {
28 | HStack(spacing: 12) {
29 | // Icon
30 | SwiftUI.Image(systemName: icon)
31 | .font(.system(size: 16, weight: .regular))
32 | .foregroundColor(iconColor)
33 | .frame(width: 20, height: 20)
34 | .opacity(isSelected ? 1.0 : 0.7)
35 |
36 | // Text content
37 | VStack(alignment: .leading, spacing: 6) {
38 | // Primary text
39 | Text(primaryText)
40 | .font(.system(size: 14, weight: .medium))
41 | .foregroundColor(.primary)
42 | .lineLimit(1)
43 |
44 | // Secondary text row
45 | if secondaryLeftText != nil || secondaryRightText != nil {
46 | HStack {
47 | if let secondaryLeft = secondaryLeftText {
48 | Text(secondaryLeft)
49 | .font(.system(size: 10, weight: .regular))
50 | .fontDesign(.monospaced)
51 | .foregroundColor(isSelected ? .primary : .secondary)
52 | .lineLimit(1)
53 | }
54 |
55 | Spacer()
56 |
57 | if let secondaryRight = secondaryRightText {
58 | Text(secondaryRight)
59 | .font(.system(size: 10, weight: .regular))
60 | .fontDesign(.monospaced)
61 | .foregroundColor(isSelected ? .primary : .secondary)
62 | .lineLimit(1)
63 | }
64 | }
65 | }
66 | }
67 |
68 | Spacer()
69 | }
70 | .padding(.horizontal, 6)
71 | .padding(.vertical, 6)
72 | .contentShape(Rectangle())
73 | }
74 | }
75 |
76 | #Preview {
77 | VStack(spacing: 4) {
78 | ListItemRow(
79 | icon: "cube",
80 | iconColor: .blue,
81 | primaryText: "buildkit",
82 | secondaryLeftText: "192.168.64.23",
83 | secondaryRightText: "Running",
84 | isSelected: true
85 | )
86 |
87 | ListItemRow(
88 | icon: "cube",
89 | iconColor: .gray,
90 | primaryText: "kafka",
91 | secondaryLeftText: "Not running"
92 | )
93 |
94 | ListItemRow(
95 | icon: "cube.transparent",
96 | iconColor: .purple,
97 | primaryText: "redis",
98 | secondaryLeftText: "latest",
99 | secondaryRightText: "529 bytes"
100 | )
101 |
102 | ListItemRow(
103 | icon: "externaldrive",
104 | iconColor: .orange,
105 | primaryText: "/ → run",
106 | secondaryLeftText: "provisioning → provisioning"
107 | )
108 | }
109 | .padding()
110 | }
111 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/Containers/ListContainers.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ContainersListView: View {
4 | @EnvironmentObject var containerService: ContainerService
5 | @Binding var selectedContainer: String?
6 | @Binding var lastSelectedContainer: String?
7 | @Binding var searchText: String
8 | @Binding var showOnlyRunning: Bool
9 | @FocusState var listFocusedTab: TabSelection?
10 |
11 | var body: some View {
12 | VStack(spacing: 0) {
13 | // Container list
14 | List(selection: $selectedContainer) {
15 | ForEach(filteredContainers, id: \.configuration.id) { container in
16 | ListItemRow(
17 | icon: "cube",
18 | iconColor: container.status.lowercased() == "running" ? .green : .secondary,
19 | primaryText: container.configuration.id,
20 | secondaryLeftText: networkAddress(for: container) ?? "-",
21 | secondaryRightText: hostname(for: container),
22 | isSelected: selectedContainer == container.configuration.id
23 | )
24 | .contextMenu {
25 | if container.status.lowercased() == "running" {
26 | Button("Stop Container") {
27 | Task {
28 | await containerService.stopContainer(container.configuration.id)
29 | }
30 | }
31 | } else {
32 | Button("Start Container") {
33 | Task {
34 | await containerService.startContainer(container.configuration.id)
35 | }
36 | }
37 | }
38 |
39 | Divider()
40 |
41 | Button("Remove Container", role: .destructive) {
42 | Task {
43 | await containerService.removeContainer(container.configuration.id)
44 | }
45 | }
46 | }
47 | .tag(container.configuration.id)
48 | }
49 | }
50 | .listStyle(PlainListStyle())
51 | .animation(.easeInOut(duration: 0.3), value: containerService.containers)
52 | .focused($listFocusedTab, equals: .containers)
53 | .onChange(of: selectedContainer) { _, newValue in
54 | lastSelectedContainer = newValue
55 | }
56 | }
57 | }
58 |
59 | private func networkAddress(for container: Container) -> String? {
60 | if let firstNetwork = container.networks.first {
61 | return firstNetwork.address
62 | }
63 | return nil
64 | }
65 |
66 | private func hostname(for container: Container) -> String? {
67 | guard !container.networks.isEmpty else { return nil }
68 | let hostname = container.networks.first?.hostname ?? ""
69 | return hostname.hasSuffix(".") ? String(hostname.dropLast()) : hostname
70 | }
71 |
72 | private var filteredContainers: [Container] {
73 | var filtered = containerService.containers
74 |
75 | // Apply running filter
76 | if showOnlyRunning {
77 | filtered = filtered.filter { $0.status.lowercased() == "running" }
78 | }
79 |
80 | // Apply search filter
81 | if !searchText.isEmpty {
82 | filtered = filtered.filter { container in
83 | container.configuration.id.localizedCaseInsensitiveContains(searchText)
84 | || container.status.localizedCaseInsensitiveContains(searchText)
85 | || (hostname(for: container)?.localizedCaseInsensitiveContains(searchText) ?? false)
86 | }
87 | }
88 |
89 | return filtered
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/DNS/AddDNS.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct AddDomainView: View {
4 | @EnvironmentObject var containerService: ContainerService
5 | @Environment(\.dismiss) private var dismiss
6 | @State private var domainName: String = ""
7 | @State private var isCreating: Bool = false
8 |
9 | var body: some View {
10 | VStack(spacing: 0) {
11 | // Header
12 | HStack {
13 | Text("Add DNS Domain")
14 | .font(.title2)
15 | .fontWeight(.semibold)
16 |
17 | Spacer()
18 |
19 | Button("Cancel") {
20 | dismiss()
21 | }
22 | .keyboardShortcut(.cancelAction)
23 | }
24 | .padding()
25 | .background(Color(NSColor.controlBackgroundColor))
26 | .overlay(
27 | Rectangle()
28 | .frame(height: 1)
29 | .foregroundColor(Color(NSColor.separatorColor)),
30 | alignment: .bottom
31 | )
32 |
33 | // Content
34 | VStack(spacing: 20) {
35 | VStack(alignment: .leading, spacing: 8) {
36 | Text("Domain Name")
37 | .font(.headline)
38 |
39 | TextField("e.g., local.dev, myapp.local", text: $domainName)
40 | .textFieldStyle(.roundedBorder)
41 | .frame(height: 32)
42 |
43 | Text("Enter a domain name for local container networking. This requires administrator privileges.")
44 | .font(.caption)
45 | .foregroundColor(.secondary)
46 | }
47 |
48 | Spacer()
49 | }
50 | .padding()
51 |
52 | // Footer
53 | HStack {
54 | Spacer()
55 |
56 | Button("Cancel") {
57 | dismiss()
58 | }
59 | .keyboardShortcut(.cancelAction)
60 |
61 | Button("Add Domain") {
62 | createDomain()
63 | }
64 | .buttonStyle(.borderedProminent)
65 | .disabled(domainName.trimmingCharacters(in: .whitespaces).isEmpty || isCreating)
66 | .keyboardShortcut(.defaultAction)
67 | }
68 | .padding()
69 | .background(Color(NSColor.controlBackgroundColor))
70 | .overlay(
71 | Rectangle()
72 | .frame(height: 1)
73 | .foregroundColor(Color(NSColor.separatorColor)),
74 | alignment: .top
75 | )
76 | }
77 | .frame(width: 500, height: 300)
78 | .background(Color(NSColor.windowBackgroundColor))
79 | }
80 |
81 | private func createDomain() {
82 | let trimmedDomain = domainName.trimmingCharacters(in: .whitespaces)
83 |
84 | guard !trimmedDomain.isEmpty else { return }
85 | guard isValidDomainName(trimmedDomain) else {
86 | containerService.errorMessage = "Invalid domain name format."
87 | return
88 | }
89 |
90 | isCreating = true
91 |
92 | Task {
93 | await containerService.createDNSDomain(trimmedDomain)
94 |
95 | await MainActor.run {
96 | isCreating = false
97 | if containerService.errorMessage == nil {
98 | dismiss()
99 | }
100 | }
101 | }
102 | }
103 |
104 | private func isValidDomainName(_ domain: String) -> Bool {
105 | let domainRegex = "^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
106 | let predicate = NSPredicate(format: "SELF MATCHES %@", domainRegex)
107 | return predicate.evaluate(with: domain)
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/Images/ListImages.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ImagesListView: View {
4 | @EnvironmentObject var containerService: ContainerService
5 | @Binding var selectedImage: String?
6 | @Binding var lastSelectedImage: String?
7 | @Binding var searchText: String
8 | @Binding var showOnlyImagesInUse: Bool
9 | @Binding var showImageSearch: Bool
10 | @FocusState var listFocusedTab: TabSelection?
11 |
12 | var body: some View {
13 | VStack(spacing: 0) {
14 | imagesList
15 | }
16 | .sheet(isPresented: $showImageSearch) {
17 | ImageSearchView()
18 | .environmentObject(containerService)
19 | }
20 | }
21 |
22 | private var imagesList: some View {
23 | List(selection: $selectedImage) {
24 | ForEach(Array(filteredImages), id: \.reference) { image in
25 | imageRowView(for: image)
26 | }
27 | }
28 | .listStyle(PlainListStyle())
29 | .animation(.easeInOut(duration: 0.3), value: containerService.images)
30 | .focused($listFocusedTab, equals: .images)
31 | .onChange(of: selectedImage) { _, newValue in
32 | lastSelectedImage = newValue
33 | }
34 | }
35 |
36 | private func imageRowView(for image: ContainerImage) -> some View {
37 | let imageName = imageName(from: image.reference)
38 | let imageTag = imageTag(from: image.reference)
39 | let sizeText = ByteCountFormatter().string(fromByteCount: Int64(image.descriptor.size))
40 |
41 | return ListItemRow(
42 | icon: "cube.transparent",
43 | iconColor: isImageInUseByRunningContainer(image) ? .green : .secondary,
44 | primaryText: imageName,
45 | secondaryLeftText: imageTag,
46 | secondaryRightText: sizeText,
47 | isSelected: selectedImage == image.reference
48 | )
49 | .contextMenu {
50 | Button("Copy Image Reference") {
51 | NSPasteboard.general.clearContents()
52 | NSPasteboard.general.setString(image.reference, forType: .string)
53 | }
54 |
55 | Divider()
56 |
57 | Button("Remove Image", role: .destructive) {
58 | Task {
59 | await containerService.deleteImage(image.reference)
60 | }
61 | }
62 | }
63 | .tag(image.reference)
64 | }
65 |
66 |
67 |
68 | private func imageName(from reference: String) -> String {
69 | let components = reference.split(separator: "/")
70 | if let lastComponent = components.last {
71 | return String(lastComponent.split(separator: ":").first ?? lastComponent)
72 | }
73 | return reference
74 | }
75 |
76 | private func imageTag(from reference: String) -> String {
77 | if let tagComponent = reference.split(separator: ":").last,
78 | tagComponent != reference.split(separator: "/").last {
79 | return String(tagComponent)
80 | }
81 | return "latest"
82 | }
83 |
84 | private var filteredImages: [ContainerImage] {
85 | var filtered = containerService.images
86 |
87 | // Apply "in use" filter
88 | if showOnlyImagesInUse {
89 | filtered = filtered.filter { image in
90 | containerService.containers.contains { container in
91 | container.configuration.image.reference == image.reference
92 | }
93 | }
94 | }
95 |
96 | // Apply search filter
97 | if !searchText.isEmpty {
98 | filtered = filtered.filter { image in
99 | image.reference.localizedCaseInsensitiveContains(searchText)
100 | }
101 | }
102 |
103 | return filtered
104 | }
105 |
106 | private func isImageInUseByRunningContainer(_ image: ContainerImage) -> Bool {
107 | return containerService.containers.contains { container in
108 | container.configuration.image.reference == image.reference &&
109 | container.status.lowercased() == "running"
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/Orchard/Views/Features/Networks/README.md:
--------------------------------------------------------------------------------
1 | # Networks Feature
2 |
3 | This directory contains the SwiftUI views and components for managing container networks in the Orchard app.
4 |
5 | ## Overview
6 |
7 | The Networks feature allows users to:
8 | - View all available container networks
9 | - Create new networks with different modes (NAT, Bridge, Host)
10 | - Delete networks (except the default network)
11 | - View network details including connected containers
12 | - Navigate between networks and containers
13 |
14 | ## Files
15 |
16 | ### `ListNetworks.swift`
17 | - Main list view for displaying networks in the sidebar
18 | - Shows network status, mode, and IP address ranges
19 | - Provides context menu for network deletion
20 | - Includes "Add Network" button
21 |
22 | ### `AddNetwork.swift`
23 | - Modal sheet for creating new networks
24 | - Network name validation
25 | - Subnet specification (optional)
26 | - Label management (key-value pairs)
27 | - Form validation and error handling
28 |
29 | ### `DetailNetwork.swift`
30 | - Detailed view showing network information
31 | - Lists all containers connected to the network
32 | - Shows network configuration (ID, state, mode, address range, gateway)
33 | - Provides actions for network management
34 |
35 | ## Network Models
36 |
37 | The feature uses these models defined in `Models.swift`:
38 |
39 | - `ContainerNetwork`: Main network object with ID, state, config, and status
40 | - `NetworkConfig`: Network configuration including labels (no mode field)
41 | - `NetworkStatus`: Network status with gateway and address information
42 |
43 | ## Integration
44 |
45 | The Networks feature is integrated into the main app through:
46 |
47 | 1. **TabSelection**: Added `.networks` case with "wifi" icon
48 | 2. **ContainerService**: Added network management methods:
49 | - `loadNetworks()`: Fetch networks from container CLI
50 | - `createNetwork()`: Create new network
51 | - `deleteNetwork()`: Remove network
52 | - `parseNetworksFromJSON()`: Parse CLI output
53 |
54 | 3. **Main Interface**: Added network bindings and state management
55 | 4. **Sidebar**: Integrated network list view
56 | 5. **Detail View**: Added network detail view routing
57 |
58 | ## CLI Integration
59 |
60 | The feature integrates with the container CLI using these commands:
61 |
62 | - `container network ls --format=json`: List all networks
63 | - `container network create [--label