├── 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 | ![container overview screen](assets/overview.png) 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