From a04a941d1bafca5e5e4971aa8a716e83b376ec6b Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 01:28:12 +0100 Subject: [PATCH 01/53] Start package-first modularization --- AGENTS.md | 9 +- .../contents.xcworkspacedata | 13 + Package.swift | 4 + Packages/ContainedDesignSystem/Package.swift | 13 + Packages/ContainedDesignSystem/README.md | 153 ++++++++ .../ActivityStatusView.swift | 29 +- .../ContainedDesignSystem/Clipboard.swift | 9 + .../CommandPreviewBar.swift | 10 +- .../ContainedDesignSystem.md | 147 ++++++++ .../ContainedDesignSystem}/ErrorToast.swift | 10 +- .../ExteriorShadow.swift | 19 +- .../ContainedDesignSystem}/GlassButton.swift | 80 +++-- .../GlassOptionTile.swift | 40 ++- .../ContainedDesignSystem}/GlassRowMenu.swift | 18 +- .../ContainedDesignSystem/GlassSurface.swift | 176 +++++++++ .../GradientAngleControl.swift | 32 +- .../ContainedDesignSystem}/InfoButton.swift | 18 +- .../JSONInspectorSheet.swift | 25 +- .../LiveSparkline.swift | 134 ++++++- .../ContainedDesignSystem}/MetricTile.swift | 26 +- .../MicroPrimitives.swift | 192 ++++++++++ .../ContainedDesignSystem/PageScaffold.swift | 70 ++++ .../ContainedDesignSystem}/PanelHeader.swift | 28 +- .../ContainedDesignSystem}/PanelSection.swift | 106 ++++-- .../ResourceCardChrome.swift | 333 ++++++++++++++++++ .../ResourceGlassCard.swift | 32 +- .../ContainedDesignSystem}/SheetHeader.swift | 30 +- .../StreamConsole.swift | 17 +- .../ContainedDesignSystem}/Theme.swift | 193 ++-------- .../ContainedDesignSystem}/TintSelector.swift | 8 +- .../ContainedDesignSystem/Tokens.swift | 200 +++++++++++ .../ToolbarControls.swift | 35 +- .../VisualEffectBackground.swift | 26 +- Packages/ContainedNavigation/Package.swift | 21 ++ Packages/ContainedNavigation/README.md | 137 +++++++ .../AppSafeAreaManager.swift | 79 +++++ .../ContainedNavigation.md | 90 +++++ .../MorphPanelScaffold.swift | 31 +- .../MorphingExpander.swift | 124 ++++--- README.md | 10 +- Sources/Contained/ContainedApp.swift | 1 + .../DesignSystem/AppSafeAreaManager.swift | 67 ---- .../Contained/DesignSystem/GlassSurface.swift | 90 ----- .../Contained/DesignSystem/PageScaffold.swift | 50 --- .../DesignSystem/ResourceCardChrome.swift | 231 ------------ .../Features/Containers/ContainerCard.swift | 9 +- .../Containers/ContainerConfigureView.swift | 2 + .../Containers/ContainerCustomizeButton.swift | 1 + .../Containers/ContainerEditSheet.swift | 1 + .../Containers/ContainerOverviewTab.swift | 1 + .../Containers/ContainerTabScaffold.swift | 1 + .../Containers/ContainersGridView.swift | 42 ++- .../Containers/Creation/CreationFlow.swift | 2 + .../Containers/Creation/CreationPages.swift | 5 +- .../Features/Containers/CustomizeSheet.swift | 1 + .../Containers/CustomizeWidgetsPanel.swift | 1 + .../Features/Containers/FilesTab.swift | 3 +- .../Features/Containers/LogsTab.swift | 7 +- .../Features/Containers/RunSpecForm.swift | 4 +- .../Features/Containers/StatsTab.swift | 55 +-- .../Features/Containers/TerminalTab.swift | 16 +- .../Features/Images/BuildWorkspaceView.swift | 1 + .../Features/Images/ImageStyleButton.swift | 1 + .../Features/Images/RegistryImageSearch.swift | 3 +- .../Features/MenuBar/MenuBarContent.swift | 21 +- .../Features/Onboarding/BootstrapView.swift | 1 + .../Features/Palette/CommandPalette.swift | 1 + .../Registries/RegistryLoginSheet.swift | 1 + .../Features/Settings/ConfigTransfer.swift | 3 +- .../Features/Settings/ReleaseNotesView.swift | 3 +- .../Features/Settings/SettingsView.swift | 2 + .../Features/Settings/Tabs/AboutTab.swift | 6 +- .../Settings/Tabs/AppearanceTab.swift | 11 +- .../Settings/Tabs/ExperimentalTab.swift | 1 + .../Features/Settings/Tabs/GeneralTab.swift | 10 +- .../Settings/Tabs/RegistriesTab.swift | 4 +- .../Features/Settings/Tabs/RuntimeTab.swift | 1 + .../Features/Settings/Tabs/UpdatesTab.swift | 1 + .../System/Components/SystemLogsSheet.swift | 1 + .../Features/System/SystemView.swift | 52 +-- Sources/Contained/History/ActivityView.swift | 2 + .../History/ContainerHistoryTab.swift | 13 +- .../Migration/DowngradeDecisionView.swift | 3 +- Sources/Contained/Navigation/AppToolbar.swift | 37 +- .../Contained/Navigation/ClassicShell.swift | 4 +- Sources/Contained/Navigation/RootView.swift | 16 +- .../ToolbarPanels/PaletteResultCard.swift | 69 ++-- .../ToolbarPanels/ToolbarCommandPalette.swift | 27 +- .../ToolbarPanels/ToolbarImageGroupCard.swift | 16 +- .../ToolbarPanels/ToolbarResourcePanels.swift | 10 +- .../ToolbarPanels/ToolbarSearchSource.swift | 3 +- .../ToolbarPanels/ToolbarUpdatesPanel.swift | 22 +- .../Navigation/ToolbarViewOptions.swift | 3 +- .../Stores/AppModel+ImageUpdates.swift | 9 + Sources/Contained/Stores/AppModel.swift | 55 ++- .../Contained/Stores/ContainersStore.swift | 95 ++++- .../Contained/Stores/RefreshCoordinator.swift | 5 +- Sources/Contained/Stores/SettingsBackup.swift | 1 + Sources/Contained/Stores/SettingsStore.swift | 1 + .../Contained/Support/Personalization.swift | 1 + Sources/Contained/Support/Platform.swift | 9 +- .../Support/WidgetConfiguration.swift | 81 +---- .../Services/ContainerClient.swift | 3 + .../Services/ContainerRuntime.swift | 147 ++++++++ .../ContainersStoreRefreshTests.swift | 131 +++++++ Tests/ContainedCoreTests/CommandTests.swift | 28 ++ .../20260701-design-system-package.md | 1 + .../unreleased/20260701-navigation-package.md | 1 + .../20260701-runtime-abstraction.md | 1 + .../unreleased/20260701-xcode-workspace.md | 3 + .../20260702-refresh-stats-performance.md | 3 + docs/wiki/Architecture.md | 24 +- docs/wiki/Contributing.md | 12 +- docs/wiki/Design-System.md | 39 +- docs/wiki/Installation.md | 6 +- docs/wiki/Troubleshooting.md | 2 +- 116 files changed, 3148 insertions(+), 1176 deletions(-) create mode 100644 Contained.xcworkspace/contents.xcworkspacedata create mode 100644 Packages/ContainedDesignSystem/Package.swift create mode 100644 Packages/ContainedDesignSystem/README.md rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/ActivityStatusView.swift (76%) create mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Clipboard.swift rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/CommandPreviewBar.swift (88%) create mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/ErrorToast.swift (79%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/ExteriorShadow.swift (50%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/GlassButton.swift (73%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/GlassOptionTile.swift (71%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/GlassRowMenu.swift (58%) create mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassSurface.swift rename {Sources/Contained/Features/Containers => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/GradientAngleControl.swift (55%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/InfoButton.swift (81%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/JSONInspectorSheet.swift (78%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/LiveSparkline.swift (64%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/MetricTile.swift (63%) create mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MicroPrimitives.swift create mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PageScaffold.swift rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/PanelHeader.swift (63%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/PanelSection.swift (72%) create mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/ResourceGlassCard.swift (94%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/SheetHeader.swift (62%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/StreamConsole.swift (89%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/Theme.swift (57%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/TintSelector.swift (93%) create mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens.swift rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/ToolbarControls.swift (70%) rename {Sources/Contained/DesignSystem => Packages/ContainedDesignSystem/Sources/ContainedDesignSystem}/VisualEffectBackground.swift (50%) create mode 100644 Packages/ContainedNavigation/Package.swift create mode 100644 Packages/ContainedNavigation/README.md create mode 100644 Packages/ContainedNavigation/Sources/ContainedNavigation/AppSafeAreaManager.swift create mode 100644 Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md rename {Sources/Contained/DesignSystem => Packages/ContainedNavigation/Sources/ContainedNavigation}/MorphPanelScaffold.swift (71%) rename {Sources/Contained/DesignSystem => Packages/ContainedNavigation/Sources/ContainedNavigation}/MorphingExpander.swift (75%) delete mode 100644 Sources/Contained/DesignSystem/AppSafeAreaManager.swift delete mode 100644 Sources/Contained/DesignSystem/GlassSurface.swift delete mode 100644 Sources/Contained/DesignSystem/PageScaffold.swift delete mode 100644 Sources/Contained/DesignSystem/ResourceCardChrome.swift create mode 100644 Sources/ContainedCore/Services/ContainerRuntime.swift create mode 100644 Tests/ContainedAppTests/ContainersStoreRefreshTests.swift create mode 100644 changes/unreleased/20260701-design-system-package.md create mode 100644 changes/unreleased/20260701-navigation-package.md create mode 100644 changes/unreleased/20260701-runtime-abstraction.md create mode 100644 changes/unreleased/20260701-xcode-workspace.md create mode 100644 changes/unreleased/20260702-refresh-stats-performance.md diff --git a/AGENTS.md b/AGENTS.md index 1a1bcee8..6135d1b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,12 @@ This file is the working contract for coding agents in this repository. Follow i ## Project Shape - This is a SwiftPM-first macOS 26 SwiftUI app. +- Local reusable packages live under `Packages/` and are consumed by the root SwiftPM package. +- `Contained.xcworkspace` is an Xcode convenience entry point over the SwiftPM manifests. Do not hand-maintain generated `.xcodeproj` state. - `Sources/ContainedCore` is pure/testable logic. Keep SwiftUI, app state, Sparkle, and persistence out of it. -- `Sources/Contained` is the app: SwiftUI screens, design system, navigation, stores, history, settings, and update support. +- `Sources/Contained` is the app: SwiftUI screens, app-specific presentation mappings, navigation, stores, history, settings, and update support. +- `Packages/ContainedDesignSystem` is the reusable SwiftUI/AppKit design-system package. Keep app state, stores, Sparkle, SwiftData, persistence, and feature routing out of it. +- `Packages/ContainedNavigation` is the reusable navigation/layout package. Keep app sections, toolbar panels, stores, and concrete routing state in `Sources/Contained`. - `docs/wiki` mirrors the GitHub wiki. User-facing behavior or workflow changes should update the matching page. - Keep directory names intentional: SwiftPM-owned folders stay `Sources` and `Tests`, Swift source domain folders use PascalCase, and repo infrastructure uses lowercase names such as `docs` and `scripts`. @@ -44,7 +48,8 @@ This file is the working contract for coding agents in this repository. Follow i ## Design And UI Rules -- Reuse design-system primitives before adding local styling: `PanelHeader`, `PanelSection`, `PanelField`, `ResourceGlassCard`, `CommandPreviewBar`, `TintSelector`, `ToolbarIconButton`, and `Tokens`. +- Reuse design-system primitives before adding local styling: `PanelHeader`, `PanelSection`, `PanelField`, `ResourceGlassCard`, `CommandPreviewBar`, `TintSelector`, `GlassButton`, `ToolbarMenuButton`, and `Tokens`. +- Do not add app-local spacing, padding, radius, shadow, material, opacity, or micro-chrome constants. Add or extend a `ContainedDesignSystem` token/primitive first, then consume it from the app. - Keep the classic sidebar fallback working. Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements. - Prefer native macOS/Liquid Glass behavior over custom chrome when the system primitive fits. - Do not make broad visual changes without a product reason. diff --git a/Contained.xcworkspace/contents.xcworkspacedata b/Contained.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..aa40158d --- /dev/null +++ b/Contained.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/Package.swift b/Package.swift index 617ccdb2..735ba052 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,8 @@ let package = Package( .library(name: "ContainedCore", targets: ["ContainedCore"]), ], dependencies: [ + .package(path: "Packages/ContainedDesignSystem"), + .package(path: "Packages/ContainedNavigation"), // The in-container terminal (Phase 5): a mature VT100/xterm emulator + PTY host, far safer // than re-implementing one. AppKit-backed, bridged via NSViewRepresentable. .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0"), @@ -30,6 +32,8 @@ let package = Package( name: "Contained", dependencies: [ "ContainedCore", + .product(name: "ContainedDesignSystem", package: "ContainedDesignSystem"), + .product(name: "ContainedNavigation", package: "ContainedNavigation"), .product(name: "SwiftTerm", package: "SwiftTerm"), .product(name: "Sparkle", package: "Sparkle"), ], diff --git a/Packages/ContainedDesignSystem/Package.swift b/Packages/ContainedDesignSystem/Package.swift new file mode 100644 index 00000000..2ce38e13 --- /dev/null +++ b/Packages/ContainedDesignSystem/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "ContainedDesignSystem", + platforms: [.macOS(.v26)], + products: [ + .library(name: "ContainedDesignSystem", targets: ["ContainedDesignSystem"]), + ], + targets: [ + .target(name: "ContainedDesignSystem"), + ] +) diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md new file mode 100644 index 00000000..7814f501 --- /dev/null +++ b/Packages/ContainedDesignSystem/README.md @@ -0,0 +1,153 @@ +# ContainedDesignSystem + +`ContainedDesignSystem` is the local Swift package that owns Contained's reusable +SwiftUI/AppKit visual language. + +Use it for app-agnostic UI primitives: tokens, glass surfaces, panel/page/sheet +scaffolds, toolbar controls, resource-card chrome, sparklines, JSON/stream +surfaces, color controls, clipboard helpers, and small chrome such as badges, +keycaps, status dots, metric tiles, terminal surfaces, and selection overlays. + +Do not put app state, stores, SwiftData models, Sparkle wiring, routing, runtime +models, or feature-specific business rules in this package. App code should pass +plain values into package views instead. + +## Importing + +From the root app package: + +```swift +.product(name: "ContainedDesignSystem", package: "ContainedDesignSystem") +``` + +From Swift code: + +```swift +import SwiftUI +import ContainedDesignSystem +``` + +This package currently depends only on platform frameworks available to a macOS +26 SwiftUI app. + +## What Belongs Here + +- `Tokens` for spacing, radius, toolbar, panel, icon, form, chart, badge, + keycap, card, terminal, and menu-bar constants. +- `WindowMaterial`, `AppTint`, `ColorLayerBlendMode`, and root environment + values for shared material/tint policy. +- `GlassSurface`, `glassSurface`, `glassCapsuleSurface`, and visual-effect + helpers for all reusable glass treatment. +- `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `SheetHeader`, and + `PageScaffold` for app-neutral scaffolding. +- `GlassButton`, `GlassButtonItem`, `GlassButtonInputItem`, `GlassRowMenu`, and + toolbar control helpers. +- `ResourceGlassCard` and `ResourceCard*` pieces for repeated card layouts. +- `ActivityStatusView` with `ActivityStatusPresentation`, where callers provide + plain status text/progress instead of app model objects. +- `LiveSparkline`, `GraphStyle`, and `WidgetInterpolation` for graph rendering + options. +- Micro-primitives such as `DesignStatusDot`, `DesignStatusBadge`, + `DesignKeyCap`, `DesignKeyboardHint`, `DesignTintSwatch`, and + `DesignMetricTile`. + +## App Root Setup + +Seed package environment values once at the shell/root instead of restyling +individual views: + +```swift +struct AppRoot: View { + var body: some View { + DesignSystemExample() + .tint(AppTint.azure.color) + .environment(\.modalMaterial, WindowMaterial.sheet) + .environment(\.buttonMaterial, WindowMaterial.glassClear) + .environment(\.cardMaterial, WindowMaterial.glassRegular) + .environment(\.buttonTintStyle, GlassButtonTintStyle(enabled: true, + tint: .azure, + opacity: 0.18)) + .environment(\.designSystemShowsInfoTips, true) + .environment(\.pageScaffoldUsesToolbarChrome, false) + .environment(\.pageScaffoldBottomClearance, 0) + } +} +``` + +## Example + +```swift +import SwiftUI +import ContainedDesignSystem + +struct DesignSystemExample: View { + @State private var tint = AppTint.azure + + var body: some View { + PageScaffold(symbol: "shippingbox", + title: "Containers", + subtitle: "Local runtime") { + VStack(spacing: Tokens.Space.l) { + PanelSection(header: "Appearance") { + PanelRow(title: "Accent") { + TintSelector(selection: $tint) + } + PanelRow(title: "Shortcut") { + DesignKeyboardHint("return", "Open") + } + } + + ResourceGlassCard(size: .small, elevated: false) { + ResourceCardHeader { + ResourceCardIconChip(symbol: "shippingbox.fill", + tint: tint.color) + } content: { + VStack(alignment: .leading, + spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardTitleText(text: "web") + ResourceCardSubtitleText(text: "nginx:latest") + } + } trailing: { + GlassListRowChevron() + } + } + .selectionFill() + + ActivityStatusView( + activity: ActivityStatusPresentation(title: "Pulling image", + detail: "nginx:latest", + fraction: 0.42), + style: .expanded + ) + } + } + .environment(\.cardMaterial, .glassRegular) + .environment(\.buttonMaterial, .glassClear) + .environment(\.designSystemShowsInfoTips, true) + } +} +``` + +## Documentation + +- DocC landing page: + `Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md` +- App-level guidance: + `../../docs/wiki/Design-System.md` +- Navigation package: + `../ContainedNavigation/README.md` + +## Verification + +Build the package by itself: + +```sh +swift build --package-path Packages/ContainedDesignSystem +``` + +Build it through the app graph: + +```sh +swift build +swift test +``` diff --git a/Sources/Contained/DesignSystem/ActivityStatusView.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ActivityStatusView.swift similarity index 76% rename from Sources/Contained/DesignSystem/ActivityStatusView.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ActivityStatusView.swift index 9bb090b6..4a79e338 100644 --- a/Sources/Contained/DesignSystem/ActivityStatusView.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ActivityStatusView.swift @@ -1,19 +1,36 @@ import SwiftUI -/// The app's single "something is happening" asset, driven by `AppModel.ActivityState`. One source of +public struct ActivityStatusPresentation: Equatable, Sendable { + public var title: String + public var detail: String + public var fraction: Double? + + public init(title: String, detail: String = "", fraction: Double? = nil) { + self.title = title + self.detail = detail + self.fraction = fraction + } +} + +/// The app's single "something is happening" asset, driven by plain status text. One source of /// truth for the spinner + title (+ progress) so the same operation reads identically wherever it /// surfaces — currently the bottom-left status capsule, which morphs from the idle service status into /// this while a long-running operation (e.g. an image pull) is in flight. /// /// `.inline` is the compact one-line form sized for a toolbar capsule; `.expanded` is the taller card /// with a linear progress bar and the streaming detail line. -struct ActivityStatusView: View { - enum Style { case inline, expanded } +public struct ActivityStatusView: View { + public enum Style { case inline, expanded } + + public let activity: ActivityStatusPresentation + public var style: Style = .inline - let activity: AppModel.ActivityState - var style: Style = .inline + public init(activity: ActivityStatusPresentation, style: Style = .inline) { + self.activity = activity + self.style = style + } - var body: some View { + public var body: some View { switch style { case .inline: inlineBody case .expanded: expandedBody diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Clipboard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Clipboard.swift new file mode 100644 index 00000000..db7ab578 --- /dev/null +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Clipboard.swift @@ -0,0 +1,9 @@ +import AppKit + +/// Shared pasteboard helper for copy affordances in package-owned chrome. +@MainActor +public func copyToPasteboard(_ string: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(string, forType: .string) +} diff --git a/Sources/Contained/DesignSystem/CommandPreviewBar.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/CommandPreviewBar.swift similarity index 88% rename from Sources/Contained/DesignSystem/CommandPreviewBar.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/CommandPreviewBar.swift index d00083ab..d2020c95 100644 --- a/Sources/Contained/DesignSystem/CommandPreviewBar.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/CommandPreviewBar.swift @@ -2,13 +2,17 @@ import SwiftUI /// The signature "Reveal CLI" strip: shows the exact `container …` command an action will run, /// copyable to the clipboard. Drives user trust and learning. -struct CommandPreviewBar: View { - let command: [String] +public struct CommandPreviewBar: View { + public let command: [String] @State private var copied = false private var rendered: String { (["container"] + command).joined(separator: " ") } - var body: some View { + public init(command: [String]) { + self.command = command + } + + public var body: some View { HStack(spacing: Tokens.Space.s) { Image(systemName: "terminal") .foregroundStyle(.primary) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md new file mode 100644 index 00000000..74ecb3a3 --- /dev/null +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -0,0 +1,147 @@ +# ``ContainedDesignSystem`` + +Reusable SwiftUI/AppKit visual primitives for Contained. + +## Overview + +`ContainedDesignSystem` owns app-agnostic visual policy: spacing, padding, +radius, material, tint, glass surfaces, panel/page/sheet scaffolds, toolbar +controls, resource-card chrome, sparklines, JSON/stream surfaces, color controls, +clipboard helpers, and small chrome such as badges, keycaps, status dots, metric +tiles, terminal surfaces, and selection overlays. + +Do not add app state, stores, SwiftData models, Sparkle wiring, routing, runtime +models, or feature-specific business rules here. Convert app/domain state into +plain values before passing it to package views. + +## Configure Shared Policy Once + +Set material and shell policy near the app root: + +```swift +struct AppRoot: View { + var body: some View { + DesignSystemExample() + .tint(AppTint.azure.color) + .environment(\.modalMaterial, WindowMaterial.sheet) + .environment(\.buttonMaterial, WindowMaterial.glassClear) + .environment(\.cardMaterial, WindowMaterial.glassRegular) + .environment(\.buttonTintStyle, GlassButtonTintStyle(enabled: true, + tint: .azure)) + .environment(\.designSystemShowsInfoTips, true) + .environment(\.pageScaffoldUsesToolbarChrome, false) + .environment(\.pageScaffoldBottomClearance, 0) + } +} +``` + +## Example + +```swift +import SwiftUI +import ContainedDesignSystem + +struct DesignSystemExample: View { + @State private var tint = AppTint.azure + + var body: some View { + PageScaffold(symbol: "shippingbox", + title: "Containers", + subtitle: "Local runtime") { + VStack(spacing: Tokens.Space.l) { + PanelSection(header: "Appearance") { + PanelRow(title: "Accent") { + TintSelector(selection: $tint) + } + PanelRow(title: "Shortcut") { + DesignKeyboardHint("return", "Open") + } + } + + ResourceGlassCard(size: .small, elevated: false) { + ResourceCardHeader { + ResourceCardIconChip(symbol: "shippingbox.fill", + tint: tint.color) + } content: { + VStack(alignment: .leading, + spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardTitleText(text: "web") + ResourceCardSubtitleText(text: "nginx:latest") + } + } trailing: { + GlassListRowChevron() + } + } + .selectionFill() + } + } + .environment(\.cardMaterial, .glassRegular) + .environment(\.buttonMaterial, .glassClear) + } +} +``` + +## Topics + +### Tokens and Theme + +- ``Tokens`` +- ``WindowMaterial`` +- ``AppTint`` +- ``ColorLayerBlendMode`` +- ``GlassButtonTintStyle`` + +### Surfaces and Scaffolds + +- ``GlassSurface`` +- ``GlassCapsuleSurface`` +- ``PageScaffold`` +- ``PanelHeader`` +- ``PanelSection`` +- ``PanelRow`` +- ``PanelField`` +- ``PanelToggleRow`` +- ``SheetHeader`` + +### Toolbar Controls + +- ``GlassButton`` +- ``GlassButtonItem`` +- ``GlassButtonInputItem`` +- ``GlassRowMenu`` + +### Resource Cards + +- ``ResourceGlassCard`` +- ``ResourceCardHeader`` +- ``ResourceCardIconChip`` +- ``ResourceBadgeText`` +- ``ResourceCardFooterMini`` +- ``ResourceCardTitleText`` +- ``ResourceCardSubtitleText`` +- ``ResourceCardMonospacedSubtitleText`` +- ``ResourceCardMetricText`` + +### Data Display and Micro Chrome + +- ``ActivityStatusView`` +- ``ActivityStatusPresentation`` +- ``LiveSparkline`` +- ``GraphStyle`` +- ``WidgetInterpolation`` +- ``MetricTile`` +- ``DesignStatusDot`` +- ``DesignStatusBadge`` +- ``DesignKeyCap`` +- ``DesignKeyboardHint`` +- ``DesignTintSwatch`` +- ``DesignMetricTile`` + +### Utilities + +- ``TintSelector`` +- ``GradientAngleControl`` +- ``JSONInspectorSheet`` +- ``InlineJSONView`` +- ``StreamConsole`` +- ``copyToPasteboard(_:)`` diff --git a/Sources/Contained/DesignSystem/ErrorToast.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ErrorToast.swift similarity index 79% rename from Sources/Contained/DesignSystem/ErrorToast.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ErrorToast.swift index 2b1a0203..40b4b19e 100644 --- a/Sources/Contained/DesignSystem/ErrorToast.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ErrorToast.swift @@ -2,10 +2,14 @@ import SwiftUI /// A transient error banner: a warning glyph + message on a glass surface, sliding up from the bottom. /// Used as a bottom overlay to surface a store's `errorMessage` without a blocking alert. -struct ErrorToast: View { - let message: String +public struct ErrorToast: View { + public let message: String - var body: some View { + public init(message: String) { + self.message = message + } + + public var body: some View { HStack(spacing: Tokens.Space.s) { Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red) Text(message).font(.callout).lineLimit(2) diff --git a/Sources/Contained/DesignSystem/ExteriorShadow.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ExteriorShadow.swift similarity index 50% rename from Sources/Contained/DesignSystem/ExteriorShadow.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ExteriorShadow.swift index 3fe53f7f..c1b15c83 100644 --- a/Sources/Contained/DesignSystem/ExteriorShadow.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ExteriorShadow.swift @@ -1,12 +1,19 @@ import SwiftUI -struct ExteriorShadow: View { - var cornerRadius: CGFloat - var color: Color - var radius: CGFloat - var y: CGFloat +public struct ExteriorShadow: View { + public var cornerRadius: CGFloat + public var color: Color + public var radius: CGFloat + public var y: CGFloat - var body: some View { + public init(cornerRadius: CGFloat, color: Color, radius: CGFloat, y: CGFloat) { + self.cornerRadius = cornerRadius + self.color = color + self.radius = radius + self.y = y + } + + public var body: some View { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) ZStack { shape diff --git a/Sources/Contained/DesignSystem/GlassButton.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassButton.swift similarity index 73% rename from Sources/Contained/DesignSystem/GlassButton.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassButton.swift index 1d110b28..69a355eb 100644 --- a/Sources/Contained/DesignSystem/GlassButton.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassButton.swift @@ -11,20 +11,34 @@ private extension EnvironmentValues { } } -struct GlassButtonTintStyle: Equatable, Sendable { - var enabled = false - var tint: AppTint = .multicolor - var opacity = 0.18 - var gradient = true - var gradientAngle = 135.0 - var blendMode: ColorLayerBlendMode = .softLight - - static let disabled = GlassButtonTintStyle() +public struct GlassButtonTintStyle: Equatable, Sendable { + public var enabled = false + public var tint: AppTint = .multicolor + public var opacity = 0.18 + public var gradient = true + public var gradientAngle = 135.0 + public var blendMode: ColorLayerBlendMode = .softLight + + public init(enabled: Bool = false, + tint: AppTint = .multicolor, + opacity: Double = 0.18, + gradient: Bool = true, + gradientAngle: Double = 135.0, + blendMode: ColorLayerBlendMode = .softLight) { + self.enabled = enabled + self.tint = tint + self.opacity = opacity + self.gradient = gradient + self.gradientAngle = gradientAngle + self.blendMode = blendMode + } + + public static let disabled = GlassButtonTintStyle() } /// A reusable glass button item: an icon or text button with the shared 28pt inner height and 4pt /// padding. Place it inside `GlassButton` to get the full 36pt glass capsule. -struct GlassButtonItem: View { +public struct GlassButtonItem: View { var role: ButtonRole? = nil var tint: Color? = nil var help: String = "" @@ -44,7 +58,7 @@ struct GlassButtonItem: View { return AnyShapeStyle(.primary) } - init(role: ButtonRole? = nil, tint: Color? = nil, help: String = "", + public init(role: ButtonRole? = nil, tint: Color? = nil, help: String = "", isCancel: Bool = false, isLabel: Bool = false, isIcon: Bool = false, action: (() -> Void)? = nil, @ViewBuilder label: @escaping () -> Label) { self.role = role @@ -77,7 +91,7 @@ struct GlassButtonItem: View { .animation(.easeOut(duration: 0.15), value: hovering) } - var body: some View { + public var body: some View { Group { if let action, !isLabel { Button(role: role, action: action) { @@ -98,11 +112,17 @@ struct GlassButtonItem: View { /// Input content that occupies the same 28pt inner lane as `GlassButtonItem`, but leaves hover/pressed /// treatment to the enclosing `GlassButton` container. -struct GlassButtonInputItem: View { - var spacing = Tokens.Toolbar.searchIconGap +public struct GlassButtonInputItem: View { + public var spacing = Tokens.Toolbar.searchIconGap @ViewBuilder var content: () -> Content - var body: some View { + public init(spacing: CGFloat = Tokens.Toolbar.searchIconGap, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.content = content + } + + public var body: some View { HStack(spacing: spacing) { content() } .font(.body.weight(.medium)) .padding(Tokens.Toolbar.iconInnerPadding) @@ -111,7 +131,7 @@ struct GlassButtonInputItem: View { } } -extension GlassButtonItem where Label == Image { +public extension GlassButtonItem where Label == Image { init(systemName: String, role: ButtonRole? = nil, tint: Color? = nil, help: String = "", isCancel: Bool = false, isLabel: Bool = false, action: (() -> Void)? = nil) { self.role = role @@ -127,20 +147,34 @@ extension GlassButtonItem where Label == Image { /// A pill that groups related glass button items under one shared interactive-glass capsule. This /// is the morph target for compact button groups across the app. -struct GlassButton: View { - var spacing: CGFloat = 0 - var height: CGFloat = Tokens.Toolbar.buttonGroupHeight - var minWidth: CGFloat? = nil - var singleItem: Bool = false +public struct GlassButton: View { + public var spacing: CGFloat = 0 + public var height: CGFloat = Tokens.Toolbar.buttonGroupHeight + public var minWidth: CGFloat? = nil + public var singleItem: Bool = false /// Set `false` for a static glass container (no hover treatment) — e.g. vanity toolbar chrome. - var interactive: Bool = true + public var interactive: Bool = true @ViewBuilder var content: () -> Content + public init(spacing: CGFloat = 0, + height: CGFloat = Tokens.Toolbar.buttonGroupHeight, + minWidth: CGFloat? = nil, + singleItem: Bool = false, + interactive: Bool = true, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.height = height + self.minWidth = minWidth + self.singleItem = singleItem + self.interactive = interactive + self.content = content + } + @State private var hovering = false @Environment(\.colorScheme) private var colorScheme @Environment(\.buttonTintStyle) private var tintStyle - var body: some View { + public var body: some View { let shape = Capsule(style: .continuous) HStack(spacing: spacing) { content() } .padding(.horizontal, Tokens.Toolbar.iconInnerPadding) diff --git a/Sources/Contained/DesignSystem/GlassOptionTile.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassOptionTile.swift similarity index 71% rename from Sources/Contained/DesignSystem/GlassOptionTile.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassOptionTile.swift index 3d819313..91bc6764 100644 --- a/Sources/Contained/DesignSystem/GlassOptionTile.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassOptionTile.swift @@ -1,18 +1,36 @@ import SwiftUI -struct GlassOptionTile: View { - static let defaultHeight: CGFloat = 100 +public struct GlassOptionTile: View { + public static let defaultHeight: CGFloat = 100 - let symbol: String - let title: String - var subtitle: String? = nil - var enabled = true - var height: CGFloat = Self.defaultHeight - var matchedID: String? - var matchedNamespace: Namespace.ID? - var action: () -> Void + public let symbol: String + public let title: String + public var subtitle: String? + public var enabled: Bool + public var height: CGFloat + public var matchedID: String? + public var matchedNamespace: Namespace.ID? + public var action: () -> Void - var body: some View { + public init(symbol: String, + title: String, + subtitle: String? = nil, + enabled: Bool = true, + height: CGFloat = Self.defaultHeight, + matchedID: String? = nil, + matchedNamespace: Namespace.ID? = nil, + action: @escaping () -> Void) { + self.symbol = symbol + self.title = title + self.subtitle = subtitle + self.enabled = enabled + self.height = height + self.matchedID = matchedID + self.matchedNamespace = matchedNamespace + self.action = action + } + + public var body: some View { Button { guard enabled else { return } action() diff --git a/Sources/Contained/DesignSystem/GlassRowMenu.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassRowMenu.swift similarity index 58% rename from Sources/Contained/DesignSystem/GlassRowMenu.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassRowMenu.swift index 560752db..85889f59 100644 --- a/Sources/Contained/DesignSystem/GlassRowMenu.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassRowMenu.swift @@ -3,12 +3,20 @@ import SwiftUI /// The circular glass ellipsis menu used as the trailing accessory on every resource row /// (Images/Volumes/Networks/Registries/Templates) and the detail header. Centralizes the styling /// chain and the VoiceOver label so icon-only menus are consistently accessible. -struct GlassRowMenu: View { - var systemImage: String = "ellipsis" - var accessibilityLabel: String = "Options" - @ViewBuilder var content: () -> Content +public struct GlassRowMenu: View { + public var systemImage: String + public var accessibilityLabel: String + @ViewBuilder public var content: () -> Content - var body: some View { + public init(systemImage: String = "ellipsis", + accessibilityLabel: String = "Options", + @ViewBuilder content: @escaping () -> Content) { + self.systemImage = systemImage + self.accessibilityLabel = accessibilityLabel + self.content = content + } + + public var body: some View { Menu { content() } label: { diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassSurface.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassSurface.swift new file mode 100644 index 00000000..fce55438 --- /dev/null +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassSurface.swift @@ -0,0 +1,176 @@ +import SwiftUI + +/// Centralized Liquid Glass surface: real `.glassEffect()` plus a soft shadow that lifts the +/// element off the backdrop, and an optional colored (optionally gradient) wash behind the glass. +public struct GlassSurface: ViewModifier { + public enum Level { case regular, thin, ultraThin } + public var level: Level + public var cornerRadius: CGFloat + public var glass: Glass + /// Lift the surface off the backdrop with a soft shadow. Pass `false` for flat tiles that sit + /// inside an already-elevated panel (e.g. cards in the toolbar morph panels / the creation menu). + public var shadow: Bool + public var fill: Color? + public var fillOpacity: Double + public var gradient: Bool + public var gradientAngle: Double + + @Environment(\.colorScheme) private var colorScheme + + public init(level: Level = .regular, + cornerRadius: CGFloat = Tokens.Radius.card, + glass: Glass = .regular, + shadow: Bool = true, + fill: Color? = nil, + fillOpacity: Double = 0.18, + gradient: Bool = false, + gradientAngle: Double = 135) { + self.level = level + self.cornerRadius = cornerRadius + self.glass = glass + self.shadow = shadow + self.fill = fill + self.fillOpacity = fillOpacity + self.gradient = gradient + self.gradientAngle = gradientAngle + } + + public func body(content: Content) -> some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + // Layering, back → front: tint wash → glass → content. + // `.glassEffect` puts the glass *behind* the content; the tint sits behind the glass so it + // shows *through* it (refracted), rather than washing over the content. + // NOTE: no `.compositingGroup()` here — it rasterizes the glass and makes it render opaque, + // breaking the live translucency. `.glassEffect` provides its own elevation. + return content + .clipShape(shape) + .background { + if shadow { + ExteriorShadow(cornerRadius: cornerRadius, + color: shadowColor, + radius: shadowRadius, + y: shadowY) + } + } + .glassEffect(glass, in: shape) + .background { + if let fill { + shape.fill(fillStyle(fill)) + } + } + } + + private func fillStyle(_ color: Color) -> AnyShapeStyle { + if gradient { + let radians = gradientAngle * .pi / 180 + let dx = cos(radians) / 2 + let dy = sin(radians) / 2 + return AnyShapeStyle(LinearGradient( + colors: [color.opacity(fillOpacity * 1.35), color.opacity(fillOpacity * 0.4)], + startPoint: UnitPoint(x: 0.5 - dx, y: 0.5 - dy), + endPoint: UnitPoint(x: 0.5 + dx, y: 0.5 + dy))) + } + return AnyShapeStyle(color.opacity(fillOpacity)) + } + + private var shadowColor: Color { + let base = colorScheme == .dark ? 0.55 : 0.18 + let scale: Double + switch level { + case .regular: scale = 1.0 + case .thin: scale = 0.6 + case .ultraThin: scale = 0.4 + } + return .black.opacity(base * scale) + } + + private var shadowRadius: CGFloat { + switch level { case .regular: return 10; case .thin: return 6; case .ultraThin: return 4 } + } + private var shadowY: CGFloat { + switch level { case .regular: return 4; case .thin: return 2; case .ultraThin: return 1 } + } +} + +/// Capsule variant for transient bars and compact floating controls that need the same glass rules +/// without pretending they are rounded cards. +public struct GlassCapsuleSurface: ViewModifier { + public var level: GlassSurface.Level + public var glass: Glass + public var shadow: Bool + public var fill: Color? + public var fillOpacity: Double + + @Environment(\.colorScheme) private var colorScheme + + public init(level: GlassSurface.Level = .regular, + glass: Glass = .regular, + shadow: Bool = true, + fill: Color? = nil, + fillOpacity: Double = 0.18) { + self.level = level + self.glass = glass + self.shadow = shadow + self.fill = fill + self.fillOpacity = fillOpacity + } + + public func body(content: Content) -> some View { + let shape = Capsule() + return content + .clipShape(shape) + .shadow(color: shadow ? shadowColor : .clear, radius: shadowRadius, y: shadowY) + .glassEffect(glass, in: shape) + .background { + if let fill { + shape.fill(fill.opacity(fillOpacity)) + } + } + } + + private var shadowColor: Color { + let base = colorScheme == .dark ? 0.55 : 0.18 + let scale: Double + switch level { + case .regular: scale = 1.0 + case .thin: scale = 0.6 + case .ultraThin: scale = 0.4 + } + return .black.opacity(base * scale) + } + + private var shadowRadius: CGFloat { + switch level { case .regular: return 10; case .thin: return 6; case .ultraThin: return 4 } + } + + private var shadowY: CGFloat { + switch level { case .regular: return 4; case .thin: return 2; case .ultraThin: return 1 } + } +} + +public extension View { + func glassSurface(_ level: GlassSurface.Level = .regular, + cornerRadius: CGFloat = Tokens.Radius.card, + glass: Glass = .regular, + shadow: Bool = true, + fill: Color? = nil, + fillOpacity: Double = 0.18, + gradient: Bool = false, + gradientAngle: Double = 135) -> some View { + modifier(GlassSurface(level: level, cornerRadius: cornerRadius, glass: glass, + shadow: shadow, fill: fill, fillOpacity: fillOpacity, gradient: gradient, + gradientAngle: gradientAngle)) + } + + func glassCapsuleSurface(_ level: GlassSurface.Level = .regular, + glass: Glass = .regular, + shadow: Bool = true, + fill: Color? = nil, + fillOpacity: Double = 0.18) -> some View { + modifier(GlassCapsuleSurface(level: level, + glass: glass, + shadow: shadow, + fill: fill, + fillOpacity: fillOpacity)) + } +} diff --git a/Sources/Contained/Features/Containers/GradientAngleControl.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GradientAngleControl.swift similarity index 55% rename from Sources/Contained/Features/Containers/GradientAngleControl.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GradientAngleControl.swift index 20d36d10..4489a724 100644 --- a/Sources/Contained/Features/Containers/GradientAngleControl.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GradientAngleControl.swift @@ -1,22 +1,30 @@ import SwiftUI /// A 360° gradient-direction control: a draggable dial plus a degree readout. -struct GradientAngleControl: View { +public struct GradientAngleControl: View { @Binding var angle: Double - var body: some View { + public init(angle: Binding) { + self._angle = angle + } + + public var body: some View { LabeledContent("Direction") { HStack(spacing: Tokens.Space.m) { - AngleDial(angle: $angle).frame(width: 36, height: 36) + AngleDial(angle: $angle) + .frame(width: Tokens.InlineControl.gradientDial, + height: Tokens.InlineControl.gradientDial) Slider(value: $angle, in: 0...360, step: 1) - Text("\(Int(angle))°").monospacedDigit().frame(width: 40) + Text("\(Int(angle))°") + .monospacedDigit() + .frame(width: Tokens.InlineControl.gradientReadout) } } } } /// A small dial knob whose pointer reflects the gradient angle; drag to set. -struct AngleDial: View { +private struct AngleDial: View { @Binding var angle: Double var body: some View { @@ -24,11 +32,17 @@ struct AngleDial: View { let radius = min(geo.size.width, geo.size.height) / 2 let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2) let radians = angle * .pi / 180 - let knob = CGPoint(x: center.x + cos(radians) * (radius - 4), - y: center.y + sin(radians) * (radius - 4)) + let knob = CGPoint(x: center.x + cos(radians) * (radius - Tokens.InlineControl.gradientKnobInset), + y: center.y + sin(radians) * (radius - Tokens.InlineControl.gradientKnobInset)) ZStack { - Circle().strokeBorder(.secondary.opacity(0.4), lineWidth: 1) - Circle().fill(.tint).frame(width: 7, height: 7).position(knob) + Circle() + .strokeBorder(.secondary.opacity(Tokens.InlineControl.gradientStrokeOpacity), + lineWidth: Tokens.Space.hairline) + Circle() + .fill(.tint) + .frame(width: Tokens.InlineControl.gradientKnob, + height: Tokens.InlineControl.gradientKnob) + .position(knob) } .contentShape(Rectangle()) .gesture( diff --git a/Sources/Contained/DesignSystem/InfoButton.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/InfoButton.swift similarity index 81% rename from Sources/Contained/DesignSystem/InfoButton.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/InfoButton.swift index c1844d79..e366fae2 100644 --- a/Sources/Contained/DesignSystem/InfoButton.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/InfoButton.swift @@ -1,23 +1,27 @@ import SwiftUI +public extension EnvironmentValues { + @Entry var designSystemShowsInfoTips = true +} + /// A small `info.circle` button that reveals help text in a popover. Replaces hover-only tooltips so /// the guidance is always discoverable (tap, not hover) and reachable by VoiceOver / keyboard. The /// popover wraps to as many lines as the text needs (it never truncates) and can be turned off /// globally in Settings → Appearance. -struct InfoButton: View { - let text: String - var visible = true - @Environment(AppModel.self) private var app +public struct InfoButton: View { + public let text: String + public var visible = true @Environment(\.modalMaterial) private var modalMaterial + @Environment(\.designSystemShowsInfoTips) private var showsInfoTips @State private var showing = false - init(_ text: String, visible: Bool = true) { + public init(_ text: String, visible: Bool = true) { self.text = text self.visible = visible } - var body: some View { - if app.settings.showInfoTips { + public var body: some View { + if showsInfoTips { Button { showing = true } label: { Image(systemName: "info.circle") .font(.caption) diff --git a/Sources/Contained/DesignSystem/JSONInspectorSheet.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/JSONInspectorSheet.swift similarity index 78% rename from Sources/Contained/DesignSystem/JSONInspectorSheet.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/JSONInspectorSheet.swift index bbe6a858..80eb42e9 100644 --- a/Sources/Contained/DesignSystem/JSONInspectorSheet.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/JSONInspectorSheet.swift @@ -2,7 +2,7 @@ import SwiftUI /// Pretty-print any `Encodable` value to sorted, indented JSON (shared by the inspector sheet and the /// in-panel inspect morph page). -func prettyJSON(_ value: Value) -> String { +public func prettyJSON(_ value: Value) -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 @@ -13,10 +13,14 @@ func prettyJSON(_ value: Value) -> String { } /// Header-less scrolling JSON body — reused by `JSONInspectorSheet` and the image-detail inspect page. -struct InlineJSONView: View { - let json: String +public struct InlineJSONView: View { + public let json: String - var body: some View { + public init(json: String) { + self.json = json + } + + public var body: some View { ScrollView([.horizontal, .vertical]) { Text(json) .font(.system(.caption, design: .monospaced)) @@ -29,12 +33,17 @@ struct InlineJSONView: View { } /// A reusable pretty-printed-JSON inspector sheet for any `Encodable` resource. -struct JSONInspectorSheet: View { +public struct JSONInspectorSheet: View { @Environment(\.dismiss) private var dismiss - let title: String - let value: Value + public let title: String + public let value: Value + + public init(title: String, value: Value) { + self.title = title + self.value = value + } - var body: some View { + public var body: some View { VStack(spacing: 0) { HStack { Text(title).font(.headline).lineLimit(1) diff --git a/Sources/Contained/DesignSystem/LiveSparkline.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift similarity index 64% rename from Sources/Contained/DesignSystem/LiveSparkline.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift index d5e14ce2..18ad38ab 100644 --- a/Sources/Contained/DesignSystem/LiveSparkline.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift @@ -1,20 +1,120 @@ import SwiftUI import Charts +public enum GraphStyle: String, CaseIterable, Identifiable, Codable, Sendable { + case area + case line + case bar + case points + case multiLine + case range + case scatter + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .area: return "Area" + case .line: return "Line" + case .bar: return "Bar" + case .points: return "Points" + case .multiLine: return "Multi-Line" + case .range: return "Range" + case .scatter: return "Scatter" + } + } + + public var requiresSecondaryMetric: Bool { + switch self { + case .multiLine, .range, .scatter: return true + case .area, .line, .bar, .points: return false + } + } + + public func resolvedSecondaryMetric(primary: Metric, + requested: Metric?, + options: [Metric]) -> Metric? { + guard requiresSecondaryMetric else { return nil } + if let requested, requested != primary, options.contains(requested) { + return requested + } + return options.first { $0 != primary } + } + + public var usesLineOptions: Bool { + switch self { + case .area, .line, .multiLine: return true + case .bar, .points, .range, .scatter: return false + } + } + + public var usesPointOptions: Bool { + switch self { + case .points, .scatter: return true + case .area, .line, .bar, .multiLine, .range: return false + } + } + + public var usesBarOptions: Bool { + switch self { + case .bar, .range: return true + case .area, .line, .points, .multiLine, .scatter: return false + } + } +} + +public enum WidgetInterpolation: String, CaseIterable, Identifiable, Codable, Sendable { + case linear, catmullRom, cardinal, monotone, stepStart, stepCenter, stepEnd + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .linear: return "Linear" + case .catmullRom: return "Smooth" + case .cardinal: return "Cardinal" + case .monotone: return "Monotone" + case .stepStart: return "Step Start" + case .stepCenter: return "Step Center" + case .stepEnd: return "Step End" + } + } +} + /// A compact Swift Charts renderer for card widgets. Each series is normalized independently so /// paired metrics with different units still make useful visual comparisons at card scale. -struct LiveSparkline: View { - var samples: [Double] - var comparisonSamples: [Double] = [] - var color: Color = .accentColor - var lineWidth: CGFloat = 1.5 - var style: GraphStyle = .area - var areaUsesGradient = true - var interpolation: WidgetInterpolation = .catmullRom - var pointSize: CGFloat = 18 - var barWidth: CGFloat = 4 - - var body: some View { +public struct LiveSparkline: View { + public var samples: [Double] + public var comparisonSamples: [Double] = [] + public var color: Color = .accentColor + public var lineWidth: CGFloat = 1.5 + public var style: GraphStyle = .area + public var areaUsesGradient = true + public var interpolation: WidgetInterpolation = .catmullRom + public var pointSize: CGFloat = 18 + public var barWidth: CGFloat = 4 + + public init(samples: [Double], + comparisonSamples: [Double] = [], + color: Color = .accentColor, + lineWidth: CGFloat = 1.5, + style: GraphStyle = .area, + areaUsesGradient: Bool = true, + interpolation: WidgetInterpolation = .catmullRom, + pointSize: CGFloat = 18, + barWidth: CGFloat = 4) { + self.samples = samples + self.comparisonSamples = comparisonSamples + self.color = color + self.lineWidth = lineWidth + self.style = style + self.areaUsesGradient = areaUsesGradient + self.interpolation = interpolation + self.pointSize = pointSize + self.barWidth = barWidth + } + + public var body: some View { Group { if samples.count > 1 { chart @@ -183,13 +283,13 @@ private extension WidgetInterpolation { } /// A fixed-size ring buffer for sparkline history. -struct SampleBuffer: Sendable, Equatable { - private(set) var values: [Double] = [] - let capacity: Int +public struct SampleBuffer: Sendable, Equatable { + public private(set) var values: [Double] = [] + public let capacity: Int - init(capacity: Int = 40) { self.capacity = capacity } + public init(capacity: Int = 40) { self.capacity = capacity } - mutating func append(_ value: Double) { + public mutating func append(_ value: Double) { values.append(value) if values.count > capacity { values.removeFirst(values.count - capacity) } } diff --git a/Sources/Contained/DesignSystem/MetricTile.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MetricTile.swift similarity index 63% rename from Sources/Contained/DesignSystem/MetricTile.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MetricTile.swift index 9f0acdc3..c0e7ea54 100644 --- a/Sources/Contained/DesignSystem/MetricTile.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MetricTile.swift @@ -1,14 +1,26 @@ import SwiftUI /// A dashboard summary tile: muted label, large value, optional symbol and sparkline. -struct MetricTile: View { - let label: String - let value: String - var systemImage: String? = nil - var tint: Color = .accentColor - var samples: [Double]? = nil +public struct MetricTile: View { + public let label: String + public let value: String + public var systemImage: String? = nil + public var tint: Color = .accentColor + public var samples: [Double]? = nil - var body: some View { + public init(label: String, + value: String, + systemImage: String? = nil, + tint: Color = .accentColor, + samples: [Double]? = nil) { + self.label = label + self.value = value + self.systemImage = systemImage + self.tint = tint + self.samples = samples + } + + public var body: some View { VStack(alignment: .leading, spacing: Tokens.Space.s) { HStack(spacing: Tokens.Space.s) { if let systemImage { diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MicroPrimitives.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MicroPrimitives.swift new file mode 100644 index 00000000..4eb701e5 --- /dev/null +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MicroPrimitives.swift @@ -0,0 +1,192 @@ +import SwiftUI + +public struct DesignStatusDot: View { + public var color: Color + public var size: CGFloat + + public init(color: Color, size: CGFloat = Tokens.IconSize.statusDot) { + self.color = color + self.size = size + } + + public var body: some View { + Circle() + .fill(color) + .frame(width: size, height: size) + } +} + +public struct DesignStatusBadge: View { + public var text: String + public var tint: Color + public var font: Font + + public init(text: String, + tint: Color, + font: Font = .caption.weight(.medium)) { + self.text = text + self.tint = tint + self.font = font + } + + public var body: some View { + Text(text) + .font(font) + .foregroundStyle(tint) + .padding(.horizontal, Tokens.Badge.horizontalPadding) + .padding(.vertical, Tokens.Badge.verticalPadding) + .background(tint.opacity(Tokens.Badge.statusOpacity), in: Capsule()) + } +} + +public struct DesignKeyCap: View { + public var text: String + + public init(_ text: String) { + self.text = text + } + + public var body: some View { + Text(text) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, Tokens.Keyboard.keyHorizontalPadding) + .padding(.vertical, Tokens.Keyboard.keyVerticalPadding) + .background(.quaternary, + in: RoundedRectangle(cornerRadius: Tokens.Radius.keyCap, + style: .continuous)) + } +} + +public struct DesignKeyboardHint: View { + public var key: String + public var label: String + + public init(_ key: String, _ label: String) { + self.key = key + self.label = label + } + + public var body: some View { + HStack(spacing: Tokens.Space.xs) { + DesignKeyCap(key) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +public struct DesignScopeChipLabel: View { + public var symbol: String + public var title: String + + public init(symbol: String, title: String) { + self.symbol = symbol + self.title = title + } + + public var body: some View { + HStack(spacing: Tokens.Space.xs) { + Image(systemName: symbol) + .font(.caption2) + Text(title) + .font(.caption.weight(.semibold)) + Image(systemName: "xmark") + .font(.caption2.weight(.bold)) + } + .padding(.horizontal, Tokens.Space.s) + .padding(.vertical, Tokens.Badge.scopeVerticalPadding) + .background(Color.accentColor.opacity(Tokens.Badge.accentOpacity), + in: Capsule(style: .continuous)) + .foregroundStyle(Color.accentColor) + } +} + +public struct DesignTintSwatch: View { + public var color: Color + public var followsAppAccent: Bool + + public init(color: Color, followsAppAccent: Bool = false) { + self.color = color + self.followsAppAccent = followsAppAccent + } + + public var body: some View { + ZStack { + Circle().fill(color) + if followsAppAccent { + Image(systemName: "link") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + } + } + .frame(width: Tokens.IconSize.chip, height: Tokens.IconSize.chip) + } +} + +public struct DesignMetricTile: View { + public var label: String + public var value: String + public var caption: String? + + public init(label: String, value: String, caption: String? = nil) { + self.label = label + self.value = value + self.caption = caption + } + + public var body: some View { + VStack(alignment: .leading, spacing: Tokens.Space.xxs) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + HStack(alignment: .firstTextBaseline, spacing: Tokens.Space.xs) { + Text(value) + .font(.title3.weight(.medium)) + if let caption { + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Tokens.Space.m) + .padding(.vertical, Tokens.Space.s) + .background(AppMaterial.toolbarHoverFill, + in: RoundedRectangle(cornerRadius: Tokens.Radius.control, + style: .continuous)) + } +} + +public extension View { + @ViewBuilder + func designCardSelectionOverlay(when isSelected: Bool) -> some View { + overlay { + if isSelected { + RoundedRectangle(cornerRadius: Tokens.Radius.card, style: .continuous) + .fill(AppMaterial.toolbarHoverFill) + .allowsHitTesting(false) + } + } + } + + func terminalSurfaceChrome() -> some View { + padding(Tokens.Space.s) + .background(.black.opacity(Tokens.Terminal.surfaceOpacity), + in: RoundedRectangle(cornerRadius: Tokens.Radius.card, + style: .continuous)) + .padding(Tokens.Space.s) + } + + func subtleTileBackground() -> some View { + background(.quaternary.opacity(Tokens.InlineControl.subtleTileOpacity), + in: RoundedRectangle(cornerRadius: Tokens.Radius.control, + style: .continuous)) + } + + func toolbarControlContentShape() -> some View { + contentShape(Capsule(style: .continuous)) + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PageScaffold.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PageScaffold.swift new file mode 100644 index 00000000..1f810503 --- /dev/null +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PageScaffold.swift @@ -0,0 +1,70 @@ +import SwiftUI + +public extension EnvironmentValues { + @Entry var pageScaffoldUsesToolbarChrome = false + @Entry var pageScaffoldBottomClearance: CGFloat = 0 +} + +public struct PageScaffold: View { + public let symbol: String + public let title: String + public let subtitle: String + public var scrolls = true + @ViewBuilder var actions: () -> Actions + @ViewBuilder var content: () -> Content + @Environment(\.pageScaffoldUsesToolbarChrome) private var usesToolbarChrome + @Environment(\.pageScaffoldBottomClearance) private var bottomClearance + + public init(symbol: String, + title: String, + subtitle: String, + scrolls: Bool = true, + @ViewBuilder actions: @escaping () -> Actions, + @ViewBuilder content: @escaping () -> Content) { + self.symbol = symbol + self.title = title + self.subtitle = subtitle + self.scrolls = scrolls + self.actions = actions + self.content = content + } + + public var body: some View { + VStack(spacing: 0) { + if !usesToolbarChrome { + PanelHeader(symbol: symbol, title: title, subtitle: subtitle) { + actions() + } + Divider() + } + if scrolls { + ScrollView { + VStack(spacing: 0) { + content() + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(usesToolbarChrome ? Tokens.Space.s : Tokens.Space.l) + if usesToolbarChrome && bottomClearance > 0 { + Color.clear + .frame(height: bottomClearance) + } + } + } + .scrollEdgeEffectStyle(.soft, for: .all) + } else { + content() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + } +} + +public extension PageScaffold where Actions == EmptyView { + init(symbol: String, + title: String, + subtitle: String, + scrolls: Bool = true, + @ViewBuilder content: @escaping () -> Content) { + self.init(symbol: symbol, title: title, subtitle: subtitle, scrolls: scrolls, + actions: { EmptyView() }, content: content) + } +} diff --git a/Sources/Contained/DesignSystem/PanelHeader.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelHeader.swift similarity index 63% rename from Sources/Contained/DesignSystem/PanelHeader.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelHeader.swift index 4c83b576..e7d8a11f 100644 --- a/Sources/Contained/DesignSystem/PanelHeader.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelHeader.swift @@ -1,15 +1,29 @@ import SwiftUI /// Standard in-window panel header for toolbar morphs and embedded panels. -struct PanelHeader: View { - let symbol: String - let title: String - var subtitle: String? - var padding: CGFloat = Tokens.Space.s - var leadingReserve: CGFloat = 0 +public struct PanelHeader: View { + public let symbol: String + public let title: String + public var subtitle: String? + public var padding: CGFloat = Tokens.Space.s + public var leadingReserve: CGFloat = 0 @ViewBuilder var trailing: () -> Trailing - var body: some View { + public init(symbol: String, + title: String, + subtitle: String? = nil, + padding: CGFloat = Tokens.Space.s, + leadingReserve: CGFloat = 0, + @ViewBuilder trailing: @escaping () -> Trailing) { + self.symbol = symbol + self.title = title + self.subtitle = subtitle + self.padding = padding + self.leadingReserve = leadingReserve + self.trailing = trailing + } + + public var body: some View { HStack(alignment: .center, spacing: Tokens.Space.s) { if leadingReserve > 0 { Color.clear diff --git a/Sources/Contained/DesignSystem/PanelSection.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelSection.swift similarity index 72% rename from Sources/Contained/DesignSystem/PanelSection.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelSection.swift index f0c81c80..d8802fb0 100644 --- a/Sources/Contained/DesignSystem/PanelSection.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelSection.swift @@ -18,15 +18,15 @@ private extension EnvironmentValues { /// Supports two header affordances: `collapsible` (a chevron that folds the card away) and an `enabled` /// binding (a switch in the header that disables/hides the body — used for opt-in sections like the /// per-card customization blocks). -struct PanelSection: View { - var header: String? = nil - var footer: String? = nil - var rowSpacing: CGFloat = Tokens.Space.m - var collapsible: Bool = false +public struct PanelSection: View { + public var header: String? = nil + public var footer: String? = nil + public var rowSpacing: CGFloat = Tokens.Space.m + public var collapsible: Bool = false /// Subtle blue treatment for sections containing explicit non-default values. - var highlighted: Bool = false + public var highlighted: Bool = false /// When provided, the header shows a switch; turning it off hides the body (and footer). - var enabled: Binding? = nil + public var enabled: Binding? = nil @ViewBuilder var content: () -> Content @State private var collapsed = false @@ -38,7 +38,23 @@ struct PanelSection: View { private var hasHeader: Bool { header != nil || collapsible || enabled != nil } - var body: some View { + public init(header: String? = nil, + footer: String? = nil, + rowSpacing: CGFloat = Tokens.Space.m, + collapsible: Bool = false, + highlighted: Bool = false, + enabled: Binding? = nil, + @ViewBuilder content: @escaping () -> Content) { + self.header = header + self.footer = footer + self.rowSpacing = rowSpacing + self.collapsible = collapsible + self.highlighted = highlighted + self.enabled = enabled + self.content = content + } + + public var body: some View { VStack(alignment: .leading, spacing: Tokens.Space.s) { if hasHeader { headerRow } if !bodyHidden { @@ -103,11 +119,11 @@ struct PanelSection: View { /// A single settings row: a leading title (+ optional subtitle), optional info next to that title, /// and a trailing control. Set `error` to tint the title red and show a red caption beneath. -struct PanelRow: View { - var title: String - var subtitle: String? = nil - var info: String? = nil - var error: String? = nil +public struct PanelRow: View { + public var title: String + public var subtitle: String? = nil + public var info: String? = nil + public var error: String? = nil @ViewBuilder var trailing: () -> Trailing @Environment(\.panelSectionHighlighted) private var sectionHighlighted @@ -118,7 +134,19 @@ struct PanelRow: View { return sectionHighlighted ? .accentColor : .primary } - var body: some View { + public init(title: String, + subtitle: String? = nil, + info: String? = nil, + error: String? = nil, + @ViewBuilder trailing: @escaping () -> Trailing) { + self.title = title + self.subtitle = subtitle + self.info = info + self.error = error + self.trailing = trailing + } + + public var body: some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: Tokens.Space.m) { VStack(alignment: .leading, spacing: 1) { @@ -144,21 +172,33 @@ struct PanelRow: View { } } -extension PanelRow where Trailing == EmptyView { +public extension PanelRow where Trailing == EmptyView { init(title: String, subtitle: String? = nil, info: String? = nil, error: String? = nil) { self.init(title: title, subtitle: subtitle, info: info, error: error) { EmptyView() } } } /// A switch row — the common Toggle case, rendered label-left / switch-right like a grouped Form. -struct PanelToggleRow: View { - var title: String - var subtitle: String? = nil - var info: String? = nil - var error: String? = nil +public struct PanelToggleRow: View { + public var title: String + public var subtitle: String? = nil + public var info: String? = nil + public var error: String? = nil @Binding var isOn: Bool - var body: some View { + public init(title: String, + subtitle: String? = nil, + info: String? = nil, + error: String? = nil, + isOn: Binding) { + self.title = title + self.subtitle = subtitle + self.info = info + self.error = error + self._isOn = isOn + } + + public var body: some View { PanelRow(title: title, subtitle: subtitle, info: info, error: error) { Toggle("", isOn: $isOn).labelsHidden().toggleStyle(.switch) } @@ -169,11 +209,11 @@ struct PanelToggleRow: View { /// The form-row counterpart to `PanelRow` (which hugs its trailing /// control); here the control fills the remaining width like a grouped Form field. `error` tints the /// label red and shows a red caption beneath. -struct PanelField: View { - var label: String - var info: String? = nil - var error: String? = nil - var labelWidth: CGFloat = 124 +public struct PanelField: View { + public var label: String + public var info: String? = nil + public var error: String? = nil + public var labelWidth: CGFloat = 124 @ViewBuilder var control: () -> Control @Environment(\.panelSectionHighlighted) private var sectionHighlighted @@ -184,7 +224,19 @@ struct PanelField: View { return sectionHighlighted ? .accentColor : .primary } - var body: some View { + public init(label: String, + info: String? = nil, + error: String? = nil, + labelWidth: CGFloat = 124, + @ViewBuilder control: @escaping () -> Control) { + self.label = label + self.info = info + self.error = error + self.labelWidth = labelWidth + self.control = control + } + + public var body: some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: Tokens.Space.m) { HStack(spacing: Tokens.Space.xs) { diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift new file mode 100644 index 00000000..5a99766b --- /dev/null +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift @@ -0,0 +1,333 @@ +import SwiftUI + +/// A reusable three-part card header: leading accessory, fill/truncate text block, and trailing +/// button rail. This keeps the container/image cards using the same top-aligned chrome structure. +public struct ResourceCardHeader: View { + public var spacing: CGFloat + public var padding: CGFloat + @ViewBuilder public var leading: () -> Leading + @ViewBuilder public var content: () -> Content + @ViewBuilder public var trailing: () -> Trailing + + public init(spacing: CGFloat = Tokens.ResourceCard.padding, + padding: CGFloat = Tokens.ResourceCard.padding, + @ViewBuilder leading: @escaping () -> Leading, + @ViewBuilder content: @escaping () -> Content, + @ViewBuilder trailing: @escaping () -> Trailing) { + self.spacing = spacing + self.padding = padding + self.leading = leading + self.content = content + self.trailing = trailing + } + + public var body: some View { + HStack(alignment: .top, spacing: spacing) { + leading() + content() + .frame(maxWidth: .infinity, alignment: .leading) + trailing() + } + .padding(padding) + } +} + +/// A small reusable footer mini: optional icon + optional text, aligned on one baseline. +public struct ResourceCardFooterMini: View { + public var spacing: CGFloat + @ViewBuilder public var icon: () -> Icon + @ViewBuilder public var text: () -> TextContent + + public init(spacing: CGFloat = Tokens.Space.xs, + @ViewBuilder icon: @escaping () -> Icon, + @ViewBuilder text: @escaping () -> TextContent) { + self.spacing = spacing + self.icon = icon + self.text = text + } + + public var body: some View { + HStack(alignment: .center, spacing: spacing) { + icon() + text() + .fixedSize(horizontal: true, vertical: false) + } + .fixedSize(horizontal: true, vertical: false) + } +} + +/// A reusable footer item band that hugs its content and anchors either left or right. +public struct ResourceCardFooterGroup: View { + public enum Alignment { + case leading, trailing + } + + public var alignment: Alignment + public var spacing: CGFloat + @ViewBuilder public var content: () -> Content + + public init(alignment: Alignment = .leading, + spacing: CGFloat = Tokens.ResourceCard.padding, + @ViewBuilder content: @escaping () -> Content) { + self.alignment = alignment + self.spacing = spacing + self.content = content + } + + public var body: some View { + HStack(spacing: spacing) { content() } + .frame(maxWidth: .infinity, + alignment: alignment == .leading ? .leading : .trailing) + } +} + +/// A reusable footer band with a left group, right group, and optional widget stacked above them. +public struct ResourceCardFooter: View { + public var showWidget: Bool + public var actionsVisible: Bool + public var spacing: CGFloat + public var horizontalPadding: CGFloat + public var topPadding: CGFloat + public var bottomPadding: CGFloat + @ViewBuilder public var leading: () -> Leading + @ViewBuilder public var trailing: () -> Trailing + @ViewBuilder public var widget: () -> Widget + + public init(showWidget: Bool = false, + actionsVisible: Bool = true, + spacing: CGFloat = Tokens.ResourceCard.padding, + horizontalPadding: CGFloat = Tokens.ResourceCard.padding, + topPadding: CGFloat = 0, + bottomPadding: CGFloat = Tokens.ResourceCard.padding, + @ViewBuilder leading: @escaping () -> Leading, + @ViewBuilder trailing: @escaping () -> Trailing, + @ViewBuilder widget: @escaping () -> Widget) { + self.showWidget = showWidget + self.actionsVisible = actionsVisible + self.spacing = spacing + self.horizontalPadding = horizontalPadding + self.topPadding = topPadding + self.bottomPadding = bottomPadding + self.leading = leading + self.trailing = trailing + self.widget = widget + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + if showWidget { + widget() + } + HStack(spacing: spacing) { + ResourceCardFooterGroup(alignment: .leading, spacing: spacing) { + leading() + } + ResourceCardFooterGroup(alignment: .trailing, spacing: spacing) { + trailing() + } + .opacity(actionsVisible ? 1 : 0) + .allowsHitTesting(actionsVisible) + .animation(.easeOut(duration: 0.18), value: actionsVisible) + } + .padding(.horizontal, horizontalPadding) + .padding(.top, topPadding) + .padding(.bottom, bottomPadding) + } + } +} + +public struct ResourceCardTitleText: View { + public let text: String + + public init(text: String) { + self.text = text + } + + public var body: some View { + Text(text) + .font(.callout.weight(.medium)) + .lineLimit(1) + } +} + +public struct ResourceCardSubtitleText: View { + public let text: String + + public init(text: String) { + self.text = text + } + + public var body: some View { + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } +} + +public struct ResourceCardMonospacedSubtitleText: View { + public let text: String + + public init(text: String) { + self.text = text + } + + public var body: some View { + Text(text) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } +} + +public struct ResourceCardMonospacedTitleText: View { + public let text: String + + public init(text: String) { + self.text = text + } + + public var body: some View { + Text(text) + .font(.system(.callout, design: .monospaced).weight(.medium)) + .lineLimit(1) + } +} + +public struct ResourceCardIconChip: View { + public var symbol: String + public var tint: Color + public var symbolFont: Font + public var backgroundOpacity: Double + + public init(symbol: String, + tint: Color = .secondary, + symbolFont: Font = .title3, + backgroundOpacity: Double = Tokens.ResourceCard.iconBackgroundOpacity) { + self.symbol = symbol + self.tint = tint + self.symbolFont = symbolFont + self.backgroundOpacity = backgroundOpacity + } + + public var body: some View { + Image(systemName: symbol) + .font(symbolFont) + .foregroundStyle(tint) + .frame(width: Tokens.IconSize.chip, height: Tokens.IconSize.chip) + .background(tint.opacity(backgroundOpacity), + in: RoundedRectangle(cornerRadius: Tokens.Radius.iconChip, style: .continuous)) + } +} + +/// Small capsule count/state badge used in section headers and compact metadata rows. +public struct ResourceBadgeText: View { + public let text: String + public var font: Font + public var foreground: Color + + public init(text: String, + font: Font = .caption.weight(.medium), + foreground: Color = .secondary) { + self.text = text + self.font = font + self.foreground = foreground + } + + public var body: some View { + Text(text) + .font(font) + .foregroundStyle(foreground) + .padding(.horizontal, Tokens.Space.s) + .padding(.vertical, Tokens.Badge.verticalPadding) + .background(.quaternary, in: Capsule()) + } +} + +/// Flat glass row for selectable lists inside panels and sheets. +public struct GlassListRow: View { + public var symbol: String + public var tint: Color + public var title: String + public var subtitle: String? + public var monospacedSubtitle: Bool + @ViewBuilder public var accessory: () -> Accessory + + public init(symbol: String, + tint: Color = .accentColor, + title: String, + subtitle: String?, + monospacedSubtitle: Bool = true, + @ViewBuilder accessory: @escaping () -> Accessory) { + self.symbol = symbol + self.tint = tint + self.title = title + self.subtitle = subtitle + self.monospacedSubtitle = monospacedSubtitle + self.accessory = accessory + } + + public var body: some View { + HStack(spacing: Tokens.Space.s) { + Image(systemName: symbol) + .font(.callout) + .foregroundStyle(tint) + .frame(width: Tokens.IconSize.rowMenu) + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + Text(title) + .font(.callout.weight(.medium)) + .lineLimit(1) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(monospacedSubtitle ? .system(.caption, design: .monospaced) : .caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: Tokens.Space.s) + accessory() + } + .padding(.horizontal, Tokens.Space.m) + .padding(.vertical, Tokens.Space.s) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .glassSurface(.ultraThin, cornerRadius: Tokens.Radius.control) + } +} + +public struct GlassListRowChevron: View { + public init() {} + + public var body: some View { + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } +} + +public extension GlassListRow where Accessory == GlassListRowChevron { + init(symbol: String, tint: Color = .accentColor, title: String, subtitle: String?, + monospacedSubtitle: Bool = true) { + self.init(symbol: symbol, + tint: tint, + title: title, + subtitle: subtitle, + monospacedSubtitle: monospacedSubtitle) { + GlassListRowChevron() + } + } +} + +public struct ResourceCardMetricText: View { + public let text: String + + public init(text: String) { + self.text = text + } + + public var body: some View { + Text(text) + .font(.caption.weight(.medium)) + .monospacedDigit() + } +} diff --git a/Sources/Contained/DesignSystem/ResourceGlassCard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift similarity index 94% rename from Sources/Contained/DesignSystem/ResourceGlassCard.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift index e858a1b4..2e3e7dad 100644 --- a/Sources/Contained/DesignSystem/ResourceGlassCard.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift @@ -1,20 +1,24 @@ import SwiftUI -enum ResourceCardSize { +public enum ResourceCardSize { case small, medium, large - var showsFooter: Bool { self != .small } - var showsWidget: Bool { self == .large } + public var showsFooter: Bool { self != .small } + public var showsWidget: Bool { self == .large } } -enum ResourceCardExpandedMetrics { - static let maxWidth: CGFloat = 760 +public enum ResourceCardExpandedMetrics { + public static let maxWidth: CGFloat = 760 } -struct CardSizePicker: View { +public struct CardSizePicker: View { @Binding var selection: CardDensity - var body: some View { + public init(selection: Binding) { + self._selection = selection + } + + public var body: some View { Picker("Card size", selection: $selection) { ForEach(CardDensity.allCases) { density in Text(density.displayName).tag(density) @@ -25,7 +29,7 @@ struct CardSizePicker: View { } } -struct ResourceGlassCard: View { var size: ResourceCardSize var isExpanded = false @@ -54,13 +58,13 @@ struct ResourceGlassCard Self { + public func selectionFill(_ on: Bool = true) -> Self { var copy = self copy.usesSelectionFill = on return copy } - init(size: ResourceCardSize, + public init(size: ResourceCardSize, isExpanded: Bool = false, cornerRadiusOverride: CGFloat? = nil, controlsVisible: Bool = true, @@ -96,7 +100,7 @@ struct ResourceGlassCard: View { - let title: String - var subtitle: String? = nil - var cancelIcon: String = "xmark" - var cancelHelp: String = "Cancel" - let onCancel: () -> Void +public struct SheetHeader: View { + public let title: String + public var subtitle: String? = nil + public var cancelIcon: String = "xmark" + public var cancelHelp: String = "Cancel" + public let onCancel: () -> Void @ViewBuilder var trailing: () -> Trailing - var body: some View { + public init(title: String, + subtitle: String? = nil, + cancelIcon: String = "xmark", + cancelHelp: String = "Cancel", + onCancel: @escaping () -> Void, + @ViewBuilder trailing: @escaping () -> Trailing) { + self.title = title + self.subtitle = subtitle + self.cancelIcon = cancelIcon + self.cancelHelp = cancelHelp + self.onCancel = onCancel + self.trailing = trailing + } + + public var body: some View { HStack { VStack(alignment: .leading, spacing: 1) { Text(title).font(.headline).lineLimit(1) @@ -30,7 +44,7 @@ struct SheetHeader: View { } } -extension SheetHeader where Trailing == EmptyView { +public extension SheetHeader where Trailing == EmptyView { /// Header with only a cancel/close button (no primary action). init(title: String, subtitle: String? = nil, cancelIcon: String = "xmark", cancelHelp: String = "Cancel", onCancel: @escaping () -> Void) { diff --git a/Sources/Contained/DesignSystem/StreamConsole.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift similarity index 89% rename from Sources/Contained/DesignSystem/StreamConsole.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift index 2dcd3d36..dcd3668e 100644 --- a/Sources/Contained/DesignSystem/StreamConsole.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift @@ -1,12 +1,11 @@ import SwiftUI -import ContainedCore /// A scrolling console that consumes a one-shot streaming command (pull / build `--progress plain`) /// to completion, auto-scrolling and reporting success/failure. Shared by the pull and build flows. -struct StreamConsole: View { +public struct StreamConsole: View { /// Factory so the stream starts with the view's `.task` (and cancels on disappear). - let stream: () -> AsyncThrowingStream - var onComplete: (Bool) -> Void = { _ in } + public let stream: () -> AsyncThrowingStream + public var onComplete: (Bool) -> Void = { _ in } enum RunState: Equatable { case running, done, failed(String) } @@ -16,7 +15,13 @@ struct StreamConsole: View { private let maxLines = 8000 private let bottomID = "console-bottom" - var body: some View { + public init(stream: @escaping () -> AsyncThrowingStream, + onComplete: @escaping (Bool) -> Void = { _ in }) { + self.stream = stream + self.onComplete = onComplete + } + + public var body: some View { VStack(spacing: 0) { statusBar Divider() @@ -79,7 +84,7 @@ struct StreamConsole: View { // View dismissed mid-stream; nothing to report. } catch { if !carry.isEmpty { lines.append(carry); carry = "" } - state = .failed((error as? CommandError)?.userMessage ?? error.localizedDescription) + state = .failed(error.localizedDescription) onComplete(false) } } diff --git a/Sources/Contained/DesignSystem/Theme.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme.swift similarity index 57% rename from Sources/Contained/DesignSystem/Theme.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme.swift index 12bc0ba1..2a024cb4 100644 --- a/Sources/Contained/DesignSystem/Theme.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme.swift @@ -1,153 +1,34 @@ import SwiftUI import AppKit -/// Design tokens — the single source of truth for spacing, radii, and type used across the app. -enum Tokens { - enum Radius { - /// Radius delta between nested glass levels: sheet → card → control → key cap. - static let step: CGFloat = 6 - static let control: CGFloat = 10 - static let card: CGFloat = 16 - static let sheet: CGFloat = 22 - static let keyCap: CGFloat = control - step - static let iconChip: CGFloat = control - - /// Radius for a shape inset inside a parent with the same corner center. - static func inset(from outer: CGFloat, by inset: CGFloat) -> CGFloat { - max(0, outer - inset) - } - } - enum Space { - static let xs: CGFloat = 4 - static let s: CGFloat = 8 - static let m: CGFloat = 12 - static let l: CGFloat = 16 - static let xl: CGFloat = 24 - static let xxl: CGFloat = 32 - } - enum CardSize { - // Generous max widths so the adaptive grid stretches the fitted columns to fill the row, - // rather than capping them tightly and leaving trailing dead space on wide windows. - static let compactMin: CGFloat = 230 - static let compactMax: CGFloat = 400 - static let largeMin: CGFloat = 300 - static let largeMax: CGFloat = 520 - } - /// Canonical sheet dimensions — pass to `.frame(Tokens.SheetSize.form)`. Replaces ad-hoc - /// `width:height:` literals so every sheet snaps to one of a few sizes. - enum SheetSize { - static let small = CGSize(width: 420, height: 280) // confirmations, short forms - static let form = CGSize(width: 560, height: 680) // the run/edit form - static let console = CGSize(width: 560, height: 540) // streamed-progress / logs - static let inspector = CGSize(width: 600, height: 560) // JSON inspector, history - static let wide = CGSize(width: 720, height: 560) // build workspace - } - /// Morph-panel dimensions shared by toolbar origins and panel content. - enum PanelSize { - // Global floor applied in MorphGeometry.fittedSize — panels never shrink below these or exceed - // the available window area (handled separately via margin clamping). The height floor is tiny - // so content-hugging panels can collapse close to their header when there's little to show. - static let minWidth: CGFloat = 300 - static let minHeight: CGFloat = 50 - - static let add = CGSize(width: 440, height: 300) - static let palette = CGSize(width: 560, height: 480) - static let updatesOrigin = CGSize(width: 440, height: 300) - static let images = CGSize(width: 520, height: 520) - static let imageDetail = CGSize(width: 560, height: 520) - static let activityOrigin = CGSize(width: 460, height: 360) - static let activity = CGSize(width: 560, height: 520) - static let templatesOrigin = CGSize(width: 440, height: 300) - static let templates = CGSize(width: 460, height: 480) - static let system = CGSize(width: 580, height: 600) - static let settings = CGSize(width: 560, height: 560) - } - /// Icon-button / chip dimensions used across menus and headers. - enum IconSize { - static let rowMenu: CGFloat = 22 // ellipsis row menus - static let control: CGFloat = 28 // sheet-header circle buttons - static let chip: CGFloat = 30 // small status chips - static let headerChip: CGFloat = 34 // detail-header chips - } - /// Fixed widths for compact form controls where stable alignment matters more than fluid sizing. - enum FormWidth { - static let shortReadout: CGFloat = 44 - static let memoryReadout: CGFloat = 64 - static let port: CGFloat = 70 - static let containerPort: CGFloat = 80 - static let userID: CGFloat = 90 - } - - /// The app toolbar band — custom (non-native) controls sized to macOS 26 Liquid Glass toolbar - /// proportions (tuned against Finder). `controlHeight` is shared by every band element (glass - /// button groups and the search field) so they align on one baseline; `groupRadius` is the - /// concentric capsule for them. Glyphs are a touch smaller than the capsule with horizontal glass - /// padding around them, matching the airy native look. - enum Toolbar { - // Exact spec: controls are 36pt tall (length hugs content), with 8pt of padding around the band - // (horizontal, top — matched below — and between groups), so the band is 8 + 36 + 8 = 52. - static let band: CGFloat = 52 // title-bar band height - static let controlHeight: CGFloat = 36 // glass groups + search field share this height - // Button glyphs use `.headline` + `.imageScale(.large)` (see ToolbarControls) so they scale - // with Dynamic Type — no fixed point size token. - static let iconInnerPadding: CGFloat = 4 // padding around the glyph inside the 28 item - static let buttonItemHeight: CGFloat = 28 - static let buttonGroupHeight: CGFloat = 36 - static let outerPadding: CGFloat = 8 // band inset from the window edges - // The toolbar now spans the whole window (no sidebar), so its leading edge must clear the - // traffic-light cluster (close/min/zoom ≈ 70pt) plus a little breathing room. - static let leadingInset: CGFloat = 80 // band inset on the left, past the traffic lights - static let trafficLightsWidth: CGFloat = 82 // close/min/zoom cluster width — the Settings slot min width - static let groupPaddingH: CGFloat = 0 // horizontal glass margin inside a group - static let groupSpacing: CGFloat = 8 // spacing between buttons / groups - static let searchMaxWidth: CGFloat = 380 - // Search field internals. - static let searchInnerPadding: CGFloat = iconInnerPadding * 2 // matches glass button edge inset - static let searchIconGap: CGFloat = 6 // gap between icon and text - static let searchOpenHeaderHeight: CGFloat = 48 // taller header row once the palette expands - // The search icon + text use the semantic `.body` style (13pt on macOS; text adds medium weight), - // so they scale with Dynamic Type — no fixed point size tokens. - /// Padding above the controls (and matched below) — the controls sit on the native toolbar line. - static let topPadding: CGFloat = 8 - static var groupRadius: CGFloat { controlHeight / 2 } // concentric capsule - } -} - -extension View { - /// Apply a canonical sheet size from `Tokens.SheetSize`. - func frame(_ size: CGSize) -> some View { - frame(width: size.width, height: size.height) - } -} - /// Material/elevation constants for reusable app surfaces. Keep glass, shadow, and stroke choices /// here so collapsed controls and expanded panels do not drift into near-duplicates. -enum AppMaterial { - static let toolbarHoverFill = Color.white.opacity(0.1) - static func toolbarInteractiveHoverFill(for colorScheme: ColorScheme) -> Color { +public enum AppMaterial { + public static let toolbarHoverFill = Color.white.opacity(0.1) + public static func toolbarInteractiveHoverFill(for colorScheme: ColorScheme) -> Color { Color.white.opacity(colorScheme == .light ? 0.2 : 0.1) } - static let floatingPanelStroke = Color.white.opacity(0.18) - static let floatingPanelShadow = Color.black.opacity(0.24) - static let floatingPanelShadowRadius: CGFloat = 24 - static let floatingPanelShadowY: CGFloat = 12 + public static let floatingPanelStroke = Color.white.opacity(0.18) + public static let floatingPanelShadow = Color.black.opacity(0.24) + public static let floatingPanelShadowRadius: CGFloat = 24 + public static let floatingPanelShadowY: CGFloat = 12 } /// A curated color, used identically for the app accent (Settings) and per-card personalization /// (icon + optional background wash) so the palette is consistent everywhere. `.multicolor` is the /// "follow the app accent" option: it resolves to `Color.accentColor`, which the root sets to the /// chosen accent tint — so a container left on `.multicolor` tracks whatever the app accent is. -enum AppTint: String, CaseIterable, Identifiable, Codable, Sendable { +public enum AppTint: String, CaseIterable, Identifiable, Codable, Sendable { case multicolor, graphite, azure, teal, coral, indigo, green, amber, pink - var id: String { rawValue } + public var id: String { rawValue } - var displayName: String { self == .multicolor ? "App Accent" : rawValue.capitalized } + public var displayName: String { self == .multicolor ? "App Accent" : rawValue.capitalized } /// True for the "follow the app accent" option (rendered with a marker in the swatch row). - var followsAppAccent: Bool { self == .multicolor } + public var followsAppAccent: Bool { self == .multicolor } - var color: Color { + public var color: Color { switch self { case .multicolor: return .accentColor case .graphite: return Color(red: 0.45, green: 0.46, blue: 0.50) @@ -163,7 +44,7 @@ enum AppTint: String, CaseIterable, Identifiable, Codable, Sendable { /// Common color words that should also surface this tint in search (e.g. typing "purple" finds /// `indigo`, "blue" finds `azure`). Keeps the curated palette discoverable under everyday names. - var searchAliases: [String] { + public var searchAliases: [String] { switch self { case .multicolor: return ["default", "app accent", "system", "auto", "rainbow"] case .graphite: return ["gray", "grey", "slate", "charcoal", "silver", "neutral", "mono"] @@ -178,18 +59,18 @@ enum AppTint: String, CaseIterable, Identifiable, Codable, Sendable { } /// Parse a legacy `contained.tint` label value, falling back to multicolor. - static func parse(_ raw: String?) -> AppTint { +public static func parse(_ raw: String?) -> AppTint { guard let raw, let tint = AppTint(rawValue: raw.lowercased()) else { return .multicolor } return tint } } -enum ColorLayerBlendMode: String, CaseIterable, Identifiable, Codable, Sendable { +public enum ColorLayerBlendMode: String, CaseIterable, Identifiable, Codable, Sendable { case normal, softLight, overlay, multiply, screen - var id: String { rawValue } + public var id: String { rawValue } - var displayName: String { + public var displayName: String { switch self { case .normal: return "Normal" case .softLight: return "Soft Light" @@ -199,7 +80,7 @@ enum ColorLayerBlendMode: String, CaseIterable, Identifiable, Codable, Sendable } } - var blendMode: BlendMode { + public var blendMode: BlendMode { switch self { case .normal: return .normal case .softLight: return .softLight @@ -210,11 +91,11 @@ enum ColorLayerBlendMode: String, CaseIterable, Identifiable, Codable, Sendable } } -enum AppearanceMode: String, CaseIterable, Identifiable, Codable, Sendable { +public enum AppearanceMode: String, CaseIterable, Identifiable, Codable, Sendable { case system, light, dark - var id: String { rawValue } - var displayName: String { rawValue.capitalized } - var colorScheme: ColorScheme? { + public var id: String { rawValue } + public var displayName: String { rawValue.capitalized } + public var colorScheme: ColorScheme? { switch self { case .system: return nil case .light: return .light @@ -225,7 +106,7 @@ enum AppearanceMode: String, CaseIterable, Identifiable, Codable, Sendable { /// The AppKit appearance to force on the app. `nil` for `.system` releases the override so the app /// tracks the live OS appearance — `.preferredColorScheme(nil)` alone doesn't reliably re-sync a /// window that was previously pinned, so we set `NSApplication.appearance` directly. - var nsAppearance: NSAppearance? { + public var nsAppearance: NSAppearance? { switch self { case .system: return nil case .light: return NSAppearance(named: .aqua) @@ -234,11 +115,11 @@ enum AppearanceMode: String, CaseIterable, Identifiable, Codable, Sendable { } } -enum CardDensity: String, CaseIterable, Identifiable, Codable, Sendable { +public enum CardDensity: String, CaseIterable, Identifiable, Codable, Sendable { case small, medium, large - var id: String { rawValue } - var displayName: String { rawValue.capitalized } - var resourceSize: ResourceCardSize { + public var id: String { rawValue } + public var displayName: String { rawValue.capitalized } + public var resourceSize: ResourceCardSize { switch self { case .small: return .small case .medium: return .medium @@ -246,7 +127,7 @@ enum CardDensity: String, CaseIterable, Identifiable, Codable, Sendable { } } - init(stored raw: String?) { + public init(stored raw: String?) { if raw == "compact" { self = .medium } else { @@ -257,7 +138,7 @@ enum CardDensity: String, CaseIterable, Identifiable, Codable, Sendable { /// The behind-window vibrancy material used for the main content area. A curated, ordered subset of /// `NSVisualEffectView.Material` (lightest → most opaque) so the picker reads sensibly. -enum WindowMaterial: String, CaseIterable, Identifiable, Codable, Sendable { +public enum WindowMaterial: String, CaseIterable, Identifiable, Codable, Sendable { // Liquid Glass options (rendered with `.glassEffect`, not an `NSVisualEffectView`). case glassClear, glassRegular // System vibrancy materials. @@ -265,9 +146,9 @@ enum WindowMaterial: String, CaseIterable, Identifiable, Codable, Sendable { windowBackground, contentBackground, sidebar, headerView, titlebar, sheet, popover, menu, selection, hudWindow, toolTip - var id: String { rawValue } + public var id: String { rawValue } - var displayName: String { + public var displayName: String { switch self { case .glassClear: return "Glass (Clear)" case .glassRegular: return "Glass (Regular)" @@ -289,10 +170,10 @@ enum WindowMaterial: String, CaseIterable, Identifiable, Codable, Sendable { } /// True for the Liquid Glass options, which render via `.glassEffect` rather than vibrancy. - var isGlass: Bool { self == .glassClear || self == .glassRegular } + public var isGlass: Bool { self == .glassClear || self == .glassRegular } /// The Liquid Glass variant for the glass cases (nil for vibrancy materials). - var glass: Glass? { + public var glass: Glass? { switch self { case .glassClear: return .clear case .glassRegular: return .regular @@ -302,7 +183,7 @@ enum WindowMaterial: String, CaseIterable, Identifiable, Codable, Sendable { /// The vibrancy material. Glass cases fall back to a sensible default for the rare place that /// needs a behind-window material (e.g. the root content backing, which can't be glass). - var nsMaterial: NSVisualEffectView.Material { + public var nsMaterial: NSVisualEffectView.Material { switch self { case .glassClear, .glassRegular: return .fullScreenUI case .fullScreenUI: return .fullScreenUI @@ -323,7 +204,7 @@ enum WindowMaterial: String, CaseIterable, Identifiable, Codable, Sendable { } } -extension EnvironmentValues { +public extension EnvironmentValues { /// The user-chosen modal material, seeded at the app root and inherited by presented sheets. @Entry var modalMaterial: WindowMaterial = .sheet /// The user-chosen toolbar-control (button) material, seeded at the app root. @@ -350,7 +231,7 @@ private struct SheetMaterial: ViewModifier { } } -extension View { +public extension View { /// Standard sheet background — the user-chosen modal material (read from the environment). /// Replaces ad-hoc `.background(.regularMaterial)` so every sheet honors the setting. func sheetMaterial() -> some View { modifier(SheetMaterial()) } @@ -417,7 +298,7 @@ private struct ToolbarControlMaterial: ViewModifier { } } -extension View { +public extension View { /// In-window floating panel material. Unlike `.sheet`, this samples the live app content instead /// of the dimmed system-modal backdrop, so thin materials actually read thin. func floatingPanelMaterial(cornerRadius: CGFloat = Tokens.Radius.sheet, diff --git a/Sources/Contained/DesignSystem/TintSelector.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/TintSelector.swift similarity index 93% rename from Sources/Contained/DesignSystem/TintSelector.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/TintSelector.swift index 7d6e2a69..8edf2171 100644 --- a/Sources/Contained/DesignSystem/TintSelector.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/TintSelector.swift @@ -2,11 +2,11 @@ import SwiftUI /// A row of colored swatches for picking an `AppTint` — each shows its actual color, the selected /// one gets a ring. -struct TintSelector: View { +public struct TintSelector: View { private let selection: Binding private let automaticLabel: String? - init(selection: Binding) { + public init(selection: Binding) { self.selection = Binding( get: { selection.wrappedValue }, set: { if let newValue = $0 { selection.wrappedValue = newValue } } @@ -14,12 +14,12 @@ struct TintSelector: View { self.automaticLabel = nil } - init(optionalSelection: Binding, automaticLabel: String) { + public init(optionalSelection: Binding, automaticLabel: String) { self.selection = optionalSelection self.automaticLabel = automaticLabel } - var body: some View { + public var body: some View { HStack(spacing: Tokens.Space.s) { if let automaticLabel { Button { selection.wrappedValue = nil } label: { automaticSwatch } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens.swift new file mode 100644 index 00000000..66bbf5c5 --- /dev/null +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens.swift @@ -0,0 +1,200 @@ +import SwiftUI + +/// Design tokens — the single source of truth for spacing, radii, and type used across the app. +public enum Tokens { + public enum Radius { + /// Radius delta between nested glass levels: sheet -> card -> control -> key cap. + public static let step: CGFloat = 6 + public static let control: CGFloat = 10 + public static let card: CGFloat = 16 + public static let sheet: CGFloat = 22 + public static let keyCap: CGFloat = control - step + public static let iconChip: CGFloat = control + + /// Radius for a shape inset inside a parent with the same corner center. + public static func inset(from outer: CGFloat, by inset: CGFloat) -> CGFloat { + max(0, outer - inset) + } + } + + public enum Space { + public static let hairline: CGFloat = 1 + public static let xxs: CGFloat = 2 + public static let xs: CGFloat = 4 + public static let s: CGFloat = 8 + public static let m: CGFloat = 12 + public static let l: CGFloat = 16 + public static let xl: CGFloat = 24 + public static let xxl: CGFloat = 32 + } + + public enum CardSize { + // Generous max widths so the adaptive grid stretches the fitted columns to fill the row, + // rather than capping them tightly and leaving trailing dead space on wide windows. + public static let compactMin: CGFloat = 230 + public static let compactMax: CGFloat = 400 + public static let largeMin: CGFloat = 300 + public static let largeMax: CGFloat = 520 + } + + /// Canonical sheet dimensions — pass to `.frame(Tokens.SheetSize.form)`. Replaces ad-hoc + /// `width:height:` literals so every sheet snaps to one of a few sizes. + public enum SheetSize { + public static let small = CGSize(width: 420, height: 280) // confirmations, short forms + public static let form = CGSize(width: 560, height: 680) // the run/edit form + public static let console = CGSize(width: 560, height: 540) // streamed-progress / logs + public static let inspector = CGSize(width: 600, height: 560) // JSON inspector, history + public static let releaseNotes = CGSize(width: 620, height: 520) + public static let wide = CGSize(width: 720, height: 560) // build workspace + public static let dialogWidth: CGFloat = 460 + } + + /// Morph-panel dimensions shared by toolbar origins and panel content. + public enum PanelSize { + // Global floor applied in MorphGeometry.fittedSize — panels never shrink below these or exceed + // the available window area (handled separately via margin clamping). The height floor is tiny + // so content-hugging panels can collapse close to their header when there's little to show. + public static let minWidth: CGFloat = 300 + public static let minHeight: CGFloat = 50 + + public static let add = CGSize(width: 440, height: 300) + public static let palette = CGSize(width: 560, height: 480) + public static let updatesOrigin = CGSize(width: 440, height: 300) + public static let images = CGSize(width: 520, height: 520) + public static let imageDetail = CGSize(width: 560, height: 520) + public static let imageTag = CGSize(width: 560, height: 360) + public static let activityOrigin = CGSize(width: 460, height: 360) + public static let activity = CGSize(width: 560, height: 520) + public static let templatesOrigin = CGSize(width: 440, height: 300) + public static let templates = CGSize(width: 460, height: 480) + public static let system = CGSize(width: 580, height: 600) + public static let settings = CGSize(width: 560, height: 560) + } + + /// Icon-button / chip dimensions used across menus and headers. + public enum IconSize { + public static let statusDot: CGFloat = 8 + public static let serviceDot: CGFloat = 9 + public static let rowIconColumn: CGFloat = 20 + public static let rowMenu: CGFloat = 22 // ellipsis row menus + public static let control: CGFloat = 28 // sheet-header circle buttons + public static let chip: CGFloat = 30 // small status chips + public static let headerChip: CGFloat = 34 // detail-header chips + public static let appIcon: CGFloat = 56 + } + + /// Fixed widths for compact form controls where stable alignment matters more than fluid sizing. + public enum FormWidth { + public static let shortReadout: CGFloat = 44 + public static let memoryReadout: CGFloat = 64 + public static let port: CGFloat = 70 + public static let containerPort: CGFloat = 80 + public static let userID: CGFloat = 90 + public static let shellPicker: CGFloat = 140 + public static let compactSlider: CGFloat = 140 + public static let networkName: CGFloat = 180 + public static let tintColorHex: CGFloat = 220 + public static let refreshReadout: CGFloat = 32 + } + + public enum ResourceCard { + public static let padding: CGFloat = 10 + public static let compactTextSpacing: CGFloat = Space.hairline + public static let detailTextSpacing: CGFloat = Space.xxs + public static let footerDividerHeight: CGFloat = Space.l + public static let sparklineHeight: CGFloat = 58 + public static let iconBackgroundOpacity: Double = 0.16 + public static let iconSelectedBackgroundOpacity: Double = 0.24 + public static let iconEmphasisBackgroundOpacity: Double = 0.22 + public static let plainFillOpacity: Double = 0.18 + public static let selectedSubtleFillOpacity: Double = 0.10 + public static let selectedResourceFillOpacity: Double = 0.12 + public static let selectedTintFillOpacity: Double = 0.18 + public static let selectedPersonalizedFillOpacity: Double = 0.14 + } + + public enum Chart { + public static let height: CGFloat = 140 + public static let axisDesiredCount = 4 + public static let areaOpacity: Double = 0.30 + public static let emptyHeight: CGFloat = 200 + } + + public enum Badge { + public static let compactHorizontalPadding: CGFloat = 7 + public static let horizontalPadding: CGFloat = Space.s + public static let verticalPadding: CGFloat = Space.xxs + public static let scopeVerticalPadding: CGFloat = 3 + public static let accentOpacity: Double = 0.16 + public static let statusOpacity: Double = 0.14 + } + + public enum Keyboard { + public static let keyHorizontalPadding: CGFloat = 5 + public static let keyVerticalPadding: CGFloat = Space.xxs + } + + public enum Terminal { + public static let surfaceOpacity: Double = 0.22 + public static let nativeBackgroundOpacity: CGFloat = 0.82 + public static let nativeForegroundWhite: CGFloat = 0.92 + public static let fontSize: CGFloat = 12 + } + + public enum InlineControl { + public static let gradientDial: CGFloat = 36 + public static let gradientReadout: CGFloat = 40 + public static let gradientKnob: CGFloat = 7 + public static let gradientKnobInset: CGFloat = Space.xs + public static let gradientStrokeOpacity: Double = 0.4 + public static let subtleTileOpacity: Double = 0.25 + } + + public enum MenuBar { + public static let width: CGFloat = 340 + public static let titleWidth: CGFloat = 78 + public static let padding: CGFloat = 14 + } + + /// The app toolbar band — custom (non-native) controls sized to macOS 26 Liquid Glass toolbar + /// proportions (tuned against Finder). `controlHeight` is shared by every band element (glass + /// button groups and the search field) so they align on one baseline; `groupRadius` is the + /// concentric capsule for them. Glyphs are a touch smaller than the capsule with horizontal glass + /// padding around them, matching the airy native look. + public enum Toolbar { + // Exact spec: controls are 36pt tall (length hugs content), with 8pt of padding around the band + // (horizontal, top — matched below — and between groups), so the band is 8 + 36 + 8 = 52. + public static let band: CGFloat = 52 // title-bar band height + public static let controlHeight: CGFloat = 36 // glass groups + search field share this height + // Button glyphs use `.headline` + `.imageScale(.large)` (see ToolbarControls) so they scale + // with Dynamic Type — no fixed point size token. + public static let iconInnerPadding: CGFloat = 4 // padding around the glyph inside the 28 item + public static let buttonItemHeight: CGFloat = 28 + public static var iconContentWidth: CGFloat { buttonItemHeight - iconInnerPadding * 2 } + public static var statusLabelTrailingPadding: CGFloat { iconInnerPadding * 2 } + public static let buttonGroupHeight: CGFloat = 36 + public static let outerPadding: CGFloat = 8 // band inset from the window edges + // Space reserved when custom toolbar chrome needs to mirror the traffic-light cluster. + public static let leadingInset: CGFloat = 80 + public static let trafficLightsWidth: CGFloat = 82 // close/min/zoom cluster width — the Settings slot min width + public static let groupPaddingH: CGFloat = 0 // horizontal glass margin inside a group + public static let groupSpacing: CGFloat = 8 // spacing between buttons / groups + public static let searchMaxWidth: CGFloat = 380 + // Search field internals. + public static let searchInnerPadding: CGFloat = iconInnerPadding * 2 // matches glass button edge inset + public static let searchIconGap: CGFloat = 6 // gap between icon and text + public static let searchOpenHeaderHeight: CGFloat = 48 // taller header row once the palette expands + // The search icon + text use the semantic `.body` style (13pt on macOS; text adds medium weight), + // so they scale with Dynamic Type — no fixed point size tokens. + /// Padding above the controls (and matched below) — the controls sit on the native toolbar line. + public static let topPadding: CGFloat = 8 + public static var groupRadius: CGFloat { controlHeight / 2 } // concentric capsule + } +} + +public extension View { + /// Apply a canonical sheet size from `Tokens.SheetSize`. + func frame(_ size: CGSize) -> some View { + frame(width: size.width, height: size.height) + } +} diff --git a/Sources/Contained/DesignSystem/ToolbarControls.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift similarity index 70% rename from Sources/Contained/DesignSystem/ToolbarControls.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift index 0011cc23..3e90919a 100644 --- a/Sources/Contained/DesignSystem/ToolbarControls.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift @@ -6,12 +6,18 @@ import SwiftUI /// A toolbar-styled menu trigger that uses the same icon sizing as `GlassButtonItem` but keeps /// the actual menu behavior native. -struct ToolbarMenuButton: View { - let systemName: String - var help: String = "" - @ViewBuilder var content: () -> Content +public struct ToolbarMenuButton: View { + public let systemName: String + public var help: String + @ViewBuilder public var content: () -> Content - var body: some View { + public init(systemName: String, help: String = "", @ViewBuilder content: @escaping () -> Content) { + self.systemName = systemName + self.help = help + self.content = content + } + + public var body: some View { Menu { content() } label: { @@ -31,13 +37,20 @@ struct ToolbarMenuButton: View { /// Shared two-line toolbar label used by page switchers and filter menus. /// The second line is always secondary so status/filter copy stays visually subordinate. -struct ToolbarTitleSubtitleLabel: View { - let symbol: String - let title: String - let subtitle: String - var showsChevron = true +public struct ToolbarTitleSubtitleLabel: View { + public let symbol: String + public let title: String + public let subtitle: String + public var showsChevron: Bool + + public init(symbol: String, title: String, subtitle: String, showsChevron: Bool = true) { + self.symbol = symbol + self.title = title + self.subtitle = subtitle + self.showsChevron = showsChevron + } - var body: some View { + public var body: some View { HStack(spacing: Tokens.Toolbar.searchIconGap) { Image(systemName: symbol) .font(.body) diff --git a/Sources/Contained/DesignSystem/VisualEffectBackground.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/VisualEffectBackground.swift similarity index 50% rename from Sources/Contained/DesignSystem/VisualEffectBackground.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/VisualEffectBackground.swift index 62628f5f..09c326cb 100644 --- a/Sources/Contained/DesignSystem/VisualEffectBackground.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/VisualEffectBackground.swift @@ -3,11 +3,17 @@ import AppKit /// Behind-window vibrancy so the desktop shows through the content area (blurred). No SwiftUI /// equivalent for `.behindWindow` blending — flagged AppKit bridge. -struct VisualEffectBackground: NSViewRepresentable { - var material: NSVisualEffectView.Material = .fullScreenUI - var blendingMode: NSVisualEffectView.BlendingMode = .behindWindow +public struct VisualEffectBackground: NSViewRepresentable { + public var material: NSVisualEffectView.Material + public var blendingMode: NSVisualEffectView.BlendingMode - func makeNSView(context: Context) -> NSVisualEffectView { + public init(material: NSVisualEffectView.Material = .fullScreenUI, + blendingMode: NSVisualEffectView.BlendingMode = .behindWindow) { + self.material = material + self.blendingMode = blendingMode + } + + public func makeNSView(context: Context) -> NSVisualEffectView { let view = NSVisualEffectView() view.blendingMode = blendingMode view.state = .active @@ -15,7 +21,7 @@ struct VisualEffectBackground: NSViewRepresentable { return view } - func updateNSView(_ view: NSVisualEffectView, context: Context) { + public func updateNSView(_ view: NSVisualEffectView, context: Context) { view.blendingMode = blendingMode view.material = material } @@ -24,10 +30,14 @@ struct VisualEffectBackground: NSViewRepresentable { /// Stable root-owned backing for the detail column. Pages render above this layer instead of /// applying their own window material. Translucency is always on — legibility under low-contrast /// wallpapers is left to the OS "Reduce transparency" accessibility setting. -struct ContentBackgroundLayer: View { - var material: NSVisualEffectView.Material = .fullScreenUI +public struct ContentBackgroundLayer: View { + public var material: NSVisualEffectView.Material + + public init(material: NSVisualEffectView.Material = .fullScreenUI) { + self.material = material + } - var body: some View { + public var body: some View { VisualEffectBackground(material: material) .ignoresSafeArea() } diff --git a/Packages/ContainedNavigation/Package.swift b/Packages/ContainedNavigation/Package.swift new file mode 100644 index 00000000..0cbfdb5d --- /dev/null +++ b/Packages/ContainedNavigation/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "ContainedNavigation", + platforms: [.macOS(.v26)], + products: [ + .library(name: "ContainedNavigation", targets: ["ContainedNavigation"]), + ], + dependencies: [ + .package(path: "../ContainedDesignSystem"), + ], + targets: [ + .target( + name: "ContainedNavigation", + dependencies: [ + .product(name: "ContainedDesignSystem", package: "ContainedDesignSystem"), + ] + ), + ] +) diff --git a/Packages/ContainedNavigation/README.md b/Packages/ContainedNavigation/README.md new file mode 100644 index 00000000..c971a085 --- /dev/null +++ b/Packages/ContainedNavigation/README.md @@ -0,0 +1,137 @@ +# ContainedNavigation + +`ContainedNavigation` is the local Swift package that owns reusable navigation +and layout infrastructure for Contained's window chrome. + +It depends on `ContainedDesignSystem` for tokens and visual primitives. It does +not own app sections, stores, toolbar panel content, `UIState`, routes, or +feature decisions. + +## Importing + +From the root app package: + +```swift +.product(name: "ContainedNavigation", package: "ContainedNavigation") +``` + +From Swift code: + +```swift +import SwiftUI +import ContainedDesignSystem +import ContainedNavigation +``` + +## What Belongs Here + +- `AppSafeAreaManager`, `AppSafeAreaPolicy`, and the `appSafeAreas` environment + value for generic top/bottom toolbar safe-area contracts. +- `MorphGeometry`, `AppMorphTarget`, and `MorphPanelPlacement` for target rects + that clamp to a safe area. +- `MorphingExpander` for the reusable grow/shrink panel shell. +- `MorphPanelScaffold` for generic fixed chrome, scrollable content, and pinned + footer layout inside a morph panel. + +Keep concrete panel contents in the app target. For example, Images, Templates, +System, Settings, Activity, and Command Palette panels are app features that use +this package; they do not live in this package. + +## Example + +This example uses a fixed `originFrame` to stay compact. Production apps usually +measure the toolbar button frame with a `GeometryReader` or preference key and +pass that measured rect into `MorphingExpander`. + +```swift +import SwiftUI +import ContainedDesignSystem +import ContainedNavigation + +struct NavigationPackageExample: View { + @State private var isPresented = false + + private let originFrame = CGRect(x: 24, + y: 24, + width: Tokens.Toolbar.buttonGroupHeight, + height: Tokens.Toolbar.buttonGroupHeight) + + var body: some View { + ZStack(alignment: .topLeading) { + GlassButton(singleItem: true) { + GlassButtonItem(systemName: "plus", help: "Add") { + isPresented = true + } + } + .padding(Tokens.Space.l) + + MorphingExpander(isPresented: $isPresented, + originFrame: originFrame, + target: .centered(size: Tokens.PanelSize.add)) { + MorphPanelScaffold(width: Tokens.PanelSize.add.width) { + PanelHeader(symbol: "plus", + title: "Add", + subtitle: "Choose a starting point") { + GlassButton(singleItem: true) { + GlassButtonItem(systemName: "xmark", + help: "Close", + isCancel: true) { + isPresented = false + } + } + } + Divider() + } content: { + VStack(spacing: Tokens.Space.s) { + GlassOptionTile(symbol: "play.rectangle", + title: "Run a container", + subtitle: "Start from an image") { + isPresented = false + } + GlassOptionTile(symbol: "square.stack.3d.up", + title: "Use an existing image", + subtitle: "Pick from local images") { + isPresented = false + } + } + .padding(Tokens.Space.s) + } + } + } + .environment(\.appSafeAreas, + AppSafeAreaManager(topToolbarHeight: Tokens.Toolbar.band, + bottomToolbarHeight: Tokens.Toolbar.band)) + } +} +``` + +## Safe-Area Policies + +Use `AppMorphTarget.centered(size:)` for modal-like work, such as creation +details. Use `AppMorphTarget.anchored(size:)` when a panel should grow from and +stay near its source control. Both paths use `AppSafeAreaManager` to avoid +toolbar bands and system insets. + +## Documentation + +- DocC landing page: + `Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md` +- Design package: + `../ContainedDesignSystem/README.md` +- App architecture: + `../../docs/wiki/Architecture.md` + +## Verification + +Build the package by itself: + +```sh +swift build --package-path Packages/ContainedNavigation +``` + +Build it through the app graph: + +```sh +swift build +swift test +``` diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/AppSafeAreaManager.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/AppSafeAreaManager.swift new file mode 100644 index 00000000..359211dc --- /dev/null +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/AppSafeAreaManager.swift @@ -0,0 +1,79 @@ +import SwiftUI + +public struct AppToolbarSafeAreaExclusion: OptionSet, Equatable, Sendable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let top = AppToolbarSafeAreaExclusion(rawValue: 1 << 0) + public static let bottom = AppToolbarSafeAreaExclusion(rawValue: 1 << 1) + public static let both: AppToolbarSafeAreaExclusion = [.top, .bottom] +} + +public enum AppSafeAreaPadding: CGFloat, Equatable, Sendable { + case none = 0 + case small = 8 + case medium = 16 + case large = 24 +} + +public struct AppSafeAreaPolicy: Equatable, Sendable { + public var excluding: AppToolbarSafeAreaExclusion + public var padding: AppSafeAreaPadding + public var includesSystemInsets: Bool + + public init(excluding: AppToolbarSafeAreaExclusion = .both, + padding: AppSafeAreaPadding = .small, + includesSystemInsets: Bool = true) { + self.excluding = excluding + self.padding = padding + self.includesSystemInsets = includesSystemInsets + } + + public static let fullBleed = AppSafeAreaPolicy(excluding: [], padding: .none) + public static let toolbarChrome = AppSafeAreaPolicy(excluding: [], padding: .small) + public static let content = AppSafeAreaPolicy(excluding: .both, padding: .medium) +} + +public struct AppSafeAreaManager: Equatable, Sendable { + public var system: EdgeInsets + public var topToolbarHeight: CGFloat + public var bottomToolbarHeight: CGFloat + + public init(system: EdgeInsets = EdgeInsets(), + topToolbarHeight: CGFloat = 0, + bottomToolbarHeight: CGFloat = 0) { + self.system = system + self.topToolbarHeight = topToolbarHeight + self.bottomToolbarHeight = bottomToolbarHeight + } + + public init(system: EdgeInsets = EdgeInsets(), toolbarHeight: CGFloat) { + self.init(system: system, topToolbarHeight: toolbarHeight, bottomToolbarHeight: 0) + } + + public func insets(_ policy: AppSafeAreaPolicy = .content) -> EdgeInsets { + let padding = policy.padding.rawValue + let systemInsets = policy.includesSystemInsets ? system : EdgeInsets() + // On an edge that excludes its toolbar, the band *is* the inset — the padding doesn't stack on + // top of it. Edges without a toolbar exclusion get the padding instead. + return EdgeInsets(top: systemInsets.top + (policy.excluding.contains(.top) ? topToolbarHeight : padding), + leading: systemInsets.leading + padding, + bottom: systemInsets.bottom + (policy.excluding.contains(.bottom) ? bottomToolbarHeight : padding), + trailing: systemInsets.trailing + padding) + } + + public func bounds(in size: CGSize, policy: AppSafeAreaPolicy = .content) -> CGRect { + let safeInsets = insets(policy) + return CGRect(x: safeInsets.leading, + y: safeInsets.top, + width: max(1, size.width - safeInsets.leading - safeInsets.trailing), + height: max(1, size.height - safeInsets.top - safeInsets.bottom)) + } +} + +public extension EnvironmentValues { + @Entry var appSafeAreas = AppSafeAreaManager() +} diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md b/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md new file mode 100644 index 00000000..042d5ab2 --- /dev/null +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md @@ -0,0 +1,90 @@ +# ``ContainedNavigation`` + +Reusable navigation and layout infrastructure for Contained's window chrome. + +## Overview + +`ContainedNavigation` owns generic safe-area and morph-panel mechanics. It uses +`ContainedDesignSystem` for tokens and visual primitives, but it does not own app +routes, stores, toolbar panel contents, or feature decisions. + +Use this package when a view needs reusable layout behavior such as a panel that +grows from a toolbar source, clamps to app safe areas, and hosts fixed chrome +above scrollable content. + +## Example + +```swift +import SwiftUI +import ContainedDesignSystem +import ContainedNavigation + +struct NavigationPackageExample: View { + @State private var isPresented = false + + private let originFrame = CGRect(x: 24, + y: 24, + width: Tokens.Toolbar.buttonGroupHeight, + height: Tokens.Toolbar.buttonGroupHeight) + + var body: some View { + ZStack(alignment: .topLeading) { + GlassButton(singleItem: true) { + GlassButtonItem(systemName: "plus", help: "Add") { + isPresented = true + } + } + .padding(Tokens.Space.l) + + MorphingExpander(isPresented: $isPresented, + originFrame: originFrame, + target: .centered(size: Tokens.PanelSize.add)) { + MorphPanelScaffold(width: Tokens.PanelSize.add.width) { + PanelHeader(symbol: "plus", + title: "Add", + subtitle: "Choose a starting point") { + GlassButton(singleItem: true) { + GlassButtonItem(systemName: "xmark", + help: "Close", + isCancel: true) { + isPresented = false + } + } + } + Divider() + } content: { + VStack(spacing: Tokens.Space.s) { + Text("Panel content") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(Tokens.Space.s) + } + } + } + .environment(\.appSafeAreas, + AppSafeAreaManager(topToolbarHeight: Tokens.Toolbar.band, + bottomToolbarHeight: Tokens.Toolbar.band)) + } +} +``` + +## Topics + +### Safe Areas + +- ``AppSafeAreaManager`` +- ``AppSafeAreaPolicy`` +- ``AppToolbarSafeAreaExclusion`` +- ``AppSafeAreaPadding`` + +### Morph Targets and Geometry + +- ``AppMorphTarget`` +- ``MorphPanelPlacement`` +- ``MorphGeometry`` + +### Morph Presentation + +- ``MorphingExpander`` +- ``MorphPanelScaffold`` +- ``GlobalBackdropStyle`` diff --git a/Sources/Contained/DesignSystem/MorphPanelScaffold.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphPanelScaffold.swift similarity index 71% rename from Sources/Contained/DesignSystem/MorphPanelScaffold.swift rename to Packages/ContainedNavigation/Sources/ContainedNavigation/MorphPanelScaffold.swift index a08c6b8d..cc659e5e 100644 --- a/Sources/Contained/DesignSystem/MorphPanelScaffold.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphPanelScaffold.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem /// A toolbar morph-panel body: fixed chrome (header, segmented pickers) above a scrollable content area /// and an optional pinned footer. @@ -13,12 +14,12 @@ import SwiftUI /// workspace, the paged run form). In that mode the scaffold doesn't wrap the content in a `ScrollView`, /// so scroll views aren't double-nested; the host (e.g. `CreationFlow`) supplies the size via /// `morphPanelSize`. -struct MorphPanelScaffold: View { +public struct MorphPanelScaffold: View { /// Retained for call-site compatibility; the panel's width comes from the host's morph target. - var width: CGFloat - var placement: MorphPanelPlacement = .anchored - var scrollEdgeStyle: ScrollEdgeEffectStyle = .soft - var scrolls: Bool = true + public var width: CGFloat + public var placement: MorphPanelPlacement = .anchored + public var scrollEdgeStyle: ScrollEdgeEffectStyle = .soft + public var scrolls: Bool = true /// Fixed chrome pinned above the scroll area (header, divider, segmented pickers). @ViewBuilder var chrome: () -> Chrome /// Scrollable content — supplied without a `ScrollView`; the scaffold provides it (unless `scrolls` @@ -27,7 +28,23 @@ struct MorphPanelScaffold: View { /// Optional fixed chrome pinned below the scroll area (submit bars, command preview). @ViewBuilder var footer: () -> Footer - var body: some View { + public init(width: CGFloat, + placement: MorphPanelPlacement = .anchored, + scrollEdgeStyle: ScrollEdgeEffectStyle = .soft, + scrolls: Bool = true, + @ViewBuilder chrome: @escaping () -> Chrome, + @ViewBuilder content: @escaping () -> Content, + @ViewBuilder footer: @escaping () -> Footer) { + self.width = width + self.placement = placement + self.scrollEdgeStyle = scrollEdgeStyle + self.scrolls = scrolls + self.chrome = chrome + self.content = content + self.footer = footer + } + + public var body: some View { VStack(spacing: 0) { chrome() if scrolls { @@ -46,7 +63,7 @@ struct MorphPanelScaffold: View { } } -extension MorphPanelScaffold where Footer == EmptyView { +public extension MorphPanelScaffold where Footer == EmptyView { init(width: CGFloat, placement: MorphPanelPlacement = .anchored, scrollEdgeStyle: ScrollEdgeEffectStyle = .soft, diff --git a/Sources/Contained/DesignSystem/MorphingExpander.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingExpander.swift similarity index 75% rename from Sources/Contained/DesignSystem/MorphingExpander.swift rename to Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingExpander.swift index cf2137a4..b3583ca2 100644 --- a/Sources/Contained/DesignSystem/MorphingExpander.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingExpander.swift @@ -1,48 +1,59 @@ import SwiftUI +import ContainedDesignSystem -enum MorphPanelPlacement: Equatable { +public enum MorphPanelPlacement: Equatable, Sendable { case anchored case centered } -struct AppMorphTarget { - var placement: MorphPanelPlacement - var safeArea: AppSafeAreaPolicy - var margin: CGFloat - var proposedSize: (CGRect) -> CGSize +public struct AppMorphTarget { + public var placement: MorphPanelPlacement + public var safeArea: AppSafeAreaPolicy + public var margin: CGFloat + public var proposedSize: (CGRect) -> CGSize + + public init(placement: MorphPanelPlacement, + safeArea: AppSafeAreaPolicy, + margin: CGFloat, + proposedSize: @escaping (CGRect) -> CGSize) { + self.placement = placement + self.safeArea = safeArea + self.margin = margin + self.proposedSize = proposedSize + } - static func anchored(size: CGSize, - safeArea: AppSafeAreaPolicy = .toolbarChrome, - margin: CGFloat = MorphGeometry.defaultMargin) -> AppMorphTarget { + public static func anchored(size: CGSize, + safeArea: AppSafeAreaPolicy = .toolbarChrome, + margin: CGFloat = MorphGeometry.defaultMargin) -> AppMorphTarget { AppMorphTarget(placement: .anchored, safeArea: safeArea, margin: margin, proposedSize: { _ in size }) } - static func centered(size: CGSize, - safeArea: AppSafeAreaPolicy = .content, - margin: CGFloat = MorphGeometry.defaultMargin) -> AppMorphTarget { + public static func centered(size: CGSize, + safeArea: AppSafeAreaPolicy = .content, + margin: CGFloat = MorphGeometry.defaultMargin) -> AppMorphTarget { AppMorphTarget(placement: .centered, safeArea: safeArea, margin: margin, proposedSize: { _ in size }) } - static func centered(safeArea: AppSafeAreaPolicy = .content, - margin: CGFloat = MorphGeometry.defaultMargin, - proposedSize: @escaping (CGRect) -> CGSize) -> AppMorphTarget { + public static func centered(safeArea: AppSafeAreaPolicy = .content, + margin: CGFloat = MorphGeometry.defaultMargin, + proposedSize: @escaping (CGRect) -> CGSize) -> AppMorphTarget { AppMorphTarget(placement: .centered, safeArea: safeArea, margin: margin, proposedSize: proposedSize) } - func rect(origin: CGRect, - in container: CGSize, - safeAreas: AppSafeAreaManager, - proposedSize overrideSize: CGSize? = nil, - placement overridePlacement: MorphPanelPlacement? = nil) -> CGRect { + public func rect(origin: CGRect, + in container: CGSize, + safeAreas: AppSafeAreaManager, + proposedSize overrideSize: CGSize? = nil, + placement overridePlacement: MorphPanelPlacement? = nil) -> CGRect { let bounds = safeAreas.bounds(in: container, policy: safeArea) return MorphGeometry.targetRect(origin: origin, proposedSize: overrideSize ?? proposedSize(bounds), @@ -52,34 +63,38 @@ struct AppMorphTarget { } } -struct GlobalBackdropStyle: OptionSet { - let rawValue: Int +public struct GlobalBackdropStyle: OptionSet, Equatable, Sendable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } - static let dim = GlobalBackdropStyle(rawValue: 1 << 0) - static let blur = GlobalBackdropStyle(rawValue: 1 << 1) - static let blurAndDim: GlobalBackdropStyle = [.blur, .dim] + public static let dim = GlobalBackdropStyle(rawValue: 1 << 0) + public static let blur = GlobalBackdropStyle(rawValue: 1 << 1) + public static let blurAndDim: GlobalBackdropStyle = [.blur, .dim] } -enum MorphGeometry { - static let defaultMargin: CGFloat = Tokens.Space.l - static let centeredTopMargin: CGFloat = Tokens.Space.xxl * 2 +public enum MorphGeometry { + public static let defaultMargin: CGFloat = Tokens.Space.l + public static let centeredTopMargin: CGFloat = Tokens.Space.xxl * 2 - static func fittedSize(_ proposed: CGSize, in container: CGSize, - margin: CGFloat = defaultMargin) -> CGSize { + public static func fittedSize(_ proposed: CGSize, in container: CGSize, + margin: CGFloat = defaultMargin) -> CGSize { fittedSize(proposed, in: CGRect(origin: .zero, size: container), margin: margin) } - static func fittedSize(_ proposed: CGSize, in bounds: CGRect, - margin: CGFloat = defaultMargin) -> CGSize { + public static func fittedSize(_ proposed: CGSize, in bounds: CGRect, + margin: CGFloat = defaultMargin) -> CGSize { let maxWidth = max(1, bounds.width - margin * 2) let maxHeight = max(1, bounds.height - margin * 2) return CGSize(width: min(max(Tokens.PanelSize.minWidth, proposed.width), maxWidth), height: min(max(Tokens.PanelSize.minHeight, proposed.height), maxHeight)) } - static func targetRect(origin: CGRect, proposedSize: CGSize, container: CGSize, - placement: MorphPanelPlacement, - margin: CGFloat = defaultMargin) -> CGRect { + public static func targetRect(origin: CGRect, proposedSize: CGSize, container: CGSize, + placement: MorphPanelPlacement, + margin: CGFloat = defaultMargin) -> CGRect { targetRect(origin: origin, proposedSize: proposedSize, bounds: CGRect(origin: .zero, size: container), @@ -87,9 +102,9 @@ enum MorphGeometry { margin: margin) } - static func targetRect(origin: CGRect, proposedSize: CGSize, bounds: CGRect, - placement: MorphPanelPlacement, - margin: CGFloat = defaultMargin) -> CGRect { + public static func targetRect(origin: CGRect, proposedSize: CGSize, bounds: CGRect, + placement: MorphPanelPlacement, + margin: CGFloat = defaultMargin) -> CGRect { let size = fittedSize(proposedSize, in: bounds, margin: margin) switch placement { case .centered: @@ -105,13 +120,13 @@ enum MorphGeometry { } } - static func clamped(_ rect: CGRect, in container: CGSize, - margin: CGFloat = defaultMargin) -> CGRect { + public static func clamped(_ rect: CGRect, in container: CGSize, + margin: CGFloat = defaultMargin) -> CGRect { clamped(rect, in: CGRect(origin: .zero, size: container), margin: margin) } - static func clamped(_ rect: CGRect, in bounds: CGRect, - margin: CGFloat = defaultMargin) -> CGRect { + public static func clamped(_ rect: CGRect, in bounds: CGRect, + margin: CGFloat = defaultMargin) -> CGRect { let width = min(max(1, rect.width), max(1, bounds.width - margin * 2)) let height = min(max(1, rect.height), max(1, bounds.height - margin * 2)) let minX = bounds.minX + margin @@ -130,6 +145,12 @@ private extension CGRect { } } +private extension CGSize { + var isUsableForMorphPanel: Bool { + width.isFinite && height.isFinite && width > 1 && height > 1 + } +} + /// A centered glass panel that **grows from an origin slot** (e.g. a toolbar button) over a /// dimmed/blurred backdrop, then shrinks back into it on close — the same in-place grow the container /// cards use for their detail panel, hoisted into a reusable primitive. @@ -137,7 +158,7 @@ private extension CGRect { /// Mount it inside a **window-spanning** `ZStack` whose coordinate space matches the one `originFrame` /// was measured in (so the grow starts from the real button location). It owns the open/close spring; /// the parent just toggles `isPresented` and supplies the slot frame + panel content. -struct MorphingExpander: View { +public struct MorphingExpander: View { /// Bound presence. The expander animates the close itself, then flips this to `false` on completion. @Binding var isPresented: Bool /// The slot the panel grows out of / collapses back into, in this view's coordinate space. @@ -164,7 +185,7 @@ struct MorphingExpander: View { @Environment(\.appSafeAreas) private var safeAreas private var spring: Animation { .spring(response: 0.42, dampingFraction: 0.86) } - init(isPresented: Binding, + public init(isPresented: Binding, originFrame: CGRect, target: AppMorphTarget = .centered(size: CGSize(width: 460, height: 440)), backdropStyle: GlobalBackdropStyle = .dim, @@ -190,10 +211,11 @@ struct MorphingExpander: View { self.content = content } - var body: some View { + public var body: some View { GeometryReader { geo in let target = targetRect(in: geo.size) - let rect = expanded ? target : originFrame + let source = originFrame.isUsable ? originFrame : target + let rect = expanded ? target : source let cornerRadius = expanded ? targetCornerRadius : sourceCornerRadius ZStack { if showsBackdrop { @@ -216,7 +238,7 @@ struct MorphingExpander: View { .matchedGeometryEffect(id: "morph-panel-shell", in: shellNamespace, properties: .frame) - .frame(width: rect.width, height: rect.height) + .frame(width: max(rect.width, 1), height: max(rect.height, 1)) .position(x: rect.midX, y: rect.midY) content() @@ -230,13 +252,13 @@ struct MorphingExpander: View { .opacity(expanded ? 1 : 0) // The animating window clips the (statically laid-out) content, pinned to the top so // headers hug the top edge as the panel grows or shrinks. - .frame(width: rect.width, height: rect.height, alignment: .top) + .frame(width: max(rect.width, 1), height: max(rect.height, 1), alignment: .top) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .position(x: rect.midX, y: rect.midY) } } .onPreferenceChange(MorphPanelSizeKey.self) { size in - guard let size else { return } + guard let size, size.isUsableForMorphPanel else { return } withAnimation(reduceMotion ? nil : spring) { liveSize = size } } .onPreferenceChange(MorphPanelPlacementKey.self) { placement in @@ -274,7 +296,7 @@ struct MorphingExpander: View { } } -extension View { +public extension View { func globalBackdrop(style: GlobalBackdropStyle, progress: Double, dimOpacity: Double = 0.28) -> some View { @@ -324,7 +346,7 @@ private struct MorphPanelPlacementKey: PreferenceKey { } } -extension View { +public extension View { /// Declare the desired size of the panel hosting this content (read by `MorphingExpander`). func morphPanelSize(_ size: CGSize) -> some View { preference(key: MorphPanelSizeKey.self, value: size) diff --git a/README.md b/README.md index 0b820aaf..f3b867f3 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,10 @@ Requirements: ## Build -Contained is a Swift Package. +Contained is a SwiftPM-first app with an Xcode workspace over the package graph. ```sh -open Package.swift +open Contained.xcworkspace swift build swift test ./scripts/bundle.sh debug @@ -81,10 +81,12 @@ instead. ## Architecture -The package has two main targets: +The root package has the app/core targets and consumes local reusable packages: - `ContainedCore`: models, CLI command builders, real `container --format json` decoders, and testable service logic. -- `Contained`: SwiftUI app shell, design system, navigation, feature views, stores, history, settings, Sparkle support, and app state migration. +- `Contained`: SwiftUI app shell, navigation, feature views, stores, history, settings, Sparkle support, app state migration, and app-specific presentation mappings. +- [`Packages/ContainedDesignSystem`](Packages/ContainedDesignSystem/README.md): reusable SwiftUI/AppKit visual primitives, tokens, spacing, material, and micro-chrome shared by the app. +- [`Packages/ContainedNavigation`](Packages/ContainedNavigation/README.md): reusable navigation and layout infrastructure shared by app chrome. Integration is intentionally CLI-based rather than private-framework based. Personalization and app-managed metadata stay local to Contained so the user's container resources remain clean when used directly from the terminal. diff --git a/Sources/Contained/ContainedApp.swift b/Sources/Contained/ContainedApp.swift index 6f153fe5..010f81b0 100644 --- a/Sources/Contained/ContainedApp.swift +++ b/Sources/Contained/ContainedApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import AppKit import ContainedCore diff --git a/Sources/Contained/DesignSystem/AppSafeAreaManager.swift b/Sources/Contained/DesignSystem/AppSafeAreaManager.swift deleted file mode 100644 index bd5a3dcc..00000000 --- a/Sources/Contained/DesignSystem/AppSafeAreaManager.swift +++ /dev/null @@ -1,67 +0,0 @@ -import SwiftUI - -struct AppToolbarSafeAreaExclusion: OptionSet, Equatable { - let rawValue: Int - - static let top = AppToolbarSafeAreaExclusion(rawValue: 1 << 0) - static let bottom = AppToolbarSafeAreaExclusion(rawValue: 1 << 1) - static let both: AppToolbarSafeAreaExclusion = [.top, .bottom] -} - -enum AppSafeAreaPadding: CGFloat, Equatable { - case none = 0 - case small = 8 - case medium = 16 - case large = 24 -} - -struct AppSafeAreaPolicy: Equatable { - var excluding: AppToolbarSafeAreaExclusion = .both - var padding: AppSafeAreaPadding = .small - var includesSystemInsets = true - - static let fullBleed = AppSafeAreaPolicy(excluding: [], padding: .none) - static let toolbarChrome = AppSafeAreaPolicy(excluding: [], padding: .small) - static let content = AppSafeAreaPolicy(excluding: .both, padding: .medium) -} - -struct AppSafeAreaManager: Equatable { - var system: EdgeInsets = EdgeInsets() - var topToolbarHeight: CGFloat = 0 - var bottomToolbarHeight: CGFloat = 0 - - init(system: EdgeInsets = EdgeInsets(), - topToolbarHeight: CGFloat = 0, - bottomToolbarHeight: CGFloat = 0) { - self.system = system - self.topToolbarHeight = topToolbarHeight - self.bottomToolbarHeight = bottomToolbarHeight - } - - init(system: EdgeInsets = EdgeInsets(), toolbarHeight: CGFloat) { - self.init(system: system, topToolbarHeight: toolbarHeight, bottomToolbarHeight: 0) - } - - func insets(_ policy: AppSafeAreaPolicy = .content) -> EdgeInsets { - let padding = policy.padding.rawValue - let systemInsets = policy.includesSystemInsets ? system : EdgeInsets() - // On an edge that excludes its toolbar, the band *is* the inset — the padding doesn't stack on - // top of it. Edges without a toolbar exclusion get the padding instead. - return EdgeInsets(top: systemInsets.top + (policy.excluding.contains(.top) ? topToolbarHeight : padding), - leading: systemInsets.leading + padding, - bottom: systemInsets.bottom + (policy.excluding.contains(.bottom) ? bottomToolbarHeight : padding), - trailing: systemInsets.trailing + padding) - } - - func bounds(in size: CGSize, policy: AppSafeAreaPolicy = .content) -> CGRect { - let safeInsets = insets(policy) - return CGRect(x: safeInsets.leading, - y: safeInsets.top, - width: max(1, size.width - safeInsets.leading - safeInsets.trailing), - height: max(1, size.height - safeInsets.top - safeInsets.bottom)) - } -} - -extension EnvironmentValues { - @Entry var appSafeAreas = AppSafeAreaManager() -} diff --git a/Sources/Contained/DesignSystem/GlassSurface.swift b/Sources/Contained/DesignSystem/GlassSurface.swift deleted file mode 100644 index ce0da5fc..00000000 --- a/Sources/Contained/DesignSystem/GlassSurface.swift +++ /dev/null @@ -1,90 +0,0 @@ -import SwiftUI - -/// Centralized Liquid Glass surface: real `.glassEffect()` plus a soft shadow that lifts the -/// element off the backdrop, and an optional colored (optionally gradient) wash behind the glass. -struct GlassSurface: ViewModifier { - enum Level { case regular, thin, ultraThin } - var level: Level = .regular - var cornerRadius: CGFloat = Tokens.Radius.card - var glass: Glass = .regular - /// Lift the surface off the backdrop with a soft shadow. Pass `false` for flat tiles that sit - /// inside an already-elevated panel (e.g. cards in the toolbar morph panels / the creation menu). - var shadow: Bool = true - var fill: Color? = nil - var fillOpacity: Double = 0.18 - var gradient: Bool = false - var gradientAngle: Double = 135 - - @Environment(\.colorScheme) private var colorScheme - - func body(content: Content) -> some View { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - // Layering, back → front: tint wash → glass → content. - // `.glassEffect` puts the glass *behind* the content; the tint sits behind the glass so it - // shows *through* it (refracted), rather than washing over the content. - // NOTE: no `.compositingGroup()` here — it rasterizes the glass and makes it render opaque, - // breaking the live translucency. `.glassEffect` provides its own elevation. - return content - .clipShape(shape) - .background { - if shadow { - ExteriorShadow(cornerRadius: cornerRadius, - color: shadowColor, - radius: shadowRadius, - y: shadowY) - } - } - .glassEffect(glass, in: shape) - .background { - if let fill { - shape.fill(fillStyle(fill)) - } - } - } - - private func fillStyle(_ color: Color) -> AnyShapeStyle { - if gradient { - let radians = gradientAngle * .pi / 180 - let dx = cos(radians) / 2 - let dy = sin(radians) / 2 - return AnyShapeStyle(LinearGradient( - colors: [color.opacity(fillOpacity * 1.35), color.opacity(fillOpacity * 0.4)], - startPoint: UnitPoint(x: 0.5 - dx, y: 0.5 - dy), - endPoint: UnitPoint(x: 0.5 + dx, y: 0.5 + dy))) - } - return AnyShapeStyle(color.opacity(fillOpacity)) - } - - private var shadowColor: Color { - let base = colorScheme == .dark ? 0.55 : 0.18 - let scale: Double - switch level { - case .regular: scale = 1.0 - case .thin: scale = 0.6 - case .ultraThin: scale = 0.4 - } - return .black.opacity(base * scale) - } - - private var shadowRadius: CGFloat { - switch level { case .regular: return 10; case .thin: return 6; case .ultraThin: return 4 } - } - private var shadowY: CGFloat { - switch level { case .regular: return 4; case .thin: return 2; case .ultraThin: return 1 } - } -} - -extension View { - func glassSurface(_ level: GlassSurface.Level = .regular, - cornerRadius: CGFloat = Tokens.Radius.card, - glass: Glass = .regular, - shadow: Bool = true, - fill: Color? = nil, - fillOpacity: Double = 0.18, - gradient: Bool = false, - gradientAngle: Double = 135) -> some View { - modifier(GlassSurface(level: level, cornerRadius: cornerRadius, glass: glass, - shadow: shadow, fill: fill, fillOpacity: fillOpacity, gradient: gradient, - gradientAngle: gradientAngle)) - } -} diff --git a/Sources/Contained/DesignSystem/PageScaffold.swift b/Sources/Contained/DesignSystem/PageScaffold.swift deleted file mode 100644 index dd8204d8..00000000 --- a/Sources/Contained/DesignSystem/PageScaffold.swift +++ /dev/null @@ -1,50 +0,0 @@ -import SwiftUI - -struct PageScaffold: View { - @Environment(UIState.self) private var ui - let symbol: String - let title: String - let subtitle: String - var scrolls = true - @ViewBuilder var actions: () -> Actions - @ViewBuilder var content: () -> Content - - var body: some View { - VStack(spacing: 0) { - if !ui.toolbarUIEnabled { - PanelHeader(symbol: symbol, title: title, subtitle: subtitle) { - actions() - } - Divider() - } - if scrolls { - ScrollView { - VStack(spacing: 0) { - content() - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(ui.toolbarUIEnabled ? Tokens.Space.s : Tokens.Space.l) - if ui.toolbarUIEnabled { - Color.clear - .frame(height: AppToolbar.bandHeight) - } - } - } - .scrollEdgeEffectStyle(.soft, for: .all) - } else { - content() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - } - } -} - -extension PageScaffold where Actions == EmptyView { - init(symbol: String, - title: String, - subtitle: String, - scrolls: Bool = true, - @ViewBuilder content: @escaping () -> Content) { - self.init(symbol: symbol, title: title, subtitle: subtitle, scrolls: scrolls, - actions: { EmptyView() }, content: content) - } -} diff --git a/Sources/Contained/DesignSystem/ResourceCardChrome.swift b/Sources/Contained/DesignSystem/ResourceCardChrome.swift deleted file mode 100644 index 882c67f6..00000000 --- a/Sources/Contained/DesignSystem/ResourceCardChrome.swift +++ /dev/null @@ -1,231 +0,0 @@ -import SwiftUI - -/// A reusable three-part card header: leading accessory, fill/truncate text block, and trailing -/// button rail. This keeps the container/image cards using the same top-aligned chrome structure. -struct ResourceCardHeader: View { - var spacing: CGFloat = 10 - var padding: CGFloat = 10 - @ViewBuilder var leading: () -> Leading - @ViewBuilder var content: () -> Content - @ViewBuilder var trailing: () -> Trailing - - var body: some View { - HStack(alignment: .top, spacing: spacing) { - leading() - content() - .frame(maxWidth: .infinity, alignment: .leading) - trailing() - } - .padding(padding) - } -} - -/// A small reusable footer mini: optional icon + optional text, aligned on one baseline. -struct ResourceCardFooterMini: View { - var spacing: CGFloat = 4 - @ViewBuilder var icon: () -> Icon - @ViewBuilder var text: () -> TextContent - - var body: some View { - HStack(alignment: .center, spacing: spacing) { - icon() - text() - .fixedSize(horizontal: true, vertical: false) - } - .fixedSize(horizontal: true, vertical: false) - } -} - -/// A reusable footer item band that hugs its content and anchors either left or right. -struct ResourceCardFooterGroup: View { - enum Alignment { - case leading, trailing - } - - var alignment: Alignment = .leading - var spacing: CGFloat = 10 - @ViewBuilder var content: () -> Content - - var body: some View { - HStack(spacing: spacing) { content() } - .frame(maxWidth: .infinity, - alignment: alignment == .leading ? .leading : .trailing) - } -} - -/// A reusable footer band with a left group, right group, and optional widget stacked above them. -struct ResourceCardFooter: View { - var showWidget: Bool = false - var actionsVisible: Bool = true - var spacing: CGFloat = 10 - var horizontalPadding: CGFloat = 10 - var topPadding: CGFloat = 0 - var bottomPadding: CGFloat = 10 - @ViewBuilder var leading: () -> Leading - @ViewBuilder var trailing: () -> Trailing - @ViewBuilder var widget: () -> Widget - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - if showWidget { - widget() - } - HStack(spacing: spacing) { - ResourceCardFooterGroup(alignment: .leading, spacing: spacing) { - leading() - } - ResourceCardFooterGroup(alignment: .trailing, spacing: spacing) { - trailing() - } - .opacity(actionsVisible ? 1 : 0) - .allowsHitTesting(actionsVisible) - .animation(.easeOut(duration: 0.18), value: actionsVisible) - } - .padding(.horizontal, horizontalPadding) - .padding(.top, topPadding) - .padding(.bottom, bottomPadding) - } - } -} - -struct ResourceCardTitleText: View { - let text: String - - var body: some View { - Text(text) - .font(.callout.weight(.medium)) - .lineLimit(1) - } -} - -struct ResourceCardSubtitleText: View { - let text: String - - var body: some View { - Text(text) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } -} - -struct ResourceCardMonospacedSubtitleText: View { - let text: String - - var body: some View { - Text(text) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - } -} - -struct ResourceCardMonospacedTitleText: View { - let text: String - - var body: some View { - Text(text) - .font(.system(.callout, design: .monospaced).weight(.medium)) - .lineLimit(1) - } -} - -struct ResourceCardIconChip: View { - var symbol: String - var tint: Color = .secondary - var symbolFont: Font = .title3 - var backgroundOpacity: Double = 0.16 - - var body: some View { - Image(systemName: symbol) - .font(symbolFont) - .foregroundStyle(tint) - .frame(width: Tokens.IconSize.chip, height: Tokens.IconSize.chip) - .background(tint.opacity(backgroundOpacity), - in: RoundedRectangle(cornerRadius: Tokens.Radius.iconChip, style: .continuous)) - } -} - -/// Small capsule count/state badge used in section headers and compact metadata rows. -struct ResourceBadgeText: View { - let text: String - var font: Font = .caption.weight(.medium) - var foreground: Color = .secondary - - var body: some View { - Text(text) - .font(font) - .foregroundStyle(foreground) - .padding(.horizontal, Tokens.Space.s) - .padding(.vertical, Tokens.Space.xs / 2) - .background(.quaternary, in: Capsule()) - } -} - -/// Flat glass row for selectable lists inside panels and sheets. -struct GlassListRow: View { - var symbol: String - var tint: Color = .accentColor - var title: String - var subtitle: String? - var monospacedSubtitle = true - @ViewBuilder var accessory: () -> Accessory - - var body: some View { - HStack(spacing: Tokens.Space.s) { - Image(systemName: symbol) - .font(.callout) - .foregroundStyle(tint) - .frame(width: Tokens.IconSize.rowMenu) - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(.callout.weight(.medium)) - .lineLimit(1) - if let subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(monospacedSubtitle ? .system(.caption, design: .monospaced) : .caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - Spacer(minLength: Tokens.Space.s) - accessory() - } - .padding(.horizontal, Tokens.Space.m) - .padding(.vertical, Tokens.Space.s) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .glassSurface(.ultraThin, cornerRadius: Tokens.Radius.control) - } -} - -struct GlassListRowChevron: View { - var body: some View { - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) - } -} - -extension GlassListRow where Accessory == GlassListRowChevron { - init(symbol: String, tint: Color = .accentColor, title: String, subtitle: String?, - monospacedSubtitle: Bool = true) { - self.init(symbol: symbol, - tint: tint, - title: title, - subtitle: subtitle, - monospacedSubtitle: monospacedSubtitle) { - GlassListRowChevron() - } - } -} - -struct ResourceCardMetricText: View { - let text: String - - var body: some View { - Text(text) - .font(.caption.weight(.medium)) - .monospacedDigit() - } -} diff --git a/Sources/Contained/Features/Containers/ContainerCard.swift b/Sources/Contained/Features/Containers/ContainerCard.swift index 4ec5820d..dcd75f88 100644 --- a/Sources/Contained/Features/Containers/ContainerCard.swift +++ b/Sources/Contained/Features/Containers/ContainerCard.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore /// A personalized clear-glass card for one container. The same component renders both the compact @@ -158,7 +159,7 @@ struct ContainerCard: View { pointSize: activeWidget.pointSize, barWidth: activeWidget.barWidth) .frame(maxWidth: .infinity) - .frame(height: 58) + .frame(height: Tokens.ResourceCard.sparklineHeight) } .overlay { if isBusy { ProgressView().controlSize(.small) } } } @@ -200,11 +201,11 @@ struct ContainerCard: View { } private var headerTitleBlock: some View { - VStack(alignment: .leading, spacing: 1) { - HStack(alignment: .top, spacing: 4) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + HStack(alignment: .top, spacing: Tokens.Space.xs) { ResourceCardTitleText(text: name) } - HStack(alignment: .top, spacing: 4) { + HStack(alignment: .top, spacing: Tokens.Space.xs) { ResourceCardMonospacedSubtitleText(text: Format.shortImage(snapshot.image)) } } diff --git a/Sources/Contained/Features/Containers/ContainerConfigureView.swift b/Sources/Contained/Features/Containers/ContainerConfigureView.swift index 786b8686..327d9a0d 100644 --- a/Sources/Contained/Features/Containers/ContainerConfigureView.swift +++ b/Sources/Contained/Features/Containers/ContainerConfigureView.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import SwiftData import ContainedCore diff --git a/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift b/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift index 3c18149a..a817eda1 100644 --- a/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift +++ b/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore /// Container identity chip that turns into the customization affordance on hover. The customize diff --git a/Sources/Contained/Features/Containers/ContainerEditSheet.swift b/Sources/Contained/Features/Containers/ContainerEditSheet.swift index a70ffc38..76a47d69 100644 --- a/Sources/Contained/Features/Containers/ContainerEditSheet.swift +++ b/Sources/Contained/Features/Containers/ContainerEditSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore /// The container Create/Edit form presented as a modal sheet. The form body lives in the shared diff --git a/Sources/Contained/Features/Containers/ContainerOverviewTab.swift b/Sources/Contained/Features/Containers/ContainerOverviewTab.swift index b02771b3..5935ab09 100644 --- a/Sources/Contained/Features/Containers/ContainerOverviewTab.swift +++ b/Sources/Contained/Features/Containers/ContainerOverviewTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore /// The Overview tab of the container detail: grouped, read-only configuration (general, resources, diff --git a/Sources/Contained/Features/Containers/ContainerTabScaffold.swift b/Sources/Contained/Features/Containers/ContainerTabScaffold.swift index a7842083..1fd3d65f 100644 --- a/Sources/Contained/Features/Containers/ContainerTabScaffold.swift +++ b/Sources/Contained/Features/Containers/ContainerTabScaffold.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem /// Shared body scaffolding for expanded container-card pages. /// Keeps tab content at the same 8pt inset as the rest of the panel surfaces. diff --git a/Sources/Contained/Features/Containers/ContainersGridView.swift b/Sources/Contained/Features/Containers/ContainersGridView.swift index fe765779..5ed2038e 100644 --- a/Sources/Contained/Features/Containers/ContainersGridView.swift +++ b/Sources/Contained/Features/Containers/ContainersGridView.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import AppKit import ContainedCore @@ -204,7 +206,6 @@ struct ContainersGridView: View { } } .coordinateSpace(.named("grid")) - .onPreferenceChange(CardFrameKey.self) { cardFrames = $0 } } .overlay(alignment: .bottom) { if selecting && !selection.isEmpty { batchBar } else if let message = store.errorMessage { ErrorToast(message: message) } @@ -324,14 +325,23 @@ struct ContainersGridView: View { // while the promoted overlay grows out of it — no second card to see double. .opacity(selected ? 0 : 1) .allowsHitTesting(detail == nil) - .background( + .background { GeometryReader { proxy in - Color.clear.preference( - key: CardFrameKey.self, - value: [snapshot.id: proxy.frame(in: .named("grid"))] - ) + Color.clear + .onAppear { + updateCardFrame(proxy.frame(in: .named("grid")), for: snapshot.id) + } + .onChange(of: proxy.frame(in: .named("grid"))) { _, frame in + updateCardFrame(frame, for: snapshot.id) + } } - ) + } + } + + private func updateCardFrame(_ frame: CGRect, for id: String) { + guard frame.isUsableForMorph else { return } + guard cardFrames[id]?.isClose(to: frame) != true else { return } + cardFrames[id] = frame } private func compactCard(_ snapshot: ContainerSnapshot) -> some View { @@ -441,7 +451,7 @@ struct ContainersGridView: View { .buttonStyle(.glass) .padding(.horizontal, Tokens.Space.l) .padding(.vertical, Tokens.Space.s) - .glassEffect(.regular, in: Capsule()) + .glassCapsuleSurface(shadow: false) .padding(.bottom, Tokens.Space.l) } @@ -494,17 +504,15 @@ struct ContainersGridView: View { } } -/// Collects each grid card's frame (in the "grid" coordinate space) so a tapped card can grow from -/// the exact slot it lives in. -private struct CardFrameKey: PreferenceKey { - static let defaultValue: [String: CGRect] = [:] - static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { - value.merge(nextValue()) { _, new in new } - } -} - private extension CGRect { var isUsableForMorph: Bool { width.isFinite && height.isFinite && minX.isFinite && minY.isFinite && width > 1 && height > 1 } + + func isClose(to other: CGRect, tolerance: CGFloat = 0.5) -> Bool { + abs(minX - other.minX) <= tolerance && + abs(minY - other.minY) <= tolerance && + abs(width - other.width) <= tolerance && + abs(height - other.height) <= tolerance + } } diff --git a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift b/Sources/Contained/Features/Containers/Creation/CreationFlow.swift index 5febfeb1..6e2129c6 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/Contained/Features/Containers/Creation/CreationFlow.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import SwiftData import AppKit import ContainedCore diff --git a/Sources/Contained/Features/Containers/Creation/CreationPages.swift b/Sources/Contained/Features/Containers/Creation/CreationPages.swift index 78ec031a..8304f8f2 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationPages.swift +++ b/Sources/Contained/Features/Containers/Creation/CreationPages.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore struct CreationNetworkFields: View { @@ -250,7 +251,7 @@ private struct CreationResourceForm: View { ResourceCardHeader { ResourceCardIconChip(symbol: symbol, tint: .accentColor) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { ResourceCardTitleText(text: title) ResourceCardSubtitleText(text: subtitle) } @@ -298,7 +299,7 @@ private struct CreationChoiceCard: View { ResourceCardHeader { ResourceCardIconChip(symbol: symbol, tint: .accentColor) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { ResourceCardTitleText(text: title) if let subtitle, !subtitle.isEmpty { ResourceCardMonospacedSubtitleText(text: subtitle) diff --git a/Sources/Contained/Features/Containers/CustomizeSheet.swift b/Sources/Contained/Features/Containers/CustomizeSheet.swift index b5c4ad94..41c9304e 100644 --- a/Sources/Contained/Features/Containers/CustomizeSheet.swift +++ b/Sources/Contained/Features/Containers/CustomizeSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore /// Edits local card personalization for containers, images, image groups, tags, and volumes. diff --git a/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift b/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift index 94816b63..92f5dbe8 100644 --- a/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift +++ b/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem /// Widget-specific controls for `CustomizeSheet`, split out so the sheet owns the target/persistence /// workflow while this view owns per-widget ordering, metric, and chart editing. diff --git a/Sources/Contained/Features/Containers/FilesTab.swift b/Sources/Contained/Features/Containers/FilesTab.swift index defb8fd1..392d849e 100644 --- a/Sources/Contained/Features/Containers/FilesTab.swift +++ b/Sources/Contained/Features/Containers/FilesTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import AppKit import ContainedCore @@ -56,7 +57,7 @@ struct FilesTab: View { } description: { Text(error) } } else { ScrollView { - LazyVStack(spacing: 1) { + LazyVStack(spacing: Tokens.Space.hairline) { ForEach(entries, id: \.self) { entry in row(entry) } } .padding(Tokens.Space.s) diff --git a/Sources/Contained/Features/Containers/LogsTab.swift b/Sources/Contained/Features/Containers/LogsTab.swift index 04a04e78..2749c145 100644 --- a/Sources/Contained/Features/Containers/LogsTab.swift +++ b/Sources/Contained/Features/Containers/LogsTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore /// Live container logs via `container logs --follow`. The stream is tied to this view's lifetime @@ -34,7 +35,7 @@ struct LogsTab: View { .buttonStyle(.glass) .buttonBorderShape(.capsule) if streaming { - HStack(spacing: 5) { + HStack(spacing: Tokens.Toolbar.searchIconGap) { ProgressView().controlSize(.small) Text("streaming").font(.caption).foregroundStyle(.secondary) } @@ -73,14 +74,14 @@ struct LogsTab: View { } else { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: 1) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.hairline) { ForEach(Array(lines.enumerated()), id: \.offset) { _, line in Text(line.isEmpty ? " " : line) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } - Color.clear.frame(height: 1).id(bottomID) + Color.clear.frame(height: Tokens.Space.hairline).id(bottomID) } .padding(Tokens.Space.s) } diff --git a/Sources/Contained/Features/Containers/RunSpecForm.swift b/Sources/Contained/Features/Containers/RunSpecForm.swift index fc49651c..587452bb 100644 --- a/Sources/Contained/Features/Containers/RunSpecForm.swift +++ b/Sources/Contained/Features/Containers/RunSpecForm.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import AppKit import ContainedCore @@ -425,7 +427,7 @@ struct RunSpecForm: View { } .fixedSize() TextField("", text: $spec.network, prompt: Text("custom network")).textFieldStyle(.roundedBorder) - .frame(width: 180) + .frame(width: Tokens.FormWidth.networkName) } .task { await app.refreshNetworks() } } diff --git a/Sources/Contained/Features/Containers/StatsTab.swift b/Sources/Contained/Features/Containers/StatsTab.swift index c21d399e..001b0986 100644 --- a/Sources/Contained/Features/Containers/StatsTab.swift +++ b/Sources/Contained/Features/Containers/StatsTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore /// Live resource stats for one container. Reads the deltas the `RefreshCoordinator` already polls @@ -18,32 +19,34 @@ struct StatsTab: View { private let columns = [GridItem(.adaptive(minimum: 200), spacing: Tokens.Space.m)] var body: some View { - if snapshot.state != .running { - ContentUnavailableView { - Label("Not running", systemImage: "chart.xyaxis.line") - } description: { Text("Start the container to see live resource usage.") } - } else if let delta { - ContainerTabScaffold { - LazyVGrid(columns: columns, spacing: Tokens.Space.m) { - tile(.cpu, delta, "cpu") - memoryTile(delta) - tile(.netRx, delta, "arrow.down.circle") - tile(.netTx, delta, "arrow.up.circle") - tile(.diskRead, delta, "arrow.down.doc") - tile(.diskWrite, delta, "arrow.up.doc") - MetricTile(label: "Processes", value: "\(delta.numProcesses)", systemImage: "gearshape.2", tint: tint) + Group { + if snapshot.state != .running { + ContentUnavailableView { + Label("Not running", systemImage: "chart.xyaxis.line") + } description: { Text("Start the container to see live resource usage.") } + } else if let delta { + ContainerTabScaffold { + LazyVGrid(columns: columns, spacing: Tokens.Space.m) { + tile(.cpu, delta, "cpu") + memoryTile(delta) + tile(.netRx, delta, "arrow.down.circle") + tile(.netTx, delta, "arrow.up.circle") + tile(.diskRead, delta, "arrow.down.doc") + tile(.diskWrite, delta, "arrow.up.doc") + MetricTile(label: "Processes", value: "\(delta.numProcesses)", systemImage: "gearshape.2", tint: tint) + } + processList } - processList - } - .task(id: snapshot.id) { await loadProcesses() } - } else { - // Running but no sample yet (first tick pending). - VStack(spacing: Tokens.Space.m) { - ProgressView() - Text("Collecting stats…").font(.callout).foregroundStyle(.secondary) + } else { + // Running but no sample yet (first tick pending). + VStack(spacing: Tokens.Space.m) { + ProgressView() + Text("Collecting stats…").font(.callout).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) } + .task(id: snapshot.id) { await refreshVisibleStatsAndProcesses() } } @ViewBuilder @@ -70,6 +73,12 @@ struct StatsTab: View { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } + private func refreshVisibleStatsAndProcesses() async { + guard snapshot.state == .running else { processes = ""; return } + await app.refreshContainerStatsNow() + await loadProcesses() + } + private func tile(_ metric: GraphMetric, _ delta: StatsDelta, _ symbol: String) -> some View { MetricTile(label: metric.displayName, value: metric.caption(from: delta), systemImage: symbol, tint: tint, samples: history[metric]?.values) diff --git a/Sources/Contained/Features/Containers/TerminalTab.swift b/Sources/Contained/Features/Containers/TerminalTab.swift index 8d3303a3..4c68c29c 100644 --- a/Sources/Contained/Features/Containers/TerminalTab.swift +++ b/Sources/Contained/Features/Containers/TerminalTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import AppKit import SwiftTerm import ContainedCore @@ -38,10 +39,7 @@ struct TerminalTab: View { ended = Ended(code: code) } .id(session) // recreating the view tears down the old exec + starts fresh - .padding(Tokens.Space.s) - .background(.black.opacity(0.22), - in: RoundedRectangle(cornerRadius: Tokens.Radius.card, style: .continuous)) - .padding(Tokens.Space.s) + .terminalSurfaceChrome() if let ended { endedOverlay(code: ended.code) } @@ -59,7 +57,7 @@ struct TerminalTab: View { ForEach(shells, id: \.self) { Text($0).tag($0) } } .labelsHidden() - .frame(width: 140) + .frame(width: Tokens.FormWidth.shellPicker) .onChange(of: shell) { _, _ in reconnect() } Text("exec into \(snapshot.id)").font(.caption).foregroundStyle(.secondary).lineLimit(1) Spacer() @@ -81,7 +79,7 @@ struct TerminalTab: View { } } .padding(Tokens.Space.xl) - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: Tokens.Radius.card, style: .continuous)) + .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) } private func reconnect() { @@ -103,9 +101,9 @@ struct TerminalSurface: NSViewRepresentable { func makeNSView(context: Context) -> LocalProcessTerminalView { let view = LocalProcessTerminalView(frame: .zero) view.processDelegate = context.coordinator - view.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) - view.nativeBackgroundColor = NSColor.black.withAlphaComponent(0.82) - view.nativeForegroundColor = NSColor(white: 0.92, alpha: 1) + view.font = NSFont.monospacedSystemFont(ofSize: Tokens.Terminal.fontSize, weight: .regular) + view.nativeBackgroundColor = NSColor.black.withAlphaComponent(Tokens.Terminal.nativeBackgroundOpacity) + view.nativeForegroundColor = NSColor(white: Tokens.Terminal.nativeForegroundWhite, alpha: 1) // `container exec -i -t ` — PTY is provided by SwiftTerm; -t requests a TTY // inside the container, -i keeps stdin attached. We must inherit the *host* environment diff --git a/Sources/Contained/Features/Images/BuildWorkspaceView.swift b/Sources/Contained/Features/Images/BuildWorkspaceView.swift index bdd3efa0..6049ad39 100644 --- a/Sources/Contained/Features/Images/BuildWorkspaceView.swift +++ b/Sources/Contained/Features/Images/BuildWorkspaceView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import AppKit import ContainedCore diff --git a/Sources/Contained/Features/Images/ImageStyleButton.swift b/Sources/Contained/Features/Images/ImageStyleButton.swift index 6ebd5277..9da7c328 100644 --- a/Sources/Contained/Features/Images/ImageStyleButton.swift +++ b/Sources/Contained/Features/Images/ImageStyleButton.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore /// Image row identity chip. If the image has a saved default style, this displays it and opens the diff --git a/Sources/Contained/Features/Images/RegistryImageSearch.swift b/Sources/Contained/Features/Images/RegistryImageSearch.swift index 05fb9d3b..8bd74274 100644 --- a/Sources/Contained/Features/Images/RegistryImageSearch.swift +++ b/Sources/Contained/Features/Images/RegistryImageSearch.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore /// Reusable Docker Hub image search. Before the user types it offers ready-to-run **starters** and a @@ -155,7 +156,7 @@ struct RegistryImageSearch: View { ResourceCardHeader { ResourceCardIconChip(symbol: symbol, tint: .accentColor) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { ResourceCardTitleText(text: title) if let subtitle, !subtitle.isEmpty { ResourceCardSubtitleText(text: subtitle) diff --git a/Sources/Contained/Features/MenuBar/MenuBarContent.swift b/Sources/Contained/Features/MenuBar/MenuBarContent.swift index 2f67f788..80c3f444 100644 --- a/Sources/Contained/Features/MenuBar/MenuBarContent.swift +++ b/Sources/Contained/Features/MenuBar/MenuBarContent.swift @@ -1,6 +1,7 @@ import SwiftUI import AppKit import ContainedCore +import ContainedDesignSystem /// The menu shown by the menu-bar extra: a compact command surface with service status, running /// containers, live resource counts, and the same creation / navigation affordances as the app menu. @@ -28,7 +29,7 @@ struct MenuBarContent: View { } var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: Tokens.Space.m) { header Divider() @@ -144,13 +145,13 @@ struct MenuBarContent: View { footerRow } - .padding(14) - .frame(width: 340) + .padding(Tokens.MenuBar.padding) + .frame(width: Tokens.MenuBar.width) } @ViewBuilder private var header: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: Tokens.Toolbar.searchIconGap) { HStack(alignment: .firstTextBaseline) { Label("Contained", systemImage: app.serviceHealthy ? "shippingbox.fill" : "shippingbox") .font(.headline) @@ -160,7 +161,7 @@ struct MenuBarContent: View { .foregroundStyle(.secondary) } - HStack(spacing: 10) { + HStack(spacing: Tokens.ResourceCard.padding) { Label(app.serviceLabel, systemImage: app.serviceHealthy ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") .foregroundStyle(app.serviceHealthy ? .green : .secondary) Text(app.settings.updateChannel.displayName) @@ -177,7 +178,7 @@ struct MenuBarContent: View { @ViewBuilder private var infoGrid: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: Tokens.Space.s) { infoRow("Containers", value: "\(store.running.count) running · \(stopped.count) stopped") infoRow("Resources", value: "\(app.images.count) images · \(app.volumes.count) volumes · \(app.networks.count) networks") infoRow("Bootstrap", value: cliLabel) @@ -187,7 +188,7 @@ struct MenuBarContent: View { } private var actionStrip: some View { - HStack(spacing: 8) { + HStack(spacing: Tokens.Space.s) { miniAction("Open", systemImage: "app") miniAction("Run", systemImage: "plus") { route(.runContainer) } miniAction("Activity", systemImage: unreadActivityCount > 0 ? "bell.badge" : "bell") { route(.activityHistory) } @@ -196,7 +197,7 @@ struct MenuBarContent: View { } private var footerRow: some View { - HStack(spacing: 8) { + HStack(spacing: Tokens.Space.s) { Button("Open Contained") { activate() } Spacer(minLength: 0) Button("Quit") { NSApplication.shared.terminate(nil) } @@ -219,10 +220,10 @@ struct MenuBarContent: View { @ViewBuilder private func infoRow(_ title: String, value: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 10) { + HStack(alignment: .firstTextBaseline, spacing: Tokens.ResourceCard.padding) { Text(title) .foregroundStyle(.secondary) - .frame(width: 78, alignment: .leading) + .frame(width: Tokens.MenuBar.titleWidth, alignment: .leading) Text(value) .foregroundStyle(.primary) Spacer(minLength: 0) diff --git a/Sources/Contained/Features/Onboarding/BootstrapView.swift b/Sources/Contained/Features/Onboarding/BootstrapView.swift index bb4c76f3..8d53a3e0 100644 --- a/Sources/Contained/Features/Onboarding/BootstrapView.swift +++ b/Sources/Contained/Features/Onboarding/BootstrapView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import AppKit import ContainedCore diff --git a/Sources/Contained/Features/Palette/CommandPalette.swift b/Sources/Contained/Features/Palette/CommandPalette.swift index 4bceb457..e347035f 100644 --- a/Sources/Contained/Features/Palette/CommandPalette.swift +++ b/Sources/Contained/Features/Palette/CommandPalette.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore /// A palette search scope. Pins a chip to the search field and searches in-place instead of leaving diff --git a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift b/Sources/Contained/Features/Registries/RegistryLoginSheet.swift index d08439bb..d964e308 100644 --- a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift +++ b/Sources/Contained/Features/Registries/RegistryLoginSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore /// Sign in to a registry. The user types their own credentials; the password is sent via stdin. diff --git a/Sources/Contained/Features/Settings/ConfigTransfer.swift b/Sources/Contained/Features/Settings/ConfigTransfer.swift index 18df2b55..c3814e7f 100644 --- a/Sources/Contained/Features/Settings/ConfigTransfer.swift +++ b/Sources/Contained/Features/Settings/ConfigTransfer.swift @@ -1,6 +1,7 @@ import SwiftUI import AppKit import UniformTypeIdentifiers +import ContainedDesignSystem enum ConfigImportMode: String, CaseIterable, Identifiable { case merge = "Merge" @@ -20,7 +21,7 @@ struct ConfigTransferControls: View { @State private var importMode: ConfigImportMode = .merge var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: Tokens.Space.s) { ForEach(AppStateSection.allCases) { section in Toggle(section.displayName, isOn: binding(for: section)) .toggleStyle(.checkbox) diff --git a/Sources/Contained/Features/Settings/ReleaseNotesView.swift b/Sources/Contained/Features/Settings/ReleaseNotesView.swift index 7fb27eb6..907f6f67 100644 --- a/Sources/Contained/Features/Settings/ReleaseNotesView.swift +++ b/Sources/Contained/Features/Settings/ReleaseNotesView.swift @@ -1,5 +1,6 @@ import SwiftUI import WebKit +import ContainedDesignSystem struct ReleaseNotesView: View { var title: String @@ -19,7 +20,7 @@ struct ReleaseNotesView: View { Divider() HTMLView(html: html) } - .frame(width: 620, height: 520) + .frame(Tokens.SheetSize.releaseNotes) } } diff --git a/Sources/Contained/Features/Settings/SettingsView.swift b/Sources/Contained/Features/Settings/SettingsView.swift index 525cc3ba..0fe5e2ac 100644 --- a/Sources/Contained/Features/Settings/SettingsView.swift +++ b/Sources/Contained/Features/Settings/SettingsView.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import AppKit import ContainedCore diff --git a/Sources/Contained/Features/Settings/Tabs/AboutTab.swift b/Sources/Contained/Features/Settings/Tabs/AboutTab.swift index 04ef4e59..bc60bbf4 100644 --- a/Sources/Contained/Features/Settings/Tabs/AboutTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/AboutTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import AppKit import ContainedCore @@ -12,8 +13,9 @@ struct AboutTab: View { PanelSection { HStack(spacing: Tokens.Space.m) { Image(nsImage: NSApp.applicationIconImage) - .resizable().frame(width: 56, height: 56) - VStack(alignment: .leading, spacing: 2) { + .resizable() + .frame(width: Tokens.IconSize.appIcon, height: Tokens.IconSize.appIcon) + VStack(alignment: .leading, spacing: Tokens.Space.xxs) { Text("Contained").font(.title3.weight(.semibold)) Text("Version \(appVersion)").font(.callout).foregroundStyle(.secondary) Text("A native macOS UI for Apple’s container runtime.") diff --git a/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift b/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift index 7ff2fbba..eea3b8c3 100644 --- a/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore // MARK: - Appearance @@ -58,7 +59,8 @@ struct AppearanceTab: View { } PanelRow(title: "Opacity") { HStack(spacing: Tokens.Space.s) { - Slider(value: $settings.buttonTintOpacity, in: 0.05...0.6).frame(width: 140) + Slider(value: $settings.buttonTintOpacity, in: 0.05...0.6) + .frame(width: Tokens.FormWidth.compactSlider) Text(Format.percent(settings.buttonTintOpacity)) .monospacedDigit() .frame(width: Tokens.FormWidth.shortReadout) @@ -103,7 +105,7 @@ private struct ImageDefaultStyleSection: View { enabled: $settings.imageDefaultStyleEnabled) { HStack(spacing: Tokens.Space.m) { ResourceCardIconChip(symbol: style.symbol, tint: style.color) - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { Text(style.displayName(fallback: "Image cards")) Text("Inherited unless an image, group, tag, or container overrides it") .font(.caption) @@ -119,14 +121,15 @@ private struct ImageDefaultStyleSection: View { PanelRow(title: "Icon") { TextField("", text: styleBinding(\.icon), prompt: Text("SF Symbol, e.g. shippingbox.fill")) .textFieldStyle(.roundedBorder) - .frame(width: 220) + .frame(width: Tokens.FormWidth.tintColorHex) } } PanelToggleRow(title: "Color the card background", isOn: styleBinding(\.fillBackground)) if style.fillBackground { PanelRow(title: "Opacity") { HStack(spacing: Tokens.Space.s) { - Slider(value: styleBinding(\.backgroundOpacity), in: 0.05...0.6).frame(width: 140) + Slider(value: styleBinding(\.backgroundOpacity), in: 0.05...0.6) + .frame(width: Tokens.FormWidth.compactSlider) Text(Format.percent(style.backgroundOpacity)) .monospacedDigit() .frame(width: Tokens.FormWidth.shortReadout) diff --git a/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift b/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift index e96bdff3..85f3068b 100644 --- a/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore // MARK: - Experimental diff --git a/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift b/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift index 8d9c39f2..7396fe79 100644 --- a/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore // MARK: - General @@ -23,11 +24,14 @@ struct GeneralTab: View { } PanelSection(header: "Data", - footer: "How often running containers are polled, and how long persistent metrics & events are kept before pruning.") { + footer: "How often the container list refreshes. Live metrics use a slower sampler unless the Containers view or Stats tab needs them.") { PanelRow(title: "Refresh interval") { HStack(spacing: Tokens.Space.s) { - Slider(value: $settings.refreshInterval, in: 1...10, step: 1).frame(width: 140) - Text("\(Int(settings.refreshInterval))s").monospacedDigit().frame(width: 32, alignment: .trailing) + Slider(value: $settings.refreshInterval, in: 1...10, step: 1) + .frame(width: Tokens.FormWidth.compactSlider) + Text("\(Int(settings.refreshInterval))s") + .monospacedDigit() + .frame(width: Tokens.FormWidth.refreshReadout, alignment: .trailing) } } PanelRow(title: "Keep history for") { diff --git a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift b/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift index a383afc7..da74ad14 100644 --- a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore // MARK: - Registries @@ -20,7 +21,7 @@ struct RegistriesTab: View { } else { ForEach(app.registries) { login in HStack { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: Tokens.Space.xxs) { Text(login.host) if let user = login.username { Text("as \(user)").font(.caption).foregroundStyle(.secondary) @@ -62,4 +63,3 @@ struct RegistriesTab: View { catch { app.flash(error.localizedDescription) } } } - diff --git a/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift b/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift index 1ece95f2..bcc05721 100644 --- a/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore // MARK: - Runtime diff --git a/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift b/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift index a6da60e5..62febcca 100644 --- a/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore // MARK: - Updates diff --git a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift b/Sources/Contained/Features/System/Components/SystemLogsSheet.swift index 5ebe25c3..3dccd21b 100644 --- a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift +++ b/Sources/Contained/Features/System/Components/SystemLogsSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ContainedCore struct SystemLogsSheet: View { diff --git a/Sources/Contained/Features/System/SystemView.swift b/Sources/Contained/Features/System/SystemView.swift index 8446f627..6331709c 100644 --- a/Sources/Contained/Features/System/SystemView.swift +++ b/Sources/Contained/Features/System/SystemView.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import ContainedCore /// System overview content: service status + controls, volumes, `system df` disk usage, a Prune @@ -215,12 +217,7 @@ struct SystemContent: View { card { HStack { Text("Volumes").font(.headline) - Text("\(volumeInventory.count)") - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 7) - .padding(.vertical, 2) - .background(AppMaterial.toolbarHoverFill, in: Capsule()) + ResourceBadgeText(text: "\(volumeInventory.count)") Spacer() GlassButton(singleItem: true) { GlassButtonItem(help: "New volume", action: { @@ -249,8 +246,10 @@ struct SystemContent: View { private func volumeRow(_ entry: VolumeInventoryEntry) -> some View { HStack(spacing: Tokens.Space.m) { - Image(systemName: entry.kind.symbol).foregroundStyle(.secondary).frame(width: 20) - VStack(alignment: .leading, spacing: 1) { + Image(systemName: entry.kind.symbol) + .foregroundStyle(.secondary) + .frame(width: Tokens.IconSize.rowIconColumn) + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { HStack(spacing: Tokens.Space.xs) { Text(entry.title).font(.system(.callout, design: .monospaced)).lineLimit(1) ResourceBadgeText(text: entry.kind.rawValue) @@ -353,7 +352,9 @@ struct SystemContent: View { isOn: settingBinding(\.autoRestartEnabled)) { EmptyView() } Divider() HStack(spacing: Tokens.Space.s) { - Image(systemName: "dot.radiowaves.left.and.right").foregroundStyle(.secondary).frame(width: 20) + Image(systemName: "dot.radiowaves.left.and.right") + .foregroundStyle(.secondary) + .frame(width: Tokens.IconSize.rowIconColumn) Text("Refresh loop").font(.callout) Spacer() Text(app.coordinator.isActive ? "Active" : "Paused") @@ -369,8 +370,8 @@ struct SystemContent: View { HStack(spacing: Tokens.Space.m) { Image(systemName: icon).font(.title3) .foregroundStyle(isOn.wrappedValue ? Color.accentColor : .secondary) - .frame(width: 20) - VStack(alignment: .leading, spacing: 2) { + .frame(width: Tokens.IconSize.rowIconColumn) + VStack(alignment: .leading, spacing: Tokens.Space.xxs) { Text(title).font(.callout) Text(detail).font(.caption).foregroundStyle(.secondary) } @@ -445,13 +446,11 @@ struct SystemContent: View { private var engineStatusCard: some View { card { HStack(spacing: Tokens.Space.s) { - Circle().fill(app.serviceHealthy ? Color.green : Color.orange).frame(width: 9, height: 9) + DesignStatusDot(color: app.serviceHealthy ? .green : .orange, + size: Tokens.IconSize.serviceDot) Text("Container engine").font(.headline) - Text(app.serviceLabel) - .font(.caption.weight(.medium)) - .foregroundStyle(app.serviceHealthy ? .green : .orange) - .padding(.horizontal, Tokens.Space.s).padding(.vertical, 2) - .background((app.serviceHealthy ? Color.green : Color.orange).opacity(0.14), in: Capsule()) + DesignStatusBadge(text: app.serviceLabel, + tint: app.serviceHealthy ? .green : .orange) Spacer(minLength: 0) if let version = app.systemStatus?.apiServerVersion { Text(version).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary) @@ -459,27 +458,14 @@ struct SystemContent: View { } } HStack(spacing: Tokens.Space.s) { - statTile("Containers", value: "\(app.containers.running.count)", caption: "running") - statTile("Images", value: "\(app.images.count)", caption: nil) - statTile("Disk used", value: app.diskUsage.map { Format.bytes($0.totalSizeInBytes) } ?? "—", caption: nil) + DesignMetricTile(label: "Containers", value: "\(app.containers.running.count)", caption: "running") + DesignMetricTile(label: "Images", value: "\(app.images.count)") + DesignMetricTile(label: "Disk used", value: app.diskUsage.map { Format.bytes($0.totalSizeInBytes) } ?? "—") } if working { ProgressView().controlSize(.small) } } } - private func statTile(_ label: String, value: String, caption: String?) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label).font(.caption).foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(value).font(.title3.weight(.medium)) - if let caption { Text(caption).font(.caption).foregroundStyle(.secondary) } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Tokens.Space.m).padding(.vertical, Tokens.Space.s) - .background(AppMaterial.toolbarHoverFill, in: RoundedRectangle(cornerRadius: Tokens.Radius.control, style: .continuous)) - } - private func run(_ action: @escaping () async -> Void) { working = true Task { await action(); working = false } diff --git a/Sources/Contained/History/ActivityView.swift b/Sources/Contained/History/ActivityView.swift index a63c92c7..04966b32 100644 --- a/Sources/Contained/History/ActivityView.swift +++ b/Sources/Contained/History/ActivityView.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import SwiftData /// System-wide activity log: every recorded event across all containers, newest first, filterable diff --git a/Sources/Contained/History/ContainerHistoryTab.swift b/Sources/Contained/History/ContainerHistoryTab.swift index 1c3e0110..668da187 100644 --- a/Sources/Contained/History/ContainerHistoryTab.swift +++ b/Sources/Contained/History/ContainerHistoryTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import SwiftData import Charts import ContainedCore @@ -76,7 +77,7 @@ private struct ContainerHistoryWindow: View { Chart(samples) { sample in AreaMark(x: .value("Time", sample.timestamp), y: .value("Memory", sample.memoryBytes / 1_048_576)) - .foregroundStyle(Color.accentColor.opacity(0.3)) + .foregroundStyle(Color.accentColor.opacity(Tokens.Chart.areaOpacity)) } } chartCard("Network", unit: "KB/s") { @@ -112,8 +113,8 @@ private struct ContainerHistoryWindow: View { Text(unit).font(.caption).foregroundStyle(.secondary) } chart() - .frame(height: 140) - .chartXAxis { AxisMarks(values: .automatic(desiredCount: 4)) } + .frame(height: Tokens.Chart.height) + .chartXAxis { AxisMarks(values: .automatic(desiredCount: Tokens.Chart.axisDesiredCount)) } } } } @@ -134,9 +135,9 @@ struct EventRow: View { ResourceCardHeader { ResourceCardIconChip(symbol: event.kind.symbol, tint: event.kind.tint, - backgroundOpacity: 0.22) + backgroundOpacity: Tokens.ResourceCard.iconEmphasisBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { ResourceCardTitleText(text: event.message) HStack(spacing: Tokens.Space.xs) { ResourceBadgeText(text: event.kind.rawValue.capitalized) @@ -145,7 +146,7 @@ struct EventRow: View { } } trailing: { if isUnread { - Circle().fill(Color.accentColor).frame(width: 8, height: 8) + DesignStatusDot(color: .accentColor) .accessibilityLabel("Unread") } else { EmptyView() diff --git a/Sources/Contained/Migration/DowngradeDecisionView.swift b/Sources/Contained/Migration/DowngradeDecisionView.swift index 6d2aa2fb..f1ea93e7 100644 --- a/Sources/Contained/Migration/DowngradeDecisionView.swift +++ b/Sources/Contained/Migration/DowngradeDecisionView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem struct DowngradeDecisionView: View { let schemaVersion: Int @@ -26,6 +27,6 @@ struct DowngradeDecisionView: View { .frame(maxWidth: .infinity, alignment: .leading) } .padding(Tokens.Space.l) - .frame(width: 460) + .frame(width: Tokens.SheetSize.dialogWidth) } } diff --git a/Sources/Contained/Navigation/AppToolbar.swift b/Sources/Contained/Navigation/AppToolbar.swift index d5941bb5..421f5737 100644 --- a/Sources/Contained/Navigation/AppToolbar.swift +++ b/Sources/Contained/Navigation/AppToolbar.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import SwiftData import ContainedCore @@ -64,7 +66,7 @@ struct AppToolbar: View { .zIndex(toolbarImageDetail == nil ? 0 : 350) } .coordinateSpace(.named(Self.space)) - .onPreferenceChange(ToolbarSlotKey.self) { slots = $0 } + .onPreferenceChange(ToolbarSlotKey.self) { updateSlots($0) } .onChange(of: ui.activeMorph) { _, morph in if morph == nil { setMorphBackdropExpanded(false) } } @@ -161,15 +163,18 @@ struct AppToolbar: View { openGlobalSectionOrPanel(.system, morph: .system) }) { if let activity = app.activity { - ActivityStatusView(activity: activity, style: .inline) + ActivityStatusView(activity: ActivityStatusPresentation(title: activity.title, + detail: activity.detail, + fraction: activity.fraction), + style: .inline) } else { HStack(spacing: Tokens.Toolbar.searchIconGap) { Image(systemName: systemStatusIcon) .foregroundStyle(systemStatusColor) - .frame(width: Tokens.Toolbar.buttonItemHeight - Tokens.Toolbar.iconInnerPadding * 2) + .frame(width: Tokens.Toolbar.iconContentWidth) Text(app.serviceLabel) .foregroundStyle(.secondary) - .padding(.trailing, Tokens.Toolbar.iconInnerPadding * 2) + .padding(.trailing, Tokens.Toolbar.statusLabelTrailingPadding) } } } @@ -354,6 +359,11 @@ struct AppToolbar: View { morphBackdropExpanded = isExpanded } + private func updateSlots(_ next: [UIState.ToolbarMorph: CGRect]) { + guard !slots.isClose(to: next) else { return } + slots = next + } + private var toolbarImageDetailBinding: Binding { Binding(get: { toolbarImageDetailPresented }, set: { guard !$0 else { @@ -516,3 +526,22 @@ private struct ToolbarSlotKey: PreferenceKey { value.merge(nextValue()) { _, new in new } } } + +private extension Dictionary where Key == UIState.ToolbarMorph, Value == CGRect { + func isClose(to other: [UIState.ToolbarMorph: CGRect], tolerance: CGFloat = 0.5) -> Bool { + guard count == other.count else { return false } + return allSatisfy { key, frame in + guard let otherFrame = other[key] else { return false } + return frame.isClose(to: otherFrame, tolerance: tolerance) + } + } +} + +private extension CGRect { + func isClose(to other: CGRect, tolerance: CGFloat = 0.5) -> Bool { + abs(minX - other.minX) <= tolerance && + abs(minY - other.minY) <= tolerance && + abs(width - other.width) <= tolerance && + abs(height - other.height) <= tolerance + } +} diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/Contained/Navigation/ClassicShell.swift index 8a7d3b0a..5ad505ff 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/Contained/Navigation/ClassicShell.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import SwiftData import ContainedCore @@ -266,7 +268,7 @@ private struct NetworksPage: View { ResourceCardIconChip(symbol: network.isBuiltin ? "network.badge.shield.half.filled" : "network", tint: network.isBuiltin ? .secondary : .accentColor) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { ResourceCardTitleText(text: network.name) ResourceCardSubtitleText(text: networkSubtitle(network)) } diff --git a/Sources/Contained/Navigation/RootView.swift b/Sources/Contained/Navigation/RootView.swift index ce08c9df..310212b6 100644 --- a/Sources/Contained/Navigation/RootView.swift +++ b/Sources/Contained/Navigation/RootView.swift @@ -1,7 +1,9 @@ import SwiftUI +import ContainedNavigation import AppKit import UniformTypeIdentifiers import ContainedCore +import ContainedDesignSystem /// The app shell. Fresh installs use the classic sidebar; the experimental toolbar shell adds morph /// panels, command palette routing, and page-background overflow actions on top of the same app state. @@ -88,12 +90,16 @@ struct RootView: View { gradientAngle: settings.buttonTintGradientAngle, blendMode: settings.buttonTintBlendMode)) .environment(\.cardMaterial, settings.cardMaterial) + .environment(\.designSystemShowsInfoTips, settings.showInfoTips) + .environment(\.pageScaffoldUsesToolbarChrome, settings.experimentalToolbarUI) + .environment(\.pageScaffoldBottomClearance, settings.experimentalToolbarUI ? AppToolbar.bandHeight : 0) .preferredColorScheme(settings.appearance.colorScheme) .onAppear { applyAppearance(settings.appearance) } .onAppear { ui.toolbarUIEnabled = settings.experimentalToolbarUI } .onAppear { ui.panelNavigationEnabled = settings.usesPanelNavigation ui.ensureSelectedSectionIsNavigable() + updateContainerStatsVisibility() } .onChange(of: settings.appearance) { _, mode in applyAppearance(mode) } .onChange(of: settings.experimentalToolbarUI) { _, enabled in @@ -101,12 +107,16 @@ struct RootView: View { ui.panelNavigationEnabled = settings.usesPanelNavigation if !enabled { ui.activeMorph = nil } ui.ensureSelectedSectionIsNavigable() + updateContainerStatsVisibility() } .onChange(of: settings.experimentalPanelNavigation) { _, _ in ui.panelNavigationEnabled = settings.usesPanelNavigation if !settings.usesPanelNavigation { ui.activeMorph = nil } ui.ensureSelectedSectionIsNavigable() + updateContainerStatsVisibility() } + .onChange(of: ui.selectedSection) { _, _ in updateContainerStatsVisibility() } + .onChange(of: ui.activeMorph) { _, _ in updateContainerStatsVisibility() } .onChange(of: settings.imageBuildEnabled) { _, enabled in if !enabled, ui.selectedSection == .build { ui.navigate(to: .images) @@ -196,6 +206,10 @@ struct RootView: View { } } + private func updateContainerStatsVisibility() { + app.setContainerStatsVisible(ui.selectedSection == .containers && ui.activeMorph == nil) + } + private func openSectionOrMorph(_ section: AppSection, morph: UIState.ToolbarMorph) { if app.settings.usesPanelNavigation { ui.toggleMorph(morph) @@ -241,7 +255,7 @@ struct RootView: View { .font(.callout.weight(.medium)) .padding(.horizontal, Tokens.Space.l) .padding(.vertical, Tokens.Space.s) - .glassEffect(.regular, in: Capsule()) + .glassCapsuleSurface(shadow: false) .transition(.move(edge: .bottom).combined(with: .opacity)) } } diff --git a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift index ab8f6c5c..4ed4b396 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import AppKit import ContainedCore @@ -39,13 +40,17 @@ struct PaletteResultCard: View { ResourceGlassCard(size: .small, isSelected: selected, fill: nil, - fillOpacity: selected ? 0.10 : 0.18, + fillOpacity: selected ? Tokens.ResourceCard.selectedSubtleFillOpacity : Tokens.ResourceCard.plainFillOpacity, elevated: false, onTap: action) { ResourceCardHeader { - ResourceCardIconChip(symbol: item.icon, tint: item.tint, backgroundOpacity: selected ? 0.24 : 0.16) + ResourceCardIconChip(symbol: item.icon, + tint: item.tint, + backgroundOpacity: selected + ? Tokens.ResourceCard.iconSelectedBackgroundOpacity + : Tokens.ResourceCard.iconBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { HStack(spacing: Tokens.Space.s) { ResourceCardTitleText(text: item.title) ResourceBadgeText(text: item.kind.rawValue, @@ -75,16 +80,20 @@ struct PaletteResultCard: View { return ResourceGlassCard(size: cardSize, isSelected: selected, fill: style.fillBackground ? style.color : nil, - fillOpacity: selected ? 0.14 : style.backgroundOpacity, + fillOpacity: selected ? Tokens.ResourceCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, gradient: style.gradient, gradientAngle: style.gradientAngle, blendMode: style.backgroundBlendMode, elevated: false, onTap: action) { ResourceCardHeader { - ResourceCardIconChip(symbol: style.symbol, tint: style.color, backgroundOpacity: selected ? 0.24 : 0.16) + ResourceCardIconChip(symbol: style.symbol, + tint: style.color, + backgroundOpacity: selected + ? Tokens.ResourceCard.iconSelectedBackgroundOpacity + : Tokens.ResourceCard.iconBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { HStack(spacing: Tokens.Space.s) { ResourceCardTitleText(text: name) ResourceBadgeText(text: snapshot.state.rawValue.capitalized, @@ -105,8 +114,8 @@ struct PaletteResultCard: View { } widget: { if snapshot.state == .running { containerPaletteWidget(snapshot) - .padding(.horizontal, 10) - .padding(.bottom, 10) + .padding(.horizontal, Tokens.ResourceCard.padding) + .padding(.bottom, Tokens.ResourceCard.padding) } } .selectionFill() @@ -115,13 +124,7 @@ struct PaletteResultCard: View { private func imageGroupCard(_ group: LocalImageTagGroup) -> some View { ToolbarImageGroupCard(group: group, isExpanded: false, onTap: action, onClose: {}) - .overlay { - if selected { - RoundedRectangle(cornerRadius: Tokens.Radius.card, style: .continuous) - .fill(AppMaterial.toolbarHoverFill) - .allowsHitTesting(false) - } - } + .designCardSelectionOverlay(when: selected) .accessibilityAddTraits(selected ? .isSelected : []) } @@ -130,16 +133,20 @@ struct PaletteResultCard: View { return ResourceGlassCard(size: .medium, isSelected: selected, fill: style.fillBackground ? style.color : nil, - fillOpacity: selected ? 0.14 : style.backgroundOpacity, + fillOpacity: selected ? Tokens.ResourceCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, gradient: style.gradient, gradientAngle: style.gradientAngle, blendMode: style.backgroundBlendMode, elevated: false, onTap: action) { ResourceCardHeader { - ResourceCardIconChip(symbol: "tag", tint: style.color, backgroundOpacity: selected ? 0.24 : 0.16) + ResourceCardIconChip(symbol: "tag", + tint: style.color, + backgroundOpacity: selected + ? Tokens.ResourceCard.iconSelectedBackgroundOpacity + : Tokens.ResourceCard.iconBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { HStack(spacing: Tokens.Space.s) { ResourceCardMonospacedTitleText(text: Format.shortImage(reference)) ResourceBadgeText(text: "Tag", font: .caption2.weight(.semibold)) @@ -169,13 +176,17 @@ struct PaletteResultCard: View { ResourceGlassCard(size: .small, isSelected: selected, fill: nil, - fillOpacity: selected ? 0.12 : 0.18, + fillOpacity: selected ? Tokens.ResourceCard.selectedResourceFillOpacity : Tokens.ResourceCard.plainFillOpacity, elevated: false, onTap: action) { ResourceCardHeader { - ResourceCardIconChip(symbol: symbol, tint: item.tint, backgroundOpacity: selected ? 0.24 : 0.16) + ResourceCardIconChip(symbol: symbol, + tint: item.tint, + backgroundOpacity: selected + ? Tokens.ResourceCard.iconSelectedBackgroundOpacity + : Tokens.ResourceCard.iconBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { HStack(spacing: Tokens.Space.s) { ResourceCardTitleText(text: title) ResourceBadgeText(text: subtitle, font: .caption2.weight(.semibold)) @@ -194,21 +205,13 @@ struct PaletteResultCard: View { ResourceGlassCard(size: .small, isSelected: selected, fill: tint.color, - fillOpacity: selected ? 0.18 : 0.10, + fillOpacity: selected ? Tokens.ResourceCard.selectedTintFillOpacity : Tokens.ResourceCard.selectedSubtleFillOpacity, elevated: false, onTap: action) { ResourceCardHeader { - ZStack { - Circle().fill(tint.color) - if tint.followsAppAccent { - Image(systemName: "link") - .font(.caption2.weight(.bold)) - .foregroundStyle(.white) - } - } - .frame(width: Tokens.IconSize.chip, height: Tokens.IconSize.chip) + DesignTintSwatch(color: tint.color, followsAppAccent: tint.followsAppAccent) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { HStack(spacing: Tokens.Space.s) { ResourceCardTitleText(text: tint.displayName) ResourceBadgeText(text: app.settings.accentTint == tint ? "Current" : "Tint", @@ -226,7 +229,7 @@ struct PaletteResultCard: View { } private func containerPaletteWidget(_ snapshot: ContainerSnapshot) -> some View { - HStack(spacing: 10) { + HStack(spacing: Tokens.ResourceCard.padding) { ResourceCardFooterMini { Image(systemName: "clock").font(.caption2) } text: { diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift index 4ffca27d..baa4fbad 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import SwiftData import AppKit import ContainedCore @@ -76,11 +78,11 @@ struct ToolbarCommandPalette: View { VStack(spacing: 0) { VStack(spacing: Tokens.Space.xs) { fieldRow - .frame(height: Tokens.Toolbar.searchOpenHeaderHeight) + .frame(height: Tokens.Toolbar.searchOpenHeaderHeight) inlineSearchRow } .padding(.bottom, Tokens.Space.s) - Divider().opacity(0.5) + Divider() } } content: { resultsList @@ -136,15 +138,7 @@ struct ToolbarCommandPalette: View { /// The pinned scope token shown in the search field. private func scopeChip(_ scope: PaletteScope) -> some View { Button { ui.paletteScope = nil } label: { - HStack(spacing: 4) { - Image(systemName: scope.symbol).font(.caption2) - Text(scope.title).font(.caption.weight(.semibold)) - Image(systemName: "xmark").font(.caption2.weight(.bold)) - } - .padding(.horizontal, Tokens.Space.s) - .padding(.vertical, 3) - .background(Color.accentColor.opacity(0.16), in: Capsule(style: .continuous)) - .foregroundStyle(Color.accentColor) + DesignScopeChipLabel(symbol: scope.symbol, title: scope.title) } .buttonStyle(.plain) .help("Remove \(scope.title) scope") @@ -305,16 +299,7 @@ struct ToolbarCommandPalette: View { } private func keyboardHint(_ key: String, _ label: String) -> some View { - HStack(spacing: Tokens.Space.xs) { - Text(key) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(.quaternary, in: RoundedRectangle(cornerRadius: Tokens.Radius.keyCap, - style: .continuous)) - Text(label).font(.caption).foregroundStyle(.secondary) - } + DesignKeyboardHint(key, label) } // MARK: Sections diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index d5bafb63..162848fa 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import SwiftData import AppKit import ContainedCore @@ -81,7 +83,7 @@ struct ToolbarImageGroupCard: View { } bodyContent: { tagList(group) } footerLeading: { - HStack(spacing: 10) { + HStack(spacing: Tokens.ResourceCard.padding) { imageFooterTagCount(group) imageFooterInfo(status) } @@ -97,7 +99,7 @@ struct ToolbarImageGroupCard: View { switch page { case .root: return Tokens.PanelSize.imageDetail case .inspect, .history: return Tokens.SheetSize.inspector - case .tag: return CGSize(width: 560, height: 360) + case .tag: return Tokens.PanelSize.imageTag case .push: return Tokens.SheetSize.console } } @@ -147,7 +149,7 @@ struct ToolbarImageGroupCard: View { ScrollView { LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { ForEach(Array(history.enumerated()), id: \.offset) { _, entry in - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: Tokens.Space.xxs) { Text(entry.createdBy ?? entry.comment ?? "—") .font(.system(.caption, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) @@ -158,9 +160,7 @@ struct ToolbarImageGroupCard: View { } .padding(Tokens.Space.s) .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary.opacity(0.25), - in: RoundedRectangle(cornerRadius: Tokens.Radius.control, - style: .continuous)) + .subtleTileBackground() } } .padding(Tokens.Space.s) @@ -236,7 +236,7 @@ struct ToolbarImageGroupCard: View { imageChip(style) } } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { ResourceCardTitleText(text: repositoryTitle(group.primaryReference)) ResourceCardSubtitleText(text: repositoryOwner(group.primaryReference)) } @@ -327,7 +327,7 @@ struct ToolbarImageGroupCard: View { style: style, target: .imageTag(reference: reference, groupID: group.id)) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { ResourceCardMonospacedTitleText(text: Format.shortImage(reference)) ResourceCardSubtitleText(text: repositoryName(reference)) } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift index a4e76fec..bb7b495f 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import SwiftData import AppKit import ContainedCore @@ -124,9 +126,11 @@ struct ToolbarTemplatesPanel: View { private var emptyCard: some View { ResourceGlassCard(size: .small, elevated: false) { ResourceCardHeader { - ResourceCardIconChip(symbol: "bookmark", tint: .secondary, backgroundOpacity: 0.22) + ResourceCardIconChip(symbol: "bookmark", + tint: .secondary, + backgroundOpacity: Tokens.ResourceCard.iconEmphasisBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { ResourceCardTitleText(text: "No templates") ResourceCardSubtitleText(text: "Save a container's settings as a template from the create form.") } @@ -141,7 +145,7 @@ struct ToolbarTemplatesPanel: View { ResourceCardHeader { ResourceCardIconChip(symbol: "bookmark.fill", tint: .accentColor) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { ResourceCardTitleText(text: template.name) ResourceCardMonospacedSubtitleText(text: Format.shortImage(template.spec?.image ?? "—")) } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift index 7ec51744..61a9afe6 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem struct ToolbarSearchSource: View { @Environment(AppModel.self) private var app @@ -36,7 +37,7 @@ struct ToolbarSearchSource: View { } } } - .contentShape(Capsule(style: .continuous)) + .toolbarControlContentShape() .simultaneousGesture(TapGesture().onEnded { focused = true }) .onChange(of: ui.searchText) { _, _ in escalateIfEmpty() } .onChange(of: ui.pageResultCount) { _, _ in escalateIfEmpty() } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift index d843e745..620f1fb9 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedNavigation +import ContainedDesignSystem import SwiftData import AppKit import ContainedCore @@ -98,7 +100,7 @@ struct ToolbarUpdatesPanel: View { ResourceCardHeader { ResourceCardIconChip(symbol: "checkmark.circle.fill", tint: .green) } content: { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { ResourceCardTitleText(text: "No images") ResourceCardSubtitleText(text: "Pull or build an image to see it here") } @@ -119,15 +121,20 @@ struct ToolbarUpdatesPanel: View { GeometryReader { proxy in Color.clear .onAppear { - imageFrames[group.id] = proxy.frame(in: .named(coordinateSpaceName)) + updateImageFrame(proxy.frame(in: .named(coordinateSpaceName)), for: group.id) } .onChange(of: proxy.frame(in: .named(coordinateSpaceName))) { _, frame in - imageFrames[group.id] = frame + updateImageFrame(frame, for: group.id) } } } } + private func updateImageFrame(_ frame: CGRect, for id: LocalImageTagGroup.ID) { + guard imageFrames[id]?.isClose(to: frame) != true else { return } + imageFrames[id] = frame + } + private func imageRank(_ group: LocalImageTagGroup) -> Int { switch app.imageUpdateStatus(for: group.primaryReference).state { case .updateAvailable: return 0 @@ -191,3 +198,12 @@ struct ToolbarUpdatesPanel: View { } } + +private extension CGRect { + func isClose(to other: CGRect, tolerance: CGFloat = 0.5) -> Bool { + abs(minX - other.minX) <= tolerance && + abs(minY - other.minY) <= tolerance && + abs(width - other.width) <= tolerance && + abs(height - other.height) <= tolerance + } +} diff --git a/Sources/Contained/Navigation/ToolbarViewOptions.swift b/Sources/Contained/Navigation/ToolbarViewOptions.swift index 0b5df3f8..af4b6444 100644 --- a/Sources/Contained/Navigation/ToolbarViewOptions.swift +++ b/Sources/Contained/Navigation/ToolbarViewOptions.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import SwiftData import ContainedCore @@ -405,6 +406,6 @@ private struct NetworkViewOptions: View { } } -private func optionLabel(symbol: String, title: String, subtitle: String) -> some View { +@MainActor private func optionLabel(symbol: String, title: String, subtitle: String) -> some View { ToolbarTitleSubtitleLabel(symbol: symbol, title: title, subtitle: subtitle) } diff --git a/Sources/Contained/Stores/AppModel+ImageUpdates.swift b/Sources/Contained/Stores/AppModel+ImageUpdates.swift index a6f8c3aa..18472485 100644 --- a/Sources/Contained/Stores/AppModel+ImageUpdates.swift +++ b/Sources/Contained/Stores/AppModel+ImageUpdates.swift @@ -145,6 +145,15 @@ extension AppModel { /// reference quietly, then (when `manual`) summarize how many have updates. private func runUpdateCheck(over references: [String], emptyMessage: String, summaryNoun: String, manual: Bool) async { + let started = Date() + defer { + let elapsed = Date().timeIntervalSince(started) + if elapsed >= 0.75 || manual { + let mode = manual ? "manual" : "background" + diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, + "Image update sweep \(mode, privacy: .public) finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s across \(references.count, privacy: .public) \(summaryNoun, privacy: .public)(s)") + } + } guard !references.isEmpty else { if manual { flash(emptyMessage) } return diff --git a/Sources/Contained/Stores/AppModel.swift b/Sources/Contained/Stores/AppModel.swift index 1c21e1a8..cdd7188e 100644 --- a/Sources/Contained/Stores/AppModel.swift +++ b/Sources/Contained/Stores/AppModel.swift @@ -1,5 +1,6 @@ import SwiftUI import ContainedCore +import OSLog /// Root app state: locates the CLI, owns the typed client and the feature stores, and tracks the /// service/CLI bootstrap status that gates plugin-dependent screens. @@ -36,6 +37,9 @@ final class AppModel { private(set) var systemStatus: SystemStatus? private(set) var diskUsage: DiskUsage? private(set) var cliVersion: String? + @ObservationIgnored private var containerStatsVisible = true + @ObservationIgnored private var lastRecordedStatsRevision = 0 + @ObservationIgnored let diagnosticLogger = Logger(subsystem: "app.contained.Contained", category: "diagnostic") // Resource caches shared by toolbar panels, creation pages, and the container grid. private(set) var volumes: [VolumeResource] = [] @@ -153,7 +157,7 @@ final class AppModel { } } - await refreshSystem() + await refreshSystem(statsDemand: .force) } /// Re-run CLI/service detection (onboarding "Try again"). @@ -171,11 +175,19 @@ final class AppModel { /// Proceed despite an unsupported CLI version (onboarding "Continue anyway"). func continueUnsupported() async { bootstrap = .checking - await refreshSystem() + await refreshSystem(statsDemand: .force) + } + + func setContainerStatsVisible(_ visible: Bool) { + guard containerStatsVisible != visible else { return } + containerStatsVisible = visible + if visible { coordinator.wake() } } - func refreshSystem() async { + func refreshSystem(statsDemand: ContainersStore.StatsRefreshDemand? = nil) async { guard let client else { return } + let started = Date() + let statsDemand = statsDemand ?? currentStatsDemand do { let status = try await client.systemStatus() systemStatus = status @@ -183,7 +195,7 @@ final class AppModel { bootstrap = .ready logger.record("Container service is running", category: .system, severity: .debug) await refreshDiskUsage() // throttled; the System panel can force a fresh read - await containers.refresh() + await containers.refresh(statsDemand: statsDemand) // One-time: import legacy contained.* card styles into the local store, now that we // no longer write personalization labels. personalization.migrateLegacyLabelsIfNeeded(containers.snapshots) @@ -198,6 +210,11 @@ final class AppModel { category: .system, severity: .error) } + let elapsed = Date().timeIntervalSince(started) + if elapsed >= 0.75 { + diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, + "System refresh finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s with stats demand \(statsDemand.label, privacy: .public)") + } } /// `system df` is throttled during background refresh; the System panel can force a fresh read. @@ -220,8 +237,15 @@ final class AppModel { func refreshImagesIfStale(force: Bool = false) async { guard let client, bootstrap == .ready else { return } if !force, let last = lastImagesDate, Date().timeIntervalSince(last) < Self.imagesThrottle { return } + let started = Date() imagesError = await captured { self.images = try await client.images() } lastImagesDate = Date() + let elapsed = Date().timeIntervalSince(started) + if elapsed >= 0.75 || force { + let mode = force ? "force" : "stale" + diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, + "Image refresh \(mode, privacy: .public) finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s with \(self.images.count, privacy: .public) image(s)") + } } /// Run a throwing CLI action, returning a user-facing error string on failure (nil on success). @@ -235,18 +259,39 @@ final class AppModel { /// One polling tick: refresh system + containers, run the restart watchdog, and keep the cached /// resources warm. Called by `RefreshCoordinator`. func tick() async { + let started = Date() await refreshSystem() guard bootstrap == .ready, let client else { return } if settings.autoRestartEnabled { await watchdog.evaluate(snapshots: containers.snapshots, store: containers, client: client) } await health.evaluate(snapshots: containers.snapshots, store: healthChecks, client: client) - historyStore.recordMetrics(containers.statsByID) + recordFreshMetricsIfNeeded() await refreshNetworks() // Keep the image list warm app-wide (throttled), so the toolbar Images panel and the // update badges populate without first opening the Images panel. await refreshImagesIfStale() await checkImageUpdatesIfNeeded() + let elapsed = Date().timeIntervalSince(started) + if elapsed >= 0.75 { + diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, + "Refresh tick finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s") + } + } + + private var currentStatsDemand: ContainersStore.StatsRefreshDemand { + containerStatsVisible ? .visible : .background + } + + func refreshContainerStatsNow() async { + await containers.refresh(statsDemand: .force) + recordFreshMetricsIfNeeded() + } + + private func recordFreshMetricsIfNeeded() { + guard containers.statsRevision != lastRecordedStatsRevision else { return } + lastRecordedStatsRevision = containers.statsRevision + historyStore.recordMetrics(containers.statsByID) } /// Refresh the data behind the System toolbar panel (volumes + a forced `system df`). Called from diff --git a/Sources/Contained/Stores/ContainersStore.swift b/Sources/Contained/Stores/ContainersStore.swift index ffb9375f..6ca7e343 100644 --- a/Sources/Contained/Stores/ContainersStore.swift +++ b/Sources/Contained/Stores/ContainersStore.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import OSLog import ContainedCore @@ -8,16 +9,39 @@ import ContainedCore @MainActor @Observable final class ContainersStore { + enum StatsRefreshDemand: Int, Comparable { + case background + case visible + case force + + static func < (lhs: StatsRefreshDemand, rhs: StatsRefreshDemand) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var label: String { + switch self { + case .background: "background" + case .visible: "visible" + case .force: "force" + } + } + } + var snapshots: [ContainerSnapshot] = [] var statsByID: [String: StatsDelta] = [:] /// Per-container, per-metric sparkline history. var historyByID: [String: [GraphMetric: SampleBuffer]] = [:] + private(set) var statsRevision = 0 var errorMessage: String? var busyIDs: Set = [] @ObservationIgnored var logger: AppLogger? + @ObservationIgnored var now: () -> Date = Date.init var client: ContainerClient? + private static let visibleStatsInterval: TimeInterval = 10 + private static let backgroundStatsInterval: TimeInterval = 30 + private var lastRawStats: [String: ContainerStats] = [:] private var lastStatsDate: Date? /// IDs the user (not a crash) just stopped/removed, so the RestartWatchdog won't fight them. @@ -28,6 +52,7 @@ final class ContainersStore { /// of redundant `list` + `stats` runs when a container is busy starting up. private var refreshTask: Task? private var refreshRequested = false + private var pendingStatsDemand: StatsRefreshDemand = .background private let diagnosticLogger = Logger(subsystem: "app.contained.Contained", category: "diagnostic") var running: [ContainerSnapshot] { snapshots.filter { $0.state == .running } } @@ -37,11 +62,12 @@ final class ContainersStore { intentionalStops.remove(id) != nil } - /// Re-list containers and resample stats. Serialized: if a refresh is already running, this one is - /// coalesced into the trailing pass once the current one finishes (never concurrently), and the - /// caller awaits that combined pass. - func refresh() async { + /// Re-list containers and opportunistically resample stats. Serialized: if a refresh is already + /// running, this one is coalesced into the trailing pass once the current one finishes (never + /// concurrently), and the caller awaits that combined pass. + func refresh(statsDemand: StatsRefreshDemand = .background) async { refreshRequested = true + pendingStatsDemand = max(pendingStatsDemand, statsDemand) if refreshTask != nil { logger?.record("Refresh already in flight; coalescing another pass", category: .system, @@ -65,8 +91,10 @@ final class ContainersStore { var passes = 0 repeat { passes += 1 + let statsDemand = pendingStatsDemand + pendingStatsDemand = .background refreshRequested = false - await performRefresh() + await performRefresh(statsDemand: statsDemand) } while refreshRequested refreshTask = nil let elapsed = Date().timeIntervalSince(started) @@ -80,7 +108,7 @@ final class ContainersStore { } } - private func performRefresh() async { + private func performRefresh(statsDemand: StatsRefreshDemand) async { guard let client else { return } do { let listed = try await client.listContainers(all: true) @@ -92,7 +120,7 @@ final class ContainersStore { // unbounded as containers are recreated/removed over a long session. intentionalStops.formIntersection(Set(snapshots.map(\.id))) errorMessage = nil - await refreshStats() + await refreshStats(statsDemand) } catch let error as CommandError { errorMessage = error.userMessage } catch { @@ -100,36 +128,67 @@ final class ContainersStore { } } - private func refreshStats() async { + private func refreshStats(_ demand: StatsRefreshDemand) async { guard let client else { return } let runningIDs = running.map(\.id) + let runningSet = Set(runningIDs) + let prunedStats = statsByID.filter { runningSet.contains($0.key) } + if prunedStats.count != statsByID.count { statsByID = prunedStats } + lastRawStats = lastRawStats.filter { runningSet.contains($0.key) } guard !runningIDs.isEmpty else { - statsByID.removeAll(); lastRawStats.removeAll(); return + if !historyByID.isEmpty { historyByID.removeAll() } + lastStatsDate = nil + return } + let now = now() + guard shouldSampleStats(demand, now: now) else { return } + let started = Date() do { let samples = try await client.stats(ids: runningIDs) - let now = Date() let interval = lastStatsDate.map { now.timeIntervalSince($0) } ?? 1 + var nextStats = statsByID + var nextHistory = historyByID + var producedDelta = false for sample in samples { if let previous = lastRawStats[sample.id] { let delta = StatsDelta.between(previous: previous, current: sample, interval: interval) - statsByID[sample.id] = delta - var metrics = historyByID[sample.id] ?? [:] + nextStats[sample.id] = delta + var metrics = nextHistory[sample.id] ?? [:] for metric in GraphMetric.allCases { var buffer = metrics[metric] ?? SampleBuffer() buffer.append(metric.value(from: delta)) metrics[metric] = buffer } - historyByID[sample.id] = metrics + nextHistory[sample.id] = metrics + producedDelta = true } lastRawStats[sample.id] = sample } + if nextStats != statsByID { statsByID = nextStats } + if nextHistory != historyByID { historyByID = nextHistory } lastStatsDate = now + if producedDelta { statsRevision &+= 1 } + let elapsed = Date().timeIntervalSince(started) + if elapsed >= 0.75 || demand == .force { + diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, + "Stats sample \(demand.label, privacy: .public) finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s for \(runningIDs.count, privacy: .public) container(s)") + } } catch { // Stats are best-effort; a failure here shouldn't blank the list. } } + private func shouldSampleStats(_ demand: StatsRefreshDemand, now: Date) -> Bool { + guard demand != .force else { return true } + guard let lastStatsDate else { return true } + let interval = switch demand { + case .background: Self.backgroundStatsInterval + case .visible: Self.visibleStatsInterval + case .force: TimeInterval.zero + } + return now.timeIntervalSince(lastStatsDate) >= interval + } + // MARK: Lifecycle func start(_ id: String) async { await act(id, verb: "Start") { try await $0.start([id]) } } @@ -159,7 +218,7 @@ final class ContainersStore { do { let output = try await client.runner.run(spec.arguments()) performHaptic() - await refresh() + await refresh(statsDemand: .force) let elapsed = Date().timeIntervalSince(started) logger?.record("Run finished in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", category: .lifecycle, @@ -208,7 +267,7 @@ final class ContainersStore { _ = try await client.deleteContainers([originalID], force: true) _ = try await client.runner.run(spec.arguments()) performHaptic() - await refresh() + await refresh(statsDemand: .force) let elapsed = Date().timeIntervalSince(started) logger?.record("Recreated \(originalID) in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", category: .lifecycle, @@ -225,7 +284,7 @@ final class ContainersStore { severity: .warning, containerID: originalID) diagnosticLogger.error("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.userMessage, privacy: .public)") - await refresh() + await refresh(statsDemand: .force) return false } catch { errorMessage = error.localizedDescription @@ -235,7 +294,7 @@ final class ContainersStore { severity: .warning, containerID: originalID) diagnosticLogger.error("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.localizedDescription, privacy: .public)") - await refresh() + await refresh(statsDemand: .force) return false } } @@ -250,7 +309,7 @@ final class ContainersStore { do { try await body(client) performHaptic() - await refresh() + await refresh(statsDemand: .force) let elapsed = Date().timeIntervalSince(started) logger?.record("\(verb) finished in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", category: .lifecycle, diff --git a/Sources/Contained/Stores/RefreshCoordinator.swift b/Sources/Contained/Stores/RefreshCoordinator.swift index e6366708..bd7087fa 100644 --- a/Sources/Contained/Stores/RefreshCoordinator.swift +++ b/Sources/Contained/Stores/RefreshCoordinator.swift @@ -2,8 +2,9 @@ import SwiftUI import ContainedCore /// Drives periodic refresh of the whole app. There is no push API from `container`, so a single -/// adaptive polling loop fetches system status + containers + stats each tick (and the active -/// resource caches), then runs the `RestartWatchdog`. Cadence speeds up while containers are +/// adaptive polling loop fetches system status, the container list, and active resource caches, then +/// runs the `RestartWatchdog`. Stats sampling is throttled separately by `ContainersStore` so the CLI +/// does not run `container stats --no-stream` on every tick. Cadence speeds up while containers are /// transitioning, slows when idle, and pauses when the window is in the background. @MainActor @Observable diff --git a/Sources/Contained/Stores/SettingsBackup.swift b/Sources/Contained/Stores/SettingsBackup.swift index 9e4f0293..5d5ed87a 100644 --- a/Sources/Contained/Stores/SettingsBackup.swift +++ b/Sources/Contained/Stores/SettingsBackup.swift @@ -1,4 +1,5 @@ import Foundation +import ContainedDesignSystem /// Portable snapshot of user preferences for config export/import. struct SettingsBackup: Codable, Equatable { diff --git a/Sources/Contained/Stores/SettingsStore.swift b/Sources/Contained/Stores/SettingsStore.swift index 29ac8a79..f2f10d45 100644 --- a/Sources/Contained/Stores/SettingsStore.swift +++ b/Sources/Contained/Stores/SettingsStore.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import ServiceManagement import ContainedCore diff --git a/Sources/Contained/Support/Personalization.swift b/Sources/Contained/Support/Personalization.swift index f2844b0f..07a2a36c 100644 --- a/Sources/Contained/Support/Personalization.swift +++ b/Sources/Contained/Support/Personalization.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedDesignSystem import AppKit /// Resolved visual style for a container card: a colored icon plus an optional colored glass diff --git a/Sources/Contained/Support/Platform.swift b/Sources/Contained/Support/Platform.swift index 6ad0a2a5..1ecee516 100644 --- a/Sources/Contained/Support/Platform.swift +++ b/Sources/Contained/Support/Platform.swift @@ -1,13 +1,6 @@ import AppKit -/// Minor AppKit host glue (flagged per the SwiftUI-first rule): clipboard + haptics. -@MainActor -func copyToPasteboard(_ string: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(string, forType: .string) -} - +/// Minor AppKit host glue (flagged per the SwiftUI-first rule): haptics. @MainActor func performHaptic(_ pattern: NSHapticFeedbackManager.FeedbackPattern = .generic) { NSHapticFeedbackManager.defaultPerformer.perform(pattern, performanceTime: .now) diff --git a/Sources/Contained/Support/WidgetConfiguration.swift b/Sources/Contained/Support/WidgetConfiguration.swift index c48d45ba..9b75f3ed 100644 --- a/Sources/Contained/Support/WidgetConfiguration.swift +++ b/Sources/Contained/Support/WidgetConfiguration.swift @@ -1,4 +1,5 @@ import AppKit +import ContainedDesignSystem struct WidgetConfiguration: Codable, Hashable, Sendable { static let schemaVersion = 4 @@ -91,83 +92,3 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { return icon } } - -enum GraphStyle: String, CaseIterable, Identifiable, Codable, Sendable { - case area - case line - case bar - case points - case multiLine - case range - case scatter - - var id: String { rawValue } - - var displayName: String { - switch self { - case .area: return "Area" - case .line: return "Line" - case .bar: return "Bar" - case .points: return "Points" - case .multiLine: return "Multi-Line" - case .range: return "Range" - case .scatter: return "Scatter" - } - } - - var requiresSecondaryMetric: Bool { - switch self { - case .multiLine, .range, .scatter: return true - case .area, .line, .bar, .points: return false - } - } - - func resolvedSecondaryMetric(primary: GraphMetric, - requested: GraphMetric?, - options: [GraphMetric]) -> GraphMetric? { - guard requiresSecondaryMetric else { return nil } - if let requested, requested != primary, options.contains(requested) { - return requested - } - return options.first { $0 != primary } - } - - var usesLineOptions: Bool { - switch self { - case .area, .line, .multiLine: return true - case .bar, .points, .range, .scatter: return false - } - } - - var usesPointOptions: Bool { - switch self { - case .points, .scatter: return true - case .area, .line, .bar, .multiLine, .range: return false - } - } - - var usesBarOptions: Bool { - switch self { - case .bar, .range: return true - case .area, .line, .points, .multiLine, .scatter: return false - } - } -} - -enum WidgetInterpolation: String, CaseIterable, Identifiable, Codable, Sendable { - case linear, catmullRom, cardinal, monotone, stepStart, stepCenter, stepEnd - - var id: String { rawValue } - - var displayName: String { - switch self { - case .linear: return "Linear" - case .catmullRom: return "Smooth" - case .cardinal: return "Cardinal" - case .monotone: return "Monotone" - case .stepStart: return "Step Start" - case .stepCenter: return "Step Center" - case .stepEnd: return "Step End" - } - } -} diff --git a/Sources/ContainedCore/Services/ContainerClient.swift b/Sources/ContainedCore/Services/ContainerClient.swift index 22848148..ab0ac2f4 100644 --- a/Sources/ContainedCore/Services/ContainerClient.swift +++ b/Sources/ContainedCore/Services/ContainerClient.swift @@ -4,6 +4,7 @@ import Foundation /// `CommandError.decodingFailed` so callers handle one error type. public struct ContainerClient: Sendable { public let runner: any CommandRunning + public var descriptor: RuntimeDescriptor { .appleContainer } public init(runner: any CommandRunning) { self.runner = runner @@ -192,3 +193,5 @@ public struct ContainerClient: Sendable { } } } + +extension ContainerClient: ContainerRuntimeClient {} diff --git a/Sources/ContainedCore/Services/ContainerRuntime.swift b/Sources/ContainedCore/Services/ContainerRuntime.swift new file mode 100644 index 00000000..07093572 --- /dev/null +++ b/Sources/ContainedCore/Services/ContainerRuntime.swift @@ -0,0 +1,147 @@ +import Foundation + +public enum RuntimeKind: String, Codable, Equatable, Sendable { + case appleContainer + case dockerCompatible +} + +public struct RuntimeCapability: OptionSet, Equatable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public static let containers = RuntimeCapability(rawValue: 1 << 0) + public static let images = RuntimeCapability(rawValue: 1 << 1) + public static let imageBuild = RuntimeCapability(rawValue: 1 << 2) + public static let imagePush = RuntimeCapability(rawValue: 1 << 3) + public static let imageArchive = RuntimeCapability(rawValue: 1 << 4) + public static let registries = RuntimeCapability(rawValue: 1 << 5) + public static let networks = RuntimeCapability(rawValue: 1 << 6) + public static let volumes = RuntimeCapability(rawValue: 1 << 7) + public static let systemStatus = RuntimeCapability(rawValue: 1 << 8) + public static let systemLogs = RuntimeCapability(rawValue: 1 << 9) + public static let systemProperties = RuntimeCapability(rawValue: 1 << 10) + public static let dnsManagement = RuntimeCapability(rawValue: 1 << 11) + public static let kernelManagement = RuntimeCapability(rawValue: 1 << 12) + public static let exec = RuntimeCapability(rawValue: 1 << 13) + public static let copy = RuntimeCapability(rawValue: 1 << 14) + public static let containerExport = RuntimeCapability(rawValue: 1 << 15) + + public static let appleContainer: RuntimeCapability = [ + .containers, + .images, + .imageBuild, + .imagePush, + .imageArchive, + .registries, + .networks, + .volumes, + .systemStatus, + .systemLogs, + .systemProperties, + .dnsManagement, + .kernelManagement, + .exec, + .copy, + .containerExport, + ] +} + +public struct RuntimeDescriptor: Equatable, Sendable { + public var kind: RuntimeKind + public var displayName: String + public var executableName: String + public var capabilities: RuntimeCapability + + public init(kind: RuntimeKind, + displayName: String, + executableName: String, + capabilities: RuntimeCapability) { + self.kind = kind + self.displayName = displayName + self.executableName = executableName + self.capabilities = capabilities + } + + public func supports(_ capability: RuntimeCapability) -> Bool { + capabilities.isSuperset(of: capability) + } + + public func require(_ capability: RuntimeCapability) throws { + guard supports(capability) else { + throw UnsupportedRuntimeCapability(kind: kind, capability: capability) + } + } + + public static let appleContainer = RuntimeDescriptor( + kind: .appleContainer, + displayName: "Apple container", + executableName: "container", + capabilities: .appleContainer + ) +} + +public struct UnsupportedRuntimeCapability: Error, Equatable, Sendable { + public var kind: RuntimeKind + public var capability: RuntimeCapability + + public init(kind: RuntimeKind, capability: RuntimeCapability) { + self.kind = kind + self.capability = capability + } +} + +extension UnsupportedRuntimeCapability: LocalizedError { + public var errorDescription: String? { + "The selected runtime does not support this operation." + } +} + +public protocol ContainerRuntimeClient: Sendable { + var descriptor: RuntimeDescriptor { get } + + func listContainers(all: Bool) async throws -> [ContainerSnapshot] + func stats(ids: [String]) async throws -> [ContainerStats] + func diskUsage() async throws -> DiskUsage + func systemProperties() async throws -> SystemProperties + func dnsDomains() async throws -> [String] + func createDNSDomain(_ domain: String) async throws -> Data + func deleteDNSDomain(_ domain: String) async throws -> Data + func setRecommendedKernel() async throws -> Data + func execCapture(_ id: String, _ command: [String]) async throws -> String + func copy(source: String, destination: String) async throws -> Data + func streamSystemLogs(follow: Bool, last: Int?) -> AsyncThrowingStream + func systemStatus() async throws -> SystemStatus + func networks() async throws -> [NetworkResource] + func volumes() async throws -> [VolumeResource] + func images() async throws -> [ImageResource] + func inspectImage(_ ref: String) async throws -> [ImageResource] + func streamLogs(id: String, follow: Bool, tail: Int?, boot: Bool) -> AsyncThrowingStream + func streamPull(_ ref: String, platform: String?) -> AsyncThrowingStream + func streamBuild(context: String, tag: String?, dockerfile: String?, + buildArgs: [String: String], noCache: Bool, + platform: String?) -> AsyncThrowingStream + func streamPush(_ ref: String, platform: String?) -> AsyncThrowingStream + func registries() async throws -> [RegistryLogin] + func registryLogin(server: String, username: String, password: String) async throws -> Data + func registryLogout(server: String) async throws -> Data + func deleteImages(_ refs: [String]) async throws -> Data + func tagImage(source: String, target: String) async throws -> Data + func saveImages(_ refs: [String], to output: String) async throws -> Data + func loadImages(from input: String) async throws -> Data + func exportContainer(_ id: String, to output: String) async throws -> Data + func pruneImages(all: Bool) async throws -> Data + func start(_ ids: [String]) async throws -> Data + func stop(_ ids: [String]) async throws -> Data + func deleteContainers(_ ids: [String], force: Bool) async throws -> Data + func pruneContainers() async throws -> Data + func pruneVolumes() async throws -> Data + func pruneNetworks() async throws -> Data + func createVolume(name: String, size: String?, labels: [String: String]) async throws -> Data + func deleteVolumes(_ names: [String]) async throws -> Data + func createNetwork(name: String, subnet: String?, internalOnly: Bool, + labels: [String: String]) async throws -> Data + func deleteNetworks(_ names: [String]) async throws -> Data +} diff --git a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift new file mode 100644 index 00000000..25d3319d --- /dev/null +++ b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing +import ContainedCore +@testable import Contained + +@Suite("Container stats refresh cadence") +@MainActor +struct ContainersStoreRefreshTests { + @Test func visibleStatsAreSampledNoMoreThanEveryTenSeconds() async { + let runner = RecordingRunner() + let store = ContainersStore() + store.client = ContainerClient(runner: runner) + let clock = TestClock(Date(timeIntervalSinceReferenceDate: 1_000)) + store.now = { clock.date } + + await store.refresh(statsDemand: .visible) + #expect(await runner.count(firstArgument: "stats") == 1) + #expect(store.statsRevision == 0) + + await store.refresh(statsDemand: .visible) + #expect(await runner.count(firstArgument: "stats") == 1) + + clock.advance(by: 9) + await store.refresh(statsDemand: .visible) + #expect(await runner.count(firstArgument: "stats") == 1) + + clock.advance(by: 1) + await store.refresh(statsDemand: .visible) + #expect(await runner.count(firstArgument: "stats") == 2) + #expect(store.statsRevision == 1) + } + + @Test func backgroundStatsUseLongerCadence() async { + let runner = RecordingRunner() + let store = ContainersStore() + store.client = ContainerClient(runner: runner) + let clock = TestClock(Date(timeIntervalSinceReferenceDate: 2_000)) + store.now = { clock.date } + + await store.refresh(statsDemand: .background) + #expect(await runner.count(firstArgument: "stats") == 1) + + clock.advance(by: 29) + await store.refresh(statsDemand: .background) + #expect(await runner.count(firstArgument: "stats") == 1) + + clock.advance(by: 1) + await store.refresh(statsDemand: .background) + #expect(await runner.count(firstArgument: "stats") == 2) + } + + @Test func forcedStatsBypassCadence() async { + let runner = RecordingRunner() + let store = ContainersStore() + store.client = ContainerClient(runner: runner) + let clock = TestClock(Date(timeIntervalSinceReferenceDate: 3_000)) + store.now = { clock.date } + + await store.refresh(statsDemand: .visible) + await store.refresh(statsDemand: .force) + + #expect(await runner.count(firstArgument: "stats") == 2) + #expect(store.statsRevision == 1) + } +} + +private final class TestClock { + var date: Date + + init(_ date: Date) { + self.date = date + } + + func advance(by seconds: TimeInterval) { + date = date.addingTimeInterval(seconds) + } +} + +private actor RecordingRunner: CommandRunning { + private var calls: [[String]] = [] + private var statsRuns = 0 + + func run(_ arguments: [String]) async throws -> Data { + calls.append(arguments) + switch arguments.first { + case "list": + return Self.listJSON + case "stats": + statsRuns += 1 + return Self.statsJSON(cpuUsageUsec: UInt64(statsRuns * 1_000_000)) + default: + return Data("[]".utf8) + } + } + + nonisolated func stream(_ arguments: [String]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in continuation.finish() } + } + + func count(firstArgument: String) -> Int { + calls.filter { $0.first == firstArgument }.count + } + + private static let listJSON = Data(""" + [{ + "configuration": { + "id": "fixture-web", + "image": { "reference": "docker.io/library/alpine:latest" }, + "initProcess": {} + }, + "id": "fixture-web", + "status": { "state": "running" } + }] + """.utf8) + + private static func statsJSON(cpuUsageUsec: UInt64) -> Data { + Data(""" + [{ + "id": "fixture-web", + "cpuUsageUsec": \(cpuUsageUsec), + "memoryUsageBytes": 2322432, + "memoryLimitBytes": 1073741824, + "networkRxBytes": \(10_000 + cpuUsageUsec / 1_000), + "networkTxBytes": 516, + "blockReadBytes": 2154496, + "blockWriteBytes": 0, + "numProcesses": 1 + }] + """.utf8) + } +} diff --git a/Tests/ContainedCoreTests/CommandTests.swift b/Tests/ContainedCoreTests/CommandTests.swift index 8e0fef70..140d4dec 100644 --- a/Tests/ContainedCoreTests/CommandTests.swift +++ b/Tests/ContainedCoreTests/CommandTests.swift @@ -117,6 +117,34 @@ struct CommandTests { #expect(!CLILocator.isSupported(nil)) } + @Test func appleRuntimeDescriptorAdvertisesCurrentCapabilities() throws { + let descriptor = RuntimeDescriptor.appleContainer + #expect(descriptor.kind == .appleContainer) + #expect(descriptor.displayName == "Apple container") + #expect(descriptor.executableName == "container") + #expect(descriptor.supports([.containers, .images, .volumes, .networks])) + #expect(descriptor.supports([.systemStatus, .systemLogs, .exec, .copy])) + try descriptor.require([.imageBuild, .imagePush, .registries]) + + let readOnly = RuntimeDescriptor(kind: .dockerCompatible, + displayName: "Read-only runtime", + executableName: "container", + capabilities: [.containers]) + #expect(!readOnly.supports(.imageBuild)) + #expect(throws: UnsupportedRuntimeCapability.self) { + try readOnly.require(.imageBuild) + } + } + + @Test func containerClientConformsToRuntimeClient() async throws { + let runner = MockCommandRunner(result: .success(try Fixture.data("list"))) + let runtime: any ContainerRuntimeClient = ContainerClient(runner: runner) + #expect(runtime.descriptor == .appleContainer) + + let containers = try await runtime.listContainers(all: true) + #expect(containers.first?.id == "fixture-web") + } + @Test func clientDecodesThroughMock() async throws { let runner = MockCommandRunner(result: .success(try Fixture.data("list"))) let client = ContainerClient(runner: runner) diff --git a/changes/unreleased/20260701-design-system-package.md b/changes/unreleased/20260701-design-system-package.md new file mode 100644 index 00000000..1a4cf50f --- /dev/null +++ b/changes/unreleased/20260701-design-system-package.md @@ -0,0 +1 @@ +- Extracted the shared Liquid Glass design system into a local `ContainedDesignSystem` Swift package, including tokens, panel/page/sheet scaffolds, resource-card chrome, toolbar controls, sparklines, JSON/stream surfaces, gradient controls, clipboard helper, reusable micro-primitives for badges, keycaps, status dots, metric tiles, terminal chrome, and selection overlays, plus package-local README and DocC entry-point documentation. diff --git a/changes/unreleased/20260701-navigation-package.md b/changes/unreleased/20260701-navigation-package.md new file mode 100644 index 00000000..33fa81a9 --- /dev/null +++ b/changes/unreleased/20260701-navigation-package.md @@ -0,0 +1 @@ +- Started the reusable navigation package by moving toolbar safe-area policy, morph target geometry, morph expander, and morph panel scaffolding into `ContainedNavigation`, with package-local README and DocC entry-point documentation. diff --git a/changes/unreleased/20260701-runtime-abstraction.md b/changes/unreleased/20260701-runtime-abstraction.md new file mode 100644 index 00000000..5917a047 --- /dev/null +++ b/changes/unreleased/20260701-runtime-abstraction.md @@ -0,0 +1 @@ +- Added the first backend runtime abstraction in `ContainedCore`, with Apple `container` capabilities advertised through `ContainerRuntimeClient` and `RuntimeDescriptor`. diff --git a/changes/unreleased/20260701-xcode-workspace.md b/changes/unreleased/20260701-xcode-workspace.md new file mode 100644 index 00000000..2b3a1904 --- /dev/null +++ b/changes/unreleased/20260701-xcode-workspace.md @@ -0,0 +1,3 @@ +## Architecture + +- Add a checked-in Xcode workspace that opens the SwiftPM root package and local reusable packages without replacing SwiftPM as the source of truth. diff --git a/changes/unreleased/20260702-refresh-stats-performance.md b/changes/unreleased/20260702-refresh-stats-performance.md new file mode 100644 index 00000000..473594fe --- /dev/null +++ b/changes/unreleased/20260702-refresh-stats-performance.md @@ -0,0 +1,3 @@ +- Reduced idle UI churn by decoupling container stats sampling from the main refresh tick: stats now sample slowly in the background, faster when container metrics are visible, and immediately after lifecycle actions or Stats-tab entry. +- Reduced runtime layout churn by replacing the container grid's bound card-frame preference with coalesced frame tracking and clamping morph panel geometry before it reaches SwiftUI frames. +- Added diagnostic timing around refresh, stats sampling, image list refreshes, and image update sweeps so future sluggishness has concrete evidence before tuning. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 3db32759..17738510 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -6,26 +6,38 @@ Contained is a SwiftUI-native macOS app that wraps Apple's `container` CLI. It s SwiftUI Views ──> @Observable Stores ──> ContainerClient ──> CommandRunner ──> `container` CLI (Features/*) (AppModel, …) (typed facade) (run / stream) (--format json) ^ │ ^ │ - └──── design system, tokens ────────────────┘ │ + └──── ContainedDesignSystem ────────────────┘ │ └─────────────────── decoded models (ContainedCore) ◀───────────┘ ``` ## Targets - **`ContainedCore`** — pure, testable logic: models, the CLI wrapper, JSON decoding, compose parsing, and ordering/decision helpers. Depends only on Yams. No SwiftUI. -- **`Contained`** — the SwiftUI executable: views, `@Observable` stores, the design system, and the SwiftData history stack. Depends on `ContainedCore` + SwiftTerm + Sparkle. +- **`ContainedDesignSystem`** — a local reusable Swift package for app-agnostic SwiftUI/AppKit visual primitives. It must not depend on stores, Sparkle, SwiftData, app routing, or feature modules. +- **`ContainedNavigation`** — a local reusable Swift package for navigation and layout infrastructure that should not own app-specific routing. It currently owns toolbar safe-area policy/measurement primitives. +- **`Contained`** — the SwiftUI executable: views, `@Observable` stores, app-specific presentation mappings, navigation, and the SwiftData history stack. Depends on `ContainedCore`, `ContainedDesignSystem`, `ContainedNavigation`, SwiftTerm, and Sparkle. + +Package-local docs: + +- [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) +- [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md) + +`Contained.xcworkspace` is the optional Xcode entry point. It references the +root `Package.swift` and local package manifests; the SwiftPM package graph +remains the source of truth for builds, tests, and release scripts. ## CLI wrapper (core) - **`ContainerCommands`** — pure argv builders, side-effect-free so golden tests assert the exact arguments (the "Reveal CLI" affordances read from the same source of truth). - **`CommandRunner`** — runs a one-shot command (`run`) or a streaming one (`stream`, an `AsyncThrowingStream`). Passwords are piped via `--password-stdin`, never argv. -- **`ContainerClient`** — a typed facade returning decoded models; maps decode failures to a single `CommandError`. +- **`ContainerClient`** — the Apple `container` implementation of `ContainerRuntimeClient`; returns decoded models and maps decode failures to a single `CommandError`. +- **`ContainerRuntimeClient`** — the backend-facing operation contract. `RuntimeDescriptor` and `RuntimeCapability` advertise what a selected runtime can do before future Docker-compatible integration reaches the UI. ## Stores (app) - **`AppModel`** — root state: locates the CLI, owns the client + feature stores, tracks bootstrap status, wires logging/updating, and runs the per-tick coordination. Focused extensions own image/resource style lookup, image-update sweeps, and configuration import/export. -- **`ContainersStore`** — the container list, live stats deltas, and lifecycle actions. -- **`RefreshCoordinator`** — adaptive polling (stats are polled, not truly streamed — the CLI emits one frame then blocks). +- **`ContainersStore`** — the container list, live stats deltas, stats sampling cadence, and lifecycle actions. +- **`RefreshCoordinator`** — adaptive polling for service/list refreshes. Stats are polled, not truly streamed — the CLI emits one frame then blocks — so `ContainersStore` samples them less often in the background, faster when the Containers UI is visible, and immediately after lifecycle actions or Stats-tab entry. - **`RestartWatchdog`** — app-managed restart policy (`container` has no native `--restart`); diffs states each tick and re-issues `start` with backoff. - **`HealthMonitor`** — app-managed healthchecks: interval-gated `exec` probes with consecutive-failure tracking. - **`HistoryStore`** — SwiftData stack for the persistent event log + metric samples (the "rewind" timeline) with bounded retention. @@ -35,7 +47,7 @@ Contained is a SwiftUI-native macOS app that wraps Apple's `container` CLI. It s ## Design system -Liquid Glass helpers and reusable primitives include `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `ResourceGlassCard`, `GlassSurface`, `CommandPreviewBar`, `InfoButton`, `ToolbarIconButton`, and `Tokens` groups for toolbar, panel, spacing, radius, and icon sizing. See [[Design System|Design-System]]. +Liquid Glass helpers and reusable primitives include `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `ResourceGlassCard`, `GlassSurface`, `CommandPreviewBar`, `InfoButton`, `GlassButton`, `ToolbarMenuButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `Tokens` groups for toolbar, panel, spacing, radius, icon sizing, resource cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, or micro-chrome recipes. App-state-aware mappings such as runtime status and graph metric extraction stay in the executable until they can cross the boundary without depending on app/core policy. Use the package READMEs for import instructions and copy-pasteable examples, and see [[Design System|Design-System]] for app-level conventions. ## Local-only personalization diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index d2177e24..4f846bee 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -57,9 +57,12 @@ enforced without breaking appcast publishing. ``` Sources/ContainedCore/ pure logic — models, CLI wrapper, decoding, compose (no SwiftUI) Sources/Contained/ the SwiftUI app - DesignSystem/ glass helpers, tokens, shared components + DesignSystem/ core-dependent presentation mappings only Features// one folder per sidebar domain Navigation/ Stores/ Support/ History/ +Packages/ContainedDesignSystem/ reusable SwiftUI/AppKit visual primitives and tokens +Packages/ContainedNavigation/ reusable navigation/layout infrastructure +Contained.xcworkspace/ optional Xcode entry point over the SwiftPM packages Tests/ContainedCoreTests/ golden-argv + decode + decision tests Tests/ContainedAppTests/ RunSpec argv + compose mapping scripts/ bundle.sh, release.sh, appcast.sh @@ -71,11 +74,16 @@ appcast.xml Sparkle feed at the root of each release branch - **Agents start at `AGENTS.md`.** Coding agents should read the root agent guide before editing; it summarizes branch, update, release-note, design-system, and verification rules. - **Directory names are intentional.** SwiftPM folders stay `Sources` and `Tests`, Swift source domains use PascalCase, and repository infrastructure uses lowercase names such as `docs` and `scripts`. Put helper scripts in `scripts/` and use hyphenated names for multi-word shell scripts. +- **Reusable packages live under `Packages/`.** Keep app-agnostic design primitives, tokens, spacing, material, opacity, and micro-chrome in `ContainedDesignSystem`; keep app state, stores, Sparkle, SwiftData, persistence, and feature routing in the executable target. +- **Package docs live with the package.** Keep package-local import/setup/examples in [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) and [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md), with DocC landing pages under each target's `.docc` catalog. Keep `docs/wiki` focused on app-level architecture and workflow guidance. +- **Xcode opens the workspace.** `Contained.xcworkspace` points at the root and local package manifests. SwiftPM package manifests remain the build graph source of truth; do not hand-maintain generated `.xcodeproj` state. +- **Navigation infrastructure belongs in `ContainedNavigation` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in the executable target until they can cross the boundary without app policy. - **Every CLI action goes through a `ContainerCommands` builder** + a `ContainerClient` wrapper, with a golden-argv test. The UI never assembles argv inline — this keeps "Reveal CLI" honest. +- **Runtime-facing code should depend on `ContainerRuntimeClient` where a backend choice matters.** The Apple `container` implementation remains the default; future Docker-compatible behavior should advertise capability differences through `RuntimeDescriptor`. - **Pure decision logic is factored into `ContainedCore`** (`RestartDecision`, `HealthDecision`, compose ordering) and unit-tested without spawning processes. - **No `contained.*` personalization labels.** Card styles and healthchecks live in local stores. Only `contained.restart` and `contained.stack` are written (they must round-trip through the container). - **Never put secrets or personal data in test fixtures.** Fixtures are captured CLI output — scrub tokens, domains, and paths before committing. (`.gitignore` blocks signing material; push protection is on.) -- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer shared primitives such as `PanelHeader`, `PanelSection`, `MorphPanelScaffold`, `ResourceGlassCard`, `CommandPreviewBar`, and `Tokens`. +- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer shared primitives such as `PanelHeader`, `PanelSection`, `MorphPanelScaffold`, `ResourceGlassCard`, `GlassButton`, `CommandPreviewBar`, and `Tokens`. Do not add app-local spacing, padding, radius, shadow, material, opacity, or badge/keycap/status-dot recipes; add them to `ContainedDesignSystem` first. - **Gate debug-only tools at compile time.** Use `#if CONTAINED_DEBUG_TOOLS` for debug menus, diagnostics, fixtures, or local-only inspection surfaces. SwiftPM defines that flag only for debug builds, so release bundles exclude the code instead of merely hiding it at runtime. - **Keep the sidebar fallback working.** Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements for the classic shell. - **Sync docs with behavior.** If behavior, settings, routes, or user-facing wording changes, update the matching page under `docs/wiki` and keep README links current. diff --git a/docs/wiki/Design-System.md b/docs/wiki/Design-System.md index 54dc2800..98674972 100644 --- a/docs/wiki/Design-System.md +++ b/docs/wiki/Design-System.md @@ -3,6 +3,27 @@ Contained's UI is built around a small Liquid Glass design system. Prefer these components before adding one-off surfaces. +App-agnostic SwiftUI/AppKit primitives live in the local `ContainedDesignSystem` +package under `Packages/`. App-specific views, stores, settings, routing, and +domain presentation mappings stay in the executable target until they have a +clean reusable boundary. + +Package-local docs: + +- [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) +- [`ContainedDesignSystem` DocC landing page](../../Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md) +- [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md) + +The package owns the shared tokens, visual-effect background bridge, exterior +shadow, glass surface modifier, panel/page/sheet scaffolds, toolbar controls, +option tiles, transient error banner, resource-card chrome, activity status, +JSON and stream-console surfaces, sparklines, clipboard helper, gradient-angle +control, and micro primitives such as status dots, badges, keycaps, metric +tiles, terminal chrome, and card-selection overlays. Components that read +`AppModel`, settings stores, feature routes, or runtime models stay in the app +target, but they should pass values into package components instead of +recreating style locally. + ## Core principles - A morph panel is one stable shell. Content can swap inside it, but the shell @@ -18,8 +39,8 @@ components before adding one-off surfaces. Style ownership: - `Personalization` is the resolved card style. -- `WidgetConfiguration`, `GraphStyle`, and `WidgetInterpolation` own card - metric-widget schema and graph options. +- `WidgetConfiguration` owns app-side metric-widget schema. `GraphStyle` and + `WidgetInterpolation` live in the design package as graph rendering options. - `PersonalizationStore` owns persistence, inheritance, backup, and legacy `contained.*` label migration. @@ -102,6 +123,8 @@ Recommended internal pieces: - `ResourceCardSubtitleText` or `ResourceCardMonospacedSubtitleText` for metadata - `ResourceBadgeText` for compact state or kind labels - `ResourceCardFooterMini` for small footer actions and metrics +- `DesignStatusDot`, `DesignStatusBadge`, `DesignKeyCap`, and + `DesignKeyboardHint` for micro chrome Use `isSelected` instead of inventing a second selection ring. Use `elevated: false` for cards inside already-elevated morph panels. @@ -122,8 +145,10 @@ Plain rows are reserved for generic actions such as refresh or opening a page. ## Tokens Use `Tokens` for spacing, radius, toolbar dimensions, panel sizes, icon sizes, -and shadows. If a new value appears repeatedly, add a token before duplicating -magic numbers. +form widths, chart sizes, badge/keycap insets, resource-card opacities, terminal +chrome, and shadows. Use `glassSurface` and `glassCapsuleSurface` for standalone +glass surfaces so feature views do not call `.glassEffect` directly. If a new +visual value appears, add a token or package primitive before using it in the app. Important groups: @@ -132,6 +157,12 @@ Important groups: - `Tokens.Space` for layout rhythm - `Tokens.Radius` for card and control rounding - `Tokens.IconSize` for chips and toolbar controls +- `Tokens.ResourceCard`, `Tokens.Badge`, `Tokens.Keyboard`, `Tokens.Chart`, + `Tokens.FormWidth`, and `Tokens.Terminal` for smaller repeated chrome values + +Feature views can choose semantic content, domain colors, and app data, but +should not create app-local spacing, padding, radius, shadow, material, opacity, +badge, keycap, status-dot, or terminal-surface recipes. ## Verification diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 38a2fd87..0f36a114 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -16,12 +16,14 @@ On first launch, the bootstrap screen checks for the `container` CLI and the run ## Build from source -This is a Swift Package — there is no `.xcodeproj`. +Contained is SwiftPM-first. The checked-in workspace is a convenience wrapper +over the root package and local packages; there is no generated `.xcodeproj` to +maintain. ```sh git clone https://github.com/tdeverx/contained-app.git cd contained-app -open Package.swift # open in Xcode +open Contained.xcworkspace # open in Xcode ``` Or from the command line: diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 33ae7373..fd03c0a6 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -31,7 +31,7 @@ Switch **Settings → Updates → Update channel** to **Beta** or **Nightly**. N ## Stats look choppy / not real-time -The `container stats --format json` command emits one frame and then blocks, so Contained **polls** on an interval rather than streaming. This is expected — adjust the cadence in **Settings → General → Data → Refresh interval**. +The `container stats --format json` command emits one frame and then blocks, so Contained **samples** stats instead of streaming them. The container list follows **Settings → General → Data → Refresh interval**; live metrics sample more slowly in the background, faster while the Containers UI is visible, and immediately when the Stats tab opens. ## A container keeps restarting From e925166815bf2d6d311dd60af84caedc2be5c69a Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 01:48:51 +0100 Subject: [PATCH 02/53] Stream container stats app-wide --- .../Features/Settings/Tabs/GeneralTab.swift | 2 +- Sources/Contained/Stores/AppModel.swift | 80 +++++++-- .../Contained/Stores/ContainersStore.swift | 140 ++++++--------- .../Contained/Stores/RefreshCoordinator.swift | 5 +- .../ContainedCore/Models/ContainerStats.swift | 64 +++++++ .../Services/CommandRunner.swift | 94 +++++++--- .../Services/ContainerClient.swift | 16 +- .../Services/ContainerCommands.swift | 15 +- .../Services/ContainerRuntime.swift | 1 + .../Services/ContainerStatsTableParser.swift | 164 ++++++++++++++++++ .../ContainersStoreRefreshTests.swift | 76 ++++---- Tests/ContainedCoreTests/CommandTests.swift | 22 +++ Tests/ContainedCoreTests/Fixtures.swift | 6 +- .../Fixtures/stats-table.txt | 9 + Tests/ContainedCoreTests/Phase4Tests.swift | 18 +- .../20260702-refresh-stats-performance.md | 5 +- docs/wiki/Architecture.md | 11 +- docs/wiki/Troubleshooting.md | 2 +- 18 files changed, 546 insertions(+), 184 deletions(-) create mode 100644 Sources/ContainedCore/Services/ContainerStatsTableParser.swift create mode 100644 Tests/ContainedCoreTests/Fixtures/stats-table.txt diff --git a/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift b/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift index 7396fe79..97a93aa5 100644 --- a/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift @@ -24,7 +24,7 @@ struct GeneralTab: View { } PanelSection(header: "Data", - footer: "How often the container list refreshes. Live metrics use a slower sampler unless the Containers view or Stats tab needs them.") { + footer: "How often the container list refreshes. Live metrics use one low-priority runtime stream independent of this interval.") { PanelRow(title: "Refresh interval") { HStack(spacing: Tokens.Space.s) { Slider(value: $settings.refreshInterval, in: 1...10, step: 1) diff --git a/Sources/Contained/Stores/AppModel.swift b/Sources/Contained/Stores/AppModel.swift index cdd7188e..2c792d44 100644 --- a/Sources/Contained/Stores/AppModel.swift +++ b/Sources/Contained/Stores/AppModel.swift @@ -38,6 +38,9 @@ final class AppModel { private(set) var diskUsage: DiskUsage? private(set) var cliVersion: String? @ObservationIgnored private var containerStatsVisible = true + @ObservationIgnored private var containerStatsStreamTask: Task? + @ObservationIgnored private var containerStatsStreamIDs: [String] = [] + @ObservationIgnored private var containerStatsStreamGeneration = 0 @ObservationIgnored private var lastRecordedStatsRevision = 0 @ObservationIgnored let diagnosticLogger = Logger(subsystem: "app.contained.Contained", category: "diagnostic") @@ -157,7 +160,7 @@ final class AppModel { } } - await refreshSystem(statsDemand: .force) + await refreshSystem() } /// Re-run CLI/service detection (onboarding "Try again"). @@ -175,7 +178,7 @@ final class AppModel { /// Proceed despite an unsupported CLI version (onboarding "Continue anyway"). func continueUnsupported() async { bootstrap = .checking - await refreshSystem(statsDemand: .force) + await refreshSystem() } func setContainerStatsVisible(_ visible: Bool) { @@ -184,10 +187,9 @@ final class AppModel { if visible { coordinator.wake() } } - func refreshSystem(statsDemand: ContainersStore.StatsRefreshDemand? = nil) async { + func refreshSystem() async { guard let client else { return } let started = Date() - let statsDemand = statsDemand ?? currentStatsDemand do { let status = try await client.systemStatus() systemStatus = status @@ -195,17 +197,20 @@ final class AppModel { bootstrap = .ready logger.record("Container service is running", category: .system, severity: .debug) await refreshDiskUsage() // throttled; the System panel can force a fresh read - await containers.refresh(statsDemand: statsDemand) + await containers.refresh() + updateContainerStatsStream() // One-time: import legacy contained.* card styles into the local store, now that we // no longer write personalization labels. personalization.migrateLegacyLabelsIfNeeded(containers.snapshots) } else { bootstrap = .serviceStopped + stopContainerStatsStream() logger.record("Container service is stopped", category: .system, severity: .warning) } } catch { // `system status` exits non-zero when the service isn't running/registered. bootstrap = .serviceStopped + stopContainerStatsStream() logger.record("Couldn't read container service status: \(error.localizedDescription)", category: .system, severity: .error) @@ -213,7 +218,7 @@ final class AppModel { let elapsed = Date().timeIntervalSince(started) if elapsed >= 0.75 { diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, - "System refresh finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s with stats demand \(statsDemand.label, privacy: .public)") + "System refresh finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s") } } @@ -279,15 +284,68 @@ final class AppModel { } } - private var currentStatsDemand: ContainersStore.StatsRefreshDemand { - containerStatsVisible ? .visible : .background - } - func refreshContainerStatsNow() async { - await containers.refresh(statsDemand: .force) + await containers.refresh() + updateContainerStatsStream() recordFreshMetricsIfNeeded() } + private func updateContainerStatsStream() { + guard bootstrap == .ready, let client else { + stopContainerStatsStream() + return + } + let ids = containers.running.map(\.id).sorted() + guard !ids.isEmpty else { + stopContainerStatsStream() + return + } + guard containerStatsStreamTask == nil || ids != containerStatsStreamIDs else { return } + + stopContainerStatsStream() + containerStatsStreamGeneration &+= 1 + let generation = containerStatsStreamGeneration + containerStatsStreamIDs = ids + diagnosticLogger.info("Stats stream starting for \(ids.count, privacy: .public) container(s)") + containerStatsStreamTask = Task(priority: .utility) { [weak self, client, ids, generation] in + var parser = ContainerStatsTableParser() + do { + for try await chunk in client.streamStatsTable(ids: ids) { + guard !Task.isCancelled else { return } + let samples = parser.append(chunk) + guard !samples.isEmpty else { continue } + await MainActor.run { + guard let self, + self.containerStatsStreamGeneration == generation else { return } + self.containers.applyStreamedStats(samples) + self.recordFreshMetricsIfNeeded() + } + } + } catch { + await MainActor.run { + guard let self, + self.containerStatsStreamGeneration == generation else { return } + self.diagnosticLogger.error("Stats stream failed: \(error.localizedDescription, privacy: .public)") + } + } + + await MainActor.run { + guard let self, + self.containerStatsStreamGeneration == generation else { return } + self.containerStatsStreamTask = nil + self.containerStatsStreamIDs = [] + if self.containerStatsVisible { self.coordinator.wake() } + } + } + } + + private func stopContainerStatsStream() { + containerStatsStreamGeneration &+= 1 + containerStatsStreamTask?.cancel() + containerStatsStreamTask = nil + containerStatsStreamIDs = [] + } + private func recordFreshMetricsIfNeeded() { guard containers.statsRevision != lastRecordedStatsRevision else { return } lastRecordedStatsRevision = containers.statsRevision diff --git a/Sources/Contained/Stores/ContainersStore.swift b/Sources/Contained/Stores/ContainersStore.swift index 6ca7e343..6778c9e8 100644 --- a/Sources/Contained/Stores/ContainersStore.swift +++ b/Sources/Contained/Stores/ContainersStore.swift @@ -4,29 +4,11 @@ import OSLog import ContainedCore /// Owns the container list and derived live stats. Lifecycle actions run through the client and -/// trigger a refresh. Stats are sampled per refresh and converted into deltas for cards, expanded -/// panels, history, and restart/health context. +/// trigger a refresh. Stats arrive from the app-wide runtime stream and are converted into deltas +/// for cards, expanded panels, history, and restart/health context. @MainActor @Observable final class ContainersStore { - enum StatsRefreshDemand: Int, Comparable { - case background - case visible - case force - - static func < (lhs: StatsRefreshDemand, rhs: StatsRefreshDemand) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var label: String { - switch self { - case .background: "background" - case .visible: "visible" - case .force: "force" - } - } - } - var snapshots: [ContainerSnapshot] = [] var statsByID: [String: StatsDelta] = [:] /// Per-container, per-metric sparkline history. @@ -39,20 +21,16 @@ final class ContainersStore { var client: ContainerClient? - private static let visibleStatsInterval: TimeInterval = 10 - private static let backgroundStatsInterval: TimeInterval = 30 - - private var lastRawStats: [String: ContainerStats] = [:] - private var lastStatsDate: Date? + private var lastStreamedStats: [String: RuntimeStatsSnapshot] = [:] + private var lastStreamedStatsDate: Date? /// IDs the user (not a crash) just stopped/removed, so the RestartWatchdog won't fight them. private var intentionalStops: Set = [] /// The currently-running refresh, if any. Refresh requests are coalesced so a burst of user /// actions plus the polling loop only keeps one trailing pass alive instead of stacking a queue - /// of redundant `list` + `stats` runs when a container is busy starting up. + /// of redundant `list` runs when a container is busy starting up. private var refreshTask: Task? private var refreshRequested = false - private var pendingStatsDemand: StatsRefreshDemand = .background private let diagnosticLogger = Logger(subsystem: "app.contained.Contained", category: "diagnostic") var running: [ContainerSnapshot] { snapshots.filter { $0.state == .running } } @@ -65,9 +43,8 @@ final class ContainersStore { /// Re-list containers and opportunistically resample stats. Serialized: if a refresh is already /// running, this one is coalesced into the trailing pass once the current one finishes (never /// concurrently), and the caller awaits that combined pass. - func refresh(statsDemand: StatsRefreshDemand = .background) async { + func refresh() async { refreshRequested = true - pendingStatsDemand = max(pendingStatsDemand, statsDemand) if refreshTask != nil { logger?.record("Refresh already in flight; coalescing another pass", category: .system, @@ -91,10 +68,8 @@ final class ContainersStore { var passes = 0 repeat { passes += 1 - let statsDemand = pendingStatsDemand - pendingStatsDemand = .background refreshRequested = false - await performRefresh(statsDemand: statsDemand) + await performRefresh() } while refreshRequested refreshTask = nil let elapsed = Date().timeIntervalSince(started) @@ -108,7 +83,7 @@ final class ContainersStore { } } - private func performRefresh(statsDemand: StatsRefreshDemand) async { + private func performRefresh() async { guard let client else { return } do { let listed = try await client.listContainers(all: true) @@ -120,7 +95,7 @@ final class ContainersStore { // unbounded as containers are recreated/removed over a long session. intentionalStops.formIntersection(Set(snapshots.map(\.id))) errorMessage = nil - await refreshStats(statsDemand) + pruneStatsForCurrentRunningSet() } catch let error as CommandError { errorMessage = error.userMessage } catch { @@ -128,65 +103,52 @@ final class ContainersStore { } } - private func refreshStats(_ demand: StatsRefreshDemand) async { - guard let client else { return } - let runningIDs = running.map(\.id) - let runningSet = Set(runningIDs) + private func pruneStatsForCurrentRunningSet() { + let runningSet = Set(running.map(\.id)) let prunedStats = statsByID.filter { runningSet.contains($0.key) } if prunedStats.count != statsByID.count { statsByID = prunedStats } - lastRawStats = lastRawStats.filter { runningSet.contains($0.key) } - guard !runningIDs.isEmpty else { + lastStreamedStats = lastStreamedStats.filter { runningSet.contains($0.key) } + guard !runningSet.isEmpty else { if !historyByID.isEmpty { historyByID.removeAll() } - lastStatsDate = nil + lastStreamedStatsDate = nil return } - let now = now() - guard shouldSampleStats(demand, now: now) else { return } - let started = Date() - do { - let samples = try await client.stats(ids: runningIDs) - let interval = lastStatsDate.map { now.timeIntervalSince($0) } ?? 1 - var nextStats = statsByID - var nextHistory = historyByID - var producedDelta = false - for sample in samples { - if let previous = lastRawStats[sample.id] { - let delta = StatsDelta.between(previous: previous, current: sample, interval: interval) - nextStats[sample.id] = delta - var metrics = nextHistory[sample.id] ?? [:] - for metric in GraphMetric.allCases { - var buffer = metrics[metric] ?? SampleBuffer() - buffer.append(metric.value(from: delta)) - metrics[metric] = buffer - } - nextHistory[sample.id] = metrics - producedDelta = true - } - lastRawStats[sample.id] = sample - } - if nextStats != statsByID { statsByID = nextStats } - if nextHistory != historyByID { historyByID = nextHistory } - lastStatsDate = now - if producedDelta { statsRevision &+= 1 } - let elapsed = Date().timeIntervalSince(started) - if elapsed >= 0.75 || demand == .force { - diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, - "Stats sample \(demand.label, privacy: .public) finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s for \(runningIDs.count, privacy: .public) container(s)") - } - } catch { - // Stats are best-effort; a failure here shouldn't blank the list. + } + + func applyStreamedStats(_ samples: [RuntimeStatsSnapshot], observedAt: Date? = nil) { + let runningSet = Set(running.map(\.id)) + let samples = samples.filter { runningSet.contains($0.id) } + guard !samples.isEmpty else { return } + + let observedAt = observedAt ?? now() + let interval = lastStreamedStatsDate.map { observedAt.timeIntervalSince($0) } ?? 1 + var nextStats = statsByID + var nextHistory = historyByID + for sample in samples { + let delta = StatsDelta.from(snapshot: sample, + previous: lastStreamedStats[sample.id], + interval: interval) + record(delta, stats: &nextStats, history: &nextHistory) + lastStreamedStats[sample.id] = sample } + + if nextStats != statsByID { statsByID = nextStats } + if nextHistory != historyByID { historyByID = nextHistory } + lastStreamedStatsDate = observedAt + statsRevision &+= 1 } - private func shouldSampleStats(_ demand: StatsRefreshDemand, now: Date) -> Bool { - guard demand != .force else { return true } - guard let lastStatsDate else { return true } - let interval = switch demand { - case .background: Self.backgroundStatsInterval - case .visible: Self.visibleStatsInterval - case .force: TimeInterval.zero + private func record(_ delta: StatsDelta, + stats: inout [String: StatsDelta], + history: inout [String: [GraphMetric: SampleBuffer]]) { + stats[delta.id] = delta + var metrics = history[delta.id] ?? [:] + for metric in GraphMetric.allCases { + var buffer = metrics[metric] ?? SampleBuffer() + buffer.append(metric.value(from: delta)) + metrics[metric] = buffer } - return now.timeIntervalSince(lastStatsDate) >= interval + history[delta.id] = metrics } // MARK: Lifecycle @@ -218,7 +180,7 @@ final class ContainersStore { do { let output = try await client.runner.run(spec.arguments()) performHaptic() - await refresh(statsDemand: .force) + await refresh() let elapsed = Date().timeIntervalSince(started) logger?.record("Run finished in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", category: .lifecycle, @@ -267,7 +229,7 @@ final class ContainersStore { _ = try await client.deleteContainers([originalID], force: true) _ = try await client.runner.run(spec.arguments()) performHaptic() - await refresh(statsDemand: .force) + await refresh() let elapsed = Date().timeIntervalSince(started) logger?.record("Recreated \(originalID) in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", category: .lifecycle, @@ -284,7 +246,7 @@ final class ContainersStore { severity: .warning, containerID: originalID) diagnosticLogger.error("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.userMessage, privacy: .public)") - await refresh(statsDemand: .force) + await refresh() return false } catch { errorMessage = error.localizedDescription @@ -294,7 +256,7 @@ final class ContainersStore { severity: .warning, containerID: originalID) diagnosticLogger.error("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.localizedDescription, privacy: .public)") - await refresh(statsDemand: .force) + await refresh() return false } } @@ -309,7 +271,7 @@ final class ContainersStore { do { try await body(client) performHaptic() - await refresh(statsDemand: .force) + await refresh() let elapsed = Date().timeIntervalSince(started) logger?.record("\(verb) finished in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", category: .lifecycle, diff --git a/Sources/Contained/Stores/RefreshCoordinator.swift b/Sources/Contained/Stores/RefreshCoordinator.swift index bd7087fa..09ee50c3 100644 --- a/Sources/Contained/Stores/RefreshCoordinator.swift +++ b/Sources/Contained/Stores/RefreshCoordinator.swift @@ -3,9 +3,8 @@ import ContainedCore /// Drives periodic refresh of the whole app. There is no push API from `container`, so a single /// adaptive polling loop fetches system status, the container list, and active resource caches, then -/// runs the `RestartWatchdog`. Stats sampling is throttled separately by `ContainersStore` so the CLI -/// does not run `container stats --no-stream` on every tick. Cadence speeds up while containers are -/// transitioning, slows when idle, and pauses when the window is in the background. +/// runs the `RestartWatchdog`. Stats use a separate app-wide stream, so this loop only owns the +/// service/list/resource cadence and pauses when the window is in the background. @MainActor @Observable final class RefreshCoordinator { diff --git a/Sources/ContainedCore/Models/ContainerStats.swift b/Sources/ContainedCore/Models/ContainerStats.swift index a7d379d4..af8ae1e1 100644 --- a/Sources/ContainedCore/Models/ContainerStats.swift +++ b/Sources/ContainedCore/Models/ContainerStats.swift @@ -21,6 +21,44 @@ public struct ContainerStats: Codable, Sendable, Identifiable, Hashable { } } +/// Runtime-agnostic resource counters parsed from a streaming source. +/// +/// Apple container's live table stream reports CPU as an already-computed percent and reports +/// memory/network/block values as current cumulative counters. Keeping this separate from +/// `ContainerStats` lets future runtime adapters, including Docker Engine API streams, publish the +/// same shape without pretending they came from Apple container's JSON schema. +public struct RuntimeStatsSnapshot: Sendable, Identifiable, Hashable { + public let id: String + public let cpuCoreFraction: Double? + public let memoryUsageBytes: UInt64? + public let memoryLimitBytes: UInt64? + public let blockReadBytes: UInt64? + public let blockWriteBytes: UInt64? + public let networkRxBytes: UInt64? + public let networkTxBytes: UInt64? + public let numProcesses: UInt64? + + public init(id: String, + cpuCoreFraction: Double?, + memoryUsageBytes: UInt64?, + memoryLimitBytes: UInt64?, + blockReadBytes: UInt64?, + blockWriteBytes: UInt64?, + networkRxBytes: UInt64?, + networkTxBytes: UInt64?, + numProcesses: UInt64?) { + self.id = id + self.cpuCoreFraction = cpuCoreFraction + self.memoryUsageBytes = memoryUsageBytes + self.memoryLimitBytes = memoryLimitBytes + self.blockReadBytes = blockReadBytes + self.blockWriteBytes = blockWriteBytes + self.networkRxBytes = networkRxBytes + self.networkTxBytes = networkTxBytes + self.numProcesses = numProcesses + } +} + /// A computed delta between two `ContainerStats` samples, the form the UI actually graphs. public struct StatsDelta: Sendable, Hashable { public let id: String @@ -93,4 +131,30 @@ public struct StatsDelta: Sendable, Hashable { numProcesses: current.numProcesses ?? 0 ) } + + /// Convert a streaming runtime snapshot into the UI delta shape. + /// + /// CPU is already a point-in-time fraction in streaming table/API sources. Throughput metrics + /// are still cumulative counters, so they need the previous streamed snapshot and interval. + public static func from(snapshot: RuntimeStatsSnapshot, + previous: RuntimeStatsSnapshot?, + interval: TimeInterval) -> StatsDelta { + let dt = max(interval, 0.001) + func rate(_ previous: UInt64?, _ current: UInt64?) -> Double { + guard let previous, let current, current >= previous else { return 0 } + return Double(current - previous) / dt + } + + return StatsDelta( + id: snapshot.id, + cpuCoreFraction: snapshot.cpuCoreFraction ?? 0, + memoryUsageBytes: snapshot.memoryUsageBytes ?? 0, + memoryLimitBytes: snapshot.memoryLimitBytes ?? 0, + netRxBytesPerSec: rate(previous?.networkRxBytes, snapshot.networkRxBytes), + netTxBytesPerSec: rate(previous?.networkTxBytes, snapshot.networkTxBytes), + blockReadBytesPerSec: rate(previous?.blockReadBytes, snapshot.blockReadBytes), + blockWriteBytesPerSec: rate(previous?.blockWriteBytes, snapshot.blockWriteBytes), + numProcesses: snapshot.numProcesses ?? 0 + ) + } } diff --git a/Sources/ContainedCore/Services/CommandRunner.swift b/Sources/ContainedCore/Services/CommandRunner.swift index dd714a2a..eee36d58 100644 --- a/Sources/ContainedCore/Services/CommandRunner.swift +++ b/Sources/ContainedCore/Services/CommandRunner.swift @@ -1,24 +1,51 @@ import Foundation +public enum CommandExecutionPriority: Sendable { + case userInitiated + case utility + case background + + var dispatchQoS: DispatchQoS.QoSClass { + switch self { + case .userInitiated: .userInitiated + case .utility: .utility + case .background: .background + } + } + + var qualityOfService: QualityOfService { + switch self { + case .userInitiated: .userInitiated + case .utility: .utility + case .background: .background + } + } +} + /// Abstraction over `container` CLI execution so stores can be tested against a mock with no daemon. public protocol CommandRunning: Sendable { /// Run a command to completion. Returns stdout `Data` on success; throws `CommandError` on /// launch failure or non-zero exit (carrying stderr). - func run(_ arguments: [String]) async throws -> Data - - /// Run a command, writing `stdin` to the child's standard input (e.g. `registry login - /// --password-stdin`, so the secret never appears in argv / the process list). - func run(_ arguments: [String], stdin: Data?) async throws -> Data + func run(_ arguments: [String], + stdin: Data?, + priority: CommandExecutionPriority) async throws -> Data /// Stream a long-running command's merged stdout+stderr as it arrives. Cancelling the consuming /// task (or finishing the stream) terminates the child process — no leaked `logs -f`/`stats`. - func stream(_ arguments: [String]) -> AsyncThrowingStream + func stream(_ arguments: [String], priority: CommandExecutionPriority) -> AsyncThrowingStream } public extension CommandRunning { - /// Default: ignore stdin (conformers that don't spawn a real process, like the test mock). + func run(_ arguments: [String]) async throws -> Data { + try await run(arguments, stdin: nil, priority: .userInitiated) + } + func run(_ arguments: [String], stdin: Data?) async throws -> Data { - try await run(arguments) + try await run(arguments, stdin: stdin, priority: .userInitiated) + } + + func stream(_ arguments: [String]) -> AsyncThrowingStream { + stream(arguments, priority: .userInitiated) } } @@ -31,16 +58,23 @@ public final class CommandRunner: CommandRunning { } public func run(_ arguments: [String]) async throws -> Data { - try await run(arguments, stdin: nil) + try await run(arguments, stdin: nil, priority: .userInitiated) } public func run(_ arguments: [String], stdin: Data?) async throws -> Data { + try await run(arguments, stdin: stdin, priority: .userInitiated) + } + + public func run(_ arguments: [String], + stdin: Data?, + priority: CommandExecutionPriority) async throws -> Data { let executableURL = self.executableURL return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { + DispatchQueue.global(qos: priority.dispatchQoS).async { let process = Process() process.executableURL = executableURL process.arguments = arguments + process.qualityOfService = priority.qualityOfService let outPipe = Pipe() let errPipe = Pipe() @@ -63,27 +97,27 @@ public final class CommandRunner: CommandRunning { } // Read both pipes concurrently so a full stderr buffer can't deadlock stdout. - var outData = Data() - var errData = Data() + let outBox = DataBox() + let errBox = DataBox() let group = DispatchGroup() - let readQueue = DispatchQueue.global(qos: .userInitiated) + let readQueue = DispatchQueue.global(qos: priority.dispatchQoS) group.enter() readQueue.async { - outData = outPipe.fileHandleForReading.readDataToEndOfFile() + outBox.set(outPipe.fileHandleForReading.readDataToEndOfFile()) group.leave() } group.enter() readQueue.async { - errData = errPipe.fileHandleForReading.readDataToEndOfFile() + errBox.set(errPipe.fileHandleForReading.readDataToEndOfFile()) group.leave() } process.waitUntilExit() group.wait() if process.terminationStatus == 0 { - continuation.resume(returning: outData) + continuation.resume(returning: outBox.data) } else { - let stderr = String(decoding: errData, as: UTF8.self) + let stderr = String(decoding: errBox.data, as: UTF8.self) .trimmingCharacters(in: .whitespacesAndNewlines) continuation.resume(throwing: CommandError.nonZeroExit( code: process.terminationStatus, @@ -95,7 +129,7 @@ public final class CommandRunner: CommandRunning { } } - public func stream(_ arguments: [String]) -> AsyncThrowingStream { + public func stream(_ arguments: [String], priority: CommandExecutionPriority) -> AsyncThrowingStream { let executableURL = self.executableURL return AsyncThrowingStream { continuation in // Process/FileHandle aren't Sendable; box them so the @Sendable onTermination closure @@ -104,6 +138,7 @@ public final class CommandRunner: CommandRunning { let process = box.process process.executableURL = executableURL process.arguments = arguments + process.qualityOfService = priority.qualityOfService let pipe = Pipe() process.standardOutput = pipe @@ -127,10 +162,12 @@ public final class CommandRunner: CommandRunning { } box.handle = handle - do { - try process.run() - } catch { - continuation.finish(throwing: CommandError.launchFailed(underlying: error.localizedDescription)) + DispatchQueue.global(qos: priority.dispatchQoS).async { + do { + try process.run() + } catch { + continuation.finish(throwing: CommandError.launchFailed(underlying: error.localizedDescription)) + } } } } @@ -147,3 +184,16 @@ private final class ProcessBox: @unchecked Sendable { let process = Process() var handle: FileHandle? } + +private final class DataBox: @unchecked Sendable { + private let lock = NSLock() + private var storage = Data() + + var data: Data { + lock.withLock { storage } + } + + func set(_ data: Data) { + lock.withLock { storage = data } + } +} diff --git a/Sources/ContainedCore/Services/ContainerClient.swift b/Sources/ContainedCore/Services/ContainerClient.swift index ab0ac2f4..5855a067 100644 --- a/Sources/ContainedCore/Services/ContainerClient.swift +++ b/Sources/ContainedCore/Services/ContainerClient.swift @@ -17,7 +17,14 @@ public struct ContainerClient: Sendable { } public func stats(ids: [String] = []) async throws -> [ContainerStats] { - try await decode([ContainerStats].self, ContainerCommands.stats(ids: ids), "stats") + try await decode([ContainerStats].self, + ContainerCommands.stats(ids: ids), + "stats", + priority: .utility) + } + + public func streamStatsTable(ids: [String] = []) -> AsyncThrowingStream { + runner.stream(ContainerCommands.statsTableStream(ids: ids), priority: .utility) } public func diskUsage() async throws -> DiskUsage { @@ -184,8 +191,11 @@ public struct ContainerClient: Sendable { // MARK: Helpers - private func decode(_ type: T.Type, _ args: [String], _ name: String) async throws -> T { - let data = try await runner.run(args) + private func decode(_ type: T.Type, + _ args: [String], + _ name: String, + priority: CommandExecutionPriority = .userInitiated) async throws -> T { + let data = try await runner.run(args, stdin: nil, priority: priority) do { return try ContainerJSON.decode(type, from: data) } catch { diff --git a/Sources/ContainedCore/Services/ContainerCommands.swift b/Sources/ContainedCore/Services/ContainerCommands.swift index 38c5e6bf..4eab7e77 100644 --- a/Sources/ContainedCore/Services/ContainerCommands.swift +++ b/Sources/ContainedCore/Services/ContainerCommands.swift @@ -5,6 +5,13 @@ import Foundation public enum ContainerCommands { static let jsonFormat = ["--format", "json"] + public enum StatsFormat: String, Sendable { + case json + case table + case toml + case yaml + } + // MARK: Containers public static func list(all: Bool) -> [String] { @@ -13,10 +20,14 @@ public enum ContainerCommands { return args + jsonFormat } - public static func stats(ids: [String] = [], noStream: Bool = true) -> [String] { + public static func stats(ids: [String] = [], noStream: Bool = true, format: StatsFormat = .json) -> [String] { var args = ["stats"] if noStream { args.append("--no-stream") } - return args + jsonFormat + ids + return args + ["--format", format.rawValue] + ids + } + + public static func statsTableStream(ids: [String] = []) -> [String] { + stats(ids: ids, noStream: false, format: .table) } public static func start(_ ids: [String]) -> [String] { ["start"] + ids } diff --git a/Sources/ContainedCore/Services/ContainerRuntime.swift b/Sources/ContainedCore/Services/ContainerRuntime.swift index 07093572..931f963f 100644 --- a/Sources/ContainedCore/Services/ContainerRuntime.swift +++ b/Sources/ContainedCore/Services/ContainerRuntime.swift @@ -104,6 +104,7 @@ public protocol ContainerRuntimeClient: Sendable { func listContainers(all: Bool) async throws -> [ContainerSnapshot] func stats(ids: [String]) async throws -> [ContainerStats] + func streamStatsTable(ids: [String]) -> AsyncThrowingStream func diskUsage() async throws -> DiskUsage func systemProperties() async throws -> SystemProperties func dnsDomains() async throws -> [String] diff --git a/Sources/ContainedCore/Services/ContainerStatsTableParser.swift b/Sources/ContainedCore/Services/ContainerStatsTableParser.swift new file mode 100644 index 00000000..1ea5736e --- /dev/null +++ b/Sources/ContainedCore/Services/ContainerStatsTableParser.swift @@ -0,0 +1,164 @@ +import Foundation + +/// Parses the ANSI table emitted by `container stats --format table`. +/// +/// Apple container currently streams live stats only in table mode. Structured formats are static, +/// so this parser is intentionally small, dependency-free, and isolated behind the runtime client +/// boundary until Apple exposes a stable structured streaming surface. +public struct ContainerStatsTableParser: Sendable { + private static let clearScreen = "\u{001B}[H\u{001B}[J" + private static let columns = ["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"] + + private var buffer = "" + private var lastEmittedFrame: String? + + public init() {} + + public mutating func append(_ chunk: String) -> [RuntimeStatsSnapshot] { + buffer += chunk + guard let frame = Self.latestParseableFrame(in: buffer), frame != lastEmittedFrame else { return [] } + guard let snapshots = Self.parseFrame(frame), !snapshots.isEmpty else { return [] } + lastEmittedFrame = frame + trimBuffer() + return snapshots + } + + public static func parseLatestFrame(in output: String) -> [RuntimeStatsSnapshot] { + guard let frame = latestParseableFrame(in: output), + let snapshots = parseFrame(frame) else { + return [] + } + return snapshots + } + + public static func parseFrame(_ frame: String) -> [RuntimeStatsSnapshot]? { + let lines = stripANSI(from: frame) + .components(separatedBy: .newlines) + .map { String($0) } + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + guard let headerIndex = lines.lastIndex(where: isHeaderLine) else { return nil } + let header = lines[headerIndex] + let starts = columns.compactMap { column -> Int? in + guard let range = header.range(of: column) else { return nil } + return header.distance(from: header.startIndex, to: range.lowerBound) + } + guard starts.count == columns.count else { return nil } + + var snapshots: [RuntimeStatsSnapshot] = [] + for row in lines.dropFirst(headerIndex + 1) { + let trimmed = row.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("error collecting stats") { continue } + if let snapshot = parseRow(row, starts: starts) { + snapshots.append(snapshot) + } + } + return snapshots + } + + private mutating func trimBuffer() { + let frames = buffer.components(separatedBy: Self.clearScreen) + guard frames.count > 3 else { return } + buffer = frames.suffix(2).joined(separator: Self.clearScreen) + } + + private static func latestParseableFrame(in output: String) -> String? { + output.components(separatedBy: clearScreen) + .reversed() + .first { frame in + let stripped = stripANSI(from: frame) + return isHeaderLine(stripped) && !stripped.contains("error collecting stats") + } + } + + private static func isHeaderLine(_ line: String) -> Bool { + columns.allSatisfy { line.contains($0) } + } + + private static func parseRow(_ row: String, starts: [Int]) -> RuntimeStatsSnapshot? { + let fields = starts.enumerated().map { index, start in + let end = index + 1 < starts.count ? starts[index + 1] : nil + return field(in: row, start: start, end: end) + } + guard fields.count == columns.count else { return nil } + let id = fields[0] + guard !id.isEmpty, id != "Container ID" else { return nil } + let memory = parseBytePair(fields[2]) + let network = parseBytePair(fields[3]) + let block = parseBytePair(fields[4]) + + return RuntimeStatsSnapshot( + id: id, + cpuCoreFraction: parseCPU(fields[1]), + memoryUsageBytes: memory.first, + memoryLimitBytes: memory.second, + blockReadBytes: block.first, + blockWriteBytes: block.second, + networkRxBytes: network.first, + networkTxBytes: network.second, + numProcesses: UInt64(fields[5]) + ) + } + + private static func field(in line: String, start: Int, end: Int?) -> String { + guard start < line.count else { return "" } + let lower = line.index(line.startIndex, offsetBy: start) + let upper: String.Index + if let end, end < line.count { + upper = line.index(line.startIndex, offsetBy: end) + } else { + upper = line.endIndex + } + return String(line[lower.. Double? { + let cleaned = value.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "%", with: "") + guard cleaned != "--", let percent = Double(cleaned) else { return nil } + return percent / 100 + } + + private static func parseBytePair(_ value: String) -> (first: UInt64?, second: UInt64?) { + let parts = value.components(separatedBy: " / ") + guard parts.count == 2 else { return (nil, nil) } + return (parseBytes(parts[0]), parseBytes(parts[1])) + } + + private static func parseBytes(_ value: String) -> UInt64? { + let cleaned = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard cleaned != "--" else { return nil } + let pieces = cleaned.split(whereSeparator: \.isWhitespace) + guard let number = pieces.first.flatMap({ Double($0) }) else { return nil } + let unit = pieces.dropFirst().first.map { String($0).lowercased() } ?? "b" + let multiplier: Double + switch unit { + case "b", "byte", "bytes": + multiplier = 1 + case "kb": + multiplier = 1_000 + case "mb": + multiplier = 1_000_000 + case "gb": + multiplier = 1_000_000_000 + case "tb": + multiplier = 1_000_000_000_000 + case "kib": + multiplier = 1_024 + case "mib": + multiplier = 1_024 * 1_024 + case "gib": + multiplier = 1_024 * 1_024 * 1_024 + case "tib": + multiplier = 1_024 * 1_024 * 1_024 * 1_024 + default: + return nil + } + return UInt64((number * multiplier).rounded()) + } + + private static func stripANSI(from string: String) -> String { + let pattern = "\u{001B}\\[[0-?]*[ -/]*[@-~]" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return string } + let range = NSRange(string.startIndex.. RuntimeStatsSnapshot { + RuntimeStatsSnapshot(id: "fixture-web", + cpuCoreFraction: cpuCoreFraction, + memoryUsageBytes: 2_322_432, + memoryLimitBytes: 1_073_741_824, + blockReadBytes: 2_154_496, + blockWriteBytes: 0, + networkRxBytes: networkRxBytes, + networkTxBytes: 516, + numProcesses: 1) } } @@ -80,7 +70,9 @@ private actor RecordingRunner: CommandRunning { private var calls: [[String]] = [] private var statsRuns = 0 - func run(_ arguments: [String]) async throws -> Data { + func run(_ arguments: [String], + stdin: Data?, + priority: CommandExecutionPriority) async throws -> Data { calls.append(arguments) switch arguments.first { case "list": @@ -93,7 +85,7 @@ private actor RecordingRunner: CommandRunning { } } - nonisolated func stream(_ arguments: [String]) -> AsyncThrowingStream { + nonisolated func stream(_ arguments: [String], priority: CommandExecutionPriority) -> AsyncThrowingStream { AsyncThrowingStream { continuation in continuation.finish() } } diff --git a/Tests/ContainedCoreTests/CommandTests.swift b/Tests/ContainedCoreTests/CommandTests.swift index 140d4dec..429d52c3 100644 --- a/Tests/ContainedCoreTests/CommandTests.swift +++ b/Tests/ContainedCoreTests/CommandTests.swift @@ -182,4 +182,26 @@ struct CommandTests { #expect(delta.netRxBytesPerSec == 2048) #expect(delta.numProcesses == 2) } + + @Test func statsDeltaConvertsRuntimeSnapshotRates() { + let previous = RuntimeStatsSnapshot(id: "x", cpuCoreFraction: 0.1, + memoryUsageBytes: 100, memoryLimitBytes: 1000, + blockReadBytes: 1_000, blockWriteBytes: 2_000, + networkRxBytes: 3_000, networkTxBytes: 4_000, + numProcesses: 1) + let current = RuntimeStatsSnapshot(id: "x", cpuCoreFraction: 0.42, + memoryUsageBytes: 200, memoryLimitBytes: 1000, + blockReadBytes: 1_500, blockWriteBytes: 2_800, + networkRxBytes: 4_000, networkTxBytes: 4_400, + numProcesses: 3) + + let delta = StatsDelta.from(snapshot: current, previous: previous, interval: 2) + #expect(delta.cpuCoreFraction == 0.42) + #expect(delta.memoryFraction == 0.2) + #expect(delta.blockReadBytesPerSec == 250) + #expect(delta.blockWriteBytesPerSec == 400) + #expect(delta.netRxBytesPerSec == 500) + #expect(delta.netTxBytesPerSec == 200) + #expect(delta.numProcesses == 3) + } } diff --git a/Tests/ContainedCoreTests/Fixtures.swift b/Tests/ContainedCoreTests/Fixtures.swift index e47c51c9..ec574be0 100644 --- a/Tests/ContainedCoreTests/Fixtures.swift +++ b/Tests/ContainedCoreTests/Fixtures.swift @@ -23,8 +23,10 @@ struct MockCommandRunner: CommandRunning { var result: Result /// Scripted chunks yielded by `stream()` (then the stream finishes). var streamChunks: [String] = [] - func run(_ arguments: [String]) async throws -> Data { try result.get() } - func stream(_ arguments: [String]) -> AsyncThrowingStream { + func run(_ arguments: [String], + stdin: Data?, + priority: CommandExecutionPriority) async throws -> Data { try result.get() } + func stream(_ arguments: [String], priority: CommandExecutionPriority) -> AsyncThrowingStream { let chunks = streamChunks return AsyncThrowingStream { continuation in for chunk in chunks { continuation.yield(chunk) } diff --git a/Tests/ContainedCoreTests/Fixtures/stats-table.txt b/Tests/ContainedCoreTests/Fixtures/stats-table.txt new file mode 100644 index 00000000..85cb21e6 --- /dev/null +++ b/Tests/ContainedCoreTests/Fixtures/stats-table.txt @@ -0,0 +1,9 @@ +[?1049h[?25lContainer ID Cpu % Memory Usage Net Rx/Tx Block I/O Pids +Container ID Cpu % Memory Usage Net Rx/Tx Block I/O Pids +buildkit 0.00% 103.19 MiB / 2.00 GiB 475.54 KiB / 0.59 KiB 59.17 MiB / 24.00 KiB 17 +sonarrhd 0.07% 276.57 MiB / 1.00 GiB 2.37 MiB / 3.68 MiB 142.96 MiB / 700 KiB 26 +Container ID Cpu % Memory Usage Net Rx/Tx Block I/O Pids +buildkit 0.00% 103.19 MiB / 2.00 GiB 475.54 KiB / 0.59 KiB 59.17 MiB / 24.00 KiB 17 +sonarrhd 0.06% 276.57 MiB / 1.00 GiB 2.37 MiB / 3.68 MiB 142.96 MiB / 700 KiB 26 +error collecting stats: CancellationError() +[?25h[?1049l diff --git a/Tests/ContainedCoreTests/Phase4Tests.swift b/Tests/ContainedCoreTests/Phase4Tests.swift index 717b6225..aaa4049f 100644 --- a/Tests/ContainedCoreTests/Phase4Tests.swift +++ b/Tests/ContainedCoreTests/Phase4Tests.swift @@ -9,9 +9,10 @@ struct Phase4Tests { @Test func imageAndStreamingArgv() { #expect(ContainerCommands.imageList() == ["image", "list", "--format", "json"]) - // Streaming stats omits --no-stream. + // Structured stats stay one-shot; table mode is the Apple CLI's streaming surface. #expect(ContainerCommands.stats(ids: ["web"], noStream: false) == ["stats", "--format", "json", "web"]) #expect(ContainerCommands.stats(ids: ["web"]) == ["stats", "--no-stream", "--format", "json", "web"]) + #expect(ContainerCommands.statsTableStream(ids: ["web"]) == ["stats", "--format", "table", "web"]) #expect(ContainerCommands.logs("web", follow: true, tail: 500) == ["logs", "--follow", "-n", "500", "web"]) } @@ -115,6 +116,21 @@ struct Phase4Tests { #expect(received == ["line one\n", "line two\n"]) } + @Test func statsTableParserUsesLatestANSIFrame() throws { + let samples = ContainerStatsTableParser.parseLatestFrame(in: try Fixture.string("stats-table")) + #expect(samples.count == 2) + #expect(samples[0].id == "buildkit") + #expect(samples[0].memoryUsageBytes == 108_202_557) + #expect(samples[0].memoryLimitBytes == 2_147_483_648) + #expect(samples[0].networkRxBytes == 486_953) + #expect(samples[0].networkTxBytes == 604) + #expect(samples[0].blockReadBytes == 62_044_242) + #expect(samples[0].blockWriteBytes == 24_576) + #expect(samples[0].numProcesses == 17) + #expect(samples[1].id == "sonarrhd") + #expect(abs((samples[1].cpuCoreFraction ?? 0) - 0.0006) < 0.00001) + } + // MARK: Restart watchdog decision logic @Test func restartPolicyParsing() { diff --git a/changes/unreleased/20260702-refresh-stats-performance.md b/changes/unreleased/20260702-refresh-stats-performance.md index 473594fe..4e92bf6a 100644 --- a/changes/unreleased/20260702-refresh-stats-performance.md +++ b/changes/unreleased/20260702-refresh-stats-performance.md @@ -1,3 +1,4 @@ -- Reduced idle UI churn by decoupling container stats sampling from the main refresh tick: stats now sample slowly in the background, faster when container metrics are visible, and immediately after lifecycle actions or Stats-tab entry. +- Reduced idle UI churn by decoupling container stats from the main refresh tick: metrics now use one app-wide low-priority `container stats --format table` stream for all running containers. +- Lifecycle actions now relist containers without forcing vanity stats, so start/stop/run/recreate flows do not wait on Apple container's two-second stats sampling path. - Reduced runtime layout churn by replacing the container grid's bound card-frame preference with coalesced frame tracking and clamping morph panel geometry before it reaches SwiftUI frames. -- Added diagnostic timing around refresh, stats sampling, image list refreshes, and image update sweeps so future sluggishness has concrete evidence before tuning. +- Added diagnostic timing around refresh, stats streaming/sampling, image list refreshes, and image update sweeps so future sluggishness has concrete evidence before tuning. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 17738510..8033ede7 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -1,10 +1,10 @@ # Architecture -Contained is a SwiftUI-native macOS app that wraps Apple's `container` CLI. It shells out to the CLI with `--format json` and decodes typed models — there is no private API or daemon. +Contained is a SwiftUI-native macOS app that wraps Apple's `container` CLI. It shells out to public CLI commands, usually with `--format json`, and decodes typed models. Visible container stats are the exception: Apple container only streams stats in table mode, so Contained parses that public table stream behind the same runtime boundary. There is no private API or daemon. ``` SwiftUI Views ──> @Observable Stores ──> ContainerClient ──> CommandRunner ──> `container` CLI - (Features/*) (AppModel, …) (typed facade) (run / stream) (--format json) + (Features/*) (AppModel, …) (typed facade) (run / stream) (json / table) ^ │ ^ │ └──── ContainedDesignSystem ────────────────┘ │ └─────────────────── decoded models (ContainedCore) ◀───────────┘ @@ -29,15 +29,16 @@ remains the source of truth for builds, tests, and release scripts. ## CLI wrapper (core) - **`ContainerCommands`** — pure argv builders, side-effect-free so golden tests assert the exact arguments (the "Reveal CLI" affordances read from the same source of truth). -- **`CommandRunner`** — runs a one-shot command (`run`) or a streaming one (`stream`, an `AsyncThrowingStream`). Passwords are piped via `--password-stdin`, never argv. +- **`CommandRunner`** — runs a one-shot command (`run`) or a streaming one (`stream`, an `AsyncThrowingStream`). Commands can opt into utility/background priority for vanity work such as stats. Passwords are piped via `--password-stdin`, never argv. - **`ContainerClient`** — the Apple `container` implementation of `ContainerRuntimeClient`; returns decoded models and maps decode failures to a single `CommandError`. +- **`ContainerStatsTableParser`** — dependency-free parser for the ANSI table emitted by `container stats --format table`. It converts table frames into runtime-agnostic snapshots so the app can keep one visible stats stream open instead of spawning repeated JSON stats processes. - **`ContainerRuntimeClient`** — the backend-facing operation contract. `RuntimeDescriptor` and `RuntimeCapability` advertise what a selected runtime can do before future Docker-compatible integration reaches the UI. ## Stores (app) - **`AppModel`** — root state: locates the CLI, owns the client + feature stores, tracks bootstrap status, wires logging/updating, and runs the per-tick coordination. Focused extensions own image/resource style lookup, image-update sweeps, and configuration import/export. -- **`ContainersStore`** — the container list, live stats deltas, stats sampling cadence, and lifecycle actions. -- **`RefreshCoordinator`** — adaptive polling for service/list refreshes. Stats are polled, not truly streamed — the CLI emits one frame then blocks — so `ContainersStore` samples them less often in the background, faster when the Containers UI is visible, and immediately after lifecycle actions or Stats-tab entry. +- **`ContainersStore`** — the container list, live stats deltas, streamed stats conversion, and lifecycle actions. +- **`RefreshCoordinator`** — adaptive polling for service/list refreshes. Stats are maintained app-wide by one utility-priority table stream for the running containers, so normal refreshes and lifecycle actions relist containers without forcing vanity stats. - **`RestartWatchdog`** — app-managed restart policy (`container` has no native `--restart`); diffs states each tick and re-issues `start` with backoff. - **`HealthMonitor`** — app-managed healthchecks: interval-gated `exec` probes with consecutive-failure tracking. - **`HistoryStore`** — SwiftData stack for the persistent event log + metric samples (the "rewind" timeline) with bounded retention. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index fd03c0a6..91ad3a31 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -31,7 +31,7 @@ Switch **Settings → Updates → Update channel** to **Beta** or **Nightly**. N ## Stats look choppy / not real-time -The `container stats --format json` command emits one frame and then blocks, so Contained **samples** stats instead of streaming them. The container list follows **Settings → General → Data → Refresh interval**; live metrics sample more slowly in the background, faster while the Containers UI is visible, and immediately when the Stats tab opens. +Apple container's structured stats formats are static, while `container stats --format table` is the public streaming surface. Contained keeps one low-priority table stream open for the running containers and converts every frame into the card, widget, history, and Stats-tab metrics. The container list itself still follows **Settings → General → Data → Refresh interval**. ## A container keeps restarting From c597bea4fe4b5de41357e085f567be51848f2214 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 02:04:26 +0100 Subject: [PATCH 03/53] Reduce stats-driven UI churn --- Packages/ContainedDesignSystem/README.md | 4 +- .../ContainedDesignSystem.md | 2 +- .../ContainedDesignSystem/LiveSparkline.swift | 351 +++++++++++------- .../Features/Containers/ContainerCard.swift | 6 +- .../Containers/ContainersGridView.swift | 64 +++- .../Containers/Creation/CreationPages.swift | 2 +- .../Features/Containers/TerminalTab.swift | 21 +- .../Contained/Navigation/ClassicShell.swift | 1 - .../ToolbarPanels/ToolbarUpdatesPanel.swift | 2 +- .../20260702-refresh-stats-performance.md | 5 + 10 files changed, 309 insertions(+), 149 deletions(-) diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 7814f501..c4ef297c 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -45,8 +45,8 @@ This package currently depends only on platform frameworks available to a macOS - `ResourceGlassCard` and `ResourceCard*` pieces for repeated card layouts. - `ActivityStatusView` with `ActivityStatusPresentation`, where callers provide plain status text/progress instead of app model objects. -- `LiveSparkline`, `GraphStyle`, and `WidgetInterpolation` for graph rendering - options. +- `LiveSparkline`, `GraphStyle`, and `WidgetInterpolation` for lightweight, + Canvas-backed live graph widgets. - Micro-primitives such as `DesignStatusDot`, `DesignStatusBadge`, `DesignKeyCap`, `DesignKeyboardHint`, `DesignTintSwatch`, and `DesignMetricTile`. diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 74ecb3a3..2b038297 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -126,7 +126,7 @@ struct DesignSystemExample: View { - ``ActivityStatusView`` - ``ActivityStatusPresentation`` -- ``LiveSparkline`` +- ``LiveSparkline`` for lightweight Canvas-backed live graph widgets - ``GraphStyle`` - ``WidgetInterpolation`` - ``MetricTile`` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift index 18ad38ab..eb383fc1 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift @@ -1,5 +1,4 @@ import SwiftUI -import Charts public enum GraphStyle: String, CaseIterable, Identifiable, Codable, Sendable { case area @@ -81,8 +80,8 @@ public enum WidgetInterpolation: String, CaseIterable, Identifiable, Codable, Se } } -/// A compact Swift Charts renderer for card widgets. Each series is normalized independently so -/// paired metrics with different units still make useful visual comparisons at card scale. +/// A compact Canvas renderer for card widgets. Each series is normalized independently so paired +/// metrics with different units still make useful visual comparisons at card scale. public struct LiveSparkline: View { public var samples: [Double] public var comparisonSamples: [Double] = [] @@ -115,130 +114,153 @@ public struct LiveSparkline: View { } public var body: some View { - Group { - if samples.count > 1 { - chart - } else { - baseline - } + Canvas(rendersAsynchronously: true) { context, size in + render(context: &context, size: size) } .accessibilityHidden(true) } - private var chart: some View { - Chart { - switch style { - case .area: - ForEach(primaryPoints) { point in - AreaMark(x: .value("Sample", point.index), y: .value("Value", point.value)) - .foregroundStyle(areaFillStyle) - .interpolationMethod(interpolation.method) - LineMark(x: .value("Sample", point.index), y: .value("Value", point.value)) - .foregroundStyle(color) - .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) - .interpolationMethod(interpolation.method) - } - case .line: - ForEach(primaryPoints) { point in - LineMark(x: .value("Sample", point.index), y: .value("Value", point.value)) - .foregroundStyle(color) - .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) - .interpolationMethod(interpolation.method) - } - case .bar: - ForEach(primaryPoints) { point in - BarMark(x: .value("Sample", point.index), y: .value("Value", point.value), width: .fixed(barWidth)) - .clipShape(Capsule()) - .foregroundStyle(color.opacity(0.76).gradient) - } - case .points: - ForEach(primaryPoints) { point in - PointMark(x: .value("Sample", point.index), y: .value("Value", point.value)) - .foregroundStyle(color) - .symbolSize(pointSize) - } - case .multiLine: - ForEach(primaryPoints) { point in - LineMark(x: .value("Sample", point.index), y: .value("Value", point.value), series: .value("Metric", "Primary")) - .foregroundStyle(color) - .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) - .interpolationMethod(interpolation.method) - } - ForEach(secondaryPoints) { point in - LineMark(x: .value("Sample", point.index), y: .value("Value", point.value), series: .value("Metric", "Secondary")) - .foregroundStyle(color.opacity(0.55)) - .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round, dash: [3, 3])) - .interpolationMethod(interpolation.method) - } - case .range: - ForEach(rangePoints) { point in - BarMark(x: .value("Sample", point.index), - yStart: .value("Low", point.low), - yEnd: .value("High", point.high), - width: .fixed(barWidth)) - .clipShape(Capsule()) - .foregroundStyle(color.opacity(0.72).gradient) - } - case .scatter: - ForEach(scatterPoints) { point in - PointMark(x: .value("Primary", point.x), y: .value("Secondary", point.y)) - .foregroundStyle(color) - .symbolSize(pointSize) - } - } + private func render(context: inout GraphicsContext, size: CGSize) { + guard size.width > 0, size.height > 0 else { return } + guard samples.count > 1 else { + drawBaseline(context: &context, size: size) + return } - .chartXAxis(.hidden) - .chartYAxis(.hidden) - .chartLegend(.hidden) - .chartYScale(domain: 0...1) - .chartPlotStyle { plot in - plot.background(.clear) + + switch style { + case .area: + let points = plottedPoints(for: samples, in: size) + drawArea(points, context: &context, size: size) + drawLine(points, color: color, context: &context) + case .line: + drawLine(plottedPoints(for: samples, in: size), color: color, context: &context) + case .bar: + drawBars(values: samples, context: &context, size: size) + case .points: + drawPoints(plottedPoints(for: samples, in: size), color: color, context: &context) + case .multiLine: + let primary = plottedPoints(for: samples, in: size) + let secondary = plottedPoints(for: comparisonSamples, in: size) + drawLine(primary, color: color, context: &context) + drawLine(secondary, color: color.opacity(0.55), context: &context, dash: [3, 3]) + case .range: + drawRange(primary: samples, secondary: comparisonSamples, context: &context, size: size) + case .scatter: + drawScatter(primary: samples, secondary: comparisonSamples, context: &context, size: size) } } - private var baseline: some View { - Canvas { context, size in - var path = Path() - path.move(to: CGPoint(x: 0, y: size.height - lineWidth)) - path.addLine(to: CGPoint(x: size.width, y: size.height - lineWidth)) - context.stroke(path, with: .color(color.opacity(0.35)), lineWidth: lineWidth) - } + private func drawBaseline(context: inout GraphicsContext, size: CGSize) { + let inset = drawingInset + var path = Path() + path.move(to: CGPoint(x: 0, y: size.height - inset)) + path.addLine(to: CGPoint(x: size.width, y: size.height - inset)) + context.stroke(path, + with: .color(color.opacity(0.35)), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) } - private var areaFillStyle: AnyShapeStyle { + private func drawArea(_ points: [CGPoint], context: inout GraphicsContext, size: CGSize) { + guard points.count > 1 else { return } + let baseline = size.height - drawingInset + let path = linePath(points: points, closingToBaseline: baseline) if areaUsesGradient { - return AnyShapeStyle( - LinearGradient(colors: [color.opacity(0.25), color.opacity(0.02)], - startPoint: .top, - endPoint: .bottom) - ) + context.fill(path, + with: .linearGradient( + Gradient(colors: [color.opacity(0.25), color.opacity(0.02)]), + startPoint: CGPoint(x: 0, y: 0), + endPoint: CGPoint(x: 0, y: size.height) + )) + } else { + context.fill(path, with: .color(color.opacity(0.22))) } - return AnyShapeStyle(color.opacity(0.22)) } - private var primaryPoints: [ChartPoint] { - normalized(samples).enumerated().map { ChartPoint(index: $0.offset, value: $0.element) } + private func drawLine(_ points: [CGPoint], + color: Color, + context: inout GraphicsContext, + dash: [CGFloat] = []) { + guard points.count > 1 else { return } + context.stroke(linePath(points: points), + with: .color(color), + style: StrokeStyle(lineWidth: lineWidth, + lineCap: .round, + lineJoin: .round, + dash: dash)) } - private var secondaryPoints: [ChartPoint] { - normalized(comparisonSamples).enumerated().map { ChartPoint(index: $0.offset, value: $0.element) } + private func drawBars(values: [Double], context: inout GraphicsContext, size: CGSize) { + let points = plottedPoints(for: values, in: size) + guard !points.isEmpty else { return } + let baseline = size.height - drawingInset + let resolvedBarWidth = min(max(barWidth, 1), max(size.width / CGFloat(max(points.count, 1)) * 0.72, 1)) + for point in points { + let top = min(point.y, baseline) + let height = max(abs(baseline - point.y), lineWidth) + let rect = CGRect(x: point.x - resolvedBarWidth / 2, + y: top, + width: resolvedBarWidth, + height: height) + context.fill(Path(roundedRect: rect, cornerRadius: resolvedBarWidth / 2), + with: .color(color.opacity(0.76))) + } } - private var rangePoints: [ChartRangePoint] { - let count = min(primaryPoints.count, secondaryPoints.count) - guard count > 0 else { return [] } - return (0.. 0 else { return [] } - return (0.. 0 else { return } + + let resolvedBarWidth = min(max(barWidth, 1), max(size.width / CGFloat(count) * 0.72, 1)) + for index in 0.. 0 else { return } + + let inset = drawingInset + let width = max(size.width - inset * 2, 1) + let height = max(size.height - inset * 2, 1) + let diameter = max(sqrt(pointSize), 2) + for index in 0.. [CGPoint] { + let normalizedValues = normalized(values) + guard !normalizedValues.isEmpty else { return [] } + let inset = drawingInset + let width = max(size.width, 1) + let height = max(size.height - inset * 2, 1) + let denominator = CGFloat(max(normalizedValues.count - 1, 1)) -private struct ChartRangePoint: Identifiable { - let index: Int - let low: Double - let high: Double - var id: Int { index } -} + return normalizedValues.enumerated().map { index, value in + let x = normalizedValues.count == 1 ? width / 2 : width * CGFloat(index) / denominator + let y = inset + CGFloat(1 - value) * height + return CGPoint(x: x, y: y) + } + } -private struct ChartScatterPoint: Identifiable { - let index: Int - let x: Double - let y: Double - var id: Int { index } -} + private func linePath(points: [CGPoint], closingToBaseline baseline: CGFloat? = nil) -> Path { + var path = Path() + guard let first = points.first else { return path } + if let baseline { + path.move(to: CGPoint(x: first.x, y: baseline)) + path.addLine(to: first) + } else { + path.move(to: first) + } -private extension WidgetInterpolation { - var method: InterpolationMethod { - switch self { - case .linear: return .linear - case .catmullRom: return .catmullRom - case .cardinal: return .cardinal - case .monotone: return .monotone - case .stepStart: return .stepStart - case .stepCenter: return .stepCenter - case .stepEnd: return .stepEnd + appendSegments(points: points, to: &path) + + if let baseline, let last = points.last { + path.addLine(to: CGPoint(x: last.x, y: baseline)) + path.closeSubpath() + } + return path + } + + private func appendSegments(points: [CGPoint], to path: inout Path) { + guard points.count > 1 else { return } + switch interpolation { + case .linear, .monotone: + for point in points.dropFirst() { + path.addLine(to: point) + } + case .stepStart: + appendStepSegments(points: points, to: &path, mode: .start) + case .stepCenter: + appendStepSegments(points: points, to: &path, mode: .center) + case .stepEnd: + appendStepSegments(points: points, to: &path, mode: .end) + case .catmullRom: + appendCatmullRomSegments(points: points, to: &path, tension: 1) + case .cardinal: + appendCatmullRomSegments(points: points, to: &path, tension: 0.65) + } + } + + private enum StepMode { case start, center, end } + + private func appendStepSegments(points: [CGPoint], to path: inout Path, mode: StepMode) { + var previous = points[0] + for point in points.dropFirst() { + switch mode { + case .start: + path.addLine(to: CGPoint(x: previous.x, y: point.y)) + path.addLine(to: point) + case .center: + let midpoint = (previous.x + point.x) / 2 + path.addLine(to: CGPoint(x: midpoint, y: previous.y)) + path.addLine(to: CGPoint(x: midpoint, y: point.y)) + path.addLine(to: point) + case .end: + path.addLine(to: CGPoint(x: point.x, y: previous.y)) + path.addLine(to: point) + } + previous = point + } + } + + private func appendCatmullRomSegments(points: [CGPoint], to path: inout Path, tension: CGFloat) { + for index in 0..<(points.count - 1) { + let p0 = points[max(index - 1, 0)] + let p1 = points[index] + let p2 = points[index + 1] + let p3 = points[min(index + 2, points.count - 1)] + let scale = tension / 6 + let control1 = CGPoint(x: p1.x + (p2.x - p0.x) * scale, + y: p1.y + (p2.y - p0.y) * scale) + let control2 = CGPoint(x: p2.x - (p3.x - p1.x) * scale, + y: p2.y - (p3.y - p1.y) * scale) + path.addCurve(to: p2, control1: control1, control2: control2) } } + + private var drawingInset: CGFloat { + max(lineWidth / 2, 1) + } } /// A fixed-size ring buffer for sparkline history. diff --git a/Sources/Contained/Features/Containers/ContainerCard.swift b/Sources/Contained/Features/Containers/ContainerCard.swift index dcd75f88..560ed5be 100644 --- a/Sources/Contained/Features/Containers/ContainerCard.swift +++ b/Sources/Contained/Features/Containers/ContainerCard.swift @@ -12,7 +12,7 @@ struct ContainerCard: View { var history: [Double] /// Every metric's recent history, so the expanded footer's chips can flip the graph instantly /// without waiting for the parent to recompute. The compact card just uses `history`. - var histories: [GraphMetric: [Double]] = [:] + var histories: [GraphMetric: SampleBuffer] = [:] var isBusy: Bool var hasImageUpdate: Bool = false var isExpanded: Bool = false @@ -149,8 +149,8 @@ struct ContainerCard: View { } footerActions: { footerActions } widget: { - LiveSparkline(samples: histories[activeWidget.metric] ?? history, - comparisonSamples: activeWidgetComparisonMetric.flatMap { histories[$0] } ?? [], + LiveSparkline(samples: histories[activeWidget.metric]?.values ?? history, + comparisonSamples: activeWidgetComparisonMetric.flatMap { histories[$0]?.values } ?? [], color: activeWidgetColor, lineWidth: activeWidget.lineWidth, style: activeWidget.style, diff --git a/Sources/Contained/Features/Containers/ContainersGridView.swift b/Sources/Contained/Features/Containers/ContainersGridView.swift index 5ed2038e..7b533061 100644 --- a/Sources/Contained/Features/Containers/ContainersGridView.swift +++ b/Sources/Contained/Features/Containers/ContainersGridView.swift @@ -363,13 +363,11 @@ struct ContainersGridView: View { cornerRadiusOverride: CGFloat? = nil, controlsVisible: Bool = true, onTap: @escaping () -> Void) -> some View { let style = app.containerStyle(for: snapshot) - return ContainerCard( + return ContainerCardMetricsRenderer( + store: store, snapshot: snapshot, style: style, density: app.settings.density, - stats: store.statsByID[snapshot.id], - history: store.historyByID[snapshot.id]?[style.graphMetric]?.values ?? [], - histories: (store.historyByID[snapshot.id] ?? [:]).mapValues(\.values), isBusy: store.busyIDs.contains(snapshot.id), hasImageUpdate: app.imageUpdateStatus(for: snapshot.image).state == .updateAvailable, isExpanded: isExpanded, @@ -504,6 +502,64 @@ struct ContainersGridView: View { } } +private struct ContainerCardMetricsRenderer: View { + let store: ContainersStore + let snapshot: ContainerSnapshot + let style: Personalization + let density: CardDensity + let isBusy: Bool + let hasImageUpdate: Bool + let isExpanded: Bool + let cornerRadiusOverride: CGFloat? + let controlsVisible: Bool + let onTap: () -> Void + let onStart: () -> Void + let onStop: () -> Void + let onRestart: () -> Void + let onEdit: () -> Void + let onUpdate: () -> Void + let onDelete: () -> Void + let onClose: () -> Void + let onSelectMultiple: () -> Void + let onToggleSelected: () -> Void + let onEndSelecting: () -> Void + let revealCLI: Bool + let health: HealthStatus + let selecting: Bool + let isSelected: Bool + + var body: some View { + ContainerCard( + snapshot: snapshot, + style: style, + density: density, + stats: store.statsByID[snapshot.id], + history: store.historyByID[snapshot.id]?[style.graphMetric]?.values ?? [], + histories: store.historyByID[snapshot.id] ?? [:], + isBusy: isBusy, + hasImageUpdate: hasImageUpdate, + isExpanded: isExpanded, + cornerRadiusOverride: cornerRadiusOverride, + controlsVisible: controlsVisible, + onTap: onTap, + onStart: onStart, + onStop: onStop, + onRestart: onRestart, + onEdit: onEdit, + onUpdate: onUpdate, + onDelete: onDelete, + onClose: onClose, + onSelectMultiple: onSelectMultiple, + onToggleSelected: onToggleSelected, + onEndSelecting: onEndSelecting, + revealCLI: revealCLI, + health: health, + selecting: selecting, + isSelected: isSelected + ) + } +} + private extension CGRect { var isUsableForMorph: Bool { width.isFinite && height.isFinite && minX.isFinite && minY.isFinite && width > 1 && height > 1 diff --git a/Sources/Contained/Features/Containers/Creation/CreationPages.swift b/Sources/Contained/Features/Containers/Creation/CreationPages.swift index 8304f8f2..13176fc4 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationPages.swift +++ b/Sources/Contained/Features/Containers/Creation/CreationPages.swift @@ -157,7 +157,7 @@ struct CreationLocalImagesContent: View { } } } - .task { await app.refreshImagesIfStale(force: true) } + .task { await app.refreshImagesIfStale() } } private var filteredLocalImages: [ContainedCore.ImageResource] { diff --git a/Sources/Contained/Features/Containers/TerminalTab.swift b/Sources/Contained/Features/Containers/TerminalTab.swift index 4c68c29c..552cbef6 100644 --- a/Sources/Contained/Features/Containers/TerminalTab.swift +++ b/Sources/Contained/Features/Containers/TerminalTab.swift @@ -3,6 +3,7 @@ import ContainedDesignSystem import AppKit import SwiftTerm import ContainedCore +import Darwin /// An interactive shell inside a running container, via `container exec -it `. /// @@ -38,7 +39,9 @@ struct TerminalTab: View { TerminalSurface(executableURL: url, containerID: snapshot.id, shell: shell) { code in ended = Ended(code: code) } - .id(session) // recreating the view tears down the old exec + starts fresh + // Recreating the view tears down the old exec. Include container/shell so rapid + // card switches cannot reuse a terminal process for a different target. + .id("\(snapshot.id)-\(shell)-\(session)") .terminalSurfaceChrome() if let ended { endedOverlay(code: ended.code) @@ -89,7 +92,8 @@ struct TerminalTab: View { } /// `NSViewRepresentable` wrapper around SwiftTerm's `LocalProcessTerminalView`, which owns the PTY -/// and child process. Tearing down the view (`dismantleNSView`) terminates the `exec` child. +/// and child process. Tearing down the view (`dismantleNSView`) terminates the `exec` child and +/// escalates stale `container exec --tty` children that do not exit from SwiftTerm's SIGTERM. struct TerminalSurface: NSViewRepresentable { let executableURL: URL let containerID: String @@ -122,7 +126,9 @@ struct TerminalSurface: NSViewRepresentable { func updateNSView(_ nsView: LocalProcessTerminalView, context: Context) {} static func dismantleNSView(_ nsView: LocalProcessTerminalView, coordinator: Coordinator) { + let pid = nsView.process?.shellPid ?? 0 nsView.terminate() + TerminalProcessReaper.terminate(pid) } final class Coordinator: NSObject, LocalProcessTerminalViewDelegate { @@ -135,3 +141,14 @@ struct TerminalSurface: NSViewRepresentable { func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} } } + +private enum TerminalProcessReaper { + static func terminate(_ pid: pid_t) { + guard pid > 0 else { return } + kill(pid, SIGTERM) + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.7) { + guard kill(pid, 0) == 0 else { return } + kill(pid, SIGKILL) + } + } +} diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/Contained/Navigation/ClassicShell.swift index 5ad505ff..aaba3b74 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/Contained/Navigation/ClassicShell.swift @@ -362,7 +362,6 @@ private struct ImagesPage: View { coordinateSpaceName: pageImageSpace, onOpenImage: openImageDetail, onClose: {}) - .task { await app.refreshImagesIfStale(force: true) } if let detail, presented { MorphingExpander(isPresented: detailBinding, diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift index 620f1fb9..a94751b1 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift @@ -69,7 +69,7 @@ struct ToolbarUpdatesPanel: View { } .padding(Tokens.Space.s) } - .task { await app.refreshImagesIfStale(force: true) } + .task { await app.refreshImagesIfStale() } } private var header: some View { diff --git a/changes/unreleased/20260702-refresh-stats-performance.md b/changes/unreleased/20260702-refresh-stats-performance.md index 4e92bf6a..24c1eabf 100644 --- a/changes/unreleased/20260702-refresh-stats-performance.md +++ b/changes/unreleased/20260702-refresh-stats-performance.md @@ -1,4 +1,9 @@ - Reduced idle UI churn by decoupling container stats from the main refresh tick: metrics now use one app-wide low-priority `container stats --format table` stream for all running containers. - Lifecycle actions now relist containers without forcing vanity stats, so start/stop/run/recreate flows do not wait on Apple container's two-second stats sampling path. +- Narrowed stats-driven SwiftUI invalidation by moving per-container metric reads into each card renderer instead of the grid parent. +- Kept long-form History/Stats charts on Swift Charts while moving always-live card sparklines to a lightweight native Canvas renderer. +- Stopped materializing every metric history array for every card frame; cards now pull only the active widget series from shared sample buffers. +- Hardened embedded terminal teardown so rapid card/tab switching does not leave stale `container exec --tty` children behind. +- Coalesced image-panel appearance refreshes so page/panel navigation does not force duplicate image-list reloads while users click through the UI. - Reduced runtime layout churn by replacing the container grid's bound card-frame preference with coalesced frame tracking and clamping morph panel geometry before it reaches SwiftUI frames. - Added diagnostic timing around refresh, stats streaming/sampling, image list refreshes, and image update sweeps so future sluggishness has concrete evidence before tuning. From 9f32ca8ecb6d4f4ed427b84df9407c00d13b145d Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 04:38:04 +0100 Subject: [PATCH 04/53] Extract design system and optimize stats base --- Package.swift | 10 + Packages/ContainedDesignSystem/README.md | 95 +++- .../ContainedDesignSystem.md | 91 +++- .../JSONInspectorSheet.swift | 2 +- .../ContainedDesignSystem/LiveSparkline.swift | 414 +++++++++--------- .../ContainedDesignSystem/MetricTile.swift | 7 +- .../MicroPrimitives.swift | 54 +++ .../ContainedDesignSystem/PageScaffold.swift | 2 +- .../ContainedDesignSystem/PanelSection.swift | 2 +- .../ResourceCardChrome.swift | 203 +++++++++ .../ResourceGlassCard.swift | 3 +- Packages/ContainedNavigation/README.md | 14 + .../ContainedNavigation.md | 7 + .../MorphPanelScaffold.swift | 6 +- .../MorphingExpander.swift | 14 +- .../MorphingSingleSurface.swift | 87 ++++ .../Contained/DesignSystem/GraphMetric.swift | 84 +++- .../DesignSystem/StatsNormalization.swift | 59 +++ .../Features/Containers/ContainerCard.swift | 188 +++++--- .../Containers/ContainerConfigureView.swift | 2 +- .../Containers/ContainerOverviewTab.swift | 2 +- .../Containers/ContainerTabScaffold.swift | 23 +- .../Containers/ContainersGridView.swift | 42 +- .../Containers/Creation/CreationFlow.swift | 2 +- .../Containers/Creation/CreationPages.swift | 38 +- .../Features/Containers/CustomizeSheet.swift | 29 +- .../Containers/CustomizeWidgetsPanel.swift | 9 +- .../Features/Containers/FilesTab.swift | 2 + .../Features/Containers/LogsTab.swift | 2 + .../Features/Containers/RunSpecForm.swift | 6 +- .../Features/Containers/StatsTab.swift | 49 ++- .../Features/Containers/TerminalTab.swift | 4 +- .../Features/Images/BuildWorkspaceView.swift | 4 +- .../Features/Images/ImageStyleButton.swift | 30 +- .../Features/Images/RegistryImageSearch.swift | 8 +- .../Features/MenuBar/MenuBarContent.swift | 4 +- .../Features/Palette/CommandPalette.swift | 3 +- .../Features/Settings/ConfigTransfer.swift | 2 +- .../Features/Settings/ReleaseNotesView.swift | 2 +- .../Features/Settings/Tabs/AboutTab.swift | 2 +- .../Settings/Tabs/AppearanceTab.swift | 2 +- .../Settings/Tabs/ExperimentalTab.swift | 2 +- .../Features/Settings/Tabs/GeneralTab.swift | 27 +- .../Settings/Tabs/RegistriesTab.swift | 2 +- .../Features/Settings/Tabs/RuntimeTab.swift | 11 +- .../Features/Settings/Tabs/UpdatesTab.swift | 2 +- .../Features/System/SystemView.swift | 16 +- .../History/ContainerHistoryTab.swift | 44 +- .../Migration/DowngradeDecisionView.swift | 4 +- Sources/Contained/Navigation/AppToolbar.swift | 2 +- .../Contained/Navigation/ClassicShell.swift | 2 +- .../ToolbarPanels/PaletteResultCard.swift | 2 - .../ToolbarPanels/ToolbarCommandPalette.swift | 43 +- .../ToolbarPanels/ToolbarImageGroupCard.swift | 27 +- .../ToolbarPanels/ToolbarUpdatesPanel.swift | 4 +- .../Navigation/ToolbarViewOptions.swift | 4 +- .../Stores/AppModel+Configuration.swift | 1 + .../Stores/AppModel+ResourceStyles.swift | 38 +- Sources/Contained/Stores/AppModel.swift | 46 +- .../Contained/Stores/ContainersStore.swift | 100 ++++- Sources/Contained/Stores/SettingsBackup.swift | 15 +- Sources/Contained/Stores/SettingsStore.swift | 13 +- Sources/Contained/Support/Formatting.swift | 64 ++- .../Support/WidgetConfiguration.swift | 15 +- .../Models/SystemProperties.swift | 2 + Tests/ContainedAppTests/AppLoggingTests.swift | 12 + .../ContainersStoreRefreshTests.swift | 244 ++++++++++- Tests/ContainedCoreTests/DecodingTests.swift | 16 + .../LiveSparklineScalingTests.swift | 40 ++ .../MorphingSingleSurfaceTests.swift | 33 ++ .../20260702-refresh-stats-performance.md | 15 +- docs/wiki/Architecture.md | 2 +- docs/wiki/Design-System.md | 19 +- docs/wiki/System-Settings.md | 21 +- docs/wiki/Troubleshooting.md | 4 +- 75 files changed, 1959 insertions(+), 542 deletions(-) create mode 100644 Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift create mode 100644 Sources/Contained/DesignSystem/StatsNormalization.swift create mode 100644 Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift create mode 100644 Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift diff --git a/Package.swift b/Package.swift index 735ba052..20241b60 100644 --- a/Package.swift +++ b/Package.swift @@ -56,5 +56,15 @@ let package = Package( dependencies: ["Contained", "ContainedCore"], path: "Tests/ContainedAppTests" ), + .testTarget( + name: "ContainedDesignSystemTests", + dependencies: [.product(name: "ContainedDesignSystem", package: "ContainedDesignSystem")], + path: "Tests/ContainedDesignSystemTests" + ), + .testTarget( + name: "ContainedNavigationTests", + dependencies: [.product(name: "ContainedNavigation", package: "ContainedNavigation")], + path: "Tests/ContainedNavigationTests" + ), ] ) diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index c4ef297c..df4bae6e 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -42,11 +42,20 @@ This package currently depends only on platform frameworks available to a macOS `PageScaffold` for app-neutral scaffolding. - `GlassButton`, `GlassButtonItem`, `GlassButtonInputItem`, `GlassRowMenu`, and toolbar control helpers. -- `ResourceGlassCard` and `ResourceCard*` pieces for repeated card layouts. +- `ResourceGlassCard`, `ResourceCardPageControls`, `ResourceCardFooterChip`, + `ResourceCardFooterButton`, `ResourceCardInsetSection`, and other + `ResourceCard*` pieces for repeated card layouts and card-local controls. + Use `resourceCardFloatingControls` and `resourceCardProgressOverlay` for + card overlays instead of app-local `.overlay` recipes. - `ActivityStatusView` with `ActivityStatusPresentation`, where callers provide plain status text/progress instead of app model objects. -- `LiveSparkline`, `GraphStyle`, and `WidgetInterpolation` for lightweight, - Canvas-backed live graph widgets. +- `DesignContentSurface` and `DesignInputSurface` for non-card content and + input surfaces. Feature code should use these named routes instead of calling + the lower-level surface modifiers directly. +- `LiveSparkline`, `GraphStyle`, `WidgetInterpolation`, and `SparklineScale` + for Swift Charts-backed live graph widgets. Use `.fraction` for values that + already live on a 0...1 scale, and `.normalized` for byte/rate series that + should fill the compact chart. - Micro-primitives such as `DesignStatusDot`, `DesignStatusBadge`, `DesignKeyCap`, `DesignKeyboardHint`, `DesignTintSwatch`, and `DesignMetricTile`. @@ -128,6 +137,86 @@ struct DesignSystemExample: View { } ``` +## Resource Card Controls + +Keep card-local controls in the package. Feature views should provide plain +values and actions instead of restyling footer chips or expanded-card page rails: + +```swift +struct CardControlsExample: View { + @State private var page = "overview" + @State private var metric = "cpu" + + private let pages = [ + ResourceCardPageControlItem(id: "overview", + title: "Overview", + systemImage: "rectangle.grid.1x2"), + ResourceCardPageControlItem(id: "logs", + title: "Logs", + systemImage: "text.alignleft") + ] + + var body: some View { + ResourceGlassCard(size: .large) { + ResourceCardHeader { + ResourceCardIconChip(symbol: "shippingbox.fill") + } content: { + ResourceCardTitleText(text: "web") + } trailing: { + EmptyView() + } + } bodyContent: { + ResourceCardInsetSection(title: "Details") { + ResourceCardSubtitleText(text: "Ready") + } + } footerLeading: { + ResourceCardFooterChip(isSelected: metric == "cpu", + tint: .accentColor, + help: "CPU", + action: { metric = "cpu" }) { + Image(systemName: "cpu") + } text: { + ResourceCardMetricText(text: "12%") + } + } footerActions: { + ResourceCardFooterButton(systemName: "play.fill", + help: "Start", + tint: .accentColor) {} + } widget: { + LiveSparkline(samples: [0, 0.12, 0.18], + color: .accentColor, + scale: .fraction) + .frame(height: Tokens.ResourceCard.sparklineHeight) + } + .overlay(alignment: .topTrailing) { + ResourceCardPageControls(items: pages, + selection: page, + tint: .accentColor, + onSelect: { page = $0 }, + onClose: {}) + .padding(Tokens.Space.s) + } + } +} +``` + +For standalone empty states or input chrome, keep the surface choice in this +package too: + +```swift +DesignContentSurface(minHeight: 220) { + ContentUnavailableView("No matches", systemImage: "magnifyingglass") +} + +DesignInputSurface { + HStack { + Image(systemName: "magnifyingglass") + TextField("Search", text: $query) + .textFieldStyle(.plain) + } +} +``` + ## Documentation - DocC landing page: diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 2b038297..0b73d61d 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -81,6 +81,85 @@ struct DesignSystemExample: View { } ``` +## Resource Card Controls + +Use the packaged card controls for page rails, widget chips, and icon actions. +Feature views should pass plain IDs, labels, metric strings, and actions: + +```swift +struct CardControlsExample: View { + @State private var page = "overview" + @State private var metric = "cpu" + + private let pages = [ + ResourceCardPageControlItem(id: "overview", + title: "Overview", + systemImage: "rectangle.grid.1x2"), + ResourceCardPageControlItem(id: "logs", + title: "Logs", + systemImage: "text.alignleft") + ] + + var body: some View { + ResourceGlassCard(size: .large) { + ResourceCardHeader { + ResourceCardIconChip(symbol: "shippingbox.fill") + } content: { + ResourceCardTitleText(text: "web") + } trailing: { + EmptyView() + } + } bodyContent: { + ResourceCardInsetSection(title: "Details") { + ResourceCardSubtitleText(text: "Ready") + } + } footerLeading: { + ResourceCardFooterChip(isSelected: metric == "cpu", + tint: .accentColor, + help: "CPU", + action: { metric = "cpu" }) { + Image(systemName: "cpu") + } text: { + ResourceCardMetricText(text: "12%") + } + } footerActions: { + ResourceCardFooterButton(systemName: "play.fill", + help: "Start", + tint: .accentColor) {} + } widget: { + LiveSparkline(samples: [0, 0.12, 0.18], + color: .accentColor, + scale: .fraction) + .frame(height: Tokens.ResourceCard.sparklineHeight) + } + .overlay(alignment: .topTrailing) { + ResourceCardPageControls(items: pages, + selection: page, + tint: .accentColor, + onSelect: { page = $0 }, + onClose: {}) + .padding(Tokens.Space.s) + } + } +} +``` + +Use package-owned surfaces for standalone empty states and input chrome: + +```swift +DesignContentSurface(minHeight: 220) { + ContentUnavailableView("No matches", systemImage: "magnifyingglass") +} + +DesignInputSurface { + HStack { + Image(systemName: "magnifyingglass") + TextField("Search", text: $query) + .textFieldStyle(.plain) + } +} +``` + ## Topics ### Tokens and Theme @@ -117,6 +196,13 @@ struct DesignSystemExample: View { - ``ResourceCardIconChip`` - ``ResourceBadgeText`` - ``ResourceCardFooterMini`` +- ``ResourceCardFooterChip`` +- ``ResourceCardFooterButton`` +- ``ResourceCardInsetSection`` +- ``ResourceCardPageControls`` +- ``ResourceCardPageControlItem`` +- ``View/resourceCardFloatingControls(when:controls:)`` +- ``View/resourceCardProgressOverlay(when:)`` - ``ResourceCardTitleText`` - ``ResourceCardSubtitleText`` - ``ResourceCardMonospacedSubtitleText`` @@ -126,12 +212,15 @@ struct DesignSystemExample: View { - ``ActivityStatusView`` - ``ActivityStatusPresentation`` -- ``LiveSparkline`` for lightweight Canvas-backed live graph widgets +- ``LiveSparkline`` for Swift Charts-backed live graph widgets - ``GraphStyle`` - ``WidgetInterpolation`` +- ``SparklineScale`` - ``MetricTile`` - ``DesignStatusDot`` - ``DesignStatusBadge`` +- ``DesignContentSurface`` +- ``DesignInputSurface`` - ``DesignKeyCap`` - ``DesignKeyboardHint`` - ``DesignTintSwatch`` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/JSONInspectorSheet.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/JSONInspectorSheet.swift index 80eb42e9..682a9407 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/JSONInspectorSheet.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/JSONInspectorSheet.swift @@ -44,7 +44,7 @@ public struct JSONInspectorSheet: View { } public var body: some View { - VStack(spacing: 0) { + LazyVStack(spacing: 0) { HStack { Text(title).font(.headline).lineLimit(1) Spacer() diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift index eb383fc1..36c4c2ad 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift @@ -1,4 +1,5 @@ import SwiftUI +import Charts public enum GraphStyle: String, CaseIterable, Identifiable, Codable, Sendable { case area @@ -80,18 +81,29 @@ public enum WidgetInterpolation: String, CaseIterable, Identifiable, Codable, Se } } -/// A compact Canvas renderer for card widgets. Each series is normalized independently so paired -/// metrics with different units still make useful visual comparisons at card scale. +public enum SparklineScale: String, CaseIterable, Identifiable, Codable, Sendable { + case normalized + case fraction + + public var id: String { rawValue } +} + +/// A compact Swift Charts renderer for card widgets. Byte/rate metrics can be normalized +/// independently, while pre-normalized fraction metrics can stay anchored to the 0...100% domain. public struct LiveSparkline: View { + private static let maximumPlottedSamples = 24 + public var samples: [Double] public var comparisonSamples: [Double] = [] public var color: Color = .accentColor public var lineWidth: CGFloat = 1.5 public var style: GraphStyle = .area public var areaUsesGradient = true - public var interpolation: WidgetInterpolation = .catmullRom + public var interpolation: WidgetInterpolation = .linear public var pointSize: CGFloat = 18 public var barWidth: CGFloat = 4 + public var scale: SparklineScale = .normalized + public var comparisonScale: SparklineScale = .normalized public init(samples: [Double], comparisonSamples: [Double] = [], @@ -99,9 +111,11 @@ public struct LiveSparkline: View { lineWidth: CGFloat = 1.5, style: GraphStyle = .area, areaUsesGradient: Bool = true, - interpolation: WidgetInterpolation = .catmullRom, + interpolation: WidgetInterpolation = .linear, pointSize: CGFloat = 18, - barWidth: CGFloat = 4) { + barWidth: CGFloat = 4, + scale: SparklineScale = .normalized, + comparisonScale: SparklineScale? = nil) { self.samples = samples self.comparisonSamples = comparisonSamples self.color = color @@ -111,257 +125,229 @@ public struct LiveSparkline: View { self.interpolation = interpolation self.pointSize = pointSize self.barWidth = barWidth + self.scale = scale + self.comparisonScale = comparisonScale ?? scale } public var body: some View { - Canvas(rendersAsynchronously: true) { context, size in - render(context: &context, size: size) + let plotted = plottedSamples(samples) + Group { + if plotted.count > 1 { + chart + } else { + baseline + } } .accessibilityHidden(true) } - private func render(context: inout GraphicsContext, size: CGSize) { - guard size.width > 0, size.height > 0 else { return } - guard samples.count > 1 else { - drawBaseline(context: &context, size: size) - return + private var chart: some View { + let primary = primaryPoints + let secondary = secondaryPoints + let ranges = rangePoints(primary: primary, secondary: secondary) + + return Chart { + switch style { + case .area: + ForEach(primary) { point in + AreaMark(x: .value("Sample", point.index), y: .value("Value", point.value)) + .foregroundStyle(areaFillStyle) + .interpolationMethod(interpolation.method) + LineMark(x: .value("Sample", point.index), y: .value("Value", point.value)) + .foregroundStyle(color) + .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + .interpolationMethod(interpolation.method) + } + case .line: + ForEach(primary) { point in + LineMark(x: .value("Sample", point.index), y: .value("Value", point.value)) + .foregroundStyle(color) + .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + .interpolationMethod(interpolation.method) + } + case .bar: + ForEach(primary) { point in + BarMark(x: .value("Sample", point.index), y: .value("Value", point.value), width: .fixed(barWidth)) + .clipShape(Capsule()) + .foregroundStyle(color.opacity(0.76).gradient) + } + case .points: + ForEach(primary) { point in + PointMark(x: .value("Sample", point.index), y: .value("Value", point.value)) + .foregroundStyle(color) + .symbolSize(pointSize) + } + case .multiLine: + ForEach(primary) { point in + LineMark(x: .value("Sample", point.index), y: .value("Value", point.value), series: .value("Metric", "Primary")) + .foregroundStyle(color) + .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + .interpolationMethod(interpolation.method) + } + ForEach(secondary) { point in + LineMark(x: .value("Sample", point.index), y: .value("Value", point.value), series: .value("Metric", "Secondary")) + .foregroundStyle(color.opacity(0.55)) + .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round, dash: [3, 3])) + .interpolationMethod(interpolation.method) + } + case .range: + ForEach(ranges) { point in + BarMark(x: .value("Sample", point.index), + yStart: .value("Low", point.low), + yEnd: .value("High", point.high), + width: .fixed(barWidth)) + .clipShape(Capsule()) + .foregroundStyle(color.opacity(0.72).gradient) + } + case .scatter: + ForEach(primary) { point in + PointMark(x: .value("Sample", point.index), y: .value("Value", point.value)) + .foregroundStyle(color) + .symbolSize(pointSize) + } + ForEach(secondary) { point in + PointMark(x: .value("Sample", point.index), y: .value("Value", point.value)) + .foregroundStyle(color.opacity(0.55)) + .symbolSize(pointSize * 0.7) + } + } } - - switch style { - case .area: - let points = plottedPoints(for: samples, in: size) - drawArea(points, context: &context, size: size) - drawLine(points, color: color, context: &context) - case .line: - drawLine(plottedPoints(for: samples, in: size), color: color, context: &context) - case .bar: - drawBars(values: samples, context: &context, size: size) - case .points: - drawPoints(plottedPoints(for: samples, in: size), color: color, context: &context) - case .multiLine: - let primary = plottedPoints(for: samples, in: size) - let secondary = plottedPoints(for: comparisonSamples, in: size) - drawLine(primary, color: color, context: &context) - drawLine(secondary, color: color.opacity(0.55), context: &context, dash: [3, 3]) - case .range: - drawRange(primary: samples, secondary: comparisonSamples, context: &context, size: size) - case .scatter: - drawScatter(primary: samples, secondary: comparisonSamples, context: &context, size: size) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartLegend(.hidden) + .chartXScale(domain: 0...(Self.maximumPlottedSamples - 1)) + .chartYScale(domain: 0...1) + .chartPlotStyle { plot in + plot.background(.clear) } + .transaction { transaction in + transaction.animation = nil + } + .allowsHitTesting(false) } - private func drawBaseline(context: inout GraphicsContext, size: CGSize) { - let inset = drawingInset - var path = Path() - path.move(to: CGPoint(x: 0, y: size.height - inset)) - path.addLine(to: CGPoint(x: size.width, y: size.height - inset)) - context.stroke(path, - with: .color(color.opacity(0.35)), - style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + private var baseline: some View { + Canvas { context, size in + var path = Path() + path.move(to: CGPoint(x: 0, y: size.height - lineWidth)) + path.addLine(to: CGPoint(x: size.width, y: size.height - lineWidth)) + context.stroke(path, with: .color(color.opacity(0.35)), lineWidth: lineWidth) + } } - private func drawArea(_ points: [CGPoint], context: inout GraphicsContext, size: CGSize) { - guard points.count > 1 else { return } - let baseline = size.height - drawingInset - let path = linePath(points: points, closingToBaseline: baseline) + private var areaFillStyle: AnyShapeStyle { if areaUsesGradient { - context.fill(path, - with: .linearGradient( - Gradient(colors: [color.opacity(0.25), color.opacity(0.02)]), - startPoint: CGPoint(x: 0, y: 0), - endPoint: CGPoint(x: 0, y: size.height) - )) - } else { - context.fill(path, with: .color(color.opacity(0.22))) + return AnyShapeStyle( + LinearGradient(colors: [color.opacity(0.25), color.opacity(0.02)], + startPoint: .top, + endPoint: .bottom) + ) } + return AnyShapeStyle(color.opacity(0.22)) } - private func drawLine(_ points: [CGPoint], - color: Color, - context: inout GraphicsContext, - dash: [CGFloat] = []) { - guard points.count > 1 else { return } - context.stroke(linePath(points: points), - with: .color(color), - style: StrokeStyle(lineWidth: lineWidth, - lineCap: .round, - lineJoin: .round, - dash: dash)) + private var primaryPoints: [ChartPoint] { + chartPoints(for: samples, scale: scale) } - private func drawBars(values: [Double], context: inout GraphicsContext, size: CGSize) { - let points = plottedPoints(for: values, in: size) - guard !points.isEmpty else { return } - let baseline = size.height - drawingInset - let resolvedBarWidth = min(max(barWidth, 1), max(size.width / CGFloat(max(points.count, 1)) * 0.72, 1)) - for point in points { - let top = min(point.y, baseline) - let height = max(abs(baseline - point.y), lineWidth) - let rect = CGRect(x: point.x - resolvedBarWidth / 2, - y: top, - width: resolvedBarWidth, - height: height) - context.fill(Path(roundedRect: rect, cornerRadius: resolvedBarWidth / 2), - with: .color(color.opacity(0.76))) - } + private var secondaryPoints: [ChartPoint] { + chartPoints(for: comparisonSamples, scale: comparisonScale) } - private func drawPoints(_ points: [CGPoint], color: Color, context: inout GraphicsContext) { - let diameter = max(sqrt(pointSize), 2) - for point in points { - let rect = CGRect(x: point.x - diameter / 2, - y: point.y - diameter / 2, - width: diameter, - height: diameter) - context.fill(Path(ellipseIn: rect), with: .color(color)) + private func rangePoints(primary: [ChartPoint], secondary: [ChartPoint]) -> [ChartRangePoint] { + let count = min(primary.count, secondary.count) + guard count > 0 else { return [] } + let primaryTail = Array(primary.suffix(count)) + let secondaryTail = Array(secondary.suffix(count)) + return primaryTail.indices.map { index in + let first = primaryTail[index] + let second = secondaryTail[index] + return ChartRangePoint(index: first.index, + low: min(first.value, second.value), + high: max(first.value, second.value)) } } - private func drawRange(primary: [Double], - secondary: [Double], - context: inout GraphicsContext, - size: CGSize) { - let first = plottedPoints(for: primary, in: size) - let second = plottedPoints(for: secondary, in: size) - let count = min(first.count, second.count) - guard count > 0 else { return } - - let resolvedBarWidth = min(max(barWidth, 1), max(size.width / CGFloat(count) * 0.72, 1)) - for index in 0.. [ChartPoint] { + let plotted = plottedSamples(values) + let startIndex = Self.maximumPlottedSamples - plotted.count + let scaled = SparklineSeriesScaling.scaled(plotted, mode: scale) + return scaled.enumerated().map { offset, value in + ChartPoint(index: startIndex + offset, value: value) } } - private func drawScatter(primary: [Double], - secondary: [Double], - context: inout GraphicsContext, - size: CGSize) { - let first = normalized(primary) - let second = normalized(secondary) - let count = min(first.count, second.count) - guard count > 0 else { return } - - let inset = drawingInset - let width = max(size.width - inset * 2, 1) - let height = max(size.height - inset * 2, 1) - let diameter = max(sqrt(pointSize), 2) - for index in 0.. [Double] { + SparklineSeriesScaling.paddedWindow(values, capacity: Self.maximumPlottedSamples) } +} - private func normalized(_ values: [Double]) -> [Double] { - let maxValue = max(values.max() ?? 1, 0.0001) - return values.map { min(max($0 / maxValue, 0), 1) } - } +enum SparklineSeriesScaling { + private static let minimumCeiling = 0.0001 - private func plottedPoints(for values: [Double], in size: CGSize) -> [CGPoint] { - let normalizedValues = normalized(values) - guard !normalizedValues.isEmpty else { return [] } - let inset = drawingInset - let width = max(size.width, 1) - let height = max(size.height - inset * 2, 1) - let denominator = CGFloat(max(normalizedValues.count - 1, 1)) - - return normalizedValues.enumerated().map { index, value in - let x = normalizedValues.count == 1 ? width / 2 : width * CGFloat(index) / denominator - let y = inset + CGFloat(1 - value) * height - return CGPoint(x: x, y: y) - } + static func paddedWindow(_ values: [Double], capacity: Int) -> [Double] { + let latest = values.suffix(capacity).map(sanitizedSample) + guard latest.count < capacity else { return latest } + return Array(repeating: 0, count: capacity - latest.count) + latest } - private func linePath(points: [CGPoint], closingToBaseline baseline: CGFloat? = nil) -> Path { - var path = Path() - guard let first = points.first else { return path } - if let baseline { - path.move(to: CGPoint(x: first.x, y: baseline)) - path.addLine(to: first) - } else { - path.move(to: first) - } - - appendSegments(points: points, to: &path) + static func normalized(_ values: [Double]) -> [Double] { + let ceiling = displayCeiling(for: values) + return values.map { min(max(sanitizedSample($0) / ceiling, 0), 1) } + } - if let baseline, let last = points.last { - path.addLine(to: CGPoint(x: last.x, y: baseline)) - path.closeSubpath() - } - return path + static func fractions(_ values: [Double]) -> [Double] { + values.map { min(max(sanitizedSample($0), 0), 1) } } - private func appendSegments(points: [CGPoint], to path: inout Path) { - guard points.count > 1 else { return } - switch interpolation { - case .linear, .monotone: - for point in points.dropFirst() { - path.addLine(to: point) - } - case .stepStart: - appendStepSegments(points: points, to: &path, mode: .start) - case .stepCenter: - appendStepSegments(points: points, to: &path, mode: .center) - case .stepEnd: - appendStepSegments(points: points, to: &path, mode: .end) - case .catmullRom: - appendCatmullRomSegments(points: points, to: &path, tension: 1) - case .cardinal: - appendCatmullRomSegments(points: points, to: &path, tension: 0.65) + static func scaled(_ values: [Double], mode: SparklineScale) -> [Double] { + switch mode { + case .normalized: return normalized(values) + case .fraction: return fractions(values) } } - private enum StepMode { case start, center, end } - - private func appendStepSegments(points: [CGPoint], to path: inout Path, mode: StepMode) { - var previous = points[0] - for point in points.dropFirst() { - switch mode { - case .start: - path.addLine(to: CGPoint(x: previous.x, y: point.y)) - path.addLine(to: point) - case .center: - let midpoint = (previous.x + point.x) / 2 - path.addLine(to: CGPoint(x: midpoint, y: previous.y)) - path.addLine(to: CGPoint(x: midpoint, y: point.y)) - path.addLine(to: point) - case .end: - path.addLine(to: CGPoint(x: point.x, y: previous.y)) - path.addLine(to: point) - } - previous = point - } + static func displayCeiling(for values: [Double]) -> Double { + let positives = values.map(sanitizedSample).filter { $0 > 0 }.sorted() + guard let maximum = positives.last else { return 1 } + guard positives.count >= 4 else { return max(maximum, minimumCeiling) } + + // One noisy stats sample should render as a clipped spike, not rescale the whole visible window. + let percentileIndex = Int(Double(positives.count - 1) * 0.9) + let robustHigh = positives[percentileIndex] + return max(robustHigh * 1.35, minimumCeiling) } - private func appendCatmullRomSegments(points: [CGPoint], to path: inout Path, tension: CGFloat) { - for index in 0..<(points.count - 1) { - let p0 = points[max(index - 1, 0)] - let p1 = points[index] - let p2 = points[index + 1] - let p3 = points[min(index + 2, points.count - 1)] - let scale = tension / 6 - let control1 = CGPoint(x: p1.x + (p2.x - p0.x) * scale, - y: p1.y + (p2.y - p0.y) * scale) - let control2 = CGPoint(x: p2.x - (p3.x - p1.x) * scale, - y: p2.y - (p3.y - p1.y) * scale) - path.addCurve(to: p2, control1: control1, control2: control2) - } + private static func sanitizedSample(_ value: Double) -> Double { + guard value.isFinite, value > 0 else { return 0 } + return value } +} + +private struct ChartPoint: Identifiable { + let index: Int + let value: Double + var id: Int { index } +} - private var drawingInset: CGFloat { - max(lineWidth / 2, 1) +private struct ChartRangePoint: Identifiable { + let index: Int + let low: Double + let high: Double + var id: Int { index } +} + +private extension WidgetInterpolation { + var method: InterpolationMethod { + switch self { + case .linear: return .linear + case .catmullRom: return .monotone + case .cardinal: return .cardinal + case .monotone: return .monotone + case .stepStart: return .stepStart + case .stepCenter: return .stepCenter + case .stepEnd: return .stepEnd + } } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MetricTile.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MetricTile.swift index c0e7ea54..58a6627f 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MetricTile.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MetricTile.swift @@ -7,17 +7,20 @@ public struct MetricTile: View { public var systemImage: String? = nil public var tint: Color = .accentColor public var samples: [Double]? = nil + public var sparklineScale: SparklineScale = .normalized public init(label: String, value: String, systemImage: String? = nil, tint: Color = .accentColor, - samples: [Double]? = nil) { + samples: [Double]? = nil, + sparklineScale: SparklineScale = .normalized) { self.label = label self.value = value self.systemImage = systemImage self.tint = tint self.samples = samples + self.sparklineScale = sparklineScale } public var body: some View { @@ -37,7 +40,7 @@ public struct MetricTile: View { .font(.title.weight(.semibold)) .contentTransition(.numericText()) if let samples { - LiveSparkline(samples: samples, color: tint) + LiveSparkline(samples: samples, color: tint, scale: sparklineScale) .frame(height: 22) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MicroPrimitives.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MicroPrimitives.swift index 4eb701e5..e74ae71e 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MicroPrimitives.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MicroPrimitives.swift @@ -160,6 +160,60 @@ public struct DesignMetricTile: View { } } +/// A package-owned content surface for empty states and grouped panel content. +public struct DesignContentSurface: View { + public var elevated: Bool + public var minHeight: CGFloat? + public var alignment: Alignment + public var padding: CGFloat + @ViewBuilder public var content: () -> Content + + public init(elevated: Bool = false, + minHeight: CGFloat? = nil, + alignment: Alignment = .center, + padding: CGFloat = Tokens.Space.s, + @ViewBuilder content: @escaping () -> Content) { + self.elevated = elevated + self.minHeight = minHeight + self.alignment = alignment + self.padding = padding + self.content = content + } + + public var body: some View { + content() + .padding(padding) + .frame(maxWidth: .infinity, minHeight: minHeight, alignment: alignment) + .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: elevated) + } +} + +/// A package-owned surface for compact inline controls such as search fields and text editors. +public struct DesignInputSurface: View { + public var horizontalPadding: CGFloat + public var verticalPadding: CGFloat + public var minHeight: CGFloat? + @ViewBuilder public var content: () -> Content + + public init(horizontalPadding: CGFloat = Tokens.Space.m, + verticalPadding: CGFloat = Tokens.Space.s, + minHeight: CGFloat? = nil, + @ViewBuilder content: @escaping () -> Content) { + self.horizontalPadding = horizontalPadding + self.verticalPadding = verticalPadding + self.minHeight = minHeight + self.content = content + } + + public var body: some View { + content() + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .leading) + .glassSurface(.thin, cornerRadius: Tokens.Radius.control) + } +} + public extension View { @ViewBuilder func designCardSelectionOverlay(when isSelected: Bool) -> some View { diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PageScaffold.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PageScaffold.swift index 1f810503..c5e785c1 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PageScaffold.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PageScaffold.swift @@ -39,7 +39,7 @@ public struct PageScaffold: View { } if scrolls { ScrollView { - VStack(spacing: 0) { + LazyVStack(spacing: 0) { content() .frame(maxWidth: .infinity, alignment: .topLeading) .padding(usesToolbarChrome ? Tokens.Space.s : Tokens.Space.l) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelSection.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelSection.swift index d8802fb0..2d904f85 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelSection.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelSection.swift @@ -58,7 +58,7 @@ public struct PanelSection: View { VStack(alignment: .leading, spacing: Tokens.Space.s) { if hasHeader { headerRow } if !bodyHidden { - VStack(alignment: .leading, spacing: rowSpacing) { + LazyVStack(alignment: .leading, spacing: rowSpacing) { content() } .environment(\.panelSectionHighlighted, highlighted) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift index 5a99766b..64ad18b7 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift @@ -56,6 +56,181 @@ public struct ResourceCardFooterMini: View { } } +/// A flat inset section for content that lives inside an expanded `ResourceGlassCard`. +/// +/// Use this for charts, process lists, read-only fields, and terminal overlays inside a card body. +/// It intentionally avoids creating a second card-shaped glass surface inside the parent card. +public struct ResourceCardInsetSection: View { + public var title: String? + public var alignment: HorizontalAlignment + public var spacing: CGFloat + public var padding: CGFloat + @ViewBuilder public var content: () -> Content + + public init(title: String? = nil, + alignment: HorizontalAlignment = .leading, + spacing: CGFloat = Tokens.Space.s, + padding: CGFloat = Tokens.Space.s, + @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.alignment = alignment + self.spacing = spacing + self.padding = padding + self.content = content + } + + public var body: some View { + VStack(alignment: alignment, spacing: Tokens.Space.s) { + if let title { + Text(title) + .font(.headline) + .padding(.leading, Tokens.Space.xs) + } + LazyVStack(alignment: alignment, spacing: spacing) { + content() + } + .padding(padding) + .frame(maxWidth: .infinity, alignment: Alignment(horizontal: alignment, vertical: .center)) + .background(AppMaterial.toolbarHoverFill, + in: RoundedRectangle(cornerRadius: Tokens.Radius.control, + style: .continuous)) + } + .frame(maxWidth: .infinity, alignment: Alignment(horizontal: alignment, vertical: .center)) + } +} + +/// A selectable footer chip for card widgets, filters, and compact tab-like metadata. +public struct ResourceCardFooterChip: View { + public var isSelected: Bool + public var tint: Color + public var help: String + public var action: () -> Void + @ViewBuilder public var icon: () -> Icon + @ViewBuilder public var text: () -> TextContent + + public init(isSelected: Bool, + tint: Color, + help: String, + action: @escaping () -> Void, + @ViewBuilder icon: @escaping () -> Icon, + @ViewBuilder text: @escaping () -> TextContent) { + self.isSelected = isSelected + self.tint = tint + self.help = help + self.action = action + self.icon = icon + self.text = text + } + + public var body: some View { + Button(action: action) { + ResourceCardFooterMini { + icon() + } text: { + text() + } + .foregroundStyle(isSelected ? AnyShapeStyle(tint) : AnyShapeStyle(.secondary)) + } + .buttonStyle(.plain) + .help(help) + .accessibilityLabel(help) + } +} + +/// Shared icon-only action used by resource-card footers. +public struct ResourceCardFooterButton: View { + public var systemName: String + public var help: String + public var tint: Color? + public var role: ButtonRole? + public var action: () -> Void + + public init(systemName: String, + help: String, + tint: Color? = nil, + role: ButtonRole? = nil, + action: @escaping () -> Void) { + self.systemName = systemName + self.help = help + self.tint = tint + self.role = role + self.action = action + } + + public var body: some View { + Button(role: role, action: action) { + ResourceCardFooterMini { + Image(systemName: systemName).font(.body) + } text: { + EmptyView() + } + } + .buttonStyle(.plain) + .foregroundStyle(foregroundStyle) + .help(help) + .accessibilityLabel(help) + } + + private var foregroundStyle: AnyShapeStyle { + if role == .destructive { return AnyShapeStyle(Color.red) } + if let tint { return AnyShapeStyle(tint) } + return AnyShapeStyle(.secondary) + } +} + +public struct ResourceCardPageControlItem: Identifiable, Hashable { + public var id: ID + public var title: String + public var systemImage: String + + public init(id: ID, title: String, systemImage: String) { + self.id = id + self.title = title + self.systemImage = systemImage + } +} + +/// Shared expanded-card page rail with page icons plus a close affordance. +public struct ResourceCardPageControls: View { + public var items: [ResourceCardPageControlItem] + public var selection: ID + public var tint: Color + public var controlsReveal: Double + public var onSelect: (ID) -> Void + public var onClose: () -> Void + + public init(items: [ResourceCardPageControlItem], + selection: ID, + tint: Color, + controlsReveal: Double = 1, + onSelect: @escaping (ID) -> Void, + onClose: @escaping () -> Void) { + self.items = items + self.selection = selection + self.tint = tint + self.controlsReveal = controlsReveal + self.onSelect = onSelect + self.onClose = onClose + } + + public var body: some View { + GlassButton(singleItem: false) { + ForEach(items) { item in + GlassButtonItem(tint: selection == item.id ? tint : nil, + help: item.title, + isIcon: true, + action: { onSelect(item.id) }) { + Image(systemName: item.systemImage) + .opacity(selection == item.id ? 1 : 0.62) + } + } + GlassButtonItem(systemName: "xmark", help: "Close", action: onClose) + } + .opacity(controlsReveal) + .animation(.easeOut(duration: 0.18), value: controlsReveal) + } +} + /// A reusable footer item band that hugs its content and anchors either left or right. public struct ResourceCardFooterGroup: View { public enum Alignment { @@ -329,5 +504,33 @@ public struct ResourceCardMetricText: View { Text(text) .font(.caption.weight(.medium)) .monospacedDigit() + .contentTransition(.numericText()) + } +} + +public extension View { + @ViewBuilder + func resourceCardFloatingControls( + when isVisible: Bool, + @ViewBuilder controls: @escaping () -> Controls + ) -> some View { + overlay(alignment: .topTrailing) { + if isVisible { + controls() + .fixedSize(horizontal: true, vertical: false) + .padding(Tokens.Space.s) + .zIndex(1) + } + } + } + + @ViewBuilder + func resourceCardProgressOverlay(when isBusy: Bool) -> some View { + overlay { + if isBusy { + ProgressView() + .controlSize(.small) + } + } } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift index 2e3e7dad..914d7bf0 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift @@ -189,7 +189,8 @@ public struct ResourceGlassCard: Vie chrome() if scrolls { ScrollView { - content() - .frame(maxWidth: .infinity) + LazyVStack(spacing: 0) { + content() + .frame(maxWidth: .infinity) + } } .scrollEdgeEffectStyle(scrollEdgeStyle, for: .all) } else { diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingExpander.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingExpander.swift index b3583ca2..4ba05a6c 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingExpander.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingExpander.swift @@ -114,7 +114,7 @@ public enum MorphGeometry { in: bounds, margin: margin) case .anchored: let fallback = CGPoint(x: bounds.minX + margin, y: bounds.minY + margin) - let originPoint = origin.isUsable ? origin.origin : fallback + let originPoint = origin.isUsableForMorph ? origin.origin : fallback return clamped(CGRect(origin: originPoint, size: size), in: bounds, margin: margin) } @@ -139,12 +139,6 @@ public enum MorphGeometry { } } -private extension CGRect { - var isUsable: Bool { - width.isFinite && height.isFinite && minX.isFinite && minY.isFinite && width > 1 && height > 1 - } -} - private extension CGSize { var isUsableForMorphPanel: Bool { width.isFinite && height.isFinite && width > 1 && height > 1 @@ -214,8 +208,10 @@ public struct MorphingExpander: View { public var body: some View { GeometryReader { geo in let target = targetRect(in: geo.size) - let source = originFrame.isUsable ? originFrame : target - let rect = expanded ? target : source + let source = originFrame.isUsableForMorph ? originFrame : target + let rect = MorphFrame(source: source, + target: target, + progress: expanded ? 1 : 0).rect let cornerRadius = expanded ? targetCornerRadius : sourceCornerRadius ZStack { if showsBackdrop { diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift new file mode 100644 index 00000000..fb87bfde --- /dev/null +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift @@ -0,0 +1,87 @@ +import SwiftUI + +public struct MorphFrame: Equatable, Sendable { + public var source: CGRect + public var target: CGRect + public var progress: CGFloat + + public init(source: CGRect, target: CGRect, progress: CGFloat) { + self.source = source + self.target = target + self.progress = MorphGeometry.clampedProgress(progress) + } + + public var rect: CGRect { + source.morphInterpolated(to: target, progress: progress) + } +} + +/// Hosts one promoted surface while it grows from an existing slot into a larger target rect. +/// +/// This is the single-card version of the rect motion used by `MorphingExpander`: callers keep the +/// source view laid out in place, hide it while selected, and render one overlay through this helper. +public struct MorphingSingleSurface: View { + public var source: CGRect + public var target: CGRect + public var progress: CGFloat + public var alignment: Alignment + @ViewBuilder private var content: () -> Content + + public init(source: CGRect, + target: CGRect, + progress: CGFloat, + alignment: Alignment = .top, + @ViewBuilder content: @escaping () -> Content) { + self.source = source + self.target = target + self.progress = progress + self.alignment = alignment + self.content = content + } + + public var body: some View { + let rect = MorphFrame(source: source, target: target, progress: progress).rect + content() + .frame(width: max(rect.width, 1), height: max(rect.height, 1), alignment: alignment) + .position(x: rect.midX, y: rect.midY) + } +} + +public extension MorphGeometry { + static func clampedProgress(_ progress: CGFloat) -> CGFloat { + min(max(progress.isFinite ? progress : 0, 0), 1) + } + + static func interpolatedRect(from source: CGRect, to target: CGRect, progress: CGFloat) -> CGRect { + let progress = clampedProgress(progress) + return CGRect( + x: interpolate(source.minX, target.minX, progress: progress), + y: interpolate(source.minY, target.minY, progress: progress), + width: max(1, interpolate(source.width, target.width, progress: progress)), + height: max(1, interpolate(source.height, target.height, progress: progress)) + ) + } + + static func isUsableFrame(_ rect: CGRect) -> Bool { + rect.width.isFinite && + rect.height.isFinite && + rect.minX.isFinite && + rect.minY.isFinite && + rect.width > 1 && + rect.height > 1 + } + + private static func interpolate(_ source: CGFloat, _ target: CGFloat, progress: CGFloat) -> CGFloat { + source + (target - source) * progress + } +} + +public extension CGRect { + var isUsableForMorph: Bool { + MorphGeometry.isUsableFrame(self) + } + + func morphInterpolated(to target: CGRect, progress: CGFloat) -> CGRect { + MorphGeometry.interpolatedRect(from: self, to: target, progress: progress) + } +} diff --git a/Sources/Contained/DesignSystem/GraphMetric.swift b/Sources/Contained/DesignSystem/GraphMetric.swift index 8db72695..25479de6 100644 --- a/Sources/Contained/DesignSystem/GraphMetric.swift +++ b/Sources/Contained/DesignSystem/GraphMetric.swift @@ -1,8 +1,8 @@ import SwiftUI import ContainedCore -/// Which live metric a card's sparkline plots. Values are derived from `StatsDelta` and normalized -/// to a 0...1-ish range for the sparkline (which auto-scales to its own max anyway). +/// Which live metric a card's sparkline plots. CPU and memory values are normalized against the +/// container's configured resource limits at the app boundary; raw runtime deltas stay in Core. enum GraphMetric: String, CaseIterable, Identifiable, Codable, Sendable { case cpu, memory, netRx, netTx, diskRead, diskWrite var id: String { rawValue } @@ -29,11 +29,14 @@ enum GraphMetric: String, CaseIterable, Identifiable, Codable, Sendable { } } - /// The raw value plotted for this metric from a stats delta. - func value(from delta: StatsDelta) -> Double { + /// The value plotted for this metric from a stats delta. CPU is a fraction of configured cores; + /// memory is a fraction of configured memory. Throughput metrics remain bytes per second. + func value(from delta: StatsDelta, + snapshot: ContainerSnapshot? = nil, + normalization: StatsNormalizationContext = .containerSpecific) -> Double { switch self { - case .cpu: return delta.cpuCoreFraction - case .memory: return delta.memoryFraction + case .cpu: return Self.cpuFraction(from: delta, snapshot: snapshot, normalization: normalization) + case .memory: return Self.memoryFraction(from: delta, snapshot: snapshot, normalization: normalization) case .netRx: return delta.netRxBytesPerSec case .netTx: return delta.netTxBytesPerSec case .diskRead: return delta.blockReadBytesPerSec @@ -41,12 +44,38 @@ enum GraphMetric: String, CaseIterable, Identifiable, Codable, Sendable { } } - /// An ultra-compact value for the card footer chips (tight space): percent for CPU/memory, - /// `Format.compactRate` for the throughput metrics ("0", "1.2K", "34M"). - func chipCaption(from delta: StatsDelta) -> String { + /// The value plotted for this metric from durable history. History persists raw samples, then + /// applies the current normalization mode at display time so old samples remain useful. + func value(from sample: MetricSample, + snapshot: ContainerSnapshot? = nil, + normalization: StatsNormalizationContext = .containerSpecific, + memoryFallbackBytes: UInt64 = 0) -> Double { switch self { - case .cpu: return Format.percent(delta.cpuCoreFraction) - case .memory: return Format.percent(delta.memoryFraction) + case .cpu: + return Self.sanitized(sample.cpuFraction) / normalization.cpuLimit(for: snapshot) + case .memory: + let limit = normalization.memoryLimitBytes(for: snapshot, fallback: memoryFallbackBytes) + guard limit > 0 else { return 0 } + return Self.sanitized(sample.memoryBytes / Double(limit)) + case .netRx: + return Self.sanitized(sample.netRxBytesPerSec) + case .netTx: + return Self.sanitized(sample.netTxBytesPerSec) + case .diskRead: + return Self.sanitized(sample.diskReadBytesPerSec) + case .diskWrite: + return Self.sanitized(sample.diskWriteBytesPerSec) + } + } + + /// An ultra-compact value for the card footer chips (tight space): decimal percent only below 1%, + /// `Format.compactRate` for throughput metrics ("0", "1.2K", "34M"). + func chipCaption(from delta: StatsDelta, + snapshot: ContainerSnapshot? = nil, + normalization: StatsNormalizationContext = .containerSpecific) -> String { + switch self { + case .cpu, .memory: + return Format.compactPercent(value(from: delta, snapshot: snapshot, normalization: normalization)) case .netRx: return Format.compactRate(delta.netRxBytesPerSec) case .netTx: return Format.compactRate(delta.netTxBytesPerSec) case .diskRead: return Format.compactRate(delta.blockReadBytesPerSec) @@ -55,14 +84,41 @@ enum GraphMetric: String, CaseIterable, Identifiable, Codable, Sendable { } /// A short current-value label for the footer. - func caption(from delta: StatsDelta) -> String { + func caption(from delta: StatsDelta, + snapshot: ContainerSnapshot? = nil, + normalization: StatsNormalizationContext = .containerSpecific) -> String { switch self { - case .cpu: return Format.percent(delta.cpuCoreFraction) - case .memory: return Format.percent(delta.memoryFraction) + case .cpu, .memory: + return Format.compactPercent(value(from: delta, snapshot: snapshot, normalization: normalization)) case .netRx: return Format.rate(delta.netRxBytesPerSec) case .netTx: return Format.rate(delta.netTxBytesPerSec) case .diskRead: return Format.rate(delta.blockReadBytesPerSec) case .diskWrite: return Format.rate(delta.blockWriteBytesPerSec) } } + + static func cpuFraction(from delta: StatsDelta, + snapshot: ContainerSnapshot?, + normalization: StatsNormalizationContext = .containerSpecific) -> Double { + sanitized(delta.cpuCoreFraction) / normalization.cpuLimit(for: snapshot) + } + + static func memoryFraction(from delta: StatsDelta, + snapshot: ContainerSnapshot?, + normalization: StatsNormalizationContext = .containerSpecific) -> Double { + let limit = memoryLimitBytes(for: delta, snapshot: snapshot, normalization: normalization) + guard limit > 0 else { return 0 } + return sanitized(Double(delta.memoryUsageBytes) / Double(limit)) + } + + static func memoryLimitBytes(for delta: StatsDelta, + snapshot: ContainerSnapshot?, + normalization: StatsNormalizationContext = .containerSpecific) -> UInt64 { + normalization.memoryLimitBytes(for: delta, snapshot: snapshot) + } + + private static func sanitized(_ value: Double) -> Double { + guard value.isFinite, value > 0 else { return 0 } + return value + } } diff --git a/Sources/Contained/DesignSystem/StatsNormalization.swift b/Sources/Contained/DesignSystem/StatsNormalization.swift new file mode 100644 index 00000000..e72bd952 --- /dev/null +++ b/Sources/Contained/DesignSystem/StatsNormalization.swift @@ -0,0 +1,59 @@ +import Foundation +import ContainedCore + +enum StatsNormalizationMode: String, CaseIterable, Identifiable, Codable, Sendable { + case container + case machine = "global" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .container: return "Container" + case .machine: return "Machine" + } + } + + var footnote: String { + switch self { + case .container: + return "CPU and memory are scaled against each container's own configured limits." + case .machine: + return "CPU and memory are scaled against Apple container's machine CPU and memory resources." + } + } +} + +struct StatsNormalizationContext: Equatable, Sendable { + var mode: StatsNormalizationMode = .container + var machineCPUs: Int? + var machineMemoryBytes: UInt64? + + static let containerSpecific = StatsNormalizationContext(mode: .container) + + func cpuLimit(for snapshot: ContainerSnapshot?) -> Double { + switch mode { + case .container: + return max(Double(snapshot?.configuration.resources.cpus ?? 1), 1) + case .machine: + return max(Double(machineCPUs ?? ProcessInfo.processInfo.activeProcessorCount), 1) + } + } + + func memoryLimitBytes(for snapshot: ContainerSnapshot?, fallback: UInt64 = 0) -> UInt64 { + switch mode { + case .container: + let configuredLimit = snapshot?.configuration.resources.memoryInBytes ?? 0 + return configuredLimit > 0 ? configuredLimit : fallback + case .machine: + let machineMemory = machineMemoryBytes ?? ProcessInfo.processInfo.physicalMemory + if machineMemory > 0 { return machineMemory } + let configuredLimit = snapshot?.configuration.resources.memoryInBytes ?? 0 + return configuredLimit > 0 ? configuredLimit : fallback + } + } + + func memoryLimitBytes(for delta: StatsDelta, snapshot: ContainerSnapshot?) -> UInt64 { + memoryLimitBytes(for: snapshot, fallback: delta.memoryLimitBytes) + } +} diff --git a/Sources/Contained/Features/Containers/ContainerCard.swift b/Sources/Contained/Features/Containers/ContainerCard.swift index 560ed5be..d7ab4417 100644 --- a/Sources/Contained/Features/Containers/ContainerCard.swift +++ b/Sources/Contained/Features/Containers/ContainerCard.swift @@ -7,11 +7,12 @@ import ContainedCore struct ContainerCard: View { let snapshot: ContainerSnapshot var style: Personalization + var hasStyleOverride: Bool = true var density: CardDensity var stats: StatsDelta? - var history: [Double] - /// Every metric's recent history, so the expanded footer's chips can flip the graph instantly - /// without waiting for the parent to recompute. The compact card just uses `history`. + var statsNormalization: StatsNormalizationContext = .containerSpecific + /// Every metric's recent history, so the footer's widget chips can flip the graph instantly + /// without borrowing another metric's samples. var histories: [GraphMetric: SampleBuffer] = [:] var isBusy: Bool var hasImageUpdate: Bool = false @@ -38,6 +39,7 @@ struct ContainerCard: View { /// Multi-select mode: tapping toggles selection instead of opening the detail. var selecting: Bool = false var isSelected: Bool = false + var selectedWidgetIndex: Binding? @State private var tab: Tab = .overview @State private var confirmingDelete = false @@ -45,7 +47,7 @@ struct ContainerCard: View { @State private var showingCustomize = false @State private var draftStyle: Personalization? = nil /// Session-only graph tab selection. The footer chips act as tabs and switch the graph. - @State private var selectedWidgetIndex = 0 + @State private var localSelectedWidgetIndex = 0 enum Tab: String, CaseIterable, Identifiable { case overview = "Overview" @@ -78,7 +80,8 @@ struct ContainerCard: View { private var isRunning: Bool { presentation == .running } private var activeWidgetIndex: Int { let enabled = styleForDisplay.widgets.indices.filter { styleForDisplay.widget(at: $0).enabled } - return enabled.contains(selectedWidgetIndex) ? selectedWidgetIndex : (enabled.first ?? 0) + let selected = selectedWidgetIndex?.wrappedValue ?? localSelectedWidgetIndex + return enabled.contains(selected) ? selected : (enabled.first ?? 0) } private var activeWidget: WidgetConfiguration { styleForDisplay.widget(at: activeWidgetIndex) } private var activeWidgetColor: Color { activeWidget.tint?.color ?? tint } @@ -110,9 +113,13 @@ struct ContainerCard: View { // Anchored to the whole card (via the surrounding Group), so the popover floats beside the // real, live card — which is itself the preview. The customizer carries only the form. .popover(isPresented: $showingCustomize, arrowEdge: .trailing) { - CustomizeSheet(snapshot: snapshot, presentation: .popover, onDraftChange: { draft in - draftStyle = draft - }) + CustomizeSheet(snapshot: snapshot, + presentation: .popover, + initialStyle: style, + initiallyOverridesInheritedStyle: hasStyleOverride, + onDraftChange: { draft in + draftStyle = draft + }) } .confirmationDialog("Delete \(name)?", isPresented: $confirmingDelete) { Button("Delete", role: .destructive) { @@ -136,7 +143,7 @@ struct ContainerCard: View { gradientAngle: styleForDisplay.gradientAngle, blendMode: styleForDisplay.backgroundBlendMode, onTap: onTap) { - headerRow(controlsReveal: controlsVisible ? 1 : 0) + compactHeaderRow } bodyContent: { detailBody } footerLeading: { @@ -149,7 +156,7 @@ struct ContainerCard: View { } footerActions: { footerActions } widget: { - LiveSparkline(samples: histories[activeWidget.metric]?.values ?? history, + LiveSparkline(samples: histories[activeWidget.metric]?.values ?? [], comparisonSamples: activeWidgetComparisonMetric.flatMap { histories[$0]?.values } ?? [], color: activeWidgetColor, lineWidth: activeWidget.lineWidth, @@ -157,26 +164,16 @@ struct ContainerCard: View { areaUsesGradient: activeWidget.areaUsesGradient, interpolation: activeWidget.interpolation, pointSize: activeWidget.pointSize, - barWidth: activeWidget.barWidth) + barWidth: activeWidget.barWidth, + scale: sparklineScale(for: activeWidget.metric), + comparisonScale: activeWidgetComparisonMetric.map(sparklineScale(for:))) .frame(maxWidth: .infinity) .frame(height: Tokens.ResourceCard.sparklineHeight) } - .overlay { if isBusy { ProgressView().controlSize(.small) } } - } - - @ViewBuilder - private func headerRow(controlsReveal: Double = 1) -> some View { - if isExpanded { - compactHeaderRow - .overlay(alignment: .topTrailing) { - headerButtons(controlsReveal: controlsReveal) - .fixedSize(horizontal: true, vertical: false) - .padding(Tokens.Space.s) - .zIndex(1) - } - } else { - compactHeaderRow + .resourceCardFloatingControls(when: isExpanded) { + headerButtons(controlsReveal: controlsVisible ? 1 : 0) } + .resourceCardProgressOverlay(when: isBusy) } private var compactHeaderRow: some View { @@ -190,14 +187,12 @@ struct ContainerCard: View { } private func headerButtons(controlsReveal: Double) -> some View { - GlassButton(singleItem: false) { - expandedPageButtons - GlassButtonItem(systemName: "xmark", help: "Close") { - onClose() - } - } - .opacity(controlsReveal) - .animation(.easeOut(duration: 0.18), value: controlsReveal) + ResourceCardPageControls(items: pageControlItems, + selection: tab, + tint: tint, + controlsReveal: controlsReveal, + onSelect: selectTab, + onClose: onClose) } private var headerTitleBlock: some View { @@ -322,16 +317,11 @@ struct ContainerCard: View { Button(role: .destructive) { onDelete() } label: { Label("Delete", systemImage: "trash") } } - @ViewBuilder - private var expandedPageButtons: some View { - ForEach(Tab.allCases) { item in - GlassButtonItem(tint: tab == item ? tint : nil, - help: item.rawValue, - isIcon: true, - action: { tab = item }) { - Image(systemName: item.systemImage) - .opacity(tab == item ? 1 : 0.62) - } + private var pageControlItems: [ResourceCardPageControlItem] { + Tab.allCases.map { item in + ResourceCardPageControlItem(id: item, + title: item.rawValue, + systemImage: item.systemImage) } } @@ -346,17 +336,34 @@ struct ContainerCard: View { case .overview: ContainerOverviewTab(snapshot: snapshot) case .logs: - LogsTab(snapshot: snapshot) + DeferredContainerPage { + LogsTab(snapshot: snapshot) + } case .terminal: - TerminalTab(snapshot: snapshot) + DeferredContainerPage { + TerminalTab(snapshot: snapshot) + } case .stats: - StatsTab(snapshot: snapshot) + DeferredContainerPage { + StatsTab(snapshot: snapshot) + } case .history: ContainerHistoryTab(snapshot: snapshot) case .files: FilesTab(snapshot: snapshot) case .inspect: - ContainerInspectTab(snapshot: snapshot) + DeferredContainerPage { + ContainerInspectTab(snapshot: snapshot) + } + } + } + + private func selectTab(_ item: Tab) { + guard tab != item else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + tab = item } } @@ -364,22 +371,35 @@ struct ContainerCard: View { private func widgetChip(_ index: Int) -> some View { let widget = styleForDisplay.widget(at: index) let active = index == activeWidgetIndex - return Button { - selectedWidgetIndex = index - } label: { - ResourceCardFooterMini { - if widget.showIcon { - Image(systemName: widget.resolvedSystemImage).font(.caption2) - } - } text: { - if widget.showText { - ResourceCardMetricText(text: stats.map(widget.metric.chipCaption(from:)) ?? "—") - } + return ResourceCardFooterChip(isSelected: active, + tint: widget.tint?.color ?? tint, + help: widget.metric.displayName, + action: { + if let selectedWidgetIndex { + selectedWidgetIndex.wrappedValue = index + } else { + localSelectedWidgetIndex = index + } + }) { + if widget.showIcon { + Image(systemName: widget.resolvedSystemImage).font(.caption2) + } + } text: { + if widget.showText { + ResourceCardMetricText(text: stats.map { + widget.metric.chipCaption(from: $0, + snapshot: snapshot, + normalization: statsNormalization) + } ?? "—") } - .foregroundStyle(active ? AnyShapeStyle(widget.tint?.color ?? tint) : AnyShapeStyle(.secondary)) } - .buttonStyle(.plain) - .help(widget.metric.displayName) + } + + private func sparklineScale(for metric: GraphMetric) -> SparklineScale { + switch metric { + case .cpu, .memory: return .fraction + case .netRx, .netTx, .diskRead, .diskWrite: return .normalized + } } /// Lifecycle + edit/delete, styled to match the compact card's small plain play/stop glyph @@ -393,21 +413,45 @@ struct ContainerCard: View { footerAction("play.fill", help: "Start", tint: tint, action: onStart) } footerAction("slider.horizontal.3", help: "Edit", action: onEdit) - footerAction("trash", help: "Delete", tint: .red) { confirmingDelete = true } + footerAction("trash", help: "Delete", role: .destructive) { confirmingDelete = true } } private func footerAction(_ systemName: String, help: String, tint: Color? = nil, + role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { - Button(action: action) { - ResourceCardFooterMini { - Image(systemName: systemName).font(.body) - } text: { - EmptyView() + ResourceCardFooterButton(systemName: systemName, + help: help, + tint: tint, + role: role, + action: action) + } +} + +private struct DeferredContainerPage: View { + private let delay: Duration + @ViewBuilder var content: () -> Content + + @State private var isReady = false + + init(delay: Duration = .milliseconds(60), @ViewBuilder content: @escaping () -> Content) { + self.delay = delay + self.content = content + } + + var body: some View { + Group { + if isReady { + content() + } else { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) } } - .buttonStyle(.plain) - .foregroundStyle(tint ?? .secondary) - .help(help) - .accessibilityLabel(help) + .task { + isReady = false + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + isReady = true + } } } diff --git a/Sources/Contained/Features/Containers/ContainerConfigureView.swift b/Sources/Contained/Features/Containers/ContainerConfigureView.swift index 327d9a0d..bfee7806 100644 --- a/Sources/Contained/Features/Containers/ContainerConfigureView.swift +++ b/Sources/Contained/Features/Containers/ContainerConfigureView.swift @@ -121,7 +121,7 @@ struct ContainerConfigureView: View { let messages = spec.validationMessages let warnings = preflightWarnings if !messages.isEmpty || !warnings.isEmpty || runError != nil { - VStack(alignment: .leading, spacing: Tokens.Space.xs) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.xs) { // Blocking issues (gate the run button) in secondary; pre-flight warnings in orange; // the run/pull failure in red. ForEach(messages, id: \.self) { message in diff --git a/Sources/Contained/Features/Containers/ContainerOverviewTab.swift b/Sources/Contained/Features/Containers/ContainerOverviewTab.swift index 5935ab09..5a039d66 100644 --- a/Sources/Contained/Features/Containers/ContainerOverviewTab.swift +++ b/Sources/Contained/Features/Containers/ContainerOverviewTab.swift @@ -10,7 +10,7 @@ struct ContainerOverviewTab: View { var body: some View { ContainerTabScaffold { - VStack(alignment: .leading, spacing: Tokens.Space.m) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.m) { section("General") { row("Image", snapshot.image) row("Platform", config.platform.display) diff --git a/Sources/Contained/Features/Containers/ContainerTabScaffold.swift b/Sources/Contained/Features/Containers/ContainerTabScaffold.swift index 1fd3d65f..ff5eeb53 100644 --- a/Sources/Contained/Features/Containers/ContainerTabScaffold.swift +++ b/Sources/Contained/Features/Containers/ContainerTabScaffold.swift @@ -9,9 +9,11 @@ struct ContainerTabScaffold: View { var body: some View { ScrollView(axes) { - content() - .padding(Tokens.Space.s) - .frame(maxWidth: .infinity, alignment: .leading) + LazyVStack(alignment: .leading, spacing: 0) { + content() + .padding(Tokens.Space.s) + .frame(maxWidth: .infinity, alignment: .leading) + } } .scrollEdgeEffectStyle(.soft, for: .all) } @@ -40,19 +42,8 @@ struct ContainerTabSection: View { @ViewBuilder var content: () -> Content var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.s) { - if let title { - Text(title) - .font(.headline) - .padding(.leading, Tokens.Space.xs) - } - VStack(alignment: .leading, spacing: Tokens.Space.s) { - content() - } - .padding(Tokens.Space.s) - .frame(maxWidth: .infinity, alignment: .leading) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) + ResourceCardInsetSection(title: title) { + content() } - .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Sources/Contained/Features/Containers/ContainersGridView.swift b/Sources/Contained/Features/Containers/ContainersGridView.swift index 7b533061..37e41c08 100644 --- a/Sources/Contained/Features/Containers/ContainersGridView.swift +++ b/Sources/Contained/Features/Containers/ContainersGridView.swift @@ -22,6 +22,7 @@ struct ContainersGridView: View { /// Live frames of every visible grid card (in the "grid" coordinate space) so the promoted card /// can start from the exact slot it was tapped in. @State private var cardFrames: [String: CGRect] = [:] + @State private var selectedWidgetIndices: [String: Int] = [:] // Each network is a collapsible section of the containers attached to it. @State private var collapsedNetworks: Set = [] @@ -236,6 +237,9 @@ struct ContainersGridView: View { // Report the in-page search count so the toolbar can escalate an empty search into the palette. .onAppear { ui.pageResultCount = filtered.count } .onChange(of: filtered.count) { _, count in ui.pageResultCount = count } + .onChange(of: store.snapshots.map(\.id)) { _, ids in + selectedWidgetIndices = selectedWidgetIndices.filter { ids.contains($0.key) } + } } // MARK: - Network sections @@ -243,7 +247,7 @@ struct ContainersGridView: View { @ViewBuilder private func groupSection(_ group: ContainerGroup) -> some View { let collapsed = collapsedNetworks.contains(group.name) - VStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { sectionHeader(group, collapsed: collapsed) if !collapsed { if group.containers.isEmpty { @@ -361,13 +365,18 @@ struct ContainersGridView: View { private func containerCard(_ snapshot: ContainerSnapshot, isExpanded: Bool, cornerRadiusOverride: CGFloat? = nil, - controlsVisible: Bool = true, onTap: @escaping () -> Void) -> some View { + controlsVisible: Bool = true, + onTap: @escaping () -> Void) -> some View { let style = app.containerStyle(for: snapshot) + let hasStyleOverride = app.personalization.hasOverride(id: snapshot.id) return ContainerCardMetricsRenderer( - store: store, + metrics: store.metricsState(for: snapshot.id), snapshot: snapshot, style: style, + hasStyleOverride: hasStyleOverride, density: app.settings.density, + statsNormalization: app.statsNormalizationContext, + selectedWidgetIndex: selectedWidgetBinding(for: snapshot.id), isBusy: store.busyIDs.contains(snapshot.id), hasImageUpdate: app.imageUpdateStatus(for: snapshot.image).state == .updateAvailable, isExpanded: isExpanded, @@ -391,6 +400,14 @@ struct ContainersGridView: View { ) } + private func selectedWidgetBinding(for id: String) -> Binding { + Binding { + selectedWidgetIndices[id] ?? 0 + } set: { index in + selectedWidgetIndices[id] = index + } + } + private var cardDetailTarget: AppMorphTarget { .centered(safeArea: cardDetailSafeAreaPolicy, margin: 0) { bounds in panelSize(in: bounds.size) @@ -503,10 +520,13 @@ struct ContainersGridView: View { } private struct ContainerCardMetricsRenderer: View { - let store: ContainersStore + let metrics: ContainerMetricsState let snapshot: ContainerSnapshot let style: Personalization + let hasStyleOverride: Bool let density: CardDensity + let statsNormalization: StatsNormalizationContext + let selectedWidgetIndex: Binding let isBusy: Bool let hasImageUpdate: Bool let isExpanded: Bool @@ -532,10 +552,11 @@ private struct ContainerCardMetricsRenderer: View { ContainerCard( snapshot: snapshot, style: style, + hasStyleOverride: hasStyleOverride, density: density, - stats: store.statsByID[snapshot.id], - history: store.historyByID[snapshot.id]?[style.graphMetric]?.values ?? [], - histories: store.historyByID[snapshot.id] ?? [:], + stats: metrics.stats, + statsNormalization: statsNormalization, + histories: metrics.historyByMetric, isBusy: isBusy, hasImageUpdate: hasImageUpdate, isExpanded: isExpanded, @@ -555,16 +576,13 @@ private struct ContainerCardMetricsRenderer: View { revealCLI: revealCLI, health: health, selecting: selecting, - isSelected: isSelected + isSelected: isSelected, + selectedWidgetIndex: selectedWidgetIndex ) } } private extension CGRect { - var isUsableForMorph: Bool { - width.isFinite && height.isFinite && minX.isFinite && minY.isFinite && width > 1 && height > 1 - } - func isClose(to other: CGRect, tolerance: CGFloat = 0.5) -> Bool { abs(minX - other.minX) <= tolerance && abs(minY - other.minY) <= tolerance && diff --git a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift b/Sources/Contained/Features/Containers/Creation/CreationFlow.swift index 6e2129c6..8974416d 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/Contained/Features/Containers/Creation/CreationFlow.swift @@ -361,7 +361,7 @@ struct CreationFlow: View { private func optionStack(@ViewBuilder content: () -> C) -> some View { GlassEffectContainer(spacing: Tokens.Space.s) { - VStack(spacing: Tokens.Space.s) { content() } + LazyVStack(spacing: Tokens.Space.s) { content() } } } diff --git a/Sources/Contained/Features/Containers/Creation/CreationPages.swift b/Sources/Contained/Features/Containers/Creation/CreationPages.swift index 13176fc4..75ea5d17 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationPages.swift +++ b/Sources/Contained/Features/Containers/Creation/CreationPages.swift @@ -123,19 +123,18 @@ struct CreationLocalImagesContent: View { var onSelect: (RunSpec) -> Void var body: some View { - VStack(spacing: Tokens.Space.m) { - HStack(spacing: Tokens.Space.s) { - Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField("Filter local images", text: $query) - .textFieldStyle(.plain) - if !query.isEmpty { - Button { query = "" } label: { Image(systemName: "xmark.circle.fill") } - .buttonStyle(.plain).foregroundStyle(.secondary) + LazyVStack(spacing: Tokens.Space.m) { + DesignInputSurface { + HStack(spacing: Tokens.Space.s) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Filter local images", text: $query) + .textFieldStyle(.plain) + if !query.isEmpty { + Button { query = "" } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain).foregroundStyle(.secondary) + } } } - .padding(.horizontal, Tokens.Space.m) - .padding(.vertical, Tokens.Space.s) - .glassSurface(.thin, cornerRadius: Tokens.Radius.control) if filteredLocalImages.isEmpty { ContentUnavailableView { @@ -175,13 +174,14 @@ struct CreationPastedComposeContent: View { var onImport: () -> Void var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.m) { - TextEditor(text: $text) - .font(.system(.callout, design: .monospaced)) - .scrollContentBackground(.hidden) - .padding(Tokens.Space.s) - .glassSurface(.thin, cornerRadius: Tokens.Radius.control) - .frame(minHeight: 260) + LazyVStack(alignment: .leading, spacing: Tokens.Space.m) { + DesignInputSurface(horizontalPadding: Tokens.Space.s, + verticalPadding: Tokens.Space.s, + minHeight: 260) { + TextEditor(text: $text) + .font(.system(.callout, design: .monospaced)) + .scrollContentBackground(.hidden) + } HStack { Spacer() @@ -246,7 +246,7 @@ private struct CreationResourceForm: View { @ViewBuilder var footer: () -> Footer var body: some View { - VStack(spacing: Tokens.Space.m) { + LazyVStack(spacing: Tokens.Space.m) { ResourceGlassCard(size: .small, elevated: false) { ResourceCardHeader { ResourceCardIconChip(symbol: symbol, tint: .accentColor) diff --git a/Sources/Contained/Features/Containers/CustomizeSheet.swift b/Sources/Contained/Features/Containers/CustomizeSheet.swift index 41c9304e..61ff530a 100644 --- a/Sources/Contained/Features/Containers/CustomizeSheet.swift +++ b/Sources/Contained/Features/Containers/CustomizeSheet.swift @@ -69,26 +69,41 @@ struct CustomizeSheet: View { let presentation: Presentation var onDraftChange: ((Personalization) -> Void)? = nil - @State private var style = Personalization() - @State private var overridesInheritedStyle = true - @State private var loaded = false + @State private var style: Personalization + @State private var overridesInheritedStyle: Bool + @State private var loaded: Bool init(snapshot: ContainerSnapshot, presentation: Presentation = .popover) { - self.target = .container(snapshot) - self.presentation = presentation + self.init(snapshot: snapshot, + presentation: presentation, + initialStyle: nil, + initiallyOverridesInheritedStyle: nil, + onDraftChange: nil) } init(snapshot: ContainerSnapshot, presentation: Presentation = .popover, + initialStyle: Personalization? = nil, + initiallyOverridesInheritedStyle: Bool? = nil, onDraftChange: ((Personalization) -> Void)? = nil) { self.target = .container(snapshot) self.presentation = presentation self.onDraftChange = onDraftChange + self._style = State(initialValue: initialStyle ?? Personalization()) + self._overridesInheritedStyle = State(initialValue: initiallyOverridesInheritedStyle ?? true) + self._loaded = State(initialValue: initialStyle != nil && initiallyOverridesInheritedStyle != nil) } - init(target: Target, presentation: Presentation = .sheet) { + init(target: Target, + presentation: Presentation = .sheet, + initialStyle: Personalization? = nil, + initiallyOverridesInheritedStyle: Bool? = nil) { self.target = target self.presentation = presentation + self._style = State(initialValue: initialStyle ?? Personalization()) + self._overridesInheritedStyle = State(initialValue: initiallyOverridesInheritedStyle ?? true) + self._loaded = State(initialValue: initialStyle != nil + && (!target.supportsInheritance || initiallyOverridesInheritedStyle != nil)) } private var isPopoverPresentation: Bool { presentation == .popover } @@ -101,7 +116,7 @@ struct CustomizeSheet: View { header Divider() ScrollView { - VStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: Tokens.Space.l) { if target.supportsInheritance { inheritanceSection } editableSection { styleSection } if case .container = target { diff --git a/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift b/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift index 92f5dbe8..90346869 100644 --- a/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift +++ b/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift @@ -17,10 +17,13 @@ struct CustomizeWidgetsPanel: View { } var body: some View { - ForEach(activeWidgetIndices, id: \.self) { index in - widgetSection(index) + LazyVStack(spacing: Tokens.Space.l) { + ForEach(activeWidgetIndices, id: \.self) { index in + widgetSection(index) + } + addWidgetSection } - addWidgetSection + .frame(maxWidth: .infinity, alignment: .leading) } private func widgetSection(_ index: Int) -> some View { diff --git a/Sources/Contained/Features/Containers/FilesTab.swift b/Sources/Contained/Features/Containers/FilesTab.swift index 392d849e..d55000b5 100644 --- a/Sources/Contained/Features/Containers/FilesTab.swift +++ b/Sources/Contained/Features/Containers/FilesTab.swift @@ -92,6 +92,8 @@ struct FilesTab: View { private func load() async { guard let client = app.client else { return } + try? await Task.sleep(for: .milliseconds(140)) + guard !Task.isCancelled else { return } loading = true; error = nil defer { loading = false } do { diff --git a/Sources/Contained/Features/Containers/LogsTab.swift b/Sources/Contained/Features/Containers/LogsTab.swift index 2749c145..81da4110 100644 --- a/Sources/Contained/Features/Containers/LogsTab.swift +++ b/Sources/Contained/Features/Containers/LogsTab.swift @@ -95,6 +95,8 @@ struct LogsTab: View { private func stream() async { guard let client = app.client else { return } + try? await Task.sleep(for: .milliseconds(140)) + guard !Task.isCancelled else { return } lines.removeAll(); carry = ""; failed = nil streaming = true defer { streaming = false } diff --git a/Sources/Contained/Features/Containers/RunSpecForm.swift b/Sources/Contained/Features/Containers/RunSpecForm.swift index 587452bb..234318c2 100644 --- a/Sources/Contained/Features/Containers/RunSpecForm.swift +++ b/Sources/Contained/Features/Containers/RunSpecForm.swift @@ -22,7 +22,7 @@ struct RunSpecForm: View { } var body: some View { - VStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: Tokens.Space.l) { Text("Blue sections contain explicit values from an import, edit, template, or manual change.") .font(.caption) .foregroundStyle(.secondary) @@ -193,7 +193,7 @@ struct RunSpecForm: View { private var volumesSection: some View { Group { ForEach($spec.volumes) { $vol in - VStack(spacing: Tokens.Space.xs) { + LazyVStack(spacing: Tokens.Space.xs) { HStack { sourcePicker(source: $vol.source) TextField("Source (host path or volume)", text: $vol.source).textFieldStyle(.roundedBorder) @@ -228,7 +228,7 @@ struct RunSpecForm: View { private var socketsSection: some View { Group { ForEach($spec.sockets) { $socket in - VStack(spacing: Tokens.Space.xs) { + LazyVStack(spacing: Tokens.Space.xs) { HStack { TextField("Host socket path", text: $socket.hostPath).textFieldStyle(.roundedBorder) removeButton { spec.sockets.removeAll { $0.id == socket.id } } diff --git a/Sources/Contained/Features/Containers/StatsTab.swift b/Sources/Contained/Features/Containers/StatsTab.swift index 001b0986..2fb5b64e 100644 --- a/Sources/Contained/Features/Containers/StatsTab.swift +++ b/Sources/Contained/Features/Containers/StatsTab.swift @@ -10,8 +10,10 @@ struct StatsTab: View { @State private var processes: String = "" - private var delta: StatsDelta? { app.containers.statsByID[snapshot.id] } - private var history: [GraphMetric: SampleBuffer] { app.containers.historyByID[snapshot.id] ?? [:] } + private var metrics: ContainerMetricsState { app.containers.metricsState(for: snapshot.id) } + private var delta: StatsDelta? { metrics.stats } + private var history: [GraphMetric: SampleBuffer] { metrics.historyByMetric } + private var normalization: StatsNormalizationContext { app.statsNormalizationContext } private var tint: Color { app.containerStyle(for: snapshot).color } @@ -39,20 +41,20 @@ struct StatsTab: View { } } else { // Running but no sample yet (first tick pending). - VStack(spacing: Tokens.Space.m) { + LazyVStack(spacing: Tokens.Space.m) { ProgressView() Text("Collecting stats…").font(.callout).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } - .task(id: snapshot.id) { await refreshVisibleStatsAndProcesses() } + .task(id: snapshot.id) { await refreshVisibleProcesses() } } @ViewBuilder private var processList: some View { if !processes.isEmpty { - VStack(alignment: .leading, spacing: Tokens.Space.s) { + ResourceCardInsetSection { Label("Processes", systemImage: "list.bullet.rectangle") .font(.caption.weight(.semibold)).foregroundStyle(.secondary) Text(processes) @@ -60,9 +62,6 @@ struct StatsTab: View { .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } - .padding(Tokens.Space.s) - .frame(maxWidth: .infinity, alignment: .leading) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) } } @@ -73,20 +72,40 @@ struct StatsTab: View { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } - private func refreshVisibleStatsAndProcesses() async { + private func refreshVisibleProcesses() async { guard snapshot.state == .running else { processes = ""; return } - await app.refreshContainerStatsNow() + try? await Task.sleep(for: .milliseconds(140)) + guard !Task.isCancelled else { return } await loadProcesses() } private func tile(_ metric: GraphMetric, _ delta: StatsDelta, _ symbol: String) -> some View { - MetricTile(label: metric.displayName, value: metric.caption(from: delta), - systemImage: symbol, tint: tint, samples: history[metric]?.values) + MetricTile(label: metric.displayName, + value: metric.caption(from: delta, snapshot: snapshot, normalization: normalization), + systemImage: symbol, + tint: tint, + samples: history[metric]?.values, + sparklineScale: sparklineScale(for: metric)) } private func memoryTile(_ delta: StatsDelta) -> some View { - MetricTile(label: "Memory \(Format.bytes(delta.memoryUsageBytes)) / \(Format.bytes(delta.memoryLimitBytes))", - value: Format.percent(delta.memoryFraction), - systemImage: "memorychip", tint: tint, samples: history[.memory]?.values) + let memoryLimit = GraphMetric.memoryLimitBytes(for: delta, + snapshot: snapshot, + normalization: normalization) + return MetricTile(label: "Memory \(Format.bytes(delta.memoryUsageBytes)) / \(Format.bytes(memoryLimit))", + value: GraphMetric.memory.caption(from: delta, + snapshot: snapshot, + normalization: normalization), + systemImage: "memorychip", + tint: tint, + samples: history[.memory]?.values, + sparklineScale: sparklineScale(for: .memory)) + } + + private func sparklineScale(for metric: GraphMetric) -> SparklineScale { + switch metric { + case .cpu, .memory: return .fraction + case .netRx, .netTx, .diskRead, .diskWrite: return .normalized + } } } diff --git a/Sources/Contained/Features/Containers/TerminalTab.swift b/Sources/Contained/Features/Containers/TerminalTab.swift index 552cbef6..86f5c88c 100644 --- a/Sources/Contained/Features/Containers/TerminalTab.swift +++ b/Sources/Contained/Features/Containers/TerminalTab.swift @@ -71,7 +71,7 @@ struct TerminalTab: View { } private func endedOverlay(code: Int32?) -> some View { - VStack(spacing: Tokens.Space.m) { + ResourceCardInsetSection(alignment: .center, padding: Tokens.Space.xl) { Image(systemName: "bolt.horizontal.circle").font(.largeTitle).foregroundStyle(.secondary) Text(code == nil || code == 0 ? "Session ended" : "Session ended (exit \(code!))") .font(.headline) @@ -81,8 +81,6 @@ struct TerminalTab: View { } } } - .padding(Tokens.Space.xl) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) } private func reconnect() { diff --git a/Sources/Contained/Features/Images/BuildWorkspaceView.swift b/Sources/Contained/Features/Images/BuildWorkspaceView.swift index 6049ad39..7b2f9f17 100644 --- a/Sources/Contained/Features/Images/BuildWorkspaceView.swift +++ b/Sources/Contained/Features/Images/BuildWorkspaceView.swift @@ -22,7 +22,7 @@ struct BuildWorkspaceView: View { private var canBuild: Bool { contextDir != nil && !tag.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { - VStack(spacing: 0) { + LazyVStack(spacing: 0) { form Divider() if building, let context = contextDir, let client = app.client { @@ -47,7 +47,7 @@ struct BuildWorkspaceView: View { private var form: some View { ScrollView { - VStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: Tokens.Space.l) { sourceSection optionsSection commandSection diff --git a/Sources/Contained/Features/Images/ImageStyleButton.swift b/Sources/Contained/Features/Images/ImageStyleButton.swift index 9da7c328..c5676da0 100644 --- a/Sources/Contained/Features/Images/ImageStyleButton.swift +++ b/Sources/Contained/Features/Images/ImageStyleButton.swift @@ -5,6 +5,8 @@ import ContainedCore /// Image row identity chip. If the image has a saved default style, this displays it and opens the /// same compact customization popover used by container cards. struct ImageStyleButton: View { + @Environment(AppModel.self) private var app + let reference: String let style: Personalization let target: CustomizeSheet.Target @@ -22,7 +24,10 @@ struct ImageStyleButton: View { .help("Customize image style") .accessibilityLabel("Customize \(Format.shortImage(reference)) image style") .popover(isPresented: $showingCustomize, arrowEdge: .trailing) { - CustomizeSheet(target: target, presentation: .popover) + CustomizeSheet(target: target, + presentation: .popover, + initialStyle: style, + initiallyOverridesInheritedStyle: target.hasOwnStyle(in: app)) } } } @@ -30,6 +35,8 @@ struct ImageStyleButton: View { /// A generic identity chip that opens the customize popover for any `CustomizeSheet.Target` (images, /// volumes, …). Mirrors `ImageStyleButton` but isn't image-specific. struct CardStyleButton: View { + @Environment(AppModel.self) private var app + let style: Personalization let target: CustomizeSheet.Target var help = "Customize" @@ -47,7 +54,26 @@ struct CardStyleButton: View { .help(help) .accessibilityLabel(help) .popover(isPresented: $showingCustomize, arrowEdge: .trailing) { - CustomizeSheet(target: target, presentation: .popover) + CustomizeSheet(target: target, + presentation: .popover, + initialStyle: style, + initiallyOverridesInheritedStyle: target.hasOwnStyle(in: app)) + } + } +} + +private extension CustomizeSheet.Target { + @MainActor + func hasOwnStyle(in app: AppModel) -> Bool { + switch self { + case .container(let snapshot): + return app.personalization.hasOverride(id: snapshot.id) + case .image(let reference), .imageTag(let reference, _): + return app.personalization.imageDefault(for: reference) != nil + case .imageGroup(let id, _): + return app.personalization.imageGroupDefault(for: id) != nil + case .volume: + return true } } } diff --git a/Sources/Contained/Features/Images/RegistryImageSearch.swift b/Sources/Contained/Features/Images/RegistryImageSearch.swift index 8bd74274..1e77d83f 100644 --- a/Sources/Contained/Features/Images/RegistryImageSearch.swift +++ b/Sources/Contained/Features/Images/RegistryImageSearch.swift @@ -24,7 +24,7 @@ struct RegistryImageSearch: View { private var trimmedQuery: String { query.trimmingCharacters(in: .whitespaces) } var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.m) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.m) { searchField if trimmedQuery.isEmpty { idleSuggestions @@ -59,7 +59,7 @@ struct RegistryImageSearch: View { private var idleSuggestions: some View { ScrollView { - VStack(alignment: .leading, spacing: Tokens.Space.l) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { suggestionSection("Starters") { ForEach(BuiltinTemplate.all, id: \.name) { item in quickPick(symbol: item.symbol, title: item.name, @@ -81,7 +81,7 @@ struct RegistryImageSearch: View { } private func suggestionSection(_ title: String, @ViewBuilder content: () -> C) -> some View { - VStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { Text(title).font(.caption.weight(.semibold)).foregroundStyle(.secondary) LazyVGrid(columns: [GridItem(.adaptive(minimum: 150, maximum: 240), spacing: Tokens.Space.s)], spacing: Tokens.Space.s) { content() } @@ -101,7 +101,7 @@ struct RegistryImageSearch: View { @ViewBuilder private var resultsList: some View { if results.isEmpty { - VStack(spacing: Tokens.Space.s) { + LazyVStack(spacing: Tokens.Space.s) { if searching { ProgressView() Text("Searching Docker Hub…").font(.callout).foregroundStyle(.secondary) diff --git a/Sources/Contained/Features/MenuBar/MenuBarContent.swift b/Sources/Contained/Features/MenuBar/MenuBarContent.swift index 80c3f444..f92b3f43 100644 --- a/Sources/Contained/Features/MenuBar/MenuBarContent.swift +++ b/Sources/Contained/Features/MenuBar/MenuBarContent.swift @@ -29,7 +29,7 @@ struct MenuBarContent: View { } var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.m) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.m) { header Divider() @@ -178,7 +178,7 @@ struct MenuBarContent: View { @ViewBuilder private var infoGrid: some View { - VStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { infoRow("Containers", value: "\(store.running.count) running · \(stopped.count) stopped") infoRow("Resources", value: "\(app.images.count) images · \(app.volumes.count) volumes · \(app.networks.count) networks") infoRow("Bootstrap", value: cliLabel) diff --git a/Sources/Contained/Features/Palette/CommandPalette.swift b/Sources/Contained/Features/Palette/CommandPalette.swift index e347035f..d127590d 100644 --- a/Sources/Contained/Features/Palette/CommandPalette.swift +++ b/Sources/Contained/Features/Palette/CommandPalette.swift @@ -271,8 +271,7 @@ struct PaletteItem: Identifiable { @MainActor private static func imageItems(app: AppModel, ui: UIState) -> [PaletteItem] { var items: [PaletteItem] = [] - let groups = LocalImageTagGroup.groups(for: app.images) - .sorted { $0.primaryReference.localizedCaseInsensitiveCompare($1.primaryReference) == .orderedAscending } + let groups = app.localImageGroups() for group in groups { items.append(PaletteItem(title: "Run \(Format.shortImage(group.primaryReference))", subtitle: "local image", diff --git a/Sources/Contained/Features/Settings/ConfigTransfer.swift b/Sources/Contained/Features/Settings/ConfigTransfer.swift index c3814e7f..4fab46cc 100644 --- a/Sources/Contained/Features/Settings/ConfigTransfer.swift +++ b/Sources/Contained/Features/Settings/ConfigTransfer.swift @@ -21,7 +21,7 @@ struct ConfigTransferControls: View { @State private var importMode: ConfigImportMode = .merge var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { ForEach(AppStateSection.allCases) { section in Toggle(section.displayName, isOn: binding(for: section)) .toggleStyle(.checkbox) diff --git a/Sources/Contained/Features/Settings/ReleaseNotesView.swift b/Sources/Contained/Features/Settings/ReleaseNotesView.swift index 907f6f67..18ccb351 100644 --- a/Sources/Contained/Features/Settings/ReleaseNotesView.swift +++ b/Sources/Contained/Features/Settings/ReleaseNotesView.swift @@ -8,7 +8,7 @@ struct ReleaseNotesView: View { var onClose: (() -> Void)? var body: some View { - VStack(spacing: 0) { + LazyVStack(spacing: 0) { HStack { Text(title).font(.headline) Spacer() diff --git a/Sources/Contained/Features/Settings/Tabs/AboutTab.swift b/Sources/Contained/Features/Settings/Tabs/AboutTab.swift index bc60bbf4..0db6850c 100644 --- a/Sources/Contained/Features/Settings/Tabs/AboutTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/AboutTab.swift @@ -9,7 +9,7 @@ struct AboutTab: View { @Environment(AppModel.self) private var app var body: some View { - VStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: Tokens.Space.l) { PanelSection { HStack(spacing: Tokens.Space.m) { Image(nsImage: NSApp.applicationIconImage) diff --git a/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift b/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift index eea3b8c3..e5530046 100644 --- a/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift @@ -8,7 +8,7 @@ struct AppearanceTab: View { @Bindable var settings: SettingsStore var body: some View { - VStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: Tokens.Space.l) { PanelSection(header: "Theme") { PanelRow(title: "Appearance") { Picker("", selection: $settings.appearance) { diff --git a/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift b/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift index 85f3068b..8d12774c 100644 --- a/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift @@ -11,7 +11,7 @@ struct ExperimentalTab: View { @Bindable var settings: SettingsStore var body: some View { - VStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: Tokens.Space.l) { PanelSection(header: "Experimental", footer: "These features are still being refined. They're off by default; enable any you want to try. You can turn them back off at any time.") { PanelToggleRow(title: "Toolbar-first UI", diff --git a/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift b/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift index 97a93aa5..7843d25c 100644 --- a/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift @@ -10,7 +10,7 @@ struct GeneralTab: View { @State private var confirmingClear = false var body: some View { - VStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: Tokens.Space.l) { PanelSection(header: "Startup") { PanelToggleRow(title: "Launch at login", isOn: $settings.launchAtLogin) PanelToggleRow(title: "Keep running in the menu bar", isOn: $settings.keepInMenuBar) @@ -24,8 +24,8 @@ struct GeneralTab: View { } PanelSection(header: "Data", - footer: "How often the container list refreshes. Live metrics use one low-priority runtime stream independent of this interval.") { - PanelRow(title: "Refresh interval") { + footer: "Live metrics use one low-priority runtime stream. The list refresh interval only controls background service, container list, and resource-cache polling. \(settings.statsNormalizationMode.footnote)") { + PanelRow(title: "List refresh interval") { HStack(spacing: Tokens.Space.s) { Slider(value: $settings.refreshInterval, in: 1...10, step: 1) .frame(width: Tokens.FormWidth.compactSlider) @@ -41,7 +41,17 @@ struct GeneralTab: View { Text("14 days").tag(14) Text("30 days").tag(30) } - .labelsHidden().fixedSize() + .labelsHidden().fixedSize() + } + PanelRow(title: "Normalize stats") { + Picker("", selection: statsNormalizationBinding) { + ForEach(StatsNormalizationMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() } Button("Clear History…", role: .destructive) { confirmingClear = true } .frame(maxWidth: .infinity, alignment: .leading) @@ -60,14 +70,14 @@ struct GeneralTab: View { .labelsHidden() .fixedSize() } - VStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { Text("Write to").font(.caption).foregroundStyle(.secondary) ForEach(AppLogDestination.allCases) { destination in Toggle(destination.displayName, isOn: setBinding(destination, in: \.enabledLogDestinations)) .toggleStyle(.checkbox) } } - VStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { Text("Categories").font(.caption).foregroundStyle(.secondary) LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), alignment: .leading)], alignment: .leading, @@ -100,6 +110,11 @@ struct GeneralTab: View { set: { app.applyHistoryRetention($0) }) } + private var statsNormalizationBinding: Binding { + Binding(get: { settings.statsNormalizationMode }, + set: { app.setStatsNormalizationMode($0) }) + } + private func setBinding(_ value: T, in keyPath: ReferenceWritableKeyPath>) -> Binding where T: Hashable { Binding { settings[keyPath: keyPath].contains(value) diff --git a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift b/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift index da74ad14..10712a21 100644 --- a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift @@ -11,7 +11,7 @@ struct RegistriesTab: View { @State private var loggingOut: RegistryLogin? var body: some View { - VStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: Tokens.Space.l) { PanelSection(header: "Signed-in registries", footer: "Credentials are typed by you and piped to the CLI via stdin, so the password never lands in the process list. Contained doesn’t store it.") { if app.registries.isEmpty { diff --git a/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift b/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift index bcc05721..188f0ad1 100644 --- a/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift @@ -16,7 +16,7 @@ struct RuntimeTab: View { @State private var deletingDomain: String? var body: some View { - VStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: Tokens.Space.l) { PanelSection(header: "Kernel", footer: "Downloads and sets the recommended kernel as the default. May prompt for your administrator password — handled by the container CLI; Contained never sees it.") { PanelRow(title: "Recommended kernel") { @@ -48,12 +48,16 @@ struct RuntimeTab: View { } if let props = app.properties { - PanelSection(header: "Defaults", - footer: "Read-only — the container runtime provides no command to change these. They apply when a container or build doesn’t specify its own resources.") { + PanelSection(header: "Runtime resources", + footer: "Read-only — machine resources are the denominator for machine-normalized stats. Defaults apply when a container or build doesn’t specify its own resources.") { if let d = props.container { if let c = d.cpus { PanelRow(title: "Default CPUs") { Text("\(c)").foregroundStyle(.secondary) } } if let m = d.memory { PanelRow(title: "Default memory") { Text(m).foregroundStyle(.secondary) } } } + if let machine = props.machine { + if let c = machine.cpus { PanelRow(title: "Machine CPUs") { Text("\(c)").foregroundStyle(.secondary) } } + if let m = machine.memory { PanelRow(title: "Machine memory") { Text(m).foregroundStyle(.secondary) } } + } if let b = props.build { if let img = b.image { PanelRow(title: "Builder image") { Text(img).foregroundStyle(.secondary) } } if let r = b.rosetta { PanelRow(title: "Builder Rosetta") { Text(r ? "On" : "Off").foregroundStyle(.secondary) } } @@ -125,4 +129,3 @@ struct RuntimeTab: View { else { await loadDNS() } } } - diff --git a/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift b/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift index 62febcca..979e073a 100644 --- a/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift @@ -11,7 +11,7 @@ struct UpdatesTab: View { var body: some View { @Bindable var settings = app.settings - VStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: Tokens.Space.l) { PanelSection(header: "Updates", footer: "\(settings.updateChannel.footnote) Each channel has its own release feed; channels without a published build yet are dimmed and unselectable. Delivered via Sparkle once a signed build points at the feed; inert in development builds.") { PanelRow(title: "Update channel") { diff --git a/Sources/Contained/Features/System/SystemView.swift b/Sources/Contained/Features/System/SystemView.swift index 6331709c..bea0720b 100644 --- a/Sources/Contained/Features/System/SystemView.swift +++ b/Sources/Contained/Features/System/SystemView.swift @@ -97,7 +97,7 @@ struct SystemContent: View { } } } content: { - VStack(alignment: .leading, spacing: Tokens.Space.l) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { switch activePage { case .engine: engineStatusCard case .automation: automationCard @@ -122,13 +122,11 @@ struct SystemContent: View { } } - /// A consistent flat-glass section card — every System section uses this so the panel reads as one - /// coherent surface instead of a mix of cards and bare headings. - private func card(@ViewBuilder _ content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: Tokens.Space.m) { content() } - .padding(Tokens.Space.s) - .frame(maxWidth: .infinity, alignment: .leading) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: elevated) + /// A consistent design-system section card. + private func card(@ViewBuilder _ content: @escaping () -> Content) -> some View { + DesignContentSurface(elevated: elevated, alignment: .leading) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.m) { content() } + } } private var header: some View { @@ -234,7 +232,7 @@ struct SystemContent: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, Tokens.Space.xs) } else { - VStack(spacing: 0) { + LazyVStack(spacing: 0) { ForEach(Array(volumeInventory.enumerated()), id: \.element.id) { index, entry in if index > 0 { Divider() } volumeRow(entry) diff --git a/Sources/Contained/History/ContainerHistoryTab.swift b/Sources/Contained/History/ContainerHistoryTab.swift index 668da187..b1018967 100644 --- a/Sources/Contained/History/ContainerHistoryTab.swift +++ b/Sources/Contained/History/ContainerHistoryTab.swift @@ -22,6 +22,7 @@ enum HistoryRange: String, CaseIterable, Identifiable { /// The "rewind" tab: persistent CPU / memory / network history for one container, plus its event /// log — the long-term counterpart to the live sparklines. Backed by SwiftData via `@Query`. struct ContainerHistoryTab: View { + @Environment(AppModel.self) private var app let snapshot: ContainerSnapshot @State private var range: HistoryRange = .day /// Window start, recomputed only when the range changes (not per render) so the windowed `@Query` @@ -30,14 +31,16 @@ struct ContainerHistoryTab: View { var body: some View { ContainerTabScaffold { - VStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { Picker("Range", selection: $range) { ForEach(HistoryRange.allCases) { Text($0.rawValue).tag($0) } } .pickerStyle(.segmented) .frame(maxWidth: 240) - ContainerHistoryWindow(containerID: snapshot.id, cutoff: cutoff) + ContainerHistoryWindow(snapshot: snapshot, + cutoff: cutoff, + normalization: app.statsNormalizationContext) } } .onChange(of: range) { _, newRange in cutoff = Date().addingTimeInterval(-newRange.seconds) } @@ -48,10 +51,15 @@ struct ContainerHistoryTab: View { /// into the SwiftData `@Query` predicates, so only the visible range is fetched — not the container's /// entire retained history (which an unbounded query then re-filtered on every render). private struct ContainerHistoryWindow: View { + private let snapshot: ContainerSnapshot + private let normalization: StatsNormalizationContext @Query private var samples: [MetricSample] @Query private var events: [EventRecord] - init(containerID: String, cutoff: Date) { + init(snapshot: ContainerSnapshot, cutoff: Date, normalization: StatsNormalizationContext) { + self.snapshot = snapshot + self.normalization = normalization + let containerID = snapshot.id _samples = Query(filter: #Predicate { $0.containerID == containerID && $0.timestamp >= cutoff }, sort: \MetricSample.timestamp) _events = Query(filter: #Predicate { $0.containerID == containerID && $0.timestamp >= cutoff }, @@ -59,24 +67,24 @@ private struct ContainerHistoryWindow: View { } var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.l) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { if samples.isEmpty { ContentUnavailableView("No history yet", systemImage: "chart.xyaxis.line", description: Text("Resource samples accumulate while the container runs.")) .frame(maxWidth: .infinity, minHeight: 200) } else { - chartCard("CPU", unit: "% of a core") { + chartCard("CPU", unit: percentUnit) { Chart(samples) { sample in LineMark(x: .value("Time", sample.timestamp), - y: .value("CPU", sample.cpuFraction * 100)) + y: .value("CPU", historyValue(.cpu, sample) * 100)) .foregroundStyle(Color.accentColor) .interpolationMethod(.monotone) } } - chartCard("Memory", unit: "MB") { + chartCard("Memory", unit: percentUnit) { Chart(samples) { sample in AreaMark(x: .value("Time", sample.timestamp), - y: .value("Memory", sample.memoryBytes / 1_048_576)) + y: .value("Memory", historyValue(.memory, sample) * 100)) .foregroundStyle(Color.accentColor.opacity(Tokens.Chart.areaOpacity)) } } @@ -95,7 +103,7 @@ private struct ContainerHistoryWindow: View { } if !events.isEmpty { - VStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { Text("Events").font(.headline) ForEach(events.prefix(50)) { event in EventRow(event: event) @@ -117,6 +125,24 @@ private struct ContainerHistoryWindow: View { .chartXAxis { AxisMarks(values: .automatic(desiredCount: Tokens.Chart.axisDesiredCount)) } } } + + private var percentUnit: String { + switch normalization.mode { + case .container: return "% of container" + case .machine: return "% of machine" + } + } + + private func historyValue(_ metric: GraphMetric, _ sample: MetricSample) -> Double { + metric.value(from: sample, + snapshot: snapshot, + normalization: normalization, + memoryFallbackBytes: memoryFallbackBytes) + } + + private var memoryFallbackBytes: UInt64 { + UInt64(max(0, samples.map(\.memoryBytes).max() ?? 0)) + } } /// One row in an event log (used by the history tab and the system Activity view). diff --git a/Sources/Contained/Migration/DowngradeDecisionView.swift b/Sources/Contained/Migration/DowngradeDecisionView.swift index f1ea93e7..76ae971b 100644 --- a/Sources/Contained/Migration/DowngradeDecisionView.swift +++ b/Sources/Contained/Migration/DowngradeDecisionView.swift @@ -8,7 +8,7 @@ struct DowngradeDecisionView: View { var onQuit: () -> Void var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.l) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { SheetHeader(title: "This data was created by a newer version", subtitle: "Stored schema \(schemaVersion), this app supports \(StateMigrator.currentSchemaVersion).", cancelHelp: "Quit", @@ -18,7 +18,7 @@ struct DowngradeDecisionView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - VStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { Button("Export Backup, Then Reset") { onExportAndReset() } .buttonStyle(.borderedProminent) Button("Try to Keep Readable Data") { onKeep() } diff --git a/Sources/Contained/Navigation/AppToolbar.swift b/Sources/Contained/Navigation/AppToolbar.swift index 421f5737..a85f1e11 100644 --- a/Sources/Contained/Navigation/AppToolbar.swift +++ b/Sources/Contained/Navigation/AppToolbar.swift @@ -470,7 +470,7 @@ struct AppToolbar: View { } private func currentToolbarImageGroup(_ group: LocalImageTagGroup) -> LocalImageTagGroup { - LocalImageTagGroup.groups(for: app.images).first { $0.id == group.id } ?? group + app.localImageGroups().first { $0.id == group.id } ?? group } private func openToolbarImageDetail(_ group: LocalImageTagGroup, sourceFrame: CGRect) { diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/Contained/Navigation/ClassicShell.swift index aaba3b74..edce10f6 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/Contained/Navigation/ClassicShell.swift @@ -435,6 +435,6 @@ private struct ImagesPage: View { } private func currentGroup(_ group: LocalImageTagGroup) -> LocalImageTagGroup { - LocalImageTagGroup.groups(for: app.images).first { $0.id == group.id } ?? group + app.localImageGroups().first { $0.id == group.id } ?? group } } diff --git a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift index 4ed4b396..63e86f08 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift @@ -114,8 +114,6 @@ struct PaletteResultCard: View { } widget: { if snapshot.state == .running { containerPaletteWidget(snapshot) - .padding(.horizontal, Tokens.ResourceCard.padding) - .padding(.bottom, Tokens.ResourceCard.padding) } } .selectionFill() diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift index baa4fbad..5961ba5f 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift @@ -236,33 +236,33 @@ struct ToolbarCommandPalette: View { if scope == .dockerHub { dockerHubPlaceholder } else { - ContentUnavailableView { - Label("No matches", systemImage: "magnifyingglass") - } description: { - Text("Try a setting, image, container, network, or action.") + DesignContentSurface(minHeight: 260) { + ContentUnavailableView { + Label("No matches", systemImage: "magnifyingglass") + } description: { + Text("Try a setting, image, container, network, or action.") + } } - .frame(maxWidth: .infinity, minHeight: 260) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) } } private var dockerHubPlaceholder: some View { - VStack(spacing: Tokens.Space.s) { - if hubSearching { - ProgressView() - Text("Searching Docker Hub…").font(.callout).foregroundStyle(.secondary) - } else if let hubError { - Image(systemName: "wifi.exclamationmark").font(.title2).foregroundStyle(.orange) - Text("Couldn't search Docker Hub").font(.callout.weight(.medium)) - Text(hubError).font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center) - } else { - Image(systemName: "magnifyingglass").font(.title2).foregroundStyle(.tertiary) - Text(trimmedQuery.isEmpty ? "Type to search Docker Hub" : "No images found for “\(trimmedQuery)”") - .font(.callout).foregroundStyle(.secondary) + DesignContentSurface(minHeight: 260) { + LazyVStack(spacing: Tokens.Space.s) { + if hubSearching { + ProgressView() + Text("Searching Docker Hub…").font(.callout).foregroundStyle(.secondary) + } else if let hubError { + Image(systemName: "wifi.exclamationmark").font(.title2).foregroundStyle(.orange) + Text("Couldn't search Docker Hub").font(.callout.weight(.medium)) + Text(hubError).font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center) + } else { + Image(systemName: "magnifyingglass").font(.title2).foregroundStyle(.tertiary) + Text(trimmedQuery.isEmpty ? "Type to search Docker Hub" : "No images found for “\(trimmedQuery)”") + .font(.callout).foregroundStyle(.secondary) + } } } - .frame(maxWidth: .infinity, minHeight: 260) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) } private var footerBar: some View { @@ -342,8 +342,7 @@ struct ToolbarCommandPalette: View { } private func localImageItems() -> [PaletteItem] { - let groups = LocalImageTagGroup.groups(for: app.images) - .sorted { $0.primaryReference.localizedCaseInsensitiveCompare($1.primaryReference) == .orderedAscending } + let groups = app.localImageGroups() let matched = trimmedQuery.isEmpty ? groups : groups.filter { PaletteSearch.score(query: trimmedQuery, in: $0.references + [Format.shortImage($0.primaryReference)]) != nil } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 162848fa..19c697c6 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -149,7 +149,7 @@ struct ToolbarImageGroupCard: View { ScrollView { LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { ForEach(Array(history.enumerated()), id: \.offset) { _, entry in - VStack(alignment: .leading, spacing: Tokens.Space.xxs) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.xxs) { Text(entry.createdBy ?? entry.comment ?? "—") .font(.system(.caption, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) @@ -172,7 +172,7 @@ struct ToolbarImageGroupCard: View { private func tagPage(_ source: String) -> some View { subPageScaffold(symbol: "tag", title: "Add tag", subtitle: Format.shortImage(source)) { - VStack(alignment: .leading, spacing: Tokens.Space.l) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { PanelSection { PanelField(label: "Source") { Text(Format.shortImage(source)).foregroundStyle(.secondary) @@ -292,11 +292,11 @@ struct ToolbarImageGroupCard: View { } footerAction("arrow.up.doc", help: "Save") { save(image) } } - footerAction("trash", help: "Prune", tint: .red) { pruning = true } + footerAction("trash", help: "Prune", role: .destructive) { pruning = true } } private func tagList(_ group: LocalImageTagGroup) -> some View { - VStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { Text("Tags") .font(.headline) .padding(.leading, Tokens.Space.xs) @@ -347,7 +347,7 @@ struct ToolbarImageGroupCard: View { } footerAction("doc.on.doc", help: "Copy reference") { copyToPasteboard(reference) } footerAction("doc.text.magnifyingglass", help: "Inspect") { inspect(reference, in: group) } - footerAction("trash", help: "Delete tag", tint: .red) { deletingReference = reference } + footerAction("trash", help: "Delete tag", role: .destructive) { deletingReference = reference } } .contextMenu { tagMenu(reference, in: group) } } @@ -364,18 +364,13 @@ struct ToolbarImageGroupCard: View { } private func footerAction(_ systemName: String, help: String, tint: Color? = nil, + role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { - Button(action: action) { - ResourceCardFooterMini { - Image(systemName: systemName).font(.body) - } text: { - EmptyView() - } - } - .buttonStyle(.plain) - .foregroundStyle(tint ?? .secondary) - .help(help) - .accessibilityLabel(help) + ResourceCardFooterButton(systemName: systemName, + help: help, + tint: tint, + role: role, + action: action) } @ViewBuilder diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift index a94751b1..6692d9a3 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift @@ -15,7 +15,7 @@ struct ToolbarUpdatesPanel: View { @State private var imageFrames: [LocalImageTagGroup.ID: CGRect] = [:] private var imageGroups: [LocalImageTagGroup] { - sortedImageGroups(LocalImageTagGroup.groups(for: app.images).filter(matchesFilter)) + sortedImageGroups(app.localImageGroups().filter(matchesFilter)) } private var imageSections: [(title: String, groups: [LocalImageTagGroup])] { @@ -34,7 +34,7 @@ struct ToolbarUpdatesPanel: View { } private var updateCount: Int { - LocalImageTagGroup.groups(for: app.images).filter { + app.localImageGroups().filter { app.imageUpdateStatus(for: $0.primaryReference).state == .updateAvailable }.count } diff --git a/Sources/Contained/Navigation/ToolbarViewOptions.swift b/Sources/Contained/Navigation/ToolbarViewOptions.swift index af4b6444..b968589d 100644 --- a/Sources/Contained/Navigation/ToolbarViewOptions.swift +++ b/Sources/Contained/Navigation/ToolbarViewOptions.swift @@ -72,7 +72,7 @@ struct ToolbarPageSwitcher: View { let running = app.containers.running.count return "\(total) container\(total == 1 ? "" : "s") · \(running) running" case .images: - let groups = LocalImageTagGroup.groups(for: app.images) + let groups = app.localImageGroups() let updates = groups.filter { app.imageUpdateStatus(for: $0.primaryReference).state == .updateAvailable }.count @@ -249,7 +249,7 @@ struct ToolbarPageContextOptions: View { } private var imagesSubtitle: String { - let groups = LocalImageTagGroup.groups(for: app.images) + let groups = app.localImageGroups() let updates = groups.filter { app.imageUpdateStatus(for: $0.primaryReference).state == .updateAvailable }.count return "\(groups.count) local · \(updates) update\(updates == 1 ? "" : "s")" } diff --git a/Sources/Contained/Stores/AppModel+Configuration.swift b/Sources/Contained/Stores/AppModel+Configuration.swift index b27f388f..7aface8a 100644 --- a/Sources/Contained/Stores/AppModel+Configuration.swift +++ b/Sources/Contained/Stores/AppModel+Configuration.swift @@ -75,6 +75,7 @@ extension AppModel { settings.applyBackup(try value.decode(SettingsBackup.self)) historyStore.retentionDays = settings.historyRetentionDays updater.channel = settings.updateChannel + applyStatsNormalizationContext() } if selected.contains(.personalization), let value = envelope.sections[.personalization] { personalization.applyBackup(try value.decode(PersonalizationBackup.self), replace: replace) diff --git a/Sources/Contained/Stores/AppModel+ResourceStyles.swift b/Sources/Contained/Stores/AppModel+ResourceStyles.swift index a7cf289b..e9341e1e 100644 --- a/Sources/Contained/Stores/AppModel+ResourceStyles.swift +++ b/Sources/Contained/Stores/AppModel+ResourceStyles.swift @@ -2,14 +2,21 @@ import Foundation import ContainedCore extension AppModel { + func localImageGroups() -> [LocalImageTagGroup] { + if let imageGroupsCache { + return imageGroupsCache + } + let groups = LocalImageTagGroup.groups(for: images) + imageGroupsCache = groups + return groups + } + var defaultImageStyle: Personalization { settings.imageDefaultStyleEnabled ? personalization.defaultImageStyle : Personalization() } func imageStyle(for reference: String) -> Personalization { - let groupID = LocalImageTagGroup.groups(for: images).first { group in - group.references.contains(reference) - }?.id + let groupID = imageGroupID(containing: reference) return personalization.imageDefault(for: reference, groupID: groupID) ?? defaultImageStyle } @@ -29,9 +36,7 @@ extension AppModel { } func containerStyle(for snapshot: ContainerSnapshot) -> Personalization { - let groupID = LocalImageTagGroup.groups(for: images).first { group in - group.references.contains(snapshot.image) - }?.id + let groupID = imageGroupID(containing: snapshot.image) return personalization.resolved(id: snapshot.id, image: snapshot.image, groupID: groupID, @@ -48,14 +53,16 @@ extension AppModel { /// Current block read/write rate for a volume, summed across every container mounting it. func volumeIORate(for name: String, metric: GraphMetric) -> Double { containersMounting(volume: name).reduce(0) { total, snapshot in - total + (containers.statsByID[snapshot.id].map { metric.value(from: $0) } ?? 0) + total + (containers.metricsState(for: snapshot.id).stats.map { + metric.value(from: $0, snapshot: snapshot, normalization: statsNormalizationContext) + } ?? 0) } } /// Read/write sparkline series for a volume. Series are right-aligned so recent samples line up. func volumeIOHistory(for name: String, metric: GraphMetric) -> [Double] { - let series = containersMounting(volume: name).compactMap { - containers.historyByID[$0.id]?[metric]?.values + let series = containersMounting(volume: name).compactMap { snapshot in + containers.metricsState(for: snapshot.id).historyByMetric[metric]?.values } return Self.sumRightAligned(series) } @@ -72,4 +79,17 @@ extension AppModel { } return result } + + private func imageGroupID(containing reference: String) -> String? { + if let cached = imageGroupIDByReferenceCache[reference] { + return cached + } + + for group in localImageGroups() { + for groupReference in group.references { + imageGroupIDByReferenceCache[groupReference] = group.id + } + } + return imageGroupIDByReferenceCache[reference] + } } diff --git a/Sources/Contained/Stores/AppModel.swift b/Sources/Contained/Stores/AppModel.swift index 2c792d44..6e7b662e 100644 --- a/Sources/Contained/Stores/AppModel.swift +++ b/Sources/Contained/Stores/AppModel.swift @@ -51,7 +51,14 @@ final class AppModel { private(set) var properties: SystemProperties? // `images`/`imagesError`/`imageUpdates` are written by both this file and the image-update sweep // in `AppModel+ImageUpdates.swift`, so their setters can't be `private(set)`. - var images: [ContainedCore.ImageResource] = [] + var images: [ContainedCore.ImageResource] = [] { + didSet { + imageGroupsCache = nil + imageGroupIDByReferenceCache.removeAll(keepingCapacity: true) + } + } + @ObservationIgnored var imageGroupsCache: [LocalImageTagGroup]? + @ObservationIgnored var imageGroupIDByReferenceCache: [String: String] = [:] var imagesError: String? var imageUpdates: [String: ImageUpdateStatus] = [:] { didSet { Self.saveImageUpdates(imageUpdates) } @@ -79,6 +86,14 @@ final class AppModel { var imageUpdateIntervalDescription: String { "Every \(settings.imageUpdateIntervalHours) hour\(settings.imageUpdateIntervalHours == 1 ? "" : "s")" } + var statsNormalizationContext: StatsNormalizationContext { + StatsNormalizationContext( + mode: settings.statsNormalizationMode, + machineCPUs: properties?.machine?.cpus ?? ProcessInfo.processInfo.activeProcessorCount, + machineMemoryBytes: Format.memoryBytes(fromSpec: properties?.machine?.memory) + ?? ProcessInfo.processInfo.physicalMemory + ) + } /// One in-flight operation shown in the bottom progress bar. struct ActivityState: Equatable { @@ -96,6 +111,7 @@ final class AppModel { historyStore.retentionDays = settings.historyRetentionDays updater.channel = settings.updateChannel updater.automaticallyChecks = settings.appUpdateChecksEnabled + applyStatsNormalizationContext() if case .newerOnDisk(let version) = migrator.reconcile() { downgradeSchemaVersion = version } @@ -187,6 +203,21 @@ final class AppModel { if visible { coordinator.wake() } } + func setStatsNormalizationMode(_ mode: StatsNormalizationMode) { + guard settings.statsNormalizationMode != mode else { return } + settings.statsNormalizationMode = mode + applyStatsNormalizationContext() + guard mode == .machine else { return } + Task { + await self.loadPropertiesIfNeeded() + self.applyStatsNormalizationContext() + } + } + + func applyStatsNormalizationContext() { + containers.configureStatsNormalization(statsNormalizationContext) + } + func refreshSystem() async { guard let client else { return } let started = Date() @@ -196,6 +227,8 @@ final class AppModel { if status.isRunning { bootstrap = .ready logger.record("Container service is running", category: .system, severity: .debug) + if settings.statsNormalizationMode == .machine { await loadPropertiesIfNeeded() } + applyStatsNormalizationContext() await refreshDiskUsage() // throttled; the System panel can force a fresh read await containers.refresh() updateContainerStatsStream() @@ -284,12 +317,6 @@ final class AppModel { } } - func refreshContainerStatsNow() async { - await containers.refresh() - updateContainerStatsStream() - recordFreshMetricsIfNeeded() - } - private func updateContainerStatsStream() { guard bootstrap == .ready, let client else { stopContainerStatsStream() @@ -376,7 +403,10 @@ final class AppModel { /// Force-reload the daemon's system properties (e.g. after a kernel change). func reloadProperties() async { guard let client, bootstrap == .ready else { return } - if let p = try? await client.systemProperties() { properties = p } + if let p = try? await client.systemProperties() { + properties = p + applyStatsNormalizationContext() + } } /// Refresh the cached volume list. Volumes live in the System panel, so this is exposed directly diff --git a/Sources/Contained/Stores/ContainersStore.swift b/Sources/Contained/Stores/ContainersStore.swift index 6778c9e8..26bc8d73 100644 --- a/Sources/Contained/Stores/ContainersStore.swift +++ b/Sources/Contained/Stores/ContainersStore.swift @@ -6,18 +6,57 @@ import ContainedCore /// Owns the container list and derived live stats. Lifecycle actions run through the client and /// trigger a refresh. Stats arrive from the app-wide runtime stream and are converted into deltas /// for cards, expanded panels, history, and restart/health context. +@MainActor +@Observable +final class ContainerMetricsState { + let id: String + var stats: StatsDelta? + var historyByMetric: [GraphMetric: SampleBuffer] + private(set) var revision = 0 + + init(id: String, stats: StatsDelta? = nil, historyByMetric: [GraphMetric: SampleBuffer] = [:]) { + self.id = id + self.stats = stats + self.historyByMetric = historyByMetric + } + + func values(for metric: GraphMetric) -> [Double] { + historyByMetric[metric]?.values ?? [] + } + + func update(stats: StatsDelta?, historyByMetric: [GraphMetric: SampleBuffer]) { + var changed = false + if self.stats != stats { + self.stats = stats + changed = true + } + if self.historyByMetric != historyByMetric { + self.historyByMetric = historyByMetric + changed = true + } + if changed { revision &+= 1 } + } +} + @MainActor @Observable final class ContainersStore { + private static let minimumStreamedStatsInterval: TimeInterval = 1 + var snapshots: [ContainerSnapshot] = [] + @ObservationIgnored var statsByID: [String: StatsDelta] = [:] /// Per-container, per-metric sparkline history. + @ObservationIgnored var historyByID: [String: [GraphMetric: SampleBuffer]] = [:] + @ObservationIgnored private(set) var statsRevision = 0 var errorMessage: String? var busyIDs: Set = [] @ObservationIgnored var logger: AppLogger? @ObservationIgnored var now: () -> Date = Date.init + @ObservationIgnored private var metricsStates: [String: ContainerMetricsState] = [:] + @ObservationIgnored private var statsNormalizationContext: StatsNormalizationContext = .containerSpecific var client: ContainerClient? @@ -35,6 +74,21 @@ final class ContainersStore { var running: [ContainerSnapshot] { snapshots.filter { $0.state == .running } } + func metricsState(for id: String) -> ContainerMetricsState { + if let state = metricsStates[id] { return state } + let state = ContainerMetricsState(id: id, + stats: statsByID[id], + historyByMetric: historyByID[id] ?? [:]) + metricsStates[id] = state + return state + } + + func configureStatsNormalization(_ context: StatsNormalizationContext) { + guard statsNormalizationContext != context else { return } + statsNormalizationContext = context + rebuildDisplayHistories() + } + /// True (consuming the flag) if the given container's last stop was user-initiated. func consumeIntentionalStop(_ id: String) -> Bool { intentionalStops.remove(id) != nil @@ -104,10 +158,21 @@ final class ContainersStore { } private func pruneStatsForCurrentRunningSet() { + let currentSet = Set(snapshots.map(\.id)) let runningSet = Set(running.map(\.id)) let prunedStats = statsByID.filter { runningSet.contains($0.key) } if prunedStats.count != statsByID.count { statsByID = prunedStats } + let prunedHistory = historyByID.filter { runningSet.contains($0.key) } + if prunedHistory.count != historyByID.count { historyByID = prunedHistory } lastStreamedStats = lastStreamedStats.filter { runningSet.contains($0.key) } + for (id, state) in metricsStates { + if runningSet.contains(id) { + state.update(stats: statsByID[id], historyByMetric: historyByID[id] ?? [:]) + } else { + state.update(stats: nil, historyByMetric: [:]) + } + } + metricsStates = metricsStates.filter { currentSet.contains($0.key) } guard !runningSet.isEmpty else { if !historyByID.isEmpty { historyByID.removeAll() } lastStreamedStatsDate = nil @@ -121,14 +186,17 @@ final class ContainersStore { guard !samples.isEmpty else { return } let observedAt = observedAt ?? now() - let interval = lastStreamedStatsDate.map { observedAt.timeIntervalSince($0) } ?? 1 + let rawInterval = lastStreamedStatsDate.map { observedAt.timeIntervalSince($0) } + let interval = max(rawInterval ?? Self.minimumStreamedStatsInterval, Self.minimumStreamedStatsInterval) + let snapshotsByID = Dictionary(snapshots.map { ($0.id, $0) }, uniquingKeysWith: { current, _ in current }) var nextStats = statsByID var nextHistory = historyByID for sample in samples { let delta = StatsDelta.from(snapshot: sample, previous: lastStreamedStats[sample.id], interval: interval) - record(delta, stats: &nextStats, history: &nextHistory) + record(delta, snapshot: snapshotsByID[sample.id], stats: &nextStats, history: &nextHistory) + metricsStates[sample.id]?.update(stats: delta, historyByMetric: nextHistory[sample.id] ?? [:]) lastStreamedStats[sample.id] = sample } @@ -139,18 +207,44 @@ final class ContainersStore { } private func record(_ delta: StatsDelta, + snapshot: ContainerSnapshot?, stats: inout [String: StatsDelta], history: inout [String: [GraphMetric: SampleBuffer]]) { stats[delta.id] = delta var metrics = history[delta.id] ?? [:] for metric in GraphMetric.allCases { var buffer = metrics[metric] ?? SampleBuffer() - buffer.append(metric.value(from: delta)) + buffer.append(metric.value(from: delta, snapshot: snapshot, normalization: statsNormalizationContext)) metrics[metric] = buffer } history[delta.id] = metrics } + private func rebuildDisplayHistories() { + let snapshotsByID = Dictionary(snapshots.map { ($0.id, $0) }, uniquingKeysWith: { current, _ in current }) + let runningSet = Set(running.map(\.id)) + var rebuilt: [String: [GraphMetric: SampleBuffer]] = [:] + for (id, delta) in statsByID where runningSet.contains(id) { + var metrics: [GraphMetric: SampleBuffer] = [:] + for metric in GraphMetric.allCases { + var buffer = SampleBuffer() + buffer.append(metric.value(from: delta, + snapshot: snapshotsByID[id], + normalization: statsNormalizationContext)) + metrics[metric] = buffer + } + rebuilt[id] = metrics + } + historyByID = rebuilt + for (id, state) in metricsStates { + if runningSet.contains(id) { + state.update(stats: statsByID[id], historyByMetric: rebuilt[id] ?? [:]) + } else { + state.update(stats: nil, historyByMetric: [:]) + } + } + } + // MARK: Lifecycle func start(_ id: String) async { await act(id, verb: "Start") { try await $0.start([id]) } } diff --git a/Sources/Contained/Stores/SettingsBackup.swift b/Sources/Contained/Stores/SettingsBackup.swift index 5d5ed87a..27e2f004 100644 --- a/Sources/Contained/Stores/SettingsBackup.swift +++ b/Sources/Contained/Stores/SettingsBackup.swift @@ -21,6 +21,7 @@ struct SettingsBackup: Codable, Equatable { var keepInMenuBar: Bool var cliPathOverride: String var refreshInterval: Double + var statsNormalizationMode: StatsNormalizationMode var imageUpdateIntervalHours: Int var imageUpdateChecksEnabled: Bool var appUpdateChecksEnabled: Bool @@ -47,7 +48,7 @@ struct SettingsBackup: Codable, Equatable { case buttonTintBlendMode case cardMaterial case showInfoTips, imageDefaultStyleEnabled, keepInMenuBar, cliPathOverride, refreshInterval - case imageUpdateIntervalHours, imageUpdateChecksEnabled, appUpdateChecksEnabled, autoRestartEnabled + case statsNormalizationMode, imageUpdateIntervalHours, imageUpdateChecksEnabled, appUpdateChecksEnabled, autoRestartEnabled case notifyOnCrash, revealCLI, historyRetentionDays, loggingLevel, enabledLogDestinations case enabledLogCategories, updateChannel, commandPaletteEnabled, hubSearchEnabled case composeImportEnabled, imageBuildEnabled, keyboardShortcutsEnabled, experimentalToolbarUI @@ -69,10 +70,11 @@ struct SettingsBackup: Codable, Equatable { cardMaterial: WindowMaterial, showInfoTips: Bool, imageDefaultStyleEnabled: Bool, - keepInMenuBar: Bool, - cliPathOverride: String, - refreshInterval: Double, - imageUpdateIntervalHours: Int, + keepInMenuBar: Bool, + cliPathOverride: String, + refreshInterval: Double, + statsNormalizationMode: StatsNormalizationMode = .container, + imageUpdateIntervalHours: Int, imageUpdateChecksEnabled: Bool, appUpdateChecksEnabled: Bool, autoRestartEnabled: Bool, @@ -109,6 +111,7 @@ struct SettingsBackup: Codable, Equatable { self.keepInMenuBar = keepInMenuBar self.cliPathOverride = cliPathOverride self.refreshInterval = refreshInterval + self.statsNormalizationMode = statsNormalizationMode self.imageUpdateIntervalHours = imageUpdateIntervalHours self.imageUpdateChecksEnabled = imageUpdateChecksEnabled self.appUpdateChecksEnabled = appUpdateChecksEnabled @@ -152,6 +155,7 @@ struct SettingsBackup: Codable, Equatable { keepInMenuBar = try container.decodeIfPresent(Bool.self, forKey: .keepInMenuBar) ?? true cliPathOverride = try container.decodeIfPresent(String.self, forKey: .cliPathOverride) ?? "" refreshInterval = try container.decodeIfPresent(Double.self, forKey: .refreshInterval) ?? 2 + statsNormalizationMode = try container.decodeIfPresent(StatsNormalizationMode.self, forKey: .statsNormalizationMode) ?? .container imageUpdateIntervalHours = try container.decodeIfPresent(Int.self, forKey: .imageUpdateIntervalHours) ?? 6 imageUpdateChecksEnabled = try container.decodeIfPresent(Bool.self, forKey: .imageUpdateChecksEnabled) ?? true appUpdateChecksEnabled = try container.decodeIfPresent(Bool.self, forKey: .appUpdateChecksEnabled) ?? true @@ -196,6 +200,7 @@ struct SettingsBackup: Codable, Equatable { try container.encode(keepInMenuBar, forKey: .keepInMenuBar) try container.encode(cliPathOverride, forKey: .cliPathOverride) try container.encode(refreshInterval, forKey: .refreshInterval) + try container.encode(statsNormalizationMode, forKey: .statsNormalizationMode) try container.encode(imageUpdateIntervalHours, forKey: .imageUpdateIntervalHours) try container.encode(imageUpdateChecksEnabled, forKey: .imageUpdateChecksEnabled) try container.encode(appUpdateChecksEnabled, forKey: .appUpdateChecksEnabled) diff --git a/Sources/Contained/Stores/SettingsStore.swift b/Sources/Contained/Stores/SettingsStore.swift index f2f10d45..73c09266 100644 --- a/Sources/Contained/Stores/SettingsStore.swift +++ b/Sources/Contained/Stores/SettingsStore.swift @@ -32,6 +32,9 @@ final class SettingsStore { var keepInMenuBar: Bool { didSet { defaults.set(keepInMenuBar, forKey: Keys.keepInMenuBar) } } var cliPathOverride: String { didSet { defaults.set(cliPathOverride, forKey: Keys.cliPath) } } var refreshInterval: Double { didSet { defaults.set(refreshInterval, forKey: Keys.refresh) } } + var statsNormalizationMode: StatsNormalizationMode { + didSet { defaults.set(statsNormalizationMode.rawValue, forKey: Keys.statsNormalizationMode) } + } var imageUpdateIntervalHours: Int { didSet { defaults.set(imageUpdateIntervalHours, forKey: Keys.imageUpdateIntervalHours) } } /// Automation toggles (surfaced in System → Automation). Each gates a background task. var imageUpdateChecksEnabled: Bool { didSet { defaults.set(imageUpdateChecksEnabled, forKey: Keys.imageUpdateChecksEnabled) } } @@ -117,6 +120,7 @@ final class SettingsStore { keepInMenuBar = defaults.object(forKey: Keys.keepInMenuBar) as? Bool ?? true cliPathOverride = defaults.string(forKey: Keys.cliPath) ?? "" refreshInterval = defaults.object(forKey: Keys.refresh) as? Double ?? 2.0 + statsNormalizationMode = StatsNormalizationMode(rawValue: defaults.string(forKey: Keys.statsNormalizationMode) ?? "") ?? .container imageUpdateIntervalHours = defaults.object(forKey: Keys.imageUpdateIntervalHours) as? Int ?? 6 imageUpdateChecksEnabled = defaults.object(forKey: Keys.imageUpdateChecksEnabled) as? Bool ?? true appUpdateChecksEnabled = defaults.object(forKey: Keys.appUpdateChecksEnabled) as? Bool ?? true @@ -165,9 +169,10 @@ final class SettingsStore { showInfoTips: showInfoTips, imageDefaultStyleEnabled: imageDefaultStyleEnabled, keepInMenuBar: keepInMenuBar, - cliPathOverride: cliPathOverride, - refreshInterval: refreshInterval, - imageUpdateIntervalHours: imageUpdateIntervalHours, + cliPathOverride: cliPathOverride, + refreshInterval: refreshInterval, + statsNormalizationMode: statsNormalizationMode, + imageUpdateIntervalHours: imageUpdateIntervalHours, imageUpdateChecksEnabled: imageUpdateChecksEnabled, appUpdateChecksEnabled: appUpdateChecksEnabled, autoRestartEnabled: autoRestartEnabled, @@ -207,6 +212,7 @@ final class SettingsStore { keepInMenuBar = snapshot.keepInMenuBar cliPathOverride = snapshot.cliPathOverride refreshInterval = snapshot.refreshInterval + statsNormalizationMode = snapshot.statsNormalizationMode imageUpdateIntervalHours = snapshot.imageUpdateIntervalHours imageUpdateChecksEnabled = snapshot.imageUpdateChecksEnabled appUpdateChecksEnabled = snapshot.appUpdateChecksEnabled @@ -255,6 +261,7 @@ final class SettingsStore { static let keepInMenuBar = "keepInMenuBar" static let cliPath = "cliPathOverride" static let refresh = "refreshInterval" + static let statsNormalizationMode = "statsNormalizationMode" static let imageUpdateIntervalHours = "imageUpdateIntervalHours" static let imageUpdateChecksEnabled = "imageUpdateChecksEnabled" static let appUpdateChecksEnabled = "appUpdateChecksEnabled" diff --git a/Sources/Contained/Support/Formatting.swift b/Sources/Contained/Support/Formatting.swift index abe96e6f..efbd0c20 100644 --- a/Sources/Contained/Support/Formatting.swift +++ b/Sources/Contained/Support/Formatting.swift @@ -24,7 +24,35 @@ enum Format { return String(format: "%.0f", value) } static func percent(_ fraction: Double) -> String { - fraction.formatted(.percent.precision(.fractionLength(0))) + adaptivePercent(fraction) + } + /// A tighter percent readout for small card chips. Kept as a named alias for callers that want + /// to document tight UI, while sharing the same adaptive precision as normal percent labels. + static func compactPercent(_ fraction: Double) -> String { + adaptivePercent(fraction) + } + + private static func adaptivePercent(_ fraction: Double) -> String { + guard fraction.isFinite else { return "0%" } + let percent = max(0, fraction * 100) + if percent == 0 { return "0%" } + if percent < 0.01 { return "<0.01%" } + let maximumFractionDigits = percent < 1 ? 2 : 0 + return "\(trimmedDecimal(percent, maximumFractionDigits: maximumFractionDigits))%" + } + + private static func trimmedDecimal(_ value: Double, maximumFractionDigits: Int) -> String { + if maximumFractionDigits == 0 { + return String(Int(value.rounded())) + } + var text = String(format: "%.\(maximumFractionDigits)f", value) + while text.contains(".") && text.last == "0" { + text.removeLast() + } + if text.last == "." { + text.removeLast() + } + return text } /// Compact relative uptime, e.g. "2h", "6d", "just now". @@ -49,6 +77,40 @@ enum Format { return "\(bytes)" } + static func memoryBytes(fromSpec spec: String?) -> UInt64? { + guard let spec else { return nil } + let trimmed = spec.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + var number = "" + var unit = "" + for character in trimmed { + if character.isNumber || character == "." { + number.append(character) + } else if !character.isWhitespace { + unit.append(character) + } + } + guard let value = Double(number), value >= 0 else { return nil } + + let multiplier: Double + switch unit.lowercased() { + case "", "b", "byte", "bytes": + multiplier = 1 + case "k", "kb", "kib": + multiplier = 1_024 + case "m", "mb", "mib": + multiplier = 1_024 * 1_024 + case "g", "gb", "gib": + multiplier = 1_024 * 1_024 * 1_024 + case "t", "tb", "tib": + multiplier = 1_024 * 1_024 * 1_024 * 1_024 + default: + return nil + } + return UInt64((value * multiplier).rounded()) + } + /// Strip the registry/namespace for a compact image label, keeping repo:tag. static func shortImage(_ reference: String) -> String { reference.replacingOccurrences(of: "docker.io/library/", with: "") diff --git a/Sources/Contained/Support/WidgetConfiguration.swift b/Sources/Contained/Support/WidgetConfiguration.swift index 9b75f3ed..1811d666 100644 --- a/Sources/Contained/Support/WidgetConfiguration.swift +++ b/Sources/Contained/Support/WidgetConfiguration.swift @@ -2,7 +2,7 @@ import AppKit import ContainedDesignSystem struct WidgetConfiguration: Codable, Hashable, Sendable { - static let schemaVersion = 4 + static let schemaVersion = 5 var schemaVersion: Int = Self.schemaVersion var enabled: Bool = true @@ -12,7 +12,7 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { var icon: String = "" var style: GraphStyle = .area var areaUsesGradient = true - var interpolation: WidgetInterpolation = .catmullRom + var interpolation: WidgetInterpolation = .linear var lineWidth: Double = 1.5 var pointSize: Double = 18 var barWidth: Double = 4 @@ -33,7 +33,7 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { icon: String = "", style: GraphStyle = .area, areaUsesGradient: Bool = true, - interpolation: WidgetInterpolation = .catmullRom, + interpolation: WidgetInterpolation = .linear, lineWidth: Double = 1.5, pointSize: Double = 18, barWidth: Double = 4, @@ -57,7 +57,8 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 0 + let decodedSchemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 0 + schemaVersion = decodedSchemaVersion enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true metric = try container.decodeIfPresent(GraphMetric.self, forKey: .metric) ?? .cpu secondaryMetric = try container.decodeIfPresent(GraphMetric.self, forKey: .secondaryMetric) @@ -65,7 +66,11 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { icon = try container.decodeIfPresent(String.self, forKey: .icon) ?? "" style = try container.decodeIfPresent(GraphStyle.self, forKey: .style) ?? .area areaUsesGradient = try container.decodeIfPresent(Bool.self, forKey: .areaUsesGradient) ?? true - interpolation = try container.decodeIfPresent(WidgetInterpolation.self, forKey: .interpolation) ?? .catmullRom + let decodedInterpolation = try container.decodeIfPresent(WidgetInterpolation.self, forKey: .interpolation) + interpolation = decodedInterpolation ?? .linear + if decodedSchemaVersion < 5, interpolation == .catmullRom { + interpolation = .linear + } lineWidth = try container.decodeIfPresent(Double.self, forKey: .lineWidth) ?? 1.5 pointSize = try container.decodeIfPresent(Double.self, forKey: .pointSize) ?? 18 barWidth = try container.decodeIfPresent(Double.self, forKey: .barWidth) ?? 4 diff --git a/Sources/ContainedCore/Models/SystemProperties.swift b/Sources/ContainedCore/Models/SystemProperties.swift index cf740a93..ac5e23a9 100644 --- a/Sources/ContainedCore/Models/SystemProperties.swift +++ b/Sources/ContainedCore/Models/SystemProperties.swift @@ -5,6 +5,7 @@ import Foundation public struct SystemProperties: Codable, Sendable, Hashable { public let build: Build? public let container: Defaults? + public let machine: Defaults? public let kernel: Kernel? public struct Build: Codable, Sendable, Hashable { @@ -26,6 +27,7 @@ public struct SystemProperties: Codable, Sendable, Hashable { let c = try decoder.container(keyedBy: CodingKeys.self) build = try c.decodeIfPresent(Build.self, forKey: .build) container = try c.decodeIfPresent(Defaults.self, forKey: .container) + machine = try c.decodeIfPresent(Defaults.self, forKey: .machine) kernel = try c.decodeIfPresent(Kernel.self, forKey: .kernel) } } diff --git a/Tests/ContainedAppTests/AppLoggingTests.swift b/Tests/ContainedAppTests/AppLoggingTests.swift index 85a8ce6f..a4cef5e3 100644 --- a/Tests/ContainedAppTests/AppLoggingTests.swift +++ b/Tests/ContainedAppTests/AppLoggingTests.swift @@ -33,6 +33,18 @@ struct AppLoggingTests { #expect(reloaded.loggingLevel.includes(.debug)) } + @Test func statsNormalizationSettingPersistsRoundTrip() { + let defaults = suiteDefaults() + var settings: SettingsStore? = SettingsStore(defaults: defaults) + #expect(settings?.statsNormalizationMode == .container) + + settings?.statsNormalizationMode = .machine + settings = nil + + let reloaded = SettingsStore(defaults: defaults) + #expect(reloaded.statsNormalizationMode == .machine) + } + @Test func errorOnlyLoggingFiltersLowerSeverity() { #expect(AppLogLevel.errors.includes(.error)) #expect(!AppLogLevel.errors.includes(.warning)) diff --git a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift index b4282fa2..7be1b36a 100644 --- a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift +++ b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift @@ -25,33 +25,271 @@ struct ContainersStoreRefreshTests { store.now = { clock.date } await store.refresh() + let metrics = store.metricsState(for: "fixture-web") store.applyStreamedStats([Self.streamedStats(cpuCoreFraction: 0.1, networkRxBytes: 10_000)], observedAt: clock.date) #expect(store.statsRevision == 1) + #expect(metrics.revision == 1) clock.advance(by: 2) store.applyStreamedStats([Self.streamedStats(cpuCoreFraction: 0.2, networkRxBytes: 11_000)], observedAt: clock.date) #expect(store.statsByID["fixture-web"]?.netRxBytesPerSec == 500) + #expect(metrics.stats?.netRxBytesPerSec == 500) + #expect(metrics.values(for: .netRx).last == 500) #expect(store.statsRevision == 2) + #expect(metrics.revision == 2) clock.advance(by: 2) store.applyStreamedStats([Self.streamedStats(cpuCoreFraction: 0.3, networkRxBytes: 12_500)], observedAt: clock.date) #expect(store.statsByID["fixture-web"]?.netRxBytesPerSec == 750) + #expect(metrics.stats?.netRxBytesPerSec == 750) + #expect(metrics.values(for: .netRx).last == 750) #expect(store.statsRevision == 3) + #expect(metrics.revision == 3) #expect(await runner.count(firstArgument: "stats") == 0) } + @Test func streamedStatsClampTinyIntervalsForCounterRates() async { + let runner = RecordingRunner() + let store = ContainersStore() + store.client = ContainerClient(runner: runner) + let clock = TestClock(Date(timeIntervalSinceReferenceDate: 1_000)) + store.now = { clock.date } + + await store.refresh() + store.applyStreamedStats([Self.streamedStats(cpuCoreFraction: 0.1, networkRxBytes: 10_000)], observedAt: clock.date) + + clock.advance(by: 0.05) + store.applyStreamedStats([Self.streamedStats(cpuCoreFraction: 0.1, networkRxBytes: 11_000)], observedAt: clock.date) + + #expect(store.statsByID["fixture-web"]?.netRxBytesPerSec == 1_000) + #expect(store.metricsState(for: "fixture-web").values(for: .netRx).last == 1_000) + } + + @Test func streamedStatsKeepHistoriesKeyedByContainerAndMetric() { + let store = ContainersStore() + let start = Date(timeIntervalSinceReferenceDate: 1_000) + store.snapshots = [ + Self.snapshot(id: "web", cpus: 2, memoryInBytes: 1_000), + Self.snapshot(id: "db", cpus: 4, memoryInBytes: 2_000) + ] + let webMetrics = store.metricsState(for: "web") + let dbMetrics = store.metricsState(for: "db") + + store.applyStreamedStats([ + Self.streamedStats(id: "web", cpuCoreFraction: 0.5, memoryUsageBytes: 100, + memoryLimitBytes: 1_000, networkRxBytes: 1_000), + Self.streamedStats(id: "db", cpuCoreFraction: 1.0, memoryUsageBytes: 400, + memoryLimitBytes: 1_000, networkRxBytes: 2_000) + ], observedAt: start) + store.applyStreamedStats([ + Self.streamedStats(id: "web", cpuCoreFraction: 1.0, memoryUsageBytes: 500, + memoryLimitBytes: 1_000, networkRxBytes: 3_000), + Self.streamedStats(id: "db", cpuCoreFraction: 1.0, memoryUsageBytes: 500, + memoryLimitBytes: 1_000, networkRxBytes: 2_600) + ], observedAt: start.addingTimeInterval(2)) + + #expect(webMetrics.stats?.id == "web") + #expect(dbMetrics.stats?.id == "db") + #expect(webMetrics.values(for: .cpu).last == 0.5) + #expect(dbMetrics.values(for: .cpu).last == 0.25) + #expect(webMetrics.values(for: .memory).last == 0.5) + #expect(dbMetrics.values(for: .memory).last == 0.25) + #expect(webMetrics.values(for: .netRx).last == 1_000) + #expect(dbMetrics.values(for: .netRx).last == 300) + } + + @Test func graphMetricCaptionsUseContainerResourceLimits() { + let snapshot = Self.snapshot(id: "web", cpus: 4, memoryInBytes: 1_024) + let delta = StatsDelta(id: "web", + cpuCoreFraction: 1, + memoryUsageBytes: 512, + memoryLimitBytes: 2_048, + netRxBytesPerSec: 10, + netTxBytesPerSec: 20, + blockReadBytesPerSec: 30, + blockWriteBytesPerSec: 40, + numProcesses: 2) + + #expect(GraphMetric.cpu.value(from: delta, snapshot: snapshot) == 0.25) + #expect(GraphMetric.memory.value(from: delta, snapshot: snapshot) == 0.5) + #expect(GraphMetric.netRx.value(from: delta, snapshot: snapshot) == 10) + #expect(GraphMetric.netTx.value(from: delta, snapshot: snapshot) == 20) + #expect(GraphMetric.diskRead.value(from: delta, snapshot: snapshot) == 30) + #expect(GraphMetric.diskWrite.value(from: delta, snapshot: snapshot) == 40) + #expect(GraphMetric.cpu.caption(from: delta, snapshot: snapshot) == "25%") + #expect(GraphMetric.memory.caption(from: delta, snapshot: snapshot) == "50%") + #expect(GraphMetric.cpu.chipCaption(from: delta, snapshot: snapshot) == "25%") + #expect(GraphMetric.memory.chipCaption(from: delta, snapshot: snapshot) == "50%") + #expect(GraphMetric.memoryLimitBytes(for: delta, snapshot: snapshot) == 1_024) + } + + @Test func percentFormattingUsesDecimalsOnlyWhenUseful() { + #expect(Format.percent(0) == "0%") + #expect(Format.percent(0.00003) == "<0.01%") + #expect(Format.percent(0.0003) == "0.03%") + #expect(Format.percent(0.004) == "0.4%") + #expect(Format.percent(0.0125) == "1%") + #expect(Format.percent(0.125) == "13%") + #expect(Format.percent(0.25) == "25%") + } + + @Test func graphMetricChipCaptionsExposeSmallPercentChanges() { + let snapshot = Self.snapshot(id: "web", cpus: 8, memoryInBytes: 1_000_000) + let delta = StatsDelta(id: "web", + cpuCoreFraction: 0.032, + memoryUsageBytes: 4_000, + memoryLimitBytes: 1_000_000, + netRxBytesPerSec: 10, + netTxBytesPerSec: 20, + blockReadBytesPerSec: 30, + blockWriteBytesPerSec: 40, + numProcesses: 2) + + #expect(GraphMetric.cpu.caption(from: delta, snapshot: snapshot) == "0.4%") + #expect(GraphMetric.cpu.chipCaption(from: delta, snapshot: snapshot) == "0.4%") + #expect(GraphMetric.memory.chipCaption(from: delta, snapshot: snapshot) == "0.4%") + + let machine = StatsNormalizationContext(mode: .machine, + machineCPUs: 16, + machineMemoryBytes: 2_000_000) + #expect(GraphMetric.cpu.chipCaption(from: delta, + snapshot: snapshot, + normalization: machine) == "0.2%") + #expect(GraphMetric.memory.chipCaption(from: delta, + snapshot: snapshot, + normalization: machine) == "0.2%") + } + + @Test func graphMetricCaptionsKeepTinyContainerCPUVisible() { + let snapshot = Self.snapshot(id: "web", cpus: 4, memoryInBytes: 1_000_000) + let delta = StatsDelta(id: "web", + cpuCoreFraction: 0.0012, + memoryUsageBytes: 4_000, + memoryLimitBytes: 1_000_000, + netRxBytesPerSec: 10, + netTxBytesPerSec: 20, + blockReadBytesPerSec: 30, + blockWriteBytesPerSec: 40, + numProcesses: 2) + + #expect(GraphMetric.cpu.value(from: delta, snapshot: snapshot) == 0.0003) + #expect(GraphMetric.cpu.caption(from: delta, snapshot: snapshot) == "0.03%") + #expect(GraphMetric.cpu.chipCaption(from: delta, snapshot: snapshot) == "0.03%") + } + + @Test func graphMetricCaptionsCanUseMachineResourceLimits() { + let snapshot = Self.snapshot(id: "web", cpus: 4, memoryInBytes: 1_024) + let normalization = StatsNormalizationContext(mode: .machine, + machineCPUs: 8, + machineMemoryBytes: 4_096) + let delta = StatsDelta(id: "web", + cpuCoreFraction: 1, + memoryUsageBytes: 512, + memoryLimitBytes: 2_048, + netRxBytesPerSec: 10, + netTxBytesPerSec: 20, + blockReadBytesPerSec: 30, + blockWriteBytesPerSec: 40, + numProcesses: 2) + + #expect(GraphMetric.cpu.value(from: delta, snapshot: snapshot, normalization: normalization) == 0.125) + #expect(GraphMetric.memory.value(from: delta, snapshot: snapshot, normalization: normalization) == 0.125) + #expect(GraphMetric.cpu.caption(from: delta, snapshot: snapshot, normalization: normalization) == "13%") + #expect(GraphMetric.memory.caption(from: delta, snapshot: snapshot, normalization: normalization) == "13%") + #expect(GraphMetric.memoryLimitBytes(for: delta, snapshot: snapshot, normalization: normalization) == 4_096) + } + + @Test func graphMetricHistoryValuesUseCurrentNormalization() { + let snapshot = Self.snapshot(id: "web", cpus: 4, memoryInBytes: 1_024) + let sample = MetricSample(timestamp: Date(timeIntervalSinceReferenceDate: 1_000), + containerID: "web", + cpuFraction: 1, + memoryBytes: 512, + netRxBytesPerSec: 10, + netTxBytesPerSec: 20, + diskReadBytesPerSec: 30, + diskWriteBytesPerSec: 40) + + #expect(GraphMetric.cpu.value(from: sample, snapshot: snapshot) == 0.25) + #expect(GraphMetric.memory.value(from: sample, snapshot: snapshot, memoryFallbackBytes: 2_048) == 0.5) + #expect(GraphMetric.netRx.value(from: sample, snapshot: snapshot) == 10) + #expect(GraphMetric.diskWrite.value(from: sample, snapshot: snapshot) == 40) + + let machine = StatsNormalizationContext(mode: .machine, + machineCPUs: 8, + machineMemoryBytes: 4_096) + #expect(GraphMetric.cpu.value(from: sample, snapshot: snapshot, normalization: machine) == 0.125) + #expect(GraphMetric.memory.value(from: sample, + snapshot: snapshot, + normalization: machine, + memoryFallbackBytes: 2_048) == 0.125) + } + + @Test func changingStatsNormalizationRebuildsDisplayHistories() { + let store = ContainersStore() + let start = Date(timeIntervalSinceReferenceDate: 1_000) + store.snapshots = [ + Self.snapshot(id: "web", cpus: 2, memoryInBytes: 1_000) + ] + let metrics = store.metricsState(for: "web") + + store.applyStreamedStats([ + Self.streamedStats(id: "web", cpuCoreFraction: 1, memoryUsageBytes: 500, + memoryLimitBytes: 1_000, networkRxBytes: 1_000) + ], observedAt: start) + #expect(metrics.values(for: .cpu).last == 0.5) + #expect(metrics.values(for: .memory).last == 0.5) + + store.configureStatsNormalization(StatsNormalizationContext(mode: .machine, + machineCPUs: 4, + machineMemoryBytes: 2_000)) + + #expect(metrics.values(for: .cpu) == [0.25]) + #expect(metrics.values(for: .memory) == [0.25]) + } + private static func streamedStats(cpuCoreFraction: Double, networkRxBytes: UInt64) -> RuntimeStatsSnapshot { - RuntimeStatsSnapshot(id: "fixture-web", + streamedStats(id: "fixture-web", + cpuCoreFraction: cpuCoreFraction, + memoryUsageBytes: 2_322_432, + networkRxBytes: networkRxBytes) + } + + private static func streamedStats(id: String, + cpuCoreFraction: Double, + memoryUsageBytes: UInt64, + memoryLimitBytes: UInt64 = 1_073_741_824, + networkRxBytes: UInt64) -> RuntimeStatsSnapshot { + RuntimeStatsSnapshot(id: id, cpuCoreFraction: cpuCoreFraction, - memoryUsageBytes: 2_322_432, - memoryLimitBytes: 1_073_741_824, + memoryUsageBytes: memoryUsageBytes, + memoryLimitBytes: memoryLimitBytes, blockReadBytes: 2_154_496, blockWriteBytes: 0, networkRxBytes: networkRxBytes, networkTxBytes: 516, numProcesses: 1) } + + private static func snapshot(id: String, cpus: Int, memoryInBytes: UInt64) -> ContainerSnapshot { + let payload = """ + { + "configuration": { + "id": "\(id)", + "image": { "reference": "docker.io/library/alpine:latest" }, + "initProcess": {}, + "resources": { + "cpus": \(cpus), + "memoryInBytes": \(memoryInBytes) + } + }, + "id": "\(id)", + "status": { "state": "running" } + } + """ + return try! JSONDecoder().decode(ContainerSnapshot.self, from: Data(payload.utf8)) + } } private final class TestClock { diff --git a/Tests/ContainedCoreTests/DecodingTests.swift b/Tests/ContainedCoreTests/DecodingTests.swift index 0ea11e54..177a5a4d 100644 --- a/Tests/ContainedCoreTests/DecodingTests.swift +++ b/Tests/ContainedCoreTests/DecodingTests.swift @@ -76,6 +76,22 @@ struct DecodingTests { #expect(status.apiServerVersion?.contains("1.0.0") == true) } + @Test func decodesSystemPropertiesMachineResources() throws { + let data = Data(""" + { + "container": { "cpus": 4, "memory": "1gb" }, + "machine": { "cpus": 5, "memory": "8gb" }, + "build": { "cpus": 2, "memory": "2048mb" } + } + """.utf8) + let properties = try ContainerJSON.decode(SystemProperties.self, from: data) + + #expect(properties.container?.cpus == 4) + #expect(properties.container?.memory == "1gb") + #expect(properties.machine?.cpus == 5) + #expect(properties.machine?.memory == "8gb") + } + @Test func decodesNetworks() throws { let nets = try ContainerJSON.decode([NetworkResource].self, from: try Fixture.data("networks")) let def = try #require(nets.first) diff --git a/Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift b/Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift new file mode 100644 index 00000000..0bfc46d8 --- /dev/null +++ b/Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import ContainedDesignSystem + +@Suite("Live sparkline scaling") +struct LiveSparklineScalingTests { + @Test func paddedWindowSanitizesInvalidSamples() { + let window = SparklineSeriesScaling.paddedWindow([.nan, -1, .infinity, 2], capacity: 6) + + #expect(window == [0, 0, 0, 0, 0, 2]) + } + + @Test func normalizationClipsOneSampleOutliersWithoutFlatteningTheWindow() { + let values = Array(repeating: 10.0, count: 23) + [1_000] + let normalized = SparklineSeriesScaling.normalized(values) + + #expect(normalized.dropLast().allSatisfy { $0 > 0.7 }) + #expect(normalized.last == 1) + } + + @Test func normalizationKeepsEmptySeriesFlat() { + let normalized = SparklineSeriesScaling.normalized(Array(repeating: 0, count: 24)) + + #expect(normalized.allSatisfy { $0 == 0 }) + } + + @Test func fractionScalePreservesAbsolutePercentShape() { + let low = SparklineSeriesScaling.scaled([0.2], mode: .fraction) + let high = SparklineSeriesScaling.scaled([0.5], mode: .fraction) + + #expect(low == [0.2]) + #expect(high == [0.5]) + } + + @Test func normalizedScaleStillExpandsSmallRateSeries() { + let scaled = SparklineSeriesScaling.scaled([2, 4], mode: .normalized) + + #expect(scaled[0] == 0.5) + #expect(scaled[1] == 1) + } +} diff --git a/Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift b/Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift new file mode 100644 index 00000000..99580581 --- /dev/null +++ b/Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift @@ -0,0 +1,33 @@ +import CoreGraphics +import Testing +@testable import ContainedNavigation + +@Suite("Single surface morph geometry") +struct MorphingSingleSurfaceTests { + @Test func interpolatesBetweenSourceAndTarget() { + let source = CGRect(x: 10, y: 20, width: 100, height: 60) + let target = CGRect(x: 40, y: 80, width: 220, height: 300) + + let rect = source.morphInterpolated(to: target, progress: 0.25) + + #expect(rect.minX == 17.5) + #expect(rect.minY == 35) + #expect(rect.width == 130) + #expect(rect.height == 120) + } + + @Test func clampsInvalidProgress() { + let source = CGRect(x: 0, y: 0, width: 20, height: 20) + let target = CGRect(x: 100, y: 100, width: 80, height: 80) + + #expect(source.morphInterpolated(to: target, progress: -1) == source) + #expect(source.morphInterpolated(to: target, progress: 2) == target) + #expect(source.morphInterpolated(to: target, progress: .nan) == source) + } + + @Test func validatesUsableMorphFrames() { + #expect(CGRect(x: 0, y: 0, width: 40, height: 40).isUsableForMorph) + #expect(!CGRect(x: 0, y: 0, width: 1, height: 40).isUsableForMorph) + #expect(!CGRect(x: CGFloat.infinity, y: 0, width: 40, height: 40).isUsableForMorph) + } +} diff --git a/changes/unreleased/20260702-refresh-stats-performance.md b/changes/unreleased/20260702-refresh-stats-performance.md index 24c1eabf..70ba58cf 100644 --- a/changes/unreleased/20260702-refresh-stats-performance.md +++ b/changes/unreleased/20260702-refresh-stats-performance.md @@ -1,8 +1,19 @@ - Reduced idle UI churn by decoupling container stats from the main refresh tick: metrics now use one app-wide low-priority `container stats --format table` stream for all running containers. - Lifecycle actions now relist containers without forcing vanity stats, so start/stop/run/recreate flows do not wait on Apple container's two-second stats sampling path. -- Narrowed stats-driven SwiftUI invalidation by moving per-container metric reads into each card renderer instead of the grid parent. -- Kept long-form History/Stats charts on Swift Charts while moving always-live card sparklines to a lightweight native Canvas renderer. +- Narrowed stats-driven SwiftUI invalidation with per-container metric state, so one streamed sample no longer dirties the whole container grid through shared stats dictionaries. +- Retested always-live card sparklines on Swift Charts after reducing stats update fan-out and per-card history churn. +- Made live sparkline data read chronologically by plotting a full-width, zero-padded window of latest contiguous samples with linear defaults, while keeping user-selectable smoothing available and preventing one noisy sample from rescaling the whole graph. +- Anchored CPU and memory sparklines to the real 0...100% scale, while keeping network and disk widgets auto-scaled for readable throughput shapes. - Stopped materializing every metric history array for every card frame; cards now pull only the active widget series from shared sample buffers. +- Tightened card metric binding so each widget renders only its own per-container series instead of falling back to another graph's samples when a buffer is missing. +- Normalized card and stats-page CPU/memory values against each container's configured CPU and memory limits while leaving network and disk throughput as raw bytes-per-second rates. +- Added a metric normalization setting, letting users switch CPU/memory percentages between per-container limits and Apple container's machine CPU/memory resources. +- Made scrollable and resource-sized app panels lazy by default, including container tabs, settings pages, image/tag panels, system/activity/history views, and package scaffolds that can host long content. +- Warmed container/image customizer sheets from the already-rendered style state and cached local image tag grouping so opening style popovers no longer rebuilds large resource views first. +- Deferred heavyweight expanded-card pages until their tab selection settles, avoiding transient Logs/Terminal/Stats/Inspect work while users rapidly switch pages. +- Kept lightweight History/Files expanded-card pages immediate and shortened heavyweight page deferral so page switching feels snappier without eagerly loading Logs/Terminal/Stats/Inspect. +- Made mini CPU/memory chip percentages preserve tiny sub-1% values so real streamed CPU changes do not disappear behind whole-percent rounding. +- Made CPU/memory percentage readouts consistent across cards, live stats, history charts, and mini chips: whole percentages stay clean, while only sub-1% values show the decimals needed. - Hardened embedded terminal teardown so rapid card/tab switching does not leave stale `container exec --tty` children behind. - Coalesced image-panel appearance refreshes so page/panel navigation does not force duplicate image-list reloads while users click through the UI. - Reduced runtime layout churn by replacing the container grid's bound card-frame preference with coalesced frame tracking and clamping morph panel geometry before it reaches SwiftUI frames. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 8033ede7..4ac3e2f6 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -48,7 +48,7 @@ remains the source of truth for builds, tests, and release scripts. ## Design system -Liquid Glass helpers and reusable primitives include `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `ResourceGlassCard`, `GlassSurface`, `CommandPreviewBar`, `InfoButton`, `GlassButton`, `ToolbarMenuButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `Tokens` groups for toolbar, panel, spacing, radius, icon sizing, resource cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, or micro-chrome recipes. App-state-aware mappings such as runtime status and graph metric extraction stay in the executable until they can cross the boundary without depending on app/core policy. Use the package READMEs for import instructions and copy-pasteable examples, and see [[Design System|Design-System]] for app-level conventions. +Liquid Glass helpers and reusable primitives include `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `ResourceGlassCard`, `ResourceCardInsetSection`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `GlassButton`, `ToolbarMenuButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `Tokens` groups for toolbar, panel, spacing, radius, icon sizing, resource cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, or micro-chrome recipes. App-state-aware mappings such as runtime status and graph metric extraction stay in the executable until they can cross the boundary without depending on app/core policy. Use the package READMEs for import instructions and copy-pasteable examples, and see [[Design System|Design-System]] for app-level conventions. ## Local-only personalization diff --git a/docs/wiki/Design-System.md b/docs/wiki/Design-System.md index 98674972..f771ddf7 100644 --- a/docs/wiki/Design-System.md +++ b/docs/wiki/Design-System.md @@ -123,12 +123,23 @@ Recommended internal pieces: - `ResourceCardSubtitleText` or `ResourceCardMonospacedSubtitleText` for metadata - `ResourceBadgeText` for compact state or kind labels - `ResourceCardFooterMini` for small footer actions and metrics +- `ResourceCardFooterChip`, `ResourceCardFooterButton`, and + `ResourceCardPageControls` for card-local controls +- `ResourceCardInsetSection` for charts, lists, and read-only groups inside an + expanded card body +- `resourceCardFloatingControls` and `resourceCardProgressOverlay` for + card-owned overlays - `DesignStatusDot`, `DesignStatusBadge`, `DesignKeyCap`, and `DesignKeyboardHint` for micro chrome Use `isSelected` instead of inventing a second selection ring. Use `elevated: false` for cards inside already-elevated morph panels. +Do not create a second `ResourceGlassCard` or direct surface modifier inside an +expanded card body unless the nested object is itself an independent resource +card, such as an image tag row. In-card content should go through +`ResourceCardInsetSection`. + ## Palette visual results The palette should not degrade rich app objects into plain text. Use @@ -146,9 +157,11 @@ Plain rows are reserved for generic actions such as refresh or opening a page. Use `Tokens` for spacing, radius, toolbar dimensions, panel sizes, icon sizes, form widths, chart sizes, badge/keycap insets, resource-card opacities, terminal -chrome, and shadows. Use `glassSurface` and `glassCapsuleSurface` for standalone -glass surfaces so feature views do not call `.glassEffect` directly. If a new -visual value appears, add a token or package primitive before using it in the app. +chrome, and shadows. Feature views should not call low-level surface modifiers +such as `glassSurface`; use named package routes such as `ResourceGlassCard`, +`PanelSection`, `DesignContentSurface`, `DesignInputSurface`, and +`ResourceCardInsetSection`. If a new visual value appears, add a token or package +primitive before using it in the app. Important groups: diff --git a/docs/wiki/System-Settings.md b/docs/wiki/System-Settings.md index 944a234f..85edbbac 100644 --- a/docs/wiki/System-Settings.md +++ b/docs/wiki/System-Settings.md @@ -15,7 +15,7 @@ macOS. Contained does not ask for or store administrator credentials. ## Settings tabs -- General: app behavior, menu bar, CLI previews, info tips, and related defaults. +- General: app behavior, menu bar, CLI previews, metric normalization, info tips, and related defaults. - Appearance: tint, material, card, panel, and theme choices. - Data: backup/export/import and local state controls. - Runtime: CLI path and runtime defaults. @@ -45,3 +45,22 @@ creation entry points where applicable. Contained stores settings, personalization, templates, health checks, activity history, image update status, and backups locally. Versioned backup and migration envelopes protect data created by newer app schema versions. + +## Metric normalization + +Settings -> General -> Data -> Normalize stats controls how CPU and memory +percentages are scaled across cards, live stats, mini chips, widgets, and +history charts: + +- Container: each container card is scaled against that container's configured + CPU and memory limits. +- Machine: every card is scaled against Apple container's machine CPU and memory + resources, so container usage appears in runtime-wide context. + +Network and disk widgets remain raw bytes-per-second rates in both modes. +History keeps raw samples on disk and applies the selected normalization mode +when rendering charts, so older samples remain usable if the mode changes. + +The neighboring **List refresh interval** setting controls background service, +container list, and resource-cache polling. Live metric widgets use their own +low-priority runtime stream instead of this interval. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 91ad3a31..806e1812 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -31,7 +31,9 @@ Switch **Settings → Updates → Update channel** to **Beta** or **Nightly**. N ## Stats look choppy / not real-time -Apple container's structured stats formats are static, while `container stats --format table` is the public streaming surface. Contained keeps one low-priority table stream open for the running containers and converts every frame into the card, widget, history, and Stats-tab metrics. The container list itself still follows **Settings → General → Data → Refresh interval**. +Apple container's structured stats formats are static, while `container stats --format table` is the public streaming surface. Contained keeps one low-priority table stream open for the running containers and converts every frame into the card, widget, history, and Stats-tab metrics. The container list itself still follows **Settings → General → Data → List refresh interval**. + +Use **Settings → General → Data → Normalize stats** to choose whether CPU and memory percentages are scaled per container or against Apple container's machine CPU and memory resources. Switching modes resets the visible sparkline buffers so the chart does not mix differently scaled samples. ## A container keeps restarting From 7a602d8fd129c28eb82b79f63677c262cefbd5dd Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 04:46:25 +0100 Subject: [PATCH 05/53] Use single-surface image detail expansion --- .../ResourceGlassCard.swift | 15 ++- Packages/ContainedNavigation/README.md | 17 ++- .../ContainedNavigation.md | 1 + .../MorphingExpander.swift | 6 +- .../MorphingSingleSurface.swift | 112 ++++++++++++++++++ Sources/Contained/Navigation/AppToolbar.swift | 21 ++-- .../Contained/Navigation/ClassicShell.swift | 19 +-- .../ToolbarPanels/ToolbarImageGroupCard.swift | 34 ++++-- .../ToolbarPanels/ToolbarUpdatesPanel.swift | 2 + .../20260702-refresh-stats-performance.md | 1 + 10 files changed, 191 insertions(+), 37 deletions(-) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift index 914d7bf0..c4b6d306 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift @@ -36,6 +36,7 @@ public struct ResourceGlassCard 1 && height > 1 } @@ -327,14 +327,14 @@ private struct MorphPanelShell: View { /// Hosted content reports its desired panel size up to the enclosing `MorphingExpander`, which animates /// the panel to it — so a paged panel can resize and re-center between sections. -private struct MorphPanelSizeKey: PreferenceKey { +struct MorphPanelSizeKey: PreferenceKey { static let defaultValue: CGSize? = nil static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) { if let next = nextValue() { value = next } } } -private struct MorphPanelPlacementKey: PreferenceKey { +struct MorphPanelPlacementKey: PreferenceKey { static let defaultValue: MorphPanelPlacement? = nil static func reduce(value: inout MorphPanelPlacement?, nextValue: () -> MorphPanelPlacement?) { diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift index fb87bfde..1624df8d 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift @@ -47,6 +47,118 @@ public struct MorphingSingleSurface: View { } } +/// Hosts one promoted card-like surface with the same lifecycle as `MorphingExpander`, but without +/// drawing a separate panel shell around the content. +/// +/// Use this when the promoted content is already its own visual surface, such as an expanded +/// `ResourceGlassCard`. Panel contents should still use `MorphingExpander`. +public struct MorphingSingleSurfaceExpander: View { + @Binding var isPresented: Bool + public var originFrame: CGRect + public var target: AppMorphTarget + public var backdropStyle: GlobalBackdropStyle + public var showsBackdrop: Bool + public var closeRequestToken: Int + public var onBackdropTap: (() -> Void)? + public var onExpansionChange: ((Bool) -> Void)? + @ViewBuilder private var content: () -> Content + + @State private var expanded = false + @State private var liveSize: CGSize? + @State private var livePlacement: MorphPanelPlacement? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.appSafeAreas) private var safeAreas + private var spring: Animation { .spring(response: 0.42, dampingFraction: 0.86) } + + public init(isPresented: Binding, + originFrame: CGRect, + target: AppMorphTarget, + backdropStyle: GlobalBackdropStyle = .dim, + showsBackdrop: Bool = true, + closeRequestToken: Int = 0, + onBackdropTap: (() -> Void)? = nil, + onExpansionChange: ((Bool) -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content) { + self._isPresented = isPresented + self.originFrame = originFrame + self.target = target + self.backdropStyle = backdropStyle + self.showsBackdrop = showsBackdrop + self.closeRequestToken = closeRequestToken + self.onBackdropTap = onBackdropTap + self.onExpansionChange = onExpansionChange + self.content = content + } + + public var body: some View { + GeometryReader { geo in + let target = targetRect(in: geo.size) + let source = originFrame.isUsableForMorph ? originFrame : target + let rect = expanded ? target : source + ZStack { + if showsBackdrop { + Color.clear + .globalBackdrop(style: backdropStyle, + progress: expanded ? 1 : 0, + dimOpacity: 0.28) + .contentShape(Rectangle()) + .onTapGesture { onBackdropTap?() ?? close() } + } + + Button(action: close) { EmptyView() } + .keyboardShortcut(.cancelAction) + .frame(width: 1, height: 1) + .opacity(0) + .accessibilityHidden(true) + + content() + .frame(width: max(rect.width, 1), height: max(rect.height, 1), alignment: .top) + .position(x: rect.midX, y: rect.midY) + } + } + .onPreferenceChange(MorphPanelSizeKey.self) { size in + guard let size, size.isUsableForMorphPanel else { return } + withAnimation(reduceMotion ? nil : spring) { liveSize = size } + } + .onPreferenceChange(MorphPanelPlacementKey.self) { placement in + guard let placement else { return } + withAnimation(reduceMotion ? nil : spring) { livePlacement = placement } + } + .onAppear { + guard !reduceMotion else { + expanded = true + onExpansionChange?(true) + return + } + DispatchQueue.main.async { + onExpansionChange?(true) + withAnimation(spring) { expanded = true } + } + } + .onChange(of: closeRequestToken) { _, _ in close() } + .onExitCommand(perform: close) + } + + private func targetRect(in size: CGSize) -> CGRect { + target.rect(origin: originFrame, + in: size, + safeAreas: safeAreas, + proposedSize: liveSize, + placement: livePlacement) + } + + private func close() { + onExpansionChange?(false) + guard !reduceMotion else { + isPresented = false + return + } + withAnimation(spring) { expanded = false } completion: { + isPresented = false + } + } +} + public extension MorphGeometry { static func clampedProgress(_ progress: CGFloat) -> CGFloat { min(max(progress.isFinite ? progress : 0, 0), 1) diff --git a/Sources/Contained/Navigation/AppToolbar.swift b/Sources/Contained/Navigation/AppToolbar.swift index a85f1e11..bf3be297 100644 --- a/Sources/Contained/Navigation/AppToolbar.swift +++ b/Sources/Contained/Navigation/AppToolbar.swift @@ -238,7 +238,8 @@ struct AppToolbar: View { showsBackdrop: false, closeRequestToken: ui.morphCloseRequestToken, onExpansionChange: setMorphBackdropExpanded) { - ToolbarUpdatesPanel(onOpenImage: openToolbarImageDetail) { + ToolbarUpdatesPanel(hiddenImageGroupID: toolbarImageDetailPresented ? toolbarImageDetail?.id : nil, + onOpenImage: openToolbarImageDetail) { ui.requestMorphClose(.updates) } } @@ -248,15 +249,15 @@ struct AppToolbar: View { @ViewBuilder private var toolbarImageDetailLayer: some View { if let detail = toolbarImageDetail, toolbarImageDetailPresented { - MorphingExpander(isPresented: toolbarImageDetailBinding, - originFrame: usableToolbarImageSource ?? .zero, - target: .anchored(size: toolbarImageDetailSize, - safeArea: toolbarMorphSafeArea(for: .updates), - margin: 16), - backdropStyle: .dim, - showsBackdrop: true, - closeRequestToken: toolbarImageCloseRequestToken, - onBackdropTap: closeToolbarImageDetail) { + MorphingSingleSurfaceExpander(isPresented: toolbarImageDetailBinding, + originFrame: usableToolbarImageSource ?? .zero, + target: .anchored(size: toolbarImageDetailSize, + safeArea: toolbarMorphSafeArea(for: .updates), + margin: 16), + backdropStyle: .dim, + showsBackdrop: true, + closeRequestToken: toolbarImageCloseRequestToken, + onBackdropTap: closeToolbarImageDetail) { ToolbarImageGroupCard(group: currentToolbarImageGroup(detail), isExpanded: true, onTap: {}, diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/Contained/Navigation/ClassicShell.swift index edce10f6..f02c5e90 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/Contained/Navigation/ClassicShell.swift @@ -360,19 +360,20 @@ private struct ImagesPage: View { ZStack { ToolbarUpdatesPanel(showClose: false, coordinateSpaceName: pageImageSpace, + hiddenImageGroupID: presented ? detail?.id : nil, onOpenImage: openImageDetail, onClose: {}) if let detail, presented { - MorphingExpander(isPresented: detailBinding, - originFrame: usableSourceFrame ?? fallbackSourceFrame(in: viewport.size), - target: .anchored(size: Tokens.PanelSize.imageDetail, - safeArea: imageDetailSafeAreaPolicy, - margin: 16), - backdropStyle: .dim, - showsBackdrop: true, - closeRequestToken: closeRequestToken, - onBackdropTap: closeDetail) { + MorphingSingleSurfaceExpander(isPresented: detailBinding, + originFrame: usableSourceFrame ?? fallbackSourceFrame(in: viewport.size), + target: .anchored(size: Tokens.PanelSize.imageDetail, + safeArea: imageDetailSafeAreaPolicy, + margin: 16), + backdropStyle: .dim, + showsBackdrop: true, + closeRequestToken: closeRequestToken, + onBackdropTap: closeDetail) { ToolbarImageGroupCard(group: currentGroup(detail), isExpanded: true, onTap: {}, diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 19c697c6..bd036512 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -106,21 +106,37 @@ struct ToolbarImageGroupCard: View { private func subPageScaffold(symbol: String, title: String, subtitle: String?, @ViewBuilder content: @escaping () -> C) -> some View { - MorphPanelScaffold(width: 0, scrolls: false) { - VStack(spacing: 0) { - PanelHeader(symbol: symbol, title: title, subtitle: subtitle) { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "chevron.left", help: "Back") { - withAnimation(spring) { page = .root } - } + ResourceGlassCard(size: .medium, + isExpanded: true, + showsFooter: false, + elevated: false) { + ResourceCardHeader { + ResourceCardIconChip(symbol: symbol, tint: .accentColor) + } content: { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardTitleText(text: title) + if let subtitle { + ResourceCardMonospacedSubtitleText(text: subtitle) + } + } + } trailing: { + GlassButton(singleItem: false) { + GlassButtonItem(systemName: "chevron.left", help: "Back") { + withAnimation(spring) { page = .root } } + GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true, action: onClose) } - Divider() } - } content: { + } bodyContent: { content() .padding(Tokens.Space.s) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift index 6692d9a3..78087d3f 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift @@ -10,6 +10,7 @@ struct ToolbarUpdatesPanel: View { @Environment(UIState.self) private var ui var showClose = true var coordinateSpaceName = AppToolbar.space + var hiddenImageGroupID: LocalImageTagGroup.ID? var onOpenImage: (LocalImageTagGroup, CGRect) -> Void var onClose: () -> Void @State private var imageFrames: [LocalImageTagGroup.ID: CGRect] = [:] @@ -117,6 +118,7 @@ struct ToolbarUpdatesPanel: View { onOpenImage(group, imageFrames[group.id] ?? .zero) }, onClose: {}) + .opacity(hiddenImageGroupID == group.id ? 0 : 1) .background { GeometryReader { proxy in Color.clear diff --git a/changes/unreleased/20260702-refresh-stats-performance.md b/changes/unreleased/20260702-refresh-stats-performance.md index 70ba58cf..8879f2d7 100644 --- a/changes/unreleased/20260702-refresh-stats-performance.md +++ b/changes/unreleased/20260702-refresh-stats-performance.md @@ -9,6 +9,7 @@ - Normalized card and stats-page CPU/memory values against each container's configured CPU and memory limits while leaving network and disk throughput as raw bytes-per-second rates. - Added a metric normalization setting, letting users switch CPU/memory percentages between per-container limits and Apple container's machine CPU/memory resources. - Made scrollable and resource-sized app panels lazy by default, including container tabs, settings pages, image/tag panels, system/activity/history views, and package scaffolds that can host long content. +- Switched singular image detail expansion to the same single-surface card path as containers, leaving morph panels for true toolbar panels instead of wrapping expanded image cards in panel chrome. - Warmed container/image customizer sheets from the already-rendered style state and cached local image tag grouping so opening style popovers no longer rebuilds large resource views first. - Deferred heavyweight expanded-card pages until their tab selection settles, avoiding transient Logs/Terminal/Stats/Inspect work while users rapidly switch pages. - Kept lightweight History/Files expanded-card pages immediate and shortened heavyweight page deferral so page switching feels snappier without eagerly loading Logs/Terminal/Stats/Inspect. From a46d7540fb7563eda7b83c42437577dfe5558438 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 04:54:33 +0100 Subject: [PATCH 06/53] Remove inspect UI and page image details --- CHANGELOG.md | 10 +- .../ContainedDesignSystem.md | 2 - .../JSONInspectorSheet.swift | 67 ------------ .../ContainedDesignSystem/Tokens.swift | 2 +- .../Features/Containers/ContainerCard.swift | 13 --- .../Containers/ContainerInspectTab.swift | 24 ----- .../Containers/ContainersGridView.swift | 6 -- .../Features/System/SystemView.swift | 8 -- .../Contained/Navigation/ClassicShell.swift | 5 - .../ToolbarPanels/ToolbarImageGroupCard.swift | 100 +++++++++--------- Sources/Contained/Resources/CHANGELOG.md | 10 +- .../20260702-refresh-stats-performance.md | 5 +- docs/wiki/Features-Containers.md | 1 - docs/wiki/Features-Images.md | 1 - docs/wiki/Features-Resources.md | 4 +- docs/wiki/Features.md | 4 +- docs/wiki/Home.md | 4 +- 17 files changed, 71 insertions(+), 195 deletions(-) delete mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/JSONInspectorSheet.swift delete mode 100644 Sources/Contained/Features/Containers/ContainerInspectTab.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d5518cc3..26e10411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,15 +25,15 @@ - Container grid with personalized Liquid Glass cards, lifecycle actions, status, image, command, resource highlights, full-card hit targets, and context menus. - Container grouping by Network, Volume, Image, or Flat list, with sort and running-only filtering in shared toolbar controls. -- Expanded container detail tabs for Overview, Logs, Terminal, Stats, History, Files, and Inspect. -- Live logs, one-shot and follow modes, terminal access through SwiftTerm, file browsing/copy workflows, JSON inspect views, and per-container metrics history. +- Expanded container detail tabs for Overview, Logs, Terminal, Stats, History, and Files. +- Live logs, one-shot and follow modes, terminal access through SwiftTerm, file browsing/copy workflows, and per-container metrics history. - Shared Run/Edit form for new containers and recreate/edit flows, with validation, image pre-pull, inline errors, and an exact CLI preview. - App-managed restart policies and health checks for behavior Apple's `container` CLI does not provide natively. - Container image update checks that can pull newer image tags and reopen the edit flow without automatically replacing the running container. #### Images -- Local image and tag browsing with grouped references, run actions, inspect/history views, tagging, pushing, saving archives, loading OCI `.tar` archives, pruning, and update status. +- Local image and tag browsing with grouped references, run actions, history pages, tagging, pushing, saving archives, loading OCI `.tar` archives, pruning, and update status. - Registry image search flow for Docker Hub, gated behind Settings -> Experimental, with selected results handed into the run configuration flow. - Image update detection based on local and remote digests, surfaced on image cards, container cards, toolbar panels, System, and command palette entries. - Manual and scheduled image update checks, including "check all", "pull all available image updates", and container-image specific sweeps. @@ -50,8 +50,8 @@ #### Resources & Registries -- Volume browsing, creation, inspection, deletion, prune actions, local card styling, and mount-aware container grouping. -- Network browsing, creation, inspection, deletion, prune actions, and network-aware container grouping. +- Volume browsing, creation, deletion, prune actions, local card styling, and mount-aware container grouping. +- Network browsing, creation, deletion, prune actions, and network-aware container grouping. - Registry credential management in Settings -> Registries, including login/logout with passwords piped through `--password-stdin` rather than process arguments. - Activity history for lifecycle, image, compose, system, registry, pull, build, watchdog, healthcheck, alert, and UI events, including unread state, filtering, copy/delete actions, and clear controls. diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 0b73d61d..14d96e8f 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -230,7 +230,5 @@ DesignInputSurface { - ``TintSelector`` - ``GradientAngleControl`` -- ``JSONInspectorSheet`` -- ``InlineJSONView`` - ``StreamConsole`` - ``copyToPasteboard(_:)`` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/JSONInspectorSheet.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/JSONInspectorSheet.swift deleted file mode 100644 index 682a9407..00000000 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/JSONInspectorSheet.swift +++ /dev/null @@ -1,67 +0,0 @@ -import SwiftUI - -/// Pretty-print any `Encodable` value to sorted, indented JSON (shared by the inspector sheet and the -/// in-panel inspect morph page). -public func prettyJSON(_ value: Value) -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - guard let data = try? encoder.encode(value), let s = String(data: data, encoding: .utf8) else { - return "Couldn't render JSON." - } - return s -} - -/// Header-less scrolling JSON body — reused by `JSONInspectorSheet` and the image-detail inspect page. -public struct InlineJSONView: View { - public let json: String - - public init(json: String) { - self.json = json - } - - public var body: some View { - ScrollView([.horizontal, .vertical]) { - Text(json) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .padding(Tokens.Space.l) - .frame(maxWidth: .infinity, alignment: .leading) - } - .scrollEdgeEffectStyle(.soft, for: .all) - } -} - -/// A reusable pretty-printed-JSON inspector sheet for any `Encodable` resource. -public struct JSONInspectorSheet: View { - @Environment(\.dismiss) private var dismiss - public let title: String - public let value: Value - - public init(title: String, value: Value) { - self.title = title - self.value = value - } - - public var body: some View { - LazyVStack(spacing: 0) { - HStack { - Text(title).font(.headline).lineLimit(1) - Spacer() - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "doc.on.doc", help: "Copy") { copyToPasteboard(prettyJSON(value)) } - } - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true) { - dismiss() - } - } - } - .padding(Tokens.Space.l) - Divider() - InlineJSONView(json: prettyJSON(value)) - } - .frame(Tokens.SheetSize.inspector) - .sheetMaterial() - } -} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens.swift index 66bbf5c5..fea0dc27 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens.swift @@ -43,7 +43,7 @@ public enum Tokens { public static let small = CGSize(width: 420, height: 280) // confirmations, short forms public static let form = CGSize(width: 560, height: 680) // the run/edit form public static let console = CGSize(width: 560, height: 540) // streamed-progress / logs - public static let inspector = CGSize(width: 600, height: 560) // JSON inspector, history + public static let inspector = CGSize(width: 600, height: 560) // Dense detail/history pages public static let releaseNotes = CGSize(width: 620, height: 520) public static let wide = CGSize(width: 720, height: 560) // build workspace public static let dialogWidth: CGFloat = 460 diff --git a/Sources/Contained/Features/Containers/ContainerCard.swift b/Sources/Contained/Features/Containers/ContainerCard.swift index d7ab4417..7b8000b4 100644 --- a/Sources/Contained/Features/Containers/ContainerCard.swift +++ b/Sources/Contained/Features/Containers/ContainerCard.swift @@ -32,8 +32,6 @@ struct ContainerCard: View { var onSelectMultiple: () -> Void = {} var onToggleSelected: () -> Void = {} var onEndSelecting: () -> Void = {} - /// Show the "Reveal CLI" affordance (gated by the global Settings toggle). - var revealCLI: Bool = true /// App-managed healthcheck status (drives the heart badge). var health: HealthStatus = .unknown /// Multi-select mode: tapping toggles selection instead of opening the detail. @@ -56,7 +54,6 @@ struct ContainerCard: View { case stats = "Stats" case history = "History" case files = "Files" - case inspect = "Inspect" var id: String { rawValue } @@ -68,7 +65,6 @@ struct ContainerCard: View { case .stats: return "chart.xyaxis.line" case .history: return "clock.arrow.circlepath" case .files: return "folder" - case .inspect: return "doc.text.magnifyingglass" } } } @@ -308,11 +304,6 @@ struct ContainerCard: View { Button { onUpdate() } label: { Label("Update Container…", systemImage: "arrow.down.circle") } } Button { copyToPasteboard(snapshot.id) } label: { Label("Copy ID", systemImage: "doc.on.doc") } - if revealCLI { - Button { copyToPasteboard("container inspect \(snapshot.id)") } label: { - Label("Copy as CLI", systemImage: "terminal") - } - } Divider() Button(role: .destructive) { onDelete() } label: { Label("Delete", systemImage: "trash") } } @@ -351,10 +342,6 @@ struct ContainerCard: View { ContainerHistoryTab(snapshot: snapshot) case .files: FilesTab(snapshot: snapshot) - case .inspect: - DeferredContainerPage { - ContainerInspectTab(snapshot: snapshot) - } } } diff --git a/Sources/Contained/Features/Containers/ContainerInspectTab.swift b/Sources/Contained/Features/Containers/ContainerInspectTab.swift deleted file mode 100644 index 9de0b924..00000000 --- a/Sources/Contained/Features/Containers/ContainerInspectTab.swift +++ /dev/null @@ -1,24 +0,0 @@ -import SwiftUI -import ContainedCore - -/// The Inspect tab: the container snapshot rendered as pretty-printed JSON. -struct ContainerInspectTab: View { - let snapshot: ContainerSnapshot - var body: some View { - ContainerTabScaffold(axes: [.horizontal, .vertical]) { - Text(json) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - private var json: String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - guard let data = try? encoder.encode(snapshot), let s = String(data: data, encoding: .utf8) else { - return "Couldn't render JSON." - } - return s - } -} diff --git a/Sources/Contained/Features/Containers/ContainersGridView.swift b/Sources/Contained/Features/Containers/ContainersGridView.swift index 37e41c08..945ebe42 100644 --- a/Sources/Contained/Features/Containers/ContainersGridView.swift +++ b/Sources/Contained/Features/Containers/ContainersGridView.swift @@ -26,7 +26,6 @@ struct ContainersGridView: View { // Each network is a collapsible section of the containers attached to it. @State private var collapsedNetworks: Set = [] - @State private var inspectingNetwork: NetworkResource? @State private var deletingNetwork: NetworkResource? private let detailSpring = Animation.spring(response: 0.42, dampingFraction: 0.86) @@ -228,7 +227,6 @@ struct ContainersGridView: View { } // Network-level actions. .task { await app.refreshNetworks() } - .sheet(item: $inspectingNetwork) { JSONInspectorSheet(title: $0.name, value: $0) } .confirmationDialog("Delete network \(deletingNetwork?.name ?? "")?", isPresented: deleteNetworkBinding, presenting: deletingNetwork) { network in Button("Delete", role: .destructive) { Task { await deleteNetwork(network) } } @@ -293,7 +291,6 @@ struct ContainersGridView: View { @ViewBuilder private func networkMenu(_ resource: NetworkResource) -> some View { - Button { inspectingNetwork = resource } label: { Label("Inspect", systemImage: "doc.text.magnifyingglass") } Button { copyToPasteboard(resource.name) } label: { Label("Copy Name", systemImage: "doc.on.doc") } if !resource.isBuiltin { Divider() @@ -393,7 +390,6 @@ struct ContainersGridView: View { onSelectMultiple: { beginSelecting(snapshot.id) }, onToggleSelected: { toggle(snapshot.id) }, onEndSelecting: { endSelecting() }, - revealCLI: app.settings.revealCLI, health: app.health.status(for: snapshot.id), selecting: selecting, isSelected: selection.contains(snapshot.id) @@ -543,7 +539,6 @@ private struct ContainerCardMetricsRenderer: View { let onSelectMultiple: () -> Void let onToggleSelected: () -> Void let onEndSelecting: () -> Void - let revealCLI: Bool let health: HealthStatus let selecting: Bool let isSelected: Bool @@ -573,7 +568,6 @@ private struct ContainerCardMetricsRenderer: View { onSelectMultiple: onSelectMultiple, onToggleSelected: onToggleSelected, onEndSelecting: onEndSelecting, - revealCLI: revealCLI, health: health, selecting: selecting, isSelected: isSelected, diff --git a/Sources/Contained/Features/System/SystemView.swift b/Sources/Contained/Features/System/SystemView.swift index bea0720b..5ce04657 100644 --- a/Sources/Contained/Features/System/SystemView.swift +++ b/Sources/Contained/Features/System/SystemView.swift @@ -18,7 +18,6 @@ struct SystemContent: View { @State private var working = false @State private var pruneTarget: PruneTarget? @State private var reclaimingAll = false - @State private var inspectingVolume: VolumeResource? @State private var deletingVolume: VolumeResource? @State private var page: SystemPage @@ -107,7 +106,6 @@ struct SystemContent: View { .padding(Tokens.Space.s) } .task { await app.refreshSystemResources() } - .sheet(item: $inspectingVolume) { JSONInspectorSheet(title: $0.name, value: $0) } .confirmationDialog("Delete volume \(deletingVolume?.name ?? "")?", isPresented: deletingVolumeBinding, presenting: deletingVolume) { volume in Button("Delete", role: .destructive) { Task { await deleteVolume(volume) } } @@ -270,12 +268,6 @@ struct SystemContent: View { @ViewBuilder private func volumeMenu(_ entry: VolumeInventoryEntry) -> some View { - if let volume = entry.resource { - Button { inspectingVolume = volume } label: { Label("Inspect", systemImage: "doc.text.magnifyingglass") } - } else { - Button("Inspect", systemImage: "doc.text.magnifyingglass") {} - .disabled(true) - } Button { copyToPasteboard(entry.source ?? entry.title) } label: { Label("Copy source", systemImage: "doc.on.doc") } if let destination = entry.destination { Button { copyToPasteboard(destination) } label: { Label("Copy destination", systemImage: "arrow.down.doc") } diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/Contained/Navigation/ClassicShell.swift index f02c5e90..55896f30 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/Contained/Navigation/ClassicShell.swift @@ -180,7 +180,6 @@ private struct BuildPage: View { private struct NetworksPage: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - @State private var inspectingNetwork: NetworkResource? @State private var deletingNetwork: NetworkResource? private var sortedNetworks: [NetworkResource] { @@ -252,7 +251,6 @@ private struct NetworksPage: View { } } .task { await app.refreshNetworks() } - .sheet(item: $inspectingNetwork) { JSONInspectorSheet(title: $0.name, value: $0) } .confirmationDialog("Delete network \(deletingNetwork?.name ?? "")?", isPresented: deleteNetworkBinding, presenting: deletingNetwork) { network in @@ -291,9 +289,6 @@ private struct NetworksPage: View { @ViewBuilder private func networkMenu(_ network: NetworkResource) -> some View { - Button { inspectingNetwork = network } label: { - Label("Inspect", systemImage: "doc.text.magnifyingglass") - } Button { copyToPasteboard(network.name) } label: { Label("Copy Name", systemImage: "doc.on.doc") } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index bd036512..dfe6a16b 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -15,15 +15,14 @@ struct ToolbarImageGroupCard: View { @State private var deletingReference: String? @State private var pruning = false - /// Detailed image operations (inspect / history / tag / push) now grow the image-detail morph into - /// a sub-page in place — matching the creation flow — instead of opening modal sheets. - @State private var page: ImageDetailPage = .root + /// Detailed image pages grow the image-detail morph in place, matching container cards: tags are + /// the default body page, while history/tag/push reuse the same card shell. + @State private var page: ImageDetailPage = .tags @State private var tagTarget = "" @State private var tagBusy = false - enum ImageDetailPage: Equatable { - case root - case inspect(String) + enum ImageDetailPage: Hashable { + case tags case history(String) case tag(String) case push(String) @@ -35,6 +34,9 @@ struct ToolbarImageGroupCard: View { Group { if isExpanded { expandedContent + .resourceCardFloatingControls(when: true) { + imagePageControls + } .morphPanelSize(size(for: page)) .morphPanelPlacement(.anchored) .animation(spring, value: page) @@ -58,8 +60,7 @@ struct ToolbarImageGroupCard: View { @ViewBuilder private var expandedContent: some View { switch page { - case .root: rootCard - case .inspect(let ref): inspectPage(ref) + case .tags: rootCard case .history(let ref): historyPage(ref) case .tag(let source): tagPage(source) case .push(let ref): pushPage(ref) @@ -97,8 +98,8 @@ struct ToolbarImageGroupCard: View { private func size(for page: ImageDetailPage) -> CGSize { switch page { - case .root: return Tokens.PanelSize.imageDetail - case .inspect, .history: return Tokens.SheetSize.inspector + case .tags: return Tokens.PanelSize.imageDetail + case .history: return Tokens.SheetSize.inspector case .tag: return Tokens.PanelSize.imageTag case .push: return Tokens.SheetSize.console } @@ -120,12 +121,7 @@ struct ToolbarImageGroupCard: View { } } } trailing: { - GlassButton(singleItem: false) { - GlassButtonItem(systemName: "chevron.left", help: "Back") { - withAnimation(spring) { page = .root } - } - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true, action: onClose) - } + EmptyView() } } bodyContent: { content() @@ -140,18 +136,6 @@ struct ToolbarImageGroupCard: View { } } - private func inspectPage(_ reference: String) -> some View { - let image = group.images.first { $0.reference == reference } ?? primaryImage(group) - return subPageScaffold(symbol: "doc.text.magnifyingglass", title: "Inspect", - subtitle: Format.shortImage(reference)) { - if let image { - InlineJSONView(json: prettyJSON(image)) - } else { - ContentUnavailableView("Unavailable", systemImage: "doc.text.magnifyingglass") - } - } - } - private func historyPage(_ reference: String) -> some View { let image = group.images.first { $0.reference == reference } ?? primaryImage(group) let variant = image?.variants.first(where: \.isRunnable) ?? image?.variants.first @@ -232,7 +216,7 @@ struct ToolbarImageGroupCard: View { await app.refreshImagesIfStale(force: true) tagBusy = false tagTarget = "" - withAnimation(spring) { page = .root } + withAnimation(spring) { page = .tags } } catch let error as CommandError { app.flash(error.userMessage); tagBusy = false } catch { @@ -257,16 +241,45 @@ struct ToolbarImageGroupCard: View { ResourceCardSubtitleText(text: repositoryOwner(group.primaryReference)) } } trailing: { - if isExpanded { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true, action: onClose) - } - } else { - EmptyView() - } + EmptyView() } } + private var imagePageControls: some View { + ResourceCardPageControls(items: imagePageControlItems, + selection: page, + tint: resolvedImageTint, + onSelect: selectPage, + onClose: onClose) + } + + private var imagePageControlItems: [ResourceCardPageControlItem] { + let reference = primaryImage(group)?.reference ?? group.primaryReference + return [ + ResourceCardPageControlItem(id: .tags, + title: "Tags", + systemImage: "tag"), + ResourceCardPageControlItem(id: .history(reference), + title: "History", + systemImage: "clock.arrow.circlepath"), + ResourceCardPageControlItem(id: .tag(reference), + title: "Add Tag", + systemImage: "plus.circle"), + ResourceCardPageControlItem(id: .push(reference), + title: "Push", + systemImage: "arrow.up.circle") + ] + } + + private var resolvedImageTint: Color { + app.imageGroupStyle(for: group).color + } + + private func selectPage(_ item: ImageDetailPage) { + guard page != item else { return } + withAnimation(spring) { page = item } + } + private func imageFooterInfo(_ status: ImageUpdateStatus) -> some View { ResourceCardFooterMini { Image(systemName: updateSymbol(status.state)) @@ -302,10 +315,6 @@ struct ToolbarImageGroupCard: View { } } if let image = primaryImage(group) { - if isExpanded { - footerAction("tag", help: "Add Tag") { withAnimation(spring) { page = .tag(image.reference) } } - footerAction("arrow.up.circle", help: "Push") { withAnimation(spring) { page = .push(image.reference) } } - } footerAction("arrow.up.doc", help: "Save") { save(image) } } footerAction("trash", help: "Prune", role: .destructive) { pruning = true } @@ -362,7 +371,6 @@ struct ToolbarImageGroupCard: View { if isExpanded { onClose() } } footerAction("doc.on.doc", help: "Copy reference") { copyToPasteboard(reference) } - footerAction("doc.text.magnifyingglass", help: "Inspect") { inspect(reference, in: group) } footerAction("trash", help: "Delete tag", role: .destructive) { deletingReference = reference } } .contextMenu { tagMenu(reference, in: group) } @@ -374,7 +382,6 @@ struct ToolbarImageGroupCard: View { private func tagMenu(_ reference: String, in group: LocalImageTagGroup) -> some View { Button { ui.runImage(reference); if isExpanded { onClose() } } label: { Label("Run…", systemImage: "play") } Button { copyToPasteboard(reference) } label: { Label("Copy reference", systemImage: "doc.on.doc") } - Button { inspect(reference, in: group) } label: { Label("Inspect", systemImage: "doc.text.magnifyingglass") } Divider() Button(role: .destructive) { deletingReference = reference } label: { Label("Delete tag", systemImage: "trash") } } @@ -393,12 +400,11 @@ struct ToolbarImageGroupCard: View { private func cardMenu(_ group: LocalImageTagGroup) -> some View { Button { ui.runImage(group.primaryReference) } label: { Label("Run…", systemImage: "play") } if let image = primaryImage(group) { - // Inspect / History / Tag / Push grow the detail morph into a sub-page, so they're offered - // only from the expanded detail (a collapsed card opens the detail first). + // History / Tag / Push grow the detail morph into a sub-page, so they're offered only + // from the expanded detail (a collapsed card opens the detail first). if isExpanded { Button { withAnimation(spring) { page = .tag(image.reference) } } label: { Label("Add Tag…", systemImage: "tag") } Button { withAnimation(spring) { page = .push(image.reference) } } label: { Label("Push…", systemImage: "arrow.up.circle") } - Button { withAnimation(spring) { page = .inspect(image.reference) } } label: { Label("Inspect", systemImage: "doc.text.magnifyingglass") } Button { withAnimation(spring) { page = .history(image.reference) } } label: { Label("History", systemImage: "clock.arrow.circlepath") } } else { Button(action: onTap) { Label("Show Details…", systemImage: "rectangle.expand.vertical") } @@ -483,10 +489,6 @@ struct ToolbarImageGroupCard: View { Binding(get: { deletingReference != nil }, set: { if !$0 { deletingReference = nil } }) } - private func inspect(_ reference: String, in group: LocalImageTagGroup) { - withAnimation(spring) { page = .inspect(reference) } - } - private func delete(_ reference: String) async { guard let client = app.client else { return } do { diff --git a/Sources/Contained/Resources/CHANGELOG.md b/Sources/Contained/Resources/CHANGELOG.md index d5518cc3..26e10411 100644 --- a/Sources/Contained/Resources/CHANGELOG.md +++ b/Sources/Contained/Resources/CHANGELOG.md @@ -25,15 +25,15 @@ - Container grid with personalized Liquid Glass cards, lifecycle actions, status, image, command, resource highlights, full-card hit targets, and context menus. - Container grouping by Network, Volume, Image, or Flat list, with sort and running-only filtering in shared toolbar controls. -- Expanded container detail tabs for Overview, Logs, Terminal, Stats, History, Files, and Inspect. -- Live logs, one-shot and follow modes, terminal access through SwiftTerm, file browsing/copy workflows, JSON inspect views, and per-container metrics history. +- Expanded container detail tabs for Overview, Logs, Terminal, Stats, History, and Files. +- Live logs, one-shot and follow modes, terminal access through SwiftTerm, file browsing/copy workflows, and per-container metrics history. - Shared Run/Edit form for new containers and recreate/edit flows, with validation, image pre-pull, inline errors, and an exact CLI preview. - App-managed restart policies and health checks for behavior Apple's `container` CLI does not provide natively. - Container image update checks that can pull newer image tags and reopen the edit flow without automatically replacing the running container. #### Images -- Local image and tag browsing with grouped references, run actions, inspect/history views, tagging, pushing, saving archives, loading OCI `.tar` archives, pruning, and update status. +- Local image and tag browsing with grouped references, run actions, history pages, tagging, pushing, saving archives, loading OCI `.tar` archives, pruning, and update status. - Registry image search flow for Docker Hub, gated behind Settings -> Experimental, with selected results handed into the run configuration flow. - Image update detection based on local and remote digests, surfaced on image cards, container cards, toolbar panels, System, and command palette entries. - Manual and scheduled image update checks, including "check all", "pull all available image updates", and container-image specific sweeps. @@ -50,8 +50,8 @@ #### Resources & Registries -- Volume browsing, creation, inspection, deletion, prune actions, local card styling, and mount-aware container grouping. -- Network browsing, creation, inspection, deletion, prune actions, and network-aware container grouping. +- Volume browsing, creation, deletion, prune actions, local card styling, and mount-aware container grouping. +- Network browsing, creation, deletion, prune actions, and network-aware container grouping. - Registry credential management in Settings -> Registries, including login/logout with passwords piped through `--password-stdin` rather than process arguments. - Activity history for lifecycle, image, compose, system, registry, pull, build, watchdog, healthcheck, alert, and UI events, including unread state, filtering, copy/delete actions, and clear controls. diff --git a/changes/unreleased/20260702-refresh-stats-performance.md b/changes/unreleased/20260702-refresh-stats-performance.md index 8879f2d7..cfd34982 100644 --- a/changes/unreleased/20260702-refresh-stats-performance.md +++ b/changes/unreleased/20260702-refresh-stats-performance.md @@ -11,8 +11,9 @@ - Made scrollable and resource-sized app panels lazy by default, including container tabs, settings pages, image/tag panels, system/activity/history views, and package scaffolds that can host long content. - Switched singular image detail expansion to the same single-surface card path as containers, leaving morph panels for true toolbar panels instead of wrapping expanded image cards in panel chrome. - Warmed container/image customizer sheets from the already-rendered style state and cached local image tag grouping so opening style popovers no longer rebuilds large resource views first. -- Deferred heavyweight expanded-card pages until their tab selection settles, avoiding transient Logs/Terminal/Stats/Inspect work while users rapidly switch pages. -- Kept lightweight History/Files expanded-card pages immediate and shortened heavyweight page deferral so page switching feels snappier without eagerly loading Logs/Terminal/Stats/Inspect. +- Deferred heavyweight expanded-card pages until their tab selection settles, avoiding transient Logs/Terminal/Stats work while users rapidly switch pages. +- Kept lightweight History/Files expanded-card pages immediate and shortened heavyweight page deferral so page switching feels snappier without eagerly loading Logs/Terminal/Stats. +- Removed user-facing JSON Inspect pages and row actions from containers, images, networks, and volumes; image details now use expanded-card pages for Tags, History, Add Tag, and Push. - Made mini CPU/memory chip percentages preserve tiny sub-1% values so real streamed CPU changes do not disappear behind whole-percent rounding. - Made CPU/memory percentage readouts consistent across cards, live stats, history charts, and mini chips: whole percentages stay clean, while only sub-1% values show the decimals needed. - Hardened embedded terminal teardown so rapid card/tab switching does not leave stale `container exec --tty` children behind. diff --git a/docs/wiki/Features-Containers.md b/docs/wiki/Features-Containers.md index a6843cd6..48dbc5d4 100644 --- a/docs/wiki/Features-Containers.md +++ b/docs/wiki/Features-Containers.md @@ -37,7 +37,6 @@ Expanded container detail surfaces include: - Stats - History - Files -- Inspect Expanded views use the same toolbar safe-area contract as morph panels, clearing top and bottom toolbar bands when the experimental toolbar is visible. diff --git a/docs/wiki/Features-Images.md b/docs/wiki/Features-Images.md index 550bfba6..cd4ab767 100644 --- a/docs/wiki/Features-Images.md +++ b/docs/wiki/Features-Images.md @@ -17,7 +17,6 @@ Common actions: - push - save archive - load archive -- inspect - history - prune diff --git a/docs/wiki/Features-Resources.md b/docs/wiki/Features-Resources.md index 77c96586..c1629c38 100644 --- a/docs/wiki/Features-Resources.md +++ b/docs/wiki/Features-Resources.md @@ -4,13 +4,13 @@ Resources cover volumes, networks, registries, templates, and activity history. ## Volumes -Volumes can be browsed, inspected, created, attached during container runs, and +Volumes can be browsed, created, attached during container runs, and deleted or pruned when appropriate. Volume styling is local app state and can be used for scan-friendly resource cards. ## Networks -Networks can be browsed, inspected, created, attached during container runs, and +Networks can be browsed, created, attached during container runs, and deleted or pruned when appropriate. The create-network path is part of the shared [[Creation Workflow|Creation-Workflow]]. diff --git a/docs/wiki/Features.md b/docs/wiki/Features.md index 93430910..ab982127 100644 --- a/docs/wiki/Features.md +++ b/docs/wiki/Features.md @@ -8,8 +8,8 @@ Toolbar-first UI and toolbar panel navigation are experimental opt-ins from ## Feature areas -- **[[Containers|Features-Containers]]** — lifecycle, detail tabs, logs, - terminal, stats, files, inspect, app-managed restart/health, and local card +- **[[Containers|Features-Containers]]** — lifecycle, detail pages, logs, + terminal, stats, files, app-managed restart/health, and local card personalization. - **[[Images|Features-Images]]** — local images and tags, run, pull, load/save, tag, push, history, image updates, Docker Hub search, and the build workspace. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index f9ae7265..d31fcd18 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -1,6 +1,6 @@ # Contained -A native, SwiftUI-first macOS app for Apple's [`container`](https://github.com/apple/container) CLI — a Liquid Glass control surface for running, customizing, and inspecting Linux containers on Apple silicon. It covers the everyday Docker-Desktop workflow plus the broader `container` command surface, without leaving the Mac-native look and feel. +A native, SwiftUI-first macOS app for Apple's [`container`](https://github.com/apple/container) CLI — a Liquid Glass control surface for running and customizing Linux containers on Apple silicon. It covers the everyday Docker-Desktop workflow plus the broader `container` command surface, without leaving the Mac-native look and feel. ## Quick start @@ -16,7 +16,7 @@ Prefer a prebuilt app? Grab the latest `.dmg` from [Releases](https://github.com ## What's inside -- **Containers** — a grid of personalized Liquid Glass cards with live sparklines, full lifecycle, and a 7-tab detail (Overview, Logs, Terminal, Stats, History, Files, Inspect). +- **Containers** — a grid of personalized Liquid Glass cards with live sparklines, full lifecycle, and a 6-page detail (Overview, Logs, Terminal, Stats, History, Files). - **Images / Build / Volumes / Networks / Registries / System** — pull (with Docker Hub search), build (streamed BuildKit log), tag/push/save/load, filesystem export, volume & network CRUD, registry login, service control, `df`, a Prune Center, and guarded kernel/DNS management. - **Templates & Compose import** — saved run recipes + built-in starters, and `compose.yaml` import that opens editable, prefilled Run forms for each service. - **Persistent history** — SwiftData-backed events and metrics powering a per-container History tab and a system-wide Activity view (Swift Charts). From 9648e7d7adf7b9f15798ffc9bc4c7a4cc2da019b Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 05:01:48 +0100 Subject: [PATCH 07/53] Stabilize image pages and release notes --- .../Features/Settings/ReleaseNotesView.swift | 27 +- .../ToolbarPanels/ToolbarImageGroupCard.swift | 294 +++++++++++++----- .../Contained/Support/UpdaterController.swift | 5 +- .../UpdaterControllerTests.swift | 21 ++ .../20260702-refresh-stats-performance.md | 2 + 5 files changed, 262 insertions(+), 87 deletions(-) diff --git a/Sources/Contained/Features/Settings/ReleaseNotesView.swift b/Sources/Contained/Features/Settings/ReleaseNotesView.swift index 18ccb351..afed785e 100644 --- a/Sources/Contained/Features/Settings/ReleaseNotesView.swift +++ b/Sources/Contained/Features/Settings/ReleaseNotesView.swift @@ -8,15 +8,10 @@ struct ReleaseNotesView: View { var onClose: (() -> Void)? var body: some View { - LazyVStack(spacing: 0) { - HStack { - Text(title).font(.headline) - Spacer() - if let onClose { - Button("Done") { onClose() } - } + VStack(spacing: 0) { + SheetHeader(title: title, cancelHelp: "Done") { + onClose?() } - .padding() Divider() HTMLView(html: html) } @@ -38,10 +33,18 @@ private struct HTMLView: NSViewRepresentable { diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index dfe6a16b..d8a612fc 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -20,6 +20,8 @@ struct ToolbarImageGroupCard: View { @State private var page: ImageDetailPage = .tags @State private var tagTarget = "" @State private var tagBusy = false + @State private var confirmingPushReference: String? + @State private var pushStartedReference: String? enum ImageDetailPage: Hashable { case tags @@ -28,16 +30,31 @@ struct ToolbarImageGroupCard: View { case push(String) } + private enum PushAction { + case push + case openRegistries + case tag + case none + } + + private struct PushState { + var title: String + var message: String + var detail: String? + var symbol: String + var tint: Color + var action: PushAction + } + private var spring: Animation { .spring(response: 0.42, dampingFraction: 0.86) } var body: some View { Group { if isExpanded { - expandedContent + rootCard .resourceCardFloatingControls(when: true) { imagePageControls } - .morphPanelSize(size(for: page)) .morphPanelPlacement(.anchored) .animation(spring, value: page) } else { @@ -55,15 +72,14 @@ struct ToolbarImageGroupCard: View { } message: { Text("Unused images aren't referenced by any container. “All” also removes dangling layers.") } - } - - @ViewBuilder - private var expandedContent: some View { - switch page { - case .tags: rootCard - case .history(let ref): historyPage(ref) - case .tag(let source): tagPage(source) - case .push(let ref): pushPage(ref) + .confirmationDialog("Push \(Format.shortImage(confirmingPushReference ?? ""))?", + isPresented: pushConfirmationBinding, + presenting: confirmingPushReference) { reference in + Button("Push", role: .none) { + pushStartedReference = reference + } + } message: { reference in + Text("This publishes \(Format.shortImage(reference)) to its registry. The registry may still reject the push if your account cannot write to that repository.") } } @@ -82,7 +98,7 @@ struct ToolbarImageGroupCard: View { onTap: onTap) { cardHeader(group, image: image, style: resolved) } bodyContent: { - tagList(group) + imageBody(group) } footerLeading: { HStack(spacing: Tokens.ResourceCard.padding) { imageFooterTagCount(group) @@ -96,43 +112,21 @@ struct ToolbarImageGroupCard: View { // MARK: Detail sub-pages - private func size(for page: ImageDetailPage) -> CGSize { - switch page { - case .tags: return Tokens.PanelSize.imageDetail - case .history: return Tokens.SheetSize.inspector - case .tag: return Tokens.PanelSize.imageTag - case .push: return Tokens.SheetSize.console - } - } - - private func subPageScaffold(symbol: String, title: String, subtitle: String?, - @ViewBuilder content: @escaping () -> C) -> some View { - ResourceGlassCard(size: .medium, - isExpanded: true, - showsFooter: false, - elevated: false) { - ResourceCardHeader { - ResourceCardIconChip(symbol: symbol, tint: .accentColor) - } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { - ResourceCardTitleText(text: title) - if let subtitle { - ResourceCardMonospacedSubtitleText(text: subtitle) - } - } - } trailing: { - EmptyView() + @ViewBuilder + private func imageBody(_ group: LocalImageTagGroup) -> some View { + if !isExpanded { + tagList(group) + } else { + switch page { + case .tags: + tagList(group) + case .history(let ref): + historyPage(ref) + case .tag(let source): + tagPage(source) + case .push(let ref): + pushPage(ref) } - } bodyContent: { - content() - .padding(Tokens.Space.s) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } footerLeading: { - EmptyView() - } footerActions: { - EmptyView() - } widget: { - EmptyView() } } @@ -140,13 +134,13 @@ struct ToolbarImageGroupCard: View { let image = group.images.first { $0.reference == reference } ?? primaryImage(group) let variant = image?.variants.first(where: \.isRunnable) ?? image?.variants.first let history = variant?.config?.history ?? [] - return subPageScaffold(symbol: "clock.arrow.circlepath", title: "History", - subtitle: Format.shortImage(reference)) { + return imagePageBody(title: "History", subtitle: Format.shortImage(reference)) { if history.isEmpty { ContentUnavailableView("No history", systemImage: "clock", description: Text("This image records no layer history.")) + .frame(maxWidth: .infinity, minHeight: 220) } else { - ScrollView { + ResourceCardInsetSection { LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { ForEach(Array(history.enumerated()), id: \.offset) { _, entry in LazyVStack(alignment: .leading, spacing: Tokens.Space.xxs) { @@ -163,46 +157,164 @@ struct ToolbarImageGroupCard: View { .subtleTileBackground() } } - .padding(Tokens.Space.s) } - .scrollEdgeEffectStyle(.soft, for: .all) } } } private func tagPage(_ source: String) -> some View { - subPageScaffold(symbol: "tag", title: "Add tag", subtitle: Format.shortImage(source)) { - LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { - PanelSection { - PanelField(label: "Source") { - Text(Format.shortImage(source)).foregroundStyle(.secondary) - } - PanelField(label: "New reference") { - TextField("", text: $tagTarget, prompt: Text("e.g. myrepo/app:v1")) - .textFieldStyle(.roundedBorder) - .onSubmit { submitTag(source: source) } - } + imagePageBody(title: "Add tag", subtitle: Format.shortImage(source)) { + ResourceCardInsetSection { + PanelField(label: "Source") { + Text(Format.shortImage(source)).foregroundStyle(.secondary) } - HStack { - Spacer() - if tagBusy { ProgressView().controlSize(.small) } - Button { submitTag(source: source) } label: { Label("Add Tag", systemImage: "checkmark") } - .buttonStyle(.glassProminent) - .disabled(tagTarget.trimmingCharacters(in: .whitespaces).isEmpty || tagBusy) + PanelField(label: "New reference") { + TextField("", text: $tagTarget, prompt: Text("e.g. ghcr.io/me/app:v1")) + .textFieldStyle(.roundedBorder) + .onSubmit { submitTag(source: source) } } } + HStack { + Spacer() + if tagBusy { ProgressView().controlSize(.small) } + Button { submitTag(source: source) } label: { Label("Add Tag", systemImage: "checkmark") } + .buttonStyle(.glassProminent) + .disabled(tagTarget.trimmingCharacters(in: .whitespaces).isEmpty || tagBusy) + } } } private func pushPage(_ reference: String) -> some View { - subPageScaffold(symbol: "arrow.up.circle", title: "Push image", - subtitle: Format.shortImage(reference)) { - if let client = app.client { + imagePageBody(title: "Push image", subtitle: Format.shortImage(reference)) { + if pushStartedReference == reference, let client = app.client { StreamConsole(stream: { client.streamPush(reference) }) + .frame(minHeight: 260) } else { - ContentUnavailableView("Runtime unavailable", systemImage: "exclamationmark.triangle") + pushReadiness(reference) + } + } + .task { await app.refreshRegistries() } + } + + private func imagePageBody(title: String, subtitle: String?, + @ViewBuilder content: @escaping () -> C) -> some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardTitleText(text: title) + if let subtitle { + ResourceCardMonospacedSubtitleText(text: subtitle) + } + } + .padding(.horizontal, Tokens.Space.s) + content() } + .padding(Tokens.Space.s) + .frame(maxWidth: .infinity, alignment: .leading) + } + .scrollEdgeEffectStyle(.soft, for: .all) + } + + private func pushReadiness(_ reference: String) -> some View { + let readiness = pushState(for: reference) + return ResourceCardInsetSection { + HStack(alignment: .top, spacing: Tokens.Space.s) { + Image(systemName: readiness.symbol) + .font(.title3) + .foregroundStyle(readiness.tint) + .frame(width: Tokens.IconSize.rowIconColumn) + VStack(alignment: .leading, spacing: Tokens.Space.xs) { + Text(readiness.title) + .font(.headline) + Text(readiness.message) + .font(.callout) + .foregroundStyle(.secondary) + if let detail = readiness.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: Tokens.Space.s) + } + HStack { + Spacer() + switch readiness.action { + case .push: + Button { + confirmingPushReference = reference + } label: { + Label("Push", systemImage: "arrow.up.circle") + } + .buttonStyle(.glassProminent) + .disabled(app.client == nil) + case .openRegistries: + Button { + onClose() + ui.openSettings(to: .registries) + } label: { + Label("Open Registries", systemImage: "key") + } + case .tag: + Button { + withAnimation(spring) { page = .tag(reference) } + } label: { + Label("Add Tag", systemImage: "tag") + } + case .none: + EmptyView() + } + } + } + } + + private func pushState(for reference: String) -> PushState { + guard app.client != nil else { + return PushState(title: "Runtime unavailable", + message: "Start the container service before pushing images.", + detail: nil, + symbol: "exclamationmark.triangle", + tint: .orange, + action: .none) } + + let parsed = RegistryImageReference.parse(reference) + let registry = displayRegistry(parsed.registry) + + guard !parsed.isDigestReference else { + return PushState(title: "Tag required", + message: "Digest references cannot be pushed directly.", + detail: "Add a writable tag such as ghcr.io/me/app:v1, then push that tag.", + symbol: "tag", + tint: .orange, + action: .tag) + } + + if normalizedRegistryHost(parsed.registry) == "docker.io", + parsed.repository.hasPrefix("library/") { + return PushState(title: "Writable namespace required", + message: "This tag points at Docker Hub's library namespace.", + detail: "Add a tag under a namespace you control before pushing.", + symbol: "tag", + tint: .orange, + action: .tag) + } + + guard let login = matchingRegistryLogin(for: parsed.registry) else { + return PushState(title: "Registry sign-in required", + message: "Sign in to \(registry) before pushing this image.", + detail: "Contained checks for a saved container registry login before starting a push.", + symbol: "key", + tint: .orange, + action: .openRegistries) + } + + return PushState(title: "Ready to push", + message: "Signed in to \(registry)\(login.username.map { " as \($0)" } ?? "").", + detail: "The registry will still enforce write permission for \(parsed.repository).", + symbol: "checkmark.circle.fill", + tint: .green, + action: .push) } private func submitTag(source: String) { @@ -277,6 +389,9 @@ struct ToolbarImageGroupCard: View { private func selectPage(_ item: ImageDetailPage) { guard page != item else { return } + if case .push = item {} else { + pushStartedReference = nil + } withAnimation(spring) { page = item } } @@ -489,6 +604,37 @@ struct ToolbarImageGroupCard: View { Binding(get: { deletingReference != nil }, set: { if !$0 { deletingReference = nil } }) } + private var pushConfirmationBinding: Binding { + Binding(get: { confirmingPushReference != nil }, + set: { if !$0 { confirmingPushReference = nil } }) + } + + private func matchingRegistryLogin(for registry: String) -> RegistryLogin? { + let normalized = normalizedRegistryHost(registry) + return app.registries.first { normalizedRegistryHost($0.host) == normalized } + } + + private func normalizedRegistryHost(_ host: String) -> String { + var value = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if let url = URL(string: value), let urlHost = url.host { + value = urlHost + } + value = value + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + value = String(value.split(separator: "/").first ?? Substring(value)) + switch value { + case "registry-1.docker.io", "index.docker.io", "docker.io": + return "docker.io" + default: + return value + } + } + + private func displayRegistry(_ registry: String) -> String { + normalizedRegistryHost(registry) == "docker.io" ? "docker.io" : registry + } + private func delete(_ reference: String) async { guard let client = app.client else { return } do { diff --git a/Sources/Contained/Support/UpdaterController.swift b/Sources/Contained/Support/UpdaterController.swift index 8890082d..581d32d2 100644 --- a/Sources/Contained/Support/UpdaterController.swift +++ b/Sources/Contained/Support/UpdaterController.swift @@ -252,7 +252,10 @@ enum ChangelogSection { }.first guard let start else { return nil } let end = lines[(start + 1)...].firstIndex { $0.hasPrefix("## ") } ?? lines.endIndex - return lines[(start + 1).. String { diff --git a/Tests/ContainedAppTests/UpdaterControllerTests.swift b/Tests/ContainedAppTests/UpdaterControllerTests.swift index 0abe1aff..c13ea71f 100644 --- a/Tests/ContainedAppTests/UpdaterControllerTests.swift +++ b/Tests/ContainedAppTests/UpdaterControllerTests.swift @@ -110,6 +110,27 @@ struct UpdaterControllerTests { #expect(html.contains("Version-level note.")) } + @Test func inAppReleaseNotesSkipEmptyBuildChanges() throws { + let changelog = """ + # Changelog + + ## [Unreleased] + + ## [1.0.0] - Version Notes + + ### Added + + - Version-level note. + """ + + let html = try #require(ChangelogSection.releaseNotesHTML(version: "1.0.0-nightly.999+abcdef", + from: changelog)) + + #expect(!html.contains("

Changes Since Last Nightly

")) + #expect(html.contains("

Full Release Notes

")) + #expect(html.contains("Version-level note.")) + } + @Test func appBundleChangelogURLUsesContentsResources() throws { let root = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("ContainedUpdaterTests-\(UUID().uuidString)") diff --git a/changes/unreleased/20260702-refresh-stats-performance.md b/changes/unreleased/20260702-refresh-stats-performance.md index cfd34982..6d5f3331 100644 --- a/changes/unreleased/20260702-refresh-stats-performance.md +++ b/changes/unreleased/20260702-refresh-stats-performance.md @@ -10,6 +10,7 @@ - Added a metric normalization setting, letting users switch CPU/memory percentages between per-container limits and Apple container's machine CPU/memory resources. - Made scrollable and resource-sized app panels lazy by default, including container tabs, settings pages, image/tag panels, system/activity/history views, and package scaffolds that can host long content. - Switched singular image detail expansion to the same single-surface card path as containers, leaving morph panels for true toolbar panels instead of wrapping expanded image cards in panel chrome. +- Kept image detail page switches inside one stable expanded card body and made Push require registry readiness plus explicit confirmation before starting the stream. - Warmed container/image customizer sheets from the already-rendered style state and cached local image tag grouping so opening style popovers no longer rebuilds large resource views first. - Deferred heavyweight expanded-card pages until their tab selection settles, avoiding transient Logs/Terminal/Stats work while users rapidly switch pages. - Kept lightweight History/Files expanded-card pages immediate and shortened heavyweight page deferral so page switching feels snappier without eagerly loading Logs/Terminal/Stats. @@ -19,4 +20,5 @@ - Hardened embedded terminal teardown so rapid card/tab switching does not leave stale `container exec --tty` children behind. - Coalesced image-panel appearance refreshes so page/panel navigation does not force duplicate image-list reloads while users click through the UI. - Reduced runtime layout churn by replacing the container grid's bound card-frame preference with coalesced frame tracking and clamping morph panel geometry before it reaches SwiftUI frames. +- Skipped empty changelog sections in the in-app What's New parser so nightly builds do not render a blank "Changes Since Last Nightly" block before full release notes. - Added diagnostic timing around refresh, stats streaming/sampling, image list refreshes, and image update sweeps so future sluggishness has concrete evidence before tuning. From 1bb5f4b7185f7aca5a34d6bf9b4b838488fde0ae Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 05:04:49 +0100 Subject: [PATCH 08/53] Anchor image page controls in card header --- .../Navigation/ToolbarPanels/ToolbarImageGroupCard.swift | 9 +++++---- changes/unreleased/20260702-refresh-stats-performance.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index d8a612fc..98e83666 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -52,9 +52,6 @@ struct ToolbarImageGroupCard: View { Group { if isExpanded { rootCard - .resourceCardFloatingControls(when: true) { - imagePageControls - } .morphPanelPlacement(.anchored) .animation(spring, value: page) } else { @@ -353,7 +350,11 @@ struct ToolbarImageGroupCard: View { ResourceCardSubtitleText(text: repositoryOwner(group.primaryReference)) } } trailing: { - EmptyView() + if isExpanded { + imagePageControls + } else { + EmptyView() + } } } diff --git a/changes/unreleased/20260702-refresh-stats-performance.md b/changes/unreleased/20260702-refresh-stats-performance.md index 6d5f3331..b8fbd3ae 100644 --- a/changes/unreleased/20260702-refresh-stats-performance.md +++ b/changes/unreleased/20260702-refresh-stats-performance.md @@ -10,7 +10,7 @@ - Added a metric normalization setting, letting users switch CPU/memory percentages between per-container limits and Apple container's machine CPU/memory resources. - Made scrollable and resource-sized app panels lazy by default, including container tabs, settings pages, image/tag panels, system/activity/history views, and package scaffolds that can host long content. - Switched singular image detail expansion to the same single-surface card path as containers, leaving morph panels for true toolbar panels instead of wrapping expanded image cards in panel chrome. -- Kept image detail page switches inside one stable expanded card body and made Push require registry readiness plus explicit confirmation before starting the stream. +- Kept image detail page switches inside one stable expanded card body, anchored the page controls inside the card header, and made Push require registry readiness plus explicit confirmation before starting the stream. - Warmed container/image customizer sheets from the already-rendered style state and cached local image tag grouping so opening style popovers no longer rebuilds large resource views first. - Deferred heavyweight expanded-card pages until their tab selection settles, avoiding transient Logs/Terminal/Stats work while users rapidly switch pages. - Kept lightweight History/Files expanded-card pages immediate and shortened heavyweight page deferral so page switching feels snappier without eagerly loading Logs/Terminal/Stats. From 8cdf4ffce6ecaf573ed891ae41841b9c3ed83aca Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 05:07:52 +0100 Subject: [PATCH 09/53] Stabilize history charts and image page switching --- .../History/ContainerHistoryTab.swift | 69 ++++++++++++------- .../ToolbarPanels/ToolbarImageGroupCard.swift | 6 +- .../20260702-refresh-stats-performance.md | 1 + 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/Sources/Contained/History/ContainerHistoryTab.swift b/Sources/Contained/History/ContainerHistoryTab.swift index b1018967..88aed25c 100644 --- a/Sources/Contained/History/ContainerHistoryTab.swift +++ b/Sources/Contained/History/ContainerHistoryTab.swift @@ -74,33 +74,35 @@ private struct ContainerHistoryWindow: View { .frame(maxWidth: .infinity, minHeight: 200) } else { chartCard("CPU", unit: percentUnit) { - Chart(samples) { sample in - LineMark(x: .value("Time", sample.timestamp), - y: .value("CPU", historyValue(.cpu, sample) * 100)) - .foregroundStyle(Color.accentColor) - .interpolationMethod(.monotone) + Chart(samples) { sample in + LineMark(x: .value("Time", sample.timestamp), + y: .value("CPU", percentHistoryValue(.cpu, sample))) + .foregroundStyle(Color.accentColor) + .interpolationMethod(.monotone) + } + .percentHistoryScale() } - } - chartCard("Memory", unit: percentUnit) { - Chart(samples) { sample in - AreaMark(x: .value("Time", sample.timestamp), - y: .value("Memory", historyValue(.memory, sample) * 100)) - .foregroundStyle(Color.accentColor.opacity(Tokens.Chart.areaOpacity)) + chartCard("Memory", unit: percentUnit) { + Chart(samples) { sample in + AreaMark(x: .value("Time", sample.timestamp), + y: .value("Memory", percentHistoryValue(.memory, sample))) + .foregroundStyle(Color.accentColor.opacity(Tokens.Chart.areaOpacity)) + } + .percentHistoryScale() } - } - chartCard("Network", unit: "KB/s") { - Chart(samples) { sample in - LineMark(x: .value("Time", sample.timestamp), - y: .value("Rx", sample.netRxBytesPerSec / 1024), - series: .value("Dir", "Rx")) - .foregroundStyle(.green) - LineMark(x: .value("Time", sample.timestamp), - y: .value("Tx", sample.netTxBytesPerSec / 1024), - series: .value("Dir", "Tx")) - .foregroundStyle(.orange) + chartCard("Network", unit: "KB/s") { + Chart(samples) { sample in + LineMark(x: .value("Time", sample.timestamp), + y: .value("Rx", sample.netRxBytesPerSec / 1024), + series: .value("Dir", "Rx")) + .foregroundStyle(.green) + LineMark(x: .value("Time", sample.timestamp), + y: .value("Tx", sample.netTxBytesPerSec / 1024), + series: .value("Dir", "Tx")) + .foregroundStyle(.orange) + } } } - } if !events.isEmpty { LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { @@ -123,6 +125,7 @@ private struct ContainerHistoryWindow: View { chart() .frame(height: Tokens.Chart.height) .chartXAxis { AxisMarks(values: .automatic(desiredCount: Tokens.Chart.axisDesiredCount)) } + .transaction { transaction in transaction.animation = nil } } } @@ -140,11 +143,31 @@ private struct ContainerHistoryWindow: View { memoryFallbackBytes: memoryFallbackBytes) } + private func percentHistoryValue(_ metric: GraphMetric, _ sample: MetricSample) -> Double { + min(max(historyValue(metric, sample), 0), 1) * 100 + } + private var memoryFallbackBytes: UInt64 { UInt64(max(0, samples.map(\.memoryBytes).max() ?? 0)) } } +private extension View { + func percentHistoryScale() -> some View { + self + .chartYScale(domain: 0...100) + .chartYAxis { + AxisMarks(position: .leading, values: [0, 25, 50, 75, 100]) { value in + AxisGridLine() + AxisTick() + if let percent = value.as(Double.self) { + AxisValueLabel("\(Int(percent))%") + } + } + } + } +} + /// One row in an event log (used by the history tab and the system Activity view). struct EventRow: View { let event: EventRecord diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 98e83666..0c36bfde 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -393,7 +393,11 @@ struct ToolbarImageGroupCard: View { if case .push = item {} else { pushStartedReference = nil } - withAnimation(spring) { page = item } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + page = item + } } private func imageFooterInfo(_ status: ImageUpdateStatus) -> some View { diff --git a/changes/unreleased/20260702-refresh-stats-performance.md b/changes/unreleased/20260702-refresh-stats-performance.md index b8fbd3ae..a1932467 100644 --- a/changes/unreleased/20260702-refresh-stats-performance.md +++ b/changes/unreleased/20260702-refresh-stats-performance.md @@ -4,6 +4,7 @@ - Retested always-live card sparklines on Swift Charts after reducing stats update fan-out and per-card history churn. - Made live sparkline data read chronologically by plotting a full-width, zero-padded window of latest contiguous samples with linear defaults, while keeping user-selectable smoothing available and preventing one noisy sample from rescaling the whole graph. - Anchored CPU and memory sparklines to the real 0...100% scale, while keeping network and disk widgets auto-scaled for readable throughput shapes. +- Anchored CPU and memory history charts to the real 0...100% scale too, so different percentage levels no longer look identical because of per-window chart auto-scaling. - Stopped materializing every metric history array for every card frame; cards now pull only the active widget series from shared sample buffers. - Tightened card metric binding so each widget renders only its own per-container series instead of falling back to another graph's samples when a buffer is missing. - Normalized card and stats-page CPU/memory values against each container's configured CPU and memory limits while leaving network and disk throughput as raw bytes-per-second rates. From 2ee4d588058e8ef788b142b45242f1df770fa7fd Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 05:14:14 +0100 Subject: [PATCH 10/53] Fix history chart render loop --- .../History/ContainerHistoryTab.swift | 76 ++++++++++++++----- .../ContainersStoreRefreshTests.swift | 39 ++++++++++ 2 files changed, 94 insertions(+), 21 deletions(-) diff --git a/Sources/Contained/History/ContainerHistoryTab.swift b/Sources/Contained/History/ContainerHistoryTab.swift index 88aed25c..4c04f19e 100644 --- a/Sources/Contained/History/ContainerHistoryTab.swift +++ b/Sources/Contained/History/ContainerHistoryTab.swift @@ -67,37 +67,41 @@ private struct ContainerHistoryWindow: View { } var body: some View { + let chartPoints = HistoryChartPoint.points(from: samples.map(MetricSampleSnapshot.init), + snapshot: snapshot, + normalization: normalization) + LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { - if samples.isEmpty { + if chartPoints.isEmpty { ContentUnavailableView("No history yet", systemImage: "chart.xyaxis.line", description: Text("Resource samples accumulate while the container runs.")) .frame(maxWidth: .infinity, minHeight: 200) } else { chartCard("CPU", unit: percentUnit) { - Chart(samples) { sample in - LineMark(x: .value("Time", sample.timestamp), - y: .value("CPU", percentHistoryValue(.cpu, sample))) + Chart(chartPoints) { point in + LineMark(x: .value("Time", point.timestamp), + y: .value("CPU", point.cpuPercent)) .foregroundStyle(Color.accentColor) .interpolationMethod(.monotone) } .percentHistoryScale() } chartCard("Memory", unit: percentUnit) { - Chart(samples) { sample in - AreaMark(x: .value("Time", sample.timestamp), - y: .value("Memory", percentHistoryValue(.memory, sample))) + Chart(chartPoints) { point in + AreaMark(x: .value("Time", point.timestamp), + y: .value("Memory", point.memoryPercent)) .foregroundStyle(Color.accentColor.opacity(Tokens.Chart.areaOpacity)) } .percentHistoryScale() } chartCard("Network", unit: "KB/s") { - Chart(samples) { sample in - LineMark(x: .value("Time", sample.timestamp), - y: .value("Rx", sample.netRxBytesPerSec / 1024), + Chart(chartPoints) { point in + LineMark(x: .value("Time", point.timestamp), + y: .value("Rx", point.netRxKBPerSec), series: .value("Dir", "Rx")) .foregroundStyle(.green) - LineMark(x: .value("Time", sample.timestamp), - y: .value("Tx", sample.netTxBytesPerSec / 1024), + LineMark(x: .value("Time", point.timestamp), + y: .value("Tx", point.netTxKBPerSec), series: .value("Dir", "Tx")) .foregroundStyle(.orange) } @@ -135,20 +139,50 @@ private struct ContainerHistoryWindow: View { case .machine: return "% of machine" } } +} + +struct HistoryChartPoint: Identifiable, Equatable { + let id: Int + let timestamp: Date + let cpuPercent: Double + let memoryPercent: Double + let netRxKBPerSec: Double + let netTxKBPerSec: Double + + static func points(from samples: [MetricSampleSnapshot], + snapshot: ContainerSnapshot, + normalization: StatsNormalizationContext) -> [HistoryChartPoint] { + guard !samples.isEmpty else { return [] } + + let memoryFallbackBytes = samples.reduce(UInt64(0)) { current, sample in + max(current, bytes(from: sample.memoryBytes)) + } + let cpuLimit = normalization.cpuLimit(for: snapshot) + let memoryLimit = normalization.memoryLimitBytes(for: snapshot, fallback: memoryFallbackBytes) + + return samples.enumerated().map { index, sample in + let cpu = sanitized(sample.cpuFraction) / cpuLimit + let memory = memoryLimit > 0 ? sanitized(sample.memoryBytes) / Double(memoryLimit) : 0 + return HistoryChartPoint(id: index, + timestamp: sample.timestamp, + cpuPercent: percent(cpu), + memoryPercent: percent(memory), + netRxKBPerSec: sanitized(sample.netRxBytesPerSec) / 1024, + netTxKBPerSec: sanitized(sample.netTxBytesPerSec) / 1024) + } + } - private func historyValue(_ metric: GraphMetric, _ sample: MetricSample) -> Double { - metric.value(from: sample, - snapshot: snapshot, - normalization: normalization, - memoryFallbackBytes: memoryFallbackBytes) + private static func percent(_ fraction: Double) -> Double { + min(max(sanitized(fraction), 0), 1) * 100 } - private func percentHistoryValue(_ metric: GraphMetric, _ sample: MetricSample) -> Double { - min(max(historyValue(metric, sample), 0), 1) * 100 + private static func sanitized(_ value: Double) -> Double { + guard value.isFinite, value > 0 else { return 0 } + return value } - private var memoryFallbackBytes: UInt64 { - UInt64(max(0, samples.map(\.memoryBytes).max() ?? 0)) + private static func bytes(from value: Double) -> UInt64 { + UInt64(min(sanitized(value), Double(UInt64.max))) } } diff --git a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift index 7be1b36a..597322a5 100644 --- a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift +++ b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift @@ -226,6 +226,45 @@ struct ContainersStoreRefreshTests { memoryFallbackBytes: 2_048) == 0.125) } + @Test func historyChartPointsUsePlainSamplesWithCurrentNormalization() { + let snapshot = Self.snapshot(id: "web", cpus: 2, memoryInBytes: 0) + let samples = [ + MetricSample(timestamp: Date(timeIntervalSinceReferenceDate: 1_000), + containerID: "web", + cpuFraction: 1, + memoryBytes: 512, + netRxBytesPerSec: 1_024, + netTxBytesPerSec: 2_048, + diskReadBytesPerSec: 0, + diskWriteBytesPerSec: 0), + MetricSample(timestamp: Date(timeIntervalSinceReferenceDate: 1_060), + containerID: "web", + cpuFraction: 0.5, + memoryBytes: 1_024, + netRxBytesPerSec: 2_048, + netTxBytesPerSec: 4_096, + diskReadBytesPerSec: 0, + diskWriteBytesPerSec: 0) + ].map(MetricSampleSnapshot.init) + + let containerPoints = HistoryChartPoint.points(from: samples, + snapshot: snapshot, + normalization: .containerSpecific) + #expect(containerPoints.map { $0.cpuPercent } == [50, 25]) + #expect(containerPoints.map { $0.memoryPercent } == [50, 100]) + #expect(containerPoints.map { $0.netRxKBPerSec } == [1, 2]) + #expect(containerPoints.map { $0.netTxKBPerSec } == [2, 4]) + + let machine = StatsNormalizationContext(mode: .machine, + machineCPUs: 4, + machineMemoryBytes: 2_048) + let machinePoints = HistoryChartPoint.points(from: samples, + snapshot: snapshot, + normalization: machine) + #expect(machinePoints.map { $0.cpuPercent } == [25, 12.5]) + #expect(machinePoints.map { $0.memoryPercent } == [25, 50]) + } + @Test func changingStatsNormalizationRebuildsDisplayHistories() { let store = ContainersStore() let start = Date(timeIntervalSinceReferenceDate: 1_000) From 7c6550f7a813367f7458347438add2c83179fb68 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 05:21:57 +0100 Subject: [PATCH 11/53] Centralize resource card anatomy --- Packages/ContainedDesignSystem/README.md | 15 ++- .../ContainedDesignSystem.md | 11 +- .../ResourceCardChrome.swift | 20 +++ .../ResourceGlassCard.swift | 116 ++++++++++++------ .../ToolbarPanels/PaletteResultCard.swift | 4 +- .../ToolbarPanels/ToolbarImageGroupCard.swift | 6 +- .../ResourceCardLayoutPolicyTests.swift | 22 ++++ changes/unreleased/20260702-card-anatomy.md | 2 + docs/wiki/Design-System.md | 10 ++ 9 files changed, 153 insertions(+), 53 deletions(-) create mode 100644 Tests/ContainedDesignSystemTests/ResourceCardLayoutPolicyTests.swift create mode 100644 changes/unreleased/20260702-card-anatomy.md diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index df4bae6e..c586a955 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -43,8 +43,9 @@ This package currently depends only on platform frameworks available to a macOS - `GlassButton`, `GlassButtonItem`, `GlassButtonInputItem`, `GlassRowMenu`, and toolbar control helpers. - `ResourceGlassCard`, `ResourceCardPageControls`, `ResourceCardFooterChip`, - `ResourceCardFooterButton`, `ResourceCardInsetSection`, and other - `ResourceCard*` pieces for repeated card layouts and card-local controls. + `ResourceCardFooterButton`, `ResourceCardWidgetGroup`, + `ResourceCardInsetSection`, and other `ResourceCard*` pieces for repeated + card layouts and card-local controls. Use `resourceCardFloatingControls` and `resourceCardProgressOverlay` for card overlays instead of app-local `.overlay` recipes. - `ActivityStatusView` with `ActivityStatusPresentation`, where callers provide @@ -142,6 +143,13 @@ struct DesignSystemExample: View { Keep card-local controls in the package. Feature views should provide plain values and actions instead of restyling footer chips or expanded-card page rails: +`ResourceGlassCard` owns card anatomy: + +- the header is always sticky and visible +- the body appears only when the card is expanded +- the widget is sticky for `.large` cards and becomes body content for `.medium` +- the footer is sticky for `.medium` and `.large` cards and becomes body content for `.small` + ```swift struct CardControlsExample: View { @State private var page = "overview" @@ -188,13 +196,12 @@ struct CardControlsExample: View { scale: .fraction) .frame(height: Tokens.ResourceCard.sparklineHeight) } - .overlay(alignment: .topTrailing) { + .resourceCardFloatingControls(when: true) { ResourceCardPageControls(items: pages, selection: page, tint: .accentColor, onSelect: { page = $0 }, onClose: {}) - .padding(Tokens.Space.s) } } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 14d96e8f..7294c4d1 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -86,6 +86,13 @@ struct DesignSystemExample: View { Use the packaged card controls for page rails, widget chips, and icon actions. Feature views should pass plain IDs, labels, metric strings, and actions: +`ResourceGlassCard` owns card anatomy: + +- the header is always sticky and visible +- the body appears only when the card is expanded +- the widget is sticky for `.large` cards and becomes body content for `.medium` +- the footer is sticky for `.medium` and `.large` cards and becomes body content for `.small` + ```swift struct CardControlsExample: View { @State private var page = "overview" @@ -132,13 +139,12 @@ struct CardControlsExample: View { scale: .fraction) .frame(height: Tokens.ResourceCard.sparklineHeight) } - .overlay(alignment: .topTrailing) { + .resourceCardFloatingControls(when: true) { ResourceCardPageControls(items: pages, selection: page, tint: .accentColor, onSelect: { page = $0 }, onClose: {}) - .padding(Tokens.Space.s) } } } @@ -196,6 +202,7 @@ DesignInputSurface { - ``ResourceCardIconChip`` - ``ResourceBadgeText`` - ``ResourceCardFooterMini`` +- ``ResourceCardWidgetGroup`` - ``ResourceCardFooterChip`` - ``ResourceCardFooterButton`` - ``ResourceCardInsetSection`` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift index 64ad18b7..7acdc505 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift @@ -256,6 +256,26 @@ public struct ResourceCardFooterGroup: View { } } +/// A horizontal group for content in a card widget band. +public struct ResourceCardWidgetGroup: View { + public var spacing: CGFloat + @ViewBuilder public var content: () -> Content + + public init(spacing: CGFloat = Tokens.ResourceCard.padding, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.content = content + } + + public var body: some View { + HStack(spacing: spacing) { + content() + } + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + /// A reusable footer band with a left group, right group, and optional widget stacked above them. public struct ResourceCardFooter: View { public var showWidget: Bool diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift index c4b6d306..527f4a58 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift @@ -3,8 +3,17 @@ import SwiftUI public enum ResourceCardSize { case small, medium, large - public var showsFooter: Bool { self != .small } - public var showsWidget: Bool { self == .large } + /// Footer actions stay visible in compact/expanded chrome for medium and large cards. + /// Small cards move footer content into the expanded body so the closed card remains header-only. + public var keepsFooterSticky: Bool { self != .small } + /// Widgets stay visible as card chrome only for large cards. Medium cards keep the widget as + /// part of the expanded body, preserving the simpler closed-card silhouette. + public var keepsWidgetSticky: Bool { self == .large } + public var embedsFooterInBody: Bool { self == .small } + public var embedsWidgetInBody: Bool { self == .medium } + + public var showsFooter: Bool { keepsFooterSticky } + public var showsWidget: Bool { keepsWidgetSticky } } public enum ResourceCardExpandedMetrics { @@ -37,6 +46,7 @@ public struct ResourceGlassCard some View { + private var stickyHeader: some View { + header() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var expandedBody: some View { VStack(alignment: .leading, spacing: 0) { bodyContent() .frame(maxWidth: .infinity, alignment: .leading) - if showsFooter && size == .small { - footer(showWidget: false, showActions: controlsVisible) + if shouldEmbedWidgetInBody { + embeddedWidget + } + if shouldEmbedFooterInBody { + stickyFooter(showActions: controlsVisible) } } } - private func footer(showWidget: Bool, showActions: Bool) -> some View { - ResourceCardFooter(showWidget: showWidget, actionsVisible: showActions) { + private var stickyWidget: some View { + widgetBand + } + + private var embeddedWidget: some View { + widgetBand + } + + private func stickyFooter(showActions: Bool) -> some View { + ResourceCardFooter(actionsVisible: showActions) { footerLeading() } trailing: { footerActions() } widget: { - widget() - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Tokens.ResourceCard.padding) - .padding(.bottom, Tokens.ResourceCard.padding) + EmptyView() } } + + private var widgetBand: some View { + widget() + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Tokens.ResourceCard.padding) + .padding(.bottom, Tokens.ResourceCard.padding) + } + + private var shouldShowStickyWidget: Bool { + showsWidget && hasWidgetSlot && size.keepsWidgetSticky + } + + private var shouldEmbedWidgetInBody: Bool { + showsWidget && hasWidgetSlot && size.embedsWidgetInBody + } + + private var shouldShowStickyFooter: Bool { + showsFooter && hasFooterSlot && size.keepsFooterSticky + } + + private var shouldEmbedFooterInBody: Bool { + showsFooter && hasFooterSlot && size.embedsFooterInBody + } + + private var hasWidgetSlot: Bool { + Widget.self != EmptyView.self + } + + private var hasFooterSlot: Bool { + FooterLeading.self != EmptyView.self || FooterActions.self != EmptyView.self + } } private struct ResourceCardMaterialSurface: ViewModifier { diff --git a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift index 63e86f08..8432cb58 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift @@ -227,7 +227,7 @@ struct PaletteResultCard: View { } private func containerPaletteWidget(_ snapshot: ContainerSnapshot) -> some View { - HStack(spacing: Tokens.ResourceCard.padding) { + ResourceCardWidgetGroup { ResourceCardFooterMini { Image(systemName: "clock").font(.caption2) } text: { @@ -244,8 +244,6 @@ struct PaletteResultCard: View { ResourceCardMetricText(text: Format.shortImage(snapshot.image)) } } - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 0c36bfde..24295cd7 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -97,10 +97,8 @@ struct ToolbarImageGroupCard: View { } bodyContent: { imageBody(group) } footerLeading: { - HStack(spacing: Tokens.ResourceCard.padding) { - imageFooterTagCount(group) - imageFooterInfo(status) - } + imageFooterTagCount(group) + imageFooterInfo(status) } footerActions: { imageFooterActions(group) } diff --git a/Tests/ContainedDesignSystemTests/ResourceCardLayoutPolicyTests.swift b/Tests/ContainedDesignSystemTests/ResourceCardLayoutPolicyTests.swift new file mode 100644 index 00000000..e656ff6f --- /dev/null +++ b/Tests/ContainedDesignSystemTests/ResourceCardLayoutPolicyTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import ContainedDesignSystem + +@Suite("Resource card layout policy") +struct ResourceCardLayoutPolicyTests { + @Test func sizeControlsStickyAndEmbeddedCardSlots() { + #expect(ResourceCardSize.small.keepsFooterSticky == false) + #expect(ResourceCardSize.small.embedsFooterInBody == true) + #expect(ResourceCardSize.small.keepsWidgetSticky == false) + #expect(ResourceCardSize.small.embedsWidgetInBody == false) + + #expect(ResourceCardSize.medium.keepsFooterSticky == true) + #expect(ResourceCardSize.medium.embedsFooterInBody == false) + #expect(ResourceCardSize.medium.keepsWidgetSticky == false) + #expect(ResourceCardSize.medium.embedsWidgetInBody == true) + + #expect(ResourceCardSize.large.keepsFooterSticky == true) + #expect(ResourceCardSize.large.embedsFooterInBody == false) + #expect(ResourceCardSize.large.keepsWidgetSticky == true) + #expect(ResourceCardSize.large.embedsWidgetInBody == false) + } +} diff --git a/changes/unreleased/20260702-card-anatomy.md b/changes/unreleased/20260702-card-anatomy.md new file mode 100644 index 00000000..a8baa1d4 --- /dev/null +++ b/changes/unreleased/20260702-card-anatomy.md @@ -0,0 +1,2 @@ +- Centralized resource-card anatomy in `ContainedDesignSystem`: headers stay visible, expanded bodies own density-specific embedded content, large widgets remain sticky, medium widgets move into the body, and small footers move into the body. +- Routed remaining card widget/footer grouping through packaged card primitives and updated design-system docs with the sticky/body slot contract. diff --git a/docs/wiki/Design-System.md b/docs/wiki/Design-System.md index f771ddf7..f35a76b1 100644 --- a/docs/wiki/Design-System.md +++ b/docs/wiki/Design-System.md @@ -123,6 +123,7 @@ Recommended internal pieces: - `ResourceCardSubtitleText` or `ResourceCardMonospacedSubtitleText` for metadata - `ResourceBadgeText` for compact state or kind labels - `ResourceCardFooterMini` for small footer actions and metrics +- `ResourceCardWidgetGroup` for horizontal widget metadata - `ResourceCardFooterChip`, `ResourceCardFooterButton`, and `ResourceCardPageControls` for card-local controls - `ResourceCardInsetSection` for charts, lists, and read-only groups inside an @@ -135,6 +136,15 @@ Recommended internal pieces: Use `isSelected` instead of inventing a second selection ring. Use `elevated: false` for cards inside already-elevated morph panels. +`ResourceGlassCard` owns the card anatomy: + +- the header is always visible and stays outside the expanding body +- the body appears only while expanded +- widgets stay sticky on `.large` cards and move into the expanded body on + `.medium` +- footers stay sticky on `.medium` and `.large` cards and move into the + expanded body on `.small` + Do not create a second `ResourceGlassCard` or direct surface modifier inside an expanded card body unless the nested object is itself an independent resource card, such as an image tag row. In-card content should go through From f88527b2d09d91f6af36e1fc86735cb33c51bb12 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 05:27:34 +0100 Subject: [PATCH 12/53] Align card controls and sticky footer chrome --- Packages/ContainedDesignSystem/README.md | 13 +++++-------- .../ContainedDesignSystem.md | 13 +++++-------- .../ContainedDesignSystem/ResourceCardChrome.swift | 1 + .../ContainedDesignSystem/ResourceGlassCard.swift | 5 +++++ .../Features/Containers/ContainerCard.swift | 9 +++++---- changes/unreleased/20260702-card-anatomy.md | 2 +- docs/wiki/Design-System.md | 1 + 7 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index c586a955..34d4ff3b 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -171,7 +171,11 @@ struct CardControlsExample: View { } content: { ResourceCardTitleText(text: "web") } trailing: { - EmptyView() + ResourceCardPageControls(items: pages, + selection: page, + tint: .accentColor, + onSelect: { page = $0 }, + onClose: {}) } } bodyContent: { ResourceCardInsetSection(title: "Details") { @@ -196,13 +200,6 @@ struct CardControlsExample: View { scale: .fraction) .frame(height: Tokens.ResourceCard.sparklineHeight) } - .resourceCardFloatingControls(when: true) { - ResourceCardPageControls(items: pages, - selection: page, - tint: .accentColor, - onSelect: { page = $0 }, - onClose: {}) - } } } ``` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 7294c4d1..424f5dec 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -114,7 +114,11 @@ struct CardControlsExample: View { } content: { ResourceCardTitleText(text: "web") } trailing: { - EmptyView() + ResourceCardPageControls(items: pages, + selection: page, + tint: .accentColor, + onSelect: { page = $0 }, + onClose: {}) } } bodyContent: { ResourceCardInsetSection(title: "Details") { @@ -139,13 +143,6 @@ struct CardControlsExample: View { scale: .fraction) .frame(height: Tokens.ResourceCard.sparklineHeight) } - .resourceCardFloatingControls(when: true) { - ResourceCardPageControls(items: pages, - selection: page, - tint: .accentColor, - onSelect: { page = $0 }, - onClose: {}) - } } } ``` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift index 7acdc505..9d63847e 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift @@ -328,6 +328,7 @@ public struct ResourceCardFooter: V .padding(.top, topPadding) .padding(.bottom, bottomPadding) } + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift index 527f4a58..89798fe3 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift @@ -158,14 +158,18 @@ public struct ResourceGlassCard Date: Thu, 2 Jul 2026 05:31:04 +0100 Subject: [PATCH 13/53] Keep card page controls layout-stable --- Packages/ContainedDesignSystem/README.md | 2 ++ .../ContainedDesignSystem.docc/ContainedDesignSystem.md | 2 ++ .../ContainedDesignSystem/ResourceCardChrome.swift | 1 + .../Contained/Features/Containers/ContainerCard.swift | 6 +----- .../Navigation/ToolbarPanels/ToolbarImageGroupCard.swift | 9 +++------ changes/unreleased/20260702-card-anatomy.md | 2 +- docs/wiki/Design-System.md | 3 ++- 7 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 34d4ff3b..33dbf826 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -146,6 +146,8 @@ values and actions instead of restyling footer chips or expanded-card page rails `ResourceGlassCard` owns card anatomy: - the header is always sticky and visible +- page controls stay mounted in the header trailing slot and use `controlsReveal` + for visibility, avoiding text reflow during expansion - the body appears only when the card is expanded - the widget is sticky for `.large` cards and becomes body content for `.medium` - the footer is sticky for `.medium` and `.large` cards and becomes body content for `.small` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 424f5dec..faa35795 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -89,6 +89,8 @@ Feature views should pass plain IDs, labels, metric strings, and actions: `ResourceGlassCard` owns card anatomy: - the header is always sticky and visible +- page controls stay mounted in the header trailing slot and use `controlsReveal` + for visibility, avoiding text reflow during expansion - the body appears only when the card is expanded - the widget is sticky for `.large` cards and becomes body content for `.medium` - the footer is sticky for `.medium` and `.large` cards and becomes body content for `.small` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift index 9d63847e..24f0fcec 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift @@ -227,6 +227,7 @@ public struct ResourceCardPageControls: View { GlassButtonItem(systemName: "xmark", help: "Close", action: onClose) } .opacity(controlsReveal) + .allowsHitTesting(controlsReveal > 0.01) .animation(.easeOut(duration: 0.18), value: controlsReveal) } } diff --git a/Sources/Contained/Features/Containers/ContainerCard.swift b/Sources/Contained/Features/Containers/ContainerCard.swift index a70ca39a..be4f7a3e 100644 --- a/Sources/Contained/Features/Containers/ContainerCard.swift +++ b/Sources/Contained/Features/Containers/ContainerCard.swift @@ -175,11 +175,7 @@ struct ContainerCard: View { } content: { headerTitleBlock } trailing: { - if isExpanded { - headerButtons(controlsReveal: controlsVisible ? 1 : 0) - } else { - EmptyView() - } + headerButtons(controlsReveal: isExpanded && controlsVisible ? 1 : 0) } } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 24295cd7..80e5fa3b 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -348,18 +348,15 @@ struct ToolbarImageGroupCard: View { ResourceCardSubtitleText(text: repositoryOwner(group.primaryReference)) } } trailing: { - if isExpanded { - imagePageControls - } else { - EmptyView() - } + imagePageControls(controlsReveal: isExpanded ? 1 : 0) } } - private var imagePageControls: some View { + private func imagePageControls(controlsReveal: Double) -> some View { ResourceCardPageControls(items: imagePageControlItems, selection: page, tint: resolvedImageTint, + controlsReveal: controlsReveal, onSelect: selectPage, onClose: onClose) } diff --git a/changes/unreleased/20260702-card-anatomy.md b/changes/unreleased/20260702-card-anatomy.md index b15f0e53..ff39e8a1 100644 --- a/changes/unreleased/20260702-card-anatomy.md +++ b/changes/unreleased/20260702-card-anatomy.md @@ -1,2 +1,2 @@ - Centralized resource-card anatomy in `ContainedDesignSystem`: headers stay visible, expanded bodies own density-specific embedded content, large widgets remain sticky, medium widgets move into the body, and small footers move into the body. -- Routed remaining card widget/footer grouping through packaged card primitives, moved expanded page controls into the shared header trailing slot, and updated design-system docs with the sticky/body slot contract. +- Routed remaining card widget/footer grouping through packaged card primitives, moved expanded page controls into the shared header trailing slot, kept that slot layout-stable during expansion, and updated design-system docs with the sticky/body slot contract. diff --git a/docs/wiki/Design-System.md b/docs/wiki/Design-System.md index 3c1bd897..ec657733 100644 --- a/docs/wiki/Design-System.md +++ b/docs/wiki/Design-System.md @@ -139,7 +139,8 @@ false` for cards inside already-elevated morph panels. `ResourceGlassCard` owns the card anatomy: - the header is always visible and stays outside the expanding body -- page controls belong in the header trailing slot, not in app-local overlays +- page controls belong in the header trailing slot, stay mounted, and use + `controlsReveal` instead of app-local overlays or conditional trailing views - the body appears only while expanded - widgets stay sticky on `.large` cards and move into the expanded body on `.medium` From ce555ecae61091e1e6637c9cb189967df931c8c9 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 05:38:21 +0100 Subject: [PATCH 14/53] Stabilize resource card header text --- Packages/ContainedDesignSystem/README.md | 12 +++-- .../ContainedDesignSystem.md | 9 ++-- .../ResourceCardChrome.swift | 48 +++++++++++++++++++ .../Features/Containers/ContainerCard.swift | 11 ++--- .../Containers/Creation/CreationPages.swift | 14 +++--- .../Features/Images/RegistryImageSearch.swift | 11 +++-- .../History/ContainerHistoryTab.swift | 3 +- .../Contained/Navigation/ClassicShell.swift | 3 +- .../ToolbarPanels/PaletteResultCard.swift | 15 ++++-- .../ToolbarPanels/ToolbarImageGroupCard.swift | 6 ++- .../ToolbarPanels/ToolbarResourcePanels.swift | 6 ++- .../ToolbarPanels/ToolbarUpdatesPanel.swift | 3 +- changes/unreleased/20260702-card-anatomy.md | 1 + docs/wiki/Command-Palette.md | 5 +- docs/wiki/Design-System.md | 1 + 15 files changed, 108 insertions(+), 40 deletions(-) diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 33dbf826..154a47d1 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -42,8 +42,8 @@ This package currently depends only on platform frameworks available to a macOS `PageScaffold` for app-neutral scaffolding. - `GlassButton`, `GlassButtonItem`, `GlassButtonInputItem`, `GlassRowMenu`, and toolbar control helpers. -- `ResourceGlassCard`, `ResourceCardPageControls`, `ResourceCardFooterChip`, - `ResourceCardFooterButton`, `ResourceCardWidgetGroup`, +- `ResourceGlassCard`, `ResourceCardHeaderTextBlock`, `ResourceCardPageControls`, + `ResourceCardFooterChip`, `ResourceCardFooterButton`, `ResourceCardWidgetGroup`, `ResourceCardInsetSection`, and other `ResourceCard*` pieces for repeated card layouts and card-local controls. Use `resourceCardFloatingControls` and `resourceCardProgressOverlay` for @@ -112,9 +112,9 @@ struct DesignSystemExample: View { ResourceCardIconChip(symbol: "shippingbox.fill", tint: tint.color) } content: { - VStack(alignment: .leading, - spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardTitleText(text: "web") + } subtitle: { ResourceCardSubtitleText(text: "nginx:latest") } } trailing: { @@ -171,7 +171,9 @@ struct CardControlsExample: View { ResourceCardHeader { ResourceCardIconChip(symbol: "shippingbox.fill") } content: { - ResourceCardTitleText(text: "web") + ResourceCardHeaderTextBlock { + ResourceCardTitleText(text: "web") + } } trailing: { ResourceCardPageControls(items: pages, selection: page, diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index faa35795..841afbdd 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -63,9 +63,9 @@ struct DesignSystemExample: View { ResourceCardIconChip(symbol: "shippingbox.fill", tint: tint.color) } content: { - VStack(alignment: .leading, - spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardTitleText(text: "web") + } subtitle: { ResourceCardSubtitleText(text: "nginx:latest") } } trailing: { @@ -114,7 +114,9 @@ struct CardControlsExample: View { ResourceCardHeader { ResourceCardIconChip(symbol: "shippingbox.fill") } content: { - ResourceCardTitleText(text: "web") + ResourceCardHeaderTextBlock { + ResourceCardTitleText(text: "web") + } } trailing: { ResourceCardPageControls(items: pages, selection: page, @@ -198,6 +200,7 @@ DesignInputSurface { - ``ResourceGlassCard`` - ``ResourceCardHeader`` +- ``ResourceCardHeaderTextBlock`` - ``ResourceCardIconChip`` - ``ResourceBadgeText`` - ``ResourceCardFooterMini`` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift index 24f0fcec..05e24f9d 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift @@ -24,14 +24,62 @@ public struct ResourceCardHeader: public var body: some View { HStack(alignment: .top, spacing: spacing) { leading() + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) content() .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + .transaction { transaction in + transaction.animation = nil + } trailing() + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) } .padding(padding) } } +/// Stable title/subtitle lane for `ResourceCardHeader`. +/// +/// Use this for card header text so the title and metadata stay anchored to the leading chip while +/// expanded-card controls appear, disappear, or change page selection. +public struct ResourceCardHeaderTextBlock: View { + public var spacing: CGFloat + @ViewBuilder public var title: () -> Title + @ViewBuilder public var subtitle: () -> Subtitle + + public init(spacing: CGFloat = Tokens.ResourceCard.compactTextSpacing, + @ViewBuilder title: @escaping () -> Title, + @ViewBuilder subtitle: @escaping () -> Subtitle) { + self.spacing = spacing + self.title = title + self.subtitle = subtitle + } + + public var body: some View { + VStack(alignment: .leading, spacing: spacing) { + title() + subtitle() + } + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + .transaction { transaction in + transaction.animation = nil + } + } +} + +public extension ResourceCardHeaderTextBlock where Subtitle == EmptyView { + init(spacing: CGFloat = Tokens.ResourceCard.compactTextSpacing, + @ViewBuilder title: @escaping () -> Title) { + self.init(spacing: spacing, title: title) { + EmptyView() + } + } +} + /// A small reusable footer mini: optional icon + optional text, aligned on one baseline. public struct ResourceCardFooterMini: View { public var spacing: CGFloat diff --git a/Sources/Contained/Features/Containers/ContainerCard.swift b/Sources/Contained/Features/Containers/ContainerCard.swift index be4f7a3e..ed9b62c2 100644 --- a/Sources/Contained/Features/Containers/ContainerCard.swift +++ b/Sources/Contained/Features/Containers/ContainerCard.swift @@ -189,13 +189,10 @@ struct ContainerCard: View { } private var headerTitleBlock: some View { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { - HStack(alignment: .top, spacing: Tokens.Space.xs) { - ResourceCardTitleText(text: name) - } - HStack(alignment: .top, spacing: Tokens.Space.xs) { - ResourceCardMonospacedSubtitleText(text: Format.shortImage(snapshot.image)) - } + ResourceCardHeaderTextBlock { + ResourceCardTitleText(text: name) + } subtitle: { + ResourceCardMonospacedSubtitleText(text: Format.shortImage(snapshot.image)) } } diff --git a/Sources/Contained/Features/Containers/Creation/CreationPages.swift b/Sources/Contained/Features/Containers/Creation/CreationPages.swift index 75ea5d17..8769c2a6 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationPages.swift +++ b/Sources/Contained/Features/Containers/Creation/CreationPages.swift @@ -251,8 +251,9 @@ private struct CreationResourceForm: View { ResourceCardHeader { ResourceCardIconChip(symbol: symbol, tint: .accentColor) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardTitleText(text: title) + } subtitle: { ResourceCardSubtitleText(text: subtitle) } } trailing: { @@ -299,16 +300,17 @@ private struct CreationChoiceCard: View { ResourceCardHeader { ResourceCardIconChip(symbol: symbol, tint: .accentColor) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardTitleText(text: title) + } subtitle: { if let subtitle, !subtitle.isEmpty { ResourceCardMonospacedSubtitleText(text: subtitle) } } - } trailing: { - GlassListRowChevron() + } trailing: { + GlassListRowChevron() + } + .contentShape(Rectangle()) } - .contentShape(Rectangle()) } } -} diff --git a/Sources/Contained/Features/Images/RegistryImageSearch.swift b/Sources/Contained/Features/Images/RegistryImageSearch.swift index 1e77d83f..5d8216c8 100644 --- a/Sources/Contained/Features/Images/RegistryImageSearch.swift +++ b/Sources/Contained/Features/Images/RegistryImageSearch.swift @@ -156,17 +156,18 @@ struct RegistryImageSearch: View { ResourceCardHeader { ResourceCardIconChip(symbol: symbol, tint: .accentColor) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardTitleText(text: title) + } subtitle: { if let subtitle, !subtitle.isEmpty { ResourceCardSubtitleText(text: subtitle) } } - } trailing: { - accessory() + } trailing: { + accessory() + } + .contentShape(Rectangle()) } - .contentShape(Rectangle()) - } } // MARK: Search plumbing diff --git a/Sources/Contained/History/ContainerHistoryTab.swift b/Sources/Contained/History/ContainerHistoryTab.swift index 4c04f19e..d71e34bb 100644 --- a/Sources/Contained/History/ContainerHistoryTab.swift +++ b/Sources/Contained/History/ContainerHistoryTab.swift @@ -220,8 +220,9 @@ struct EventRow: View { tint: event.kind.tint, backgroundOpacity: Tokens.ResourceCard.iconEmphasisBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardTitleText(text: event.message) + } subtitle: { HStack(spacing: Tokens.Space.xs) { ResourceBadgeText(text: event.kind.rawValue.capitalized) ResourceCardSubtitleText(text: subtitle) diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/Contained/Navigation/ClassicShell.swift index 55896f30..42cb52d4 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/Contained/Navigation/ClassicShell.swift @@ -266,8 +266,9 @@ private struct NetworksPage: View { ResourceCardIconChip(symbol: network.isBuiltin ? "network.badge.shield.half.filled" : "network", tint: network.isBuiltin ? .secondary : .accentColor) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardTitleText(text: network.name) + } subtitle: { ResourceCardSubtitleText(text: networkSubtitle(network)) } } trailing: { diff --git a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift index 8432cb58..4a709654 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift @@ -50,13 +50,14 @@ struct PaletteResultCard: View { ? Tokens.ResourceCard.iconSelectedBackgroundOpacity : Tokens.ResourceCard.iconBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { HStack(spacing: Tokens.Space.s) { ResourceCardTitleText(text: item.title) ResourceBadgeText(text: item.kind.rawValue, font: .caption2.weight(.semibold), foreground: selected ? .accentColor : .secondary) } + } subtitle: { if let subtitle = item.subtitle, !subtitle.isEmpty { ResourceCardSubtitleText(text: subtitle) } @@ -93,13 +94,14 @@ struct PaletteResultCard: View { ? Tokens.ResourceCard.iconSelectedBackgroundOpacity : Tokens.ResourceCard.iconBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { HStack(spacing: Tokens.Space.s) { ResourceCardTitleText(text: name) ResourceBadgeText(text: snapshot.state.rawValue.capitalized, font: .caption2.weight(.semibold), foreground: snapshot.state == .running ? .green : .secondary) } + } subtitle: { ResourceCardMonospacedSubtitleText(text: Format.shortImage(snapshot.image)) } } trailing: { @@ -144,11 +146,12 @@ struct PaletteResultCard: View { ? Tokens.ResourceCard.iconSelectedBackgroundOpacity : Tokens.ResourceCard.iconBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { HStack(spacing: Tokens.Space.s) { ResourceCardMonospacedTitleText(text: Format.shortImage(reference)) ResourceBadgeText(text: "Tag", font: .caption2.weight(.semibold)) } + } subtitle: { ResourceCardSubtitleText(text: repositoryTitle(reference)) } } trailing: { @@ -184,11 +187,12 @@ struct PaletteResultCard: View { ? Tokens.ResourceCard.iconSelectedBackgroundOpacity : Tokens.ResourceCard.iconBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { HStack(spacing: Tokens.Space.s) { ResourceCardTitleText(text: title) ResourceBadgeText(text: subtitle, font: .caption2.weight(.semibold)) } + } subtitle: { ResourceCardSubtitleText(text: footer) } } trailing: { @@ -209,13 +213,14 @@ struct PaletteResultCard: View { ResourceCardHeader { DesignTintSwatch(color: tint.color, followsAppAccent: tint.followsAppAccent) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { HStack(spacing: Tokens.Space.s) { ResourceCardTitleText(text: tint.displayName) ResourceBadgeText(text: app.settings.accentTint == tint ? "Current" : "Tint", font: .caption2.weight(.semibold), foreground: app.settings.accentTint == tint ? .accentColor : .secondary) } + } subtitle: { ResourceCardSubtitleText(text: item.title) } } trailing: { diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 80e5fa3b..377f8b7d 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -343,8 +343,9 @@ struct ToolbarImageGroupCard: View { imageChip(style) } } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardTitleText(text: repositoryTitle(group.primaryReference)) + } subtitle: { ResourceCardSubtitleText(text: repositoryOwner(group.primaryReference)) } } trailing: { @@ -467,8 +468,9 @@ struct ToolbarImageGroupCard: View { style: style, target: .imageTag(reference: reference, groupID: group.id)) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardMonospacedTitleText(text: Format.shortImage(reference)) + } subtitle: { ResourceCardSubtitleText(text: repositoryName(reference)) } } trailing: { diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift index bb7b495f..9695e5d6 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift @@ -130,8 +130,9 @@ struct ToolbarTemplatesPanel: View { tint: .secondary, backgroundOpacity: Tokens.ResourceCard.iconEmphasisBackgroundOpacity) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardTitleText(text: "No templates") + } subtitle: { ResourceCardSubtitleText(text: "Save a container's settings as a template from the create form.") } } trailing: { @@ -145,8 +146,9 @@ struct ToolbarTemplatesPanel: View { ResourceCardHeader { ResourceCardIconChip(symbol: "bookmark.fill", tint: .accentColor) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardTitleText(text: template.name) + } subtitle: { ResourceCardMonospacedSubtitleText(text: Format.shortImage(template.spec?.image ?? "—")) } } trailing: { diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift index 78087d3f..d70ed666 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift @@ -101,8 +101,9 @@ struct ToolbarUpdatesPanel: View { ResourceCardHeader { ResourceCardIconChip(symbol: "checkmark.circle.fill", tint: .green) } content: { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + ResourceCardHeaderTextBlock { ResourceCardTitleText(text: "No images") + } subtitle: { ResourceCardSubtitleText(text: "Pull or build an image to see it here") } } trailing: { diff --git a/changes/unreleased/20260702-card-anatomy.md b/changes/unreleased/20260702-card-anatomy.md index ff39e8a1..1668e2c8 100644 --- a/changes/unreleased/20260702-card-anatomy.md +++ b/changes/unreleased/20260702-card-anatomy.md @@ -1,2 +1,3 @@ - Centralized resource-card anatomy in `ContainedDesignSystem`: headers stay visible, expanded bodies own density-specific embedded content, large widgets remain sticky, medium widgets move into the body, and small footers move into the body. - Routed remaining card widget/footer grouping through packaged card primitives, moved expanded page controls into the shared header trailing slot, kept that slot layout-stable during expansion, and updated design-system docs with the sticky/body slot contract. +- Added a packaged sticky header text lane so card titles and subtitles stay anchored with the leading icon chip while expanded controls and page selections change. diff --git a/docs/wiki/Command-Palette.md b/docs/wiki/Command-Palette.md index a5b41383..ec606204 100644 --- a/docs/wiki/Command-Palette.md +++ b/docs/wiki/Command-Palette.md @@ -30,8 +30,9 @@ resource panel, it should be discoverable from `CommandPalette.swift`. Results should use the design system rather than custom row chrome: - use `ResourceGlassCard` for every result card -- use `ResourceCardHeader`, `ResourceCardIconChip`, `ResourceCardTitleText`, - `ResourceCardSubtitleText`, and `ResourceBadgeText` inside cards +- use `ResourceCardHeader`, `ResourceCardHeaderTextBlock`, `ResourceCardIconChip`, + `ResourceCardTitleText`, `ResourceCardSubtitleText`, and `ResourceBadgeText` + inside cards - use `PaletteItemVisual` for anything that can be represented visually - render containers, image groups, tags, volumes, networks, and tints as mini cards using their actual app styling diff --git a/docs/wiki/Design-System.md b/docs/wiki/Design-System.md index ec657733..5c05e469 100644 --- a/docs/wiki/Design-System.md +++ b/docs/wiki/Design-System.md @@ -118,6 +118,7 @@ palette result cards. Recommended internal pieces: - `ResourceCardHeader` for the top row +- `ResourceCardHeaderTextBlock` for sticky title/subtitle lanes inside headers - `ResourceCardIconChip` for icons and symbols - `ResourceCardTitleText` for names - `ResourceCardSubtitleText` or `ResourceCardMonospacedSubtitleText` for metadata From 734340a1612f274213b9c965f1a9fdf905cfd40e Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 05:51:49 +0100 Subject: [PATCH 15/53] Introduce unified resource card API --- Packages/ContainedDesignSystem/README.md | 81 ++--- .../ContainedDesignSystem.md | 77 +++-- .../ContainedDesignSystem/ResourceCard.swift | 279 ++++++++++++++++++ .../Features/Containers/ContainerCard.swift | 66 ++--- .../Containers/Creation/CreationPages.swift | 68 +++-- .../Features/Images/RegistryImageSearch.swift | 36 ++- .../History/ContainerHistoryTab.swift | 49 +-- .../Contained/Navigation/ClassicShell.swift | 29 +- .../ToolbarPanels/PaletteResultCard.swift | 259 ++++++++-------- .../ToolbarPanels/ToolbarImageGroupCard.swift | 119 ++++---- .../ToolbarPanels/ToolbarResourcePanels.swift | 68 +++-- .../ToolbarPanels/ToolbarUpdatesPanel.swift | 31 +- changes/unreleased/20260702-card-anatomy.md | 1 + docs/wiki/Architecture.md | 2 +- docs/wiki/Command-Palette.md | 7 +- docs/wiki/Contributing.md | 2 +- docs/wiki/Design-System.md | 28 +- 17 files changed, 770 insertions(+), 432 deletions(-) create mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCard.swift diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 154a47d1..4c86d33a 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -42,10 +42,11 @@ This package currently depends only on platform frameworks available to a macOS `PageScaffold` for app-neutral scaffolding. - `GlassButton`, `GlassButtonItem`, `GlassButtonInputItem`, `GlassRowMenu`, and toolbar control helpers. -- `ResourceGlassCard`, `ResourceCardHeaderTextBlock`, `ResourceCardPageControls`, - `ResourceCardFooterChip`, `ResourceCardFooterButton`, `ResourceCardWidgetGroup`, - `ResourceCardInsetSection`, and other `ResourceCard*` pieces for repeated - card layouts and card-local controls. +- `ResourceCard`, `ResourceCardPages`, `ResourceCardFooterChip`, + `ResourceCardFooterButton`, `ResourceCardWidgetGroup`, `ResourceCardInsetSection`, + and other `ResourceCard*` pieces for repeated card layouts and card-local controls. + App code should use `ResourceCard`; `ResourceGlassCard` and header primitives are + lower-level package composition pieces. Use `resourceCardFloatingControls` and `resourceCardProgressOverlay` for card overlays instead of app-local `.overlay` recipes. - `ActivityStatusView` with `ActivityStatusPresentation`, where callers provide @@ -107,19 +108,26 @@ struct DesignSystemExample: View { } } - ResourceGlassCard(size: .small, elevated: false) { - ResourceCardHeader { - ResourceCardIconChip(symbol: "shippingbox.fill", - tint: tint.color) - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: "web") - } subtitle: { - ResourceCardSubtitleText(text: "nginx:latest") - } - } trailing: { - GlassListRowChevron() - } + ResourceCard(size: .small, + elevated: false, + title: "web", + subtitle: "nginx:latest") { + ResourceCardIconChip(symbol: "shippingbox.fill", + tint: tint.color) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + GlassListRowChevron() + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } .selectionFill() @@ -140,14 +148,14 @@ struct DesignSystemExample: View { ## Resource Card Controls -Keep card-local controls in the package. Feature views should provide plain -values and actions instead of restyling footer chips or expanded-card page rails: +Keep card-local controls in the package. Feature views provide plain values and +actions instead of assembling headers, footer groups, or expanded-card page rails: -`ResourceGlassCard` owns card anatomy: +`ResourceCard` owns card anatomy: - the header is always sticky and visible -- page controls stay mounted in the header trailing slot and use `controlsReveal` - for visibility, avoiding text reflow during expansion +- page controls are declared through `ResourceCardPages`, stay mounted in the + header trailing slot, and use `controlsReveal` for visibility - the body appears only when the card is expanded - the widget is sticky for `.large` cards and becomes body content for `.medium` - the footer is sticky for `.medium` and `.large` cards and becomes body content for `.small` @@ -167,20 +175,21 @@ struct CardControlsExample: View { ] var body: some View { - ResourceGlassCard(size: .large) { - ResourceCardHeader { - ResourceCardIconChip(symbol: "shippingbox.fill") - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: "web") - } - } trailing: { - ResourceCardPageControls(items: pages, - selection: page, - tint: .accentColor, - onSelect: { page = $0 }, - onClose: {}) - } + ResourceCard(size: .large, + title: "web", + subtitle: "nginx:latest", + pages: ResourceCardPages(items: pages, + selection: page, + tint: .accentColor, + onSelect: { page = $0 }, + onClose: {})) { + ResourceCardIconChip(symbol: "shippingbox.fill") + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() } bodyContent: { ResourceCardInsetSection(title: "Details") { ResourceCardSubtitleText(text: "Ready") diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 841afbdd..0d5f2b16 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -58,19 +58,26 @@ struct DesignSystemExample: View { } } - ResourceGlassCard(size: .small, elevated: false) { - ResourceCardHeader { - ResourceCardIconChip(symbol: "shippingbox.fill", - tint: tint.color) - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: "web") - } subtitle: { - ResourceCardSubtitleText(text: "nginx:latest") - } - } trailing: { - GlassListRowChevron() - } + ResourceCard(size: .small, + elevated: false, + title: "web", + subtitle: "nginx:latest") { + ResourceCardIconChip(symbol: "shippingbox.fill", + tint: tint.color) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + GlassListRowChevron() + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } .selectionFill() } @@ -83,14 +90,15 @@ struct DesignSystemExample: View { ## Resource Card Controls -Use the packaged card controls for page rails, widget chips, and icon actions. -Feature views should pass plain IDs, labels, metric strings, and actions: +Use `ResourceCard` for cards. Feature views pass plain titles, subtitles, page +IDs, labels, metric strings, and actions; the package owns header/body/widget/footer +placement: -`ResourceGlassCard` owns card anatomy: +`ResourceCard` owns card anatomy: - the header is always sticky and visible -- page controls stay mounted in the header trailing slot and use `controlsReveal` - for visibility, avoiding text reflow during expansion +- page controls are declared through `ResourceCardPages`, stay mounted in the + header trailing slot, and use `controlsReveal` for visibility - the body appears only when the card is expanded - the widget is sticky for `.large` cards and becomes body content for `.medium` - the footer is sticky for `.medium` and `.large` cards and becomes body content for `.small` @@ -110,20 +118,21 @@ struct CardControlsExample: View { ] var body: some View { - ResourceGlassCard(size: .large) { - ResourceCardHeader { - ResourceCardIconChip(symbol: "shippingbox.fill") - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: "web") - } - } trailing: { - ResourceCardPageControls(items: pages, - selection: page, - tint: .accentColor, - onSelect: { page = $0 }, - onClose: {}) - } + ResourceCard(size: .large, + title: "web", + subtitle: "nginx:latest", + pages: ResourceCardPages(items: pages, + selection: page, + tint: .accentColor, + onSelect: { page = $0 }, + onClose: {})) { + ResourceCardIconChip(symbol: "shippingbox.fill") + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() } bodyContent: { ResourceCardInsetSection(title: "Details") { ResourceCardSubtitleText(text: "Ready") @@ -198,6 +207,10 @@ DesignInputSurface { ### Resource Cards +- ``ResourceCard`` +- ``ResourceCardPages`` +- ``ResourceCardNoPage`` +- ``ResourceCardTextStyle`` - ``ResourceGlassCard`` - ``ResourceCardHeader`` - ``ResourceCardHeaderTextBlock`` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCard.swift new file mode 100644 index 00000000..47817ec0 --- /dev/null +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCard.swift @@ -0,0 +1,279 @@ +import SwiftUI + +/// Text rendering style for the built-in `ResourceCard` title and subtitle lanes. +public enum ResourceCardTextStyle { + case standard + case monospaced +} + +/// Sentinel page type used by `ResourceCard` when a card has no page controls. +public enum ResourceCardNoPage: Hashable { + case none +} + +/// Typed page-control configuration for `ResourceCard`. +public struct ResourceCardPages { + public var items: [ResourceCardPageControlItem] + public var selection: ID + public var tint: Color + public var controlsReveal: Double + public var onSelect: (ID) -> Void + public var onClose: () -> Void + + public init(items: [ResourceCardPageControlItem], + selection: ID, + tint: Color, + controlsReveal: Double = 1, + onSelect: @escaping (ID) -> Void, + onClose: @escaping () -> Void) { + self.items = items + self.selection = selection + self.tint = tint + self.controlsReveal = controlsReveal + self.onSelect = onSelect + self.onClose = onClose + } +} + +/// Source-of-truth resource card API. +/// +/// Feature code supplies semantic title/subtitle data plus optional slots; this view owns how those +/// inputs become sticky header chrome, expanded body content, widgets, and footer controls. +public struct ResourceCard: View { + public var size: ResourceCardSize + public var isExpanded: Bool + public var cornerRadiusOverride: CGFloat? + public var controlsVisible: Bool + public var isSelected: Bool + public var showsFooter: Bool + public var showsWidget: Bool + public var fill: Color? + public var fillOpacity: Double + public var gradient: Bool + public var gradientAngle: Double + public var blendMode: ColorLayerBlendMode + public var elevated: Bool + public var onTap: () -> Void + public var title: String + public var subtitle: String? + public var titleStyle: ResourceCardTextStyle + public var subtitleStyle: ResourceCardTextStyle + public var pages: ResourceCardPages? + @ViewBuilder public var icon: () -> Icon + @ViewBuilder public var titleAccessory: () -> TitleAccessory + @ViewBuilder public var subtitleAccessory: () -> SubtitleAccessory + @ViewBuilder public var headerAccessory: () -> HeaderAccessory + @ViewBuilder public var bodyContent: () -> BodyContent + @ViewBuilder public var footerLeading: () -> FooterLeading + @ViewBuilder public var footerActions: () -> FooterActions + @ViewBuilder public var widget: () -> Widget + + private var usesSelectionFill = false + + public init(size: ResourceCardSize = .small, + isExpanded: Bool = false, + cornerRadiusOverride: CGFloat? = nil, + controlsVisible: Bool = true, + isSelected: Bool = false, + showsFooter: Bool = true, + showsWidget: Bool = true, + fill: Color? = nil, + fillOpacity: Double = 0.18, + gradient: Bool = false, + gradientAngle: Double = 135, + blendMode: ColorLayerBlendMode = .softLight, + elevated: Bool = true, + onTap: @escaping () -> Void = {}, + title: String, + subtitle: String? = nil, + titleStyle: ResourceCardTextStyle = .standard, + subtitleStyle: ResourceCardTextStyle = .standard, + pages: ResourceCardPages?, + @ViewBuilder icon: @escaping () -> Icon, + @ViewBuilder titleAccessory: @escaping () -> TitleAccessory, + @ViewBuilder subtitleAccessory: @escaping () -> SubtitleAccessory, + @ViewBuilder headerAccessory: @escaping () -> HeaderAccessory, + @ViewBuilder bodyContent: @escaping () -> BodyContent, + @ViewBuilder footerLeading: @escaping () -> FooterLeading, + @ViewBuilder footerActions: @escaping () -> FooterActions, + @ViewBuilder widget: @escaping () -> Widget) { + self.size = size + self.isExpanded = isExpanded + self.cornerRadiusOverride = cornerRadiusOverride + self.controlsVisible = controlsVisible + self.isSelected = isSelected + self.showsFooter = showsFooter + self.showsWidget = showsWidget + self.fill = fill + self.fillOpacity = fillOpacity + self.gradient = gradient + self.gradientAngle = gradientAngle + self.blendMode = blendMode + self.elevated = elevated + self.onTap = onTap + self.title = title + self.subtitle = subtitle + self.titleStyle = titleStyle + self.subtitleStyle = subtitleStyle + self.pages = pages + self.icon = icon + self.titleAccessory = titleAccessory + self.subtitleAccessory = subtitleAccessory + self.headerAccessory = headerAccessory + self.bodyContent = bodyContent + self.footerLeading = footerLeading + self.footerActions = footerActions + self.widget = widget + } + + public func selectionFill(_ on: Bool = true) -> Self { + var copy = self + copy.usesSelectionFill = on + return copy + } + + public var body: some View { + ResourceGlassCard(size: size, + isExpanded: isExpanded, + cornerRadiusOverride: cornerRadiusOverride, + controlsVisible: controlsVisible, + isSelected: isSelected, + showsFooter: showsFooter, + showsWidget: showsWidget, + fill: fill, + fillOpacity: fillOpacity, + gradient: gradient, + gradientAngle: gradientAngle, + blendMode: blendMode, + elevated: elevated, + onTap: onTap) { + header + } bodyContent: { + bodyContent() + } footerLeading: { + footerLeading() + } footerActions: { + footerActions() + } widget: { + widget() + } + .selectionFill(usesSelectionFill) + } + + private var header: some View { + ResourceCardHeader { + icon() + } content: { + ResourceCardHeaderTextBlock { + HStack(spacing: Tokens.Space.s) { + titleText + titleAccessory() + } + } subtitle: { + if hasSubtitleRow { + HStack(spacing: Tokens.Space.xs) { + subtitleAccessory() + if let subtitle, !subtitle.isEmpty { + subtitleText(subtitle) + } + } + } + } + } trailing: { + HStack(spacing: Tokens.Space.s) { + if let pages { + ResourceCardPageControls(items: pages.items, + selection: pages.selection, + tint: pages.tint, + controlsReveal: pages.controlsReveal, + onSelect: pages.onSelect, + onClose: pages.onClose) + } + headerAccessory() + } + } + } + + @ViewBuilder + private var titleText: some View { + switch titleStyle { + case .standard: + ResourceCardTitleText(text: title) + case .monospaced: + ResourceCardMonospacedTitleText(text: title) + } + } + + @ViewBuilder + private func subtitleText(_ text: String) -> some View { + switch subtitleStyle { + case .standard: + ResourceCardSubtitleText(text: text) + case .monospaced: + ResourceCardMonospacedSubtitleText(text: text) + } + } + + private var hasSubtitleRow: Bool { + (subtitle?.isEmpty == false) || SubtitleAccessory.self != EmptyView.self + } +} + +public extension ResourceCard where PageID == ResourceCardNoPage { + init(size: ResourceCardSize = .small, + isExpanded: Bool = false, + cornerRadiusOverride: CGFloat? = nil, + controlsVisible: Bool = true, + isSelected: Bool = false, + showsFooter: Bool = true, + showsWidget: Bool = true, + fill: Color? = nil, + fillOpacity: Double = 0.18, + gradient: Bool = false, + gradientAngle: Double = 135, + blendMode: ColorLayerBlendMode = .softLight, + elevated: Bool = true, + onTap: @escaping () -> Void = {}, + title: String, + subtitle: String? = nil, + titleStyle: ResourceCardTextStyle = .standard, + subtitleStyle: ResourceCardTextStyle = .standard, + @ViewBuilder icon: @escaping () -> Icon, + @ViewBuilder titleAccessory: @escaping () -> TitleAccessory, + @ViewBuilder subtitleAccessory: @escaping () -> SubtitleAccessory, + @ViewBuilder headerAccessory: @escaping () -> HeaderAccessory, + @ViewBuilder bodyContent: @escaping () -> BodyContent, + @ViewBuilder footerLeading: @escaping () -> FooterLeading, + @ViewBuilder footerActions: @escaping () -> FooterActions, + @ViewBuilder widget: @escaping () -> Widget) { + self.init(size: size, + isExpanded: isExpanded, + cornerRadiusOverride: cornerRadiusOverride, + controlsVisible: controlsVisible, + isSelected: isSelected, + showsFooter: showsFooter, + showsWidget: showsWidget, + fill: fill, + fillOpacity: fillOpacity, + gradient: gradient, + gradientAngle: gradientAngle, + blendMode: blendMode, + elevated: elevated, + onTap: onTap, + title: title, + subtitle: subtitle, + titleStyle: titleStyle, + subtitleStyle: subtitleStyle, + pages: nil, + icon: icon, + titleAccessory: titleAccessory, + subtitleAccessory: subtitleAccessory, + headerAccessory: headerAccessory, + bodyContent: bodyContent, + footerLeading: footerLeading, + footerActions: footerActions, + widget: widget) + } +} diff --git a/Sources/Contained/Features/Containers/ContainerCard.swift b/Sources/Contained/Features/Containers/ContainerCard.swift index ed9b62c2..9871e500 100644 --- a/Sources/Contained/Features/Containers/ContainerCard.swift +++ b/Sources/Contained/Features/Containers/ContainerCard.swift @@ -128,18 +128,28 @@ struct ContainerCard: View { } private var cardSurface: some View { - ResourceGlassCard(size: cardSize, - isExpanded: isExpanded, - cornerRadiusOverride: cornerRadiusOverride, - controlsVisible: controlsVisible, - isSelected: isSelected, - fill: styleForDisplay.fillBackground ? styleForDisplay.color : nil, - fillOpacity: styleForDisplay.backgroundOpacity, - gradient: styleForDisplay.gradient, - gradientAngle: styleForDisplay.gradientAngle, - blendMode: styleForDisplay.backgroundBlendMode, - onTap: onTap) { - compactHeaderRow + ResourceCard(size: cardSize, + isExpanded: isExpanded, + cornerRadiusOverride: cornerRadiusOverride, + controlsVisible: controlsVisible, + isSelected: isSelected, + fill: styleForDisplay.fillBackground ? styleForDisplay.color : nil, + fillOpacity: styleForDisplay.backgroundOpacity, + gradient: styleForDisplay.gradient, + gradientAngle: styleForDisplay.gradientAngle, + blendMode: styleForDisplay.backgroundBlendMode, + onTap: onTap, + title: name, + subtitle: Format.shortImage(snapshot.image), + subtitleStyle: .monospaced, + pages: cardPages) { + iconChip + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() } bodyContent: { detailBody } footerLeading: { @@ -169,31 +179,13 @@ struct ContainerCard: View { .resourceCardProgressOverlay(when: isBusy) } - private var compactHeaderRow: some View { - ResourceCardHeader { - iconChip - } content: { - headerTitleBlock - } trailing: { - headerButtons(controlsReveal: isExpanded && controlsVisible ? 1 : 0) - } - } - - private func headerButtons(controlsReveal: Double) -> some View { - ResourceCardPageControls(items: pageControlItems, - selection: tab, - tint: tint, - controlsReveal: controlsReveal, - onSelect: selectTab, - onClose: onClose) - } - - private var headerTitleBlock: some View { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: name) - } subtitle: { - ResourceCardMonospacedSubtitleText(text: Format.shortImage(snapshot.image)) - } + private var cardPages: ResourceCardPages { + ResourceCardPages(items: pageControlItems, + selection: tab, + tint: tint, + controlsReveal: isExpanded && controlsVisible ? 1 : 0, + onSelect: selectTab, + onClose: onClose) } private var iconChip: some View { diff --git a/Sources/Contained/Features/Containers/Creation/CreationPages.swift b/Sources/Contained/Features/Containers/Creation/CreationPages.swift index 8769c2a6..23a7ab84 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationPages.swift +++ b/Sources/Contained/Features/Containers/Creation/CreationPages.swift @@ -247,18 +247,25 @@ private struct CreationResourceForm: View { var body: some View { LazyVStack(spacing: Tokens.Space.m) { - ResourceGlassCard(size: .small, elevated: false) { - ResourceCardHeader { - ResourceCardIconChip(symbol: symbol, tint: .accentColor) - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: title) - } subtitle: { - ResourceCardSubtitleText(text: subtitle) - } - } trailing: { - ResourceBadgeText(text: "new", font: .caption2.weight(.semibold)) - } + ResourceCard(size: .small, + elevated: false, + title: title, + subtitle: subtitle) { + ResourceCardIconChip(symbol: symbol, tint: .accentColor) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + ResourceBadgeText(text: "new", font: .caption2.weight(.semibold)) + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } fields() @@ -296,21 +303,28 @@ private struct CreationChoiceCard: View { var action: () -> Void var body: some View { - ResourceGlassCard(size: .small, elevated: false, onTap: action) { - ResourceCardHeader { - ResourceCardIconChip(symbol: symbol, tint: .accentColor) - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: title) - } subtitle: { - if let subtitle, !subtitle.isEmpty { - ResourceCardMonospacedSubtitleText(text: subtitle) - } - } - } trailing: { - GlassListRowChevron() - } - .contentShape(Rectangle()) + ResourceCard(size: .small, + elevated: false, + onTap: action, + title: title, + subtitle: subtitle, + subtitleStyle: .monospaced) { + ResourceCardIconChip(symbol: symbol, tint: .accentColor) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + GlassListRowChevron() + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } + .contentShape(Rectangle()) } } diff --git a/Sources/Contained/Features/Images/RegistryImageSearch.swift b/Sources/Contained/Features/Images/RegistryImageSearch.swift index 5d8216c8..5bd04052 100644 --- a/Sources/Contained/Features/Images/RegistryImageSearch.swift +++ b/Sources/Contained/Features/Images/RegistryImageSearch.swift @@ -152,22 +152,28 @@ struct RegistryImageSearch: View { subtitle: String?, action: @escaping () -> Void, @ViewBuilder accessory: @escaping () -> Accessory) -> some View { - ResourceGlassCard(size: .small, elevated: false, onTap: action) { - ResourceCardHeader { - ResourceCardIconChip(symbol: symbol, tint: .accentColor) - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: title) - } subtitle: { - if let subtitle, !subtitle.isEmpty { - ResourceCardSubtitleText(text: subtitle) - } - } - } trailing: { - accessory() - } - .contentShape(Rectangle()) + ResourceCard(size: .small, + elevated: false, + onTap: action, + title: title, + subtitle: subtitle) { + ResourceCardIconChip(symbol: symbol, tint: .accentColor) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + accessory() + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } + .contentShape(Rectangle()) } // MARK: Search plumbing diff --git a/Sources/Contained/History/ContainerHistoryTab.swift b/Sources/Contained/History/ContainerHistoryTab.swift index d71e34bb..e074f9b5 100644 --- a/Sources/Contained/History/ContainerHistoryTab.swift +++ b/Sources/Contained/History/ContainerHistoryTab.swift @@ -212,30 +212,33 @@ struct EventRow: View { @Environment(\.modelContext) private var modelContext var body: some View { - ResourceGlassCard(size: .small, - isSelected: isUnread, - elevated: elevated) { - ResourceCardHeader { - ResourceCardIconChip(symbol: event.kind.symbol, - tint: event.kind.tint, - backgroundOpacity: Tokens.ResourceCard.iconEmphasisBackgroundOpacity) - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: event.message) - } subtitle: { - HStack(spacing: Tokens.Space.xs) { - ResourceBadgeText(text: event.kind.rawValue.capitalized) - ResourceCardSubtitleText(text: subtitle) - } - } - } trailing: { - if isUnread { - DesignStatusDot(color: .accentColor) - .accessibilityLabel("Unread") - } else { - EmptyView() - } + ResourceCard(size: .small, + isSelected: isUnread, + elevated: elevated, + title: event.message, + subtitle: subtitle) { + ResourceCardIconChip(symbol: event.kind.symbol, + tint: event.kind.tint, + backgroundOpacity: Tokens.ResourceCard.iconEmphasisBackgroundOpacity) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + ResourceBadgeText(text: event.kind.rawValue.capitalized) + } headerAccessory: { + if isUnread { + DesignStatusDot(color: .accentColor) + .accessibilityLabel("Unread") + } else { + EmptyView() } + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } .selectionFill() .contextMenu { rowMenu } diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/Contained/Navigation/ClassicShell.swift index 42cb52d4..f2d4f98e 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/Contained/Navigation/ClassicShell.swift @@ -261,19 +261,20 @@ private struct NetworksPage: View { } private func networkRow(_ network: NetworkResource) -> some View { - ResourceGlassCard(size: .medium, elevated: false) { - ResourceCardHeader { - ResourceCardIconChip(symbol: network.isBuiltin ? "network.badge.shield.half.filled" : "network", - tint: network.isBuiltin ? .secondary : .accentColor) - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: network.name) - } subtitle: { - ResourceCardSubtitleText(text: networkSubtitle(network)) - } - } trailing: { - GlassRowMenu { networkMenu(network) } - } + ResourceCard(size: .medium, + elevated: false, + title: network.name, + subtitle: networkSubtitle(network)) { + ResourceCardIconChip(symbol: network.isBuiltin ? "network.badge.shield.half.filled" : "network", + tint: network.isBuiltin ? .secondary : .accentColor) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + GlassRowMenu { networkMenu(network) } + } bodyContent: { + EmptyView() } footerLeading: { if network.isBuiltin { ResourceCardFooterMini { @@ -284,6 +285,8 @@ private struct NetworksPage: View { } } footerActions: { EmptyView() + } widget: { + EmptyView() } .contextMenu { networkMenu(network) } } diff --git a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift index 4a709654..8fa6cd2b 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift @@ -37,38 +37,35 @@ struct PaletteResultCard: View { } private var plainCard: some View { - ResourceGlassCard(size: .small, - isSelected: selected, - fill: nil, - fillOpacity: selected ? Tokens.ResourceCard.selectedSubtleFillOpacity : Tokens.ResourceCard.plainFillOpacity, - elevated: false, - onTap: action) { - ResourceCardHeader { - ResourceCardIconChip(symbol: item.icon, - tint: item.tint, - backgroundOpacity: selected - ? Tokens.ResourceCard.iconSelectedBackgroundOpacity - : Tokens.ResourceCard.iconBackgroundOpacity) - } content: { - ResourceCardHeaderTextBlock { - HStack(spacing: Tokens.Space.s) { - ResourceCardTitleText(text: item.title) - ResourceBadgeText(text: item.kind.rawValue, - font: .caption2.weight(.semibold), - foreground: selected ? .accentColor : .secondary) - } - } subtitle: { - if let subtitle = item.subtitle, !subtitle.isEmpty { - ResourceCardSubtitleText(text: subtitle) - } - } - } trailing: { - accessory - } + ResourceCard(size: .small, + isSelected: selected, + fill: nil, + fillOpacity: selected ? Tokens.ResourceCard.selectedSubtleFillOpacity : Tokens.ResourceCard.plainFillOpacity, + elevated: false, + onTap: action, + title: item.title, + subtitle: item.subtitle) { + ResourceCardIconChip(symbol: item.icon, + tint: item.tint, + backgroundOpacity: selected + ? Tokens.ResourceCard.iconSelectedBackgroundOpacity + : Tokens.ResourceCard.iconBackgroundOpacity) + } titleAccessory: { + ResourceBadgeText(text: item.kind.rawValue, + font: .caption2.weight(.semibold), + foreground: selected ? .accentColor : .secondary) + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + accessory + } bodyContent: { + EmptyView() } footerLeading: { EmptyView() } footerActions: { EmptyView() + } widget: { + EmptyView() } .selectionFill() .accessibilityAddTraits(selected ? .isSelected : []) @@ -78,35 +75,31 @@ struct PaletteResultCard: View { let style = app.containerStyle(for: snapshot) let name = style.displayName(fallback: snapshot.id) let cardSize: ResourceCardSize = snapshot.state == .running ? .large : .medium - return ResourceGlassCard(size: cardSize, - isSelected: selected, - fill: style.fillBackground ? style.color : nil, - fillOpacity: selected ? Tokens.ResourceCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, - gradient: style.gradient, - gradientAngle: style.gradientAngle, - blendMode: style.backgroundBlendMode, - elevated: false, - onTap: action) { - ResourceCardHeader { - ResourceCardIconChip(symbol: style.symbol, - tint: style.color, - backgroundOpacity: selected - ? Tokens.ResourceCard.iconSelectedBackgroundOpacity - : Tokens.ResourceCard.iconBackgroundOpacity) - } content: { - ResourceCardHeaderTextBlock { - HStack(spacing: Tokens.Space.s) { - ResourceCardTitleText(text: name) - ResourceBadgeText(text: snapshot.state.rawValue.capitalized, - font: .caption2.weight(.semibold), - foreground: snapshot.state == .running ? .green : .secondary) - } - } subtitle: { - ResourceCardMonospacedSubtitleText(text: Format.shortImage(snapshot.image)) - } - } trailing: { - EmptyView() - } + return ResourceCard(size: cardSize, + isSelected: selected, + fill: style.fillBackground ? style.color : nil, + fillOpacity: selected ? Tokens.ResourceCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, + gradient: style.gradient, + gradientAngle: style.gradientAngle, + blendMode: style.backgroundBlendMode, + elevated: false, + onTap: action, + title: name, + subtitle: Format.shortImage(snapshot.image), + subtitleStyle: .monospaced) { + ResourceCardIconChip(symbol: style.symbol, + tint: style.color, + backgroundOpacity: selected + ? Tokens.ResourceCard.iconSelectedBackgroundOpacity + : Tokens.ResourceCard.iconBackgroundOpacity) + } titleAccessory: { + ResourceBadgeText(text: snapshot.state.rawValue.capitalized, + font: .caption2.weight(.semibold), + foreground: snapshot.state == .running ? .green : .secondary) + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() } bodyContent: { EmptyView() } footerLeading: { @@ -130,33 +123,31 @@ struct PaletteResultCard: View { private func imageTagCard(_ reference: String, groupID: String) -> some View { let style = app.imageGroupStyle(forID: groupID) - return ResourceGlassCard(size: .medium, - isSelected: selected, - fill: style.fillBackground ? style.color : nil, - fillOpacity: selected ? Tokens.ResourceCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, - gradient: style.gradient, - gradientAngle: style.gradientAngle, - blendMode: style.backgroundBlendMode, - elevated: false, - onTap: action) { - ResourceCardHeader { - ResourceCardIconChip(symbol: "tag", - tint: style.color, - backgroundOpacity: selected - ? Tokens.ResourceCard.iconSelectedBackgroundOpacity - : Tokens.ResourceCard.iconBackgroundOpacity) - } content: { - ResourceCardHeaderTextBlock { - HStack(spacing: Tokens.Space.s) { - ResourceCardMonospacedTitleText(text: Format.shortImage(reference)) - ResourceBadgeText(text: "Tag", font: .caption2.weight(.semibold)) - } - } subtitle: { - ResourceCardSubtitleText(text: repositoryTitle(reference)) - } - } trailing: { - EmptyView() - } + return ResourceCard(size: .medium, + isSelected: selected, + fill: style.fillBackground ? style.color : nil, + fillOpacity: selected ? Tokens.ResourceCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, + gradient: style.gradient, + gradientAngle: style.gradientAngle, + blendMode: style.backgroundBlendMode, + elevated: false, + onTap: action, + title: Format.shortImage(reference), + subtitle: repositoryTitle(reference), + titleStyle: .monospaced) { + ResourceCardIconChip(symbol: "tag", + tint: style.color, + backgroundOpacity: selected + ? Tokens.ResourceCard.iconSelectedBackgroundOpacity + : Tokens.ResourceCard.iconBackgroundOpacity) + } titleAccessory: { + ResourceBadgeText(text: "Tag", font: .caption2.weight(.semibold)) + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + EmptyView() } footerLeading: { ResourceCardFooterMini { Image(systemName: style.symbol) @@ -168,64 +159,72 @@ struct PaletteResultCard: View { } } footerActions: { accessory + } widget: { + EmptyView() } .selectionFill() .accessibilityAddTraits(selected ? .isSelected : []) } private func resourceCard(symbol: String, title: String, subtitle: String, footer: String) -> some View { - ResourceGlassCard(size: .small, - isSelected: selected, - fill: nil, - fillOpacity: selected ? Tokens.ResourceCard.selectedResourceFillOpacity : Tokens.ResourceCard.plainFillOpacity, - elevated: false, - onTap: action) { - ResourceCardHeader { - ResourceCardIconChip(symbol: symbol, - tint: item.tint, - backgroundOpacity: selected - ? Tokens.ResourceCard.iconSelectedBackgroundOpacity - : Tokens.ResourceCard.iconBackgroundOpacity) - } content: { - ResourceCardHeaderTextBlock { - HStack(spacing: Tokens.Space.s) { - ResourceCardTitleText(text: title) - ResourceBadgeText(text: subtitle, font: .caption2.weight(.semibold)) - } - } subtitle: { - ResourceCardSubtitleText(text: footer) - } - } trailing: { - accessory - } + ResourceCard(size: .small, + isSelected: selected, + fill: nil, + fillOpacity: selected ? Tokens.ResourceCard.selectedResourceFillOpacity : Tokens.ResourceCard.plainFillOpacity, + elevated: false, + onTap: action, + title: title, + subtitle: footer) { + ResourceCardIconChip(symbol: symbol, + tint: item.tint, + backgroundOpacity: selected + ? Tokens.ResourceCard.iconSelectedBackgroundOpacity + : Tokens.ResourceCard.iconBackgroundOpacity) + } titleAccessory: { + ResourceBadgeText(text: subtitle, font: .caption2.weight(.semibold)) + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + accessory + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } .selectionFill() .accessibilityAddTraits(selected ? .isSelected : []) } private func tintCard(_ tint: AppTint) -> some View { - ResourceGlassCard(size: .small, - isSelected: selected, - fill: tint.color, - fillOpacity: selected ? Tokens.ResourceCard.selectedTintFillOpacity : Tokens.ResourceCard.selectedSubtleFillOpacity, - elevated: false, - onTap: action) { - ResourceCardHeader { - DesignTintSwatch(color: tint.color, followsAppAccent: tint.followsAppAccent) - } content: { - ResourceCardHeaderTextBlock { - HStack(spacing: Tokens.Space.s) { - ResourceCardTitleText(text: tint.displayName) - ResourceBadgeText(text: app.settings.accentTint == tint ? "Current" : "Tint", - font: .caption2.weight(.semibold), - foreground: app.settings.accentTint == tint ? .accentColor : .secondary) - } - } subtitle: { - ResourceCardSubtitleText(text: item.title) - } - } trailing: { - accessory - } + ResourceCard(size: .small, + isSelected: selected, + fill: tint.color, + fillOpacity: selected ? Tokens.ResourceCard.selectedTintFillOpacity : Tokens.ResourceCard.selectedSubtleFillOpacity, + elevated: false, + onTap: action, + title: tint.displayName, + subtitle: item.title) { + DesignTintSwatch(color: tint.color, followsAppAccent: tint.followsAppAccent) + } titleAccessory: { + ResourceBadgeText(text: app.settings.accentTint == tint ? "Current" : "Tint", + font: .caption2.weight(.semibold), + foreground: app.settings.accentTint == tint ? .accentColor : .secondary) + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + accessory + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } .selectionFill() .accessibilityAddTraits(selected ? .isSelected : []) diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 377f8b7d..5ec929a4 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -84,16 +84,31 @@ struct ToolbarImageGroupCard: View { let image = primaryImage(group) let status = app.imageUpdateStatus(for: group.primaryReference) let resolved = app.imageGroupStyle(for: group) - return ResourceGlassCard(size: .medium, - isExpanded: isExpanded, - fill: resolved.fillBackground ? resolved.color : nil, - fillOpacity: resolved.backgroundOpacity, - gradient: resolved.gradient, - gradientAngle: resolved.gradientAngle, - blendMode: resolved.backgroundBlendMode, - elevated: false, - onTap: onTap) { - cardHeader(group, image: image, style: resolved) + return ResourceCard(size: .medium, + isExpanded: isExpanded, + fill: resolved.fillBackground ? resolved.color : nil, + fillOpacity: resolved.backgroundOpacity, + gradient: resolved.gradient, + gradientAngle: resolved.gradientAngle, + blendMode: resolved.backgroundBlendMode, + elevated: false, + onTap: onTap, + title: repositoryTitle(group.primaryReference), + subtitle: repositoryOwner(group.primaryReference), + pages: imagePages) { + if let image { + ImageStyleButton(reference: image.reference, + style: resolved, + target: .imageGroup(id: group.id, reference: group.primaryReference)) + } else { + imageChip(resolved) + } + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() } bodyContent: { imageBody(group) } footerLeading: { @@ -101,6 +116,8 @@ struct ToolbarImageGroupCard: View { imageFooterInfo(status) } footerActions: { imageFooterActions(group) + } widget: { + EmptyView() } .contextMenu { cardMenu(group) } } @@ -332,36 +349,6 @@ struct ToolbarImageGroupCard: View { } } - private func cardHeader(_ group: LocalImageTagGroup, image: ContainedCore.ImageResource?, - style: Personalization) -> some View { - ResourceCardHeader { - if let image { - ImageStyleButton(reference: image.reference, - style: style, - target: .imageGroup(id: group.id, reference: group.primaryReference)) - } else { - imageChip(style) - } - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: repositoryTitle(group.primaryReference)) - } subtitle: { - ResourceCardSubtitleText(text: repositoryOwner(group.primaryReference)) - } - } trailing: { - imagePageControls(controlsReveal: isExpanded ? 1 : 0) - } - } - - private func imagePageControls(controlsReveal: Double) -> some View { - ResourceCardPageControls(items: imagePageControlItems, - selection: page, - tint: resolvedImageTint, - controlsReveal: controlsReveal, - onSelect: selectPage, - onClose: onClose) - } - private var imagePageControlItems: [ResourceCardPageControlItem] { let reference = primaryImage(group)?.reference ?? group.primaryReference return [ @@ -380,6 +367,15 @@ struct ToolbarImageGroupCard: View { ] } + private var imagePages: ResourceCardPages { + ResourceCardPages(items: imagePageControlItems, + selection: page, + tint: resolvedImageTint, + controlsReveal: isExpanded ? 1 : 0, + onSelect: selectPage, + onClose: onClose) + } + private var resolvedImageTint: Color { app.imageGroupStyle(for: group).color } @@ -456,26 +452,27 @@ struct ToolbarImageGroupCard: View { private func tagRow(_ reference: String, in group: LocalImageTagGroup) -> some View { let style = app.imageStyle(for: reference) - return ResourceGlassCard(size: .medium, - fill: style.fillBackground ? style.color : nil, - fillOpacity: style.backgroundOpacity, - gradient: style.gradient, - gradientAngle: style.gradientAngle, - blendMode: style.backgroundBlendMode, - elevated: false) { - ResourceCardHeader { - ImageStyleButton(reference: reference, - style: style, - target: .imageTag(reference: reference, groupID: group.id)) - } content: { - ResourceCardHeaderTextBlock { - ResourceCardMonospacedTitleText(text: Format.shortImage(reference)) - } subtitle: { - ResourceCardSubtitleText(text: repositoryName(reference)) - } - } trailing: { - EmptyView() - } + return ResourceCard(size: .medium, + fill: style.fillBackground ? style.color : nil, + fillOpacity: style.backgroundOpacity, + gradient: style.gradient, + gradientAngle: style.gradientAngle, + blendMode: style.backgroundBlendMode, + elevated: false, + title: Format.shortImage(reference), + subtitle: repositoryName(reference), + titleStyle: .monospaced) { + ImageStyleButton(reference: reference, + style: style, + target: .imageTag(reference: reference, groupID: group.id)) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + EmptyView() } footerLeading: { ResourceCardFooterMini { Image(systemName: "tag").font(.caption2) @@ -489,6 +486,8 @@ struct ToolbarImageGroupCard: View { } footerAction("doc.on.doc", help: "Copy reference") { copyToPasteboard(reference) } footerAction("trash", help: "Delete tag", role: .destructive) { deletingReference = reference } + } widget: { + EmptyView() } .contextMenu { tagMenu(reference, in: group) } } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift index 9695e5d6..e4be1025 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift @@ -124,38 +124,48 @@ struct ToolbarTemplatesPanel: View { } private var emptyCard: some View { - ResourceGlassCard(size: .small, elevated: false) { - ResourceCardHeader { - ResourceCardIconChip(symbol: "bookmark", - tint: .secondary, - backgroundOpacity: Tokens.ResourceCard.iconEmphasisBackgroundOpacity) - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: "No templates") - } subtitle: { - ResourceCardSubtitleText(text: "Save a container's settings as a template from the create form.") - } - } trailing: { - EmptyView() - } + ResourceCard(size: .small, + elevated: false, + title: "No templates", + subtitle: "Save a container's settings as a template from the create form.") { + ResourceCardIconChip(symbol: "bookmark", + tint: .secondary, + backgroundOpacity: Tokens.ResourceCard.iconEmphasisBackgroundOpacity) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } } private func templateCard(_ template: Template) -> some View { - ResourceGlassCard(size: .medium, elevated: false, onTap: { use(template) }) { - ResourceCardHeader { - ResourceCardIconChip(symbol: "bookmark.fill", tint: .accentColor) - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: template.name) - } subtitle: { - ResourceCardMonospacedSubtitleText(text: Format.shortImage(template.spec?.image ?? "—")) - } - } trailing: { - // Chevron affordance: tapping the card hands off to the create morph (parity with the - // image cards that grow into the morph detail). - GlassListRowChevron() - } + ResourceCard(size: .medium, + elevated: false, + onTap: { use(template) }, + title: template.name, + subtitle: Format.shortImage(template.spec?.image ?? "—"), + subtitleStyle: .monospaced) { + ResourceCardIconChip(symbol: "bookmark.fill", tint: .accentColor) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + // Chevron affordance: tapping the card hands off to the create morph (parity with the + // image cards that grow into the morph detail). + GlassListRowChevron() + } bodyContent: { + EmptyView() } footerLeading: { ResourceCardFooterMini { Image(systemName: "bookmark").font(.caption2) @@ -175,6 +185,8 @@ struct ToolbarTemplatesPanel: View { .help("Delete") .accessibilityLabel("Delete") Button("Use") { use(template) }.buttonStyle(.glassProminent).controlSize(.small) + } widget: { + EmptyView() } .contextMenu { Button { use(template) } label: { Label("Use", systemImage: "plus.circle") } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift index d70ed666..ad99db3f 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift @@ -97,18 +97,25 @@ struct ToolbarUpdatesPanel: View { } private var emptyCard: some View { - ResourceGlassCard(size: .small, elevated: false) { - ResourceCardHeader { - ResourceCardIconChip(symbol: "checkmark.circle.fill", tint: .green) - } content: { - ResourceCardHeaderTextBlock { - ResourceCardTitleText(text: "No images") - } subtitle: { - ResourceCardSubtitleText(text: "Pull or build an image to see it here") - } - } trailing: { - EmptyView() - } + ResourceCard(size: .small, + elevated: false, + title: "No images", + subtitle: "Pull or build an image to see it here") { + ResourceCardIconChip(symbol: "checkmark.circle.fill", tint: .green) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } } diff --git a/changes/unreleased/20260702-card-anatomy.md b/changes/unreleased/20260702-card-anatomy.md index 1668e2c8..c27bf633 100644 --- a/changes/unreleased/20260702-card-anatomy.md +++ b/changes/unreleased/20260702-card-anatomy.md @@ -1,3 +1,4 @@ - Centralized resource-card anatomy in `ContainedDesignSystem`: headers stay visible, expanded bodies own density-specific embedded content, large widgets remain sticky, medium widgets move into the body, and small footers move into the body. - Routed remaining card widget/footer grouping through packaged card primitives, moved expanded page controls into the shared header trailing slot, kept that slot layout-stable during expansion, and updated design-system docs with the sticky/body slot contract. - Added a packaged sticky header text lane so card titles and subtitles stay anchored with the leading icon chip while expanded controls and page selections change. +- Introduced `ResourceCard` as the app-facing resource-card API, including typed page configuration and named icon/title/subtitle/header/body/widget/footer slots, and migrated app cards away from direct `ResourceGlassCard`/header composition. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 4ac3e2f6..e373320f 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -48,7 +48,7 @@ remains the source of truth for builds, tests, and release scripts. ## Design system -Liquid Glass helpers and reusable primitives include `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `ResourceGlassCard`, `ResourceCardInsetSection`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `GlassButton`, `ToolbarMenuButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `Tokens` groups for toolbar, panel, spacing, radius, icon sizing, resource cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, or micro-chrome recipes. App-state-aware mappings such as runtime status and graph metric extraction stay in the executable until they can cross the boundary without depending on app/core policy. Use the package READMEs for import instructions and copy-pasteable examples, and see [[Design System|Design-System]] for app-level conventions. +Liquid Glass helpers and reusable primitives include `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `ResourceCard`, `ResourceCardInsetSection`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `GlassButton`, `ToolbarMenuButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `Tokens` groups for toolbar, panel, spacing, radius, icon sizing, resource cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, or micro-chrome recipes. App-state-aware mappings such as runtime status and graph metric extraction stay in the executable until they can cross the boundary without depending on app/core policy. App-side resource cards should use `ResourceCard` rather than assembling `ResourceGlassCard`, headers, page rails, widgets, or footers directly. Use the package READMEs for import instructions and copy-pasteable examples, and see [[Design System|Design-System]] for app-level conventions. ## Local-only personalization diff --git a/docs/wiki/Command-Palette.md b/docs/wiki/Command-Palette.md index ec606204..25bd6d71 100644 --- a/docs/wiki/Command-Palette.md +++ b/docs/wiki/Command-Palette.md @@ -29,10 +29,9 @@ resource panel, it should be discoverable from `CommandPalette.swift`. Results should use the design system rather than custom row chrome: -- use `ResourceGlassCard` for every result card -- use `ResourceCardHeader`, `ResourceCardHeaderTextBlock`, `ResourceCardIconChip`, - `ResourceCardTitleText`, `ResourceCardSubtitleText`, and `ResourceBadgeText` - inside cards +- use `ResourceCard` for every result card +- pass badges, chevrons, return hints, and unread dots through the card's named + accessory slots instead of building custom headers - use `PaletteItemVisual` for anything that can be represented visually - render containers, image groups, tags, volumes, networks, and tints as mini cards using their actual app styling diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index 4f846bee..52d3653c 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -83,7 +83,7 @@ appcast.xml Sparkle feed at the root of each release branch - **Pure decision logic is factored into `ContainedCore`** (`RestartDecision`, `HealthDecision`, compose ordering) and unit-tested without spawning processes. - **No `contained.*` personalization labels.** Card styles and healthchecks live in local stores. Only `contained.restart` and `contained.stack` are written (they must round-trip through the container). - **Never put secrets or personal data in test fixtures.** Fixtures are captured CLI output — scrub tokens, domains, and paths before committing. (`.gitignore` blocks signing material; push protection is on.) -- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer shared primitives such as `PanelHeader`, `PanelSection`, `MorphPanelScaffold`, `ResourceGlassCard`, `GlassButton`, `CommandPreviewBar`, and `Tokens`. Do not add app-local spacing, padding, radius, shadow, material, opacity, or badge/keycap/status-dot recipes; add them to `ContainedDesignSystem` first. +- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer shared primitives such as `PanelHeader`, `PanelSection`, `MorphPanelScaffold`, `ResourceCard`, `GlassButton`, `CommandPreviewBar`, and `Tokens`. Do not add app-local spacing, padding, radius, shadow, material, opacity, or badge/keycap/status-dot recipes; add them to `ContainedDesignSystem` first. - **Gate debug-only tools at compile time.** Use `#if CONTAINED_DEBUG_TOOLS` for debug menus, diagnostics, fixtures, or local-only inspection surfaces. SwiftPM defines that flag only for debug builds, so release bundles exclude the code instead of merely hiding it at runtime. - **Keep the sidebar fallback working.** Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements for the classic shell. - **Sync docs with behavior.** If behavior, settings, routes, or user-facing wording changes, update the matching page under `docs/wiki` and keep README links current. diff --git a/docs/wiki/Design-System.md b/docs/wiki/Design-System.md index 5c05e469..ca544961 100644 --- a/docs/wiki/Design-System.md +++ b/docs/wiki/Design-System.md @@ -112,21 +112,18 @@ safe-area contract as morph panels, clearing both top and bottom toolbar bands. ## Resource cards -Use `ResourceGlassCard` for containers, images, tags, volumes, networks, and +Use `ResourceCard` for containers, images, tags, volumes, networks, and palette result cards. -Recommended internal pieces: +Recommended inputs and package pieces: -- `ResourceCardHeader` for the top row -- `ResourceCardHeaderTextBlock` for sticky title/subtitle lanes inside headers +- `ResourceCardPages` for expanded-card page rails - `ResourceCardIconChip` for icons and symbols -- `ResourceCardTitleText` for names -- `ResourceCardSubtitleText` or `ResourceCardMonospacedSubtitleText` for metadata +- `ResourceCardTextStyle` for standard versus monospaced title/subtitle text - `ResourceBadgeText` for compact state or kind labels - `ResourceCardFooterMini` for small footer actions and metrics - `ResourceCardWidgetGroup` for horizontal widget metadata -- `ResourceCardFooterChip`, `ResourceCardFooterButton`, and - `ResourceCardPageControls` for card-local controls +- `ResourceCardFooterChip` and `ResourceCardFooterButton` for card-local controls - `ResourceCardInsetSection` for charts, lists, and read-only groups inside an expanded card body - `resourceCardFloatingControls` and `resourceCardProgressOverlay` for @@ -137,18 +134,23 @@ Recommended internal pieces: Use `isSelected` instead of inventing a second selection ring. Use `elevated: false` for cards inside already-elevated morph panels. -`ResourceGlassCard` owns the card anatomy: +`ResourceCard` owns the card anatomy: - the header is always visible and stays outside the expanding body -- page controls belong in the header trailing slot, stay mounted, and use - `controlsReveal` instead of app-local overlays or conditional trailing views +- page controls are declared with `ResourceCardPages`, stay mounted in the header + trailing slot, and use `controlsReveal` instead of app-local overlays or + conditional trailing views - the body appears only while expanded - widgets stay sticky on `.large` cards and move into the expanded body on `.medium` - footers stay sticky on `.medium` and `.large` cards and move into the expanded body on `.small` -Do not create a second `ResourceGlassCard` or direct surface modifier inside an +Feature views should not call `ResourceGlassCard`, `ResourceCardHeader`, or +`ResourceCardPageControls` directly. Those are package composition pieces used by +`ResourceCard`. + +Do not create a second `ResourceCard` or direct surface modifier inside an expanded card body unless the nested object is itself an independent resource card, such as an image tag row. In-card content should go through `ResourceCardInsetSection`. @@ -171,7 +173,7 @@ Plain rows are reserved for generic actions such as refresh or opening a page. Use `Tokens` for spacing, radius, toolbar dimensions, panel sizes, icon sizes, form widths, chart sizes, badge/keycap insets, resource-card opacities, terminal chrome, and shadows. Feature views should not call low-level surface modifiers -such as `glassSurface`; use named package routes such as `ResourceGlassCard`, +such as `glassSurface`; use named package routes such as `ResourceCard`, `PanelSection`, `DesignContentSurface`, `DesignInputSurface`, and `ResourceCardInsetSection`. If a new visual value appears, add a token or package primitive before using it in the app. From e66b3d3a7f6bcf00b7124a4c1a19e98162024c6a Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 06:10:37 +0100 Subject: [PATCH 16/53] Route command chrome through design system --- AGENTS.md | 4 +- Packages/ContainedDesignSystem/README.md | 54 +++- .../ContainedDesignSystem.md | 45 +++ .../ContainedDesignSystem/DesignActions.swift | 279 ++++++++++++++++++ .../ToolbarControls.swift | 140 +++++++++ .../Containers/ContainerConfigureView.swift | 37 ++- .../Containers/ContainersGridView.swift | 22 +- .../Containers/Creation/CreationFlow.swift | 11 +- .../Containers/Creation/CreationPages.swift | 18 +- .../Features/Containers/CustomizeSheet.swift | 8 +- .../Features/Containers/FilesTab.swift | 18 +- .../Features/Containers/LogsTab.swift | 21 +- .../Features/Containers/RunSpecForm.swift | 20 +- .../Features/Containers/TerminalTab.swift | 13 +- .../Features/Images/BuildWorkspaceView.swift | 49 ++- .../Features/Images/RegistryImageSearch.swift | 5 +- .../Registries/RegistryLoginSheet.swift | 13 +- .../Features/Settings/SettingsView.swift | 27 +- .../System/Components/SystemLogsSheet.swift | 11 +- .../Features/System/SystemView.swift | 85 +++--- Sources/Contained/History/ActivityView.swift | 45 +-- Sources/Contained/Navigation/AppToolbar.swift | 67 ++--- .../Contained/Navigation/ClassicShell.swift | 10 +- Sources/Contained/Navigation/RootView.swift | 6 +- .../ToolbarPanels/ToolbarImageGroupCard.swift | 18 +- .../ToolbarPanels/ToolbarResourcePanels.swift | 14 +- .../ToolbarPanels/ToolbarSearchSource.swift | 37 +-- .../ToolbarPanels/ToolbarUpdatesPanel.swift | 25 +- .../Navigation/ToolbarViewOptions.swift | 156 ++++------ changes/unreleased/20260702-card-anatomy.md | 1 + docs/wiki/Architecture.md | 2 +- docs/wiki/Contributing.md | 2 +- docs/wiki/Design-System.md | 42 ++- 33 files changed, 897 insertions(+), 408 deletions(-) create mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift diff --git a/AGENTS.md b/AGENTS.md index 6135d1b2..195ae50a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,8 +48,8 @@ This file is the working contract for coding agents in this repository. Follow i ## Design And UI Rules -- Reuse design-system primitives before adding local styling: `PanelHeader`, `PanelSection`, `PanelField`, `ResourceGlassCard`, `CommandPreviewBar`, `TintSelector`, `GlassButton`, `ToolbarMenuButton`, and `Tokens`. -- Do not add app-local spacing, padding, radius, shadow, material, opacity, or micro-chrome constants. Add or extend a `ContainedDesignSystem` token/primitive first, then consume it from the app. +- Reuse app-facing design-system routes before adding local styling: `PanelHeader`, `PanelSection`, `PanelField`, `ResourceCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `CommandPreviewBar`, `TintSelector`, and `Tokens`. +- Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or micro-chrome constants. Add or extend a `ContainedDesignSystem` token/primitive first, then consume it from the app. Low-level package composition pieces such as `ResourceGlassCard`, `ResourceCardHeader`, `GlassButton`, and glass surface modifiers should not be called from `Sources/Contained`. - Keep the classic sidebar fallback working. Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements. - Prefer native macOS/Liquid Glass behavior over custom chrome when the system primitive fits. - Do not make broad visual changes without a product reason. diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 4c86d33a..c354408c 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -40,8 +40,10 @@ This package currently depends only on platform frameworks available to a macOS helpers for all reusable glass treatment. - `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `SheetHeader`, and `PageScaffold` for app-neutral scaffolding. -- `GlassButton`, `GlassButtonItem`, `GlassButtonInputItem`, `GlassRowMenu`, and - toolbar control helpers. +- `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, + `DesignSelectionActionBar`, `DesignStatusBanner`, and toolbar controls for + package-owned command chrome. `GlassButton`, `GlassButtonItem`, and + `GlassButtonInputItem` are lower-level package composition pieces. - `ResourceCard`, `ResourceCardPages`, `ResourceCardFooterChip`, `ResourceCardFooterButton`, `ResourceCardWidgetGroup`, `ResourceCardInsetSection`, and other `ResourceCard*` pieces for repeated card layouts and card-local controls. @@ -234,6 +236,54 @@ DesignInputSurface { } ``` +## Action and Toolbar Controls + +Feature views should pass action intent into package controls. Do not restate +glass button styles, capsule surfaces, hover treatment, or toolbar search chrome +in the app target. + +```swift +DesignActionGroup([ + DesignAction(systemName: "doc.on.doc", help: "Copy") { + copyToPasteboard(output) + }, + DesignAction(systemName: "trash", + help: "Clear", + role: .destructive) { + clear() + } +]) + +DesignTextActionButton(title: "Import", + systemName: "arrow.down.doc", + prominence: .prominent, + isEnabled: canImport) { + importArchive() +} + +DesignGlassToggle(isOn: $following, + title: "Follow", + systemName: "arrow.down.to.line") + +DesignSelectionActionBar(count: selection.count, actions: [ + DesignAction(systemName: "play.fill", title: "Start") { startSelection() }, + DesignAction(systemName: "trash", + title: "Delete", + role: .destructive) { deleteSelection() } +]) +``` + +Toolbar-specific controls follow the same rule: + +```swift +DesignToolbarSearchField(text: $query, + prompt: "Search this page", + focused: $focused, + onClear: { query = "" }) { + DesignKeyboardHint("command", "K") +} +``` + ## Documentation - DocC landing page: diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 0d5f2b16..1f487bc6 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -176,6 +176,36 @@ DesignInputSurface { } ``` +## Action and Toolbar Controls + +Feature views pass action intent into package-owned controls. The package owns +glass button grouping, hover, tint, destructive/cancel treatment, toggle chrome, +selection-bar capsules, status banners, and toolbar search shape. + +```swift +DesignActionGroup([ + DesignAction(systemName: "doc.on.doc", help: "Copy") { + copyToPasteboard(output) + }, + DesignAction(systemName: "trash", + help: "Clear", + role: .destructive) { + clear() + } +]) + +DesignTextActionButton(title: "Import", + systemName: "arrow.down.doc", + prominence: .prominent, + isEnabled: canImport) { + importArchive() +} + +DesignGlassToggle(isOn: $following, + title: "Follow", + systemName: "arrow.down.to.line") +``` + ## Topics ### Tokens and Theme @@ -200,6 +230,21 @@ DesignInputSurface { ### Toolbar Controls +- ``DesignAction`` +- ``DesignActionGroup`` +- ``DesignActionItems`` +- ``DesignMenuActionLabel`` +- ``DesignTextActionButton`` +- ``DesignTextActionProminence`` +- ``DesignGlassToggle`` +- ``DesignSelectionActionBar`` +- ``DesignStatusBanner`` +- ``DesignProgressActionCapsule`` +- ``DesignGlassMenuButton`` +- ``DesignToolbarSearchField`` +- ``DesignToolbarVanitySlot`` +- ``DesignToolbarStatusButton`` +- ``DesignToolbarActionCluster`` - ``GlassButton`` - ``GlassButtonItem`` - ``GlassButtonInputItem`` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift new file mode 100644 index 00000000..b69a6db8 --- /dev/null +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift @@ -0,0 +1,279 @@ +import SwiftUI + +/// Semantic action description for package-owned glass action chrome. +public struct DesignAction { + public var systemName: String + public var title: String? + public var help: String + public var role: ButtonRole? + public var tint: Color? + public var isCancel: Bool + public var isEnabled: Bool + public var action: () -> Void + + public init(systemName: String, + title: String? = nil, + help: String? = nil, + role: ButtonRole? = nil, + tint: Color? = nil, + isCancel: Bool = false, + isEnabled: Bool = true, + action: @escaping () -> Void) { + self.systemName = systemName + self.title = title + self.help = help ?? title ?? systemName + self.role = role + self.tint = tint + self.isCancel = isCancel + self.isEnabled = isEnabled + self.action = action + } +} + +/// Source-of-truth glass action group. +/// +/// Feature views provide action descriptions; the design system owns button grouping, sizing, +/// selection tinting, hover treatment, accessibility labels, and cancel/destructive behavior. +public struct DesignActionGroup: View { + public var actions: [DesignAction] + public var spacing: CGFloat + public var height: CGFloat + public var minWidth: CGFloat? + public var singleItem: Bool? + public var interactive: Bool + + public init(_ actions: [DesignAction], + spacing: CGFloat = 0, + height: CGFloat = Tokens.Toolbar.buttonGroupHeight, + minWidth: CGFloat? = nil, + singleItem: Bool? = nil, + interactive: Bool = true) { + self.actions = actions + self.spacing = spacing + self.height = height + self.minWidth = minWidth + self.singleItem = singleItem + self.interactive = interactive + } + + public init(_ action: DesignAction, + height: CGFloat = Tokens.Toolbar.buttonGroupHeight, + minWidth: CGFloat? = nil, + interactive: Bool = true) { + self.init([action], + height: height, + minWidth: minWidth, + singleItem: true, + interactive: interactive) + } + + public var body: some View { + GlassButton(spacing: spacing, + height: height, + minWidth: minWidth, + singleItem: singleItem ?? (actions.count == 1), + interactive: interactive) { + DesignActionItems(actions) + } + } +} + +/// Package-owned action item renderer for mixed groups that also contain menus or status labels. +public struct DesignActionItems: View { + public var actions: [DesignAction] + + public init(_ actions: [DesignAction]) { + self.actions = actions + } + + public var body: some View { + ForEach(Array(actions.enumerated()), id: \.offset) { _, item in + GlassButtonItem(role: item.role, + tint: item.tint, + help: item.help, + isCancel: item.isCancel, + isIcon: item.title == nil, + action: item.action) { + if let title = item.title { + Label(title, systemImage: item.systemName) + } else { + Image(systemName: item.systemName) + } + } + .disabled(!item.isEnabled) + } + } +} + +/// Semantic label for menus embedded in glass action groups. +public struct DesignMenuActionLabel: View { + public var systemName: String + public var help: String + public var role: ButtonRole? + public var tint: Color? + + public init(systemName: String, + help: String, + role: ButtonRole? = nil, + tint: Color? = nil) { + self.systemName = systemName + self.help = help + self.role = role + self.tint = tint + } + + public var body: some View { + GlassButtonItem(systemName: systemName, + role: role, + tint: tint, + help: help) + } +} + +/// Package-owned progress capsule for action slots that are temporarily busy. +public struct DesignProgressActionCapsule: View { + public var controlSize: ControlSize + + public init(controlSize: ControlSize = .small) { + self.controlSize = controlSize + } + + public var body: some View { + GlassButton(singleItem: true) { + ProgressView() + .controlSize(controlSize) + .frame(width: Tokens.Toolbar.buttonItemHeight, + height: Tokens.Toolbar.buttonItemHeight) + } + } +} + +/// Prominence levels for package-owned text action buttons. +public enum DesignTextActionProminence { + case standard + case prominent +} + +/// Package-owned text action button for command rows and form footers. +public struct DesignTextActionButton: View { + public var title: String + public var systemName: String + public var help: String + public var role: ButtonRole? + public var prominence: DesignTextActionProminence + public var controlSize: ControlSize + public var isEnabled: Bool + public var action: () -> Void + + public init(title: String, + systemName: String, + help: String? = nil, + role: ButtonRole? = nil, + prominence: DesignTextActionProminence = .standard, + controlSize: ControlSize = .regular, + isEnabled: Bool = true, + action: @escaping () -> Void) { + self.title = title + self.systemName = systemName + self.help = help ?? title + self.role = role + self.prominence = prominence + self.controlSize = controlSize + self.isEnabled = isEnabled + self.action = action + } + + public var body: some View { + switch prominence { + case .standard: + Button(role: role, action: action) { + Label(title, systemImage: systemName) + } + .buttonStyle(.glass) + .controlSize(controlSize) + .help(help) + .disabled(!isEnabled) + case .prominent: + Button(role: role, action: action) { + Label(title, systemImage: systemName) + } + .buttonStyle(.glassProminent) + .controlSize(controlSize) + .help(help) + .disabled(!isEnabled) + } + } +} + +/// Package-owned glass toggle used when a binary command belongs in toolbar/panel chrome. +public struct DesignGlassToggle: View { + @Binding public var isOn: Bool + public var title: String + public var systemName: String + + public init(isOn: Binding, + title: String, + systemName: String) { + self._isOn = isOn + self.title = title + self.systemName = systemName + } + + public var body: some View { + Toggle(isOn: $isOn) { + Label(title, systemImage: systemName) + } + .toggleStyle(.button) + .buttonStyle(.glass) + .buttonBorderShape(.capsule) + } +} + +/// Package-owned floating selection action bar. +public struct DesignSelectionActionBar: View { + public var count: Int + public var actions: [DesignAction] + + public init(count: Int, actions: [DesignAction]) { + self.count = count + self.actions = actions + } + + public var body: some View { + HStack(spacing: Tokens.Space.m) { + Text("\(count) selected") + .font(.callout.weight(.medium)) + Divider() + .frame(height: 16) + ForEach(Array(actions.enumerated()), id: \.offset) { _, item in + DesignTextActionButton(title: item.title ?? item.help, + systemName: item.systemName, + help: item.help, + role: item.role, + prominence: .standard, + isEnabled: item.isEnabled, + action: item.action) + } + } + .padding(.horizontal, Tokens.Space.l) + .padding(.vertical, Tokens.Space.s) + .glassCapsuleSurface(shadow: false) + } +} + +/// Package-owned transient banner chrome. +public struct DesignStatusBanner: View { + public var text: String + + public init(_ text: String) { + self.text = text + } + + public var body: some View { + Text(text) + .font(.callout.weight(.medium)) + .padding(.horizontal, Tokens.Space.l) + .padding(.vertical, Tokens.Space.s) + .glassCapsuleSurface(shadow: false) + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift index 3e90919a..d6bf2cbd 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift @@ -4,6 +4,146 @@ import SwiftUI /// Glass toolbar proportions. Centralizing them here keeps the toolbar, the creation tiles /// (`GlassOptionTile`), and any future band controls visually consistent and on one source of truth. +public struct DesignGlassMenuButton: View { + @ViewBuilder public var menuContent: () -> MenuContent + @ViewBuilder public var labelContent: () -> LabelContent + + public init(@ViewBuilder menuContent: @escaping () -> MenuContent, + @ViewBuilder labelContent: @escaping () -> LabelContent) { + self.menuContent = menuContent + self.labelContent = labelContent + } + + public var body: some View { + Menu { + menuContent() + } label: { + GlassButton(singleItem: true) { + labelContent() + } + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize(horizontal: true, vertical: false) + } +} + +public struct DesignToolbarSearchField: View { + @Binding public var text: String + public var prompt: String + public var focused: FocusState.Binding + public var onSubmit: () -> Void + public var onClear: () -> Void + @ViewBuilder public var trailing: () -> Trailing + + public init(text: Binding, + prompt: String, + focused: FocusState.Binding, + onSubmit: @escaping () -> Void = {}, + onClear: @escaping () -> Void, + @ViewBuilder trailing: @escaping () -> Trailing) { + self._text = text + self.prompt = prompt + self.focused = focused + self.onSubmit = onSubmit + self.onClear = onClear + self.trailing = trailing + } + + public var body: some View { + GlassButton(singleItem: true) { + GlassButtonInputItem { + Image(systemName: "magnifyingglass") + .font(.body) + .foregroundStyle(.secondary) + TextField(prompt, text: $text) + .textFieldStyle(.plain) + .font(.body) + .fontWeight(.medium) + .focused(focused) + .onSubmit(onSubmit) + if !text.isEmpty { + Button(action: onClear) { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Clear search") + .accessibilityLabel("Clear search") + } else { + trailing() + } + } + } + .toolbarControlContentShape() + .simultaneousGesture(TapGesture().onEnded { focused.wrappedValue = true }) + } +} + +/// Package-owned empty toolbar slot for stable morph origins and vanity chrome. +public struct DesignToolbarVanitySlot: View { + public var minWidth: CGFloat + public var interactive: Bool + @ViewBuilder public var content: () -> Content + + public init(minWidth: CGFloat = Tokens.Toolbar.trafficLightsWidth, + interactive: Bool = false, + @ViewBuilder content: @escaping () -> Content = { Color.clear }) { + self.minWidth = minWidth + self.interactive = interactive + self.content = content + } + + public var body: some View { + GlassButton(minWidth: minWidth, singleItem: true, interactive: interactive) { + content() + } + .fixedSize(horizontal: true, vertical: false) + } +} + +/// Package-owned toolbar button for custom status content. +public struct DesignToolbarStatusButton: View { + public var help: String + public var action: () -> Void + @ViewBuilder public var content: () -> Content + + public init(help: String, + action: @escaping () -> Void, + @ViewBuilder content: @escaping () -> Content) { + self.help = help + self.action = action + self.content = content + } + + public var body: some View { + GlassButton(singleItem: true) { + GlassButtonItem(help: help, action: action) { + content() + } + } + } +} + +/// Package-owned glass shell for toolbar clusters that mix action items and status/menu items. +public struct DesignToolbarActionCluster: View { + public var spacing: CGFloat + @ViewBuilder public var content: () -> Content + + public init(spacing: CGFloat = 0, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.content = content + } + + public var body: some View { + GlassButton(spacing: spacing) { + content() + } + } +} + /// A toolbar-styled menu trigger that uses the same icon sizing as `GlassButtonItem` but keeps /// the actual menu behavior native. public struct ToolbarMenuButton: View { diff --git a/Sources/Contained/Features/Containers/ContainerConfigureView.swift b/Sources/Contained/Features/Containers/ContainerConfigureView.swift index bfee7806..a8c6b0bf 100644 --- a/Sources/Contained/Features/Containers/ContainerConfigureView.swift +++ b/Sources/Contained/Features/Containers/ContainerConfigureView.swift @@ -81,38 +81,35 @@ struct ContainerConfigureView: View { title: isEdit ? "Edit container" : "Run a container", subtitle: isEdit ? "Replaces the existing container with your edits" : nil) { HStack(spacing: Tokens.Space.s) { - GlassButton(singleItem: true) { - leadingButton - } - GlassButton { - if working { - ProgressView().controlSize(.small) - .frame(width: Tokens.Toolbar.buttonItemHeight, height: Tokens.Toolbar.buttonItemHeight) - } else { - GlassButtonItem(systemName: "bookmark", help: "Save as template") { - guard spec.isRunnable else { return } + DesignActionGroup(leadingAction) + if working { + DesignProgressActionCapsule() + } else { + DesignActionGroup([ + DesignAction(systemName: "bookmark", + help: "Save as template", + isEnabled: spec.isRunnable) { templateName = spec.name.isEmpty ? Format.shortImage(spec.image) : spec.name savingTemplate = true - } - GlassButtonItem(systemName: isEdit ? "checkmark" : "play.fill", - help: isEdit ? "Save" : "Create") { - guard spec.isRunnable else { return } + }, + DesignAction(systemName: isEdit ? "checkmark" : "play.fill", + help: isEdit ? "Save" : "Create", + isEnabled: spec.isRunnable) { if isEdit { confirming = true } else { create() } } - } + ]) + .opacity(spec.isRunnable ? 1 : 0.55) } - .opacity(!working && !spec.isRunnable ? 0.55 : 1) } } } - @ViewBuilder - private var leadingButton: some View { + private var leadingAction: DesignAction { switch leading { case .cancel(let action): - GlassButtonItem(systemName: "xmark", help: "Cancel", isCancel: true, action: action) + return DesignAction(systemName: "xmark", help: "Cancel", isCancel: true, action: action) case .back(let action): - GlassButtonItem(systemName: "chevron.left", help: "Back", action: action) + return DesignAction(systemName: "chevron.left", help: "Back", action: action) } } diff --git a/Sources/Contained/Features/Containers/ContainersGridView.swift b/Sources/Contained/Features/Containers/ContainersGridView.swift index 945ebe42..e5af17ca 100644 --- a/Sources/Contained/Features/Containers/ContainersGridView.swift +++ b/Sources/Contained/Features/Containers/ContainersGridView.swift @@ -450,19 +450,17 @@ struct ContainersGridView: View { } private var batchBar: some View { - HStack(spacing: Tokens.Space.m) { - Text("\(selection.count) selected").font(.callout.weight(.medium)) - Divider().frame(height: 16) - Button { batch { await store.start($0) } } label: { Label("Start", systemImage: "play.fill") } - Button { batch { await store.stop($0) } } label: { Label("Stop", systemImage: "stop.fill") } - Button(role: .destructive) { batch { await store.remove($0, force: true) } } label: { - Label("Delete", systemImage: "trash") + DesignSelectionActionBar(count: selection.count, actions: [ + DesignAction(systemName: "play.fill", title: "Start") { + batch { await store.start($0) } + }, + DesignAction(systemName: "stop.fill", title: "Stop") { + batch { await store.stop($0) } + }, + DesignAction(systemName: "trash", title: "Delete", role: .destructive) { + batch { await store.remove($0, force: true) } } - } - .buttonStyle(.glass) - .padding(.horizontal, Tokens.Space.l) - .padding(.vertical, Tokens.Space.s) - .glassCapsuleSurface(shadow: false) + ]) .padding(.bottom, Tokens.Space.l) } diff --git a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift b/Sources/Contained/Features/Containers/Creation/CreationFlow.swift index 8974416d..1a39be72 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/Contained/Features/Containers/Creation/CreationFlow.swift @@ -323,9 +323,7 @@ struct CreationFlow: View { MorphPanelScaffold(width: 0, scrolls: false) { VStack(spacing: 0) { PanelHeader(symbol: symbol, title: title, subtitle: subtitle) { - GlassButton(singleItem: true) { - leadingControl(leading) - } + DesignActionGroup(leadingAction(leading)) } Divider() } @@ -343,13 +341,12 @@ struct CreationFlow: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: contentAlignment) } - @ViewBuilder - private func leadingControl(_ leading: Leading) -> some View { + private func leadingAction(_ leading: Leading) -> DesignAction { switch leading { case .close: - GlassButtonItem(systemName: "xmark", help: "Cancel", isCancel: true) { onClose() } + return DesignAction(systemName: "xmark", help: "Cancel", isCancel: true) { onClose() } case .back(let action): - GlassButtonItem(systemName: "chevron.left", help: "Back", action: action) + return DesignAction(systemName: "chevron.left", help: "Back", action: action) } } diff --git a/Sources/Contained/Features/Containers/Creation/CreationPages.swift b/Sources/Contained/Features/Containers/Creation/CreationPages.swift index 23a7ab84..9364a495 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationPages.swift +++ b/Sources/Contained/Features/Containers/Creation/CreationPages.swift @@ -185,11 +185,12 @@ struct CreationPastedComposeContent: View { HStack { Spacer() - Button(action: onImport) { - Label("Import", systemImage: "arrow.down.doc") + DesignTextActionButton(title: "Import", + systemName: "arrow.down.doc", + prominence: .prominent, + isEnabled: !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) { + onImport() } - .buttonStyle(.glassProminent) - .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } } @@ -226,11 +227,12 @@ private struct CreationSubmitBar: View { HStack(spacing: Tokens.Space.s) { Spacer() if working { ProgressView().controlSize(.small) } - Button(action: action) { - Label(title, systemImage: systemImage) + DesignTextActionButton(title: title, + systemName: systemImage, + prominence: .prominent, + isEnabled: canSubmit && !working) { + action() } - .buttonStyle(.glassProminent) - .disabled(!canSubmit || working) } .padding(Tokens.Space.s) .background(.clear) diff --git a/Sources/Contained/Features/Containers/CustomizeSheet.swift b/Sources/Contained/Features/Containers/CustomizeSheet.swift index 61ff530a..10849814 100644 --- a/Sources/Contained/Features/Containers/CustomizeSheet.swift +++ b/Sources/Contained/Features/Containers/CustomizeSheet.swift @@ -145,10 +145,10 @@ struct CustomizeSheet: View { PanelHeader(symbol: "paintbrush.pointed", title: headerTitle, subtitle: imageSubtitle) { - GlassButton { - GlassButtonItem(systemName: "checkmark", help: "Save") { save() } - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true) { dismiss() } - } + DesignActionGroup([ + DesignAction(systemName: "checkmark", help: "Save") { save() }, + DesignAction(systemName: "xmark", help: "Close", isCancel: true) { dismiss() } + ]) } } diff --git a/Sources/Contained/Features/Containers/FilesTab.swift b/Sources/Contained/Features/Containers/FilesTab.swift index d55000b5..a8ea2935 100644 --- a/Sources/Contained/Features/Containers/FilesTab.swift +++ b/Sources/Contained/Features/Containers/FilesTab.swift @@ -31,21 +31,17 @@ struct FilesTab: View { private var pathBar: some View { HStack(spacing: Tokens.Space.s) { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "chevron.up", help: "Parent") { goUp() } - .disabled(path == "/") - } + DesignActionGroup(DesignAction(systemName: "chevron.up", + help: "Parent", + isEnabled: path != "/") { goUp() }) Text(path).font(.system(.callout, design: .monospaced)).lineLimit(1).truncationMode(.middle) Spacer() if loading { ProgressView().controlSize(.small) } - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "square.and.arrow.down", help: "Copy a file into this folder") { + DesignActionGroup(DesignAction(systemName: "square.and.arrow.down", + help: "Copy a file into this folder") { copyIn() - } - } - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "arrow.clockwise", help: "Refresh") { Task { await load() } } - } + }) + DesignActionGroup(DesignAction(systemName: "arrow.clockwise", help: "Refresh") { Task { await load() } }) } } diff --git a/Sources/Contained/Features/Containers/LogsTab.swift b/Sources/Contained/Features/Containers/LogsTab.swift index 81da4110..3ca260d9 100644 --- a/Sources/Contained/Features/Containers/LogsTab.swift +++ b/Sources/Contained/Features/Containers/LogsTab.swift @@ -30,10 +30,7 @@ struct LogsTab: View { private var controls: some View { HStack(spacing: Tokens.Space.m) { - Toggle(isOn: $following) { Label("Follow", systemImage: "arrow.down.to.line") } - .toggleStyle(.button) - .buttonStyle(.glass) - .buttonBorderShape(.capsule) + DesignGlassToggle(isOn: $following, title: "Follow", systemName: "arrow.down.to.line") if streaming { HStack(spacing: Tokens.Toolbar.searchIconGap) { ProgressView().controlSize(.small) @@ -42,17 +39,15 @@ struct LogsTab: View { } Spacer() Text("\(lines.count) lines").font(.caption).foregroundStyle(.secondary).monospacedDigit() - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "doc.on.doc", help: "Copy all") { + DesignActionGroup(DesignAction(systemName: "doc.on.doc", help: "Copy all") { copyToPasteboard(lines.joined(separator: "\n")) - } - } - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "trash", role: .destructive, help: "Clear") { + }) + DesignActionGroup(DesignAction(systemName: "trash", + help: "Clear", + role: .destructive, + isEnabled: !lines.isEmpty) { lines.removeAll(); carry = "" - } - .disabled(lines.isEmpty) - } + }) } } diff --git a/Sources/Contained/Features/Containers/RunSpecForm.swift b/Sources/Contained/Features/Containers/RunSpecForm.swift index 234318c2..617a10d3 100644 --- a/Sources/Contained/Features/Containers/RunSpecForm.swift +++ b/Sources/Contained/Features/Containers/RunSpecForm.swift @@ -61,12 +61,9 @@ struct RunSpecForm: View { PanelRow(title: "Image defaults", subtitle: "Fill empty command, entrypoint, user, working directory, and environment fields from the pulled image config.", info: "Images can define default startup settings. Adopt copies those defaults into this form so you can see and edit them before running.") { - Button { + DesignTextActionButton(title: "Adopt", systemName: "wand.and.stars") { adoptImageDefaults() - } label: { - Label("Adopt", systemImage: "wand.and.stars") } - .buttonStyle(.glass) } } PanelRow(title: "Platform", info: "Use this only when an image supports more than one CPU type. Leave Default unless you specifically need arm64 or amd64.") { @@ -553,17 +550,16 @@ struct RunSpecForm: View { } private func addButton(_ title: String, action: @escaping () -> Void) -> some View { - GlassButton(singleItem: true) { - GlassButtonItem(help: title, action: action) { - Label(title, systemImage: "plus.circle") - } - } + DesignActionGroup(DesignAction(systemName: "plus.circle", + title: title, + help: title, + action: action)) } private func removeButton(action: @escaping () -> Void) -> some View { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "minus.circle.fill", help: "Remove", action: action) - } + DesignActionGroup(DesignAction(systemName: "minus.circle.fill", + help: "Remove", + action: action)) } private func sourcePicker(source: Binding) -> some View { diff --git a/Sources/Contained/Features/Containers/TerminalTab.swift b/Sources/Contained/Features/Containers/TerminalTab.swift index 86f5c88c..afce4762 100644 --- a/Sources/Contained/Features/Containers/TerminalTab.swift +++ b/Sources/Contained/Features/Containers/TerminalTab.swift @@ -64,9 +64,7 @@ struct TerminalTab: View { .onChange(of: shell) { _, _ in reconnect() } Text("exec into \(snapshot.id)").font(.caption).foregroundStyle(.secondary).lineLimit(1) Spacer() - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "arrow.clockwise", help: "Reconnect") { reconnect() } - } + DesignActionGroup(DesignAction(systemName: "arrow.clockwise", help: "Reconnect") { reconnect() }) } } @@ -75,11 +73,10 @@ struct TerminalTab: View { Image(systemName: "bolt.horizontal.circle").font(.largeTitle).foregroundStyle(.secondary) Text(code == nil || code == 0 ? "Session ended" : "Session ended (exit \(code!))") .font(.headline) - GlassButton(singleItem: true) { - GlassButtonItem(help: "Reconnect terminal", action: reconnect) { - Label("Reconnect", systemImage: "arrow.clockwise") - } - } + DesignActionGroup(DesignAction(systemName: "arrow.clockwise", + title: "Reconnect", + help: "Reconnect terminal", + action: reconnect)) } } diff --git a/Sources/Contained/Features/Images/BuildWorkspaceView.swift b/Sources/Contained/Features/Images/BuildWorkspaceView.swift index 7b2f9f17..98782d00 100644 --- a/Sources/Contained/Features/Images/BuildWorkspaceView.swift +++ b/Sources/Contained/Features/Images/BuildWorkspaceView.swift @@ -68,11 +68,10 @@ struct BuildWorkspaceView: View { .lineLimit(1) .truncationMode(.middle) Spacer() - GlassButton(singleItem: true) { - GlassButtonItem(help: "Choose context folder", action: chooseFolder) { - Label("Choose", systemImage: "folder") - } - } + DesignActionGroup(DesignAction(systemName: "folder", + title: "Choose", + help: "Choose context folder", + action: chooseFolder)) } } PanelField(label: "Dockerfile", @@ -105,22 +104,20 @@ struct BuildWorkspaceView: View { Text("=").foregroundStyle(.secondary) TextField("value", text: $arg.value) .textFieldStyle(.roundedBorder) - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "minus.circle.fill", - help: "Remove build argument") { + DesignActionGroup(DesignAction(systemName: "minus.circle.fill", + help: "Remove build argument") { buildArgs.removeAll { $0.id == arg.id } - } - } + }) } } } PanelRow(title: "Build arguments", subtitle: buildArgs.isEmpty ? "No build-time variables added." : "\(buildArgs.count) argument(s)") { - GlassButton(singleItem: true) { - GlassButtonItem(help: "Add build argument", action: { buildArgs.append(KeyValue()) }) { - Label("Add build arg", systemImage: "plus.circle") - } - } + DesignActionGroup(DesignAction(systemName: "plus.circle", + title: "Add build arg", + help: "Add build argument") { + buildArgs.append(KeyValue()) + }) } } } @@ -131,18 +128,18 @@ struct BuildWorkspaceView: View { CommandPreviewBar(command: previewCommand) .frame(maxWidth: .infinity) if building { - GlassButton(singleItem: true) { - GlassButtonItem(role: .destructive, help: "Cancel build", action: { building = false }) { - Label("Cancel", systemImage: "xmark") - } - } + DesignActionGroup(DesignAction(systemName: "xmark", + title: "Cancel", + help: "Cancel build", + role: .destructive) { + building = false + }) } else { - GlassButton(singleItem: true) { - GlassButtonItem(help: "Build image", action: startBuild) { - Label("Build", systemImage: "hammer.fill") - } - } - .disabled(!canBuild) + DesignActionGroup(DesignAction(systemName: "hammer.fill", + title: "Build", + help: "Build image", + isEnabled: canBuild, + action: startBuild)) } } } diff --git a/Sources/Contained/Features/Images/RegistryImageSearch.swift b/Sources/Contained/Features/Images/RegistryImageSearch.swift index 5bd04052..42026487 100644 --- a/Sources/Contained/Features/Images/RegistryImageSearch.swift +++ b/Sources/Contained/Features/Images/RegistryImageSearch.swift @@ -109,8 +109,9 @@ struct RegistryImageSearch: View { Image(systemName: "wifi.exclamationmark").font(.title2).foregroundStyle(.orange) Text("Couldn't search Docker Hub").font(.callout.weight(.medium)) Text(errorMessage).font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center) - Button { searchNow() } label: { Label("Retry", systemImage: "arrow.clockwise") } - .buttonStyle(.glass) + DesignTextActionButton(title: "Retry", systemName: "arrow.clockwise") { + searchNow() + } } else { Image(systemName: "magnifyingglass").font(.title2).foregroundStyle(.tertiary) Text("No images found for “\(trimmedQuery)”").font(.callout).foregroundStyle(.secondary) diff --git a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift b/Sources/Contained/Features/Registries/RegistryLoginSheet.swift index d964e308..136bcb3a 100644 --- a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift +++ b/Sources/Contained/Features/Registries/RegistryLoginSheet.swift @@ -19,12 +19,13 @@ struct RegistryLoginSheet: View { if busy { ProgressView().controlSize(.small).frame(width: Tokens.IconSize.control, height: Tokens.IconSize.control) } else { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "checkmark", help: "Log in") { submit() } - .disabled(server.trimmingCharacters(in: .whitespaces).isEmpty - || username.trimmingCharacters(in: .whitespaces).isEmpty - || password.isEmpty) - } + DesignActionGroup(DesignAction(systemName: "checkmark", + help: "Log in", + isEnabled: !server.trimmingCharacters(in: .whitespaces).isEmpty + && !username.trimmingCharacters(in: .whitespaces).isEmpty + && !password.isEmpty) { + submit() + }) } } VStack(spacing: Tokens.Space.l) { diff --git a/Sources/Contained/Features/Settings/SettingsView.swift b/Sources/Contained/Features/Settings/SettingsView.swift index 0fe5e2ac..b680a5f7 100644 --- a/Sources/Contained/Features/Settings/SettingsView.swift +++ b/Sources/Contained/Features/Settings/SettingsView.swift @@ -79,21 +79,22 @@ struct SettingsContent: View { PanelHeader(symbol: page.systemImage, title: "Settings", subtitle: page.rawValue) { - GlassButton { - ForEach(SettingsPage.allCases) { item in - GlassButtonItem(tint: page == item ? .accentColor : nil, - help: item.rawValue, - isIcon: true, - action: { page = item }) { - Image(systemName: item.systemImage) - .opacity(page == item ? 1 : 0.62) - } - } - if let onClose { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true, action: onClose) - } + DesignActionGroup(headerActions) + } + } + + private var headerActions: [DesignAction] { + var actions = SettingsPage.allCases.map { item in + DesignAction(systemName: item.systemImage, + help: item.rawValue, + tint: page == item ? .accentColor : nil) { + page = item } } + if let onClose { + actions.append(DesignAction(systemName: "xmark", help: "Close", isCancel: true, action: onClose)) + } + return actions } @ViewBuilder diff --git a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift b/Sources/Contained/Features/System/Components/SystemLogsSheet.swift index 3dccd21b..89b6a3e3 100644 --- a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift +++ b/Sources/Contained/Features/System/Components/SystemLogsSheet.swift @@ -12,15 +12,14 @@ struct SystemLogsSheet: View { VStack(spacing: 0) { HStack(spacing: Tokens.Space.m) { Text("System logs").font(.headline) - Toggle(isOn: $follow) { Label("Follow", systemImage: "arrow.down.to.line") } - .toggleStyle(.button).buttonStyle(.glass).buttonBorderShape(.capsule) + DesignGlassToggle(isOn: $follow, title: "Follow", systemName: "arrow.down.to.line") .onChange(of: follow) { _, _ in session += 1 } Spacer() - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true) { + DesignActionGroup(DesignAction(systemName: "xmark", + help: "Close", + isCancel: true) { dismiss() - } - } + }) } .padding(Tokens.Space.s) if let client = app.client { diff --git a/Sources/Contained/Features/System/SystemView.swift b/Sources/Contained/Features/System/SystemView.swift index 5ce04657..7ea8807e 100644 --- a/Sources/Contained/Features/System/SystemView.swift +++ b/Sources/Contained/Features/System/SystemView.swift @@ -134,51 +134,54 @@ struct SystemContent: View { HStack(spacing: Tokens.Toolbar.groupSpacing) { engineControls GlassButton { - pageButtons + DesignActionItems(pageActions) storageMenu if showClose { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true, action: onClose) + DesignActionItems([DesignAction(systemName: "xmark", + help: "Close", + isCancel: true, + action: onClose)]) } } } } } - @ViewBuilder - private var pageButtons: some View { - ForEach(SystemPage.allCases) { item in - GlassButtonItem(tint: activePage == item ? .accentColor : nil, - help: item.rawValue, - isIcon: true, - action: { setPage(item) }) { - Image(systemName: item.systemImage) - .opacity(activePage == item ? 1 : 0.62) + private var pageActions: [DesignAction] { + SystemPage.allCases.map { item in + DesignAction(systemName: item.systemImage, + help: item.rawValue, + tint: activePage == item ? .accentColor : nil) { + setPage(item) } } } private var engineControls: some View { - GlassButton { - servicePowerButton - GlassButtonItem(systemName: "arrow.clockwise", help: "Restart service") { + DesignActionGroup([ + servicePowerAction, + DesignAction(systemName: "arrow.clockwise", + help: "Restart service", + isEnabled: !working) { run { await app.restartService() } } - .disabled(working) - } + ]) } - @ViewBuilder - private var servicePowerButton: some View { + private var servicePowerAction: DesignAction { if app.serviceHealthy { - GlassButtonItem(systemName: "stop.fill", role: .destructive, help: "Stop service") { + return DesignAction(systemName: "stop.fill", + help: "Stop service", + role: .destructive, + isEnabled: !working) { run { await app.stopService() } } - .disabled(working) } else { - GlassButtonItem(systemName: "play.fill", help: "Start service") { + return DesignAction(systemName: "play.fill", + help: "Start service", + isEnabled: !working) { run { await app.startService() } } - .disabled(working) } } @@ -194,8 +197,9 @@ struct SystemContent: View { Button { pruneTarget = .volumes } label: { Label("Unused volumes", systemImage: "externaldrive") } Button { pruneTarget = .networks } label: { Label("Unused networks", systemImage: "network") } } label: { - GlassButtonItem(systemName: "trash", role: .destructive, - help: "Storage cleanup") + DesignMenuActionLabel(systemName: "trash", + help: "Storage cleanup", + role: .destructive) } .buttonStyle(.plain) } @@ -215,14 +219,12 @@ struct SystemContent: View { Text("Volumes").font(.headline) ResourceBadgeText(text: "\(volumeInventory.count)") Spacer() - GlassButton(singleItem: true) { - GlassButtonItem(help: "New volume", action: { + DesignActionGroup(DesignAction(systemName: "plus", + title: "New", + help: "New volume") { onClose() ui.dispatch(.createVolume) - }) { - Label("New", systemImage: "plus") - } - } + }) } if volumeInventory.isEmpty { Text("No named volumes or container mounts found.") @@ -307,13 +309,11 @@ struct SystemContent: View { if app.settings.imageUpdateChecksEnabled { Text(countdown(to: app.imageUpdateNextRunDate, now: context.date)) .font(.system(.caption, design: .monospaced).weight(.semibold)).monospacedDigit() - GlassButton(singleItem: true) { - GlassButtonItem(help: "Run image update check now", action: { + DesignActionGroup(DesignAction(systemName: "arrow.triangle.2.circlepath", + title: "Run now", + help: "Run image update check now") { Task { await app.runImageUpdateSweepNow() } - }) { - Label("Run now", systemImage: "arrow.triangle.2.circlepath") - } - } + }) } } } @@ -324,14 +324,13 @@ struct SystemContent: View { ? "Sparkle · \(app.settings.updateChannel.rawValue.capitalized) channel" : "Unavailable in this build", isOn: appUpdateBinding) { - GlassButton(singleItem: true) { - GlassButtonItem(help: "Check for app updates now", action: { + DesignActionGroup(DesignAction(systemName: "arrow.down.app", + title: "Check now", + help: "Check for app updates now", + isEnabled: app.updater.canCheckForUpdates + && app.settings.appUpdateChecksEnabled) { app.updater.checkForUpdates() - }) { - Label("Check now", systemImage: "arrow.down.app") - } - } - .disabled(!app.updater.canCheckForUpdates || !app.settings.appUpdateChecksEnabled) + }) } Divider() automationRow(icon: "arrow.clockwise.circle", diff --git a/Sources/Contained/History/ActivityView.swift b/Sources/Contained/History/ActivityView.swift index 04966b32..44286c3f 100644 --- a/Sources/Contained/History/ActivityView.swift +++ b/Sources/Contained/History/ActivityView.swift @@ -69,9 +69,10 @@ struct ActivityContent: View { } .pickerStyle(.inline) } label: { - GlassButtonItem(systemName: ui.activityFilter == nil ? "line.3.horizontal.decrease" - : "line.3.horizontal.decrease.circle.fill", - help: ui.activityFilter == nil ? "Filter" : "Filter: \(ui.activityFilter!.rawValue.capitalized)") + DesignMenuActionLabel(systemName: ui.activityFilter == nil ? "line.3.horizontal.decrease" + : "line.3.horizontal.decrease.circle.fill", + help: ui.activityFilter == nil ? "Filter" + : "Filter: \(ui.activityFilter!.rawValue.capitalized)") } .buttonStyle(.plain) .disabled(presentKinds.isEmpty) @@ -90,20 +91,7 @@ struct ActivityContent: View { subtitle: subtitle) { GlassButton { filterMenu - GlassButtonItem(systemName: "checkmark.circle", - help: ui.activityFilter == nil ? "Mark all as read" - : "Mark \(ui.activityFilter!.rawValue.capitalized) as read", - action: markFilteredRead) - .disabled(filteredUnreadCount == 0) - GlassButtonItem(systemName: "trash", - role: .destructive, - help: ui.activityFilter == nil ? "Clear activity" - : "Clear \(ui.activityFilter!.rawValue.capitalized) events", - action: clearFiltered) - .disabled(filtered.isEmpty) - if showClose { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true, action: onClose) - } + DesignActionItems(activityHeaderActions) } } Divider() @@ -127,6 +115,29 @@ struct ActivityContent: View { .onDisappear(perform: markAllRead) } + private var activityHeaderActions: [DesignAction] { + var actions = [ + DesignAction(systemName: "checkmark.circle", + help: ui.activityFilter == nil ? "Mark all as read" + : "Mark \(ui.activityFilter!.rawValue.capitalized) as read", + isEnabled: filteredUnreadCount > 0, + action: markFilteredRead), + DesignAction(systemName: "trash", + help: ui.activityFilter == nil ? "Clear activity" + : "Clear \(ui.activityFilter!.rawValue.capitalized) events", + role: .destructive, + isEnabled: !filtered.isEmpty, + action: clearFiltered) + ] + if showClose { + actions.append(DesignAction(systemName: "xmark", + help: "Close", + isCancel: true, + action: onClose)) + } + return actions + } + /// Marks every event read — used on dismiss (the whole panel has been seen). private func markAllRead() { let unread = events.filter { !$0.isRead } diff --git a/Sources/Contained/Navigation/AppToolbar.swift b/Sources/Contained/Navigation/AppToolbar.swift index bf3be297..c31a5672 100644 --- a/Sources/Contained/Navigation/AppToolbar.swift +++ b/Sources/Contained/Navigation/AppToolbar.swift @@ -102,12 +102,7 @@ struct AppToolbar: View { /// it's vanity chrome that owns the `.settings` morph slot so the Settings panel (opened via ⌘, or /// the menu) has a frame to grow from. private var settingsZone: some View { - // The same GlassButton container as the other toolbar controls (for consistency), but empty and - // non-interactive — vanity chrome and a stable morph origin, sized by a min width. - GlassButton(minWidth: Tokens.Toolbar.trafficLightsWidth, singleItem: true, interactive: false) { - Color.clear - } - .fixedSize(horizontal: true, vertical: false) + DesignToolbarVanitySlot() .opacity(ui.activeMorph == .settings ? 0 : 1) .background(singleSlotReader(.settings)) } @@ -158,24 +153,21 @@ struct AppToolbar: View { } private var systemStatusButton: some View { - GlassButton(singleItem: true) { - GlassButtonItem(help: app.activity?.title ?? "System \(app.serviceLabel)", action: { - openGlobalSectionOrPanel(.system, morph: .system) - }) { - if let activity = app.activity { - ActivityStatusView(activity: ActivityStatusPresentation(title: activity.title, - detail: activity.detail, - fraction: activity.fraction), - style: .inline) - } else { - HStack(spacing: Tokens.Toolbar.searchIconGap) { - Image(systemName: systemStatusIcon) - .foregroundStyle(systemStatusColor) - .frame(width: Tokens.Toolbar.iconContentWidth) - Text(app.serviceLabel) - .foregroundStyle(.secondary) - .padding(.trailing, Tokens.Toolbar.statusLabelTrailingPadding) - } + DesignToolbarStatusButton(help: app.activity?.title ?? "System \(app.serviceLabel)", + action: { openGlobalSectionOrPanel(.system, morph: .system) }) { + if let activity = app.activity { + ActivityStatusView(activity: ActivityStatusPresentation(title: activity.title, + detail: activity.detail, + fraction: activity.fraction), + style: .inline) + } else { + HStack(spacing: Tokens.Toolbar.searchIconGap) { + Image(systemName: systemStatusIcon) + .foregroundStyle(systemStatusColor) + .frame(width: Tokens.Toolbar.iconContentWidth) + Text(app.serviceLabel) + .foregroundStyle(.secondary) + .padding(.trailing, Tokens.Toolbar.statusLabelTrailingPadding) } } } @@ -186,10 +178,16 @@ struct AppToolbar: View { private var bottomActionGroup: some View { HStack(spacing: Tokens.Toolbar.groupSpacing) { - GlassButton { - GlassButtonItem(systemName: "plus", help: "Add") { ui.openCreationPanel() } - GlassButtonItem(systemName: "shippingbox", help: "Images") { openGlobalSectionOrPanel(.images, morph: .updates) } - GlassButtonItem(systemName: "bookmark", help: "Templates") { openGlobalSectionOrPanel(.templates, morph: .templates) } + DesignToolbarActionCluster { + DesignActionItems([ + DesignAction(systemName: "plus", help: "Add") { ui.openCreationPanel() }, + DesignAction(systemName: "shippingbox", help: "Images") { + openGlobalSectionOrPanel(.images, morph: .updates) + }, + DesignAction(systemName: "bookmark", help: "Templates") { + openGlobalSectionOrPanel(.templates, morph: .templates) + } + ]) ActivityToolbarButton() } .opacity(isBottomGroupMorphActive ? 0 : 1) @@ -503,18 +501,17 @@ private struct ActivityToolbarButton: View { var body: some View { let count = unread.count let hasUnread = count > 0 - return GlassButtonItem(tint: hasUnread ? app.settings.accentTint.color : .white, - help: hasUnread ? "Activity — \(count) unread" : "Activity", - isIcon: true, - action: { + return DesignActionItems([ + DesignAction(systemName: hasUnread ? "bell.fill" : "bell", + help: hasUnread ? "Activity — \(count) unread" : "Activity", + tint: hasUnread ? app.settings.accentTint.color : .white) { if ui.panelNavigationEnabled { ui.toggleMorph(.activity) } else { ui.navigate(to: .activity) } - }) { - Image(systemName: hasUnread ? "bell.fill" : "bell") - } + } + ]) } } diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/Contained/Navigation/ClassicShell.swift index f2d4f98e..787ab52f 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/Contained/Navigation/ClassicShell.swift @@ -223,11 +223,11 @@ private struct NetworksPage: View { PageScaffold(symbol: "network", title: "Networks", subtitle: "\(sortedNetworks.count) network\(sortedNetworks.count == 1 ? "" : "s")") { - GlassButton(singleItem: true) { - GlassButtonItem(help: "New network", action: { ui.dispatch(.createNetwork) }) { - Label("New", systemImage: "plus") - } - } + DesignActionGroup(DesignAction(systemName: "plus", + title: "New", + help: "New network") { + ui.dispatch(.createNetwork) + }) } content: { if sortedNetworks.isEmpty { ContentUnavailableView("No networks", diff --git a/Sources/Contained/Navigation/RootView.swift b/Sources/Contained/Navigation/RootView.swift index 310212b6..2057d3bf 100644 --- a/Sources/Contained/Navigation/RootView.swift +++ b/Sources/Contained/Navigation/RootView.swift @@ -251,11 +251,7 @@ struct RootView: View { @ViewBuilder private var bannerView: some View { if let banner = app.banner { - Text(banner) - .font(.callout.weight(.medium)) - .padding(.horizontal, Tokens.Space.l) - .padding(.vertical, Tokens.Space.s) - .glassCapsuleSurface(shadow: false) + DesignStatusBanner(banner) .transition(.move(edge: .bottom).combined(with: .opacity)) } } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 5ec929a4..f946d605 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -189,9 +189,12 @@ struct ToolbarImageGroupCard: View { HStack { Spacer() if tagBusy { ProgressView().controlSize(.small) } - Button { submitTag(source: source) } label: { Label("Add Tag", systemImage: "checkmark") } - .buttonStyle(.glassProminent) - .disabled(tagTarget.trimmingCharacters(in: .whitespaces).isEmpty || tagBusy) + DesignTextActionButton(title: "Add Tag", + systemName: "checkmark", + prominence: .prominent, + isEnabled: !tagTarget.trimmingCharacters(in: .whitespaces).isEmpty && !tagBusy) { + submitTag(source: source) + } } } } @@ -253,13 +256,12 @@ struct ToolbarImageGroupCard: View { Spacer() switch readiness.action { case .push: - Button { + DesignTextActionButton(title: "Push", + systemName: "arrow.up.circle", + prominence: .prominent, + isEnabled: app.client != nil) { confirmingPushReference = reference - } label: { - Label("Push", systemImage: "arrow.up.circle") } - .buttonStyle(.glassProminent) - .disabled(app.client == nil) case .openRegistries: Button { onClose() diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift index e4be1025..f5997f05 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift @@ -116,9 +116,10 @@ struct ToolbarTemplatesPanel: View { title: "Templates", subtitle: "\(saved.count) saved") { if showClose { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true, action: onClose) - } + DesignActionGroup(DesignAction(systemName: "xmark", + help: "Close", + isCancel: true, + action: onClose)) } } } @@ -184,7 +185,12 @@ struct ToolbarTemplatesPanel: View { .foregroundStyle(.red) .help("Delete") .accessibilityLabel("Delete") - Button("Use") { use(template) }.buttonStyle(.glassProminent).controlSize(.small) + DesignTextActionButton(title: "Use", + systemName: "plus.circle", + prominence: .prominent, + controlSize: .small) { + use(template) + } } widget: { EmptyView() } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift index 61a9afe6..276cf8ae 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift @@ -12,33 +12,20 @@ struct ToolbarSearchSource: View { var body: some View { @Bindable var ui = ui - return GlassButton(singleItem: true) { - GlassButtonInputItem { - Image(systemName: "magnifyingglass") - .font(.body) - .foregroundStyle(.secondary) - TextField(paletteEnabled ? "Search this page, or ⌘K for commands" : "Search this page", - text: $ui.searchText) - .textFieldStyle(.plain) - .font(.body).fontWeight(.medium) - .focused($focused) - .onSubmit { if paletteEnabled { ui.activeMorph = .palette } } - if !ui.searchText.isEmpty { - Button { ui.searchText = "" } label: { Image(systemName: "xmark.circle.fill") } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .help("Clear search") - .accessibilityLabel("Clear search") - } else if paletteEnabled { - Text("⌘K") - .font(.caption2) - .fontWeight(.medium) - .foregroundStyle(.tertiary) - } + return DesignToolbarSearchField(text: $ui.searchText, + prompt: paletteEnabled + ? "Search this page, or ⌘K for commands" + : "Search this page", + focused: $focused, + onSubmit: { if paletteEnabled { ui.activeMorph = .palette } }, + onClear: { ui.searchText = "" }) { + if paletteEnabled { + Text("⌘K") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.tertiary) } } - .toolbarControlContentShape() - .simultaneousGesture(TapGesture().onEnded { focused = true }) .onChange(of: ui.searchText) { _, _ in escalateIfEmpty() } .onChange(of: ui.pageResultCount) { _, _ in escalateIfEmpty() } .onChange(of: ui.searchFocusToken) { _, _ in focused = true } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift index ad99db3f..05011cb3 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift @@ -77,23 +77,28 @@ struct ToolbarUpdatesPanel: View { PanelHeader(symbol: "square.stack.3d.up", title: "Images", subtitle: "\(imageGroups.count) local · \(updateCount) update\(updateCount == 1 ? "" : "s")") { - GlassButton { - GlassButtonItem(systemName: "square.and.arrow.down", help: "Load Image Tar") { + DesignActionGroup(imageHeaderActions) + } + } + + private var imageHeaderActions: [DesignAction] { + var actions = [ + DesignAction(systemName: "square.and.arrow.down", help: "Load Image Tar") { ui.dispatch(.loadImage) onClose() - } - GlassButtonItem(systemName: "arrow.triangle.2.circlepath", help: "Check for Updates") { + }, + DesignAction(systemName: "arrow.triangle.2.circlepath", help: "Check for Updates") { Task { await app.runImageUpdateSweepNow() } - } - GlassButtonItem(systemName: "trash", role: .destructive, help: "Prune Images") { + }, + DesignAction(systemName: "trash", help: "Prune Images", role: .destructive) { ui.dispatch(.pruneImages) onClose() - } - if showClose { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true, action: onClose) - } } + ] + if showClose { + actions.append(DesignAction(systemName: "xmark", help: "Close", isCancel: true, action: onClose)) } + return actions } private var emptyCard: some View { diff --git a/Sources/Contained/Navigation/ToolbarViewOptions.swift b/Sources/Contained/Navigation/ToolbarViewOptions.swift index b968589d..7fe084f3 100644 --- a/Sources/Contained/Navigation/ToolbarViewOptions.swift +++ b/Sources/Contained/Navigation/ToolbarViewOptions.swift @@ -3,31 +3,6 @@ import ContainedDesignSystem import SwiftData import ContainedCore -private struct ToolbarGlassMenuButton: View { - @ViewBuilder var menuContent: () -> MenuContent - @ViewBuilder var labelContent: () -> LabelContent - - init(@ViewBuilder menuContent: @escaping () -> MenuContent, - @ViewBuilder labelContent: @escaping () -> LabelContent) { - self.menuContent = menuContent - self.labelContent = labelContent - } - - var body: some View { - Menu { - menuContent() - } label: { - GlassButton(singleItem: true) { - labelContent() - } - } - .menuStyle(.button) - .buttonStyle(.plain) - .menuIndicator(.hidden) - .fixedSize(horizontal: true, vertical: false) - } -} - /// The toolbar page switcher. In the experimental toolbar shell it complements the sidebar, and when /// the sidebar is hidden it becomes the compact page-jump control. struct ToolbarPageSwitcher: View { @@ -37,7 +12,7 @@ struct ToolbarPageSwitcher: View { @Query private var templates: [Template] var body: some View { - ToolbarGlassMenuButton { + DesignGlassMenuButton { ForEach(AppSectionGroup.allCases) { group in let sections = AppSection.navigableSections(panelNavigationEnabled: ui.panelNavigationEnabled) .filter { $0.group == group && ($0 != .build || app.settings.imageBuildEnabled) } @@ -106,7 +81,7 @@ struct ToolbarViewOptions: View { var body: some View { @Bindable var ui = ui - return ToolbarGlassMenuButton { + return DesignGlassMenuButton { Picker("Group by", selection: $ui.grouping) { ForEach(ContainerGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) @@ -153,101 +128,100 @@ struct ToolbarPageContextOptions: View { case .containers: EmptyView() case .images: - GlassButton { - GlassButtonItem(systemName: "square.and.arrow.down", help: "Load Image Tar") { + DesignActionGroup([ + DesignAction(systemName: "square.and.arrow.down", help: "Load Image Tar") { ui.dispatch(.loadImage) - } - GlassButtonItem(systemName: "arrow.triangle.2.circlepath", help: "Check for Updates") { + }, + DesignAction(systemName: "arrow.triangle.2.circlepath", help: "Check for Updates") { Task { await app.runImageUpdateSweepNow() } - } - GlassButtonItem(systemName: "trash", role: .destructive, help: "Prune Images") { + }, + DesignAction(systemName: "trash", help: "Prune Images", role: .destructive) { ui.dispatch(.pruneImages) } - } + ]) .help(imagesSubtitle) case .build: EmptyView() case .networks: - GlassButton { - GlassButtonItem(systemName: "plus", help: "New Network") { + DesignActionGroup([ + DesignAction(systemName: "plus", help: "New Network") { ui.dispatch(.createNetwork) - } - GlassButtonItem(systemName: "arrow.clockwise", help: "Refresh Networks") { + }, + DesignAction(systemName: "arrow.clockwise", help: "Refresh Networks") { Task { await app.refreshNetworks() } } - } + ]) .help("\(app.networks.count) network\(app.networks.count == 1 ? "" : "s")") case .volumes: - GlassButton { - GlassButtonItem(systemName: "plus", help: "New Volume") { + DesignActionGroup([ + DesignAction(systemName: "plus", help: "New Volume") { ui.dispatch(.createVolume) - } - GlassButtonItem(systemName: "arrow.clockwise", help: "Refresh Volumes") { + }, + DesignAction(systemName: "arrow.clockwise", help: "Refresh Volumes") { Task { await app.refreshSystemResources() } } - } + ]) .help("\(app.volumes.count) volume\(app.volumes.count == 1 ? "" : "s")") case .system: HStack(spacing: Tokens.Toolbar.groupSpacing) { - GlassButton { - if app.serviceHealthy { - GlassButtonItem(systemName: "stop.fill", role: .destructive, help: "Stop service") { - Task { await app.stopService() } - } - } else { - GlassButtonItem(systemName: "play.fill", help: "Start service") { - Task { await app.startService() } - } - } - GlassButtonItem(systemName: "arrow.clockwise", help: "Restart service") { - Task { await app.restartService() } - } - } - GlassButton { - ForEach(SystemContent.SystemPage.allCases) { page in - GlassButtonItem(tint: ui.systemPage == page ? .accentColor : nil, - help: page.rawValue, - isIcon: true, - action: { ui.systemPage = page }) { - Image(systemName: page.systemImage) - .opacity(ui.systemPage == page ? 1 : 0.62) - } - } - GlassButtonItem(systemName: "text.alignleft", help: "System Logs") { + DesignActionGroup(serviceActions) + DesignActionGroup(systemPageActions + [ + DesignAction(systemName: "text.alignleft", help: "System Logs") { ui.dispatch(.systemLogs) } - } + ]) } case .activity: - GlassButton { - GlassButtonItem(systemName: "checkmark.circle", help: "Mark all read") { + DesignActionGroup([ + DesignAction(systemName: "checkmark.circle", help: "Mark all read") { app.historyStore.markAllEventsRead() - } - GlassButtonItem(systemName: "trash", role: .destructive, help: "Clear activity") { + }, + DesignAction(systemName: "trash", help: "Clear activity", role: .destructive) { app.historyStore.clearEvents() } - } + ]) case .registries: EmptyView() case .settings: - GlassButton { - ForEach(SettingsContent.SettingsPage.allCases) { page in - GlassButtonItem(tint: ui.settingsPage == page ? .accentColor : nil, - help: page.rawValue, - isIcon: true, - action: { - ui.settingsPage = page - ui.navigate(to: .settings) - }) { - Image(systemName: page.systemImage) - } + DesignActionGroup(SettingsContent.SettingsPage.allCases.map { page in + DesignAction(systemName: page.systemImage, + help: page.rawValue, + tint: ui.settingsPage == page ? .accentColor : nil) { + ui.settingsPage = page + ui.navigate(to: .settings) } - } + }) case .templates: EmptyView() } } + private var serviceActions: [DesignAction] { + let power = app.serviceHealthy + ? DesignAction(systemName: "stop.fill", help: "Stop service", role: .destructive) { + Task { await app.stopService() } + } + : DesignAction(systemName: "play.fill", help: "Start service") { + Task { await app.startService() } + } + return [ + power, + DesignAction(systemName: "arrow.clockwise", help: "Restart service") { + Task { await app.restartService() } + } + ] + } + + private var systemPageActions: [DesignAction] { + SystemContent.SystemPage.allCases.map { page in + DesignAction(systemName: page.systemImage, + help: page.rawValue, + tint: ui.systemPage == page ? .accentColor : nil) { + ui.systemPage = page + } + } + } + private var imagesSubtitle: String { let groups = app.localImageGroups() let updates = groups.filter { app.imageUpdateStatus(for: $0.primaryReference).state == .updateAvailable }.count @@ -273,7 +247,7 @@ struct ToolbarPageFilterOptions: View { NetworkViewOptions() case .activity: @Bindable var ui = ui - ToolbarGlassMenuButton { + DesignGlassMenuButton { Picker("Filter", selection: $ui.activityFilter) { Label("All events", systemImage: "tray.full").tag(EventKind?.none) Divider() @@ -304,7 +278,7 @@ private struct ImageViewOptions: View { var body: some View { @Bindable var ui = ui - return ToolbarGlassMenuButton { + return DesignGlassMenuButton { Picker("Group by", selection: $ui.imageGrouping) { ForEach(ImageGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) @@ -344,7 +318,7 @@ private struct TemplateViewOptions: View { var body: some View { @Bindable var ui = ui - return ToolbarGlassMenuButton { + return DesignGlassMenuButton { Picker("Group by", selection: $ui.templateGrouping) { ForEach(TemplateGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) @@ -371,7 +345,7 @@ private struct NetworkViewOptions: View { var body: some View { @Bindable var ui = ui - return ToolbarGlassMenuButton { + return DesignGlassMenuButton { Picker("Group by", selection: $ui.networkGrouping) { ForEach(NetworkGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) diff --git a/changes/unreleased/20260702-card-anatomy.md b/changes/unreleased/20260702-card-anatomy.md index c27bf633..ef2684fc 100644 --- a/changes/unreleased/20260702-card-anatomy.md +++ b/changes/unreleased/20260702-card-anatomy.md @@ -2,3 +2,4 @@ - Routed remaining card widget/footer grouping through packaged card primitives, moved expanded page controls into the shared header trailing slot, kept that slot layout-stable during expansion, and updated design-system docs with the sticky/body slot contract. - Added a packaged sticky header text lane so card titles and subtitles stay anchored with the leading icon chip while expanded controls and page selections change. - Introduced `ResourceCard` as the app-facing resource-card API, including typed page configuration and named icon/title/subtitle/header/body/widget/footer slots, and migrated app cards away from direct `ResourceGlassCard`/header composition. +- Added semantic design-system action, toggle, toolbar, selection bar, progress, and status banner routes, then migrated app command chrome away from direct glass button/surface styling. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index e373320f..b41e68ac 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -48,7 +48,7 @@ remains the source of truth for builds, tests, and release scripts. ## Design system -Liquid Glass helpers and reusable primitives include `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `ResourceCard`, `ResourceCardInsetSection`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `GlassButton`, `ToolbarMenuButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `Tokens` groups for toolbar, panel, spacing, radius, icon sizing, resource cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, or micro-chrome recipes. App-state-aware mappings such as runtime status and graph metric extraction stay in the executable until they can cross the boundary without depending on app/core policy. App-side resource cards should use `ResourceCard` rather than assembling `ResourceGlassCard`, headers, page rails, widgets, or footers directly. Use the package READMEs for import instructions and copy-pasteable examples, and see [[Design System|Design-System]] for app-level conventions. +Liquid Glass helpers and reusable primitives include `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `ResourceCard`, `ResourceCardInsetSection`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `DesignStatusBanner`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `Tokens` groups for toolbar, panel, spacing, radius, icon sizing, resource cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, glass button styles, or micro-chrome recipes. App-state-aware mappings such as runtime status and graph metric extraction stay in the executable until they can cross the boundary without depending on app/core policy. App-side resource cards should use `ResourceCard` rather than assembling `ResourceGlassCard`, headers, page rails, widgets, or footers directly, and app-side command chrome should use the named design action/toolbar controls rather than `GlassButton`, `glassSurface`, or `.buttonStyle(.glass*)` directly. Use the package READMEs for import instructions and copy-pasteable examples, and see [[Design System|Design-System]] for app-level conventions. ## Local-only personalization diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index 52d3653c..3a23319f 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -83,7 +83,7 @@ appcast.xml Sparkle feed at the root of each release branch - **Pure decision logic is factored into `ContainedCore`** (`RestartDecision`, `HealthDecision`, compose ordering) and unit-tested without spawning processes. - **No `contained.*` personalization labels.** Card styles and healthchecks live in local stores. Only `contained.restart` and `contained.stack` are written (they must round-trip through the container). - **Never put secrets or personal data in test fixtures.** Fixtures are captured CLI output — scrub tokens, domains, and paths before committing. (`.gitignore` blocks signing material; push protection is on.) -- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer shared primitives such as `PanelHeader`, `PanelSection`, `MorphPanelScaffold`, `ResourceCard`, `GlassButton`, `CommandPreviewBar`, and `Tokens`. Do not add app-local spacing, padding, radius, shadow, material, opacity, or badge/keycap/status-dot recipes; add them to `ContainedDesignSystem` first. +- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer app-facing design routes such as `PanelHeader`, `PanelSection`, `MorphPanelScaffold`, `ResourceCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `CommandPreviewBar`, and `Tokens`. Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or badge/keycap/status-dot recipes; add them to `ContainedDesignSystem` first. - **Gate debug-only tools at compile time.** Use `#if CONTAINED_DEBUG_TOOLS` for debug menus, diagnostics, fixtures, or local-only inspection surfaces. SwiftPM defines that flag only for debug builds, so release bundles exclude the code instead of merely hiding it at runtime. - **Keep the sidebar fallback working.** Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements for the classic shell. - **Sync docs with behavior.** If behavior, settings, routes, or user-facing wording changes, update the matching page under `docs/wiki` and keep README links current. diff --git a/docs/wiki/Design-System.md b/docs/wiki/Design-System.md index ca544961..8ad8ecbc 100644 --- a/docs/wiki/Design-System.md +++ b/docs/wiki/Design-System.md @@ -16,13 +16,13 @@ Package-local docs: The package owns the shared tokens, visual-effect background bridge, exterior shadow, glass surface modifier, panel/page/sheet scaffolds, toolbar controls, -option tiles, transient error banner, resource-card chrome, activity status, -JSON and stream-console surfaces, sparklines, clipboard helper, gradient-angle -control, and micro primitives such as status dots, badges, keycaps, metric -tiles, terminal chrome, and card-selection overlays. Components that read -`AppModel`, settings stores, feature routes, or runtime models stay in the app -target, but they should pass values into package components instead of -recreating style locally. +option tiles, transient status/error banners, resource-card chrome, action +buttons, toggles, selection bars, activity status, JSON and stream-console +surfaces, sparklines, clipboard helper, gradient-angle control, and micro +primitives such as status dots, badges, keycaps, metric tiles, terminal chrome, +and card-selection overlays. Components that read `AppModel`, settings stores, +feature routes, or runtime models stay in the app target, but they should pass +values into package components instead of recreating style locally. ## Core principles @@ -110,6 +110,25 @@ available through their toolbar/menu entry points. Expanded resource cards opened from full pages should receive the same toolbar safe-area contract as morph panels, clearing both top and bottom toolbar bands. +## Action and toolbar chrome + +Use package-owned semantic controls for command chrome: + +- `DesignActionGroup` and `DesignActionItems` for icon action groups +- `DesignTextActionButton` for labeled standard or prominent actions +- `DesignGlassToggle` for glass toggle buttons +- `DesignSelectionActionBar` for floating selection bars +- `DesignStatusBanner` for transient bottom banners +- `DesignToolbarSearchField`, `DesignGlassMenuButton`, + `DesignToolbarStatusButton`, `DesignToolbarActionCluster`, and + `DesignToolbarVanitySlot` for toolbar-specific slots + +Feature views should not call `GlassButton`, `GlassButtonItem`, +`GlassButtonInputItem`, `glassSurface`, `glassCapsuleSurface`, or +`.buttonStyle(.glass/.glassProminent)` directly. Those are package composition +pieces. If a view needs a new command shape, add a named design-system route and +then consume it from the app. + ## Resource cards Use `ResourceCard` for containers, images, tags, volumes, networks, and @@ -173,10 +192,11 @@ Plain rows are reserved for generic actions such as refresh or opening a page. Use `Tokens` for spacing, radius, toolbar dimensions, panel sizes, icon sizes, form widths, chart sizes, badge/keycap insets, resource-card opacities, terminal chrome, and shadows. Feature views should not call low-level surface modifiers -such as `glassSurface`; use named package routes such as `ResourceCard`, -`PanelSection`, `DesignContentSurface`, `DesignInputSurface`, and -`ResourceCardInsetSection`. If a new visual value appears, add a token or package -primitive before using it in the app. +or glass button styles; use named package routes such as `ResourceCard`, +`PanelSection`, `DesignContentSurface`, `DesignInputSurface`, +`DesignActionGroup`, `DesignTextActionButton`, and `ResourceCardInsetSection`. +If a new visual value appears, add a token or package primitive before using it +in the app. Important groups: From 4437f08c271cb36b54b32628d5d3a9ca5529ac31 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 06:27:13 +0100 Subject: [PATCH 17/53] Split runtime contracts from Apple adapter --- AGENTS.md | 4 +- CHANGELOG.md | 2 +- Package.swift | 26 +- README.md | 6 +- .../AppleContainerCLILocator.swift} | 2 +- .../AppleContainerClient.swift} | 38 +- .../ContainerStatsTableParser.swift | 1 + Sources/Contained/ContainedApp.swift | 3 +- .../Containers/ContainersGridView.swift | 1 + .../Features/Containers/FilesTab.swift | 1 + .../Features/Containers/LogsTab.swift | 1 + .../Features/MenuBar/MenuBarContent.swift | 3 +- .../Registries/RegistryLoginSheet.swift | 1 + .../Settings/Tabs/RegistriesTab.swift | 1 + .../Features/System/SystemView.swift | 1 + .../Contained/Navigation/ClassicShell.swift | 1 + .../ToolbarPanels/ToolbarImageGroupCard.swift | 1 + Sources/Contained/Resources/CHANGELOG.md | 2 +- .../Stores/AppModel+ImageUpdates.swift | 1 + Sources/Contained/Stores/AppModel.swift | 18 +- .../Contained/Stores/ContainersStore.swift | 12 +- .../Contained/Stores/RestartWatchdog.swift | 5 +- Sources/Contained/Support/HealthMonitor.swift | 7 +- .../CommandError.swift | 0 .../CommandRunner.swift | 0 .../RuntimeDescriptor.swift} | 116 +++- .../ContainersStoreRefreshTests.swift | 8 +- Tests/ContainedCoreTests/CommandTests.swift | 60 -- Tests/ContainedCoreTests/Fixtures.swift | 17 - Tests/ContainedCoreTests/Phase4Tests.swift | 33 -- .../AppleContainerRuntimeTests.swift | 129 +++++ Tests/ContainedRuntimeTests/Fixtures.swift | 38 ++ Tests/ContainedRuntimeTests/Fixtures/df.json | 20 + .../Fixtures/image-inspect.json | 526 ++++++++++++++++++ .../Fixtures/images-error.txt | 1 + .../Fixtures/inspect.json | 119 ++++ .../Fixtures/list-current.json | 1 + .../ContainedRuntimeTests/Fixtures/list.json | 1 + .../Fixtures/networks.json | 1 + .../Fixtures/stats-table.txt | 9 + .../ContainedRuntimeTests/Fixtures/stats.json | 1 + .../Fixtures/status.json | 1 + .../Fixtures/volumes.json | 1 + .../20260701-runtime-abstraction.md | 2 +- docs/wiki/Architecture.md | 34 +- docs/wiki/Contributing.md | 13 +- docs/wiki/Runtime-Adapters.md | 47 ++ docs/wiki/_Sidebar.md | 1 + 48 files changed, 1126 insertions(+), 191 deletions(-) rename Sources/{ContainedCore/Services/CLILocator.swift => AppleContainerRuntime/AppleContainerCLILocator.swift} (97%) rename Sources/{ContainedCore/Services/ContainerClient.swift => AppleContainerRuntime/AppleContainerClient.swift} (87%) rename Sources/{ContainedCore/Services => AppleContainerRuntime}/ContainerStatsTableParser.swift (99%) rename Sources/{ContainedCore/Services => ContainedRuntime}/CommandError.swift (100%) rename Sources/{ContainedCore/Services => ContainedRuntime}/CommandRunner.swift (100%) rename Sources/{ContainedCore/Services/ContainerRuntime.swift => ContainedRuntime/RuntimeDescriptor.swift} (50%) create mode 100644 Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift create mode 100644 Tests/ContainedRuntimeTests/Fixtures.swift create mode 100644 Tests/ContainedRuntimeTests/Fixtures/df.json create mode 100644 Tests/ContainedRuntimeTests/Fixtures/image-inspect.json create mode 100644 Tests/ContainedRuntimeTests/Fixtures/images-error.txt create mode 100644 Tests/ContainedRuntimeTests/Fixtures/inspect.json create mode 100644 Tests/ContainedRuntimeTests/Fixtures/list-current.json create mode 100644 Tests/ContainedRuntimeTests/Fixtures/list.json create mode 100644 Tests/ContainedRuntimeTests/Fixtures/networks.json create mode 100644 Tests/ContainedRuntimeTests/Fixtures/stats-table.txt create mode 100644 Tests/ContainedRuntimeTests/Fixtures/stats.json create mode 100644 Tests/ContainedRuntimeTests/Fixtures/status.json create mode 100644 Tests/ContainedRuntimeTests/Fixtures/volumes.json create mode 100644 docs/wiki/Runtime-Adapters.md diff --git a/AGENTS.md b/AGENTS.md index 195ae50a..81515ad4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ This file is the working contract for coding agents in this repository. Follow i - Local reusable packages live under `Packages/` and are consumed by the root SwiftPM package. - `Contained.xcworkspace` is an Xcode convenience entry point over the SwiftPM manifests. Do not hand-maintain generated `.xcodeproj` state. - `Sources/ContainedCore` is pure/testable logic. Keep SwiftUI, app state, Sparkle, and persistence out of it. +- `Sources/ContainedRuntime` is the shared runtime contract module. Keep it adapter-neutral so future runtimes can conform without changing app stores or views. +- `Sources/AppleContainerRuntime` is the Apple `container` adapter. Future Docker-compatible, Podman, Lima-backed, remote, or other engines should be sibling adapter targets rather than switches in the app. - `Sources/Contained` is the app: SwiftUI screens, app-specific presentation mappings, navigation, stores, history, settings, and update support. - `Packages/ContainedDesignSystem` is the reusable SwiftUI/AppKit design-system package. Keep app state, stores, Sparkle, SwiftData, persistence, and feature routing out of it. - `Packages/ContainedNavigation` is the reusable navigation/layout package. Keep app sections, toolbar panels, stores, and concrete routing state in `Sources/Contained`. @@ -56,7 +58,7 @@ This file is the working contract for coding agents in this repository. Follow i ## Coding Rules -- Keep CLI actions behind `ContainerCommands` and `ContainerClient`; do not assemble argv inline in SwiftUI. +- Keep Apple `container` CLI actions behind `ContainerCommands` and `AppleContainerRuntime`; do not assemble argv inline in SwiftUI. App stores should depend on `any ContainerRuntimeClient` where backend choice matters. - Put pure decision logic in `ContainedCore` with focused tests. - Do not write app personalization back as `contained.*` labels. Only `contained.restart` and `contained.stack` may round-trip through container labels. - Keep helper scripts in `scripts/` and prefer hyphenated file names for multi-word shell scripts. diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e10411..7ebe7688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,7 +95,7 @@ - Swift Package layout with a pure `ContainedCore` library for CLI command builders, JSON models, compose parsing, decision helpers, and service logic, plus a `Contained` SwiftUI executable for UI, stores, Sparkle, SwiftData, and migration. - `ContainerCommands` is the single source of truth for `container` argv construction and is covered by golden tests. -- `CommandRunner` supports one-shot commands and streamed output; `ContainerClient` exposes typed async methods over real `container --format json` output. +- `ContainedRuntime` defines the shared runtime contract, while `AppleContainerRuntime` exposes typed async methods over real `container --format json` output and typed stats streams. - `RunSpec` is the single source of truth for Run/Edit form state, validation, CLI preview, and actual execution. - `AppModel` owns bootstrap, client wiring, stores, refresh coordination, image updates, service lifecycle, config transfer, and resource-style lookup through focused extensions. - `UIState`, `AppSection`, toolbar option enums, and pending actions centralize navigation, filters, morph routing, and classic fallback routing. diff --git a/Package.swift b/Package.swift index 20241b60..7c1ae83e 100644 --- a/Package.swift +++ b/Package.swift @@ -7,6 +7,8 @@ let package = Package( products: [ .executable(name: "Contained", targets: ["Contained"]), .library(name: "ContainedCore", targets: ["ContainedCore"]), + .library(name: "ContainedRuntime", targets: ["ContainedRuntime"]), + .library(name: "AppleContainerRuntime", targets: ["AppleContainerRuntime"]), ], dependencies: [ .package(path: "Packages/ContainedDesignSystem"), @@ -27,11 +29,27 @@ let package = Package( dependencies: [.product(name: "Yams", package: "Yams")], path: "Sources/ContainedCore" ), + // Runtime adapter contracts. Keep this generic so Apple container, Docker-compatible, + // and future engines can share one app-facing capability/client boundary. + .target( + name: "ContainedRuntime", + dependencies: ["ContainedCore"], + path: "Sources/ContainedRuntime" + ), + // Current Apple `container` CLI adapter. Future runtime adapters should be sibling targets, + // not branches inside the app stores or SwiftUI views. + .target( + name: "AppleContainerRuntime", + dependencies: ["ContainedCore", "ContainedRuntime"], + path: "Sources/AppleContainerRuntime" + ), // The SwiftUI app, including Sparkle wiring for signed release builds. .executableTarget( name: "Contained", dependencies: [ "ContainedCore", + "ContainedRuntime", + "AppleContainerRuntime", .product(name: "ContainedDesignSystem", package: "ContainedDesignSystem"), .product(name: "ContainedNavigation", package: "ContainedNavigation"), .product(name: "SwiftTerm", package: "SwiftTerm"), @@ -49,11 +67,17 @@ let package = Package( path: "Tests/ContainedCoreTests", resources: [.copy("Fixtures")] ), + .testTarget( + name: "ContainedRuntimeTests", + dependencies: ["ContainedCore", "ContainedRuntime", "AppleContainerRuntime"], + path: "Tests/ContainedRuntimeTests", + resources: [.copy("Fixtures")] + ), // Tests for app-target value types (RunSpec argv, compose→spec mapping). Imports the // executable target with @testable. .testTarget( name: "ContainedAppTests", - dependencies: ["Contained", "ContainedCore"], + dependencies: ["Contained", "ContainedCore", "ContainedRuntime", "AppleContainerRuntime"], path: "Tests/ContainedAppTests" ), .testTarget( diff --git a/README.md b/README.md index f3b867f3..89318c17 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ changes can be reviewed with code changes: - Start: [Features](https://github.com/tdeverx/contained-app/wiki/Features), [Installation](https://github.com/tdeverx/contained-app/wiki/Installation), [Keyboard Shortcuts](https://github.com/tdeverx/contained-app/wiki/Keyboard-Shortcuts), [Troubleshooting](https://github.com/tdeverx/contained-app/wiki/Troubleshooting) - Workflows: [Creation Workflow](https://github.com/tdeverx/contained-app/wiki/Creation-Workflow), [Run / Edit Form](https://github.com/tdeverx/contained-app/wiki/Run-Edit-Form), [Compose Import](https://github.com/tdeverx/contained-app/wiki/Compose-Import), [Command Palette](https://github.com/tdeverx/contained-app/wiki/Command-Palette), [Updates](https://github.com/tdeverx/contained-app/wiki/Updates) - Feature areas: [Containers](https://github.com/tdeverx/contained-app/wiki/Features-Containers), [Images](https://github.com/tdeverx/contained-app/wiki/Features-Images), [Resources](https://github.com/tdeverx/contained-app/wiki/Features-Resources), [System & Settings](https://github.com/tdeverx/contained-app/wiki/System-Settings) -- Maintainers: [Architecture](https://github.com/tdeverx/contained-app/wiki/Architecture), [Design System](https://github.com/tdeverx/contained-app/wiki/Design-System), [Release Runbook](https://github.com/tdeverx/contained-app/wiki/Release), [Contributing](https://github.com/tdeverx/contained-app/wiki/Contributing), [Issues and Discussions](https://github.com/tdeverx/contained-app/wiki/Issues-and-Discussions) +- Maintainers: [Architecture](https://github.com/tdeverx/contained-app/wiki/Architecture), [Runtime Adapters](https://github.com/tdeverx/contained-app/wiki/Runtime-Adapters), [Design System](https://github.com/tdeverx/contained-app/wiki/Design-System), [Release Runbook](https://github.com/tdeverx/contained-app/wiki/Release), [Contributing](https://github.com/tdeverx/contained-app/wiki/Contributing), [Issues and Discussions](https://github.com/tdeverx/contained-app/wiki/Issues-and-Discussions) ## Contributing And Support @@ -83,7 +83,9 @@ instead. The root package has the app/core targets and consumes local reusable packages: -- `ContainedCore`: models, CLI command builders, real `container --format json` decoders, and testable service logic. +- `ContainedCore`: models, Apple `container` argv builders, real `container --format json` decoders, compose parsing, and testable service logic. +- `ContainedRuntime`: shared runtime contracts, descriptors, capabilities, command errors, and command execution primitives. +- `AppleContainerRuntime`: the current Apple `container` adapter. Future Docker-compatible, Podman, Lima-backed, remote, or other runtime engines should be sibling adapter targets. - `Contained`: SwiftUI app shell, navigation, feature views, stores, history, settings, Sparkle support, app state migration, and app-specific presentation mappings. - [`Packages/ContainedDesignSystem`](Packages/ContainedDesignSystem/README.md): reusable SwiftUI/AppKit visual primitives, tokens, spacing, material, and micro-chrome shared by the app. - [`Packages/ContainedNavigation`](Packages/ContainedNavigation/README.md): reusable navigation and layout infrastructure shared by app chrome. diff --git a/Sources/ContainedCore/Services/CLILocator.swift b/Sources/AppleContainerRuntime/AppleContainerCLILocator.swift similarity index 97% rename from Sources/ContainedCore/Services/CLILocator.swift rename to Sources/AppleContainerRuntime/AppleContainerCLILocator.swift index b3fc92e1..91d998dc 100644 --- a/Sources/ContainedCore/Services/CLILocator.swift +++ b/Sources/AppleContainerRuntime/AppleContainerCLILocator.swift @@ -2,7 +2,7 @@ import Foundation /// Finds the `container` binary and reports its version. The app is not sandboxed, so it can read /// these well-known install locations directly. -public enum CLILocator { +public enum AppleContainerCLILocator { public static let defaultCandidates = [ "/usr/local/bin/container", "/opt/homebrew/bin/container", diff --git a/Sources/ContainedCore/Services/ContainerClient.swift b/Sources/AppleContainerRuntime/AppleContainerClient.swift similarity index 87% rename from Sources/ContainedCore/Services/ContainerClient.swift rename to Sources/AppleContainerRuntime/AppleContainerClient.swift index 5855a067..4892ef6d 100644 --- a/Sources/ContainedCore/Services/ContainerClient.swift +++ b/Sources/AppleContainerRuntime/AppleContainerClient.swift @@ -1,8 +1,10 @@ import Foundation +import ContainedCore +import ContainedRuntime /// Typed facade over a `CommandRunning`. Returns decoded models; maps decode failures to /// `CommandError.decodingFailed` so callers handle one error type. -public struct ContainerClient: Sendable { +public struct AppleContainerClient: Sendable { public let runner: any CommandRunning public var descriptor: RuntimeDescriptor { .appleContainer } @@ -23,10 +25,32 @@ public struct ContainerClient: Sendable { priority: .utility) } - public func streamStatsTable(ids: [String] = []) -> AsyncThrowingStream { + private func statsTableStream(ids: [String] = []) -> AsyncThrowingStream { runner.stream(ContainerCommands.statsTableStream(ids: ids), priority: .utility) } + public func streamStats(ids: [String] = []) -> AsyncThrowingStream<[RuntimeStatsSnapshot], Error> { + let source = statsTableStream(ids: ids) + return AsyncThrowingStream { continuation in + let task = Task(priority: .utility) { + var parser = ContainerStatsTableParser() + do { + for try await chunk in source { + try Task.checkCancellation() + let samples = parser.append(chunk) + if !samples.isEmpty { continuation.yield(samples) } + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + public func diskUsage() async throws -> DiskUsage { try await decode(DiskUsage.self, ContainerCommands.systemDF, "system df") } @@ -113,6 +137,14 @@ public struct ContainerClient: Sendable { runner.stream(ContainerCommands.imagePush(ref, platform: platform)) } + @discardableResult public func runContainer(arguments: [String]) async throws -> Data { + try await runner.run(arguments) + } + + @discardableResult public func performSystemAction(_ action: String) async throws -> Data { + try await runner.run(["system", action]) + } + // MARK: Registries public func registries() async throws -> [RegistryLogin] { @@ -204,4 +236,4 @@ public struct ContainerClient: Sendable { } } -extension ContainerClient: ContainerRuntimeClient {} +extension AppleContainerClient: ContainerRuntimeClient {} diff --git a/Sources/ContainedCore/Services/ContainerStatsTableParser.swift b/Sources/AppleContainerRuntime/ContainerStatsTableParser.swift similarity index 99% rename from Sources/ContainedCore/Services/ContainerStatsTableParser.swift rename to Sources/AppleContainerRuntime/ContainerStatsTableParser.swift index 1ea5736e..19a06de9 100644 --- a/Sources/ContainedCore/Services/ContainerStatsTableParser.swift +++ b/Sources/AppleContainerRuntime/ContainerStatsTableParser.swift @@ -1,4 +1,5 @@ import Foundation +import ContainedCore /// Parses the ANSI table emitted by `container stats --format table`. /// diff --git a/Sources/Contained/ContainedApp.swift b/Sources/Contained/ContainedApp.swift index 010f81b0..22dcb9fb 100644 --- a/Sources/Contained/ContainedApp.swift +++ b/Sources/Contained/ContainedApp.swift @@ -2,6 +2,7 @@ import SwiftUI import ContainedDesignSystem import AppKit import ContainedCore +import AppleContainerRuntime @main struct ContainedApp: App { @@ -236,7 +237,7 @@ struct ContainedApp: App { /// Reveal the resolved `container` binary in Finder (honoring the CLI-path override). private func revealCLIBinary() { - guard let url = CLILocator.locate(override: app.settings.cliPathOverride) else { return } + guard let url = AppleContainerCLILocator.locate(override: app.settings.cliPathOverride) else { return } NSWorkspace.shared.activateFileViewerSelecting([url]) } diff --git a/Sources/Contained/Features/Containers/ContainersGridView.swift b/Sources/Contained/Features/Containers/ContainersGridView.swift index e5af17ca..ae973376 100644 --- a/Sources/Contained/Features/Containers/ContainersGridView.swift +++ b/Sources/Contained/Features/Containers/ContainersGridView.swift @@ -3,6 +3,7 @@ import ContainedNavigation import ContainedDesignSystem import AppKit import ContainedCore +import ContainedRuntime /// The Containers screen: a responsive grid of personalized glass cards. Density and the running /// filter live in the background context menu and menu commands; tapping a card grows it in place diff --git a/Sources/Contained/Features/Containers/FilesTab.swift b/Sources/Contained/Features/Containers/FilesTab.swift index a8ea2935..a3081ef5 100644 --- a/Sources/Contained/Features/Containers/FilesTab.swift +++ b/Sources/Contained/Features/Containers/FilesTab.swift @@ -2,6 +2,7 @@ import SwiftUI import ContainedDesignSystem import AppKit import ContainedCore +import ContainedRuntime /// Browse a running container's filesystem (`exec ls -1ap`) and copy files in/out with the native /// `container cp`. AppKit bridge (flagged): `NSOpenPanel`/`NSSavePanel` for host file selection. diff --git a/Sources/Contained/Features/Containers/LogsTab.swift b/Sources/Contained/Features/Containers/LogsTab.swift index 3ca260d9..4444b57f 100644 --- a/Sources/Contained/Features/Containers/LogsTab.swift +++ b/Sources/Contained/Features/Containers/LogsTab.swift @@ -1,6 +1,7 @@ import SwiftUI import ContainedDesignSystem import ContainedCore +import ContainedRuntime /// Live container logs via `container logs --follow`. The stream is tied to this view's lifetime /// (`.task(id:)`), so leaving the tab cancels it and terminates the child process (SIGTERM). diff --git a/Sources/Contained/Features/MenuBar/MenuBarContent.swift b/Sources/Contained/Features/MenuBar/MenuBarContent.swift index f92b3f43..60ddcb04 100644 --- a/Sources/Contained/Features/MenuBar/MenuBarContent.swift +++ b/Sources/Contained/Features/MenuBar/MenuBarContent.swift @@ -1,6 +1,7 @@ import SwiftUI import AppKit import ContainedCore +import AppleContainerRuntime import ContainedDesignSystem /// The menu shown by the menu-bar extra: a compact command surface with service status, running @@ -283,7 +284,7 @@ struct MenuBarContent: View { /// Reveal the resolved `container` binary in Finder (honoring the CLI-path override). private func revealCLIBinary() { - guard let url = CLILocator.locate(override: app.settings.cliPathOverride) else { return } + guard let url = AppleContainerCLILocator.locate(override: app.settings.cliPathOverride) else { return } NSWorkspace.shared.activateFileViewerSelecting([url]) } } diff --git a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift b/Sources/Contained/Features/Registries/RegistryLoginSheet.swift index 136bcb3a..5a010369 100644 --- a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift +++ b/Sources/Contained/Features/Registries/RegistryLoginSheet.swift @@ -1,6 +1,7 @@ import SwiftUI import ContainedDesignSystem import ContainedCore +import ContainedRuntime /// Sign in to a registry. The user types their own credentials; the password is sent via stdin. /// Registry credential management lives in Settings → Registries; this sheet is launched from that tab. diff --git a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift b/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift index 10712a21..819862d1 100644 --- a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift @@ -1,6 +1,7 @@ import SwiftUI import ContainedDesignSystem import ContainedCore +import ContainedRuntime // MARK: - Registries diff --git a/Sources/Contained/Features/System/SystemView.swift b/Sources/Contained/Features/System/SystemView.swift index 7ea8807e..6c7fd06e 100644 --- a/Sources/Contained/Features/System/SystemView.swift +++ b/Sources/Contained/Features/System/SystemView.swift @@ -2,6 +2,7 @@ import SwiftUI import ContainedNavigation import ContainedDesignSystem import ContainedCore +import ContainedRuntime /// System overview content: service status + controls, volumes, `system df` disk usage, a Prune /// Center, and a system-logs viewer. Hosted header-less in the toolbar System morph panel. Daemon diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/Contained/Navigation/ClassicShell.swift index 787ab52f..397663bd 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/Contained/Navigation/ClassicShell.swift @@ -3,6 +3,7 @@ import ContainedNavigation import ContainedDesignSystem import SwiftData import ContainedCore +import ContainedRuntime struct ClassicShell: View { @Environment(AppModel.self) private var app diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index f946d605..33871130 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -4,6 +4,7 @@ import ContainedDesignSystem import SwiftData import AppKit import ContainedCore +import ContainedRuntime struct ToolbarImageGroupCard: View { @Environment(AppModel.self) private var app diff --git a/Sources/Contained/Resources/CHANGELOG.md b/Sources/Contained/Resources/CHANGELOG.md index 26e10411..7ebe7688 100644 --- a/Sources/Contained/Resources/CHANGELOG.md +++ b/Sources/Contained/Resources/CHANGELOG.md @@ -95,7 +95,7 @@ - Swift Package layout with a pure `ContainedCore` library for CLI command builders, JSON models, compose parsing, decision helpers, and service logic, plus a `Contained` SwiftUI executable for UI, stores, Sparkle, SwiftData, and migration. - `ContainerCommands` is the single source of truth for `container` argv construction and is covered by golden tests. -- `CommandRunner` supports one-shot commands and streamed output; `ContainerClient` exposes typed async methods over real `container --format json` output. +- `ContainedRuntime` defines the shared runtime contract, while `AppleContainerRuntime` exposes typed async methods over real `container --format json` output and typed stats streams. - `RunSpec` is the single source of truth for Run/Edit form state, validation, CLI preview, and actual execution. - `AppModel` owns bootstrap, client wiring, stores, refresh coordination, image updates, service lifecycle, config transfer, and resource-style lookup through focused extensions. - `UIState`, `AppSection`, toolbar option enums, and pending actions centralize navigation, filters, morph routing, and classic fallback routing. diff --git a/Sources/Contained/Stores/AppModel+ImageUpdates.swift b/Sources/Contained/Stores/AppModel+ImageUpdates.swift index 18472485..d7856483 100644 --- a/Sources/Contained/Stores/AppModel+ImageUpdates.swift +++ b/Sources/Contained/Stores/AppModel+ImageUpdates.swift @@ -1,5 +1,6 @@ import SwiftUI import ContainedCore +import ContainedRuntime /// Image-update tracking: comparing the local digest of each image against the registry's current /// manifest digest, on a throttled background sweep and on demand. Split out of `AppModel` because it diff --git a/Sources/Contained/Stores/AppModel.swift b/Sources/Contained/Stores/AppModel.swift index 6e7b662e..4774e9ee 100644 --- a/Sources/Contained/Stores/AppModel.swift +++ b/Sources/Contained/Stores/AppModel.swift @@ -1,5 +1,7 @@ import SwiftUI import ContainedCore +import ContainedRuntime +import AppleContainerRuntime import OSLog /// Root app state: locates the CLI, owns the typed client and the feature stores, and tracks the @@ -31,7 +33,7 @@ final class AppModel { let manifestClient = RegistryManifestClient() private(set) var bootstrap: Bootstrap = .checking - private(set) var client: ContainerClient? + private(set) var client: (any ContainerRuntimeClient)? /// Resolved path to the `container` binary — needed to spawn the terminal's `exec` process. private(set) var cliURL: URL? private(set) var systemStatus: SystemStatus? @@ -154,13 +156,13 @@ final class AppModel { func bootstrapIfNeeded() async { logger.record("Checking container CLI", category: .system, severity: .debug) - guard let url = CLILocator.locate(override: settings.cliPathOverride) else { + guard let url = AppleContainerCLILocator.locate(override: settings.cliPathOverride) else { bootstrap = .cliMissing logger.record("Container CLI missing", category: .system, severity: .error) return } let runner = CommandRunner(executableURL: url) - let client = ContainerClient(runner: runner) + let client = AppleContainerClient(runner: runner) self.client = client self.cliURL = url containers.client = client @@ -168,8 +170,8 @@ final class AppModel { // Version check. if let versionData = try? await runner.run(ContainerCommands.version) { let raw = String(decoding: versionData, as: UTF8.self) - cliVersion = CLILocator.parseVersion(raw) - if let v = cliVersion, !CLILocator.isSupported(v) { + cliVersion = AppleContainerCLILocator.parseVersion(raw) + if let v = cliVersion, !AppleContainerCLILocator.isSupported(v) { bootstrap = .unsupported(version: v) logger.record("Unsupported container CLI version \(v)", category: .system, severity: .error) return @@ -335,11 +337,9 @@ final class AppModel { containerStatsStreamIDs = ids diagnosticLogger.info("Stats stream starting for \(ids.count, privacy: .public) container(s)") containerStatsStreamTask = Task(priority: .utility) { [weak self, client, ids, generation] in - var parser = ContainerStatsTableParser() do { - for try await chunk in client.streamStatsTable(ids: ids) { + for try await samples in client.streamStats(ids: ids) { guard !Task.isCancelled else { return } - let samples = parser.append(chunk) guard !samples.isEmpty else { continue } await MainActor.run { guard let self, @@ -620,7 +620,7 @@ final class AppModel { guard let client else { return } bootstrap = .checking if resetWatchdog { watchdog.reset() } - for action in actions { _ = try? await client.runner.run(["system", action]) } + for action in actions { _ = try? await client.performSystemAction(action) } await refreshSystem() } diff --git a/Sources/Contained/Stores/ContainersStore.swift b/Sources/Contained/Stores/ContainersStore.swift index 26bc8d73..fe67cd5d 100644 --- a/Sources/Contained/Stores/ContainersStore.swift +++ b/Sources/Contained/Stores/ContainersStore.swift @@ -2,6 +2,8 @@ import SwiftUI import ContainedDesignSystem import OSLog import ContainedCore +import ContainedRuntime +import ContainedRuntime /// Owns the container list and derived live stats. Lifecycle actions run through the client and /// trigger a refresh. Stats arrive from the app-wide runtime stream and are converted into deltas @@ -58,7 +60,7 @@ final class ContainersStore { @ObservationIgnored private var metricsStates: [String: ContainerMetricsState] = [:] @ObservationIgnored private var statsNormalizationContext: StatsNormalizationContext = .containerSpecific - var client: ContainerClient? + var client: (any ContainerRuntimeClient)? private var lastStreamedStats: [String: RuntimeStatsSnapshot] = [:] private var lastStreamedStatsDate: Date? @@ -272,7 +274,7 @@ final class ContainersStore { severity: .info) diagnosticLogger.notice("Run started from creation flow") do { - let output = try await client.runner.run(spec.arguments()) + let output = try await client.runContainer(arguments: spec.arguments()) performHaptic() await refresh() let elapsed = Date().timeIntervalSince(started) @@ -321,7 +323,7 @@ final class ContainersStore { do { _ = try? await client.stop([originalID]) // best-effort; may already be stopped _ = try await client.deleteContainers([originalID], force: true) - _ = try await client.runner.run(spec.arguments()) + _ = try await client.runContainer(arguments: spec.arguments()) performHaptic() await refresh() let elapsed = Date().timeIntervalSince(started) @@ -355,7 +357,9 @@ final class ContainersStore { } } - private func act(_ id: String, verb: String, _ body: @escaping (ContainerClient) async throws -> Void) async { + private func act(_ id: String, + verb: String, + _ body: @escaping (any ContainerRuntimeClient) async throws -> Void) async { guard let client else { return } busyIDs.insert(id) defer { busyIDs.remove(id) } diff --git a/Sources/Contained/Stores/RestartWatchdog.swift b/Sources/Contained/Stores/RestartWatchdog.swift index 6ca85ca7..27c77318 100644 --- a/Sources/Contained/Stores/RestartWatchdog.swift +++ b/Sources/Contained/Stores/RestartWatchdog.swift @@ -1,5 +1,6 @@ import Foundation import ContainedCore +import ContainedRuntime /// App-managed restart policy. The `container` CLI has no native `--restart`, so on each refresh /// tick we diff container states and re-issue `start` for containers that crashed (transitioned @@ -22,7 +23,9 @@ final class RestartWatchdog { private var nextEligible: [String: Date] = [:] /// Evaluate the latest snapshots against the previous tick and act on crashes. - func evaluate(snapshots: [ContainerSnapshot], store: ContainersStore, client: ContainerClient, + func evaluate(snapshots: [ContainerSnapshot], + store: ContainersStore, + client: any ContainerRuntimeClient, now: Date = Date()) async { var restarts: [(ContainerSnapshot, Int)] = [] diff --git a/Sources/Contained/Support/HealthMonitor.swift b/Sources/Contained/Support/HealthMonitor.swift index a7a6b75a..f63b0de8 100644 --- a/Sources/Contained/Support/HealthMonitor.swift +++ b/Sources/Contained/Support/HealthMonitor.swift @@ -1,5 +1,6 @@ import SwiftUI import ContainedCore +import ContainedRuntime /// Local store of per-container healthchecks (keyed by container id), persisted to UserDefaults. /// Migrated to SwiftData in WS7, alongside personalization. @@ -66,8 +67,10 @@ final class HealthMonitor { /// Fired once when a container transitions into the unhealthy state. var onUnhealthy: ((ContainerSnapshot) -> Void)? - func evaluate(snapshots: [ContainerSnapshot], store: HealthCheckStore, - client: ContainerClient, now: Date = Date()) async { + func evaluate(snapshots: [ContainerSnapshot], + store: HealthCheckStore, + client: any ContainerRuntimeClient, + now: Date = Date()) async { let running = Dictionary(snapshots.filter { $0.state == .running }.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) diff --git a/Sources/ContainedCore/Services/CommandError.swift b/Sources/ContainedRuntime/CommandError.swift similarity index 100% rename from Sources/ContainedCore/Services/CommandError.swift rename to Sources/ContainedRuntime/CommandError.swift diff --git a/Sources/ContainedCore/Services/CommandRunner.swift b/Sources/ContainedRuntime/CommandRunner.swift similarity index 100% rename from Sources/ContainedCore/Services/CommandRunner.swift rename to Sources/ContainedRuntime/CommandRunner.swift diff --git a/Sources/ContainedCore/Services/ContainerRuntime.swift b/Sources/ContainedRuntime/RuntimeDescriptor.swift similarity index 50% rename from Sources/ContainedCore/Services/ContainerRuntime.swift rename to Sources/ContainedRuntime/RuntimeDescriptor.swift index 931f963f..79eee147 100644 --- a/Sources/ContainedCore/Services/ContainerRuntime.swift +++ b/Sources/ContainedRuntime/RuntimeDescriptor.swift @@ -1,8 +1,20 @@ import Foundation +import ContainedCore -public enum RuntimeKind: String, Codable, Equatable, Sendable { - case appleContainer - case dockerCompatible +/// Stable identifier for a runtime adapter. +/// +/// This is intentionally open-ended rather than a closed enum. Apple `container` is the first +/// adapter, Docker-compatible engines are an obvious future adapter, and the package should also be +/// able to host runtimes that do not exist yet without editing this shared module. +public struct RuntimeKind: RawRepresentable, Codable, Equatable, Hashable, Sendable { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public static let appleContainer = RuntimeKind(rawValue: "apple-container") + public static let dockerCompatible = RuntimeKind(rawValue: "docker-compatible") } public struct RuntimeCapability: OptionSet, Equatable, Sendable { @@ -52,12 +64,12 @@ public struct RuntimeCapability: OptionSet, Equatable, Sendable { public struct RuntimeDescriptor: Equatable, Sendable { public var kind: RuntimeKind public var displayName: String - public var executableName: String + public var executableName: String? public var capabilities: RuntimeCapability public init(kind: RuntimeKind, displayName: String, - executableName: String, + executableName: String? = nil, capabilities: RuntimeCapability) { self.kind = kind self.displayName = displayName @@ -104,15 +116,15 @@ public protocol ContainerRuntimeClient: Sendable { func listContainers(all: Bool) async throws -> [ContainerSnapshot] func stats(ids: [String]) async throws -> [ContainerStats] - func streamStatsTable(ids: [String]) -> AsyncThrowingStream + func streamStats(ids: [String]) -> AsyncThrowingStream<[RuntimeStatsSnapshot], Error> func diskUsage() async throws -> DiskUsage func systemProperties() async throws -> SystemProperties func dnsDomains() async throws -> [String] - func createDNSDomain(_ domain: String) async throws -> Data - func deleteDNSDomain(_ domain: String) async throws -> Data - func setRecommendedKernel() async throws -> Data + @discardableResult func createDNSDomain(_ domain: String) async throws -> Data + @discardableResult func deleteDNSDomain(_ domain: String) async throws -> Data + @discardableResult func setRecommendedKernel() async throws -> Data func execCapture(_ id: String, _ command: [String]) async throws -> String - func copy(source: String, destination: String) async throws -> Data + @discardableResult func copy(source: String, destination: String) async throws -> Data func streamSystemLogs(follow: Bool, last: Int?) -> AsyncThrowingStream func systemStatus() async throws -> SystemStatus func networks() async throws -> [NetworkResource] @@ -125,24 +137,72 @@ public protocol ContainerRuntimeClient: Sendable { buildArgs: [String: String], noCache: Bool, platform: String?) -> AsyncThrowingStream func streamPush(_ ref: String, platform: String?) -> AsyncThrowingStream + @discardableResult func runContainer(arguments: [String]) async throws -> Data + @discardableResult func performSystemAction(_ action: String) async throws -> Data func registries() async throws -> [RegistryLogin] - func registryLogin(server: String, username: String, password: String) async throws -> Data - func registryLogout(server: String) async throws -> Data - func deleteImages(_ refs: [String]) async throws -> Data - func tagImage(source: String, target: String) async throws -> Data - func saveImages(_ refs: [String], to output: String) async throws -> Data - func loadImages(from input: String) async throws -> Data - func exportContainer(_ id: String, to output: String) async throws -> Data - func pruneImages(all: Bool) async throws -> Data - func start(_ ids: [String]) async throws -> Data - func stop(_ ids: [String]) async throws -> Data - func deleteContainers(_ ids: [String], force: Bool) async throws -> Data - func pruneContainers() async throws -> Data - func pruneVolumes() async throws -> Data - func pruneNetworks() async throws -> Data - func createVolume(name: String, size: String?, labels: [String: String]) async throws -> Data - func deleteVolumes(_ names: [String]) async throws -> Data - func createNetwork(name: String, subnet: String?, internalOnly: Bool, + @discardableResult func registryLogin(server: String, username: String, password: String) async throws -> Data + @discardableResult func registryLogout(server: String) async throws -> Data + @discardableResult func deleteImages(_ refs: [String]) async throws -> Data + @discardableResult func tagImage(source: String, target: String) async throws -> Data + @discardableResult func saveImages(_ refs: [String], to output: String) async throws -> Data + @discardableResult func loadImages(from input: String) async throws -> Data + @discardableResult func exportContainer(_ id: String, to output: String) async throws -> Data + @discardableResult func pruneImages(all: Bool) async throws -> Data + @discardableResult func start(_ ids: [String]) async throws -> Data + @discardableResult func stop(_ ids: [String]) async throws -> Data + @discardableResult func deleteContainers(_ ids: [String], force: Bool) async throws -> Data + @discardableResult func pruneContainers() async throws -> Data + @discardableResult func pruneVolumes() async throws -> Data + @discardableResult func pruneNetworks() async throws -> Data + @discardableResult func createVolume(name: String, size: String?, labels: [String: String]) async throws -> Data + @discardableResult func deleteVolumes(_ names: [String]) async throws -> Data + @discardableResult func createNetwork(name: String, subnet: String?, internalOnly: Bool, labels: [String: String]) async throws -> Data - func deleteNetworks(_ names: [String]) async throws -> Data + @discardableResult func deleteNetworks(_ names: [String]) async throws -> Data +} + +public extension ContainerRuntimeClient { + func listContainers() async throws -> [ContainerSnapshot] { + try await listContainers(all: true) + } + + func stats() async throws -> [ContainerStats] { + try await stats(ids: []) + } + + func streamStats() -> AsyncThrowingStream<[RuntimeStatsSnapshot], Error> { + streamStats(ids: []) + } + + func streamLogs(id: String, follow: Bool, tail: Int?) -> AsyncThrowingStream { + streamLogs(id: id, follow: follow, tail: tail, boot: false) + } + + func streamLogs(id: String) -> AsyncThrowingStream { + streamLogs(id: id, follow: true, tail: 200, boot: false) + } + + func streamPull(_ ref: String) -> AsyncThrowingStream { + streamPull(ref, platform: nil) + } + + func streamPush(_ ref: String) -> AsyncThrowingStream { + streamPush(ref, platform: nil) + } + + @discardableResult func createVolume(name: String, size: String?) async throws -> Data { + try await createVolume(name: name, size: size, labels: [:]) + } + + @discardableResult func createVolume(name: String) async throws -> Data { + try await createVolume(name: name, size: nil, labels: [:]) + } + + @discardableResult func createNetwork(name: String, subnet: String?, internalOnly: Bool) async throws -> Data { + try await createNetwork(name: name, subnet: subnet, internalOnly: internalOnly, labels: [:]) + } + + @discardableResult func createNetwork(name: String) async throws -> Data { + try await createNetwork(name: name, subnet: nil, internalOnly: false, labels: [:]) + } } diff --git a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift index 597322a5..2b89867f 100644 --- a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift +++ b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift @@ -1,6 +1,8 @@ import Foundation import Testing import ContainedCore +import ContainedRuntime +import AppleContainerRuntime @testable import Contained @Suite("Container stats streaming") @@ -9,7 +11,7 @@ struct ContainersStoreRefreshTests { @Test func refreshDoesNotRunStatsCommand() async { let runner = RecordingRunner() let store = ContainersStore() - store.client = ContainerClient(runner: runner) + store.client = AppleContainerClient(runner: runner) await store.refresh() @@ -20,7 +22,7 @@ struct ContainersStoreRefreshTests { @Test func streamedStatsUpdateEveryFrameWithoutAppThrottle() async { let runner = RecordingRunner() let store = ContainersStore() - store.client = ContainerClient(runner: runner) + store.client = AppleContainerClient(runner: runner) let clock = TestClock(Date(timeIntervalSinceReferenceDate: 1_000)) store.now = { clock.date } @@ -52,7 +54,7 @@ struct ContainersStoreRefreshTests { @Test func streamedStatsClampTinyIntervalsForCounterRates() async { let runner = RecordingRunner() let store = ContainersStore() - store.client = ContainerClient(runner: runner) + store.client = AppleContainerClient(runner: runner) let clock = TestClock(Date(timeIntervalSinceReferenceDate: 1_000)) store.now = { clock.date } diff --git a/Tests/ContainedCoreTests/CommandTests.swift b/Tests/ContainedCoreTests/CommandTests.swift index 429d52c3..368524aa 100644 --- a/Tests/ContainedCoreTests/CommandTests.swift +++ b/Tests/ContainedCoreTests/CommandTests.swift @@ -109,66 +109,6 @@ struct CommandTests { #expect(decoded.results.first?.pullReference == "nginx") } - @Test func parseVersionAndSupport() { - let v = CLILocator.parseVersion("container CLI version 1.0.0 (build: release, commit: ee848e3)") - #expect(v == "1.0.0") - #expect(CLILocator.isSupported(v)) - #expect(!CLILocator.isSupported("0.10.0")) - #expect(!CLILocator.isSupported(nil)) - } - - @Test func appleRuntimeDescriptorAdvertisesCurrentCapabilities() throws { - let descriptor = RuntimeDescriptor.appleContainer - #expect(descriptor.kind == .appleContainer) - #expect(descriptor.displayName == "Apple container") - #expect(descriptor.executableName == "container") - #expect(descriptor.supports([.containers, .images, .volumes, .networks])) - #expect(descriptor.supports([.systemStatus, .systemLogs, .exec, .copy])) - try descriptor.require([.imageBuild, .imagePush, .registries]) - - let readOnly = RuntimeDescriptor(kind: .dockerCompatible, - displayName: "Read-only runtime", - executableName: "container", - capabilities: [.containers]) - #expect(!readOnly.supports(.imageBuild)) - #expect(throws: UnsupportedRuntimeCapability.self) { - try readOnly.require(.imageBuild) - } - } - - @Test func containerClientConformsToRuntimeClient() async throws { - let runner = MockCommandRunner(result: .success(try Fixture.data("list"))) - let runtime: any ContainerRuntimeClient = ContainerClient(runner: runner) - #expect(runtime.descriptor == .appleContainer) - - let containers = try await runtime.listContainers(all: true) - #expect(containers.first?.id == "fixture-web") - } - - @Test func clientDecodesThroughMock() async throws { - let runner = MockCommandRunner(result: .success(try Fixture.data("list"))) - let client = ContainerClient(runner: runner) - let containers = try await client.listContainers() - #expect(containers.first?.id == "fixture-web") - } - - @Test func clientMapsDecodeFailure() async throws { - // The real `image list` error case: stdout is an error line, not JSON. - let bad = MockCommandRunner(result: .success(Data("Error: content with digest sha256:…".utf8))) - let client = ContainerClient(runner: bad) - await #expect(throws: CommandError.self) { - _ = try await client.listContainers() - } - } - - @Test func clientPropagatesNonZeroExit() async throws { - let failing = MockCommandRunner(result: .failure(.nonZeroExit(code: 1, stderr: "boom", command: "list"))) - let client = ContainerClient(runner: failing) - await #expect(throws: CommandError.self) { - _ = try await client.listContainers() - } - } - @Test func statsDeltaComputesCPUFraction() { let prev = ContainerStats(id: "x", cpuUsageUsec: 1_000_000, memoryUsageBytes: 100, memoryLimitBytes: 1000, blockReadBytes: 0, blockWriteBytes: 0, networkRxBytes: 0, networkTxBytes: 0, numProcesses: 1) diff --git a/Tests/ContainedCoreTests/Fixtures.swift b/Tests/ContainedCoreTests/Fixtures.swift index ec574be0..1e157ca2 100644 --- a/Tests/ContainedCoreTests/Fixtures.swift +++ b/Tests/ContainedCoreTests/Fixtures.swift @@ -17,20 +17,3 @@ enum Fixture { enum FixtureError: Error { case notFound(String) } } - -/// A `CommandRunning` that replays canned output / errors — no daemon required. -struct MockCommandRunner: CommandRunning { - var result: Result - /// Scripted chunks yielded by `stream()` (then the stream finishes). - var streamChunks: [String] = [] - func run(_ arguments: [String], - stdin: Data?, - priority: CommandExecutionPriority) async throws -> Data { try result.get() } - func stream(_ arguments: [String], priority: CommandExecutionPriority) -> AsyncThrowingStream { - let chunks = streamChunks - return AsyncThrowingStream { continuation in - for chunk in chunks { continuation.yield(chunk) } - continuation.finish() - } - } -} diff --git a/Tests/ContainedCoreTests/Phase4Tests.swift b/Tests/ContainedCoreTests/Phase4Tests.swift index aaa4049f..f837915f 100644 --- a/Tests/ContainedCoreTests/Phase4Tests.swift +++ b/Tests/ContainedCoreTests/Phase4Tests.swift @@ -98,39 +98,6 @@ struct Phase4Tests { #expect(project.warnings.contains { $0.contains("networks") }) } - @Test func clientImagesDecode() async throws { - // `image inspect` and `image list` share the ImageResource shape; reuse the inspect fixture. - let runner = MockCommandRunner(result: .success(try Fixture.data("image-inspect"))) - let client = ContainerClient(runner: runner) - let images = try await client.images() - #expect(!images.isEmpty) - } - - // MARK: Streaming - - @Test func streamLogsYieldsChunks() async throws { - let runner = MockCommandRunner(result: .success(Data()), streamChunks: ["line one\n", "line two\n"]) - let client = ContainerClient(runner: runner) - var received: [String] = [] - for try await chunk in client.streamLogs(id: "web") { received.append(chunk) } - #expect(received == ["line one\n", "line two\n"]) - } - - @Test func statsTableParserUsesLatestANSIFrame() throws { - let samples = ContainerStatsTableParser.parseLatestFrame(in: try Fixture.string("stats-table")) - #expect(samples.count == 2) - #expect(samples[0].id == "buildkit") - #expect(samples[0].memoryUsageBytes == 108_202_557) - #expect(samples[0].memoryLimitBytes == 2_147_483_648) - #expect(samples[0].networkRxBytes == 486_953) - #expect(samples[0].networkTxBytes == 604) - #expect(samples[0].blockReadBytes == 62_044_242) - #expect(samples[0].blockWriteBytes == 24_576) - #expect(samples[0].numProcesses == 17) - #expect(samples[1].id == "sonarrhd") - #expect(abs((samples[1].cpuCoreFraction ?? 0) - 0.0006) < 0.00001) - } - // MARK: Restart watchdog decision logic @Test func restartPolicyParsing() { diff --git a/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift b/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift new file mode 100644 index 00000000..251a7c32 --- /dev/null +++ b/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift @@ -0,0 +1,129 @@ +import Foundation +import Testing +import ContainedCore +import ContainedRuntime +import AppleContainerRuntime + +@Suite("Runtime adapter boundary") +struct AppleContainerRuntimeTests { + @Test func runtimeKindAcceptsFutureAdapters() throws { + let descriptor = RuntimeDescriptor(kind: RuntimeKind(rawValue: "future-engine"), + displayName: "Future Engine", + capabilities: [.containers]) + + #expect(descriptor.kind.rawValue == "future-engine") + #expect(descriptor.executableName == nil) + #expect(descriptor.supports(.containers)) + #expect(!descriptor.supports(.imageBuild)) + #expect(throws: UnsupportedRuntimeCapability.self) { + try descriptor.require(.imageBuild) + } + } + + @Test func appleCLIVersionParsingAndSupport() { + let version = AppleContainerCLILocator.parseVersion( + "container CLI version 1.0.0 (build: release, commit: ee848e3)" + ) + + #expect(version == "1.0.0") + #expect(AppleContainerCLILocator.isSupported(version)) + #expect(!AppleContainerCLILocator.isSupported("0.10.0")) + #expect(!AppleContainerCLILocator.isSupported(nil)) + } + + @Test func appleRuntimeDescriptorAdvertisesCurrentCapabilities() throws { + let descriptor = RuntimeDescriptor.appleContainer + + #expect(descriptor.kind == .appleContainer) + #expect(descriptor.displayName == "Apple container") + #expect(descriptor.executableName == "container") + #expect(descriptor.supports([.containers, .images, .volumes, .networks])) + #expect(descriptor.supports([.systemStatus, .systemLogs, .exec, .copy])) + try descriptor.require([.imageBuild, .imagePush, .registries]) + } + + @Test func appleClientConformsToRuntimeClient() async throws { + let runner = MockCommandRunner(result: .success(try Fixture.data("list"))) + let runtime: any ContainerRuntimeClient = AppleContainerClient(runner: runner) + + #expect(runtime.descriptor == .appleContainer) + let containers = try await runtime.listContainers(all: true) + #expect(containers.first?.id == "fixture-web") + } + + @Test func appleClientDecodesThroughMock() async throws { + let runner = MockCommandRunner(result: .success(try Fixture.data("list"))) + let client = AppleContainerClient(runner: runner) + + let containers = try await client.listContainers() + #expect(containers.first?.id == "fixture-web") + } + + @Test func appleClientMapsDecodeFailure() async throws { + let bad = MockCommandRunner(result: .success(Data("Error: content with digest sha256:...".utf8))) + let client = AppleContainerClient(runner: bad) + + await #expect(throws: CommandError.self) { + _ = try await client.listContainers() + } + } + + @Test func appleClientPropagatesNonZeroExit() async throws { + let failing = MockCommandRunner(result: .failure(.nonZeroExit(code: 1, + stderr: "boom", + command: "list"))) + let client = AppleContainerClient(runner: failing) + + await #expect(throws: CommandError.self) { + _ = try await client.listContainers() + } + } + + @Test func appleClientImagesDecode() async throws { + let runner = MockCommandRunner(result: .success(try Fixture.data("image-inspect"))) + let client = AppleContainerClient(runner: runner) + + let images = try await client.images() + #expect(!images.isEmpty) + } + + @Test func appleClientStreamLogsYieldsChunks() async throws { + let runner = MockCommandRunner(result: .success(Data()), streamChunks: ["line one\n", "line two\n"]) + let client = AppleContainerClient(runner: runner) + var received: [String] = [] + + for try await chunk in client.streamLogs(id: "web") { received.append(chunk) } + + #expect(received == ["line one\n", "line two\n"]) + } + + @Test func appleStatsTableParserUsesLatestANSIFrame() throws { + let samples = ContainerStatsTableParser.parseLatestFrame(in: try Fixture.string("stats-table")) + + #expect(samples.count == 2) + #expect(samples[0].id == "buildkit") + #expect(samples[0].memoryUsageBytes == 108_202_557) + #expect(samples[0].memoryLimitBytes == 2_147_483_648) + #expect(samples[0].networkRxBytes == 486_953) + #expect(samples[0].networkTxBytes == 604) + #expect(samples[0].blockReadBytes == 62_044_242) + #expect(samples[0].blockWriteBytes == 24_576) + #expect(samples[0].numProcesses == 17) + #expect(samples[1].id == "sonarrhd") + #expect(abs((samples[1].cpuCoreFraction ?? 0) - 0.0006) < 0.00001) + } + + @Test func appleClientStreamsTypedStatsSnapshots() async throws { + let stream = try Fixture.string("stats-table") + let runner = MockCommandRunner(result: .success(Data()), streamChunks: [stream]) + let runtime: any ContainerRuntimeClient = AppleContainerClient(runner: runner) + var received: [[RuntimeStatsSnapshot]] = [] + + for try await samples in runtime.streamStats(ids: ["buildkit", "sonarrhd"]) { + received.append(samples) + } + + #expect(received.count == 1) + #expect(received.first?.map(\.id) == ["buildkit", "sonarrhd"]) + } +} diff --git a/Tests/ContainedRuntimeTests/Fixtures.swift b/Tests/ContainedRuntimeTests/Fixtures.swift new file mode 100644 index 00000000..3cb86854 --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures.swift @@ -0,0 +1,38 @@ +import Foundation +import ContainedRuntime + +/// Loads a captured CLI fixture from the test bundle. +enum Fixture { + static func data(_ name: String, ext: String = "json") throws -> Data { + guard let url = Bundle.module.url(forResource: name, withExtension: ext, subdirectory: "Fixtures") else { + throw FixtureError.notFound("\(name).\(ext)") + } + return try Data(contentsOf: url) + } + + static func string(_ name: String, ext: String = "txt") throws -> String { + String(decoding: try data(name, ext: ext), as: UTF8.self) + } + + enum FixtureError: Error { case notFound(String) } +} + +/// A `CommandRunning` that replays canned output / errors with no runtime daemon. +struct MockCommandRunner: CommandRunning { + var result: Result + var streamChunks: [String] = [] + + func run(_ arguments: [String], + stdin: Data?, + priority: CommandExecutionPriority) async throws -> Data { + try result.get() + } + + func stream(_ arguments: [String], priority: CommandExecutionPriority) -> AsyncThrowingStream { + let chunks = streamChunks + return AsyncThrowingStream { continuation in + for chunk in chunks { continuation.yield(chunk) } + continuation.finish() + } + } +} diff --git a/Tests/ContainedRuntimeTests/Fixtures/df.json b/Tests/ContainedRuntimeTests/Fixtures/df.json new file mode 100644 index 00000000..308b06ce --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures/df.json @@ -0,0 +1,20 @@ +{ + "containers" : { + "active" : 0, + "reclaimable" : 4074598400, + "sizeInBytes" : 4074598400, + "total" : 3 + }, + "images" : { + "active" : 3, + "reclaimable" : 1991077888, + "sizeInBytes" : 6629847040, + "total" : 11 + }, + "volumes" : { + "active" : 0, + "reclaimable" : 0, + "sizeInBytes" : 0, + "total" : 0 + } +} diff --git a/Tests/ContainedRuntimeTests/Fixtures/image-inspect.json b/Tests/ContainedRuntimeTests/Fixtures/image-inspect.json new file mode 100644 index 00000000..8143f75d --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures/image-inspect.json @@ -0,0 +1,526 @@ +[ + { + "configuration" : { + "creationDate" : "2026-06-16T00:00:15Z", + "descriptor" : { + "digest" : "sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b", + "mediaType" : "application\/vnd.oci.image.index.v1+json", + "size" : 9218 + }, + "name" : "docker.io\/library\/alpine:latest" + }, + "id" : "28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b", + "variants" : [ + { + "config" : { + "architecture" : "amd64", + "config" : { + "Cmd" : [ + "\/bin\/sh" + ], + "Env" : [ + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "WorkingDir" : "\/" + }, + "created" : "2026-06-16T00:01:29.967161902Z", + "history" : [ + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:01:29.967161902Z", + "created_by" : "ADD alpine-minirootfs-3.24.1-x86_64.tar.gz \/ # buildkit" + }, + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:01:29.967161902Z", + "created_by" : "CMD [\"\/bin\/sh\"]", + "empty_layer" : true + } + ], + "os" : "linux", + "rootfs" : { + "diff_ids" : [ + "sha256:34884abbe92863fce933ed7c39c0e045631af0ed86d5cc0dfbdf9fdca426ce3c" + ], + "type" : "layers" + } + }, + "digest" : "sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f", + "platform" : { + "architecture" : "amd64", + "os" : "linux" + }, + "size" : 3848024 + }, + { + "config" : { + "architecture" : "unknown", + "config" : { + + }, + "os" : "unknown", + "rootfs" : { + "diff_ids" : [ + "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", + "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" + ], + "type" : "layers" + } + }, + "digest" : "sha256:f21951a6120df0f5f9329311202f7869e7b70120f4748632d58753cff662b126", + "platform" : { + "architecture" : "unknown", + "os" : "unknown" + }, + "size" : 86390 + }, + { + "config" : { + "architecture" : "arm", + "config" : { + "Cmd" : [ + "\/bin\/sh" + ], + "Env" : [ + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "WorkingDir" : "\/" + }, + "created" : "2026-06-16T00:00:25.329026823Z", + "history" : [ + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:00:25.329026823Z", + "created_by" : "ADD alpine-minirootfs-3.24.1-armhf.tar.gz \/ # buildkit" + }, + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:00:25.329026823Z", + "created_by" : "CMD [\"\/bin\/sh\"]", + "empty_layer" : true + } + ], + "os" : "linux", + "rootfs" : { + "diff_ids" : [ + "sha256:78ed6782dc1bda42a805a54b6a8cf65a497ca4e409bc5bfe00e38ec85bdf826d" + ], + "type" : "layers" + }, + "variant" : "v6" + }, + "digest" : "sha256:bc301c70d7e7b929e5d3251d08ecc2e2894c22580958f79298052ed73ada4be0", + "platform" : { + "architecture" : "arm", + "os" : "linux", + "variant" : "v6" + }, + "size" : 3555096 + }, + { + "config" : { + "architecture" : "unknown", + "config" : { + + }, + "os" : "unknown", + "rootfs" : { + "diff_ids" : [ + "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", + "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" + ], + "type" : "layers" + } + }, + "digest" : "sha256:9ebfa72c35ca370f63e20c92dfa3ac11b67f753b193cc345aa2b23f89241227c", + "platform" : { + "architecture" : "unknown", + "os" : "unknown" + }, + "size" : 86118 + }, + { + "config" : { + "architecture" : "arm", + "config" : { + "Cmd" : [ + "\/bin\/sh" + ], + "Env" : [ + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "WorkingDir" : "\/" + }, + "created" : "2026-06-16T00:00:26.526765088Z", + "history" : [ + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:00:26.526765088Z", + "created_by" : "ADD alpine-minirootfs-3.24.1-armv7.tar.gz \/ # buildkit" + }, + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:00:26.526765088Z", + "created_by" : "CMD [\"\/bin\/sh\"]", + "empty_layer" : true + } + ], + "os" : "linux", + "rootfs" : { + "diff_ids" : [ + "sha256:1e30d7d82ee088ef08d915b4394ec1aa51a54d871dcd0770dc7503bee836e489" + ], + "type" : "layers" + }, + "variant" : "v7" + }, + "digest" : "sha256:48bf253520b161ff0f7bd9c6b2aded4126fa8ee2bc29386580b2a8a0322a1742", + "platform" : { + "architecture" : "arm", + "os" : "linux", + "variant" : "v7" + }, + "size" : 3262261 + }, + { + "config" : { + "architecture" : "unknown", + "config" : { + + }, + "os" : "unknown", + "rootfs" : { + "diff_ids" : [ + "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", + "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" + ], + "type" : "layers" + } + }, + "digest" : "sha256:b47a7a1760540077a1aefc00795604cd7e1b2acf556312118e10806f9a58d072", + "platform" : { + "architecture" : "unknown", + "os" : "unknown" + }, + "size" : 86390 + }, + { + "config" : { + "architecture" : "arm64", + "config" : { + "Cmd" : [ + "\/bin\/sh" + ], + "Env" : [ + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "WorkingDir" : "\/" + }, + "created" : "2026-06-16T00:01:20.474100947Z", + "history" : [ + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:01:20.474100947Z", + "created_by" : "ADD alpine-minirootfs-3.24.1-aarch64.tar.gz \/ # buildkit" + }, + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:01:20.474100947Z", + "created_by" : "CMD [\"\/bin\/sh\"]", + "empty_layer" : true + } + ], + "os" : "linux", + "rootfs" : { + "diff_ids" : [ + "sha256:b2848c02ac6ff53d265469b5b30f649f335e546a83330cd8916d54e65e640409" + ], + "type" : "layers" + }, + "variant" : "v8" + }, + "digest" : "sha256:e7a1a92a5bfeee40966aea60f0796b0e7917cc35591542701834f03a68fa3d18", + "platform" : { + "architecture" : "arm64", + "os" : "linux", + "variant" : "v8" + }, + "size" : 4184689 + }, + { + "config" : { + "architecture" : "unknown", + "config" : { + + }, + "os" : "unknown", + "rootfs" : { + "diff_ids" : [ + "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", + "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" + ], + "type" : "layers" + } + }, + "digest" : "sha256:d9dc32c63a23ac682a41ab2eae01051d2a4fbe472eefd109faf97be63a5216e5", + "platform" : { + "architecture" : "unknown", + "os" : "unknown" + }, + "size" : 86390 + }, + { + "config" : { + "architecture" : "386", + "config" : { + "Cmd" : [ + "\/bin\/sh" + ], + "Env" : [ + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "WorkingDir" : "\/" + }, + "created" : "2026-06-16T00:01:19.360099979Z", + "history" : [ + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:01:19.360099979Z", + "created_by" : "ADD alpine-minirootfs-3.24.1-x86.tar.gz \/ # buildkit" + }, + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:01:19.360099979Z", + "created_by" : "CMD [\"\/bin\/sh\"]", + "empty_layer" : true + } + ], + "os" : "linux", + "rootfs" : { + "diff_ids" : [ + "sha256:20868c90c269dd93125a9ddea2bec8b326ccfc8229d85a7456ee028a165b29b1" + ], + "type" : "layers" + } + }, + "digest" : "sha256:6f5908cdf811d574b30ec394e405ef74ee293bed5af1620a5187d604604a90a8", + "platform" : { + "architecture" : "386", + "os" : "linux" + }, + "size" : 3671765 + }, + { + "config" : { + "architecture" : "unknown", + "config" : { + + }, + "os" : "unknown", + "rootfs" : { + "diff_ids" : [ + "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", + "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" + ], + "type" : "layers" + } + }, + "digest" : "sha256:bf1792d3b17c3493e465c13b8357c5f60c2acc36cb69aab0813d4e7f1995ac2b", + "platform" : { + "architecture" : "unknown", + "os" : "unknown" + }, + "size" : 86390 + }, + { + "config" : { + "architecture" : "ppc64le", + "config" : { + "Cmd" : [ + "\/bin\/sh" + ], + "Env" : [ + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "WorkingDir" : "\/" + }, + "created" : "2026-06-16T00:00:15.017984356Z", + "history" : [ + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:00:15.017984356Z", + "created_by" : "ADD alpine-minirootfs-3.24.1-ppc64le.tar.gz \/ # buildkit" + }, + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:00:15.017984356Z", + "created_by" : "CMD [\"\/bin\/sh\"]", + "empty_layer" : true + } + ], + "os" : "linux", + "rootfs" : { + "diff_ids" : [ + "sha256:8c55296752dfcaf17e3fa825036cfe5050c55402025b508314048e741c9a5f16" + ], + "type" : "layers" + } + }, + "digest" : "sha256:a30366c2d2645b131e92b797b39357db29f197966c660db2767f143353000d6f", + "platform" : { + "architecture" : "ppc64le", + "os" : "linux" + }, + "size" : 3815039 + }, + { + "config" : { + "architecture" : "unknown", + "config" : { + + }, + "os" : "unknown", + "rootfs" : { + "diff_ids" : [ + "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", + "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" + ], + "type" : "layers" + } + }, + "digest" : "sha256:7a21929832908e662272c6e3333f68c662e3854ad4956e49d995251dcd9ccbfe", + "platform" : { + "architecture" : "unknown", + "os" : "unknown" + }, + "size" : 86390 + }, + { + "config" : { + "architecture" : "riscv64", + "config" : { + "Cmd" : [ + "\/bin\/sh" + ], + "Env" : [ + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "WorkingDir" : "\/" + }, + "created" : "2026-06-16T05:59:15.56408535Z", + "history" : [ + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T05:59:15.56408535Z", + "created_by" : "ADD alpine-minirootfs-3.24.1-riscv64.tar.gz \/ # buildkit" + }, + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T05:59:15.56408535Z", + "created_by" : "CMD [\"\/bin\/sh\"]", + "empty_layer" : true + } + ], + "os" : "linux", + "rootfs" : { + "diff_ids" : [ + "sha256:d806e99d43aeb11f79a974b2623809f37ead271ba59a954bd643342d3ae87e71" + ], + "type" : "layers" + } + }, + "digest" : "sha256:20a26477b54fb521bc8f633ea0af1f8f9b3dac5661739f857b381b117226919b", + "platform" : { + "architecture" : "riscv64", + "os" : "linux" + }, + "size" : 3575994 + }, + { + "config" : { + "architecture" : "unknown", + "config" : { + + }, + "os" : "unknown", + "rootfs" : { + "diff_ids" : [ + "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", + "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" + ], + "type" : "layers" + } + }, + "digest" : "sha256:83b3fe59c5ee68a257a6e179c792b9bd5b0aceb02ceb2813f14eef469a020c00", + "platform" : { + "architecture" : "unknown", + "os" : "unknown" + }, + "size" : 86390 + }, + { + "config" : { + "architecture" : "s390x", + "config" : { + "Cmd" : [ + "\/bin\/sh" + ], + "Env" : [ + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" + ], + "WorkingDir" : "\/" + }, + "created" : "2026-06-16T00:00:21.879382071Z", + "history" : [ + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:00:21.879382071Z", + "created_by" : "ADD alpine-minirootfs-3.24.1-s390x.tar.gz \/ # buildkit" + }, + { + "comment" : "buildkit.dockerfile.v0", + "created" : "2026-06-16T00:00:21.879382071Z", + "created_by" : "CMD [\"\/bin\/sh\"]", + "empty_layer" : true + } + ], + "os" : "linux", + "rootfs" : { + "diff_ids" : [ + "sha256:ff89eb082f59748c49390592d343caad75d5a0f38be634c4a5ff87b55add7c8d" + ], + "type" : "layers" + } + }, + "digest" : "sha256:4eea4e45f63fc6b38e5f653120aaf8a7c19043706f6d76070821495371e3017b", + "platform" : { + "architecture" : "s390x", + "os" : "linux" + }, + "size" : 3710951 + }, + { + "config" : { + "architecture" : "unknown", + "config" : { + + }, + "os" : "unknown", + "rootfs" : { + "diff_ids" : [ + "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", + "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" + ], + "type" : "layers" + } + }, + "digest" : "sha256:4a411cfbe9fc8bbe39859f835711f280d91a856a28a6ca01ef82ca298170c727", + "platform" : { + "architecture" : "unknown", + "os" : "unknown" + }, + "size" : 86390 + } + ] + } +] diff --git a/Tests/ContainedRuntimeTests/Fixtures/images-error.txt b/Tests/ContainedRuntimeTests/Fixtures/images-error.txt new file mode 100644 index 00000000..8aa851b0 --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures/images-error.txt @@ -0,0 +1 @@ +Error: content with digest sha256:666991717c0d26a211b400b09ad45ac84a9f64af8a08c2c64a54884e76d98954 diff --git a/Tests/ContainedRuntimeTests/Fixtures/inspect.json b/Tests/ContainedRuntimeTests/Fixtures/inspect.json new file mode 100644 index 00000000..f73f540e --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures/inspect.json @@ -0,0 +1,119 @@ +[ + { + "configuration" : { + "capAdd" : [ + + ], + "capDrop" : [ + + ], + "creationDate" : "2026-06-24T10:16:58Z", + "dns" : { + "nameservers" : [ + + ], + "options" : [ + + ], + "searchDomains" : [ + + ] + }, + "id" : "fixture-web", + "image" : { + "descriptor" : { + "digest" : "sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b", + "mediaType" : "application\/vnd.oci.image.index.v1+json", + "size" : 9218 + }, + "reference" : "docker.io\/library\/alpine:latest" + }, + "initProcess" : { + "arguments" : [ + "600" + ], + "environment" : [ + "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin", + "FOO=bar" + ], + "executable" : "sleep", + "rlimits" : [ + + ], + "supplementalGroups" : [ + + ], + "terminal" : false, + "user" : { + "id" : { + "gid" : 0, + "uid" : 0 + } + }, + "workingDirectory" : "\/" + }, + "labels" : { + "contained.icon" : "globe", + "contained.tint" : "teal" + }, + "mounts" : [ + + ], + "networks" : [ + { + "network" : "default", + "options" : { + "hostname" : "fixture-web", + "mtu" : 1280 + } + } + ], + "platform" : { + "architecture" : "arm64", + "os" : "linux" + }, + "publishedPorts" : [ + { + "containerPort" : 80, + "count" : 1, + "hostAddress" : "0.0.0.0", + "hostPort" : 18080, + "proto" : "tcp" + } + ], + "publishedSockets" : [ + + ], + "readOnly" : false, + "resources" : { + "cpuOverhead" : 1, + "cpus" : 4, + "memoryInBytes" : 1073741824 + }, + "rosetta" : false, + "runtimeHandler" : "container-runtime-linux", + "ssh" : false, + "sysctls" : { + + }, + "useInit" : false, + "virtualization" : false + }, + "id" : "fixture-web", + "status" : { + "networks" : [ + { + "hostname" : "fixture-web", + "ipv4Address" : "192.168.64.3\/24", + "ipv4Gateway" : "192.168.64.1", + "ipv6Address" : "fdfd:fa52:b619:2ef7:fcb1:66ff:fe77:68e1\/64", + "macAddress" : "fe:b1:66:77:68:e1", + "mtu" : 1280, + "network" : "default" + } + ], + "startedDate" : "2026-06-24T10:16:59Z", + "state" : "running" + } + } +] diff --git a/Tests/ContainedRuntimeTests/Fixtures/list-current.json b/Tests/ContainedRuntimeTests/Fixtures/list-current.json new file mode 100644 index 00000000..bf64c211 --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures/list-current.json @@ -0,0 +1 @@ +[{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"ddns-latest","image":{"descriptor":{"digest":"sha256:19bd73d8aea9641dc329ec18ae693b2b9c33ff7cdc007f368266ce584446f995","mediaType":"application\/vnd.docker.distribution.manifest.list.v2+json","size":1108},"reference":"docker.io\/example\/ddns:latest"},"initProcess":{"arguments":[],"environment":["ZONE=example.com","RRTYPE=A","API_KEY=example-token-redacted","PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","CRON=*\/5\t*\t*\t*\t*"],"executable":"\/init","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"raw":{"userString":""}},"workingDirectory":""},"labels":{},"mounts":[],"networks":[{"network":"default","options":{"hostname":"ddns-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"ddns-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"nginx-proxy-manager-latest","image":{"descriptor":{"digest":"sha256:2aa69b382a384b676c0d4f1d6f2eac40ecd478fcf7af1cfb3f9f1d3cd0c81e12","mediaType":"application\/vnd.oci.image.index.v1+json","size":1609},"reference":"docker.io\/jc21\/nginx-proxy-manager:latest"},"initProcess":{"arguments":[],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","NODE_ENV=production","TZ=UTC"],"executable":"\/init","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/app"},"labels":{},"mounts":[{"destination":"\/data","options":[],"source":"\/srv\/nginx\/data\/","type":{"virtiofs":{}}},{"destination":"\/etc\/letsencrypt","options":[],"source":"\/srv\/nginx\/letsencrypt\/","type":{"virtiofs":{}}}],"networks":[{"network":"default","options":{"hostname":"nginx-proxy-manager-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":80,"proto":"tcp"},{"containerPort":81,"count":1,"hostAddress":"0.0.0.0","hostPort":81,"proto":"tcp"},{"containerPort":443,"count":1,"hostAddress":"0.0.0.0","hostPort":443,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"nginx-proxy-manager-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"app-latest","image":{"descriptor":{"digest":"sha256:b35ba0461c4a1033d117ac1e5968fd4cbe777899e4cbfbdeaf3d10a42a0eb7e9","mediaType":"application\/vnd.docker.distribution.manifest.list.v2+json","size":685},"reference":"docker.io\/example\/app:latest"},"initProcess":{"arguments":["npm","start"],"environment":["TZ=UTC","NODE_ENV=production","PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin"],"executable":"docker-entrypoint.sh","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"raw":{"userString":"node:node"}},"workingDirectory":"\/app"},"labels":{},"mounts":[{"destination":"\/app\/config","options":[],"source":"\/srv\/app\/config\/","type":{"virtiofs":{}}}],"networks":[{"network":"default","options":{"hostname":"app-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":5055,"count":1,"hostAddress":"0.0.0.0","hostPort":5055,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"app-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"2026-06-24T10:16:58Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"fixture-web","image":{"descriptor":{"digest":"sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b","mediaType":"application\/vnd.oci.image.index.v1+json","size":9218},"reference":"docker.io\/library\/alpine:latest"},"initProcess":{"arguments":["600"],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","FOO=bar"],"executable":"sleep","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/"},"labels":{"contained.icon":"globe","contained.tint":"teal"},"mounts":[],"networks":[{"network":"default","options":{"hostname":"fixture-web","mtu":1280}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":18080,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":4,"memoryInBytes":1073741824},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"fixture-web","status":{"networks":[],"startedDate":"2026-06-24T10:16:59Z","state":"stopped"}}] diff --git a/Tests/ContainedRuntimeTests/Fixtures/list.json b/Tests/ContainedRuntimeTests/Fixtures/list.json new file mode 100644 index 00000000..05161683 --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures/list.json @@ -0,0 +1 @@ +[{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"2026-06-24T10:16:58Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"fixture-web","image":{"descriptor":{"digest":"sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b","mediaType":"application\/vnd.oci.image.index.v1+json","size":9218},"reference":"docker.io\/library\/alpine:latest"},"initProcess":{"arguments":["600"],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","FOO=bar"],"executable":"sleep","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/"},"labels":{"contained.icon":"globe","contained.tint":"teal"},"mounts":[],"networks":[{"network":"default","options":{"hostname":"fixture-web","mtu":1280}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":18080,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":4,"memoryInBytes":1073741824},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"fixture-web","status":{"networks":[{"hostname":"fixture-web","ipv4Address":"192.168.64.3\/24","ipv4Gateway":"192.168.64.1","ipv6Address":"fdfd:fa52:b619:2ef7:fcb1:66ff:fe77:68e1\/64","macAddress":"fe:b1:66:77:68:e1","mtu":1280,"network":"default"}],"startedDate":"2026-06-24T10:16:59Z","state":"running"}}] diff --git a/Tests/ContainedRuntimeTests/Fixtures/networks.json b/Tests/ContainedRuntimeTests/Fixtures/networks.json new file mode 100644 index 00000000..03d08faf --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures/networks.json @@ -0,0 +1 @@ +[{"configuration":{"creationDate":"2026-04-01T07:12:13Z","labels":{"com.apple.container.resource.role":"builtin"},"mode":"nat","name":"default","options":{"variant":"reserved"},"plugin":"container-network-vmnet"},"id":"default","status":{"ipv4Gateway":"192.168.64.1","ipv4Subnet":"192.168.64.0\/24","ipv6Subnet":"fdfd:fa52:b619:2ef7::\/64"}}] diff --git a/Tests/ContainedRuntimeTests/Fixtures/stats-table.txt b/Tests/ContainedRuntimeTests/Fixtures/stats-table.txt new file mode 100644 index 00000000..85cb21e6 --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures/stats-table.txt @@ -0,0 +1,9 @@ +[?1049h[?25lContainer ID Cpu % Memory Usage Net Rx/Tx Block I/O Pids +Container ID Cpu % Memory Usage Net Rx/Tx Block I/O Pids +buildkit 0.00% 103.19 MiB / 2.00 GiB 475.54 KiB / 0.59 KiB 59.17 MiB / 24.00 KiB 17 +sonarrhd 0.07% 276.57 MiB / 1.00 GiB 2.37 MiB / 3.68 MiB 142.96 MiB / 700 KiB 26 +Container ID Cpu % Memory Usage Net Rx/Tx Block I/O Pids +buildkit 0.00% 103.19 MiB / 2.00 GiB 475.54 KiB / 0.59 KiB 59.17 MiB / 24.00 KiB 17 +sonarrhd 0.06% 276.57 MiB / 1.00 GiB 2.37 MiB / 3.68 MiB 142.96 MiB / 700 KiB 26 +error collecting stats: CancellationError() +[?25h[?1049l diff --git a/Tests/ContainedRuntimeTests/Fixtures/stats.json b/Tests/ContainedRuntimeTests/Fixtures/stats.json new file mode 100644 index 00000000..f9f10451 --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures/stats.json @@ -0,0 +1 @@ +[{"blockReadBytes":2154496,"blockWriteBytes":0,"cpuUsageUsec":1827,"id":"fixture-web","memoryLimitBytes":1073741824,"memoryUsageBytes":2322432,"networkRxBytes":9548,"networkTxBytes":516,"numProcesses":1}] diff --git a/Tests/ContainedRuntimeTests/Fixtures/status.json b/Tests/ContainedRuntimeTests/Fixtures/status.json new file mode 100644 index 00000000..8a43ec11 --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures/status.json @@ -0,0 +1 @@ +{"apiServerAppName":"container-apiserver","apiServerBuild":"release","apiServerCommit":"ee848e3ebfd7c73b04dd419683be54fb450b8779","apiServerVersion":"container-apiserver version 1.0.0 (build: release, commit: ee848e3)","appRoot":"\/Users\/admin\/Library\/Application Support\/com.apple.container\/","installRoot":"\/usr\/local\/","status":"running"} diff --git a/Tests/ContainedRuntimeTests/Fixtures/volumes.json b/Tests/ContainedRuntimeTests/Fixtures/volumes.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/Tests/ContainedRuntimeTests/Fixtures/volumes.json @@ -0,0 +1 @@ +[] diff --git a/changes/unreleased/20260701-runtime-abstraction.md b/changes/unreleased/20260701-runtime-abstraction.md index 5917a047..3d110d39 100644 --- a/changes/unreleased/20260701-runtime-abstraction.md +++ b/changes/unreleased/20260701-runtime-abstraction.md @@ -1 +1 @@ -- Added the first backend runtime abstraction in `ContainedCore`, with Apple `container` capabilities advertised through `ContainerRuntimeClient` and `RuntimeDescriptor`. +- Split runtime contracts into `ContainedRuntime` and the current Apple `container` adapter into `AppleContainerRuntime`, with open-ended runtime identifiers and typed stats streams so future Docker-compatible or other engine adapters can be added as sibling modules. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index b41e68ac..7eecebc3 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -3,19 +3,21 @@ Contained is a SwiftUI-native macOS app that wraps Apple's `container` CLI. It shells out to public CLI commands, usually with `--format json`, and decodes typed models. Visible container stats are the exception: Apple container only streams stats in table mode, so Contained parses that public table stream behind the same runtime boundary. There is no private API or daemon. ``` - SwiftUI Views ──> @Observable Stores ──> ContainerClient ──> CommandRunner ──> `container` CLI - (Features/*) (AppModel, …) (typed facade) (run / stream) (json / table) - ^ │ ^ │ - └──── ContainedDesignSystem ────────────────┘ │ - └─────────────────── decoded models (ContainedCore) ◀───────────┘ + SwiftUI Views ──> @Observable Stores ──> ContainerRuntimeClient ──> Runtime Adapter + (Features/*) (AppModel, …) (ContainedRuntime) (AppleContainerRuntime) + ^ │ │ │ + └──── ContainedDesignSystem ───────────────────┘ ▼ + └──────── decoded models / argv builders (ContainedCore) ``` ## Targets -- **`ContainedCore`** — pure, testable logic: models, the CLI wrapper, JSON decoding, compose parsing, and ordering/decision helpers. Depends only on Yams. No SwiftUI. +- **`ContainedCore`** — pure, testable logic: models, JSON decoding, compose parsing, Apple `container` argv builders, and ordering/decision helpers. Depends only on Yams. No SwiftUI. +- **`ContainedRuntime`** — shared runtime contracts: `ContainerRuntimeClient`, `RuntimeDescriptor`, `RuntimeKind`, `RuntimeCapability`, `CommandError`, and command execution primitives. It is adapter-neutral and should not contain Apple-, Docker-, or UI-specific policy. +- **`AppleContainerRuntime`** — the current Apple `container` adapter: `AppleContainerClient`, `AppleContainerCLILocator`, `CommandRunner` usage, and the Apple stats-table parser. Future runtime engines should be sibling adapter targets that conform to `ContainerRuntimeClient`. - **`ContainedDesignSystem`** — a local reusable Swift package for app-agnostic SwiftUI/AppKit visual primitives. It must not depend on stores, Sparkle, SwiftData, app routing, or feature modules. - **`ContainedNavigation`** — a local reusable Swift package for navigation and layout infrastructure that should not own app-specific routing. It currently owns toolbar safe-area policy/measurement primitives. -- **`Contained`** — the SwiftUI executable: views, `@Observable` stores, app-specific presentation mappings, navigation, and the SwiftData history stack. Depends on `ContainedCore`, `ContainedDesignSystem`, `ContainedNavigation`, SwiftTerm, and Sparkle. +- **`Contained`** — the SwiftUI executable: views, `@Observable` stores, app-specific presentation mappings, navigation, and the SwiftData history stack. Depends on `ContainedCore`, `ContainedRuntime`, `AppleContainerRuntime`, `ContainedDesignSystem`, `ContainedNavigation`, SwiftTerm, and Sparkle. Package-local docs: @@ -26,19 +28,19 @@ Package-local docs: root `Package.swift` and local package manifests; the SwiftPM package graph remains the source of truth for builds, tests, and release scripts. -## CLI wrapper (core) +## Runtime wrapper - **`ContainerCommands`** — pure argv builders, side-effect-free so golden tests assert the exact arguments (the "Reveal CLI" affordances read from the same source of truth). -- **`CommandRunner`** — runs a one-shot command (`run`) or a streaming one (`stream`, an `AsyncThrowingStream`). Commands can opt into utility/background priority for vanity work such as stats. Passwords are piped via `--password-stdin`, never argv. -- **`ContainerClient`** — the Apple `container` implementation of `ContainerRuntimeClient`; returns decoded models and maps decode failures to a single `CommandError`. -- **`ContainerStatsTableParser`** — dependency-free parser for the ANSI table emitted by `container stats --format table`. It converts table frames into runtime-agnostic snapshots so the app can keep one visible stats stream open instead of spawning repeated JSON stats processes. -- **`ContainerRuntimeClient`** — the backend-facing operation contract. `RuntimeDescriptor` and `RuntimeCapability` advertise what a selected runtime can do before future Docker-compatible integration reaches the UI. +- **`CommandRunner`** — shared command-execution primitive used by CLI-backed adapters. It runs one-shot commands (`run`) or streaming commands (`stream`, an `AsyncThrowingStream`) at the requested priority. Passwords are piped via `--password-stdin`, never argv. +- **`AppleContainerClient`** — the Apple `container` implementation of `ContainerRuntimeClient`; returns decoded models and maps decode failures to a single `CommandError`. +- **`ContainerStatsTableParser`** — Apple-adapter parser for the ANSI table emitted by `container stats --format table`. It converts table frames into runtime-agnostic snapshots inside `AppleContainerRuntime`. +- **`ContainerRuntimeClient`** — the backend-facing operation contract. `RuntimeDescriptor`, open-ended `RuntimeKind`, and `RuntimeCapability` advertise what a selected runtime can do before adapter-specific UI routes enable a command. See [[Runtime Adapters|Runtime-Adapters]]. ## Stores (app) -- **`AppModel`** — root state: locates the CLI, owns the client + feature stores, tracks bootstrap status, wires logging/updating, and runs the per-tick coordination. Focused extensions own image/resource style lookup, image-update sweeps, and configuration import/export. -- **`ContainersStore`** — the container list, live stats deltas, streamed stats conversion, and lifecycle actions. -- **`RefreshCoordinator`** — adaptive polling for service/list refreshes. Stats are maintained app-wide by one utility-priority table stream for the running containers, so normal refreshes and lifecycle actions relist containers without forcing vanity stats. +- **`AppModel`** — root state: selects/bootstrap the runtime adapter, owns the runtime client + feature stores, tracks bootstrap status, wires logging/updating, and runs the per-tick coordination. Focused extensions own image/resource style lookup, image-update sweeps, and configuration import/export. +- **`ContainersStore`** — the container list, live stats deltas, streamed stats conversion, and lifecycle actions against `any ContainerRuntimeClient`. +- **`RefreshCoordinator`** — adaptive polling for service/list refreshes. Stats are maintained app-wide by one utility-priority runtime stats stream for the running containers, so normal refreshes and lifecycle actions relist containers without forcing vanity stats. - **`RestartWatchdog`** — app-managed restart policy (`container` has no native `--restart`); diffs states each tick and re-issues `start` with backoff. - **`HealthMonitor`** — app-managed healthchecks: interval-gated `exec` probes with consecutive-failure tracking. - **`HistoryStore`** — SwiftData stack for the persistent event log + metric samples (the "rewind" timeline) with bounded retention. @@ -56,4 +58,4 @@ Card styles and healthchecks are stored locally (keyed by container id / image r ## Testing -`Tests/ContainedCoreTests` holds golden-argv tests (every `ContainerCommands` builder), decode tests against captured real CLI fixtures, and pure decision tests (`RestartDecision`, `HealthDecision`, compose ordering). `Tests/ContainedAppTests` covers `RunSpec` argv + compose mapping. Run with `swift test`. +`Tests/ContainedCoreTests` holds golden-argv tests (every `ContainerCommands` builder), decode tests against captured real CLI fixtures, and pure decision tests (`RestartDecision`, `HealthDecision`, compose ordering). `Tests/ContainedRuntimeTests` covers runtime descriptors, future-kind extensibility, Apple adapter decoding/streaming, and typed stats snapshots. `Tests/ContainedAppTests` covers `RunSpec` argv + compose mapping. Run with `swift test`. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index 3a23319f..e9585fa6 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -55,7 +55,9 @@ enforced without breaking appcast publishing. ## Layout ``` -Sources/ContainedCore/ pure logic — models, CLI wrapper, decoding, compose (no SwiftUI) +Sources/ContainedCore/ pure logic — models, decoding, compose, argv builders (no SwiftUI) +Sources/ContainedRuntime/ shared runtime contracts and capabilities +Sources/AppleContainerRuntime/ Apple container runtime adapter Sources/Contained/ the SwiftUI app DesignSystem/ core-dependent presentation mappings only Features// one folder per sidebar domain @@ -63,8 +65,9 @@ Sources/Contained/ the SwiftUI app Packages/ContainedDesignSystem/ reusable SwiftUI/AppKit visual primitives and tokens Packages/ContainedNavigation/ reusable navigation/layout infrastructure Contained.xcworkspace/ optional Xcode entry point over the SwiftPM packages -Tests/ContainedCoreTests/ golden-argv + decode + decision tests -Tests/ContainedAppTests/ RunSpec argv + compose mapping +Tests/ContainedCoreTests/ golden-argv + decode + decision tests +Tests/ContainedRuntimeTests/ runtime adapter contract + Apple adapter tests +Tests/ContainedAppTests/ RunSpec argv + compose mapping scripts/ bundle.sh, release.sh, appcast.sh docs/wiki/ local mirror of the GitHub wiki pages appcast.xml Sparkle feed at the root of each release branch @@ -78,8 +81,8 @@ appcast.xml Sparkle feed at the root of each release branch - **Package docs live with the package.** Keep package-local import/setup/examples in [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) and [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md), with DocC landing pages under each target's `.docc` catalog. Keep `docs/wiki` focused on app-level architecture and workflow guidance. - **Xcode opens the workspace.** `Contained.xcworkspace` points at the root and local package manifests. SwiftPM package manifests remain the build graph source of truth; do not hand-maintain generated `.xcodeproj` state. - **Navigation infrastructure belongs in `ContainedNavigation` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in the executable target until they can cross the boundary without app policy. -- **Every CLI action goes through a `ContainerCommands` builder** + a `ContainerClient` wrapper, with a golden-argv test. The UI never assembles argv inline — this keeps "Reveal CLI" honest. -- **Runtime-facing code should depend on `ContainerRuntimeClient` where a backend choice matters.** The Apple `container` implementation remains the default; future Docker-compatible behavior should advertise capability differences through `RuntimeDescriptor`. +- **Every Apple `container` CLI action goes through a `ContainerCommands` builder** + `AppleContainerRuntime`, with a golden-argv test. The UI never assembles argv inline — this keeps "Reveal CLI" honest. +- **Runtime-facing code should depend on `ContainerRuntimeClient` where a backend choice matters.** The Apple `container` implementation remains the default adapter; future Docker-compatible, Podman, Lima-backed, remote, or other runtimes should be sibling adapter targets that advertise capability differences through `RuntimeDescriptor`. - **Pure decision logic is factored into `ContainedCore`** (`RestartDecision`, `HealthDecision`, compose ordering) and unit-tested without spawning processes. - **No `contained.*` personalization labels.** Card styles and healthchecks live in local stores. Only `contained.restart` and `contained.stack` are written (they must round-trip through the container). - **Never put secrets or personal data in test fixtures.** Fixtures are captured CLI output — scrub tokens, domains, and paths before committing. (`.gitignore` blocks signing material; push protection is on.) diff --git a/docs/wiki/Runtime-Adapters.md b/docs/wiki/Runtime-Adapters.md new file mode 100644 index 00000000..9fadab65 --- /dev/null +++ b/docs/wiki/Runtime-Adapters.md @@ -0,0 +1,47 @@ +# Runtime Adapters + +Contained's app-facing runtime boundary is split into shared contracts and +concrete adapters: + +- `ContainedRuntime` owns `ContainerRuntimeClient`, `RuntimeDescriptor`, + `RuntimeKind`, `RuntimeCapability`, `CommandError`, and command execution + primitives. +- `AppleContainerRuntime` owns the current Apple `container` CLI adapter: + `AppleContainerClient`, `AppleContainerCLILocator`, and the Apple table stats + parser. +- `ContainedCore` owns pure models, JSON decoding, compose parsing, decision + helpers, and `ContainerCommands` argv builders. +- `Sources/Contained` owns app state, settings, stores, SwiftUI, SwiftData, and + app-specific presentation mapping. + +## Adapter Shape + +Runtime adapters are sibling SwiftPM targets. The current adapter is +`AppleContainerRuntime`; future engines such as Docker-compatible, Podman, +Lima-backed, remote, or other runtimes should be added as new adapter targets +that conform to `ContainerRuntimeClient`. + +Do not add backend `switch` statements to SwiftUI views or stores. Stores should +depend on `any ContainerRuntimeClient`, while bootstrap/configuration chooses the +concrete adapter. + +`RuntimeKind` is an open raw-value type, not a closed enum. New adapters can +define their own stable identifiers without editing the shared runtime target. +Use `RuntimeCapability` and `RuntimeDescriptor` to advertise support before a UI +route enables a command. + +## Stats + +The shared runtime protocol exposes typed `RuntimeStatsSnapshot` batches from +`streamStats(ids:)`. Apple `container` currently provides live stats only as an +ANSI table stream, so `AppleContainerRuntime` parses that table internally. +Future adapters should publish the same snapshot shape from their own native +source, such as an engine API stream, without leaking transport details into the +app. + +## Transitional Surface + +`runContainer(arguments:)` still accepts the existing generated command +arguments from `RunSpec`. That keeps this extraction behavior-preserving. A later +runtime-abstraction slice should replace it with a runtime-neutral run request +model before non-Apple adapters become user-selectable. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index e8d07a90..96c6389f 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -18,6 +18,7 @@ - [[Troubleshooting]] - Maintainers - [[Architecture]] + - [[Runtime Adapters|Runtime-Adapters]] - [[Design System|Design-System]] - [[Release]] - [[Contributing]] From 0f63d14848ea3a91dbd6ac3fcccad1fdbdf5c886 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 06:46:05 +0100 Subject: [PATCH 18/53] Localize app-owned design copy --- AGENTS.md | 5 + Package.swift | 1 + Packages/ContainedDesignSystem/README.md | 22 +- .../CommandPreviewBar.swift | 12 +- .../ContainedDesignSystem.md | 21 +- .../ContainedDesignSystem/DesignActions.swift | 10 +- .../ContainedDesignSystem/GlassButton.swift | 14 +- .../ContainedDesignSystem/GlassRowMenu.swift | 2 +- .../GradientAngleControl.swift | 6 +- .../ContainedDesignSystem/InfoButton.swift | 2 +- .../ContainedDesignSystem/LiveSparkline.swift | 23 -- .../ContainedDesignSystem/ResourceCard.swift | 4 + .../ResourceCardChrome.swift | 5 +- .../ResourceGlassCard.swift | 12 +- .../ContainedDesignSystem/SheetHeader.swift | 6 +- .../ContainedDesignSystem/StreamConsole.swift | 20 +- .../Sources/ContainedDesignSystem/Theme.swift | 53 +--- .../ContainedDesignSystem/TintSelector.swift | 15 +- .../ToolbarControls.swift | 7 +- Packages/ContainedNavigation/README.md | 4 + .../ContainedNavigation.md | 4 + Sources/Contained/ContainedApp.swift | 2 +- .../Contained/DesignSystem/GraphMetric.swift | 12 +- .../DesignSystem/StatsNormalization.swift | 14 +- .../Features/Containers/ContainerCard.swift | 13 +- .../Containers/ContainerConfigureView.swift | 10 +- .../Containers/ContainerCustomizeButton.swift | 2 +- .../Containers/ContainersGridView.swift | 10 +- .../Containers/Creation/CreationFlow.swift | 4 +- .../Containers/Creation/CreationPages.swift | 6 +- .../Features/Containers/CustomizeSheet.swift | 10 +- .../Containers/CustomizeWidgetsPanel.swift | 7 +- .../Features/Containers/FilesTab.swift | 6 +- .../Features/Containers/LogsTab.swift | 6 +- .../Features/Containers/RunSpecForm.swift | 13 +- .../Features/Containers/TerminalTab.swift | 4 +- .../Features/Images/BuildWorkspaceView.swift | 21 +- .../Features/Images/ImageStyleButton.swift | 2 +- .../Features/Images/RegistryImageSearch.swift | 3 +- .../Features/Palette/CommandPalette.swift | 4 +- .../Registries/RegistryLoginSheet.swift | 4 +- .../Features/Settings/ConfigTransfer.swift | 8 +- .../Features/Settings/ReleaseNotesView.swift | 2 +- .../Features/Settings/SettingsView.swift | 2 +- .../Settings/Tabs/AppearanceTab.swift | 20 +- .../System/Components/SystemLogsSheet.swift | 10 +- .../Features/System/SystemView.swift | 20 +- Sources/Contained/History/ActivityView.swift | 2 +- .../History/ContainerHistoryTab.swift | 2 +- .../Migration/AppStateEnvelope.swift | 12 +- .../Migration/DowngradeDecisionView.swift | 2 +- Sources/Contained/Navigation/AppToolbar.swift | 6 +- .../Contained/Navigation/ClassicShell.swift | 6 +- Sources/Contained/Navigation/RootView.swift | 2 +- .../ToolbarPanels/PaletteResultCard.swift | 4 +- .../ToolbarPanels/ToolbarCommandPalette.swift | 4 +- .../ToolbarPanels/ToolbarImageGroupCard.swift | 27 +- .../ToolbarPanels/ToolbarResourcePanels.swift | 6 +- .../ToolbarPanels/ToolbarSearchSource.swift | 1 + .../ToolbarPanels/ToolbarUpdatesPanel.swift | 8 +- .../Navigation/ToolbarViewOptions.swift | 26 +- .../Contained/Support/AppLocalization.swift | 292 ++++++++++++++++++ Sources/Contained/Support/AppLogging.swift | 42 ++- Sources/Contained/Support/UpdateChannel.swift | 22 +- .../ContainedCore/Models/RestartPolicy.swift | 7 - .../20260702-localization-boundary.md | 3 + docs/wiki/Architecture.md | 7 + docs/wiki/Contributing.md | 5 + docs/wiki/Design-System.md | 24 ++ docs/wiki/Localization.md | 53 ++++ docs/wiki/_Sidebar.md | 1 + 71 files changed, 754 insertions(+), 273 deletions(-) create mode 100644 Sources/Contained/Support/AppLocalization.swift create mode 100644 changes/unreleased/20260702-localization-boundary.md create mode 100644 docs/wiki/Localization.md diff --git a/AGENTS.md b/AGENTS.md index 81515ad4..6e92585b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,11 @@ This file is the working contract for coding agents in this repository. Follow i - Keep Apple `container` CLI actions behind `ContainerCommands` and `AppleContainerRuntime`; do not assemble argv inline in SwiftUI. App stores should depend on `any ContainerRuntimeClient` where backend choice matters. - Put pure decision logic in `ContainedCore` with focused tests. +- Keep localization owned by `Sources/Contained`. Packages should receive + app-supplied labels/help/accessibility strings and should not add English UI + defaults or localized resource bundles. Use `AppText` for reusable app copy + and dynamic templates; plain SwiftUI literals are acceptable when SwiftUI keeps + them localization-ready. - Do not write app personalization back as `contained.*` labels. Only `contained.restart` and `contained.stack` may round-trip through container labels. - Keep helper scripts in `scripts/` and prefer hyphenated file names for multi-word shell scripts. - Keep comments human and useful. Explain surprising intent, not obvious syntax. diff --git a/Package.swift b/Package.swift index 7c1ae83e..d40843b2 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,7 @@ import PackageDescription let package = Package( name: "Contained", + defaultLocalization: "en", platforms: [.macOS(.v26)], products: [ .executable(name: "Contained", targets: ["Contained"]), diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index c354408c..47c52608 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -12,6 +12,11 @@ Do not put app state, stores, SwiftData models, Sparkle wiring, routing, runtime models, or feature-specific business rules in this package. App code should pass plain values into package views instead. +This package also does not own localized resources or English UI defaults. When +a primitive needs visible text, accessibility copy, help, or display names, the +app supplies those strings through parameters. The executable target owns +localization keys and English fallbacks. + ## Importing From the root app package: @@ -103,7 +108,7 @@ struct DesignSystemExample: View { VStack(spacing: Tokens.Space.l) { PanelSection(header: "Appearance") { PanelRow(title: "Accent") { - TintSelector(selection: $tint) + TintSelector(selection: $tint, labelForTint: tintName) } PanelRow(title: "Shortcut") { DesignKeyboardHint("return", "Open") @@ -145,6 +150,20 @@ struct DesignSystemExample: View { .environment(\.buttonMaterial, .glassClear) .environment(\.designSystemShowsInfoTips, true) } + + private func tintName(_ tint: AppTint) -> String { + switch tint { + case .multicolor: return "App Accent" + case .graphite: return "Graphite" + case .azure: return "Azure" + case .teal: return "Teal" + case .coral: return "Coral" + case .indigo: return "Indigo" + case .green: return "Green" + case .amber: return "Amber" + case .pink: return "Pink" + } + } } ``` @@ -183,6 +202,7 @@ struct CardControlsExample: View { pages: ResourceCardPages(items: pages, selection: page, tint: .accentColor, + closeLabel: "Close", onSelect: { page = $0 }, onClose: {})) { ResourceCardIconChip(symbol: "shippingbox.fill") diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/CommandPreviewBar.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/CommandPreviewBar.swift index d2020c95..b1f255dd 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/CommandPreviewBar.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/CommandPreviewBar.swift @@ -4,12 +4,18 @@ import SwiftUI /// copyable to the clipboard. Drives user trust and learning. public struct CommandPreviewBar: View { public let command: [String] + public var copyHelp: String + public var copiedAccessibilityLabel: String @State private var copied = false private var rendered: String { (["container"] + command).joined(separator: " ") } - public init(command: [String]) { + public init(command: [String], + copyHelp: String, + copiedAccessibilityLabel: String) { self.command = command + self.copyHelp = copyHelp + self.copiedAccessibilityLabel = copiedAccessibilityLabel } public var body: some View { @@ -32,8 +38,8 @@ public struct CommandPreviewBar: View { .foregroundStyle(copied ? .green : .secondary) } .buttonStyle(.plain) - .help("Copy command") - .accessibilityLabel(copied ? "Copied" : "Copy command") + .help(copyHelp) + .accessibilityLabel(copied ? copiedAccessibilityLabel : copyHelp) } .padding(.horizontal, Tokens.Space.s) .padding(.vertical, Tokens.Space.s) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 1f487bc6..155fe042 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -14,6 +14,10 @@ Do not add app state, stores, SwiftData models, Sparkle wiring, routing, runtime models, or feature-specific business rules here. Convert app/domain state into plain values before passing it to package views. +The executable app owns localization. Package controls that need visible text, +help, accessibility labels, or display names take those strings from the caller +instead of shipping English defaults or localized resources here. + ## Configure Shared Policy Once Set material and shell policy near the app root: @@ -51,7 +55,7 @@ struct DesignSystemExample: View { VStack(spacing: Tokens.Space.l) { PanelSection(header: "Appearance") { PanelRow(title: "Accent") { - TintSelector(selection: $tint) + TintSelector(selection: $tint, labelForTint: tintName) } PanelRow(title: "Shortcut") { DesignKeyboardHint("return", "Open") @@ -85,6 +89,20 @@ struct DesignSystemExample: View { .environment(\.cardMaterial, .glassRegular) .environment(\.buttonMaterial, .glassClear) } + + private func tintName(_ tint: AppTint) -> String { + switch tint { + case .multicolor: return "App Accent" + case .graphite: return "Graphite" + case .azure: return "Azure" + case .teal: return "Teal" + case .coral: return "Coral" + case .indigo: return "Indigo" + case .green: return "Green" + case .amber: return "Amber" + case .pink: return "Pink" + } + } } ``` @@ -124,6 +142,7 @@ struct CardControlsExample: View { pages: ResourceCardPages(items: pages, selection: page, tint: .accentColor, + closeLabel: "Close", onSelect: { page = $0 }, onClose: {})) { ResourceCardIconChip(symbol: "shippingbox.fill") diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift index b69a6db8..78581065 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift @@ -21,7 +21,7 @@ public struct DesignAction { action: @escaping () -> Void) { self.systemName = systemName self.title = title - self.help = help ?? title ?? systemName + self.help = help ?? title ?? "" self.role = role self.tint = tint self.isCancel = isCancel @@ -232,16 +232,20 @@ public struct DesignGlassToggle: View { /// Package-owned floating selection action bar. public struct DesignSelectionActionBar: View { public var count: Int + public var countLabel: (Int) -> String public var actions: [DesignAction] - public init(count: Int, actions: [DesignAction]) { + public init(count: Int, + countLabel: @escaping (Int) -> String, + actions: [DesignAction]) { self.count = count + self.countLabel = countLabel self.actions = actions } public var body: some View { HStack(spacing: Tokens.Space.m) { - Text("\(count) selected") + Text(countLabel(count)) .font(.callout.weight(.medium)) Divider() .frame(height: 16) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassButton.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassButton.swift index 69a355eb..37772b4a 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassButton.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassButton.swift @@ -11,6 +11,18 @@ private extension EnvironmentValues { } } +private struct OptionalAccessibilityLabel: ViewModifier { + var label: String + + func body(content: Content) -> some View { + if label.isEmpty { + content + } else { + content.accessibilityLabel(label) + } + } +} + public struct GlassButtonTintStyle: Equatable, Sendable { public var enabled = false public var tint: AppTint = .multicolor @@ -105,7 +117,7 @@ public struct GlassButtonItem: View { } } .help(help) - .accessibilityLabel(help.isEmpty ? "Button" : help) + .modifier(OptionalAccessibilityLabel(label: help)) .keyboardShortcut(isCancel && action != nil ? .cancelAction : nil) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassRowMenu.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassRowMenu.swift index 85889f59..07a7b26b 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassRowMenu.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassRowMenu.swift @@ -9,7 +9,7 @@ public struct GlassRowMenu: View { @ViewBuilder public var content: () -> Content public init(systemImage: String = "ellipsis", - accessibilityLabel: String = "Options", + accessibilityLabel: String, @ViewBuilder content: @escaping () -> Content) { self.systemImage = systemImage self.accessibilityLabel = accessibilityLabel diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GradientAngleControl.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GradientAngleControl.swift index 4489a724..4b52c13f 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GradientAngleControl.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GradientAngleControl.swift @@ -3,13 +3,15 @@ import SwiftUI /// A 360° gradient-direction control: a draggable dial plus a degree readout. public struct GradientAngleControl: View { @Binding var angle: Double + public var title: String - public init(angle: Binding) { + public init(angle: Binding, title: String) { self._angle = angle + self.title = title } public var body: some View { - LabeledContent("Direction") { + LabeledContent(title) { HStack(spacing: Tokens.Space.m) { AngleDial(angle: $angle) .frame(width: Tokens.InlineControl.gradientDial, diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/InfoButton.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/InfoButton.swift index e366fae2..8fa83c93 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/InfoButton.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/InfoButton.swift @@ -31,7 +31,7 @@ public struct InfoButton: View { .opacity(visible || showing ? 1 : 0) .allowsHitTesting(visible || showing) .help(text) // hover still works as a bonus for mouse users - .accessibilityLabel("More info") + .accessibilityLabel(text) .popover(isPresented: $showing, arrowEdge: .trailing) { Text(.init(text)) // Markdown-aware so tips can use **bold** / `code` .font(.callout) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift index 36c4c2ad..6a5bbea4 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift @@ -12,18 +12,6 @@ public enum GraphStyle: String, CaseIterable, Identifiable, Codable, Sendable { public var id: String { rawValue } - public var displayName: String { - switch self { - case .area: return "Area" - case .line: return "Line" - case .bar: return "Bar" - case .points: return "Points" - case .multiLine: return "Multi-Line" - case .range: return "Range" - case .scatter: return "Scatter" - } - } - public var requiresSecondaryMetric: Bool { switch self { case .multiLine, .range, .scatter: return true @@ -68,17 +56,6 @@ public enum WidgetInterpolation: String, CaseIterable, Identifiable, Codable, Se public var id: String { rawValue } - public var displayName: String { - switch self { - case .linear: return "Linear" - case .catmullRom: return "Smooth" - case .cardinal: return "Cardinal" - case .monotone: return "Monotone" - case .stepStart: return "Step Start" - case .stepCenter: return "Step Center" - case .stepEnd: return "Step End" - } - } } public enum SparklineScale: String, CaseIterable, Identifiable, Codable, Sendable { diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCard.swift index 47817ec0..1c135482 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCard.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCard.swift @@ -17,6 +17,7 @@ public struct ResourceCardPages { public var selection: ID public var tint: Color public var controlsReveal: Double + public var closeLabel: String public var onSelect: (ID) -> Void public var onClose: () -> Void @@ -24,12 +25,14 @@ public struct ResourceCardPages { selection: ID, tint: Color, controlsReveal: Double = 1, + closeLabel: String, onSelect: @escaping (ID) -> Void, onClose: @escaping () -> Void) { self.items = items self.selection = selection self.tint = tint self.controlsReveal = controlsReveal + self.closeLabel = closeLabel self.onSelect = onSelect self.onClose = onClose } @@ -188,6 +191,7 @@ public struct ResourceCard: View { public var selection: ID public var tint: Color public var controlsReveal: Double + public var closeLabel: String public var onSelect: (ID) -> Void public var onClose: () -> Void @@ -251,12 +252,14 @@ public struct ResourceCardPageControls: View { selection: ID, tint: Color, controlsReveal: Double = 1, + closeLabel: String, onSelect: @escaping (ID) -> Void, onClose: @escaping () -> Void) { self.items = items self.selection = selection self.tint = tint self.controlsReveal = controlsReveal + self.closeLabel = closeLabel self.onSelect = onSelect self.onClose = onClose } @@ -272,7 +275,7 @@ public struct ResourceCardPageControls: View { .opacity(selection == item.id ? 1 : 0.62) } } - GlassButtonItem(systemName: "xmark", help: "Close", action: onClose) + GlassButtonItem(systemName: "xmark", help: closeLabel, action: onClose) } .opacity(controlsReveal) .allowsHitTesting(controlsReveal > 0.01) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift index 89798fe3..ce0d051c 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift @@ -22,15 +22,21 @@ public enum ResourceCardExpandedMetrics { public struct CardSizePicker: View { @Binding var selection: CardDensity + public var title: String + public var labelForDensity: (CardDensity) -> String - public init(selection: Binding) { + public init(selection: Binding, + title: String, + labelForDensity: @escaping (CardDensity) -> String) { self._selection = selection + self.title = title + self.labelForDensity = labelForDensity } public var body: some View { - Picker("Card size", selection: $selection) { + Picker(title, selection: $selection) { ForEach(CardDensity.allCases) { density in - Text(density.displayName).tag(density) + Text(labelForDensity(density)).tag(density) } } .pickerStyle(.segmented) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/SheetHeader.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/SheetHeader.swift index 82c01b75..aa5bafdf 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/SheetHeader.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/SheetHeader.swift @@ -8,14 +8,14 @@ public struct SheetHeader: View { public let title: String public var subtitle: String? = nil public var cancelIcon: String = "xmark" - public var cancelHelp: String = "Cancel" + public var cancelHelp: String public let onCancel: () -> Void @ViewBuilder var trailing: () -> Trailing public init(title: String, subtitle: String? = nil, cancelIcon: String = "xmark", - cancelHelp: String = "Cancel", + cancelHelp: String, onCancel: @escaping () -> Void, @ViewBuilder trailing: @escaping () -> Trailing) { self.title = title @@ -47,7 +47,7 @@ public struct SheetHeader: View { public extension SheetHeader where Trailing == EmptyView { /// Header with only a cancel/close button (no primary action). init(title: String, subtitle: String? = nil, cancelIcon: String = "xmark", - cancelHelp: String = "Cancel", onCancel: @escaping () -> Void) { + cancelHelp: String, onCancel: @escaping () -> Void) { self.init(title: title, subtitle: subtitle, cancelIcon: cancelIcon, cancelHelp: cancelHelp, onCancel: onCancel) { EmptyView() } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift index dcd3668e..436fed9b 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift @@ -5,6 +5,10 @@ import SwiftUI public struct StreamConsole: View { /// Factory so the stream starts with the view's `.task` (and cancels on disappear). public let stream: () -> AsyncThrowingStream + public var workingLabel: String + public var completedLabel: String + public var lineCountLabel: (Int) -> String + public var copyLogHelp: String public var onComplete: (Bool) -> Void = { _ in } enum RunState: Equatable { case running, done, failed(String) } @@ -16,8 +20,16 @@ public struct StreamConsole: View { private let bottomID = "console-bottom" public init(stream: @escaping () -> AsyncThrowingStream, + workingLabel: String, + completedLabel: String, + lineCountLabel: @escaping (Int) -> String, + copyLogHelp: String, onComplete: @escaping (Bool) -> Void = { _ in }) { self.stream = stream + self.workingLabel = workingLabel + self.completedLabel = completedLabel + self.lineCountLabel = lineCountLabel + self.copyLogHelp = copyLogHelp self.onComplete = onComplete } @@ -54,18 +66,18 @@ public struct StreamConsole: View { switch state { case .running: ProgressView().controlSize(.small) - Text("Working…").foregroundStyle(.secondary) + Text(workingLabel).foregroundStyle(.secondary) case .done: Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - Text("Completed").foregroundStyle(.secondary) + Text(completedLabel).foregroundStyle(.secondary) case .failed(let message): Image(systemName: "xmark.octagon.fill").foregroundStyle(.red) Text(message).foregroundStyle(.secondary).lineLimit(1) } Spacer() - Text("\(lines.count) lines").font(.caption).foregroundStyle(.secondary).monospacedDigit() + Text(lineCountLabel(lines.count)).font(.caption).foregroundStyle(.secondary).monospacedDigit() GlassButton(singleItem: true) { - GlassButtonItem(systemName: "doc.on.doc", help: "Copy log") { + GlassButtonItem(systemName: "doc.on.doc", help: copyLogHelp) { copyToPasteboard(lines.joined(separator: "\n")) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme.swift index 2a024cb4..7dee58cf 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme.swift @@ -23,8 +23,6 @@ public enum AppTint: String, CaseIterable, Identifiable, Codable, Sendable { public var id: String { rawValue } - public var displayName: String { self == .multicolor ? "App Accent" : rawValue.capitalized } - /// True for the "follow the app accent" option (rendered with a marker in the swatch row). public var followsAppAccent: Bool { self == .multicolor } @@ -42,24 +40,8 @@ public enum AppTint: String, CaseIterable, Identifiable, Codable, Sendable { } } - /// Common color words that should also surface this tint in search (e.g. typing "purple" finds - /// `indigo`, "blue" finds `azure`). Keeps the curated palette discoverable under everyday names. - public var searchAliases: [String] { - switch self { - case .multicolor: return ["default", "app accent", "system", "auto", "rainbow"] - case .graphite: return ["gray", "grey", "slate", "charcoal", "silver", "neutral", "mono"] - case .azure: return ["blue", "sky", "ocean", "cobalt"] - case .teal: return ["cyan", "aqua", "turquoise", "mint", "seafoam"] - case .coral: return ["orange", "salmon", "burnt", "terracotta", "rust"] - case .indigo: return ["purple", "violet", "blurple", "royal"] - case .green: return ["lime", "olive", "emerald", "forest", "moss"] - case .amber: return ["yellow", "gold", "honey", "mustard"] - case .pink: return ["magenta", "rose", "fuchsia", "crimson", "hot pink"] - } - } - /// Parse a legacy `contained.tint` label value, falling back to multicolor. -public static func parse(_ raw: String?) -> AppTint { + public static func parse(_ raw: String?) -> AppTint { guard let raw, let tint = AppTint(rawValue: raw.lowercased()) else { return .multicolor } return tint } @@ -70,16 +52,6 @@ public enum ColorLayerBlendMode: String, CaseIterable, Identifiable, Codable, Se public var id: String { rawValue } - public var displayName: String { - switch self { - case .normal: return "Normal" - case .softLight: return "Soft Light" - case .overlay: return "Overlay" - case .multiply: return "Multiply" - case .screen: return "Screen" - } - } - public var blendMode: BlendMode { switch self { case .normal: return .normal @@ -94,7 +66,6 @@ public enum ColorLayerBlendMode: String, CaseIterable, Identifiable, Codable, Se public enum AppearanceMode: String, CaseIterable, Identifiable, Codable, Sendable { case system, light, dark public var id: String { rawValue } - public var displayName: String { rawValue.capitalized } public var colorScheme: ColorScheme? { switch self { case .system: return nil @@ -118,7 +89,6 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Codable, Sendabl public enum CardDensity: String, CaseIterable, Identifiable, Codable, Sendable { case small, medium, large public var id: String { rawValue } - public var displayName: String { rawValue.capitalized } public var resourceSize: ResourceCardSize { switch self { case .small: return .small @@ -148,27 +118,6 @@ public enum WindowMaterial: String, CaseIterable, Identifiable, Codable, Sendabl public var id: String { rawValue } - public var displayName: String { - switch self { - case .glassClear: return "Glass (Clear)" - case .glassRegular: return "Glass (Regular)" - case .fullScreenUI: return "Full-screen UI (default)" - case .underWindowBackground: return "Under Window" - case .underPageBackground: return "Under Page" - case .windowBackground: return "Window" - case .contentBackground: return "Content" - case .sidebar: return "Sidebar" - case .headerView: return "Header" - case .titlebar: return "Titlebar" - case .sheet: return "Sheet" - case .popover: return "Popover" - case .menu: return "Menu" - case .selection: return "Selection" - case .hudWindow: return "HUD" - case .toolTip: return "Tooltip" - } - } - /// True for the Liquid Glass options, which render via `.glassEffect` rather than vibrancy. public var isGlass: Bool { self == .glassClear || self == .glassRegular } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/TintSelector.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/TintSelector.swift index 8edf2171..9236c5d5 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/TintSelector.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/TintSelector.swift @@ -5,18 +5,24 @@ import SwiftUI public struct TintSelector: View { private let selection: Binding private let automaticLabel: String? + private let labelForTint: (AppTint) -> String - public init(selection: Binding) { + public init(selection: Binding, + labelForTint: @escaping (AppTint) -> String) { self.selection = Binding( get: { selection.wrappedValue }, set: { if let newValue = $0 { selection.wrappedValue = newValue } } ) self.automaticLabel = nil + self.labelForTint = labelForTint } - public init(optionalSelection: Binding, automaticLabel: String) { + public init(optionalSelection: Binding, + automaticLabel: String, + labelForTint: @escaping (AppTint) -> String) { self.selection = optionalSelection self.automaticLabel = automaticLabel + self.labelForTint = labelForTint } public var body: some View { @@ -29,10 +35,11 @@ public struct TintSelector: View { .accessibilityAddTraits(selection.wrappedValue == nil ? .isSelected : []) } ForEach(AppTint.allCases) { tint in + let label = labelForTint(tint) Button { selection.wrappedValue = tint } label: { swatch(tint) } .buttonStyle(.plain) - .help(tint.displayName) - .accessibilityLabel(tint.displayName) + .help(label) + .accessibilityLabel(label) .accessibilityAddTraits(selection.wrappedValue == tint ? .isSelected : []) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift index d6bf2cbd..facd89b5 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift @@ -32,6 +32,7 @@ public struct DesignGlassMenuButton: View public struct DesignToolbarSearchField: View { @Binding public var text: String public var prompt: String + public var clearSearchLabel: String public var focused: FocusState.Binding public var onSubmit: () -> Void public var onClear: () -> Void @@ -39,12 +40,14 @@ public struct DesignToolbarSearchField: View { public init(text: Binding, prompt: String, + clearSearchLabel: String, focused: FocusState.Binding, onSubmit: @escaping () -> Void = {}, onClear: @escaping () -> Void, @ViewBuilder trailing: @escaping () -> Trailing) { self._text = text self.prompt = prompt + self.clearSearchLabel = clearSearchLabel self.focused = focused self.onSubmit = onSubmit self.onClear = onClear @@ -69,8 +72,8 @@ public struct DesignToolbarSearchField: View { } .buttonStyle(.plain) .foregroundStyle(.secondary) - .help("Clear search") - .accessibilityLabel("Clear search") + .help(clearSearchLabel) + .accessibilityLabel(clearSearchLabel) } else { trailing() } diff --git a/Packages/ContainedNavigation/README.md b/Packages/ContainedNavigation/README.md index 45a29be2..05648210 100644 --- a/Packages/ContainedNavigation/README.md +++ b/Packages/ContainedNavigation/README.md @@ -7,6 +7,10 @@ It depends on `ContainedDesignSystem` for tokens and visual primitives. It does not own app sections, stores, toolbar panel content, `UIState`, routes, or feature decisions. +It also does not own localized resources or English UI defaults. Host apps pass +their own titles, help text, accessibility labels, and panel copy into the +navigation/design-system views they compose. + ## Importing From the root app package: diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md b/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md index 577cd23b..c265e230 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md @@ -8,6 +8,10 @@ Reusable navigation and layout infrastructure for Contained's window chrome. `ContainedDesignSystem` for tokens and visual primitives, but it does not own app routes, stores, toolbar panel contents, or feature decisions. +Host apps own localized strings. This package provides layout behavior and +accepts caller-supplied titles, help text, accessibility labels, and panel copy +through the views it composes. + Use this package when a view needs reusable layout behavior such as a panel that grows from a toolbar source, clamps to app safe areas, and hosts fixed chrome above scrollable content. diff --git a/Sources/Contained/ContainedApp.swift b/Sources/Contained/ContainedApp.swift index 22dcb9fb..846cf5b4 100644 --- a/Sources/Contained/ContainedApp.swift +++ b/Sources/Contained/ContainedApp.swift @@ -117,7 +117,7 @@ struct ContainedApp: App { CommandGroup(replacing: .toolbar) { Toggle("Show Running Only", isOn: runningOnlyBinding) Picker("Card Size", selection: cardSizeBinding) { - ForEach(CardDensity.allCases) { Text($0.displayName).tag($0) } + ForEach(CardDensity.allCases) { Text($0.localizedDisplayName).tag($0) } } Divider() Button("Reload") { app.coordinator.wake() } diff --git a/Sources/Contained/DesignSystem/GraphMetric.swift b/Sources/Contained/DesignSystem/GraphMetric.swift index 25479de6..51d5ea33 100644 --- a/Sources/Contained/DesignSystem/GraphMetric.swift +++ b/Sources/Contained/DesignSystem/GraphMetric.swift @@ -9,12 +9,12 @@ enum GraphMetric: String, CaseIterable, Identifiable, Codable, Sendable { var displayName: String { switch self { - case .cpu: return "CPU" - case .memory: return "Memory" - case .netRx: return "Net In" - case .netTx: return "Net Out" - case .diskRead: return "Disk Read" - case .diskWrite: return "Disk Write" + case .cpu: return AppText.string("graphMetric.cpu", defaultValue: "CPU") + case .memory: return AppText.string("graphMetric.memory", defaultValue: "Memory") + case .netRx: return AppText.string("graphMetric.netRx", defaultValue: "Net In") + case .netTx: return AppText.string("graphMetric.netTx", defaultValue: "Net Out") + case .diskRead: return AppText.string("graphMetric.diskRead", defaultValue: "Disk Read") + case .diskWrite: return AppText.string("graphMetric.diskWrite", defaultValue: "Disk Write") } } diff --git a/Sources/Contained/DesignSystem/StatsNormalization.swift b/Sources/Contained/DesignSystem/StatsNormalization.swift index e72bd952..232f7f34 100644 --- a/Sources/Contained/DesignSystem/StatsNormalization.swift +++ b/Sources/Contained/DesignSystem/StatsNormalization.swift @@ -9,17 +9,23 @@ enum StatsNormalizationMode: String, CaseIterable, Identifiable, Codable, Sendab var displayName: String { switch self { - case .container: return "Container" - case .machine: return "Machine" + case .container: return AppText.string("statsNormalization.container", defaultValue: "Container") + case .machine: return AppText.string("statsNormalization.machine", defaultValue: "Machine") } } var footnote: String { switch self { case .container: - return "CPU and memory are scaled against each container's own configured limits." + return AppText.string( + "statsNormalization.container.footnote", + defaultValue: "CPU and memory are scaled against each container's own configured limits." + ) case .machine: - return "CPU and memory are scaled against Apple container's machine CPU and memory resources." + return AppText.string( + "statsNormalization.machine.footnote", + defaultValue: "CPU and memory are scaled against Apple container's machine CPU and memory resources." + ) } } } diff --git a/Sources/Contained/Features/Containers/ContainerCard.swift b/Sources/Contained/Features/Containers/ContainerCard.swift index 9871e500..f0bbd77d 100644 --- a/Sources/Contained/Features/Containers/ContainerCard.swift +++ b/Sources/Contained/Features/Containers/ContainerCard.swift @@ -98,7 +98,7 @@ struct ContainerCard: View { } } .accessibilityElement(children: .combine) - .accessibilityLabel("\(name), \(presentation.label)") + .accessibilityLabel(AppText.containerCardAccessibility(name: name, status: presentation.label)) .onChange(of: showingCustomize) { _, isShowing in if isShowing { draftStyle = style @@ -184,6 +184,7 @@ struct ContainerCard: View { selection: tab, tint: tint, controlsReveal: isExpanded && controlsVisible ? 1 : 0, + closeLabel: AppText.close, onSelect: selectTab, onClose: onClose) } @@ -380,13 +381,13 @@ struct ContainerCard: View { @ViewBuilder private var footerActions: some View { if isRunning { - footerAction("stop.fill", help: "Stop", action: onStop) - footerAction("arrow.clockwise", help: "Restart", action: onRestart) + footerAction("stop.fill", help: AppText.stop, action: onStop) + footerAction("arrow.clockwise", help: AppText.restart, action: onRestart) } else { - footerAction("play.fill", help: "Start", tint: tint, action: onStart) + footerAction("play.fill", help: AppText.start, tint: tint, action: onStart) } - footerAction("slider.horizontal.3", help: "Edit", action: onEdit) - footerAction("trash", help: "Delete", role: .destructive) { confirmingDelete = true } + footerAction("slider.horizontal.3", help: AppText.edit, action: onEdit) + footerAction("trash", help: AppText.delete, role: .destructive) { confirmingDelete = true } } private func footerAction(_ systemName: String, help: String, tint: Color? = nil, diff --git a/Sources/Contained/Features/Containers/ContainerConfigureView.swift b/Sources/Contained/Features/Containers/ContainerConfigureView.swift index a8c6b0bf..b71a453c 100644 --- a/Sources/Contained/Features/Containers/ContainerConfigureView.swift +++ b/Sources/Contained/Features/Containers/ContainerConfigureView.swift @@ -55,7 +55,9 @@ struct ContainerConfigureView: View { .padding(Tokens.Space.s) } footer: { if app.settings.revealCLI { - CommandPreviewBar(command: spec.arguments()) + CommandPreviewBar(command: spec.arguments(), + copyHelp: AppText.copyCommand, + copiedAccessibilityLabel: AppText.copied) .padding(Tokens.Space.s) .frame(maxWidth: .infinity) } @@ -87,7 +89,7 @@ struct ContainerConfigureView: View { } else { DesignActionGroup([ DesignAction(systemName: "bookmark", - help: "Save as template", + help: AppText.saveAsTemplate, isEnabled: spec.isRunnable) { templateName = spec.name.isEmpty ? Format.shortImage(spec.image) : spec.name savingTemplate = true @@ -107,9 +109,9 @@ struct ContainerConfigureView: View { private var leadingAction: DesignAction { switch leading { case .cancel(let action): - return DesignAction(systemName: "xmark", help: "Cancel", isCancel: true, action: action) + return DesignAction(systemName: "xmark", help: AppText.cancel, isCancel: true, action: action) case .back(let action): - return DesignAction(systemName: "chevron.left", help: "Back", action: action) + return DesignAction(systemName: "chevron.left", help: AppText.back, action: action) } } diff --git a/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift b/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift index a817eda1..282aec9d 100644 --- a/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift +++ b/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift @@ -20,6 +20,6 @@ struct ContainerCustomizeButton: View { .buttonStyle(.plain) .onHover { hovering = $0 } .help("Customize card") - .accessibilityLabel("Customize \(style.displayName(fallback: snapshot.id))") + .accessibilityLabel(AppText.customizeAccessibilityLabel(style.displayName(fallback: snapshot.id))) } } diff --git a/Sources/Contained/Features/Containers/ContainersGridView.swift b/Sources/Contained/Features/Containers/ContainersGridView.swift index ae973376..97b56de0 100644 --- a/Sources/Contained/Features/Containers/ContainersGridView.swift +++ b/Sources/Contained/Features/Containers/ContainersGridView.swift @@ -451,14 +451,16 @@ struct ContainersGridView: View { } private var batchBar: some View { - DesignSelectionActionBar(count: selection.count, actions: [ - DesignAction(systemName: "play.fill", title: "Start") { + DesignSelectionActionBar(count: selection.count, + countLabel: AppText.selectedCount, + actions: [ + DesignAction(systemName: "play.fill", title: AppText.start) { batch { await store.start($0) } }, - DesignAction(systemName: "stop.fill", title: "Stop") { + DesignAction(systemName: "stop.fill", title: AppText.stop) { batch { await store.stop($0) } }, - DesignAction(systemName: "trash", title: "Delete", role: .destructive) { + DesignAction(systemName: "trash", title: AppText.delete, role: .destructive) { batch { await store.remove($0, force: true) } } ]) diff --git a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift b/Sources/Contained/Features/Containers/Creation/CreationFlow.swift index 1a39be72..07e7ea72 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/Contained/Features/Containers/Creation/CreationFlow.swift @@ -344,9 +344,9 @@ struct CreationFlow: View { private func leadingAction(_ leading: Leading) -> DesignAction { switch leading { case .close: - return DesignAction(systemName: "xmark", help: "Cancel", isCancel: true) { onClose() } + return DesignAction(systemName: "xmark", help: AppText.cancel, isCancel: true) { onClose() } case .back(let action): - return DesignAction(systemName: "chevron.left", help: "Back", action: action) + return DesignAction(systemName: "chevron.left", help: AppText.back, action: action) } } diff --git a/Sources/Contained/Features/Containers/Creation/CreationPages.swift b/Sources/Contained/Features/Containers/Creation/CreationPages.swift index 9364a495..7a901709 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationPages.swift +++ b/Sources/Contained/Features/Containers/Creation/CreationPages.swift @@ -185,7 +185,7 @@ struct CreationPastedComposeContent: View { HStack { Spacer() - DesignTextActionButton(title: "Import", + DesignTextActionButton(title: AppText.string("common.import", defaultValue: "Import"), systemName: "arrow.down.doc", prominence: .prominent, isEnabled: !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) { @@ -272,7 +272,9 @@ private struct CreationResourceForm: View { fields() - CommandPreviewBar(command: command) + CommandPreviewBar(command: command, + copyHelp: AppText.copyCommand, + copiedAccessibilityLabel: AppText.copied) .frame(maxWidth: .infinity) footer() diff --git a/Sources/Contained/Features/Containers/CustomizeSheet.swift b/Sources/Contained/Features/Containers/CustomizeSheet.swift index 10849814..7839878c 100644 --- a/Sources/Contained/Features/Containers/CustomizeSheet.swift +++ b/Sources/Contained/Features/Containers/CustomizeSheet.swift @@ -146,8 +146,8 @@ struct CustomizeSheet: View { title: headerTitle, subtitle: imageSubtitle) { DesignActionGroup([ - DesignAction(systemName: "checkmark", help: "Save") { save() }, - DesignAction(systemName: "xmark", help: "Close", isCancel: true) { dismiss() } + DesignAction(systemName: "checkmark", help: AppText.save) { save() }, + DesignAction(systemName: "xmark", help: AppText.close, isCancel: true) { dismiss() } ]) } } @@ -177,7 +177,7 @@ struct CustomizeSheet: View { } PanelRow(title: "Color", info: "App Accent follows the accent tint from Settings; other swatches pin this style.") { - TintSelector(selection: $style.tint) + TintSelector(selection: $style.tint) { $0.localizedDisplayName } } } } @@ -206,12 +206,12 @@ struct CustomizeSheet: View { } PanelToggleRow(title: "Gradient", isOn: $style.gradient) if style.gradient { - GradientAngleControl(angle: $style.gradientAngle) + GradientAngleControl(angle: $style.gradientAngle, title: AppText.direction) } PanelRow(title: "Blend mode") { Picker("", selection: $style.backgroundBlendMode) { ForEach(ColorLayerBlendMode.allCases) { mode in - Text(mode.displayName).tag(mode) + Text(mode.localizedDisplayName).tag(mode) } } .labelsHidden() diff --git a/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift b/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift index 90346869..2acc4625 100644 --- a/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift +++ b/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift @@ -95,7 +95,8 @@ struct CustomizeWidgetsPanel: View { } PanelToggleRow(title: "Show text", isOn: widgetBinding(index, \.showText)) PanelRow(title: "Color") { - TintSelector(optionalSelection: widgetBinding(index, \.tint), automaticLabel: "Card Color") + TintSelector(optionalSelection: widgetBinding(index, \.tint), + automaticLabel: AppText.cardColor) { $0.localizedDisplayName } } } @@ -134,7 +135,7 @@ struct CustomizeWidgetsPanel: View { widgetGroupLabel("Chart", systemImage: "chart.xyaxis.line") PanelRow(title: "Type") { Picker("", selection: widgetStyleBinding(index)) { - ForEach(GraphStyle.allCases) { Text($0.displayName).tag($0) } + ForEach(GraphStyle.allCases) { Text($0.localizedDisplayName).tag($0) } } .labelsHidden() .fixedSize() @@ -145,7 +146,7 @@ struct CustomizeWidgetsPanel: View { if chartStyle.usesLineOptions { PanelRow(title: "Interpolation") { Picker("", selection: widgetBinding(index, \.interpolation)) { - ForEach(WidgetInterpolation.allCases) { Text($0.displayName).tag($0) } + ForEach(WidgetInterpolation.allCases) { Text($0.localizedDisplayName).tag($0) } } .labelsHidden() .fixedSize() diff --git a/Sources/Contained/Features/Containers/FilesTab.swift b/Sources/Contained/Features/Containers/FilesTab.swift index a3081ef5..ee6cf0ea 100644 --- a/Sources/Contained/Features/Containers/FilesTab.swift +++ b/Sources/Contained/Features/Containers/FilesTab.swift @@ -33,16 +33,16 @@ struct FilesTab: View { private var pathBar: some View { HStack(spacing: Tokens.Space.s) { DesignActionGroup(DesignAction(systemName: "chevron.up", - help: "Parent", + help: AppText.parent, isEnabled: path != "/") { goUp() }) Text(path).font(.system(.callout, design: .monospaced)).lineLimit(1).truncationMode(.middle) Spacer() if loading { ProgressView().controlSize(.small) } DesignActionGroup(DesignAction(systemName: "square.and.arrow.down", - help: "Copy a file into this folder") { + help: AppText.string("files.copyIntoFolder", defaultValue: "Copy a file into this folder")) { copyIn() }) - DesignActionGroup(DesignAction(systemName: "arrow.clockwise", help: "Refresh") { Task { await load() } }) + DesignActionGroup(DesignAction(systemName: "arrow.clockwise", help: AppText.refresh) { Task { await load() } }) } } diff --git a/Sources/Contained/Features/Containers/LogsTab.swift b/Sources/Contained/Features/Containers/LogsTab.swift index 4444b57f..450dfb54 100644 --- a/Sources/Contained/Features/Containers/LogsTab.swift +++ b/Sources/Contained/Features/Containers/LogsTab.swift @@ -31,7 +31,7 @@ struct LogsTab: View { private var controls: some View { HStack(spacing: Tokens.Space.m) { - DesignGlassToggle(isOn: $following, title: "Follow", systemName: "arrow.down.to.line") + DesignGlassToggle(isOn: $following, title: AppText.follow, systemName: "arrow.down.to.line") if streaming { HStack(spacing: Tokens.Toolbar.searchIconGap) { ProgressView().controlSize(.small) @@ -40,11 +40,11 @@ struct LogsTab: View { } Spacer() Text("\(lines.count) lines").font(.caption).foregroundStyle(.secondary).monospacedDigit() - DesignActionGroup(DesignAction(systemName: "doc.on.doc", help: "Copy all") { + DesignActionGroup(DesignAction(systemName: "doc.on.doc", help: AppText.copyAll) { copyToPasteboard(lines.joined(separator: "\n")) }) DesignActionGroup(DesignAction(systemName: "trash", - help: "Clear", + help: AppText.clear, role: .destructive, isEnabled: !lines.isEmpty) { lines.removeAll(); carry = "" diff --git a/Sources/Contained/Features/Containers/RunSpecForm.swift b/Sources/Contained/Features/Containers/RunSpecForm.swift index 617a10d3..a99c2e57 100644 --- a/Sources/Contained/Features/Containers/RunSpecForm.swift +++ b/Sources/Contained/Features/Containers/RunSpecForm.swift @@ -61,7 +61,8 @@ struct RunSpecForm: View { PanelRow(title: "Image defaults", subtitle: "Fill empty command, entrypoint, user, working directory, and environment fields from the pulled image config.", info: "Images can define default startup settings. Adopt copies those defaults into this form so you can see and edit them before running.") { - DesignTextActionButton(title: "Adopt", systemName: "wand.and.stars") { + DesignTextActionButton(title: AppText.string("runSpec.adopt", defaultValue: "Adopt"), + systemName: "wand.and.stars") { adoptImageDefaults() } } @@ -260,7 +261,7 @@ struct RunSpecForm: View { TextField("", text: $spec.personalization.icon, prompt: Text("SF Symbol, e.g. globe, bolt")).textFieldStyle(.roundedBorder) } PanelRow(title: "Color", info: "Sets the card icon color. If background color is enabled, it also tints the glass card.") { - TintSelector(selection: $spec.personalization.tint) + TintSelector(selection: $spec.personalization.tint) { $0.localizedDisplayName } } PanelToggleRow(title: "Color the card background", info: "Adds a soft color wash behind the glass. Turn it off for clear glass with only a colored icon.", @@ -278,13 +279,13 @@ struct RunSpecForm: View { info: "Blends the color across the card instead of using one flat wash.", isOn: $spec.personalization.gradient) if spec.personalization.gradient { - GradientAngleControl(angle: $spec.personalization.gradientAngle) + GradientAngleControl(angle: $spec.personalization.gradientAngle, title: AppText.direction) } PanelRow(title: "Blend mode", info: "Controls how the card color wash blends with the glass behind it.") { Picker("", selection: $spec.personalization.backgroundBlendMode) { ForEach(ColorLayerBlendMode.allCases) { mode in - Text(mode.displayName).tag(mode) + Text(mode.localizedDisplayName).tag(mode) } } .labelsHidden() @@ -297,7 +298,7 @@ struct RunSpecForm: View { private var restartSection: some View { PanelRow(title: "Restart policy", info: "Contained restarts the container automatically based on this setting.") { Picker("", selection: $spec.restart) { - ForEach(RestartPolicy.allCases) { Text($0.displayName).tag($0) } + ForEach(RestartPolicy.allCases) { Text($0.localizedDisplayName).tag($0) } } .labelsHidden().fixedSize() } @@ -558,7 +559,7 @@ struct RunSpecForm: View { private func removeButton(action: @escaping () -> Void) -> some View { DesignActionGroup(DesignAction(systemName: "minus.circle.fill", - help: "Remove", + help: AppText.string("common.remove", defaultValue: "Remove"), action: action)) } diff --git a/Sources/Contained/Features/Containers/TerminalTab.swift b/Sources/Contained/Features/Containers/TerminalTab.swift index afce4762..17f3720b 100644 --- a/Sources/Contained/Features/Containers/TerminalTab.swift +++ b/Sources/Contained/Features/Containers/TerminalTab.swift @@ -64,7 +64,7 @@ struct TerminalTab: View { .onChange(of: shell) { _, _ in reconnect() } Text("exec into \(snapshot.id)").font(.caption).foregroundStyle(.secondary).lineLimit(1) Spacer() - DesignActionGroup(DesignAction(systemName: "arrow.clockwise", help: "Reconnect") { reconnect() }) + DesignActionGroup(DesignAction(systemName: "arrow.clockwise", help: AppText.reconnect) { reconnect() }) } } @@ -75,7 +75,7 @@ struct TerminalTab: View { .font(.headline) DesignActionGroup(DesignAction(systemName: "arrow.clockwise", title: "Reconnect", - help: "Reconnect terminal", + help: AppText.reconnectTerminal, action: reconnect)) } } diff --git a/Sources/Contained/Features/Images/BuildWorkspaceView.swift b/Sources/Contained/Features/Images/BuildWorkspaceView.swift index 98782d00..51a64248 100644 --- a/Sources/Contained/Features/Images/BuildWorkspaceView.swift +++ b/Sources/Contained/Features/Images/BuildWorkspaceView.swift @@ -32,7 +32,12 @@ struct BuildWorkspaceView: View { dockerfile: dockerfile.isEmpty ? nil : dockerfile, buildArgs: argsDict, noCache: noCache, platform: platform.isEmpty ? nil : platform) - }, onComplete: { ok in if ok { Task { await app.refreshImagesIfStale(force: true) } } }) + }, + workingLabel: AppText.working, + completedLabel: AppText.completed, + lineCountLabel: AppText.lineCount, + copyLogHelp: AppText.copyLog, + onComplete: { ok in if ok { Task { await app.refreshImagesIfStale(force: true) } } }) .id(run) .padding(Tokens.Space.s) } else { @@ -70,7 +75,7 @@ struct BuildWorkspaceView: View { Spacer() DesignActionGroup(DesignAction(systemName: "folder", title: "Choose", - help: "Choose context folder", + help: AppText.chooseContextFolder, action: chooseFolder)) } } @@ -105,7 +110,7 @@ struct BuildWorkspaceView: View { TextField("value", text: $arg.value) .textFieldStyle(.roundedBorder) DesignActionGroup(DesignAction(systemName: "minus.circle.fill", - help: "Remove build argument") { + help: AppText.removeBuildArgument) { buildArgs.removeAll { $0.id == arg.id } }) } @@ -115,7 +120,7 @@ struct BuildWorkspaceView: View { subtitle: buildArgs.isEmpty ? "No build-time variables added." : "\(buildArgs.count) argument(s)") { DesignActionGroup(DesignAction(systemName: "plus.circle", title: "Add build arg", - help: "Add build argument") { + help: AppText.addBuildArgument) { buildArgs.append(KeyValue()) }) } @@ -125,19 +130,21 @@ struct BuildWorkspaceView: View { private var commandSection: some View { PanelSection { HStack(spacing: Tokens.Space.s) { - CommandPreviewBar(command: previewCommand) + CommandPreviewBar(command: previewCommand, + copyHelp: AppText.copyCommand, + copiedAccessibilityLabel: AppText.copied) .frame(maxWidth: .infinity) if building { DesignActionGroup(DesignAction(systemName: "xmark", title: "Cancel", - help: "Cancel build", + help: AppText.cancelBuild, role: .destructive) { building = false }) } else { DesignActionGroup(DesignAction(systemName: "hammer.fill", title: "Build", - help: "Build image", + help: AppText.buildImage, isEnabled: canBuild, action: startBuild)) } diff --git a/Sources/Contained/Features/Images/ImageStyleButton.swift b/Sources/Contained/Features/Images/ImageStyleButton.swift index c5676da0..1606e984 100644 --- a/Sources/Contained/Features/Images/ImageStyleButton.swift +++ b/Sources/Contained/Features/Images/ImageStyleButton.swift @@ -22,7 +22,7 @@ struct ImageStyleButton: View { .buttonStyle(.plain) .onHover { hovering = $0 } .help("Customize image style") - .accessibilityLabel("Customize \(Format.shortImage(reference)) image style") + .accessibilityLabel(AppText.customizeImageStyleAccessibility(Format.shortImage(reference))) .popover(isPresented: $showingCustomize, arrowEdge: .trailing) { CustomizeSheet(target: target, presentation: .popover, diff --git a/Sources/Contained/Features/Images/RegistryImageSearch.swift b/Sources/Contained/Features/Images/RegistryImageSearch.swift index 42026487..4e5ec6db 100644 --- a/Sources/Contained/Features/Images/RegistryImageSearch.swift +++ b/Sources/Contained/Features/Images/RegistryImageSearch.swift @@ -109,7 +109,8 @@ struct RegistryImageSearch: View { Image(systemName: "wifi.exclamationmark").font(.title2).foregroundStyle(.orange) Text("Couldn't search Docker Hub").font(.callout.weight(.medium)) Text(errorMessage).font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center) - DesignTextActionButton(title: "Retry", systemName: "arrow.clockwise") { + DesignTextActionButton(title: AppText.string("common.retry", defaultValue: "Retry"), + systemName: "arrow.clockwise") { searchNow() } } else { diff --git a/Sources/Contained/Features/Palette/CommandPalette.swift b/Sources/Contained/Features/Palette/CommandPalette.swift index d127590d..01e3a5f9 100644 --- a/Sources/Contained/Features/Palette/CommandPalette.swift +++ b/Sources/Contained/Features/Palette/CommandPalette.swift @@ -255,9 +255,9 @@ struct PaletteItem: Identifiable { app.settings.showInfoTips.toggle() }) for tint in AppTint.allCases { - items.append(PaletteItem(title: "Set app tint to \(tint.displayName)", + items.append(PaletteItem(title: AppText.setAppTintTitle(tint.localizedDisplayName), subtitle: "appearance", - keywords: ["accent", "color", "theme", "tint", tint.rawValue] + tint.searchAliases, + keywords: ["accent", "color", "theme", "tint", tint.rawValue] + tint.localizedSearchAliases, kind: .settings, visual: .tint(tint), icon: "paintpalette", diff --git a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift b/Sources/Contained/Features/Registries/RegistryLoginSheet.swift index 5a010369..59e96541 100644 --- a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift +++ b/Sources/Contained/Features/Registries/RegistryLoginSheet.swift @@ -16,12 +16,12 @@ struct RegistryLoginSheet: View { var body: some View { VStack(spacing: 0) { - SheetHeader(title: "Registry login", onCancel: { dismiss() }) { + SheetHeader(title: "Registry login", cancelHelp: AppText.close, onCancel: { dismiss() }) { if busy { ProgressView().controlSize(.small).frame(width: Tokens.IconSize.control, height: Tokens.IconSize.control) } else { DesignActionGroup(DesignAction(systemName: "checkmark", - help: "Log in", + help: AppText.logIn, isEnabled: !server.trimmingCharacters(in: .whitespaces).isEmpty && !username.trimmingCharacters(in: .whitespaces).isEmpty && !password.isEmpty) { diff --git a/Sources/Contained/Features/Settings/ConfigTransfer.swift b/Sources/Contained/Features/Settings/ConfigTransfer.swift index 4fab46cc..6433453c 100644 --- a/Sources/Contained/Features/Settings/ConfigTransfer.swift +++ b/Sources/Contained/Features/Settings/ConfigTransfer.swift @@ -8,6 +8,12 @@ enum ConfigImportMode: String, CaseIterable, Identifiable { case replace = "Replace" var id: String { rawValue } + var displayName: String { + switch self { + case .merge: return AppText.string("backup.importMode.merge", defaultValue: "Merge") + case .replace: return AppText.string("backup.importMode.replace", defaultValue: "Replace") + } + } var replacesExistingData: Bool { self == .replace } } @@ -28,7 +34,7 @@ struct ConfigTransferControls: View { } Picker("Import mode", selection: $importMode) { ForEach(ConfigImportMode.allCases) { mode in - Text(mode.rawValue).tag(mode) + Text(mode.displayName).tag(mode) } } .pickerStyle(.segmented) diff --git a/Sources/Contained/Features/Settings/ReleaseNotesView.swift b/Sources/Contained/Features/Settings/ReleaseNotesView.swift index afed785e..8f1c5159 100644 --- a/Sources/Contained/Features/Settings/ReleaseNotesView.swift +++ b/Sources/Contained/Features/Settings/ReleaseNotesView.swift @@ -9,7 +9,7 @@ struct ReleaseNotesView: View { var body: some View { VStack(spacing: 0) { - SheetHeader(title: title, cancelHelp: "Done") { + SheetHeader(title: title, cancelHelp: AppText.done) { onClose?() } Divider() diff --git a/Sources/Contained/Features/Settings/SettingsView.swift b/Sources/Contained/Features/Settings/SettingsView.swift index b680a5f7..e74aabc2 100644 --- a/Sources/Contained/Features/Settings/SettingsView.swift +++ b/Sources/Contained/Features/Settings/SettingsView.swift @@ -92,7 +92,7 @@ struct SettingsContent: View { } } if let onClose { - actions.append(DesignAction(systemName: "xmark", help: "Close", isCancel: true, action: onClose)) + actions.append(DesignAction(systemName: "xmark", help: AppText.close, isCancel: true, action: onClose)) } return actions } diff --git a/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift b/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift index e5530046..73ad3868 100644 --- a/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift @@ -12,19 +12,19 @@ struct AppearanceTab: View { PanelSection(header: "Theme") { PanelRow(title: "Appearance") { Picker("", selection: $settings.appearance) { - ForEach(AppearanceMode.allCases) { Text($0.displayName).tag($0) } + ForEach(AppearanceMode.allCases) { Text($0.localizedDisplayName).tag($0) } } .pickerStyle(.segmented).labelsHidden().fixedSize() } PanelRow(title: "Accent tint") { - TintSelector(selection: $settings.accentTint) + TintSelector(selection: $settings.accentTint) { $0.localizedDisplayName } } } PanelSection(header: "Layout") { PanelRow(title: "Card size") { Picker("", selection: $settings.density) { - ForEach(CardDensity.allCases) { Text($0.displayName).tag($0) } + ForEach(CardDensity.allCases) { Text($0.localizedDisplayName).tag($0) } } .pickerStyle(.segmented).labelsHidden().fixedSize() } @@ -55,7 +55,7 @@ struct AppearanceTab: View { footer: "Button tint uses the same color layer model as card backgrounds, applied inside toolbar glass controls.", enabled: $settings.buttonTintEnabled) { PanelRow(title: "Tint") { - TintSelector(selection: $settings.buttonTint) + TintSelector(selection: $settings.buttonTint) { $0.localizedDisplayName } } PanelRow(title: "Opacity") { HStack(spacing: Tokens.Space.s) { @@ -68,12 +68,12 @@ struct AppearanceTab: View { } PanelToggleRow(title: "Gradient", isOn: $settings.buttonTintGradient) if settings.buttonTintGradient { - GradientAngleControl(angle: $settings.buttonTintGradientAngle) + GradientAngleControl(angle: $settings.buttonTintGradientAngle, title: AppText.direction) } PanelRow(title: "Blend mode") { Picker("", selection: $settings.buttonTintBlendMode) { ForEach(ColorLayerBlendMode.allCases) { mode in - Text(mode.displayName).tag(mode) + Text(mode.localizedDisplayName).tag(mode) } } .labelsHidden() @@ -87,7 +87,7 @@ struct AppearanceTab: View { private func materialMenu(_ binding: Binding) -> some View { Picker("", selection: binding) { - ForEach(WindowMaterial.allCases) { Text($0.displayName).tag($0) } + ForEach(WindowMaterial.allCases) { Text($0.localizedDisplayName).tag($0) } } .labelsHidden().fixedSize() } @@ -114,7 +114,7 @@ private struct ImageDefaultStyleSection: View { Spacer() } PanelRow(title: "Color") { - TintSelector(selection: styleBinding(\.tint)) + TintSelector(selection: styleBinding(\.tint)) { $0.localizedDisplayName } } PanelToggleRow(title: "Custom icon", isOn: styleBinding(\.iconEnabled)) if style.iconEnabled { @@ -137,12 +137,12 @@ private struct ImageDefaultStyleSection: View { } PanelToggleRow(title: "Gradient", isOn: styleBinding(\.gradient)) if style.gradient { - GradientAngleControl(angle: styleBinding(\.gradientAngle)) + GradientAngleControl(angle: styleBinding(\.gradientAngle), title: AppText.direction) } PanelRow(title: "Blend mode") { Picker("", selection: styleBinding(\.backgroundBlendMode)) { ForEach(ColorLayerBlendMode.allCases) { mode in - Text(mode.displayName).tag(mode) + Text(mode.localizedDisplayName).tag(mode) } } .labelsHidden() diff --git a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift b/Sources/Contained/Features/System/Components/SystemLogsSheet.swift index 89b6a3e3..a30e61a4 100644 --- a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift +++ b/Sources/Contained/Features/System/Components/SystemLogsSheet.swift @@ -12,18 +12,22 @@ struct SystemLogsSheet: View { VStack(spacing: 0) { HStack(spacing: Tokens.Space.m) { Text("System logs").font(.headline) - DesignGlassToggle(isOn: $follow, title: "Follow", systemName: "arrow.down.to.line") + DesignGlassToggle(isOn: $follow, title: AppText.follow, systemName: "arrow.down.to.line") .onChange(of: follow) { _, _ in session += 1 } Spacer() DesignActionGroup(DesignAction(systemName: "xmark", - help: "Close", + help: AppText.close, isCancel: true) { dismiss() }) } .padding(Tokens.Space.s) if let client = app.client { - StreamConsole(stream: { client.streamSystemLogs(follow: follow, last: 500) }) + StreamConsole(stream: { client.streamSystemLogs(follow: follow, last: 500) }, + workingLabel: AppText.working, + completedLabel: AppText.completed, + lineCountLabel: AppText.lineCount, + copyLogHelp: AppText.copyLog) .id(session) .padding(.horizontal, Tokens.Space.s) .padding(.bottom, Tokens.Space.s) diff --git a/Sources/Contained/Features/System/SystemView.swift b/Sources/Contained/Features/System/SystemView.swift index 6c7fd06e..af05fe94 100644 --- a/Sources/Contained/Features/System/SystemView.swift +++ b/Sources/Contained/Features/System/SystemView.swift @@ -139,7 +139,7 @@ struct SystemContent: View { storageMenu if showClose { DesignActionItems([DesignAction(systemName: "xmark", - help: "Close", + help: AppText.close, isCancel: true, action: onClose)]) } @@ -162,7 +162,7 @@ struct SystemContent: View { DesignActionGroup([ servicePowerAction, DesignAction(systemName: "arrow.clockwise", - help: "Restart service", + help: AppText.restartService, isEnabled: !working) { run { await app.restartService() } } @@ -172,14 +172,14 @@ struct SystemContent: View { private var servicePowerAction: DesignAction { if app.serviceHealthy { return DesignAction(systemName: "stop.fill", - help: "Stop service", + help: AppText.stopService, role: .destructive, isEnabled: !working) { run { await app.stopService() } } } else { return DesignAction(systemName: "play.fill", - help: "Start service", + help: AppText.startService, isEnabled: !working) { run { await app.startService() } } @@ -199,7 +199,7 @@ struct SystemContent: View { Button { pruneTarget = .networks } label: { Label("Unused networks", systemImage: "network") } } label: { DesignMenuActionLabel(systemName: "trash", - help: "Storage cleanup", + help: AppText.storageCleanup, role: .destructive) } .buttonStyle(.plain) @@ -222,7 +222,7 @@ struct SystemContent: View { Spacer() DesignActionGroup(DesignAction(systemName: "plus", title: "New", - help: "New volume") { + help: AppText.newVolume) { onClose() ui.dispatch(.createVolume) }) @@ -263,7 +263,9 @@ struct SystemContent: View { .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } - GlassRowMenu { volumeMenu(entry) } + GlassRowMenu(accessibilityLabel: AppText.string("menu.volumeActions", defaultValue: "Volume actions")) { + volumeMenu(entry) + } } .padding(.vertical, Tokens.Space.s) .contextMenu { volumeMenu(entry) } @@ -312,7 +314,7 @@ struct SystemContent: View { .font(.system(.caption, design: .monospaced).weight(.semibold)).monospacedDigit() DesignActionGroup(DesignAction(systemName: "arrow.triangle.2.circlepath", title: "Run now", - help: "Run image update check now") { + help: AppText.runImageUpdateCheckNow) { Task { await app.runImageUpdateSweepNow() } }) } @@ -327,7 +329,7 @@ struct SystemContent: View { isOn: appUpdateBinding) { DesignActionGroup(DesignAction(systemName: "arrow.down.app", title: "Check now", - help: "Check for app updates now", + help: AppText.checkForUpdatesNow, isEnabled: app.updater.canCheckForUpdates && app.settings.appUpdateChecksEnabled) { app.updater.checkForUpdates() diff --git a/Sources/Contained/History/ActivityView.swift b/Sources/Contained/History/ActivityView.swift index 44286c3f..c37f5805 100644 --- a/Sources/Contained/History/ActivityView.swift +++ b/Sources/Contained/History/ActivityView.swift @@ -131,7 +131,7 @@ struct ActivityContent: View { ] if showClose { actions.append(DesignAction(systemName: "xmark", - help: "Close", + help: AppText.close, isCancel: true, action: onClose)) } diff --git a/Sources/Contained/History/ContainerHistoryTab.swift b/Sources/Contained/History/ContainerHistoryTab.swift index e074f9b5..5c41e04e 100644 --- a/Sources/Contained/History/ContainerHistoryTab.swift +++ b/Sources/Contained/History/ContainerHistoryTab.swift @@ -227,7 +227,7 @@ struct EventRow: View { } headerAccessory: { if isUnread { DesignStatusDot(color: .accentColor) - .accessibilityLabel("Unread") + .accessibilityLabel(AppText.unread) } else { EmptyView() } diff --git a/Sources/Contained/Migration/AppStateEnvelope.swift b/Sources/Contained/Migration/AppStateEnvelope.swift index 898838a3..c8663f5c 100644 --- a/Sources/Contained/Migration/AppStateEnvelope.swift +++ b/Sources/Contained/Migration/AppStateEnvelope.swift @@ -12,12 +12,12 @@ enum AppStateSection: String, Codable, CaseIterable, Identifiable, Sendable { var displayName: String { switch self { - case .settings: return "Settings" - case .personalization: return "Personalization" - case .healthChecks: return "Health checks" - case .templates: return "Templates" - case .history: return "Activity history" - case .caches: return "Caches" + case .settings: return AppText.string("backup.section.settings", defaultValue: "Settings") + case .personalization: return AppText.string("backup.section.personalization", defaultValue: "Personalization") + case .healthChecks: return AppText.string("backup.section.healthChecks", defaultValue: "Health checks") + case .templates: return AppText.string("backup.section.templates", defaultValue: "Templates") + case .history: return AppText.string("backup.section.history", defaultValue: "Activity history") + case .caches: return AppText.string("backup.section.caches", defaultValue: "Caches") } } } diff --git a/Sources/Contained/Migration/DowngradeDecisionView.swift b/Sources/Contained/Migration/DowngradeDecisionView.swift index 76ae971b..045593ed 100644 --- a/Sources/Contained/Migration/DowngradeDecisionView.swift +++ b/Sources/Contained/Migration/DowngradeDecisionView.swift @@ -11,7 +11,7 @@ struct DowngradeDecisionView: View { LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { SheetHeader(title: "This data was created by a newer version", subtitle: "Stored schema \(schemaVersion), this app supports \(StateMigrator.currentSchemaVersion).", - cancelHelp: "Quit", + cancelHelp: AppText.quit, onCancel: onQuit) Text("You can export a backup before resetting incompatible local data, try to keep what this build can still read, or quit and install the newer build again.") diff --git a/Sources/Contained/Navigation/AppToolbar.swift b/Sources/Contained/Navigation/AppToolbar.swift index c31a5672..39785c3b 100644 --- a/Sources/Contained/Navigation/AppToolbar.swift +++ b/Sources/Contained/Navigation/AppToolbar.swift @@ -180,11 +180,11 @@ struct AppToolbar: View { HStack(spacing: Tokens.Toolbar.groupSpacing) { DesignToolbarActionCluster { DesignActionItems([ - DesignAction(systemName: "plus", help: "Add") { ui.openCreationPanel() }, - DesignAction(systemName: "shippingbox", help: "Images") { + DesignAction(systemName: "plus", help: AppText.add) { ui.openCreationPanel() }, + DesignAction(systemName: "shippingbox", help: AppText.string("section.images", defaultValue: "Images")) { openGlobalSectionOrPanel(.images, morph: .updates) }, - DesignAction(systemName: "bookmark", help: "Templates") { + DesignAction(systemName: "bookmark", help: AppText.string("section.templates", defaultValue: "Templates")) { openGlobalSectionOrPanel(.templates, morph: .templates) } ]) diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/Contained/Navigation/ClassicShell.swift index 397663bd..7af026f7 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/Contained/Navigation/ClassicShell.swift @@ -226,7 +226,7 @@ private struct NetworksPage: View { subtitle: "\(sortedNetworks.count) network\(sortedNetworks.count == 1 ? "" : "s")") { DesignActionGroup(DesignAction(systemName: "plus", title: "New", - help: "New network") { + help: AppText.string("network.newNetwork.lowercase", defaultValue: "New network")) { ui.dispatch(.createNetwork) }) } content: { @@ -273,7 +273,9 @@ private struct NetworksPage: View { } subtitleAccessory: { EmptyView() } headerAccessory: { - GlassRowMenu { networkMenu(network) } + GlassRowMenu(accessibilityLabel: AppText.string("menu.networkActions", defaultValue: "Network actions")) { + networkMenu(network) + } } bodyContent: { EmptyView() } footerLeading: { diff --git a/Sources/Contained/Navigation/RootView.swift b/Sources/Contained/Navigation/RootView.swift index 2057d3bf..074f071b 100644 --- a/Sources/Contained/Navigation/RootView.swift +++ b/Sources/Contained/Navigation/RootView.swift @@ -185,7 +185,7 @@ struct RootView: View { Divider() Toggle(isOn: $ui.runningOnly) { Label("Show Running Only", systemImage: "play.circle") } Picker(selection: $settings.density) { - ForEach(CardDensity.allCases) { Text($0.displayName).tag($0) } + ForEach(CardDensity.allCases) { Text($0.localizedDisplayName).tag($0) } } label: { Label("Card Size", systemImage: "square.grid.2x2") } Divider() Button { openSectionOrMorph(.images, morph: .updates) } label: { Label("Images", systemImage: "square.stack.3d.up") } diff --git a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift index 8fa6cd2b..3f264e0d 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift @@ -206,11 +206,11 @@ struct PaletteResultCard: View { fillOpacity: selected ? Tokens.ResourceCard.selectedTintFillOpacity : Tokens.ResourceCard.selectedSubtleFillOpacity, elevated: false, onTap: action, - title: tint.displayName, + title: tint.localizedDisplayName, subtitle: item.title) { DesignTintSwatch(color: tint.color, followsAppAccent: tint.followsAppAccent) } titleAccessory: { - ResourceBadgeText(text: app.settings.accentTint == tint ? "Current" : "Tint", + ResourceBadgeText(text: app.settings.accentTint == tint ? AppText.current : AppText.tint, font: .caption2.weight(.semibold), foreground: app.settings.accentTint == tint ? .accentColor : .secondary) } subtitleAccessory: { diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift index 5961ba5f..b99725a7 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift @@ -127,7 +127,7 @@ struct ToolbarCommandPalette: View { if !ui.searchText.isEmpty { Button { ui.searchText = "" } label: { Image(systemName: "xmark.circle.fill") } .buttonStyle(.plain).foregroundStyle(.secondary) - .help("Clear search").accessibilityLabel("Clear search") + .help(AppText.clearSearch).accessibilityLabel(AppText.clearSearch) } else { Text("esc").font(.caption2).fontWeight(.medium).foregroundStyle(.tertiary) } @@ -142,7 +142,7 @@ struct ToolbarCommandPalette: View { } .buttonStyle(.plain) .help("Remove \(scope.title) scope") - .accessibilityLabel("Remove \(scope.title) scope") + .accessibilityLabel(AppText.removeScopeAccessibility(scope.title)) .fixedSize() } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 33871130..3f08f66a 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -190,7 +190,7 @@ struct ToolbarImageGroupCard: View { HStack { Spacer() if tagBusy { ProgressView().controlSize(.small) } - DesignTextActionButton(title: "Add Tag", + DesignTextActionButton(title: AppText.addTag, systemName: "checkmark", prominence: .prominent, isEnabled: !tagTarget.trimmingCharacters(in: .whitespaces).isEmpty && !tagBusy) { @@ -203,7 +203,11 @@ struct ToolbarImageGroupCard: View { private func pushPage(_ reference: String) -> some View { imagePageBody(title: "Push image", subtitle: Format.shortImage(reference)) { if pushStartedReference == reference, let client = app.client { - StreamConsole(stream: { client.streamPush(reference) }) + StreamConsole(stream: { client.streamPush(reference) }, + workingLabel: AppText.working, + completedLabel: AppText.completed, + lineCountLabel: AppText.lineCount, + copyLogHelp: AppText.copyLog) .frame(minHeight: 260) } else { pushReadiness(reference) @@ -257,7 +261,7 @@ struct ToolbarImageGroupCard: View { Spacer() switch readiness.action { case .push: - DesignTextActionButton(title: "Push", + DesignTextActionButton(title: AppText.push, systemName: "arrow.up.circle", prominence: .prominent, isEnabled: app.client != nil) { @@ -375,6 +379,7 @@ struct ToolbarImageGroupCard: View { selection: page, tint: resolvedImageTint, controlsReveal: isExpanded ? 1 : 0, + closeLabel: AppText.close, onSelect: selectPage, onClose: onClose) } @@ -417,22 +422,22 @@ struct ToolbarImageGroupCard: View { @ViewBuilder private func imageFooterActions(_ group: LocalImageTagGroup) -> some View { - footerAction("play", help: "Run") { + footerAction("play", help: AppText.run) { ui.runImage(group.primaryReference) if isExpanded { onClose() } } - footerAction("arrow.triangle.2.circlepath", help: "Check for Updates") { + footerAction("arrow.triangle.2.circlepath", help: AppText.checkForUpdates) { Task { await app.checkImageUpdate(group.primaryReference) } } if app.imageUpdateStatus(for: group.primaryReference).state == .updateAvailable { - footerAction("arrow.down.circle", help: "Pull Update", tint: .orange) { + footerAction("arrow.down.circle", help: AppText.pullUpdate, tint: .orange) { Task { await app.pullImageUpdate(group.primaryReference) } } } if let image = primaryImage(group) { - footerAction("arrow.up.doc", help: "Save") { save(image) } + footerAction("arrow.up.doc", help: AppText.save) { save(image) } } - footerAction("trash", help: "Prune", role: .destructive) { pruning = true } + footerAction("trash", help: AppText.prune, role: .destructive) { pruning = true } } private func tagList(_ group: LocalImageTagGroup) -> some View { @@ -483,12 +488,12 @@ struct ToolbarImageGroupCard: View { ResourceCardMetricText(text: "Local tag") } } footerActions: { - footerAction("play", help: "Run") { + footerAction("play", help: AppText.run) { ui.runImage(reference) if isExpanded { onClose() } } - footerAction("doc.on.doc", help: "Copy reference") { copyToPasteboard(reference) } - footerAction("trash", help: "Delete tag", role: .destructive) { deletingReference = reference } + footerAction("doc.on.doc", help: AppText.copyReference) { copyToPasteboard(reference) } + footerAction("trash", help: AppText.deleteTag, role: .destructive) { deletingReference = reference } } widget: { EmptyView() } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift index f5997f05..946a50ba 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift @@ -117,7 +117,7 @@ struct ToolbarTemplatesPanel: View { subtitle: "\(saved.count) saved") { if showClose { DesignActionGroup(DesignAction(systemName: "xmark", - help: "Close", + help: AppText.close, isCancel: true, action: onClose)) } @@ -184,8 +184,8 @@ struct ToolbarTemplatesPanel: View { .buttonStyle(.plain) .foregroundStyle(.red) .help("Delete") - .accessibilityLabel("Delete") - DesignTextActionButton(title: "Use", + .accessibilityLabel(AppText.delete) + DesignTextActionButton(title: AppText.string("common.use", defaultValue: "Use"), systemName: "plus.circle", prominence: .prominent, controlSize: .small) { diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift index 276cf8ae..ef8aaeba 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift @@ -16,6 +16,7 @@ struct ToolbarSearchSource: View { prompt: paletteEnabled ? "Search this page, or ⌘K for commands" : "Search this page", + clearSearchLabel: AppText.clearSearch, focused: $focused, onSubmit: { if paletteEnabled { ui.activeMorph = .palette } }, onClear: { ui.searchText = "" }) { diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift index 05011cb3..51a3eb5d 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift @@ -83,20 +83,20 @@ struct ToolbarUpdatesPanel: View { private var imageHeaderActions: [DesignAction] { var actions = [ - DesignAction(systemName: "square.and.arrow.down", help: "Load Image Tar") { + DesignAction(systemName: "square.and.arrow.down", help: AppText.loadImageTar) { ui.dispatch(.loadImage) onClose() }, - DesignAction(systemName: "arrow.triangle.2.circlepath", help: "Check for Updates") { + DesignAction(systemName: "arrow.triangle.2.circlepath", help: AppText.checkForUpdates) { Task { await app.runImageUpdateSweepNow() } }, - DesignAction(systemName: "trash", help: "Prune Images", role: .destructive) { + DesignAction(systemName: "trash", help: AppText.pruneImages, role: .destructive) { ui.dispatch(.pruneImages) onClose() } ] if showClose { - actions.append(DesignAction(systemName: "xmark", help: "Close", isCancel: true, action: onClose)) + actions.append(DesignAction(systemName: "xmark", help: AppText.close, isCancel: true, action: onClose)) } return actions } diff --git a/Sources/Contained/Navigation/ToolbarViewOptions.swift b/Sources/Contained/Navigation/ToolbarViewOptions.swift index 7fe084f3..1128fda0 100644 --- a/Sources/Contained/Navigation/ToolbarViewOptions.swift +++ b/Sources/Contained/Navigation/ToolbarViewOptions.swift @@ -129,13 +129,13 @@ struct ToolbarPageContextOptions: View { EmptyView() case .images: DesignActionGroup([ - DesignAction(systemName: "square.and.arrow.down", help: "Load Image Tar") { + DesignAction(systemName: "square.and.arrow.down", help: AppText.loadImageTar) { ui.dispatch(.loadImage) }, - DesignAction(systemName: "arrow.triangle.2.circlepath", help: "Check for Updates") { + DesignAction(systemName: "arrow.triangle.2.circlepath", help: AppText.checkForUpdates) { Task { await app.runImageUpdateSweepNow() } }, - DesignAction(systemName: "trash", help: "Prune Images", role: .destructive) { + DesignAction(systemName: "trash", help: AppText.pruneImages, role: .destructive) { ui.dispatch(.pruneImages) } ]) @@ -144,20 +144,20 @@ struct ToolbarPageContextOptions: View { EmptyView() case .networks: DesignActionGroup([ - DesignAction(systemName: "plus", help: "New Network") { + DesignAction(systemName: "plus", help: AppText.newNetwork) { ui.dispatch(.createNetwork) }, - DesignAction(systemName: "arrow.clockwise", help: "Refresh Networks") { + DesignAction(systemName: "arrow.clockwise", help: AppText.refreshNetworks) { Task { await app.refreshNetworks() } } ]) .help("\(app.networks.count) network\(app.networks.count == 1 ? "" : "s")") case .volumes: DesignActionGroup([ - DesignAction(systemName: "plus", help: "New Volume") { + DesignAction(systemName: "plus", help: AppText.newVolume) { ui.dispatch(.createVolume) }, - DesignAction(systemName: "arrow.clockwise", help: "Refresh Volumes") { + DesignAction(systemName: "arrow.clockwise", help: AppText.refreshVolumes) { Task { await app.refreshSystemResources() } } ]) @@ -166,17 +166,17 @@ struct ToolbarPageContextOptions: View { HStack(spacing: Tokens.Toolbar.groupSpacing) { DesignActionGroup(serviceActions) DesignActionGroup(systemPageActions + [ - DesignAction(systemName: "text.alignleft", help: "System Logs") { + DesignAction(systemName: "text.alignleft", help: AppText.systemLogs) { ui.dispatch(.systemLogs) } ]) } case .activity: DesignActionGroup([ - DesignAction(systemName: "checkmark.circle", help: "Mark all read") { + DesignAction(systemName: "checkmark.circle", help: AppText.markAllRead) { app.historyStore.markAllEventsRead() }, - DesignAction(systemName: "trash", help: "Clear activity", role: .destructive) { + DesignAction(systemName: "trash", help: AppText.clearActivity, role: .destructive) { app.historyStore.clearEvents() } ]) @@ -198,15 +198,15 @@ struct ToolbarPageContextOptions: View { private var serviceActions: [DesignAction] { let power = app.serviceHealthy - ? DesignAction(systemName: "stop.fill", help: "Stop service", role: .destructive) { + ? DesignAction(systemName: "stop.fill", help: AppText.stopService, role: .destructive) { Task { await app.stopService() } } - : DesignAction(systemName: "play.fill", help: "Start service") { + : DesignAction(systemName: "play.fill", help: AppText.startService) { Task { await app.startService() } } return [ power, - DesignAction(systemName: "arrow.clockwise", help: "Restart service") { + DesignAction(systemName: "arrow.clockwise", help: AppText.restartService) { Task { await app.restartService() } } ] diff --git a/Sources/Contained/Support/AppLocalization.swift b/Sources/Contained/Support/AppLocalization.swift new file mode 100644 index 00000000..c10ba948 --- /dev/null +++ b/Sources/Contained/Support/AppLocalization.swift @@ -0,0 +1,292 @@ +import Foundation +import ContainedCore +import ContainedDesignSystem + +/// App-owned user-facing copy. Packages receive resolved strings from here; they do not own +/// localized resources or English defaults. +enum AppText { + static func string(_ key: StaticString, defaultValue: String.LocalizationValue) -> String { + String(localized: key, defaultValue: defaultValue, bundle: .main) + } + + static var back: String { string("common.back", defaultValue: "Back") } + static var cancel: String { string("common.cancel", defaultValue: "Cancel") } + static var clearSearch: String { string("common.clearSearch", defaultValue: "Clear search") } + static var close: String { string("common.close", defaultValue: "Close") } + static var add: String { string("common.add", defaultValue: "Add") } + static var copied: String { string("common.copied", defaultValue: "Copied") } + static var copyCommand: String { string("common.copyCommand", defaultValue: "Copy command") } + static var copyLog: String { string("common.copyLog", defaultValue: "Copy log") } + static var copyAll: String { string("common.copyAll", defaultValue: "Copy all") } + static var copyReference: String { string("common.copyReference", defaultValue: "Copy reference") } + static var clear: String { string("common.clear", defaultValue: "Clear") } + static var delete: String { string("common.delete", defaultValue: "Delete") } + static var edit: String { string("common.edit", defaultValue: "Edit") } + static var refresh: String { string("common.refresh", defaultValue: "Refresh") } + static var completed: String { string("common.completed", defaultValue: "Completed") } + static var direction: String { string("common.direction", defaultValue: "Direction") } + static var done: String { string("common.done", defaultValue: "Done") } + static var follow: String { string("common.follow", defaultValue: "Follow") } + static var logIn: String { string("common.logIn", defaultValue: "Log in") } + static var parent: String { string("common.parent", defaultValue: "Parent") } + static var reconnect: String { string("common.reconnect", defaultValue: "Reconnect") } + static var quit: String { string("common.quit", defaultValue: "Quit") } + static var run: String { string("common.run", defaultValue: "Run") } + static var save: String { string("common.save", defaultValue: "Save") } + static var start: String { string("common.start", defaultValue: "Start") } + static var stop: String { string("common.stop", defaultValue: "Stop") } + static var restart: String { string("common.restart", defaultValue: "Restart") } + static var working: String { string("common.working", defaultValue: "Working...") } + + static var addBuildArgument: String { string("build.addBuildArgument", defaultValue: "Add build argument") } + static var buildImage: String { string("build.buildImage", defaultValue: "Build image") } + static var cancelBuild: String { string("build.cancelBuild", defaultValue: "Cancel build") } + static var chooseContextFolder: String { string("build.chooseContextFolder", defaultValue: "Choose context folder") } + static var removeBuildArgument: String { string("build.removeBuildArgument", defaultValue: "Remove build argument") } + static var cardColor: String { string("customize.cardColor", defaultValue: "Card Color") } + static var current: String { string("common.current", defaultValue: "Current") } + static var deleteTag: String { string("image.deleteTag", defaultValue: "Delete tag") } + static var loadImageTar: String { string("image.loadImageTar", defaultValue: "Load Image Tar") } + static var prune: String { string("image.prune", defaultValue: "Prune") } + static var pruneImages: String { string("image.pruneImages", defaultValue: "Prune Images") } + static var pullUpdate: String { string("image.pullUpdate", defaultValue: "Pull Update") } + static var checkForUpdates: String { string("updates.checkForUpdates", defaultValue: "Check for Updates") } + static var checkForUpdatesNow: String { string("updates.checkForUpdatesNow", defaultValue: "Check for app updates now") } + static var runImageUpdateCheckNow: String { + string("updates.runImageUpdateCheckNow", defaultValue: "Run image update check now") + } + static var addTag: String { string("image.addTag", defaultValue: "Add Tag") } + static var push: String { string("image.push", defaultValue: "Push") } + static var saveAsTemplate: String { string("template.saveAsTemplate", defaultValue: "Save as template") } + static var newNetwork: String { string("network.newNetwork", defaultValue: "New Network") } + static var newVolume: String { string("volume.newVolume", defaultValue: "New Volume") } + static var refreshNetworks: String { string("network.refreshNetworks", defaultValue: "Refresh Networks") } + static var refreshVolumes: String { string("volume.refreshVolumes", defaultValue: "Refresh Volumes") } + static var systemLogs: String { string("system.systemLogs", defaultValue: "System Logs") } + static var storageCleanup: String { string("system.storageCleanup", defaultValue: "Storage cleanup") } + static var markAllRead: String { string("activity.markAllRead", defaultValue: "Mark all read") } + static var clearActivity: String { string("activity.clearActivity", defaultValue: "Clear activity") } + static var tint: String { string("common.tint", defaultValue: "Tint") } + static var unread: String { string("common.unread", defaultValue: "Unread") } + + static var reconnectTerminal: String { + string("terminal.reconnectTerminal", defaultValue: "Reconnect terminal") + } + + static var startService: String { string("service.start", defaultValue: "Start service") } + static var stopService: String { string("service.stop", defaultValue: "Stop service") } + static var restartService: String { string("service.restart", defaultValue: "Restart service") } + + static func lineCount(_ count: Int) -> String { + string("streamConsole.lineCount", defaultValue: "\(count) lines") + } + + static func selectedCount(_ count: Int) -> String { + string("selection.count", defaultValue: "\(count) selected") + } + + static func customizeAccessibilityLabel(_ name: String) -> String { + string("customize.accessibilityLabel", defaultValue: "Customize \(name)") + } + + static func customizeImageStyleAccessibility(_ name: String) -> String { + string("customize.imageStyleAccessibilityLabel", defaultValue: "Customize \(name) image style") + } + + static func containerCardAccessibility(name: String, status: String) -> String { + string("containerCard.accessibilityLabel", defaultValue: "\(name), \(status)") + } + + static func removeScopeAccessibility(_ scope: String) -> String { + string("palette.removeScopeAccessibilityLabel", defaultValue: "Remove \(scope) scope") + } + + static func setAppTintTitle(_ tintName: String) -> String { + string("palette.setAppTint", defaultValue: "Set app tint to \(tintName)") + } +} + +extension AppTint { + var localizedDisplayName: String { + switch self { + case .multicolor: return AppText.string("tint.multicolor", defaultValue: "App Accent") + case .graphite: return AppText.string("tint.graphite", defaultValue: "Graphite") + case .azure: return AppText.string("tint.azure", defaultValue: "Azure") + case .teal: return AppText.string("tint.teal", defaultValue: "Teal") + case .coral: return AppText.string("tint.coral", defaultValue: "Coral") + case .indigo: return AppText.string("tint.indigo", defaultValue: "Indigo") + case .green: return AppText.string("tint.green", defaultValue: "Green") + case .amber: return AppText.string("tint.amber", defaultValue: "Amber") + case .pink: return AppText.string("tint.pink", defaultValue: "Pink") + } + } + + var localizedSearchAliases: [String] { + switch self { + case .multicolor: + return [ + AppText.string("tint.multicolor.alias.default", defaultValue: "default"), + AppText.string("tint.multicolor.alias.appAccent", defaultValue: "app accent"), + AppText.string("tint.multicolor.alias.system", defaultValue: "system"), + AppText.string("tint.multicolor.alias.auto", defaultValue: "auto"), + AppText.string("tint.multicolor.alias.rainbow", defaultValue: "rainbow"), + ] + case .graphite: + return [ + AppText.string("tint.graphite.alias.gray", defaultValue: "gray"), + AppText.string("tint.graphite.alias.grey", defaultValue: "grey"), + AppText.string("tint.graphite.alias.slate", defaultValue: "slate"), + AppText.string("tint.graphite.alias.charcoal", defaultValue: "charcoal"), + AppText.string("tint.graphite.alias.silver", defaultValue: "silver"), + AppText.string("tint.graphite.alias.neutral", defaultValue: "neutral"), + AppText.string("tint.graphite.alias.mono", defaultValue: "mono"), + ] + case .azure: + return [ + AppText.string("tint.azure.alias.blue", defaultValue: "blue"), + AppText.string("tint.azure.alias.sky", defaultValue: "sky"), + AppText.string("tint.azure.alias.ocean", defaultValue: "ocean"), + AppText.string("tint.azure.alias.cobalt", defaultValue: "cobalt"), + ] + case .teal: + return [ + AppText.string("tint.teal.alias.cyan", defaultValue: "cyan"), + AppText.string("tint.teal.alias.aqua", defaultValue: "aqua"), + AppText.string("tint.teal.alias.turquoise", defaultValue: "turquoise"), + AppText.string("tint.teal.alias.mint", defaultValue: "mint"), + AppText.string("tint.teal.alias.seafoam", defaultValue: "seafoam"), + ] + case .coral: + return [ + AppText.string("tint.coral.alias.orange", defaultValue: "orange"), + AppText.string("tint.coral.alias.salmon", defaultValue: "salmon"), + AppText.string("tint.coral.alias.burnt", defaultValue: "burnt"), + AppText.string("tint.coral.alias.terracotta", defaultValue: "terracotta"), + AppText.string("tint.coral.alias.rust", defaultValue: "rust"), + ] + case .indigo: + return [ + AppText.string("tint.indigo.alias.purple", defaultValue: "purple"), + AppText.string("tint.indigo.alias.violet", defaultValue: "violet"), + AppText.string("tint.indigo.alias.blurple", defaultValue: "blurple"), + AppText.string("tint.indigo.alias.royal", defaultValue: "royal"), + ] + case .green: + return [ + AppText.string("tint.green.alias.lime", defaultValue: "lime"), + AppText.string("tint.green.alias.olive", defaultValue: "olive"), + AppText.string("tint.green.alias.emerald", defaultValue: "emerald"), + AppText.string("tint.green.alias.forest", defaultValue: "forest"), + AppText.string("tint.green.alias.moss", defaultValue: "moss"), + ] + case .amber: + return [ + AppText.string("tint.amber.alias.yellow", defaultValue: "yellow"), + AppText.string("tint.amber.alias.gold", defaultValue: "gold"), + AppText.string("tint.amber.alias.honey", defaultValue: "honey"), + AppText.string("tint.amber.alias.mustard", defaultValue: "mustard"), + ] + case .pink: + return [ + AppText.string("tint.pink.alias.magenta", defaultValue: "magenta"), + AppText.string("tint.pink.alias.rose", defaultValue: "rose"), + AppText.string("tint.pink.alias.fuchsia", defaultValue: "fuchsia"), + AppText.string("tint.pink.alias.crimson", defaultValue: "crimson"), + AppText.string("tint.pink.alias.hotPink", defaultValue: "hot pink"), + ] + } + } +} + +extension AppearanceMode { + var localizedDisplayName: String { + switch self { + case .system: return AppText.string("appearance.system", defaultValue: "System") + case .light: return AppText.string("appearance.light", defaultValue: "Light") + case .dark: return AppText.string("appearance.dark", defaultValue: "Dark") + } + } +} + +extension CardDensity { + var localizedDisplayName: String { + switch self { + case .small: return AppText.string("cardDensity.small", defaultValue: "Small") + case .medium: return AppText.string("cardDensity.medium", defaultValue: "Medium") + case .large: return AppText.string("cardDensity.large", defaultValue: "Large") + } + } +} + +extension ColorLayerBlendMode { + var localizedDisplayName: String { + switch self { + case .normal: return AppText.string("blendMode.normal", defaultValue: "Normal") + case .softLight: return AppText.string("blendMode.softLight", defaultValue: "Soft Light") + case .overlay: return AppText.string("blendMode.overlay", defaultValue: "Overlay") + case .multiply: return AppText.string("blendMode.multiply", defaultValue: "Multiply") + case .screen: return AppText.string("blendMode.screen", defaultValue: "Screen") + } + } +} + +extension WindowMaterial { + var localizedDisplayName: String { + switch self { + case .glassClear: return AppText.string("windowMaterial.glassClear", defaultValue: "Glass (Clear)") + case .glassRegular: return AppText.string("windowMaterial.glassRegular", defaultValue: "Glass (Regular)") + case .fullScreenUI: return AppText.string("windowMaterial.fullScreenUI", defaultValue: "Full-screen UI (default)") + case .underWindowBackground: return AppText.string("windowMaterial.underWindowBackground", defaultValue: "Under Window") + case .underPageBackground: return AppText.string("windowMaterial.underPageBackground", defaultValue: "Under Page") + case .windowBackground: return AppText.string("windowMaterial.windowBackground", defaultValue: "Window") + case .contentBackground: return AppText.string("windowMaterial.contentBackground", defaultValue: "Content") + case .sidebar: return AppText.string("windowMaterial.sidebar", defaultValue: "Sidebar") + case .headerView: return AppText.string("windowMaterial.headerView", defaultValue: "Header") + case .titlebar: return AppText.string("windowMaterial.titlebar", defaultValue: "Titlebar") + case .sheet: return AppText.string("windowMaterial.sheet", defaultValue: "Sheet") + case .popover: return AppText.string("windowMaterial.popover", defaultValue: "Popover") + case .menu: return AppText.string("windowMaterial.menu", defaultValue: "Menu") + case .selection: return AppText.string("windowMaterial.selection", defaultValue: "Selection") + case .hudWindow: return AppText.string("windowMaterial.hudWindow", defaultValue: "HUD") + case .toolTip: return AppText.string("windowMaterial.toolTip", defaultValue: "Tooltip") + } + } +} + +extension GraphStyle { + var localizedDisplayName: String { + switch self { + case .area: return AppText.string("graphStyle.area", defaultValue: "Area") + case .line: return AppText.string("graphStyle.line", defaultValue: "Line") + case .bar: return AppText.string("graphStyle.bar", defaultValue: "Bar") + case .points: return AppText.string("graphStyle.points", defaultValue: "Points") + case .multiLine: return AppText.string("graphStyle.multiLine", defaultValue: "Multi-Line") + case .range: return AppText.string("graphStyle.range", defaultValue: "Range") + case .scatter: return AppText.string("graphStyle.scatter", defaultValue: "Scatter") + } + } +} + +extension WidgetInterpolation { + var localizedDisplayName: String { + switch self { + case .linear: return AppText.string("widgetInterpolation.linear", defaultValue: "Linear") + case .catmullRom: return AppText.string("widgetInterpolation.catmullRom", defaultValue: "Smooth") + case .cardinal: return AppText.string("widgetInterpolation.cardinal", defaultValue: "Cardinal") + case .monotone: return AppText.string("widgetInterpolation.monotone", defaultValue: "Monotone") + case .stepStart: return AppText.string("widgetInterpolation.stepStart", defaultValue: "Step Start") + case .stepCenter: return AppText.string("widgetInterpolation.stepCenter", defaultValue: "Step Center") + case .stepEnd: return AppText.string("widgetInterpolation.stepEnd", defaultValue: "Step End") + } + } +} + +extension RestartPolicy { + var localizedDisplayName: String { + switch self { + case .no: return AppText.string("restartPolicy.no", defaultValue: "No") + case .onFailure: return AppText.string("restartPolicy.onFailure", defaultValue: "On failure") + case .always: return AppText.string("restartPolicy.always", defaultValue: "Always") + } + } +} diff --git a/Sources/Contained/Support/AppLogging.swift b/Sources/Contained/Support/AppLogging.swift index e7156f42..253083d7 100644 --- a/Sources/Contained/Support/AppLogging.swift +++ b/Sources/Contained/Support/AppLogging.swift @@ -13,19 +13,27 @@ enum AppLogLevel: String, CaseIterable, Identifiable { var displayName: String { switch self { - case .off: return "Off" - case .errors: return "Errors" - case .important: return "Important" - case .verbose: return "Verbose" + case .off: return AppText.string("logging.level.off", defaultValue: "Off") + case .errors: return AppText.string("logging.level.errors", defaultValue: "Errors") + case .important: return AppText.string("logging.level.important", defaultValue: "Important") + case .verbose: return AppText.string("logging.level.verbose", defaultValue: "Verbose") } } var footnote: String { switch self { - case .off: return "No app events are recorded." - case .errors: return "Only failures are recorded." - case .important: return "User actions, failures, and state changes are recorded." - case .verbose: return "Adds routine refreshes and background work." + case .off: return AppText.string("logging.level.off.footnote", defaultValue: "No app events are recorded.") + case .errors: return AppText.string("logging.level.errors.footnote", defaultValue: "Only failures are recorded.") + case .important: + return AppText.string( + "logging.level.important.footnote", + defaultValue: "User actions, failures, and state changes are recorded." + ) + case .verbose: + return AppText.string( + "logging.level.verbose.footnote", + defaultValue: "Adds routine refreshes and background work." + ) } } @@ -47,8 +55,8 @@ enum AppLogDestination: String, CaseIterable, Identifiable { var displayName: String { switch self { - case .activity: return "Activity history" - case .console: return "macOS Console" + case .activity: return AppText.string("logging.destination.activity", defaultValue: "Activity history") + case .console: return AppText.string("logging.destination.console", defaultValue: "macOS Console") } } } @@ -66,13 +74,13 @@ enum AppLogCategory: String, CaseIterable, Identifiable { var displayName: String { switch self { - case .lifecycle: return "Containers" - case .image: return "Images" - case .compose: return "Compose" - case .system: return "System" - case .health: return "Health" - case .registry: return "Registries" - case .ui: return "Interface" + case .lifecycle: return AppText.string("logging.category.lifecycle", defaultValue: "Containers") + case .image: return AppText.string("logging.category.image", defaultValue: "Images") + case .compose: return AppText.string("logging.category.compose", defaultValue: "Compose") + case .system: return AppText.string("logging.category.system", defaultValue: "System") + case .health: return AppText.string("logging.category.health", defaultValue: "Health") + case .registry: return AppText.string("logging.category.registry", defaultValue: "Registries") + case .ui: return AppText.string("logging.category.ui", defaultValue: "Interface") } } diff --git a/Sources/Contained/Support/UpdateChannel.swift b/Sources/Contained/Support/UpdateChannel.swift index f27302bf..f5a19b9e 100644 --- a/Sources/Contained/Support/UpdateChannel.swift +++ b/Sources/Contained/Support/UpdateChannel.swift @@ -12,7 +12,13 @@ enum UpdateChannel: String, CaseIterable, Identifiable, Codable, Sendable { var id: String { rawValue } - var displayName: String { rawValue.capitalized } + var displayName: String { + switch self { + case .stable: return AppText.string("updateChannel.stable", defaultValue: "Stable") + case .beta: return AppText.string("updateChannel.beta", defaultValue: "Beta") + case .nightly: return AppText.string("updateChannel.nightly", defaultValue: "Nightly") + } + } /// The git branch whose `appcast.xml` (at repo root) backs this channel. var branch: String { @@ -34,9 +40,17 @@ enum UpdateChannel: String, CaseIterable, Identifiable, Codable, Sendable { var footnote: String { switch self { - case .stable: return "Only finished releases." - case .beta: return "Pre-release builds, ahead of stable. May be rough." - case .nightly: return "The latest build from every commit. Expect rough edges." + case .stable: return AppText.string("updateChannel.stable.footnote", defaultValue: "Only finished releases.") + case .beta: + return AppText.string( + "updateChannel.beta.footnote", + defaultValue: "Pre-release builds, ahead of stable. May be rough." + ) + case .nightly: + return AppText.string( + "updateChannel.nightly.footnote", + defaultValue: "The latest build from every commit. Expect rough edges." + ) } } } diff --git a/Sources/ContainedCore/Models/RestartPolicy.swift b/Sources/ContainedCore/Models/RestartPolicy.swift index e37ef618..48da3014 100644 --- a/Sources/ContainedCore/Models/RestartPolicy.swift +++ b/Sources/ContainedCore/Models/RestartPolicy.swift @@ -5,13 +5,6 @@ import Foundation public enum RestartPolicy: String, CaseIterable, Identifiable, Codable, Sendable { case no, onFailure = "on-failure", always public var id: String { rawValue } - public var displayName: String { - switch self { - case .no: return "No" - case .onFailure: return "On failure" - case .always: return "Always" - } - } /// Parse a label value into a policy (`nil`/unknown → `.no`). public init(label: String?) { diff --git a/changes/unreleased/20260702-localization-boundary.md b/changes/unreleased/20260702-localization-boundary.md new file mode 100644 index 00000000..047161c8 --- /dev/null +++ b/changes/unreleased/20260702-localization-boundary.md @@ -0,0 +1,3 @@ +## Changed + +- Routed design-system and navigation package copy through app-supplied strings so reusable packages stay language-light while the app is ready for English-only localization support. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 7eecebc3..7ceeadcf 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -19,6 +19,13 @@ Contained is a SwiftUI-native macOS app that wraps Apple's `container` CLI. It s - **`ContainedNavigation`** — a local reusable Swift package for navigation and layout infrastructure that should not own app-specific routing. It currently owns toolbar safe-area policy/measurement primitives. - **`Contained`** — the SwiftUI executable: views, `@Observable` stores, app-specific presentation mappings, navigation, and the SwiftData history stack. Depends on `ContainedCore`, `ContainedRuntime`, `AppleContainerRuntime`, `ContainedDesignSystem`, `ContainedNavigation`, SwiftTerm, and Sparkle. +The executable target owns localization. Reusable packages do not ship localized +resources or English UI defaults; app code supplies user-facing text through +package parameters and routes reusable enum labels/dynamic templates through +`AppText`. `ContainedCore`, `ContainedRuntime`, and adapter targets stay +language-free unless they expose technical identifiers such as raw values, +runtime descriptors, or command output. + Package-local docs: - [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index e9585fa6..3d1fcf24 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -78,6 +78,11 @@ appcast.xml Sparkle feed at the root of each release branch - **Agents start at `AGENTS.md`.** Coding agents should read the root agent guide before editing; it summarizes branch, update, release-note, design-system, and verification rules. - **Directory names are intentional.** SwiftPM folders stay `Sources` and `Tests`, Swift source domains use PascalCase, and repository infrastructure uses lowercase names such as `docs` and `scripts`. Put helper scripts in `scripts/` and use hyphenated names for multi-word shell scripts. - **Reusable packages live under `Packages/`.** Keep app-agnostic design primitives, tokens, spacing, material, opacity, and micro-chrome in `ContainedDesignSystem`; keep app state, stores, Sparkle, SwiftData, persistence, and feature routing in the executable target. +- **The app owns localization.** Reusable packages should not introduce + user-facing English defaults or localized resource bundles. If a package + component needs text, add an explicit parameter and pass app-owned strings + from `Sources/Contained`; reusable enum labels and dynamic templates should + flow through `AppText` with English fallbacks. - **Package docs live with the package.** Keep package-local import/setup/examples in [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) and [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md), with DocC landing pages under each target's `.docc` catalog. Keep `docs/wiki` focused on app-level architecture and workflow guidance. - **Xcode opens the workspace.** `Contained.xcworkspace` points at the root and local package manifests. SwiftPM package manifests remain the build graph source of truth; do not hand-maintain generated `.xcodeproj` state. - **Navigation infrastructure belongs in `ContainedNavigation` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in the executable target until they can cross the boundary without app policy. diff --git a/docs/wiki/Design-System.md b/docs/wiki/Design-System.md index 8ad8ecbc..8fdbd8b8 100644 --- a/docs/wiki/Design-System.md +++ b/docs/wiki/Design-System.md @@ -24,6 +24,30 @@ and card-selection overlays. Components that read `AppModel`, settings stores, feature routes, or runtime models stay in the app target, but they should pass values into package components instead of recreating style locally. +## Localization boundary + +The design system is a building block package. It owns layout, materials, +tokens, animation behavior, and control anatomy, but it does not own app copy or +localized resources. + +Guidelines: + +- user-facing labels, help text, accessibility labels, picker names, page names, + and empty-state copy are supplied by `Sources/Contained` +- package APIs that need words take app-supplied strings or semantic item + titles, such as `ResourceCardPages.closeLabel`, + `DesignToolbarSearchField.clearSearchLabel`, and `TintSelector`'s + `labelForTint` +- app-owned enum labels and dynamic templates flow through `AppText`, which uses + `String(localized:defaultValue:bundle:)` with English fallbacks today +- package-owned strings are limited to non-user identifiers such as SF Symbol + names, raw values, chart field identifiers, and accessibility-hidden chart + dimensions + +Only the executable target owns localization catalogs. The local packages should +remain reusable without shipping their own language bundles unless a future +package genuinely owns standalone user-facing copy. + ## Core principles - A morph panel is one stable shell. Content can swap inside it, but the shell diff --git a/docs/wiki/Localization.md b/docs/wiki/Localization.md new file mode 100644 index 00000000..5fa4ebd1 --- /dev/null +++ b/docs/wiki/Localization.md @@ -0,0 +1,53 @@ +# Localization + +Contained is English-only for now, but app copy should already be routed through +localization-ready APIs. + +## Ownership + +- `Sources/Contained` owns all user-facing strings and the localization catalog. +- `ContainedDesignSystem` and `ContainedNavigation` are building-block packages: + they own structure and visuals, not app copy. +- `ContainedCore`, `ContainedRuntime`, and runtime adapters should stay + language-free except for stable technical identifiers, raw values, and command + output. + +If a package component needs visible text, add an explicit parameter instead of +adding an English default in the package. Examples include action help, close +labels, search clear labels, page-control titles, selection-count text, and +color/tint display names. + +## App Strings + +Use `AppText` for reusable app-owned labels and dynamic templates: + +```swift +DesignToolbarSearchField(text: $query, + prompt: "Search this page", + clearSearchLabel: AppText.clearSearch, + focused: $focused, + onClear: { query = "" }) { + EmptyView() +} + +TintSelector(selection: $settings.accentTint) { + $0.localizedDisplayName +} + +DesignSelectionActionBar(count: selection.count, + countLabel: AppText.selectedCount, + actions: actions) +``` + +Plain SwiftUI literals such as `Text("Settings")`, `Button("Refresh")`, and +`Label("Logs", systemImage: "text.alignleft")` remain localization-ready through +SwiftUI. Strings that are passed into package `String` parameters, generated +dynamically, used as accessibility labels, or exposed as enum display names +should go through `AppText` or an app-side localized display extension. + +## English-Only Baseline + +The root package declares `defaultLocalization: "en"` and the app carries +`Sources/Contained/Resources/Localizable.xcstrings`. English currently comes +from `String(localized:defaultValue:bundle:)` fallbacks in app code. Future +translations can fill the string catalog without changing package APIs. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 96c6389f..4106d6c6 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -20,6 +20,7 @@ - [[Architecture]] - [[Runtime Adapters|Runtime-Adapters]] - [[Design System|Design-System]] + - [[Localization]] - [[Release]] - [[Contributing]] - [[Issues-and-Discussions]] From 159ddec75dd5dadcb03ebb456af0187f6faf46fa Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 06:58:01 +0100 Subject: [PATCH 19/53] Add package error presentation boundary --- AGENTS.md | 5 + README.md | 1 + .../Features/Containers/ComposeImport.swift | 15 +- .../Containers/ContainersGridView.swift | 4 +- .../Features/Containers/FilesTab.swift | 12 +- .../Features/Containers/LogsTab.swift | 2 +- .../Features/Images/RegistryImageSearch.swift | 2 +- .../Registries/RegistryLoginSheet.swift | 4 +- .../Features/Settings/ConfigTransfer.swift | 4 +- .../Settings/Tabs/RegistriesTab.swift | 4 +- .../Features/System/SystemView.swift | 4 +- .../Contained/Navigation/ClassicShell.swift | 4 +- .../ToolbarPanels/ToolbarCommandPalette.swift | 2 +- .../ToolbarPanels/ToolbarImageGroupCard.swift | 12 +- .../Stores/AppModel+Configuration.swift | 2 +- .../Stores/AppModel+ImageUpdates.swift | 13 +- Sources/Contained/Stores/AppModel.swift | 67 +++++---- .../Contained/Stores/ContainersStore.swift | 67 +++------ .../Support/AppErrorPresentation.swift | 128 ++++++++++++++++++ Sources/Contained/Support/AppLogging.swift | 11 ++ .../ContainedCore/Models/ComposeProject.swift | 12 +- .../Services/RegistryManifestClient.swift | 25 ++-- .../Support/ContainedPackageError.swift | 15 ++ Sources/ContainedRuntime/CommandError.swift | 29 ++-- .../ContainedRuntime/RuntimeDescriptor.swift | 11 +- .../ImageWorkflowTests.swift | 5 + .../AppleContainerRuntimeTests.swift | 17 +++ .../20260702-localization-boundary.md | 1 + docs/wiki/Architecture.md | 8 ++ docs/wiki/Contributing.md | 6 + docs/wiki/Localization.md | 17 +++ docs/wiki/Runtime-Adapters.md | 15 ++ 32 files changed, 387 insertions(+), 137 deletions(-) create mode 100644 Sources/Contained/Support/AppErrorPresentation.swift create mode 100644 Sources/ContainedCore/Support/ContainedPackageError.swift diff --git a/AGENTS.md b/AGENTS.md index 6e92585b..93788f34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,11 @@ This file is the working contract for coding agents in this repository. Follow i defaults or localized resource bundles. Use `AppText` for reusable app copy and dynamic templates; plain SwiftUI literals are acceptable when SwiftUI keeps them localization-ready. +- Keep package errors display-neutral. Reusable targets should throw typed errors + with stable codes/context, preferably `ContainedPackageError`, while + `Sources/Contained` maps them through `AppErrorPresentation`/`AppText` before + showing toasts, alerts, inline errors, or Activity entries. Preserve arbitrary + backend stderr as runtime detail rather than pretending to localize it. - Do not write app personalization back as `contained.*` labels. Only `contained.restart` and `contained.stack` may round-trip through container labels. - Keep helper scripts in `scripts/` and prefer hyphenated file names for multi-word shell scripts. - Keep comments human and useful. Explain surprising intent, not obvious syntax. diff --git a/README.md b/README.md index 89318c17..302f483d 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ The root package has the app/core targets and consumes local reusable packages: - [`Packages/ContainedNavigation`](Packages/ContainedNavigation/README.md): reusable navigation and layout infrastructure shared by app chrome. Integration is intentionally CLI-based rather than private-framework based. Personalization and app-managed metadata stay local to Contained so the user's container resources remain clean when used directly from the terminal. +Reusable packages expose display-neutral errors with stable package codes/context; the app owns localized messages, alerts, and Activity history presentation. ## License diff --git a/Sources/Contained/Features/Containers/ComposeImport.swift b/Sources/Contained/Features/Containers/ComposeImport.swift index f4344039..428a9b7d 100644 --- a/Sources/Contained/Features/Containers/ComposeImport.swift +++ b/Sources/Contained/Features/Containers/ComposeImport.swift @@ -28,7 +28,7 @@ enum ComposeImport { } catch let error as ComposeError { app.flash({ if case .invalid(let message) = error { return message }; return "Invalid compose file." }()) } catch { - app.flash(error.localizedDescription) + app.flash(error.appDisplayMessage) } } @@ -61,15 +61,12 @@ enum ComposeImport { app.logger.record("Imported compose project \(parsed.name) with \(specs.count) service\(specs.count == 1 ? "" : "s")", category: .compose) ui.beginPrefillQueue(specs, using: app) - } catch let error as ComposeError { - let message = { if case .invalid(let message) = error { return message }; return "Invalid compose file." }() - app.flash(message) - app.logger.record("Compose import failed: \(message)", category: .compose, severity: .error) } catch { - app.flash(error.localizedDescription) - app.logger.record("Compose import failed: \(error.localizedDescription)", - category: .compose, - severity: .error) + app.flash(error.appDisplayMessage) + app.logger.recordFailure("Compose import failed", + error: error, + category: .compose, + severity: .error) } } diff --git a/Sources/Contained/Features/Containers/ContainersGridView.swift b/Sources/Contained/Features/Containers/ContainersGridView.swift index 97b56de0..91ca46fb 100644 --- a/Sources/Contained/Features/Containers/ContainersGridView.swift +++ b/Sources/Contained/Features/Containers/ContainersGridView.swift @@ -315,8 +315,8 @@ struct ContainersGridView: View { private func deleteNetwork(_ network: NetworkResource) async { guard let client = app.client else { return } do { _ = try await client.deleteNetworks([network.name]); await app.refreshNetworks() } - catch let error as CommandError { app.flash(error.userMessage) } - catch { app.flash(error.localizedDescription) } + catch let error as CommandError { app.flash(error.appDisplayMessage) } + catch { app.flash(error.appDisplayMessage) } } @ViewBuilder diff --git a/Sources/Contained/Features/Containers/FilesTab.swift b/Sources/Contained/Features/Containers/FilesTab.swift index ee6cf0ea..7e02da82 100644 --- a/Sources/Contained/Features/Containers/FilesTab.swift +++ b/Sources/Contained/Features/Containers/FilesTab.swift @@ -98,8 +98,8 @@ struct FilesTab: View { entries = out.split(separator: "\n").map(String.init) .filter { $0 != "./" && $0 != "../" && !$0.isEmpty } .sorted { ($0.hasSuffix("/") ? 0 : 1, $0.lowercased()) < ($1.hasSuffix("/") ? 0 : 1, $1.lowercased()) } - } catch let e as CommandError { error = e.userMessage } - catch { self.error = error.localizedDescription } + } catch let e as CommandError { error = e.appDisplayMessage } + catch { self.error = error.appDisplayMessage } } private func goUp() { @@ -122,8 +122,8 @@ struct FilesTab: View { do { _ = try await app.client?.copy(source: "\(snapshot.id):\(joined(name))", destination: dest.path) app.flash("Copied \(name) to host") - } catch let e as CommandError { app.flash(e.userMessage) } - catch { app.flash(error.localizedDescription) } + } catch let e as CommandError { app.flash(e.appDisplayMessage) } + catch { app.flash(error.appDisplayMessage) } } } @@ -140,8 +140,8 @@ struct FilesTab: View { destination: "\(snapshot.id):\(joined(src.lastPathComponent))") app.flash("Copied \(src.lastPathComponent) into container") await load() - } catch let e as CommandError { app.flash(e.userMessage) } - catch { app.flash(error.localizedDescription) } + } catch let e as CommandError { app.flash(e.appDisplayMessage) } + catch { app.flash(error.appDisplayMessage) } } } } diff --git a/Sources/Contained/Features/Containers/LogsTab.swift b/Sources/Contained/Features/Containers/LogsTab.swift index 450dfb54..d920e90f 100644 --- a/Sources/Contained/Features/Containers/LogsTab.swift +++ b/Sources/Contained/Features/Containers/LogsTab.swift @@ -105,7 +105,7 @@ struct LogsTab: View { } catch is CancellationError { // Expected on tab/container switch — the child process is terminated for us. } catch { - failed = (error as? CommandError)?.userMessage ?? error.localizedDescription + failed = (error as? CommandError)?.appDisplayMessage ?? error.appDisplayMessage } } diff --git a/Sources/Contained/Features/Images/RegistryImageSearch.swift b/Sources/Contained/Features/Images/RegistryImageSearch.swift index 4e5ec6db..d5c3ff14 100644 --- a/Sources/Contained/Features/Images/RegistryImageSearch.swift +++ b/Sources/Contained/Features/Images/RegistryImageSearch.swift @@ -225,7 +225,7 @@ struct RegistryImageSearch: View { } catch { guard !Task.isCancelled, searchQuery == query else { return } results = [] - errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + errorMessage = error.appDisplayMessage } } } diff --git a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift b/Sources/Contained/Features/Registries/RegistryLoginSheet.swift index 59e96541..e942320e 100644 --- a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift +++ b/Sources/Contained/Features/Registries/RegistryLoginSheet.swift @@ -71,8 +71,8 @@ struct RegistryLoginSheet: View { password: password) await app.refreshRegistries() dismiss() - } catch let e as CommandError { error = e.userMessage; busy = false } - catch { self.error = error.localizedDescription; busy = false } + } catch let e as CommandError { error = e.appDisplayMessage; busy = false } + catch { self.error = error.appDisplayMessage; busy = false } } } } diff --git a/Sources/Contained/Features/Settings/ConfigTransfer.swift b/Sources/Contained/Features/Settings/ConfigTransfer.swift index 6433453c..11d33751 100644 --- a/Sources/Contained/Features/Settings/ConfigTransfer.swift +++ b/Sources/Contained/Features/Settings/ConfigTransfer.swift @@ -66,7 +66,7 @@ struct ConfigTransferControls: View { try app.exportConfiguration(to: url, sections: sections) app.flash("Exported backup") } catch { - app.flash(error.localizedDescription) + app.flash(error.appDisplayMessage) } } @@ -82,7 +82,7 @@ struct ConfigTransferControls: View { replace: importMode.replacesExistingData) app.flash("Imported backup") } catch { - app.flash(error.localizedDescription) + app.flash(error.appDisplayMessage) } } } diff --git a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift b/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift index 819862d1..379a1261 100644 --- a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift +++ b/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift @@ -60,7 +60,7 @@ struct RegistriesTab: View { private func logout(_ login: RegistryLogin) async { guard let client = app.client else { return } do { _ = try await client.registryLogout(server: login.host); await app.refreshRegistries() } - catch let error as CommandError { app.flash(error.userMessage) } - catch { app.flash(error.localizedDescription) } + catch let error as CommandError { app.flash(error.appDisplayMessage) } + catch { app.flash(error.appDisplayMessage) } } } diff --git a/Sources/Contained/Features/System/SystemView.swift b/Sources/Contained/Features/System/SystemView.swift index af05fe94..662d15a9 100644 --- a/Sources/Contained/Features/System/SystemView.swift +++ b/Sources/Contained/Features/System/SystemView.swift @@ -417,8 +417,8 @@ struct SystemContent: View { } await app.refreshSystemResources() await app.refreshSystem() - } catch let error as CommandError { app.flash(error.userMessage) } - catch { app.flash(error.localizedDescription) } + } catch let error as CommandError { app.flash(error.appDisplayMessage) } + catch { app.flash(error.appDisplayMessage) } } private func reclaimAll() async { diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/Contained/Navigation/ClassicShell.swift index 7af026f7..dd7fd3f7 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/Contained/Navigation/ClassicShell.swift @@ -342,9 +342,9 @@ private struct NetworksPage: View { _ = try await client.deleteNetworks([network.name]) await app.refreshNetworks() } catch let error as CommandError { - app.flash(error.userMessage) + app.flash(error.appDisplayMessage) } catch { - app.flash(error.localizedDescription) + app.flash(error.appDisplayMessage) } } } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift index b99725a7..cca2d2b8 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift @@ -421,7 +421,7 @@ struct ToolbarCommandPalette: View { } catch { guard !Task.isCancelled else { return } hubResults = [] - hubError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + hubError = error.appDisplayMessage } } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 3f08f66a..d48247c8 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -349,9 +349,9 @@ struct ToolbarImageGroupCard: View { tagTarget = "" withAnimation(spring) { page = .tags } } catch let error as CommandError { - app.flash(error.userMessage); tagBusy = false + app.flash(error.appDisplayMessage); tagBusy = false } catch { - app.flash(error.localizedDescription); tagBusy = false + app.flash(error.appDisplayMessage); tagBusy = false } } } @@ -651,15 +651,15 @@ struct ToolbarImageGroupCard: View { await app.refreshImagesIfStale(force: true) app.flash("Deleted \(Format.shortImage(reference))") deletingReference = nil - } catch let error as CommandError { app.flash(error.userMessage) } - catch { app.flash(error.localizedDescription) } + } catch let error as CommandError { app.flash(error.appDisplayMessage) } + catch { app.flash(error.appDisplayMessage) } } private func prune(all: Bool) async { guard let client = app.client else { return } do { _ = try await client.pruneImages(all: all); await app.refreshImagesIfStale(force: true) } - catch let error as CommandError { app.flash(error.userMessage) } - catch { app.flash(error.localizedDescription) } + catch let error as CommandError { app.flash(error.appDisplayMessage) } + catch { app.flash(error.appDisplayMessage) } } private func save(_ image: ContainedCore.ImageResource) { diff --git a/Sources/Contained/Stores/AppModel+Configuration.swift b/Sources/Contained/Stores/AppModel+Configuration.swift index 7aface8a..c8c62c44 100644 --- a/Sources/Contained/Stores/AppModel+Configuration.swift +++ b/Sources/Contained/Stores/AppModel+Configuration.swift @@ -51,7 +51,7 @@ extension AppModel { downgradeSchemaVersion = nil flash("Exported backup and reset local state") } catch { - flash(error.localizedDescription) + flash(error.appDisplayMessage) } } diff --git a/Sources/Contained/Stores/AppModel+ImageUpdates.swift b/Sources/Contained/Stores/AppModel+ImageUpdates.swift index d7856483..43aec39b 100644 --- a/Sources/Contained/Stores/AppModel+ImageUpdates.swift +++ b/Sources/Contained/Stores/AppModel+ImageUpdates.swift @@ -98,12 +98,13 @@ extension AppModel { } } } catch { - let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + let message = error.appDisplayMessage imageUpdates[key] = .failed(localDigest: localDigest, message: message) if notify { flash(message) } - logger.record("Failed checking image update for \(Format.shortImage(reference)): \(message)", - category: .image, - severity: .error) + logger.recordFailure("Failed checking image update for \(Format.shortImage(reference))", + error: error, + category: .image, + severity: .error) } } @@ -129,10 +130,10 @@ extension AppModel { images = try await client.images() imagesError = nil } catch let error as CommandError { - imagesError = error.userMessage + imagesError = error.appDisplayMessage return } catch { - imagesError = error.localizedDescription + imagesError = error.appDisplayMessage return } } diff --git a/Sources/Contained/Stores/AppModel.swift b/Sources/Contained/Stores/AppModel.swift index 4774e9ee..a588c2af 100644 --- a/Sources/Contained/Stores/AppModel.swift +++ b/Sources/Contained/Stores/AppModel.swift @@ -246,9 +246,10 @@ final class AppModel { // `system status` exits non-zero when the service isn't running/registered. bootstrap = .serviceStopped stopContainerStatsStream() - logger.record("Couldn't read container service status: \(error.localizedDescription)", - category: .system, - severity: .error) + logger.recordFailure("Couldn't read container service status", + error: error, + category: .system, + severity: .error) } let elapsed = Date().timeIntervalSince(started) if elapsed >= 0.75 { @@ -291,9 +292,17 @@ final class AppModel { /// Run a throwing CLI action, returning a user-facing error string on failure (nil on success). /// Collapses the repeated `do / catch CommandError / catch` blocks across the stores and sheets. func captured(_ work: () async throws -> Void) async -> String? { - do { try await work(); return nil } - catch let error as CommandError { return error.userMessage } - catch { return error.localizedDescription } + await capturedError(work)?.appDisplayMessage + } + + /// Run a throwing action while preserving the original error for Activity/package metadata. + func capturedError(_ work: () async throws -> Void) async -> Error? { + do { + try await work() + return nil + } catch { + return error + } } /// One polling tick: refresh system + containers, run the restart watchdog, and keep the cached @@ -352,7 +361,7 @@ final class AppModel { await MainActor.run { guard let self, self.containerStatsStreamGeneration == generation else { return } - self.diagnosticLogger.error("Stats stream failed: \(error.localizedDescription, privacy: .public)") + self.diagnosticLogger.error("Stats stream failed: \(error.appDisplayMessage, privacy: .public)") } } @@ -478,11 +487,12 @@ final class AppModel { func loadImageTar(at url: URL) { guard let client else { return } Task { - if let error = await captured({ _ = try await client.loadImages(from: url.path) }) { - flash(error) - logger.record("Failed loading image archive \(url.lastPathComponent): \(error)", - category: .image, - severity: .error) + if let error = await capturedError({ _ = try await client.loadImages(from: url.path) }) { + flash(error.appDisplayMessage) + logger.recordFailure("Failed loading image archive \(url.lastPathComponent)", + error: error, + category: .image, + severity: .error) } else { await refreshImagesIfStale(force: true) flash("Loaded \(url.lastPathComponent)") @@ -494,13 +504,16 @@ final class AppModel { @discardableResult func createVolume(name: String, size: String?) async -> Bool { guard let client else { return false } - let error = await captured { + let error = await capturedError { _ = try await client.createVolume(name: name, size: size) await refreshVolumes() } if let error { - flash(error) - logger.record("Failed creating volume \(name): \(error)", category: .system, severity: .error) + flash(error.appDisplayMessage) + logger.recordFailure("Failed creating volume \(name)", + error: error, + category: .system, + severity: .error) return false } flash("Created volume \(name)") @@ -511,13 +524,16 @@ final class AppModel { @discardableResult func createNetwork(name: String, subnet: String?, internalOnly: Bool) async -> Bool { guard let client else { return false } - let error = await captured { + let error = await capturedError { _ = try await client.createNetwork(name: name, subnet: subnet, internalOnly: internalOnly) await refreshNetworks() } if let error { - flash(error) - logger.record("Failed creating network \(name): \(error)", category: .system, severity: .error) + flash(error.appDisplayMessage) + logger.recordFailure("Failed creating network \(name)", + error: error, + category: .system, + severity: .error) return false } flash("Created network \(name)") @@ -549,17 +565,12 @@ final class AppModel { await refreshImagesIfStale(force: true) logger.record("Pulled \(Format.shortImage(reference))", category: .image) return true - } catch let error as CommandError { - flash(error.userMessage) - logger.record("Failed pulling \(Format.shortImage(reference)): \(error.userMessage)", - category: .image, - severity: .error) - return false } catch { - flash(error.localizedDescription) - logger.record("Failed pulling \(Format.shortImage(reference)): \(error.localizedDescription)", - category: .image, - severity: .error) + flash(error.appDisplayMessage) + logger.recordFailure("Failed pulling \(Format.shortImage(reference))", + error: error, + category: .image, + severity: .error) return false } } diff --git a/Sources/Contained/Stores/ContainersStore.swift b/Sources/Contained/Stores/ContainersStore.swift index fe67cd5d..aad4bb97 100644 --- a/Sources/Contained/Stores/ContainersStore.swift +++ b/Sources/Contained/Stores/ContainersStore.swift @@ -153,9 +153,9 @@ final class ContainersStore { errorMessage = nil pruneStatsForCurrentRunningSet() } catch let error as CommandError { - errorMessage = error.userMessage + errorMessage = error.appDisplayMessage } catch { - errorMessage = error.localizedDescription + errorMessage = error.appDisplayMessage } } @@ -289,21 +289,14 @@ final class ContainersStore { .map { $0.trimmingCharacters(in: .whitespaces) } .last(where: { !$0.isEmpty }) return printed - } catch let error as CommandError { - errorMessage = error.userMessage - let elapsed = Date().timeIntervalSince(started) - logger?.record("Run failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.userMessage)", - category: .lifecycle, - severity: .warning) - diagnosticLogger.error("Run failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.userMessage, privacy: .public)") - return nil } catch { - errorMessage = error.localizedDescription + errorMessage = error.appDisplayMessage let elapsed = Date().timeIntervalSince(started) - logger?.record("Run failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.localizedDescription)", - category: .lifecycle, - severity: .warning) - diagnosticLogger.error("Run failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.localizedDescription, privacy: .public)") + logger?.recordFailure("Run failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s", + error: error, + category: .lifecycle, + severity: .warning) + diagnosticLogger.error("Run failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.appDisplayMessage, privacy: .public)") return nil } } @@ -334,24 +327,15 @@ final class ContainersStore { diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, "Recreated \(originalID, privacy: .public) in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s") return true - } catch let error as CommandError { - errorMessage = error.userMessage - let elapsed = Date().timeIntervalSince(started) - logger?.record("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.userMessage)", - category: .lifecycle, - severity: .warning, - containerID: originalID) - diagnosticLogger.error("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.userMessage, privacy: .public)") - await refresh() - return false } catch { - errorMessage = error.localizedDescription + errorMessage = error.appDisplayMessage let elapsed = Date().timeIntervalSince(started) - logger?.record("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.localizedDescription)", - category: .lifecycle, - severity: .warning, - containerID: originalID) - diagnosticLogger.error("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.localizedDescription, privacy: .public)") + logger?.recordFailure("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s", + error: error, + category: .lifecycle, + severity: .warning, + containerID: originalID) + diagnosticLogger.error("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.appDisplayMessage, privacy: .public)") await refresh() return false } @@ -377,22 +361,15 @@ final class ContainersStore { containerID: id) diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, "\(verb) finished for \(id, privacy: .public) in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s") - } catch let error as CommandError { - errorMessage = error.userMessage - let elapsed = Date().timeIntervalSince(started) - logger?.record("\(verb) failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.userMessage)", - category: .lifecycle, - severity: .warning, - containerID: id) - diagnosticLogger.error("\(verb) failed for \(id, privacy: .public) after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.userMessage, privacy: .public)") } catch { - errorMessage = error.localizedDescription + errorMessage = error.appDisplayMessage let elapsed = Date().timeIntervalSince(started) - logger?.record("\(verb) failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.localizedDescription)", - category: .lifecycle, - severity: .warning, - containerID: id) - diagnosticLogger.error("\(verb) failed for \(id, privacy: .public) after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.localizedDescription, privacy: .public)") + logger?.recordFailure("\(verb) failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s", + error: error, + category: .lifecycle, + severity: .warning, + containerID: id) + diagnosticLogger.error("\(verb) failed for \(id, privacy: .public) after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.appDisplayMessage, privacy: .public)") } } } diff --git a/Sources/Contained/Support/AppErrorPresentation.swift b/Sources/Contained/Support/AppErrorPresentation.swift new file mode 100644 index 00000000..13208559 --- /dev/null +++ b/Sources/Contained/Support/AppErrorPresentation.swift @@ -0,0 +1,128 @@ +import Foundation +import ContainedCore +import ContainedRuntime + +enum AppErrorPresentation { + static func message(for error: Error) -> String { + switch error { + case let error as CommandError: + return message(for: error) + case let error as UnsupportedRuntimeCapability: + return message(for: error) + case let error as RegistryManifestError: + return message(for: error) + case let error as ComposeError: + return message(for: error) + case let error as LocalizedError: + return error.errorDescription ?? (error as NSError).localizedDescription + default: + return (error as NSError).localizedDescription + } + } + + static func packageSummary(for error: Error) -> String? { + guard let packageError = error as? ContainedPackageError else { return nil } + let context = packageError.packageErrorContext + .sorted { $0.key < $1.key } + .map { "\($0.key)=\(compactContextValue($0.value))" } + .joined(separator: ", ") + if context.isEmpty { + return "\(packageError.packageName).\(packageError.packageErrorCode)" + } + return "\(packageError.packageName).\(packageError.packageErrorCode) (\(context))" + } + + static func activityMessage(_ prefix: String, error: Error) -> String { + let message = "\(prefix): \(message(for: error))" + guard let summary = packageSummary(for: error) else { return message } + return "\(message) [\(summary)]" + } + + private static func compactContextValue(_ value: String) -> String { + let collapsed = value + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard collapsed.count > 160 else { return collapsed } + return "\(collapsed.prefix(157))..." + } + + private static func message(for error: CommandError) -> String { + switch error { + case .cliNotFound(let searched): + return AppText.string( + "error.command.cliNotFound", + defaultValue: "Couldn't find the container CLI (looked in \(searched.joined(separator: ", ")))." + ) + case .nonZeroExit(_, let stderr, _): + if !stderr.isEmpty { return stderr } + return AppText.string("error.command.nonZeroExit", defaultValue: "The container command failed.") + case .decodingFailed: + return AppText.string( + "error.command.decodingFailed", + defaultValue: "Couldn't read the response from the container CLI." + ) + case .launchFailed(let underlying): + return AppText.string( + "error.command.launchFailed", + defaultValue: "Couldn't run the container CLI: \(underlying)" + ) + } + } + + private static func message(for error: UnsupportedRuntimeCapability) -> String { + AppText.string( + "error.runtime.unsupportedCapability", + defaultValue: "The selected runtime does not support this operation." + ) + } + + private static func message(for error: RegistryManifestError) -> String { + switch error { + case .invalidResponse: + return AppText.string( + "error.registry.invalidResponse", + defaultValue: "The registry returned an invalid response." + ) + case .unauthorized: + return AppText.string( + "error.registry.unauthorized", + defaultValue: "The registry requires authentication." + ) + case .notFound: + return AppText.string("error.registry.notFound", defaultValue: "The image or tag was not found.") + case .missingDigest: + return AppText.string( + "error.registry.missingDigest", + defaultValue: "The registry response did not include a content digest." + ) + case .httpStatus(let code): + return AppText.string("error.registry.httpStatus", defaultValue: "The registry returned HTTP \(code).") + case .tokenUnavailable: + return AppText.string( + "error.registry.tokenUnavailable", + defaultValue: "Couldn't get a registry authorization token." + ) + } + } + + private static func message(for error: ComposeError) -> String { + switch error { + case .invalid(let reason): + if reason.isEmpty { + return AppText.string("error.compose.invalid", defaultValue: "Invalid compose file.") + } + return reason + } + } +} + +extension Error { + var appDisplayMessage: String { + AppErrorPresentation.message(for: self) + } + + var appPackageSummary: String? { + AppErrorPresentation.packageSummary(for: self) + } +} diff --git a/Sources/Contained/Support/AppLogging.swift b/Sources/Contained/Support/AppLogging.swift index 253083d7..53595351 100644 --- a/Sources/Contained/Support/AppLogging.swift +++ b/Sources/Contained/Support/AppLogging.swift @@ -144,4 +144,15 @@ final class AppLogger { logger.log(level: severity.osLogType, "\(message, privacy: .public)") } } + + func recordFailure(_ prefix: String, + error: Error, + category: AppLogCategory, + severity: AppLogSeverity = .error, + containerID: String? = nil) { + record(AppErrorPresentation.activityMessage(prefix, error: error), + category: category, + severity: severity, + containerID: containerID) + } } diff --git a/Sources/ContainedCore/Models/ComposeProject.swift b/Sources/ContainedCore/Models/ComposeProject.swift index 963e2b1b..28c76e14 100644 --- a/Sources/ContainedCore/Models/ComposeProject.swift +++ b/Sources/ContainedCore/Models/ComposeProject.swift @@ -92,7 +92,17 @@ public struct ComposeHealthcheck: Sendable, Hashable { } } -public enum ComposeError: Error, Sendable { case invalid(String) } +public enum ComposeError: ContainedPackageError, Equatable { + case invalid(String) + + public var packageName: String { "ContainedCore" } + public var packageErrorCode: String { "composeInvalid" } + public var packageErrorContext: [String: String] { + switch self { + case .invalid(let reason): return ["reason": reason] + } + } +} /// Dependency ordering for a stack launch. Pure + testable (factored like `RestartDecision`). public enum ComposeOrder { diff --git a/Sources/ContainedCore/Services/RegistryManifestClient.swift b/Sources/ContainedCore/Services/RegistryManifestClient.swift index 777f8764..29f0103f 100644 --- a/Sources/ContainedCore/Services/RegistryManifestClient.swift +++ b/Sources/ContainedCore/Services/RegistryManifestClient.swift @@ -1,6 +1,6 @@ import Foundation -public enum RegistryManifestError: Error, LocalizedError, Equatable { +public enum RegistryManifestError: ContainedPackageError, Equatable { case invalidResponse case unauthorized case notFound @@ -8,14 +8,23 @@ public enum RegistryManifestError: Error, LocalizedError, Equatable { case httpStatus(Int) case tokenUnavailable - public var errorDescription: String? { + public var packageName: String { "ContainedCore" } + + public var packageErrorCode: String { + switch self { + case .invalidResponse: return "registryInvalidResponse" + case .unauthorized: return "registryUnauthorized" + case .notFound: return "registryNotFound" + case .missingDigest: return "registryMissingDigest" + case .httpStatus: return "registryHTTPStatus" + case .tokenUnavailable: return "registryTokenUnavailable" + } + } + + public var packageErrorContext: [String: String] { switch self { - case .invalidResponse: return "The registry returned an invalid response." - case .unauthorized: return "The registry requires authentication." - case .notFound: return "The image or tag was not found." - case .missingDigest: return "The registry response did not include a content digest." - case .httpStatus(let code): return "The registry returned HTTP \(code)." - case .tokenUnavailable: return "Couldn't get a registry authorization token." + case .httpStatus(let code): return ["status": String(code)] + case .invalidResponse, .unauthorized, .notFound, .missingDigest, .tokenUnavailable: return [:] } } } diff --git a/Sources/ContainedCore/Support/ContainedPackageError.swift b/Sources/ContainedCore/Support/ContainedPackageError.swift new file mode 100644 index 00000000..2f1fa8a0 --- /dev/null +++ b/Sources/ContainedCore/Support/ContainedPackageError.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Machine-readable error metadata for reusable packages. +/// +/// Packages throw typed errors with stable codes and context. Apps decide how to +/// localize, display, alert, or record those errors. +public protocol ContainedPackageError: Error, Sendable { + var packageName: String { get } + var packageErrorCode: String { get } + var packageErrorContext: [String: String] { get } +} + +public extension ContainedPackageError { + var packageErrorContext: [String: String] { [:] } +} diff --git a/Sources/ContainedRuntime/CommandError.swift b/Sources/ContainedRuntime/CommandError.swift index 284a5890..28764803 100644 --- a/Sources/ContainedRuntime/CommandError.swift +++ b/Sources/ContainedRuntime/CommandError.swift @@ -1,7 +1,8 @@ import Foundation +import ContainedCore /// A typed error for everything that can go wrong invoking the `container` CLI. -public enum CommandError: Error, Sendable, Equatable { +public enum CommandError: ContainedPackageError, Equatable { /// The `container` binary could not be located on disk. case cliNotFound(searched: [String]) /// The process launched but exited non-zero. `stderr` is the trimmed error text. @@ -11,17 +12,27 @@ public enum CommandError: Error, Sendable, Equatable { /// The process could not be launched at all. case launchFailed(underlying: String) - /// A short, user-facing message suitable for a toast. - public var userMessage: String { + public var packageName: String { "ContainedRuntime" } + + public var packageErrorCode: String { + switch self { + case .cliNotFound: return "cliNotFound" + case .nonZeroExit: return "nonZeroExit" + case .decodingFailed: return "decodingFailed" + case .launchFailed: return "launchFailed" + } + } + + public var packageErrorContext: [String: String] { switch self { case .cliNotFound(let searched): - return "Couldn't find the container CLI (looked in \(searched.joined(separator: ", "))." - case .nonZeroExit(_, let stderr, _): - return stderr.isEmpty ? "The container command failed." : stderr - case .decodingFailed: - return "Couldn't read the response from the container CLI." + return ["searched": searched.joined(separator: ", ")] + case .nonZeroExit(let code, let stderr, let command): + return ["code": String(code), "stderr": stderr, "command": command] + case .decodingFailed(let underlying, let command): + return ["underlying": underlying, "command": command] case .launchFailed(let underlying): - return "Couldn't run the container CLI: \(underlying)" + return ["underlying": underlying] } } } diff --git a/Sources/ContainedRuntime/RuntimeDescriptor.swift b/Sources/ContainedRuntime/RuntimeDescriptor.swift index 79eee147..7a787db8 100644 --- a/Sources/ContainedRuntime/RuntimeDescriptor.swift +++ b/Sources/ContainedRuntime/RuntimeDescriptor.swift @@ -105,9 +105,14 @@ public struct UnsupportedRuntimeCapability: Error, Equatable, Sendable { } } -extension UnsupportedRuntimeCapability: LocalizedError { - public var errorDescription: String? { - "The selected runtime does not support this operation." +extension UnsupportedRuntimeCapability: ContainedPackageError { + public var packageName: String { "ContainedRuntime" } + public var packageErrorCode: String { "unsupportedRuntimeCapability" } + public var packageErrorContext: [String: String] { + [ + "kind": kind.rawValue, + "capability": String(capability.rawValue), + ] } } diff --git a/Tests/ContainedCoreTests/ImageWorkflowTests.swift b/Tests/ContainedCoreTests/ImageWorkflowTests.swift index 2c6e4779..f9a06668 100644 --- a/Tests/ContainedCoreTests/ImageWorkflowTests.swift +++ b/Tests/ContainedCoreTests/ImageWorkflowTests.swift @@ -125,6 +125,11 @@ struct ImageWorkflowTests { await #expect(throws: RegistryManifestError.notFound) { _ = try await RegistryManifestClient(session: notFound).remoteDigest(for: "nginx") } + + let status = RegistryManifestError.httpStatus(500) + #expect(status.packageName == "ContainedCore") + #expect(status.packageErrorCode == "registryHTTPStatus") + #expect(status.packageErrorContext["status"] == "500") } private static func session(_ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)) -> URLSession { diff --git a/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift b/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift index 251a7c32..e70e046a 100644 --- a/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift +++ b/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift @@ -18,6 +18,23 @@ struct AppleContainerRuntimeTests { #expect(throws: UnsupportedRuntimeCapability.self) { try descriptor.require(.imageBuild) } + do { + try descriptor.require(.imageBuild) + } catch let error as UnsupportedRuntimeCapability { + #expect(error.packageName == "ContainedRuntime") + #expect(error.packageErrorCode == "unsupportedRuntimeCapability") + #expect(error.packageErrorContext["kind"] == "future-engine") + } + } + + @Test func commandErrorsExposePackageCodesAndContext() { + let error = CommandError.nonZeroExit(code: 42, stderr: "boom", command: "container list") + + #expect(error.packageName == "ContainedRuntime") + #expect(error.packageErrorCode == "nonZeroExit") + #expect(error.packageErrorContext["code"] == "42") + #expect(error.packageErrorContext["stderr"] == "boom") + #expect(error.packageErrorContext["command"] == "container list") } @Test func appleCLIVersionParsingAndSupport() { diff --git a/changes/unreleased/20260702-localization-boundary.md b/changes/unreleased/20260702-localization-boundary.md index 047161c8..6664b69e 100644 --- a/changes/unreleased/20260702-localization-boundary.md +++ b/changes/unreleased/20260702-localization-boundary.md @@ -1,3 +1,4 @@ ## Changed - Routed design-system and navigation package copy through app-supplied strings so reusable packages stay language-light while the app is ready for English-only localization support. +- Added a display-neutral package error contract so Core/Runtime throw typed codes and context while the app owns localized error messages, alerts, and Activity entries. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 7ceeadcf..3278b3a1 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -26,6 +26,13 @@ package parameters and routes reusable enum labels/dynamic templates through language-free unless they expose technical identifiers such as raw values, runtime descriptors, or command output. +Package errors follow the same ownership boundary. Core/runtime packages expose +stable codes and compact context through `ContainedPackageError`; the app maps +those failures through `AppErrorPresentation` and `AppText` before showing +toasts, inline errors, alerts, or Activity history. Arbitrary backend stderr is +preserved as runtime-provided detail unless an adapter maps it to a known typed +case. + Package-local docs: - [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) @@ -39,6 +46,7 @@ remains the source of truth for builds, tests, and release scripts. - **`ContainerCommands`** — pure argv builders, side-effect-free so golden tests assert the exact arguments (the "Reveal CLI" affordances read from the same source of truth). - **`CommandRunner`** — shared command-execution primitive used by CLI-backed adapters. It runs one-shot commands (`run`) or streaming commands (`stream`, an `AsyncThrowingStream`) at the requested priority. Passwords are piped via `--password-stdin`, never argv. +- **`ContainedPackageError`** — display-neutral error metadata shared by reusable packages. It gives the app a package name, stable code, and context without forcing packages to own localized copy. - **`AppleContainerClient`** — the Apple `container` implementation of `ContainerRuntimeClient`; returns decoded models and maps decode failures to a single `CommandError`. - **`ContainerStatsTableParser`** — Apple-adapter parser for the ANSI table emitted by `container stats --format table`. It converts table frames into runtime-agnostic snapshots inside `AppleContainerRuntime`. - **`ContainerRuntimeClient`** — the backend-facing operation contract. `RuntimeDescriptor`, open-ended `RuntimeKind`, and `RuntimeCapability` advertise what a selected runtime can do before adapter-specific UI routes enable a command. See [[Runtime Adapters|Runtime-Adapters]]. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index 3d1fcf24..a434730d 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -83,6 +83,12 @@ appcast.xml Sparkle feed at the root of each release branch component needs text, add an explicit parameter and pass app-owned strings from `Sources/Contained`; reusable enum labels and dynamic templates should flow through `AppText` with English fallbacks. +- **The app owns package error presentation.** Reusable targets should throw + typed errors with stable codes/context, preferably `ContainedPackageError`. + Map those failures through `AppErrorPresentation`/`AppText` in + `Sources/Contained` before showing toasts, inline errors, alerts, or Activity + entries. Preserve arbitrary backend stderr as runtime detail unless an adapter + can classify it as a known typed case. - **Package docs live with the package.** Keep package-local import/setup/examples in [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) and [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md), with DocC landing pages under each target's `.docc` catalog. Keep `docs/wiki` focused on app-level architecture and workflow guidance. - **Xcode opens the workspace.** `Contained.xcworkspace` points at the root and local package manifests. SwiftPM package manifests remain the build graph source of truth; do not hand-maintain generated `.xcodeproj` state. - **Navigation infrastructure belongs in `ContainedNavigation` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in the executable target until they can cross the boundary without app policy. diff --git a/docs/wiki/Localization.md b/docs/wiki/Localization.md index 5fa4ebd1..a810dec5 100644 --- a/docs/wiki/Localization.md +++ b/docs/wiki/Localization.md @@ -17,6 +17,14 @@ adding an English default in the package. Examples include action help, close labels, search clear labels, page-control titles, selection-count text, and color/tint display names. +Package failures follow the same ownership rule. Reusable targets should throw +typed errors with stable codes/context, usually by conforming to +`ContainedPackageError`. The app maps those errors through +`AppErrorPresentation` and `AppText`, then decides whether to show a toast, +inline error, alert, or Activity entry. Do not attempt to localize arbitrary +backend stderr; preserve it as runtime-provided detail unless an adapter can map +it to a known typed case. + ## App Strings Use `AppText` for reusable app-owned labels and dynamic templates: @@ -37,6 +45,15 @@ TintSelector(selection: $settings.accentTint) { DesignSelectionActionBar(count: selection.count, countLabel: AppText.selectedCount, actions: actions) + +do { + try await runtime.performSystemAction("start") +} catch { + app.flash(error.appDisplayMessage) + app.logger.recordFailure("Start service failed", + error: error, + category: .system) +} ``` Plain SwiftUI literals such as `Text("Settings")`, `Button("Refresh")`, and diff --git a/docs/wiki/Runtime-Adapters.md b/docs/wiki/Runtime-Adapters.md index 9fadab65..d847e690 100644 --- a/docs/wiki/Runtime-Adapters.md +++ b/docs/wiki/Runtime-Adapters.md @@ -30,6 +30,21 @@ define their own stable identifiers without editing the shared runtime target. Use `RuntimeCapability` and `RuntimeDescriptor` to advertise support before a UI route enables a command. +## Error Boundary + +Runtime and core packages throw typed errors. They expose stable package names, +error codes, and machine-readable context through `ContainedPackageError`; they +do not decide how those failures are displayed to users. + +The app target maps package errors through `AppErrorPresentation` and `AppText`. +That keeps toast copy, inline messages, alerts, and Activity history wording in +`Sources/Contained`, while reusable packages remain suitable for other hosts. +Backend stderr is not translated wholesale; non-zero CLI output is preserved as +runtime-provided detail unless the adapter maps it to a known typed case. + +When adding a new adapter or package error, prefer a specific error case with a +stable `packageErrorCode` over throwing a preformatted English sentence. + ## Stats The shared runtime protocol exposes typed `RuntimeStatsSnapshot` batches from From 759b2734f87e19346376b9de1cacb7c672e01433 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 07:22:29 +0100 Subject: [PATCH 20/53] Tighten navigation and design package boundaries --- Packages/ContainedDesignSystem/README.md | 27 +++++++-- .../ContainedDesignSystem.md | 20 +++++++ .../ContainedDesignSystem/DesignActions.swift | 51 ++++++++++++++++ Packages/ContainedNavigation/README.md | 32 ++++++---- .../ContainedNavigation.md | 32 ++++++---- .../MorphSourceFrames.swift | 58 +++++++++++++++++++ .../MorphingSingleSurface.swift | 4 -- .../Features/Images/RegistryImageSearch.swift | 20 +++---- .../Features/System/SystemView.swift | 2 +- Sources/Contained/History/ActivityView.swift | 2 +- Sources/Contained/Navigation/AppToolbar.swift | 49 ++-------------- docs/wiki/Design-System.md | 5 +- 12 files changed, 211 insertions(+), 91 deletions(-) create mode 100644 Packages/ContainedNavigation/Sources/ContainedNavigation/MorphSourceFrames.swift diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 47c52608..468012d7 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -45,10 +45,11 @@ This package currently depends only on platform frameworks available to a macOS helpers for all reusable glass treatment. - `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `SheetHeader`, and `PageScaffold` for app-neutral scaffolding. -- `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, - `DesignSelectionActionBar`, `DesignStatusBanner`, and toolbar controls for - package-owned command chrome. `GlassButton`, `GlassButtonItem`, and - `GlassButtonInputItem` are lower-level package composition pieces. +- `DesignActionGroup`, `DesignActionCluster`, `DesignInputCluster`, + `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, + `DesignStatusBanner`, and toolbar controls for package-owned command chrome. + `GlassButton`, `GlassButtonItem`, and `GlassButtonInputItem` are lower-level + package composition pieces. - `ResourceCard`, `ResourceCardPages`, `ResourceCardFooterChip`, `ResourceCardFooterButton`, `ResourceCardWidgetGroup`, `ResourceCardInsetSection`, and other `ResourceCard*` pieces for repeated card layouts and card-local controls. @@ -274,6 +275,24 @@ DesignActionGroup([ } ]) +DesignActionCluster { + Menu { + Button("All") {} + } label: { + DesignMenuActionLabel(systemName: "line.3.horizontal.decrease", + help: "Filter") + } + DesignActionItems([ + DesignAction(systemName: "checkmark.circle", help: "Mark read") {} + ]) +} + +DesignInputCluster { + Image(systemName: "magnifyingglass") + TextField("Search", text: $query) + .textFieldStyle(.plain) +} + DesignTextActionButton(title: "Import", systemName: "arrow.down.doc", prominence: .prominent, diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 155fe042..75449cf4 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -213,6 +213,24 @@ DesignActionGroup([ } ]) +DesignActionCluster { + Menu { + Button("All") {} + } label: { + DesignMenuActionLabel(systemName: "line.3.horizontal.decrease", + help: "Filter") + } + DesignActionItems([ + DesignAction(systemName: "checkmark.circle", help: "Mark read") {} + ]) +} + +DesignInputCluster { + Image(systemName: "magnifyingglass") + TextField("Search", text: $query) + .textFieldStyle(.plain) +} + DesignTextActionButton(title: "Import", systemName: "arrow.down.doc", prominence: .prominent, @@ -234,6 +252,8 @@ DesignGlassToggle(isOn: $following, - ``AppTint`` - ``ColorLayerBlendMode`` - ``GlassButtonTintStyle`` +- ``DesignActionCluster`` +- ``DesignInputCluster`` ### Surfaces and Scaffolds diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift index 78581065..12fea505 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift @@ -78,6 +78,57 @@ public struct DesignActionGroup: View { } } +/// Package-owned glass cluster for mixed content, such as a menu plus action items. +public struct DesignActionCluster: View { + public var spacing: CGFloat + public var height: CGFloat + public var minWidth: CGFloat? + public var singleItem: Bool? + public var interactive: Bool + @ViewBuilder public var content: () -> Content + + public init(spacing: CGFloat = 0, + height: CGFloat = Tokens.Toolbar.buttonGroupHeight, + minWidth: CGFloat? = nil, + singleItem: Bool? = nil, + interactive: Bool = true, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.height = height + self.minWidth = minWidth + self.singleItem = singleItem + self.interactive = interactive + self.content = content + } + + public var body: some View { + GlassButton(spacing: spacing, + height: height, + minWidth: minWidth, + singleItem: singleItem ?? false, + interactive: interactive) { + content() + } + } +} + +/// Package-owned input cluster for search fields and compact inline controls. +public struct DesignInputCluster: View { + @ViewBuilder public var content: () -> Content + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + GlassButton(singleItem: true) { + GlassButtonInputItem { + content() + } + } + } +} + /// Package-owned action item renderer for mixed groups that also contain menus or status labels. public struct DesignActionItems: View { public var actions: [DesignAction] diff --git a/Packages/ContainedNavigation/README.md b/Packages/ContainedNavigation/README.md index 05648210..65b02cfb 100644 --- a/Packages/ContainedNavigation/README.md +++ b/Packages/ContainedNavigation/README.md @@ -37,6 +37,8 @@ import ContainedNavigation - `MorphingSingleSurface` and `MorphingSingleSurfaceExpander` for card-like overlays that grow from one existing source slot into one target rect without a handoff panel. +- `MorphSourceFrameReader` and `MorphSourceFramesKey` for measuring source + frames in a named coordinate space without app-local preference keys. - `MorphPanelScaffold` for generic fixed chrome, scrollable content, and pinned footer layout inside a morph panel. @@ -65,11 +67,9 @@ struct NavigationPackageExample: View { var body: some View { ZStack(alignment: .topLeading) { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "plus", help: "Add") { - isPresented = true - } - } + DesignActionGroup(DesignAction(systemName: "plus", help: "Add") { + isPresented = true + }) .padding(Tokens.Space.l) MorphingExpander(isPresented: $isPresented, @@ -79,13 +79,11 @@ struct NavigationPackageExample: View { PanelHeader(symbol: "plus", title: "Add", subtitle: "Choose a starting point") { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "xmark", - help: "Close", - isCancel: true) { - isPresented = false - } - } + DesignActionGroup(DesignAction(systemName: "xmark", + help: "Close", + isCancel: true) { + isPresented = false + }) } Divider() } content: { @@ -143,6 +141,16 @@ details. Use `AppMorphTarget.anchored(size:)` when a panel should grow from and stay near its source control. Both paths use `AppSafeAreaManager` to avoid toolbar bands and system insets. +For real toolbar sources, measure source controls in the same named coordinate +space that hosts the morph overlay: + +```swift +Button("Open") { isPresented = true } + .background(MorphSourceFrameReader("settings", + coordinateSpaceName: "toolbar")) + .onPreferenceChange(MorphSourceFramesKey.self) { frames = $0 } +``` + ## Documentation - DocC landing page: diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md b/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md index c265e230..132fb861 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md @@ -38,11 +38,9 @@ struct NavigationPackageExample: View { var body: some View { ZStack(alignment: .topLeading) { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "plus", help: "Add") { - isPresented = true - } - } + DesignActionGroup(DesignAction(systemName: "plus", help: "Add") { + isPresented = true + }) .padding(Tokens.Space.l) MorphingExpander(isPresented: $isPresented, @@ -52,13 +50,11 @@ struct NavigationPackageExample: View { PanelHeader(symbol: "plus", title: "Add", subtitle: "Choose a starting point") { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "xmark", - help: "Close", - isCancel: true) { - isPresented = false - } - } + DesignActionGroup(DesignAction(systemName: "xmark", + help: "Close", + isCancel: true) { + isPresented = false + }) } Divider() } content: { @@ -77,6 +73,16 @@ struct NavigationPackageExample: View { } ``` +Measure real toolbar sources with `MorphSourceFrameReader` in the same named +coordinate space that hosts the morph overlay: + +```swift +Button("Open") { isPresented = true } + .background(MorphSourceFrameReader("settings", + coordinateSpaceName: "toolbar")) + .onPreferenceChange(MorphSourceFramesKey.self) { frames = $0 } +``` + ## Topics ### Safe Areas @@ -99,4 +105,6 @@ struct NavigationPackageExample: View { - ``MorphingSingleSurfaceExpander`` - ``MorphFrame`` - ``MorphPanelScaffold`` +- ``MorphSourceFrameReader`` +- ``MorphSourceFramesKey`` - ``GlobalBackdropStyle`` diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphSourceFrames.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphSourceFrames.swift new file mode 100644 index 00000000..5f1a0865 --- /dev/null +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphSourceFrames.swift @@ -0,0 +1,58 @@ +import SwiftUI + +public struct MorphSourceFrameReader: View { + public var ids: [ID] + public var coordinateSpaceName: String + + public init(_ id: ID, coordinateSpaceName: String) { + self.ids = [id] + self.coordinateSpaceName = coordinateSpaceName + } + + public init(_ ids: [ID], coordinateSpaceName: String) { + self.ids = ids + self.coordinateSpaceName = coordinateSpaceName + } + + public var body: some View { + GeometryReader { proxy in + let frame = proxy.frame(in: .named(coordinateSpaceName)) + Color.clear.preference( + key: MorphSourceFramesKey.self, + value: Dictionary(uniqueKeysWithValues: ids.map { ($0, frame) }) + ) + } + } +} + +public struct MorphSourceFramesKey: PreferenceKey { + public static var defaultValue: [ID: CGRect] { [:] } + + public static func reduce(value: inout [ID: CGRect], + nextValue: () -> [ID: CGRect]) { + value.merge(nextValue()) { _, new in new } + } +} + +public extension Dictionary where Value == CGRect { + func isClose(to other: [Key: CGRect], tolerance: CGFloat = 0.5) -> Bool { + guard count == other.count else { return false } + return allSatisfy { key, frame in + guard let otherFrame = other[key] else { return false } + return frame.isClose(to: otherFrame, tolerance: tolerance) + } + } +} + +public extension CGRect { + var isUsableForMorph: Bool { + MorphGeometry.isUsableFrame(self) + } + + func isClose(to other: CGRect, tolerance: CGFloat = 0.5) -> Bool { + abs(minX - other.minX) <= tolerance && + abs(minY - other.minY) <= tolerance && + abs(width - other.width) <= tolerance && + abs(height - other.height) <= tolerance + } +} diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift index 1624df8d..02cfc58d 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift @@ -189,10 +189,6 @@ public extension MorphGeometry { } public extension CGRect { - var isUsableForMorph: Bool { - MorphGeometry.isUsableFrame(self) - } - func morphInterpolated(to target: CGRect, progress: CGFloat) -> CGRect { MorphGeometry.interpolatedRect(from: self, to: target, progress: progress) } diff --git a/Sources/Contained/Features/Images/RegistryImageSearch.swift b/Sources/Contained/Features/Images/RegistryImageSearch.swift index d5c3ff14..314ead98 100644 --- a/Sources/Contained/Features/Images/RegistryImageSearch.swift +++ b/Sources/Contained/Features/Images/RegistryImageSearch.swift @@ -39,17 +39,15 @@ struct RegistryImageSearch: View { // MARK: Search field private var searchField: some View { - GlassButton { - GlassButtonInputItem { - Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField("Search Docker Hub…", text: $query) - .textFieldStyle(.plain) - .onSubmit { searchNow() } - if searching { ProgressView().controlSize(.small) } - else if !query.isEmpty { - Button { query = "" } label: { Image(systemName: "xmark.circle.fill") } - .buttonStyle(.plain).foregroundStyle(.secondary) - } + DesignInputCluster { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search Docker Hub…", text: $query) + .textFieldStyle(.plain) + .onSubmit { searchNow() } + if searching { ProgressView().controlSize(.small) } + else if !query.isEmpty { + Button { query = "" } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain).foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/Contained/Features/System/SystemView.swift b/Sources/Contained/Features/System/SystemView.swift index 662d15a9..72886f57 100644 --- a/Sources/Contained/Features/System/SystemView.swift +++ b/Sources/Contained/Features/System/SystemView.swift @@ -134,7 +134,7 @@ struct SystemContent: View { subtitle: activePage.subtitle) { HStack(spacing: Tokens.Toolbar.groupSpacing) { engineControls - GlassButton { + DesignActionCluster { DesignActionItems(pageActions) storageMenu if showClose { diff --git a/Sources/Contained/History/ActivityView.swift b/Sources/Contained/History/ActivityView.swift index c37f5805..cfa57e68 100644 --- a/Sources/Contained/History/ActivityView.swift +++ b/Sources/Contained/History/ActivityView.swift @@ -89,7 +89,7 @@ struct ActivityContent: View { PanelHeader(symbol: "bell", title: "Activity", subtitle: subtitle) { - GlassButton { + DesignActionCluster { filterMenu DesignActionItems(activityHeaderActions) } diff --git a/Sources/Contained/Navigation/AppToolbar.swift b/Sources/Contained/Navigation/AppToolbar.swift index 39785c3b..f9813dce 100644 --- a/Sources/Contained/Navigation/AppToolbar.swift +++ b/Sources/Contained/Navigation/AppToolbar.swift @@ -66,7 +66,7 @@ struct AppToolbar: View { .zIndex(toolbarImageDetail == nil ? 0 : 350) } .coordinateSpace(.named(Self.space)) - .onPreferenceChange(ToolbarSlotKey.self) { updateSlots($0) } + .onPreferenceChange(MorphSourceFramesKey.self) { updateSlots($0) } .onChange(of: ui.activeMorph) { _, morph in if morph == nil { setMorphBackdropExpanded(false) } } @@ -115,12 +115,7 @@ struct AppToolbar: View { ToolbarSearchSource() .frame(width: Tokens.Toolbar.searchMaxWidth, height: Tokens.Toolbar.controlHeight) .opacity(ui.activeMorph == .palette ? 0 : 1) - .background( - GeometryReader { proxy in - Color.clear.preference(key: ToolbarSlotKey.self, - value: [.palette: proxy.frame(in: .named(Self.space))]) - } - ) + .background(singleSlotReader(.palette)) } @ViewBuilder @@ -377,18 +372,11 @@ struct AppToolbar: View { /// Report one shared frame (the cluster capsule) as the morph origin for several morphs at once. private func clusterSlotReader(_ morphs: [UIState.ToolbarMorph]) -> some View { - GeometryReader { proxy in - let frame = proxy.frame(in: .named(Self.space)) - Color.clear.preference(key: ToolbarSlotKey.self, - value: Dictionary(uniqueKeysWithValues: morphs.map { ($0, frame) })) - } + MorphSourceFrameReader(morphs, coordinateSpaceName: Self.space) } private func singleSlotReader(_ morph: UIState.ToolbarMorph) -> some View { - GeometryReader { proxy in - Color.clear.preference(key: ToolbarSlotKey.self, - value: [morph: proxy.frame(in: .named(Self.space))]) - } + MorphSourceFrameReader(morph, coordinateSpaceName: Self.space) } /// Safe area for a morph panel. Bottom-row panels clear the top toolbar; top-row panels clear the @@ -514,32 +502,3 @@ private struct ActivityToolbarButton: View { ]) } } - -/// Collects toolbar button slot frames (in the toolbar coordinate space) so a morph can grow from the -/// exact button that opened it. -private struct ToolbarSlotKey: PreferenceKey { - static let defaultValue: [UIState.ToolbarMorph: CGRect] = [:] - static func reduce(value: inout [UIState.ToolbarMorph: CGRect], - nextValue: () -> [UIState.ToolbarMorph: CGRect]) { - value.merge(nextValue()) { _, new in new } - } -} - -private extension Dictionary where Key == UIState.ToolbarMorph, Value == CGRect { - func isClose(to other: [UIState.ToolbarMorph: CGRect], tolerance: CGFloat = 0.5) -> Bool { - guard count == other.count else { return false } - return allSatisfy { key, frame in - guard let otherFrame = other[key] else { return false } - return frame.isClose(to: otherFrame, tolerance: tolerance) - } - } -} - -private extension CGRect { - func isClose(to other: CGRect, tolerance: CGFloat = 0.5) -> Bool { - abs(minX - other.minX) <= tolerance && - abs(minY - other.minY) <= tolerance && - abs(width - other.width) <= tolerance && - abs(height - other.height) <= tolerance - } -} diff --git a/docs/wiki/Design-System.md b/docs/wiki/Design-System.md index 8fdbd8b8..331dfe54 100644 --- a/docs/wiki/Design-System.md +++ b/docs/wiki/Design-System.md @@ -139,6 +139,8 @@ safe-area contract as morph panels, clearing both top and bottom toolbar bands. Use package-owned semantic controls for command chrome: - `DesignActionGroup` and `DesignActionItems` for icon action groups +- `DesignActionCluster` for mixed menu/action capsules +- `DesignInputCluster` for inline search/input glass lanes - `DesignTextActionButton` for labeled standard or prominent actions - `DesignGlassToggle` for glass toggle buttons - `DesignSelectionActionBar` for floating selection bars @@ -218,7 +220,8 @@ form widths, chart sizes, badge/keycap insets, resource-card opacities, terminal chrome, and shadows. Feature views should not call low-level surface modifiers or glass button styles; use named package routes such as `ResourceCard`, `PanelSection`, `DesignContentSurface`, `DesignInputSurface`, -`DesignActionGroup`, `DesignTextActionButton`, and `ResourceCardInsetSection`. +`DesignActionGroup`, `DesignActionCluster`, `DesignInputCluster`, +`DesignTextActionButton`, and `ResourceCardInsetSection`. If a new visual value appears, add a token or package primitive before using it in the app. From 3334ef27c913d10901633203a6c149446e490940 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 07:44:02 +0100 Subject: [PATCH 21/53] Add runtime-neutral create translation foundation --- .../AppleContainerClient.swift | 18 ++ .../AppleContainerCreateTranslator.swift | 157 ++++++++++++ .../Features/Containers/ComposeImport.swift | 33 +-- .../Containers/ContainerConfigureView.swift | 2 +- .../Features/Containers/RunSpec.swift | 228 ++++++++---------- .../Features/Containers/RunSpecForm.swift | 26 +- .../Containers/RunSpecFormSupport.swift | 44 +--- Sources/Contained/Stores/AppModel.swift | 52 +++- .../Contained/Stores/ContainersStore.swift | 20 +- Sources/Contained/Stores/SettingsBackup.swift | 10 +- Sources/Contained/Stores/SettingsStore.swift | 8 +- .../Contained/Support/AppLocalization.swift | 19 ++ .../Models/ContainerCreateRequest.swift | 198 +++++++++++++++ .../ContainedCore/Models/RuntimeKind.swift | 17 ++ .../Services/ContainerCommands.swift | 54 +++++ .../ContainedRuntime/RuntimeDescriptor.swift | 56 +++-- .../ContainedRuntime/RuntimeTranslation.swift | 58 +++++ Tests/ContainedAppTests/RunSpecTests.swift | 68 ++++-- .../AppleContainerRuntimeTests.swift | 56 +++++ 19 files changed, 881 insertions(+), 243 deletions(-) create mode 100644 Sources/AppleContainerRuntime/AppleContainerCreateTranslator.swift create mode 100644 Sources/ContainedCore/Models/ContainerCreateRequest.swift create mode 100644 Sources/ContainedCore/Models/RuntimeKind.swift create mode 100644 Sources/ContainedRuntime/RuntimeTranslation.swift diff --git a/Sources/AppleContainerRuntime/AppleContainerClient.swift b/Sources/AppleContainerRuntime/AppleContainerClient.swift index 4892ef6d..da9f9207 100644 --- a/Sources/AppleContainerRuntime/AppleContainerClient.swift +++ b/Sources/AppleContainerRuntime/AppleContainerClient.swift @@ -94,6 +94,24 @@ public struct AppleContainerClient: Sendable { try await decode(SystemStatus.self, ContainerCommands.systemStatus, "system status") } + public func previewCreateCommand(for request: ContainerCreateRequest) throws -> RuntimeCommandPreview { + AppleContainerCreateTranslator.preview(for: request) + } + + @discardableResult public func createContainer(_ request: ContainerCreateRequest) async throws -> ContainerCreateResult { + let data = try await runner.run(ContainerCommands.run(request)) + return AppleContainerCreateTranslator.result(from: data, request: request) + } + + public func translateCompose(_ project: ComposeProject, baseDirectory: URL?) throws -> RuntimeComposeImportPlan { + AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: baseDirectory) + } + + public func imageDefaults(for request: ContainerCreateRequest, + in images: [ImageResource]) throws -> ContainerImageDefaults? { + AppleContainerCreateTranslator.imageDefaults(for: request, in: images) + } + public func networks() async throws -> [NetworkResource] { try await decode([NetworkResource].self, ContainerCommands.networkList(), "network list") } diff --git a/Sources/AppleContainerRuntime/AppleContainerCreateTranslator.swift b/Sources/AppleContainerRuntime/AppleContainerCreateTranslator.swift new file mode 100644 index 00000000..fb7e0d6c --- /dev/null +++ b/Sources/AppleContainerRuntime/AppleContainerCreateTranslator.swift @@ -0,0 +1,157 @@ +import Foundation +import ContainedCore +import ContainedRuntime + +public enum AppleContainerCreateTranslator { + public static func preview(for request: ContainerCreateRequest) -> RuntimeCommandPreview { + RuntimeCommandPreview(command: ContainerCommands.run(request)) + } + + public static func result(from data: Data, request: ContainerCreateRequest) -> ContainerCreateResult { + let output = String(decoding: data, as: UTF8.self) + let printedID = output + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .last(where: { !$0.isEmpty }) + return ContainerCreateResult(id: request.effectiveName ?? printedID, output: output) + } + + public static func composePlan(for project: ComposeProject, + baseDirectory: URL?) -> RuntimeComposeImportPlan { + let items = project.services.compactMap { service -> RuntimeComposeImportItem? in + guard service.image != nil else { return nil } + return RuntimeComposeImportItem( + request: createRequest(for: service, projectName: project.name, baseDirectory: baseDirectory), + healthCheck: healthCheck(for: service) + ) + } + return RuntimeComposeImportPlan(items: items, warnings: project.warnings) + } + + public static func imageDefaults(for request: ContainerCreateRequest, + in images: [ImageResource]) -> ContainerImageDefaults? { + guard let image = matchingImage(for: request.image, in: images) else { return nil } + let runnable = image.variants.filter(\.isRunnable) + let platformMatch = runnable.first { variant in + !request.platform.isEmpty && variant.platform.display == request.platform + } + #if arch(arm64) + let hostMatch = runnable.first { $0.platform.os == "linux" && $0.platform.architecture == "arm64" } + #else + let hostMatch = runnable.first { $0.platform.os == "linux" && $0.platform.architecture == "amd64" } + #endif + guard let config = (platformMatch ?? hostMatch ?? runnable.first)?.config?.config else { return nil } + return ContainerImageDefaults( + command: config.cmd ?? [], + entrypoint: config.entrypoint ?? [], + workingDirectory: config.workingDir, + user: config.user, + environment: (config.env ?? []).compactMap(keyValue) + ) + } + + private static func createRequest(for service: ComposeService, + projectName: String, + baseDirectory: URL?) -> ContainerCreateRequest { + var request = ContainerCreateRequest() + request.runtimeKind = .appleContainer + request.image = service.image ?? "" + request.platform = service.platform ?? "" + request.name = service.name + request.command = splitCommand(service.command) + request.entrypoint = service.entrypoint ?? "" + request.detach = true + request.interactive = service.interactive + request.tty = service.tty + request.restart = RestartPolicy(label: service.restart) + request.cpus = service.cpus ?? "" + request.memory = service.memory ?? "" + request.readOnly = service.readOnly + request.useInit = service.initProcess + request.workingDir = service.workingDir ?? "" + request.user = service.user ?? "" + request.capAdd = service.capAdd + request.capDrop = service.capDrop + request.network = service.network ?? "" + request.dns = service.dns + request.dnsSearch = service.dnsSearch + request.dnsOption = service.dnsOptions + request.tmpfs = service.tmpfs + request.ulimits = service.ulimits + request.ports = service.ports.compactMap(portMap) + request.volumes = service.volumes.compactMap { volumeMap($0, baseDirectory: baseDirectory) } + request.env = service.environment.compactMap(keyValue) + request.envFiles = service.envFiles + request.labels = service.labels.compactMap(keyValue) + request.labels.append(ContainerCreateKeyValue(key: "contained.stack", value: projectName)) + return request + } + + private static func healthCheck(for service: ComposeService) -> HealthCheck? { + guard let healthcheck = service.healthcheck else { return nil } + return HealthCheck(command: healthcheck.test, + intervalSeconds: healthcheck.intervalSeconds, + retries: healthcheck.retries, + enabled: true) + } + + private static func splitCommand(_ command: String?) -> [String] { + guard let command else { return [] } + return command.split(separator: " ").map(String.init) + } + + private static func portMap(_ spec: String) -> ContainerCreatePort? { + var raw = spec + let proto: String + if let slash = raw.lastIndex(of: "/") { + proto = String(raw[raw.index(after: slash)...]) + raw = String(raw[..= 2 else { return nil } + let host = parts.dropLast().joined(separator: ":") + let container = parts[parts.count - 1] + guard !host.isEmpty, !container.isEmpty else { return nil } + return ContainerCreatePort(hostPort: host, containerPort: container, proto: proto) + } + + private static func volumeMap(_ spec: String, baseDirectory: URL?) -> ContainerCreateVolume? { + let parts = spec.split(separator: ":", maxSplits: 2).map(String.init) + guard parts.count > 1 else { return nil } + var source = parts.first ?? "" + if let baseDirectory, source.hasPrefix("./") || source.hasPrefix("../") { + source = baseDirectory.appending(path: source).standardizedFileURL.path + } + return ContainerCreateVolume(source: source, + target: parts.count > 1 ? parts[1] : "", + readOnly: parts.count > 2 && parts[2] == "ro") + } + + private static func keyValue(_ entry: String) -> ContainerCreateKeyValue? { + guard let eq = entry.firstIndex(of: "=") else { return nil } + return ContainerCreateKeyValue(key: String(entry[.. ImageResource? { + let target = normalizedImageReference(reference) + return images.first { normalizedImageReference($0.reference) == target } + } + + private static func normalizedImageReference(_ reference: String) -> String { + let short = shortImage(reference.trimmingCharacters(in: .whitespaces)) + let nameStart = short.lastIndex(of: "/").map { short.index(after: $0) } ?? short.startIndex + let namePart = short[nameStart...] + if namePart.contains(":") || namePart.contains("@") { return short } + return short + ":latest" + } + + private static func shortImage(_ reference: String) -> String { + let prefixes = ["docker.io/library/", "docker.io/"] + return prefixes.reduce(reference) { value, prefix in + value.hasPrefix(prefix) ? String(value.dropFirst(prefix.count)) : value + } + } +} diff --git a/Sources/Contained/Features/Containers/ComposeImport.swift b/Sources/Contained/Features/Containers/ComposeImport.swift index 428a9b7d..b06f6030 100644 --- a/Sources/Contained/Features/Containers/ComposeImport.swift +++ b/Sources/Contained/Features/Containers/ComposeImport.swift @@ -1,5 +1,6 @@ import AppKit import ContainedCore +import ContainedRuntime /// Import a `compose.yaml` without a dedicated page: pick the file, translate each service with an /// image into a `RunSpec`, pull the images, then open a prefilled New-Container editor per service @@ -37,24 +38,25 @@ enum ComposeImport { baseDirectory: URL? = nil, app: AppModel, ui: UIState) { do { let parsed = try ComposeParser.parse(text, projectName: projectName) - var specs: [RunSpec] = [] - for service in parsed.services where service.image != nil { - var spec = RunSpec(service: service, projectName: parsed.name) - if let baseDirectory { - spec.volumes = spec.volumes.map { resolveRelativeVolume($0, baseDirectory: baseDirectory) } - } - specs.append(spec) + guard let client = app.client else { + app.flash(AppText.containerRuntimeNotReady) + app.logger.record("Compose import \(parsed.name) could not start because no runtime is available", + category: .compose, + severity: .warning) + return } + let plan = try client.translateCompose(parsed, baseDirectory: baseDirectory) + let specs = plan.items.map { RunSpec(request: $0.request, healthCheck: $0.healthCheck) } guard !specs.isEmpty else { - app.flash("No services with an image to import.") + app.flash(AppText.composeNoServicesWithImages) app.logger.record("Compose import \(parsed.name) had no services with images", category: .compose, severity: .warning) return } - if !parsed.warnings.isEmpty { - app.flash("Some compose keys weren't translated — review each container before creating.") - app.logger.record("Compose import \(parsed.name) produced \(parsed.warnings.count) warning\(parsed.warnings.count == 1 ? "" : "s")", + if !plan.warnings.isEmpty { + app.flash(AppText.composeWarnings) + app.logger.record("Compose import \(parsed.name) produced \(plan.warnings.count) warning\(plan.warnings.count == 1 ? "" : "s")", category: .compose, severity: .warning) } @@ -69,13 +71,4 @@ enum ComposeImport { severity: .error) } } - - /// Docker Compose resolves relative bind sources from the compose file's directory. The runtime - /// receives `container run` from the app process, so make those sources absolute at import time. - static func resolveRelativeVolume(_ volume: VolumeMap, baseDirectory: URL) -> VolumeMap { - guard volume.source.hasPrefix("./") || volume.source.hasPrefix("../") else { return volume } - var resolved = volume - resolved.source = baseDirectory.appending(path: volume.source).standardizedFileURL.path - return resolved - } } diff --git a/Sources/Contained/Features/Containers/ContainerConfigureView.swift b/Sources/Contained/Features/Containers/ContainerConfigureView.swift index b71a453c..20ead7a4 100644 --- a/Sources/Contained/Features/Containers/ContainerConfigureView.swift +++ b/Sources/Contained/Features/Containers/ContainerConfigureView.swift @@ -55,7 +55,7 @@ struct ContainerConfigureView: View { .padding(Tokens.Space.s) } footer: { if app.settings.revealCLI { - CommandPreviewBar(command: spec.arguments(), + CommandPreviewBar(command: app.previewCreateCommand(for: spec), copyHelp: AppText.copyCommand, copiedAccessibilityLabel: AppText.copied) .padding(Tokens.Space.s) diff --git a/Sources/Contained/Features/Containers/RunSpec.swift b/Sources/Contained/Features/Containers/RunSpec.swift index f07f8c8c..8eade415 100644 --- a/Sources/Contained/Features/Containers/RunSpec.swift +++ b/Sources/Contained/Features/Containers/RunSpec.swift @@ -53,6 +53,7 @@ struct SocketMap: Identifiable, Hashable, Codable { /// The complete state of the Create/Run form. Knows how to render itself as a `container run` argv. struct RunSpec: Codable { + var runtimeKind: RuntimeKind? = .appleContainer var image = "" var platform = "" var name = "" @@ -221,136 +222,113 @@ struct RunSpec: Codable { /// Build the `container run …` argument vector. Single source of truth for the live preview /// and the actual execution. func arguments() -> [String] { - var args = ["run"] - if detach { args.append("--detach") } - if removeOnExit { args.append("--rm") } - if interactive { args.append("--interactive") } - if tty { args.append("--tty") } - if !name.isEmpty { args += ["--name", name] } - if !cpus.isEmpty { args += ["--cpus", cpus] } - if !memory.isEmpty { args += ["--memory", memory] } - if !entrypoint.isEmpty { args += ["--entrypoint", entrypoint] } - if readOnly { args.append("--read-only") } - if useInit { args.append("--init") } - if rosetta { args.append("--rosetta") } - if ssh { args.append("--ssh") } - if virtualization { args.append("--virtualization") } - if !platform.isEmpty { args += ["--platform", platform] } - - // Advanced - if !workingDir.isEmpty { args += ["--workdir", workingDir] } - if !user.isEmpty { args += ["--user", user] } - if !uid.isEmpty { args += ["--uid", uid] } - if !gid.isEmpty { args += ["--gid", gid] } - if !shmSize.isEmpty { args += ["--shm-size", shmSize] } - for cap in capAdd where !cap.isEmpty { args += ["--cap-add", cap] } - for cap in capDrop where !cap.isEmpty { args += ["--cap-drop", cap] } - if !cidFile.isEmpty { args += ["--cidfile", cidFile] } - if !initImage.isEmpty { args += ["--init-image", initImage] } - if !kernel.isEmpty { args += ["--kernel", kernel] } - if !network.isEmpty { args += ["--network", network] } - if noDNS { args.append("--no-dns") } - if !noDNS { - for server in dns where !server.isEmpty { args += ["--dns", server] } - if !dnsDomain.isEmpty { args += ["--dns-domain", dnsDomain] } - for domain in dnsSearch where !domain.isEmpty { args += ["--dns-search", domain] } - for option in dnsOption where !option.isEmpty { args += ["--dns-option", option] } - } - for mount in tmpfs where !mount.isEmpty { args += ["--tmpfs", mount] } - for limit in ulimits where !limit.isEmpty { args += ["--ulimit", limit] } - if !runtime.isEmpty { args += ["--runtime", runtime] } - if !scheme.isEmpty { args += ["--scheme", scheme] } - if !progress.isEmpty { args += ["--progress", progress] } - if !maxConcurrentDownloads.isEmpty { args += ["--max-concurrent-downloads", maxConcurrentDownloads] } - - for port in ports where port.isValid { args += ["--publish", port.spec] } - for volume in volumes where volume.isValid { args += ["--volume", volume.spec] } - for mount in mounts where !mount.isEmpty { args += ["--mount", mount] } - for socket in sockets where socket.isValid { args += ["--publish-socket", socket.spec] } - for file in envFiles where !file.isEmpty { args += ["--env-file", file] } - for variable in env where variable.isValid { args += ["--env", "\(variable.key)=\(variable.value)"] } - - for label in allLabels() { args += ["--label", label] } - - args.append(image) - let extra = command.split(separator: " ").map(String.init) - args += extra - return args + ContainerCommands.run(createRequest) } - /// Build a run spec from a compose service, tagged with `contained.stack` so the launched - /// containers retain their imported Compose group. - init(service: ComposeService, projectName: String) { - image = service.image ?? "" - platform = service.platform ?? "" - name = service.name - command = service.command ?? "" - entrypoint = service.entrypoint ?? "" - detach = true - interactive = service.interactive - tty = service.tty - restart = RestartPolicy(label: service.restart) - cpus = service.cpus ?? "" - memory = service.memory ?? "" - readOnly = service.readOnly - useInit = service.initProcess - workingDir = service.workingDir ?? "" - user = service.user ?? "" - capAdd = service.capAdd - capDrop = service.capDrop - network = service.network ?? "" - dns = service.dns - dnsSearch = service.dnsSearch - dnsOption = service.dnsOptions - tmpfs = service.tmpfs - ulimits = service.ulimits - ports = service.ports.compactMap(Self.portMap) - volumes = service.volumes.compactMap { spec in - let parts = spec.split(separator: ":", maxSplits: 2).map(String.init) - guard parts.count > 1 else { return nil } - return VolumeMap(source: parts.first ?? "", target: parts.count > 1 ? parts[1] : "", - readOnly: parts.count > 2 && parts[2] == "ro") - } - env = service.environment.compactMap { entry in - guard let eq = entry.firstIndex(of: "=") else { return nil } - return KeyValue(key: String(entry[.. PortMap? { - var raw = spec - let proto: String - if let slash = raw.lastIndex(of: "/") { - proto = String(raw[raw.index(after: slash)...]) - raw = String(raw[..= 2 else { return nil } - let host = parts.dropLast().joined(separator: ":") - let container = parts[parts.count - 1] - guard !host.isEmpty, !container.isEmpty else { return nil } - return PortMap(hostPort: host, containerPort: container, proto: proto) + var createRequest: ContainerCreateRequest { + var request = ContainerCreateRequest() + request.runtimeKind = effectiveRuntimeKind + request.image = image + request.platform = platform + request.name = name + request.command = command.split(separator: " ").map(String.init) + request.entrypoint = entrypoint + request.detach = detach + request.removeOnExit = removeOnExit + request.interactive = interactive + request.tty = tty + request.cpus = cpus + request.memory = memory + request.env = env.map { ContainerCreateKeyValue(key: $0.key, value: $0.value) } + request.envFiles = envFiles + request.ports = ports.map { ContainerCreatePort(hostPort: $0.hostPort, containerPort: $0.containerPort, proto: $0.proto) } + request.volumes = volumes.map { ContainerCreateVolume(source: $0.source, target: $0.target, readOnly: $0.readOnly) } + request.mounts = mounts + request.sockets = sockets.map { ContainerCreateSocket(hostPath: $0.hostPath, containerPath: $0.containerPath) } + request.labels = labels.map { ContainerCreateKeyValue(key: $0.key, value: $0.value) } + request.restart = restart + request.readOnly = readOnly + request.useInit = useInit + request.rosetta = rosetta + request.ssh = ssh + request.virtualization = virtualization + request.workingDir = workingDir + request.user = user + request.uid = uid + request.gid = gid + request.shmSize = shmSize + request.capAdd = capAdd + request.capDrop = capDrop + request.cidFile = cidFile + request.initImage = initImage + request.kernel = kernel + request.network = network + request.noDNS = noDNS + request.dns = dns + request.dnsDomain = dnsDomain + request.dnsSearch = dnsSearch + request.dnsOption = dnsOption + request.tmpfs = tmpfs + request.ulimits = ulimits + request.runtime = runtime + request.scheme = scheme + request.progress = progress + request.maxConcurrentDownloads = maxConcurrentDownloads + return request } - private static func keyValue(_ entry: String) -> KeyValue? { - guard let eq = entry.firstIndex(of: "=") else { return nil } - return KeyValue(key: String(entry[.. [String] { - var result: [String: String] = [:] - for label in labels where label.isValid { result[label.key] = label.value } - if restart != .no { result["contained.restart"] = restart.rawValue } - return result.map { "\($0.key)=\($0.value)" }.sorted() + var effectiveRuntimeKind: RuntimeKind { + runtimeKind ?? .appleContainer } } diff --git a/Sources/Contained/Features/Containers/RunSpecForm.swift b/Sources/Contained/Features/Containers/RunSpecForm.swift index a99c2e57..6256a2ab 100644 --- a/Sources/Contained/Features/Containers/RunSpecForm.swift +++ b/Sources/Contained/Features/Containers/RunSpecForm.swift @@ -52,12 +52,23 @@ struct RunSpecForm: View { private var generalSection: some View { Group { + PanelRow(title: AppText.runtimeCore, + subtitle: app.runtimeCoreSelectorIsEnabled ? AppText.runtimeCoreSubtitle : app.runtimeCoreSelectorDisabledReason) { + Picker("", selection: runtimeKindBinding) { + ForEach(app.availableRuntimeDescriptors, id: \.kind) { descriptor in + Text(descriptor.displayName).tag(descriptor.kind) + } + } + .labelsHidden() + .fixedSize() + .disabled(!app.runtimeCoreSelectorIsEnabled) + } PanelField(label: "Image", info: "The container image to start, such as `nginx:latest`. If it is not on this Mac yet, Contained pulls it before running.", error: spec.image.trimmingCharacters(in: .whitespaces).isEmpty ? "An image reference is required." : nil) { TextField("", text: $spec.image, prompt: Text("e.g. nginx:latest")).textFieldStyle(.roundedBorder) } - if imageDefaultConfig != nil { + if imageDefaults != nil { PanelRow(title: "Image defaults", subtitle: "Fill empty command, entrypoint, user, working directory, and environment fields from the pulled image config.", info: "Images can define default startup settings. Adopt copies those defaults into this form so you can see and edit them before running.") { @@ -149,13 +160,18 @@ struct RunSpecForm: View { set: { if $0 != "custom" { spec.platform = $0 } }) } - private var imageDefaultConfig: VariantConfig.OCIConfig? { - spec.imageDefaults(in: app.images) + private var imageDefaults: ContainerImageDefaults? { + app.imageDefaults(for: spec) + } + + private var runtimeKindBinding: Binding { + Binding(get: { spec.effectiveRuntimeKind }, + set: { spec.runtimeKind = $0 }) } private func adoptImageDefaults() { - guard let imageDefaultConfig else { return } - let applied = spec.adoptImageDefaults(from: imageDefaultConfig) + guard let imageDefaults else { return } + let applied = spec.adoptImageDefaults(from: imageDefaults) if applied > 0 { app.flash("Adopted \(applied) image default\(applied == 1 ? "" : "s")") } else { diff --git a/Sources/Contained/Features/Containers/RunSpecFormSupport.swift b/Sources/Contained/Features/Containers/RunSpecFormSupport.swift index b0aa64b7..f4fd70bb 100644 --- a/Sources/Contained/Features/Containers/RunSpecFormSupport.swift +++ b/Sources/Contained/Features/Containers/RunSpecFormSupport.swift @@ -44,60 +44,36 @@ extension RunSpec { return short + ":latest" } - func matchingImage(in images: [ContainedCore.ImageResource]) -> ContainedCore.ImageResource? { - let target = normalizedImageReference - return images.first { Self.normalizedImageReference($0.reference) == target } - } - - func imageDefaults(in images: [ContainedCore.ImageResource]) -> VariantConfig.OCIConfig? { - matchingImage(in: images).flatMap { imageDefaults(from: $0) } - } - - func imageDefaults(from image: ContainedCore.ImageResource) -> VariantConfig.OCIConfig? { - let runnable = image.variants.filter(\.isRunnable) - let platformMatch = runnable.first { variant in - !platform.isEmpty && variant.platform.display == platform - } - #if arch(arm64) - let hostMatch = runnable.first { $0.platform.os == "linux" && $0.platform.architecture == "arm64" } - #else - let hostMatch = runnable.first { $0.platform.os == "linux" && $0.platform.architecture == "amd64" } - #endif - return (platformMatch ?? hostMatch ?? runnable.first)?.config?.config - } - @discardableResult - mutating func adoptImageDefaults(from config: VariantConfig.OCIConfig) -> Int { + mutating func adoptImageDefaults(from defaults: ContainerImageDefaults) -> Int { var applied = 0 - if command.trimmingCharacters(in: .whitespaces).isEmpty, let cmd = config.cmd, !cmd.isEmpty { + if command.trimmingCharacters(in: .whitespaces).isEmpty, !defaults.command.isEmpty { + let cmd = defaults.command command = cmd.joined(separator: " ") applied += 1 } if entrypoint.trimmingCharacters(in: .whitespaces).isEmpty, - let entrypointValue = config.entrypoint, - !entrypointValue.isEmpty { + !defaults.entrypoint.isEmpty { + let entrypointValue = defaults.entrypoint entrypoint = entrypointValue.joined(separator: " ") applied += 1 } if workingDir.trimmingCharacters(in: .whitespaces).isEmpty, - let workingDirValue = config.workingDir, + let workingDirValue = defaults.workingDirectory, !workingDirValue.isEmpty { workingDir = workingDirValue applied += 1 } if user.trimmingCharacters(in: .whitespaces).isEmpty, - let userValue = config.user, + let userValue = defaults.user, !userValue.isEmpty { user = userValue applied += 1 } let existingEnvKeys = Set(env.map(\.key)) - for entry in config.env ?? [] { - guard let separator = entry.firstIndex(of: "=") else { continue } - let key = String(entry[.. 1 + } + var runtimeCoreSelectorDisabledReason: String { + AppText.string( + "runtime.coreSelector.disabledReason", + defaultValue: "Apple container is the only available core in this build. Additional cores will enable this selector." + ) + } /// One in-flight operation shown in the bottom progress bar. struct ActivityState: Equatable { @@ -216,6 +228,15 @@ final class AppModel { } } + func runtimeDescriptor(for kind: RuntimeKind) -> RuntimeDescriptor { + availableRuntimeDescriptors.first { $0.kind == kind } ?? .appleContainer + } + + func runtimeClient(for kind: RuntimeKind) -> (any ContainerRuntimeClient)? { + guard let client, client.descriptor.kind == kind else { return nil } + return client + } + func applyStatsNormalizationContext() { containers.configureStatsNormalization(statsNormalizationContext) } @@ -295,6 +316,16 @@ final class AppModel { await capturedError(work)?.appDisplayMessage } + func previewCreateCommand(for spec: RunSpec) -> [String] { + (try? runtimeClient(for: spec.effectiveRuntimeKind)?.previewCreateCommand(for: spec.createRequest).command) + ?? spec.arguments() + } + + func imageDefaults(for spec: RunSpec) -> ContainerImageDefaults? { + guard let client = runtimeClient(for: spec.effectiveRuntimeKind) else { return nil } + return try? client.imageDefaults(for: spec.createRequest, in: images) + } + /// Run a throwing action while preserving the original error for Activity/package metadata. func capturedError(_ work: () async throws -> Void) async -> Error? { do { @@ -438,7 +469,15 @@ final class AppModel { /// nothing while the image silently downloads. Attaches local style + healthcheck on success. @discardableResult func createContainer(_ spec: RunSpec) async -> String? { - guard client != nil else { return nil } + guard runtimeClient(for: spec.effectiveRuntimeKind) != nil else { + let error = UnsupportedRuntimeCapability(kind: spec.effectiveRuntimeKind, capability: .containers) + createError = error.appDisplayMessage + logger.recordFailure("Create requested unavailable runtime", + error: error, + category: .lifecycle, + severity: .warning) + return nil + } createError = nil if !(await imageIsLocal(spec.image)) { guard await pullImage(spec.image) else { @@ -462,7 +501,16 @@ final class AppModel { /// deleting the current container so an unavailable image does not strand the edit flow. @discardableResult func recreateContainer(originalID: String, spec: RunSpec) async -> String? { - guard client != nil else { return nil } + guard runtimeClient(for: spec.effectiveRuntimeKind) != nil else { + let error = UnsupportedRuntimeCapability(kind: spec.effectiveRuntimeKind, capability: .containers) + flash(error.appDisplayMessage) + logger.recordFailure("Recreate requested unavailable runtime", + error: error, + category: .lifecycle, + severity: .warning, + containerID: originalID) + return nil + } if !(await imageIsLocal(spec.image)) { guard await pullImage(spec.image) else { return nil } } diff --git a/Sources/Contained/Stores/ContainersStore.swift b/Sources/Contained/Stores/ContainersStore.swift index aad4bb97..2de4608d 100644 --- a/Sources/Contained/Stores/ContainersStore.swift +++ b/Sources/Contained/Stores/ContainersStore.swift @@ -3,7 +3,6 @@ import ContainedDesignSystem import OSLog import ContainedCore import ContainedRuntime -import ContainedRuntime /// Owns the container list and derived live stats. Lifecycle actions run through the client and /// trigger a refresh. Stats arrive from the app-wide runtime stream and are converted into deltas @@ -274,7 +273,10 @@ final class ContainersStore { severity: .info) diagnosticLogger.notice("Run started from creation flow") do { - let output = try await client.runContainer(arguments: spec.arguments()) + guard client.descriptor.kind == spec.effectiveRuntimeKind else { + throw UnsupportedRuntimeCapability(kind: spec.effectiveRuntimeKind, capability: .containers) + } + let result = try await client.createContainer(spec.createRequest) performHaptic() await refresh() let elapsed = Date().timeIntervalSince(started) @@ -283,12 +285,7 @@ final class ContainersStore { severity: elapsed >= 1.5 ? .warning : .info) diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, "Run finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s") - if !spec.name.isEmpty { return spec.name } - let printed = String(decoding: output, as: UTF8.self) - .components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespaces) } - .last(where: { !$0.isEmpty }) - return printed + return result.id } catch { errorMessage = error.appDisplayMessage let elapsed = Date().timeIntervalSince(started) @@ -314,9 +311,10 @@ final class ContainersStore { logger?.record("Recreating \(originalID)", category: .lifecycle, containerID: originalID) diagnosticLogger.notice("Recreate started for \(originalID, privacy: .public)") do { - _ = try? await client.stop([originalID]) // best-effort; may already be stopped - _ = try await client.deleteContainers([originalID], force: true) - _ = try await client.runContainer(arguments: spec.arguments()) + guard client.descriptor.kind == spec.effectiveRuntimeKind else { + throw UnsupportedRuntimeCapability(kind: spec.effectiveRuntimeKind, capability: .containers) + } + _ = try await client.recreateContainer(originalID: originalID, request: spec.createRequest) performHaptic() await refresh() let elapsed = Date().timeIntervalSince(started) diff --git a/Sources/Contained/Stores/SettingsBackup.swift b/Sources/Contained/Stores/SettingsBackup.swift index 27e2f004..88299741 100644 --- a/Sources/Contained/Stores/SettingsBackup.swift +++ b/Sources/Contained/Stores/SettingsBackup.swift @@ -70,11 +70,11 @@ struct SettingsBackup: Codable, Equatable { cardMaterial: WindowMaterial, showInfoTips: Bool, imageDefaultStyleEnabled: Bool, - keepInMenuBar: Bool, - cliPathOverride: String, - refreshInterval: Double, - statsNormalizationMode: StatsNormalizationMode = .container, - imageUpdateIntervalHours: Int, + keepInMenuBar: Bool, + cliPathOverride: String, + refreshInterval: Double, + statsNormalizationMode: StatsNormalizationMode = .container, + imageUpdateIntervalHours: Int, imageUpdateChecksEnabled: Bool, appUpdateChecksEnabled: Bool, autoRestartEnabled: Bool, diff --git a/Sources/Contained/Stores/SettingsStore.swift b/Sources/Contained/Stores/SettingsStore.swift index 73c09266..8f05a5a9 100644 --- a/Sources/Contained/Stores/SettingsStore.swift +++ b/Sources/Contained/Stores/SettingsStore.swift @@ -169,10 +169,10 @@ final class SettingsStore { showInfoTips: showInfoTips, imageDefaultStyleEnabled: imageDefaultStyleEnabled, keepInMenuBar: keepInMenuBar, - cliPathOverride: cliPathOverride, - refreshInterval: refreshInterval, - statsNormalizationMode: statsNormalizationMode, - imageUpdateIntervalHours: imageUpdateIntervalHours, + cliPathOverride: cliPathOverride, + refreshInterval: refreshInterval, + statsNormalizationMode: statsNormalizationMode, + imageUpdateIntervalHours: imageUpdateIntervalHours, imageUpdateChecksEnabled: imageUpdateChecksEnabled, appUpdateChecksEnabled: appUpdateChecksEnabled, autoRestartEnabled: autoRestartEnabled, diff --git a/Sources/Contained/Support/AppLocalization.swift b/Sources/Contained/Support/AppLocalization.swift index c10ba948..4047d518 100644 --- a/Sources/Contained/Support/AppLocalization.swift +++ b/Sources/Contained/Support/AppLocalization.swift @@ -68,6 +68,25 @@ enum AppText { static var clearActivity: String { string("activity.clearActivity", defaultValue: "Clear activity") } static var tint: String { string("common.tint", defaultValue: "Tint") } static var unread: String { string("common.unread", defaultValue: "Unread") } + static var runtimeCore: String { string("runtime.core", defaultValue: "Core") } + static var runtimeCoreSubtitle: String { + string( + "runtime.core.subtitle", + defaultValue: "Runtime adapter used for create, recreate, imports, images, logs, stats, and system actions." + ) + } + static var containerRuntimeNotReady: String { + string("runtime.notReady", defaultValue: "Container runtime is not ready.") + } + static var composeNoServicesWithImages: String { + string("compose.noServicesWithImages", defaultValue: "No services with an image to import.") + } + static var composeWarnings: String { + string( + "compose.warnings", + defaultValue: "Some compose keys were not translated; review each container before creating." + ) + } static var reconnectTerminal: String { string("terminal.reconnectTerminal", defaultValue: "Reconnect terminal") diff --git a/Sources/ContainedCore/Models/ContainerCreateRequest.swift b/Sources/ContainedCore/Models/ContainerCreateRequest.swift new file mode 100644 index 00000000..9c4abb86 --- /dev/null +++ b/Sources/ContainedCore/Models/ContainerCreateRequest.swift @@ -0,0 +1,198 @@ +import Foundation + +public struct ContainerCreateKeyValue: Codable, Equatable, Hashable, Sendable, Identifiable { + public var id = UUID() + public var key: String + public var value: String + + public init(key: String = "", value: String = "") { + self.key = key + self.value = value + } + + public var isValid: Bool { + !key.trimmingCharacters(in: .whitespaces).isEmpty + } + + private enum CodingKeys: String, CodingKey { + case key + case value + } +} + +public struct ContainerCreatePort: Codable, Equatable, Hashable, Sendable, Identifiable { + public var id = UUID() + public var hostPort: String + public var containerPort: String + public var proto: String + + public init(hostPort: String = "", containerPort: String = "", proto: String = "tcp") { + self.hostPort = hostPort + self.containerPort = containerPort + self.proto = proto + } + + public var isValid: Bool { + !hostPort.isEmpty && !containerPort.isEmpty + } + + public var spec: String { + let base = "\(hostPort):\(containerPort)" + return proto == "tcp" ? base : "\(base)/\(proto)" + } + + private enum CodingKeys: String, CodingKey { + case hostPort + case containerPort + case proto + } +} + +public struct ContainerCreateVolume: Codable, Equatable, Hashable, Sendable, Identifiable { + public var id = UUID() + public var source: String + public var target: String + public var readOnly: Bool + + public init(source: String = "", target: String = "", readOnly: Bool = false) { + self.source = source + self.target = target + self.readOnly = readOnly + } + + public var isValid: Bool { + !source.isEmpty && !target.isEmpty + } + + public var spec: String { + let base = "\(source):\(target)" + return readOnly ? "\(base):ro" : base + } + + private enum CodingKeys: String, CodingKey { + case source + case target + case readOnly + } +} + +public struct ContainerCreateSocket: Codable, Equatable, Hashable, Sendable, Identifiable { + public var id = UUID() + public var hostPath: String + public var containerPath: String + + public init(hostPath: String = "", containerPath: String = "") { + self.hostPath = hostPath + self.containerPath = containerPath + } + + public var isValid: Bool { + !hostPath.isEmpty && !containerPath.isEmpty + } + + public var spec: String { + "\(hostPath):\(containerPath)" + } + + private enum CodingKeys: String, CodingKey { + case hostPath + case containerPath + } +} + +public struct ContainerImageDefaults: Codable, Equatable, Sendable { + public var command: [String] + public var entrypoint: [String] + public var workingDirectory: String? + public var user: String? + public var environment: [ContainerCreateKeyValue] + + public init(command: [String] = [], + entrypoint: [String] = [], + workingDirectory: String? = nil, + user: String? = nil, + environment: [ContainerCreateKeyValue] = []) { + self.command = command + self.entrypoint = entrypoint + self.workingDirectory = workingDirectory + self.user = user + self.environment = environment + } +} + +public struct ContainerCreateResult: Codable, Equatable, Sendable { + public var id: String? + public var output: String + + public init(id: String? = nil, output: String = "") { + self.id = id + self.output = output + } +} + +public struct ContainerCreateRequest: Codable, Equatable, Sendable { + public var runtimeKind: RuntimeKind = .appleContainer + public var image = "" + public var platform = "" + public var name = "" + public var command: [String] = [] + public var entrypoint = "" + public var detach = true + public var removeOnExit = false + public var interactive = false + public var tty = false + public var cpus = "" + public var memory = "" + public var env: [ContainerCreateKeyValue] = [] + public var envFiles: [String] = [] + public var ports: [ContainerCreatePort] = [] + public var volumes: [ContainerCreateVolume] = [] + public var mounts: [String] = [] + public var sockets: [ContainerCreateSocket] = [] + public var labels: [ContainerCreateKeyValue] = [] + public var restart: RestartPolicy = .no + public var readOnly = false + public var useInit = false + public var rosetta = false + public var ssh = false + public var virtualization = false + public var workingDir = "" + public var user = "" + public var uid = "" + public var gid = "" + public var shmSize = "" + public var capAdd: [String] = [] + public var capDrop: [String] = [] + public var cidFile = "" + public var initImage = "" + public var kernel = "" + public var network = "" + public var noDNS = false + public var dns: [String] = [] + public var dnsDomain = "" + public var dnsSearch: [String] = [] + public var dnsOption: [String] = [] + public var tmpfs: [String] = [] + public var ulimits: [String] = [] + public var runtime = "" + public var scheme = "" + public var progress = "" + public var maxConcurrentDownloads = "" + + public init() {} + + public var effectiveName: String? { + name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name + } + + public func allLabelArguments() -> [String] { + var result: [String: String] = [:] + for label in labels where label.isValid { + result[label.key] = label.value + } + if restart != .no { + result["contained.restart"] = restart.rawValue + } + return result.map { "\($0.key)=\($0.value)" }.sorted() + } +} diff --git a/Sources/ContainedCore/Models/RuntimeKind.swift b/Sources/ContainedCore/Models/RuntimeKind.swift new file mode 100644 index 00000000..4af5d73a --- /dev/null +++ b/Sources/ContainedCore/Models/RuntimeKind.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Stable identifier for a runtime adapter. +/// +/// This is intentionally open-ended rather than a closed enum. Apple `container` is the first +/// adapter, Docker-compatible engines are an obvious future adapter, and the app should also be able +/// to host runtimes that do not exist yet without editing stored app state. +public struct RuntimeKind: RawRepresentable, Codable, Equatable, Hashable, Sendable { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public static let appleContainer = RuntimeKind(rawValue: "apple-container") + public static let dockerCompatible = RuntimeKind(rawValue: "docker-compatible") +} diff --git a/Sources/ContainedCore/Services/ContainerCommands.swift b/Sources/ContainedCore/Services/ContainerCommands.swift index 4eab7e77..1be5b7d5 100644 --- a/Sources/ContainedCore/Services/ContainerCommands.swift +++ b/Sources/ContainedCore/Services/ContainerCommands.swift @@ -62,6 +62,60 @@ public enum ContainerCommands { return args } + public static func run(_ request: ContainerCreateRequest) -> [String] { + var args = ["run"] + if request.detach { args.append("--detach") } + if request.removeOnExit { args.append("--rm") } + if request.interactive { args.append("--interactive") } + if request.tty { args.append("--tty") } + if !request.name.isEmpty { args += ["--name", request.name] } + if !request.cpus.isEmpty { args += ["--cpus", request.cpus] } + if !request.memory.isEmpty { args += ["--memory", request.memory] } + if !request.entrypoint.isEmpty { args += ["--entrypoint", request.entrypoint] } + if request.readOnly { args.append("--read-only") } + if request.useInit { args.append("--init") } + if request.rosetta { args.append("--rosetta") } + if request.ssh { args.append("--ssh") } + if request.virtualization { args.append("--virtualization") } + if !request.platform.isEmpty { args += ["--platform", request.platform] } + if !request.workingDir.isEmpty { args += ["--workdir", request.workingDir] } + if !request.user.isEmpty { args += ["--user", request.user] } + if !request.uid.isEmpty { args += ["--uid", request.uid] } + if !request.gid.isEmpty { args += ["--gid", request.gid] } + if !request.shmSize.isEmpty { args += ["--shm-size", request.shmSize] } + for cap in request.capAdd where !cap.isEmpty { args += ["--cap-add", cap] } + for cap in request.capDrop where !cap.isEmpty { args += ["--cap-drop", cap] } + if !request.cidFile.isEmpty { args += ["--cidfile", request.cidFile] } + if !request.initImage.isEmpty { args += ["--init-image", request.initImage] } + if !request.kernel.isEmpty { args += ["--kernel", request.kernel] } + if !request.network.isEmpty { args += ["--network", request.network] } + if request.noDNS { args.append("--no-dns") } + if !request.noDNS { + for server in request.dns where !server.isEmpty { args += ["--dns", server] } + if !request.dnsDomain.isEmpty { args += ["--dns-domain", request.dnsDomain] } + for domain in request.dnsSearch where !domain.isEmpty { args += ["--dns-search", domain] } + for option in request.dnsOption where !option.isEmpty { args += ["--dns-option", option] } + } + for mount in request.tmpfs where !mount.isEmpty { args += ["--tmpfs", mount] } + for limit in request.ulimits where !limit.isEmpty { args += ["--ulimit", limit] } + if !request.runtime.isEmpty { args += ["--runtime", request.runtime] } + if !request.scheme.isEmpty { args += ["--scheme", request.scheme] } + if !request.progress.isEmpty { args += ["--progress", request.progress] } + if !request.maxConcurrentDownloads.isEmpty { + args += ["--max-concurrent-downloads", request.maxConcurrentDownloads] + } + for port in request.ports where port.isValid { args += ["--publish", port.spec] } + for volume in request.volumes where volume.isValid { args += ["--volume", volume.spec] } + for mount in request.mounts where !mount.isEmpty { args += ["--mount", mount] } + for socket in request.sockets where socket.isValid { args += ["--publish-socket", socket.spec] } + for file in request.envFiles where !file.isEmpty { args += ["--env-file", file] } + for variable in request.env where variable.isValid { args += ["--env", "\(variable.key)=\(variable.value)"] } + for label in request.allLabelArguments() { args += ["--label", label] } + args.append(request.image) + args += request.command + return args + } + // MARK: Images public static func imageList() -> [String] { ["image", "list"] + jsonFormat } diff --git a/Sources/ContainedRuntime/RuntimeDescriptor.swift b/Sources/ContainedRuntime/RuntimeDescriptor.swift index 7a787db8..045f142a 100644 --- a/Sources/ContainedRuntime/RuntimeDescriptor.swift +++ b/Sources/ContainedRuntime/RuntimeDescriptor.swift @@ -1,22 +1,6 @@ import Foundation import ContainedCore -/// Stable identifier for a runtime adapter. -/// -/// This is intentionally open-ended rather than a closed enum. Apple `container` is the first -/// adapter, Docker-compatible engines are an obvious future adapter, and the package should also be -/// able to host runtimes that do not exist yet without editing this shared module. -public struct RuntimeKind: RawRepresentable, Codable, Equatable, Hashable, Sendable { - public var rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - public static let appleContainer = RuntimeKind(rawValue: "apple-container") - public static let dockerCompatible = RuntimeKind(rawValue: "docker-compatible") -} - public struct RuntimeCapability: OptionSet, Equatable, Sendable { public let rawValue: UInt64 @@ -40,6 +24,8 @@ public struct RuntimeCapability: OptionSet, Equatable, Sendable { public static let exec = RuntimeCapability(rawValue: 1 << 13) public static let copy = RuntimeCapability(rawValue: 1 << 14) public static let containerExport = RuntimeCapability(rawValue: 1 << 15) + public static let composeImport = RuntimeCapability(rawValue: 1 << 16) + public static let coreMigration = RuntimeCapability(rawValue: 1 << 17) public static let appleContainer: RuntimeCapability = [ .containers, @@ -58,6 +44,7 @@ public struct RuntimeCapability: OptionSet, Equatable, Sendable { .exec, .copy, .containerExport, + .composeImport, ] } @@ -131,6 +118,11 @@ public protocol ContainerRuntimeClient: Sendable { func execCapture(_ id: String, _ command: [String]) async throws -> String @discardableResult func copy(source: String, destination: String) async throws -> Data func streamSystemLogs(follow: Bool, last: Int?) -> AsyncThrowingStream + func previewCreateCommand(for request: ContainerCreateRequest) throws -> RuntimeCommandPreview + @discardableResult func createContainer(_ request: ContainerCreateRequest) async throws -> ContainerCreateResult + func translateCompose(_ project: ComposeProject, baseDirectory: URL?) throws -> RuntimeComposeImportPlan + func imageDefaults(for request: ContainerCreateRequest, in images: [ImageResource]) throws -> ContainerImageDefaults? + func coreSwitchPlan(for containerID: String, to target: RuntimeDescriptor?) throws -> RuntimeCoreSwitchPlan func systemStatus() async throws -> SystemStatus func networks() async throws -> [NetworkResource] func volumes() async throws -> [VolumeResource] @@ -195,6 +187,38 @@ public extension ContainerRuntimeClient { streamPush(ref, platform: nil) } + func previewCreateCommand(for request: ContainerCreateRequest) throws -> RuntimeCommandPreview { + throw UnsupportedRuntimeCapability(kind: descriptor.kind, capability: .containers) + } + + @discardableResult func createContainer(_ request: ContainerCreateRequest) async throws -> ContainerCreateResult { + throw UnsupportedRuntimeCapability(kind: descriptor.kind, capability: .containers) + } + + @discardableResult func recreateContainer(originalID: String, + request: ContainerCreateRequest) async throws -> ContainerCreateResult { + _ = try? await stop([originalID]) + _ = try await deleteContainers([originalID], force: true) + return try await createContainer(request) + } + + func translateCompose(_ project: ComposeProject, baseDirectory: URL? = nil) throws -> RuntimeComposeImportPlan { + throw UnsupportedRuntimeCapability(kind: descriptor.kind, capability: .composeImport) + } + + func imageDefaults(for request: ContainerCreateRequest, in images: [ImageResource]) throws -> ContainerImageDefaults? { + nil + } + + func coreSwitchPlan(for containerID: String, to target: RuntimeDescriptor?) throws -> RuntimeCoreSwitchPlan { + RuntimeCoreSwitchPlan( + isAvailable: false, + reason: "Core switching requires export/import support from both runtimes.", + source: descriptor.kind, + target: target?.kind + ) + } + @discardableResult func createVolume(name: String, size: String?) async throws -> Data { try await createVolume(name: name, size: size, labels: [:]) } diff --git a/Sources/ContainedRuntime/RuntimeTranslation.swift b/Sources/ContainedRuntime/RuntimeTranslation.swift new file mode 100644 index 00000000..e662fd15 --- /dev/null +++ b/Sources/ContainedRuntime/RuntimeTranslation.swift @@ -0,0 +1,58 @@ +import Foundation +import ContainedCore + +public struct RuntimeCommandPreview: Equatable, Sendable { + public var command: [String] + public var warnings: [String] + + public init(command: [String], warnings: [String] = []) { + self.command = command + self.warnings = warnings + } +} + +public struct RuntimeComposeImportPlan: Equatable, Sendable { + public var items: [RuntimeComposeImportItem] + public var warnings: [String] + + public init(requests: [ContainerCreateRequest], warnings: [String] = []) { + self.items = requests.map { RuntimeComposeImportItem(request: $0) } + self.warnings = warnings + } + + public init(items: [RuntimeComposeImportItem], warnings: [String] = []) { + self.items = items + self.warnings = warnings + } + + public var requests: [ContainerCreateRequest] { + items.map(\.request) + } +} + +public struct RuntimeComposeImportItem: Equatable, Sendable { + public var request: ContainerCreateRequest + public var healthCheck: HealthCheck? + + public init(request: ContainerCreateRequest, healthCheck: HealthCheck? = nil) { + self.request = request + self.healthCheck = healthCheck + } +} + +public struct RuntimeCoreSwitchPlan: Equatable, Sendable { + public var isAvailable: Bool + public var reason: String + public var source: RuntimeKind + public var target: RuntimeKind? + + public init(isAvailable: Bool, + reason: String, + source: RuntimeKind, + target: RuntimeKind?) { + self.isAvailable = isAvailable + self.reason = reason + self.source = source + self.target = target + } +} diff --git a/Tests/ContainedAppTests/RunSpecTests.swift b/Tests/ContainedAppTests/RunSpecTests.swift index e7cade25..5ae4ccc1 100644 --- a/Tests/ContainedAppTests/RunSpecTests.swift +++ b/Tests/ContainedAppTests/RunSpecTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import ContainedCore +import AppleContainerRuntime @testable import Contained @Suite("RunSpec argv + compose mapping") @@ -119,14 +120,28 @@ struct RunSpecTests { #expect(!args.contains { $0.hasPrefix("contained.tint") || $0.hasPrefix("contained.icon") }) } - @Test func composeServiceMapping() { - let service = ComposeService( - key: "db", name: "db", image: "postgres:16", platform: "linux/arm64", command: nil, - ports: ["5432:5432"], volumes: ["pgdata:/var/lib/postgresql/data"], - environment: ["POSTGRES_PASSWORD=secret"], restart: "always", - dependsOn: [], healthcheck: ComposeHealthcheck(test: ["sh", "-c", "pg_isready"], - intervalSeconds: 10, retries: 5)) - let spec = RunSpec(service: service, projectName: "demo") + @Test func composeServiceMapping() throws { + let yaml = """ + services: + db: + image: postgres:16 + platform: linux/arm64 + ports: + - "5432:5432" + volumes: + - "pgdata:/var/lib/postgresql/data" + environment: + POSTGRES_PASSWORD: secret + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: 10s + retries: 5 + """ + let project = try ComposeParser.parse(yaml, projectName: "demo") + let plan = AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: nil) + let item = try #require(plan.items.first) + let spec = RunSpec(request: item.request, healthCheck: item.healthCheck) #expect(spec.image == "postgres:16") #expect(spec.platform == "linux/arm64") #expect(spec.name == "db") @@ -140,13 +155,25 @@ struct RunSpecTests { } @Test func composeImportResolvesRelativeBindMountsFromComposeDirectory() { - let volume = VolumeMap(source: "../configs/bazarr", target: "/config") + let yaml = """ + services: + app: + image: example/app:1 + volumes: + - "../configs/bazarr:/config" + """ let base = URL(filePath: "/Volumes/Vault/.Docker/compose", directoryHint: .isDirectory) - let resolved = ComposeImport.resolveRelativeVolume(volume, baseDirectory: base) + let project = try! ComposeParser.parse(yaml, projectName: "demo") + let resolved = AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: base) + .items + .first? + .request + .volumes + .first - #expect(resolved.source == "/Volumes/Vault/.Docker/configs/bazarr") - #expect(resolved.target == "/config") - #expect(!resolved.readOnly) + #expect(resolved?.source == "/Volumes/Vault/.Docker/configs/bazarr") + #expect(resolved?.target == "/config") + #expect(resolved?.readOnly == false) } @Test func composeImportMapsAvailableRunOptions() throws { @@ -205,11 +232,12 @@ struct RunSpecTests { """ let project = try ComposeParser.parse(yaml, projectName: "demo") - let service = try #require(project.services.first) - var spec = RunSpec(service: service, projectName: project.name) - spec.volumes = spec.volumes.map { - ComposeImport.resolveRelativeVolume($0, baseDirectory: URL(filePath: "/opt/stacks/demo", directoryHint: .isDirectory)) - } + let plan = AppleContainerCreateTranslator.composePlan( + for: project, + baseDirectory: URL(filePath: "/opt/stacks/demo", directoryHint: .isDirectory) + ) + let item = try #require(plan.items.first) + let spec = RunSpec(request: item.request, healthCheck: item.healthCheck) let args = spec.arguments() #expect(spec.image == "example/app:1") @@ -275,7 +303,7 @@ struct RunSpecTests { var spec = RunSpec() spec.image = "alpine" - let defaults = try #require(spec.imageDefaults(in: images)) + let defaults = try #require(AppleContainerCreateTranslator.imageDefaults(for: spec.createRequest, in: images)) let applied = spec.adoptImageDefaults(from: defaults) #expect(applied >= 3) @@ -296,7 +324,7 @@ struct RunSpecTests { spec.workingDir = "/app" spec.env = [KeyValue(key: "PATH", value: "/custom")] - let defaults = try #require(spec.imageDefaults(in: images)) + let defaults = try #require(AppleContainerCreateTranslator.imageDefaults(for: spec.createRequest, in: images)) _ = spec.adoptImageDefaults(from: defaults) #expect(spec.command == "custom") diff --git a/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift b/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift index e70e046a..ce34d2a0 100644 --- a/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift +++ b/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift @@ -56,9 +56,65 @@ struct AppleContainerRuntimeTests { #expect(descriptor.executableName == "container") #expect(descriptor.supports([.containers, .images, .volumes, .networks])) #expect(descriptor.supports([.systemStatus, .systemLogs, .exec, .copy])) + #expect(descriptor.supports(.composeImport)) + #expect(!descriptor.supports(.coreMigration)) try descriptor.require([.imageBuild, .imagePush, .registries]) } + @Test func appleCreateTranslatorBuildsPreviewAndResult() { + var request = ContainerCreateRequest() + request.image = "nginx:latest" + request.name = "web" + request.cpus = "2" + + let preview = AppleContainerCreateTranslator.preview(for: request) + #expect(preview.command == ["run", "--detach", "--name", "web", "--cpus", "2", "nginx:latest"]) + #expect(preview.warnings.isEmpty) + + let namedResult = AppleContainerCreateTranslator.result(from: Data("generated-id\n".utf8), request: request) + #expect(namedResult.id == "web") + + request.name = "" + let generatedResult = AppleContainerCreateTranslator.result(from: Data("generated-id\n".utf8), request: request) + #expect(generatedResult.id == "generated-id") + } + + @Test func appleComposeTranslationReturnsStandardCreateFields() throws { + let yaml = """ + services: + app: + image: example/app:1 + container_name: demo-app + command: ["serve", "--port", "8080"] + ports: + - "18080:8080" + volumes: + - "./config:/config:ro" + environment: + TZ: Europe/London + restart: always + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080"] + retries: 2 + """ + let project = try ComposeParser.parse(yaml, projectName: "demo") + let base = URL(filePath: "/opt/stacks/demo", directoryHint: .isDirectory) + let plan = AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: base) + let item = try #require(plan.items.first) + + #expect(item.request.runtimeKind == .appleContainer) + #expect(item.request.name == "demo-app") + #expect(item.request.image == "example/app:1") + #expect(item.request.command == ["serve", "--port", "8080"]) + #expect(item.request.ports.map(\.spec) == ["18080:8080"]) + #expect(item.request.volumes.map(\.spec) == ["/opt/stacks/demo/config:/config:ro"]) + #expect(item.request.env.map { "\($0.key)=\($0.value)" } == ["TZ=Europe/London"]) + #expect(item.request.restart == .always) + #expect(item.request.labels.contains { $0.key == "contained.stack" && $0.value == "demo" }) + #expect(item.healthCheck?.command == ["curl", "-f", "http://localhost:8080"]) + #expect(item.healthCheck?.retries == 2) + } + @Test func appleClientConformsToRuntimeClient() async throws { let runner = MockCommandRunner(result: .success(try Fixture.data("list"))) let runtime: any ContainerRuntimeClient = AppleContainerClient(runner: runner) From 61c791a2b45d8caee45f91c7d0dcd2c2ae675ec8 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 07:50:52 +0100 Subject: [PATCH 22/53] Add Xcode workspace schemes backed by SwiftPM --- Contained.xcodeproj/project.pbxproj | 164 ++++++++++++++++++ .../xcshareddata/xcschemes/Contained.xcscheme | 60 +++++++ .../xcschemes/ContainedPackageTests.xcscheme | 60 +++++++ .../contents.xcworkspacedata | 3 + README.md | 11 +- .../20260701-runtime-abstraction.md | 2 +- .../unreleased/20260701-xcode-workspace.md | 2 +- docs/wiki/Architecture.md | 15 +- docs/wiki/Compose-Import.md | 11 +- docs/wiki/Contributing.md | 5 +- docs/wiki/Installation.md | 6 +- docs/wiki/Run-Edit-Form.md | 18 +- docs/wiki/Runtime-Adapters.md | 53 ++++-- scripts/xcode-swiftpm-build.sh | 27 +++ 14 files changed, 400 insertions(+), 37 deletions(-) create mode 100644 Contained.xcodeproj/project.pbxproj create mode 100644 Contained.xcodeproj/xcshareddata/xcschemes/Contained.xcscheme create mode 100644 Contained.xcodeproj/xcshareddata/xcschemes/ContainedPackageTests.xcscheme create mode 100755 scripts/xcode-swiftpm-build.sh diff --git a/Contained.xcodeproj/project.pbxproj b/Contained.xcodeproj/project.pbxproj new file mode 100644 index 00000000..83849288 --- /dev/null +++ b/Contained.xcodeproj/project.pbxproj @@ -0,0 +1,164 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXFileReference section */ + 0E1000010000000000000001 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = text; path = Package.swift; sourceTree = ""; }; + 0E1000010000000000000002 /* Sources */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Sources; sourceTree = ""; }; + 0E1000010000000000000003 /* Tests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Tests; sourceTree = ""; }; + 0E1000010000000000000004 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Packages; sourceTree = ""; }; + 0E1000010000000000000005 /* docs */ = {isa = PBXFileReference; lastKnownFileType = folder; path = docs; sourceTree = ""; }; + 0E1000010000000000000006 /* scripts */ = {isa = PBXFileReference; lastKnownFileType = folder; path = scripts; sourceTree = ""; }; + 0E1000010000000000000007 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; + 0E1000010000000000000008 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 0E1000020000000000000001 = { + isa = PBXGroup; + children = ( + 0E1000010000000000000001 /* Package.swift */, + 0E1000010000000000000002 /* Sources */, + 0E1000010000000000000003 /* Tests */, + 0E1000010000000000000004 /* Packages */, + 0E1000010000000000000005 /* docs */, + 0E1000010000000000000006 /* scripts */, + 0E1000010000000000000008 /* README.md */, + 0E1000010000000000000007 /* CHANGELOG.md */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXLegacyTarget section */ + 0E1000030000000000000001 /* Contained */ = { + isa = PBXLegacyTarget; + buildArgumentsString = "build Contained"; + buildConfigurationList = 0E1000040000000000000002 /* Build configuration list for PBXLegacyTarget "Contained" */; + buildPhases = ( + ); + buildToolPath = scripts/xcode-swiftpm-build.sh; + buildWorkingDirectory = ""; + dependencies = ( + ); + name = Contained; + passBuildSettingsInEnvironment = 1; + productName = Contained; + }; + 0E1000030000000000000002 /* Contained Package Tests */ = { + isa = PBXLegacyTarget; + buildArgumentsString = test; + buildConfigurationList = 0E1000040000000000000003 /* Build configuration list for PBXLegacyTarget "Contained Package Tests" */; + buildPhases = ( + ); + buildToolPath = scripts/xcode-swiftpm-build.sh; + buildWorkingDirectory = ""; + dependencies = ( + ); + name = "Contained Package Tests"; + passBuildSettingsInEnvironment = 1; + productName = ContainedPackageTests; + }; +/* End PBXLegacyTarget section */ + +/* Begin PBXProject section */ + 0E1000050000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 2600; + }; + buildConfigurationList = 0E1000040000000000000001 /* Build configuration list for PBXProject "Contained" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 0E1000020000000000000001; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 0E1000030000000000000001 /* Contained */, + 0E1000030000000000000002 /* Contained Package Tests */, + ); + }; +/* End PBXProject section */ + +/* Begin XCBuildConfiguration section */ + 0E1000060000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + }; + name = Debug; + }; + 0E1000060000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + }; + name = Release; + }; + 0E1000060000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + }; + name = Debug; + }; + 0E1000060000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + }; + name = Release; + }; + 0E1000060000000000000005 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + }; + name = Debug; + }; + 0E1000060000000000000006 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0E1000040000000000000001 /* Build configuration list for PBXProject "Contained" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0E1000060000000000000001 /* Debug */, + 0E1000060000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 0E1000040000000000000002 /* Build configuration list for PBXLegacyTarget "Contained" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0E1000060000000000000003 /* Debug */, + 0E1000060000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 0E1000040000000000000003 /* Build configuration list for PBXLegacyTarget "Contained Package Tests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0E1000060000000000000005 /* Debug */, + 0E1000060000000000000006 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 0E1000050000000000000001 /* Project object */; +} diff --git a/Contained.xcodeproj/xcshareddata/xcschemes/Contained.xcscheme b/Contained.xcodeproj/xcshareddata/xcschemes/Contained.xcscheme new file mode 100644 index 00000000..6075f298 --- /dev/null +++ b/Contained.xcodeproj/xcshareddata/xcschemes/Contained.xcscheme @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPackageTests.xcscheme b/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPackageTests.xcscheme new file mode 100644 index 00000000..a41c7a1e --- /dev/null +++ b/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPackageTests.xcscheme @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Contained.xcworkspace/contents.xcworkspacedata b/Contained.xcworkspace/contents.xcworkspacedata index aa40158d..77d7efc5 100644 --- a/Contained.xcworkspace/contents.xcworkspacedata +++ b/Contained.xcworkspace/contents.xcworkspacedata @@ -1,6 +1,9 @@ + + diff --git a/README.md b/README.md index 302f483d..ae2d6bfc 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,15 @@ Requirements: ## Build -Contained is a SwiftPM-first app with an Xcode workspace over the package graph. +Contained is SwiftPM-first. The checked-in Xcode workspace is for manual editing +and SwiftUI iteration; its shared schemes delegate back to SwiftPM so CI/release +behavior stays identical to the command line. ```sh open Contained.xcworkspace swift build swift test +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug build ./scripts/bundle.sh debug open Contained.app ``` @@ -83,9 +86,9 @@ instead. The root package has the app/core targets and consumes local reusable packages: -- `ContainedCore`: models, Apple `container` argv builders, real `container --format json` decoders, compose parsing, and testable service logic. -- `ContainedRuntime`: shared runtime contracts, descriptors, capabilities, command errors, and command execution primitives. -- `AppleContainerRuntime`: the current Apple `container` adapter. Future Docker-compatible, Podman, Lima-backed, remote, or other runtime engines should be sibling adapter targets. +- `ContainedCore`: models, runtime-neutral create/recreate request fields, Apple `container` argv builders, real `container --format json` decoders, compose parsing, and testable service logic. +- `ContainedRuntime`: shared runtime contracts, descriptors, capabilities, translation plans, command errors, and command execution primitives. +- `AppleContainerRuntime`: the current Apple `container` adapter, including translation from shared create/import models to Apple CLI commands. Future Docker-compatible, Podman, Lima-backed, remote, or other runtime engines should be sibling adapter targets. - `Contained`: SwiftUI app shell, navigation, feature views, stores, history, settings, Sparkle support, app state migration, and app-specific presentation mappings. - [`Packages/ContainedDesignSystem`](Packages/ContainedDesignSystem/README.md): reusable SwiftUI/AppKit visual primitives, tokens, spacing, material, and micro-chrome shared by the app. - [`Packages/ContainedNavigation`](Packages/ContainedNavigation/README.md): reusable navigation and layout infrastructure shared by app chrome. diff --git a/changes/unreleased/20260701-runtime-abstraction.md b/changes/unreleased/20260701-runtime-abstraction.md index 3d110d39..dd8a55a6 100644 --- a/changes/unreleased/20260701-runtime-abstraction.md +++ b/changes/unreleased/20260701-runtime-abstraction.md @@ -1 +1 @@ -- Split runtime contracts into `ContainedRuntime` and the current Apple `container` adapter into `AppleContainerRuntime`, with open-ended runtime identifiers and typed stats streams so future Docker-compatible or other engine adapters can be added as sibling modules. +- Split runtime contracts into `ContainedRuntime` and the current Apple `container` adapter into `AppleContainerRuntime`, with open-ended runtime identifiers, typed stats streams, runtime-neutral create/import models, adapter-owned Compose/image-default translation, and disabled per-container core selection so future Docker-compatible or other engine adapters can be added as sibling modules. diff --git a/changes/unreleased/20260701-xcode-workspace.md b/changes/unreleased/20260701-xcode-workspace.md index 2b3a1904..a3709c1f 100644 --- a/changes/unreleased/20260701-xcode-workspace.md +++ b/changes/unreleased/20260701-xcode-workspace.md @@ -1,3 +1,3 @@ ## Architecture -- Add a checked-in Xcode workspace that opens the SwiftPM root package and local reusable packages without replacing SwiftPM as the source of truth. +- Add a checked-in Xcode workspace and small shared-scheme project wrapper that opens the SwiftPM root package and local reusable packages, delegates app/test schemes back to SwiftPM, and preserves SwiftPM as the CI/release source of truth. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 3278b3a1..5dcecde3 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -12,9 +12,9 @@ Contained is a SwiftUI-native macOS app that wraps Apple's `container` CLI. It s ## Targets -- **`ContainedCore`** — pure, testable logic: models, JSON decoding, compose parsing, Apple `container` argv builders, and ordering/decision helpers. Depends only on Yams. No SwiftUI. -- **`ContainedRuntime`** — shared runtime contracts: `ContainerRuntimeClient`, `RuntimeDescriptor`, `RuntimeKind`, `RuntimeCapability`, `CommandError`, and command execution primitives. It is adapter-neutral and should not contain Apple-, Docker-, or UI-specific policy. -- **`AppleContainerRuntime`** — the current Apple `container` adapter: `AppleContainerClient`, `AppleContainerCLILocator`, `CommandRunner` usage, and the Apple stats-table parser. Future runtime engines should be sibling adapter targets that conform to `ContainerRuntimeClient`. +- **`ContainedCore`** — pure, testable logic: models, open-ended `RuntimeKind`, runtime-neutral create/recreate request fields, JSON decoding, compose parsing, Apple `container` argv builders, and ordering/decision helpers. Depends only on Yams. No SwiftUI. +- **`ContainedRuntime`** — shared runtime contracts: `ContainerRuntimeClient`, `RuntimeDescriptor`, `RuntimeCapability`, translation plans, `CommandError`, and command execution primitives. It is adapter-neutral and should not contain Apple-, Docker-, or UI-specific policy. +- **`AppleContainerRuntime`** — the current Apple `container` adapter: `AppleContainerClient`, `AppleContainerCLILocator`, `CommandRunner` usage, Apple create/import/default translation, and the Apple stats-table parser. Future runtime engines should be sibling adapter targets that conform to `ContainerRuntimeClient`. - **`ContainedDesignSystem`** — a local reusable Swift package for app-agnostic SwiftUI/AppKit visual primitives. It must not depend on stores, Sparkle, SwiftData, app routing, or feature modules. - **`ContainedNavigation`** — a local reusable Swift package for navigation and layout infrastructure that should not own app-specific routing. It currently owns toolbar safe-area policy/measurement primitives. - **`Contained`** — the SwiftUI executable: views, `@Observable` stores, app-specific presentation mappings, navigation, and the SwiftData history stack. Depends on `ContainedCore`, `ContainedRuntime`, `AppleContainerRuntime`, `ContainedDesignSystem`, `ContainedNavigation`, SwiftTerm, and Sparkle. @@ -38,8 +38,10 @@ Package-local docs: - [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) - [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md) -`Contained.xcworkspace` is the optional Xcode entry point. It references the -root `Package.swift` and local package manifests; the SwiftPM package graph +`Contained.xcworkspace` is the optional Xcode entry point. It contains a small +checked-in `Contained.xcodeproj` with shared schemes that call SwiftPM through +`scripts/xcode-swiftpm-build.sh`, and it also references the root and local +package manifests for manual SwiftUI/package editing. The SwiftPM package graph remains the source of truth for builds, tests, and release scripts. ## Runtime wrapper @@ -47,6 +49,7 @@ remains the source of truth for builds, tests, and release scripts. - **`ContainerCommands`** — pure argv builders, side-effect-free so golden tests assert the exact arguments (the "Reveal CLI" affordances read from the same source of truth). - **`CommandRunner`** — shared command-execution primitive used by CLI-backed adapters. It runs one-shot commands (`run`) or streaming commands (`stream`, an `AsyncThrowingStream`) at the requested priority. Passwords are piped via `--password-stdin`, never argv. - **`ContainedPackageError`** — display-neutral error metadata shared by reusable packages. It gives the app a package name, stable code, and context without forcing packages to own localized copy. +- **`ContainerCreateRequest`** — runtime-neutral create/recreate fields used by the app form and adapter import/default translation. It carries the intended `RuntimeKind` per container so future adapters can fill the same global run/edit form without making the core choice app-wide. - **`AppleContainerClient`** — the Apple `container` implementation of `ContainerRuntimeClient`; returns decoded models and maps decode failures to a single `CommandError`. - **`ContainerStatsTableParser`** — Apple-adapter parser for the ANSI table emitted by `container stats --format table`. It converts table frames into runtime-agnostic snapshots inside `AppleContainerRuntime`. - **`ContainerRuntimeClient`** — the backend-facing operation contract. `RuntimeDescriptor`, open-ended `RuntimeKind`, and `RuntimeCapability` advertise what a selected runtime can do before adapter-specific UI routes enable a command. See [[Runtime Adapters|Runtime-Adapters]]. @@ -73,4 +76,4 @@ Card styles and healthchecks are stored locally (keyed by container id / image r ## Testing -`Tests/ContainedCoreTests` holds golden-argv tests (every `ContainerCommands` builder), decode tests against captured real CLI fixtures, and pure decision tests (`RestartDecision`, `HealthDecision`, compose ordering). `Tests/ContainedRuntimeTests` covers runtime descriptors, future-kind extensibility, Apple adapter decoding/streaming, and typed stats snapshots. `Tests/ContainedAppTests` covers `RunSpec` argv + compose mapping. Run with `swift test`. +`Tests/ContainedCoreTests` holds golden-argv tests (every `ContainerCommands` builder), decode tests against captured real CLI fixtures, and pure decision tests (`RestartDecision`, `HealthDecision`, compose ordering). `Tests/ContainedRuntimeTests` covers runtime descriptors, future-kind extensibility, Apple adapter decoding/streaming, create/import translation, unsupported capabilities, and typed stats snapshots. `Tests/ContainedAppTests` covers `RunSpec` argv, form round-trips, and runtime-translated compose mapping. Run with `swift test`. diff --git a/docs/wiki/Compose-Import.md b/docs/wiki/Compose-Import.md index 45a254ec..2b12a106 100644 --- a/docs/wiki/Compose-Import.md +++ b/docs/wiki/Compose-Import.md @@ -17,8 +17,10 @@ Compose import can start from: ## Behavior Compose import fills one or more editable [[Run / Edit Form|Run-Edit-Form]] -entries instead of launching an opaque stack. Services with images become -prefilled runs; unsupported or ambiguous fields produce warnings. +entries instead of launching an opaque stack. The selected runtime adapter +translates parsed services into standardized create fields, then the app fills +the global Run/Edit form. Services with images become prefilled runs; +unsupported or ambiguous fields produce warnings. Important translations: @@ -38,3 +40,8 @@ provide a host port to publish for this runtime. The importer should preserve user control: imported values remain editable, the CLI preview stays visible, and unsupported values are reported rather than silently guessed. + +Runtime-specific import rules belong in the adapter. Apple container currently +owns the Compose-to-`ContainerCreateRequest` translation; future Docker-compatible +or other adapters should return the same standardized fields with their own +warnings and unsupported-operation plans. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index a434730d..b93f1c6d 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -65,6 +65,7 @@ Sources/Contained/ the SwiftUI app Packages/ContainedDesignSystem/ reusable SwiftUI/AppKit visual primitives and tokens Packages/ContainedNavigation/ reusable navigation/layout infrastructure Contained.xcworkspace/ optional Xcode entry point over the SwiftPM packages +Contained.xcodeproj/ small Xcode scheme wrapper that delegates to SwiftPM Tests/ContainedCoreTests/ golden-argv + decode + decision tests Tests/ContainedRuntimeTests/ runtime adapter contract + Apple adapter tests Tests/ContainedAppTests/ RunSpec argv + compose mapping @@ -90,10 +91,10 @@ appcast.xml Sparkle feed at the root of each release branch entries. Preserve arbitrary backend stderr as runtime detail unless an adapter can classify it as a known typed case. - **Package docs live with the package.** Keep package-local import/setup/examples in [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) and [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md), with DocC landing pages under each target's `.docc` catalog. Keep `docs/wiki` focused on app-level architecture and workflow guidance. -- **Xcode opens the workspace.** `Contained.xcworkspace` points at the root and local package manifests. SwiftPM package manifests remain the build graph source of truth; do not hand-maintain generated `.xcodeproj` state. +- **Xcode opens the workspace.** `Contained.xcworkspace` points at the SwiftPM root package, local package manifests, and a small committed `Contained.xcodeproj` wrapper with shared schemes. Keep `Contained.xcodeproj` limited to Xcode entry-point metadata that delegates to `scripts/xcode-swiftpm-build.sh`; do not duplicate the SwiftPM source graph as hand-maintained native compile phases. - **Navigation infrastructure belongs in `ContainedNavigation` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in the executable target until they can cross the boundary without app policy. - **Every Apple `container` CLI action goes through a `ContainerCommands` builder** + `AppleContainerRuntime`, with a golden-argv test. The UI never assembles argv inline — this keeps "Reveal CLI" honest. -- **Runtime-facing code should depend on `ContainerRuntimeClient` where a backend choice matters.** The Apple `container` implementation remains the default adapter; future Docker-compatible, Podman, Lima-backed, remote, or other runtimes should be sibling adapter targets that advertise capability differences through `RuntimeDescriptor`. +- **Runtime-facing code should depend on `ContainerRuntimeClient` where a backend choice matters.** The Apple `container` implementation remains the default adapter; future Docker-compatible, Podman, Lima-backed, remote, or other runtimes should be sibling adapter targets that advertise capability differences through `RuntimeDescriptor`. Create/import flows should translate through `ContainerCreateRequest` and carry `RuntimeKind` per container, not as a global app setting. - **Pure decision logic is factored into `ContainedCore`** (`RestartDecision`, `HealthDecision`, compose ordering) and unit-tested without spawning processes. - **No `contained.*` personalization labels.** Card styles and healthchecks live in local stores. Only `contained.restart` and `contained.stack` are written (they must round-trip through the container). - **Never put secrets or personal data in test fixtures.** Fixtures are captured CLI output — scrub tokens, domains, and paths before committing. (`.gitignore` blocks signing material; push protection is on.) diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 0f36a114..3ccb0ee8 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -17,8 +17,9 @@ On first launch, the bootstrap screen checks for the `container` CLI and the run ## Build from source Contained is SwiftPM-first. The checked-in workspace is a convenience wrapper -over the root package and local packages; there is no generated `.xcodeproj` to -maintain. +over the root package, local packages, and shared Xcode schemes. The small +`Contained.xcodeproj` delegates build/test actions to SwiftPM; it is not the +release or CI source of truth. ```sh git clone https://github.com/tdeverx/contained-app.git @@ -31,6 +32,7 @@ Or from the command line: ```sh swift build # debug build swift test # run the unit tests +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug build ./scripts/bundle.sh debug # assemble a runnable development Contained.app open Contained.app ``` diff --git a/docs/wiki/Run-Edit-Form.md b/docs/wiki/Run-Edit-Form.md index af4a1e2e..e0d319f5 100644 --- a/docs/wiki/Run-Edit-Form.md +++ b/docs/wiki/Run-Edit-Form.md @@ -1,8 +1,8 @@ # Run / Edit Form -The Run / Edit form is intentionally UI-first. It bridges Apple's `container run` -flags into controls that feel native on macOS, while the live CLI preview remains -the source of truth for exactly what will execute. +The Run / Edit form is intentionally UI-first. It fills a runtime-neutral create +request with controls that feel native on macOS, while the selected runtime +adapter provides the live command preview for exactly what will execute. ## Design rule @@ -27,7 +27,7 @@ strings, paths, names, or raw specs. The form uses native grouped macOS settings-style sections rather than mirroring CLI flag order: -- Essentials: image, platform, name, command, and basic run behavior +- Essentials: core, image, platform, name, command, and basic run behavior - Resources: CPU and memory limits - Networking: published ports, network attachment, and socket forwarding - Storage: volume mounts @@ -52,6 +52,7 @@ These controls deliberately do not mirror the CLI one-to-one: | UI control | CLI output | Notes | | --- | --- | --- | +| Core picker | runtime adapter selection | Apple container is currently the only available core, so the picker is disabled. Future adapters fill the same form fields through `ContainerCreateRequest` instead of adding backend-specific forms. | | Platform picker | `--platform ` | The UI offers common Apple-silicon presets plus Custom. Separate `--os` and `--arch` controls are omitted to avoid duplicate platform concepts because `--platform` takes precedence. | | Memory limit toggle + slider | `--memory ` | The user chooses a host-bounded amount; the app formats it as `M` or `G`. | | Shared memory toggle + slider | `--shm-size ` | Same UI pattern as memory, with a small default of `64M`. | @@ -81,10 +82,11 @@ the app should not pretend to know every valid shape: ## Compose import behavior -Compose import follows the same UI-first rule: it fills editable Run forms rather -than directly launching an opaque stack. The importer maps every Compose service -field that has a Run form equivalent and warns for fields it cannot safely -translate. +Compose import follows the same UI-first rule: it asks the selected runtime +adapter to translate parsed Compose services into standardized create fields, +then fills editable Run forms rather than directly launching an opaque stack. +The importer maps every Compose service field that has a Run form equivalent and +warns for fields it cannot safely translate. Important translations: diff --git a/docs/wiki/Runtime-Adapters.md b/docs/wiki/Runtime-Adapters.md index d847e690..c57b6c55 100644 --- a/docs/wiki/Runtime-Adapters.md +++ b/docs/wiki/Runtime-Adapters.md @@ -4,13 +4,14 @@ Contained's app-facing runtime boundary is split into shared contracts and concrete adapters: - `ContainedRuntime` owns `ContainerRuntimeClient`, `RuntimeDescriptor`, - `RuntimeKind`, `RuntimeCapability`, `CommandError`, and command execution - primitives. + `RuntimeCapability`, runtime translation plans, `CommandError`, and command + execution primitives. - `AppleContainerRuntime` owns the current Apple `container` CLI adapter: - `AppleContainerClient`, `AppleContainerCLILocator`, and the Apple table stats - parser. + `AppleContainerClient`, `AppleContainerCLILocator`, Apple create/import/default + translation, and the Apple table stats parser. - `ContainedCore` owns pure models, JSON decoding, compose parsing, decision - helpers, and `ContainerCommands` argv builders. + helpers, open-ended `RuntimeKind`, runtime-neutral create/recreate request + fields, and `ContainerCommands` argv builders. - `Sources/Contained` owns app state, settings, stores, SwiftUI, SwiftData, and app-specific presentation mapping. @@ -26,10 +27,40 @@ depend on `any ContainerRuntimeClient`, while bootstrap/configuration chooses th concrete adapter. `RuntimeKind` is an open raw-value type, not a closed enum. New adapters can -define their own stable identifiers without editing the shared runtime target. +define their own stable identifiers without editing the shared runtime package. Use `RuntimeCapability` and `RuntimeDescriptor` to advertise support before a UI route enables a command. +## Create, Import, And Core Choice + +The global Run/Edit form is app-owned form state, but it now round-trips through +`ContainerCreateRequest`, a runtime-neutral model in `ContainedCore`. Each +request carries its intended `RuntimeKind`, so the core choice is per-container +or per-import item rather than a global app setting. + +The UI currently shows Apple container as the only core and disables the picker +until another runtime descriptor is registered. The disabled control is still +intentional: it proves where future Docker-compatible or other adapters will +plug in without making Apple-specific fields the app/backend boundary. + +Adapters translate into and out of the shared model: + +- `previewCreateCommand(for:)` returns the command preview for the selected + runtime. +- `createContainer(_:)` and `recreateContainer(originalID:request:)` create from + the shared fields. +- `translateCompose(_:baseDirectory:)` turns parsed Compose projects into one + or more standardized create requests plus warnings. +- `imageDefaults(for:in:)` lets an adapter provide image-specific defaults for + the same form fields. +- `coreSwitchPlan(for:to:)` describes future export/import migration before the + app enables a cross-core swap. + +`ContainerCommands` remains the Apple argv source of truth. The Apple adapter +uses it to translate `ContainerCreateRequest` into `container run` today. Future +adapters should implement their own translator without adding backend `switch` +statements to SwiftUI views. + ## Error Boundary Runtime and core packages throw typed errors. They expose stable package names, @@ -54,9 +85,9 @@ Future adapters should publish the same snapshot shape from their own native source, such as an engine API stream, without leaking transport details into the app. -## Transitional Surface +## Low-Level Commands -`runContainer(arguments:)` still accepts the existing generated command -arguments from `RunSpec`. That keeps this extraction behavior-preserving. A later -runtime-abstraction slice should replace it with a runtime-neutral run request -model before non-Apple adapters become user-selectable. +`runContainer(arguments:)` remains available for compatibility and direct +Apple-container affordances, but Run/Edit creation should use +`ContainerCreateRequest` through the selected runtime client. Do not assemble +backend argv in SwiftUI or stores. diff --git a/scripts/xcode-swiftpm-build.sh b/scripts/xcode-swiftpm-build.sh new file mode 100755 index 00000000..2130df17 --- /dev/null +++ b/scripts/xcode-swiftpm-build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +mode="${1:-build}" +product="${2:-Contained}" +configuration="$(printf '%s' "${CONFIGURATION:-Debug}" | tr '[:upper:]' '[:lower:]')" + +case "$configuration" in + debug|release) ;; + *) configuration="debug" ;; +esac + +case "$mode" in + build) + swift build -c "$configuration" --product "$product" + ;; + test) + swift test + ;; + *) + echo "Unknown Xcode SwiftPM bridge mode: $mode" >&2 + exit 64 + ;; +esac From 5f3a08d8555c01f94f3d294d0067f87d74f2ed97 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 07:53:45 +0100 Subject: [PATCH 23/53] Seed app localization string catalog --- .../Contained/Resources/Localizable.xcstrings | 241 +++++++++++++++++- 1 file changed, 237 insertions(+), 4 deletions(-) diff --git a/Sources/Contained/Resources/Localizable.xcstrings b/Sources/Contained/Resources/Localizable.xcstrings index d1fe3470..0174206a 100644 --- a/Sources/Contained/Resources/Localizable.xcstrings +++ b/Sources/Contained/Resources/Localizable.xcstrings @@ -1,7 +1,240 @@ { - "sourceLanguage" : "en", - "strings" : { - + "sourceLanguage": "en", + "strings": { + "activity.clearActivity": {}, + "activity.markAllRead": {}, + "appearance.dark": {}, + "appearance.light": {}, + "appearance.system": {}, + "backup.importMode.merge": {}, + "backup.importMode.replace": {}, + "backup.section.caches": {}, + "backup.section.healthChecks": {}, + "backup.section.history": {}, + "backup.section.personalization": {}, + "backup.section.settings": {}, + "backup.section.templates": {}, + "blendMode.multiply": {}, + "blendMode.normal": {}, + "blendMode.overlay": {}, + "blendMode.screen": {}, + "blendMode.softLight": {}, + "build.addBuildArgument": {}, + "build.buildImage": {}, + "build.cancelBuild": {}, + "build.chooseContextFolder": {}, + "build.removeBuildArgument": {}, + "cardDensity.large": {}, + "cardDensity.medium": {}, + "cardDensity.small": {}, + "common.add": {}, + "common.back": {}, + "common.cancel": {}, + "common.clear": {}, + "common.clearSearch": {}, + "common.close": {}, + "common.completed": {}, + "common.copied": {}, + "common.copyAll": {}, + "common.copyCommand": {}, + "common.copyLog": {}, + "common.copyReference": {}, + "common.current": {}, + "common.delete": {}, + "common.direction": {}, + "common.done": {}, + "common.edit": {}, + "common.follow": {}, + "common.import": {}, + "common.logIn": {}, + "common.parent": {}, + "common.quit": {}, + "common.reconnect": {}, + "common.refresh": {}, + "common.remove": {}, + "common.restart": {}, + "common.retry": {}, + "common.run": {}, + "common.save": {}, + "common.start": {}, + "common.stop": {}, + "common.tint": {}, + "common.unread": {}, + "common.use": {}, + "common.working": {}, + "compose.noServicesWithImages": {}, + "compose.warnings": {}, + "containerCard.accessibilityLabel": {}, + "customize.accessibilityLabel": {}, + "customize.cardColor": {}, + "customize.imageStyleAccessibilityLabel": {}, + "error.command.cliNotFound": {}, + "error.command.decodingFailed": {}, + "error.command.launchFailed": {}, + "error.command.nonZeroExit": {}, + "error.compose.invalid": {}, + "error.registry.httpStatus": {}, + "error.registry.invalidResponse": {}, + "error.registry.missingDigest": {}, + "error.registry.notFound": {}, + "error.registry.tokenUnavailable": {}, + "error.registry.unauthorized": {}, + "error.runtime.unsupportedCapability": {}, + "files.copyIntoFolder": {}, + "graphMetric.cpu": {}, + "graphMetric.diskRead": {}, + "graphMetric.diskWrite": {}, + "graphMetric.memory": {}, + "graphMetric.netRx": {}, + "graphMetric.netTx": {}, + "graphStyle.area": {}, + "graphStyle.bar": {}, + "graphStyle.line": {}, + "graphStyle.multiLine": {}, + "graphStyle.points": {}, + "graphStyle.range": {}, + "graphStyle.scatter": {}, + "image.addTag": {}, + "image.deleteTag": {}, + "image.loadImageTar": {}, + "image.prune": {}, + "image.pruneImages": {}, + "image.pullUpdate": {}, + "image.push": {}, + "logging.category.compose": {}, + "logging.category.health": {}, + "logging.category.image": {}, + "logging.category.lifecycle": {}, + "logging.category.registry": {}, + "logging.category.system": {}, + "logging.category.ui": {}, + "logging.destination.activity": {}, + "logging.destination.console": {}, + "logging.level.errors": {}, + "logging.level.errors.footnote": {}, + "logging.level.important": {}, + "logging.level.important.footnote": {}, + "logging.level.off": {}, + "logging.level.off.footnote": {}, + "logging.level.verbose": {}, + "logging.level.verbose.footnote": {}, + "menu.networkActions": {}, + "menu.volumeActions": {}, + "network.newNetwork": {}, + "network.newNetwork.lowercase": {}, + "network.refreshNetworks": {}, + "palette.removeScopeAccessibilityLabel": {}, + "palette.setAppTint": {}, + "restartPolicy.always": {}, + "restartPolicy.no": {}, + "restartPolicy.onFailure": {}, + "runSpec.adopt": {}, + "runtime.core": {}, + "runtime.core.subtitle": {}, + "runtime.coreSelector.disabledReason": {}, + "runtime.notReady": {}, + "section.images": {}, + "section.templates": {}, + "selection.count": {}, + "service.restart": {}, + "service.start": {}, + "service.stop": {}, + "statsNormalization.container": {}, + "statsNormalization.container.footnote": {}, + "statsNormalization.machine": {}, + "statsNormalization.machine.footnote": {}, + "streamConsole.lineCount": {}, + "system.storageCleanup": {}, + "system.systemLogs": {}, + "template.saveAsTemplate": {}, + "terminal.reconnectTerminal": {}, + "tint.amber": {}, + "tint.amber.alias.gold": {}, + "tint.amber.alias.honey": {}, + "tint.amber.alias.mustard": {}, + "tint.amber.alias.yellow": {}, + "tint.azure": {}, + "tint.azure.alias.blue": {}, + "tint.azure.alias.cobalt": {}, + "tint.azure.alias.ocean": {}, + "tint.azure.alias.sky": {}, + "tint.coral": {}, + "tint.coral.alias.burnt": {}, + "tint.coral.alias.orange": {}, + "tint.coral.alias.rust": {}, + "tint.coral.alias.salmon": {}, + "tint.coral.alias.terracotta": {}, + "tint.graphite": {}, + "tint.graphite.alias.charcoal": {}, + "tint.graphite.alias.gray": {}, + "tint.graphite.alias.grey": {}, + "tint.graphite.alias.mono": {}, + "tint.graphite.alias.neutral": {}, + "tint.graphite.alias.silver": {}, + "tint.graphite.alias.slate": {}, + "tint.green": {}, + "tint.green.alias.emerald": {}, + "tint.green.alias.forest": {}, + "tint.green.alias.lime": {}, + "tint.green.alias.moss": {}, + "tint.green.alias.olive": {}, + "tint.indigo": {}, + "tint.indigo.alias.blurple": {}, + "tint.indigo.alias.purple": {}, + "tint.indigo.alias.royal": {}, + "tint.indigo.alias.violet": {}, + "tint.multicolor": {}, + "tint.multicolor.alias.appAccent": {}, + "tint.multicolor.alias.auto": {}, + "tint.multicolor.alias.default": {}, + "tint.multicolor.alias.rainbow": {}, + "tint.multicolor.alias.system": {}, + "tint.pink": {}, + "tint.pink.alias.crimson": {}, + "tint.pink.alias.fuchsia": {}, + "tint.pink.alias.hotPink": {}, + "tint.pink.alias.magenta": {}, + "tint.pink.alias.rose": {}, + "tint.teal": {}, + "tint.teal.alias.aqua": {}, + "tint.teal.alias.cyan": {}, + "tint.teal.alias.mint": {}, + "tint.teal.alias.seafoam": {}, + "tint.teal.alias.turquoise": {}, + "updateChannel.beta": {}, + "updateChannel.beta.footnote": {}, + "updateChannel.nightly": {}, + "updateChannel.nightly.footnote": {}, + "updateChannel.stable": {}, + "updateChannel.stable.footnote": {}, + "updates.checkForUpdates": {}, + "updates.checkForUpdatesNow": {}, + "updates.runImageUpdateCheckNow": {}, + "volume.newVolume": {}, + "volume.refreshVolumes": {}, + "widgetInterpolation.cardinal": {}, + "widgetInterpolation.catmullRom": {}, + "widgetInterpolation.linear": {}, + "widgetInterpolation.monotone": {}, + "widgetInterpolation.stepCenter": {}, + "widgetInterpolation.stepEnd": {}, + "widgetInterpolation.stepStart": {}, + "windowMaterial.contentBackground": {}, + "windowMaterial.fullScreenUI": {}, + "windowMaterial.glassClear": {}, + "windowMaterial.glassRegular": {}, + "windowMaterial.headerView": {}, + "windowMaterial.hudWindow": {}, + "windowMaterial.menu": {}, + "windowMaterial.popover": {}, + "windowMaterial.selection": {}, + "windowMaterial.sheet": {}, + "windowMaterial.sidebar": {}, + "windowMaterial.titlebar": {}, + "windowMaterial.toolTip": {}, + "windowMaterial.underPageBackground": {}, + "windowMaterial.underWindowBackground": {}, + "windowMaterial.windowBackground": {} }, - "version" : "1.0" + "version": "1.0" } From 62f2fb651f2fe9acfdd4a79cf60b8b9a0f20a6bc Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 07:56:46 +0100 Subject: [PATCH 24/53] Refresh package boundary docs --- Package.swift | 3 ++- Tests/ContainedAppTests/RunSpecTests.swift | 2 +- docs/wiki/Architecture.md | 2 +- docs/wiki/Contributing.md | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index d40843b2..04ffd096 100644 --- a/Package.swift +++ b/Package.swift @@ -74,7 +74,8 @@ let package = Package( path: "Tests/ContainedRuntimeTests", resources: [.copy("Fixtures")] ), - // Tests for app-target value types (RunSpec argv, compose→spec mapping). Imports the + // Tests for app-target value types (RunSpec form state and runtime-translated create mapping). + // Imports the // executable target with @testable. .testTarget( name: "ContainedAppTests", diff --git a/Tests/ContainedAppTests/RunSpecTests.swift b/Tests/ContainedAppTests/RunSpecTests.swift index 5ae4ccc1..07faba3e 100644 --- a/Tests/ContainedAppTests/RunSpecTests.swift +++ b/Tests/ContainedAppTests/RunSpecTests.swift @@ -4,7 +4,7 @@ import ContainedCore import AppleContainerRuntime @testable import Contained -@Suite("RunSpec argv + compose mapping") +@Suite("RunSpec create requests + runtime mapping") @MainActor struct RunSpecTests { diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 5dcecde3..5f81d5af 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -76,4 +76,4 @@ Card styles and healthchecks are stored locally (keyed by container id / image r ## Testing -`Tests/ContainedCoreTests` holds golden-argv tests (every `ContainerCommands` builder), decode tests against captured real CLI fixtures, and pure decision tests (`RestartDecision`, `HealthDecision`, compose ordering). `Tests/ContainedRuntimeTests` covers runtime descriptors, future-kind extensibility, Apple adapter decoding/streaming, create/import translation, unsupported capabilities, and typed stats snapshots. `Tests/ContainedAppTests` covers `RunSpec` argv, form round-trips, and runtime-translated compose mapping. Run with `swift test`. +`Tests/ContainedCoreTests` holds golden-argv tests (every `ContainerCommands` builder), decode tests against captured real CLI fixtures, and pure decision tests (`RestartDecision`, `HealthDecision`, compose ordering). `Tests/ContainedRuntimeTests` covers runtime descriptors, future-kind extensibility, Apple adapter decoding/streaming, create/import translation, unsupported capabilities, and typed stats snapshots. `Tests/ContainedAppTests` covers `RunSpec` form state, create-request round-trips, and runtime-translated compose mapping. Run with `swift test`. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index b93f1c6d..38f2d9f5 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -68,7 +68,7 @@ Contained.xcworkspace/ optional Xcode entry point over the SwiftPM packages Contained.xcodeproj/ small Xcode scheme wrapper that delegates to SwiftPM Tests/ContainedCoreTests/ golden-argv + decode + decision tests Tests/ContainedRuntimeTests/ runtime adapter contract + Apple adapter tests -Tests/ContainedAppTests/ RunSpec argv + compose mapping +Tests/ContainedAppTests/ RunSpec form state + runtime mapping scripts/ bundle.sh, release.sh, appcast.sh docs/wiki/ local mirror of the GitHub wiki pages appcast.xml Sparkle feed at the root of each release branch From eae2380929fd0026b599a701e182dd2492e8369b Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 07:59:13 +0100 Subject: [PATCH 25/53] Keep stream console errors app-owned --- Packages/ContainedDesignSystem/README.md | 6 +++--- .../ContainedDesignSystem.docc/ContainedDesignSystem.md | 5 +++-- .../Sources/ContainedDesignSystem/StreamConsole.swift | 5 ++++- Sources/Contained/Features/Images/BuildWorkspaceView.swift | 1 + .../Features/System/Components/SystemLogsSheet.swift | 3 ++- .../Navigation/ToolbarPanels/ToolbarImageGroupCard.swift | 3 ++- 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 468012d7..4e3887bd 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -13,9 +13,9 @@ models, or feature-specific business rules in this package. App code should pass plain values into package views instead. This package also does not own localized resources or English UI defaults. When -a primitive needs visible text, accessibility copy, help, or display names, the -app supplies those strings through parameters. The executable target owns -localization keys and English fallbacks. +a primitive needs visible text, accessibility copy, help, display names, or +error/failure messages, the app supplies those strings through parameters. The +executable target owns localization keys and English fallbacks. ## Importing diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 75449cf4..a22b1f99 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -15,8 +15,9 @@ models, or feature-specific business rules here. Convert app/domain state into plain values before passing it to package views. The executable app owns localization. Package controls that need visible text, -help, accessibility labels, or display names take those strings from the caller -instead of shipping English defaults or localized resources here. +help, accessibility labels, display names, or error/failure messages take those +strings from the caller instead of shipping English defaults or localized +resources here. ## Configure Shared Policy Once diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift index 436fed9b..b2cb3770 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift @@ -9,6 +9,7 @@ public struct StreamConsole: View { public var completedLabel: String public var lineCountLabel: (Int) -> String public var copyLogHelp: String + public var failureLabel: (Error) -> String public var onComplete: (Bool) -> Void = { _ in } enum RunState: Equatable { case running, done, failed(String) } @@ -24,12 +25,14 @@ public struct StreamConsole: View { completedLabel: String, lineCountLabel: @escaping (Int) -> String, copyLogHelp: String, + failureLabel: @escaping (Error) -> String, onComplete: @escaping (Bool) -> Void = { _ in }) { self.stream = stream self.workingLabel = workingLabel self.completedLabel = completedLabel self.lineCountLabel = lineCountLabel self.copyLogHelp = copyLogHelp + self.failureLabel = failureLabel self.onComplete = onComplete } @@ -96,7 +99,7 @@ public struct StreamConsole: View { // View dismissed mid-stream; nothing to report. } catch { if !carry.isEmpty { lines.append(carry); carry = "" } - state = .failed(error.localizedDescription) + state = .failed(failureLabel(error)) onComplete(false) } } diff --git a/Sources/Contained/Features/Images/BuildWorkspaceView.swift b/Sources/Contained/Features/Images/BuildWorkspaceView.swift index 51a64248..ef1080c1 100644 --- a/Sources/Contained/Features/Images/BuildWorkspaceView.swift +++ b/Sources/Contained/Features/Images/BuildWorkspaceView.swift @@ -37,6 +37,7 @@ struct BuildWorkspaceView: View { completedLabel: AppText.completed, lineCountLabel: AppText.lineCount, copyLogHelp: AppText.copyLog, + failureLabel: AppErrorPresentation.message, onComplete: { ok in if ok { Task { await app.refreshImagesIfStale(force: true) } } }) .id(run) .padding(Tokens.Space.s) diff --git a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift b/Sources/Contained/Features/System/Components/SystemLogsSheet.swift index a30e61a4..e803cbcf 100644 --- a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift +++ b/Sources/Contained/Features/System/Components/SystemLogsSheet.swift @@ -27,7 +27,8 @@ struct SystemLogsSheet: View { workingLabel: AppText.working, completedLabel: AppText.completed, lineCountLabel: AppText.lineCount, - copyLogHelp: AppText.copyLog) + copyLogHelp: AppText.copyLog, + failureLabel: AppErrorPresentation.message) .id(session) .padding(.horizontal, Tokens.Space.s) .padding(.bottom, Tokens.Space.s) diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index d48247c8..61ce44eb 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -207,7 +207,8 @@ struct ToolbarImageGroupCard: View { workingLabel: AppText.working, completedLabel: AppText.completed, lineCountLabel: AppText.lineCount, - copyLogHelp: AppText.copyLog) + copyLogHelp: AppText.copyLog, + failureLabel: AppErrorPresentation.message) .frame(minHeight: 260) } else { pushReadiness(reference) From fff1bd03f79a4452140b1f1e4d0472db2f65a85b Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 08:24:20 +0100 Subject: [PATCH 26/53] x --- .../contents.xcworkspacedata | 7 ++ .../xcschemes/ContainedPackageTests.xcscheme | 74 +++++++++---------- 2 files changed, 44 insertions(+), 37 deletions(-) create mode 100644 Contained.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/Contained.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Contained.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Contained.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPackageTests.xcscheme b/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPackageTests.xcscheme index a41c7a1e..7c3f00fa 100644 --- a/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPackageTests.xcscheme +++ b/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPackageTests.xcscheme @@ -1,60 +1,60 @@ + LastUpgradeVersion = "2600" + version = "1.3"> + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + BuildableIdentifier = "primary" + BlueprintIdentifier = "0E1000030000000000000002" + BuildableName = "Contained Package Tests" + BlueprintName = "Contained Package Tests" + ReferencedContainer = "container:Contained.xcodeproj"> + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES"> + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "" + selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn" + launchStyle = "0" + useCustomWorkingDirectory = "YES" + customWorkingDirectory = "$(SRCROOT)" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "NO"> + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "YES" + customWorkingDirectory = "$(SRCROOT)" + debugDocumentVersioning = "YES"> + buildConfiguration = "Debug"> + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> From 67925cbceb42e6a06a83ac083112a9fb979ba383 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 10:46:28 +0100 Subject: [PATCH 27/53] Finish package boundaries, native Xcode, and docs cleanup --- .github/ISSUE_TEMPLATE/design_system.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 4 +- .github/ISSUE_TEMPLATE/navigation_tooling.yml | 2 +- .../ISSUE_TEMPLATE/research_exploration.yml | 2 +- .github/pull_request_template.md | 4 +- .github/workflows/codeql.yml | 4 +- .gitignore | 10 + AGENTS.md | 34 +- CHANGELOG.md | 2 +- Contained.xcodeproj/project.pbxproj | 362 +++++++++++++++--- .../xcshareddata/swiftpm/Package.resolved | 42 ++ .../xcshareddata/xcschemes/Contained.xcscheme | 126 ++++-- .../xcschemes/ContainedAppTests.xcscheme | 112 ++++++ ...ts.xcscheme => ContainedPreviews.xcscheme} | 47 ++- .../contents.xcworkspacedata | 11 +- .../xcshareddata/swiftpm/Package.resolved | 42 ++ Package.swift | 79 ++-- Packages/AppleContainerRuntime/Package.swift | 32 ++ Packages/AppleContainerRuntime/README.md | 39 ++ .../AppleContainerRuntime.md | 12 + .../Clients}/AppleContainerClient.swift | 0 .../Parsing}/ContainerStatsTableParser.swift | 0 .../Support}/AppleContainerCLILocator.swift | 0 .../AppleContainerCreateTranslator.swift | 0 .../AppleContainerRuntimeTests.swift | 0 .../Fixtures.swift | 0 .../Fixtures/df.json | 0 .../Fixtures/image-inspect.json | 0 .../Fixtures/images-error.txt | 0 .../Fixtures/inspect.json | 0 .../Fixtures/list-current.json | 0 .../Fixtures/list.json | 0 .../Fixtures/networks.json | 0 .../Fixtures/stats-table.txt | 0 .../Fixtures/stats.json | 0 .../Fixtures/status.json | 0 .../Fixtures/volumes.json | 0 Packages/ContainedCore/Package.swift | 24 ++ Packages/ContainedCore/README.md | 41 ++ .../Commands}/ContainerCommands.swift | 0 .../ContainedCore.docc/ContainedCore.md | 37 ++ .../Errors}/ContainedPackageError.swift | 0 .../ContainedCore/Models/ComposeProject.swift | 0 .../ContainedCore/Models/Container.swift | 0 .../Models/ContainerCreateRequest.swift | 0 .../ContainedCore/Models/ContainerJSON.swift | 0 .../ContainedCore/Models/ContainerStats.swift | 2 +- .../ContainedCore/Models/GraphMetric.swift | 75 ++++ .../ContainedCore/Models/HealthCheck.swift | 0 .../ContainedCore/Models/HubSearch.swift | 0 .../ContainedCore/Models/ImageResource.swift | 0 .../Models/ImageUpdateStatus.swift | 0 .../Models/LocalImageTagGroup.swift | 0 .../Models/NetworkResource.swift | 0 .../Models/RegistryImageReference.swift | 0 .../ContainedCore/Models/RegistryLogin.swift | 0 .../ContainedCore/Models/RestartPolicy.swift | 0 .../ContainedCore/Models/RuntimeKind.swift | 0 .../ContainedCore/Models/RuntimeStatus.swift | 0 .../Models/StatsNormalization.swift | 50 +++ .../ContainedCore/Models/SystemInfo.swift | 0 .../Models/SystemProperties.swift | 0 .../ContainedCore/Models/VolumeResource.swift | 0 .../Support}/RegistryManifestClient.swift | 0 .../ContainedCoreTests/CommandTests.swift | 0 .../ContainedCoreTests/DecodingTests.swift | 0 .../Tests}/ContainedCoreTests/Fixtures.swift | 0 .../ContainedCoreTests}/Fixtures/df.json | 0 .../Fixtures/image-inspect.json | 0 .../Fixtures/images-error.txt | 0 .../ContainedCoreTests}/Fixtures/inspect.json | 0 .../Fixtures/list-current.json | 0 .../ContainedCoreTests}/Fixtures/list.json | 0 .../Fixtures/networks.json | 0 .../Fixtures/stats-table.txt | 0 .../ContainedCoreTests}/Fixtures/stats.json | 0 .../ContainedCoreTests}/Fixtures/status.json | 0 .../ContainedCoreTests}/Fixtures/volumes.json | 0 .../ImageWorkflowTests.swift | 0 .../RuntimeWorkflowTests.swift | 4 +- Packages/ContainedDesignSystem/Package.swift | 4 + Packages/ContainedDesignSystem/README.md | 59 +-- .../DesignCard.swift} | 66 ++-- .../DesignCardChrome.swift} | 118 +++--- .../DesignCardSurface.swift} | 47 ++- .../ContainedDesignSystem.md | 92 ++--- .../{ => Controls}/DesignActions.swift | 20 +- .../{ => Controls}/GlassButton.swift | 28 +- .../{ => Controls}/GlassOptionTile.swift | 10 +- .../{ => Controls}/GlassRowMenu.swift | 8 +- .../{ => Controls}/GradientAngleControl.swift | 20 +- .../{ => Controls}/InfoButton.swift | 2 +- .../{ => Controls}/TintSelector.swift | 26 +- .../{ => Controls}/ToolbarControls.swift | 16 +- .../{ => DataViz}/CommandPreviewBar.swift | 10 +- .../{ => DataViz}/LiveSparkline.swift | 0 .../{ => DataViz}/MetricTile.swift | 8 +- .../{ => Feedback}/ActivityStatusView.swift | 8 +- .../{ => Feedback}/ErrorToast.swift | 10 +- .../{ => Feedback}/StreamConsole.swift | 12 +- .../{ => Panels}/PageScaffold.swift | 2 +- .../{ => Panels}/PanelHeader.swift | 16 +- .../{ => Panels}/PanelSection.swift | 34 +- .../{ => Panels}/SheetHeader.swift | 2 +- .../Previews/DesignSystemPreviews.swift | 87 +++++ .../{ => Support}/Clipboard.swift | 0 .../{ => Support}/MicroPrimitives.swift | 72 ++-- .../{ => Surfaces}/ExteriorShadow.swift | 0 .../{ => Surfaces}/GlassSurface.swift | 4 +- .../VisualEffectBackground.swift | 0 .../{ => Theme}/Theme.swift | 37 +- .../DesignTokens.swift} | 8 +- .../DesignCardLayoutPolicyTests.swift | 22 ++ .../LiveSparklineScalingTests.swift | 0 Packages/ContainedNavigation/Package.swift | 4 + Packages/ContainedNavigation/README.md | 41 +- .../ContainedNavigation.md | 32 +- .../{ => Measurement}/MorphSourceFrames.swift | 0 .../{ => Morphing}/MorphingExpander.swift | 50 +-- .../MorphingSingleSurface.swift | 8 +- .../DesignPanelScaffold.swift} | 6 +- .../Previews/NavigationPreviews.swift | 55 +++ .../MorphSafeAreaManager.swift} | 34 +- .../MorphingSingleSurfaceTests.swift | 0 .../ContainedPreviewSupport/Package.swift | 27 ++ Packages/ContainedPreviewSupport/README.md | 33 ++ .../ContainedPreviewSupport.md | 9 + .../PreviewSamples.swift | 110 ++++++ .../PreviewSamplesTests.swift | 12 + Packages/ContainedRuntime/Package.swift | 28 ++ Packages/ContainedRuntime/README.md | 46 +++ .../Clients}/RuntimeDescriptor.swift | 6 +- .../ContainedRuntime.docc/ContainedRuntime.md | 20 + .../Errors}/CommandError.swift | 0 .../Support}/CommandRunner.swift | 0 .../Translation}/RuntimeTranslation.swift | 13 +- .../RuntimeDescriptorTests.swift | 117 ++++++ README.md | 53 ++- .../Contained/DesignSystem/GraphMetric.swift | 124 ------ .../DesignSystem/StatsNormalization.swift | 65 ---- Sources/Contained/main.swift | 3 + .../ContainedApp.swift | 7 +- .../Features/Containers/ComposeImport.swift | 0 .../Features/Containers/ContainerCard.swift | 26 +- .../Containers/ContainerConfigureView.swift | 14 +- .../Containers/ContainerCustomizeButton.swift | 2 +- .../Containers/ContainerEditSheet.swift | 2 +- .../Containers/ContainerOverviewTab.swift | 2 +- .../Containers/ContainerTabScaffold.swift | 6 +- .../Containers/ContainerViewOptions.swift | 0 .../Containers/ContainersGridView.swift | 46 +-- .../Containers/Creation/CreationFlow.swift | 20 +- .../Containers/Creation/CreationPages.swift | 30 +- .../Features/Containers/CustomizeSheet.swift | 10 +- .../Containers/CustomizeWidgetsPanel.swift | 9 +- .../Features/Containers/FilesTab.swift | 14 +- .../Features/Containers/LogsTab.swift | 10 +- .../Features/Containers/RunSpec.swift | 0 .../Features/Containers/RunSpecForm.swift | 34 +- .../Containers/RunSpecFormSupport.swift | 0 .../Features/Containers/StatsTab.swift | 8 +- .../Features/Containers/TerminalTab.swift | 12 +- .../Features/Images/BuildWorkspaceView.swift | 8 +- .../Features/Images/ImageStyleButton.swift | 4 +- .../Features/Images/RegistryImageSearch.swift | 24 +- .../Features/MenuBar/MenuBarContent.swift | 20 +- .../Features/Onboarding/BootstrapView.swift | 10 +- .../Features/Palette/CommandPalette.swift | 6 +- .../Features/Palette/PaletteSearch.swift | 0 .../Registries/RegistryLoginSheet.swift | 8 +- .../Features/Settings/ConfigTransfer.swift | 2 +- .../Features/Settings/ReleaseNotesView.swift | 2 +- .../Features/Settings/SettingsView.swift | 6 +- .../Features/Settings/Tabs/AboutTab.swift | 8 +- .../Settings/Tabs/AppearanceTab.swift | 24 +- .../Settings/Tabs/ExperimentalTab.swift | 2 +- .../Features/Settings/Tabs/GeneralTab.swift | 14 +- .../Settings/Tabs/RegistriesTab.swift | 4 +- .../Features/Settings/Tabs/RuntimeTab.swift | 4 +- .../Features/Settings/Tabs/UpdatesTab.swift | 2 +- .../System/Components/SystemLogsSheet.swift | 10 +- .../Components/SystemVolumeInventory.swift | 0 .../Features/System/SystemView.swift | 46 +-- .../History/ActivityView.swift | 10 +- .../History/ContainerHistoryTab.swift | 20 +- .../History/HistoryModels.swift | 4 + .../History/HistoryStore.swift | 0 .../History/Template.swift | 0 .../Migration/AppStateEnvelope+Transfer.swift | 0 .../Migration/AppStateEnvelope.swift | 0 .../Migration/DowngradeDecisionView.swift | 8 +- .../Migration/JSONValue.swift | 0 .../Migration/MigrationStep.swift | 0 .../Migration/StateMigrator.swift | 0 .../Navigation/AppSection.swift | 0 .../Navigation/AppToolbar.swift | 66 ++-- .../Navigation/ClassicShell.swift | 38 +- .../Navigation/PendingAction.swift | 0 .../Navigation/RootView.swift | 4 +- .../Navigation/ToolbarFilterOptions.swift | 0 .../ToolbarPanels/PaletteResultCard.swift | 92 ++--- .../ToolbarPanels/ToolbarCommandPalette.swift | 42 +- .../ToolbarPanels/ToolbarImageGroupCard.swift | 74 ++-- .../ToolbarPanels/ToolbarResourcePanels.swift | 32 +- .../ToolbarPanels/ToolbarSearchSource.swift | 0 .../ToolbarPanels/ToolbarUpdatesPanel.swift | 14 +- .../Navigation/ToolbarViewOptions.swift | 2 +- .../Navigation/UIState.swift | 0 .../GraphMetricPresentation.swift | 75 ++++ .../Presentation}/StatusPresentation.swift | 10 +- .../ContainedApp/Previews/AppPreviews.swift | 55 +++ .../Resources/CHANGELOG.md | 2 +- .../Resources/Localizable.xcstrings | 2 +- .../Stores/AppModel+Configuration.swift | 0 .../Stores/AppModel+ImageUpdates.swift | 0 .../Stores/AppModel+ResourceStyles.swift | 0 .../Stores/AppModel.swift | 0 .../Stores/ContainersStore.swift | 0 .../Stores/RefreshCoordinator.swift | 0 .../Stores/RestartWatchdog.swift | 0 .../Stores/SettingsBackup.swift | 13 +- .../Stores/SettingsStore.swift | 10 +- .../Support/AppErrorPresentation.swift | 0 .../Support/AppLocalization.swift | 6 +- .../Support/AppLogging.swift | 0 .../Support/Formatting.swift | 0 .../Support/HealthMonitor.swift | 0 .../Support/Links.swift | 0 .../Support/Notifier.swift | 0 .../Support/Personalization.swift | 7 +- .../Support/PersonalizationStore.swift | 0 .../Support/Platform.swift | 0 .../Support/UpdateChannel.swift | 0 .../Support/UpdaterController.swift | 103 ++++- .../Support/WidgetConfiguration.swift | 7 +- Tests/ContainedAppTests/AppLoggingTests.swift | 2 +- .../ContainersStoreRefreshTests.swift | 2 +- Tests/ContainedAppTests/MigrationTests.swift | 2 +- .../PaletteSearchTests.swift | 2 +- Tests/ContainedAppTests/RunSpecTests.swift | 13 +- .../SystemVolumeInventoryTests.swift | 2 +- .../UpdaterControllerTests.swift | 40 +- .../ResourceCardLayoutPolicyTests.swift | 22 -- Xcode/Contained/ContainedXcodeApp.swift | 8 + Xcode/Contained/Info.plist | 36 ++ changes/unreleased/20260702-card-anatomy.md | 2 +- .../20260702-standalone-packages-xcode.md | 4 + docs/README.md | 41 ++ docs/{wiki => app}/Home.md | 38 +- docs/{wiki => app}/Installation.md | 13 +- docs/{wiki => app}/Keyboard-Shortcuts.md | 0 docs/{wiki => app}/Localization.md | 4 +- docs/{wiki => app}/System-Settings.md | 0 docs/{wiki => app}/Troubleshooting.md | 2 +- docs/{wiki => app}/Updates.md | 0 docs/{wiki => architecture}/Architecture.md | 26 +- docs/{wiki => architecture}/Design-System.md | 81 ++-- .../Runtime-Adapters.md | 4 +- docs/{wiki => development}/Contributing.md | 46 ++- .../Issues-and-Discussions.md | 0 docs/{wiki => features}/Command-Palette.md | 12 +- docs/{wiki => features}/Compose-Import.md | 2 +- .../Containers.md} | 4 +- docs/{wiki => features}/Creation-Workflow.md | 2 +- docs/{wiki => features}/Features.md | 18 +- .../Features-Images.md => features/Images.md} | 4 +- .../Resources.md} | 4 +- docs/{wiki => features}/Run-Edit-Form.md | 0 docs/{wiki => release}/Release.md | 2 +- docs/wiki/README.md | 12 + docs/wiki/_Sidebar.md | 45 ++- scripts/bundle.sh | 15 +- scripts/require-release-note.sh | 4 +- scripts/sync-changelog-resource.sh | 2 +- scripts/validate-bundle.sh | 11 +- scripts/xcode-swiftpm-build.sh | 27 -- 276 files changed, 3111 insertions(+), 1540 deletions(-) create mode 100644 Contained.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Contained.xcodeproj/xcshareddata/xcschemes/ContainedAppTests.xcscheme rename Contained.xcodeproj/xcshareddata/xcschemes/{ContainedPackageTests.xcscheme => ContainedPreviews.xcscheme} (50%) create mode 100644 Contained.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Packages/AppleContainerRuntime/Package.swift create mode 100644 Packages/AppleContainerRuntime/README.md create mode 100644 Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/AppleContainerRuntime.docc/AppleContainerRuntime.md rename {Sources/AppleContainerRuntime => Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Clients}/AppleContainerClient.swift (100%) rename {Sources/AppleContainerRuntime => Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Parsing}/ContainerStatsTableParser.swift (100%) rename {Sources/AppleContainerRuntime => Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Support}/AppleContainerCLILocator.swift (100%) rename {Sources/AppleContainerRuntime => Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Translation}/AppleContainerCreateTranslator.swift (100%) rename {Tests/ContainedRuntimeTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/AppleContainerRuntimeTests.swift (100%) rename {Tests/ContainedRuntimeTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures.swift (100%) rename {Tests/ContainedCoreTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures/df.json (100%) rename {Tests/ContainedCoreTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures/image-inspect.json (100%) rename {Tests/ContainedCoreTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures/images-error.txt (100%) rename {Tests/ContainedCoreTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures/inspect.json (100%) rename {Tests/ContainedCoreTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures/list-current.json (100%) rename {Tests/ContainedCoreTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures/list.json (100%) rename {Tests/ContainedCoreTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures/networks.json (100%) rename {Tests/ContainedCoreTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures/stats-table.txt (100%) rename {Tests/ContainedCoreTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures/stats.json (100%) rename {Tests/ContainedCoreTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures/status.json (100%) rename {Tests/ContainedCoreTests => Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests}/Fixtures/volumes.json (100%) create mode 100644 Packages/ContainedCore/Package.swift create mode 100644 Packages/ContainedCore/README.md rename {Sources/ContainedCore/Services => Packages/ContainedCore/Sources/ContainedCore/Commands}/ContainerCommands.swift (100%) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md rename {Sources/ContainedCore/Support => Packages/ContainedCore/Sources/ContainedCore/Errors}/ContainedPackageError.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/ComposeProject.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/Container.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/ContainerCreateRequest.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/ContainerJSON.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/ContainerStats.swift (99%) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Models/GraphMetric.swift rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/HealthCheck.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/HubSearch.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/ImageResource.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/ImageUpdateStatus.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/LocalImageTagGroup.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/NetworkResource.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/RegistryImageReference.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/RegistryLogin.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/RestartPolicy.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/RuntimeKind.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/RuntimeStatus.swift (100%) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Models/StatsNormalization.swift rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/SystemInfo.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/SystemProperties.swift (100%) rename {Sources => Packages/ContainedCore/Sources}/ContainedCore/Models/VolumeResource.swift (100%) rename {Sources/ContainedCore/Services => Packages/ContainedCore/Sources/ContainedCore/Support}/RegistryManifestClient.swift (100%) rename {Tests => Packages/ContainedCore/Tests}/ContainedCoreTests/CommandTests.swift (100%) rename {Tests => Packages/ContainedCore/Tests}/ContainedCoreTests/DecodingTests.swift (100%) rename {Tests => Packages/ContainedCore/Tests}/ContainedCoreTests/Fixtures.swift (100%) rename {Tests/ContainedRuntimeTests => Packages/ContainedCore/Tests/ContainedCoreTests}/Fixtures/df.json (100%) rename {Tests/ContainedRuntimeTests => Packages/ContainedCore/Tests/ContainedCoreTests}/Fixtures/image-inspect.json (100%) rename {Tests/ContainedRuntimeTests => Packages/ContainedCore/Tests/ContainedCoreTests}/Fixtures/images-error.txt (100%) rename {Tests/ContainedRuntimeTests => Packages/ContainedCore/Tests/ContainedCoreTests}/Fixtures/inspect.json (100%) rename {Tests/ContainedRuntimeTests => Packages/ContainedCore/Tests/ContainedCoreTests}/Fixtures/list-current.json (100%) rename {Tests/ContainedRuntimeTests => Packages/ContainedCore/Tests/ContainedCoreTests}/Fixtures/list.json (100%) rename {Tests/ContainedRuntimeTests => Packages/ContainedCore/Tests/ContainedCoreTests}/Fixtures/networks.json (100%) rename {Tests/ContainedRuntimeTests => Packages/ContainedCore/Tests/ContainedCoreTests}/Fixtures/stats-table.txt (100%) rename {Tests/ContainedRuntimeTests => Packages/ContainedCore/Tests/ContainedCoreTests}/Fixtures/stats.json (100%) rename {Tests/ContainedRuntimeTests => Packages/ContainedCore/Tests/ContainedCoreTests}/Fixtures/status.json (100%) rename {Tests/ContainedRuntimeTests => Packages/ContainedCore/Tests/ContainedCoreTests}/Fixtures/volumes.json (100%) rename {Tests => Packages/ContainedCore/Tests}/ContainedCoreTests/ImageWorkflowTests.swift (100%) rename Tests/ContainedCoreTests/Phase4Tests.swift => Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift (98%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ResourceCard.swift => Cards/DesignCard.swift} (83%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ResourceCardChrome.swift => Cards/DesignCardChrome.swift} (81%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ResourceGlassCard.swift => Cards/DesignCardSurface.swift} (91%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Controls}/DesignActions.swift (94%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Controls}/GlassButton.swift (88%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Controls}/GlassOptionTile.swift (89%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Controls}/GlassRowMenu.swift (70%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Controls}/GradientAngleControl.swift (70%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Controls}/InfoButton.swift (97%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Controls}/TintSelector.swift (75%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Controls}/ToolbarControls.swift (92%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => DataViz}/CommandPreviewBar.swift (84%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => DataViz}/LiveSparkline.swift (100%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => DataViz}/MetricTile.swift (85%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Feedback}/ActivityStatusView.swift (91%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Feedback}/ErrorToast.swift (69%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Feedback}/StreamConsole.swift (93%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Panels}/PageScaffold.swift (95%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Panels}/PanelHeader.swift (73%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Panels}/PanelSection.swift (89%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Panels}/SheetHeader.swift (98%) create mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Previews/DesignSystemPreviews.swift rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Support}/Clipboard.swift (100%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Support}/MicroPrimitives.swift (69%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Surfaces}/ExteriorShadow.swift (100%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Surfaces}/GlassSurface.swift (98%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Surfaces}/VisualEffectBackground.swift (100%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{ => Theme}/Theme.swift (86%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/{Tokens.swift => Tokens/DesignTokens.swift} (97%) create mode 100644 Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/DesignCardLayoutPolicyTests.swift rename {Tests => Packages/ContainedDesignSystem/Tests}/ContainedDesignSystemTests/LiveSparklineScalingTests.swift (100%) rename Packages/ContainedNavigation/Sources/ContainedNavigation/{ => Measurement}/MorphSourceFrames.swift (100%) rename Packages/ContainedNavigation/Sources/ContainedNavigation/{ => Morphing}/MorphingExpander.swift (90%) rename Packages/ContainedNavigation/Sources/ContainedNavigation/{ => Morphing}/MorphingSingleSurface.swift (97%) rename Packages/ContainedNavigation/Sources/ContainedNavigation/{MorphPanelScaffold.swift => Panels/DesignPanelScaffold.swift} (94%) create mode 100644 Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift rename Packages/ContainedNavigation/Sources/ContainedNavigation/{AppSafeAreaManager.swift => SafeAreas/MorphSafeAreaManager.swift} (63%) rename {Tests => Packages/ContainedNavigation/Tests}/ContainedNavigationTests/MorphingSingleSurfaceTests.swift (100%) create mode 100644 Packages/ContainedPreviewSupport/Package.swift create mode 100644 Packages/ContainedPreviewSupport/README.md create mode 100644 Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md create mode 100644 Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/PreviewSamples.swift create mode 100644 Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift create mode 100644 Packages/ContainedRuntime/Package.swift create mode 100644 Packages/ContainedRuntime/README.md rename {Sources/ContainedRuntime => Packages/ContainedRuntime/Sources/ContainedRuntime/Clients}/RuntimeDescriptor.swift (98%) create mode 100644 Packages/ContainedRuntime/Sources/ContainedRuntime/ContainedRuntime.docc/ContainedRuntime.md rename {Sources/ContainedRuntime => Packages/ContainedRuntime/Sources/ContainedRuntime/Errors}/CommandError.swift (100%) rename {Sources/ContainedRuntime => Packages/ContainedRuntime/Sources/ContainedRuntime/Support}/CommandRunner.swift (100%) rename {Sources/ContainedRuntime => Packages/ContainedRuntime/Sources/ContainedRuntime/Translation}/RuntimeTranslation.swift (78%) create mode 100644 Packages/ContainedRuntime/Tests/ContainedRuntimeTests/RuntimeDescriptorTests.swift delete mode 100644 Sources/Contained/DesignSystem/GraphMetric.swift delete mode 100644 Sources/Contained/DesignSystem/StatsNormalization.swift create mode 100644 Sources/Contained/main.swift rename Sources/{Contained => ContainedApp}/ContainedApp.swift (99%) rename Sources/{Contained => ContainedApp}/Features/Containers/ComposeImport.swift (100%) rename Sources/{Contained => ContainedApp}/Features/Containers/ContainerCard.swift (95%) rename Sources/{Contained => ContainedApp}/Features/Containers/ContainerConfigureView.swift (95%) rename Sources/{Contained => ContainedApp}/Features/Containers/ContainerCustomizeButton.swift (89%) rename Sources/{Contained => ContainedApp}/Features/Containers/ContainerEditSheet.swift (94%) rename Sources/{Contained => ContainedApp}/Features/Containers/ContainerOverviewTab.swift (97%) rename Sources/{Contained => ContainedApp}/Features/Containers/ContainerTabScaffold.swift (90%) rename Sources/{Contained => ContainedApp}/Features/Containers/ContainerViewOptions.swift (100%) rename Sources/{Contained => ContainedApp}/Features/Containers/ContainersGridView.swift (93%) rename Sources/{Contained => ContainedApp}/Features/Containers/Creation/CreationFlow.swift (97%) rename Sources/{Contained => ContainedApp}/Features/Containers/Creation/CreationPages.swift (93%) rename Sources/{Contained => ContainedApp}/Features/Containers/CustomizeSheet.swift (98%) rename Sources/{Contained => ContainedApp}/Features/Containers/CustomizeWidgetsPanel.swift (98%) rename Sources/{Contained => ContainedApp}/Features/Containers/FilesTab.swift (93%) rename Sources/{Contained => ContainedApp}/Features/Containers/LogsTab.swift (93%) rename Sources/{Contained => ContainedApp}/Features/Containers/RunSpec.swift (100%) rename Sources/{Contained => ContainedApp}/Features/Containers/RunSpecForm.swift (96%) rename Sources/{Contained => ContainedApp}/Features/Containers/RunSpecFormSupport.swift (100%) rename Sources/{Contained => ContainedApp}/Features/Containers/StatsTab.swift (95%) rename Sources/{Contained => ContainedApp}/Features/Containers/TerminalTab.swift (93%) rename Sources/{Contained => ContainedApp}/Features/Images/BuildWorkspaceView.swift (97%) rename Sources/{Contained => ContainedApp}/Features/Images/ImageStyleButton.swift (93%) rename Sources/{Contained => ContainedApp}/Features/Images/RegistryImageSearch.swift (93%) rename Sources/{Contained => ContainedApp}/Features/MenuBar/MenuBarContent.swift (94%) rename Sources/{Contained => ContainedApp}/Features/Onboarding/BootstrapView.swift (93%) rename Sources/{Contained => ContainedApp}/Features/Palette/CommandPalette.swift (99%) rename Sources/{Contained => ContainedApp}/Features/Palette/PaletteSearch.swift (100%) rename Sources/{Contained => ContainedApp}/Features/Registries/RegistryLoginSheet.swift (94%) rename Sources/{Contained => ContainedApp}/Features/Settings/ConfigTransfer.swift (97%) rename Sources/{Contained => ContainedApp}/Features/Settings/ReleaseNotesView.swift (96%) rename Sources/{Contained => ContainedApp}/Features/Settings/SettingsView.swift (93%) rename Sources/{Contained => ContainedApp}/Features/Settings/Tabs/AboutTab.swift (83%) rename Sources/{Contained => ContainedApp}/Features/Settings/Tabs/AppearanceTab.swift (88%) rename Sources/{Contained => ContainedApp}/Features/Settings/Tabs/ExperimentalTab.swift (98%) rename Sources/{Contained => ContainedApp}/Features/Settings/Tabs/GeneralTab.swift (91%) rename Sources/{Contained => ContainedApp}/Features/Settings/Tabs/RegistriesTab.swift (97%) rename Sources/{Contained => ContainedApp}/Features/Settings/Tabs/RuntimeTab.swift (98%) rename Sources/{Contained => ContainedApp}/Features/Settings/Tabs/UpdatesTab.swift (98%) rename Sources/{Contained => ContainedApp}/Features/System/Components/SystemLogsSheet.swift (84%) rename Sources/{Contained => ContainedApp}/Features/System/Components/SystemVolumeInventory.swift (100%) rename Sources/{Contained => ContainedApp}/Features/System/SystemView.swift (92%) rename Sources/{Contained => ContainedApp}/History/ActivityView.swift (95%) rename Sources/{Contained => ContainedApp}/History/ContainerHistoryTab.swift (94%) rename Sources/{Contained => ContainedApp}/History/HistoryModels.swift (97%) rename Sources/{Contained => ContainedApp}/History/HistoryStore.swift (100%) rename Sources/{Contained => ContainedApp}/History/Template.swift (100%) rename Sources/{Contained => ContainedApp}/Migration/AppStateEnvelope+Transfer.swift (100%) rename Sources/{Contained => ContainedApp}/Migration/AppStateEnvelope.swift (100%) rename Sources/{Contained => ContainedApp}/Migration/DowngradeDecisionView.swift (82%) rename Sources/{Contained => ContainedApp}/Migration/JSONValue.swift (100%) rename Sources/{Contained => ContainedApp}/Migration/MigrationStep.swift (100%) rename Sources/{Contained => ContainedApp}/Migration/StateMigrator.swift (100%) rename Sources/{Contained => ContainedApp}/Navigation/AppSection.swift (100%) rename Sources/{Contained => ContainedApp}/Navigation/AppToolbar.swift (90%) rename Sources/{Contained => ContainedApp}/Navigation/ClassicShell.swift (92%) rename Sources/{Contained => ContainedApp}/Navigation/PendingAction.swift (100%) rename Sources/{Contained => ContainedApp}/Navigation/RootView.swift (98%) rename Sources/{Contained => ContainedApp}/Navigation/ToolbarFilterOptions.swift (100%) rename Sources/{Contained => ContainedApp}/Navigation/ToolbarPanels/PaletteResultCard.swift (73%) rename Sources/{Contained => ContainedApp}/Navigation/ToolbarPanels/ToolbarCommandPalette.swift (92%) rename Sources/{Contained => ContainedApp}/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift (92%) rename Sources/{Contained => ContainedApp}/Navigation/ToolbarPanels/ToolbarResourcePanels.swift (87%) rename Sources/{Contained => ContainedApp}/Navigation/ToolbarPanels/ToolbarSearchSource.swift (100%) rename Sources/{Contained => ContainedApp}/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift (94%) rename Sources/{Contained => ContainedApp}/Navigation/ToolbarViewOptions.swift (99%) rename Sources/{Contained => ContainedApp}/Navigation/UIState.swift (100%) create mode 100644 Sources/ContainedApp/Presentation/GraphMetricPresentation.swift rename Sources/{Contained/DesignSystem => ContainedApp/Presentation}/StatusPresentation.swift (66%) create mode 100644 Sources/ContainedApp/Previews/AppPreviews.swift rename Sources/{Contained => ContainedApp}/Resources/CHANGELOG.md (97%) rename Sources/{Contained => ContainedApp}/Resources/Localizable.xcstrings (99%) rename Sources/{Contained => ContainedApp}/Stores/AppModel+Configuration.swift (100%) rename Sources/{Contained => ContainedApp}/Stores/AppModel+ImageUpdates.swift (100%) rename Sources/{Contained => ContainedApp}/Stores/AppModel+ResourceStyles.swift (100%) rename Sources/{Contained => ContainedApp}/Stores/AppModel.swift (100%) rename Sources/{Contained => ContainedApp}/Stores/ContainersStore.swift (100%) rename Sources/{Contained => ContainedApp}/Stores/RefreshCoordinator.swift (100%) rename Sources/{Contained => ContainedApp}/Stores/RestartWatchdog.swift (100%) rename Sources/{Contained => ContainedApp}/Stores/SettingsBackup.swift (97%) rename Sources/{Contained => ContainedApp}/Stores/SettingsStore.swift (97%) rename Sources/{Contained => ContainedApp}/Support/AppErrorPresentation.swift (100%) rename Sources/{Contained => ContainedApp}/Support/AppLocalization.swift (99%) rename Sources/{Contained => ContainedApp}/Support/AppLogging.swift (100%) rename Sources/{Contained => ContainedApp}/Support/Formatting.swift (100%) rename Sources/{Contained => ContainedApp}/Support/HealthMonitor.swift (100%) rename Sources/{Contained => ContainedApp}/Support/Links.swift (100%) rename Sources/{Contained => ContainedApp}/Support/Notifier.swift (100%) rename Sources/{Contained => ContainedApp}/Support/Personalization.swift (97%) rename Sources/{Contained => ContainedApp}/Support/PersonalizationStore.swift (100%) rename Sources/{Contained => ContainedApp}/Support/Platform.swift (100%) rename Sources/{Contained => ContainedApp}/Support/UpdateChannel.swift (100%) rename Sources/{Contained => ContainedApp}/Support/UpdaterController.swift (74%) rename Sources/{Contained => ContainedApp}/Support/WidgetConfiguration.swift (96%) delete mode 100644 Tests/ContainedDesignSystemTests/ResourceCardLayoutPolicyTests.swift create mode 100644 Xcode/Contained/ContainedXcodeApp.swift create mode 100644 Xcode/Contained/Info.plist create mode 100644 changes/unreleased/20260702-standalone-packages-xcode.md create mode 100644 docs/README.md rename docs/{wiki => app}/Home.md (60%) rename docs/{wiki => app}/Installation.md (83%) rename docs/{wiki => app}/Keyboard-Shortcuts.md (100%) rename docs/{wiki => app}/Localization.md (94%) rename docs/{wiki => app}/System-Settings.md (100%) rename docs/{wiki => app}/Troubleshooting.md (96%) rename docs/{wiki => app}/Updates.md (100%) rename docs/{wiki => architecture}/Architecture.md (67%) rename docs/{wiki => architecture}/Design-System.md (78%) rename docs/{wiki => architecture}/Runtime-Adapters.md (96%) rename docs/{wiki => development}/Contributing.md (78%) rename docs/{wiki => development}/Issues-and-Discussions.md (100%) rename docs/{wiki => features}/Command-Palette.md (88%) rename docs/{wiki => features}/Compose-Import.md (94%) rename docs/{wiki/Features-Containers.md => features/Containers.md} (86%) rename docs/{wiki => features}/Creation-Workflow.md (95%) rename docs/{wiki => features}/Features.md (60%) rename docs/{wiki/Features-Images.md => features/Images.md} (86%) rename docs/{wiki/Features-Resources.md => features/Resources.md} (88%) rename docs/{wiki => features}/Run-Edit-Form.md (100%) rename docs/{wiki => release}/Release.md (99%) create mode 100644 docs/wiki/README.md delete mode 100755 scripts/xcode-swiftpm-build.sh diff --git a/.github/ISSUE_TEMPLATE/design_system.yml b/.github/ISSUE_TEMPLATE/design_system.yml index 1e23043d..a63803c0 100644 --- a/.github/ISSUE_TEMPLATE/design_system.yml +++ b/.github/ISSUE_TEMPLATE/design_system.yml @@ -47,7 +47,7 @@ body: placeholder: | - [ ] Move shared behavior into the primitive - [ ] Remove duplicated local styling where appropriate - - [ ] Update docs/wiki if behavior or language changes + - [ ] Update docs if behavior or language changes validations: required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index a7782d0f..b7e3a4f2 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -45,7 +45,7 @@ body: placeholder: | - [ ] Compare the main options - [ ] Check affected screens, commands, or settings - - [ ] Confirm docs/wiki impact + - [ ] Confirm docs impact validations: required: false @@ -57,7 +57,7 @@ body: placeholder: | - [ ] Update the relevant app/core code - [ ] Add or update focused tests - - [ ] Update docs/wiki if behavior or language changes + - [ ] Update docs if behavior or language changes validations: required: false diff --git a/.github/ISSUE_TEMPLATE/navigation_tooling.yml b/.github/ISSUE_TEMPLATE/navigation_tooling.yml index 29096995..da363cb9 100644 --- a/.github/ISSUE_TEMPLATE/navigation_tooling.yml +++ b/.github/ISSUE_TEMPLATE/navigation_tooling.yml @@ -47,7 +47,7 @@ body: placeholder: | - [ ] Update navigation, toolbar, panel, or measurement code - [ ] Add focused tests or smoke coverage where practical - - [ ] Update docs/wiki if behavior or terminology changes + - [ ] Update docs if behavior or terminology changes validations: required: false diff --git a/.github/ISSUE_TEMPLATE/research_exploration.yml b/.github/ISSUE_TEMPLATE/research_exploration.yml index aaa65334..c9dde5fc 100644 --- a/.github/ISSUE_TEMPLATE/research_exploration.yml +++ b/.github/ISSUE_TEMPLATE/research_exploration.yml @@ -38,7 +38,7 @@ body: placeholder: | - [ ] Post the recommendation and tradeoffs - [ ] Create or update follow-up implementation issues - - [ ] Update docs/wiki if policy or workflow changes + - [ ] Update docs if policy or workflow changes validations: required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 63ee45a0..e6e965f7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -31,8 +31,8 @@ ## Release Notes And Docs - [ ] Added or updated a release/change note, or applied the `no-release-note` label for docs/meta/dependency-only maintenance -- [ ] Updated `docs/wiki` for user-facing behavior or workflow changes, or this PR does not need docs -- [ ] Synced `Sources/Contained/Resources/CHANGELOG.md` when `CHANGELOG.md` changed (`./scripts/sync-changelog-resource.sh --check` passes) +- [ ] Updated `docs` for user-facing behavior or workflow changes, or this PR does not need docs +- [ ] Synced `Sources/ContainedApp/Resources/CHANGELOG.md` when `CHANGELOG.md` changed (`./scripts/sync-changelog-resource.sh --check` passes) ## Update Safety diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fffb04c3..194cab2e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -17,7 +17,7 @@ on: - 'LICENSE' - 'NOTICE' - '.gitignore' - - 'Sources/Contained/Resources/CHANGELOG.md' + - 'Sources/ContainedApp/Resources/CHANGELOG.md' - 'appcast.xml' - 'changes/**' push: @@ -33,7 +33,7 @@ on: - 'LICENSE' - 'NOTICE' - '.gitignore' - - 'Sources/Contained/Resources/CHANGELOG.md' + - 'Sources/ContainedApp/Resources/CHANGELOG.md' - 'appcast.xml' - 'changes/**' schedule: diff --git a/.gitignore b/.gitignore index 57cc2115..1dc92b18 100644 --- a/.gitignore +++ b/.gitignore @@ -7,13 +7,23 @@ Icon? # Swift Package Manager .build/ +**/.build/ .swiftpm/ +**/.swiftpm/ # Package.resolved IS committed — this is an app, so pin exact dependency versions. +Packages/**/Package.resolved # Xcode *.xcodeproj/xcuserdata/ *.xcworkspace/xcuserdata/ +**/*.xcodeproj/xcuserdata/ +**/*.xcworkspace/xcuserdata/ +**/xcuserdata/ DerivedData/ +**/DerivedData/ +*.xcresult +*.result +*.resultbundle *.xcuserstate *.moved-aside diff --git a/AGENTS.md b/AGENTS.md index 93788f34..a39b5362 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,16 +4,20 @@ This file is the working contract for coding agents in this repository. Follow i ## Project Shape -- This is a SwiftPM-first macOS 26 SwiftUI app. -- Local reusable packages live under `Packages/` and are consumed by the root SwiftPM package. -- `Contained.xcworkspace` is an Xcode convenience entry point over the SwiftPM manifests. Do not hand-maintain generated `.xcodeproj` state. -- `Sources/ContainedCore` is pure/testable logic. Keep SwiftUI, app state, Sparkle, and persistence out of it. -- `Sources/ContainedRuntime` is the shared runtime contract module. Keep it adapter-neutral so future runtimes can conform without changing app stores or views. -- `Sources/AppleContainerRuntime` is the Apple `container` adapter. Future Docker-compatible, Podman, Lima-backed, remote, or other engines should be sibling adapter targets rather than switches in the app. -- `Sources/Contained` is the app: SwiftUI screens, app-specific presentation mappings, navigation, stores, history, settings, and update support. +- This is a package-first macOS 26 SwiftUI app with both SwiftPM and native Xcode entry points. +- Local reusable packages live under `Packages/` and are consumed by the root SwiftPM package and the native Xcode app target. +- `Contained.xcworkspace` is the Xcode entry point. `Contained.xcodeproj` contains a native macOS app target that links the `ContainedApp` package product and builds/runs `Contained.app` directly from Xcode. +- SwiftPM remains the CI, release, packaging, signing, notarization, and appcast source of truth. Keep `Package.swift`, `swift build`, `swift test`, and `scripts/bundle.sh` working. +- `Packages/ContainedCore/Sources/ContainedCore` is pure/testable logic. Keep SwiftUI, app state, Sparkle, and persistence out of it. +- `Packages/ContainedRuntime/Sources/ContainedRuntime` is the shared runtime contract module. Keep it adapter-neutral so future runtimes can conform without changing app stores or views. +- `Packages/AppleContainerRuntime/Sources/AppleContainerRuntime` is the Apple `container` adapter. Future Docker-compatible, Podman, Lima-backed, remote, or other engines should be sibling adapter packages rather than switches in the app. +- `Sources/ContainedApp` is the app implementation: SwiftUI screens, app-specific presentation mappings, navigation, stores, history, settings, localization, and update support. +- `Sources/Contained` is only the tiny SwiftPM executable launcher. - `Packages/ContainedDesignSystem` is the reusable SwiftUI/AppKit design-system package. Keep app state, stores, Sparkle, SwiftData, persistence, and feature routing out of it. -- `Packages/ContainedNavigation` is the reusable navigation/layout package. Keep app sections, toolbar panels, stores, and concrete routing state in `Sources/Contained`. -- `docs/wiki` mirrors the GitHub wiki. User-facing behavior or workflow changes should update the matching page. +- `Packages/ContainedNavigation` is the reusable navigation/layout package. Keep app sections, toolbar panels, stores, and concrete routing state in `Sources/ContainedApp`. +- `Packages/ContainedPreviewSupport` is deterministic fixture data for package examples and SwiftUI previews. Keep live runtime calls and localized copy out of it. +- `docs/` is structured by audience and ownership. User-facing behavior or workflow changes should update the matching page under `docs/app`, `docs/features`, `docs/development`, `docs/architecture`, or `docs/release`. +- Package docs live beside each package as README + DocC. Keep package examples working and app-supplied strings explicit. - Keep directory names intentional: SwiftPM-owned folders stay `Sources` and `Tests`, Swift source domain folders use PascalCase, and repo infrastructure uses lowercase names such as `docs` and `scripts`. ## Branches And Updates @@ -30,7 +34,7 @@ This file is the working contract for coding agents in this repository. Follow i ## GitHub Issues -- Follow `docs/wiki/Issues-and-Discussions.md` for issue routing, naming, labels, milestones, native parent/sub-issue links, and blocked-by/blocking links. +- Follow `docs/development/Issues-and-Discussions.md` for issue routing, naming, labels, milestones, native parent/sub-issue links, and blocked-by/blocking links. - Use area labels for ownership only; do not treat area labels as workflow state. ## Release Notes @@ -50,8 +54,8 @@ This file is the working contract for coding agents in this repository. Follow i ## Design And UI Rules -- Reuse app-facing design-system routes before adding local styling: `PanelHeader`, `PanelSection`, `PanelField`, `ResourceCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `CommandPreviewBar`, `TintSelector`, and `Tokens`. -- Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or micro-chrome constants. Add or extend a `ContainedDesignSystem` token/primitive first, then consume it from the app. Low-level package composition pieces such as `ResourceGlassCard`, `ResourceCardHeader`, `GlassButton`, and glass surface modifiers should not be called from `Sources/Contained`. +- Reuse app-facing design-system routes before adding local styling: `PanelHeader`, `PanelSection`, `PanelField`, `DesignCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `CommandPreviewBar`, `TintSelector`, and `DesignTokens`. +- Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or micro-chrome constants. Add or extend a `ContainedDesignSystem` token/primitive first, then consume it from the app. Low-level package composition pieces such as `DesignCardSurface`, `DesignCardHeader`, `GlassButton`, and glass surface modifiers should not be called from `Sources/ContainedApp`. - Keep the classic sidebar fallback working. Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements. - Prefer native macOS/Liquid Glass behavior over custom chrome when the system primitive fits. - Do not make broad visual changes without a product reason. @@ -60,14 +64,14 @@ This file is the working contract for coding agents in this repository. Follow i - Keep Apple `container` CLI actions behind `ContainerCommands` and `AppleContainerRuntime`; do not assemble argv inline in SwiftUI. App stores should depend on `any ContainerRuntimeClient` where backend choice matters. - Put pure decision logic in `ContainedCore` with focused tests. -- Keep localization owned by `Sources/Contained`. Packages should receive +- Keep localization owned by `Sources/ContainedApp`. Packages should receive app-supplied labels/help/accessibility strings and should not add English UI defaults or localized resource bundles. Use `AppText` for reusable app copy and dynamic templates; plain SwiftUI literals are acceptable when SwiftUI keeps them localization-ready. - Keep package errors display-neutral. Reusable targets should throw typed errors with stable codes/context, preferably `ContainedPackageError`, while - `Sources/Contained` maps them through `AppErrorPresentation`/`AppText` before + `Sources/ContainedApp` maps them through `AppErrorPresentation`/`AppText` before showing toasts, alerts, inline errors, or Activity entries. Preserve arbitrary backend stderr as runtime detail rather than pretending to localize it. - Do not write app personalization back as `contained.*` labels. Only `contained.restart` and `contained.stack` may round-trip through container labels. @@ -112,6 +116,8 @@ CHANNEL=nightly ./scripts/validate-appcast.sh appcast.xml For app/UI changes: ```sh +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug build +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug test ./scripts/bundle.sh debug open Contained.app ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ebe7688..e9a18e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,7 @@ #### Personalization & Design System - Local-only personalization for containers, image groups, image tags, and volumes, including nickname, icon, tint, background, graph/widget choices, and inheritance from image or app defaults. -- Shared Liquid Glass design system primitives: `ResourceGlassCard`, `GlassSurface`, `GlassButton`, `GlassOptionTile`, `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. +- Shared Liquid Glass design system primitives: `DesignCardSurface`, `GlassSurface`, `GlassButton`, `GlassOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. - Accessibility-aware custom visual effects and motion handling, including Reduce Transparency and Reduce Motion support where the app supplies custom glass or animation. - Shared page and panel scaffolds so sidebar pages, sheets, and toolbar morphs can reuse content without duplicate layouts. diff --git a/Contained.xcodeproj/project.pbxproj b/Contained.xcodeproj/project.pbxproj index 83849288..124f06d2 100644 --- a/Contained.xcodeproj/project.pbxproj +++ b/Contained.xcodeproj/project.pbxproj @@ -3,162 +3,408 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { +/* Begin PBXBuildFile section */ + 0E2000010000000000000001 /* ContainedXcodeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2000020000000000000001 /* ContainedXcodeApp.swift */; }; + 0E2000010000000000000002 /* ContainedApp in Frameworks */ = {isa = PBXBuildFile; productRef = 0E20000B0000000000000001 /* ContainedApp */; }; + 0E2100010000000000000001 /* AppLoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000001 /* AppLoggingTests.swift */; }; + 0E2100010000000000000002 /* ContainersStoreRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000002 /* ContainersStoreRefreshTests.swift */; }; + 0E2100010000000000000003 /* MigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000003 /* MigrationTests.swift */; }; + 0E2100010000000000000004 /* PaletteSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000004 /* PaletteSearchTests.swift */; }; + 0E2100010000000000000005 /* RunSpecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000005 /* RunSpecTests.swift */; }; + 0E2100010000000000000006 /* SystemVolumeInventoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000006 /* SystemVolumeInventoryTests.swift */; }; + 0E2100010000000000000007 /* UpdaterControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000007 /* UpdaterControllerTests.swift */; }; + 0E2100010000000000000008 /* ContainedApp in Frameworks */ = {isa = PBXBuildFile; productRef = 0E20000B0000000000000001 /* ContainedApp */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ - 0E1000010000000000000001 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = text; path = Package.swift; sourceTree = ""; }; - 0E1000010000000000000002 /* Sources */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Sources; sourceTree = ""; }; - 0E1000010000000000000003 /* Tests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Tests; sourceTree = ""; }; - 0E1000010000000000000004 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Packages; sourceTree = ""; }; - 0E1000010000000000000005 /* docs */ = {isa = PBXFileReference; lastKnownFileType = folder; path = docs; sourceTree = ""; }; - 0E1000010000000000000006 /* scripts */ = {isa = PBXFileReference; lastKnownFileType = folder; path = scripts; sourceTree = ""; }; - 0E1000010000000000000007 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; - 0E1000010000000000000008 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 0E2000020000000000000001 /* ContainedXcodeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainedXcodeApp.swift; sourceTree = ""; }; + 0E2000020000000000000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0E2000020000000000000003 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = text; path = Package.swift; sourceTree = ""; }; + 0E2000020000000000000004 /* Sources */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Sources; sourceTree = ""; }; + 0E2000020000000000000005 /* Tests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Tests; sourceTree = ""; }; + 0E2000020000000000000006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Packages; sourceTree = ""; }; + 0E2000020000000000000007 /* Xcode */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Xcode; sourceTree = ""; }; + 0E2000020000000000000008 /* Resources */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Resources; sourceTree = ""; }; + 0E2000020000000000000009 /* docs */ = {isa = PBXFileReference; lastKnownFileType = folder; path = docs; sourceTree = ""; }; + 0E200002000000000000000A /* scripts */ = {isa = PBXFileReference; lastKnownFileType = folder; path = scripts; sourceTree = ""; }; + 0E200002000000000000000B /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; + 0E200002000000000000000C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 0E200002000000000000000D /* Contained.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Contained.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0E2100020000000000000001 /* AppLoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLoggingTests.swift; sourceTree = ""; }; + 0E2100020000000000000002 /* ContainersStoreRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainersStoreRefreshTests.swift; sourceTree = ""; }; + 0E2100020000000000000003 /* MigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationTests.swift; sourceTree = ""; }; + 0E2100020000000000000004 /* PaletteSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteSearchTests.swift; sourceTree = ""; }; + 0E2100020000000000000005 /* RunSpecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunSpecTests.swift; sourceTree = ""; }; + 0E2100020000000000000006 /* SystemVolumeInventoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemVolumeInventoryTests.swift; sourceTree = ""; }; + 0E2100020000000000000007 /* UpdaterControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterControllerTests.swift; sourceTree = ""; }; + 0E2100020000000000000008 /* ContainedAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ContainedAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFrameworksBuildPhase section */ + 0E2000030000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0E2000010000000000000002 /* ContainedApp in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0E2100030000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0E2100010000000000000008 /* ContainedApp in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ - 0E1000020000000000000001 = { + 0E2000040000000000000001 = { + isa = PBXGroup; + children = ( + 0E2000040000000000000002 /* Contained Xcode App */, + 0E2100040000000000000001 /* ContainedAppTests */, + 0E2000020000000000000003 /* Package.swift */, + 0E2000020000000000000004 /* Sources */, + 0E2000020000000000000005 /* Tests */, + 0E2000020000000000000006 /* Packages */, + 0E2000020000000000000007 /* Xcode */, + 0E2000020000000000000008 /* Resources */, + 0E2000020000000000000009 /* docs */, + 0E200002000000000000000A /* scripts */, + 0E200002000000000000000C /* README.md */, + 0E200002000000000000000B /* CHANGELOG.md */, + 0E2000040000000000000003 /* Products */, + ); + sourceTree = ""; + }; + 0E2000040000000000000002 /* Contained Xcode App */ = { + isa = PBXGroup; + children = ( + 0E2000020000000000000001 /* ContainedXcodeApp.swift */, + 0E2000020000000000000002 /* Info.plist */, + ); + path = Xcode/Contained; + sourceTree = ""; + }; + 0E2000040000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + 0E200002000000000000000D /* Contained.app */, + 0E2100020000000000000008 /* ContainedAppTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 0E2100040000000000000001 /* ContainedAppTests */ = { isa = PBXGroup; children = ( - 0E1000010000000000000001 /* Package.swift */, - 0E1000010000000000000002 /* Sources */, - 0E1000010000000000000003 /* Tests */, - 0E1000010000000000000004 /* Packages */, - 0E1000010000000000000005 /* docs */, - 0E1000010000000000000006 /* scripts */, - 0E1000010000000000000008 /* README.md */, - 0E1000010000000000000007 /* CHANGELOG.md */, + 0E2100020000000000000001 /* AppLoggingTests.swift */, + 0E2100020000000000000002 /* ContainersStoreRefreshTests.swift */, + 0E2100020000000000000003 /* MigrationTests.swift */, + 0E2100020000000000000004 /* PaletteSearchTests.swift */, + 0E2100020000000000000005 /* RunSpecTests.swift */, + 0E2100020000000000000006 /* SystemVolumeInventoryTests.swift */, + 0E2100020000000000000007 /* UpdaterControllerTests.swift */, ); + path = Tests/ContainedAppTests; sourceTree = ""; }; /* End PBXGroup section */ -/* Begin PBXLegacyTarget section */ - 0E1000030000000000000001 /* Contained */ = { - isa = PBXLegacyTarget; - buildArgumentsString = "build Contained"; - buildConfigurationList = 0E1000040000000000000002 /* Build configuration list for PBXLegacyTarget "Contained" */; +/* Begin PBXNativeTarget section */ + 0E2000050000000000000001 /* Contained */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0E2000060000000000000002 /* Build configuration list for PBXNativeTarget "Contained" */; buildPhases = ( + 0E2000070000000000000001 /* Sources */, + 0E2000030000000000000001 /* Frameworks */, + 0E2000080000000000000001 /* Resources */, + ); + buildRules = ( ); - buildToolPath = scripts/xcode-swiftpm-build.sh; - buildWorkingDirectory = ""; dependencies = ( ); name = Contained; - passBuildSettingsInEnvironment = 1; + packageProductDependencies = ( + 0E20000B0000000000000001 /* ContainedApp */, + ); productName = Contained; + productReference = 0E200002000000000000000D /* Contained.app */; + productType = "com.apple.product-type.application"; }; - 0E1000030000000000000002 /* Contained Package Tests */ = { - isa = PBXLegacyTarget; - buildArgumentsString = test; - buildConfigurationList = 0E1000040000000000000003 /* Build configuration list for PBXLegacyTarget "Contained Package Tests" */; + 0E2100050000000000000001 /* ContainedAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0E2100060000000000000001 /* Build configuration list for PBXNativeTarget "ContainedAppTests" */; buildPhases = ( + 0E2100070000000000000001 /* Sources */, + 0E2100030000000000000001 /* Frameworks */, + 0E2100080000000000000001 /* Resources */, + ); + buildRules = ( ); - buildToolPath = scripts/xcode-swiftpm-build.sh; - buildWorkingDirectory = ""; dependencies = ( ); - name = "Contained Package Tests"; - passBuildSettingsInEnvironment = 1; - productName = ContainedPackageTests; + name = ContainedAppTests; + packageProductDependencies = ( + 0E20000B0000000000000001 /* ContainedApp */, + ); + productName = ContainedAppTests; + productReference = 0E2100020000000000000008 /* ContainedAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; }; -/* End PBXLegacyTarget section */ +/* End PBXNativeTarget section */ /* Begin PBXProject section */ - 0E1000050000000000000001 /* Project object */ = { + 0E2000090000000000000001 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2600; + TargetAttributes = { + 0E2000050000000000000001 = { + CreatedOnToolsVersion = 26.0; + }; + 0E2100050000000000000001 = { + CreatedOnToolsVersion = 26.0; + }; + }; }; - buildConfigurationList = 0E1000040000000000000001 /* Build configuration list for PBXProject "Contained" */; - compatibilityVersion = "Xcode 14.0"; + buildConfigurationList = 0E2000060000000000000001 /* Build configuration list for PBXProject "Contained" */; + compatibilityVersion = "Xcode 16.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); - mainGroup = 0E1000020000000000000001; + mainGroup = 0E2000040000000000000001; + packageReferences = ( + 0E20000A0000000000000001 /* XCLocalSwiftPackageReference "." */, + ); + productRefGroup = 0E2000040000000000000003 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 0E1000030000000000000001 /* Contained */, - 0E1000030000000000000002 /* Contained Package Tests */, + 0E2000050000000000000001 /* Contained */, + 0E2100050000000000000001 /* ContainedAppTests */, ); }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + 0E2000080000000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0E2100080000000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0E2000070000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0E2000010000000000000001 /* ContainedXcodeApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0E2100070000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0E2100010000000000000001 /* AppLoggingTests.swift in Sources */, + 0E2100010000000000000002 /* ContainersStoreRefreshTests.swift in Sources */, + 0E2100010000000000000003 /* MigrationTests.swift in Sources */, + 0E2100010000000000000004 /* PaletteSearchTests.swift in Sources */, + 0E2100010000000000000005 /* RunSpecTests.swift in Sources */, + 0E2100010000000000000006 /* SystemVolumeInventoryTests.swift in Sources */, + 0E2100010000000000000007 /* UpdaterControllerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + /* Begin XCBuildConfiguration section */ - 0E1000060000000000000001 /* Debug */ = { + 0E20000C0000000000000001 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = arm64; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + SDKROOT = macosx; + SWIFT_VERSION = 6.0; }; name = Debug; }; - 0E1000060000000000000002 /* Release */ = { + 0E20000C0000000000000002 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = arm64; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + SDKROOT = macosx; + SWIFT_VERSION = 6.0; }; name = Release; }; - 0E1000060000000000000003 /* Debug */ = { + 0E20000C0000000000000003 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = Contained; + ARCHS = arm64; + CODE_SIGN_ENTITLEMENTS = scripts/Contained.entitlements; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 0; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Xcode/Contained/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = "1.0.0-nightly"; + PRODUCT_BUNDLE_IDENTIFIER = com.contained.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG CONTAINED_DEBUG_TOOLS"; + SWIFT_VERSION = 6.0; }; name = Debug; }; - 0E1000060000000000000004 /* Release */ = { + 0E20000C0000000000000004 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = Contained; + ARCHS = arm64; + CODE_SIGN_ENTITLEMENTS = scripts/Contained.entitlements; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 0; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Xcode/Contained/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = "1.0.0-nightly"; + PRODUCT_BUNDLE_IDENTIFIER = com.contained.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + ONLY_ACTIVE_ARCH = YES; + SWIFT_VERSION = 6.0; }; name = Release; }; - 0E1000060000000000000005 /* Debug */ = { + 0E21000C0000000000000001 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = arm64; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 0; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.contained.app.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 6.0; + TESTING_FRAMEWORK_SEARCH_PATHS = "$(inherited)"; }; name = Debug; }; - 0E1000060000000000000006 /* Release */ = { + 0E21000C0000000000000002 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = arm64; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 0; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.contained.app.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 6.0; + TESTING_FRAMEWORK_SEARCH_PATHS = "$(inherited)"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 0E1000040000000000000001 /* Build configuration list for PBXProject "Contained" */ = { + 0E2000060000000000000001 /* Build configuration list for PBXProject "Contained" */ = { isa = XCConfigurationList; buildConfigurations = ( - 0E1000060000000000000001 /* Debug */, - 0E1000060000000000000002 /* Release */, + 0E20000C0000000000000001 /* Debug */, + 0E20000C0000000000000002 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - 0E1000040000000000000002 /* Build configuration list for PBXLegacyTarget "Contained" */ = { + 0E2000060000000000000002 /* Build configuration list for PBXNativeTarget "Contained" */ = { isa = XCConfigurationList; buildConfigurations = ( - 0E1000060000000000000003 /* Debug */, - 0E1000060000000000000004 /* Release */, + 0E20000C0000000000000003 /* Debug */, + 0E20000C0000000000000004 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - 0E1000040000000000000003 /* Build configuration list for PBXLegacyTarget "Contained Package Tests" */ = { + 0E2100060000000000000001 /* Build configuration list for PBXNativeTarget "ContainedAppTests" */ = { isa = XCConfigurationList; buildConfigurations = ( - 0E1000060000000000000005 /* Debug */, - 0E1000060000000000000006 /* Release */, + 0E21000C0000000000000001 /* Debug */, + 0E21000C0000000000000002 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 0E20000A0000000000000001 /* XCLocalSwiftPackageReference "." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = .; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 0E20000B0000000000000001 /* ContainedApp */ = { + isa = XCSwiftPackageProductDependency; + package = 0E20000A0000000000000001 /* XCLocalSwiftPackageReference "." */; + productName = ContainedApp; + }; +/* End XCSwiftPackageProductDependency section */ }; - rootObject = 0E1000050000000000000001 /* Project object */; + rootObject = 0E2000090000000000000001 /* Project object */; } diff --git a/Contained.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Contained.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..da2d624c --- /dev/null +++ b/Contained.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "64845cdedc3622ad5983371228ce353541173019f7db435e596ac30175dcb159", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle.git", + "state" : { + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm.git", + "state" : { + "revision" : "8e7a1e154f470e19c709a00a8768df348ba5fc43", + "version" : "1.13.0" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" + } + } + ], + "version" : 3 +} diff --git a/Contained.xcodeproj/xcshareddata/xcschemes/Contained.xcscheme b/Contained.xcodeproj/xcshareddata/xcschemes/Contained.xcscheme index 6075f298..9f74c253 100644 --- a/Contained.xcodeproj/xcshareddata/xcschemes/Contained.xcscheme +++ b/Contained.xcodeproj/xcshareddata/xcschemes/Contained.xcscheme @@ -1,60 +1,112 @@ + LastUpgradeVersion = "2600" + version = "1.7"> + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + BuildableIdentifier = "primary" + BlueprintIdentifier = "0E2000050000000000000001" + BuildableName = "Contained.app" + BlueprintName = "Contained" + ReferencedContainer = "container:Contained.xcodeproj"> + + + + + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + + + + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "NO"> + + + + + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + + + + + buildConfiguration = "Debug"> + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> diff --git a/Contained.xcodeproj/xcshareddata/xcschemes/ContainedAppTests.xcscheme b/Contained.xcodeproj/xcshareddata/xcschemes/ContainedAppTests.xcscheme new file mode 100644 index 00000000..9f74c253 --- /dev/null +++ b/Contained.xcodeproj/xcshareddata/xcschemes/ContainedAppTests.xcscheme @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPackageTests.xcscheme b/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPreviews.xcscheme similarity index 50% rename from Contained.xcodeproj/xcshareddata/xcschemes/ContainedPackageTests.xcscheme rename to Contained.xcodeproj/xcshareddata/xcschemes/ContainedPreviews.xcscheme index 7c3f00fa..b9816fe8 100644 --- a/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPackageTests.xcscheme +++ b/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPreviews.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> @@ -14,9 +14,9 @@ buildForAnalyzing = "YES"> @@ -29,26 +29,53 @@ shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + + + + + + + diff --git a/Contained.xcworkspace/contents.xcworkspacedata b/Contained.xcworkspace/contents.xcworkspacedata index 77d7efc5..243ba30c 100644 --- a/Contained.xcworkspace/contents.xcworkspacedata +++ b/Contained.xcworkspace/contents.xcworkspacedata @@ -5,7 +5,13 @@ location = "group:Contained.xcodeproj"> + location = "group:Packages/ContainedCore/Package.swift"> + + + + @@ -13,4 +19,7 @@ + + diff --git a/Contained.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Contained.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..da2d624c --- /dev/null +++ b/Contained.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "64845cdedc3622ad5983371228ce353541173019f7db435e596ac30175dcb159", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle.git", + "state" : { + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm.git", + "state" : { + "revision" : "8e7a1e154f470e19c709a00a8768df348ba5fc43", + "version" : "1.13.0" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 04ffd096..306356ad 100644 --- a/Package.swift +++ b/Package.swift @@ -7,90 +7,59 @@ let package = Package( platforms: [.macOS(.v26)], products: [ .executable(name: "Contained", targets: ["Contained"]), - .library(name: "ContainedCore", targets: ["ContainedCore"]), - .library(name: "ContainedRuntime", targets: ["ContainedRuntime"]), - .library(name: "AppleContainerRuntime", targets: ["AppleContainerRuntime"]), + .library(name: "ContainedApp", targets: ["ContainedApp"]), ], dependencies: [ + .package(path: "Packages/ContainedCore"), + .package(path: "Packages/ContainedRuntime"), + .package(path: "Packages/AppleContainerRuntime"), .package(path: "Packages/ContainedDesignSystem"), .package(path: "Packages/ContainedNavigation"), + .package(path: "Packages/ContainedPreviewSupport"), // The in-container terminal (Phase 5): a mature VT100/xterm emulator + PTY host, far safer // than re-implementing one. AppKit-backed, bridged via NSViewRepresentable. .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0"), - // Compose import: a real YAML parser beats a fragile hand-rolled one. Pure Swift. - .package(url: "https://github.com/jpsim/Yams.git", from: "6.2.2"), // Auto-update (release): Sparkle, the de-facto macOS updater. Inert until a signed build // points SUFeedURL at a hosted appcast (see scripts/appcast.sh). .package(url: "https://github.com/sparkle-project/Sparkle.git", from: "2.6.0"), ], targets: [ - // Pure logic: models, CLI wrapper, decoding, compose parsing. Unit-testable (Yams is pure Swift). + // Shared SwiftUI app implementation. SwiftPM and Xcode use different tiny launchers. .target( - name: "ContainedCore", - dependencies: [.product(name: "Yams", package: "Yams")], - path: "Sources/ContainedCore" - ), - // Runtime adapter contracts. Keep this generic so Apple container, Docker-compatible, - // and future engines can share one app-facing capability/client boundary. - .target( - name: "ContainedRuntime", - dependencies: ["ContainedCore"], - path: "Sources/ContainedRuntime" - ), - // Current Apple `container` CLI adapter. Future runtime adapters should be sibling targets, - // not branches inside the app stores or SwiftUI views. - .target( - name: "AppleContainerRuntime", - dependencies: ["ContainedCore", "ContainedRuntime"], - path: "Sources/AppleContainerRuntime" - ), - // The SwiftUI app, including Sparkle wiring for signed release builds. - .executableTarget( - name: "Contained", + name: "ContainedApp", dependencies: [ - "ContainedCore", - "ContainedRuntime", - "AppleContainerRuntime", + .product(name: "ContainedCore", package: "ContainedCore"), + .product(name: "ContainedRuntime", package: "ContainedRuntime"), + .product(name: "AppleContainerRuntime", package: "AppleContainerRuntime"), .product(name: "ContainedDesignSystem", package: "ContainedDesignSystem"), .product(name: "ContainedNavigation", package: "ContainedNavigation"), + .product(name: "ContainedPreviewSupport", package: "ContainedPreviewSupport"), .product(name: "SwiftTerm", package: "SwiftTerm"), .product(name: "Sparkle", package: "Sparkle"), ], - path: "Sources/Contained", + path: "Sources/ContainedApp", resources: [.process("Resources")], swiftSettings: [ .define("CONTAINED_DEBUG_TOOLS", .when(configuration: .debug)), ] ), - .testTarget( - name: "ContainedCoreTests", - dependencies: ["ContainedCore"], - path: "Tests/ContainedCoreTests", - resources: [.copy("Fixtures")] - ), - .testTarget( - name: "ContainedRuntimeTests", - dependencies: ["ContainedCore", "ContainedRuntime", "AppleContainerRuntime"], - path: "Tests/ContainedRuntimeTests", - resources: [.copy("Fixtures")] + // SwiftPM executable launcher used by bundle/release scripts. + .executableTarget( + name: "Contained", + dependencies: ["ContainedApp"], + path: "Sources/Contained" ), // Tests for app-target value types (RunSpec form state and runtime-translated create mapping). - // Imports the - // executable target with @testable. + // Imports the shared app module with @testable. .testTarget( name: "ContainedAppTests", - dependencies: ["Contained", "ContainedCore", "ContainedRuntime", "AppleContainerRuntime"], + dependencies: [ + "ContainedApp", + .product(name: "ContainedCore", package: "ContainedCore"), + .product(name: "ContainedRuntime", package: "ContainedRuntime"), + .product(name: "AppleContainerRuntime", package: "AppleContainerRuntime"), + ], path: "Tests/ContainedAppTests" ), - .testTarget( - name: "ContainedDesignSystemTests", - dependencies: [.product(name: "ContainedDesignSystem", package: "ContainedDesignSystem")], - path: "Tests/ContainedDesignSystemTests" - ), - .testTarget( - name: "ContainedNavigationTests", - dependencies: [.product(name: "ContainedNavigation", package: "ContainedNavigation")], - path: "Tests/ContainedNavigationTests" - ), ] ) diff --git a/Packages/AppleContainerRuntime/Package.swift b/Packages/AppleContainerRuntime/Package.swift new file mode 100644 index 00000000..10c0ea1c --- /dev/null +++ b/Packages/AppleContainerRuntime/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "AppleContainerRuntime", + platforms: [.macOS(.v26)], + products: [ + .library(name: "AppleContainerRuntime", targets: ["AppleContainerRuntime"]), + ], + dependencies: [ + .package(path: "../ContainedCore"), + .package(path: "../ContainedRuntime"), + ], + targets: [ + .target( + name: "AppleContainerRuntime", + dependencies: [ + .product(name: "ContainedCore", package: "ContainedCore"), + .product(name: "ContainedRuntime", package: "ContainedRuntime"), + ] + ), + .testTarget( + name: "AppleContainerRuntimeTests", + dependencies: [ + "AppleContainerRuntime", + .product(name: "ContainedCore", package: "ContainedCore"), + .product(name: "ContainedRuntime", package: "ContainedRuntime"), + ], + resources: [.copy("Fixtures")] + ), + ] +) diff --git a/Packages/AppleContainerRuntime/README.md b/Packages/AppleContainerRuntime/README.md new file mode 100644 index 00000000..cdf37a0c --- /dev/null +++ b/Packages/AppleContainerRuntime/README.md @@ -0,0 +1,39 @@ +# AppleContainerRuntime + +AppleContainerRuntime is the concrete adapter for Apple's `container` CLI. It +conforms to `ContainerRuntimeClient` and keeps Apple-specific translation out of +the app. + +## Owns + +- `AppleContainerClient`, the current `container` runtime client. +- Apple CLI discovery through `AppleContainerCLILocator`. +- Apple create/import/default translation from `ContainerCreateRequest`. +- Apple stats-table parsing for `container stats --format table`. + +## Does Not Own + +- SwiftUI views or app stores. +- Localized copy or app error presentation. +- Docker-compatible, Podman, Lima, remote, or future runtime behavior. + +Future engines should be sibling packages that conform to +`ContainerRuntimeClient` rather than switches inside the app. + +## Example + +```swift +import AppleContainerRuntime +import ContainedCore + +let client = AppleContainerClient() +let preview = try client.previewCreateCommand(for: ContainerCreateRequest()) +print(preview.command) +``` + +## Build And Test + +```sh +swift build --package-path Packages/AppleContainerRuntime +swift test --package-path Packages/AppleContainerRuntime +``` diff --git a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/AppleContainerRuntime.docc/AppleContainerRuntime.md b/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/AppleContainerRuntime.docc/AppleContainerRuntime.md new file mode 100644 index 00000000..42ae6baf --- /dev/null +++ b/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/AppleContainerRuntime.docc/AppleContainerRuntime.md @@ -0,0 +1,12 @@ +# ``AppleContainerRuntime`` + +Apple `container` adapter for the shared runtime contract. + +## Overview + +This package owns Apple-specific process execution, decode behavior, create and +Compose translation, and stats parsing. It depends on `ContainedCore` for pure +models and on `ContainedRuntime` for the adapter protocol. + +The app should talk to this package through `ContainerRuntimeClient`. SwiftUI +views should not assemble Apple CLI argv directly. diff --git a/Sources/AppleContainerRuntime/AppleContainerClient.swift b/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Clients/AppleContainerClient.swift similarity index 100% rename from Sources/AppleContainerRuntime/AppleContainerClient.swift rename to Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Clients/AppleContainerClient.swift diff --git a/Sources/AppleContainerRuntime/ContainerStatsTableParser.swift b/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Parsing/ContainerStatsTableParser.swift similarity index 100% rename from Sources/AppleContainerRuntime/ContainerStatsTableParser.swift rename to Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Parsing/ContainerStatsTableParser.swift diff --git a/Sources/AppleContainerRuntime/AppleContainerCLILocator.swift b/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Support/AppleContainerCLILocator.swift similarity index 100% rename from Sources/AppleContainerRuntime/AppleContainerCLILocator.swift rename to Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Support/AppleContainerCLILocator.swift diff --git a/Sources/AppleContainerRuntime/AppleContainerCreateTranslator.swift b/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Translation/AppleContainerCreateTranslator.swift similarity index 100% rename from Sources/AppleContainerRuntime/AppleContainerCreateTranslator.swift rename to Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Translation/AppleContainerCreateTranslator.swift diff --git a/Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/AppleContainerRuntimeTests.swift similarity index 100% rename from Tests/ContainedRuntimeTests/AppleContainerRuntimeTests.swift rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/AppleContainerRuntimeTests.swift diff --git a/Tests/ContainedRuntimeTests/Fixtures.swift b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures.swift similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures.swift rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures.swift diff --git a/Tests/ContainedCoreTests/Fixtures/df.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/df.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/df.json rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/df.json diff --git a/Tests/ContainedCoreTests/Fixtures/image-inspect.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/image-inspect.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/image-inspect.json rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/image-inspect.json diff --git a/Tests/ContainedCoreTests/Fixtures/images-error.txt b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/images-error.txt similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/images-error.txt rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/images-error.txt diff --git a/Tests/ContainedCoreTests/Fixtures/inspect.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/inspect.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/inspect.json rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/inspect.json diff --git a/Tests/ContainedCoreTests/Fixtures/list-current.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list-current.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/list-current.json rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list-current.json diff --git a/Tests/ContainedCoreTests/Fixtures/list.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/list.json rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list.json diff --git a/Tests/ContainedCoreTests/Fixtures/networks.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/networks.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/networks.json rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/networks.json diff --git a/Tests/ContainedCoreTests/Fixtures/stats-table.txt b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats-table.txt similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/stats-table.txt rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats-table.txt diff --git a/Tests/ContainedCoreTests/Fixtures/stats.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/stats.json rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats.json diff --git a/Tests/ContainedCoreTests/Fixtures/status.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/status.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/status.json rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/status.json diff --git a/Tests/ContainedCoreTests/Fixtures/volumes.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/volumes.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/volumes.json rename to Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/volumes.json diff --git a/Packages/ContainedCore/Package.swift b/Packages/ContainedCore/Package.swift new file mode 100644 index 00000000..9e66e67c --- /dev/null +++ b/Packages/ContainedCore/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "ContainedCore", + platforms: [.macOS(.v26)], + products: [ + .library(name: "ContainedCore", targets: ["ContainedCore"]), + ], + dependencies: [ + .package(url: "https://github.com/jpsim/Yams.git", from: "6.2.2"), + ], + targets: [ + .target( + name: "ContainedCore", + dependencies: [.product(name: "Yams", package: "Yams")] + ), + .testTarget( + name: "ContainedCoreTests", + dependencies: ["ContainedCore"], + resources: [.copy("Fixtures")] + ), + ] +) diff --git a/Packages/ContainedCore/README.md b/Packages/ContainedCore/README.md new file mode 100644 index 00000000..7cbc644e --- /dev/null +++ b/Packages/ContainedCore/README.md @@ -0,0 +1,41 @@ +# ContainedCore + +ContainedCore is the pure model and command-building package for Contained. It +contains no SwiftUI, app state, Sparkle, persistence, or presentation code. + +## Owns + +- Runtime-neutral models such as `ContainerSnapshot`, `ContainerCreateRequest`, + `RuntimeKind`, resources, stats, and system information. +- Apple `container` command builders in `ContainerCommands`. +- Compose parsing and ordering helpers. +- Display-neutral package errors through `ContainedPackageError`. +- Pure metric and normalization helpers used by app widgets and history. + +## Does Not Own + +- Localized strings or user-facing copy. +- SwiftUI views, stores, routing, or settings. +- Runtime execution or process launching. +- Apple-specific create/import translation beyond argv builders. + +## Example + +```swift +import ContainedCore + +var request = ContainerCreateRequest() +request.name = "web" +request.image = "nginx:latest" +request.runtimeKind = .appleContainer +request.ports = [.init(hostPort: "8080", containerPort: "80", proto: "tcp")] + +let command = ContainerCommands.run(request) +``` + +## Build And Test + +```sh +swift build --package-path Packages/ContainedCore +swift test --package-path Packages/ContainedCore +``` diff --git a/Sources/ContainedCore/Services/ContainerCommands.swift b/Packages/ContainedCore/Sources/ContainedCore/Commands/ContainerCommands.swift similarity index 100% rename from Sources/ContainedCore/Services/ContainerCommands.swift rename to Packages/ContainedCore/Sources/ContainedCore/Commands/ContainerCommands.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md b/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md new file mode 100644 index 00000000..60d03542 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md @@ -0,0 +1,37 @@ +# ``ContainedCore`` + +Pure models, command builders, compose parsing, and display-neutral error +metadata shared by Contained packages and the app. + +## Overview + +`ContainedCore` is intentionally UI-free. It can be imported by runtime +adapters, preview fixtures, tests, or future host apps without bringing in +SwiftUI, Sparkle, SwiftData, stores, or localization resources. + +Use it for: + +- runtime-neutral container create and recreate requests +- decoded Apple `container` JSON models +- Compose project parsing +- Apple `container` argv builders +- stats normalization helpers +- stable package error metadata + +The app remains responsible for localized presentation and user-visible error +copy. + +## Example + +```swift +import ContainedCore + +let kind = RuntimeKind(rawValue: "docker-compatible") +let context = StatsNormalizationContext( + mode: .container, + containerCPUCores: 2, + containerMemoryLimitBytes: 2_147_483_648, + hostCPUCores: 10, + hostMemoryBytes: 34_359_738_368 +) +``` diff --git a/Sources/ContainedCore/Support/ContainedPackageError.swift b/Packages/ContainedCore/Sources/ContainedCore/Errors/ContainedPackageError.swift similarity index 100% rename from Sources/ContainedCore/Support/ContainedPackageError.swift rename to Packages/ContainedCore/Sources/ContainedCore/Errors/ContainedPackageError.swift diff --git a/Sources/ContainedCore/Models/ComposeProject.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/ComposeProject.swift similarity index 100% rename from Sources/ContainedCore/Models/ComposeProject.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/ComposeProject.swift diff --git a/Sources/ContainedCore/Models/Container.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/Container.swift similarity index 100% rename from Sources/ContainedCore/Models/Container.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/Container.swift diff --git a/Sources/ContainedCore/Models/ContainerCreateRequest.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/ContainerCreateRequest.swift similarity index 100% rename from Sources/ContainedCore/Models/ContainerCreateRequest.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/ContainerCreateRequest.swift diff --git a/Sources/ContainedCore/Models/ContainerJSON.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/ContainerJSON.swift similarity index 100% rename from Sources/ContainedCore/Models/ContainerJSON.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/ContainerJSON.swift diff --git a/Sources/ContainedCore/Models/ContainerStats.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/ContainerStats.swift similarity index 99% rename from Sources/ContainedCore/Models/ContainerStats.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/ContainerStats.swift index af8ae1e1..cc28c27b 100644 --- a/Sources/ContainedCore/Models/ContainerStats.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Models/ContainerStats.swift @@ -90,7 +90,7 @@ public struct StatsDelta: Sendable, Hashable { self.numProcesses = numProcesses } - /// Pleasant fake values for previews (e.g. the card customizer). + /// Deterministic sample values for previews and package examples. public static func sample(id: String = "preview") -> StatsDelta { StatsDelta(id: id, cpuCoreFraction: 0.42, memoryUsageBytes: 384_000_000, memoryLimitBytes: 1_073_741_824, netRxBytesPerSec: 124_000, netTxBytesPerSec: 48_000, blockReadBytesPerSec: 0, diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/GraphMetric.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/GraphMetric.swift new file mode 100644 index 00000000..6f7f3c7f --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Models/GraphMetric.swift @@ -0,0 +1,75 @@ +/// A runtime-neutral metric that can be graphed from live or persisted container stats. +public enum GraphMetric: String, CaseIterable, Identifiable, Codable, Hashable, Sendable { + case cpu, memory, netRx, netTx, diskRead, diskWrite + + public var id: String { rawValue } + + public func value(from delta: StatsDelta, + snapshot: ContainerSnapshot? = nil, + normalization: StatsNormalizationContext = .containerSpecific) -> Double { + switch self { + case .cpu: return Self.cpuFraction(from: delta, snapshot: snapshot, normalization: normalization) + case .memory: return Self.memoryFraction(from: delta, snapshot: snapshot, normalization: normalization) + case .netRx: return delta.netRxBytesPerSec + case .netTx: return delta.netTxBytesPerSec + case .diskRead: return delta.blockReadBytesPerSec + case .diskWrite: return delta.blockWriteBytesPerSec + } + } + + public func value(from sample: any MetricHistorySample, + snapshot: ContainerSnapshot? = nil, + normalization: StatsNormalizationContext = .containerSpecific, + memoryFallbackBytes: UInt64 = 0) -> Double { + switch self { + case .cpu: + return Self.sanitized(sample.cpuFraction) / normalization.cpuLimit(for: snapshot) + case .memory: + let limit = normalization.memoryLimitBytes(for: snapshot, fallback: memoryFallbackBytes) + guard limit > 0 else { return 0 } + return Self.sanitized(sample.memoryBytes / Double(limit)) + case .netRx: + return Self.sanitized(sample.netRxBytesPerSec) + case .netTx: + return Self.sanitized(sample.netTxBytesPerSec) + case .diskRead: + return Self.sanitized(sample.diskReadBytesPerSec) + case .diskWrite: + return Self.sanitized(sample.diskWriteBytesPerSec) + } + } + + public static func cpuFraction(from delta: StatsDelta, + snapshot: ContainerSnapshot?, + normalization: StatsNormalizationContext = .containerSpecific) -> Double { + sanitized(delta.cpuCoreFraction) / normalization.cpuLimit(for: snapshot) + } + + public static func memoryFraction(from delta: StatsDelta, + snapshot: ContainerSnapshot?, + normalization: StatsNormalizationContext = .containerSpecific) -> Double { + let limit = memoryLimitBytes(for: delta, snapshot: snapshot, normalization: normalization) + guard limit > 0 else { return 0 } + return sanitized(Double(delta.memoryUsageBytes) / Double(limit)) + } + + public static func memoryLimitBytes(for delta: StatsDelta, + snapshot: ContainerSnapshot?, + normalization: StatsNormalizationContext = .containerSpecific) -> UInt64 { + normalization.memoryLimitBytes(for: delta, snapshot: snapshot) + } + + private static func sanitized(_ value: Double) -> Double { + guard value.isFinite, value > 0 else { return 0 } + return value + } +} + +public protocol MetricHistorySample { + var cpuFraction: Double { get } + var memoryBytes: Double { get } + var netRxBytesPerSec: Double { get } + var netTxBytesPerSec: Double { get } + var diskReadBytesPerSec: Double { get } + var diskWriteBytesPerSec: Double { get } +} diff --git a/Sources/ContainedCore/Models/HealthCheck.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/HealthCheck.swift similarity index 100% rename from Sources/ContainedCore/Models/HealthCheck.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/HealthCheck.swift diff --git a/Sources/ContainedCore/Models/HubSearch.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/HubSearch.swift similarity index 100% rename from Sources/ContainedCore/Models/HubSearch.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/HubSearch.swift diff --git a/Sources/ContainedCore/Models/ImageResource.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/ImageResource.swift similarity index 100% rename from Sources/ContainedCore/Models/ImageResource.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/ImageResource.swift diff --git a/Sources/ContainedCore/Models/ImageUpdateStatus.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/ImageUpdateStatus.swift similarity index 100% rename from Sources/ContainedCore/Models/ImageUpdateStatus.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/ImageUpdateStatus.swift diff --git a/Sources/ContainedCore/Models/LocalImageTagGroup.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/LocalImageTagGroup.swift similarity index 100% rename from Sources/ContainedCore/Models/LocalImageTagGroup.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/LocalImageTagGroup.swift diff --git a/Sources/ContainedCore/Models/NetworkResource.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/NetworkResource.swift similarity index 100% rename from Sources/ContainedCore/Models/NetworkResource.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/NetworkResource.swift diff --git a/Sources/ContainedCore/Models/RegistryImageReference.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/RegistryImageReference.swift similarity index 100% rename from Sources/ContainedCore/Models/RegistryImageReference.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/RegistryImageReference.swift diff --git a/Sources/ContainedCore/Models/RegistryLogin.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/RegistryLogin.swift similarity index 100% rename from Sources/ContainedCore/Models/RegistryLogin.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/RegistryLogin.swift diff --git a/Sources/ContainedCore/Models/RestartPolicy.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/RestartPolicy.swift similarity index 100% rename from Sources/ContainedCore/Models/RestartPolicy.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/RestartPolicy.swift diff --git a/Sources/ContainedCore/Models/RuntimeKind.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/RuntimeKind.swift similarity index 100% rename from Sources/ContainedCore/Models/RuntimeKind.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/RuntimeKind.swift diff --git a/Sources/ContainedCore/Models/RuntimeStatus.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/RuntimeStatus.swift similarity index 100% rename from Sources/ContainedCore/Models/RuntimeStatus.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/RuntimeStatus.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/StatsNormalization.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/StatsNormalization.swift new file mode 100644 index 00000000..9812cd84 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Models/StatsNormalization.swift @@ -0,0 +1,50 @@ +import Foundation + +public enum StatsNormalizationMode: String, CaseIterable, Identifiable, Codable, Hashable, Sendable { + case container + case machine = "global" + + public var id: String { rawValue } +} + +public struct StatsNormalizationContext: Equatable, Sendable { + public var mode: StatsNormalizationMode + public var machineCPUs: Int? + public var machineMemoryBytes: UInt64? + + public static let containerSpecific = StatsNormalizationContext(mode: .container) + + public init(mode: StatsNormalizationMode = .container, + machineCPUs: Int? = nil, + machineMemoryBytes: UInt64? = nil) { + self.mode = mode + self.machineCPUs = machineCPUs + self.machineMemoryBytes = machineMemoryBytes + } + + public func cpuLimit(for snapshot: ContainerSnapshot?) -> Double { + switch mode { + case .container: + return max(Double(snapshot?.configuration.resources.cpus ?? 1), 1) + case .machine: + return max(Double(machineCPUs ?? ProcessInfo.processInfo.activeProcessorCount), 1) + } + } + + public func memoryLimitBytes(for snapshot: ContainerSnapshot?, fallback: UInt64 = 0) -> UInt64 { + switch mode { + case .container: + let configuredLimit = snapshot?.configuration.resources.memoryInBytes ?? 0 + return configuredLimit > 0 ? configuredLimit : fallback + case .machine: + let machineMemory = machineMemoryBytes ?? ProcessInfo.processInfo.physicalMemory + if machineMemory > 0 { return machineMemory } + let configuredLimit = snapshot?.configuration.resources.memoryInBytes ?? 0 + return configuredLimit > 0 ? configuredLimit : fallback + } + } + + public func memoryLimitBytes(for delta: StatsDelta, snapshot: ContainerSnapshot?) -> UInt64 { + memoryLimitBytes(for: snapshot, fallback: delta.memoryLimitBytes) + } +} diff --git a/Sources/ContainedCore/Models/SystemInfo.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/SystemInfo.swift similarity index 100% rename from Sources/ContainedCore/Models/SystemInfo.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/SystemInfo.swift diff --git a/Sources/ContainedCore/Models/SystemProperties.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/SystemProperties.swift similarity index 100% rename from Sources/ContainedCore/Models/SystemProperties.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/SystemProperties.swift diff --git a/Sources/ContainedCore/Models/VolumeResource.swift b/Packages/ContainedCore/Sources/ContainedCore/Models/VolumeResource.swift similarity index 100% rename from Sources/ContainedCore/Models/VolumeResource.swift rename to Packages/ContainedCore/Sources/ContainedCore/Models/VolumeResource.swift diff --git a/Sources/ContainedCore/Services/RegistryManifestClient.swift b/Packages/ContainedCore/Sources/ContainedCore/Support/RegistryManifestClient.swift similarity index 100% rename from Sources/ContainedCore/Services/RegistryManifestClient.swift rename to Packages/ContainedCore/Sources/ContainedCore/Support/RegistryManifestClient.swift diff --git a/Tests/ContainedCoreTests/CommandTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift similarity index 100% rename from Tests/ContainedCoreTests/CommandTests.swift rename to Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift diff --git a/Tests/ContainedCoreTests/DecodingTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift similarity index 100% rename from Tests/ContainedCoreTests/DecodingTests.swift rename to Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift diff --git a/Tests/ContainedCoreTests/Fixtures.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift similarity index 100% rename from Tests/ContainedCoreTests/Fixtures.swift rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift diff --git a/Tests/ContainedRuntimeTests/Fixtures/df.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/df.json similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures/df.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/df.json diff --git a/Tests/ContainedRuntimeTests/Fixtures/image-inspect.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/image-inspect.json similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures/image-inspect.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/image-inspect.json diff --git a/Tests/ContainedRuntimeTests/Fixtures/images-error.txt b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/images-error.txt similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures/images-error.txt rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/images-error.txt diff --git a/Tests/ContainedRuntimeTests/Fixtures/inspect.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/inspect.json similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures/inspect.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/inspect.json diff --git a/Tests/ContainedRuntimeTests/Fixtures/list-current.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list-current.json similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures/list-current.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list-current.json diff --git a/Tests/ContainedRuntimeTests/Fixtures/list.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list.json similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures/list.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list.json diff --git a/Tests/ContainedRuntimeTests/Fixtures/networks.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/networks.json similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures/networks.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/networks.json diff --git a/Tests/ContainedRuntimeTests/Fixtures/stats-table.txt b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/stats-table.txt similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures/stats-table.txt rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/stats-table.txt diff --git a/Tests/ContainedRuntimeTests/Fixtures/stats.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/stats.json similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures/stats.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/stats.json diff --git a/Tests/ContainedRuntimeTests/Fixtures/status.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/status.json similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures/status.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/status.json diff --git a/Tests/ContainedRuntimeTests/Fixtures/volumes.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/volumes.json similarity index 100% rename from Tests/ContainedRuntimeTests/Fixtures/volumes.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/volumes.json diff --git a/Tests/ContainedCoreTests/ImageWorkflowTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/ImageWorkflowTests.swift similarity index 100% rename from Tests/ContainedCoreTests/ImageWorkflowTests.swift rename to Packages/ContainedCore/Tests/ContainedCoreTests/ImageWorkflowTests.swift diff --git a/Tests/ContainedCoreTests/Phase4Tests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift similarity index 98% rename from Tests/ContainedCoreTests/Phase4Tests.swift rename to Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift index f837915f..ebd8ca07 100644 --- a/Tests/ContainedCoreTests/Phase4Tests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import ContainedCore -@Suite("Phase 4 — streaming, watchdog, resource writes") -struct Phase4Tests { +@Suite("Runtime command workflows") +struct RuntimeWorkflowTests { // MARK: Command builders diff --git a/Packages/ContainedDesignSystem/Package.swift b/Packages/ContainedDesignSystem/Package.swift index 2ce38e13..84f60448 100644 --- a/Packages/ContainedDesignSystem/Package.swift +++ b/Packages/ContainedDesignSystem/Package.swift @@ -9,5 +9,9 @@ let package = Package( ], targets: [ .target(name: "ContainedDesignSystem"), + .testTarget( + name: "ContainedDesignSystemTests", + dependencies: ["ContainedDesignSystem"] + ), ] ) diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 4e3887bd..7f95311c 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -4,7 +4,7 @@ SwiftUI/AppKit visual language. Use it for app-agnostic UI primitives: tokens, glass surfaces, panel/page/sheet -scaffolds, toolbar controls, resource-card chrome, sparklines, JSON/stream +scaffolds, toolbar controls, design-card chrome, sparklines, JSON/stream surfaces, color controls, clipboard helpers, and small chrome such as badges, keycaps, status dots, metric tiles, terminal surfaces, and selection overlays. @@ -37,9 +37,9 @@ This package currently depends only on platform frameworks available to a macOS ## What Belongs Here -- `Tokens` for spacing, radius, toolbar, panel, icon, form, chart, badge, +- `DesignTokens` for spacing, radius, toolbar, panel, icon, form, chart, badge, keycap, card, terminal, and menu-bar constants. -- `WindowMaterial`, `AppTint`, `ColorLayerBlendMode`, and root environment +- `WindowMaterial`, `DesignTint`, `ColorLayerBlendMode`, and root environment values for shared material/tint policy. - `GlassSurface`, `glassSurface`, `glassCapsuleSurface`, and visual-effect helpers for all reusable glass treatment. @@ -50,12 +50,12 @@ This package currently depends only on platform frameworks available to a macOS `DesignStatusBanner`, and toolbar controls for package-owned command chrome. `GlassButton`, `GlassButtonItem`, and `GlassButtonInputItem` are lower-level package composition pieces. -- `ResourceCard`, `ResourceCardPages`, `ResourceCardFooterChip`, - `ResourceCardFooterButton`, `ResourceCardWidgetGroup`, `ResourceCardInsetSection`, - and other `ResourceCard*` pieces for repeated card layouts and card-local controls. - App code should use `ResourceCard`; `ResourceGlassCard` and header primitives are +- `DesignCard`, `DesignCardPages`, `DesignCardFooterChip`, + `DesignCardFooterButton`, `DesignCardWidgetGroup`, `DesignCardInsetSection`, + and other `DesignCard*` pieces for repeated card layouts and card-local controls. + App code should use `DesignCard`; `DesignCardSurface` and header primitives are lower-level package composition pieces. - Use `resourceCardFloatingControls` and `resourceCardProgressOverlay` for + Use `designCardFloatingControls` and `designCardProgressOverlay` for card overlays instead of app-local `.overlay` recipes. - `ActivityStatusView` with `ActivityStatusPresentation`, where callers provide plain status text/progress instead of app model objects. @@ -79,7 +79,7 @@ individual views: struct AppRoot: View { var body: some View { DesignSystemExample() - .tint(AppTint.azure.color) + .tint(DesignTint.azure.color) .environment(\.modalMaterial, WindowMaterial.sheet) .environment(\.buttonMaterial, WindowMaterial.glassClear) .environment(\.cardMaterial, WindowMaterial.glassRegular) @@ -100,13 +100,13 @@ import SwiftUI import ContainedDesignSystem struct DesignSystemExample: View { - @State private var tint = AppTint.azure + @State private var tint = DesignTint.azure var body: some View { PageScaffold(symbol: "shippingbox", title: "Containers", subtitle: "Local runtime") { - VStack(spacing: Tokens.Space.l) { + VStack(spacing: DesignTokens.Space.l) { PanelSection(header: "Appearance") { PanelRow(title: "Accent") { TintSelector(selection: $tint, labelForTint: tintName) @@ -116,11 +116,11 @@ struct DesignSystemExample: View { } } - ResourceCard(size: .small, + DesignCard(size: .small, elevated: false, title: "web", subtitle: "nginx:latest") { - ResourceCardIconChip(symbol: "shippingbox.fill", + DesignCardIconChip(symbol: "shippingbox.fill", tint: tint.color) } titleAccessory: { EmptyView() @@ -152,7 +152,7 @@ struct DesignSystemExample: View { .environment(\.designSystemShowsInfoTips, true) } - private func tintName(_ tint: AppTint) -> String { + private func tintName(_ tint: DesignTint) -> String { switch tint { case .multicolor: return "App Accent" case .graphite: return "Graphite" @@ -173,10 +173,10 @@ struct DesignSystemExample: View { Keep card-local controls in the package. Feature views provide plain values and actions instead of assembling headers, footer groups, or expanded-card page rails: -`ResourceCard` owns card anatomy: +`DesignCard` owns card anatomy: - the header is always sticky and visible -- page controls are declared through `ResourceCardPages`, stay mounted in the +- page controls are declared through `DesignCardPages`, stay mounted in the header trailing slot, and use `controlsReveal` for visibility - the body appears only when the card is expanded - the widget is sticky for `.large` cards and becomes body content for `.medium` @@ -188,25 +188,25 @@ struct CardControlsExample: View { @State private var metric = "cpu" private let pages = [ - ResourceCardPageControlItem(id: "overview", + DesignCardPageControlItem(id: "overview", title: "Overview", systemImage: "rectangle.grid.1x2"), - ResourceCardPageControlItem(id: "logs", + DesignCardPageControlItem(id: "logs", title: "Logs", systemImage: "text.alignleft") ] var body: some View { - ResourceCard(size: .large, + DesignCard(size: .large, title: "web", subtitle: "nginx:latest", - pages: ResourceCardPages(items: pages, + pages: DesignCardPages(items: pages, selection: page, tint: .accentColor, closeLabel: "Close", onSelect: { page = $0 }, onClose: {})) { - ResourceCardIconChip(symbol: "shippingbox.fill") + DesignCardIconChip(symbol: "shippingbox.fill") } titleAccessory: { EmptyView() } subtitleAccessory: { @@ -214,27 +214,27 @@ struct CardControlsExample: View { } headerAccessory: { EmptyView() } bodyContent: { - ResourceCardInsetSection(title: "Details") { - ResourceCardSubtitleText(text: "Ready") + DesignCardInsetSection(title: "Details") { + DesignCardSubtitleText(text: "Ready") } } footerLeading: { - ResourceCardFooterChip(isSelected: metric == "cpu", + DesignCardFooterChip(isSelected: metric == "cpu", tint: .accentColor, help: "CPU", action: { metric = "cpu" }) { Image(systemName: "cpu") } text: { - ResourceCardMetricText(text: "12%") + DesignCardMetricText(text: "12%") } } footerActions: { - ResourceCardFooterButton(systemName: "play.fill", + DesignCardFooterButton(systemName: "play.fill", help: "Start", tint: .accentColor) {} } widget: { LiveSparkline(samples: [0, 0.12, 0.18], color: .accentColor, scale: .fraction) - .frame(height: Tokens.ResourceCard.sparklineHeight) + .frame(height: DesignTokens.DesignCard.sparklineHeight) } } } @@ -328,16 +328,17 @@ DesignToolbarSearchField(text: $query, - DocC landing page: `Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md` - App-level guidance: - `../../docs/wiki/Design-System.md` + `../../docs/architecture/Design-System.md` - Navigation package: `../ContainedNavigation/README.md` ## Verification -Build the package by itself: +Build and test the package by itself: ```sh swift build --package-path Packages/ContainedDesignSystem +swift test --package-path Packages/ContainedDesignSystem ``` Build it through the app graph: diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCard.swift similarity index 83% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCard.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCard.swift index 1c135482..66a77b33 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCard.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCard.swift @@ -1,19 +1,19 @@ import SwiftUI -/// Text rendering style for the built-in `ResourceCard` title and subtitle lanes. -public enum ResourceCardTextStyle { +/// Text rendering style for the built-in `DesignCard` title and subtitle lanes. +public enum DesignCardTextStyle { case standard case monospaced } -/// Sentinel page type used by `ResourceCard` when a card has no page controls. -public enum ResourceCardNoPage: Hashable { +/// Sentinel page type used by `DesignCard` when a card has no page controls. +public enum DesignCardNoPage: Hashable { case none } -/// Typed page-control configuration for `ResourceCard`. -public struct ResourceCardPages { - public var items: [ResourceCardPageControlItem] +/// Typed page-control configuration for `DesignCard`. +public struct DesignCardPages { + public var items: [DesignCardPageControlItem] public var selection: ID public var tint: Color public var controlsReveal: Double @@ -21,7 +21,7 @@ public struct ResourceCardPages { public var onSelect: (ID) -> Void public var onClose: () -> Void - public init(items: [ResourceCardPageControlItem], + public init(items: [DesignCardPageControlItem], selection: ID, tint: Color, controlsReveal: Double = 1, @@ -38,14 +38,14 @@ public struct ResourceCardPages { } } -/// Source-of-truth resource card API. +/// Source-of-truth design card API. /// /// Feature code supplies semantic title/subtitle data plus optional slots; this view owns how those /// inputs become sticky header chrome, expanded body content, widgets, and footer controls. -public struct ResourceCard: View { - public var size: ResourceCardSize + public var size: DesignCardSize public var isExpanded: Bool public var cornerRadiusOverride: CGFloat? public var controlsVisible: Bool @@ -61,9 +61,9 @@ public struct ResourceCard Void public var title: String public var subtitle: String? - public var titleStyle: ResourceCardTextStyle - public var subtitleStyle: ResourceCardTextStyle - public var pages: ResourceCardPages? + public var titleStyle: DesignCardTextStyle + public var subtitleStyle: DesignCardTextStyle + public var pages: DesignCardPages? @ViewBuilder public var icon: () -> Icon @ViewBuilder public var titleAccessory: () -> TitleAccessory @ViewBuilder public var subtitleAccessory: () -> SubtitleAccessory @@ -75,7 +75,7 @@ public struct ResourceCard Void = {}, title: String, subtitle: String? = nil, - titleStyle: ResourceCardTextStyle = .standard, - subtitleStyle: ResourceCardTextStyle = .standard, - pages: ResourceCardPages?, + titleStyle: DesignCardTextStyle = .standard, + subtitleStyle: DesignCardTextStyle = .standard, + pages: DesignCardPages?, @ViewBuilder icon: @escaping () -> Icon, @ViewBuilder titleAccessory: @escaping () -> TitleAccessory, @ViewBuilder subtitleAccessory: @escaping () -> SubtitleAccessory, @@ -138,7 +138,7 @@ public struct ResourceCard some View { switch subtitleStyle { case .standard: - ResourceCardSubtitleText(text: text) + DesignCardSubtitleText(text: text) case .monospaced: - ResourceCardMonospacedSubtitleText(text: text) + DesignCardMonospacedSubtitleText(text: text) } } @@ -225,8 +225,8 @@ public struct ResourceCard Void = {}, title: String, subtitle: String? = nil, - titleStyle: ResourceCardTextStyle = .standard, - subtitleStyle: ResourceCardTextStyle = .standard, + titleStyle: DesignCardTextStyle = .standard, + subtitleStyle: DesignCardTextStyle = .standard, @ViewBuilder icon: @escaping () -> Icon, @ViewBuilder titleAccessory: @escaping () -> TitleAccessory, @ViewBuilder subtitleAccessory: @escaping () -> SubtitleAccessory, diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift similarity index 81% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift index bd83dda6..3e253dc9 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceCardChrome.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift @@ -2,15 +2,15 @@ import SwiftUI /// A reusable three-part card header: leading accessory, fill/truncate text block, and trailing /// button rail. This keeps the container/image cards using the same top-aligned chrome structure. -public struct ResourceCardHeader: View { +public struct DesignCardHeader: View { public var spacing: CGFloat public var padding: CGFloat @ViewBuilder public var leading: () -> Leading @ViewBuilder public var content: () -> Content @ViewBuilder public var trailing: () -> Trailing - public init(spacing: CGFloat = Tokens.ResourceCard.padding, - padding: CGFloat = Tokens.ResourceCard.padding, + public init(spacing: CGFloat = DesignTokens.DesignCard.padding, + padding: CGFloat = DesignTokens.DesignCard.padding, @ViewBuilder leading: @escaping () -> Leading, @ViewBuilder content: @escaping () -> Content, @ViewBuilder trailing: @escaping () -> Trailing) { @@ -40,16 +40,16 @@ public struct ResourceCardHeader: } } -/// Stable title/subtitle lane for `ResourceCardHeader`. +/// Stable title/subtitle lane for `DesignCardHeader`. /// /// Use this for card header text so the title and metadata stay anchored to the leading chip while /// expanded-card controls appear, disappear, or change page selection. -public struct ResourceCardHeaderTextBlock: View { +public struct DesignCardHeaderTextBlock: View { public var spacing: CGFloat @ViewBuilder public var title: () -> Title @ViewBuilder public var subtitle: () -> Subtitle - public init(spacing: CGFloat = Tokens.ResourceCard.compactTextSpacing, + public init(spacing: CGFloat = DesignTokens.DesignCard.compactTextSpacing, @ViewBuilder title: @escaping () -> Title, @ViewBuilder subtitle: @escaping () -> Subtitle) { self.spacing = spacing @@ -71,8 +71,8 @@ public struct ResourceCardHeaderTextBlock: View { } } -public extension ResourceCardHeaderTextBlock where Subtitle == EmptyView { - init(spacing: CGFloat = Tokens.ResourceCard.compactTextSpacing, +public extension DesignCardHeaderTextBlock where Subtitle == EmptyView { + init(spacing: CGFloat = DesignTokens.DesignCard.compactTextSpacing, @ViewBuilder title: @escaping () -> Title) { self.init(spacing: spacing, title: title) { EmptyView() @@ -81,12 +81,12 @@ public extension ResourceCardHeaderTextBlock where Subtitle == EmptyView { } /// A small reusable footer mini: optional icon + optional text, aligned on one baseline. -public struct ResourceCardFooterMini: View { +public struct DesignCardFooterMini: View { public var spacing: CGFloat @ViewBuilder public var icon: () -> Icon @ViewBuilder public var text: () -> TextContent - public init(spacing: CGFloat = Tokens.Space.xs, + public init(spacing: CGFloat = DesignTokens.Space.xs, @ViewBuilder icon: @escaping () -> Icon, @ViewBuilder text: @escaping () -> TextContent) { self.spacing = spacing @@ -104,11 +104,11 @@ public struct ResourceCardFooterMini: View { } } -/// A flat inset section for content that lives inside an expanded `ResourceGlassCard`. +/// A flat inset section for content that lives inside an expanded `DesignCardSurface`. /// /// Use this for charts, process lists, read-only fields, and terminal overlays inside a card body. /// It intentionally avoids creating a second card-shaped glass surface inside the parent card. -public struct ResourceCardInsetSection: View { +public struct DesignCardInsetSection: View { public var title: String? public var alignment: HorizontalAlignment public var spacing: CGFloat @@ -117,8 +117,8 @@ public struct ResourceCardInsetSection: View { public init(title: String? = nil, alignment: HorizontalAlignment = .leading, - spacing: CGFloat = Tokens.Space.s, - padding: CGFloat = Tokens.Space.s, + spacing: CGFloat = DesignTokens.Space.s, + padding: CGFloat = DesignTokens.Space.s, @ViewBuilder content: @escaping () -> Content) { self.title = title self.alignment = alignment @@ -128,19 +128,19 @@ public struct ResourceCardInsetSection: View { } public var body: some View { - VStack(alignment: alignment, spacing: Tokens.Space.s) { + VStack(alignment: alignment, spacing: DesignTokens.Space.s) { if let title { Text(title) .font(.headline) - .padding(.leading, Tokens.Space.xs) + .padding(.leading, DesignTokens.Space.xs) } LazyVStack(alignment: alignment, spacing: spacing) { content() } .padding(padding) .frame(maxWidth: .infinity, alignment: Alignment(horizontal: alignment, vertical: .center)) - .background(AppMaterial.toolbarHoverFill, - in: RoundedRectangle(cornerRadius: Tokens.Radius.control, + .background(DesignMaterial.toolbarHoverFill, + in: RoundedRectangle(cornerRadius: DesignTokens.Radius.control, style: .continuous)) } .frame(maxWidth: .infinity, alignment: Alignment(horizontal: alignment, vertical: .center)) @@ -148,7 +148,7 @@ public struct ResourceCardInsetSection: View { } /// A selectable footer chip for card widgets, filters, and compact tab-like metadata. -public struct ResourceCardFooterChip: View { +public struct DesignCardFooterChip: View { public var isSelected: Bool public var tint: Color public var help: String @@ -172,7 +172,7 @@ public struct ResourceCardFooterChip: View { public var body: some View { Button(action: action) { - ResourceCardFooterMini { + DesignCardFooterMini { icon() } text: { text() @@ -185,8 +185,8 @@ public struct ResourceCardFooterChip: View { } } -/// Shared icon-only action used by resource-card footers. -public struct ResourceCardFooterButton: View { +/// Shared icon-only action used by design-card footers. +public struct DesignCardFooterButton: View { public var systemName: String public var help: String public var tint: Color? @@ -207,7 +207,7 @@ public struct ResourceCardFooterButton: View { public var body: some View { Button(role: role, action: action) { - ResourceCardFooterMini { + DesignCardFooterMini { Image(systemName: systemName).font(.body) } text: { EmptyView() @@ -226,7 +226,7 @@ public struct ResourceCardFooterButton: View { } } -public struct ResourceCardPageControlItem: Identifiable, Hashable { +public struct DesignCardPageControlItem: Identifiable, Hashable { public var id: ID public var title: String public var systemImage: String @@ -239,8 +239,8 @@ public struct ResourceCardPageControlItem: Identifiable, Hashable } /// Shared expanded-card page rail with page icons plus a close affordance. -public struct ResourceCardPageControls: View { - public var items: [ResourceCardPageControlItem] +public struct DesignCardPageControls: View { + public var items: [DesignCardPageControlItem] public var selection: ID public var tint: Color public var controlsReveal: Double @@ -248,7 +248,7 @@ public struct ResourceCardPageControls: View { public var onSelect: (ID) -> Void public var onClose: () -> Void - public init(items: [ResourceCardPageControlItem], + public init(items: [DesignCardPageControlItem], selection: ID, tint: Color, controlsReveal: Double = 1, @@ -284,7 +284,7 @@ public struct ResourceCardPageControls: View { } /// A reusable footer item band that hugs its content and anchors either left or right. -public struct ResourceCardFooterGroup: View { +public struct DesignCardFooterGroup: View { public enum Alignment { case leading, trailing } @@ -294,7 +294,7 @@ public struct ResourceCardFooterGroup: View { @ViewBuilder public var content: () -> Content public init(alignment: Alignment = .leading, - spacing: CGFloat = Tokens.ResourceCard.padding, + spacing: CGFloat = DesignTokens.DesignCard.padding, @ViewBuilder content: @escaping () -> Content) { self.alignment = alignment self.spacing = spacing @@ -309,11 +309,11 @@ public struct ResourceCardFooterGroup: View { } /// A horizontal group for content in a card widget band. -public struct ResourceCardWidgetGroup: View { +public struct DesignCardWidgetGroup: View { public var spacing: CGFloat @ViewBuilder public var content: () -> Content - public init(spacing: CGFloat = Tokens.ResourceCard.padding, + public init(spacing: CGFloat = DesignTokens.DesignCard.padding, @ViewBuilder content: @escaping () -> Content) { self.spacing = spacing self.content = content @@ -329,7 +329,7 @@ public struct ResourceCardWidgetGroup: View { } /// A reusable footer band with a left group, right group, and optional widget stacked above them. -public struct ResourceCardFooter: View { +public struct DesignCardFooter: View { public var showWidget: Bool public var actionsVisible: Bool public var spacing: CGFloat @@ -342,10 +342,10 @@ public struct ResourceCardFooter: V public init(showWidget: Bool = false, actionsVisible: Bool = true, - spacing: CGFloat = Tokens.ResourceCard.padding, - horizontalPadding: CGFloat = Tokens.ResourceCard.padding, + spacing: CGFloat = DesignTokens.DesignCard.padding, + horizontalPadding: CGFloat = DesignTokens.DesignCard.padding, topPadding: CGFloat = 0, - bottomPadding: CGFloat = Tokens.ResourceCard.padding, + bottomPadding: CGFloat = DesignTokens.DesignCard.padding, @ViewBuilder leading: @escaping () -> Leading, @ViewBuilder trailing: @escaping () -> Trailing, @ViewBuilder widget: @escaping () -> Widget) { @@ -366,10 +366,10 @@ public struct ResourceCardFooter: V widget() } HStack(spacing: spacing) { - ResourceCardFooterGroup(alignment: .leading, spacing: spacing) { + DesignCardFooterGroup(alignment: .leading, spacing: spacing) { leading() } - ResourceCardFooterGroup(alignment: .trailing, spacing: spacing) { + DesignCardFooterGroup(alignment: .trailing, spacing: spacing) { trailing() } .opacity(actionsVisible ? 1 : 0) @@ -384,7 +384,7 @@ public struct ResourceCardFooter: V } } -public struct ResourceCardTitleText: View { +public struct DesignCardTitleText: View { public let text: String public init(text: String) { @@ -398,7 +398,7 @@ public struct ResourceCardTitleText: View { } } -public struct ResourceCardSubtitleText: View { +public struct DesignCardSubtitleText: View { public let text: String public init(text: String) { @@ -413,7 +413,7 @@ public struct ResourceCardSubtitleText: View { } } -public struct ResourceCardMonospacedSubtitleText: View { +public struct DesignCardMonospacedSubtitleText: View { public let text: String public init(text: String) { @@ -428,7 +428,7 @@ public struct ResourceCardMonospacedSubtitleText: View { } } -public struct ResourceCardMonospacedTitleText: View { +public struct DesignCardMonospacedTitleText: View { public let text: String public init(text: String) { @@ -442,7 +442,7 @@ public struct ResourceCardMonospacedTitleText: View { } } -public struct ResourceCardIconChip: View { +public struct DesignCardIconChip: View { public var symbol: String public var tint: Color public var symbolFont: Font @@ -451,7 +451,7 @@ public struct ResourceCardIconChip: View { public init(symbol: String, tint: Color = .secondary, symbolFont: Font = .title3, - backgroundOpacity: Double = Tokens.ResourceCard.iconBackgroundOpacity) { + backgroundOpacity: Double = DesignTokens.DesignCard.iconBackgroundOpacity) { self.symbol = symbol self.tint = tint self.symbolFont = symbolFont @@ -462,14 +462,14 @@ public struct ResourceCardIconChip: View { Image(systemName: symbol) .font(symbolFont) .foregroundStyle(tint) - .frame(width: Tokens.IconSize.chip, height: Tokens.IconSize.chip) + .frame(width: DesignTokens.IconSize.chip, height: DesignTokens.IconSize.chip) .background(tint.opacity(backgroundOpacity), - in: RoundedRectangle(cornerRadius: Tokens.Radius.iconChip, style: .continuous)) + in: RoundedRectangle(cornerRadius: DesignTokens.Radius.iconChip, style: .continuous)) } } /// Small capsule count/state badge used in section headers and compact metadata rows. -public struct ResourceBadgeText: View { +public struct DesignBadgeText: View { public let text: String public var font: Font public var foreground: Color @@ -486,8 +486,8 @@ public struct ResourceBadgeText: View { Text(text) .font(font) .foregroundStyle(foreground) - .padding(.horizontal, Tokens.Space.s) - .padding(.vertical, Tokens.Badge.verticalPadding) + .padding(.horizontal, DesignTokens.Space.s) + .padding(.vertical, DesignTokens.Badge.verticalPadding) .background(.quaternary, in: Capsule()) } } @@ -516,12 +516,12 @@ public struct GlassListRow: View { } public var body: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Image(systemName: symbol) .font(.callout) .foregroundStyle(tint) - .frame(width: Tokens.IconSize.rowMenu) - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + .frame(width: DesignTokens.IconSize.rowMenu) + VStack(alignment: .leading, spacing: DesignTokens.DesignCard.compactTextSpacing) { Text(title) .font(.callout.weight(.medium)) .lineLimit(1) @@ -532,14 +532,14 @@ public struct GlassListRow: View { .lineLimit(1) } } - Spacer(minLength: Tokens.Space.s) + Spacer(minLength: DesignTokens.Space.s) accessory() } - .padding(.horizontal, Tokens.Space.m) - .padding(.vertical, Tokens.Space.s) + .padding(.horizontal, DesignTokens.Space.m) + .padding(.vertical, DesignTokens.Space.s) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) - .glassSurface(.ultraThin, cornerRadius: Tokens.Radius.control) + .glassSurface(.ultraThin, cornerRadius: DesignTokens.Radius.control) } } @@ -566,7 +566,7 @@ public extension GlassListRow where Accessory == GlassListRowChevron { } } -public struct ResourceCardMetricText: View { +public struct DesignCardMetricText: View { public let text: String public init(text: String) { @@ -583,7 +583,7 @@ public struct ResourceCardMetricText: View { public extension View { @ViewBuilder - func resourceCardFloatingControls( + func designCardFloatingControls( when isVisible: Bool, @ViewBuilder controls: @escaping () -> Controls ) -> some View { @@ -591,14 +591,14 @@ public extension View { if isVisible { controls() .fixedSize(horizontal: true, vertical: false) - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) .zIndex(1) } } } @ViewBuilder - func resourceCardProgressOverlay(when isBusy: Bool) -> some View { + func designCardProgressOverlay(when isBusy: Bool) -> some View { overlay { if isBusy { ProgressView() diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift similarity index 91% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift index ce0d051c..74621812 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ResourceGlassCard.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift @@ -1,6 +1,6 @@ import SwiftUI -public enum ResourceCardSize { +public enum DesignCardSize { case small, medium, large /// Footer actions stay visible in compact/expanded chrome for medium and large cards. @@ -16,7 +16,7 @@ public enum ResourceCardSize { public var showsWidget: Bool { keepsWidgetSticky } } -public enum ResourceCardExpandedMetrics { +public enum DesignCardExpandedMetrics { public static let maxWidth: CGFloat = 760 } @@ -44,9 +44,9 @@ public struct CardSizePicker: View { } } -public struct ResourceGlassCard: View { - var size: ResourceCardSize + var size: DesignCardSize var isExpanded = false var cornerRadiusOverride: CGFloat? var controlsVisible = true @@ -61,8 +61,7 @@ public struct ResourceGlassCard Void = {} @ViewBuilder var header: () -> Header @@ -81,7 +80,7 @@ public struct ResourceGlassCard some View { - ResourceCardFooter(actionsVisible: showActions) { + DesignCardFooter(actionsVisible: showActions) { footerLeading() } trailing: { footerActions() @@ -220,8 +219,8 @@ public struct ResourceGlassCard some View { - modifier(ResourceCardMaterialSurface(material: material, + modifier(DesignCardMaterialSurface(material: material, cornerRadius: cornerRadius, shadow: shadow, fill: fill, @@ -334,9 +333,9 @@ private extension View { } } -public extension ResourceGlassCard where BodyContent == EmptyView, FooterLeading == EmptyView, +public extension DesignCardSurface where BodyContent == EmptyView, FooterLeading == EmptyView, FooterActions == EmptyView, Widget == EmptyView { - init(size: ResourceCardSize = .small, + init(size: DesignCardSize = .small, isSelected: Bool = false, fill: Color? = nil, fillOpacity: Double = 0.18, @@ -363,8 +362,8 @@ public extension ResourceGlassCard where BodyContent == EmptyView, FooterLeading } } -public extension ResourceGlassCard where BodyContent == EmptyView, Widget == EmptyView { - init(size: ResourceCardSize, +public extension DesignCardSurface where BodyContent == EmptyView, Widget == EmptyView { + init(size: DesignCardSize, isExpanded: Bool = false, controlsVisible: Bool = true, isSelected: Bool = false, @@ -397,8 +396,8 @@ public extension ResourceGlassCard where BodyContent == EmptyView, Widget == Emp } } -public extension ResourceGlassCard where Widget == EmptyView { - init(size: ResourceCardSize, +public extension DesignCardSurface where Widget == EmptyView { + init(size: DesignCardSize, isExpanded: Bool = false, controlsVisible: Bool = true, isSelected: Bool = false, diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index a22b1f99..84804fa3 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -6,7 +6,7 @@ Reusable SwiftUI/AppKit visual primitives for Contained. `ContainedDesignSystem` owns app-agnostic visual policy: spacing, padding, radius, material, tint, glass surfaces, panel/page/sheet scaffolds, toolbar -controls, resource-card chrome, sparklines, JSON/stream surfaces, color controls, +controls, design-card chrome, sparklines, JSON/stream surfaces, color controls, clipboard helpers, and small chrome such as badges, keycaps, status dots, metric tiles, terminal surfaces, and selection overlays. @@ -27,7 +27,7 @@ Set material and shell policy near the app root: struct AppRoot: View { var body: some View { DesignSystemExample() - .tint(AppTint.azure.color) + .tint(DesignTint.azure.color) .environment(\.modalMaterial, WindowMaterial.sheet) .environment(\.buttonMaterial, WindowMaterial.glassClear) .environment(\.cardMaterial, WindowMaterial.glassRegular) @@ -47,13 +47,13 @@ import SwiftUI import ContainedDesignSystem struct DesignSystemExample: View { - @State private var tint = AppTint.azure + @State private var tint = DesignTint.azure var body: some View { PageScaffold(symbol: "shippingbox", title: "Containers", subtitle: "Local runtime") { - VStack(spacing: Tokens.Space.l) { + VStack(spacing: DesignTokens.Space.l) { PanelSection(header: "Appearance") { PanelRow(title: "Accent") { TintSelector(selection: $tint, labelForTint: tintName) @@ -63,11 +63,11 @@ struct DesignSystemExample: View { } } - ResourceCard(size: .small, + DesignCard(size: .small, elevated: false, title: "web", subtitle: "nginx:latest") { - ResourceCardIconChip(symbol: "shippingbox.fill", + DesignCardIconChip(symbol: "shippingbox.fill", tint: tint.color) } titleAccessory: { EmptyView() @@ -91,7 +91,7 @@ struct DesignSystemExample: View { .environment(\.buttonMaterial, .glassClear) } - private func tintName(_ tint: AppTint) -> String { + private func tintName(_ tint: DesignTint) -> String { switch tint { case .multicolor: return "App Accent" case .graphite: return "Graphite" @@ -109,14 +109,14 @@ struct DesignSystemExample: View { ## Resource Card Controls -Use `ResourceCard` for cards. Feature views pass plain titles, subtitles, page +Use `DesignCard` for cards. Feature views pass plain titles, subtitles, page IDs, labels, metric strings, and actions; the package owns header/body/widget/footer placement: -`ResourceCard` owns card anatomy: +`DesignCard` owns card anatomy: - the header is always sticky and visible -- page controls are declared through `ResourceCardPages`, stay mounted in the +- page controls are declared through `DesignCardPages`, stay mounted in the header trailing slot, and use `controlsReveal` for visibility - the body appears only when the card is expanded - the widget is sticky for `.large` cards and becomes body content for `.medium` @@ -128,25 +128,25 @@ struct CardControlsExample: View { @State private var metric = "cpu" private let pages = [ - ResourceCardPageControlItem(id: "overview", + DesignCardPageControlItem(id: "overview", title: "Overview", systemImage: "rectangle.grid.1x2"), - ResourceCardPageControlItem(id: "logs", + DesignCardPageControlItem(id: "logs", title: "Logs", systemImage: "text.alignleft") ] var body: some View { - ResourceCard(size: .large, + DesignCard(size: .large, title: "web", subtitle: "nginx:latest", - pages: ResourceCardPages(items: pages, + pages: DesignCardPages(items: pages, selection: page, tint: .accentColor, closeLabel: "Close", onSelect: { page = $0 }, onClose: {})) { - ResourceCardIconChip(symbol: "shippingbox.fill") + DesignCardIconChip(symbol: "shippingbox.fill") } titleAccessory: { EmptyView() } subtitleAccessory: { @@ -154,27 +154,27 @@ struct CardControlsExample: View { } headerAccessory: { EmptyView() } bodyContent: { - ResourceCardInsetSection(title: "Details") { - ResourceCardSubtitleText(text: "Ready") + DesignCardInsetSection(title: "Details") { + DesignCardSubtitleText(text: "Ready") } } footerLeading: { - ResourceCardFooterChip(isSelected: metric == "cpu", + DesignCardFooterChip(isSelected: metric == "cpu", tint: .accentColor, help: "CPU", action: { metric = "cpu" }) { Image(systemName: "cpu") } text: { - ResourceCardMetricText(text: "12%") + DesignCardMetricText(text: "12%") } } footerActions: { - ResourceCardFooterButton(systemName: "play.fill", + DesignCardFooterButton(systemName: "play.fill", help: "Start", tint: .accentColor) {} } widget: { LiveSparkline(samples: [0, 0.12, 0.18], color: .accentColor, scale: .fraction) - .frame(height: Tokens.ResourceCard.sparklineHeight) + .frame(height: DesignTokens.DesignCard.sparklineHeight) } } } @@ -246,11 +246,11 @@ DesignGlassToggle(isOn: $following, ## Topics -### Tokens and Theme +### DesignTokens and Theme -- ``Tokens`` +- ``DesignTokens`` - ``WindowMaterial`` -- ``AppTint`` +- ``DesignTint`` - ``ColorLayerBlendMode`` - ``GlassButtonTintStyle`` - ``DesignActionCluster`` @@ -292,28 +292,28 @@ DesignGlassToggle(isOn: $following, ### Resource Cards -- ``ResourceCard`` -- ``ResourceCardPages`` -- ``ResourceCardNoPage`` -- ``ResourceCardTextStyle`` -- ``ResourceGlassCard`` -- ``ResourceCardHeader`` -- ``ResourceCardHeaderTextBlock`` -- ``ResourceCardIconChip`` -- ``ResourceBadgeText`` -- ``ResourceCardFooterMini`` -- ``ResourceCardWidgetGroup`` -- ``ResourceCardFooterChip`` -- ``ResourceCardFooterButton`` -- ``ResourceCardInsetSection`` -- ``ResourceCardPageControls`` -- ``ResourceCardPageControlItem`` -- ``View/resourceCardFloatingControls(when:controls:)`` -- ``View/resourceCardProgressOverlay(when:)`` -- ``ResourceCardTitleText`` -- ``ResourceCardSubtitleText`` -- ``ResourceCardMonospacedSubtitleText`` -- ``ResourceCardMetricText`` +- ``DesignCard`` +- ``DesignCardPages`` +- ``DesignCardNoPage`` +- ``DesignCardTextStyle`` +- ``DesignCardSurface`` +- ``DesignCardHeader`` +- ``DesignCardHeaderTextBlock`` +- ``DesignCardIconChip`` +- ``DesignBadgeText`` +- ``DesignCardFooterMini`` +- ``DesignCardWidgetGroup`` +- ``DesignCardFooterChip`` +- ``DesignCardFooterButton`` +- ``DesignCardInsetSection`` +- ``DesignCardPageControls`` +- ``DesignCardPageControlItem`` +- ``View/designCardFloatingControls(when:controls:)`` +- ``View/designCardProgressOverlay(when:)`` +- ``DesignCardTitleText`` +- ``DesignCardSubtitleText`` +- ``DesignCardMonospacedSubtitleText`` +- ``DesignCardMetricText`` ### Data Display and Micro Chrome diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignActions.swift similarity index 94% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignActions.swift index 12fea505..8fd10976 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DesignActions.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignActions.swift @@ -44,7 +44,7 @@ public struct DesignActionGroup: View { public init(_ actions: [DesignAction], spacing: CGFloat = 0, - height: CGFloat = Tokens.Toolbar.buttonGroupHeight, + height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight, minWidth: CGFloat? = nil, singleItem: Bool? = nil, interactive: Bool = true) { @@ -57,7 +57,7 @@ public struct DesignActionGroup: View { } public init(_ action: DesignAction, - height: CGFloat = Tokens.Toolbar.buttonGroupHeight, + height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight, minWidth: CGFloat? = nil, interactive: Bool = true) { self.init([action], @@ -88,7 +88,7 @@ public struct DesignActionCluster: View { @ViewBuilder public var content: () -> Content public init(spacing: CGFloat = 0, - height: CGFloat = Tokens.Toolbar.buttonGroupHeight, + height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight, minWidth: CGFloat? = nil, singleItem: Bool? = nil, interactive: Bool = true, @@ -193,8 +193,8 @@ public struct DesignProgressActionCapsule: View { GlassButton(singleItem: true) { ProgressView() .controlSize(controlSize) - .frame(width: Tokens.Toolbar.buttonItemHeight, - height: Tokens.Toolbar.buttonItemHeight) + .frame(width: DesignTokens.Toolbar.buttonItemHeight, + height: DesignTokens.Toolbar.buttonItemHeight) } } } @@ -295,7 +295,7 @@ public struct DesignSelectionActionBar: View { } public var body: some View { - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { Text(countLabel(count)) .font(.callout.weight(.medium)) Divider() @@ -310,8 +310,8 @@ public struct DesignSelectionActionBar: View { action: item.action) } } - .padding(.horizontal, Tokens.Space.l) - .padding(.vertical, Tokens.Space.s) + .padding(.horizontal, DesignTokens.Space.l) + .padding(.vertical, DesignTokens.Space.s) .glassCapsuleSurface(shadow: false) } } @@ -327,8 +327,8 @@ public struct DesignStatusBanner: View { public var body: some View { Text(text) .font(.callout.weight(.medium)) - .padding(.horizontal, Tokens.Space.l) - .padding(.vertical, Tokens.Space.s) + .padding(.horizontal, DesignTokens.Space.l) + .padding(.vertical, DesignTokens.Space.s) .glassCapsuleSurface(shadow: false) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassButton.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassButton.swift similarity index 88% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassButton.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassButton.swift index 37772b4a..48abbff0 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassButton.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassButton.swift @@ -25,14 +25,14 @@ private struct OptionalAccessibilityLabel: ViewModifier { public struct GlassButtonTintStyle: Equatable, Sendable { public var enabled = false - public var tint: AppTint = .multicolor + public var tint: DesignTint = .multicolor public var opacity = 0.18 public var gradient = true public var gradientAngle = 135.0 public var blendMode: ColorLayerBlendMode = .softLight public init(enabled: Bool = false, - tint: AppTint = .multicolor, + tint: DesignTint = .multicolor, opacity: Double = 0.18, gradient: Bool = true, gradientAngle: Double = 135.0, @@ -87,15 +87,15 @@ public struct GlassButtonItem: View { label() .font(.body.weight(.medium)) .foregroundStyle(itemForegroundStyle) - .padding(Tokens.Toolbar.iconInnerPadding) - .frame(width: isIcon ? Tokens.Toolbar.buttonItemHeight : nil, - height: Tokens.Toolbar.buttonItemHeight) + .padding(DesignTokens.Toolbar.iconInnerPadding) + .frame(width: isIcon ? DesignTokens.Toolbar.buttonItemHeight : nil, + height: DesignTokens.Toolbar.buttonItemHeight) .contentShape(Rectangle()) .background { Capsule(style: .continuous) .fill( hoverEnabled && hovering && !isLabel - ? AppMaterial.toolbarInteractiveHoverFill(for: colorScheme) + ? DesignMaterial.toolbarInteractiveHoverFill(for: colorScheme) : .clear ) } @@ -125,10 +125,10 @@ public struct GlassButtonItem: View { /// Input content that occupies the same 28pt inner lane as `GlassButtonItem`, but leaves hover/pressed /// treatment to the enclosing `GlassButton` container. public struct GlassButtonInputItem: View { - public var spacing = Tokens.Toolbar.searchIconGap + public var spacing = DesignTokens.Toolbar.searchIconGap @ViewBuilder var content: () -> Content - public init(spacing: CGFloat = Tokens.Toolbar.searchIconGap, + public init(spacing: CGFloat = DesignTokens.Toolbar.searchIconGap, @ViewBuilder content: @escaping () -> Content) { self.spacing = spacing self.content = content @@ -137,8 +137,8 @@ public struct GlassButtonInputItem: View { public var body: some View { HStack(spacing: spacing) { content() } .font(.body.weight(.medium)) - .padding(Tokens.Toolbar.iconInnerPadding) - .frame(height: Tokens.Toolbar.buttonItemHeight) + .padding(DesignTokens.Toolbar.iconInnerPadding) + .frame(height: DesignTokens.Toolbar.buttonItemHeight) .contentShape(Rectangle()) } } @@ -161,7 +161,7 @@ public extension GlassButtonItem where Label == Image { /// is the morph target for compact button groups across the app. public struct GlassButton: View { public var spacing: CGFloat = 0 - public var height: CGFloat = Tokens.Toolbar.buttonGroupHeight + public var height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight public var minWidth: CGFloat? = nil public var singleItem: Bool = false /// Set `false` for a static glass container (no hover treatment) — e.g. vanity toolbar chrome. @@ -169,7 +169,7 @@ public struct GlassButton: View { @ViewBuilder var content: () -> Content public init(spacing: CGFloat = 0, - height: CGFloat = Tokens.Toolbar.buttonGroupHeight, + height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight, minWidth: CGFloat? = nil, singleItem: Bool = false, interactive: Bool = true, @@ -189,7 +189,7 @@ public struct GlassButton: View { public var body: some View { let shape = Capsule(style: .continuous) HStack(spacing: spacing) { content() } - .padding(.horizontal, Tokens.Toolbar.iconInnerPadding) + .padding(.horizontal, DesignTokens.Toolbar.iconInnerPadding) .frame(height: height) .frame(minWidth: minWidth) .background { @@ -197,7 +197,7 @@ public struct GlassButton: View { Capsule(style: .continuous) .fill( hovering - ? AppMaterial.toolbarInteractiveHoverFill(for: colorScheme) + ? DesignMaterial.toolbarInteractiveHoverFill(for: colorScheme) : .clear ) } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassOptionTile.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassOptionTile.swift similarity index 89% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassOptionTile.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassOptionTile.swift index 91bc6764..2637d5ea 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassOptionTile.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassOptionTile.swift @@ -35,13 +35,13 @@ public struct GlassOptionTile: View { guard enabled else { return } action() } label: { - VStack(alignment: .leading, spacing: Tokens.Space.xs) { + VStack(alignment: .leading, spacing: DesignTokens.Space.xs) { Image(systemName: symbol) .font(.title3) .foregroundStyle(enabled ? Color.accentColor : Color.secondary) .frame(width: 24, height: 24, alignment: .leading) - Spacer(minLength: Tokens.Space.s) + Spacer(minLength: DesignTokens.Space.s) VStack(alignment: .leading, spacing: 2) { Text(title) @@ -57,10 +57,10 @@ public struct GlassOptionTile: View { .accessibilityHidden(subtitle == nil) } } - .padding(Tokens.Space.m) + .padding(DesignTokens.Space.m) .frame(maxWidth: .infinity, minHeight: height, maxHeight: height, alignment: .leading) - .glassOptionTileSurface(cornerRadius: Tokens.Radius.card, interactive: enabled) - .contentShape(RoundedRectangle(cornerRadius: Tokens.Radius.card, style: .continuous)) + .glassOptionTileSurface(cornerRadius: DesignTokens.Radius.card, interactive: enabled) + .contentShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous)) .optionalMatchedGeometry(id: matchedID, namespace: matchedNamespace) } .buttonStyle(.plain) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassRowMenu.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassRowMenu.swift similarity index 70% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassRowMenu.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassRowMenu.swift index 07a7b26b..6a22ea22 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassRowMenu.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassRowMenu.swift @@ -1,8 +1,8 @@ import SwiftUI -/// The circular glass ellipsis menu used as the trailing accessory on every resource row -/// (Images/Volumes/Networks/Registries/Templates) and the detail header. Centralizes the styling -/// chain and the VoiceOver label so icon-only menus are consistently accessible. +/// The circular glass ellipsis menu used as a trailing accessory on compact rows and detail headers. +/// Centralizes the styling chain and the VoiceOver label so icon-only menus are consistently +/// accessible. public struct GlassRowMenu: View { public var systemImage: String public var accessibilityLabel: String @@ -22,7 +22,7 @@ public struct GlassRowMenu: View { } label: { Image(systemName: systemImage) .font(.body.weight(.medium)) - .frame(width: Tokens.IconSize.rowMenu, height: Tokens.IconSize.rowMenu) + .frame(width: DesignTokens.IconSize.rowMenu, height: DesignTokens.IconSize.rowMenu) } .menuStyle(.button) .buttonStyle(.glass) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GradientAngleControl.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GradientAngleControl.swift similarity index 70% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GradientAngleControl.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GradientAngleControl.swift index 4b52c13f..1f3d5073 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GradientAngleControl.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GradientAngleControl.swift @@ -12,14 +12,14 @@ public struct GradientAngleControl: View { public var body: some View { LabeledContent(title) { - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { AngleDial(angle: $angle) - .frame(width: Tokens.InlineControl.gradientDial, - height: Tokens.InlineControl.gradientDial) + .frame(width: DesignTokens.InlineControl.gradientDial, + height: DesignTokens.InlineControl.gradientDial) Slider(value: $angle, in: 0...360, step: 1) Text("\(Int(angle))°") .monospacedDigit() - .frame(width: Tokens.InlineControl.gradientReadout) + .frame(width: DesignTokens.InlineControl.gradientReadout) } } } @@ -34,16 +34,16 @@ private struct AngleDial: View { let radius = min(geo.size.width, geo.size.height) / 2 let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2) let radians = angle * .pi / 180 - let knob = CGPoint(x: center.x + cos(radians) * (radius - Tokens.InlineControl.gradientKnobInset), - y: center.y + sin(radians) * (radius - Tokens.InlineControl.gradientKnobInset)) + let knob = CGPoint(x: center.x + cos(radians) * (radius - DesignTokens.InlineControl.gradientKnobInset), + y: center.y + sin(radians) * (radius - DesignTokens.InlineControl.gradientKnobInset)) ZStack { Circle() - .strokeBorder(.secondary.opacity(Tokens.InlineControl.gradientStrokeOpacity), - lineWidth: Tokens.Space.hairline) + .strokeBorder(.secondary.opacity(DesignTokens.InlineControl.gradientStrokeOpacity), + lineWidth: DesignTokens.Space.hairline) Circle() .fill(.tint) - .frame(width: Tokens.InlineControl.gradientKnob, - height: Tokens.InlineControl.gradientKnob) + .frame(width: DesignTokens.InlineControl.gradientKnob, + height: DesignTokens.InlineControl.gradientKnob) .position(knob) } .contentShape(Rectangle()) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/InfoButton.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/InfoButton.swift similarity index 97% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/InfoButton.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/InfoButton.swift index 8fa83c93..813efa72 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/InfoButton.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/InfoButton.swift @@ -38,7 +38,7 @@ public struct InfoButton: View { .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) .frame(width: 300) - .padding(Tokens.Space.m) + .padding(DesignTokens.Space.m) .background { VisualEffectBackground(material: modalMaterial.nsMaterial, blendingMode: .withinWindow) } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/TintSelector.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/TintSelector.swift similarity index 75% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/TintSelector.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/TintSelector.swift index 9236c5d5..67d773f0 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/TintSelector.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/TintSelector.swift @@ -1,15 +1,15 @@ import SwiftUI -/// A row of colored swatches for picking an `AppTint` — each shows its actual color, the selected +/// A row of colored swatches for picking a `DesignTint` — each shows its actual color, the selected /// one gets a ring. public struct TintSelector: View { - private let selection: Binding + private let selection: Binding private let automaticLabel: String? - private let labelForTint: (AppTint) -> String + private let labelForTint: (DesignTint) -> String - public init(selection: Binding, - labelForTint: @escaping (AppTint) -> String) { - self.selection = Binding( + public init(selection: Binding, + labelForTint: @escaping (DesignTint) -> String) { + self.selection = Binding( get: { selection.wrappedValue }, set: { if let newValue = $0 { selection.wrappedValue = newValue } } ) @@ -17,16 +17,16 @@ public struct TintSelector: View { self.labelForTint = labelForTint } - public init(optionalSelection: Binding, + public init(optionalSelection: Binding, automaticLabel: String, - labelForTint: @escaping (AppTint) -> String) { + labelForTint: @escaping (DesignTint) -> String) { self.selection = optionalSelection self.automaticLabel = automaticLabel self.labelForTint = labelForTint } public var body: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { if let automaticLabel { Button { selection.wrappedValue = nil } label: { automaticSwatch } .buttonStyle(.plain) @@ -34,7 +34,7 @@ public struct TintSelector: View { .accessibilityLabel(automaticLabel) .accessibilityAddTraits(selection.wrappedValue == nil ? .isSelected : []) } - ForEach(AppTint.allCases) { tint in + ForEach(DesignTint.allCases) { tint in let label = labelForTint(tint) Button { selection.wrappedValue = tint } label: { swatch(tint) } .buttonStyle(.plain) @@ -59,11 +59,11 @@ public struct TintSelector: View { .frame(width: 26, height: 26) } - private func swatch(_ tint: AppTint) -> some View { + private func swatch(_ tint: DesignTint) -> some View { ZStack { Circle().fill(tint.color).frame(width: 22, height: 22) - // Mark the "follow the app accent" option so it reads as automatic, not a fixed color. - if tint.followsAppAccent { + // Mark the "follow the host accent" option so it reads as automatic, not a fixed color. + if tint.followsAccent { Image(systemName: "link") .font(.caption2.weight(.bold)) .foregroundStyle(.white) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/ToolbarControls.swift similarity index 92% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/ToolbarControls.swift index facd89b5..a91a238f 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ToolbarControls.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/ToolbarControls.swift @@ -1,6 +1,6 @@ import SwiftUI -/// Design-system controls for the app toolbar band, sized from `Tokens.Toolbar` to macOS 26 Liquid +/// Design-system controls for the app toolbar band, sized from `DesignTokens.Toolbar` to macOS 26 Liquid /// Glass toolbar proportions. Centralizing them here keeps the toolbar, the creation tiles /// (`GlassOptionTile`), and any future band controls visually consistent and on one source of truth. @@ -90,7 +90,7 @@ public struct DesignToolbarVanitySlot: View { public var interactive: Bool @ViewBuilder public var content: () -> Content - public init(minWidth: CGFloat = Tokens.Toolbar.trafficLightsWidth, + public init(minWidth: CGFloat = DesignTokens.Toolbar.trafficLightsWidth, interactive: Bool = false, @ViewBuilder content: @escaping () -> Content = { Color.clear }) { self.minWidth = minWidth @@ -166,8 +166,8 @@ public struct ToolbarMenuButton: View { } label: { Image(systemName: systemName) .font(.body.weight(.medium)) - .padding(Tokens.Toolbar.iconInnerPadding) - .frame(height: Tokens.Toolbar.buttonItemHeight) + .padding(DesignTokens.Toolbar.iconInnerPadding) + .frame(height: DesignTokens.Toolbar.buttonItemHeight) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -194,11 +194,11 @@ public struct ToolbarTitleSubtitleLabel: View { } public var body: some View { - HStack(spacing: Tokens.Toolbar.searchIconGap) { + HStack(spacing: DesignTokens.Toolbar.searchIconGap) { Image(systemName: symbol) .font(.body) .foregroundStyle(.secondary) - .frame(width: Tokens.Toolbar.buttonItemHeight - Tokens.Toolbar.iconInnerPadding * 2) + .frame(width: DesignTokens.Toolbar.buttonItemHeight - DesignTokens.Toolbar.iconInnerPadding * 2) VStack(alignment: .leading, spacing: 0) { Text(title) .font(.caption.weight(.semibold)) @@ -214,8 +214,8 @@ public struct ToolbarTitleSubtitleLabel: View { } } .lineLimit(1) - .padding(.trailing, Tokens.Toolbar.iconInnerPadding * 2) - .frame(height: Tokens.Toolbar.buttonGroupHeight) + .padding(.trailing, DesignTokens.Toolbar.iconInnerPadding * 2) + .frame(height: DesignTokens.Toolbar.buttonGroupHeight) .contentShape(Rectangle()) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/CommandPreviewBar.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/CommandPreviewBar.swift similarity index 84% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/CommandPreviewBar.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/CommandPreviewBar.swift index b1f255dd..1c0652f4 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/CommandPreviewBar.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/CommandPreviewBar.swift @@ -19,7 +19,7 @@ public struct CommandPreviewBar: View { } public var body: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Image(systemName: "terminal") .foregroundStyle(.primary) ScrollView(.horizontal, showsIndicators: false) { @@ -28,7 +28,7 @@ public struct CommandPreviewBar: View { .textSelection(.enabled) .lineLimit(1) } - Spacer(minLength: Tokens.Space.s) + Spacer(minLength: DesignTokens.Space.s) Button { copyToPasteboard(rendered) withAnimation { copied = true } @@ -41,8 +41,8 @@ public struct CommandPreviewBar: View { .help(copyHelp) .accessibilityLabel(copied ? copiedAccessibilityLabel : copyHelp) } - .padding(.horizontal, Tokens.Space.s) - .padding(.vertical, Tokens.Space.s) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) + .padding(.horizontal, DesignTokens.Space.s) + .padding(.vertical, DesignTokens.Space.s) + .glassSurface(.regular, cornerRadius: DesignTokens.Radius.card, shadow: false) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/LiveSparkline.swift similarity index 100% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/LiveSparkline.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/LiveSparkline.swift diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MetricTile.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/MetricTile.swift similarity index 85% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MetricTile.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/MetricTile.swift index 58a6627f..6f8f0a48 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MetricTile.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/MetricTile.swift @@ -24,8 +24,8 @@ public struct MetricTile: View { } public var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.s) { - HStack(spacing: Tokens.Space.s) { + VStack(alignment: .leading, spacing: DesignTokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { if let systemImage { Image(systemName: systemImage) .font(.body.weight(.medium)) @@ -44,8 +44,8 @@ public struct MetricTile: View { .frame(height: 22) } } - .padding(Tokens.Space.l) + .padding(DesignTokens.Space.l) .frame(maxWidth: .infinity, alignment: .leading) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, fill: tint, fillOpacity: 0.10) + .glassSurface(.regular, cornerRadius: DesignTokens.Radius.card, fill: tint, fillOpacity: 0.10) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ActivityStatusView.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ActivityStatusView.swift similarity index 91% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ActivityStatusView.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ActivityStatusView.swift index 4a79e338..aa24b09d 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ActivityStatusView.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ActivityStatusView.swift @@ -38,11 +38,11 @@ public struct ActivityStatusView: View { } private var inlineBody: some View { - HStack(spacing: Tokens.Toolbar.searchIconGap) { + HStack(spacing: DesignTokens.Toolbar.searchIconGap) { ProgressView() .controlSize(.small) .scaleEffect(0.8) - .frame(width: Tokens.Toolbar.buttonItemHeight - Tokens.Toolbar.iconInnerPadding * 2) + .frame(width: DesignTokens.Toolbar.buttonItemHeight - DesignTokens.Toolbar.iconInnerPadding * 2) Text(activity.title) .lineLimit(1) .truncationMode(.tail) @@ -54,12 +54,12 @@ public struct ActivityStatusView: View { .monospacedDigit() } } - .padding(.trailing, Tokens.Toolbar.iconInnerPadding * 2) + .padding(.trailing, DesignTokens.Toolbar.iconInnerPadding * 2) } private var expandedBody: some View { VStack(alignment: .leading, spacing: 6) { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { ProgressView().controlSize(.small) Text(activity.title).font(.callout.weight(.medium)) Spacer(minLength: 0) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ErrorToast.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ErrorToast.swift similarity index 69% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ErrorToast.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ErrorToast.swift index 40b4b19e..a71ff03c 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ErrorToast.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ErrorToast.swift @@ -10,14 +10,14 @@ public struct ErrorToast: View { } public var body: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red) Text(message).font(.callout).lineLimit(2) } - .padding(.horizontal, Tokens.Space.l) - .padding(.vertical, Tokens.Space.m) - .glassSurface(.regular, cornerRadius: Tokens.Radius.control) - .padding(Tokens.Space.l) + .padding(.horizontal, DesignTokens.Space.l) + .padding(.vertical, DesignTokens.Space.m) + .glassSurface(.regular, cornerRadius: DesignTokens.Radius.control) + .padding(DesignTokens.Space.l) .transition(.move(edge: .bottom).combined(with: .opacity)) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/StreamConsole.swift similarity index 93% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/StreamConsole.swift index b2cb3770..febd2615 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/StreamConsole.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/StreamConsole.swift @@ -51,21 +51,21 @@ public struct StreamConsole: View { } Color.clear.frame(height: 1).id(bottomID) } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } .scrollEdgeEffectStyle(.soft, for: .all) .onChange(of: lines.count) { _, _ in proxy.scrollTo(bottomID, anchor: .bottom) } } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) .background(.black.opacity(0.22), - in: RoundedRectangle(cornerRadius: Tokens.Radius.card, style: .continuous)) - .padding(Tokens.Space.s) + in: RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous)) + .padding(DesignTokens.Space.s) } .task { await consume() } } private var statusBar: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { switch state { case .running: ProgressView().controlSize(.small) @@ -86,7 +86,7 @@ public struct StreamConsole: View { } } .font(.callout) - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } private func consume() async { diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PageScaffold.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PageScaffold.swift similarity index 95% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PageScaffold.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PageScaffold.swift index c5e785c1..67eec5c6 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PageScaffold.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PageScaffold.swift @@ -42,7 +42,7 @@ public struct PageScaffold: View { LazyVStack(spacing: 0) { content() .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(usesToolbarChrome ? Tokens.Space.s : Tokens.Space.l) + .padding(usesToolbarChrome ? DesignTokens.Space.s : DesignTokens.Space.l) if usesToolbarChrome && bottomClearance > 0 { Color.clear .frame(height: bottomClearance) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelHeader.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelHeader.swift similarity index 73% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelHeader.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelHeader.swift index e7d8a11f..c393e5bc 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelHeader.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelHeader.swift @@ -5,14 +5,14 @@ public struct PanelHeader: View { public let symbol: String public let title: String public var subtitle: String? - public var padding: CGFloat = Tokens.Space.s + public var padding: CGFloat = DesignTokens.Space.s public var leadingReserve: CGFloat = 0 @ViewBuilder var trailing: () -> Trailing public init(symbol: String, title: String, subtitle: String? = nil, - padding: CGFloat = Tokens.Space.s, + padding: CGFloat = DesignTokens.Space.s, leadingReserve: CGFloat = 0, @ViewBuilder trailing: @escaping () -> Trailing) { self.symbol = symbol @@ -24,14 +24,14 @@ public struct PanelHeader: View { } public var body: some View { - HStack(alignment: .center, spacing: Tokens.Space.s) { + HStack(alignment: .center, spacing: DesignTokens.Space.s) { if leadingReserve > 0 { Color.clear - .frame(width: leadingReserve, height: Tokens.Toolbar.buttonGroupHeight) + .frame(width: leadingReserve, height: DesignTokens.Toolbar.buttonGroupHeight) } GlassButtonItem(systemName: symbol, help: title, isLabel: true) - .frame(width: Tokens.Toolbar.buttonGroupHeight, - height: Tokens.Toolbar.buttonGroupHeight, + .frame(width: DesignTokens.Toolbar.buttonGroupHeight, + height: DesignTokens.Toolbar.buttonGroupHeight, alignment: .center) VStack(alignment: .leading, spacing: 1) { Text(title) @@ -45,11 +45,11 @@ public struct PanelHeader: View { } } .frame(maxWidth: .infinity, - minHeight: Tokens.Toolbar.buttonGroupHeight, + minHeight: DesignTokens.Toolbar.buttonGroupHeight, alignment: .leading) trailing() } - .frame(minHeight: Tokens.Toolbar.buttonGroupHeight) + .frame(minHeight: DesignTokens.Toolbar.buttonGroupHeight) .padding(padding) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelSection.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelSection.swift similarity index 89% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelSection.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelSection.swift index 2d904f85..ea93d132 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/PanelSection.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelSection.swift @@ -21,7 +21,7 @@ private extension EnvironmentValues { public struct PanelSection: View { public var header: String? = nil public var footer: String? = nil - public var rowSpacing: CGFloat = Tokens.Space.m + public var rowSpacing: CGFloat = DesignTokens.Space.m public var collapsible: Bool = false /// Subtle blue treatment for sections containing explicit non-default values. public var highlighted: Bool = false @@ -40,7 +40,7 @@ public struct PanelSection: View { public init(header: String? = nil, footer: String? = nil, - rowSpacing: CGFloat = Tokens.Space.m, + rowSpacing: CGFloat = DesignTokens.Space.m, collapsible: Bool = false, highlighted: Bool = false, enabled: Binding? = nil, @@ -55,19 +55,19 @@ public struct PanelSection: View { } public var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.s) { + VStack(alignment: .leading, spacing: DesignTokens.Space.s) { if hasHeader { headerRow } if !bodyHidden { LazyVStack(alignment: .leading, spacing: rowSpacing) { content() } .environment(\.panelSectionHighlighted, highlighted) - .padding(Tokens.Space.m) + .padding(DesignTokens.Space.m) .frame(maxWidth: .infinity, alignment: .leading) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) + .glassSurface(.regular, cornerRadius: DesignTokens.Radius.card, shadow: false) .overlay { if highlighted { - RoundedRectangle(cornerRadius: Tokens.Radius.card, style: .continuous) + RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous) .stroke(Color.accentColor.opacity(0.38), lineWidth: 1) } } @@ -75,7 +75,7 @@ public struct PanelSection: View { // Markdown-aware so footers can use **bold** / `code`, like the old Form footers. Text(.init(footer)).font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - .padding(.leading, Tokens.Space.xs) + .padding(.leading, DesignTokens.Space.xs) } } } @@ -84,7 +84,7 @@ public struct PanelSection: View { @ViewBuilder private var headerRow: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { if collapsible { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) @@ -92,7 +92,7 @@ public struct PanelSection: View { .rotationEffect(.degrees(collapsed ? 0 : 90)) } if let header { - HStack(spacing: Tokens.Space.xs) { + HStack(spacing: DesignTokens.Space.xs) { if highlighted { Circle() .fill(Color.accentColor) @@ -103,12 +103,12 @@ public struct PanelSection: View { .foregroundStyle(highlighted ? Color.accentColor : Color.primary) } } - Spacer(minLength: Tokens.Space.s) + Spacer(minLength: DesignTokens.Space.s) if let enabled { Toggle("", isOn: enabled).labelsHidden().toggleStyle(.switch) } } - .padding(.leading, Tokens.Space.xs) + .padding(.leading, DesignTokens.Space.xs) .contentShape(Rectangle()) .onTapGesture { guard collapsible else { return } @@ -148,9 +148,9 @@ public struct PanelRow: View { public var body: some View { VStack(alignment: .leading, spacing: 2) { - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { VStack(alignment: .leading, spacing: 1) { - HStack(spacing: Tokens.Space.xs) { + HStack(spacing: DesignTokens.Space.xs) { Text(title).foregroundStyle(labelColor) if let info { InfoButton(info, visible: labelHovering) } } @@ -159,7 +159,7 @@ public struct PanelRow: View { .fixedSize(horizontal: false, vertical: true) } } - Spacer(minLength: Tokens.Space.m) + Spacer(minLength: DesignTokens.Space.m) trailing() } .contentShape(Rectangle()) @@ -238,8 +238,8 @@ public struct PanelField: View { public var body: some View { VStack(alignment: .leading, spacing: 2) { - HStack(spacing: Tokens.Space.m) { - HStack(spacing: Tokens.Space.xs) { + HStack(spacing: DesignTokens.Space.m) { + HStack(spacing: DesignTokens.Space.xs) { Text(label) .foregroundStyle(labelColor) if let info { InfoButton(info, visible: labelHovering) } @@ -251,7 +251,7 @@ public struct PanelField: View { .onHover { labelHovering = $0 } if let error { Text(error).font(.caption).foregroundStyle(.red) - .padding(.leading, labelWidth + Tokens.Space.m) + .padding(.leading, labelWidth + DesignTokens.Space.m) } } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/SheetHeader.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/SheetHeader.swift similarity index 98% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/SheetHeader.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/SheetHeader.swift index aa5bafdf..68cd8379 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/SheetHeader.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/SheetHeader.swift @@ -40,7 +40,7 @@ public struct SheetHeader: View { } trailing() } - .padding(Tokens.Space.l) + .padding(DesignTokens.Space.l) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Previews/DesignSystemPreviews.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Previews/DesignSystemPreviews.swift new file mode 100644 index 00000000..70a61574 --- /dev/null +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Previews/DesignSystemPreviews.swift @@ -0,0 +1,87 @@ +import SwiftUI + +#Preview("Design Card") { + DesignCardPreview() + .padding(DesignTokens.Space.xl) + .frame(width: 420) + .environment(\.cardMaterial, .glassRegular) + .environment(\.buttonMaterial, .glassClear) +} + +#Preview("Design Controls") { + VStack(alignment: .leading, spacing: DesignTokens.Space.l) { + PanelSection(header: "Controls") { + PanelRow(title: "Tint") { + TintSelector(selection: .constant(.azure)) { tint in + tint.rawValue.capitalized + } + } + PanelRow(title: "Actions") { + DesignActionGroup([ + DesignAction(systemName: "play.fill", help: "Start") {}, + DesignAction(systemName: "stop.fill", help: "Stop", role: .destructive) {} + ]) + } + } + + CommandPreviewBar(command: ["container", "run", "--name", "preview-web", "nginx"], + copyHelp: "Copy command", + copiedAccessibilityLabel: "Copied") + } + .padding(DesignTokens.Space.xl) + .frame(width: 520) + .environment(\.buttonMaterial, .glassClear) +} + +private struct DesignCardPreview: View { + @State private var page = "overview" + + private let pages = [ + DesignCardPageControlItem(id: "overview", title: "Overview", systemImage: "rectangle.grid.1x2"), + DesignCardPageControlItem(id: "stats", title: "Stats", systemImage: "chart.xyaxis.line"), + ] + + var body: some View { + DesignCard(size: .large, + isExpanded: true, + title: "preview-web", + subtitle: "docker.io/library/nginx:latest", + pages: DesignCardPages(items: pages, + selection: page, + tint: .accentColor, + closeLabel: "Close", + onSelect: { page = $0 }, + onClose: {})) { + DesignCardIconChip(symbol: "shippingbox.fill", tint: .accentColor) + } titleAccessory: { + DesignBadgeText(text: "Running") + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + DesignCardInsetSection(title: "Live") { + LiveSparkline(samples: [0.1, 0.2, 0.18, 0.4, 0.34, 0.55], + color: .accentColor, + scale: .fraction) + .frame(height: DesignTokens.DesignCard.sparklineHeight) + } + } footerLeading: { + DesignCardFooterChip(isSelected: true, tint: .accentColor, help: "CPU", action: {}) { + Image(systemName: "cpu") + } text: { + DesignCardMetricText(text: "42%") + } + } footerActions: { + DesignCardFooterButton(systemName: "play.fill", help: "Start", tint: .accentColor) {} + } widget: { + DesignCardWidgetGroup { + DesignCardFooterMini { + Image(systemName: "memorychip") + } text: { + DesignCardMetricText(text: "420 MB") + } + } + } + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Clipboard.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Support/Clipboard.swift similarity index 100% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Clipboard.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Support/Clipboard.swift diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MicroPrimitives.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Support/MicroPrimitives.swift similarity index 69% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MicroPrimitives.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Support/MicroPrimitives.swift index e74ae71e..19f478c7 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/MicroPrimitives.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Support/MicroPrimitives.swift @@ -4,7 +4,7 @@ public struct DesignStatusDot: View { public var color: Color public var size: CGFloat - public init(color: Color, size: CGFloat = Tokens.IconSize.statusDot) { + public init(color: Color, size: CGFloat = DesignTokens.IconSize.statusDot) { self.color = color self.size = size } @@ -33,9 +33,9 @@ public struct DesignStatusBadge: View { Text(text) .font(font) .foregroundStyle(tint) - .padding(.horizontal, Tokens.Badge.horizontalPadding) - .padding(.vertical, Tokens.Badge.verticalPadding) - .background(tint.opacity(Tokens.Badge.statusOpacity), in: Capsule()) + .padding(.horizontal, DesignTokens.Badge.horizontalPadding) + .padding(.vertical, DesignTokens.Badge.verticalPadding) + .background(tint.opacity(DesignTokens.Badge.statusOpacity), in: Capsule()) } } @@ -50,10 +50,10 @@ public struct DesignKeyCap: View { Text(text) .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) - .padding(.horizontal, Tokens.Keyboard.keyHorizontalPadding) - .padding(.vertical, Tokens.Keyboard.keyVerticalPadding) + .padding(.horizontal, DesignTokens.Keyboard.keyHorizontalPadding) + .padding(.vertical, DesignTokens.Keyboard.keyVerticalPadding) .background(.quaternary, - in: RoundedRectangle(cornerRadius: Tokens.Radius.keyCap, + in: RoundedRectangle(cornerRadius: DesignTokens.Radius.keyCap, style: .continuous)) } } @@ -68,7 +68,7 @@ public struct DesignKeyboardHint: View { } public var body: some View { - HStack(spacing: Tokens.Space.xs) { + HStack(spacing: DesignTokens.Space.xs) { DesignKeyCap(key) Text(label) .font(.caption) @@ -87,7 +87,7 @@ public struct DesignScopeChipLabel: View { } public var body: some View { - HStack(spacing: Tokens.Space.xs) { + HStack(spacing: DesignTokens.Space.xs) { Image(systemName: symbol) .font(.caption2) Text(title) @@ -95,9 +95,9 @@ public struct DesignScopeChipLabel: View { Image(systemName: "xmark") .font(.caption2.weight(.bold)) } - .padding(.horizontal, Tokens.Space.s) - .padding(.vertical, Tokens.Badge.scopeVerticalPadding) - .background(Color.accentColor.opacity(Tokens.Badge.accentOpacity), + .padding(.horizontal, DesignTokens.Space.s) + .padding(.vertical, DesignTokens.Badge.scopeVerticalPadding) + .background(Color.accentColor.opacity(DesignTokens.Badge.accentOpacity), in: Capsule(style: .continuous)) .foregroundStyle(Color.accentColor) } @@ -105,23 +105,23 @@ public struct DesignScopeChipLabel: View { public struct DesignTintSwatch: View { public var color: Color - public var followsAppAccent: Bool + public var followsAccent: Bool - public init(color: Color, followsAppAccent: Bool = false) { + public init(color: Color, followsAccent: Bool = false) { self.color = color - self.followsAppAccent = followsAppAccent + self.followsAccent = followsAccent } public var body: some View { ZStack { Circle().fill(color) - if followsAppAccent { + if followsAccent { Image(systemName: "link") .font(.caption2.weight(.bold)) .foregroundStyle(.white) } } - .frame(width: Tokens.IconSize.chip, height: Tokens.IconSize.chip) + .frame(width: DesignTokens.IconSize.chip, height: DesignTokens.IconSize.chip) } } @@ -137,11 +137,11 @@ public struct DesignMetricTile: View { } public var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.xxs) { + VStack(alignment: .leading, spacing: DesignTokens.Space.xxs) { Text(label) .font(.caption) .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: Tokens.Space.xs) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Space.xs) { Text(value) .font(.title3.weight(.medium)) if let caption { @@ -152,10 +152,10 @@ public struct DesignMetricTile: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Tokens.Space.m) - .padding(.vertical, Tokens.Space.s) - .background(AppMaterial.toolbarHoverFill, - in: RoundedRectangle(cornerRadius: Tokens.Radius.control, + .padding(.horizontal, DesignTokens.Space.m) + .padding(.vertical, DesignTokens.Space.s) + .background(DesignMaterial.toolbarHoverFill, + in: RoundedRectangle(cornerRadius: DesignTokens.Radius.control, style: .continuous)) } } @@ -171,7 +171,7 @@ public struct DesignContentSurface: View { public init(elevated: Bool = false, minHeight: CGFloat? = nil, alignment: Alignment = .center, - padding: CGFloat = Tokens.Space.s, + padding: CGFloat = DesignTokens.Space.s, @ViewBuilder content: @escaping () -> Content) { self.elevated = elevated self.minHeight = minHeight @@ -184,7 +184,7 @@ public struct DesignContentSurface: View { content() .padding(padding) .frame(maxWidth: .infinity, minHeight: minHeight, alignment: alignment) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: elevated) + .glassSurface(.regular, cornerRadius: DesignTokens.Radius.card, shadow: elevated) } } @@ -195,8 +195,8 @@ public struct DesignInputSurface: View { public var minHeight: CGFloat? @ViewBuilder public var content: () -> Content - public init(horizontalPadding: CGFloat = Tokens.Space.m, - verticalPadding: CGFloat = Tokens.Space.s, + public init(horizontalPadding: CGFloat = DesignTokens.Space.m, + verticalPadding: CGFloat = DesignTokens.Space.s, minHeight: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) { self.horizontalPadding = horizontalPadding @@ -210,7 +210,7 @@ public struct DesignInputSurface: View { .padding(.horizontal, horizontalPadding) .padding(.vertical, verticalPadding) .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .leading) - .glassSurface(.thin, cornerRadius: Tokens.Radius.control) + .glassSurface(.thin, cornerRadius: DesignTokens.Radius.control) } } @@ -219,24 +219,24 @@ public extension View { func designCardSelectionOverlay(when isSelected: Bool) -> some View { overlay { if isSelected { - RoundedRectangle(cornerRadius: Tokens.Radius.card, style: .continuous) - .fill(AppMaterial.toolbarHoverFill) + RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous) + .fill(DesignMaterial.toolbarHoverFill) .allowsHitTesting(false) } } } func terminalSurfaceChrome() -> some View { - padding(Tokens.Space.s) - .background(.black.opacity(Tokens.Terminal.surfaceOpacity), - in: RoundedRectangle(cornerRadius: Tokens.Radius.card, + padding(DesignTokens.Space.s) + .background(.black.opacity(DesignTokens.Terminal.surfaceOpacity), + in: RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous)) - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } func subtleTileBackground() -> some View { - background(.quaternary.opacity(Tokens.InlineControl.subtleTileOpacity), - in: RoundedRectangle(cornerRadius: Tokens.Radius.control, + background(.quaternary.opacity(DesignTokens.InlineControl.subtleTileOpacity), + in: RoundedRectangle(cornerRadius: DesignTokens.Radius.control, style: .continuous)) } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ExteriorShadow.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/ExteriorShadow.swift similarity index 100% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ExteriorShadow.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/ExteriorShadow.swift diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassSurface.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/GlassSurface.swift similarity index 98% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassSurface.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/GlassSurface.swift index fce55438..2f0095a4 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/GlassSurface.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/GlassSurface.swift @@ -18,7 +18,7 @@ public struct GlassSurface: ViewModifier { @Environment(\.colorScheme) private var colorScheme public init(level: Level = .regular, - cornerRadius: CGFloat = Tokens.Radius.card, + cornerRadius: CGFloat = DesignTokens.Radius.card, glass: Glass = .regular, shadow: Bool = true, fill: Color? = nil, @@ -150,7 +150,7 @@ public struct GlassCapsuleSurface: ViewModifier { public extension View { func glassSurface(_ level: GlassSurface.Level = .regular, - cornerRadius: CGFloat = Tokens.Radius.card, + cornerRadius: CGFloat = DesignTokens.Radius.card, glass: Glass = .regular, shadow: Bool = true, fill: Color? = nil, diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/VisualEffectBackground.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift similarity index 100% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/VisualEffectBackground.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme/Theme.swift similarity index 86% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme/Theme.swift index 7dee58cf..73550eee 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme/Theme.swift @@ -1,9 +1,9 @@ import SwiftUI import AppKit -/// Material/elevation constants for reusable app surfaces. Keep glass, shadow, and stroke choices +/// Material/elevation constants for reusable design surfaces. Keep glass, shadow, and stroke choices /// here so collapsed controls and expanded panels do not drift into near-duplicates. -public enum AppMaterial { +public enum DesignMaterial { public static let toolbarHoverFill = Color.white.opacity(0.1) public static func toolbarInteractiveHoverFill(for colorScheme: ColorScheme) -> Color { Color.white.opacity(colorScheme == .light ? 0.2 : 0.1) @@ -14,17 +14,16 @@ public enum AppMaterial { public static let floatingPanelShadowY: CGFloat = 12 } -/// A curated color, used identically for the app accent (Settings) and per-card personalization -/// (icon + optional background wash) so the palette is consistent everywhere. `.multicolor` is the -/// "follow the app accent" option: it resolves to `Color.accentColor`, which the root sets to the -/// chosen accent tint — so a container left on `.multicolor` tracks whatever the app accent is. -public enum AppTint: String, CaseIterable, Identifiable, Codable, Sendable { +/// A curated color, used consistently for host accent choices and per-surface personalization. +/// `.multicolor` follows `Color.accentColor`, so package callers can decide whether that means a +/// global accent, a scoped accent, or the platform default. +public enum DesignTint: String, CaseIterable, Identifiable, Codable, Sendable { case multicolor, graphite, azure, teal, coral, indigo, green, amber, pink public var id: String { rawValue } - /// True for the "follow the app accent" option (rendered with a marker in the swatch row). - public var followsAppAccent: Bool { self == .multicolor } + /// True for the "follow the host accent" option (rendered with a marker in the swatch row). + public var followsAccent: Bool { self == .multicolor } public var color: Color { switch self { @@ -41,8 +40,8 @@ public enum AppTint: String, CaseIterable, Identifiable, Codable, Sendable { } /// Parse a legacy `contained.tint` label value, falling back to multicolor. - public static func parse(_ raw: String?) -> AppTint { - guard let raw, let tint = AppTint(rawValue: raw.lowercased()) else { return .multicolor } + public static func parse(_ raw: String?) -> DesignTint { + guard let raw, let tint = DesignTint(rawValue: raw.lowercased()) else { return .multicolor } return tint } } @@ -89,7 +88,7 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Codable, Sendabl public enum CardDensity: String, CaseIterable, Identifiable, Codable, Sendable { case small, medium, large public var id: String { rawValue } - public var resourceSize: ResourceCardSize { + public var resourceSize: DesignCardSize { switch self { case .small: return .small case .medium: return .medium @@ -158,7 +157,7 @@ public extension EnvironmentValues { @Entry var modalMaterial: WindowMaterial = .sheet /// The user-chosen toolbar-control (button) material, seeded at the app root. @Entry var buttonMaterial: WindowMaterial = .glassClear - /// The user-chosen resource-card material, seeded at the app root. + /// The user-chosen design-card material, seeded at the app root. @Entry var cardMaterial: WindowMaterial = .glassRegular /// Optional color/gradient wash layered into glass buttons. @Entry var buttonTintStyle: GlassButtonTintStyle = .disabled @@ -196,7 +195,7 @@ public extension View { private struct FloatingPanelMaterial: AnimatableModifier { @Environment(\.modalMaterial) private var material - var cornerRadius = Tokens.Radius.sheet + var cornerRadius = DesignTokens.Radius.sheet var showsShadow = true nonisolated var animatableData: CGFloat { @@ -210,9 +209,9 @@ private struct FloatingPanelMaterial: AnimatableModifier { .background { if showsShadow { ExteriorShadow(cornerRadius: cornerRadius, - color: AppMaterial.floatingPanelShadow, - radius: AppMaterial.floatingPanelShadowRadius, - y: AppMaterial.floatingPanelShadowY) + color: DesignMaterial.floatingPanelShadow, + radius: DesignMaterial.floatingPanelShadowRadius, + y: DesignMaterial.floatingPanelShadowY) } } .background { @@ -225,7 +224,7 @@ private struct FloatingPanelMaterial: AnimatableModifier { } .clipShape(shape) .overlay { - shape.strokeBorder(AppMaterial.floatingPanelStroke, lineWidth: 1) + shape.strokeBorder(DesignMaterial.floatingPanelStroke, lineWidth: 1) } } } @@ -250,7 +249,7 @@ private struct ToolbarControlMaterial: ViewModifier { public extension View { /// In-window floating panel material. Unlike `.sheet`, this samples the live app content instead /// of the dimmed system-modal backdrop, so thin materials actually read thin. - func floatingPanelMaterial(cornerRadius: CGFloat = Tokens.Radius.sheet, + func floatingPanelMaterial(cornerRadius: CGFloat = DesignTokens.Radius.sheet, showsShadow: Bool = true) -> some View { modifier(FloatingPanelMaterial(cornerRadius: cornerRadius, showsShadow: showsShadow)) } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens/DesignTokens.swift similarity index 97% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens/DesignTokens.swift index fea0dc27..474296d5 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens/DesignTokens.swift @@ -1,7 +1,7 @@ import SwiftUI /// Design tokens — the single source of truth for spacing, radii, and type used across the app. -public enum Tokens { +public enum DesignTokens { public enum Radius { /// Radius delta between nested glass levels: sheet -> card -> control -> key cap. public static let step: CGFloat = 6 @@ -37,7 +37,7 @@ public enum Tokens { public static let largeMax: CGFloat = 520 } - /// Canonical sheet dimensions — pass to `.frame(Tokens.SheetSize.form)`. Replaces ad-hoc + /// Canonical sheet dimensions — pass to `.frame(DesignTokens.SheetSize.form)`. Replaces ad-hoc /// `width:height:` literals so every sheet snaps to one of a few sizes. public enum SheetSize { public static let small = CGSize(width: 420, height: 280) // confirmations, short forms @@ -97,7 +97,7 @@ public enum Tokens { public static let refreshReadout: CGFloat = 32 } - public enum ResourceCard { + public enum DesignCard { public static let padding: CGFloat = 10 public static let compactTextSpacing: CGFloat = Space.hairline public static let detailTextSpacing: CGFloat = Space.xxs @@ -193,7 +193,7 @@ public enum Tokens { } public extension View { - /// Apply a canonical sheet size from `Tokens.SheetSize`. + /// Apply a canonical sheet size from `DesignTokens.SheetSize`. func frame(_ size: CGSize) -> some View { frame(width: size.width, height: size.height) } diff --git a/Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/DesignCardLayoutPolicyTests.swift b/Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/DesignCardLayoutPolicyTests.swift new file mode 100644 index 00000000..7f8144e7 --- /dev/null +++ b/Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/DesignCardLayoutPolicyTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import ContainedDesignSystem + +@Suite("Design card layout policy") +struct DesignCardLayoutPolicyTests { + @Test func sizeControlsStickyAndEmbeddedCardSlots() { + #expect(DesignCardSize.small.keepsFooterSticky == false) + #expect(DesignCardSize.small.embedsFooterInBody == true) + #expect(DesignCardSize.small.keepsWidgetSticky == false) + #expect(DesignCardSize.small.embedsWidgetInBody == false) + + #expect(DesignCardSize.medium.keepsFooterSticky == true) + #expect(DesignCardSize.medium.embedsFooterInBody == false) + #expect(DesignCardSize.medium.keepsWidgetSticky == false) + #expect(DesignCardSize.medium.embedsWidgetInBody == true) + + #expect(DesignCardSize.large.keepsFooterSticky == true) + #expect(DesignCardSize.large.embedsFooterInBody == false) + #expect(DesignCardSize.large.keepsWidgetSticky == true) + #expect(DesignCardSize.large.embedsWidgetInBody == false) + } +} diff --git a/Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift b/Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift similarity index 100% rename from Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift rename to Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift diff --git a/Packages/ContainedNavigation/Package.swift b/Packages/ContainedNavigation/Package.swift index 0cbfdb5d..a2cc9dc9 100644 --- a/Packages/ContainedNavigation/Package.swift +++ b/Packages/ContainedNavigation/Package.swift @@ -17,5 +17,9 @@ let package = Package( .product(name: "ContainedDesignSystem", package: "ContainedDesignSystem"), ] ), + .testTarget( + name: "ContainedNavigationTests", + dependencies: ["ContainedNavigation"] + ), ] ) diff --git a/Packages/ContainedNavigation/README.md b/Packages/ContainedNavigation/README.md index 65b02cfb..9aa78522 100644 --- a/Packages/ContainedNavigation/README.md +++ b/Packages/ContainedNavigation/README.md @@ -29,9 +29,9 @@ import ContainedNavigation ## What Belongs Here -- `AppSafeAreaManager`, `AppSafeAreaPolicy`, and the `appSafeAreas` environment +- `MorphSafeAreaManager`, `MorphSafeAreaPolicy`, and the `morphSafeAreas` environment value for generic top/bottom toolbar safe-area contracts. -- `MorphGeometry`, `AppMorphTarget`, and `MorphPanelPlacement` for target rects +- `MorphGeometry`, `MorphTarget`, and `MorphPanelPlacement` for target rects that clamp to a safe area. - `MorphingExpander` for the reusable grow/shrink panel shell. - `MorphingSingleSurface` and `MorphingSingleSurfaceExpander` for card-like @@ -39,7 +39,7 @@ import ContainedNavigation a handoff panel. - `MorphSourceFrameReader` and `MorphSourceFramesKey` for measuring source frames in a named coordinate space without app-local preference keys. -- `MorphPanelScaffold` for generic fixed chrome, scrollable content, and pinned +- `DesignPanelScaffold` for generic fixed chrome, scrollable content, and pinned footer layout inside a morph panel. Keep concrete panel contents in the app target. For example, Images, Templates, @@ -62,20 +62,20 @@ struct NavigationPackageExample: View { private let originFrame = CGRect(x: 24, y: 24, - width: Tokens.Toolbar.buttonGroupHeight, - height: Tokens.Toolbar.buttonGroupHeight) + width: DesignTokens.Toolbar.buttonGroupHeight, + height: DesignTokens.Toolbar.buttonGroupHeight) var body: some View { ZStack(alignment: .topLeading) { DesignActionGroup(DesignAction(systemName: "plus", help: "Add") { isPresented = true }) - .padding(Tokens.Space.l) + .padding(DesignTokens.Space.l) MorphingExpander(isPresented: $isPresented, originFrame: originFrame, - target: .centered(size: Tokens.PanelSize.add)) { - MorphPanelScaffold(width: Tokens.PanelSize.add.width) { + target: .centered(size: DesignTokens.PanelSize.add)) { + DesignPanelScaffold(width: DesignTokens.PanelSize.add.width) { PanelHeader(symbol: "plus", title: "Add", subtitle: "Choose a starting point") { @@ -87,7 +87,7 @@ struct NavigationPackageExample: View { } Divider() } content: { - VStack(spacing: Tokens.Space.s) { + VStack(spacing: DesignTokens.Space.s) { GlassOptionTile(symbol: "play.rectangle", title: "Run a container", subtitle: "Start from an image") { @@ -99,13 +99,13 @@ struct NavigationPackageExample: View { isPresented = false } } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } } } - .environment(\.appSafeAreas, - AppSafeAreaManager(topToolbarHeight: Tokens.Toolbar.band, - bottomToolbarHeight: Tokens.Toolbar.band)) + .environment(\.morphSafeAreas, + MorphSafeAreaManager(topToolbarHeight: DesignTokens.Toolbar.band, + bottomToolbarHeight: DesignTokens.Toolbar.band)) } } ``` @@ -129,16 +129,16 @@ draws its own surface, use `MorphingSingleSurfaceExpander` instead of ```swift MorphingSingleSurfaceExpander(isPresented: $isPresented, originFrame: sourceFrame, - target: .anchored(size: Tokens.PanelSize.imageDetail)) { - ExpandedResourceCard() + target: .anchored(size: DesignTokens.PanelSize.imageDetail)) { + ExpandedDesignCard() } ``` ## Safe-Area Policies -Use `AppMorphTarget.centered(size:)` for modal-like work, such as creation -details. Use `AppMorphTarget.anchored(size:)` when a panel should grow from and -stay near its source control. Both paths use `AppSafeAreaManager` to avoid +Use `MorphTarget.centered(size:)` for modal-like work, such as creation +details. Use `MorphTarget.anchored(size:)` when a panel should grow from and +stay near its source control. Both paths use `MorphSafeAreaManager` to avoid toolbar bands and system insets. For real toolbar sources, measure source controls in the same named coordinate @@ -158,14 +158,15 @@ Button("Open") { isPresented = true } - Design package: `../ContainedDesignSystem/README.md` - App architecture: - `../../docs/wiki/Architecture.md` + `../../docs/architecture/Architecture.md` ## Verification -Build the package by itself: +Build and test the package by itself: ```sh swift build --package-path Packages/ContainedNavigation +swift test --package-path Packages/ContainedNavigation ``` Build it through the app graph: diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md b/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md index 132fb861..31e1b124 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md @@ -33,20 +33,20 @@ struct NavigationPackageExample: View { private let originFrame = CGRect(x: 24, y: 24, - width: Tokens.Toolbar.buttonGroupHeight, - height: Tokens.Toolbar.buttonGroupHeight) + width: DesignTokens.Toolbar.buttonGroupHeight, + height: DesignTokens.Toolbar.buttonGroupHeight) var body: some View { ZStack(alignment: .topLeading) { DesignActionGroup(DesignAction(systemName: "plus", help: "Add") { isPresented = true }) - .padding(Tokens.Space.l) + .padding(DesignTokens.Space.l) MorphingExpander(isPresented: $isPresented, originFrame: originFrame, - target: .centered(size: Tokens.PanelSize.add)) { - MorphPanelScaffold(width: Tokens.PanelSize.add.width) { + target: .centered(size: DesignTokens.PanelSize.add)) { + DesignPanelScaffold(width: DesignTokens.PanelSize.add.width) { PanelHeader(symbol: "plus", title: "Add", subtitle: "Choose a starting point") { @@ -58,17 +58,17 @@ struct NavigationPackageExample: View { } Divider() } content: { - VStack(spacing: Tokens.Space.s) { + VStack(spacing: DesignTokens.Space.s) { Text("Panel content") .frame(maxWidth: .infinity, alignment: .leading) } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } } } - .environment(\.appSafeAreas, - AppSafeAreaManager(topToolbarHeight: Tokens.Toolbar.band, - bottomToolbarHeight: Tokens.Toolbar.band)) + .environment(\.morphSafeAreas, + MorphSafeAreaManager(topToolbarHeight: DesignTokens.Toolbar.band, + bottomToolbarHeight: DesignTokens.Toolbar.band)) } } ``` @@ -87,14 +87,14 @@ Button("Open") { isPresented = true } ### Safe Areas -- ``AppSafeAreaManager`` -- ``AppSafeAreaPolicy`` -- ``AppToolbarSafeAreaExclusion`` -- ``AppSafeAreaPadding`` +- ``MorphSafeAreaManager`` +- ``MorphSafeAreaPolicy`` +- ``MorphToolbarSafeAreaExclusion`` +- ``MorphSafeAreaPadding`` ### Morph Targets and Geometry -- ``AppMorphTarget`` +- ``MorphTarget`` - ``MorphPanelPlacement`` - ``MorphGeometry`` @@ -104,7 +104,7 @@ Button("Open") { isPresented = true } - ``MorphingSingleSurface`` - ``MorphingSingleSurfaceExpander`` - ``MorphFrame`` -- ``MorphPanelScaffold`` +- ``DesignPanelScaffold`` - ``MorphSourceFrameReader`` - ``MorphSourceFramesKey`` - ``GlobalBackdropStyle`` diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphSourceFrames.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/Measurement/MorphSourceFrames.swift similarity index 100% rename from Packages/ContainedNavigation/Sources/ContainedNavigation/MorphSourceFrames.swift rename to Packages/ContainedNavigation/Sources/ContainedNavigation/Measurement/MorphSourceFrames.swift diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingExpander.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingExpander.swift similarity index 90% rename from Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingExpander.swift rename to Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingExpander.swift index 4672bfe5..7721d245 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingExpander.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingExpander.swift @@ -6,14 +6,14 @@ public enum MorphPanelPlacement: Equatable, Sendable { case centered } -public struct AppMorphTarget { +public struct MorphTarget { public var placement: MorphPanelPlacement - public var safeArea: AppSafeAreaPolicy + public var safeArea: MorphSafeAreaPolicy public var margin: CGFloat public var proposedSize: (CGRect) -> CGSize public init(placement: MorphPanelPlacement, - safeArea: AppSafeAreaPolicy, + safeArea: MorphSafeAreaPolicy, margin: CGFloat, proposedSize: @escaping (CGRect) -> CGSize) { self.placement = placement @@ -23,27 +23,27 @@ public struct AppMorphTarget { } public static func anchored(size: CGSize, - safeArea: AppSafeAreaPolicy = .toolbarChrome, - margin: CGFloat = MorphGeometry.defaultMargin) -> AppMorphTarget { - AppMorphTarget(placement: .anchored, + safeArea: MorphSafeAreaPolicy = .toolbarChrome, + margin: CGFloat = MorphGeometry.defaultMargin) -> MorphTarget { + MorphTarget(placement: .anchored, safeArea: safeArea, margin: margin, proposedSize: { _ in size }) } public static func centered(size: CGSize, - safeArea: AppSafeAreaPolicy = .content, - margin: CGFloat = MorphGeometry.defaultMargin) -> AppMorphTarget { - AppMorphTarget(placement: .centered, + safeArea: MorphSafeAreaPolicy = .content, + margin: CGFloat = MorphGeometry.defaultMargin) -> MorphTarget { + MorphTarget(placement: .centered, safeArea: safeArea, margin: margin, proposedSize: { _ in size }) } - public static func centered(safeArea: AppSafeAreaPolicy = .content, + public static func centered(safeArea: MorphSafeAreaPolicy = .content, margin: CGFloat = MorphGeometry.defaultMargin, - proposedSize: @escaping (CGRect) -> CGSize) -> AppMorphTarget { - AppMorphTarget(placement: .centered, + proposedSize: @escaping (CGRect) -> CGSize) -> MorphTarget { + MorphTarget(placement: .centered, safeArea: safeArea, margin: margin, proposedSize: proposedSize) @@ -51,7 +51,7 @@ public struct AppMorphTarget { public func rect(origin: CGRect, in container: CGSize, - safeAreas: AppSafeAreaManager, + safeAreas: MorphSafeAreaManager, proposedSize overrideSize: CGSize? = nil, placement overridePlacement: MorphPanelPlacement? = nil) -> CGRect { let bounds = safeAreas.bounds(in: container, policy: safeArea) @@ -76,8 +76,8 @@ public struct GlobalBackdropStyle: OptionSet, Equatable, Sendable { } public enum MorphGeometry { - public static let defaultMargin: CGFloat = Tokens.Space.l - public static let centeredTopMargin: CGFloat = Tokens.Space.xxl * 2 + public static let defaultMargin: CGFloat = DesignTokens.Space.l + public static let centeredTopMargin: CGFloat = DesignTokens.Space.xxl * 2 public static func fittedSize(_ proposed: CGSize, in container: CGSize, margin: CGFloat = defaultMargin) -> CGSize { @@ -88,8 +88,8 @@ public enum MorphGeometry { margin: CGFloat = defaultMargin) -> CGSize { let maxWidth = max(1, bounds.width - margin * 2) let maxHeight = max(1, bounds.height - margin * 2) - return CGSize(width: min(max(Tokens.PanelSize.minWidth, proposed.width), maxWidth), - height: min(max(Tokens.PanelSize.minHeight, proposed.height), maxHeight)) + return CGSize(width: min(max(DesignTokens.PanelSize.minWidth, proposed.width), maxWidth), + height: min(max(DesignTokens.PanelSize.minHeight, proposed.height), maxHeight)) } public static func targetRect(origin: CGRect, proposedSize: CGSize, container: CGSize, @@ -157,13 +157,13 @@ public struct MorphingExpander: View { @Binding var isPresented: Bool /// The slot the panel grows out of / collapses back into, in this view's coordinate space. let originFrame: CGRect - var target: AppMorphTarget + var target: MorphTarget var backdropStyle: GlobalBackdropStyle = .dim var showsBackdrop = true var showsPanelShadow = true var closeRequestToken = 0 - var sourceCornerRadius = Tokens.Toolbar.groupRadius - var targetCornerRadius = Tokens.Radius.sheet + var sourceCornerRadius = DesignTokens.Toolbar.groupRadius + var targetCornerRadius = DesignTokens.Radius.sheet var onBackdropTap: (() -> Void)? var onExpansionChange: ((Bool) -> Void)? @ViewBuilder var content: () -> Content @@ -176,18 +176,18 @@ public struct MorphingExpander: View { @State private var livePlacement: MorphPanelPlacement? @Namespace private var shellNamespace @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.appSafeAreas) private var safeAreas + @Environment(\.morphSafeAreas) private var safeAreas private var spring: Animation { .spring(response: 0.42, dampingFraction: 0.86) } public init(isPresented: Binding, originFrame: CGRect, - target: AppMorphTarget = .centered(size: CGSize(width: 460, height: 440)), + target: MorphTarget = .centered(size: CGSize(width: 460, height: 440)), backdropStyle: GlobalBackdropStyle = .dim, showsBackdrop: Bool = true, showsPanelShadow: Bool = true, closeRequestToken: Int = 0, - sourceCornerRadius: CGFloat = Tokens.Toolbar.groupRadius, - targetCornerRadius: CGFloat = Tokens.Radius.sheet, + sourceCornerRadius: CGFloat = DesignTokens.Toolbar.groupRadius, + targetCornerRadius: CGFloat = DesignTokens.Radius.sheet, onBackdropTap: (() -> Void)? = nil, onExpansionChange: ((Bool) -> Void)? = nil, @ViewBuilder content: @escaping () -> Content) { @@ -316,7 +316,7 @@ public extension View { } private struct MorphPanelShell: View { - var cornerRadius = Tokens.Radius.sheet + var cornerRadius = DesignTokens.Radius.sheet var showsShadow = true var body: some View { diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingSingleSurface.swift similarity index 97% rename from Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift rename to Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingSingleSurface.swift index 02cfc58d..f29ae31f 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphingSingleSurface.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingSingleSurface.swift @@ -51,11 +51,11 @@ public struct MorphingSingleSurface: View { /// drawing a separate panel shell around the content. /// /// Use this when the promoted content is already its own visual surface, such as an expanded -/// `ResourceGlassCard`. Panel contents should still use `MorphingExpander`. +/// `DesignCardSurface`. Panel contents should still use `MorphingExpander`. public struct MorphingSingleSurfaceExpander: View { @Binding var isPresented: Bool public var originFrame: CGRect - public var target: AppMorphTarget + public var target: MorphTarget public var backdropStyle: GlobalBackdropStyle public var showsBackdrop: Bool public var closeRequestToken: Int @@ -67,12 +67,12 @@ public struct MorphingSingleSurfaceExpander: View { @State private var liveSize: CGSize? @State private var livePlacement: MorphPanelPlacement? @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.appSafeAreas) private var safeAreas + @Environment(\.morphSafeAreas) private var safeAreas private var spring: Animation { .spring(response: 0.42, dampingFraction: 0.86) } public init(isPresented: Binding, originFrame: CGRect, - target: AppMorphTarget, + target: MorphTarget, backdropStyle: GlobalBackdropStyle = .dim, showsBackdrop: Bool = true, closeRequestToken: Int = 0, diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphPanelScaffold.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/Panels/DesignPanelScaffold.swift similarity index 94% rename from Packages/ContainedNavigation/Sources/ContainedNavigation/MorphPanelScaffold.swift rename to Packages/ContainedNavigation/Sources/ContainedNavigation/Panels/DesignPanelScaffold.swift index b6f6d210..609b1975 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/MorphPanelScaffold.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/Panels/DesignPanelScaffold.swift @@ -4,7 +4,7 @@ import ContainedDesignSystem /// A toolbar morph-panel body: fixed chrome (header, segmented pickers) above a scrollable content area /// and an optional pinned footer. /// -/// The panel takes the **fixed size** its host hands the `MorphingExpander` (e.g. `Tokens.PanelSize.*`). +/// The panel takes the **fixed size** its host hands the `MorphingExpander` (e.g. `DesignTokens.PanelSize.*`). /// The inner `ScrollView` simply fills that area and scrolls; it does **not** measure its content. This /// matters for performance: an earlier version measured the scroll content's natural height (to make the /// panel hug it), which forced the whole `LazyVStack` to realize on open — slow for long lists like @@ -14,7 +14,7 @@ import ContainedDesignSystem /// workspace, the paged run form). In that mode the scaffold doesn't wrap the content in a `ScrollView`, /// so scroll views aren't double-nested; the host (e.g. `CreationFlow`) supplies the size via /// `morphPanelSize`. -public struct MorphPanelScaffold: View { +public struct DesignPanelScaffold: View { /// Retained for call-site compatibility; the panel's width comes from the host's morph target. public var width: CGFloat public var placement: MorphPanelPlacement = .anchored @@ -65,7 +65,7 @@ public struct MorphPanelScaffold: Vie } } -public extension MorphPanelScaffold where Footer == EmptyView { +public extension DesignPanelScaffold where Footer == EmptyView { init(width: CGFloat, placement: MorphPanelPlacement = .anchored, scrollEdgeStyle: ScrollEdgeEffectStyle = .soft, diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift new file mode 100644 index 00000000..73b82247 --- /dev/null +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift @@ -0,0 +1,55 @@ +import SwiftUI +import ContainedDesignSystem + +#Preview("Morph Panel") { + NavigationPreview() + .frame(width: 720, height: 520) + .environment(\.morphSafeAreas, + MorphSafeAreaManager(topToolbarHeight: DesignTokens.Toolbar.band, + bottomToolbarHeight: DesignTokens.Toolbar.band)) + .environment(\.buttonMaterial, .glassClear) +} + +private struct NavigationPreview: View { + @State private var isPresented = true + + private let origin = CGRect(x: 24, + y: 24, + width: DesignTokens.Toolbar.buttonGroupHeight, + height: DesignTokens.Toolbar.buttonGroupHeight) + + var body: some View { + ZStack(alignment: .topLeading) { + DesignActionGroup(DesignAction(systemName: "plus", help: "Open") { + isPresented = true + }) + .padding(DesignTokens.Space.l) + + MorphingExpander(isPresented: $isPresented, + originFrame: origin, + target: .centered(size: DesignTokens.PanelSize.add)) { + DesignPanelScaffold(width: DesignTokens.PanelSize.add.width) { + PanelHeader(symbol: "plus", + title: "Preview panel", + subtitle: "Reusable morph layout") { + DesignActionGroup(DesignAction(systemName: "xmark", + help: "Close", + isCancel: true) { + isPresented = false + }) + } + } content: { + VStack(spacing: DesignTokens.Space.s) { + GlassOptionTile(symbol: "shippingbox", + title: "Container", + subtitle: "Start from an image") {} + GlassOptionTile(symbol: "square.stack.3d.up", + title: "Image", + subtitle: "Use a local image") {} + } + .padding(DesignTokens.Space.s) + } + } + } + } +} diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/AppSafeAreaManager.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/SafeAreas/MorphSafeAreaManager.swift similarity index 63% rename from Packages/ContainedNavigation/Sources/ContainedNavigation/AppSafeAreaManager.swift rename to Packages/ContainedNavigation/Sources/ContainedNavigation/SafeAreas/MorphSafeAreaManager.swift index 359211dc..e11f143f 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/AppSafeAreaManager.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/SafeAreas/MorphSafeAreaManager.swift @@ -1,43 +1,43 @@ import SwiftUI -public struct AppToolbarSafeAreaExclusion: OptionSet, Equatable, Sendable { +public struct MorphToolbarSafeAreaExclusion: OptionSet, Equatable, Sendable { public let rawValue: Int public init(rawValue: Int) { self.rawValue = rawValue } - public static let top = AppToolbarSafeAreaExclusion(rawValue: 1 << 0) - public static let bottom = AppToolbarSafeAreaExclusion(rawValue: 1 << 1) - public static let both: AppToolbarSafeAreaExclusion = [.top, .bottom] + public static let top = MorphToolbarSafeAreaExclusion(rawValue: 1 << 0) + public static let bottom = MorphToolbarSafeAreaExclusion(rawValue: 1 << 1) + public static let both: MorphToolbarSafeAreaExclusion = [.top, .bottom] } -public enum AppSafeAreaPadding: CGFloat, Equatable, Sendable { +public enum MorphSafeAreaPadding: CGFloat, Equatable, Sendable { case none = 0 case small = 8 case medium = 16 case large = 24 } -public struct AppSafeAreaPolicy: Equatable, Sendable { - public var excluding: AppToolbarSafeAreaExclusion - public var padding: AppSafeAreaPadding +public struct MorphSafeAreaPolicy: Equatable, Sendable { + public var excluding: MorphToolbarSafeAreaExclusion + public var padding: MorphSafeAreaPadding public var includesSystemInsets: Bool - public init(excluding: AppToolbarSafeAreaExclusion = .both, - padding: AppSafeAreaPadding = .small, + public init(excluding: MorphToolbarSafeAreaExclusion = .both, + padding: MorphSafeAreaPadding = .small, includesSystemInsets: Bool = true) { self.excluding = excluding self.padding = padding self.includesSystemInsets = includesSystemInsets } - public static let fullBleed = AppSafeAreaPolicy(excluding: [], padding: .none) - public static let toolbarChrome = AppSafeAreaPolicy(excluding: [], padding: .small) - public static let content = AppSafeAreaPolicy(excluding: .both, padding: .medium) + public static let fullBleed = MorphSafeAreaPolicy(excluding: [], padding: .none) + public static let toolbarChrome = MorphSafeAreaPolicy(excluding: [], padding: .small) + public static let content = MorphSafeAreaPolicy(excluding: .both, padding: .medium) } -public struct AppSafeAreaManager: Equatable, Sendable { +public struct MorphSafeAreaManager: Equatable, Sendable { public var system: EdgeInsets public var topToolbarHeight: CGFloat public var bottomToolbarHeight: CGFloat @@ -54,7 +54,7 @@ public struct AppSafeAreaManager: Equatable, Sendable { self.init(system: system, topToolbarHeight: toolbarHeight, bottomToolbarHeight: 0) } - public func insets(_ policy: AppSafeAreaPolicy = .content) -> EdgeInsets { + public func insets(_ policy: MorphSafeAreaPolicy = .content) -> EdgeInsets { let padding = policy.padding.rawValue let systemInsets = policy.includesSystemInsets ? system : EdgeInsets() // On an edge that excludes its toolbar, the band *is* the inset — the padding doesn't stack on @@ -65,7 +65,7 @@ public struct AppSafeAreaManager: Equatable, Sendable { trailing: systemInsets.trailing + padding) } - public func bounds(in size: CGSize, policy: AppSafeAreaPolicy = .content) -> CGRect { + public func bounds(in size: CGSize, policy: MorphSafeAreaPolicy = .content) -> CGRect { let safeInsets = insets(policy) return CGRect(x: safeInsets.leading, y: safeInsets.top, @@ -75,5 +75,5 @@ public struct AppSafeAreaManager: Equatable, Sendable { } public extension EnvironmentValues { - @Entry var appSafeAreas = AppSafeAreaManager() + @Entry var morphSafeAreas = MorphSafeAreaManager() } diff --git a/Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift b/Packages/ContainedNavigation/Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift similarity index 100% rename from Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift rename to Packages/ContainedNavigation/Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift diff --git a/Packages/ContainedPreviewSupport/Package.swift b/Packages/ContainedPreviewSupport/Package.swift new file mode 100644 index 00000000..743eb19f --- /dev/null +++ b/Packages/ContainedPreviewSupport/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "ContainedPreviewSupport", + platforms: [.macOS(.v26)], + products: [ + .library(name: "ContainedPreviewSupport", targets: ["ContainedPreviewSupport"]), + ], + dependencies: [ + .package(path: "../ContainedCore"), + .package(path: "../ContainedRuntime"), + ], + targets: [ + .target( + name: "ContainedPreviewSupport", + dependencies: [ + .product(name: "ContainedCore", package: "ContainedCore"), + .product(name: "ContainedRuntime", package: "ContainedRuntime"), + ] + ), + .testTarget( + name: "ContainedPreviewSupportTests", + dependencies: ["ContainedPreviewSupport"] + ), + ] +) diff --git a/Packages/ContainedPreviewSupport/README.md b/Packages/ContainedPreviewSupport/README.md new file mode 100644 index 00000000..4f290092 --- /dev/null +++ b/Packages/ContainedPreviewSupport/README.md @@ -0,0 +1,33 @@ +# ContainedPreviewSupport + +ContainedPreviewSupport provides deterministic fixtures for previews, examples, +and lightweight package tests. + +## Owns + +- Sample containers, images, runtime descriptors, stats, histories, and activity + states. +- Fixture values that packages and the app can reuse without spinning up the + Apple `container` service. + +## Does Not Own + +- Localized strings. +- Production app state or persistence. +- Live runtime calls. + +## Example + +```swift +import ContainedPreviewSupport + +let container = PreviewSamples.webContainer +let values = PreviewSamples.sparklineValues +``` + +## Build And Test + +```sh +swift build --package-path Packages/ContainedPreviewSupport +swift test --package-path Packages/ContainedPreviewSupport +``` diff --git a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md new file mode 100644 index 00000000..86b9f2fc --- /dev/null +++ b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md @@ -0,0 +1,9 @@ +# ``ContainedPreviewSupport`` + +Deterministic sample data for SwiftUI previews and package examples. + +## Overview + +`ContainedPreviewSupport` gives app and package previews stable data without +launching a runtime service. It intentionally avoids localized strings and live +process calls so previews remain fast and predictable. diff --git a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/PreviewSamples.swift b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/PreviewSamples.swift new file mode 100644 index 00000000..d20d594b --- /dev/null +++ b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/PreviewSamples.swift @@ -0,0 +1,110 @@ +import Foundation +import ContainedCore +import ContainedRuntime + +public enum PreviewSamples { + public static let now = Date(timeIntervalSinceReferenceDate: 790_000_000) + + public static let appleRuntime = RuntimeDescriptor.appleContainer + + public static let webContainer = ContainerSnapshot.placeholder( + id: "preview-web", + image: "docker.io/library/nginx:latest", + state: .running + ) + + public static let workerContainer = ContainerSnapshot.placeholder( + id: "preview-worker", + image: "ghcr.io/example/worker:nightly", + state: .stopped + ) + + public static let stats = StatsDelta( + id: "preview-web", + cpuCoreFraction: 0.62, + memoryUsageBytes: 420_000_000, + memoryLimitBytes: 1_073_741_824, + netRxBytesPerSec: 186_000, + netTxBytesPerSec: 72_000, + blockReadBytesPerSec: 8_400, + blockWriteBytesPerSec: 16_800, + numProcesses: 9 + ) + + public static let sparklineValues: [Double] = [ + 0.12, 0.16, 0.18, 0.25, 0.22, 0.31, 0.38, 0.35, + 0.44, 0.48, 0.43, 0.52, 0.57, 0.54, 0.61, 0.58, + 0.66, 0.62, 0.70, 0.68, 0.74, 0.71, 0.78, 0.76 + ] + + public static let networkSamples: [Double] = [ + 18_000, 24_000, 22_000, 46_000, 42_000, 54_000, + 66_000, 72_000, 68_000, 80_000, 92_000, 88_000 + ] + + public static let image = decode(ImageResource.self, from: """ + { + "configuration": { + "name": "docker.io/library/nginx:latest", + "descriptor": { + "digest": "sha256:previewnginx", + "mediaType": "application/vnd.oci.image.index.v1+json", + "size": 146120 + }, + "creationDate": "2026-07-01T12:00:00Z" + }, + "id": "sha256:previewnginx", + "variants": [ + { + "digest": "sha256:previewnginx-arm64", + "size": 48120000, + "platform": { "architecture": "arm64", "os": "linux" }, + "config": { + "architecture": "arm64", + "os": "linux", + "created": "2026-07-01T12:00:00Z", + "config": { + "Cmd": ["nginx", "-g", "daemon off;"], + "Entrypoint": ["/docker-entrypoint.sh"], + "Env": ["NGINX_VERSION=preview"], + "WorkingDir": "/", + "User": "101" + } + } + } + ] + } + """) + + public static let imageGroup = LocalImageTagGroup.group(containing: image, in: [image]) + + public static let createRequest: ContainerCreateRequest = { + var request = ContainerCreateRequest() + request.runtimeKind = .appleContainer + request.image = image.reference + request.platform = "linux/arm64" + request.name = "preview-web" + request.command = ["nginx", "-g", "daemon off;"] + request.env = [ContainerCreateKeyValue(key: "ENV", value: "preview")] + request.labels = [ContainerCreateKeyValue(key: "contained.stack", value: "preview")] + request.ports = [ContainerCreatePort(hostPort: "8080", containerPort: "80")] + request.cpus = "2" + request.memory = "1g" + request.workingDir = "/" + request.useInit = true + return request + }() + + public static let activityTitle = "Preview activity" + public static let activityDetail = "Deterministic preview data" +} + +private func decode(_ type: T.Type, from json: String) -> T { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + do { + return try decoder.decode(T.self, from: Data(json.utf8)) + } catch { + preconditionFailure("Invalid preview fixture for \(T.self): \(error)") + } +} diff --git a/Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift b/Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift new file mode 100644 index 00000000..6bdb9820 --- /dev/null +++ b/Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift @@ -0,0 +1,12 @@ +import Testing +import ContainedPreviewSupport + +@Suite("Preview samples") +struct PreviewSamplesTests { + @Test func samplesAreDeterministicAndUsable() { + #expect(PreviewSamples.webContainer.id == "preview-web") + #expect(PreviewSamples.image.reference == "docker.io/library/nginx:latest") + #expect(!PreviewSamples.sparklineValues.isEmpty) + #expect(PreviewSamples.createRequest.image == PreviewSamples.image.reference) + } +} diff --git a/Packages/ContainedRuntime/Package.swift b/Packages/ContainedRuntime/Package.swift new file mode 100644 index 00000000..f17c48d3 --- /dev/null +++ b/Packages/ContainedRuntime/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "ContainedRuntime", + platforms: [.macOS(.v26)], + products: [ + .library(name: "ContainedRuntime", targets: ["ContainedRuntime"]), + ], + dependencies: [ + .package(path: "../ContainedCore"), + ], + targets: [ + .target( + name: "ContainedRuntime", + dependencies: [ + .product(name: "ContainedCore", package: "ContainedCore"), + ] + ), + .testTarget( + name: "ContainedRuntimeTests", + dependencies: [ + "ContainedRuntime", + .product(name: "ContainedCore", package: "ContainedCore"), + ] + ), + ] +) diff --git a/Packages/ContainedRuntime/README.md b/Packages/ContainedRuntime/README.md new file mode 100644 index 00000000..1f16b1e3 --- /dev/null +++ b/Packages/ContainedRuntime/README.md @@ -0,0 +1,46 @@ +# ContainedRuntime + +ContainedRuntime defines the backend contract used by the app and concrete +runtime adapters. It is adapter-neutral and owns no SwiftUI or display policy. + +## Owns + +- `ContainerRuntimeClient`, the async operation protocol for runtime adapters. +- `RuntimeDescriptor` and `RuntimeCapability`, used by the app to decide what a + selected runtime can support. +- Runtime translation result models for create, Compose import, image defaults, + and future core switching. +- `CommandRunner` and `CommandError` for CLI-backed adapters. +- Display-neutral error and unavailable-reason codes that the app maps to + localized alerts, toasts, and Activity entries. + +## Does Not Own + +- Apple-, Docker-, Podman-, Lima-, or remote-specific policy. +- UI routes, app settings, or localization. +- User-facing error messages. + +## Example + +```swift +import ContainedCore +import ContainedRuntime + +let descriptor = RuntimeDescriptor( + kind: RuntimeKind(rawValue: "future-runtime"), + displayName: "Future runtime", + executableName: "future", + capabilities: [.containers, .composeImport] +) + +if descriptor.supports(.composeImport) { + // Enable a runtime-specific Compose import route. +} +``` + +## Build And Test + +```sh +swift build --package-path Packages/ContainedRuntime +swift test --package-path Packages/ContainedRuntime +``` diff --git a/Sources/ContainedRuntime/RuntimeDescriptor.swift b/Packages/ContainedRuntime/Sources/ContainedRuntime/Clients/RuntimeDescriptor.swift similarity index 98% rename from Sources/ContainedRuntime/RuntimeDescriptor.swift rename to Packages/ContainedRuntime/Sources/ContainedRuntime/Clients/RuntimeDescriptor.swift index 045f142a..4d3fe1c5 100644 --- a/Sources/ContainedRuntime/RuntimeDescriptor.swift +++ b/Packages/ContainedRuntime/Sources/ContainedRuntime/Clients/RuntimeDescriptor.swift @@ -213,7 +213,11 @@ public extension ContainerRuntimeClient { func coreSwitchPlan(for containerID: String, to target: RuntimeDescriptor?) throws -> RuntimeCoreSwitchPlan { RuntimeCoreSwitchPlan( isAvailable: false, - reason: "Core switching requires export/import support from both runtimes.", + unavailableReason: .exportImportUnsupported, + context: [ + "source": descriptor.kind.rawValue, + "target": target?.kind.rawValue ?? "", + ], source: descriptor.kind, target: target?.kind ) diff --git a/Packages/ContainedRuntime/Sources/ContainedRuntime/ContainedRuntime.docc/ContainedRuntime.md b/Packages/ContainedRuntime/Sources/ContainedRuntime/ContainedRuntime.docc/ContainedRuntime.md new file mode 100644 index 00000000..e2dfc5bd --- /dev/null +++ b/Packages/ContainedRuntime/Sources/ContainedRuntime/ContainedRuntime.docc/ContainedRuntime.md @@ -0,0 +1,20 @@ +# ``ContainedRuntime`` + +Adapter-neutral runtime contracts for container engines. + +## Overview + +`ContainedRuntime` is the boundary between app stores and concrete engine +adapters. Stores depend on `any ContainerRuntimeClient`; adapter packages +implement the protocol and advertise capabilities with `RuntimeDescriptor`. + +Use this package to: + +- describe a runtime and its capabilities +- translate shared create/import models into runtime-specific plans +- report unsupported operations as display-neutral typed errors +- return unavailable operation reasons as stable codes instead of UI copy +- run CLI-backed commands through `CommandRunner` + +The app chooses which runtime to use for each container or import item, then maps +typed package errors into localized alerts, toasts, and Activity entries. diff --git a/Sources/ContainedRuntime/CommandError.swift b/Packages/ContainedRuntime/Sources/ContainedRuntime/Errors/CommandError.swift similarity index 100% rename from Sources/ContainedRuntime/CommandError.swift rename to Packages/ContainedRuntime/Sources/ContainedRuntime/Errors/CommandError.swift diff --git a/Sources/ContainedRuntime/CommandRunner.swift b/Packages/ContainedRuntime/Sources/ContainedRuntime/Support/CommandRunner.swift similarity index 100% rename from Sources/ContainedRuntime/CommandRunner.swift rename to Packages/ContainedRuntime/Sources/ContainedRuntime/Support/CommandRunner.swift diff --git a/Sources/ContainedRuntime/RuntimeTranslation.swift b/Packages/ContainedRuntime/Sources/ContainedRuntime/Translation/RuntimeTranslation.swift similarity index 78% rename from Sources/ContainedRuntime/RuntimeTranslation.swift rename to Packages/ContainedRuntime/Sources/ContainedRuntime/Translation/RuntimeTranslation.swift index e662fd15..ecb4fb2b 100644 --- a/Sources/ContainedRuntime/RuntimeTranslation.swift +++ b/Packages/ContainedRuntime/Sources/ContainedRuntime/Translation/RuntimeTranslation.swift @@ -40,18 +40,25 @@ public struct RuntimeComposeImportItem: Equatable, Sendable { } } +public enum RuntimeCoreSwitchUnavailableReason: String, Equatable, Sendable { + case exportImportUnsupported +} + public struct RuntimeCoreSwitchPlan: Equatable, Sendable { public var isAvailable: Bool - public var reason: String + public var unavailableReason: RuntimeCoreSwitchUnavailableReason? + public var context: [String: String] public var source: RuntimeKind public var target: RuntimeKind? public init(isAvailable: Bool, - reason: String, + unavailableReason: RuntimeCoreSwitchUnavailableReason?, + context: [String: String] = [:], source: RuntimeKind, target: RuntimeKind?) { self.isAvailable = isAvailable - self.reason = reason + self.unavailableReason = unavailableReason + self.context = context self.source = source self.target = target } diff --git a/Packages/ContainedRuntime/Tests/ContainedRuntimeTests/RuntimeDescriptorTests.swift b/Packages/ContainedRuntime/Tests/ContainedRuntimeTests/RuntimeDescriptorTests.swift new file mode 100644 index 00000000..e451d690 --- /dev/null +++ b/Packages/ContainedRuntime/Tests/ContainedRuntimeTests/RuntimeDescriptorTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing +import ContainedCore +@testable import ContainedRuntime + +@Suite("Runtime descriptor contracts") +struct RuntimeDescriptorTests { + @Test func openRuntimeKindsCanAdvertiseCapabilities() throws { + let descriptor = RuntimeDescriptor( + kind: RuntimeKind(rawValue: "future-runtime"), + displayName: "Future runtime", + executableName: "future", + capabilities: [.containers, .composeImport] + ) + + #expect(descriptor.supports(.containers)) + #expect(descriptor.supports(.composeImport)) + #expect(!descriptor.supports(.imageBuild)) + try descriptor.require(.containers) + } + + @Test func unsupportedCapabilityIsDisplayNeutralPackageError() { + let error = UnsupportedRuntimeCapability( + kind: .dockerCompatible, + capability: .imageBuild + ) + + #expect(error.packageName == "ContainedRuntime") + #expect(error.packageErrorCode == "unsupportedRuntimeCapability") + #expect(error.packageErrorContext["kind"] == RuntimeKind.dockerCompatible.rawValue) + #expect(error.packageErrorContext["capability"] == String(RuntimeCapability.imageBuild.rawValue)) + } + + @Test func defaultCoreSwitchPlanIsDisplayNeutral() throws { + let runtime = UnavailableRuntime( + descriptor: RuntimeDescriptor( + kind: RuntimeKind(rawValue: "future-runtime"), + displayName: "Future runtime", + executableName: "future", + capabilities: [.containers] + ) + ) + + let plan = try runtime.coreSwitchPlan(for: "web", to: .appleContainer) + + #expect(!plan.isAvailable) + #expect(plan.unavailableReason == .exportImportUnsupported) + #expect(plan.context["source"] == "future-runtime") + #expect(plan.context["target"] == RuntimeKind.appleContainer.rawValue) + } +} + +private struct UnavailableRuntime: ContainerRuntimeClient { + let descriptor: RuntimeDescriptor + + func listContainers(all: Bool) async throws -> [ContainerSnapshot] { [] } + func stats(ids: [String]) async throws -> [ContainerStats] { [] } + func streamStats(ids: [String]) -> AsyncThrowingStream<[RuntimeStatsSnapshot], Error> { + AsyncThrowingStream { $0.finish() } + } + func diskUsage() async throws -> DiskUsage { throw TestStubError.unused } + func systemProperties() async throws -> SystemProperties { throw TestStubError.unused } + func dnsDomains() async throws -> [String] { [] } + func createDNSDomain(_ domain: String) async throws -> Data { throw TestStubError.unused } + func deleteDNSDomain(_ domain: String) async throws -> Data { throw TestStubError.unused } + func setRecommendedKernel() async throws -> Data { throw TestStubError.unused } + func execCapture(_ id: String, _ command: [String]) async throws -> String { "" } + func copy(source: String, destination: String) async throws -> Data { throw TestStubError.unused } + func streamSystemLogs(follow: Bool, last: Int?) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } + func systemStatus() async throws -> SystemStatus { throw TestStubError.unused } + func networks() async throws -> [NetworkResource] { [] } + func volumes() async throws -> [VolumeResource] { [] } + func images() async throws -> [ImageResource] { [] } + func inspectImage(_ ref: String) async throws -> [ImageResource] { [] } + func streamLogs(id: String, follow: Bool, tail: Int?, boot: Bool) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } + func streamPull(_ ref: String, platform: String?) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } + func streamBuild(context: String, tag: String?, dockerfile: String?, + buildArgs: [String: String], noCache: Bool, + platform: String?) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } + func streamPush(_ ref: String, platform: String?) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } + func runContainer(arguments: [String]) async throws -> Data { throw TestStubError.unused } + func performSystemAction(_ action: String) async throws -> Data { throw TestStubError.unused } + func registries() async throws -> [RegistryLogin] { [] } + func registryLogin(server: String, username: String, password: String) async throws -> Data { throw TestStubError.unused } + func registryLogout(server: String) async throws -> Data { throw TestStubError.unused } + func deleteImages(_ refs: [String]) async throws -> Data { throw TestStubError.unused } + func tagImage(source: String, target: String) async throws -> Data { throw TestStubError.unused } + func saveImages(_ refs: [String], to output: String) async throws -> Data { throw TestStubError.unused } + func loadImages(from input: String) async throws -> Data { throw TestStubError.unused } + func exportContainer(_ id: String, to output: String) async throws -> Data { throw TestStubError.unused } + func pruneImages(all: Bool) async throws -> Data { throw TestStubError.unused } + func start(_ ids: [String]) async throws -> Data { throw TestStubError.unused } + func stop(_ ids: [String]) async throws -> Data { throw TestStubError.unused } + func deleteContainers(_ ids: [String], force: Bool) async throws -> Data { throw TestStubError.unused } + func pruneContainers() async throws -> Data { throw TestStubError.unused } + func pruneVolumes() async throws -> Data { throw TestStubError.unused } + func pruneNetworks() async throws -> Data { throw TestStubError.unused } + func createVolume(name: String, size: String?, labels: [String: String]) async throws -> Data { throw TestStubError.unused } + func deleteVolumes(_ names: [String]) async throws -> Data { throw TestStubError.unused } + func createNetwork(name: String, subnet: String?, internalOnly: Bool, + labels: [String: String]) async throws -> Data { throw TestStubError.unused } + func deleteNetworks(_ names: [String]) async throws -> Data { throw TestStubError.unused } +} + +private enum TestStubError: Error { + case unused +} diff --git a/README.md b/README.md index ae2d6bfc..fb3cf684 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,20 @@ Requirements: ## Build -Contained is SwiftPM-first. The checked-in Xcode workspace is for manual editing -and SwiftUI iteration; its shared schemes delegate back to SwiftPM so CI/release -behavior stays identical to the command line. +Contained has two supported development entry points that share the same package +graph: + +- Xcode: `Contained.xcworkspace` contains a native macOS app target that builds + and runs `Contained.app` directly for SwiftUI iteration. +- SwiftPM: `Package.swift`, `swift build`, `swift test`, and `scripts/bundle.sh` + remain the CI, release, packaging, signing, notarization, and appcast path. ```sh open Contained.xcworkspace swift build swift test xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug build +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug test ./scripts/bundle.sh debug open Contained.app ``` @@ -59,39 +64,45 @@ Maintainers use `scripts/release.sh` and `scripts/appcast.sh` for signing, notar ## Documentation -The GitHub wiki is the source of truth for feature and implementation notes. -The maintained wiki pages are mirrored in [`docs/wiki`](docs/wiki) so docs -changes can be reviewed with code changes: +Maintained docs live in [`docs`](docs) beside the code so architecture and +workflow changes can be reviewed with implementation changes: + +- App: [Home](docs/app/Home.md), [Installation](docs/app/Installation.md), [Keyboard Shortcuts](docs/app/Keyboard-Shortcuts.md), [Troubleshooting](docs/app/Troubleshooting.md), [Updates](docs/app/Updates.md), [System Settings](docs/app/System-Settings.md) +- Features: [Feature Overview](docs/features/Features.md), [Containers](docs/features/Containers.md), [Images](docs/features/Images.md), [Resources](docs/features/Resources.md), [Creation Workflow](docs/features/Creation-Workflow.md), [Run / Edit Form](docs/features/Run-Edit-Form.md), [Compose Import](docs/features/Compose-Import.md), [Command Palette](docs/features/Command-Palette.md) +- Architecture: [Architecture](docs/architecture/Architecture.md), [Runtime Adapters](docs/architecture/Runtime-Adapters.md), [Design System](docs/architecture/Design-System.md) +- Development: [Contributing](docs/development/Contributing.md), [Issues and Discussions](docs/development/Issues-and-Discussions.md), [Localization](docs/app/Localization.md) +- Release: [Release Runbook](docs/release/Release.md) -- Start: [Features](https://github.com/tdeverx/contained-app/wiki/Features), [Installation](https://github.com/tdeverx/contained-app/wiki/Installation), [Keyboard Shortcuts](https://github.com/tdeverx/contained-app/wiki/Keyboard-Shortcuts), [Troubleshooting](https://github.com/tdeverx/contained-app/wiki/Troubleshooting) -- Workflows: [Creation Workflow](https://github.com/tdeverx/contained-app/wiki/Creation-Workflow), [Run / Edit Form](https://github.com/tdeverx/contained-app/wiki/Run-Edit-Form), [Compose Import](https://github.com/tdeverx/contained-app/wiki/Compose-Import), [Command Palette](https://github.com/tdeverx/contained-app/wiki/Command-Palette), [Updates](https://github.com/tdeverx/contained-app/wiki/Updates) -- Feature areas: [Containers](https://github.com/tdeverx/contained-app/wiki/Features-Containers), [Images](https://github.com/tdeverx/contained-app/wiki/Features-Images), [Resources](https://github.com/tdeverx/contained-app/wiki/Features-Resources), [System & Settings](https://github.com/tdeverx/contained-app/wiki/System-Settings) -- Maintainers: [Architecture](https://github.com/tdeverx/contained-app/wiki/Architecture), [Runtime Adapters](https://github.com/tdeverx/contained-app/wiki/Runtime-Adapters), [Design System](https://github.com/tdeverx/contained-app/wiki/Design-System), [Release Runbook](https://github.com/tdeverx/contained-app/wiki/Release), [Contributing](https://github.com/tdeverx/contained-app/wiki/Contributing), [Issues and Discussions](https://github.com/tdeverx/contained-app/wiki/Issues-and-Discussions) +Each local package also has its own README and DocC landing page under +`Packages//`. ## Contributing And Support -Start with the [wiki](https://github.com/tdeverx/contained-app/wiki) and -[Troubleshooting](https://github.com/tdeverx/contained-app/wiki/Troubleshooting). +Start with the [docs](docs) and +[Troubleshooting](docs/app/Troubleshooting.md). Use [Discussions Q&A](https://github.com/tdeverx/contained-app/discussions/categories/q-a) for setup help and questions, and [open an issue](https://github.com/tdeverx/contained-app/issues/new/choose) for actionable bugs, crashes, regressions, or tracked feature work. -Please read the [contributing guide](https://github.com/tdeverx/contained-app/wiki/Contributing) +Please read the [contributing guide](docs/development/Contributing.md) before opening a larger PR. Do not post vulnerabilities publicly; use [private vulnerability reporting](https://github.com/tdeverx/contained-app/security/advisories/new) instead. ## Architecture -The root package has the app/core targets and consumes local reusable packages: - -- `ContainedCore`: models, runtime-neutral create/recreate request fields, Apple `container` argv builders, real `container --format json` decoders, compose parsing, and testable service logic. -- `ContainedRuntime`: shared runtime contracts, descriptors, capabilities, translation plans, command errors, and command execution primitives. -- `AppleContainerRuntime`: the current Apple `container` adapter, including translation from shared create/import models to Apple CLI commands. Future Docker-compatible, Podman, Lima-backed, remote, or other runtime engines should be sibling adapter targets. -- `Contained`: SwiftUI app shell, navigation, feature views, stores, history, settings, Sparkle support, app state migration, and app-specific presentation mappings. -- [`Packages/ContainedDesignSystem`](Packages/ContainedDesignSystem/README.md): reusable SwiftUI/AppKit visual primitives, tokens, spacing, material, and micro-chrome shared by the app. -- [`Packages/ContainedNavigation`](Packages/ContainedNavigation/README.md): reusable navigation and layout infrastructure shared by app chrome. +The root package contains the app launcher and app implementation, then consumes +standalone local packages: + +- [`ContainedCore`](Packages/ContainedCore/README.md): pure models, runtime-neutral create/recreate request fields, Apple `container` argv builders, decoders, compose parsing, metric normalization, and package error metadata. +- [`ContainedRuntime`](Packages/ContainedRuntime/README.md): shared runtime contracts, descriptors, capabilities, translation plans, command errors, and command execution primitives. +- [`AppleContainerRuntime`](Packages/AppleContainerRuntime/README.md): the current Apple `container` adapter, including translation from shared create/import models to Apple CLI commands. Future Docker-compatible, Podman, Lima-backed, remote, or other runtime engines should be sibling adapter packages. +- [`ContainedDesignSystem`](Packages/ContainedDesignSystem/README.md): reusable SwiftUI/AppKit visual primitives, tokens, spacing, material, cards, panels, controls, feedback, and data visualization. +- [`ContainedNavigation`](Packages/ContainedNavigation/README.md): reusable safe-area, morphing, measurement, and panel-host infrastructure. +- [`ContainedPreviewSupport`](Packages/ContainedPreviewSupport/README.md): deterministic fixtures for package examples and SwiftUI previews. +- `ContainedApp`: SwiftUI app shell, navigation, feature views, stores, history, settings, Sparkle support, app state migration, app-specific presentation mappings, and localization. +- `Contained`: tiny SwiftPM executable launcher used by command-line builds and bundle scripts. Integration is intentionally CLI-based rather than private-framework based. Personalization and app-managed metadata stay local to Contained so the user's container resources remain clean when used directly from the terminal. Reusable packages expose display-neutral errors with stable package codes/context; the app owns localized messages, alerts, and Activity history presentation. diff --git a/Sources/Contained/DesignSystem/GraphMetric.swift b/Sources/Contained/DesignSystem/GraphMetric.swift deleted file mode 100644 index 51d5ea33..00000000 --- a/Sources/Contained/DesignSystem/GraphMetric.swift +++ /dev/null @@ -1,124 +0,0 @@ -import SwiftUI -import ContainedCore - -/// Which live metric a card's sparkline plots. CPU and memory values are normalized against the -/// container's configured resource limits at the app boundary; raw runtime deltas stay in Core. -enum GraphMetric: String, CaseIterable, Identifiable, Codable, Sendable { - case cpu, memory, netRx, netTx, diskRead, diskWrite - var id: String { rawValue } - - var displayName: String { - switch self { - case .cpu: return AppText.string("graphMetric.cpu", defaultValue: "CPU") - case .memory: return AppText.string("graphMetric.memory", defaultValue: "Memory") - case .netRx: return AppText.string("graphMetric.netRx", defaultValue: "Net In") - case .netTx: return AppText.string("graphMetric.netTx", defaultValue: "Net Out") - case .diskRead: return AppText.string("graphMetric.diskRead", defaultValue: "Disk Read") - case .diskWrite: return AppText.string("graphMetric.diskWrite", defaultValue: "Disk Write") - } - } - - var systemImage: String { - switch self { - case .cpu: return "cpu" - case .memory: return "memorychip" - case .netRx: return "arrow.down.circle" - case .netTx: return "arrow.up.circle" - case .diskRead: return "arrow.down.doc" - case .diskWrite: return "arrow.up.doc" - } - } - - /// The value plotted for this metric from a stats delta. CPU is a fraction of configured cores; - /// memory is a fraction of configured memory. Throughput metrics remain bytes per second. - func value(from delta: StatsDelta, - snapshot: ContainerSnapshot? = nil, - normalization: StatsNormalizationContext = .containerSpecific) -> Double { - switch self { - case .cpu: return Self.cpuFraction(from: delta, snapshot: snapshot, normalization: normalization) - case .memory: return Self.memoryFraction(from: delta, snapshot: snapshot, normalization: normalization) - case .netRx: return delta.netRxBytesPerSec - case .netTx: return delta.netTxBytesPerSec - case .diskRead: return delta.blockReadBytesPerSec - case .diskWrite: return delta.blockWriteBytesPerSec - } - } - - /// The value plotted for this metric from durable history. History persists raw samples, then - /// applies the current normalization mode at display time so old samples remain useful. - func value(from sample: MetricSample, - snapshot: ContainerSnapshot? = nil, - normalization: StatsNormalizationContext = .containerSpecific, - memoryFallbackBytes: UInt64 = 0) -> Double { - switch self { - case .cpu: - return Self.sanitized(sample.cpuFraction) / normalization.cpuLimit(for: snapshot) - case .memory: - let limit = normalization.memoryLimitBytes(for: snapshot, fallback: memoryFallbackBytes) - guard limit > 0 else { return 0 } - return Self.sanitized(sample.memoryBytes / Double(limit)) - case .netRx: - return Self.sanitized(sample.netRxBytesPerSec) - case .netTx: - return Self.sanitized(sample.netTxBytesPerSec) - case .diskRead: - return Self.sanitized(sample.diskReadBytesPerSec) - case .diskWrite: - return Self.sanitized(sample.diskWriteBytesPerSec) - } - } - - /// An ultra-compact value for the card footer chips (tight space): decimal percent only below 1%, - /// `Format.compactRate` for throughput metrics ("0", "1.2K", "34M"). - func chipCaption(from delta: StatsDelta, - snapshot: ContainerSnapshot? = nil, - normalization: StatsNormalizationContext = .containerSpecific) -> String { - switch self { - case .cpu, .memory: - return Format.compactPercent(value(from: delta, snapshot: snapshot, normalization: normalization)) - case .netRx: return Format.compactRate(delta.netRxBytesPerSec) - case .netTx: return Format.compactRate(delta.netTxBytesPerSec) - case .diskRead: return Format.compactRate(delta.blockReadBytesPerSec) - case .diskWrite: return Format.compactRate(delta.blockWriteBytesPerSec) - } - } - - /// A short current-value label for the footer. - func caption(from delta: StatsDelta, - snapshot: ContainerSnapshot? = nil, - normalization: StatsNormalizationContext = .containerSpecific) -> String { - switch self { - case .cpu, .memory: - return Format.compactPercent(value(from: delta, snapshot: snapshot, normalization: normalization)) - case .netRx: return Format.rate(delta.netRxBytesPerSec) - case .netTx: return Format.rate(delta.netTxBytesPerSec) - case .diskRead: return Format.rate(delta.blockReadBytesPerSec) - case .diskWrite: return Format.rate(delta.blockWriteBytesPerSec) - } - } - - static func cpuFraction(from delta: StatsDelta, - snapshot: ContainerSnapshot?, - normalization: StatsNormalizationContext = .containerSpecific) -> Double { - sanitized(delta.cpuCoreFraction) / normalization.cpuLimit(for: snapshot) - } - - static func memoryFraction(from delta: StatsDelta, - snapshot: ContainerSnapshot?, - normalization: StatsNormalizationContext = .containerSpecific) -> Double { - let limit = memoryLimitBytes(for: delta, snapshot: snapshot, normalization: normalization) - guard limit > 0 else { return 0 } - return sanitized(Double(delta.memoryUsageBytes) / Double(limit)) - } - - static func memoryLimitBytes(for delta: StatsDelta, - snapshot: ContainerSnapshot?, - normalization: StatsNormalizationContext = .containerSpecific) -> UInt64 { - normalization.memoryLimitBytes(for: delta, snapshot: snapshot) - } - - private static func sanitized(_ value: Double) -> Double { - guard value.isFinite, value > 0 else { return 0 } - return value - } -} diff --git a/Sources/Contained/DesignSystem/StatsNormalization.swift b/Sources/Contained/DesignSystem/StatsNormalization.swift deleted file mode 100644 index 232f7f34..00000000 --- a/Sources/Contained/DesignSystem/StatsNormalization.swift +++ /dev/null @@ -1,65 +0,0 @@ -import Foundation -import ContainedCore - -enum StatsNormalizationMode: String, CaseIterable, Identifiable, Codable, Sendable { - case container - case machine = "global" - - var id: String { rawValue } - - var displayName: String { - switch self { - case .container: return AppText.string("statsNormalization.container", defaultValue: "Container") - case .machine: return AppText.string("statsNormalization.machine", defaultValue: "Machine") - } - } - - var footnote: String { - switch self { - case .container: - return AppText.string( - "statsNormalization.container.footnote", - defaultValue: "CPU and memory are scaled against each container's own configured limits." - ) - case .machine: - return AppText.string( - "statsNormalization.machine.footnote", - defaultValue: "CPU and memory are scaled against Apple container's machine CPU and memory resources." - ) - } - } -} - -struct StatsNormalizationContext: Equatable, Sendable { - var mode: StatsNormalizationMode = .container - var machineCPUs: Int? - var machineMemoryBytes: UInt64? - - static let containerSpecific = StatsNormalizationContext(mode: .container) - - func cpuLimit(for snapshot: ContainerSnapshot?) -> Double { - switch mode { - case .container: - return max(Double(snapshot?.configuration.resources.cpus ?? 1), 1) - case .machine: - return max(Double(machineCPUs ?? ProcessInfo.processInfo.activeProcessorCount), 1) - } - } - - func memoryLimitBytes(for snapshot: ContainerSnapshot?, fallback: UInt64 = 0) -> UInt64 { - switch mode { - case .container: - let configuredLimit = snapshot?.configuration.resources.memoryInBytes ?? 0 - return configuredLimit > 0 ? configuredLimit : fallback - case .machine: - let machineMemory = machineMemoryBytes ?? ProcessInfo.processInfo.physicalMemory - if machineMemory > 0 { return machineMemory } - let configuredLimit = snapshot?.configuration.resources.memoryInBytes ?? 0 - return configuredLimit > 0 ? configuredLimit : fallback - } - } - - func memoryLimitBytes(for delta: StatsDelta, snapshot: ContainerSnapshot?) -> UInt64 { - memoryLimitBytes(for: snapshot, fallback: delta.memoryLimitBytes) - } -} diff --git a/Sources/Contained/main.swift b/Sources/Contained/main.swift new file mode 100644 index 00000000..971583b5 --- /dev/null +++ b/Sources/Contained/main.swift @@ -0,0 +1,3 @@ +import ContainedApp + +ContainedApplication.main() diff --git a/Sources/Contained/ContainedApp.swift b/Sources/ContainedApp/ContainedApp.swift similarity index 99% rename from Sources/Contained/ContainedApp.swift rename to Sources/ContainedApp/ContainedApp.swift index 846cf5b4..b3fcfc9c 100644 --- a/Sources/Contained/ContainedApp.swift +++ b/Sources/ContainedApp/ContainedApp.swift @@ -4,16 +4,15 @@ import AppKit import ContainedCore import AppleContainerRuntime -@main -struct ContainedApp: App { +public struct ContainedApplication: App { @State private var app = AppModel() @State private var ui = UIState() - init() { + public init() { NSWindow.allowsAutomaticWindowTabbing = false } - var body: some Scene { + public var body: some Scene { WindowGroup { RootView() .environment(app) diff --git a/Sources/Contained/Features/Containers/ComposeImport.swift b/Sources/ContainedApp/Features/Containers/ComposeImport.swift similarity index 100% rename from Sources/Contained/Features/Containers/ComposeImport.swift rename to Sources/ContainedApp/Features/Containers/ComposeImport.swift diff --git a/Sources/Contained/Features/Containers/ContainerCard.swift b/Sources/ContainedApp/Features/Containers/ContainerCard.swift similarity index 95% rename from Sources/Contained/Features/Containers/ContainerCard.swift rename to Sources/ContainedApp/Features/Containers/ContainerCard.swift index f0bbd77d..f0d06d43 100644 --- a/Sources/Contained/Features/Containers/ContainerCard.swift +++ b/Sources/ContainedApp/Features/Containers/ContainerCard.swift @@ -86,7 +86,7 @@ struct ContainerCard: View { requested: activeWidget.secondaryMetric, options: GraphMetric.allCases) } - private var cardSize: ResourceCardSize { density.resourceSize } + private var cardSize: DesignCardSize { density.resourceSize } var body: some View { Group { @@ -128,7 +128,7 @@ struct ContainerCard: View { } private var cardSurface: some View { - ResourceCard(size: cardSize, + DesignCard(size: cardSize, isExpanded: isExpanded, cornerRadiusOverride: cornerRadiusOverride, controlsVisible: controlsVisible, @@ -174,13 +174,13 @@ struct ContainerCard: View { scale: sparklineScale(for: activeWidget.metric), comparisonScale: activeWidgetComparisonMetric.map(sparklineScale(for:))) .frame(maxWidth: .infinity) - .frame(height: Tokens.ResourceCard.sparklineHeight) + .frame(height: DesignTokens.DesignCard.sparklineHeight) } - .resourceCardProgressOverlay(when: isBusy) + .designCardProgressOverlay(when: isBusy) } - private var cardPages: ResourceCardPages { - ResourceCardPages(items: pageControlItems, + private var cardPages: DesignCardPages { + DesignCardPages(items: pageControlItems, selection: tab, tint: tint, controlsReveal: isExpanded && controlsVisible ? 1 : 0, @@ -197,7 +197,7 @@ struct ContainerCard: View { } private var statusChip: some View { - ResourceCardFooterMini { + DesignCardFooterMini { if styleForDisplay.showStatusIcon { Image(systemName: statusSymbol) .font(.caption2) @@ -205,7 +205,7 @@ struct ContainerCard: View { } } text: { if styleForDisplay.showStatusText { - ResourceCardMetricText(text: statusLabel) + DesignCardMetricText(text: statusLabel) .foregroundStyle(.secondary) } } @@ -295,9 +295,9 @@ struct ContainerCard: View { Button(role: .destructive) { onDelete() } label: { Label("Delete", systemImage: "trash") } } - private var pageControlItems: [ResourceCardPageControlItem] { + private var pageControlItems: [DesignCardPageControlItem] { Tab.allCases.map { item in - ResourceCardPageControlItem(id: item, + DesignCardPageControlItem(id: item, title: item.rawValue, systemImage: item.systemImage) } @@ -345,7 +345,7 @@ struct ContainerCard: View { private func widgetChip(_ index: Int) -> some View { let widget = styleForDisplay.widget(at: index) let active = index == activeWidgetIndex - return ResourceCardFooterChip(isSelected: active, + return DesignCardFooterChip(isSelected: active, tint: widget.tint?.color ?? tint, help: widget.metric.displayName, action: { @@ -360,7 +360,7 @@ struct ContainerCard: View { } } text: { if widget.showText { - ResourceCardMetricText(text: stats.map { + DesignCardMetricText(text: stats.map { widget.metric.chipCaption(from: $0, snapshot: snapshot, normalization: statsNormalization) @@ -393,7 +393,7 @@ struct ContainerCard: View { private func footerAction(_ systemName: String, help: String, tint: Color? = nil, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { - ResourceCardFooterButton(systemName: systemName, + DesignCardFooterButton(systemName: systemName, help: help, tint: tint, role: role, diff --git a/Sources/Contained/Features/Containers/ContainerConfigureView.swift b/Sources/ContainedApp/Features/Containers/ContainerConfigureView.swift similarity index 95% rename from Sources/Contained/Features/Containers/ContainerConfigureView.swift rename to Sources/ContainedApp/Features/Containers/ContainerConfigureView.swift index 20ead7a4..e97d85c1 100644 --- a/Sources/Contained/Features/Containers/ContainerConfigureView.swift +++ b/Sources/ContainedApp/Features/Containers/ContainerConfigureView.swift @@ -44,7 +44,7 @@ struct ContainerConfigureView: View { private var isEdit: Bool { if case .edit = mode { return true }; return false } var body: some View { - MorphPanelScaffold(width: Tokens.SheetSize.form.width) { + DesignPanelScaffold(width: DesignTokens.SheetSize.form.width) { VStack(spacing: 0) { header Divider() @@ -52,13 +52,13 @@ struct ContainerConfigureView: View { } } content: { RunSpecForm(spec: $spec) - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } footer: { if app.settings.revealCLI { CommandPreviewBar(command: app.previewCreateCommand(for: spec), copyHelp: AppText.copyCommand, copiedAccessibilityLabel: AppText.copied) - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) .frame(maxWidth: .infinity) } } @@ -82,7 +82,7 @@ struct ContainerConfigureView: View { PanelHeader(symbol: isEdit ? "slider.horizontal.3" : "play.fill", title: isEdit ? "Edit container" : "Run a container", subtitle: isEdit ? "Replaces the existing container with your edits" : nil) { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { DesignActionGroup(leadingAction) if working { DesignProgressActionCapsule() @@ -120,7 +120,7 @@ struct ContainerConfigureView: View { let messages = spec.validationMessages let warnings = preflightWarnings if !messages.isEmpty || !warnings.isEmpty || runError != nil { - LazyVStack(alignment: .leading, spacing: Tokens.Space.xs) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.xs) { // Blocking issues (gate the run button) in secondary; pre-flight warnings in orange; // the run/pull failure in red. ForEach(messages, id: \.self) { message in @@ -137,8 +137,8 @@ struct ContainerConfigureView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Tokens.Space.l) - .padding(.bottom, Tokens.Space.s) + .padding(.horizontal, DesignTokens.Space.l) + .padding(.bottom, DesignTokens.Space.s) } } diff --git a/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift b/Sources/ContainedApp/Features/Containers/ContainerCustomizeButton.swift similarity index 89% rename from Sources/Contained/Features/Containers/ContainerCustomizeButton.swift rename to Sources/ContainedApp/Features/Containers/ContainerCustomizeButton.swift index 282aec9d..5c7fa4f7 100644 --- a/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift +++ b/Sources/ContainedApp/Features/Containers/ContainerCustomizeButton.swift @@ -14,7 +14,7 @@ struct ContainerCustomizeButton: View { var body: some View { Button(action: onTap) { - ResourceCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, + DesignCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, tint: style.color) } .buttonStyle(.plain) diff --git a/Sources/Contained/Features/Containers/ContainerEditSheet.swift b/Sources/ContainedApp/Features/Containers/ContainerEditSheet.swift similarity index 94% rename from Sources/Contained/Features/Containers/ContainerEditSheet.swift rename to Sources/ContainedApp/Features/Containers/ContainerEditSheet.swift index 76a47d69..ce03118c 100644 --- a/Sources/Contained/Features/Containers/ContainerEditSheet.swift +++ b/Sources/ContainedApp/Features/Containers/ContainerEditSheet.swift @@ -17,7 +17,7 @@ struct ContainerEditSheet: View { var body: some View { ContainerConfigureView(mode: mode, leading: .cancel { dismiss() }, onFinished: { dismiss() }) - .frame(Tokens.SheetSize.form) + .frame(DesignTokens.SheetSize.form) .sheetMaterial() } } diff --git a/Sources/Contained/Features/Containers/ContainerOverviewTab.swift b/Sources/ContainedApp/Features/Containers/ContainerOverviewTab.swift similarity index 97% rename from Sources/Contained/Features/Containers/ContainerOverviewTab.swift rename to Sources/ContainedApp/Features/Containers/ContainerOverviewTab.swift index 5a039d66..a45c2850 100644 --- a/Sources/Contained/Features/Containers/ContainerOverviewTab.swift +++ b/Sources/ContainedApp/Features/Containers/ContainerOverviewTab.swift @@ -10,7 +10,7 @@ struct ContainerOverviewTab: View { var body: some View { ContainerTabScaffold { - LazyVStack(alignment: .leading, spacing: Tokens.Space.m) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.m) { section("General") { row("Image", snapshot.image) row("Platform", config.platform.display) diff --git a/Sources/Contained/Features/Containers/ContainerTabScaffold.swift b/Sources/ContainedApp/Features/Containers/ContainerTabScaffold.swift similarity index 90% rename from Sources/Contained/Features/Containers/ContainerTabScaffold.swift rename to Sources/ContainedApp/Features/Containers/ContainerTabScaffold.swift index ff5eeb53..a3ff8787 100644 --- a/Sources/Contained/Features/Containers/ContainerTabScaffold.swift +++ b/Sources/ContainedApp/Features/Containers/ContainerTabScaffold.swift @@ -11,7 +11,7 @@ struct ContainerTabScaffold: View { ScrollView(axes) { LazyVStack(alignment: .leading, spacing: 0) { content() - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -27,7 +27,7 @@ struct ContainerToolTabScaffold: View { var body: some View { VStack(spacing: 0) { chrome() - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) .frame(maxWidth: .infinity, alignment: .leading) Divider() content() @@ -42,7 +42,7 @@ struct ContainerTabSection: View { @ViewBuilder var content: () -> Content var body: some View { - ResourceCardInsetSection(title: title) { + DesignCardInsetSection(title: title) { content() } } diff --git a/Sources/Contained/Features/Containers/ContainerViewOptions.swift b/Sources/ContainedApp/Features/Containers/ContainerViewOptions.swift similarity index 100% rename from Sources/Contained/Features/Containers/ContainerViewOptions.swift rename to Sources/ContainedApp/Features/Containers/ContainerViewOptions.swift diff --git a/Sources/Contained/Features/Containers/ContainersGridView.swift b/Sources/ContainedApp/Features/Containers/ContainersGridView.swift similarity index 93% rename from Sources/Contained/Features/Containers/ContainersGridView.swift rename to Sources/ContainedApp/Features/Containers/ContainersGridView.swift index 91ca46fb..f8fd1101 100644 --- a/Sources/Contained/Features/Containers/ContainersGridView.swift +++ b/Sources/ContainedApp/Features/Containers/ContainersGridView.swift @@ -11,7 +11,7 @@ import ContainedRuntime struct ContainersGridView: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - @Environment(\.appSafeAreas) private var safeAreas + @Environment(\.morphSafeAreas) private var safeAreas @State private var detail: ContainerSnapshot? @State private var deleting: ContainerSnapshot? @@ -147,8 +147,8 @@ struct ContainersGridView: View { } private var columns: [GridItem] { - return [GridItem(.adaptive(minimum: Tokens.CardSize.largeMin, maximum: Tokens.CardSize.largeMax), - spacing: Tokens.Space.m)] + return [GridItem(.adaptive(minimum: DesignTokens.CardSize.largeMin, maximum: DesignTokens.CardSize.largeMax), + spacing: DesignTokens.Space.m)] } private var filtered: [ContainerSnapshot] { @@ -174,17 +174,17 @@ struct ContainersGridView: View { .frame(maxWidth: .infinity, minHeight: scrollBounds.height) .contentShape(Rectangle()) .onTapGesture(count: 2) { zoomFrontWindow() } - LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.l) { ForEach(groups) { group in groupSection(group) } Color.clear - .frame(height: Tokens.Toolbar.band) + .frame(height: DesignTokens.Toolbar.band) } - .padding(.horizontal, Tokens.Space.l) + .padding(.horizontal, DesignTokens.Space.l) } } - .contentMargins(.top, ui.toolbarUIEnabled ? 0 : Tokens.Toolbar.band, for: .scrollContent) + .contentMargins(.top, ui.toolbarUIEnabled ? 0 : DesignTokens.Toolbar.band, for: .scrollContent) if detail != nil { Color.clear @@ -246,7 +246,7 @@ struct ContainersGridView: View { @ViewBuilder private func groupSection(_ group: ContainerGroup) -> some View { let collapsed = collapsedNetworks.contains(group.name) - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { sectionHeader(group, collapsed: collapsed) if !collapsed { if group.containers.isEmpty { @@ -254,9 +254,9 @@ struct ContainersGridView: View { .font(.callout) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, Tokens.Space.s) + .padding(.vertical, DesignTokens.Space.s) } else { - LazyVGrid(columns: columns, spacing: Tokens.Space.m) { + LazyVGrid(columns: columns, spacing: DesignTokens.Space.m) { ForEach(group.containers) { snapshot in gridCard(snapshot) } @@ -267,7 +267,7 @@ struct ContainersGridView: View { } private func sectionHeader(_ group: ContainerGroup, collapsed: Bool) -> some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Button { toggleCollapsed(group.name) } label: { @@ -279,14 +279,14 @@ struct ContainersGridView: View { .buttonStyle(.plain) Image(systemName: group.symbol).font(.callout).foregroundStyle(.secondary) Text(group.name).font(.headline) - ResourceBadgeText(text: "\(group.containers.count)") + DesignBadgeText(text: "\(group.containers.count)") if group.isBuiltin { - ResourceBadgeText(text: "builtin", font: .caption2.weight(.medium)) + DesignBadgeText(text: "builtin", font: .caption2.weight(.medium)) } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Tokens.Space.xs) - .padding(.vertical, Tokens.Space.xs) + .padding(.horizontal, DesignTokens.Space.xs) + .padding(.vertical, DesignTokens.Space.xs) .contextMenu { if let resource = group.resource { networkMenu(resource) } } } @@ -357,7 +357,7 @@ struct ContainersGridView: View { // starts (expanded → false), finishing before the shrink animation does. containerCard(snapshot, isExpanded: true, - cornerRadiusOverride: expanded ? Tokens.Radius.sheet : Tokens.Radius.card, + cornerRadiusOverride: expanded ? DesignTokens.Radius.sheet : DesignTokens.Radius.card, controlsVisible: expanded) {} } @@ -405,20 +405,20 @@ struct ContainersGridView: View { } } - private var cardDetailTarget: AppMorphTarget { + private var cardDetailTarget: MorphTarget { .centered(safeArea: cardDetailSafeAreaPolicy, margin: 0) { bounds in panelSize(in: bounds.size) } } - private var cardDetailSafeAreaPolicy: AppSafeAreaPolicy { - let toolbarExclusion: AppToolbarSafeAreaExclusion = ui.toolbarUIEnabled ? .bottom : .both - return AppSafeAreaPolicy(excluding: toolbarExclusion, padding: .none, includesSystemInsets: false) + private var cardDetailSafeAreaPolicy: MorphSafeAreaPolicy { + let toolbarExclusion: MorphToolbarSafeAreaExclusion = ui.toolbarUIEnabled ? .bottom : .both + return MorphSafeAreaPolicy(excluding: toolbarExclusion, padding: .none, includesSystemInsets: false) } - private var cardDetailSafeAreas: AppSafeAreaManager { + private var cardDetailSafeAreas: MorphSafeAreaManager { guard ui.toolbarUIEnabled else { return safeAreas } - return AppSafeAreaManager(system: safeAreas.system, + return MorphSafeAreaManager(system: safeAreas.system, topToolbarHeight: AppToolbar.bandHeight, bottomToolbarHeight: AppToolbar.bandHeight) } @@ -464,7 +464,7 @@ struct ContainersGridView: View { batch { await store.remove($0, force: true) } } ]) - .padding(.bottom, Tokens.Space.l) + .padding(.bottom, DesignTokens.Space.l) } private func toggle(_ id: String) { diff --git a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift similarity index 97% rename from Sources/Contained/Features/Containers/Creation/CreationFlow.swift rename to Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift index 07e7ea72..665be754 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift @@ -73,8 +73,8 @@ struct CreationFlow: View { @Namespace private var tileNamespace private var springAnim: Animation { .spring(response: 0.42, dampingFraction: 0.86) } - private var optionPageHeight: CGFloat { GlassOptionTile.defaultHeight + (Tokens.Space.s * 2) } - private var twoRowOptionPageHeight: CGFloat { optionPageHeight + GlassOptionTile.defaultHeight + Tokens.Space.s } + private var optionPageHeight: CGFloat { GlassOptionTile.defaultHeight + (DesignTokens.Space.s * 2) } + private var twoRowOptionPageHeight: CGFloat { optionPageHeight + GlassOptionTile.defaultHeight + DesignTokens.Space.s } private var menuSize: CGSize { CGSize(width: 760, height: optionPageHeight) } private var chooserSize: CGSize { CGSize(width: 640, height: twoRowOptionPageHeight) } @@ -320,7 +320,7 @@ struct CreationFlow: View { // These pages own their own scrolling (search results, build workspace, template lists), so the // scaffold runs in non-scrolling mode — unified chrome without nesting scroll views. Size is set // by `CreationFlow.body`'s `morphPanelSize(size(for:))`. - MorphPanelScaffold(width: 0, scrolls: false) { + DesignPanelScaffold(width: 0, scrolls: false) { VStack(spacing: 0) { PanelHeader(symbol: symbol, title: title, subtitle: subtitle) { DesignActionGroup(leadingAction(leading)) @@ -329,7 +329,7 @@ struct CreationFlow: View { } } content: { content() - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: contentAlignment) } } @@ -337,7 +337,7 @@ struct CreationFlow: View { private func contentOnlyScaffold(contentAlignment: Alignment = .topLeading, @ViewBuilder content: @escaping () -> C) -> some View { content() - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: contentAlignment) } @@ -352,18 +352,18 @@ struct CreationFlow: View { private func gridScaffold(@ViewBuilder content: () -> C) -> some View { content() - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func optionStack(@ViewBuilder content: () -> C) -> some View { - GlassEffectContainer(spacing: Tokens.Space.s) { - LazyVStack(spacing: Tokens.Space.s) { content() } + GlassEffectContainer(spacing: DesignTokens.Space.s) { + LazyVStack(spacing: DesignTokens.Space.s) { content() } } } private func optionRow(@ViewBuilder content: () -> C) -> some View { - HStack(spacing: Tokens.Space.s) { content() } + HStack(spacing: DesignTokens.Space.s) { content() } } private func box(symbol: String, title: String, subtitle: String? = nil, @@ -437,7 +437,7 @@ struct CreationFlow: View { case .network: return PanelSize.resource case .volume: return PanelSize.resource case .build: return PanelSize.build - case .configure: return Tokens.SheetSize.form + case .configure: return DesignTokens.SheetSize.form } } diff --git a/Sources/Contained/Features/Containers/Creation/CreationPages.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift similarity index 93% rename from Sources/Contained/Features/Containers/Creation/CreationPages.swift rename to Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift index 7a901709..9688483d 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationPages.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift @@ -123,9 +123,9 @@ struct CreationLocalImagesContent: View { var onSelect: (RunSpec) -> Void var body: some View { - LazyVStack(spacing: Tokens.Space.m) { + LazyVStack(spacing: DesignTokens.Space.m) { DesignInputSurface { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) TextField("Filter local images", text: $query) .textFieldStyle(.plain) @@ -145,7 +145,7 @@ struct CreationLocalImagesContent: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { - LazyVStack(spacing: Tokens.Space.xs) { + LazyVStack(spacing: DesignTokens.Space.xs) { ForEach(filteredLocalImages) { image in CreationLocalImageRow(image: image) { onSelect(RecommendedImage.spec(for: image.reference)) @@ -174,9 +174,9 @@ struct CreationPastedComposeContent: View { var onImport: () -> Void var body: some View { - LazyVStack(alignment: .leading, spacing: Tokens.Space.m) { - DesignInputSurface(horizontalPadding: Tokens.Space.s, - verticalPadding: Tokens.Space.s, + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.m) { + DesignInputSurface(horizontalPadding: DesignTokens.Space.s, + verticalPadding: DesignTokens.Space.s, minHeight: 260) { TextEditor(text: $text) .font(.system(.callout, design: .monospaced)) @@ -202,7 +202,7 @@ struct CreationTemplatesContent: View { var body: some View { ScrollView { - LazyVStack(spacing: Tokens.Space.s) { + LazyVStack(spacing: DesignTokens.Space.s) { ForEach(templates) { template in CreationChoiceCard(symbol: "bookmark", title: template.name, @@ -224,7 +224,7 @@ private struct CreationSubmitBar: View { var action: () -> Void var body: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Spacer() if working { ProgressView().controlSize(.small) } DesignTextActionButton(title: title, @@ -234,7 +234,7 @@ private struct CreationSubmitBar: View { action() } } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) .background(.clear) } } @@ -248,18 +248,18 @@ private struct CreationResourceForm: View { @ViewBuilder var footer: () -> Footer var body: some View { - LazyVStack(spacing: Tokens.Space.m) { - ResourceCard(size: .small, + LazyVStack(spacing: DesignTokens.Space.m) { + DesignCard(size: .small, elevated: false, title: title, subtitle: subtitle) { - ResourceCardIconChip(symbol: symbol, tint: .accentColor) + DesignCardIconChip(symbol: symbol, tint: .accentColor) } titleAccessory: { EmptyView() } subtitleAccessory: { EmptyView() } headerAccessory: { - ResourceBadgeText(text: "new", font: .caption2.weight(.semibold)) + DesignBadgeText(text: "new", font: .caption2.weight(.semibold)) } bodyContent: { EmptyView() } footerLeading: { @@ -307,13 +307,13 @@ private struct CreationChoiceCard: View { var action: () -> Void var body: some View { - ResourceCard(size: .small, + DesignCard(size: .small, elevated: false, onTap: action, title: title, subtitle: subtitle, subtitleStyle: .monospaced) { - ResourceCardIconChip(symbol: symbol, tint: .accentColor) + DesignCardIconChip(symbol: symbol, tint: .accentColor) } titleAccessory: { EmptyView() } subtitleAccessory: { diff --git a/Sources/Contained/Features/Containers/CustomizeSheet.swift b/Sources/ContainedApp/Features/Containers/CustomizeSheet.swift similarity index 98% rename from Sources/Contained/Features/Containers/CustomizeSheet.swift rename to Sources/ContainedApp/Features/Containers/CustomizeSheet.swift index 7839878c..c135fa8c 100644 --- a/Sources/Contained/Features/Containers/CustomizeSheet.swift +++ b/Sources/ContainedApp/Features/Containers/CustomizeSheet.swift @@ -116,7 +116,7 @@ struct CustomizeSheet: View { header Divider() ScrollView { - LazyVStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: DesignTokens.Space.l) { if target.supportsInheritance { inheritanceSection } editableSection { styleSection } if case .container = target { @@ -130,8 +130,8 @@ struct CustomizeSheet: View { editableSection { backgroundSection } actionsSection } - .padding(.horizontal, Tokens.Space.l) - .padding(.vertical, Tokens.Space.m) + .padding(.horizontal, DesignTokens.Space.l) + .padding(.vertical, DesignTokens.Space.m) } .scrollEdgeEffectStyle(.soft, for: .all) } @@ -197,11 +197,11 @@ struct CustomizeSheet: View { PanelToggleRow(title: "Color the card background", isOn: $style.fillBackground) if style.fillBackground { PanelRow(title: "Opacity") { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Slider(value: $style.backgroundOpacity, in: 0.05...0.6) Text(Format.percent(style.backgroundOpacity)) .monospacedDigit() - .frame(width: Tokens.FormWidth.shortReadout) + .frame(width: DesignTokens.FormWidth.shortReadout) } } PanelToggleRow(title: "Gradient", isOn: $style.gradient) diff --git a/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift b/Sources/ContainedApp/Features/Containers/CustomizeWidgetsPanel.swift similarity index 98% rename from Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift rename to Sources/ContainedApp/Features/Containers/CustomizeWidgetsPanel.swift index 2acc4625..33ec5592 100644 --- a/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift +++ b/Sources/ContainedApp/Features/Containers/CustomizeWidgetsPanel.swift @@ -1,5 +1,6 @@ import SwiftUI import ContainedDesignSystem +import ContainedCore /// Widget-specific controls for `CustomizeSheet`, split out so the sheet owns the target/persistence /// workflow while this view owns per-widget ordering, metric, and chart editing. @@ -17,7 +18,7 @@ struct CustomizeWidgetsPanel: View { } var body: some View { - LazyVStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: DesignTokens.Space.l) { ForEach(activeWidgetIndices, id: \.self) { index in widgetSection(index) } @@ -58,7 +59,7 @@ struct CustomizeWidgetsPanel: View { private func widgetOrderControls(_ index: Int) -> some View { let position = activeWidgetIndices.firstIndex(of: index) ?? 0 return PanelRow(title: "Order", subtitle: "Move or remove this widget.") { - HStack(spacing: Tokens.Space.xs) { + HStack(spacing: DesignTokens.Space.xs) { Button { moveWidget(index, by: -1) } label: { Label("Move up", systemImage: "chevron.up").labelStyle(.iconOnly) } @@ -179,11 +180,11 @@ struct CustomizeWidgetsPanel: View { step: Double, readout: String) -> some View { PanelRow(title: title) { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Slider(value: value, in: range, step: step) Text(readout) .monospacedDigit() - .frame(width: Tokens.FormWidth.shortReadout) + .frame(width: DesignTokens.FormWidth.shortReadout) } } } diff --git a/Sources/Contained/Features/Containers/FilesTab.swift b/Sources/ContainedApp/Features/Containers/FilesTab.swift similarity index 93% rename from Sources/Contained/Features/Containers/FilesTab.swift rename to Sources/ContainedApp/Features/Containers/FilesTab.swift index 7e02da82..94379b55 100644 --- a/Sources/Contained/Features/Containers/FilesTab.swift +++ b/Sources/ContainedApp/Features/Containers/FilesTab.swift @@ -31,7 +31,7 @@ struct FilesTab: View { } private var pathBar: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { DesignActionGroup(DesignAction(systemName: "chevron.up", help: AppText.parent, isEnabled: path != "/") { goUp() }) @@ -54,10 +54,10 @@ struct FilesTab: View { } description: { Text(error) } } else { ScrollView { - LazyVStack(spacing: Tokens.Space.hairline) { + LazyVStack(spacing: DesignTokens.Space.hairline) { ForEach(entries, id: \.self) { entry in row(entry) } } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } .scrollEdgeEffectStyle(.soft, for: .all) } @@ -66,10 +66,10 @@ struct FilesTab: View { private func row(_ entry: String) -> some View { let isDir = entry.hasSuffix("/") let name = isDir ? String(entry.dropLast()) : entry - return HStack(spacing: Tokens.Space.s) { + return HStack(spacing: DesignTokens.Space.s) { Image(systemName: isDir ? "folder.fill" : "doc") .foregroundStyle(isDir ? Color.accentColor : Color.secondary) - .frame(width: Tokens.IconSize.rowMenu) + .frame(width: DesignTokens.IconSize.rowMenu) Text(name).font(.system(.callout, design: .monospaced)) Spacer() if isDir { @@ -79,8 +79,8 @@ struct FilesTab: View { .buttonStyle(.plain).foregroundStyle(.secondary).help("Copy to host") } } - .padding(.vertical, Tokens.Space.xs) - .padding(.horizontal, Tokens.Space.s) + .padding(.vertical, DesignTokens.Space.xs) + .padding(.horizontal, DesignTokens.Space.s) .contentShape(Rectangle()) .onTapGesture { if isDir { path = joined(name) + "/" } } } diff --git a/Sources/Contained/Features/Containers/LogsTab.swift b/Sources/ContainedApp/Features/Containers/LogsTab.swift similarity index 93% rename from Sources/Contained/Features/Containers/LogsTab.swift rename to Sources/ContainedApp/Features/Containers/LogsTab.swift index d920e90f..99e8b579 100644 --- a/Sources/Contained/Features/Containers/LogsTab.swift +++ b/Sources/ContainedApp/Features/Containers/LogsTab.swift @@ -30,10 +30,10 @@ struct LogsTab: View { } private var controls: some View { - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { DesignGlassToggle(isOn: $following, title: AppText.follow, systemName: "arrow.down.to.line") if streaming { - HStack(spacing: Tokens.Toolbar.searchIconGap) { + HStack(spacing: DesignTokens.Toolbar.searchIconGap) { ProgressView().controlSize(.small) Text("streaming").font(.caption).foregroundStyle(.secondary) } @@ -70,16 +70,16 @@ struct LogsTab: View { } else { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: Tokens.Space.hairline) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.hairline) { ForEach(Array(lines.enumerated()), id: \.offset) { _, line in Text(line.isEmpty ? " " : line) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } - Color.clear.frame(height: Tokens.Space.hairline).id(bottomID) + Color.clear.frame(height: DesignTokens.Space.hairline).id(bottomID) } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } .scrollEdgeEffectStyle(.soft, for: .all) .onChange(of: lines.count) { _, _ in diff --git a/Sources/Contained/Features/Containers/RunSpec.swift b/Sources/ContainedApp/Features/Containers/RunSpec.swift similarity index 100% rename from Sources/Contained/Features/Containers/RunSpec.swift rename to Sources/ContainedApp/Features/Containers/RunSpec.swift diff --git a/Sources/Contained/Features/Containers/RunSpecForm.swift b/Sources/ContainedApp/Features/Containers/RunSpecForm.swift similarity index 96% rename from Sources/Contained/Features/Containers/RunSpecForm.swift rename to Sources/ContainedApp/Features/Containers/RunSpecForm.swift index 6256a2ab..f611a8ce 100644 --- a/Sources/Contained/Features/Containers/RunSpecForm.swift +++ b/Sources/ContainedApp/Features/Containers/RunSpecForm.swift @@ -6,7 +6,7 @@ import ContainedCore /// The shared container Create/Edit form body: progressive-disclosure sections mapping the `run` /// flags. Reused by `ContainerEditSheet` for both new and edit modes. Built from the unified -/// `PanelSection` glass-card primitives (not `Form`) so it lives inside the shared `MorphPanelScaffold` +/// `PanelSection` glass-card primitives (not `Form`) so it lives inside the shared `DesignPanelScaffold` /// and measures/scrolls consistently. Field guidance is delivered through tappable `info.circle` /// popovers, not hover tooltips. struct RunSpecForm: View { @@ -22,7 +22,7 @@ struct RunSpecForm: View { } var body: some View { - LazyVStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: DesignTokens.Space.l) { Text("Blue sections contain explicit values from an import, edit, template, or manual change.") .font(.caption) .foregroundStyle(.secondary) @@ -119,9 +119,9 @@ struct RunSpecForm: View { info: "Set a memory ceiling for the container. If it goes past the limit, the runtime may stop it.", isOn: memoryLimitBinding) if !spec.memory.isEmpty { PanelField(label: "Memory") { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Slider(value: memoryGBBinding, in: 0.5...max(0.5, maxMemoryGB), step: 0.5) - Text(memoryReadout).monospacedDigit().frame(width: Tokens.FormWidth.memoryReadout) + Text(memoryReadout).monospacedDigit().frame(width: DesignTokens.FormWidth.memoryReadout) } } } @@ -191,11 +191,11 @@ struct RunSpecForm: View { Group { ForEach($spec.ports) { $port in HStack { - TextField("Host", text: $port.hostPort).textFieldStyle(.roundedBorder).frame(width: Tokens.FormWidth.port) + TextField("Host", text: $port.hostPort).textFieldStyle(.roundedBorder).frame(width: DesignTokens.FormWidth.port) Image(systemName: "arrow.right").foregroundStyle(.secondary) - TextField("Container", text: $port.containerPort).textFieldStyle(.roundedBorder).frame(width: Tokens.FormWidth.containerPort) + TextField("Container", text: $port.containerPort).textFieldStyle(.roundedBorder).frame(width: DesignTokens.FormWidth.containerPort) Picker("", selection: $port.proto) { Text("tcp").tag("tcp"); Text("udp").tag("udp") } - .labelsHidden().frame(width: Tokens.FormWidth.port) + .labelsHidden().frame(width: DesignTokens.FormWidth.port) Spacer() removeButton { spec.ports.removeAll { $0.id == port.id } } } @@ -207,7 +207,7 @@ struct RunSpecForm: View { private var volumesSection: some View { Group { ForEach($spec.volumes) { $vol in - LazyVStack(spacing: Tokens.Space.xs) { + LazyVStack(spacing: DesignTokens.Space.xs) { HStack { sourcePicker(source: $vol.source) TextField("Source (host path or volume)", text: $vol.source).textFieldStyle(.roundedBorder) @@ -242,7 +242,7 @@ struct RunSpecForm: View { private var socketsSection: some View { Group { ForEach($spec.sockets) { $socket in - LazyVStack(spacing: Tokens.Space.xs) { + LazyVStack(spacing: DesignTokens.Space.xs) { HStack { TextField("Host socket path", text: $socket.hostPath).textFieldStyle(.roundedBorder) removeButton { spec.sockets.removeAll { $0.id == socket.id } } @@ -284,11 +284,11 @@ struct RunSpecForm: View { isOn: $spec.personalization.fillBackground) if spec.personalization.fillBackground { PanelField(label: "Opacity") { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Slider(value: $spec.personalization.backgroundOpacity, in: 0.05...0.6) Text(Format.percent(spec.personalization.backgroundOpacity)) .monospacedDigit() - .frame(width: Tokens.FormWidth.shortReadout) + .frame(width: DesignTokens.FormWidth.shortReadout) } } PanelToggleRow(title: "Gradient", @@ -367,8 +367,8 @@ struct RunSpecForm: View { } PanelField(label: "User ID", info: "Numeric user / group IDs (--uid / --gid).") { HStack { - TextField("UID", text: $spec.uid).textFieldStyle(.roundedBorder).frame(width: Tokens.FormWidth.userID) - TextField("GID", text: $spec.gid).textFieldStyle(.roundedBorder).frame(width: Tokens.FormWidth.userID) + TextField("UID", text: $spec.uid).textFieldStyle(.roundedBorder).frame(width: DesignTokens.FormWidth.userID) + TextField("GID", text: $spec.gid).textFieldStyle(.roundedBorder).frame(width: DesignTokens.FormWidth.userID) Spacer() } } @@ -376,11 +376,11 @@ struct RunSpecForm: View { info: "Size of /dev/shm (--shm-size).", isOn: shmLimitBinding) if !spec.shmSize.isEmpty { PanelField(label: "Shared memory") { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Slider(value: shmGBBinding, in: 0.0625...max(0.0625, maxMemoryGB), step: 0.0625) Text(memoryReadout(spec.shmSize, fallbackGB: 0.0625)) .monospacedDigit() - .frame(width: Tokens.FormWidth.memoryReadout) + .frame(width: DesignTokens.FormWidth.memoryReadout) } } } @@ -441,7 +441,7 @@ struct RunSpecForm: View { } .fixedSize() TextField("", text: $spec.network, prompt: Text("custom network")).textFieldStyle(.roundedBorder) - .frame(width: Tokens.FormWidth.networkName) + .frame(width: DesignTokens.FormWidth.networkName) } .task { await app.refreshNetworks() } } @@ -559,7 +559,7 @@ struct RunSpecForm: View { removeButton { list.wrappedValue.remove(at: idx) } } } - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { addButton(addTitle) { list.wrappedValue.append("") } InfoButton(info) Spacer() diff --git a/Sources/Contained/Features/Containers/RunSpecFormSupport.swift b/Sources/ContainedApp/Features/Containers/RunSpecFormSupport.swift similarity index 100% rename from Sources/Contained/Features/Containers/RunSpecFormSupport.swift rename to Sources/ContainedApp/Features/Containers/RunSpecFormSupport.swift diff --git a/Sources/Contained/Features/Containers/StatsTab.swift b/Sources/ContainedApp/Features/Containers/StatsTab.swift similarity index 95% rename from Sources/Contained/Features/Containers/StatsTab.swift rename to Sources/ContainedApp/Features/Containers/StatsTab.swift index 2fb5b64e..41cb23db 100644 --- a/Sources/Contained/Features/Containers/StatsTab.swift +++ b/Sources/ContainedApp/Features/Containers/StatsTab.swift @@ -18,7 +18,7 @@ struct StatsTab: View { app.containerStyle(for: snapshot).color } - private let columns = [GridItem(.adaptive(minimum: 200), spacing: Tokens.Space.m)] + private let columns = [GridItem(.adaptive(minimum: 200), spacing: DesignTokens.Space.m)] var body: some View { Group { @@ -28,7 +28,7 @@ struct StatsTab: View { } description: { Text("Start the container to see live resource usage.") } } else if let delta { ContainerTabScaffold { - LazyVGrid(columns: columns, spacing: Tokens.Space.m) { + LazyVGrid(columns: columns, spacing: DesignTokens.Space.m) { tile(.cpu, delta, "cpu") memoryTile(delta) tile(.netRx, delta, "arrow.down.circle") @@ -41,7 +41,7 @@ struct StatsTab: View { } } else { // Running but no sample yet (first tick pending). - LazyVStack(spacing: Tokens.Space.m) { + LazyVStack(spacing: DesignTokens.Space.m) { ProgressView() Text("Collecting stats…").font(.callout).foregroundStyle(.secondary) } @@ -54,7 +54,7 @@ struct StatsTab: View { @ViewBuilder private var processList: some View { if !processes.isEmpty { - ResourceCardInsetSection { + DesignCardInsetSection { Label("Processes", systemImage: "list.bullet.rectangle") .font(.caption.weight(.semibold)).foregroundStyle(.secondary) Text(processes) diff --git a/Sources/Contained/Features/Containers/TerminalTab.swift b/Sources/ContainedApp/Features/Containers/TerminalTab.swift similarity index 93% rename from Sources/Contained/Features/Containers/TerminalTab.swift rename to Sources/ContainedApp/Features/Containers/TerminalTab.swift index 17f3720b..3cf67922 100644 --- a/Sources/Contained/Features/Containers/TerminalTab.swift +++ b/Sources/ContainedApp/Features/Containers/TerminalTab.swift @@ -55,12 +55,12 @@ struct TerminalTab: View { } private var controls: some View { - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { Picker("Shell", selection: $shell) { ForEach(shells, id: \.self) { Text($0).tag($0) } } .labelsHidden() - .frame(width: Tokens.FormWidth.shellPicker) + .frame(width: DesignTokens.FormWidth.shellPicker) .onChange(of: shell) { _, _ in reconnect() } Text("exec into \(snapshot.id)").font(.caption).foregroundStyle(.secondary).lineLimit(1) Spacer() @@ -69,7 +69,7 @@ struct TerminalTab: View { } private func endedOverlay(code: Int32?) -> some View { - ResourceCardInsetSection(alignment: .center, padding: Tokens.Space.xl) { + DesignCardInsetSection(alignment: .center, padding: DesignTokens.Space.xl) { Image(systemName: "bolt.horizontal.circle").font(.largeTitle).foregroundStyle(.secondary) Text(code == nil || code == 0 ? "Session ended" : "Session ended (exit \(code!))") .font(.headline) @@ -100,9 +100,9 @@ struct TerminalSurface: NSViewRepresentable { func makeNSView(context: Context) -> LocalProcessTerminalView { let view = LocalProcessTerminalView(frame: .zero) view.processDelegate = context.coordinator - view.font = NSFont.monospacedSystemFont(ofSize: Tokens.Terminal.fontSize, weight: .regular) - view.nativeBackgroundColor = NSColor.black.withAlphaComponent(Tokens.Terminal.nativeBackgroundOpacity) - view.nativeForegroundColor = NSColor(white: Tokens.Terminal.nativeForegroundWhite, alpha: 1) + view.font = NSFont.monospacedSystemFont(ofSize: DesignTokens.Terminal.fontSize, weight: .regular) + view.nativeBackgroundColor = NSColor.black.withAlphaComponent(DesignTokens.Terminal.nativeBackgroundOpacity) + view.nativeForegroundColor = NSColor(white: DesignTokens.Terminal.nativeForegroundWhite, alpha: 1) // `container exec -i -t ` — PTY is provided by SwiftTerm; -t requests a TTY // inside the container, -i keeps stdin attached. We must inherit the *host* environment diff --git a/Sources/Contained/Features/Images/BuildWorkspaceView.swift b/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift similarity index 97% rename from Sources/Contained/Features/Images/BuildWorkspaceView.swift rename to Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift index ef1080c1..e4412e4e 100644 --- a/Sources/Contained/Features/Images/BuildWorkspaceView.swift +++ b/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift @@ -40,7 +40,7 @@ struct BuildWorkspaceView: View { failureLabel: AppErrorPresentation.message, onComplete: { ok in if ok { Task { await app.refreshImagesIfStale(force: true) } } }) .id(run) - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } else { ContentUnavailableView { Label("Build an image", systemImage: "hammer") @@ -53,12 +53,12 @@ struct BuildWorkspaceView: View { private var form: some View { ScrollView { - LazyVStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: DesignTokens.Space.l) { sourceSection optionsSection commandSection } - .padding(Tokens.Space.l) + .padding(DesignTokens.Space.l) } .scrollEdgeEffectStyle(.soft, for: .all) .frame(maxHeight: 420) @@ -130,7 +130,7 @@ struct BuildWorkspaceView: View { private var commandSection: some View { PanelSection { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { CommandPreviewBar(command: previewCommand, copyHelp: AppText.copyCommand, copiedAccessibilityLabel: AppText.copied) diff --git a/Sources/Contained/Features/Images/ImageStyleButton.swift b/Sources/ContainedApp/Features/Images/ImageStyleButton.swift similarity index 93% rename from Sources/Contained/Features/Images/ImageStyleButton.swift rename to Sources/ContainedApp/Features/Images/ImageStyleButton.swift index 1606e984..6b98181c 100644 --- a/Sources/Contained/Features/Images/ImageStyleButton.swift +++ b/Sources/ContainedApp/Features/Images/ImageStyleButton.swift @@ -16,7 +16,7 @@ struct ImageStyleButton: View { var body: some View { Button { showingCustomize = true } label: { - ResourceCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, + DesignCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, tint: style.color) } .buttonStyle(.plain) @@ -46,7 +46,7 @@ struct CardStyleButton: View { var body: some View { Button { showingCustomize = true } label: { - ResourceCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, + DesignCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, tint: style.color) } .buttonStyle(.plain) diff --git a/Sources/Contained/Features/Images/RegistryImageSearch.swift b/Sources/ContainedApp/Features/Images/RegistryImageSearch.swift similarity index 93% rename from Sources/Contained/Features/Images/RegistryImageSearch.swift rename to Sources/ContainedApp/Features/Images/RegistryImageSearch.swift index 314ead98..9debac29 100644 --- a/Sources/Contained/Features/Images/RegistryImageSearch.swift +++ b/Sources/ContainedApp/Features/Images/RegistryImageSearch.swift @@ -24,7 +24,7 @@ struct RegistryImageSearch: View { private var trimmedQuery: String { query.trimmingCharacters(in: .whitespaces) } var body: some View { - LazyVStack(alignment: .leading, spacing: Tokens.Space.m) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.m) { searchField if trimmedQuery.isEmpty { idleSuggestions @@ -57,7 +57,7 @@ struct RegistryImageSearch: View { private var idleSuggestions: some View { ScrollView { - LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.l) { suggestionSection("Starters") { ForEach(BuiltinTemplate.all, id: \.name) { item in quickPick(symbol: item.symbol, title: item.name, @@ -74,15 +74,15 @@ struct RegistryImageSearch: View { } } } - .padding(.bottom, Tokens.Space.s) + .padding(.bottom, DesignTokens.Space.s) } } private func suggestionSection(_ title: String, @ViewBuilder content: () -> C) -> some View { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { Text(title).font(.caption.weight(.semibold)).foregroundStyle(.secondary) - LazyVGrid(columns: [GridItem(.adaptive(minimum: 150, maximum: 240), spacing: Tokens.Space.s)], - spacing: Tokens.Space.s) { content() } + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150, maximum: 240), spacing: DesignTokens.Space.s)], + spacing: DesignTokens.Space.s) { content() } } } @@ -99,7 +99,7 @@ struct RegistryImageSearch: View { @ViewBuilder private var resultsList: some View { if results.isEmpty { - LazyVStack(spacing: Tokens.Space.s) { + LazyVStack(spacing: DesignTokens.Space.s) { if searching { ProgressView() Text("Searching Docker Hub…").font(.callout).foregroundStyle(.secondary) @@ -117,10 +117,10 @@ struct RegistryImageSearch: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(Tokens.Space.xl) + .padding(DesignTokens.Space.xl) } else { ScrollView { - LazyVStack(spacing: Tokens.Space.xs) { + LazyVStack(spacing: DesignTokens.Space.xs) { ForEach(results) { result in resultRow(result) .accessibilityAddTraits(.isButton) @@ -135,7 +135,7 @@ struct RegistryImageSearch: View { title: result.repoName, subtitle: result.shortDescription?.isEmpty == false ? result.shortDescription : nil, action: { onSelect(RecommendedImage.spec(for: result.pullReference)) }) { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { if result.isOfficial { Image(systemName: "checkmark.seal.fill").font(.caption2).foregroundStyle(.blue) } @@ -152,12 +152,12 @@ struct RegistryImageSearch: View { subtitle: String?, action: @escaping () -> Void, @ViewBuilder accessory: @escaping () -> Accessory) -> some View { - ResourceCard(size: .small, + DesignCard(size: .small, elevated: false, onTap: action, title: title, subtitle: subtitle) { - ResourceCardIconChip(symbol: symbol, tint: .accentColor) + DesignCardIconChip(symbol: symbol, tint: .accentColor) } titleAccessory: { EmptyView() } subtitleAccessory: { diff --git a/Sources/Contained/Features/MenuBar/MenuBarContent.swift b/Sources/ContainedApp/Features/MenuBar/MenuBarContent.swift similarity index 94% rename from Sources/Contained/Features/MenuBar/MenuBarContent.swift rename to Sources/ContainedApp/Features/MenuBar/MenuBarContent.swift index 60ddcb04..6d8d01cc 100644 --- a/Sources/Contained/Features/MenuBar/MenuBarContent.swift +++ b/Sources/ContainedApp/Features/MenuBar/MenuBarContent.swift @@ -30,7 +30,7 @@ struct MenuBarContent: View { } var body: some View { - LazyVStack(alignment: .leading, spacing: Tokens.Space.m) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.m) { header Divider() @@ -146,13 +146,13 @@ struct MenuBarContent: View { footerRow } - .padding(Tokens.MenuBar.padding) - .frame(width: Tokens.MenuBar.width) + .padding(DesignTokens.MenuBar.padding) + .frame(width: DesignTokens.MenuBar.width) } @ViewBuilder private var header: some View { - VStack(alignment: .leading, spacing: Tokens.Toolbar.searchIconGap) { + VStack(alignment: .leading, spacing: DesignTokens.Toolbar.searchIconGap) { HStack(alignment: .firstTextBaseline) { Label("Contained", systemImage: app.serviceHealthy ? "shippingbox.fill" : "shippingbox") .font(.headline) @@ -162,7 +162,7 @@ struct MenuBarContent: View { .foregroundStyle(.secondary) } - HStack(spacing: Tokens.ResourceCard.padding) { + HStack(spacing: DesignTokens.DesignCard.padding) { Label(app.serviceLabel, systemImage: app.serviceHealthy ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") .foregroundStyle(app.serviceHealthy ? .green : .secondary) Text(app.settings.updateChannel.displayName) @@ -179,7 +179,7 @@ struct MenuBarContent: View { @ViewBuilder private var infoGrid: some View { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { infoRow("Containers", value: "\(store.running.count) running · \(stopped.count) stopped") infoRow("Resources", value: "\(app.images.count) images · \(app.volumes.count) volumes · \(app.networks.count) networks") infoRow("Bootstrap", value: cliLabel) @@ -189,7 +189,7 @@ struct MenuBarContent: View { } private var actionStrip: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { miniAction("Open", systemImage: "app") miniAction("Run", systemImage: "plus") { route(.runContainer) } miniAction("Activity", systemImage: unreadActivityCount > 0 ? "bell.badge" : "bell") { route(.activityHistory) } @@ -198,7 +198,7 @@ struct MenuBarContent: View { } private var footerRow: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Button("Open Contained") { activate() } Spacer(minLength: 0) Button("Quit") { NSApplication.shared.terminate(nil) } @@ -221,10 +221,10 @@ struct MenuBarContent: View { @ViewBuilder private func infoRow(_ title: String, value: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: Tokens.ResourceCard.padding) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.DesignCard.padding) { Text(title) .foregroundStyle(.secondary) - .frame(width: Tokens.MenuBar.titleWidth, alignment: .leading) + .frame(width: DesignTokens.MenuBar.titleWidth, alignment: .leading) Text(value) .foregroundStyle(.primary) Spacer(minLength: 0) diff --git a/Sources/Contained/Features/Onboarding/BootstrapView.swift b/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift similarity index 93% rename from Sources/Contained/Features/Onboarding/BootstrapView.swift rename to Sources/ContainedApp/Features/Onboarding/BootstrapView.swift index 8d53a3e0..152fc82c 100644 --- a/Sources/Contained/Features/Onboarding/BootstrapView.swift +++ b/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift @@ -10,7 +10,7 @@ struct BootstrapView: View { @State private var starting = false var body: some View { - VStack(spacing: Tokens.Space.l) { + VStack(spacing: DesignTokens.Space.l) { Image(systemName: icon) .font(.system(size: 52)) .foregroundStyle(.tint) @@ -22,7 +22,7 @@ struct BootstrapView: View { actions } - .padding(Tokens.Space.xxl) + .padding(DesignTokens.Space.xxl) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -34,19 +34,19 @@ struct BootstrapView: View { Task { starting = true; await app.startService(); starting = false } } label: { Label(starting ? "Starting…" : "Start container service", systemImage: "play.circle") - .padding(.horizontal, Tokens.Space.s) + .padding(.horizontal, DesignTokens.Space.s) } .buttonStyle(.borderedProminent) .disabled(starting) case .cliMissing: - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { Button { openReleases() } label: { Label("Get the CLI", systemImage: "arrow.down.circle") } .buttonStyle(.borderedProminent) Button { locateCLI() } label: { Label("Locate binary…", systemImage: "folder") } } Button("Try again") { Task { await app.retryBootstrap() } }.buttonStyle(.link) case .unsupported: - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { Button("Continue anyway") { Task { await app.continueUnsupported() } } .buttonStyle(.borderedProminent) Button("Try again") { Task { await app.retryBootstrap() } } diff --git a/Sources/Contained/Features/Palette/CommandPalette.swift b/Sources/ContainedApp/Features/Palette/CommandPalette.swift similarity index 99% rename from Sources/Contained/Features/Palette/CommandPalette.swift rename to Sources/ContainedApp/Features/Palette/CommandPalette.swift index 01e3a5f9..be9b0a18 100644 --- a/Sources/Contained/Features/Palette/CommandPalette.swift +++ b/Sources/ContainedApp/Features/Palette/CommandPalette.swift @@ -254,8 +254,8 @@ struct PaletteItem: Identifiable { tint: .secondary) { app.settings.showInfoTips.toggle() }) - for tint in AppTint.allCases { - items.append(PaletteItem(title: AppText.setAppTintTitle(tint.localizedDisplayName), + for tint in DesignTint.allCases { + items.append(PaletteItem(title: AppText.setDesignTintTitle(tint.localizedDisplayName), subtitle: "appearance", keywords: ["accent", "color", "theme", "tint", tint.rawValue] + tint.localizedSearchAliases, kind: .settings, @@ -414,7 +414,7 @@ enum PaletteItemVisual { case imageTag(String, groupID: String) case volume(VolumeResource) case network(NetworkResource) - case tint(AppTint) + case tint(DesignTint) } private extension SettingsStore { diff --git a/Sources/Contained/Features/Palette/PaletteSearch.swift b/Sources/ContainedApp/Features/Palette/PaletteSearch.swift similarity index 100% rename from Sources/Contained/Features/Palette/PaletteSearch.swift rename to Sources/ContainedApp/Features/Palette/PaletteSearch.swift diff --git a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift similarity index 94% rename from Sources/Contained/Features/Registries/RegistryLoginSheet.swift rename to Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift index e942320e..6253fc6f 100644 --- a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift +++ b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift @@ -18,7 +18,7 @@ struct RegistryLoginSheet: View { VStack(spacing: 0) { SheetHeader(title: "Registry login", cancelHelp: AppText.close, onCancel: { dismiss() }) { if busy { - ProgressView().controlSize(.small).frame(width: Tokens.IconSize.control, height: Tokens.IconSize.control) + ProgressView().controlSize(.small).frame(width: DesignTokens.IconSize.control, height: DesignTokens.IconSize.control) } else { DesignActionGroup(DesignAction(systemName: "checkmark", help: AppText.logIn, @@ -29,7 +29,7 @@ struct RegistryLoginSheet: View { }) } } - VStack(spacing: Tokens.Space.l) { + VStack(spacing: DesignTokens.Space.l) { PanelSection(header: "Credentials") { PanelField(label: "Server") { TextField("", text: $server, prompt: Text("e.g. ghcr.io, docker.io")) @@ -55,9 +55,9 @@ struct RegistryLoginSheet: View { } } } - .padding(Tokens.Space.l) + .padding(DesignTokens.Space.l) } - .frame(Tokens.SheetSize.small) + .frame(DesignTokens.SheetSize.small) .sheetMaterial() } diff --git a/Sources/Contained/Features/Settings/ConfigTransfer.swift b/Sources/ContainedApp/Features/Settings/ConfigTransfer.swift similarity index 97% rename from Sources/Contained/Features/Settings/ConfigTransfer.swift rename to Sources/ContainedApp/Features/Settings/ConfigTransfer.swift index 11d33751..60b1dd2a 100644 --- a/Sources/Contained/Features/Settings/ConfigTransfer.swift +++ b/Sources/ContainedApp/Features/Settings/ConfigTransfer.swift @@ -27,7 +27,7 @@ struct ConfigTransferControls: View { @State private var importMode: ConfigImportMode = .merge var body: some View { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { ForEach(AppStateSection.allCases) { section in Toggle(section.displayName, isOn: binding(for: section)) .toggleStyle(.checkbox) diff --git a/Sources/Contained/Features/Settings/ReleaseNotesView.swift b/Sources/ContainedApp/Features/Settings/ReleaseNotesView.swift similarity index 96% rename from Sources/Contained/Features/Settings/ReleaseNotesView.swift rename to Sources/ContainedApp/Features/Settings/ReleaseNotesView.swift index 8f1c5159..c1d24c66 100644 --- a/Sources/Contained/Features/Settings/ReleaseNotesView.swift +++ b/Sources/ContainedApp/Features/Settings/ReleaseNotesView.swift @@ -15,7 +15,7 @@ struct ReleaseNotesView: View { Divider() HTMLView(html: html) } - .frame(Tokens.SheetSize.releaseNotes) + .frame(DesignTokens.SheetSize.releaseNotes) } } diff --git a/Sources/Contained/Features/Settings/SettingsView.swift b/Sources/ContainedApp/Features/Settings/SettingsView.swift similarity index 93% rename from Sources/Contained/Features/Settings/SettingsView.swift rename to Sources/ContainedApp/Features/Settings/SettingsView.swift index e74aabc2..76d4d2d8 100644 --- a/Sources/Contained/Features/Settings/SettingsView.swift +++ b/Sources/ContainedApp/Features/Settings/SettingsView.swift @@ -8,7 +8,7 @@ import ContainedCore /// headers, and explanatory footers stay consistent: Appearance (theme + glass), General (behavior, /// data, CLI), Runtime, Registries, Updates, and About. /// -/// Hosted in the toolbar Settings morph panel via the shared `MorphPanelScaffold`, so the panel hugs +/// Hosted in the toolbar Settings morph panel via the shared `DesignPanelScaffold`, so the panel hugs /// the active section's content height. Sections switch via a header menu rather than a `TabView`. struct SettingsContent: View { @Environment(AppModel.self) private var app @@ -51,7 +51,7 @@ struct SettingsContent: View { var body: some View { @Bindable var settings = app.settings - MorphPanelScaffold(width: Tokens.PanelSize.settings.width, placement: .centered) { + DesignPanelScaffold(width: DesignTokens.PanelSize.settings.width, placement: .centered) { if showsHeader { VStack(spacing: 0) { header @@ -60,7 +60,7 @@ struct SettingsContent: View { } } content: { sectionBody(settings: settings) - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } .onAppear { consumeRequestedPage() } .onChange(of: ui.settingsPage) { _, requested in diff --git a/Sources/Contained/Features/Settings/Tabs/AboutTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift similarity index 83% rename from Sources/Contained/Features/Settings/Tabs/AboutTab.swift rename to Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift index 0db6850c..f66a62aa 100644 --- a/Sources/Contained/Features/Settings/Tabs/AboutTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift @@ -9,13 +9,13 @@ struct AboutTab: View { @Environment(AppModel.self) private var app var body: some View { - LazyVStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: DesignTokens.Space.l) { PanelSection { - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { Image(nsImage: NSApp.applicationIconImage) .resizable() - .frame(width: Tokens.IconSize.appIcon, height: Tokens.IconSize.appIcon) - VStack(alignment: .leading, spacing: Tokens.Space.xxs) { + .frame(width: DesignTokens.IconSize.appIcon, height: DesignTokens.IconSize.appIcon) + VStack(alignment: .leading, spacing: DesignTokens.Space.xxs) { Text("Contained").font(.title3.weight(.semibold)) Text("Version \(appVersion)").font(.callout).foregroundStyle(.secondary) Text("A native macOS UI for Apple’s container runtime.") diff --git a/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift similarity index 88% rename from Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift rename to Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift index 73ad3868..93008a71 100644 --- a/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift @@ -8,7 +8,7 @@ struct AppearanceTab: View { @Bindable var settings: SettingsStore var body: some View { - LazyVStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: DesignTokens.Space.l) { PanelSection(header: "Theme") { PanelRow(title: "Appearance") { Picker("", selection: $settings.appearance) { @@ -42,7 +42,7 @@ struct AppearanceTab: View { materialMenu($settings.modalMaterial) } PanelRow(title: "Card material", - info: "Changes all resource cards, including compact cards and expanded detail cards.") { + info: "Changes all cards, including compact cards and expanded detail cards.") { materialMenu($settings.cardMaterial) } PanelRow(title: "Button material", @@ -58,12 +58,12 @@ struct AppearanceTab: View { TintSelector(selection: $settings.buttonTint) { $0.localizedDisplayName } } PanelRow(title: "Opacity") { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Slider(value: $settings.buttonTintOpacity, in: 0.05...0.6) - .frame(width: Tokens.FormWidth.compactSlider) + .frame(width: DesignTokens.FormWidth.compactSlider) Text(Format.percent(settings.buttonTintOpacity)) .monospacedDigit() - .frame(width: Tokens.FormWidth.shortReadout) + .frame(width: DesignTokens.FormWidth.shortReadout) } } PanelToggleRow(title: "Gradient", isOn: $settings.buttonTintGradient) @@ -103,9 +103,9 @@ private struct ImageDefaultStyleSection: View { PanelSection(header: "Default image card style", footer: "When on, image groups, image rows, and containers without their own style inherit this design. Specific image, image-group, tag, and container styles remain local overrides above this default.", enabled: $settings.imageDefaultStyleEnabled) { - HStack(spacing: Tokens.Space.m) { - ResourceCardIconChip(symbol: style.symbol, tint: style.color) - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { + HStack(spacing: DesignTokens.Space.m) { + DesignCardIconChip(symbol: style.symbol, tint: style.color) + VStack(alignment: .leading, spacing: DesignTokens.DesignCard.compactTextSpacing) { Text(style.displayName(fallback: "Image cards")) Text("Inherited unless an image, group, tag, or container overrides it") .font(.caption) @@ -121,18 +121,18 @@ private struct ImageDefaultStyleSection: View { PanelRow(title: "Icon") { TextField("", text: styleBinding(\.icon), prompt: Text("SF Symbol, e.g. shippingbox.fill")) .textFieldStyle(.roundedBorder) - .frame(width: Tokens.FormWidth.tintColorHex) + .frame(width: DesignTokens.FormWidth.tintColorHex) } } PanelToggleRow(title: "Color the card background", isOn: styleBinding(\.fillBackground)) if style.fillBackground { PanelRow(title: "Opacity") { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Slider(value: styleBinding(\.backgroundOpacity), in: 0.05...0.6) - .frame(width: Tokens.FormWidth.compactSlider) + .frame(width: DesignTokens.FormWidth.compactSlider) Text(Format.percent(style.backgroundOpacity)) .monospacedDigit() - .frame(width: Tokens.FormWidth.shortReadout) + .frame(width: DesignTokens.FormWidth.shortReadout) } } PanelToggleRow(title: "Gradient", isOn: styleBinding(\.gradient)) diff --git a/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift similarity index 98% rename from Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift rename to Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift index 8d12774c..67119353 100644 --- a/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift @@ -11,7 +11,7 @@ struct ExperimentalTab: View { @Bindable var settings: SettingsStore var body: some View { - LazyVStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: DesignTokens.Space.l) { PanelSection(header: "Experimental", footer: "These features are still being refined. They're off by default; enable any you want to try. You can turn them back off at any time.") { PanelToggleRow(title: "Toolbar-first UI", diff --git a/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift similarity index 91% rename from Sources/Contained/Features/Settings/Tabs/GeneralTab.swift rename to Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift index 7843d25c..2775dd31 100644 --- a/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift @@ -10,7 +10,7 @@ struct GeneralTab: View { @State private var confirmingClear = false var body: some View { - LazyVStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: DesignTokens.Space.l) { PanelSection(header: "Startup") { PanelToggleRow(title: "Launch at login", isOn: $settings.launchAtLogin) PanelToggleRow(title: "Keep running in the menu bar", isOn: $settings.keepInMenuBar) @@ -26,12 +26,12 @@ struct GeneralTab: View { PanelSection(header: "Data", footer: "Live metrics use one low-priority runtime stream. The list refresh interval only controls background service, container list, and resource-cache polling. \(settings.statsNormalizationMode.footnote)") { PanelRow(title: "List refresh interval") { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Slider(value: $settings.refreshInterval, in: 1...10, step: 1) - .frame(width: Tokens.FormWidth.compactSlider) + .frame(width: DesignTokens.FormWidth.compactSlider) Text("\(Int(settings.refreshInterval))s") .monospacedDigit() - .frame(width: Tokens.FormWidth.refreshReadout, alignment: .trailing) + .frame(width: DesignTokens.FormWidth.refreshReadout, alignment: .trailing) } } PanelRow(title: "Keep history for") { @@ -70,18 +70,18 @@ struct GeneralTab: View { .labelsHidden() .fixedSize() } - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { Text("Write to").font(.caption).foregroundStyle(.secondary) ForEach(AppLogDestination.allCases) { destination in Toggle(destination.displayName, isOn: setBinding(destination, in: \.enabledLogDestinations)) .toggleStyle(.checkbox) } } - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { Text("Categories").font(.caption).foregroundStyle(.secondary) LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), alignment: .leading)], alignment: .leading, - spacing: Tokens.Space.s) { + spacing: DesignTokens.Space.s) { ForEach(AppLogCategory.allCases) { category in Toggle(category.displayName, isOn: setBinding(category, in: \.enabledLogCategories)) .toggleStyle(.checkbox) diff --git a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift similarity index 97% rename from Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift rename to Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift index 379a1261..74905134 100644 --- a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift @@ -12,7 +12,7 @@ struct RegistriesTab: View { @State private var loggingOut: RegistryLogin? var body: some View { - LazyVStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: DesignTokens.Space.l) { PanelSection(header: "Signed-in registries", footer: "Credentials are typed by you and piped to the CLI via stdin, so the password never lands in the process list. Contained doesn’t store it.") { if app.registries.isEmpty { @@ -22,7 +22,7 @@ struct RegistriesTab: View { } else { ForEach(app.registries) { login in HStack { - VStack(alignment: .leading, spacing: Tokens.Space.xxs) { + VStack(alignment: .leading, spacing: DesignTokens.Space.xxs) { Text(login.host) if let user = login.username { Text("as \(user)").font(.caption).foregroundStyle(.secondary) diff --git a/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift similarity index 98% rename from Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift rename to Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift index 188f0ad1..66729899 100644 --- a/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift @@ -16,7 +16,7 @@ struct RuntimeTab: View { @State private var deletingDomain: String? var body: some View { - LazyVStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: DesignTokens.Space.l) { PanelSection(header: "Kernel", footer: "Downloads and sets the recommended kernel as the default. May prompt for your administrator password — handled by the container CLI; Contained never sees it.") { PanelRow(title: "Recommended kernel") { @@ -89,7 +89,7 @@ struct RuntimeTab: View { @ViewBuilder private func revealCLIHint(_ command: String) -> some View { if app.settings.revealCLI { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Image(systemName: "terminal").foregroundStyle(.secondary) Text(command).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary) .lineLimit(1).truncationMode(.middle) diff --git a/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift similarity index 98% rename from Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift rename to Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift index 979e073a..c707862e 100644 --- a/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift @@ -11,7 +11,7 @@ struct UpdatesTab: View { var body: some View { @Bindable var settings = app.settings - LazyVStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: DesignTokens.Space.l) { PanelSection(header: "Updates", footer: "\(settings.updateChannel.footnote) Each channel has its own release feed; channels without a published build yet are dimmed and unselectable. Delivered via Sparkle once a signed build points at the feed; inert in development builds.") { PanelRow(title: "Update channel") { diff --git a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift b/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift similarity index 84% rename from Sources/Contained/Features/System/Components/SystemLogsSheet.swift rename to Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift index e803cbcf..d810b807 100644 --- a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift +++ b/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift @@ -10,7 +10,7 @@ struct SystemLogsSheet: View { var body: some View { VStack(spacing: 0) { - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { Text("System logs").font(.headline) DesignGlassToggle(isOn: $follow, title: AppText.follow, systemName: "arrow.down.to.line") .onChange(of: follow) { _, _ in session += 1 } @@ -21,7 +21,7 @@ struct SystemLogsSheet: View { dismiss() }) } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) if let client = app.client { StreamConsole(stream: { client.streamSystemLogs(follow: follow, last: 500) }, workingLabel: AppText.working, @@ -30,11 +30,11 @@ struct SystemLogsSheet: View { copyLogHelp: AppText.copyLog, failureLabel: AppErrorPresentation.message) .id(session) - .padding(.horizontal, Tokens.Space.s) - .padding(.bottom, Tokens.Space.s) + .padding(.horizontal, DesignTokens.Space.s) + .padding(.bottom, DesignTokens.Space.s) } } - .frame(Tokens.SheetSize.wide) + .frame(DesignTokens.SheetSize.wide) .sheetMaterial() } } diff --git a/Sources/Contained/Features/System/Components/SystemVolumeInventory.swift b/Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift similarity index 100% rename from Sources/Contained/Features/System/Components/SystemVolumeInventory.swift rename to Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift diff --git a/Sources/Contained/Features/System/SystemView.swift b/Sources/ContainedApp/Features/System/SystemView.swift similarity index 92% rename from Sources/Contained/Features/System/SystemView.swift rename to Sources/ContainedApp/Features/System/SystemView.swift index 72886f57..63c9b93d 100644 --- a/Sources/Contained/Features/System/SystemView.swift +++ b/Sources/ContainedApp/Features/System/SystemView.swift @@ -89,7 +89,7 @@ struct SystemContent: View { } var body: some View { - MorphPanelScaffold(width: Tokens.PanelSize.system.width) { + DesignPanelScaffold(width: DesignTokens.PanelSize.system.width) { if showsHeader { VStack(spacing: 0) { header @@ -97,14 +97,14 @@ struct SystemContent: View { } } } content: { - LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.l) { switch activePage { case .engine: engineStatusCard case .automation: automationCard case .volumes: volumesCard } } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } .task { await app.refreshSystemResources() } .confirmationDialog("Delete volume \(deletingVolume?.name ?? "")?", @@ -124,7 +124,7 @@ struct SystemContent: View { /// A consistent design-system section card. private func card(@ViewBuilder _ content: @escaping () -> Content) -> some View { DesignContentSurface(elevated: elevated, alignment: .leading) { - LazyVStack(alignment: .leading, spacing: Tokens.Space.m) { content() } + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.m) { content() } } } @@ -132,7 +132,7 @@ struct SystemContent: View { PanelHeader(symbol: "gearshape.2", title: "System", subtitle: activePage.subtitle) { - HStack(spacing: Tokens.Toolbar.groupSpacing) { + HStack(spacing: DesignTokens.Toolbar.groupSpacing) { engineControls DesignActionCluster { DesignActionItems(pageActions) @@ -218,7 +218,7 @@ struct SystemContent: View { card { HStack { Text("Volumes").font(.headline) - ResourceBadgeText(text: "\(volumeInventory.count)") + DesignBadgeText(text: "\(volumeInventory.count)") Spacer() DesignActionGroup(DesignAction(systemName: "plus", title: "New", @@ -231,7 +231,7 @@ struct SystemContent: View { Text("No named volumes or container mounts found.") .font(.callout).foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, Tokens.Space.xs) + .padding(.vertical, DesignTokens.Space.xs) } else { LazyVStack(spacing: 0) { ForEach(Array(volumeInventory.enumerated()), id: \.element.id) { index, entry in @@ -244,20 +244,20 @@ struct SystemContent: View { } private func volumeRow(_ entry: VolumeInventoryEntry) -> some View { - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { Image(systemName: entry.kind.symbol) .foregroundStyle(.secondary) - .frame(width: Tokens.IconSize.rowIconColumn) - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { - HStack(spacing: Tokens.Space.xs) { + .frame(width: DesignTokens.IconSize.rowIconColumn) + VStack(alignment: .leading, spacing: DesignTokens.DesignCard.compactTextSpacing) { + HStack(spacing: DesignTokens.Space.xs) { Text(entry.title).font(.system(.callout, design: .monospaced)).lineLimit(1) - ResourceBadgeText(text: entry.kind.rawValue) + DesignBadgeText(text: entry.kind.rawValue) } if let subtitle = SystemVolumeInventory.rowSubtitle(entry) { Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) } } - Spacer(minLength: Tokens.Space.s) + Spacer(minLength: DesignTokens.Space.s) if !entry.containers.isEmpty { Text("\(entry.containers.count)") .font(.caption.monospacedDigit()) @@ -267,7 +267,7 @@ struct SystemContent: View { volumeMenu(entry) } } - .padding(.vertical, Tokens.Space.s) + .padding(.vertical, DesignTokens.Space.s) .contextMenu { volumeMenu(entry) } } @@ -343,10 +343,10 @@ struct SystemContent: View { : "Off", isOn: settingBinding(\.autoRestartEnabled)) { EmptyView() } Divider() - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { Image(systemName: "dot.radiowaves.left.and.right") .foregroundStyle(.secondary) - .frame(width: Tokens.IconSize.rowIconColumn) + .frame(width: DesignTokens.IconSize.rowIconColumn) Text("Refresh loop").font(.callout) Spacer() Text(app.coordinator.isActive ? "Active" : "Paused") @@ -359,15 +359,15 @@ struct SystemContent: View { private func automationRow(icon: String, title: String, detail: String, isOn: Binding, @ViewBuilder trailing: () -> Trailing) -> some View { - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { Image(systemName: icon).font(.title3) .foregroundStyle(isOn.wrappedValue ? Color.accentColor : .secondary) - .frame(width: Tokens.IconSize.rowIconColumn) - VStack(alignment: .leading, spacing: Tokens.Space.xxs) { + .frame(width: DesignTokens.IconSize.rowIconColumn) + VStack(alignment: .leading, spacing: DesignTokens.Space.xxs) { Text(title).font(.callout) Text(detail).font(.caption).foregroundStyle(.secondary) } - Spacer(minLength: Tokens.Space.s) + Spacer(minLength: DesignTokens.Space.s) trailing() Toggle("", isOn: isOn).labelsHidden().controlSize(.mini) } @@ -437,9 +437,9 @@ struct SystemContent: View { private var engineStatusCard: some View { card { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { DesignStatusDot(color: app.serviceHealthy ? .green : .orange, - size: Tokens.IconSize.serviceDot) + size: DesignTokens.IconSize.serviceDot) Text("Container engine").font(.headline) DesignStatusBadge(text: app.serviceLabel, tint: app.serviceHealthy ? .green : .orange) @@ -449,7 +449,7 @@ struct SystemContent: View { .textSelection(.enabled) } } - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { DesignMetricTile(label: "Containers", value: "\(app.containers.running.count)", caption: "running") DesignMetricTile(label: "Images", value: "\(app.images.count)") DesignMetricTile(label: "Disk used", value: app.diskUsage.map { Format.bytes($0.totalSizeInBytes) } ?? "—") diff --git a/Sources/Contained/History/ActivityView.swift b/Sources/ContainedApp/History/ActivityView.swift similarity index 95% rename from Sources/Contained/History/ActivityView.swift rename to Sources/ContainedApp/History/ActivityView.swift index cfa57e68..e7333105 100644 --- a/Sources/Contained/History/ActivityView.swift +++ b/Sources/ContainedApp/History/ActivityView.swift @@ -10,7 +10,7 @@ struct ActivityView: View { var body: some View { ActivityContent(showClose: true) { dismiss() } - .frame(Tokens.SheetSize.wide) + .frame(DesignTokens.SheetSize.wide) .sheetMaterial() } } @@ -83,7 +83,7 @@ struct ActivityContent: View { } var body: some View { - MorphPanelScaffold(width: Tokens.PanelSize.activity.width) { + DesignPanelScaffold(width: DesignTokens.PanelSize.activity.width) { if showsHeader { VStack(spacing: 0) { PanelHeader(symbol: "bell", @@ -101,14 +101,14 @@ struct ActivityContent: View { if filtered.isEmpty { ContentUnavailableView("No activity", systemImage: "bell", description: Text("Events from container lifecycle, the watchdog, and healthchecks land here.")) - .padding(.vertical, Tokens.Space.xl) + .padding(.vertical, DesignTokens.Space.xl) } else { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { ForEach(filtered) { event in EventRow(event: event, elevated: elevated, isUnread: !event.isRead) } } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } } // Once the user has seen the panel, the events are read — clears the toolbar badge on dismiss. diff --git a/Sources/Contained/History/ContainerHistoryTab.swift b/Sources/ContainedApp/History/ContainerHistoryTab.swift similarity index 94% rename from Sources/Contained/History/ContainerHistoryTab.swift rename to Sources/ContainedApp/History/ContainerHistoryTab.swift index 5c41e04e..14c12973 100644 --- a/Sources/Contained/History/ContainerHistoryTab.swift +++ b/Sources/ContainedApp/History/ContainerHistoryTab.swift @@ -31,7 +31,7 @@ struct ContainerHistoryTab: View { var body: some View { ContainerTabScaffold { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { Picker("Range", selection: $range) { ForEach(HistoryRange.allCases) { Text($0.rawValue).tag($0) } } @@ -71,7 +71,7 @@ private struct ContainerHistoryWindow: View { snapshot: snapshot, normalization: normalization) - LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.l) { if chartPoints.isEmpty { ContentUnavailableView("No history yet", systemImage: "chart.xyaxis.line", description: Text("Resource samples accumulate while the container runs.")) @@ -90,7 +90,7 @@ private struct ContainerHistoryWindow: View { Chart(chartPoints) { point in AreaMark(x: .value("Time", point.timestamp), y: .value("Memory", point.memoryPercent)) - .foregroundStyle(Color.accentColor.opacity(Tokens.Chart.areaOpacity)) + .foregroundStyle(Color.accentColor.opacity(DesignTokens.Chart.areaOpacity)) } .percentHistoryScale() } @@ -109,7 +109,7 @@ private struct ContainerHistoryWindow: View { } if !events.isEmpty { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { Text("Events").font(.headline) ForEach(events.prefix(50)) { event in EventRow(event: event) @@ -127,8 +127,8 @@ private struct ContainerHistoryWindow: View { Text(unit).font(.caption).foregroundStyle(.secondary) } chart() - .frame(height: Tokens.Chart.height) - .chartXAxis { AxisMarks(values: .automatic(desiredCount: Tokens.Chart.axisDesiredCount)) } + .frame(height: DesignTokens.Chart.height) + .chartXAxis { AxisMarks(values: .automatic(desiredCount: DesignTokens.Chart.axisDesiredCount)) } .transaction { transaction in transaction.animation = nil } } } @@ -212,18 +212,18 @@ struct EventRow: View { @Environment(\.modelContext) private var modelContext var body: some View { - ResourceCard(size: .small, + DesignCard(size: .small, isSelected: isUnread, elevated: elevated, title: event.message, subtitle: subtitle) { - ResourceCardIconChip(symbol: event.kind.symbol, + DesignCardIconChip(symbol: event.kind.symbol, tint: event.kind.tint, - backgroundOpacity: Tokens.ResourceCard.iconEmphasisBackgroundOpacity) + backgroundOpacity: DesignTokens.DesignCard.iconEmphasisBackgroundOpacity) } titleAccessory: { EmptyView() } subtitleAccessory: { - ResourceBadgeText(text: event.kind.rawValue.capitalized) + DesignBadgeText(text: event.kind.rawValue.capitalized) } headerAccessory: { if isUnread { DesignStatusDot(color: .accentColor) diff --git a/Sources/Contained/History/HistoryModels.swift b/Sources/ContainedApp/History/HistoryModels.swift similarity index 97% rename from Sources/Contained/History/HistoryModels.swift rename to Sources/ContainedApp/History/HistoryModels.swift index 428b0dc5..13bbb2ee 100644 --- a/Sources/Contained/History/HistoryModels.swift +++ b/Sources/ContainedApp/History/HistoryModels.swift @@ -1,5 +1,6 @@ import Foundation import SwiftData +import ContainedCore /// Categories of events recorded to the persistent history/timeline. enum EventKind: String, Codable, CaseIterable, Sendable { @@ -135,3 +136,6 @@ struct MetricSampleSnapshot: Codable, Equatable { diskWriteBytesPerSec = sample.diskWriteBytesPerSec } } + +extension MetricSample: MetricHistorySample {} +extension MetricSampleSnapshot: MetricHistorySample {} diff --git a/Sources/Contained/History/HistoryStore.swift b/Sources/ContainedApp/History/HistoryStore.swift similarity index 100% rename from Sources/Contained/History/HistoryStore.swift rename to Sources/ContainedApp/History/HistoryStore.swift diff --git a/Sources/Contained/History/Template.swift b/Sources/ContainedApp/History/Template.swift similarity index 100% rename from Sources/Contained/History/Template.swift rename to Sources/ContainedApp/History/Template.swift diff --git a/Sources/Contained/Migration/AppStateEnvelope+Transfer.swift b/Sources/ContainedApp/Migration/AppStateEnvelope+Transfer.swift similarity index 100% rename from Sources/Contained/Migration/AppStateEnvelope+Transfer.swift rename to Sources/ContainedApp/Migration/AppStateEnvelope+Transfer.swift diff --git a/Sources/Contained/Migration/AppStateEnvelope.swift b/Sources/ContainedApp/Migration/AppStateEnvelope.swift similarity index 100% rename from Sources/Contained/Migration/AppStateEnvelope.swift rename to Sources/ContainedApp/Migration/AppStateEnvelope.swift diff --git a/Sources/Contained/Migration/DowngradeDecisionView.swift b/Sources/ContainedApp/Migration/DowngradeDecisionView.swift similarity index 82% rename from Sources/Contained/Migration/DowngradeDecisionView.swift rename to Sources/ContainedApp/Migration/DowngradeDecisionView.swift index 045593ed..edf41767 100644 --- a/Sources/Contained/Migration/DowngradeDecisionView.swift +++ b/Sources/ContainedApp/Migration/DowngradeDecisionView.swift @@ -8,7 +8,7 @@ struct DowngradeDecisionView: View { var onQuit: () -> Void var body: some View { - LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.l) { SheetHeader(title: "This data was created by a newer version", subtitle: "Stored schema \(schemaVersion), this app supports \(StateMigrator.currentSchemaVersion).", cancelHelp: AppText.quit, @@ -18,7 +18,7 @@ struct DowngradeDecisionView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { Button("Export Backup, Then Reset") { onExportAndReset() } .buttonStyle(.borderedProminent) Button("Try to Keep Readable Data") { onKeep() } @@ -26,7 +26,7 @@ struct DowngradeDecisionView: View { } .frame(maxWidth: .infinity, alignment: .leading) } - .padding(Tokens.Space.l) - .frame(width: Tokens.SheetSize.dialogWidth) + .padding(DesignTokens.Space.l) + .frame(width: DesignTokens.SheetSize.dialogWidth) } } diff --git a/Sources/Contained/Migration/JSONValue.swift b/Sources/ContainedApp/Migration/JSONValue.swift similarity index 100% rename from Sources/Contained/Migration/JSONValue.swift rename to Sources/ContainedApp/Migration/JSONValue.swift diff --git a/Sources/Contained/Migration/MigrationStep.swift b/Sources/ContainedApp/Migration/MigrationStep.swift similarity index 100% rename from Sources/Contained/Migration/MigrationStep.swift rename to Sources/ContainedApp/Migration/MigrationStep.swift diff --git a/Sources/Contained/Migration/StateMigrator.swift b/Sources/ContainedApp/Migration/StateMigrator.swift similarity index 100% rename from Sources/Contained/Migration/StateMigrator.swift rename to Sources/ContainedApp/Migration/StateMigrator.swift diff --git a/Sources/Contained/Navigation/AppSection.swift b/Sources/ContainedApp/Navigation/AppSection.swift similarity index 100% rename from Sources/Contained/Navigation/AppSection.swift rename to Sources/ContainedApp/Navigation/AppSection.swift diff --git a/Sources/Contained/Navigation/AppToolbar.swift b/Sources/ContainedApp/Navigation/AppToolbar.swift similarity index 90% rename from Sources/Contained/Navigation/AppToolbar.swift rename to Sources/ContainedApp/Navigation/AppToolbar.swift index f9813dce..b5d71f77 100644 --- a/Sources/Contained/Navigation/AppToolbar.swift +++ b/Sources/ContainedApp/Navigation/AppToolbar.swift @@ -13,11 +13,11 @@ import ContainedCore /// toolbar safe-area contract. /// The add `+`, search field, and bottom toolbar controls all grow through the same /// `MorphingExpander` shell from their measured toolbar slots. Control sizing and source radius come -/// from `Tokens.Toolbar` / `ToolbarControls`. +/// from `DesignTokens.Toolbar` / `ToolbarControls`. struct AppToolbar: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - @Environment(\.appSafeAreas) private var safeAreas + @Environment(\.morphSafeAreas) private var safeAreas @State private var slots: [UIState.ToolbarMorph: CGRect] = [:] @State private var addSoftDismiss: (() -> Void)? @@ -29,9 +29,9 @@ struct AppToolbar: View { static let space = "appToolbar" /// Title-bar band height. The toolbar lives in the detail column (no traffic lights there), so the - /// leading inset is just normal padding. Sourced from `Tokens.Toolbar` so the band, the safe-area + /// leading inset is just normal padding. Sourced from `DesignTokens.Toolbar` so the band, the safe-area /// manager, and the controls all agree. - static let bandHeight: CGFloat = Tokens.Toolbar.band + static let bandHeight: CGFloat = DesignTokens.Toolbar.band var body: some View { ZStack(alignment: .top) { @@ -39,12 +39,12 @@ struct AppToolbar: View { .zIndex(40) VStack(spacing: 0) { topToolbarRow - .frame(height: Tokens.Toolbar.controlHeight) + .frame(height: DesignTokens.Toolbar.controlHeight) .padding(.top, rowTopInset) // centered on the traffic-light line Spacer(minLength: 0) .allowsHitTesting(false) bottomToolbarRow - .frame(height: Tokens.Toolbar.controlHeight) + .frame(height: DesignTokens.Toolbar.controlHeight) .padding(.bottom, bottomRowInset) } .zIndex(100) @@ -84,17 +84,17 @@ struct AppToolbar: View { // MARK: Top Row private var topToolbarRow: some View { - HStack(spacing: Tokens.Toolbar.groupSpacing) { + HStack(spacing: DesignTokens.Toolbar.groupSpacing) { if !isSidebarOpen { settingsZone ToolbarPageSwitcher() } ToolbarPageContextOptions() - Spacer(minLength: Tokens.Space.m) + Spacer(minLength: DesignTokens.Space.m) searchZone } - .padding(.leading, Tokens.Toolbar.outerPadding) - .padding(.trailing, Tokens.Toolbar.outerPadding) + .padding(.leading, DesignTokens.Toolbar.outerPadding) + .padding(.trailing, DesignTokens.Toolbar.outerPadding) .frame(maxWidth: .infinity) } @@ -113,7 +113,7 @@ struct AppToolbar: View { private var searchZone: some View { ToolbarSearchSource() - .frame(width: Tokens.Toolbar.searchMaxWidth, height: Tokens.Toolbar.controlHeight) + .frame(width: DesignTokens.Toolbar.searchMaxWidth, height: DesignTokens.Toolbar.controlHeight) .opacity(ui.activeMorph == .palette ? 0 : 1) .background(singleSlotReader(.palette)) } @@ -125,7 +125,7 @@ struct AppToolbar: View { if ui.activeMorph == .palette, app.settings.commandPaletteEnabled { MorphingExpander(isPresented: paletteMorphBinding, originFrame: slots[.palette] ?? .zero, - target: toolbarMorphTarget(for: .palette, size: Tokens.PanelSize.palette), + target: toolbarMorphTarget(for: .palette, size: DesignTokens.PanelSize.palette), showsBackdrop: false, closeRequestToken: ui.morphCloseRequestToken, onExpansionChange: setMorphBackdropExpanded) { @@ -137,13 +137,13 @@ struct AppToolbar: View { // MARK: Bottom Row private var bottomToolbarRow: some View { - HStack(spacing: Tokens.Toolbar.groupSpacing) { + HStack(spacing: DesignTokens.Toolbar.groupSpacing) { systemStatusButton ToolbarPageFilterOptions() - Spacer(minLength: Tokens.Space.m) + Spacer(minLength: DesignTokens.Space.m) bottomActionGroup } - .padding(.horizontal, Tokens.Toolbar.outerPadding) + .padding(.horizontal, DesignTokens.Toolbar.outerPadding) .frame(maxWidth: .infinity) } @@ -156,13 +156,13 @@ struct AppToolbar: View { fraction: activity.fraction), style: .inline) } else { - HStack(spacing: Tokens.Toolbar.searchIconGap) { + HStack(spacing: DesignTokens.Toolbar.searchIconGap) { Image(systemName: systemStatusIcon) .foregroundStyle(systemStatusColor) - .frame(width: Tokens.Toolbar.iconContentWidth) + .frame(width: DesignTokens.Toolbar.iconContentWidth) Text(app.serviceLabel) .foregroundStyle(.secondary) - .padding(.trailing, Tokens.Toolbar.statusLabelTrailingPadding) + .padding(.trailing, DesignTokens.Toolbar.statusLabelTrailingPadding) } } } @@ -172,7 +172,7 @@ struct AppToolbar: View { } private var bottomActionGroup: some View { - HStack(spacing: Tokens.Toolbar.groupSpacing) { + HStack(spacing: DesignTokens.Toolbar.groupSpacing) { DesignToolbarActionCluster { DesignActionItems([ DesignAction(systemName: "plus", help: AppText.add) { ui.openCreationPanel() }, @@ -196,7 +196,7 @@ struct AppToolbar: View { private var addMorphLayer: some View { if ui.activeMorph == .add { MorphingExpander(isPresented: addMorphBinding, originFrame: slots[.add] ?? .zero, - target: toolbarMorphTarget(for: .add, size: Tokens.PanelSize.add), + target: toolbarMorphTarget(for: .add, size: DesignTokens.PanelSize.add), showsBackdrop: false, closeRequestToken: ui.morphCloseRequestToken, onBackdropTap: addSoftDismiss, @@ -227,7 +227,7 @@ struct AppToolbar: View { if ui.activeMorph == .updates { MorphingExpander(isPresented: morphBinding(.updates), originFrame: slots[.updates] ?? .zero, - target: toolbarMorphTarget(for: .updates, size: Tokens.PanelSize.images), + target: toolbarMorphTarget(for: .updates, size: DesignTokens.PanelSize.images), showsBackdrop: false, closeRequestToken: ui.morphCloseRequestToken, onExpansionChange: setMorphBackdropExpanded) { @@ -264,7 +264,7 @@ struct AppToolbar: View { if ui.activeMorph == .activity { MorphingExpander(isPresented: morphBinding(.activity), originFrame: slots[.activity] ?? .zero, - target: toolbarMorphTarget(for: .activity, size: Tokens.PanelSize.activity), + target: toolbarMorphTarget(for: .activity, size: DesignTokens.PanelSize.activity), showsBackdrop: false, closeRequestToken: ui.morphCloseRequestToken, onExpansionChange: setMorphBackdropExpanded) { @@ -280,7 +280,7 @@ struct AppToolbar: View { if ui.activeMorph == .templates { MorphingExpander(isPresented: morphBinding(.templates), originFrame: slots[.templates] ?? .zero, - target: toolbarMorphTarget(for: .templates, size: Tokens.PanelSize.templates), + target: toolbarMorphTarget(for: .templates, size: DesignTokens.PanelSize.templates), showsBackdrop: false, closeRequestToken: ui.morphCloseRequestToken, onExpansionChange: setMorphBackdropExpanded) { @@ -296,7 +296,7 @@ struct AppToolbar: View { if ui.activeMorph == .system { MorphingExpander(isPresented: morphBinding(.system), originFrame: slots[.system] ?? .zero, - target: toolbarMorphTarget(for: .system, size: Tokens.PanelSize.system), + target: toolbarMorphTarget(for: .system, size: DesignTokens.PanelSize.system), showsBackdrop: false, closeRequestToken: ui.morphCloseRequestToken, onExpansionChange: setMorphBackdropExpanded) { @@ -310,7 +310,7 @@ struct AppToolbar: View { if ui.activeMorph == .settings { MorphingExpander(isPresented: morphBinding(.settings), originFrame: slots[.settings] ?? .zero, - target: toolbarMorphTarget(for: .settings, size: Tokens.PanelSize.settings), + target: toolbarMorphTarget(for: .settings, size: DesignTokens.PanelSize.settings), showsBackdrop: false, closeRequestToken: ui.morphCloseRequestToken, onExpansionChange: setMorphBackdropExpanded) { @@ -382,17 +382,17 @@ struct AppToolbar: View { /// Safe area for a morph panel. Bottom-row panels clear the top toolbar; top-row panels clear the /// bottom. Settings is special: it grows from the vanity slot behind the traffic lights and must /// clear *both* bands so the panel starts fully below the native titlebar chrome. - private func toolbarMorphSafeArea(for morph: UIState.ToolbarMorph) -> AppSafeAreaPolicy { + private func toolbarMorphSafeArea(for morph: UIState.ToolbarMorph) -> MorphSafeAreaPolicy { switch morph { - case .settings: AppSafeAreaPolicy(excluding: .both, padding: .small) - case .palette: AppSafeAreaPolicy(excluding: .bottom, padding: .small) - default: AppSafeAreaPolicy(excluding: .top, padding: .small) + case .settings: MorphSafeAreaPolicy(excluding: .both, padding: .small) + case .palette: MorphSafeAreaPolicy(excluding: .bottom, padding: .small) + default: MorphSafeAreaPolicy(excluding: .top, padding: .small) } } private func toolbarMorphTarget(for morph: UIState.ToolbarMorph, size: CGSize, - placement: MorphPanelPlacement = .anchored) -> AppMorphTarget { + placement: MorphPanelPlacement = .anchored) -> MorphTarget { let safeArea = toolbarMorphSafeArea(for: morph) switch placement { case .anchored: @@ -402,10 +402,10 @@ struct AppToolbar: View { } } - private var rowTopInset: CGFloat { Tokens.Toolbar.topPadding } + private var rowTopInset: CGFloat { DesignTokens.Toolbar.topPadding } private var bottomRowInset: CGFloat { - max(Tokens.Toolbar.outerPadding, safeAreas.system.bottom + Tokens.Toolbar.outerPadding) + max(DesignTokens.Toolbar.outerPadding, safeAreas.system.bottom + DesignTokens.Toolbar.outerPadding) } private var systemStatusColor: Color { @@ -453,7 +453,7 @@ struct AppToolbar: View { } private var toolbarImageDetailSize: CGSize { - Tokens.PanelSize.imageDetail + DesignTokens.PanelSize.imageDetail } private func currentToolbarImageGroup(_ group: LocalImageTagGroup) -> LocalImageTagGroup { diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/ContainedApp/Navigation/ClassicShell.swift similarity index 92% rename from Sources/Contained/Navigation/ClassicShell.swift rename to Sources/ContainedApp/Navigation/ClassicShell.swift index dd7fd3f7..9545eea8 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/ContainedApp/Navigation/ClassicShell.swift @@ -42,15 +42,15 @@ struct ClassicShell: View { // The custom toolbar belongs to the detail column, not the whole split view. Mounting // it here keeps the sidebar outside the toolbar safe-area bands. AppToolbar() - .environment(\.appSafeAreas, toolbarSafeAreas) + .environment(\.morphSafeAreas, toolbarSafeAreas) .ignoresSafeArea(.container, edges: .vertical) } } - .environment(\.appSafeAreas, AppSafeAreaManager(system: EdgeInsets())) + .environment(\.morphSafeAreas, MorphSafeAreaManager(system: EdgeInsets())) } private var detailPage: some View { - let insets = toolbarSafeAreas.insets(AppSafeAreaPolicy(excluding: .top, padding: .none)) + let insets = toolbarSafeAreas.insets(MorphSafeAreaPolicy(excluding: .top, padding: .none)) return ClassicSectionPage(section: ui.selectedSection) .frame(maxWidth: .infinity, maxHeight: .infinity) // Body-only padding from the same custom safe-area measurer used by morph panels. @@ -58,8 +58,8 @@ struct ClassicShell: View { .ignoresSafeArea(.container, edges: .vertical) } - private var toolbarSafeAreas: AppSafeAreaManager { - AppSafeAreaManager(system: EdgeInsets(), + private var toolbarSafeAreas: MorphSafeAreaManager { + MorphSafeAreaManager(system: EdgeInsets(), topToolbarHeight: AppToolbar.bandHeight, bottomToolbarHeight: AppToolbar.bandHeight) } @@ -236,12 +236,12 @@ private struct NetworksPage: View { description: Text("Create or refresh container networks to see them here.")) .frame(maxWidth: .infinity, minHeight: 280) } else { - LazyVStack(spacing: Tokens.Space.s) { + LazyVStack(spacing: DesignTokens.Space.s) { ForEach(Array(networkSections.enumerated()), id: \.offset) { _, section in if ui.networkGrouping != .none { - ResourceBadgeText(text: section.title, font: .caption.weight(.semibold)) + DesignBadgeText(text: section.title, font: .caption.weight(.semibold)) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Tokens.Space.xs) + .padding(.horizontal, DesignTokens.Space.xs) } ForEach(section.networks) { network in networkRow(network) @@ -262,11 +262,11 @@ private struct NetworksPage: View { } private func networkRow(_ network: NetworkResource) -> some View { - ResourceCard(size: .medium, + DesignCard(size: .medium, elevated: false, title: network.name, subtitle: networkSubtitle(network)) { - ResourceCardIconChip(symbol: network.isBuiltin ? "network.badge.shield.half.filled" : "network", + DesignCardIconChip(symbol: network.isBuiltin ? "network.badge.shield.half.filled" : "network", tint: network.isBuiltin ? .secondary : .accentColor) } titleAccessory: { EmptyView() @@ -280,10 +280,10 @@ private struct NetworksPage: View { EmptyView() } footerLeading: { if network.isBuiltin { - ResourceCardFooterMini { + DesignCardFooterMini { Image(systemName: "network.badge.shield.half.filled").font(.caption2) } text: { - ResourceCardMetricText(text: "Built-in") + DesignCardMetricText(text: "Built-in") } } } footerActions: { @@ -369,7 +369,7 @@ private struct ImagesPage: View { if let detail, presented { MorphingSingleSurfaceExpander(isPresented: detailBinding, originFrame: usableSourceFrame ?? fallbackSourceFrame(in: viewport.size), - target: .anchored(size: Tokens.PanelSize.imageDetail, + target: .anchored(size: DesignTokens.PanelSize.imageDetail, safeArea: imageDetailSafeAreaPolicy, margin: 16), backdropStyle: .dim, @@ -381,7 +381,7 @@ private struct ImagesPage: View { onTap: {}, onClose: closeDetail) } - .environment(\.appSafeAreas, imageDetailSafeAreas) + .environment(\.morphSafeAreas, imageDetailSafeAreas) .zIndex(10) } } @@ -391,13 +391,13 @@ private struct ImagesPage: View { private let pageImageSpace = "imagesPage" - private var imageDetailSafeAreaPolicy: AppSafeAreaPolicy { - ui.toolbarUIEnabled ? AppSafeAreaPolicy(excluding: .both, padding: .small) : .content + private var imageDetailSafeAreaPolicy: MorphSafeAreaPolicy { + ui.toolbarUIEnabled ? MorphSafeAreaPolicy(excluding: .both, padding: .small) : .content } - private var imageDetailSafeAreas: AppSafeAreaManager { - guard ui.toolbarUIEnabled else { return AppSafeAreaManager(system: EdgeInsets()) } - return AppSafeAreaManager(system: EdgeInsets(), + private var imageDetailSafeAreas: MorphSafeAreaManager { + guard ui.toolbarUIEnabled else { return MorphSafeAreaManager(system: EdgeInsets()) } + return MorphSafeAreaManager(system: EdgeInsets(), topToolbarHeight: AppToolbar.bandHeight, bottomToolbarHeight: AppToolbar.bandHeight) } diff --git a/Sources/Contained/Navigation/PendingAction.swift b/Sources/ContainedApp/Navigation/PendingAction.swift similarity index 100% rename from Sources/Contained/Navigation/PendingAction.swift rename to Sources/ContainedApp/Navigation/PendingAction.swift diff --git a/Sources/Contained/Navigation/RootView.swift b/Sources/ContainedApp/Navigation/RootView.swift similarity index 98% rename from Sources/Contained/Navigation/RootView.swift rename to Sources/ContainedApp/Navigation/RootView.swift index 074f071b..8086ac82 100644 --- a/Sources/Contained/Navigation/RootView.swift +++ b/Sources/ContainedApp/Navigation/RootView.swift @@ -76,7 +76,7 @@ struct RootView: View { // (see `AppToolbar` → `ActivityStatusView`); only transient banners float at the bottom. .overlay(alignment: .bottom) { bannerView - .padding(.bottom, Tokens.Space.l) + .padding(.bottom, DesignTokens.Space.l) } .animation(reduceMotion ? nil : .smooth(duration: 0.25), value: app.banner) .animation(reduceMotion ? nil : .smooth(duration: 0.25), value: app.activity) @@ -162,7 +162,7 @@ struct RootView: View { content .ignoresSafeArea(.container, edges: .vertical) } - .environment(\.appSafeAreas, AppSafeAreaManager(system: EdgeInsets())) + .environment(\.morphSafeAreas, MorphSafeAreaManager(system: EdgeInsets())) .contextMenu { backgroundMenu() } } diff --git a/Sources/Contained/Navigation/ToolbarFilterOptions.swift b/Sources/ContainedApp/Navigation/ToolbarFilterOptions.swift similarity index 100% rename from Sources/Contained/Navigation/ToolbarFilterOptions.swift rename to Sources/ContainedApp/Navigation/ToolbarFilterOptions.swift diff --git a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift similarity index 73% rename from Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift rename to Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift index 3f264e0d..29829a91 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift @@ -22,12 +22,12 @@ struct PaletteResultCard: View { case .imageTag(let reference, let groupID): imageTagCard(reference, groupID: groupID) case .volume(let volume): - resourceCard(symbol: "externaldrive", + designCard(symbol: "externaldrive", title: volume.name, subtitle: "Volume", footer: "Use in a new run") case .network(let network): - resourceCard(symbol: "network", + designCard(symbol: "network", title: network.name, subtitle: network.isBuiltin ? "Built-in network" : "Network", footer: "Run a container on this network") @@ -37,21 +37,21 @@ struct PaletteResultCard: View { } private var plainCard: some View { - ResourceCard(size: .small, + DesignCard(size: .small, isSelected: selected, fill: nil, - fillOpacity: selected ? Tokens.ResourceCard.selectedSubtleFillOpacity : Tokens.ResourceCard.plainFillOpacity, + fillOpacity: selected ? DesignTokens.DesignCard.selectedSubtleFillOpacity : DesignTokens.DesignCard.plainFillOpacity, elevated: false, onTap: action, title: item.title, subtitle: item.subtitle) { - ResourceCardIconChip(symbol: item.icon, + DesignCardIconChip(symbol: item.icon, tint: item.tint, backgroundOpacity: selected - ? Tokens.ResourceCard.iconSelectedBackgroundOpacity - : Tokens.ResourceCard.iconBackgroundOpacity) + ? DesignTokens.DesignCard.iconSelectedBackgroundOpacity + : DesignTokens.DesignCard.iconBackgroundOpacity) } titleAccessory: { - ResourceBadgeText(text: item.kind.rawValue, + DesignBadgeText(text: item.kind.rawValue, font: .caption2.weight(.semibold), foreground: selected ? .accentColor : .secondary) } subtitleAccessory: { @@ -74,11 +74,11 @@ struct PaletteResultCard: View { private func containerCard(_ snapshot: ContainerSnapshot) -> some View { let style = app.containerStyle(for: snapshot) let name = style.displayName(fallback: snapshot.id) - let cardSize: ResourceCardSize = snapshot.state == .running ? .large : .medium - return ResourceCard(size: cardSize, + let cardSize: DesignCardSize = snapshot.state == .running ? .large : .medium + return DesignCard(size: cardSize, isSelected: selected, fill: style.fillBackground ? style.color : nil, - fillOpacity: selected ? Tokens.ResourceCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, + fillOpacity: selected ? DesignTokens.DesignCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, gradient: style.gradient, gradientAngle: style.gradientAngle, blendMode: style.backgroundBlendMode, @@ -87,13 +87,13 @@ struct PaletteResultCard: View { title: name, subtitle: Format.shortImage(snapshot.image), subtitleStyle: .monospaced) { - ResourceCardIconChip(symbol: style.symbol, + DesignCardIconChip(symbol: style.symbol, tint: style.color, backgroundOpacity: selected - ? Tokens.ResourceCard.iconSelectedBackgroundOpacity - : Tokens.ResourceCard.iconBackgroundOpacity) + ? DesignTokens.DesignCard.iconSelectedBackgroundOpacity + : DesignTokens.DesignCard.iconBackgroundOpacity) } titleAccessory: { - ResourceBadgeText(text: snapshot.state.rawValue.capitalized, + DesignBadgeText(text: snapshot.state.rawValue.capitalized, font: .caption2.weight(.semibold), foreground: snapshot.state == .running ? .green : .secondary) } subtitleAccessory: { @@ -123,10 +123,10 @@ struct PaletteResultCard: View { private func imageTagCard(_ reference: String, groupID: String) -> some View { let style = app.imageGroupStyle(forID: groupID) - return ResourceCard(size: .medium, + return DesignCard(size: .medium, isSelected: selected, fill: style.fillBackground ? style.color : nil, - fillOpacity: selected ? Tokens.ResourceCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, + fillOpacity: selected ? DesignTokens.DesignCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, gradient: style.gradient, gradientAngle: style.gradientAngle, blendMode: style.backgroundBlendMode, @@ -135,13 +135,13 @@ struct PaletteResultCard: View { title: Format.shortImage(reference), subtitle: repositoryTitle(reference), titleStyle: .monospaced) { - ResourceCardIconChip(symbol: "tag", + DesignCardIconChip(symbol: "tag", tint: style.color, backgroundOpacity: selected - ? Tokens.ResourceCard.iconSelectedBackgroundOpacity - : Tokens.ResourceCard.iconBackgroundOpacity) + ? DesignTokens.DesignCard.iconSelectedBackgroundOpacity + : DesignTokens.DesignCard.iconBackgroundOpacity) } titleAccessory: { - ResourceBadgeText(text: "Tag", font: .caption2.weight(.semibold)) + DesignBadgeText(text: "Tag", font: .caption2.weight(.semibold)) } subtitleAccessory: { EmptyView() } headerAccessory: { @@ -149,12 +149,12 @@ struct PaletteResultCard: View { } bodyContent: { EmptyView() } footerLeading: { - ResourceCardFooterMini { + DesignCardFooterMini { Image(systemName: style.symbol) .font(.caption2) .foregroundStyle(style.color) } text: { - ResourceCardMetricText(text: "Image") + DesignCardMetricText(text: "Image") .foregroundStyle(.secondary) } } footerActions: { @@ -166,22 +166,22 @@ struct PaletteResultCard: View { .accessibilityAddTraits(selected ? .isSelected : []) } - private func resourceCard(symbol: String, title: String, subtitle: String, footer: String) -> some View { - ResourceCard(size: .small, + private func designCard(symbol: String, title: String, subtitle: String, footer: String) -> some View { + DesignCard(size: .small, isSelected: selected, fill: nil, - fillOpacity: selected ? Tokens.ResourceCard.selectedResourceFillOpacity : Tokens.ResourceCard.plainFillOpacity, + fillOpacity: selected ? DesignTokens.DesignCard.selectedResourceFillOpacity : DesignTokens.DesignCard.plainFillOpacity, elevated: false, onTap: action, title: title, subtitle: footer) { - ResourceCardIconChip(symbol: symbol, + DesignCardIconChip(symbol: symbol, tint: item.tint, backgroundOpacity: selected - ? Tokens.ResourceCard.iconSelectedBackgroundOpacity - : Tokens.ResourceCard.iconBackgroundOpacity) + ? DesignTokens.DesignCard.iconSelectedBackgroundOpacity + : DesignTokens.DesignCard.iconBackgroundOpacity) } titleAccessory: { - ResourceBadgeText(text: subtitle, font: .caption2.weight(.semibold)) + DesignBadgeText(text: subtitle, font: .caption2.weight(.semibold)) } subtitleAccessory: { EmptyView() } headerAccessory: { @@ -199,18 +199,18 @@ struct PaletteResultCard: View { .accessibilityAddTraits(selected ? .isSelected : []) } - private func tintCard(_ tint: AppTint) -> some View { - ResourceCard(size: .small, + private func tintCard(_ tint: DesignTint) -> some View { + DesignCard(size: .small, isSelected: selected, fill: tint.color, - fillOpacity: selected ? Tokens.ResourceCard.selectedTintFillOpacity : Tokens.ResourceCard.selectedSubtleFillOpacity, + fillOpacity: selected ? DesignTokens.DesignCard.selectedTintFillOpacity : DesignTokens.DesignCard.selectedSubtleFillOpacity, elevated: false, onTap: action, title: tint.localizedDisplayName, subtitle: item.title) { - DesignTintSwatch(color: tint.color, followsAppAccent: tint.followsAppAccent) + DesignTintSwatch(color: tint.color, followsAccent: tint.followsAccent) } titleAccessory: { - ResourceBadgeText(text: app.settings.accentTint == tint ? AppText.current : AppText.tint, + DesignBadgeText(text: app.settings.accentTint == tint ? AppText.current : AppText.tint, font: .caption2.weight(.semibold), foreground: app.settings.accentTint == tint ? .accentColor : .secondary) } subtitleAccessory: { @@ -231,21 +231,21 @@ struct PaletteResultCard: View { } private func containerPaletteWidget(_ snapshot: ContainerSnapshot) -> some View { - ResourceCardWidgetGroup { - ResourceCardFooterMini { + DesignCardWidgetGroup { + DesignCardFooterMini { Image(systemName: "clock").font(.caption2) } text: { - ResourceCardMetricText(text: Format.uptime(since: snapshot.startedDate)) + DesignCardMetricText(text: Format.uptime(since: snapshot.startedDate)) } - ResourceCardFooterMini { + DesignCardFooterMini { Image(systemName: "network").font(.caption2) } text: { - ResourceCardMetricText(text: "\(snapshot.status.networks.count)") + DesignCardMetricText(text: "\(snapshot.status.networks.count)") } - ResourceCardFooterMini { + DesignCardFooterMini { Image(systemName: "shippingbox").font(.caption2) } text: { - ResourceCardMetricText(text: Format.shortImage(snapshot.image)) + DesignCardMetricText(text: Format.shortImage(snapshot.image)) } } } @@ -258,10 +258,10 @@ struct PaletteResultCard: View { Image(systemName: "return") .font(.caption.weight(.semibold)) .foregroundStyle(.tertiary) - .frame(width: Tokens.IconSize.chip, height: Tokens.IconSize.chip) + .frame(width: DesignTokens.IconSize.chip, height: DesignTokens.IconSize.chip) } else { GlassListRowChevron() - .frame(width: Tokens.IconSize.chip, height: Tokens.IconSize.chip) + .frame(width: DesignTokens.IconSize.chip, height: DesignTokens.IconSize.chip) } case .toggle(let isOn, let set): Toggle("", isOn: Binding { @@ -294,12 +294,12 @@ struct PaletteResultCard: View { } private func containerStatus(_ snapshot: ContainerSnapshot) -> some View { - ResourceCardFooterMini { + DesignCardFooterMini { Image(systemName: snapshot.state == .running ? "circle.fill" : "circle") .font(.caption2) .foregroundStyle(snapshot.state == .running ? .green : .secondary) } text: { - ResourceCardMetricText(text: snapshot.state.rawValue.capitalized) + DesignCardMetricText(text: snapshot.state.rawValue.capitalized) .foregroundStyle(.secondary) } } diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift similarity index 92% rename from Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift rename to Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift index cca2d2b8..3a179a7b 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift @@ -74,14 +74,14 @@ struct ToolbarCommandPalette: View { private var hubSearchKey: String { "\(scope == .dockerHub)|\(trimmedQuery)" } var body: some View { - MorphPanelScaffold(width: Tokens.PanelSize.palette.width, scrolls: false) { + DesignPanelScaffold(width: DesignTokens.PanelSize.palette.width, scrolls: false) { VStack(spacing: 0) { - VStack(spacing: Tokens.Space.xs) { + VStack(spacing: DesignTokens.Space.xs) { fieldRow - .frame(height: Tokens.Toolbar.searchOpenHeaderHeight) + .frame(height: DesignTokens.Toolbar.searchOpenHeaderHeight) inlineSearchRow } - .padding(.bottom, Tokens.Space.s) + .padding(.bottom, DesignTokens.Space.s) Divider() } } content: { @@ -89,7 +89,7 @@ struct ToolbarCommandPalette: View { } footer: { footerBar } - .morphPanelSize(Tokens.PanelSize.palette) + .morphPanelSize(DesignTokens.PanelSize.palette) .morphPanelPlacement(.anchored) .onAppear { ui.paletteIndex = 0 @@ -103,7 +103,7 @@ struct ToolbarCommandPalette: View { private var fieldRow: some View { @Bindable var ui = ui - return HStack(spacing: Tokens.Toolbar.searchIconGap) { + return HStack(spacing: DesignTokens.Toolbar.searchIconGap) { Image(systemName: scope?.symbol ?? "magnifyingglass") .font(.body) .foregroundStyle(scope == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.accentColor)) @@ -132,7 +132,7 @@ struct ToolbarCommandPalette: View { Text("esc").font(.caption2).fontWeight(.medium).foregroundStyle(.tertiary) } } - .padding(.horizontal, Tokens.Space.l) + .padding(.horizontal, DesignTokens.Space.l) } /// The pinned scope token shown in the search field. @@ -147,9 +147,9 @@ struct ToolbarCommandPalette: View { } private var inlineSearchRow: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: DesignTokens.Space.s) { if let scope { - ResourceBadgeText(text: scopeCountText(scope)) + DesignBadgeText(text: scopeCountText(scope)) Spacer() Button { ui.paletteScope = nil } label: { Label("Commands", systemImage: "command") @@ -160,8 +160,8 @@ struct ToolbarCommandPalette: View { .foregroundStyle(.secondary) .help("Back to commands") } else { - ResourceBadgeText(text: "\(flatItems.count) match\(flatItems.count == 1 ? "" : "es")") - ResourceBadgeText(text: "\(localImageMatches) local image\(localImageMatches == 1 ? "" : "s")") + DesignBadgeText(text: "\(flatItems.count) match\(flatItems.count == 1 ? "" : "es")") + DesignBadgeText(text: "\(localImageMatches) local image\(localImageMatches == 1 ? "" : "s")") Spacer() if !trimmedQuery.isEmpty { // "Hit search on a search entry" — pins the Docker Hub scope and keeps the typed @@ -177,7 +177,7 @@ struct ToolbarCommandPalette: View { } } } - .padding(.horizontal, Tokens.Space.l) + .padding(.horizontal, DesignTokens.Space.l) } private func scopeCountText(_ scope: PaletteScope) -> String { @@ -197,7 +197,7 @@ struct ToolbarCommandPalette: View { private var resultsList: some View { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { let indexed = indexedSections // one evaluation → stable positional indices if indexed.isEmpty { emptyState @@ -216,7 +216,7 @@ struct ToolbarCommandPalette: View { } } } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } .onChange(of: ui.paletteIndex) { _, new in proxy.scrollTo(new, anchor: .center) } } @@ -226,8 +226,8 @@ struct ToolbarCommandPalette: View { Text(title.uppercased()) .font(.caption2.weight(.semibold)) .foregroundStyle(.tertiary) - .padding(.horizontal, Tokens.Space.xs) - .padding(.top, Tokens.Space.xs) + .padding(.horizontal, DesignTokens.Space.xs) + .padding(.top, DesignTokens.Space.xs) .frame(maxWidth: .infinity, alignment: .leading) } @@ -248,7 +248,7 @@ struct ToolbarCommandPalette: View { private var dockerHubPlaceholder: some View { DesignContentSurface(minHeight: 260) { - LazyVStack(spacing: Tokens.Space.s) { + LazyVStack(spacing: DesignTokens.Space.s) { if hubSearching { ProgressView() Text("Searching Docker Hub…").font(.callout).foregroundStyle(.secondary) @@ -266,17 +266,17 @@ struct ToolbarCommandPalette: View { } private var footerBar: some View { - HStack(spacing: Tokens.Space.m) { + HStack(spacing: DesignTokens.Space.m) { keyboardHint("↑↓", "Select") keyboardHint("return", "Run") keyboardHint("esc", scope == nil ? "Close" : "Clear scope") Spacer() if let selected = selectedItem { - ResourceBadgeText(text: selected.kind.rawValue) + DesignBadgeText(text: selected.kind.rawValue) } } - .padding(.horizontal, Tokens.Space.l) - .padding(.vertical, Tokens.Space.s) + .padding(.horizontal, DesignTokens.Space.l) + .padding(.vertical, DesignTokens.Space.s) } private var selectedItem: PaletteItem? { diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift similarity index 92% rename from Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift rename to Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 61ce44eb..6a82abfd 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -85,7 +85,7 @@ struct ToolbarImageGroupCard: View { let image = primaryImage(group) let status = app.imageUpdateStatus(for: group.primaryReference) let resolved = app.imageGroupStyle(for: group) - return ResourceCard(size: .medium, + return DesignCard(size: .medium, isExpanded: isExpanded, fill: resolved.fillBackground ? resolved.color : nil, fillOpacity: resolved.backgroundOpacity, @@ -153,10 +153,10 @@ struct ToolbarImageGroupCard: View { description: Text("This image records no layer history.")) .frame(maxWidth: .infinity, minHeight: 220) } else { - ResourceCardInsetSection { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + DesignCardInsetSection { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { ForEach(Array(history.enumerated()), id: \.offset) { _, entry in - LazyVStack(alignment: .leading, spacing: Tokens.Space.xxs) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.xxs) { Text(entry.createdBy ?? entry.comment ?? "—") .font(.system(.caption, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) @@ -165,7 +165,7 @@ struct ToolbarImageGroupCard: View { .font(.caption2).foregroundStyle(.secondary) } } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) .frame(maxWidth: .infinity, alignment: .leading) .subtleTileBackground() } @@ -177,7 +177,7 @@ struct ToolbarImageGroupCard: View { private func tagPage(_ source: String) -> some View { imagePageBody(title: "Add tag", subtitle: Format.shortImage(source)) { - ResourceCardInsetSection { + DesignCardInsetSection { PanelField(label: "Source") { Text(Format.shortImage(source)).foregroundStyle(.secondary) } @@ -220,17 +220,17 @@ struct ToolbarImageGroupCard: View { private func imagePageBody(title: String, subtitle: String?, @ViewBuilder content: @escaping () -> C) -> some View { ScrollView { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { - VStack(alignment: .leading, spacing: Tokens.ResourceCard.compactTextSpacing) { - ResourceCardTitleText(text: title) + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { + VStack(alignment: .leading, spacing: DesignTokens.DesignCard.compactTextSpacing) { + DesignCardTitleText(text: title) if let subtitle { - ResourceCardMonospacedSubtitleText(text: subtitle) + DesignCardMonospacedSubtitleText(text: subtitle) } } - .padding(.horizontal, Tokens.Space.s) + .padding(.horizontal, DesignTokens.Space.s) content() } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) .frame(maxWidth: .infinity, alignment: .leading) } .scrollEdgeEffectStyle(.soft, for: .all) @@ -238,13 +238,13 @@ struct ToolbarImageGroupCard: View { private func pushReadiness(_ reference: String) -> some View { let readiness = pushState(for: reference) - return ResourceCardInsetSection { - HStack(alignment: .top, spacing: Tokens.Space.s) { + return DesignCardInsetSection { + HStack(alignment: .top, spacing: DesignTokens.Space.s) { Image(systemName: readiness.symbol) .font(.title3) .foregroundStyle(readiness.tint) - .frame(width: Tokens.IconSize.rowIconColumn) - VStack(alignment: .leading, spacing: Tokens.Space.xs) { + .frame(width: DesignTokens.IconSize.rowIconColumn) + VStack(alignment: .leading, spacing: DesignTokens.Space.xs) { Text(readiness.title) .font(.headline) Text(readiness.message) @@ -256,7 +256,7 @@ struct ToolbarImageGroupCard: View { .foregroundStyle(.secondary) } } - Spacer(minLength: Tokens.Space.s) + Spacer(minLength: DesignTokens.Space.s) } HStack { Spacer() @@ -357,26 +357,26 @@ struct ToolbarImageGroupCard: View { } } - private var imagePageControlItems: [ResourceCardPageControlItem] { + private var imagePageControlItems: [DesignCardPageControlItem] { let reference = primaryImage(group)?.reference ?? group.primaryReference return [ - ResourceCardPageControlItem(id: .tags, + DesignCardPageControlItem(id: .tags, title: "Tags", systemImage: "tag"), - ResourceCardPageControlItem(id: .history(reference), + DesignCardPageControlItem(id: .history(reference), title: "History", systemImage: "clock.arrow.circlepath"), - ResourceCardPageControlItem(id: .tag(reference), + DesignCardPageControlItem(id: .tag(reference), title: "Add Tag", systemImage: "plus.circle"), - ResourceCardPageControlItem(id: .push(reference), + DesignCardPageControlItem(id: .push(reference), title: "Push", systemImage: "arrow.up.circle") ] } - private var imagePages: ResourceCardPages { - ResourceCardPages(items: imagePageControlItems, + private var imagePages: DesignCardPages { + DesignCardPages(items: imagePageControlItems, selection: page, tint: resolvedImageTint, controlsReveal: isExpanded ? 1 : 0, @@ -402,22 +402,22 @@ struct ToolbarImageGroupCard: View { } private func imageFooterInfo(_ status: ImageUpdateStatus) -> some View { - ResourceCardFooterMini { + DesignCardFooterMini { Image(systemName: updateSymbol(status.state)) .font(.caption) .foregroundStyle(updateTint(status.state)) } text: { - ResourceCardSubtitleText(text: updateFooterText(status)) + DesignCardSubtitleText(text: updateFooterText(status)) } } private func imageFooterTagCount(_ group: LocalImageTagGroup) -> some View { - ResourceCardFooterMini { + DesignCardFooterMini { Image(systemName: "tag") .font(.caption) .foregroundStyle(.secondary) } text: { - ResourceCardMetricText(text: "\(group.references.count)") + DesignCardMetricText(text: "\(group.references.count)") } } @@ -442,18 +442,18 @@ struct ToolbarImageGroupCard: View { } private func tagList(_ group: LocalImageTagGroup) -> some View { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { Text("Tags") .font(.headline) - .padding(.leading, Tokens.Space.xs) + .padding(.leading, DesignTokens.Space.xs) ScrollView(.vertical) { - LazyVStack(spacing: Tokens.Space.s) { + LazyVStack(spacing: DesignTokens.Space.s) { ForEach(group.references, id: \.self) { reference in tagRow(reference, in: group) .frame(maxWidth: .infinity) } } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } .scrollEdgeEffectStyle(.soft, for: .all) } @@ -461,7 +461,7 @@ struct ToolbarImageGroupCard: View { private func tagRow(_ reference: String, in group: LocalImageTagGroup) -> some View { let style = app.imageStyle(for: reference) - return ResourceCard(size: .medium, + return DesignCard(size: .medium, fill: style.fillBackground ? style.color : nil, fillOpacity: style.backgroundOpacity, gradient: style.gradient, @@ -483,10 +483,10 @@ struct ToolbarImageGroupCard: View { } bodyContent: { EmptyView() } footerLeading: { - ResourceCardFooterMini { + DesignCardFooterMini { Image(systemName: "tag").font(.caption2) } text: { - ResourceCardMetricText(text: "Local tag") + DesignCardMetricText(text: "Local tag") } } footerActions: { footerAction("play", help: AppText.run) { @@ -514,7 +514,7 @@ struct ToolbarImageGroupCard: View { private func footerAction(_ systemName: String, help: String, tint: Color? = nil, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { - ResourceCardFooterButton(systemName: systemName, + DesignCardFooterButton(systemName: systemName, help: help, tint: tint, role: role, @@ -552,7 +552,7 @@ struct ToolbarImageGroupCard: View { } private func imageChip(_ style: Personalization) -> some View { - ResourceCardIconChip(symbol: style.symbol, tint: style.color) + DesignCardIconChip(symbol: style.symbol, tint: style.color) } private func updateSymbol(_ state: ImageUpdateState) -> String { diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift similarity index 87% rename from Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift rename to Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift index 946a50ba..22b1bfc2 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift @@ -9,7 +9,7 @@ struct ToolbarActivityPanel: View { var onClose: () -> Void var body: some View { - // Size + placement are reported by the MorphPanelScaffold inside ActivityContent (it hugs its + // Size + placement are reported by the DesignPanelScaffold inside ActivityContent (it hugs its // content height), so no fixed morphPanelSize here. ActivityContent(showClose: true, elevated: false, onClose: onClose) } @@ -22,7 +22,7 @@ struct ToolbarSystemPanel: View { var onClose: () -> Void var body: some View { - // Size + placement are reported by the MorphPanelScaffold inside SystemContent (it hugs its + // Size + placement are reported by the DesignPanelScaffold inside SystemContent (it hugs its // content height), so no fixed morphPanelSize here. SystemContent(elevated: false, onClose: onClose) } @@ -35,7 +35,7 @@ struct ToolbarSettingsPanel: View { var onClose: () -> Void var body: some View { - // Size + placement are reported by the MorphPanelScaffold inside SettingsContent (it hugs its + // Size + placement are reported by the DesignPanelScaffold inside SettingsContent (it hugs its // content height and centers), so no fixed morphPanelSize here. SettingsContent(onClose: onClose) } @@ -86,7 +86,7 @@ struct ToolbarTemplatesPanel: View { } var body: some View { - MorphPanelScaffold(width: Tokens.PanelSize.templates.width) { + DesignPanelScaffold(width: DesignTokens.PanelSize.templates.width) { if showsHeader { VStack(alignment: .leading, spacing: 0) { header @@ -94,20 +94,20 @@ struct ToolbarTemplatesPanel: View { } } } content: { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { if sortedTemplates.isEmpty { emptyCard } else { ForEach(Array(templateSections.enumerated()), id: \.offset) { _, section in if ui.templateGrouping != .none { - ResourceBadgeText(text: section.title, font: .caption.weight(.semibold)) - .padding(.horizontal, Tokens.Space.xs) + DesignBadgeText(text: section.title, font: .caption.weight(.semibold)) + .padding(.horizontal, DesignTokens.Space.xs) } ForEach(section.templates) { template in templateCard(template) } } } } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } } @@ -125,13 +125,13 @@ struct ToolbarTemplatesPanel: View { } private var emptyCard: some View { - ResourceCard(size: .small, + DesignCard(size: .small, elevated: false, title: "No templates", subtitle: "Save a container's settings as a template from the create form.") { - ResourceCardIconChip(symbol: "bookmark", + DesignCardIconChip(symbol: "bookmark", tint: .secondary, - backgroundOpacity: Tokens.ResourceCard.iconEmphasisBackgroundOpacity) + backgroundOpacity: DesignTokens.DesignCard.iconEmphasisBackgroundOpacity) } titleAccessory: { EmptyView() } subtitleAccessory: { @@ -150,13 +150,13 @@ struct ToolbarTemplatesPanel: View { } private func templateCard(_ template: Template) -> some View { - ResourceCard(size: .medium, + DesignCard(size: .medium, elevated: false, onTap: { use(template) }, title: template.name, subtitle: Format.shortImage(template.spec?.image ?? "—"), subtitleStyle: .monospaced) { - ResourceCardIconChip(symbol: "bookmark.fill", tint: .accentColor) + DesignCardIconChip(symbol: "bookmark.fill", tint: .accentColor) } titleAccessory: { EmptyView() } subtitleAccessory: { @@ -168,14 +168,14 @@ struct ToolbarTemplatesPanel: View { } bodyContent: { EmptyView() } footerLeading: { - ResourceCardFooterMini { + DesignCardFooterMini { Image(systemName: "bookmark").font(.caption2) } text: { - ResourceCardMetricText(text: "Saved run configuration") + DesignCardMetricText(text: "Saved run configuration") } } footerActions: { Button(role: .destructive) { delete(template) } label: { - ResourceCardFooterMini { + DesignCardFooterMini { Image(systemName: "trash").font(.body) } text: { EmptyView() diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarSearchSource.swift similarity index 100% rename from Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift rename to Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarSearchSource.swift diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift similarity index 94% rename from Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift rename to Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift index 51a3eb5d..672638d6 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift @@ -45,7 +45,7 @@ struct ToolbarUpdatesPanel: View { } var body: some View { - MorphPanelScaffold(width: Tokens.PanelSize.images.width) { + DesignPanelScaffold(width: DesignTokens.PanelSize.images.width) { if showsHeader { VStack(alignment: .leading, spacing: 0) { header @@ -53,14 +53,14 @@ struct ToolbarUpdatesPanel: View { } } } content: { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { if imageGroups.isEmpty { emptyCard } else { ForEach(Array(imageSections.enumerated()), id: \.offset) { _, section in if ui.imageGrouping != .none { - ResourceBadgeText(text: section.title, font: .caption.weight(.semibold)) - .padding(.horizontal, Tokens.Space.xs) + DesignBadgeText(text: section.title, font: .caption.weight(.semibold)) + .padding(.horizontal, DesignTokens.Space.xs) } ForEach(section.groups) { group in imageRow(group) @@ -68,7 +68,7 @@ struct ToolbarUpdatesPanel: View { } } } - .padding(Tokens.Space.s) + .padding(DesignTokens.Space.s) } .task { await app.refreshImagesIfStale() } } @@ -102,11 +102,11 @@ struct ToolbarUpdatesPanel: View { } private var emptyCard: some View { - ResourceCard(size: .small, + DesignCard(size: .small, elevated: false, title: "No images", subtitle: "Pull or build an image to see it here") { - ResourceCardIconChip(symbol: "checkmark.circle.fill", tint: .green) + DesignCardIconChip(symbol: "checkmark.circle.fill", tint: .green) } titleAccessory: { EmptyView() } subtitleAccessory: { diff --git a/Sources/Contained/Navigation/ToolbarViewOptions.swift b/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift similarity index 99% rename from Sources/Contained/Navigation/ToolbarViewOptions.swift rename to Sources/ContainedApp/Navigation/ToolbarViewOptions.swift index 1128fda0..b43b404c 100644 --- a/Sources/Contained/Navigation/ToolbarViewOptions.swift +++ b/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift @@ -163,7 +163,7 @@ struct ToolbarPageContextOptions: View { ]) .help("\(app.volumes.count) volume\(app.volumes.count == 1 ? "" : "s")") case .system: - HStack(spacing: Tokens.Toolbar.groupSpacing) { + HStack(spacing: DesignTokens.Toolbar.groupSpacing) { DesignActionGroup(serviceActions) DesignActionGroup(systemPageActions + [ DesignAction(systemName: "text.alignleft", help: AppText.systemLogs) { diff --git a/Sources/Contained/Navigation/UIState.swift b/Sources/ContainedApp/Navigation/UIState.swift similarity index 100% rename from Sources/Contained/Navigation/UIState.swift rename to Sources/ContainedApp/Navigation/UIState.swift diff --git a/Sources/ContainedApp/Presentation/GraphMetricPresentation.swift b/Sources/ContainedApp/Presentation/GraphMetricPresentation.swift new file mode 100644 index 00000000..b01736fd --- /dev/null +++ b/Sources/ContainedApp/Presentation/GraphMetricPresentation.swift @@ -0,0 +1,75 @@ +import ContainedCore + +extension GraphMetric { + var displayName: String { + switch self { + case .cpu: return AppText.string("graphMetric.cpu", defaultValue: "CPU") + case .memory: return AppText.string("graphMetric.memory", defaultValue: "Memory") + case .netRx: return AppText.string("graphMetric.netRx", defaultValue: "Net In") + case .netTx: return AppText.string("graphMetric.netTx", defaultValue: "Net Out") + case .diskRead: return AppText.string("graphMetric.diskRead", defaultValue: "Disk Read") + case .diskWrite: return AppText.string("graphMetric.diskWrite", defaultValue: "Disk Write") + } + } + + var systemImage: String { + switch self { + case .cpu: return "cpu" + case .memory: return "memorychip" + case .netRx: return "arrow.down.circle" + case .netTx: return "arrow.up.circle" + case .diskRead: return "arrow.down.doc" + case .diskWrite: return "arrow.up.doc" + } + } + + func chipCaption(from delta: StatsDelta, + snapshot: ContainerSnapshot? = nil, + normalization: StatsNormalizationContext = .containerSpecific) -> String { + switch self { + case .cpu, .memory: + return Format.compactPercent(value(from: delta, snapshot: snapshot, normalization: normalization)) + case .netRx: return Format.compactRate(delta.netRxBytesPerSec) + case .netTx: return Format.compactRate(delta.netTxBytesPerSec) + case .diskRead: return Format.compactRate(delta.blockReadBytesPerSec) + case .diskWrite: return Format.compactRate(delta.blockWriteBytesPerSec) + } + } + + func caption(from delta: StatsDelta, + snapshot: ContainerSnapshot? = nil, + normalization: StatsNormalizationContext = .containerSpecific) -> String { + switch self { + case .cpu, .memory: + return Format.compactPercent(value(from: delta, snapshot: snapshot, normalization: normalization)) + case .netRx: return Format.rate(delta.netRxBytesPerSec) + case .netTx: return Format.rate(delta.netTxBytesPerSec) + case .diskRead: return Format.rate(delta.blockReadBytesPerSec) + case .diskWrite: return Format.rate(delta.blockWriteBytesPerSec) + } + } +} + +extension StatsNormalizationMode { + var displayName: String { + switch self { + case .container: return AppText.string("statsNormalization.container", defaultValue: "Container") + case .machine: return AppText.string("statsNormalization.machine", defaultValue: "Machine") + } + } + + var footnote: String { + switch self { + case .container: + return AppText.string( + "statsNormalization.container.footnote", + defaultValue: "CPU and memory are scaled against each container's own configured limits." + ) + case .machine: + return AppText.string( + "statsNormalization.machine.footnote", + defaultValue: "CPU and memory are scaled against Apple container's machine CPU and memory resources." + ) + } + } +} diff --git a/Sources/Contained/DesignSystem/StatusPresentation.swift b/Sources/ContainedApp/Presentation/StatusPresentation.swift similarity index 66% rename from Sources/Contained/DesignSystem/StatusPresentation.swift rename to Sources/ContainedApp/Presentation/StatusPresentation.swift index 92002220..680f1e3c 100644 --- a/Sources/Contained/DesignSystem/StatusPresentation.swift +++ b/Sources/ContainedApp/Presentation/StatusPresentation.swift @@ -27,11 +27,11 @@ enum StatusPresentation: Sendable, Equatable { var label: String { switch self { - case .running: return "Running" - case .stopped: return "Stopped" - case .stopping: return "Stopping" - case .unknown: return "Unknown" - case .errored: return "Errored" + case .running: return AppText.string("status.running", defaultValue: "Running") + case .stopped: return AppText.string("status.stopped", defaultValue: "Stopped") + case .stopping: return AppText.string("status.stopping", defaultValue: "Stopping") + case .unknown: return AppText.string("status.unknown", defaultValue: "Unknown") + case .errored: return AppText.string("status.errored", defaultValue: "Errored") } } diff --git a/Sources/ContainedApp/Previews/AppPreviews.swift b/Sources/ContainedApp/Previews/AppPreviews.swift new file mode 100644 index 00000000..40a8dfdc --- /dev/null +++ b/Sources/ContainedApp/Previews/AppPreviews.swift @@ -0,0 +1,55 @@ +import SwiftUI +import ContainedCore +import ContainedDesignSystem +import ContainedPreviewSupport + +#Preview("Container Card") { + ContainerCard(snapshot: PreviewSamples.webContainer, + style: .previewContainer, + density: .large, + stats: PreviewSamples.stats, + histories: [.cpu: PreviewSamples.sparklineBuffer], + isBusy: false, + isExpanded: true, + onTap: {}, + onStart: {}, + onStop: {}, + onRestart: {}, + onDelete: {}) + .padding(DesignTokens.Space.xl) + .frame(width: 520) + .environment(\.cardMaterial, .glassRegular) + .environment(\.buttonMaterial, .glassClear) +} + +#Preview("General Settings") { + GeneralTab(settings: SettingsStore()) + .padding(DesignTokens.Space.xl) + .frame(width: 560) + .environment(AppModel()) + .environment(\.buttonMaterial, .glassClear) + .environment(\.cardMaterial, .glassRegular) +} + +private extension PreviewSamples { + static var sparklineBuffer: SampleBuffer { + var buffer = SampleBuffer() + for value in sparklineValues { + buffer.append(value) + } + return buffer + } +} + +private extension Personalization { + static var previewContainer: Personalization { + var style = Personalization() + style.nickname = "Preview Web" + style.icon = "shippingbox.fill" + style.tint = .azure + style.fillBackground = true + style.backgroundOpacity = 0.16 + style.widgets = WidgetConfiguration.defaultWidgets() + return style + } +} diff --git a/Sources/Contained/Resources/CHANGELOG.md b/Sources/ContainedApp/Resources/CHANGELOG.md similarity index 97% rename from Sources/Contained/Resources/CHANGELOG.md rename to Sources/ContainedApp/Resources/CHANGELOG.md index 7ebe7688..e9a18e19 100644 --- a/Sources/Contained/Resources/CHANGELOG.md +++ b/Sources/ContainedApp/Resources/CHANGELOG.md @@ -68,7 +68,7 @@ #### Personalization & Design System - Local-only personalization for containers, image groups, image tags, and volumes, including nickname, icon, tint, background, graph/widget choices, and inheritance from image or app defaults. -- Shared Liquid Glass design system primitives: `ResourceGlassCard`, `GlassSurface`, `GlassButton`, `GlassOptionTile`, `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. +- Shared Liquid Glass design system primitives: `DesignCardSurface`, `GlassSurface`, `GlassButton`, `GlassOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. - Accessibility-aware custom visual effects and motion handling, including Reduce Transparency and Reduce Motion support where the app supplies custom glass or animation. - Shared page and panel scaffolds so sidebar pages, sheets, and toolbar morphs can reuse content without duplicate layouts. diff --git a/Sources/Contained/Resources/Localizable.xcstrings b/Sources/ContainedApp/Resources/Localizable.xcstrings similarity index 99% rename from Sources/Contained/Resources/Localizable.xcstrings rename to Sources/ContainedApp/Resources/Localizable.xcstrings index 0174206a..7003565e 100644 --- a/Sources/Contained/Resources/Localizable.xcstrings +++ b/Sources/ContainedApp/Resources/Localizable.xcstrings @@ -124,7 +124,7 @@ "network.newNetwork.lowercase": {}, "network.refreshNetworks": {}, "palette.removeScopeAccessibilityLabel": {}, - "palette.setAppTint": {}, + "palette.setDesignTint": {}, "restartPolicy.always": {}, "restartPolicy.no": {}, "restartPolicy.onFailure": {}, diff --git a/Sources/Contained/Stores/AppModel+Configuration.swift b/Sources/ContainedApp/Stores/AppModel+Configuration.swift similarity index 100% rename from Sources/Contained/Stores/AppModel+Configuration.swift rename to Sources/ContainedApp/Stores/AppModel+Configuration.swift diff --git a/Sources/Contained/Stores/AppModel+ImageUpdates.swift b/Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift similarity index 100% rename from Sources/Contained/Stores/AppModel+ImageUpdates.swift rename to Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift diff --git a/Sources/Contained/Stores/AppModel+ResourceStyles.swift b/Sources/ContainedApp/Stores/AppModel+ResourceStyles.swift similarity index 100% rename from Sources/Contained/Stores/AppModel+ResourceStyles.swift rename to Sources/ContainedApp/Stores/AppModel+ResourceStyles.swift diff --git a/Sources/Contained/Stores/AppModel.swift b/Sources/ContainedApp/Stores/AppModel.swift similarity index 100% rename from Sources/Contained/Stores/AppModel.swift rename to Sources/ContainedApp/Stores/AppModel.swift diff --git a/Sources/Contained/Stores/ContainersStore.swift b/Sources/ContainedApp/Stores/ContainersStore.swift similarity index 100% rename from Sources/Contained/Stores/ContainersStore.swift rename to Sources/ContainedApp/Stores/ContainersStore.swift diff --git a/Sources/Contained/Stores/RefreshCoordinator.swift b/Sources/ContainedApp/Stores/RefreshCoordinator.swift similarity index 100% rename from Sources/Contained/Stores/RefreshCoordinator.swift rename to Sources/ContainedApp/Stores/RefreshCoordinator.swift diff --git a/Sources/Contained/Stores/RestartWatchdog.swift b/Sources/ContainedApp/Stores/RestartWatchdog.swift similarity index 100% rename from Sources/Contained/Stores/RestartWatchdog.swift rename to Sources/ContainedApp/Stores/RestartWatchdog.swift diff --git a/Sources/Contained/Stores/SettingsBackup.swift b/Sources/ContainedApp/Stores/SettingsBackup.swift similarity index 97% rename from Sources/Contained/Stores/SettingsBackup.swift rename to Sources/ContainedApp/Stores/SettingsBackup.swift index 88299741..4c8b11c5 100644 --- a/Sources/Contained/Stores/SettingsBackup.swift +++ b/Sources/ContainedApp/Stores/SettingsBackup.swift @@ -1,16 +1,17 @@ import Foundation import ContainedDesignSystem +import ContainedCore /// Portable snapshot of user preferences for config export/import. struct SettingsBackup: Codable, Equatable { - var accentTint: AppTint + var accentTint: DesignTint var appearance: AppearanceMode var density: CardDensity var windowMaterial: WindowMaterial var modalMaterial: WindowMaterial var buttonMaterial: WindowMaterial var buttonTintEnabled: Bool - var buttonTint: AppTint + var buttonTint: DesignTint var buttonTintOpacity: Double var buttonTintGradient: Bool var buttonTintGradientAngle: Double @@ -55,14 +56,14 @@ struct SettingsBackup: Codable, Equatable { case experimentalPanelNavigation, sidebarNavigationEnabled } - init(accentTint: AppTint, + init(accentTint: DesignTint, appearance: AppearanceMode, density: CardDensity, windowMaterial: WindowMaterial, modalMaterial: WindowMaterial, buttonMaterial: WindowMaterial, buttonTintEnabled: Bool = false, - buttonTint: AppTint = .multicolor, + buttonTint: DesignTint = .multicolor, buttonTintOpacity: Double = 0.18, buttonTintGradient: Bool = true, buttonTintGradientAngle: Double = Personalization.defaultGradientAngle, @@ -135,14 +136,14 @@ struct SettingsBackup: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - accentTint = try container.decode(AppTint.self, forKey: .accentTint) + accentTint = try container.decode(DesignTint.self, forKey: .accentTint) appearance = try container.decode(AppearanceMode.self, forKey: .appearance) density = try container.decode(CardDensity.self, forKey: .density) windowMaterial = try container.decode(WindowMaterial.self, forKey: .windowMaterial) modalMaterial = try container.decode(WindowMaterial.self, forKey: .modalMaterial) buttonMaterial = try container.decodeIfPresent(WindowMaterial.self, forKey: .buttonMaterial) ?? .glassClear buttonTintEnabled = try container.decodeIfPresent(Bool.self, forKey: .buttonTintEnabled) ?? false - buttonTint = try container.decodeIfPresent(AppTint.self, forKey: .buttonTint) ?? .multicolor + buttonTint = try container.decodeIfPresent(DesignTint.self, forKey: .buttonTint) ?? .multicolor buttonTintOpacity = try container.decodeIfPresent(Double.self, forKey: .buttonTintOpacity) ?? 0.18 buttonTintGradient = try container.decodeIfPresent(Bool.self, forKey: .buttonTintGradient) ?? true buttonTintGradientAngle = try container.decodeIfPresent(Double.self, forKey: .buttonTintGradientAngle) diff --git a/Sources/Contained/Stores/SettingsStore.swift b/Sources/ContainedApp/Stores/SettingsStore.swift similarity index 97% rename from Sources/Contained/Stores/SettingsStore.swift rename to Sources/ContainedApp/Stores/SettingsStore.swift index 8f05a5a9..0b24a8f8 100644 --- a/Sources/Contained/Stores/SettingsStore.swift +++ b/Sources/ContainedApp/Stores/SettingsStore.swift @@ -7,7 +7,7 @@ import ContainedCore @MainActor @Observable final class SettingsStore { - var accentTint: AppTint { didSet { defaults.set(accentTint.rawValue, forKey: Keys.tint) } } + var accentTint: DesignTint { didSet { defaults.set(accentTint.rawValue, forKey: Keys.tint) } } var appearance: AppearanceMode { didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) } } var density: CardDensity { didSet { defaults.set(density.rawValue, forKey: Keys.density) } } /// Behind-window vibrancy material for the main content area. @@ -18,12 +18,12 @@ final class SettingsStore { var buttonMaterial: WindowMaterial { didSet { defaults.set(buttonMaterial.rawValue, forKey: Keys.buttonMaterial) } } /// Optional color wash applied inside toolbar glass buttons. var buttonTintEnabled: Bool { didSet { defaults.set(buttonTintEnabled, forKey: Keys.buttonTintEnabled) } } - var buttonTint: AppTint { didSet { defaults.set(buttonTint.rawValue, forKey: Keys.buttonTint) } } + var buttonTint: DesignTint { didSet { defaults.set(buttonTint.rawValue, forKey: Keys.buttonTint) } } var buttonTintOpacity: Double { didSet { defaults.set(buttonTintOpacity, forKey: Keys.buttonTintOpacity) } } var buttonTintGradient: Bool { didSet { defaults.set(buttonTintGradient, forKey: Keys.buttonTintGradient) } } var buttonTintGradientAngle: Double { didSet { defaults.set(buttonTintGradientAngle, forKey: Keys.buttonTintGradientAngle) } } var buttonTintBlendMode: ColorLayerBlendMode { didSet { defaults.set(buttonTintBlendMode.rawValue, forKey: Keys.buttonTintBlendMode) } } - /// Material for resource cards, both compact and expanded. + /// Material for cards, both compact and expanded. var cardMaterial: WindowMaterial { didSet { defaults.set(cardMaterial.rawValue, forKey: Keys.cardMaterial) } } /// Show the info.circle help popovers throughout the app. var showInfoTips: Bool { didSet { defaults.set(showInfoTips, forKey: Keys.showInfoTips) } } @@ -102,14 +102,14 @@ final class SettingsStore { init(defaults: UserDefaults = .standard) { self.defaults = defaults - accentTint = AppTint(rawValue: defaults.string(forKey: Keys.tint) ?? "") ?? .multicolor + accentTint = DesignTint(rawValue: defaults.string(forKey: Keys.tint) ?? "") ?? .multicolor appearance = AppearanceMode(rawValue: defaults.string(forKey: Keys.appearance) ?? "") ?? .system density = CardDensity(stored: defaults.string(forKey: Keys.density)) windowMaterial = WindowMaterial(rawValue: defaults.string(forKey: Keys.windowMaterial) ?? "") ?? .fullScreenUI modalMaterial = WindowMaterial(rawValue: defaults.string(forKey: Keys.modalMaterial) ?? "") ?? .sheet buttonMaterial = WindowMaterial(rawValue: defaults.string(forKey: Keys.buttonMaterial) ?? "") ?? .glassClear buttonTintEnabled = defaults.object(forKey: Keys.buttonTintEnabled) as? Bool ?? false - buttonTint = AppTint(rawValue: defaults.string(forKey: Keys.buttonTint) ?? "") ?? .multicolor + buttonTint = DesignTint(rawValue: defaults.string(forKey: Keys.buttonTint) ?? "") ?? .multicolor buttonTintOpacity = defaults.object(forKey: Keys.buttonTintOpacity) as? Double ?? 0.18 buttonTintGradient = defaults.object(forKey: Keys.buttonTintGradient) as? Bool ?? true buttonTintGradientAngle = defaults.object(forKey: Keys.buttonTintGradientAngle) as? Double ?? Personalization.defaultGradientAngle diff --git a/Sources/Contained/Support/AppErrorPresentation.swift b/Sources/ContainedApp/Support/AppErrorPresentation.swift similarity index 100% rename from Sources/Contained/Support/AppErrorPresentation.swift rename to Sources/ContainedApp/Support/AppErrorPresentation.swift diff --git a/Sources/Contained/Support/AppLocalization.swift b/Sources/ContainedApp/Support/AppLocalization.swift similarity index 99% rename from Sources/Contained/Support/AppLocalization.swift rename to Sources/ContainedApp/Support/AppLocalization.swift index 4047d518..be17f178 100644 --- a/Sources/Contained/Support/AppLocalization.swift +++ b/Sources/ContainedApp/Support/AppLocalization.swift @@ -120,12 +120,12 @@ enum AppText { string("palette.removeScopeAccessibilityLabel", defaultValue: "Remove \(scope) scope") } - static func setAppTintTitle(_ tintName: String) -> String { - string("palette.setAppTint", defaultValue: "Set app tint to \(tintName)") + static func setDesignTintTitle(_ tintName: String) -> String { + string("palette.setDesignTint", defaultValue: "Set app tint to \(tintName)") } } -extension AppTint { +extension DesignTint { var localizedDisplayName: String { switch self { case .multicolor: return AppText.string("tint.multicolor", defaultValue: "App Accent") diff --git a/Sources/Contained/Support/AppLogging.swift b/Sources/ContainedApp/Support/AppLogging.swift similarity index 100% rename from Sources/Contained/Support/AppLogging.swift rename to Sources/ContainedApp/Support/AppLogging.swift diff --git a/Sources/Contained/Support/Formatting.swift b/Sources/ContainedApp/Support/Formatting.swift similarity index 100% rename from Sources/Contained/Support/Formatting.swift rename to Sources/ContainedApp/Support/Formatting.swift diff --git a/Sources/Contained/Support/HealthMonitor.swift b/Sources/ContainedApp/Support/HealthMonitor.swift similarity index 100% rename from Sources/Contained/Support/HealthMonitor.swift rename to Sources/ContainedApp/Support/HealthMonitor.swift diff --git a/Sources/Contained/Support/Links.swift b/Sources/ContainedApp/Support/Links.swift similarity index 100% rename from Sources/Contained/Support/Links.swift rename to Sources/ContainedApp/Support/Links.swift diff --git a/Sources/Contained/Support/Notifier.swift b/Sources/ContainedApp/Support/Notifier.swift similarity index 100% rename from Sources/Contained/Support/Notifier.swift rename to Sources/ContainedApp/Support/Notifier.swift diff --git a/Sources/Contained/Support/Personalization.swift b/Sources/ContainedApp/Support/Personalization.swift similarity index 97% rename from Sources/Contained/Support/Personalization.swift rename to Sources/ContainedApp/Support/Personalization.swift index 07a2a36c..af94afa7 100644 --- a/Sources/Contained/Support/Personalization.swift +++ b/Sources/ContainedApp/Support/Personalization.swift @@ -1,5 +1,6 @@ import SwiftUI import ContainedDesignSystem +import ContainedCore import AppKit /// Resolved visual style for a container card: a colored icon plus an optional colored glass @@ -11,7 +12,7 @@ struct Personalization: Codable, Hashable, Sendable { static let schemaVersion = 2 var schemaVersion: Int = Self.schemaVersion - var tint: AppTint = .multicolor + var tint: DesignTint = .multicolor var iconEnabled: Bool = true var icon: String = "" // SF Symbol name; empty = default var nickname: String = "" @@ -79,7 +80,7 @@ struct Personalization: Codable, Hashable, Sendable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 0 - tint = try container.decodeIfPresent(AppTint.self, forKey: .tint) ?? .multicolor + tint = try container.decodeIfPresent(DesignTint.self, forKey: .tint) ?? .multicolor iconEnabled = try container.decodeIfPresent(Bool.self, forKey: .iconEnabled) ?? true icon = try container.decodeIfPresent(String.self, forKey: .icon) ?? "" nickname = try container.decodeIfPresent(String.self, forKey: .nickname) ?? "" @@ -134,7 +135,7 @@ struct Personalization: Codable, Hashable, Sendable { /// of styles created by older versions that injected labels. Nothing writes these labels anymore. init(migratingLabels labels: [String: String]) { schemaVersion = Self.schemaVersion - tint = AppTint.parse(labels["contained.tint"]) + tint = DesignTint.parse(labels["contained.tint"]) iconEnabled = true icon = labels["contained.icon"] ?? "" nickname = labels["contained.nickname"] ?? "" diff --git a/Sources/Contained/Support/PersonalizationStore.swift b/Sources/ContainedApp/Support/PersonalizationStore.swift similarity index 100% rename from Sources/Contained/Support/PersonalizationStore.swift rename to Sources/ContainedApp/Support/PersonalizationStore.swift diff --git a/Sources/Contained/Support/Platform.swift b/Sources/ContainedApp/Support/Platform.swift similarity index 100% rename from Sources/Contained/Support/Platform.swift rename to Sources/ContainedApp/Support/Platform.swift diff --git a/Sources/Contained/Support/UpdateChannel.swift b/Sources/ContainedApp/Support/UpdateChannel.swift similarity index 100% rename from Sources/Contained/Support/UpdateChannel.swift rename to Sources/ContainedApp/Support/UpdateChannel.swift diff --git a/Sources/Contained/Support/UpdaterController.swift b/Sources/ContainedApp/Support/UpdaterController.swift similarity index 74% rename from Sources/Contained/Support/UpdaterController.swift rename to Sources/ContainedApp/Support/UpdaterController.swift index 581d32d2..f2755b8c 100644 --- a/Sources/Contained/Support/UpdaterController.swift +++ b/Sources/ContainedApp/Support/UpdaterController.swift @@ -1,6 +1,8 @@ import SwiftUI import Sparkle +private final class UpdaterBundleToken: NSObject {} + /// Thin wrapper over Sparkle's standard updater. Inert until a signed build sets `SUFeedURL` + /// `SUPublicEDKey` in Info.plist and points them at a hosted appcast. /// @@ -129,26 +131,117 @@ final class UpdaterController { let info = Bundle.main.infoDictionary return (info?["CFBundleShortVersionString"] as? String) ?? (info?["CFBundleVersion"] as? String) - ?? "1.0" + ?? "1.0.0" } private static func releaseNotesHTML(for version: String) -> String? { - guard let url = changelogResourceURL(), + let containingBundle = Bundle(for: UpdaterBundleToken.self) + guard let url = changelogResourceURL(bundle: containingBundle) ?? changelogResourceURL(), let text = try? String(contentsOf: url, encoding: .utf8) else { return nil } return ChangelogSection.releaseNotesHTML(version: version, from: text) } static func changelogResourceURL(bundle: Bundle = .main) -> URL? { + if let directResource = bundle.url(forResource: "CHANGELOG", withExtension: "md") { + return directResource.absoluteURL.standardizedFileURL + } + if let resourceDirectory = bundle.resourceURL, + let containedResource = resourceBundleChangelog(in: resourceDirectory) { + return containedResource + } + if let siblingResource = siblingResourceBundleChangelog(near: bundle.bundleURL) { + return siblingResource + } if bundle.bundleURL.pathExtension == "app" { - return bundle.bundleURL + let resources = bundle.bundleURL .appendingPathComponent("Contents") .appendingPathComponent("Resources") - .appendingPathComponent("Contained_Contained.bundle") - .appendingPathComponent("CHANGELOG.md") + for bundleName in ["Contained_ContainedApp.bundle", "Contained_Contained.bundle"] { + for candidate in resourceBundleCandidates(at: resources.appendingPathComponent(bundleName)) { + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate.absoluteURL.standardizedFileURL + } + } + } + let directCandidate = resources.appendingPathComponent("CHANGELOG.md") + if FileManager.default.fileExists(atPath: directCandidate.path) { + return directCandidate.absoluteURL.standardizedFileURL + } + } + if let loadedBundleResource = loadedResourceBundleChangelog() { + return loadedBundleResource + } + #if DEBUG + if let sourceTreeResource = sourceTreeChangelogResourceURL() { + return sourceTreeResource } + #endif return Bundle.module.url(forResource: "CHANGELOG", withExtension: "md") } + private static func loadedResourceBundleChangelog() -> URL? { + for bundle in Bundle.allBundles + Bundle.allFrameworks { + if ["Contained_ContainedApp.bundle", "Contained_Contained.bundle"].contains(bundle.bundleURL.lastPathComponent), + let resource = bundle.url(forResource: "CHANGELOG", withExtension: "md") { + return resource.absoluteURL.standardizedFileURL + } + if let resourceURL = bundle.resourceURL, + let resource = resourceBundleChangelog(in: resourceURL) { + return resource + } + } + return nil + } + + private static func siblingResourceBundleChangelog(near url: URL) -> URL? { + let directory = url.deletingLastPathComponent() + for bundleName in ["Contained_ContainedApp.bundle", "Contained_Contained.bundle"] { + for candidate in resourceBundleCandidates(at: directory.appendingPathComponent(bundleName)) { + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate.absoluteURL.standardizedFileURL + } + } + } + return nil + } + + private static func resourceBundleChangelog(in directory: URL) -> URL? { + for bundleName in ["Contained_ContainedApp.bundle", "Contained_Contained.bundle"] { + for candidate in resourceBundleCandidates(at: directory.appendingPathComponent(bundleName)) { + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate.absoluteURL.standardizedFileURL + } + } + } + return nil + } + + private static func resourceBundleCandidates(at bundleURL: URL) -> [URL] { + [ + bundleURL.appendingPathComponent("CHANGELOG.md"), + bundleURL + .appendingPathComponent("Contents") + .appendingPathComponent("Resources") + .appendingPathComponent("CHANGELOG.md"), + ] + } + + #if DEBUG + private static func sourceTreeChangelogResourceURL() -> URL? { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + for candidate in [ + root.appendingPathComponent("Sources/ContainedApp/Resources/CHANGELOG.md"), + root.appendingPathComponent("CHANGELOG.md"), + ] where FileManager.default.fileExists(atPath: candidate.path) { + return candidate.standardizedFileURL + } + return nil + } + #endif + /// Sparkle's delegate must be an `NSObject`. It points the updater at the selected channel's feed /// (overriding `SUFeedURL`) and reports the allowed channel set (empty — feed selection *is* the /// channel with per-branch feeds). diff --git a/Sources/Contained/Support/WidgetConfiguration.swift b/Sources/ContainedApp/Support/WidgetConfiguration.swift similarity index 96% rename from Sources/Contained/Support/WidgetConfiguration.swift rename to Sources/ContainedApp/Support/WidgetConfiguration.swift index 1811d666..7b77d0ee 100644 --- a/Sources/Contained/Support/WidgetConfiguration.swift +++ b/Sources/ContainedApp/Support/WidgetConfiguration.swift @@ -1,5 +1,6 @@ import AppKit import ContainedDesignSystem +import ContainedCore struct WidgetConfiguration: Codable, Hashable, Sendable { static let schemaVersion = 5 @@ -8,7 +9,7 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { var enabled: Bool = true var metric: GraphMetric = .cpu var secondaryMetric: GraphMetric? - var tint: AppTint? + var tint: DesignTint? var icon: String = "" var style: GraphStyle = .area var areaUsesGradient = true @@ -29,7 +30,7 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { init(enabled: Bool = true, metric: GraphMetric = .cpu, secondaryMetric: GraphMetric? = nil, - tint: AppTint? = nil, + tint: DesignTint? = nil, icon: String = "", style: GraphStyle = .area, areaUsesGradient: Bool = true, @@ -62,7 +63,7 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true metric = try container.decodeIfPresent(GraphMetric.self, forKey: .metric) ?? .cpu secondaryMetric = try container.decodeIfPresent(GraphMetric.self, forKey: .secondaryMetric) - tint = try container.decodeIfPresent(AppTint.self, forKey: .tint) + tint = try container.decodeIfPresent(DesignTint.self, forKey: .tint) icon = try container.decodeIfPresent(String.self, forKey: .icon) ?? "" style = try container.decodeIfPresent(GraphStyle.self, forKey: .style) ?? .area areaUsesGradient = try container.decodeIfPresent(Bool.self, forKey: .areaUsesGradient) ?? true diff --git a/Tests/ContainedAppTests/AppLoggingTests.swift b/Tests/ContainedAppTests/AppLoggingTests.swift index a4cef5e3..037fa510 100644 --- a/Tests/ContainedAppTests/AppLoggingTests.swift +++ b/Tests/ContainedAppTests/AppLoggingTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import Contained +@testable import ContainedApp @Suite("App logging settings") @MainActor diff --git a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift index 2b89867f..276cc3c4 100644 --- a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift +++ b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift @@ -3,7 +3,7 @@ import Testing import ContainedCore import ContainedRuntime import AppleContainerRuntime -@testable import Contained +@testable import ContainedApp @Suite("Container stats streaming") @MainActor diff --git a/Tests/ContainedAppTests/MigrationTests.swift b/Tests/ContainedAppTests/MigrationTests.swift index 9f0b527f..2d7fb467 100644 --- a/Tests/ContainedAppTests/MigrationTests.swift +++ b/Tests/ContainedAppTests/MigrationTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import Contained +@testable import ContainedApp struct MigrationTests { @Test func envelopeRoundTripPreservesUnknownFields() throws { diff --git a/Tests/ContainedAppTests/PaletteSearchTests.swift b/Tests/ContainedAppTests/PaletteSearchTests.swift index a86f5ec4..dc7a6976 100644 --- a/Tests/ContainedAppTests/PaletteSearchTests.swift +++ b/Tests/ContainedAppTests/PaletteSearchTests.swift @@ -1,5 +1,5 @@ import Testing -@testable import Contained +@testable import ContainedApp @Suite("Palette fuzzy search") struct PaletteSearchTests { diff --git a/Tests/ContainedAppTests/RunSpecTests.swift b/Tests/ContainedAppTests/RunSpecTests.swift index 07faba3e..0dea04f9 100644 --- a/Tests/ContainedAppTests/RunSpecTests.swift +++ b/Tests/ContainedAppTests/RunSpecTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing import ContainedCore import AppleContainerRuntime -@testable import Contained +@testable import ContainedApp @Suite("RunSpec create requests + runtime mapping") @MainActor @@ -425,9 +425,16 @@ struct RunSpecTests { } private var fixturesURL: URL { - URL(filePath: #filePath) + let testsDirectory = URL(filePath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() - .appending(path: "ContainedCoreTests/Fixtures", directoryHint: .isDirectory) + let repositoryRoot = testsDirectory.deletingLastPathComponent() + let packageFixtures = repositoryRoot + .appending(path: "Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures", + directoryHint: .isDirectory) + if FileManager.default.fileExists(atPath: packageFixtures.path) { + return packageFixtures + } + return testsDirectory.appending(path: "ContainedCoreTests/Fixtures", directoryHint: .isDirectory) } } diff --git a/Tests/ContainedAppTests/SystemVolumeInventoryTests.swift b/Tests/ContainedAppTests/SystemVolumeInventoryTests.swift index edd77b91..4b184f04 100644 --- a/Tests/ContainedAppTests/SystemVolumeInventoryTests.swift +++ b/Tests/ContainedAppTests/SystemVolumeInventoryTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing import ContainedCore -@testable import Contained +@testable import ContainedApp @Suite("System volume inventory") struct SystemVolumeInventoryTests { diff --git a/Tests/ContainedAppTests/UpdaterControllerTests.swift b/Tests/ContainedAppTests/UpdaterControllerTests.swift index c13ea71f..05fdbb25 100644 --- a/Tests/ContainedAppTests/UpdaterControllerTests.swift +++ b/Tests/ContainedAppTests/UpdaterControllerTests.swift @@ -1,9 +1,17 @@ import Foundation import Testing -@testable import Contained +@testable import ContainedApp @MainActor +@Suite(.serialized) struct UpdaterControllerTests { + private static var repositoryRoot: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + @Test func whatsNewIsNotMarkedSeenUntilClose() { let defaults = UserDefaults(suiteName: "ContainedUpdaterTests-\(UUID().uuidString)")! let updater = UpdaterController(defaults: defaults) @@ -53,9 +61,9 @@ struct UpdaterControllerTests { } @Test func bundledChangelogResourceMatchesReleaseChangelog() throws { - let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let root = Self.repositoryRoot let releaseChangelog = root.appendingPathComponent("CHANGELOG.md") - let bundledChangelog = root.appendingPathComponent("Sources/Contained/Resources/CHANGELOG.md") + let bundledChangelog = root.appendingPathComponent("Sources/ContainedApp/Resources/CHANGELOG.md") let releaseText = try String(contentsOf: releaseChangelog, encoding: .utf8) let bundledText = try String(contentsOf: bundledChangelog, encoding: .utf8) @@ -63,15 +71,18 @@ struct UpdaterControllerTests { #expect(bundledText == releaseText) } - @Test func bundledReleaseNotesAreReadable() { - let updater = UpdaterController(defaults: UserDefaults(suiteName: "ContainedUpdaterTests-\(UUID().uuidString)")!) + @Test func bundledReleaseNotesAreReadable() throws { + let fallback = Self.repositoryRoot.appendingPathComponent("Sources/ContainedApp/Resources/CHANGELOG.md") + let changelogURL = UpdaterController.changelogResourceURL() ?? fallback + let changelog = try String(contentsOf: changelogURL, encoding: .utf8) + let html = try #require(ChangelogSection.releaseNotesHTML(version: "1.0.0", from: changelog)) - #expect(!updater.currentReleaseNotesHTML.contains("No release notes are bundled")) - #expect(updater.currentReleaseNotesHTML.contains("First complete Contained release")) + #expect(!html.contains("No release notes are bundled")) + #expect(html.contains("First complete Contained release")) } @Test func prereleaseVersionsUseBaseChangelogSection() throws { - let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let root = Self.repositoryRoot let releaseChangelog = root.appendingPathComponent("CHANGELOG.md") let releaseText = try String(contentsOf: releaseChangelog, encoding: .utf8) @@ -135,7 +146,8 @@ struct UpdaterControllerTests { let root = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("ContainedUpdaterTests-\(UUID().uuidString)") let app = root.appendingPathComponent("Contained.app") - let resources = app.appendingPathComponent("Contents/Resources/Contained_Contained.bundle") + let resources = app + .appendingPathComponent("Contents/Resources/Contained_ContainedApp.bundle/Contents/Resources") try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) try "# Changelog\n".write(to: resources.appendingPathComponent("CHANGELOG.md"), atomically: true, @@ -153,7 +165,7 @@ struct UpdaterControllerTests { } @Test func releaseNotesScriptComposesNightlyChangesAndFullNotes() throws { - let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let root = Self.repositoryRoot let work = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("ContainedReleaseNotesTests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) @@ -210,7 +222,7 @@ struct UpdaterControllerTests { } @Test func betaReleaseNotesIncludeBetaChangesAndFullNotes() throws { - let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let root = Self.repositoryRoot let work = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("ContainedReleaseNotesTests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) @@ -269,7 +281,7 @@ struct UpdaterControllerTests { } @Test func stableReleaseNotesOnlyIncludeFullVersionNotes() throws { - let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let root = Self.repositoryRoot let work = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("ContainedReleaseNotesTests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) @@ -321,7 +333,7 @@ struct UpdaterControllerTests { } @Test func betaReleaseNotesCanCompileChangeFragments() throws { - let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let root = Self.repositoryRoot let work = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("ContainedReleaseNotesTests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) @@ -376,7 +388,7 @@ struct UpdaterControllerTests { } @Test func promotedAppcastItemsMergeIntoNightlyIdempotently() throws { - let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let root = Self.repositoryRoot let work = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("ContainedAppcastMergeTests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) diff --git a/Tests/ContainedDesignSystemTests/ResourceCardLayoutPolicyTests.swift b/Tests/ContainedDesignSystemTests/ResourceCardLayoutPolicyTests.swift deleted file mode 100644 index e656ff6f..00000000 --- a/Tests/ContainedDesignSystemTests/ResourceCardLayoutPolicyTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Testing -@testable import ContainedDesignSystem - -@Suite("Resource card layout policy") -struct ResourceCardLayoutPolicyTests { - @Test func sizeControlsStickyAndEmbeddedCardSlots() { - #expect(ResourceCardSize.small.keepsFooterSticky == false) - #expect(ResourceCardSize.small.embedsFooterInBody == true) - #expect(ResourceCardSize.small.keepsWidgetSticky == false) - #expect(ResourceCardSize.small.embedsWidgetInBody == false) - - #expect(ResourceCardSize.medium.keepsFooterSticky == true) - #expect(ResourceCardSize.medium.embedsFooterInBody == false) - #expect(ResourceCardSize.medium.keepsWidgetSticky == false) - #expect(ResourceCardSize.medium.embedsWidgetInBody == true) - - #expect(ResourceCardSize.large.keepsFooterSticky == true) - #expect(ResourceCardSize.large.embedsFooterInBody == false) - #expect(ResourceCardSize.large.keepsWidgetSticky == true) - #expect(ResourceCardSize.large.embedsWidgetInBody == false) - } -} diff --git a/Xcode/Contained/ContainedXcodeApp.swift b/Xcode/Contained/ContainedXcodeApp.swift new file mode 100644 index 00000000..9ba109af --- /dev/null +++ b/Xcode/Contained/ContainedXcodeApp.swift @@ -0,0 +1,8 @@ +import ContainedApp + +@main +struct ContainedXcodeApp { + static func main() { + ContainedApplication.main() + } +} diff --git a/Xcode/Contained/Info.plist b/Xcode/Contained/Info.plist new file mode 100644 index 00000000..ffc2fd79 --- /dev/null +++ b/Xcode/Contained/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleName + Contained + CFBundleDisplayName + Contained + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + 26.0 + NSPrincipalClass + NSApplication + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.developer-tools + NSHumanReadableCopyright + Copyright 2026 Contained. All rights reserved. + SUFeedURL + https://raw.githubusercontent.com/tdeverx/contained-app/nightly/appcast.xml + SUPublicEDKey + M/wt6mIO/OCxhM5wK8Le0jCtaCBIhlRh2aBWv0jkq8o= + SUEnableInstallerLauncherService + + + diff --git a/changes/unreleased/20260702-card-anatomy.md b/changes/unreleased/20260702-card-anatomy.md index ef2684fc..17ca7629 100644 --- a/changes/unreleased/20260702-card-anatomy.md +++ b/changes/unreleased/20260702-card-anatomy.md @@ -1,5 +1,5 @@ - Centralized resource-card anatomy in `ContainedDesignSystem`: headers stay visible, expanded bodies own density-specific embedded content, large widgets remain sticky, medium widgets move into the body, and small footers move into the body. - Routed remaining card widget/footer grouping through packaged card primitives, moved expanded page controls into the shared header trailing slot, kept that slot layout-stable during expansion, and updated design-system docs with the sticky/body slot contract. - Added a packaged sticky header text lane so card titles and subtitles stay anchored with the leading icon chip while expanded controls and page selections change. -- Introduced `ResourceCard` as the app-facing resource-card API, including typed page configuration and named icon/title/subtitle/header/body/widget/footer slots, and migrated app cards away from direct `ResourceGlassCard`/header composition. +- Introduced `DesignCard` as the app-facing resource-card API, including typed page configuration and named icon/title/subtitle/header/body/widget/footer slots, and migrated app cards away from direct `DesignCardSurface`/header composition. - Added semantic design-system action, toggle, toolbar, selection bar, progress, and status banner routes, then migrated app command chrome away from direct glass button/surface styling. diff --git a/changes/unreleased/20260702-standalone-packages-xcode.md b/changes/unreleased/20260702-standalone-packages-xcode.md new file mode 100644 index 00000000..77d9b3c6 --- /dev/null +++ b/changes/unreleased/20260702-standalone-packages-xcode.md @@ -0,0 +1,4 @@ +- Moved core, runtime, Apple adapter, design-system, navigation, and preview fixtures into standalone local packages with package-local docs, DocC landing pages, and tests. +- Split the SwiftUI app implementation into `ContainedApp` with a tiny SwiftPM launcher, while keeping app-owned localization, stores, Sparkle, SwiftData, settings, and feature routing out of reusable packages. +- Replaced the legacy Xcode shell wrapper with a checked-in native macOS app target that links `ContainedApp`, builds/runs `Contained.app` directly, and includes an app test bundle for SwiftUI-focused functional checks. +- Reorganized docs into app, feature, architecture, development, and release sections, updated package boundary guidance, and removed stale flat-wiki and wrapper-build wording. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..504b0cb2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,41 @@ +# Contained Docs + +Contained documentation is organized by audience and ownership. + +## App + +- [Home](app/Home.md) +- [Installation](app/Installation.md) +- [Keyboard Shortcuts](app/Keyboard-Shortcuts.md) +- [Troubleshooting](app/Troubleshooting.md) +- [Updates](app/Updates.md) +- [System Settings](app/System-Settings.md) +- [Localization](app/Localization.md) + +## Features + +- [Feature Overview](features/Features.md) +- [Containers](features/Containers.md) +- [Images](features/Images.md) +- [Resources](features/Resources.md) +- [Creation Workflow](features/Creation-Workflow.md) +- [Run / Edit Form](features/Run-Edit-Form.md) +- [Compose Import](features/Compose-Import.md) +- [Command Palette](features/Command-Palette.md) + +## Architecture + +- [Architecture](architecture/Architecture.md) +- [Runtime Adapters](architecture/Runtime-Adapters.md) +- [Design System](architecture/Design-System.md) + +## Development + +- [Contributing](development/Contributing.md) +- [Issues and Discussions](development/Issues-and-Discussions.md) + +## Release + +- [Release Runbook](release/Release.md) + +Package-specific docs live beside each package under `Packages//`. diff --git a/docs/wiki/Home.md b/docs/app/Home.md similarity index 60% rename from docs/wiki/Home.md rename to docs/app/Home.md index d31fcd18..610e9687 100644 --- a/docs/wiki/Home.md +++ b/docs/app/Home.md @@ -12,7 +12,7 @@ swift build && swift test open Contained.app ``` -Prefer a prebuilt app? Grab the latest `.dmg` from [Releases](https://github.com/tdeverx/contained-app/releases). Full details on [[Installation]]. +Prefer a prebuilt app? Grab the latest `.dmg` from [Releases](https://github.com/tdeverx/contained-app/releases). Full details on [Installation](/docs/app/Installation.md). ## What's inside @@ -22,38 +22,38 @@ Prefer a prebuilt app? Grab the latest `.dmg` from [Releases](https://github.com - **Persistent history** — SwiftData-backed events and metrics powering a per-container History tab and a system-wide Activity view (Swift Charts). - **App-managed restart & healthchecks** — `container` has no native `--restart` or healthcheck; Contained runs both itself. -See the full tour on [[Features]]. +See the full tour on [Features](/docs/features/Features.md). ## Pages ### Start here -- **[[Features]]** — overview and links into each feature area -- **[[Installation]]** — requirements, install, build from source, updates -- **[[Keyboard Shortcuts|Keyboard-Shortcuts]]** — shortcut gates and current bindings -- **[[Troubleshooting]]** — common issues and fixes +- **[Features](/docs/features/Features.md)** — overview and links into each feature area +- **[Installation](/docs/app/Installation.md)** — requirements, install, build from source, updates +- **[Keyboard Shortcuts](/docs/app/Keyboard-Shortcuts.md)** — shortcut gates and current bindings +- **[Troubleshooting](/docs/app/Troubleshooting.md)** — common issues and fixes ### Workflows -- **[[Creation Workflow|Creation-Workflow]]** — the shared create/edit/search/build front door -- **[[Run / Edit Form|Run-Edit-Form]]** — UI-first mapping of `container run` flags -- **[[Compose Import|Compose-Import]]** — compose-to-run-form behavior -- **[[Command Palette|Command-Palette]]** — the app-wide command index and palette rules -- **[[Updates]]** — app updates, image updates, channels, and release notes +- **[Creation Workflow](/docs/features/Creation-Workflow.md)** — the shared create/edit/search/build front door +- **[Run / Edit Form](/docs/features/Run-Edit-Form.md)** — UI-first mapping of `container run` flags +- **[Compose Import](/docs/features/Compose-Import.md)** — compose-to-run-form behavior +- **[Command Palette](/docs/features/Command-Palette.md)** — the app-wide command index and palette rules +- **[Updates](/docs/app/Updates.md)** — app updates, image updates, channels, and release notes ### Feature areas -- **[[Containers|Features-Containers]]** -- **[[Images|Features-Images]]** -- **[[Resources|Features-Resources]]** -- **[[System & Settings|System-Settings]]** +- **[Containers](/docs/features/Containers.md)** +- **[Images](/docs/features/Images.md)** +- **[Resources](/docs/features/Resources.md)** +- **[System & Settings](/docs/app/System-Settings.md)** ### Maintainers -- **[[Architecture]]** — how the CLI wrapper, stores, and design system fit together -- **[[Design System|Design-System]]** — shared Liquid Glass components and layout rules -- **[[Contributing]]** — layout, conventions, and the pre-PR checklist -- **[[Release]]** — the maintainer signing / notarization / appcast runbook +- **[Architecture](/docs/architecture/Architecture.md)** — how the CLI wrapper, stores, and design system fit together +- **[Design System](/docs/architecture/Design-System.md)** — shared Liquid Glass components and layout rules +- **[Contributing](/docs/development/Contributing.md)** — layout, conventions, and the pre-PR checklist +- **[Release](/docs/release/Release.md)** — the maintainer signing / notarization / appcast runbook ## Updates diff --git a/docs/wiki/Installation.md b/docs/app/Installation.md similarity index 83% rename from docs/wiki/Installation.md rename to docs/app/Installation.md index 3ccb0ee8..c42501de 100644 --- a/docs/wiki/Installation.md +++ b/docs/app/Installation.md @@ -16,10 +16,12 @@ On first launch, the bootstrap screen checks for the `container` CLI and the run ## Build from source -Contained is SwiftPM-first. The checked-in workspace is a convenience wrapper -over the root package, local packages, and shared Xcode schemes. The small -`Contained.xcodeproj` delegates build/test actions to SwiftPM; it is not the -release or CI source of truth. +Contained has two supported development entry points: + +- `Contained.xcworkspace` for Xcode. It contains a native macOS app target that + links the same package graph and builds/runs `Contained.app` directly. +- SwiftPM for command-line CI/release parity. `swift build`, `swift test`, and + `scripts/bundle.sh` remain the release packaging path. ```sh git clone https://github.com/tdeverx/contained-app.git @@ -27,12 +29,13 @@ cd contained-app open Contained.xcworkspace # open in Xcode ``` -Or from the command line: +From the command line: ```sh swift build # debug build swift test # run the unit tests xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug build +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug test ./scripts/bundle.sh debug # assemble a runnable development Contained.app open Contained.app ``` diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/app/Keyboard-Shortcuts.md similarity index 100% rename from docs/wiki/Keyboard-Shortcuts.md rename to docs/app/Keyboard-Shortcuts.md diff --git a/docs/wiki/Localization.md b/docs/app/Localization.md similarity index 94% rename from docs/wiki/Localization.md rename to docs/app/Localization.md index a810dec5..edf35a1a 100644 --- a/docs/wiki/Localization.md +++ b/docs/app/Localization.md @@ -5,7 +5,7 @@ localization-ready APIs. ## Ownership -- `Sources/Contained` owns all user-facing strings and the localization catalog. +- `Sources/ContainedApp` owns all user-facing strings and the localization catalog. - `ContainedDesignSystem` and `ContainedNavigation` are building-block packages: they own structure and visuals, not app copy. - `ContainedCore`, `ContainedRuntime`, and runtime adapters should stay @@ -65,6 +65,6 @@ should go through `AppText` or an app-side localized display extension. ## English-Only Baseline The root package declares `defaultLocalization: "en"` and the app carries -`Sources/Contained/Resources/Localizable.xcstrings`. English currently comes +`Sources/ContainedApp/Resources/Localizable.xcstrings`. English currently comes from `String(localized:defaultValue:bundle:)` fallbacks in app code. Future translations can fill the string catalog without changing package APIs. diff --git a/docs/wiki/System-Settings.md b/docs/app/System-Settings.md similarity index 100% rename from docs/wiki/System-Settings.md rename to docs/app/System-Settings.md diff --git a/docs/wiki/Troubleshooting.md b/docs/app/Troubleshooting.md similarity index 96% rename from docs/wiki/Troubleshooting.md rename to docs/app/Troubleshooting.md index 806e1812..08ca32d6 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/app/Troubleshooting.md @@ -22,7 +22,7 @@ Kernel and DNS operations (System → Kernel & DNS) may prompt for admin rights. ## Updates do nothing / "Check for Updates" is greyed out - The updater is **inert in development builds** by design — it only runs in a signed release build pointed at the appcast feed. -- Make sure you installed a released `.dmg` (see [[Installation]]), not a locally-built `Contained.app`. +- Make sure you installed a released `.dmg` (see [Installation](/docs/app/Installation.md)), not a locally-built `Contained.app`. - Check your channel in **Settings → Updates**. Each channel reads a branch-hosted appcast feed, and fresh pre-1.0 installs default to Nightly. Nightly also receives promoted Beta and Stable appcast items. ## I want bleeding-edge / pre-release builds diff --git a/docs/wiki/Updates.md b/docs/app/Updates.md similarity index 100% rename from docs/wiki/Updates.md rename to docs/app/Updates.md diff --git a/docs/wiki/Architecture.md b/docs/architecture/Architecture.md similarity index 67% rename from docs/wiki/Architecture.md rename to docs/architecture/Architecture.md index 5f81d5af..76427954 100644 --- a/docs/wiki/Architecture.md +++ b/docs/architecture/Architecture.md @@ -17,7 +17,8 @@ Contained is a SwiftUI-native macOS app that wraps Apple's `container` CLI. It s - **`AppleContainerRuntime`** — the current Apple `container` adapter: `AppleContainerClient`, `AppleContainerCLILocator`, `CommandRunner` usage, Apple create/import/default translation, and the Apple stats-table parser. Future runtime engines should be sibling adapter targets that conform to `ContainerRuntimeClient`. - **`ContainedDesignSystem`** — a local reusable Swift package for app-agnostic SwiftUI/AppKit visual primitives. It must not depend on stores, Sparkle, SwiftData, app routing, or feature modules. - **`ContainedNavigation`** — a local reusable Swift package for navigation and layout infrastructure that should not own app-specific routing. It currently owns toolbar safe-area policy/measurement primitives. -- **`Contained`** — the SwiftUI executable: views, `@Observable` stores, app-specific presentation mappings, navigation, and the SwiftData history stack. Depends on `ContainedCore`, `ContainedRuntime`, `AppleContainerRuntime`, `ContainedDesignSystem`, `ContainedNavigation`, SwiftTerm, and Sparkle. +- **`ContainedApp`** — the shared SwiftUI app implementation: views, `@Observable` stores, app-specific presentation mappings, localization, navigation, and the SwiftData history stack. Depends on `ContainedCore`, `ContainedRuntime`, `AppleContainerRuntime`, `ContainedDesignSystem`, `ContainedNavigation`, `ContainedPreviewSupport`, SwiftTerm, and Sparkle. +- **`Contained`** — the tiny SwiftPM executable launcher used by command-line builds and bundle scripts. The executable target owns localization. Reusable packages do not ship localized resources or English UI defaults; app code supplies user-facing text through @@ -35,14 +36,21 @@ case. Package-local docs: +- [`Packages/ContainedCore/README.md`](../../Packages/ContainedCore/README.md) +- [`Packages/ContainedRuntime/README.md`](../../Packages/ContainedRuntime/README.md) +- [`Packages/AppleContainerRuntime/README.md`](../../Packages/AppleContainerRuntime/README.md) - [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) - [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md) +- [`Packages/ContainedPreviewSupport/README.md`](../../Packages/ContainedPreviewSupport/README.md) -`Contained.xcworkspace` is the optional Xcode entry point. It contains a small -checked-in `Contained.xcodeproj` with shared schemes that call SwiftPM through -`scripts/xcode-swiftpm-build.sh`, and it also references the root and local -package manifests for manual SwiftUI/package editing. The SwiftPM package graph -remains the source of truth for builds, tests, and release scripts. +`Contained.xcworkspace` is the Xcode entry point. It contains a checked-in +native `Contained.xcodeproj` app target with a tiny Xcode launcher in +`Xcode/Contained/`, and that target links the root package's `ContainedApp` +product. Xcode therefore builds and runs a real `Contained.app` for manual +SwiftUI work. The shared `Contained` scheme builds/runs the app and runs the +native `ContainedAppTests` bundle; local package schemes come from their package +manifests. SwiftPM remains the source of truth for CI, package tests, release +bundles, signing, notarization, and appcast scripts. ## Runtime wrapper @@ -52,7 +60,7 @@ remains the source of truth for builds, tests, and release scripts. - **`ContainerCreateRequest`** — runtime-neutral create/recreate fields used by the app form and adapter import/default translation. It carries the intended `RuntimeKind` per container so future adapters can fill the same global run/edit form without making the core choice app-wide. - **`AppleContainerClient`** — the Apple `container` implementation of `ContainerRuntimeClient`; returns decoded models and maps decode failures to a single `CommandError`. - **`ContainerStatsTableParser`** — Apple-adapter parser for the ANSI table emitted by `container stats --format table`. It converts table frames into runtime-agnostic snapshots inside `AppleContainerRuntime`. -- **`ContainerRuntimeClient`** — the backend-facing operation contract. `RuntimeDescriptor`, open-ended `RuntimeKind`, and `RuntimeCapability` advertise what a selected runtime can do before adapter-specific UI routes enable a command. See [[Runtime Adapters|Runtime-Adapters]]. +- **`ContainerRuntimeClient`** — the backend-facing operation contract. `RuntimeDescriptor`, open-ended `RuntimeKind`, and `RuntimeCapability` advertise what a selected runtime can do before adapter-specific UI routes enable a command. See [Runtime Adapters](Runtime-Adapters.md). ## Stores (app) @@ -68,7 +76,7 @@ remains the source of truth for builds, tests, and release scripts. ## Design system -Liquid Glass helpers and reusable primitives include `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `ResourceCard`, `ResourceCardInsetSection`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `DesignStatusBanner`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `Tokens` groups for toolbar, panel, spacing, radius, icon sizing, resource cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, glass button styles, or micro-chrome recipes. App-state-aware mappings such as runtime status and graph metric extraction stay in the executable until they can cross the boundary without depending on app/core policy. App-side resource cards should use `ResourceCard` rather than assembling `ResourceGlassCard`, headers, page rails, widgets, or footers directly, and app-side command chrome should use the named design action/toolbar controls rather than `GlassButton`, `glassSurface`, or `.buttonStyle(.glass*)` directly. Use the package READMEs for import instructions and copy-pasteable examples, and see [[Design System|Design-System]] for app-level conventions. +Liquid Glass helpers and reusable primitives include `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `DesignCard`, `DesignCardInsetSection`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `DesignStatusBanner`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `DesignTokens` groups for toolbar, panel, spacing, radius, icon sizing, design cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, glass button styles, or micro-chrome recipes. App-state-aware mappings such as runtime status and graph metric extraction stay in `ContainedApp` until they can cross the boundary without depending on app/core policy. App-side cards should use `DesignCard` rather than assembling `DesignCardSurface`, headers, page rails, widgets, or footers directly, and app-side command chrome should use the named design action/toolbar controls rather than `GlassButton`, `glassSurface`, or `.buttonStyle(.glass*)` directly. Use the package READMEs for import instructions and copy-pasteable examples, and see [Design System](Design-System.md) for app-level conventions. ## Local-only personalization @@ -76,4 +84,4 @@ Card styles and healthchecks are stored locally (keyed by container id / image r ## Testing -`Tests/ContainedCoreTests` holds golden-argv tests (every `ContainerCommands` builder), decode tests against captured real CLI fixtures, and pure decision tests (`RestartDecision`, `HealthDecision`, compose ordering). `Tests/ContainedRuntimeTests` covers runtime descriptors, future-kind extensibility, Apple adapter decoding/streaming, create/import translation, unsupported capabilities, and typed stats snapshots. `Tests/ContainedAppTests` covers `RunSpec` form state, create-request round-trips, and runtime-translated compose mapping. Run with `swift test`. +Package-local tests hold golden-argv tests, decode tests against captured real CLI fixtures, pure decision tests, runtime descriptor contracts, Apple adapter decoding/streaming, create/import translation, unsupported capabilities, typed stats snapshots, design-system layout rules, navigation behavior, and preview fixtures. `Tests/ContainedAppTests` covers app-owned form state, create-request round-trips, and runtime-translated compose mapping. Run with `swift test` at the root or `swift test --package-path Packages/` for a standalone package. diff --git a/docs/wiki/Design-System.md b/docs/architecture/Design-System.md similarity index 78% rename from docs/wiki/Design-System.md rename to docs/architecture/Design-System.md index 331dfe54..43895578 100644 --- a/docs/wiki/Design-System.md +++ b/docs/architecture/Design-System.md @@ -4,9 +4,9 @@ Contained's UI is built around a small Liquid Glass design system. Prefer these components before adding one-off surfaces. App-agnostic SwiftUI/AppKit primitives live in the local `ContainedDesignSystem` -package under `Packages/`. App-specific views, stores, settings, routing, and -domain presentation mappings stay in the executable target until they have a -clean reusable boundary. +package under `Packages/`. App-specific views, stores, settings, routing, +localization, and domain presentation mappings stay in `ContainedApp` until they +have a clean reusable boundary. Package-local docs: @@ -16,7 +16,7 @@ Package-local docs: The package owns the shared tokens, visual-effect background bridge, exterior shadow, glass surface modifier, panel/page/sheet scaffolds, toolbar controls, -option tiles, transient status/error banners, resource-card chrome, action +option tiles, transient status/error banners, design-card chrome, action buttons, toggles, selection bars, activity status, JSON and stream-console surfaces, sparklines, clipboard helper, gradient-angle control, and micro primitives such as status dots, badges, keycaps, metric tiles, terminal chrome, @@ -33,9 +33,9 @@ localized resources. Guidelines: - user-facing labels, help text, accessibility labels, picker names, page names, - and empty-state copy are supplied by `Sources/Contained` + and empty-state copy are supplied by `Sources/ContainedApp` - package APIs that need words take app-supplied strings or semantic item - titles, such as `ResourceCardPages.closeLabel`, + titles, such as `DesignCardPages.closeLabel`, `DesignToolbarSearchField.clearSearchLabel`, and `TintSelector`'s `labelForTint` - app-owned enum labels and dynamic templates flow through `AppText`, which uses @@ -44,7 +44,7 @@ Guidelines: names, raw values, chart field identifiers, and accessibility-hidden chart dimensions -Only the executable target owns localization catalogs. The local packages should +Only `ContainedApp` owns localization catalogs. The local packages should remain reusable without shipping their own language bundles unless a future package genuinely owns standalone user-facing copy. @@ -70,7 +70,7 @@ Style ownership: ## Panel scaffolding -Use `MorphPanelScaffold` for toolbar panels. It provides the shared chrome, +Use `DesignPanelScaffold` for toolbar panels. It provides the shared chrome, content, and footer structure used by Images, Templates, Activity, System, Settings, and the Command Palette. @@ -108,7 +108,7 @@ or fall back to classic pages and sheets. `AppToolbar` is mounted inside the `NavigationSplitView` detail column by `ClassicShell`, not across the whole split view. The detail body receives top -padding from `AppSafeAreaManager`, while the sidebar and bottom page edge keep +padding from `MorphSafeAreaManager`, while the sidebar and bottom page edge keep native split-view layout. Scrollable page interiors add bottom scroll-content clearance for the floating toolbar, so the last row can move above it without lifting the page itself. Toolbar page actions live in the top row to the left of @@ -131,7 +131,7 @@ When toolbar panel navigation is enabled, panel-owned destinations such as System, Activity, and Settings are removed from page navigation and remain available through their toolbar/menu entry points. -Expanded resource cards opened from full pages should receive the same toolbar +Expanded design cards opened from full pages should receive the same toolbar safe-area contract as morph panels, clearing both top and bottom toolbar bands. ## Action and toolbar chrome @@ -155,23 +155,23 @@ Feature views should not call `GlassButton`, `GlassButtonItem`, pieces. If a view needs a new command shape, add a named design-system route and then consume it from the app. -## Resource cards +## Design cards -Use `ResourceCard` for containers, images, tags, volumes, networks, and +Use `DesignCard` for containers, images, tags, volumes, networks, and palette result cards. Recommended inputs and package pieces: -- `ResourceCardPages` for expanded-card page rails -- `ResourceCardIconChip` for icons and symbols -- `ResourceCardTextStyle` for standard versus monospaced title/subtitle text -- `ResourceBadgeText` for compact state or kind labels -- `ResourceCardFooterMini` for small footer actions and metrics -- `ResourceCardWidgetGroup` for horizontal widget metadata -- `ResourceCardFooterChip` and `ResourceCardFooterButton` for card-local controls -- `ResourceCardInsetSection` for charts, lists, and read-only groups inside an +- `DesignCardPages` for expanded-card page rails +- `DesignCardIconChip` for icons and symbols +- `DesignCardTextStyle` for standard versus monospaced title/subtitle text +- `DesignBadgeText` for compact state or kind labels +- `DesignCardFooterMini` for small footer actions and metrics +- `DesignCardWidgetGroup` for horizontal widget metadata +- `DesignCardFooterChip` and `DesignCardFooterButton` for card-local controls +- `DesignCardInsetSection` for charts, lists, and read-only groups inside an expanded card body -- `resourceCardFloatingControls` and `resourceCardProgressOverlay` for +- `designCardFloatingControls` and `designCardProgressOverlay` for card-owned overlays - `DesignStatusDot`, `DesignStatusBadge`, `DesignKeyCap`, and `DesignKeyboardHint` for micro chrome @@ -179,10 +179,10 @@ Recommended inputs and package pieces: Use `isSelected` instead of inventing a second selection ring. Use `elevated: false` for cards inside already-elevated morph panels. -`ResourceCard` owns the card anatomy: +`DesignCard` owns the card anatomy: - the header is always visible and stays outside the expanding body -- page controls are declared with `ResourceCardPages`, stay mounted in the header +- page controls are declared with `DesignCardPages`, stay mounted in the header trailing slot, and use `controlsReveal` instead of app-local overlays or conditional trailing views - the body appears only while expanded @@ -191,14 +191,14 @@ false` for cards inside already-elevated morph panels. - footers stay sticky on `.medium` and `.large` cards and move into the expanded body on `.small` -Feature views should not call `ResourceGlassCard`, `ResourceCardHeader`, or -`ResourceCardPageControls` directly. Those are package composition pieces used by -`ResourceCard`. +Feature views should not call `DesignCardSurface`, `DesignCardHeader`, or +`DesignCardPageControls` directly. Those are package composition pieces used by +`DesignCard`. -Do not create a second `ResourceCard` or direct surface modifier inside an +Do not create a second `DesignCard` or direct surface modifier inside an expanded card body unless the nested object is itself an independent resource card, such as an image tag row. In-card content should go through -`ResourceCardInsetSection`. +`DesignCardInsetSection`. ## Palette visual results @@ -213,27 +213,27 @@ The palette should not degrade rich app objects into plain text. Use Plain rows are reserved for generic actions such as refresh or opening a page. -## Tokens +## DesignTokens -Use `Tokens` for spacing, radius, toolbar dimensions, panel sizes, icon sizes, -form widths, chart sizes, badge/keycap insets, resource-card opacities, terminal +Use `DesignTokens` for spacing, radius, toolbar dimensions, panel sizes, icon sizes, +form widths, chart sizes, badge/keycap insets, design-card opacities, terminal chrome, and shadows. Feature views should not call low-level surface modifiers -or glass button styles; use named package routes such as `ResourceCard`, +or glass button styles; use named package routes such as `DesignCard`, `PanelSection`, `DesignContentSurface`, `DesignInputSurface`, `DesignActionGroup`, `DesignActionCluster`, `DesignInputCluster`, -`DesignTextActionButton`, and `ResourceCardInsetSection`. +`DesignTextActionButton`, and `DesignCardInsetSection`. If a new visual value appears, add a token or package primitive before using it in the app. Important groups: -- `Tokens.Toolbar` for toolbar band and control sizing -- `Tokens.PanelSize` for morph target sizes -- `Tokens.Space` for layout rhythm -- `Tokens.Radius` for card and control rounding -- `Tokens.IconSize` for chips and toolbar controls -- `Tokens.ResourceCard`, `Tokens.Badge`, `Tokens.Keyboard`, `Tokens.Chart`, - `Tokens.FormWidth`, and `Tokens.Terminal` for smaller repeated chrome values +- `DesignTokens.Toolbar` for toolbar band and control sizing +- `DesignTokens.PanelSize` for morph target sizes +- `DesignTokens.Space` for layout rhythm +- `DesignTokens.Radius` for card and control rounding +- `DesignTokens.IconSize` for chips and toolbar controls +- `DesignTokens.DesignCard`, `DesignTokens.Badge`, `DesignTokens.Keyboard`, `DesignTokens.Chart`, + `DesignTokens.FormWidth`, and `DesignTokens.Terminal` for smaller repeated chrome values Feature views can choose semantic content, domain colors, and app data, but should not create app-local spacing, padding, radius, shadow, material, opacity, @@ -246,6 +246,7 @@ UI changes should run: ```sh swift test git diff --check +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug build ./scripts/bundle.sh debug open Contained.app ``` diff --git a/docs/wiki/Runtime-Adapters.md b/docs/architecture/Runtime-Adapters.md similarity index 96% rename from docs/wiki/Runtime-Adapters.md rename to docs/architecture/Runtime-Adapters.md index c57b6c55..1d8ec7d7 100644 --- a/docs/wiki/Runtime-Adapters.md +++ b/docs/architecture/Runtime-Adapters.md @@ -12,7 +12,7 @@ concrete adapters: - `ContainedCore` owns pure models, JSON decoding, compose parsing, decision helpers, open-ended `RuntimeKind`, runtime-neutral create/recreate request fields, and `ContainerCommands` argv builders. -- `Sources/Contained` owns app state, settings, stores, SwiftUI, SwiftData, and +- `Sources/ContainedApp` owns app state, settings, stores, SwiftUI, SwiftData, and app-specific presentation mapping. ## Adapter Shape @@ -69,7 +69,7 @@ do not decide how those failures are displayed to users. The app target maps package errors through `AppErrorPresentation` and `AppText`. That keeps toast copy, inline messages, alerts, and Activity history wording in -`Sources/Contained`, while reusable packages remain suitable for other hosts. +`Sources/ContainedApp`, while reusable packages remain suitable for other hosts. Backend stderr is not translated wholesale; non-zero CLI output is preserved as runtime-provided detail unless the adapter maps it to a known typed case. diff --git a/docs/wiki/Contributing.md b/docs/development/Contributing.md similarity index 78% rename from docs/wiki/Contributing.md rename to docs/development/Contributing.md index 38f2d9f5..4d129472 100644 --- a/docs/wiki/Contributing.md +++ b/docs/development/Contributing.md @@ -18,7 +18,7 @@ the place that fits: architecture tasks, and implementation checklists. Blank issues are disabled. Use the closest issue form so reports stay readable -and easy to triage. See [[Issues-and-Discussions]] for examples. +and easy to triage. See [Issues and Discussions](/docs/development/Issues-and-Discussions.md) for examples. For feature, architecture, backend, navigation, design-system, and exploration work, issues should include a short goal or context, a research/design checklist, @@ -45,7 +45,7 @@ unscheduled or post-beta work. Issue titles should be concise sentence-case summaries without `[Type]` or area prefixes. PR titles should use conventional-commit style when practical, such as `fix: handle missing container stats`, `docs: update support links`, or -`chore(deps): bump yams`. See [[Issues-and-Discussions]] for examples. +`chore(deps): bump yams`. See [Issues and Discussions](/docs/development/Issues-and-Discussions.md) for examples. Release branches (`nightly`, `beta`, and `stable`) are protected against deletion and force-pushes. Normal work should move through pull requests. A full @@ -55,22 +55,28 @@ enforced without breaking appcast publishing. ## Layout ``` -Sources/ContainedCore/ pure logic — models, decoding, compose, argv builders (no SwiftUI) -Sources/ContainedRuntime/ shared runtime contracts and capabilities -Sources/AppleContainerRuntime/ Apple container runtime adapter -Sources/Contained/ the SwiftUI app - DesignSystem/ core-dependent presentation mappings only +Packages/ContainedCore/Sources/ContainedCore/ pure logic — models, decoding, compose, argv builders (no SwiftUI) +Packages/ContainedRuntime/Sources/ContainedRuntime/ shared runtime contracts and capabilities +Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/ Apple container runtime adapter +Sources/ContainedApp/ the SwiftUI app + Presentation/ app-owned labels, icons, formatting, and localization mapping Features// one folder per sidebar domain Navigation/ Stores/ Support/ History/ Packages/ContainedDesignSystem/ reusable SwiftUI/AppKit visual primitives and tokens Packages/ContainedNavigation/ reusable navigation/layout infrastructure -Contained.xcworkspace/ optional Xcode entry point over the SwiftPM packages -Contained.xcodeproj/ small Xcode scheme wrapper that delegates to SwiftPM -Tests/ContainedCoreTests/ golden-argv + decode + decision tests -Tests/ContainedRuntimeTests/ runtime adapter contract + Apple adapter tests +Packages/ContainedPreviewSupport/ deterministic fixtures for previews/examples +Sources/Contained/ tiny SwiftPM executable launcher +Xcode/Contained/ tiny native Xcode app launcher and Info.plist +Contained.xcworkspace/ Xcode entry point +Contained.xcodeproj/ native macOS app target that links ContainedApp +Packages/*/Tests/ package-local unit tests Tests/ContainedAppTests/ RunSpec form state + runtime mapping scripts/ bundle.sh, release.sh, appcast.sh -docs/wiki/ local mirror of the GitHub wiki pages +docs/app/ user-facing app docs +docs/features/ feature and workflow docs +docs/architecture/ architecture and package-boundary docs +docs/development/ contribution, issue, and repo workflow docs +docs/release/ release and updater runbooks appcast.xml Sparkle feed at the root of each release branch ``` @@ -82,26 +88,27 @@ appcast.xml Sparkle feed at the root of each release branch - **The app owns localization.** Reusable packages should not introduce user-facing English defaults or localized resource bundles. If a package component needs text, add an explicit parameter and pass app-owned strings - from `Sources/Contained`; reusable enum labels and dynamic templates should + from `Sources/ContainedApp`; reusable enum labels and dynamic templates should flow through `AppText` with English fallbacks. - **The app owns package error presentation.** Reusable targets should throw typed errors with stable codes/context, preferably `ContainedPackageError`. Map those failures through `AppErrorPresentation`/`AppText` in - `Sources/Contained` before showing toasts, inline errors, alerts, or Activity + `Sources/ContainedApp` before showing toasts, inline errors, alerts, or Activity entries. Preserve arbitrary backend stderr as runtime detail unless an adapter can classify it as a known typed case. -- **Package docs live with the package.** Keep package-local import/setup/examples in [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) and [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md), with DocC landing pages under each target's `.docc` catalog. Keep `docs/wiki` focused on app-level architecture and workflow guidance. -- **Xcode opens the workspace.** `Contained.xcworkspace` points at the SwiftPM root package, local package manifests, and a small committed `Contained.xcodeproj` wrapper with shared schemes. Keep `Contained.xcodeproj` limited to Xcode entry-point metadata that delegates to `scripts/xcode-swiftpm-build.sh`; do not duplicate the SwiftPM source graph as hand-maintained native compile phases. +- **Package docs live with the package.** Keep package-local import/setup/examples in each `Packages//README.md`, with DocC landing pages under each target's `.docc` catalog. Keep app-level architecture and workflow guidance under `docs/`. +- **Xcode opens the workspace.** `Contained.xcworkspace` points at the native `Contained.xcodeproj` and local package manifests. The Xcode target links the root package's `ContainedApp` product and builds/runs a real `Contained.app`; SwiftPM remains the release, CI, bundle, signing, notarization, and appcast source of truth. +- **Use Xcode for functional SwiftUI loops.** The shared `Contained` scheme builds/runs the app and runs `ContainedAppTests`; `ContainedAppTests` is the focused app-test scheme; package schemes come from the package manifests; `ContainedPreviews` is reserved for preview-oriented development. - **Navigation infrastructure belongs in `ContainedNavigation` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in the executable target until they can cross the boundary without app policy. - **Every Apple `container` CLI action goes through a `ContainerCommands` builder** + `AppleContainerRuntime`, with a golden-argv test. The UI never assembles argv inline — this keeps "Reveal CLI" honest. - **Runtime-facing code should depend on `ContainerRuntimeClient` where a backend choice matters.** The Apple `container` implementation remains the default adapter; future Docker-compatible, Podman, Lima-backed, remote, or other runtimes should be sibling adapter targets that advertise capability differences through `RuntimeDescriptor`. Create/import flows should translate through `ContainerCreateRequest` and carry `RuntimeKind` per container, not as a global app setting. - **Pure decision logic is factored into `ContainedCore`** (`RestartDecision`, `HealthDecision`, compose ordering) and unit-tested without spawning processes. - **No `contained.*` personalization labels.** Card styles and healthchecks live in local stores. Only `contained.restart` and `contained.stack` are written (they must round-trip through the container). - **Never put secrets or personal data in test fixtures.** Fixtures are captured CLI output — scrub tokens, domains, and paths before committing. (`.gitignore` blocks signing material; push protection is on.) -- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer app-facing design routes such as `PanelHeader`, `PanelSection`, `MorphPanelScaffold`, `ResourceCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `CommandPreviewBar`, and `Tokens`. Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or badge/keycap/status-dot recipes; add them to `ContainedDesignSystem` first. +- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer app-facing design routes such as `PanelHeader`, `PanelSection`, `DesignPanelScaffold`, `DesignCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `CommandPreviewBar`, and `DesignTokens`. Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or badge/keycap/status-dot recipes; add them to `ContainedDesignSystem` first. - **Gate debug-only tools at compile time.** Use `#if CONTAINED_DEBUG_TOOLS` for debug menus, diagnostics, fixtures, or local-only inspection surfaces. SwiftPM defines that flag only for debug builds, so release bundles exclude the code instead of merely hiding it at runtime. - **Keep the sidebar fallback working.** Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements for the classic shell. -- **Sync docs with behavior.** If behavior, settings, routes, or user-facing wording changes, update the matching page under `docs/wiki` and keep README links current. +- **Sync docs with behavior.** If behavior, settings, routes, or user-facing wording changes, update the matching page under `docs/app`, `docs/features`, `docs/development`, `docs/architecture`, or `docs/release` and keep README links current. - **Preserve update build numbers.** `scripts/version-info.sh` is the single build-number source of truth; beta/stable workflows must pass the retained `BUILD` into `scripts/bundle.sh` and merge promoted appcast items into the nightly feed. - **Keep code scanning intentional.** `.github/workflows/codeql.yml` is the repository-owned CodeQL setup. GitHub Actions workflow analysis runs on PRs and pushes that touch source, scripts, workflows, package files, or tests, plus a weekly scheduled baseline. Swift analysis is scheduled/manual because Swift CodeQL currently takes too long to be a healthy per-PR gate. Appcast-only, docs-only, changelog-resource-only, and change-fragment-only commits are ignored so generated release feed commits do not burn macOS scan minutes. - **Write release notes at the right level.** Keep `CHANGELOG.md` curated and version-level: use the base version section, such as `## [1.0.0]`, for durable user-facing release notes. Put PR/build deltas in `changes/unreleased/` fragments by default, not in `CHANGELOG.md` as a running implementation inventory. Use `changes/beta/` or `changes/nightly/` only for channel-specific notes. `scripts/collect-changes.sh` can compile those fragments for a directory or git range. When no explicit `CHANGES`/`CHANGES_DIR` source is provided, Beta/Nightly notes first try the previous matching appcast item plus the changelog/change-fragment git delta, then fall back to channel sections and `Unreleased` only as compatibility fallbacks. Stable ships full notes only; Beta/Nightly ship channel changes plus full notes. @@ -121,6 +128,7 @@ fixes, and direct review follow-ups can skip the issue when the PR explains why. ./scripts/test-release-scripts.sh # release script fixtures swift build && swift test # must be green git diff --check # no whitespace damage +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug build ./scripts/bundle.sh debug && open Contained.app # smoke-test the screens you touched ``` @@ -130,4 +138,4 @@ git diff --check # no whitespace damage - Add a golden-argv or decode test for an under-covered command. - Improve an empty / loading / error state. -See [[Architecture]] for how the pieces fit together. +See [Architecture](/docs/architecture/Architecture.md) for how the pieces fit together. diff --git a/docs/wiki/Issues-and-Discussions.md b/docs/development/Issues-and-Discussions.md similarity index 100% rename from docs/wiki/Issues-and-Discussions.md rename to docs/development/Issues-and-Discussions.md diff --git a/docs/wiki/Command-Palette.md b/docs/features/Command-Palette.md similarity index 88% rename from docs/wiki/Command-Palette.md rename to docs/features/Command-Palette.md index 25bd6d71..623fa9d9 100644 --- a/docs/wiki/Command-Palette.md +++ b/docs/features/Command-Palette.md @@ -29,7 +29,7 @@ resource panel, it should be discoverable from `CommandPalette.swift`. Results should use the design system rather than custom row chrome: -- use `ResourceCard` for every result card +- use `DesignCard` for every result card - pass badges, chevrons, return hints, and unread dots through the card's named accessory slots instead of building custom headers - use `PaletteItemVisual` for anything that can be represented visually @@ -59,14 +59,14 @@ results. ## Implementation map -- `Sources/Contained/Features/Palette/CommandPalette.swift` owns the indexed +- `Sources/ContainedApp/Features/Palette/CommandPalette.swift` owns the indexed actions and search fields. -- `Sources/Contained/Features/Palette/PaletteSearch.swift` owns scoring. -- `Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift` owns the +- `Sources/ContainedApp/Features/Palette/PaletteSearch.swift` owns scoring. +- `Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarSearchSource.swift` owns the toolbar search field (and the empty-query escalation into the palette). -- `Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift` owns +- `Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift` owns the visual panel and keyboard handling. -- `Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift` owns +- `Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift` owns per-result card rendering. - `Tests/ContainedAppTests/PaletteSearchTests.swift` locks in fuzzy matching and ranking expectations. diff --git a/docs/wiki/Compose-Import.md b/docs/features/Compose-Import.md similarity index 94% rename from docs/wiki/Compose-Import.md rename to docs/features/Compose-Import.md index 2b12a106..7e5ea4ce 100644 --- a/docs/wiki/Compose-Import.md +++ b/docs/features/Compose-Import.md @@ -16,7 +16,7 @@ Compose import can start from: ## Behavior -Compose import fills one or more editable [[Run / Edit Form|Run-Edit-Form]] +Compose import fills one or more editable [Run / Edit Form](/docs/features/Run-Edit-Form.md) entries instead of launching an opaque stack. The selected runtime adapter translates parsed services into standardized create fields, then the app fills the global Run/Edit form. Services with images become prefilled runs; diff --git a/docs/wiki/Features-Containers.md b/docs/features/Containers.md similarity index 86% rename from docs/wiki/Features-Containers.md rename to docs/features/Containers.md index 48dbc5d4..983a72f8 100644 --- a/docs/wiki/Features-Containers.md +++ b/docs/features/Containers.md @@ -49,6 +49,6 @@ records events in Activity/History. ## Edit -Container edit opens the same [[Run / Edit Form|Run-Edit-Form]] used for new -containers. Toolbar panel navigation opens it in the [[Creation Workflow|Creation-Workflow]] +Container edit opens the same [Run / Edit Form](/docs/features/Run-Edit-Form.md) used for new +containers. Toolbar panel navigation opens it in the [Creation Workflow](/docs/features/Creation-Workflow.md) morph; classic routing opens the same form state as a sheet. diff --git a/docs/wiki/Creation-Workflow.md b/docs/features/Creation-Workflow.md similarity index 95% rename from docs/wiki/Creation-Workflow.md rename to docs/features/Creation-Workflow.md index 4d5145eb..1402fcfa 100644 --- a/docs/wiki/Creation-Workflow.md +++ b/docs/features/Creation-Workflow.md @@ -36,7 +36,7 @@ Creation flow pages include: - build The final container form is `ContainerConfigureView`, shared by new runs and -edits. See [[Run / Edit Form|Run-Edit-Form]]. +edits. See [Run / Edit Form](/docs/features/Run-Edit-Form.md). ## Feature gates diff --git a/docs/wiki/Features.md b/docs/features/Features.md similarity index 60% rename from docs/wiki/Features.md rename to docs/features/Features.md index ab982127..feb899b9 100644 --- a/docs/wiki/Features.md +++ b/docs/features/Features.md @@ -8,27 +8,27 @@ Toolbar-first UI and toolbar panel navigation are experimental opt-ins from ## Feature areas -- **[[Containers|Features-Containers]]** — lifecycle, detail pages, logs, +- **[Containers](/docs/features/Containers.md)** — lifecycle, detail pages, logs, terminal, stats, files, app-managed restart/health, and local card personalization. -- **[[Images|Features-Images]]** — local images and tags, run, pull, load/save, +- **[Images](/docs/features/Images.md)** — local images and tags, run, pull, load/save, tag, push, history, image updates, Docker Hub search, and the build workspace. -- **[[Resources|Features-Resources]]** — volumes, networks, registries, +- **[Resources](/docs/features/Resources.md)** — volumes, networks, registries, templates, and activity history. -- **[[System & Settings|System-Settings]]** — service status, runtime defaults, +- **[System & Settings](/docs/app/System-Settings.md)** — service status, runtime defaults, app settings, experimental gates, updates, and local data. ## Shared workflows -- **[[Creation Workflow|Creation-Workflow]]** — the shared front door for run, +- **[Creation Workflow](/docs/features/Creation-Workflow.md)** — the shared front door for run, edit, pull/search, compose import, network creation, volume creation, and image build work. -- **[[Run / Edit Form|Run-Edit-Form]]** — native controls over `container run` +- **[Run / Edit Form](/docs/features/Run-Edit-Form.md)** — native controls over `container run` flags with a live CLI preview. -- **[[Compose Import|Compose-Import]]** — paste, pick, or drag Compose YAML into +- **[Compose Import](/docs/features/Compose-Import.md)** — paste, pick, or drag Compose YAML into editable run forms. -- **[[Command Palette|Command-Palette]]** — fuzzy app-wide action index. -- **[[Updates]]** — Sparkle app updates, branch appcasts, image update checks, +- **[Command Palette](/docs/features/Command-Palette.md)** — fuzzy app-wide action index. +- **[Updates](/docs/app/Updates.md)** — Sparkle app updates, branch appcasts, image update checks, and release notes. ## Throughout diff --git a/docs/wiki/Features-Images.md b/docs/features/Images.md similarity index 86% rename from docs/wiki/Features-Images.md rename to docs/features/Images.md index cd4ab767..a64159b4 100644 --- a/docs/wiki/Features-Images.md +++ b/docs/features/Images.md @@ -36,7 +36,7 @@ cards, palette results, toolbar panels, and System can show whether an update is available. Manual checks are available from Images, System, the toolbar, and the command -palette. Background cadence is configured in [[Updates]]. +palette. Background cadence is configured in [Updates](/docs/app/Updates.md). ## Build workspace @@ -44,4 +44,4 @@ The image build workspace is experimental and defaults off. Enable **Settings Experimental → Image build workspace** to build from a Dockerfile and context while streaming the BuildKit log. -The build path is entered through the shared [[Creation Workflow|Creation-Workflow]]. +The build path is entered through the shared [Creation Workflow](/docs/features/Creation-Workflow.md). diff --git a/docs/wiki/Features-Resources.md b/docs/features/Resources.md similarity index 88% rename from docs/wiki/Features-Resources.md rename to docs/features/Resources.md index c1629c38..7b9802a9 100644 --- a/docs/wiki/Features-Resources.md +++ b/docs/features/Resources.md @@ -12,7 +12,7 @@ used for scan-friendly resource cards. Networks can be browsed, created, attached during container runs, and deleted or pruned when appropriate. The create-network path is part of the -shared [[Creation Workflow|Creation-Workflow]]. +shared [Creation Workflow](/docs/features/Creation-Workflow.md). ## Registries @@ -26,7 +26,7 @@ but they route to Settings. ## Templates Templates save reusable container run configurations. Using a template fills the -same [[Run / Edit Form|Run-Edit-Form]] as other creation paths. +same [Run / Edit Form](/docs/features/Run-Edit-Form.md) as other creation paths. ## Activity diff --git a/docs/wiki/Run-Edit-Form.md b/docs/features/Run-Edit-Form.md similarity index 100% rename from docs/wiki/Run-Edit-Form.md rename to docs/features/Run-Edit-Form.md diff --git a/docs/wiki/Release.md b/docs/release/Release.md similarity index 99% rename from docs/wiki/Release.md rename to docs/release/Release.md index 03478045..6fa3f3e2 100644 --- a/docs/wiki/Release.md +++ b/docs/release/Release.md @@ -1,6 +1,6 @@ # Release -The maintainer runbook for cutting a build and feeding the Sparkle update channels. Most of this is scripted; the parts that need Apple credentials or the Sparkle signing key are called out. For just installing or building, see [[Installation]]. +The maintainer runbook for cutting a build and feeding the Sparkle update channels. Most of this is scripted; the parts that need Apple credentials or the Sparkle signing key are called out. For just installing or building, see [Installation](/docs/app/Installation.md). ## Versioning diff --git a/docs/wiki/README.md b/docs/wiki/README.md new file mode 100644 index 00000000..a87c827c --- /dev/null +++ b/docs/wiki/README.md @@ -0,0 +1,12 @@ +# Wiki Mirror Note + +The old flat wiki mirror moved into structured docs: + +- [`docs/app`](../app) +- [`docs/features`](../features) +- [`docs/development`](../development) +- [`docs/architecture`](../architecture) +- [`docs/release`](../release) + +When syncing GitHub wiki content, use those structured files as the maintained +source and adapt page names only at the wiki boundary. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 4106d6c6..01c1cde3 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -1,26 +1,29 @@ # Contained -- [[Home]] -- [[Features]] - - [[Containers|Features-Containers]] - - [[Images|Features-Images]] - - [[Resources|Features-Resources]] - - [[System & Settings|System-Settings]] +This compatibility sidebar points to the structured docs that replaced the old +flat wiki mirror. + +- [Home](/docs/app/Home.md) +- [Features](/docs/features/Features.md) + - [Containers](/docs/features/Containers.md) + - [Images](/docs/features/Images.md) + - [Resources](/docs/features/Resources.md) + - [System & Settings](/docs/app/System-Settings.md) - Workflows - - [[Creation Workflow|Creation-Workflow]] - - [[Run / Edit Form|Run-Edit-Form]] - - [[Compose Import|Compose-Import]] - - [[Command Palette|Command-Palette]] - - [[Updates]] + - [Creation Workflow](/docs/features/Creation-Workflow.md) + - [Run / Edit Form](/docs/features/Run-Edit-Form.md) + - [Compose Import](/docs/features/Compose-Import.md) + - [Command Palette](/docs/features/Command-Palette.md) + - [Updates](/docs/app/Updates.md) - Start - - [[Installation]] - - [[Keyboard Shortcuts|Keyboard-Shortcuts]] - - [[Troubleshooting]] + - [Installation](/docs/app/Installation.md) + - [Keyboard Shortcuts](/docs/app/Keyboard-Shortcuts.md) + - [Troubleshooting](/docs/app/Troubleshooting.md) - Maintainers - - [[Architecture]] - - [[Runtime Adapters|Runtime-Adapters]] - - [[Design System|Design-System]] - - [[Localization]] - - [[Release]] -- [[Contributing]] -- [[Issues-and-Discussions]] + - [Architecture](/docs/architecture/Architecture.md) + - [Runtime Adapters](/docs/architecture/Runtime-Adapters.md) + - [Design System](/docs/architecture/Design-System.md) + - [Localization](/docs/app/Localization.md) + - [Release](/docs/release/Release.md) +- [Contributing](/docs/development/Contributing.md) +- [Issues and Discussions](/docs/development/Issues-and-Discussions.md) diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 8f7515fc..39ac8581 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -59,11 +59,16 @@ if [ -d "$FRAMEWORK_SRC" ]; then install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/Contained" 2>/dev/null || true fi -# Bundle the compiled String Catalog (Base localization) if SwiftPM produced one. -BUNDLE_RES="$(swift build -c "$CONFIG" --show-bin-path)/Contained_Contained.bundle" -if [ -d "$BUNDLE_RES" ]; then - cp -R "$BUNDLE_RES" "$APP/Contents/Resources/" || true -fi +# Bundle app resources if SwiftPM produced them. The app implementation lives in the +# ContainedApp library target; keep the previous bundle name as a fallback for older build folders. +BUILD_PRODUCTS="$(swift build -c "$CONFIG" --show-bin-path)" +for bundle_name in Contained_ContainedApp.bundle Contained_Contained.bundle; do + BUNDLE_RES="$BUILD_PRODUCTS/$bundle_name" + if [ -d "$BUNDLE_RES" ]; then + cp -R "$BUNDLE_RES" "$APP/Contents/Resources/" || true + break + fi +done cat > "$APP/Contents/Info.plist" < diff --git a/scripts/require-release-note.sh b/scripts/require-release-note.sh index 2a2576be..eba9e37c 100755 --- a/scripts/require-release-note.sh +++ b/scripts/require-release-note.sh @@ -34,7 +34,7 @@ while IFS= read -r file; do [ -n "$file" ] || continue case "$file" in - CHANGELOG.md|Sources/Contained/Resources/CHANGELOG.md|RELEASE_NOTES.md|CHANGES.md|changes/*.md|changes/*/*.md) + CHANGELOG.md|Sources/ContainedApp/Resources/CHANGELOG.md|RELEASE_NOTES.md|CHANGES.md|changes/*.md|changes/*/*.md) has_note=true ;; esac @@ -55,7 +55,7 @@ if $has_material_change && ! $has_note; then done <<< "$changed_files" echo >&2 echo "Add one of:" >&2 - echo " - CHANGELOG.md plus synced Sources/Contained/Resources/CHANGELOG.md" >&2 + echo " - CHANGELOG.md plus synced Sources/ContainedApp/Resources/CHANGELOG.md" >&2 echo " - changes/unreleased/YYYYMMDD-short-slug.md" >&2 echo " - RELEASE_NOTES.md / CHANGES.md when the release train uses split files" >&2 echo >&2 diff --git a/scripts/sync-changelog-resource.sh b/scripts/sync-changelog-resource.sh index 06897cff..7a171921 100755 --- a/scripts/sync-changelog-resource.sh +++ b/scripts/sync-changelog-resource.sh @@ -8,7 +8,7 @@ set -euo pipefail cd "$(dirname "$0")/.." SOURCE="CHANGELOG.md" -TARGET="Sources/Contained/Resources/CHANGELOG.md" +TARGET="Sources/ContainedApp/Resources/CHANGELOG.md" MODE="${1:-sync}" [ -f "$SOURCE" ] || { echo "✗ $SOURCE not found" >&2; exit 1; } diff --git a/scripts/validate-bundle.sh b/scripts/validate-bundle.sh index a8824300..09b75dd3 100755 --- a/scripts/validate-bundle.sh +++ b/scripts/validate-bundle.sh @@ -5,7 +5,6 @@ set -euo pipefail app="${1:-Contained.app}" plist="$app/Contents/Info.plist" binary="$app/Contents/MacOS/Contained" -resource_changelog="$app/Contents/Resources/Contained_Contained.bundle/CHANGELOG.md" sparkle_framework="$app/Contents/Frameworks/Sparkle.framework" fail() { @@ -16,7 +15,15 @@ fail() { [ -d "$app" ] || fail "App bundle '$app' was not found" [ -f "$plist" ] || fail "Info.plist is missing" [ -x "$binary" ] || fail "Executable '$binary' is missing or not executable" -[ -f "$resource_changelog" ] || fail "Bundled CHANGELOG.md resource is missing" +resource_changelog="" +for bundle_name in Contained_ContainedApp.bundle Contained_Contained.bundle; do + candidate="$app/Contents/Resources/$bundle_name/CHANGELOG.md" + if [ -f "$candidate" ]; then + resource_changelog="$candidate" + break + fi +done +[ -n "$resource_changelog" ] || fail "Bundled CHANGELOG.md resource is missing" [ -d "$sparkle_framework" ] || fail "Sparkle.framework is missing from the bundle" plist_value() { diff --git a/scripts/xcode-swiftpm-build.sh b/scripts/xcode-swiftpm-build.sh deleted file mode 100755 index 2130df17..00000000 --- a/scripts/xcode-swiftpm-build.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$repo_root" - -mode="${1:-build}" -product="${2:-Contained}" -configuration="$(printf '%s' "${CONFIGURATION:-Debug}" | tr '[:upper:]' '[:lower:]')" - -case "$configuration" in - debug|release) ;; - *) configuration="debug" ;; -esac - -case "$mode" in - build) - swift build -c "$configuration" --product "$product" - ;; - test) - swift test - ;; - *) - echo "Unknown Xcode SwiftPM bridge mode: $mode" >&2 - exit 64 - ;; -esac From 024972dafb886cab4dc81f7ebd9717a6d1bd773f Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 11:02:16 +0100 Subject: [PATCH 28/53] Tighten final package boundary sweep --- .../Clients/AppleContainerClient.swift | 4 ++-- .../Commands/ContainerCommands.swift | 4 ++++ .../Tests/ContainedCoreTests/CommandTests.swift | 2 ++ .../Cards/DesignCardSurface.swift | 2 +- .../Controls/InfoButton.swift | 4 ++-- .../Tokens/DesignTokens.swift | 2 +- .../Morphing/MorphingExpander.swift | 4 ++-- .../Panels/DesignPanelScaffold.swift | 4 ++-- .../Previews/NavigationPreviews.swift | 4 ++-- .../Clients/RuntimeDescriptor.swift | 7 ++++++- .../RuntimeDescriptorTests.swift | 2 +- .../Features/Containers/TerminalTab.swift | 6 +++--- Sources/ContainedApp/Stores/AppModel.swift | 16 ++++++++-------- changes/unreleased/20260701-xcode-workspace.md | 2 +- .../20260702-standalone-packages-xcode.md | 1 + 15 files changed, 38 insertions(+), 26 deletions(-) diff --git a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Clients/AppleContainerClient.swift b/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Clients/AppleContainerClient.swift index da9f9207..271632e2 100644 --- a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Clients/AppleContainerClient.swift +++ b/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Clients/AppleContainerClient.swift @@ -159,8 +159,8 @@ public struct AppleContainerClient: Sendable { try await runner.run(arguments) } - @discardableResult public func performSystemAction(_ action: String) async throws -> Data { - try await runner.run(["system", action]) + @discardableResult public func performSystemAction(_ action: RuntimeSystemAction) async throws -> Data { + try await runner.run(["system", action.rawValue]) } // MARK: Registries diff --git a/Packages/ContainedCore/Sources/ContainedCore/Commands/ContainerCommands.swift b/Packages/ContainedCore/Sources/ContainedCore/Commands/ContainerCommands.swift index 1be5b7d5..effc94d5 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Commands/ContainerCommands.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Commands/ContainerCommands.swift @@ -46,6 +46,10 @@ public enum ContainerCommands { public static func containerPrune() -> [String] { ["prune"] } /// `container exec ` (no TTY) — for one-shot captures like `ps`, `ls`. public static func exec(_ id: String, _ command: [String]) -> [String] { ["exec", id] + command } + /// `container exec --interactive --tty ` — for hosted terminal sessions. + public static func execInteractive(_ id: String, shell: String) -> [String] { + ["exec", "--interactive", "--tty", id, shell] + } /// `container export --output ` — export a container's filesystem as a tar archive. /// Note: this is a filesystem tarball, **not** an OCI image (the runtime has no `commit`). public static func containerExport(_ id: String, output: String) -> [String] { diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift index 368524aa..900bb93c 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift @@ -14,6 +14,8 @@ struct CommandTests { #expect(ContainerCommands.start(["a", "b"]) == ["start", "a", "b"]) #expect(ContainerCommands.stop(["a"], signal: "SIGTERM", time: 5) == ["stop", "--signal", "SIGTERM", "--time", "5", "a"]) #expect(ContainerCommands.deleteContainers(["a"], force: true) == ["delete", "--force", "a"]) + #expect(ContainerCommands.execInteractive("web", shell: "/bin/sh") + == ["exec", "--interactive", "--tty", "web", "/bin/sh"]) } @Test func logsArgv() { diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift index 74621812..35fd8334 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift @@ -54,7 +54,7 @@ public struct DesignCardSurface: View { content() // Lay the content out ONCE at the final panel size, then reveal it through the // growing/clipping window below. Framing it to the *animating* rect instead would - // re-lay-out — and re-draw Canvas-based sparklines (System's volume cards) — on every - // frame of the open spring, which made content-heavy panels jitter. + // re-lay-out — and re-draw Canvas-based sparklines — on every frame of the open + // spring, which made content-heavy panels jitter. .frame(width: max(target.width, 1), height: max(target.height, 1), alignment: .top) // Fade only foreground content. The panel surface and shadow are separate, always // visible layers so elevation participates in the morph instead of popping in late. diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/Panels/DesignPanelScaffold.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/Panels/DesignPanelScaffold.swift index 609b1975..ce316b1c 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/Panels/DesignPanelScaffold.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/Panels/DesignPanelScaffold.swift @@ -7,8 +7,8 @@ import ContainedDesignSystem /// The panel takes the **fixed size** its host hands the `MorphingExpander` (e.g. `DesignTokens.PanelSize.*`). /// The inner `ScrollView` simply fills that area and scrolls; it does **not** measure its content. This /// matters for performance: an earlier version measured the scroll content's natural height (to make the -/// panel hug it), which forced the whole `LazyVStack` to realize on open — slow for long lists like -/// Activity. Filling a definite height keeps the list lazy (only visible rows render). +/// panel hug it), which forced the whole `LazyVStack` to realize on open. Filling a definite height +/// keeps long lists lazy (only visible rows render). /// /// Pass `scrolls: false` for content that brings **its own** scroll view (search results, build /// workspace, the paged run form). In that mode the scaffold doesn't wrap the content in a `ScrollView`, diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift index 73b82247..35b98557 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift @@ -41,8 +41,8 @@ private struct NavigationPreview: View { } content: { VStack(spacing: DesignTokens.Space.s) { GlassOptionTile(symbol: "shippingbox", - title: "Container", - subtitle: "Start from an image") {} + title: "Primary item", + subtitle: "Start from a template") {} GlassOptionTile(symbol: "square.stack.3d.up", title: "Image", subtitle: "Use a local image") {} diff --git a/Packages/ContainedRuntime/Sources/ContainedRuntime/Clients/RuntimeDescriptor.swift b/Packages/ContainedRuntime/Sources/ContainedRuntime/Clients/RuntimeDescriptor.swift index 4d3fe1c5..e9d43580 100644 --- a/Packages/ContainedRuntime/Sources/ContainedRuntime/Clients/RuntimeDescriptor.swift +++ b/Packages/ContainedRuntime/Sources/ContainedRuntime/Clients/RuntimeDescriptor.swift @@ -103,6 +103,11 @@ extension UnsupportedRuntimeCapability: ContainedPackageError { } } +public enum RuntimeSystemAction: String, CaseIterable, Sendable { + case start + case stop +} + public protocol ContainerRuntimeClient: Sendable { var descriptor: RuntimeDescriptor { get } @@ -135,7 +140,7 @@ public protocol ContainerRuntimeClient: Sendable { platform: String?) -> AsyncThrowingStream func streamPush(_ ref: String, platform: String?) -> AsyncThrowingStream @discardableResult func runContainer(arguments: [String]) async throws -> Data - @discardableResult func performSystemAction(_ action: String) async throws -> Data + @discardableResult func performSystemAction(_ action: RuntimeSystemAction) async throws -> Data func registries() async throws -> [RegistryLogin] @discardableResult func registryLogin(server: String, username: String, password: String) async throws -> Data @discardableResult func registryLogout(server: String) async throws -> Data diff --git a/Packages/ContainedRuntime/Tests/ContainedRuntimeTests/RuntimeDescriptorTests.swift b/Packages/ContainedRuntime/Tests/ContainedRuntimeTests/RuntimeDescriptorTests.swift index e451d690..a633e6f5 100644 --- a/Packages/ContainedRuntime/Tests/ContainedRuntimeTests/RuntimeDescriptorTests.swift +++ b/Packages/ContainedRuntime/Tests/ContainedRuntimeTests/RuntimeDescriptorTests.swift @@ -89,7 +89,7 @@ private struct UnavailableRuntime: ContainerRuntimeClient { AsyncThrowingStream { $0.finish() } } func runContainer(arguments: [String]) async throws -> Data { throw TestStubError.unused } - func performSystemAction(_ action: String) async throws -> Data { throw TestStubError.unused } + func performSystemAction(_ action: RuntimeSystemAction) async throws -> Data { throw TestStubError.unused } func registries() async throws -> [RegistryLogin] { [] } func registryLogin(server: String, username: String, password: String) async throws -> Data { throw TestStubError.unused } func registryLogout(server: String) async throws -> Data { throw TestStubError.unused } diff --git a/Sources/ContainedApp/Features/Containers/TerminalTab.swift b/Sources/ContainedApp/Features/Containers/TerminalTab.swift index 3cf67922..27aac11f 100644 --- a/Sources/ContainedApp/Features/Containers/TerminalTab.swift +++ b/Sources/ContainedApp/Features/Containers/TerminalTab.swift @@ -104,8 +104,8 @@ struct TerminalSurface: NSViewRepresentable { view.nativeBackgroundColor = NSColor.black.withAlphaComponent(DesignTokens.Terminal.nativeBackgroundOpacity) view.nativeForegroundColor = NSColor(white: DesignTokens.Terminal.nativeForegroundWhite, alpha: 1) - // `container exec -i -t ` — PTY is provided by SwiftTerm; -t requests a TTY - // inside the container, -i keeps stdin attached. We must inherit the *host* environment + // PTY is provided by SwiftTerm; the command builder requests a TTY inside the container + // and keeps stdin attached. We must inherit the *host* environment // (notably HOME) so the `container` CLI can find its data dir — SwiftTerm's nil-default env // is too sparse and the exec would silently fail to connect. TERM/LANG drive the emulator. var env = ProcessInfo.processInfo.environment @@ -113,7 +113,7 @@ struct TerminalSurface: NSViewRepresentable { env["LANG"] = env["LANG"] ?? "en_US.UTF-8" env["COLORTERM"] = "truecolor" view.startProcess(executable: executableURL.path, - args: ["exec", "--interactive", "--tty", containerID, shell], + args: ContainerCommands.execInteractive(containerID, shell: shell), environment: env.map { "\($0.key)=\($0.value)" }, execName: nil) return view } diff --git a/Sources/ContainedApp/Stores/AppModel.swift b/Sources/ContainedApp/Stores/AppModel.swift index 0c00d7ea..7a2454fc 100644 --- a/Sources/ContainedApp/Stores/AppModel.swift +++ b/Sources/ContainedApp/Stores/AppModel.swift @@ -655,27 +655,27 @@ final class AppModel { /// Start the container system service, then re-bootstrap. func startService() async { - await runServiceLifecycle(["start"], resetWatchdog: false) + await runServiceLifecycle([.start], resetWatchdog: false) logger.record("Started container service", category: .system) } /// Stop the container system service, then re-bootstrap. func stopService() async { - await runServiceLifecycle(["stop"], resetWatchdog: true) + await runServiceLifecycle([.stop], resetWatchdog: true) logger.record("Stopped container service", category: .system, severity: .warning) } /// Stop then start the container system service, then re-bootstrap. func restartService() async { - await runServiceLifecycle(["stop", "start"], resetWatchdog: true) + await runServiceLifecycle([.stop, .start], resetWatchdog: true) logger.record("Restarted container service", category: .system, severity: .warning) } - /// Shared driver for the service lifecycle commands. Marks the app `.checking` for immediate UI - /// feedback, optionally resets the restart watchdog (so a deliberate stop isn't fought), runs each - /// `system ` in order, then re-reads service status. Failures are intentionally ignored — - /// `refreshSystem` reports the resulting state regardless. - private func runServiceLifecycle(_ actions: [String], resetWatchdog: Bool) async { + /// Shared driver for service lifecycle commands. Marks the app `.checking` for immediate UI + /// feedback, optionally resets the restart watchdog, runs each typed runtime action in order, + /// then re-reads service status. Failures are intentionally ignored because `refreshSystem` + /// reports the resulting state regardless. + private func runServiceLifecycle(_ actions: [RuntimeSystemAction], resetWatchdog: Bool) async { guard let client else { return } bootstrap = .checking if resetWatchdog { watchdog.reset() } diff --git a/changes/unreleased/20260701-xcode-workspace.md b/changes/unreleased/20260701-xcode-workspace.md index a3709c1f..23f91f36 100644 --- a/changes/unreleased/20260701-xcode-workspace.md +++ b/changes/unreleased/20260701-xcode-workspace.md @@ -1,3 +1,3 @@ ## Architecture -- Add a checked-in Xcode workspace and small shared-scheme project wrapper that opens the SwiftPM root package and local reusable packages, delegates app/test schemes back to SwiftPM, and preserves SwiftPM as the CI/release source of truth. +- Add a checked-in Xcode workspace and project entry point for local app development while preserving SwiftPM as the CI/release source of truth. diff --git a/changes/unreleased/20260702-standalone-packages-xcode.md b/changes/unreleased/20260702-standalone-packages-xcode.md index 77d9b3c6..9c23d0ba 100644 --- a/changes/unreleased/20260702-standalone-packages-xcode.md +++ b/changes/unreleased/20260702-standalone-packages-xcode.md @@ -2,3 +2,4 @@ - Split the SwiftUI app implementation into `ContainedApp` with a tiny SwiftPM launcher, while keeping app-owned localization, stores, Sparkle, SwiftData, settings, and feature routing out of reusable packages. - Replaced the legacy Xcode shell wrapper with a checked-in native macOS app target that links `ContainedApp`, builds/runs `Contained.app` directly, and includes an app test bundle for SwiftUI-focused functional checks. - Reorganized docs into app, feature, architecture, development, and release sections, updated package boundary guidance, and removed stale flat-wiki and wrapper-build wording. +- Tightened final boundary leaks by routing terminal `exec` through `ContainerCommands`, making service lifecycle actions typed runtime values, and removing app-specific wording from reusable package comments/previews. From fbae0707a168d8b3365a21ce6aa48655c7afb46f Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 11:11:02 +0100 Subject: [PATCH 29/53] Refresh ownership and release-note housekeeping --- CODEOWNERS | 10 ++++++++-- Package.swift | 4 ++-- Packages/ContainedDesignSystem/README.md | 2 +- .../ContainedDesignSystem/Panels/PanelSection.swift | 2 +- .../unreleased/20260702-standalone-packages-xcode.md | 1 + docs/architecture/Architecture.md | 2 +- docs/development/Contributing.md | 4 ++-- scripts/require-release-note.sh | 2 +- 8 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index fd054870..9e0c3aed 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -2,8 +2,14 @@ * @tdeverx # Core app areas. -/Sources/ContainedCore/ @tdeverx +/Packages/ContainedCore/ @tdeverx +/Packages/ContainedRuntime/ @tdeverx +/Packages/AppleContainerRuntime/ @tdeverx +/Packages/ContainedDesignSystem/ @tdeverx +/Packages/ContainedNavigation/ @tdeverx +/Packages/ContainedPreviewSupport/ @tdeverx /Sources/Contained/ @tdeverx +/Sources/ContainedApp/ @tdeverx /Tests/ @tdeverx # Repository operations. @@ -11,7 +17,7 @@ /scripts/ @tdeverx /docs/ @tdeverx /CHANGELOG.md @tdeverx -/Sources/Contained/Resources/CHANGELOG.md @tdeverx +/Sources/ContainedApp/Resources/CHANGELOG.md @tdeverx # Release channels and update feeds. /appcast.xml @tdeverx diff --git a/Package.swift b/Package.swift index 306356ad..930a21e1 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,8 @@ let package = Package( .package(path: "Packages/ContainedDesignSystem"), .package(path: "Packages/ContainedNavigation"), .package(path: "Packages/ContainedPreviewSupport"), - // The in-container terminal (Phase 5): a mature VT100/xterm emulator + PTY host, far safer - // than re-implementing one. AppKit-backed, bridged via NSViewRepresentable. + // Mature VT100/xterm emulator + PTY host for the in-container terminal. AppKit-backed, + // bridged through NSViewRepresentable in the app target. .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0"), // Auto-update (release): Sparkle, the de-facto macOS updater. Inert until a signed build // points SUFeedURL at a hosted appcast (see scripts/appcast.sh). diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 7f95311c..1b46af9c 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -15,7 +15,7 @@ plain values into package views instead. This package also does not own localized resources or English UI defaults. When a primitive needs visible text, accessibility copy, help, display names, or error/failure messages, the app supplies those strings through parameters. The -executable target owns localization keys and English fallbacks. +app target owns localization keys and English fallbacks. ## Importing diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelSection.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelSection.swift index ea93d132..09c7f959 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelSection.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelSection.swift @@ -13,7 +13,7 @@ private extension EnvironmentValues { /// A grouped settings-style section for hugging panels — the intrinsic-height replacement for a Form /// `Section`. Renders an optional header label, a flat glass card holding its rows, and an optional -/// caption footer, matching the app's glass-card language instead of Form's solid grouped backing. +/// caption footer, matching the package's glass-card language instead of Form's solid grouped backing. /// /// Supports two header affordances: `collapsible` (a chevron that folds the card away) and an `enabled` /// binding (a switch in the header that disables/hides the body — used for opt-in sections like the diff --git a/changes/unreleased/20260702-standalone-packages-xcode.md b/changes/unreleased/20260702-standalone-packages-xcode.md index 9c23d0ba..35c5cf7c 100644 --- a/changes/unreleased/20260702-standalone-packages-xcode.md +++ b/changes/unreleased/20260702-standalone-packages-xcode.md @@ -3,3 +3,4 @@ - Replaced the legacy Xcode shell wrapper with a checked-in native macOS app target that links `ContainedApp`, builds/runs `Contained.app` directly, and includes an app test bundle for SwiftUI-focused functional checks. - Reorganized docs into app, feature, architecture, development, and release sections, updated package boundary guidance, and removed stale flat-wiki and wrapper-build wording. - Tightened final boundary leaks by routing terminal `exec` through `ContainerCommands`, making service lifecycle actions typed runtime values, and removing app-specific wording from reusable package comments/previews. +- Updated ownership and release-note enforcement so `Packages/**` changes are covered by repository housekeeping after the package split. diff --git a/docs/architecture/Architecture.md b/docs/architecture/Architecture.md index 76427954..f3e636cc 100644 --- a/docs/architecture/Architecture.md +++ b/docs/architecture/Architecture.md @@ -20,7 +20,7 @@ Contained is a SwiftUI-native macOS app that wraps Apple's `container` CLI. It s - **`ContainedApp`** — the shared SwiftUI app implementation: views, `@Observable` stores, app-specific presentation mappings, localization, navigation, and the SwiftData history stack. Depends on `ContainedCore`, `ContainedRuntime`, `AppleContainerRuntime`, `ContainedDesignSystem`, `ContainedNavigation`, `ContainedPreviewSupport`, SwiftTerm, and Sparkle. - **`Contained`** — the tiny SwiftPM executable launcher used by command-line builds and bundle scripts. -The executable target owns localization. Reusable packages do not ship localized +`ContainedApp` owns localization. Reusable packages do not ship localized resources or English UI defaults; app code supplies user-facing text through package parameters and routes reusable enum labels/dynamic templates through `AppText`. `ContainedCore`, `ContainedRuntime`, and adapter targets stay diff --git a/docs/development/Contributing.md b/docs/development/Contributing.md index 4d129472..5d92c33c 100644 --- a/docs/development/Contributing.md +++ b/docs/development/Contributing.md @@ -84,7 +84,7 @@ appcast.xml Sparkle feed at the root of each release branch - **Agents start at `AGENTS.md`.** Coding agents should read the root agent guide before editing; it summarizes branch, update, release-note, design-system, and verification rules. - **Directory names are intentional.** SwiftPM folders stay `Sources` and `Tests`, Swift source domains use PascalCase, and repository infrastructure uses lowercase names such as `docs` and `scripts`. Put helper scripts in `scripts/` and use hyphenated names for multi-word shell scripts. -- **Reusable packages live under `Packages/`.** Keep app-agnostic design primitives, tokens, spacing, material, opacity, and micro-chrome in `ContainedDesignSystem`; keep app state, stores, Sparkle, SwiftData, persistence, and feature routing in the executable target. +- **Reusable packages live under `Packages/`.** Keep app-agnostic design primitives, tokens, spacing, material, opacity, and micro-chrome in `ContainedDesignSystem`; keep app state, stores, Sparkle, SwiftData, persistence, and feature routing in `Sources/ContainedApp`. - **The app owns localization.** Reusable packages should not introduce user-facing English defaults or localized resource bundles. If a package component needs text, add an explicit parameter and pass app-owned strings @@ -99,7 +99,7 @@ appcast.xml Sparkle feed at the root of each release branch - **Package docs live with the package.** Keep package-local import/setup/examples in each `Packages//README.md`, with DocC landing pages under each target's `.docc` catalog. Keep app-level architecture and workflow guidance under `docs/`. - **Xcode opens the workspace.** `Contained.xcworkspace` points at the native `Contained.xcodeproj` and local package manifests. The Xcode target links the root package's `ContainedApp` product and builds/runs a real `Contained.app`; SwiftPM remains the release, CI, bundle, signing, notarization, and appcast source of truth. - **Use Xcode for functional SwiftUI loops.** The shared `Contained` scheme builds/runs the app and runs `ContainedAppTests`; `ContainedAppTests` is the focused app-test scheme; package schemes come from the package manifests; `ContainedPreviews` is reserved for preview-oriented development. -- **Navigation infrastructure belongs in `ContainedNavigation` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in the executable target until they can cross the boundary without app policy. +- **Navigation infrastructure belongs in `ContainedNavigation` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in `Sources/ContainedApp` until they can cross the boundary without app policy. - **Every Apple `container` CLI action goes through a `ContainerCommands` builder** + `AppleContainerRuntime`, with a golden-argv test. The UI never assembles argv inline — this keeps "Reveal CLI" honest. - **Runtime-facing code should depend on `ContainerRuntimeClient` where a backend choice matters.** The Apple `container` implementation remains the default adapter; future Docker-compatible, Podman, Lima-backed, remote, or other runtimes should be sibling adapter targets that advertise capability differences through `RuntimeDescriptor`. Create/import flows should translate through `ContainerCreateRequest` and carry `RuntimeKind` per container, not as a global app setting. - **Pure decision logic is factored into `ContainedCore`** (`RestartDecision`, `HealthDecision`, compose ordering) and unit-tested without spawning processes. diff --git a/scripts/require-release-note.sh b/scripts/require-release-note.sh index eba9e37c..f2667696 100755 --- a/scripts/require-release-note.sh +++ b/scripts/require-release-note.sh @@ -40,7 +40,7 @@ while IFS= read -r file; do esac case "$file" in - Package.swift|Package.resolved|VERSION|Sources/**|Tests/**|Resources/**|scripts/**|.github/workflows/**) + Package.swift|Package.resolved|VERSION|Sources/**|Tests/**|Packages/**|Resources/**|scripts/**|.github/workflows/**) has_material_change=true ;; esac From 25d99eb02e9faae6a6a3dd831d1d00bf4d63be2e Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 11:26:03 +0100 Subject: [PATCH 30/53] Hide design system internals --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- Packages/ContainedDesignSystem/README.md | 15 ++- .../Cards/DesignCardChrome.swift | 122 +++++++++--------- .../Cards/DesignCardSurface.swift | 14 +- .../ContainedDesignSystem.md | 15 +-- .../Controls/GlassButton.swift | 44 +++---- .../Surfaces/ExteriorShadow.swift | 14 +- .../Surfaces/GlassSurface.swift | 64 ++++----- .../Surfaces/VisualEffectBackground.swift | 14 +- .../Morphing/MorphingSingleSurface.swift | 4 +- Sources/ContainedApp/Resources/CHANGELOG.md | 2 +- docs/architecture/Architecture.md | 2 +- docs/architecture/Design-System.md | 15 +-- 14 files changed, 161 insertions(+), 168 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a39b5362..8c937357 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ This file is the working contract for coding agents in this repository. Follow i ## Design And UI Rules - Reuse app-facing design-system routes before adding local styling: `PanelHeader`, `PanelSection`, `PanelField`, `DesignCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `CommandPreviewBar`, `TintSelector`, and `DesignTokens`. -- Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or micro-chrome constants. Add or extend a `ContainedDesignSystem` token/primitive first, then consume it from the app. Low-level package composition pieces such as `DesignCardSurface`, `DesignCardHeader`, `GlassButton`, and glass surface modifiers should not be called from `Sources/ContainedApp`. +- Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or micro-chrome constants. Add or extend a `ContainedDesignSystem` token/primitive first, then consume it from the app. Low-level package composition pieces such as card shell/header/page-rail assembly, glass button groups, and glass surface modifiers are package-internal and should not be reintroduced in `Sources/ContainedApp`. - Keep the classic sidebar fallback working. Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements. - Prefer native macOS/Liquid Glass behavior over custom chrome when the system primitive fits. - Do not make broad visual changes without a product reason. diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a18e19..0398a3fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,7 @@ #### Personalization & Design System - Local-only personalization for containers, image groups, image tags, and volumes, including nickname, icon, tint, background, graph/widget choices, and inheritance from image or app defaults. -- Shared Liquid Glass design system primitives: `DesignCardSurface`, `GlassSurface`, `GlassButton`, `GlassOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. +- Shared Liquid Glass design system primitives: `DesignCard`, `DesignContentSurface`, `DesignInputSurface`, `GlassOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. - Accessibility-aware custom visual effects and motion handling, including Reduce Transparency and Reduce Motion support where the app supplies custom glass or animation. - Shared page and panel scaffolds so sidebar pages, sheets, and toolbar morphs can reuse content without duplicate layouts. diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 1b46af9c..9ed9125a 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -41,20 +41,21 @@ This package currently depends only on platform frameworks available to a macOS keycap, card, terminal, and menu-bar constants. - `WindowMaterial`, `DesignTint`, `ColorLayerBlendMode`, and root environment values for shared material/tint policy. -- `GlassSurface`, `glassSurface`, `glassCapsuleSurface`, and visual-effect - helpers for all reusable glass treatment. +- Named surface routes such as `DesignContentSurface`, `DesignInputSurface`, + panel scaffolds, and toolbar controls. Low-level glass modifiers and + visual-effect bridges are package implementation details. - `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `SheetHeader`, and `PageScaffold` for app-neutral scaffolding. - `DesignActionGroup`, `DesignActionCluster`, `DesignInputCluster`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `DesignStatusBanner`, and toolbar controls for package-owned command chrome. - `GlassButton`, `GlassButtonItem`, and `GlassButtonInputItem` are lower-level - package composition pieces. + Low-level glass button groups are package implementation details behind these + named controls. - `DesignCard`, `DesignCardPages`, `DesignCardFooterChip`, `DesignCardFooterButton`, `DesignCardWidgetGroup`, `DesignCardInsetSection`, and other `DesignCard*` pieces for repeated card layouts and card-local controls. - App code should use `DesignCard`; `DesignCardSurface` and header primitives are - lower-level package composition pieces. + App code should use `DesignCard`; card shell, header, and page-rail assembly is + package-owned. Use `designCardFloatingControls` and `designCardProgressOverlay` for card overlays instead of app-local `.overlay` recipes. - `ActivityStatusView` with `ActivityStatusPresentation`, where callers provide @@ -168,7 +169,7 @@ struct DesignSystemExample: View { } ``` -## Resource Card Controls +## Design Card Controls Keep card-local controls in the package. Feature views provide plain values and actions instead of assembling headers, footer groups, or expanded-card page rails: diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift index 3e253dc9..1a3b4198 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift @@ -2,18 +2,18 @@ import SwiftUI /// A reusable three-part card header: leading accessory, fill/truncate text block, and trailing /// button rail. This keeps the container/image cards using the same top-aligned chrome structure. -public struct DesignCardHeader: View { - public var spacing: CGFloat - public var padding: CGFloat - @ViewBuilder public var leading: () -> Leading - @ViewBuilder public var content: () -> Content - @ViewBuilder public var trailing: () -> Trailing - - public init(spacing: CGFloat = DesignTokens.DesignCard.padding, - padding: CGFloat = DesignTokens.DesignCard.padding, - @ViewBuilder leading: @escaping () -> Leading, - @ViewBuilder content: @escaping () -> Content, - @ViewBuilder trailing: @escaping () -> Trailing) { +struct DesignCardHeader: View { + var spacing: CGFloat + var padding: CGFloat + @ViewBuilder var leading: () -> Leading + @ViewBuilder var content: () -> Content + @ViewBuilder var trailing: () -> Trailing + + init(spacing: CGFloat = DesignTokens.DesignCard.padding, + padding: CGFloat = DesignTokens.DesignCard.padding, + @ViewBuilder leading: @escaping () -> Leading, + @ViewBuilder content: @escaping () -> Content, + @ViewBuilder trailing: @escaping () -> Trailing) { self.spacing = spacing self.padding = padding self.leading = leading @@ -21,7 +21,7 @@ public struct DesignCardHeader: Vi self.trailing = trailing } - public var body: some View { + var body: some View { HStack(alignment: .top, spacing: spacing) { leading() .fixedSize(horizontal: true, vertical: false) @@ -44,20 +44,20 @@ public struct DesignCardHeader: Vi /// /// Use this for card header text so the title and metadata stay anchored to the leading chip while /// expanded-card controls appear, disappear, or change page selection. -public struct DesignCardHeaderTextBlock: View { - public var spacing: CGFloat - @ViewBuilder public var title: () -> Title - @ViewBuilder public var subtitle: () -> Subtitle +struct DesignCardHeaderTextBlock: View { + var spacing: CGFloat + @ViewBuilder var title: () -> Title + @ViewBuilder var subtitle: () -> Subtitle - public init(spacing: CGFloat = DesignTokens.DesignCard.compactTextSpacing, - @ViewBuilder title: @escaping () -> Title, - @ViewBuilder subtitle: @escaping () -> Subtitle) { + init(spacing: CGFloat = DesignTokens.DesignCard.compactTextSpacing, + @ViewBuilder title: @escaping () -> Title, + @ViewBuilder subtitle: @escaping () -> Subtitle) { self.spacing = spacing self.title = title self.subtitle = subtitle } - public var body: some View { + var body: some View { VStack(alignment: .leading, spacing: spacing) { title() subtitle() @@ -71,7 +71,7 @@ public struct DesignCardHeaderTextBlock: View { } } -public extension DesignCardHeaderTextBlock where Subtitle == EmptyView { +extension DesignCardHeaderTextBlock where Subtitle == EmptyView { init(spacing: CGFloat = DesignTokens.DesignCard.compactTextSpacing, @ViewBuilder title: @escaping () -> Title) { self.init(spacing: spacing, title: title) { @@ -104,7 +104,7 @@ public struct DesignCardFooterMini: View { } } -/// A flat inset section for content that lives inside an expanded `DesignCardSurface`. +/// A flat inset section for content that lives inside an expanded `DesignCard`. /// /// Use this for charts, process lists, read-only fields, and terminal overlays inside a card body. /// It intentionally avoids creating a second card-shaped glass surface inside the parent card. @@ -239,22 +239,22 @@ public struct DesignCardPageControlItem: Identifiable, Hashable { } /// Shared expanded-card page rail with page icons plus a close affordance. -public struct DesignCardPageControls: View { - public var items: [DesignCardPageControlItem] - public var selection: ID - public var tint: Color - public var controlsReveal: Double - public var closeLabel: String - public var onSelect: (ID) -> Void - public var onClose: () -> Void - - public init(items: [DesignCardPageControlItem], - selection: ID, - tint: Color, - controlsReveal: Double = 1, - closeLabel: String, - onSelect: @escaping (ID) -> Void, - onClose: @escaping () -> Void) { +struct DesignCardPageControls: View { + var items: [DesignCardPageControlItem] + var selection: ID + var tint: Color + var controlsReveal: Double + var closeLabel: String + var onSelect: (ID) -> Void + var onClose: () -> Void + + init(items: [DesignCardPageControlItem], + selection: ID, + tint: Color, + controlsReveal: Double = 1, + closeLabel: String, + onSelect: @escaping (ID) -> Void, + onClose: @escaping () -> Void) { self.items = items self.selection = selection self.tint = tint @@ -264,7 +264,7 @@ public struct DesignCardPageControls: View { self.onClose = onClose } - public var body: some View { + var body: some View { GlassButton(singleItem: false) { ForEach(items) { item in GlassButtonItem(tint: selection == item.id ? tint : nil, @@ -329,26 +329,26 @@ public struct DesignCardWidgetGroup: View { } /// A reusable footer band with a left group, right group, and optional widget stacked above them. -public struct DesignCardFooter: View { - public var showWidget: Bool - public var actionsVisible: Bool - public var spacing: CGFloat - public var horizontalPadding: CGFloat - public var topPadding: CGFloat - public var bottomPadding: CGFloat - @ViewBuilder public var leading: () -> Leading - @ViewBuilder public var trailing: () -> Trailing - @ViewBuilder public var widget: () -> Widget - - public init(showWidget: Bool = false, - actionsVisible: Bool = true, - spacing: CGFloat = DesignTokens.DesignCard.padding, - horizontalPadding: CGFloat = DesignTokens.DesignCard.padding, - topPadding: CGFloat = 0, - bottomPadding: CGFloat = DesignTokens.DesignCard.padding, - @ViewBuilder leading: @escaping () -> Leading, - @ViewBuilder trailing: @escaping () -> Trailing, - @ViewBuilder widget: @escaping () -> Widget) { +struct DesignCardFooter: View { + var showWidget: Bool + var actionsVisible: Bool + var spacing: CGFloat + var horizontalPadding: CGFloat + var topPadding: CGFloat + var bottomPadding: CGFloat + @ViewBuilder var leading: () -> Leading + @ViewBuilder var trailing: () -> Trailing + @ViewBuilder var widget: () -> Widget + + init(showWidget: Bool = false, + actionsVisible: Bool = true, + spacing: CGFloat = DesignTokens.DesignCard.padding, + horizontalPadding: CGFloat = DesignTokens.DesignCard.padding, + topPadding: CGFloat = 0, + bottomPadding: CGFloat = DesignTokens.DesignCard.padding, + @ViewBuilder leading: @escaping () -> Leading, + @ViewBuilder trailing: @escaping () -> Trailing, + @ViewBuilder widget: @escaping () -> Widget) { self.showWidget = showWidget self.actionsVisible = actionsVisible self.spacing = spacing @@ -360,7 +360,7 @@ public struct DesignCardFooter: Vie self.widget = widget } - public var body: some View { + var body: some View { VStack(alignment: .leading, spacing: 0) { if showWidget { widget() diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift index 35fd8334..28a8d4a7 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift @@ -44,7 +44,7 @@ public struct CardSizePicker: View { } } -public struct DesignCardSurface: View { var size: DesignCardSize var isExpanded = false @@ -74,13 +74,13 @@ public struct DesignCardSurface Self { + func selectionFill(_ on: Bool = true) -> Self { var copy = self copy.usesSelectionFill = on return copy } - public init(size: DesignCardSize, + init(size: DesignCardSize, isExpanded: Bool = false, cornerRadiusOverride: CGFloat? = nil, controlsVisible: Bool = true, @@ -120,7 +120,7 @@ public struct DesignCardSurface: View { +struct GlassButtonItem: View { var role: ButtonRole? = nil var tint: Color? = nil var help: String = "" @@ -70,7 +70,7 @@ public struct GlassButtonItem: View { return AnyShapeStyle(.primary) } - public init(role: ButtonRole? = nil, tint: Color? = nil, help: String = "", + init(role: ButtonRole? = nil, tint: Color? = nil, help: String = "", isCancel: Bool = false, isLabel: Bool = false, isIcon: Bool = false, action: (() -> Void)? = nil, @ViewBuilder label: @escaping () -> Label) { self.role = role @@ -103,7 +103,7 @@ public struct GlassButtonItem: View { .animation(.easeOut(duration: 0.15), value: hovering) } - public var body: some View { + var body: some View { Group { if let action, !isLabel { Button(role: role, action: action) { @@ -124,17 +124,17 @@ public struct GlassButtonItem: View { /// Input content that occupies the same 28pt inner lane as `GlassButtonItem`, but leaves hover/pressed /// treatment to the enclosing `GlassButton` container. -public struct GlassButtonInputItem: View { - public var spacing = DesignTokens.Toolbar.searchIconGap +struct GlassButtonInputItem: View { + var spacing = DesignTokens.Toolbar.searchIconGap @ViewBuilder var content: () -> Content - public init(spacing: CGFloat = DesignTokens.Toolbar.searchIconGap, - @ViewBuilder content: @escaping () -> Content) { + init(spacing: CGFloat = DesignTokens.Toolbar.searchIconGap, + @ViewBuilder content: @escaping () -> Content) { self.spacing = spacing self.content = content } - public var body: some View { + var body: some View { HStack(spacing: spacing) { content() } .font(.body.weight(.medium)) .padding(DesignTokens.Toolbar.iconInnerPadding) @@ -143,7 +143,7 @@ public struct GlassButtonInputItem: View { } } -public extension GlassButtonItem where Label == Image { +extension GlassButtonItem where Label == Image { init(systemName: String, role: ButtonRole? = nil, tint: Color? = nil, help: String = "", isCancel: Bool = false, isLabel: Bool = false, action: (() -> Void)? = nil) { self.role = role @@ -159,21 +159,21 @@ public extension GlassButtonItem where Label == Image { /// A pill that groups related glass button items under one shared interactive-glass capsule. This /// is the morph target for compact button groups across the app. -public struct GlassButton: View { - public var spacing: CGFloat = 0 - public var height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight - public var minWidth: CGFloat? = nil - public var singleItem: Bool = false +struct GlassButton: View { + var spacing: CGFloat = 0 + var height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight + var minWidth: CGFloat? = nil + var singleItem: Bool = false /// Set `false` for a static glass container (no hover treatment) — e.g. vanity toolbar chrome. - public var interactive: Bool = true + var interactive: Bool = true @ViewBuilder var content: () -> Content - public init(spacing: CGFloat = 0, - height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight, - minWidth: CGFloat? = nil, - singleItem: Bool = false, - interactive: Bool = true, - @ViewBuilder content: @escaping () -> Content) { + init(spacing: CGFloat = 0, + height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight, + minWidth: CGFloat? = nil, + singleItem: Bool = false, + interactive: Bool = true, + @ViewBuilder content: @escaping () -> Content) { self.spacing = spacing self.height = height self.minWidth = minWidth @@ -186,7 +186,7 @@ public struct GlassButton: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.buttonTintStyle) private var tintStyle - public var body: some View { + var body: some View { let shape = Capsule(style: .continuous) HStack(spacing: spacing) { content() } .padding(.horizontal, DesignTokens.Toolbar.iconInnerPadding) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/ExteriorShadow.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/ExteriorShadow.swift index c1b15c83..a4fbf4ae 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/ExteriorShadow.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/ExteriorShadow.swift @@ -1,19 +1,19 @@ import SwiftUI -public struct ExteriorShadow: View { - public var cornerRadius: CGFloat - public var color: Color - public var radius: CGFloat - public var y: CGFloat +struct ExteriorShadow: View { + var cornerRadius: CGFloat + var color: Color + var radius: CGFloat + var y: CGFloat - public init(cornerRadius: CGFloat, color: Color, radius: CGFloat, y: CGFloat) { + init(cornerRadius: CGFloat, color: Color, radius: CGFloat, y: CGFloat) { self.cornerRadius = cornerRadius self.color = color self.radius = radius self.y = y } - public var body: some View { + var body: some View { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) ZStack { shape diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/GlassSurface.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/GlassSurface.swift index 2f0095a4..b469c3b5 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/GlassSurface.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/GlassSurface.swift @@ -2,29 +2,29 @@ import SwiftUI /// Centralized Liquid Glass surface: real `.glassEffect()` plus a soft shadow that lifts the /// element off the backdrop, and an optional colored (optionally gradient) wash behind the glass. -public struct GlassSurface: ViewModifier { - public enum Level { case regular, thin, ultraThin } - public var level: Level - public var cornerRadius: CGFloat - public var glass: Glass +struct GlassSurface: ViewModifier { + enum Level { case regular, thin, ultraThin } + var level: Level + var cornerRadius: CGFloat + var glass: Glass /// Lift the surface off the backdrop with a soft shadow. Pass `false` for flat tiles that sit /// inside an already-elevated panel (e.g. cards in the toolbar morph panels / the creation menu). - public var shadow: Bool - public var fill: Color? - public var fillOpacity: Double - public var gradient: Bool - public var gradientAngle: Double + var shadow: Bool + var fill: Color? + var fillOpacity: Double + var gradient: Bool + var gradientAngle: Double @Environment(\.colorScheme) private var colorScheme - public init(level: Level = .regular, - cornerRadius: CGFloat = DesignTokens.Radius.card, - glass: Glass = .regular, - shadow: Bool = true, - fill: Color? = nil, - fillOpacity: Double = 0.18, - gradient: Bool = false, - gradientAngle: Double = 135) { + init(level: Level = .regular, + cornerRadius: CGFloat = DesignTokens.Radius.card, + glass: Glass = .regular, + shadow: Bool = true, + fill: Color? = nil, + fillOpacity: Double = 0.18, + gradient: Bool = false, + gradientAngle: Double = 135) { self.level = level self.cornerRadius = cornerRadius self.glass = glass @@ -35,7 +35,7 @@ public struct GlassSurface: ViewModifier { self.gradientAngle = gradientAngle } - public func body(content: Content) -> some View { + func body(content: Content) -> some View { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) // Layering, back → front: tint wash → glass → content. // `.glassEffect` puts the glass *behind* the content; the tint sits behind the glass so it @@ -94,20 +94,20 @@ public struct GlassSurface: ViewModifier { /// Capsule variant for transient bars and compact floating controls that need the same glass rules /// without pretending they are rounded cards. -public struct GlassCapsuleSurface: ViewModifier { - public var level: GlassSurface.Level - public var glass: Glass - public var shadow: Bool - public var fill: Color? - public var fillOpacity: Double +struct GlassCapsuleSurface: ViewModifier { + var level: GlassSurface.Level + var glass: Glass + var shadow: Bool + var fill: Color? + var fillOpacity: Double @Environment(\.colorScheme) private var colorScheme - public init(level: GlassSurface.Level = .regular, - glass: Glass = .regular, - shadow: Bool = true, - fill: Color? = nil, - fillOpacity: Double = 0.18) { + init(level: GlassSurface.Level = .regular, + glass: Glass = .regular, + shadow: Bool = true, + fill: Color? = nil, + fillOpacity: Double = 0.18) { self.level = level self.glass = glass self.shadow = shadow @@ -115,7 +115,7 @@ public struct GlassCapsuleSurface: ViewModifier { self.fillOpacity = fillOpacity } - public func body(content: Content) -> some View { + func body(content: Content) -> some View { let shape = Capsule() return content .clipShape(shape) @@ -148,7 +148,7 @@ public struct GlassCapsuleSurface: ViewModifier { } } -public extension View { +extension View { func glassSurface(_ level: GlassSurface.Level = .regular, cornerRadius: CGFloat = DesignTokens.Radius.card, glass: Glass = .regular, diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift index 09c326cb..f8e8338b 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift @@ -3,17 +3,17 @@ import AppKit /// Behind-window vibrancy so the desktop shows through the content area (blurred). No SwiftUI /// equivalent for `.behindWindow` blending — flagged AppKit bridge. -public struct VisualEffectBackground: NSViewRepresentable { - public var material: NSVisualEffectView.Material - public var blendingMode: NSVisualEffectView.BlendingMode +struct VisualEffectBackground: NSViewRepresentable { + var material: NSVisualEffectView.Material + var blendingMode: NSVisualEffectView.BlendingMode - public init(material: NSVisualEffectView.Material = .fullScreenUI, - blendingMode: NSVisualEffectView.BlendingMode = .behindWindow) { + init(material: NSVisualEffectView.Material = .fullScreenUI, + blendingMode: NSVisualEffectView.BlendingMode = .behindWindow) { self.material = material self.blendingMode = blendingMode } - public func makeNSView(context: Context) -> NSVisualEffectView { + func makeNSView(context: Context) -> NSVisualEffectView { let view = NSVisualEffectView() view.blendingMode = blendingMode view.state = .active @@ -21,7 +21,7 @@ public struct VisualEffectBackground: NSViewRepresentable { return view } - public func updateNSView(_ view: NSVisualEffectView, context: Context) { + func updateNSView(_ view: NSVisualEffectView, context: Context) { view.blendingMode = blendingMode view.material = material } diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingSingleSurface.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingSingleSurface.swift index f29ae31f..96e53807 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingSingleSurface.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingSingleSurface.swift @@ -50,8 +50,8 @@ public struct MorphingSingleSurface: View { /// Hosts one promoted card-like surface with the same lifecycle as `MorphingExpander`, but without /// drawing a separate panel shell around the content. /// -/// Use this when the promoted content is already its own visual surface, such as an expanded -/// `DesignCardSurface`. Panel contents should still use `MorphingExpander`. +/// Use this when the promoted content is already its own visual surface, such as an expanded design +/// card. Panel contents should still use `MorphingExpander`. public struct MorphingSingleSurfaceExpander: View { @Binding var isPresented: Bool public var originFrame: CGRect diff --git a/Sources/ContainedApp/Resources/CHANGELOG.md b/Sources/ContainedApp/Resources/CHANGELOG.md index e9a18e19..0398a3fd 100644 --- a/Sources/ContainedApp/Resources/CHANGELOG.md +++ b/Sources/ContainedApp/Resources/CHANGELOG.md @@ -68,7 +68,7 @@ #### Personalization & Design System - Local-only personalization for containers, image groups, image tags, and volumes, including nickname, icon, tint, background, graph/widget choices, and inheritance from image or app defaults. -- Shared Liquid Glass design system primitives: `DesignCardSurface`, `GlassSurface`, `GlassButton`, `GlassOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. +- Shared Liquid Glass design system primitives: `DesignCard`, `DesignContentSurface`, `DesignInputSurface`, `GlassOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. - Accessibility-aware custom visual effects and motion handling, including Reduce Transparency and Reduce Motion support where the app supplies custom glass or animation. - Shared page and panel scaffolds so sidebar pages, sheets, and toolbar morphs can reuse content without duplicate layouts. diff --git a/docs/architecture/Architecture.md b/docs/architecture/Architecture.md index f3e636cc..843102da 100644 --- a/docs/architecture/Architecture.md +++ b/docs/architecture/Architecture.md @@ -76,7 +76,7 @@ bundles, signing, notarization, and appcast scripts. ## Design system -Liquid Glass helpers and reusable primitives include `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `DesignCard`, `DesignCardInsetSection`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `DesignStatusBanner`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `DesignTokens` groups for toolbar, panel, spacing, radius, icon sizing, design cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, glass button styles, or micro-chrome recipes. App-state-aware mappings such as runtime status and graph metric extraction stay in `ContainedApp` until they can cross the boundary without depending on app/core policy. App-side cards should use `DesignCard` rather than assembling `DesignCardSurface`, headers, page rails, widgets, or footers directly, and app-side command chrome should use the named design action/toolbar controls rather than `GlassButton`, `glassSurface`, or `.buttonStyle(.glass*)` directly. Use the package READMEs for import instructions and copy-pasteable examples, and see [Design System](Design-System.md) for app-level conventions. +Liquid Glass helpers and reusable primitives include `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `DesignCard`, `DesignCardInsetSection`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `DesignStatusBanner`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `DesignTokens` groups for toolbar, panel, spacing, radius, icon sizing, design cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, glass button styles, or micro-chrome recipes. App-side cards use `DesignCard`; card shell/header/page-rail assembly and low-level glass button/surface routes are package-internal. App-state-aware mappings such as runtime status and graph metric extraction stay in `ContainedApp` until they can cross the boundary without depending on app/core policy. Use the package READMEs for import instructions and copy-pasteable examples, and see [Design System](Design-System.md) for app-level conventions. ## Local-only personalization diff --git a/docs/architecture/Design-System.md b/docs/architecture/Design-System.md index 43895578..43731683 100644 --- a/docs/architecture/Design-System.md +++ b/docs/architecture/Design-System.md @@ -149,11 +149,11 @@ Use package-owned semantic controls for command chrome: `DesignToolbarStatusButton`, `DesignToolbarActionCluster`, and `DesignToolbarVanitySlot` for toolbar-specific slots -Feature views should not call `GlassButton`, `GlassButtonItem`, -`GlassButtonInputItem`, `glassSurface`, `glassCapsuleSurface`, or -`.buttonStyle(.glass/.glassProminent)` directly. Those are package composition -pieces. If a view needs a new command shape, add a named design-system route and -then consume it from the app. +Feature views cannot call the package-internal `GlassButton`, `GlassButtonItem`, +`GlassButtonInputItem`, `glassSurface`, or `glassCapsuleSurface` routes. They +also should not use `.buttonStyle(.glass/.glassProminent)` directly. If a view +needs a new command shape, add a named design-system route and then consume it +from the app. ## Design cards @@ -191,9 +191,8 @@ false` for cards inside already-elevated morph panels. - footers stay sticky on `.medium` and `.large` cards and move into the expanded body on `.small` -Feature views should not call `DesignCardSurface`, `DesignCardHeader`, or -`DesignCardPageControls` directly. Those are package composition pieces used by -`DesignCard`. +`DesignCardSurface`, `DesignCardHeader`, and `DesignCardPageControls` are +package-internal composition pieces used by `DesignCard`. Do not create a second `DesignCard` or direct surface modifier inside an expanded card body unless the nested object is itself an independent resource From 78c65fe2bbafb04582c01d72b133e92ea9ab71ab Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 18:32:50 +0100 Subject: [PATCH 31/53] Normalize design system public API names --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- Packages/ContainedDesignSystem/README.md | 10 +++++--- .../Cards/DesignCardChrome.swift | 10 ++++---- .../Cards/DesignCardSurface.swift | 2 +- .../ContainedDesignSystem.md | 18 +++++++------ .../Controls/DesignActions.swift | 4 +-- ...ptionTile.swift => DesignOptionTile.swift} | 25 ++++++++++++++++--- ...GlassRowMenu.swift => DesignRowMenu.swift} | 5 ++-- .../Controls/GlassButton.swift | 4 +-- .../Controls/ToolbarControls.swift | 14 +++++------ ....swift => DesignSparklineMetricTile.swift} | 2 +- .../Surfaces/VisualEffectBackground.swift | 2 +- .../ContainedDesignSystem/Theme/Theme.swift | 4 +-- Packages/ContainedNavigation/README.md | 4 +-- .../Previews/NavigationPreviews.swift | 4 +-- .../Containers/Creation/CreationFlow.swift | 12 ++++----- .../Containers/Creation/CreationPages.swift | 2 +- .../Features/Containers/LogsTab.swift | 2 +- .../Features/Containers/StatsTab.swift | 6 ++--- .../Features/Images/RegistryImageSearch.swift | 2 +- .../System/Components/SystemLogsSheet.swift | 2 +- .../Features/System/SystemView.swift | 2 +- .../Navigation/ClassicShell.swift | 2 +- .../ContainedApp/Navigation/RootView.swift | 6 ++--- .../ToolbarPanels/PaletteResultCard.swift | 2 +- .../ToolbarPanels/ToolbarResourcePanels.swift | 2 +- .../Navigation/ToolbarViewOptions.swift | 12 ++++----- Sources/ContainedApp/Resources/CHANGELOG.md | 2 +- docs/architecture/Architecture.md | 2 +- docs/architecture/Design-System.md | 6 ++--- docs/development/Contributing.md | 2 +- 32 files changed, 100 insertions(+), 76 deletions(-) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/{GlassOptionTile.swift => DesignOptionTile.swift} (81%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/{GlassRowMenu.swift => DesignRowMenu.swift} (86%) rename Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/{MetricTile.swift => DesignSparklineMetricTile.swift} (97%) diff --git a/AGENTS.md b/AGENTS.md index 8c937357..f66751d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ This file is the working contract for coding agents in this repository. Follow i ## Design And UI Rules -- Reuse app-facing design-system routes before adding local styling: `PanelHeader`, `PanelSection`, `PanelField`, `DesignCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `CommandPreviewBar`, `TintSelector`, and `DesignTokens`. +- Reuse app-facing design-system routes before adding local styling: `PanelHeader`, `PanelSection`, `PanelField`, `DesignCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignToggleButton`, `DesignSelectionActionBar`, `CommandPreviewBar`, `TintSelector`, and `DesignTokens`. - Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or micro-chrome constants. Add or extend a `ContainedDesignSystem` token/primitive first, then consume it from the app. Low-level package composition pieces such as card shell/header/page-rail assembly, glass button groups, and glass surface modifiers are package-internal and should not be reintroduced in `Sources/ContainedApp`. - Keep the classic sidebar fallback working. Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements. - Prefer native macOS/Liquid Glass behavior over custom chrome when the system primitive fits. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0398a3fd..02ce7985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,7 @@ #### Personalization & Design System - Local-only personalization for containers, image groups, image tags, and volumes, including nickname, icon, tint, background, graph/widget choices, and inheritance from image or app defaults. -- Shared Liquid Glass design system primitives: `DesignCard`, `DesignContentSurface`, `DesignInputSurface`, `GlassOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. +- Shared Liquid Glass design system primitives: `DesignCard`, `DesignContentSurface`, `DesignInputSurface`, `DesignOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. - Accessibility-aware custom visual effects and motion handling, including Reduce Transparency and Reduce Motion support where the app supplies custom glass or animation. - Shared page and panel scaffolds so sidebar pages, sheets, and toolbar morphs can reuse content without duplicate layouts. diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 9ed9125a..4e359fba 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -47,10 +47,12 @@ This package currently depends only on platform frameworks available to a macOS - `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `SheetHeader`, and `PageScaffold` for app-neutral scaffolding. - `DesignActionGroup`, `DesignActionCluster`, `DesignInputCluster`, - `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, + `DesignTextActionButton`, `DesignToggleButton`, `DesignSelectionActionBar`, `DesignStatusBanner`, and toolbar controls for package-owned command chrome. Low-level glass button groups are package implementation details behind these named controls. +- `DesignOptionStack` and `DesignOptionTile` for option grids and creation-style + choice lists. - `DesignCard`, `DesignCardPages`, `DesignCardFooterChip`, `DesignCardFooterButton`, `DesignCardWidgetGroup`, `DesignCardInsetSection`, and other `DesignCard*` pieces for repeated card layouts and card-local controls. @@ -84,7 +86,7 @@ struct AppRoot: View { .environment(\.modalMaterial, WindowMaterial.sheet) .environment(\.buttonMaterial, WindowMaterial.glassClear) .environment(\.cardMaterial, WindowMaterial.glassRegular) - .environment(\.buttonTintStyle, GlassButtonTintStyle(enabled: true, + .environment(\.buttonTintStyle, DesignButtonTintStyle(enabled: true, tint: .azure, opacity: 0.18)) .environment(\.designSystemShowsInfoTips, true) @@ -128,7 +130,7 @@ struct DesignSystemExample: View { } subtitleAccessory: { EmptyView() } headerAccessory: { - GlassListRowChevron() + DesignListRowChevron() } bodyContent: { EmptyView() } footerLeading: { @@ -301,7 +303,7 @@ DesignTextActionButton(title: "Import", importArchive() } -DesignGlassToggle(isOn: $following, +DesignToggleButton(isOn: $following, title: "Follow", systemName: "arrow.down.to.line") diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift index 1a3b4198..35901624 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift @@ -492,8 +492,8 @@ public struct DesignBadgeText: View { } } -/// Flat glass row for selectable lists inside panels and sheets. -public struct GlassListRow: View { +/// Flat selectable row for lists inside panels and sheets. +public struct DesignListRow: View { public var symbol: String public var tint: Color public var title: String @@ -543,7 +543,7 @@ public struct GlassListRow: View { } } -public struct GlassListRowChevron: View { +public struct DesignListRowChevron: View { public init() {} public var body: some View { @@ -553,7 +553,7 @@ public struct GlassListRowChevron: View { } } -public extension GlassListRow where Accessory == GlassListRowChevron { +public extension DesignListRow where Accessory == DesignListRowChevron { init(symbol: String, tint: Color = .accentColor, title: String, subtitle: String?, monospacedSubtitle: Bool = true) { self.init(symbol: symbol, @@ -561,7 +561,7 @@ public extension GlassListRow where Accessory == GlassListRowChevron { title: title, subtitle: subtitle, monospacedSubtitle: monospacedSubtitle) { - GlassListRowChevron() + DesignListRowChevron() } } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift index 28a8d4a7..8853a316 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift @@ -20,7 +20,7 @@ public enum DesignCardExpandedMetrics { public static let maxWidth: CGFloat = 760 } -public struct CardSizePicker: View { +public struct DesignCardSizePicker: View { @Binding var selection: CardDensity public var title: String public var labelForDensity: (CardDensity) -> String diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index c7616981..4665bd9f 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -31,7 +31,7 @@ struct AppRoot: View { .environment(\.modalMaterial, WindowMaterial.sheet) .environment(\.buttonMaterial, WindowMaterial.glassClear) .environment(\.cardMaterial, WindowMaterial.glassRegular) - .environment(\.buttonTintStyle, GlassButtonTintStyle(enabled: true, + .environment(\.buttonTintStyle, DesignButtonTintStyle(enabled: true, tint: .azure)) .environment(\.designSystemShowsInfoTips, true) .environment(\.pageScaffoldUsesToolbarChrome, false) @@ -74,7 +74,7 @@ struct DesignSystemExample: View { } subtitleAccessory: { EmptyView() } headerAccessory: { - GlassListRowChevron() + DesignListRowChevron() } bodyContent: { EmptyView() } footerLeading: { @@ -239,7 +239,7 @@ DesignTextActionButton(title: "Import", importArchive() } -DesignGlassToggle(isOn: $following, +DesignToggleButton(isOn: $following, title: "Follow", systemName: "arrow.down.to.line") ``` @@ -252,7 +252,7 @@ DesignGlassToggle(isOn: $following, - ``WindowMaterial`` - ``DesignTint`` - ``ColorLayerBlendMode`` -- ``GlassButtonTintStyle`` +- ``DesignButtonTintStyle`` - ``DesignActionCluster`` - ``DesignInputCluster`` @@ -276,16 +276,18 @@ DesignGlassToggle(isOn: $following, - ``DesignMenuActionLabel`` - ``DesignTextActionButton`` - ``DesignTextActionProminence`` -- ``DesignGlassToggle`` +- ``DesignToggleButton`` - ``DesignSelectionActionBar`` - ``DesignStatusBanner`` - ``DesignProgressActionCapsule`` -- ``DesignGlassMenuButton`` +- ``DesignMenuButton`` - ``DesignToolbarSearchField`` - ``DesignToolbarVanitySlot`` - ``DesignToolbarStatusButton`` - ``DesignToolbarActionCluster`` -- ``GlassRowMenu`` +- ``DesignRowMenu`` +- ``DesignOptionStack`` +- ``DesignOptionTile`` ### Design Cards @@ -316,7 +318,7 @@ DesignGlassToggle(isOn: $following, - ``GraphStyle`` - ``WidgetInterpolation`` - ``SparklineScale`` -- ``MetricTile`` +- ``DesignSparklineMetricTile`` - ``DesignStatusDot`` - ``DesignStatusBadge`` - ``DesignContentSurface`` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignActions.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignActions.swift index 8fd10976..ec05c79f 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignActions.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignActions.swift @@ -256,8 +256,8 @@ public struct DesignTextActionButton: View { } } -/// Package-owned glass toggle used when a binary command belongs in toolbar/panel chrome. -public struct DesignGlassToggle: View { +/// Package-owned toggle button used when a binary command belongs in toolbar or panel chrome. +public struct DesignToggleButton: View { @Binding public var isOn: Bool public var title: String public var systemName: String diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassOptionTile.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignOptionTile.swift similarity index 81% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassOptionTile.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignOptionTile.swift index 2637d5ea..875038b3 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassOptionTile.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignOptionTile.swift @@ -1,6 +1,25 @@ import SwiftUI -public struct GlassOptionTile: View { +public struct DesignOptionStack: View { + public var spacing: CGFloat + @ViewBuilder public var content: () -> Content + + public init(spacing: CGFloat = DesignTokens.Space.s, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.content = content + } + + public var body: some View { + GlassEffectContainer(spacing: spacing) { + LazyVStack(spacing: spacing) { + content() + } + } + } +} + +public struct DesignOptionTile: View { public static let defaultHeight: CGFloat = 100 public let symbol: String @@ -59,7 +78,7 @@ public struct GlassOptionTile: View { } .padding(DesignTokens.Space.m) .frame(maxWidth: .infinity, minHeight: height, maxHeight: height, alignment: .leading) - .glassOptionTileSurface(cornerRadius: DesignTokens.Radius.card, interactive: enabled) + .designOptionTileSurface(cornerRadius: DesignTokens.Radius.card, interactive: enabled) .contentShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous)) .optionalMatchedGeometry(id: matchedID, namespace: matchedNamespace) } @@ -90,7 +109,7 @@ private extension View { modifier(OptionalMatchedGeometry(id: id, namespace: namespace)) } - func glassOptionTileSurface(cornerRadius: CGFloat, interactive: Bool) -> some View { + func designOptionTileSurface(cornerRadius: CGFloat, interactive: Bool) -> some View { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) return self .glassEffect(interactive ? .regular.interactive() : .regular, in: shape) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassRowMenu.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignRowMenu.swift similarity index 86% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassRowMenu.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignRowMenu.swift index 6a22ea22..6993a3a7 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassRowMenu.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignRowMenu.swift @@ -1,9 +1,10 @@ import SwiftUI -/// The circular glass ellipsis menu used as a trailing accessory on compact rows and detail headers. +/// The circular design-system ellipsis menu used as a trailing accessory on compact rows and +/// detail headers. /// Centralizes the styling chain and the VoiceOver label so icon-only menus are consistently /// accessible. -public struct GlassRowMenu: View { +public struct DesignRowMenu: View { public var systemImage: String public var accessibilityLabel: String @ViewBuilder public var content: () -> Content diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassButton.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassButton.swift index b9273843..31bffb69 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassButton.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassButton.swift @@ -23,7 +23,7 @@ private struct OptionalAccessibilityLabel: ViewModifier { } } -public struct GlassButtonTintStyle: Equatable, Sendable { +public struct DesignButtonTintStyle: Equatable, Sendable { public var enabled = false public var tint: DesignTint = .multicolor public var opacity = 0.18 @@ -45,7 +45,7 @@ public struct GlassButtonTintStyle: Equatable, Sendable { self.blendMode = blendMode } - public static let disabled = GlassButtonTintStyle() + public static let disabled = DesignButtonTintStyle() } /// A reusable glass button item: an icon or text button with the shared 28pt inner height and 4pt diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/ToolbarControls.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/ToolbarControls.swift index a91a238f..e632511f 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/ToolbarControls.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/ToolbarControls.swift @@ -1,10 +1,10 @@ import SwiftUI -/// Design-system controls for the app toolbar band, sized from `DesignTokens.Toolbar` to macOS 26 Liquid -/// Glass toolbar proportions. Centralizing them here keeps the toolbar, the creation tiles -/// (`GlassOptionTile`), and any future band controls visually consistent and on one source of truth. +/// Design-system controls for the app toolbar band, sized from `DesignTokens.Toolbar` to macOS 26 +/// toolbar proportions. Centralizing them here keeps the toolbar, creation tiles +/// (`DesignOptionTile`), and future band controls visually consistent. -public struct DesignGlassMenuButton: View { +public struct DesignMenuButton: View { @ViewBuilder public var menuContent: () -> MenuContent @ViewBuilder public var labelContent: () -> LabelContent @@ -147,9 +147,9 @@ public struct DesignToolbarActionCluster: View { } } -/// A toolbar-styled menu trigger that uses the same icon sizing as `GlassButtonItem` but keeps -/// the actual menu behavior native. -public struct ToolbarMenuButton: View { +/// A toolbar-styled menu trigger that uses the shared toolbar icon lane while keeping native menu +/// behavior. +public struct DesignToolbarMenuButton: View { public let systemName: String public var help: String @ViewBuilder public var content: () -> Content diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/MetricTile.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/DesignSparklineMetricTile.swift similarity index 97% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/MetricTile.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/DesignSparklineMetricTile.swift index 6f8f0a48..fbc844dc 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/MetricTile.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/DesignSparklineMetricTile.swift @@ -1,7 +1,7 @@ import SwiftUI /// A dashboard summary tile: muted label, large value, optional symbol and sparkline. -public struct MetricTile: View { +public struct DesignSparklineMetricTile: View { public let label: String public let value: String public var systemImage: String? = nil diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift index f8e8338b..e438c04f 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift @@ -30,7 +30,7 @@ struct VisualEffectBackground: NSViewRepresentable { /// Stable root-owned backing for the detail column. Pages render above this layer instead of /// applying their own window material. Translucency is always on — legibility under low-contrast /// wallpapers is left to the OS "Reduce transparency" accessibility setting. -public struct ContentBackgroundLayer: View { +public struct DesignContentBackgroundLayer: View { public var material: NSVisualEffectView.Material public init(material: NSVisualEffectView.Material = .fullScreenUI) { diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme/Theme.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme/Theme.swift index 73550eee..801e8db2 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme/Theme.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme/Theme.swift @@ -159,8 +159,8 @@ public extension EnvironmentValues { @Entry var buttonMaterial: WindowMaterial = .glassClear /// The user-chosen design-card material, seeded at the app root. @Entry var cardMaterial: WindowMaterial = .glassRegular - /// Optional color/gradient wash layered into glass buttons. - @Entry var buttonTintStyle: GlassButtonTintStyle = .disabled + /// Optional color/gradient wash layered into toolbar button groups. + @Entry var buttonTintStyle: DesignButtonTintStyle = .disabled } private struct SheetMaterial: ViewModifier { diff --git a/Packages/ContainedNavigation/README.md b/Packages/ContainedNavigation/README.md index 9aa78522..ce35e2e4 100644 --- a/Packages/ContainedNavigation/README.md +++ b/Packages/ContainedNavigation/README.md @@ -88,12 +88,12 @@ struct NavigationPackageExample: View { Divider() } content: { VStack(spacing: DesignTokens.Space.s) { - GlassOptionTile(symbol: "play.rectangle", + DesignOptionTile(symbol: "play.rectangle", title: "Run a container", subtitle: "Start from an image") { isPresented = false } - GlassOptionTile(symbol: "square.stack.3d.up", + DesignOptionTile(symbol: "square.stack.3d.up", title: "Use an existing image", subtitle: "Pick from local images") { isPresented = false diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift b/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift index 35b98557..f9bd0fcc 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift @@ -40,10 +40,10 @@ private struct NavigationPreview: View { } } content: { VStack(spacing: DesignTokens.Space.s) { - GlassOptionTile(symbol: "shippingbox", + DesignOptionTile(symbol: "shippingbox", title: "Primary item", subtitle: "Start from a template") {} - GlassOptionTile(symbol: "square.stack.3d.up", + DesignOptionTile(symbol: "square.stack.3d.up", title: "Image", subtitle: "Use a local image") {} } diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift index 665be754..61a50c61 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift @@ -73,8 +73,8 @@ struct CreationFlow: View { @Namespace private var tileNamespace private var springAnim: Animation { .spring(response: 0.42, dampingFraction: 0.86) } - private var optionPageHeight: CGFloat { GlassOptionTile.defaultHeight + (DesignTokens.Space.s * 2) } - private var twoRowOptionPageHeight: CGFloat { optionPageHeight + GlassOptionTile.defaultHeight + DesignTokens.Space.s } + private var optionPageHeight: CGFloat { DesignOptionTile.defaultHeight + (DesignTokens.Space.s * 2) } + private var twoRowOptionPageHeight: CGFloat { optionPageHeight + DesignOptionTile.defaultHeight + DesignTokens.Space.s } private var menuSize: CGSize { CGSize(width: 760, height: optionPageHeight) } private var chooserSize: CGSize { CGSize(width: 640, height: twoRowOptionPageHeight) } @@ -356,9 +356,9 @@ struct CreationFlow: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private func optionStack(@ViewBuilder content: () -> C) -> some View { - GlassEffectContainer(spacing: DesignTokens.Space.s) { - LazyVStack(spacing: DesignTokens.Space.s) { content() } + private func optionStack(@ViewBuilder content: @escaping () -> C) -> some View { + DesignOptionStack(spacing: DesignTokens.Space.s) { + content() } } @@ -369,7 +369,7 @@ struct CreationFlow: View { private func box(symbol: String, title: String, subtitle: String? = nil, matchedID: String? = nil, enabled: Bool = true, action: @escaping () -> Void) -> some View { - GlassOptionTile(symbol: symbol, title: title, subtitle: subtitle, + DesignOptionTile(symbol: symbol, title: title, subtitle: subtitle, enabled: enabled, matchedID: matchedID, matchedNamespace: matchedID == nil ? nil : tileNamespace, diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift index 9688483d..c5bb2f7f 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift @@ -319,7 +319,7 @@ private struct CreationChoiceCard: View { } subtitleAccessory: { EmptyView() } headerAccessory: { - GlassListRowChevron() + DesignListRowChevron() } bodyContent: { EmptyView() } footerLeading: { diff --git a/Sources/ContainedApp/Features/Containers/LogsTab.swift b/Sources/ContainedApp/Features/Containers/LogsTab.swift index 99e8b579..1c790a86 100644 --- a/Sources/ContainedApp/Features/Containers/LogsTab.swift +++ b/Sources/ContainedApp/Features/Containers/LogsTab.swift @@ -31,7 +31,7 @@ struct LogsTab: View { private var controls: some View { HStack(spacing: DesignTokens.Space.m) { - DesignGlassToggle(isOn: $following, title: AppText.follow, systemName: "arrow.down.to.line") + DesignToggleButton(isOn: $following, title: AppText.follow, systemName: "arrow.down.to.line") if streaming { HStack(spacing: DesignTokens.Toolbar.searchIconGap) { ProgressView().controlSize(.small) diff --git a/Sources/ContainedApp/Features/Containers/StatsTab.swift b/Sources/ContainedApp/Features/Containers/StatsTab.swift index 41cb23db..26389176 100644 --- a/Sources/ContainedApp/Features/Containers/StatsTab.swift +++ b/Sources/ContainedApp/Features/Containers/StatsTab.swift @@ -35,7 +35,7 @@ struct StatsTab: View { tile(.netTx, delta, "arrow.up.circle") tile(.diskRead, delta, "arrow.down.doc") tile(.diskWrite, delta, "arrow.up.doc") - MetricTile(label: "Processes", value: "\(delta.numProcesses)", systemImage: "gearshape.2", tint: tint) + DesignSparklineMetricTile(label: "Processes", value: "\(delta.numProcesses)", systemImage: "gearshape.2", tint: tint) } processList } @@ -80,7 +80,7 @@ struct StatsTab: View { } private func tile(_ metric: GraphMetric, _ delta: StatsDelta, _ symbol: String) -> some View { - MetricTile(label: metric.displayName, + DesignSparklineMetricTile(label: metric.displayName, value: metric.caption(from: delta, snapshot: snapshot, normalization: normalization), systemImage: symbol, tint: tint, @@ -92,7 +92,7 @@ struct StatsTab: View { let memoryLimit = GraphMetric.memoryLimitBytes(for: delta, snapshot: snapshot, normalization: normalization) - return MetricTile(label: "Memory \(Format.bytes(delta.memoryUsageBytes)) / \(Format.bytes(memoryLimit))", + return DesignSparklineMetricTile(label: "Memory \(Format.bytes(delta.memoryUsageBytes)) / \(Format.bytes(memoryLimit))", value: GraphMetric.memory.caption(from: delta, snapshot: snapshot, normalization: normalization), diff --git a/Sources/ContainedApp/Features/Images/RegistryImageSearch.swift b/Sources/ContainedApp/Features/Images/RegistryImageSearch.swift index 9debac29..f7eea2c5 100644 --- a/Sources/ContainedApp/Features/Images/RegistryImageSearch.swift +++ b/Sources/ContainedApp/Features/Images/RegistryImageSearch.swift @@ -89,7 +89,7 @@ struct RegistryImageSearch: View { private func quickPick(symbol: String, title: String, subtitle: String, action: @escaping () -> Void) -> some View { choiceCard(symbol: symbol, title: title, subtitle: subtitle, action: action) { - GlassListRowChevron() + DesignListRowChevron() } .accessibilityAddTraits(.isButton) } diff --git a/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift b/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift index d810b807..1553771a 100644 --- a/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift +++ b/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift @@ -12,7 +12,7 @@ struct SystemLogsSheet: View { VStack(spacing: 0) { HStack(spacing: DesignTokens.Space.m) { Text("System logs").font(.headline) - DesignGlassToggle(isOn: $follow, title: AppText.follow, systemName: "arrow.down.to.line") + DesignToggleButton(isOn: $follow, title: AppText.follow, systemName: "arrow.down.to.line") .onChange(of: follow) { _, _ in session += 1 } Spacer() DesignActionGroup(DesignAction(systemName: "xmark", diff --git a/Sources/ContainedApp/Features/System/SystemView.swift b/Sources/ContainedApp/Features/System/SystemView.swift index 63c9b93d..952f4bbc 100644 --- a/Sources/ContainedApp/Features/System/SystemView.swift +++ b/Sources/ContainedApp/Features/System/SystemView.swift @@ -263,7 +263,7 @@ struct SystemContent: View { .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } - GlassRowMenu(accessibilityLabel: AppText.string("menu.volumeActions", defaultValue: "Volume actions")) { + DesignRowMenu(accessibilityLabel: AppText.string("menu.volumeActions", defaultValue: "Volume actions")) { volumeMenu(entry) } } diff --git a/Sources/ContainedApp/Navigation/ClassicShell.swift b/Sources/ContainedApp/Navigation/ClassicShell.swift index 9545eea8..6d0f2230 100644 --- a/Sources/ContainedApp/Navigation/ClassicShell.swift +++ b/Sources/ContainedApp/Navigation/ClassicShell.swift @@ -273,7 +273,7 @@ private struct NetworksPage: View { } subtitleAccessory: { EmptyView() } headerAccessory: { - GlassRowMenu(accessibilityLabel: AppText.string("menu.networkActions", defaultValue: "Network actions")) { + DesignRowMenu(accessibilityLabel: AppText.string("menu.networkActions", defaultValue: "Network actions")) { networkMenu(network) } } bodyContent: { diff --git a/Sources/ContainedApp/Navigation/RootView.swift b/Sources/ContainedApp/Navigation/RootView.swift index 8086ac82..0929689d 100644 --- a/Sources/ContainedApp/Navigation/RootView.swift +++ b/Sources/ContainedApp/Navigation/RootView.swift @@ -83,7 +83,7 @@ struct RootView: View { .tint(settings.accentTint.color) .environment(\.modalMaterial, settings.modalMaterial) .environment(\.buttonMaterial, settings.buttonMaterial) - .environment(\.buttonTintStyle, GlassButtonTintStyle(enabled: settings.buttonTintEnabled, + .environment(\.buttonTintStyle, DesignButtonTintStyle(enabled: settings.buttonTintEnabled, tint: settings.buttonTint, opacity: settings.buttonTintOpacity, gradient: settings.buttonTintGradient, @@ -143,7 +143,7 @@ struct RootView: View { private func toolbarShell(settings: SettingsStore) -> some View { GeometryReader { _ in ZStack { - ContentBackgroundLayer(material: settings.windowMaterial.nsMaterial) + DesignContentBackgroundLayer(material: settings.windowMaterial.nsMaterial) toolbarContent } } @@ -158,7 +158,7 @@ struct RootView: View { private func classicShell(settings: SettingsStore) -> some View { ZStack { - ContentBackgroundLayer(material: settings.windowMaterial.nsMaterial) + DesignContentBackgroundLayer(material: settings.windowMaterial.nsMaterial) content .ignoresSafeArea(.container, edges: .vertical) } diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift index 29829a91..9c22d393 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift @@ -260,7 +260,7 @@ struct PaletteResultCard: View { .foregroundStyle(.tertiary) .frame(width: DesignTokens.IconSize.chip, height: DesignTokens.IconSize.chip) } else { - GlassListRowChevron() + DesignListRowChevron() .frame(width: DesignTokens.IconSize.chip, height: DesignTokens.IconSize.chip) } case .toggle(let isOn, let set): diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift index 22b1bfc2..a304e1fd 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift @@ -164,7 +164,7 @@ struct ToolbarTemplatesPanel: View { } headerAccessory: { // Chevron affordance: tapping the card hands off to the create morph (parity with the // image cards that grow into the morph detail). - GlassListRowChevron() + DesignListRowChevron() } bodyContent: { EmptyView() } footerLeading: { diff --git a/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift b/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift index b43b404c..107b7c1d 100644 --- a/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift +++ b/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift @@ -12,7 +12,7 @@ struct ToolbarPageSwitcher: View { @Query private var templates: [Template] var body: some View { - DesignGlassMenuButton { + DesignMenuButton { ForEach(AppSectionGroup.allCases) { group in let sections = AppSection.navigableSections(panelNavigationEnabled: ui.panelNavigationEnabled) .filter { $0.group == group && ($0 != .build || app.settings.imageBuildEnabled) } @@ -81,7 +81,7 @@ struct ToolbarViewOptions: View { var body: some View { @Bindable var ui = ui - return DesignGlassMenuButton { + return DesignMenuButton { Picker("Group by", selection: $ui.grouping) { ForEach(ContainerGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) @@ -247,7 +247,7 @@ struct ToolbarPageFilterOptions: View { NetworkViewOptions() case .activity: @Bindable var ui = ui - DesignGlassMenuButton { + DesignMenuButton { Picker("Filter", selection: $ui.activityFilter) { Label("All events", systemImage: "tray.full").tag(EventKind?.none) Divider() @@ -278,7 +278,7 @@ private struct ImageViewOptions: View { var body: some View { @Bindable var ui = ui - return DesignGlassMenuButton { + return DesignMenuButton { Picker("Group by", selection: $ui.imageGrouping) { ForEach(ImageGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) @@ -318,7 +318,7 @@ private struct TemplateViewOptions: View { var body: some View { @Bindable var ui = ui - return DesignGlassMenuButton { + return DesignMenuButton { Picker("Group by", selection: $ui.templateGrouping) { ForEach(TemplateGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) @@ -345,7 +345,7 @@ private struct NetworkViewOptions: View { var body: some View { @Bindable var ui = ui - return DesignGlassMenuButton { + return DesignMenuButton { Picker("Group by", selection: $ui.networkGrouping) { ForEach(NetworkGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) diff --git a/Sources/ContainedApp/Resources/CHANGELOG.md b/Sources/ContainedApp/Resources/CHANGELOG.md index 0398a3fd..02ce7985 100644 --- a/Sources/ContainedApp/Resources/CHANGELOG.md +++ b/Sources/ContainedApp/Resources/CHANGELOG.md @@ -68,7 +68,7 @@ #### Personalization & Design System - Local-only personalization for containers, image groups, image tags, and volumes, including nickname, icon, tint, background, graph/widget choices, and inheritance from image or app defaults. -- Shared Liquid Glass design system primitives: `DesignCard`, `DesignContentSurface`, `DesignInputSurface`, `GlassOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. +- Shared Liquid Glass design system primitives: `DesignCard`, `DesignContentSurface`, `DesignInputSurface`, `DesignOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. - Accessibility-aware custom visual effects and motion handling, including Reduce Transparency and Reduce Motion support where the app supplies custom glass or animation. - Shared page and panel scaffolds so sidebar pages, sheets, and toolbar morphs can reuse content without duplicate layouts. diff --git a/docs/architecture/Architecture.md b/docs/architecture/Architecture.md index 843102da..a1bc3e75 100644 --- a/docs/architecture/Architecture.md +++ b/docs/architecture/Architecture.md @@ -76,7 +76,7 @@ bundles, signing, notarization, and appcast scripts. ## Design system -Liquid Glass helpers and reusable primitives include `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `DesignCard`, `DesignCardInsetSection`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `DesignSelectionActionBar`, `DesignStatusBanner`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `DesignTokens` groups for toolbar, panel, spacing, radius, icon sizing, design cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, glass button styles, or micro-chrome recipes. App-side cards use `DesignCard`; card shell/header/page-rail assembly and low-level glass button/surface routes are package-internal. App-state-aware mappings such as runtime status and graph metric extraction stay in `ContainedApp` until they can cross the boundary without depending on app/core policy. Use the package READMEs for import instructions and copy-pasteable examples, and see [Design System](Design-System.md) for app-level conventions. +Liquid Glass helpers and reusable primitives include `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `DesignCard`, `DesignCardInsetSection`, `DesignActionGroup`, `DesignTextActionButton`, `DesignToggleButton`, `DesignSelectionActionBar`, `DesignStatusBanner`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `DesignTokens` groups for toolbar, panel, spacing, radius, icon sizing, design cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, glass button styles, or micro-chrome recipes. App-side cards use `DesignCard`; card shell/header/page-rail assembly and low-level glass button/surface routes are package-internal. App-state-aware mappings such as runtime status and graph metric extraction stay in `ContainedApp` until they can cross the boundary without depending on app/core policy. Use the package READMEs for import instructions and copy-pasteable examples, and see [Design System](Design-System.md) for app-level conventions. ## Local-only personalization diff --git a/docs/architecture/Design-System.md b/docs/architecture/Design-System.md index 43731683..79c9dafd 100644 --- a/docs/architecture/Design-System.md +++ b/docs/architecture/Design-System.md @@ -140,12 +140,12 @@ Use package-owned semantic controls for command chrome: - `DesignActionGroup` and `DesignActionItems` for icon action groups - `DesignActionCluster` for mixed menu/action capsules -- `DesignInputCluster` for inline search/input glass lanes +- `DesignInputCluster` for inline search/input lanes - `DesignTextActionButton` for labeled standard or prominent actions -- `DesignGlassToggle` for glass toggle buttons +- `DesignToggleButton` for toggle buttons in toolbar or panel chrome - `DesignSelectionActionBar` for floating selection bars - `DesignStatusBanner` for transient bottom banners -- `DesignToolbarSearchField`, `DesignGlassMenuButton`, +- `DesignToolbarSearchField`, `DesignMenuButton`, `DesignToolbarStatusButton`, `DesignToolbarActionCluster`, and `DesignToolbarVanitySlot` for toolbar-specific slots diff --git a/docs/development/Contributing.md b/docs/development/Contributing.md index 5d92c33c..e0f989a2 100644 --- a/docs/development/Contributing.md +++ b/docs/development/Contributing.md @@ -105,7 +105,7 @@ appcast.xml Sparkle feed at the root of each release branch - **Pure decision logic is factored into `ContainedCore`** (`RestartDecision`, `HealthDecision`, compose ordering) and unit-tested without spawning processes. - **No `contained.*` personalization labels.** Card styles and healthchecks live in local stores. Only `contained.restart` and `contained.stack` are written (they must round-trip through the container). - **Never put secrets or personal data in test fixtures.** Fixtures are captured CLI output — scrub tokens, domains, and paths before committing. (`.gitignore` blocks signing material; push protection is on.) -- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer app-facing design routes such as `PanelHeader`, `PanelSection`, `DesignPanelScaffold`, `DesignCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignGlassToggle`, `CommandPreviewBar`, and `DesignTokens`. Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or badge/keycap/status-dot recipes; add them to `ContainedDesignSystem` first. +- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer app-facing design routes such as `PanelHeader`, `PanelSection`, `DesignPanelScaffold`, `DesignCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignToggleButton`, `CommandPreviewBar`, and `DesignTokens`. Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or badge/keycap/status-dot recipes; add them to `ContainedDesignSystem` first. - **Gate debug-only tools at compile time.** Use `#if CONTAINED_DEBUG_TOOLS` for debug menus, diagnostics, fixtures, or local-only inspection surfaces. SwiftPM defines that flag only for debug builds, so release bundles exclude the code instead of merely hiding it at runtime. - **Keep the sidebar fallback working.** Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements for the classic shell. - **Sync docs with behavior.** If behavior, settings, routes, or user-facing wording changes, update the matching page under `docs/app`, `docs/features`, `docs/development`, `docs/architecture`, or `docs/release` and keep README links current. From 3d14dd679d9169fb8f6b701943e2f8506711720b Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 18:44:27 +0100 Subject: [PATCH 32/53] Move panel scaffold into design system --- Packages/ContainedDesignSystem/README.md | 29 ++++++++++++++++++- .../ContainedDesignSystem.md | 28 ++++++++++++++++++ .../Panels/DesignPanelScaffold.swift | 24 +++++---------- Packages/ContainedNavigation/README.md | 4 +-- .../ContainedNavigation.md | 6 ++-- .../Features/Settings/SettingsView.swift | 7 +++-- .../ToolbarPanels/ToolbarResourcePanels.swift | 9 ++---- 7 files changed, 76 insertions(+), 31 deletions(-) rename Packages/{ContainedNavigation/Sources/ContainedNavigation => ContainedDesignSystem/Sources/ContainedDesignSystem}/Panels/DesignPanelScaffold.swift (67%) diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md index 4e359fba..e2aa1f8c 100644 --- a/Packages/ContainedDesignSystem/README.md +++ b/Packages/ContainedDesignSystem/README.md @@ -42,7 +42,7 @@ This package currently depends only on platform frameworks available to a macOS - `WindowMaterial`, `DesignTint`, `ColorLayerBlendMode`, and root environment values for shared material/tint policy. - Named surface routes such as `DesignContentSurface`, `DesignInputSurface`, - panel scaffolds, and toolbar controls. Low-level glass modifiers and + `DesignPanelScaffold`, and toolbar controls. Low-level glass modifiers and visual-effect bridges are package implementation details. - `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `SheetHeader`, and `PageScaffold` for app-neutral scaffolding. @@ -171,6 +171,33 @@ struct DesignSystemExample: View { } ``` +## Panel Scaffold Example + +Use `DesignPanelScaffold` when a host already owns presentation, size, and +placement but needs shared fixed chrome, lazy scrolling content, and an optional +pinned footer: + +```swift +DesignPanelScaffold(width: DesignTokens.PanelSize.settings.width) { + PanelHeader(symbol: "gearshape", + title: "Settings", + subtitle: "Appearance") { + DesignActionGroup(DesignAction(systemName: "xmark", + help: "Close", + isCancel: true) {}) + } + Divider() +} content: { + PanelSection(header: "Theme") { + PanelRow(title: "Accent") { + TintSelector(selection: .constant(.azure), + labelForTint: { _ in "Azure" }) + } + } + .padding(DesignTokens.Space.s) +} +``` + ## Design Card Controls Keep card-local controls in the package. Feature views provide plain values and diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md index 4665bd9f..61bab11a 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md @@ -107,6 +107,33 @@ struct DesignSystemExample: View { } ``` +## Panel Scaffolds + +Use `DesignPanelScaffold` when a host already owns presentation, size, and +placement but needs shared fixed chrome, lazy scrolling content, and an optional +pinned footer: + +```swift +DesignPanelScaffold(width: DesignTokens.PanelSize.settings.width) { + PanelHeader(symbol: "gearshape", + title: "Settings", + subtitle: "Appearance") { + DesignActionGroup(DesignAction(systemName: "xmark", + help: "Close", + isCancel: true) {}) + } + Divider() +} content: { + PanelSection(header: "Theme") { + PanelRow(title: "Accent") { + TintSelector(selection: .constant(.azure), + labelForTint: { _ in "Azure" }) + } + } + .padding(DesignTokens.Space.s) +} +``` + ## Design Card Controls Use `DesignCard` for cards. Feature views pass plain titles, subtitles, page @@ -260,6 +287,7 @@ DesignToggleButton(isOn: $following, - ``DesignContentSurface`` - ``DesignInputSurface`` +- ``DesignPanelScaffold`` - ``PageScaffold`` - ``PanelHeader`` - ``PanelSection`` diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/Panels/DesignPanelScaffold.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/DesignPanelScaffold.swift similarity index 67% rename from Packages/ContainedNavigation/Sources/ContainedNavigation/Panels/DesignPanelScaffold.swift rename to Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/DesignPanelScaffold.swift index ce316b1c..ba931759 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/Panels/DesignPanelScaffold.swift +++ b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/DesignPanelScaffold.swift @@ -1,23 +1,18 @@ import SwiftUI -import ContainedDesignSystem -/// A toolbar morph-panel body: fixed chrome (header, segmented pickers) above a scrollable content area -/// and an optional pinned footer. +/// A reusable panel body: fixed chrome above a scrollable content area and an optional pinned footer. /// -/// The panel takes the **fixed size** its host hands the `MorphingExpander` (e.g. `DesignTokens.PanelSize.*`). -/// The inner `ScrollView` simply fills that area and scrolls; it does **not** measure its content. This -/// matters for performance: an earlier version measured the scroll content's natural height (to make the -/// panel hug it), which forced the whole `LazyVStack` to realize on open. Filling a definite height -/// keeps long lists lazy (only visible rows render). +/// The panel fills the fixed size its presentation host gives it (for example, +/// `DesignTokens.PanelSize.*`). The inner `ScrollView` fills that area and scrolls; it does not measure +/// its content. This matters for performance: an earlier version measured the scroll content's natural +/// height, which forced long lazy lists to realize on open. /// /// Pass `scrolls: false` for content that brings **its own** scroll view (search results, build /// workspace, the paged run form). In that mode the scaffold doesn't wrap the content in a `ScrollView`, -/// so scroll views aren't double-nested; the host (e.g. `CreationFlow`) supplies the size via -/// `morphPanelSize`. +/// so scroll views are not double-nested. public struct DesignPanelScaffold: View { - /// Retained for call-site compatibility; the panel's width comes from the host's morph target. + /// The expected host width. The scaffold still expands to the width assigned by its presentation host. public var width: CGFloat - public var placement: MorphPanelPlacement = .anchored public var scrollEdgeStyle: ScrollEdgeEffectStyle = .soft public var scrolls: Bool = true /// Fixed chrome pinned above the scroll area (header, divider, segmented pickers). @@ -29,14 +24,12 @@ public struct DesignPanelScaffold: Vi @ViewBuilder var footer: () -> Footer public init(width: CGFloat, - placement: MorphPanelPlacement = .anchored, scrollEdgeStyle: ScrollEdgeEffectStyle = .soft, scrolls: Bool = true, @ViewBuilder chrome: @escaping () -> Chrome, @ViewBuilder content: @escaping () -> Content, @ViewBuilder footer: @escaping () -> Footer) { self.width = width - self.placement = placement self.scrollEdgeStyle = scrollEdgeStyle self.scrolls = scrolls self.chrome = chrome @@ -67,12 +60,11 @@ public struct DesignPanelScaffold: Vi public extension DesignPanelScaffold where Footer == EmptyView { init(width: CGFloat, - placement: MorphPanelPlacement = .anchored, scrollEdgeStyle: ScrollEdgeEffectStyle = .soft, scrolls: Bool = true, @ViewBuilder chrome: @escaping () -> Chrome, @ViewBuilder content: @escaping () -> Content) { - self.init(width: width, placement: placement, scrollEdgeStyle: scrollEdgeStyle, scrolls: scrolls, + self.init(width: width, scrollEdgeStyle: scrollEdgeStyle, scrolls: scrolls, chrome: chrome, content: content, footer: { EmptyView() }) } } diff --git a/Packages/ContainedNavigation/README.md b/Packages/ContainedNavigation/README.md index ce35e2e4..c71fb43b 100644 --- a/Packages/ContainedNavigation/README.md +++ b/Packages/ContainedNavigation/README.md @@ -39,12 +39,12 @@ import ContainedNavigation a handoff panel. - `MorphSourceFrameReader` and `MorphSourceFramesKey` for measuring source frames in a named coordinate space without app-local preference keys. -- `DesignPanelScaffold` for generic fixed chrome, scrollable content, and pinned - footer layout inside a morph panel. Keep concrete panel contents in the app target. For example, Images, Templates, System, Settings, Activity, and Command Palette panels are app features that use this package; they do not live in this package. +Use `DesignPanelScaffold` from `ContainedDesignSystem` for panel anatomy; +`ContainedNavigation` performs the morph and placement around that visual body. ## Example diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md b/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md index 31e1b124..0974d749 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md +++ b/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md @@ -13,8 +13,9 @@ accepts caller-supplied titles, help text, accessibility labels, and panel copy through the views it composes. Use this package when a view needs reusable layout behavior such as a panel that -grows from a toolbar source, clamps to app safe areas, and hosts fixed chrome -above scrollable content. +grows from a toolbar source and clamps to app safe areas. Pair it with +`DesignPanelScaffold` from `ContainedDesignSystem` when the promoted surface +needs fixed chrome above scrollable content. Card-like detail views can use `MorphingSingleSurface` when the source and destination are the same conceptual surface rather than a toolbar-to-panel @@ -104,7 +105,6 @@ Button("Open") { isPresented = true } - ``MorphingSingleSurface`` - ``MorphingSingleSurfaceExpander`` - ``MorphFrame`` -- ``DesignPanelScaffold`` - ``MorphSourceFrameReader`` - ``MorphSourceFramesKey`` - ``GlobalBackdropStyle`` diff --git a/Sources/ContainedApp/Features/Settings/SettingsView.swift b/Sources/ContainedApp/Features/Settings/SettingsView.swift index 76d4d2d8..60a71b81 100644 --- a/Sources/ContainedApp/Features/Settings/SettingsView.swift +++ b/Sources/ContainedApp/Features/Settings/SettingsView.swift @@ -8,8 +8,8 @@ import ContainedCore /// headers, and explanatory footers stay consistent: Appearance (theme + glass), General (behavior, /// data, CLI), Runtime, Registries, Updates, and About. /// -/// Hosted in the toolbar Settings morph panel via the shared `DesignPanelScaffold`, so the panel hugs -/// the active section's content height. Sections switch via a header menu rather than a `TabView`. +/// Hosted in the toolbar Settings morph panel via the shared `DesignPanelScaffold`. +/// Sections switch via a header menu rather than a `TabView`. struct SettingsContent: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui @@ -51,7 +51,7 @@ struct SettingsContent: View { var body: some View { @Bindable var settings = app.settings - DesignPanelScaffold(width: DesignTokens.PanelSize.settings.width, placement: .centered) { + DesignPanelScaffold(width: DesignTokens.PanelSize.settings.width) { if showsHeader { VStack(spacing: 0) { header @@ -62,6 +62,7 @@ struct SettingsContent: View { sectionBody(settings: settings) .padding(DesignTokens.Space.s) } + .morphPanelPlacement(.centered) .onAppear { consumeRequestedPage() } .onChange(of: ui.settingsPage) { _, requested in guard let requested else { return } diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift index a304e1fd..bdd0ffaf 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift @@ -9,8 +9,7 @@ struct ToolbarActivityPanel: View { var onClose: () -> Void var body: some View { - // Size + placement are reported by the DesignPanelScaffold inside ActivityContent (it hugs its - // content height), so no fixed morphPanelSize here. + // ActivityContent owns its design scaffold and morph sizing preferences. ActivityContent(showClose: true, elevated: false, onClose: onClose) } } @@ -22,8 +21,7 @@ struct ToolbarSystemPanel: View { var onClose: () -> Void var body: some View { - // Size + placement are reported by the DesignPanelScaffold inside SystemContent (it hugs its - // content height), so no fixed morphPanelSize here. + // SystemContent owns its design scaffold and morph sizing preferences. SystemContent(elevated: false, onClose: onClose) } } @@ -35,8 +33,7 @@ struct ToolbarSettingsPanel: View { var onClose: () -> Void var body: some View { - // Size + placement are reported by the DesignPanelScaffold inside SettingsContent (it hugs its - // content height and centers), so no fixed morphPanelSize here. + // SettingsContent owns its design scaffold and centered morph placement. SettingsContent(onClose: onClose) } } From bc891f4cad0939bc090ff39f15b178546021ed68 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 18:52:10 +0100 Subject: [PATCH 33/53] Route compose errors through app copy --- Contained.xcodeproj/project.pbxproj | 4 ++++ .../Features/Containers/ComposeImport.swift | 4 +--- .../Containers/Creation/CreationFlow.swift | 2 +- .../ContainedApp/Resources/Localizable.xcstrings | 2 ++ .../Support/AppErrorPresentation.swift | 7 +++---- Sources/ContainedApp/Support/AppLocalization.swift | 9 +++++++++ .../AppErrorPresentationTests.swift | 14 ++++++++++++++ 7 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 Tests/ContainedAppTests/AppErrorPresentationTests.swift diff --git a/Contained.xcodeproj/project.pbxproj b/Contained.xcodeproj/project.pbxproj index 124f06d2..12b9e9f4 100644 --- a/Contained.xcodeproj/project.pbxproj +++ b/Contained.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 0E2100010000000000000006 /* SystemVolumeInventoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000006 /* SystemVolumeInventoryTests.swift */; }; 0E2100010000000000000007 /* UpdaterControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000007 /* UpdaterControllerTests.swift */; }; 0E2100010000000000000008 /* ContainedApp in Frameworks */ = {isa = PBXBuildFile; productRef = 0E20000B0000000000000001 /* ContainedApp */; }; + 0E2100010000000000000009 /* AppErrorPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000009 /* AppErrorPresentationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -41,6 +42,7 @@ 0E2100020000000000000006 /* SystemVolumeInventoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemVolumeInventoryTests.swift; sourceTree = ""; }; 0E2100020000000000000007 /* UpdaterControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterControllerTests.swift; sourceTree = ""; }; 0E2100020000000000000008 /* ContainedAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ContainedAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 0E2100020000000000000009 /* AppErrorPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppErrorPresentationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -103,6 +105,7 @@ 0E2100040000000000000001 /* ContainedAppTests */ = { isa = PBXGroup; children = ( + 0E2100020000000000000009 /* AppErrorPresentationTests.swift */, 0E2100020000000000000001 /* AppLoggingTests.swift */, 0E2100020000000000000002 /* ContainersStoreRefreshTests.swift */, 0E2100020000000000000003 /* MigrationTests.swift */, @@ -227,6 +230,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0E2100010000000000000009 /* AppErrorPresentationTests.swift in Sources */, 0E2100010000000000000001 /* AppLoggingTests.swift in Sources */, 0E2100010000000000000002 /* ContainersStoreRefreshTests.swift in Sources */, 0E2100010000000000000003 /* MigrationTests.swift in Sources */, diff --git a/Sources/ContainedApp/Features/Containers/ComposeImport.swift b/Sources/ContainedApp/Features/Containers/ComposeImport.swift index b06f6030..5d33be5d 100644 --- a/Sources/ContainedApp/Features/Containers/ComposeImport.swift +++ b/Sources/ContainedApp/Features/Containers/ComposeImport.swift @@ -14,7 +14,7 @@ enum ComposeImport { panel.canChooseFiles = true panel.allowsMultipleSelection = false panel.allowedContentTypes = [.yaml] - panel.message = "Choose a compose.yaml" + panel.message = AppText.chooseComposeFile guard panel.runModal() == .OK, let url = panel.url else { return } importFile(at: url, app: app, ui: ui) } @@ -26,8 +26,6 @@ enum ComposeImport { let projectName = url.deletingLastPathComponent().lastPathComponent importText(text, projectName: projectName.isEmpty ? "stack" : projectName, baseDirectory: url.deletingLastPathComponent(), app: app, ui: ui) - } catch let error as ComposeError { - app.flash({ if case .invalid(let message) = error { return message }; return "Invalid compose file." }()) } catch { app.flash(error.appDisplayMessage) } diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift index 61a50c61..3b5398e1 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift @@ -451,7 +451,7 @@ struct CreationFlow: View { panel.canChooseFiles = true panel.allowsMultipleSelection = false panel.allowedContentTypes = [.yaml] - panel.message = "Choose a compose.yaml" + panel.message = AppText.chooseComposeFile guard panel.runModal() == .OK, let url = panel.url else { return } onClose() ComposeImport.importFile(at: url, app: app, ui: ui) diff --git a/Sources/ContainedApp/Resources/Localizable.xcstrings b/Sources/ContainedApp/Resources/Localizable.xcstrings index 7003565e..3ac8e896 100644 --- a/Sources/ContainedApp/Resources/Localizable.xcstrings +++ b/Sources/ContainedApp/Resources/Localizable.xcstrings @@ -62,6 +62,7 @@ "common.unread": {}, "common.use": {}, "common.working": {}, + "compose.chooseFile": {}, "compose.noServicesWithImages": {}, "compose.warnings": {}, "containerCard.accessibilityLabel": {}, @@ -73,6 +74,7 @@ "error.command.launchFailed": {}, "error.command.nonZeroExit": {}, "error.compose.invalid": {}, + "error.compose.invalidWithReason": {}, "error.registry.httpStatus": {}, "error.registry.invalidResponse": {}, "error.registry.missingDigest": {}, diff --git a/Sources/ContainedApp/Support/AppErrorPresentation.swift b/Sources/ContainedApp/Support/AppErrorPresentation.swift index 13208559..817a79f4 100644 --- a/Sources/ContainedApp/Support/AppErrorPresentation.swift +++ b/Sources/ContainedApp/Support/AppErrorPresentation.swift @@ -109,10 +109,9 @@ enum AppErrorPresentation { private static func message(for error: ComposeError) -> String { switch error { case .invalid(let reason): - if reason.isEmpty { - return AppText.string("error.compose.invalid", defaultValue: "Invalid compose file.") - } - return reason + let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + guard !reason.isEmpty else { return AppText.composeInvalid } + return AppText.composeInvalid(reason: reason) } } } diff --git a/Sources/ContainedApp/Support/AppLocalization.swift b/Sources/ContainedApp/Support/AppLocalization.swift index be17f178..5d20f5ec 100644 --- a/Sources/ContainedApp/Support/AppLocalization.swift +++ b/Sources/ContainedApp/Support/AppLocalization.swift @@ -87,6 +87,15 @@ enum AppText { defaultValue: "Some compose keys were not translated; review each container before creating." ) } + static var chooseComposeFile: String { + string("compose.chooseFile", defaultValue: "Choose a compose.yaml") + } + static var composeInvalid: String { + string("error.compose.invalid", defaultValue: "Invalid compose file.") + } + static func composeInvalid(reason: String) -> String { + string("error.compose.invalidWithReason", defaultValue: "Invalid compose file: \(reason)") + } static var reconnectTerminal: String { string("terminal.reconnectTerminal", defaultValue: "Reconnect terminal") diff --git a/Tests/ContainedAppTests/AppErrorPresentationTests.swift b/Tests/ContainedAppTests/AppErrorPresentationTests.swift new file mode 100644 index 00000000..f9423a14 --- /dev/null +++ b/Tests/ContainedAppTests/AppErrorPresentationTests.swift @@ -0,0 +1,14 @@ +import ContainedCore +import Testing +@testable import ContainedApp + +@Suite("App error presentation") +struct AppErrorPresentationTests { + @Test func composeErrorsUseAppOwnedCopy() { + #expect(AppErrorPresentation.message(for: ComposeError.invalid("")) == "Invalid compose file.") + #expect( + AppErrorPresentation.message(for: ComposeError.invalid("Top level is not a mapping.")) + == "Invalid compose file: Top level is not a mapping." + ) + } +} From 9540116f26507d96925719d18f5604da0a723cd6 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 19:04:50 +0100 Subject: [PATCH 34/53] Route imperative app copy through localization --- .../Containers/ContainerConfigureView.swift | 2 +- .../Containers/Creation/CreationFlow.swift | 2 +- .../Features/Containers/FilesTab.swift | 8 +- .../Features/Containers/RunSpec.swift | 10 +- .../Features/Containers/RunSpecForm.swift | 6 +- .../Features/Images/BuildWorkspaceView.swift | 4 +- .../Features/Onboarding/BootstrapView.swift | 2 +- .../Features/Palette/CommandPalette.swift | 2 +- .../Features/Settings/ConfigTransfer.swift | 4 +- .../Features/Settings/Tabs/RuntimeTab.swift | 2 +- .../ContainedApp/Navigation/RootView.swift | 2 +- .../ToolbarPanels/ToolbarImageGroupCard.swift | 6 +- .../Resources/Localizable.xcstrings | 49 +++++ .../Stores/AppModel+Configuration.swift | 8 +- .../Stores/AppModel+ImageUpdates.swift | 58 ++++-- Sources/ContainedApp/Stores/AppModel.swift | 12 +- .../Support/AppLocalization.swift | 192 ++++++++++++++++++ 17 files changed, 314 insertions(+), 55 deletions(-) diff --git a/Sources/ContainedApp/Features/Containers/ContainerConfigureView.swift b/Sources/ContainedApp/Features/Containers/ContainerConfigureView.swift index e97d85c1..57c369d1 100644 --- a/Sources/ContainedApp/Features/Containers/ContainerConfigureView.swift +++ b/Sources/ContainedApp/Features/Containers/ContainerConfigureView.swift @@ -170,7 +170,7 @@ struct ContainerConfigureView: View { guard !name.isEmpty else { return } modelContext.insert(Template(name: name, spec: spec)) try? modelContext.save() - app.flash("Saved template “\(name)”") + app.flash(AppText.savedTemplate(name)) } /// The id of the container being edited (empty in `.new` mode). diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift index 3b5398e1..45e6dfca 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift @@ -470,7 +470,7 @@ struct CreationFlow: View { panel.canChooseFiles = true panel.allowsMultipleSelection = false panel.allowedContentTypes = [.init(filenameExtension: "tar") ?? .data] - panel.message = "Choose an image .tar archive" + panel.message = AppText.chooseImageTarArchive guard panel.runModal() == .OK, let url = panel.url else { return } onClose() app.loadImageTar(at: url) diff --git a/Sources/ContainedApp/Features/Containers/FilesTab.swift b/Sources/ContainedApp/Features/Containers/FilesTab.swift index 94379b55..a5640f28 100644 --- a/Sources/ContainedApp/Features/Containers/FilesTab.swift +++ b/Sources/ContainedApp/Features/Containers/FilesTab.swift @@ -116,12 +116,12 @@ struct FilesTab: View { private func copyOut(_ name: String) { let panel = NSSavePanel() panel.nameFieldStringValue = name - panel.message = "Copy \(name) from the container" + panel.message = AppText.copyFileFromContainerPanel(name) guard panel.runModal() == .OK, let dest = panel.url else { return } Task { do { _ = try await app.client?.copy(source: "\(snapshot.id):\(joined(name))", destination: dest.path) - app.flash("Copied \(name) to host") + app.flash(AppText.copiedFileToHost(name)) } catch let e as CommandError { app.flash(e.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } @@ -132,13 +132,13 @@ struct FilesTab: View { panel.canChooseFiles = true panel.canChooseDirectories = true panel.allowsMultipleSelection = false - panel.message = "Copy a file into \(path)" + panel.message = AppText.copyFileIntoContainerPanel(path) guard panel.runModal() == .OK, let src = panel.url else { return } Task { do { _ = try await app.client?.copy(source: src.path, destination: "\(snapshot.id):\(joined(src.lastPathComponent))") - app.flash("Copied \(src.lastPathComponent) into container") + app.flash(AppText.copiedFileIntoContainer(src.lastPathComponent)) await load() } catch let e as CommandError { app.flash(e.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } diff --git a/Sources/ContainedApp/Features/Containers/RunSpec.swift b/Sources/ContainedApp/Features/Containers/RunSpec.swift index 8eade415..837efc86 100644 --- a/Sources/ContainedApp/Features/Containers/RunSpec.swift +++ b/Sources/ContainedApp/Features/Containers/RunSpec.swift @@ -112,13 +112,13 @@ struct RunSpec: Codable { var validationMessages: [String] { var messages: [String] = [] if image.trimmingCharacters(in: .whitespaces).isEmpty { - messages.append("Choose an image to run.") + messages.append(AppText.runSpecChooseImageToRun) } for port in ports { let hasHost = !port.hostPort.trimmingCharacters(in: .whitespaces).isEmpty let hasContainer = !port.containerPort.trimmingCharacters(in: .whitespaces).isEmpty if hasHost != hasContainer { - messages.append("Complete or remove partial port mappings.") + messages.append(AppText.runSpecCompletePortMappings) break } } @@ -126,7 +126,7 @@ struct RunSpec: Codable { let hasSource = !volume.source.trimmingCharacters(in: .whitespaces).isEmpty let hasTarget = !volume.target.trimmingCharacters(in: .whitespaces).isEmpty if hasSource != hasTarget { - messages.append("Complete or remove partial volume mounts.") + messages.append(AppText.runSpecCompleteVolumeMounts) break } } @@ -134,11 +134,11 @@ struct RunSpec: Codable { $0.key.trimmingCharacters(in: .whitespaces).isEmpty && !$0.value.trimmingCharacters(in: .whitespaces).isEmpty }) { - messages.append("Environment variables with values need names.") + messages.append(AppText.runSpecEnvironmentNeedsNames) } if !memory.trimmingCharacters(in: .whitespaces).isEmpty, RunSpec.parseMemoryBytes(memory) == nil { - messages.append("Memory must use a value like 512M or 2G.") + messages.append(AppText.runSpecMemoryFormat) } return messages } diff --git a/Sources/ContainedApp/Features/Containers/RunSpecForm.swift b/Sources/ContainedApp/Features/Containers/RunSpecForm.swift index f611a8ce..865aeba6 100644 --- a/Sources/ContainedApp/Features/Containers/RunSpecForm.swift +++ b/Sources/ContainedApp/Features/Containers/RunSpecForm.swift @@ -173,9 +173,9 @@ struct RunSpecForm: View { guard let imageDefaults else { return } let applied = spec.adoptImageDefaults(from: imageDefaults) if applied > 0 { - app.flash("Adopted \(applied) image default\(applied == 1 ? "" : "s")") + app.flash(AppText.adoptedImageDefaults(applied)) } else { - app.flash("Image defaults are already represented") + app.flash(AppText.imageDefaultsAlreadyRepresented) } } @@ -615,7 +615,7 @@ struct RunSpecForm: View { panel.canChooseFiles = true panel.canChooseDirectories = true panel.allowsMultipleSelection = false - panel.message = "Choose a host file or folder" + panel.message = AppText.chooseHostFileOrFolder guard panel.runModal() == .OK, let url = panel.url else { return } source.wrappedValue = url.path } diff --git a/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift b/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift index e4412e4e..a21ee8b0 100644 --- a/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift +++ b/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift @@ -175,8 +175,8 @@ struct BuildWorkspaceView: View { panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = false - panel.prompt = "Choose" - panel.message = "Choose the build context folder" + panel.prompt = AppText.choose + panel.message = AppText.chooseBuildContextFolder if panel.runModal() == .OK { contextDir = panel.url } } } diff --git a/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift b/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift index 152fc82c..e1f93b1c 100644 --- a/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift +++ b/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift @@ -67,7 +67,7 @@ struct BootstrapView: View { panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false - panel.message = "Select the `container` binary" + panel.message = AppText.selectContainerBinary panel.directoryURL = URL(fileURLWithPath: "/usr/local/bin") if panel.runModal() == .OK, let url = panel.url { Task { await app.useCLIPath(url.path) } diff --git a/Sources/ContainedApp/Features/Palette/CommandPalette.swift b/Sources/ContainedApp/Features/Palette/CommandPalette.swift index be9b0a18..0ff0ea92 100644 --- a/Sources/ContainedApp/Features/Palette/CommandPalette.swift +++ b/Sources/ContainedApp/Features/Palette/CommandPalette.swift @@ -113,7 +113,7 @@ struct PaletteItem: Identifiable { if app.updater.canCheckForUpdates { app.updater.checkForUpdates() } else { - app.flash("App update checks are unavailable in this build") + app.flash(AppText.appUpdateChecksUnavailable) } }) items.append(PaletteItem(title: "Check all image updates", subtitle: app.imageUpdateIntervalDescription, diff --git a/Sources/ContainedApp/Features/Settings/ConfigTransfer.swift b/Sources/ContainedApp/Features/Settings/ConfigTransfer.swift index 60b1dd2a..da64eaf2 100644 --- a/Sources/ContainedApp/Features/Settings/ConfigTransfer.swift +++ b/Sources/ContainedApp/Features/Settings/ConfigTransfer.swift @@ -64,7 +64,7 @@ struct ConfigTransferControls: View { guard panel.runModal() == .OK, let url = panel.url else { return } do { try app.exportConfiguration(to: url, sections: sections) - app.flash("Exported backup") + app.flash(AppText.exportedBackup) } catch { app.flash(error.appDisplayMessage) } @@ -80,7 +80,7 @@ struct ConfigTransferControls: View { try app.importConfiguration(from: url, sections: sections, replace: importMode.replacesExistingData) - app.flash("Imported backup") + app.flash(AppText.importedBackup) } catch { app.flash(error.appDisplayMessage) } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift index 66729899..583d5722 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift @@ -112,7 +112,7 @@ struct RuntimeTab: View { private func installKernel() async { guard let client = app.client else { return } if let error = await app.captured({ _ = try await client.setRecommendedKernel() }) { app.flash(error) } - else { app.flash("Recommended kernel installed"); await app.reloadProperties() } + else { app.flash(AppText.recommendedKernelInstalled); await app.reloadProperties() } } private func addDNS() async { diff --git a/Sources/ContainedApp/Navigation/RootView.swift b/Sources/ContainedApp/Navigation/RootView.swift index 0929689d..e3f53f83 100644 --- a/Sources/ContainedApp/Navigation/RootView.swift +++ b/Sources/ContainedApp/Navigation/RootView.swift @@ -231,7 +231,7 @@ struct RootView: View { panel.canChooseFiles = true panel.allowsMultipleSelection = false panel.allowedContentTypes = [.init(filenameExtension: "tar") ?? .data] - panel.message = "Choose an image tar archive" + panel.message = AppText.chooseImageTarArchive guard panel.runModal() == .OK, let url = panel.url else { return } app.loadImageTar(at: url) } diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index 6a82abfd..bc439aff 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -650,7 +650,7 @@ struct ToolbarImageGroupCard: View { do { _ = try await client.deleteImages([reference]) await app.refreshImagesIfStale(force: true) - app.flash("Deleted \(Format.shortImage(reference))") + app.flash(AppText.deletedImage(Format.shortImage(reference))) deletingReference = nil } catch let error as CommandError { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } @@ -668,13 +668,13 @@ struct ToolbarImageGroupCard: View { let panel = NSSavePanel() panel.allowedContentTypes = [.init(filenameExtension: "tar") ?? .data] panel.nameFieldStringValue = Format.shortImage(image.reference).replacingOccurrences(of: ":", with: "_") + ".tar" - panel.message = "Save \(Format.shortImage(image.reference)) to a tar archive" + panel.message = AppText.saveImageTarArchive(Format.shortImage(image.reference)) guard panel.runModal() == .OK, let url = panel.url else { return } Task { if let error = await app.captured({ _ = try await client.saveImages([image.reference], to: url.path) }) { app.flash(error) } else { - app.flash("Saved \(url.lastPathComponent)") + app.flash(AppText.savedFile(url.lastPathComponent)) } } } diff --git a/Sources/ContainedApp/Resources/Localizable.xcstrings b/Sources/ContainedApp/Resources/Localizable.xcstrings index 3ac8e896..588db624 100644 --- a/Sources/ContainedApp/Resources/Localizable.xcstrings +++ b/Sources/ContainedApp/Resources/Localizable.xcstrings @@ -6,8 +6,12 @@ "appearance.dark": {}, "appearance.light": {}, "appearance.system": {}, + "backup.exported": {}, + "backup.exportedAndReset": {}, "backup.importMode.merge": {}, "backup.importMode.replace": {}, + "backup.imported": {}, + "backup.keptReadableLocalData": {}, "backup.section.caches": {}, "backup.section.healthChecks": {}, "backup.section.history": {}, @@ -23,6 +27,7 @@ "build.buildImage": {}, "build.cancelBuild": {}, "build.chooseContextFolder": {}, + "build.chooseContextFolderPanel": {}, "build.removeBuildArgument": {}, "cardDensity.large": {}, "cardDensity.medium": {}, @@ -32,6 +37,7 @@ "common.cancel": {}, "common.clear": {}, "common.clearSearch": {}, + "common.choose": {}, "common.close": {}, "common.completed": {}, "common.copied": {}, @@ -65,7 +71,12 @@ "compose.chooseFile": {}, "compose.noServicesWithImages": {}, "compose.warnings": {}, + "container.created": {}, + "container.restartedAttempt": {}, + "container.unhealthy": {}, "containerCard.accessibilityLabel": {}, + "cleanup.cleanedStaleRows": {}, + "cli.selectContainerBinary": {}, "customize.accessibilityLabel": {}, "customize.cardColor": {}, "customize.imageStyleAccessibilityLabel": {}, @@ -82,6 +93,13 @@ "error.registry.tokenUnavailable": {}, "error.registry.unauthorized": {}, "error.runtime.unsupportedCapability": {}, + "file.loaded": {}, + "file.saved": {}, + "files.chooseHostFileOrFolder": {}, + "files.copiedIntoContainer": {}, + "files.copiedToHost": {}, + "files.copyFromContainerPanel": {}, + "files.copyIntoContainerPanel": {}, "files.copyIntoFolder": {}, "graphMetric.cpu": {}, "graphMetric.diskRead": {}, @@ -96,13 +114,19 @@ "graphStyle.points": {}, "graphStyle.range": {}, "graphStyle.scatter": {}, + "history.cleared": {}, "image.addTag": {}, + "image.chooseTarArchive": {}, + "image.deleted": {}, "image.deleteTag": {}, "image.loadImageTar": {}, "image.prune": {}, "image.pruneImages": {}, "image.pullUpdate": {}, "image.push": {}, + "image.saveTarArchive": {}, + "imageDefaults.adopted": {}, + "imageDefaults.alreadyRepresented": {}, "logging.category.compose": {}, "logging.category.health": {}, "logging.category.image": {}, @@ -122,6 +146,7 @@ "logging.level.verbose.footnote": {}, "menu.networkActions": {}, "menu.volumeActions": {}, + "network.created": {}, "network.newNetwork": {}, "network.newNetwork.lowercase": {}, "network.refreshNetworks": {}, @@ -131,10 +156,16 @@ "restartPolicy.no": {}, "restartPolicy.onFailure": {}, "runSpec.adopt": {}, + "runSpec.validation.chooseImage": {}, + "runSpec.validation.completePortMappings": {}, + "runSpec.validation.completeVolumeMounts": {}, + "runSpec.validation.environmentNeedsNames": {}, + "runSpec.validation.memoryFormat": {}, "runtime.core": {}, "runtime.core.subtitle": {}, "runtime.coreSelector.disabledReason": {}, "runtime.notReady": {}, + "runtime.recommendedKernelInstalled": {}, "section.images": {}, "section.templates": {}, "selection.count": {}, @@ -149,6 +180,7 @@ "system.storageCleanup": {}, "system.systemLogs": {}, "template.saveAsTemplate": {}, + "template.saved": {}, "terminal.reconnectTerminal": {}, "tint.amber": {}, "tint.amber.alias.gold": {}, @@ -211,7 +243,24 @@ "updateChannel.stable.footnote": {}, "updates.checkForUpdates": {}, "updates.checkForUpdatesNow": {}, + "updates.containerImageNoun": {}, + "updates.containerImagesTitle": {}, + "updates.imageNoun": {}, + "updates.imagesTitle": {}, + "updates.imageUpdateAvailable": {}, + "updates.imageUpToDate": {}, + "updates.localDigestUnavailable": {}, + "updates.noContainerImageUpdatesAvailable": {}, + "updates.noContainerImagesToCheck": {}, + "updates.noImageUpdatesAvailable": {}, + "updates.noLocalImagesToCheck": {}, "updates.runImageUpdateCheckNow": {}, + "updates.sweepAvailable": {}, + "updates.sweepUpToDate": {}, + "updates.unavailable": {}, + "updates.updatedImage": {}, + "updates.updatedItems": {}, + "volume.created": {}, "volume.newVolume": {}, "volume.refreshVolumes": {}, "widgetInterpolation.cardinal": {}, diff --git a/Sources/ContainedApp/Stores/AppModel+Configuration.swift b/Sources/ContainedApp/Stores/AppModel+Configuration.swift index c8c62c44..3a9c61e7 100644 --- a/Sources/ContainedApp/Stores/AppModel+Configuration.swift +++ b/Sources/ContainedApp/Stores/AppModel+Configuration.swift @@ -13,7 +13,7 @@ extension AppModel { /// Wipe all recorded metrics and events. func clearHistory() { historyStore.clearAll() - flash("History cleared") + flash(AppText.historyCleared) logger.record("History cleared", category: .system, severity: .warning) } @@ -36,7 +36,7 @@ extension AppModel { func resolveDowngradeByKeepingReadableData() { UserDefaults.standard.set(StateMigrator.currentSchemaVersion, forKey: StateMigrator.schemaVersionKey) downgradeSchemaVersion = nil - flash("Kept readable local data") + flash(AppText.keptReadableLocalData) } func exportForDowngradeAndReset() { @@ -49,7 +49,7 @@ extension AppModel { try exportConfiguration(to: url) resetIncompatibleLocalState() downgradeSchemaVersion = nil - flash("Exported backup and reset local state") + flash(AppText.exportedBackupAndReset) } catch { flash(error.appDisplayMessage) } @@ -67,7 +67,7 @@ extension AppModel { liveImageRefs: liveImageRefs) let checks = healthChecks.purgeOrphans(liveContainerIDs: liveContainerIDs) let history = historyStore.purgeOrphans(liveContainerIDs: liveContainerIDs) - flash("Cleaned \(personalizations + checks + history.events + history.metrics) stale row(s)") + flash(AppText.cleanedStaleRows(personalizations + checks + history.events + history.metrics)) } private func apply(envelope: AppStateEnvelope, selected: Set, replace: Bool) throws { diff --git a/Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift b/Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift index 43aec39b..ccd8b514 100644 --- a/Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift +++ b/Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift @@ -2,6 +2,25 @@ import SwiftUI import ContainedCore import ContainedRuntime +private enum ImageUpdateSummaryScope { + case localImages + case containerImages + + var noun: String { + switch self { + case .localImages: AppText.imageUpdateImageNoun + case .containerImages: AppText.imageUpdateContainerImageNoun + } + } + + var pluralTitle: String { + switch self { + case .localImages: AppText.imageUpdateImagesTitle + case .containerImages: AppText.imageUpdateContainerImagesTitle + } + } +} + /// Image-update tracking: comparing the local digest of each image against the registry's current /// manifest digest, on a throttled background sweep and on demand. Split out of `AppModel` because it /// is a self-contained subsystem with its own persisted state (`imageUpdates`, `lastImageUpdateSweep`, @@ -30,8 +49,8 @@ extension AppModel { /// background sweep stays quiet). func checkAllImageUpdates(manual: Bool = false) async { await runUpdateCheck(over: uniqueImageReferences(), - emptyMessage: "No local images to check", - summaryNoun: "image", + emptyMessage: AppText.noLocalImagesToCheck, + summary: .localImages, manual: manual) lastImageUpdateSweep = Date() } @@ -44,8 +63,8 @@ extension AppModel { /// Check only the images that existing containers were created from. func checkContainerImageUpdates(manual: Bool = true) async { await runUpdateCheck(over: uniqueContainerImageReferences(), - emptyMessage: "No container images to check", - summaryNoun: "container image", + emptyMessage: AppText.noContainerImagesToCheck, + summary: .containerImages, manual: manual) } @@ -53,8 +72,8 @@ extension AppModel { @discardableResult func pullAvailableImageUpdates(manual: Bool = false) async -> Int { await pullUpdates(over: uniqueImageReferences(), - emptyMessage: "No image updates available", - summaryNoun: "image", + emptyMessage: AppText.noImageUpdatesAvailable, + summary: .localImages, manual: manual) } @@ -62,8 +81,8 @@ extension AppModel { @discardableResult func pullAvailableContainerImageUpdates(manual: Bool = true) async -> Int { await pullUpdates(over: uniqueContainerImageReferences(), - emptyMessage: "No container image updates available", - summaryNoun: "container image", + emptyMessage: AppText.noContainerImageUpdatesAvailable, + summary: .containerImages, manual: manual) } @@ -79,7 +98,7 @@ extension AppModel { let remoteDigest = try await manifestClient.remoteDigest(for: reference) guard let localDigest, !localDigest.isEmpty else { imageUpdates[key] = .failed(localDigest: nil, message: "Local digest unavailable") - if notify { flash("Couldn't compare \(Format.shortImage(reference)): local digest unavailable") } + if notify { flash(AppText.imageLocalDigestUnavailable(Format.shortImage(reference))) } return } let status = ImageUpdateStatus.resolved(localDigest: localDigest, remoteDigest: remoteDigest) @@ -87,12 +106,12 @@ extension AppModel { if notify { switch status.state { case .updateAvailable: - flash("Update available for \(Format.shortImage(reference))") + flash(AppText.imageUpdateAvailable(Format.shortImage(reference))) logger.record("Update available for \(Format.shortImage(reference))", category: .image, severity: .warning) case .current: - flash("\(Format.shortImage(reference)) is up to date") + flash(AppText.imageUpToDate(Format.shortImage(reference))) default: break } @@ -114,7 +133,7 @@ extension AppModel { let ok = await pullImage(reference) if ok { await checkImageUpdate(reference, notify: false) - flash("Updated \(Format.shortImage(reference))") + flash(AppText.updatedImage(Format.shortImage(reference))) logger.record("Updated \(Format.shortImage(reference))", category: .image) } return ok @@ -146,14 +165,14 @@ extension AppModel { /// Shared sweep body for `checkAllImageUpdates` / `checkContainerImageUpdates`: check each /// reference quietly, then (when `manual`) summarize how many have updates. private func runUpdateCheck(over references: [String], emptyMessage: String, - summaryNoun: String, manual: Bool) async { + summary: ImageUpdateSummaryScope, manual: Bool) async { let started = Date() defer { let elapsed = Date().timeIntervalSince(started) if elapsed >= 0.75 || manual { let mode = manual ? "manual" : "background" diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, - "Image update sweep \(mode, privacy: .public) finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s across \(references.count, privacy: .public) \(summaryNoun, privacy: .public)(s)") + "Image update sweep \(mode, privacy: .public) finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s across \(references.count, privacy: .public) \(summary.noun, privacy: .public)(s)") } } guard !references.isEmpty else { @@ -163,16 +182,15 @@ extension AppModel { for reference in references { await checkImageUpdate(reference, notify: false) } guard manual else { return } let available = references.filter { imageUpdateStatus(for: $0).state == .updateAvailable }.count - let plural = summaryNoun.prefix(1).uppercased() + summaryNoun.dropFirst() + "s" // "Images" / "Container images" - flash(available == 0 - ? "\(plural) are up to date" - : "\(available) \(summaryNoun) update\(available == 1 ? "" : "s") available") + flash(AppText.updateSweepResult(available: available, + singular: summary.noun, + pluralTitle: summary.pluralTitle)) } /// Shared pull body for `pullAvailableImageUpdates` / `pullAvailableContainerImageUpdates`: pull /// every reference with an update available and report the count. private func pullUpdates(over references: [String], emptyMessage: String, - summaryNoun: String, manual: Bool) async -> Int { + summary: ImageUpdateSummaryScope, manual: Bool) async -> Int { let pending = references.filter { imageUpdateStatus(for: $0).state == .updateAvailable } guard !pending.isEmpty else { if manual { flash(emptyMessage) } @@ -180,7 +198,7 @@ extension AppModel { } var updated = 0 for reference in pending where await pullImageUpdate(reference) { updated += 1 } - if manual { flash("Updated \(updated) \(summaryNoun)\(updated == 1 ? "" : "s")") } + if manual { flash(AppText.updatedItems(updated, singular: summary.noun)) } return updated } diff --git a/Sources/ContainedApp/Stores/AppModel.swift b/Sources/ContainedApp/Stores/AppModel.swift index 7a2454fc..88c81d9e 100644 --- a/Sources/ContainedApp/Stores/AppModel.swift +++ b/Sources/ContainedApp/Stores/AppModel.swift @@ -133,7 +133,7 @@ final class AppModel { guard let self else { return } let name = self.containerStyle(for: snapshot) .displayName(fallback: snapshot.id) - self.flash("Restarted \(name) (attempt \(attempt))") + self.flash(AppText.restartedContainer(name, attempt: attempt)) self.logger.record("Restarted \(name) (attempt \(attempt))", category: .health, severity: .warning, @@ -154,7 +154,7 @@ final class AppModel { guard let self else { return } let name = self.containerStyle(for: snapshot) .displayName(fallback: snapshot.id) - self.flash("\(name) is unhealthy") + self.flash(AppText.containerUnhealthy(name)) self.logger.record("\(name) failed its healthcheck", category: .health, severity: .warning, @@ -492,7 +492,7 @@ final class AppModel { if !spec.personalization.isDefault { personalization.setOverride(spec.personalization, for: newID) } healthChecks.setCheck(spec.healthCheck, for: newID) logger.record("Created \(newID)", category: .lifecycle, containerID: newID) - flash("Created \(newID)") + flash(AppText.createdContainer(newID)) } return newID } @@ -543,7 +543,7 @@ final class AppModel { severity: .error) } else { await refreshImagesIfStale(force: true) - flash("Loaded \(url.lastPathComponent)") + flash(AppText.loadedFile(url.lastPathComponent)) logger.record("Loaded image archive \(url.lastPathComponent)", category: .image) } } @@ -564,7 +564,7 @@ final class AppModel { severity: .error) return false } - flash("Created volume \(name)") + flash(AppText.createdVolume(name)) logger.record("Created volume \(name)", category: .system) return true } @@ -584,7 +584,7 @@ final class AppModel { severity: .error) return false } - flash("Created network \(name)") + flash(AppText.createdNetwork(name)) logger.record("Created network \(name)", category: .system) return true } diff --git a/Sources/ContainedApp/Support/AppLocalization.swift b/Sources/ContainedApp/Support/AppLocalization.swift index 5d20f5ec..cbb4ce51 100644 --- a/Sources/ContainedApp/Support/AppLocalization.swift +++ b/Sources/ContainedApp/Support/AppLocalization.swift @@ -12,6 +12,7 @@ enum AppText { static var back: String { string("common.back", defaultValue: "Back") } static var cancel: String { string("common.cancel", defaultValue: "Cancel") } static var clearSearch: String { string("common.clearSearch", defaultValue: "Clear search") } + static var choose: String { string("common.choose", defaultValue: "Choose") } static var close: String { string("common.close", defaultValue: "Close") } static var add: String { string("common.add", defaultValue: "Add") } static var copied: String { string("common.copied", defaultValue: "Copied") } @@ -42,6 +43,9 @@ enum AppText { static var buildImage: String { string("build.buildImage", defaultValue: "Build image") } static var cancelBuild: String { string("build.cancelBuild", defaultValue: "Cancel build") } static var chooseContextFolder: String { string("build.chooseContextFolder", defaultValue: "Choose context folder") } + static var chooseBuildContextFolder: String { + string("build.chooseContextFolderPanel", defaultValue: "Choose the build context folder") + } static var removeBuildArgument: String { string("build.removeBuildArgument", defaultValue: "Remove build argument") } static var cardColor: String { string("customize.cardColor", defaultValue: "Card Color") } static var current: String { string("common.current", defaultValue: "Current") } @@ -50,6 +54,9 @@ enum AppText { static var prune: String { string("image.prune", defaultValue: "Prune") } static var pruneImages: String { string("image.pruneImages", defaultValue: "Prune Images") } static var pullUpdate: String { string("image.pullUpdate", defaultValue: "Pull Update") } + static var chooseImageTarArchive: String { + string("image.chooseTarArchive", defaultValue: "Choose an image tar archive") + } static var checkForUpdates: String { string("updates.checkForUpdates", defaultValue: "Check for Updates") } static var checkForUpdatesNow: String { string("updates.checkForUpdatesNow", defaultValue: "Check for app updates now") } static var runImageUpdateCheckNow: String { @@ -78,6 +85,9 @@ enum AppText { static var containerRuntimeNotReady: String { string("runtime.notReady", defaultValue: "Container runtime is not ready.") } + static var selectContainerBinary: String { + string("cli.selectContainerBinary", defaultValue: "Select the `container` binary") + } static var composeNoServicesWithImages: String { string("compose.noServicesWithImages", defaultValue: "No services with an image to import.") } @@ -101,6 +111,10 @@ enum AppText { string("terminal.reconnectTerminal", defaultValue: "Reconnect terminal") } + static var appUpdateChecksUnavailable: String { + string("updates.unavailable", defaultValue: "App update checks are unavailable in this build") + } + static var startService: String { string("service.start", defaultValue: "Start service") } static var stopService: String { string("service.stop", defaultValue: "Stop service") } static var restartService: String { string("service.restart", defaultValue: "Restart service") } @@ -109,6 +123,184 @@ enum AppText { string("streamConsole.lineCount", defaultValue: "\(count) lines") } + static func restartedContainer(_ name: String, attempt: Int) -> String { + string("container.restartedAttempt", defaultValue: "Restarted \(name) (attempt \(attempt))") + } + + static func containerUnhealthy(_ name: String) -> String { + string("container.unhealthy", defaultValue: "\(name) is unhealthy") + } + + static func createdContainer(_ id: String) -> String { + string("container.created", defaultValue: "Created \(id)") + } + + static func loadedFile(_ name: String) -> String { + string("file.loaded", defaultValue: "Loaded \(name)") + } + + static func createdVolume(_ name: String) -> String { + string("volume.created", defaultValue: "Created volume \(name)") + } + + static func createdNetwork(_ name: String) -> String { + string("network.created", defaultValue: "Created network \(name)") + } + + static func copiedFileToHost(_ name: String) -> String { + string("files.copiedToHost", defaultValue: "Copied \(name) to host") + } + + static func copiedFileIntoContainer(_ name: String) -> String { + string("files.copiedIntoContainer", defaultValue: "Copied \(name) into container") + } + + static func copyFileFromContainerPanel(_ name: String) -> String { + string("files.copyFromContainerPanel", defaultValue: "Copy \(name) from the container") + } + + static func copyFileIntoContainerPanel(_ path: String) -> String { + string("files.copyIntoContainerPanel", defaultValue: "Copy a file into \(path)") + } + + static var chooseHostFileOrFolder: String { + string("files.chooseHostFileOrFolder", defaultValue: "Choose a host file or folder") + } + + static var historyCleared: String { + string("history.cleared", defaultValue: "History cleared") + } + + static var keptReadableLocalData: String { + string("backup.keptReadableLocalData", defaultValue: "Kept readable local data") + } + + static var exportedBackupAndReset: String { + string("backup.exportedAndReset", defaultValue: "Exported backup and reset local state") + } + + static var exportedBackup: String { + string("backup.exported", defaultValue: "Exported backup") + } + + static var importedBackup: String { + string("backup.imported", defaultValue: "Imported backup") + } + + static func cleanedStaleRows(_ count: Int) -> String { + string("cleanup.cleanedStaleRows", defaultValue: "Cleaned \(count) stale row(s)") + } + + static func savedTemplate(_ name: String) -> String { + string("template.saved", defaultValue: "Saved template \"\(name)\"") + } + + static func deletedImage(_ reference: String) -> String { + string("image.deleted", defaultValue: "Deleted \(reference)") + } + + static func saveImageTarArchive(_ reference: String) -> String { + string("image.saveTarArchive", defaultValue: "Save \(reference) to a tar archive") + } + + static func savedFile(_ name: String) -> String { + string("file.saved", defaultValue: "Saved \(name)") + } + + static var recommendedKernelInstalled: String { + string("runtime.recommendedKernelInstalled", defaultValue: "Recommended kernel installed") + } + + static var runSpecChooseImageToRun: String { + string("runSpec.validation.chooseImage", defaultValue: "Choose an image to run.") + } + + static var runSpecCompletePortMappings: String { + string("runSpec.validation.completePortMappings", defaultValue: "Complete or remove partial port mappings.") + } + + static var runSpecCompleteVolumeMounts: String { + string("runSpec.validation.completeVolumeMounts", defaultValue: "Complete or remove partial volume mounts.") + } + + static var runSpecEnvironmentNeedsNames: String { + string("runSpec.validation.environmentNeedsNames", defaultValue: "Environment variables with values need names.") + } + + static var runSpecMemoryFormat: String { + string("runSpec.validation.memoryFormat", defaultValue: "Memory must use a value like 512M or 2G.") + } + + static func adoptedImageDefaults(_ count: Int) -> String { + string("imageDefaults.adopted", defaultValue: "Adopted \(count) image default\(count == 1 ? "" : "s")") + } + + static var imageDefaultsAlreadyRepresented: String { + string("imageDefaults.alreadyRepresented", defaultValue: "Image defaults are already represented") + } + + static var noLocalImagesToCheck: String { + string("updates.noLocalImagesToCheck", defaultValue: "No local images to check") + } + + static var noContainerImagesToCheck: String { + string("updates.noContainerImagesToCheck", defaultValue: "No container images to check") + } + + static var noImageUpdatesAvailable: String { + string("updates.noImageUpdatesAvailable", defaultValue: "No image updates available") + } + + static var noContainerImageUpdatesAvailable: String { + string("updates.noContainerImageUpdatesAvailable", defaultValue: "No container image updates available") + } + + static var imageUpdateImageNoun: String { + string("updates.imageNoun", defaultValue: "image") + } + + static var imageUpdateContainerImageNoun: String { + string("updates.containerImageNoun", defaultValue: "container image") + } + + static var imageUpdateImagesTitle: String { + string("updates.imagesTitle", defaultValue: "Images") + } + + static var imageUpdateContainerImagesTitle: String { + string("updates.containerImagesTitle", defaultValue: "Container images") + } + + static func imageLocalDigestUnavailable(_ reference: String) -> String { + string("updates.localDigestUnavailable", defaultValue: "Couldn't compare \(reference): local digest unavailable") + } + + static func imageUpdateAvailable(_ reference: String) -> String { + string("updates.imageUpdateAvailable", defaultValue: "Update available for \(reference)") + } + + static func imageUpToDate(_ reference: String) -> String { + string("updates.imageUpToDate", defaultValue: "\(reference) is up to date") + } + + static func updatedImage(_ reference: String) -> String { + string("updates.updatedImage", defaultValue: "Updated \(reference)") + } + + static func updateSweepResult(available: Int, singular: String, pluralTitle: String) -> String { + if available == 0 { + return string("updates.sweepUpToDate", defaultValue: "\(pluralTitle) are up to date") + } + return string( + "updates.sweepAvailable", + defaultValue: "\(available) \(singular) update\(available == 1 ? "" : "s") available" + ) + } + + static func updatedItems(_ count: Int, singular: String) -> String { + string("updates.updatedItems", defaultValue: "Updated \(count) \(singular)\(count == 1 ? "" : "s")") + } + static func selectedCount(_ count: Int) -> String { string("selection.count", defaultValue: "\(count) selected") } From 8baaca23a061c7f5b2ce7481978c595ccdcda086 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 19:15:59 +0100 Subject: [PATCH 35/53] Route palette copy through app localization --- .../Features/Palette/CommandPalette.swift | 150 +++++++----- .../ContainedApp/Navigation/AppSection.swift | 28 ++- .../Navigation/ClassicShell.swift | 2 +- .../ToolbarPanels/PaletteResultCard.swift | 2 +- .../ToolbarPanels/ToolbarCommandPalette.swift | 56 ++--- .../Navigation/ToolbarViewOptions.swift | 2 +- .../Resources/Localizable.xcstrings | 112 +++++++++ Sources/ContainedApp/Stores/AppModel.swift | 2 +- .../Support/AppLocalization.swift | 224 ++++++++++++++++++ Sources/ContainedApp/Support/Notifier.swift | 10 +- 10 files changed, 483 insertions(+), 105 deletions(-) diff --git a/Sources/ContainedApp/Features/Palette/CommandPalette.swift b/Sources/ContainedApp/Features/Palette/CommandPalette.swift index 0ff0ea92..79ab995e 100644 --- a/Sources/ContainedApp/Features/Palette/CommandPalette.swift +++ b/Sources/ContainedApp/Features/Palette/CommandPalette.swift @@ -10,8 +10,8 @@ enum PaletteScope: Hashable { var title: String { switch self { - case .dockerHub: return "Docker Hub" - case .localImages: return "Local images" + case .dockerHub: return AppText.paletteDockerHub + case .localImages: return AppText.paletteLocalImages } } @@ -24,8 +24,8 @@ enum PaletteScope: Hashable { var placeholder: String { switch self { - case .dockerHub: return "Search Docker Hub…" - case .localImages: return "Filter local images…" + case .dockerHub: return AppText.paletteSearchDockerHubPlaceholder + case .localImages: return AppText.paletteFilterLocalImagesPlaceholder } } } @@ -55,7 +55,7 @@ struct PaletteItem: Identifiable { for section in AppSection.navigableSections(panelNavigationEnabled: ui.panelNavigationEnabled) where section != .build || app.settings.imageBuildEnabled { items.append(PaletteItem(title: section.title, - subtitle: section.group.rawValue, + subtitle: section.group.title, kind: .navigation, icon: section.symbol, tint: .secondary) { @@ -65,22 +65,26 @@ struct PaletteItem: Identifiable { // Add anything, from anywhere. (Pulling an image is covered by the Docker Hub search scope // below, so there's no separate "Pull an image" entry.) let adds: [(String, String, PendingAction)] = [ - ("Run a container", "shippingbox", .runContainer), - ("New volume", "externaldrive.badge.plus", .createVolume), - ("New network", "network", .createNetwork), + (AppText.paletteRunContainer, "shippingbox", .runContainer), + (AppText.paletteNewVolume, "externaldrive.badge.plus", .createVolume), + (AppText.paletteNewNetwork, "network", .createNetwork), ] for (title, icon, action) in adds { - items.append(PaletteItem(title: title, subtitle: "create", kind: .create, icon: icon, tint: .accentColor) { + items.append(PaletteItem(title: title, subtitle: AppText.paletteCreateSubtitle, kind: .create, icon: icon, tint: .accentColor) { ui.dispatch(action) }) } if app.settings.composeImportEnabled { - items.append(PaletteItem(title: "Import compose…", subtitle: "create", kind: .create, icon: "square.on.square", tint: .accentColor) { + items.append(PaletteItem(title: AppText.paletteImportCompose, + subtitle: AppText.paletteCreateSubtitle, + kind: .create, + icon: "square.on.square", + tint: .accentColor) { ComposeImport.pickAndImport(app: app, ui: ui) }) } // Registry credentials live on the Settings → Registries page, not as a standalone app page. - items.append(PaletteItem(title: "Registry login", subtitle: "create", + items.append(PaletteItem(title: AppText.paletteRegistryLogin, subtitle: AppText.paletteCreateSubtitle, keywords: ["registry", "credentials", "docker login", "sign in"], kind: .create, icon: "person.badge.key", tint: .accentColor) { ui.openSettings(to: .registries) @@ -88,27 +92,31 @@ struct PaletteItem: Identifiable { // Search scopes: these pin a chip to the search field and search in-place (they keep the // palette open) instead of opening another panel. if app.settings.hubSearchEnabled { - items.append(PaletteItem(title: "Search Docker Hub", subtitle: "scope", + items.append(PaletteItem(title: AppText.paletteSearchDockerHub, subtitle: AppText.paletteScopeSubtitle, keywords: ["registry", "pull", "dockerhub", "image"], kind: .search, keepsPaletteOpen: true, icon: "globe", tint: .accentColor) { ui.paletteScope = .dockerHub }) } - items.append(PaletteItem(title: "Search local images", subtitle: "scope", + items.append(PaletteItem(title: AppText.paletteSearchLocalImages, subtitle: AppText.paletteScopeSubtitle, keywords: ["image", "tag", "local", "filter"], kind: .search, keepsPaletteOpen: true, icon: "square.stack.3d.up", tint: .accentColor) { ui.paletteScope = .localImages }) // Page / global actions. - items.append(PaletteItem(title: "Refresh", subtitle: "action", kind: .action, icon: "arrow.clockwise", tint: .secondary) { + items.append(PaletteItem(title: AppText.paletteRefresh, + subtitle: AppText.paletteActionSubtitle, + kind: .action, + icon: "arrow.clockwise", + tint: .secondary) { app.coordinator.wake() }) - items.append(PaletteItem(title: "Check for app updates…", subtitle: "updates", + items.append(PaletteItem(title: AppText.paletteCheckAppUpdates, subtitle: AppText.paletteUpdatesSubtitle, keywords: ["sparkle", "software", "release"], kind: .action, - accessory: app.updater.canCheckForUpdates ? .run : .disabled("Unavailable"), + accessory: app.updater.canCheckForUpdates ? .run : .disabled(AppText.paletteUnavailable), icon: "arrow.down.app", tint: .blue) { if app.updater.canCheckForUpdates { app.updater.checkForUpdates() @@ -116,37 +124,39 @@ struct PaletteItem: Identifiable { app.flash(AppText.appUpdateChecksUnavailable) } }) - items.append(PaletteItem(title: "Check all image updates", subtitle: app.imageUpdateIntervalDescription, + items.append(PaletteItem(title: AppText.paletteCheckAllImageUpdates, subtitle: app.imageUpdateIntervalDescription, keywords: ["updates", "tags", "registry", "all images"], kind: .image, icon: "arrow.triangle.2.circlepath", tint: .blue) { Task { await app.runImageUpdateSweepNow() } }) - items.append(PaletteItem(title: "Update all images with available updates", subtitle: "pull newer tags", + items.append(PaletteItem(title: AppText.paletteUpdateAllImages, subtitle: AppText.palettePullNewerTagsSubtitle, keywords: ["pull", "updates", "tags", "all images"], kind: .image, icon: "arrow.down.circle", tint: .orange) { Task { await app.pullAvailableImageUpdates(manual: true) } }) - items.append(PaletteItem(title: "Check all container images for updates", subtitle: "\(app.containers.snapshots.count) containers", + items.append(PaletteItem(title: AppText.paletteCheckAllContainerImages, + subtitle: AppText.paletteContainerCountSubtitle(app.containers.snapshots.count), keywords: ["container updates", "check all containers", "image updates"], kind: .container, icon: "shippingbox.and.arrow.backward", tint: .blue) { Task { await app.checkContainerImageUpdates() } }) - items.append(PaletteItem(title: "Pull available container image updates", subtitle: "does not recreate containers", + items.append(PaletteItem(title: AppText.palettePullContainerImageUpdates, + subtitle: AppText.paletteDoesNotRecreateContainersSubtitle, keywords: ["update all containers", "pull container images", "container image updates"], kind: .container, icon: "arrow.down.circle", tint: .orange) { Task { await app.pullAvailableContainerImageUpdates() } }) // Maintenance actions. Load uses a native open panel and Prune a native confirmation. let pageActions: [(String, String, PendingAction)] = [ - ("Load image tar…", "square.and.arrow.down", .loadImage), - ("Prune images…", "trash", .pruneImages), + (AppText.paletteLoadImageTar, "square.and.arrow.down", .loadImage), + (AppText.palettePruneImages, "trash", .pruneImages), ] for (title, icon, action) in pageActions { - items.append(PaletteItem(title: title, subtitle: "action", kind: .action, icon: icon, tint: .secondary) { + items.append(PaletteItem(title: title, subtitle: AppText.paletteActionSubtitle, kind: .action, icon: icon, tint: .secondary) { ui.dispatch(action) }) } - items.append(PaletteItem(title: "System logs", subtitle: "action", + items.append(PaletteItem(title: AppText.paletteSystemLogs, subtitle: AppText.paletteActionSubtitle, keywords: ["service", "runtime", "diagnostics"], kind: .action, icon: "text.alignleft", tint: .secondary) { ui.dispatch(.systemLogs) @@ -158,13 +168,15 @@ struct PaletteItem: Identifiable { for snapshot in app.containers.snapshots { let name = app.containerStyle(for: snapshot) .displayName(fallback: snapshot.id) - items.append(PaletteItem(title: "Edit \(name)", subtitle: "container", keywords: [snapshot.id, snapshot.image], + items.append(PaletteItem(title: AppText.paletteEditContainer(name), + subtitle: AppText.paletteContainerSubtitle, + keywords: [snapshot.id, snapshot.image], kind: .container, visual: .container(snapshot), icon: "slider.horizontal.3", tint: .secondary) { ui.openCreationPanel(editing: snapshot) }) - items.append(PaletteItem(title: "Update image for \(name)", subtitle: snapshot.image, keywords: [snapshot.id, snapshot.image], + items.append(PaletteItem(title: AppText.paletteUpdateContainerImage(name), subtitle: snapshot.image, keywords: [snapshot.id, snapshot.image], kind: .container, visual: .container(snapshot), icon: "arrow.down.circle", tint: .blue) { @@ -175,14 +187,20 @@ struct PaletteItem: Identifiable { } }) if snapshot.state == .running { - items.append(PaletteItem(title: "Stop \(name)", subtitle: "container", kind: .container, visual: .container(snapshot), icon: "stop.fill", tint: .orange) { + items.append(PaletteItem(title: AppText.paletteStopContainer(name), + subtitle: AppText.paletteContainerSubtitle, + kind: .container, visual: .container(snapshot), icon: "stop.fill", tint: .orange) { Task { await app.containers.stop(snapshot.id) } }) - items.append(PaletteItem(title: "Restart \(name)", subtitle: "container", kind: .container, visual: .container(snapshot), icon: "arrow.clockwise", tint: .blue) { + items.append(PaletteItem(title: AppText.paletteRestartContainer(name), + subtitle: AppText.paletteContainerSubtitle, + kind: .container, visual: .container(snapshot), icon: "arrow.clockwise", tint: .blue) { Task { await app.containers.restart(snapshot.id) } }) } else { - items.append(PaletteItem(title: "Start \(name)", subtitle: "container", kind: .container, visual: .container(snapshot), icon: "play.fill", tint: .green) { + items.append(PaletteItem(title: AppText.paletteStartContainer(name), + subtitle: AppText.paletteContainerSubtitle, + kind: .container, visual: .container(snapshot), icon: "play.fill", tint: .green) { Task { await app.containers.start(snapshot.id) } }) } @@ -208,24 +226,24 @@ struct PaletteItem: Identifiable { (.about, "info.circle"), ] var items = settingsPages.map { page, icon in - PaletteItem(title: "\(page.rawValue) Settings", subtitle: "settings", + PaletteItem(title: AppText.paletteSettingsTitle(page.rawValue), subtitle: AppText.paletteSettingsSubtitle, keywords: ["preferences", page.rawValue.lowercased()], kind: .settings, icon: icon, tint: .secondary) { ui.openSettings(to: page) } } items.append(PaletteItem(title: app.settings.runningOnlyTitle(ui.runningOnly), - subtitle: "toggle", + subtitle: AppText.paletteToggleSubtitle, keywords: ["filter", "containers"], kind: .toggle, accessory: .toggle(isOn: { ui.runningOnly }, set: { ui.runningOnly = $0 }), icon: "play.circle", tint: .secondary) { - ui.runningOnly.toggle() + ui.runningOnly.toggle() }) - items.append(PaletteItem(title: app.settings.keepInMenuBar ? "Hide Menu Bar Item" : "Show Menu Bar Item", - subtitle: "toggle", + items.append(PaletteItem(title: app.settings.keepInMenuBar ? AppText.paletteHideMenuBarItem : AppText.paletteShowMenuBarItem, + subtitle: AppText.paletteToggleSubtitle, keywords: ["setting", "menubar", "menu bar"], kind: .toggle, accessory: .toggle(isOn: { app.settings.keepInMenuBar }, @@ -234,8 +252,8 @@ struct PaletteItem: Identifiable { tint: .secondary) { app.settings.keepInMenuBar.toggle() }) - items.append(PaletteItem(title: app.settings.revealCLI ? "Hide CLI Previews" : "Show CLI Previews", - subtitle: "toggle", + items.append(PaletteItem(title: app.settings.revealCLI ? AppText.paletteHideCLIPreviews : AppText.paletteShowCLIPreviews, + subtitle: AppText.paletteToggleSubtitle, keywords: ["setting", "command", "terminal"], kind: .toggle, accessory: .toggle(isOn: { app.settings.revealCLI }, @@ -244,8 +262,8 @@ struct PaletteItem: Identifiable { tint: .secondary) { app.settings.revealCLI.toggle() }) - items.append(PaletteItem(title: app.settings.showInfoTips ? "Hide Info Tips" : "Show Info Tips", - subtitle: "toggle", + items.append(PaletteItem(title: app.settings.showInfoTips ? AppText.paletteHideInfoTips : AppText.paletteShowInfoTips, + subtitle: AppText.paletteToggleSubtitle, keywords: ["setting", "help", "popover"], kind: .toggle, accessory: .toggle(isOn: { app.settings.showInfoTips }, @@ -256,7 +274,7 @@ struct PaletteItem: Identifiable { }) for tint in DesignTint.allCases { items.append(PaletteItem(title: AppText.setDesignTintTitle(tint.localizedDisplayName), - subtitle: "appearance", + subtitle: AppText.paletteAppearanceSubtitle, keywords: ["accent", "color", "theme", "tint", tint.rawValue] + tint.localizedSearchAliases, kind: .settings, visual: .tint(tint), @@ -273,8 +291,8 @@ struct PaletteItem: Identifiable { var items: [PaletteItem] = [] let groups = app.localImageGroups() for group in groups { - items.append(PaletteItem(title: "Run \(Format.shortImage(group.primaryReference))", - subtitle: "local image", + items.append(PaletteItem(title: AppText.paletteRunImage(Format.shortImage(group.primaryReference)), + subtitle: AppText.paletteLocalImageSubtitle, keywords: group.references, kind: .image, visual: .imageGroup(group), @@ -282,8 +300,8 @@ struct PaletteItem: Identifiable { tint: .green) { ui.runImage(group.primaryReference) }) - items.append(PaletteItem(title: "Check update for \(Format.shortImage(group.primaryReference))", - subtitle: "image", + items.append(PaletteItem(title: AppText.paletteCheckImageUpdate(Format.shortImage(group.primaryReference)), + subtitle: AppText.paletteImageSubtitle, keywords: group.references, kind: .image, visual: .imageGroup(group), @@ -292,8 +310,8 @@ struct PaletteItem: Identifiable { Task { await app.checkImageUpdate(group.primaryReference) } }) if app.imageUpdateStatus(for: group.primaryReference).state == .updateAvailable { - items.append(PaletteItem(title: "Pull update for \(Format.shortImage(group.primaryReference))", - subtitle: "image", + items.append(PaletteItem(title: AppText.palettePullImageUpdate(Format.shortImage(group.primaryReference)), + subtitle: AppText.paletteImageSubtitle, keywords: group.references, kind: .image, visual: .imageGroup(group), @@ -303,8 +321,8 @@ struct PaletteItem: Identifiable { }) } for reference in group.references where reference != group.primaryReference { - items.append(PaletteItem(title: "Run \(Format.shortImage(reference))", - subtitle: "image tag", + items.append(PaletteItem(title: AppText.paletteRunImage(Format.shortImage(reference)), + subtitle: AppText.paletteImageTagSubtitle, keywords: [group.primaryReference, reference], kind: .image, visual: .imageTag(reference, groupID: group.id), @@ -321,8 +339,8 @@ struct PaletteItem: Identifiable { private static func volumeItems(app: AppModel, ui: UIState) -> [PaletteItem] { app.volumes.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } .map { volume in - PaletteItem(title: "Use volume \(volume.name)", - subtitle: "volume", + PaletteItem(title: AppText.paletteUseVolume(volume.name), + subtitle: AppText.paletteVolumeSubtitle, keywords: ["storage", volume.name], kind: .resource, visual: .volume(volume), @@ -339,8 +357,8 @@ struct PaletteItem: Identifiable { private static func networkItems(app: AppModel, ui: UIState) -> [PaletteItem] { app.networks.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } .map { network in - PaletteItem(title: "Run on network \(network.name)", - subtitle: network.isBuiltin ? "built-in network" : "network", + PaletteItem(title: AppText.paletteRunOnNetwork(network.name), + subtitle: network.isBuiltin ? AppText.paletteBuiltInNetworkSubtitle : AppText.paletteNetworkSubtitle, keywords: ["network", network.name], kind: .resource, visual: .network(network), @@ -390,13 +408,27 @@ enum PaletteItemKind: String { /// palette into labelled sections when there's no active query. var section: (order: Int, title: String) { switch self { - case .navigation: return (0, "Navigate") - case .create, .search: return (1, "Create & Search") - case .container: return (2, "Containers") - case .image: return (3, "Images") - case .resource: return (4, "Volumes & Networks") - case .settings, .toggle: return (5, "Settings") - case .action: return (6, "Actions") + case .navigation: return (0, AppText.paletteKindNavigate) + case .create, .search: return (1, AppText.paletteSectionCreateSearch) + case .container: return (2, AppText.paletteSectionContainers) + case .image: return (3, AppText.paletteSectionImages) + case .resource: return (4, AppText.paletteSectionVolumesNetworks) + case .settings, .toggle: return (5, AppText.paletteKindSettings) + case .action: return (6, AppText.paletteSectionActions) + } + } + + var localizedTitle: String { + switch self { + case .action: AppText.paletteKindAction + case .create: AppText.paletteKindCreate + case .navigation: AppText.paletteKindNavigate + case .settings: AppText.paletteKindSettings + case .toggle: AppText.paletteKindToggle + case .image: AppText.paletteKindImage + case .container: AppText.paletteKindContainer + case .resource: AppText.paletteKindResource + case .search: AppText.paletteKindSearch } } } @@ -419,6 +451,6 @@ enum PaletteItemVisual { private extension SettingsStore { func runningOnlyTitle(_ runningOnly: Bool) -> String { - runningOnly ? "Show All Containers" : "Show Running Containers Only" + runningOnly ? AppText.paletteShowAllContainers : AppText.paletteShowRunningContainersOnly } } diff --git a/Sources/ContainedApp/Navigation/AppSection.swift b/Sources/ContainedApp/Navigation/AppSection.swift index 9e70726d..c2b6caf0 100644 --- a/Sources/ContainedApp/Navigation/AppSection.swift +++ b/Sources/ContainedApp/Navigation/AppSection.swift @@ -44,16 +44,16 @@ enum AppSection: String, CaseIterable, Identifiable, Hashable { var title: String { switch self { - case .containers: return "Containers" - case .images: return "Images" - case .build: return "Build" - case .volumes: return "Volumes" - case .networks: return "Networks" - case .system: return "System" - case .registries: return "Registries" - case .templates: return "Templates" - case .activity: return "Activity" - case .settings: return "Settings" + case .containers: return AppText.sectionContainers + case .images: return AppText.sectionImages + case .build: return AppText.sectionBuild + case .volumes: return AppText.sectionVolumes + case .networks: return AppText.sectionNetworks + case .system: return AppText.sectionSystem + case .registries: return AppText.sectionRegistries + case .templates: return AppText.sectionTemplates + case .activity: return AppText.sectionActivity + case .settings: return AppText.sectionSettings } } @@ -90,4 +90,12 @@ enum AppSectionGroup: String, CaseIterable, Identifiable { case system = "System" var id: String { rawValue } + + var title: String { + switch self { + case .workloads: AppText.sectionGroupWorkloads + case .infra: AppText.sectionGroupInfra + case .system: AppText.sectionGroupSystem + } + } } diff --git a/Sources/ContainedApp/Navigation/ClassicShell.swift b/Sources/ContainedApp/Navigation/ClassicShell.swift index 6d0f2230..9195c32d 100644 --- a/Sources/ContainedApp/Navigation/ClassicShell.swift +++ b/Sources/ContainedApp/Navigation/ClassicShell.swift @@ -86,7 +86,7 @@ private struct AppSidebar: View { let sections = AppSection.navigableSections(panelNavigationEnabled: ui.panelNavigationEnabled) .filter { $0.group == group && isVisible($0) } if !sections.isEmpty { - Section(group.rawValue) { + Section(group.title) { ForEach(sections) { section in Label { HStack { diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift index 9c22d393..f5cee298 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift @@ -51,7 +51,7 @@ struct PaletteResultCard: View { ? DesignTokens.DesignCard.iconSelectedBackgroundOpacity : DesignTokens.DesignCard.iconBackgroundOpacity) } titleAccessory: { - DesignBadgeText(text: item.kind.rawValue, + DesignBadgeText(text: item.kind.localizedTitle, font: .caption2.weight(.semibold), foreground: selected ? .accentColor : .secondary) } subtitleAccessory: { diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift index 3a179a7b..7d264002 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift @@ -46,9 +46,9 @@ struct ToolbarCommandPalette: View { private var sections: [PaletteSection] { switch scope { case .dockerHub: - return [PaletteSection(title: "Docker Hub", items: PaletteItem.deduplicated(hubItems()))] + return [PaletteSection(title: AppText.paletteDockerHub, items: PaletteItem.deduplicated(hubItems()))] case .localImages: - return [PaletteSection(title: "Local images", items: PaletteItem.deduplicated(localImageItems()))] + return [PaletteSection(title: AppText.paletteLocalImages, items: PaletteItem.deduplicated(localImageItems()))] case nil: if trimmedQuery.isEmpty { return browseSections() @@ -110,7 +110,7 @@ struct ToolbarCommandPalette: View { if let scope { scopeChip(scope) } - TextField(scope?.placeholder ?? "Search or run a command…", text: $ui.searchText) + TextField(scope?.placeholder ?? AppText.paletteSearchOrRunPlaceholder, text: $ui.searchText) .textFieldStyle(.plain) .font(.body).fontWeight(.medium) .focused($focused) @@ -141,7 +141,7 @@ struct ToolbarCommandPalette: View { DesignScopeChipLabel(symbol: scope.symbol, title: scope.title) } .buttonStyle(.plain) - .help("Remove \(scope.title) scope") + .help(AppText.removeScopeAccessibility(scope.title)) .accessibilityLabel(AppText.removeScopeAccessibility(scope.title)) .fixedSize() } @@ -152,28 +152,28 @@ struct ToolbarCommandPalette: View { DesignBadgeText(text: scopeCountText(scope)) Spacer() Button { ui.paletteScope = nil } label: { - Label("Commands", systemImage: "command") + Label(AppText.paletteCommands, systemImage: "command") .labelStyle(.titleAndIcon) } .buttonStyle(.plain) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) - .help("Back to commands") + .help(AppText.paletteBackToCommands) } else { - DesignBadgeText(text: "\(flatItems.count) match\(flatItems.count == 1 ? "" : "es")") - DesignBadgeText(text: "\(localImageMatches) local image\(localImageMatches == 1 ? "" : "s")") + DesignBadgeText(text: AppText.paletteMatchesCount(flatItems.count)) + DesignBadgeText(text: AppText.paletteLocalImagesCount(localImageMatches)) Spacer() if !trimmedQuery.isEmpty { // "Hit search on a search entry" — pins the Docker Hub scope and keeps the typed // query, searching in-place inside the same panel area. Button { ui.paletteScope = .dockerHub } label: { - Label("Search Docker Hub", systemImage: "globe") + Label(AppText.paletteSearchDockerHub, systemImage: "globe") .labelStyle(.titleAndIcon) } .buttonStyle(.plain) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) - .help("Search Docker Hub for \(trimmedQuery)") + .help(AppText.paletteSearchDockerHubFor(trimmedQuery)) } } } @@ -183,14 +183,14 @@ struct ToolbarCommandPalette: View { private func scopeCountText(_ scope: PaletteScope) -> String { switch scope { case .dockerHub: - if hubSearching { return "Searching…" } - if hubError != nil { return "Couldn't reach Docker Hub" } - if trimmedQuery.isEmpty { return "Popular images" } + if hubSearching { return AppText.paletteSearching } + if hubError != nil { return AppText.paletteDockerHubUnreachable } + if trimmedQuery.isEmpty { return AppText.palettePopularImages } let n = hubResults.count - return "\(n) result\(n == 1 ? "" : "s")" + return AppText.paletteResultsCount(n) case .localImages: let n = flatItems.count - return "\(n) image\(n == 1 ? "" : "s")" + return AppText.paletteImagesCount(n) } } @@ -238,9 +238,9 @@ struct ToolbarCommandPalette: View { } else { DesignContentSurface(minHeight: 260) { ContentUnavailableView { - Label("No matches", systemImage: "magnifyingglass") + Label(AppText.paletteNoMatches, systemImage: "magnifyingglass") } description: { - Text("Try a setting, image, container, network, or action.") + Text(AppText.paletteNoMatchesDescription) } } } @@ -251,14 +251,14 @@ struct ToolbarCommandPalette: View { LazyVStack(spacing: DesignTokens.Space.s) { if hubSearching { ProgressView() - Text("Searching Docker Hub…").font(.callout).foregroundStyle(.secondary) + Text(AppText.paletteSearchingDockerHub).font(.callout).foregroundStyle(.secondary) } else if let hubError { Image(systemName: "wifi.exclamationmark").font(.title2).foregroundStyle(.orange) - Text("Couldn't search Docker Hub").font(.callout.weight(.medium)) + Text(AppText.paletteCouldNotSearchDockerHub).font(.callout.weight(.medium)) Text(hubError).font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center) } else { Image(systemName: "magnifyingglass").font(.title2).foregroundStyle(.tertiary) - Text(trimmedQuery.isEmpty ? "Type to search Docker Hub" : "No images found for “\(trimmedQuery)”") + Text(trimmedQuery.isEmpty ? AppText.paletteTypeToSearchDockerHub : AppText.paletteNoImagesFound(trimmedQuery)) .font(.callout).foregroundStyle(.secondary) } } @@ -267,12 +267,12 @@ struct ToolbarCommandPalette: View { private var footerBar: some View { HStack(spacing: DesignTokens.Space.m) { - keyboardHint("↑↓", "Select") - keyboardHint("return", "Run") - keyboardHint("esc", scope == nil ? "Close" : "Clear scope") + keyboardHint("↑↓", AppText.paletteKeyboardSelect) + keyboardHint("return", AppText.paletteKeyboardRun) + keyboardHint("esc", scope == nil ? AppText.close : AppText.paletteKeyboardClearScope) Spacer() if let selected = selectedItem { - DesignBadgeText(text: selected.kind.rawValue) + DesignBadgeText(text: selected.kind.localizedTitle) } } .padding(.horizontal, DesignTokens.Space.l) @@ -318,7 +318,7 @@ struct ToolbarCommandPalette: View { private func hubItems() -> [PaletteItem] { if trimmedQuery.isEmpty { return RecommendedImage.all.map { rec in - PaletteItem(title: "Run \(rec.name)", subtitle: rec.reference, + PaletteItem(title: AppText.paletteRunImage(rec.name), subtitle: rec.reference, keywords: [rec.reference], kind: .image, icon: rec.symbol, tint: .accentColor) { ui.runImage(rec.reference, returningTo: .search) @@ -329,7 +329,7 @@ struct ToolbarCommandPalette: View { return hubResults.map { result in let subtitle = result.shortDescription?.isEmpty == false ? result.shortDescription - : (result.isOfficial ? "Official image" : "Docker Hub") + : (result.isOfficial ? AppText.paletteOfficialImage : AppText.paletteDockerHub) return PaletteItem(title: result.repoName, subtitle: subtitle, keywords: [result.repoName], @@ -347,8 +347,8 @@ struct ToolbarCommandPalette: View { PaletteSearch.score(query: trimmedQuery, in: $0.references + [Format.shortImage($0.primaryReference)]) != nil } return matched.map { group in - PaletteItem(title: "Run \(Format.shortImage(group.primaryReference))", - subtitle: "\(group.references.count) tag\(group.references.count == 1 ? "" : "s")", + PaletteItem(title: AppText.paletteRunImage(Format.shortImage(group.primaryReference)), + subtitle: AppText.paletteTagCountSubtitle(group.references.count), keywords: group.references, kind: .image, visual: .imageGroup(group), diff --git a/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift b/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift index 107b7c1d..a89be02c 100644 --- a/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift +++ b/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift @@ -17,7 +17,7 @@ struct ToolbarPageSwitcher: View { let sections = AppSection.navigableSections(panelNavigationEnabled: ui.panelNavigationEnabled) .filter { $0.group == group && ($0 != .build || app.settings.imageBuildEnabled) } if !sections.isEmpty { - Section(group.rawValue) { + Section(group.title) { ForEach(sections) { section in Button { ui.navigate(to: section) diff --git a/Sources/ContainedApp/Resources/Localizable.xcstrings b/Sources/ContainedApp/Resources/Localizable.xcstrings index 588db624..4ea344a8 100644 --- a/Sources/ContainedApp/Resources/Localizable.xcstrings +++ b/Sources/ContainedApp/Resources/Localizable.xcstrings @@ -3,6 +3,7 @@ "strings": { "activity.clearActivity": {}, "activity.markAllRead": {}, + "activity.pullingImage": {}, "appearance.dark": {}, "appearance.light": {}, "appearance.system": {}, @@ -150,8 +151,108 @@ "network.newNetwork": {}, "network.newNetwork.lowercase": {}, "network.refreshNetworks": {}, + "notification.containerRestarted.body": {}, + "notification.containerRestarted.bodyWithAttempt": {}, + "notification.containerRestarted.title": {}, + "notification.containerStopped.body": {}, + "notification.containerStopped.title": {}, + "notification.containerUnhealthy.body": {}, + "notification.containerUnhealthy.title": {}, + "palette.action.checkAllContainerImages": {}, + "palette.action.checkAllImageUpdates": {}, + "palette.action.checkAppUpdates": {}, + "palette.action.checkImageUpdate": {}, + "palette.action.editContainer": {}, + "palette.action.hideCLIPreviews": {}, + "palette.action.hideInfoTips": {}, + "palette.action.hideMenuBarItem": {}, + "palette.action.importCompose": {}, + "palette.action.loadImageTar": {}, + "palette.action.newNetwork": {}, + "palette.action.newVolume": {}, + "palette.action.pruneImages": {}, + "palette.action.pullContainerImageUpdates": {}, + "palette.action.pullImageUpdate": {}, + "palette.action.refresh": {}, + "palette.action.registryLogin": {}, + "palette.action.restartContainer": {}, + "palette.action.runContainer": {}, + "palette.action.runImage": {}, + "palette.action.runOnNetwork": {}, + "palette.action.searchDockerHub": {}, + "palette.action.searchLocalImages": {}, + "palette.action.showAllContainers": {}, + "palette.action.showCLIPreviews": {}, + "palette.action.showInfoTips": {}, + "palette.action.showMenuBarItem": {}, + "palette.action.showRunningOnly": {}, + "palette.action.startContainer": {}, + "palette.action.stopContainer": {}, + "palette.action.systemLogs": {}, + "palette.action.updateAllImages": {}, + "palette.action.updateContainerImage": {}, + "palette.action.useVolume": {}, + "palette.backToCommands": {}, + "palette.commands": {}, + "palette.couldNotSearchDockerHub": {}, + "palette.dockerHub": {}, + "palette.dockerHubUnreachable": {}, + "palette.filterLocalImagesPlaceholder": {}, + "palette.imagesCount": {}, + "palette.kind.action": {}, + "palette.kind.container": {}, + "palette.kind.create": {}, + "palette.kind.image": {}, + "palette.kind.navigate": {}, + "palette.kind.resource": {}, + "palette.kind.search": {}, + "palette.kind.settings": {}, + "palette.kind.toggle": {}, + "palette.keyboard.clearScope": {}, + "palette.keyboard.run": {}, + "palette.keyboard.select": {}, + "palette.localImages": {}, + "palette.localImagesCount": {}, + "palette.matchesCount": {}, + "palette.noImagesFound": {}, + "palette.noMatches": {}, + "palette.noMatchesDescription": {}, + "palette.officialImage": {}, + "palette.popularImages": {}, "palette.removeScopeAccessibilityLabel": {}, + "palette.resultsCount": {}, + "palette.searchDockerHubFor": {}, + "palette.searchDockerHubPlaceholder": {}, + "palette.searchOrRunPlaceholder": {}, + "palette.searching": {}, + "palette.searchingDockerHub": {}, + "palette.section.actions": {}, + "palette.section.containers": {}, + "palette.section.createSearch": {}, + "palette.section.images": {}, + "palette.section.volumesNetworks": {}, "palette.setDesignTint": {}, + "palette.settingsTitle": {}, + "palette.subtitle.action": {}, + "palette.subtitle.appearance": {}, + "palette.subtitle.builtInNetwork": {}, + "palette.subtitle.container": {}, + "palette.subtitle.containerCount": {}, + "palette.subtitle.create": {}, + "palette.subtitle.doesNotRecreateContainers": {}, + "palette.subtitle.image": {}, + "palette.subtitle.imageTag": {}, + "palette.subtitle.localImage": {}, + "palette.subtitle.network": {}, + "palette.subtitle.pullNewerTags": {}, + "palette.subtitle.scope": {}, + "palette.subtitle.settings": {}, + "palette.subtitle.tagCount": {}, + "palette.subtitle.toggle": {}, + "palette.subtitle.updates": {}, + "palette.subtitle.volume": {}, + "palette.typeToSearchDockerHub": {}, + "palette.unavailable": {}, "restartPolicy.always": {}, "restartPolicy.no": {}, "restartPolicy.onFailure": {}, @@ -166,8 +267,19 @@ "runtime.coreSelector.disabledReason": {}, "runtime.notReady": {}, "runtime.recommendedKernelInstalled": {}, + "section.activity": {}, + "section.build": {}, + "section.containers": {}, "section.images": {}, + "section.networks": {}, + "section.registries": {}, + "section.settings": {}, + "section.system": {}, "section.templates": {}, + "section.volumes": {}, + "sectionGroup.infra": {}, + "sectionGroup.system": {}, + "sectionGroup.workloads": {}, "selection.count": {}, "service.restart": {}, "service.start": {}, diff --git a/Sources/ContainedApp/Stores/AppModel.swift b/Sources/ContainedApp/Stores/AppModel.swift index 88c81d9e..d3323a9e 100644 --- a/Sources/ContainedApp/Stores/AppModel.swift +++ b/Sources/ContainedApp/Stores/AppModel.swift @@ -603,7 +603,7 @@ final class AppModel { @discardableResult func pullImage(_ reference: String) async -> Bool { guard let client else { return false } - activity = ActivityState(title: "Pulling \(Format.shortImage(reference))…") + activity = ActivityState(title: AppText.activityPullingImage(Format.shortImage(reference))) defer { activity = nil } do { for try await line in client.streamPull(reference, platform: nil) { diff --git a/Sources/ContainedApp/Support/AppLocalization.swift b/Sources/ContainedApp/Support/AppLocalization.swift index cbb4ce51..5d143779 100644 --- a/Sources/ContainedApp/Support/AppLocalization.swift +++ b/Sources/ContainedApp/Support/AppLocalization.swift @@ -73,6 +73,19 @@ enum AppText { static var storageCleanup: String { string("system.storageCleanup", defaultValue: "Storage cleanup") } static var markAllRead: String { string("activity.markAllRead", defaultValue: "Mark all read") } static var clearActivity: String { string("activity.clearActivity", defaultValue: "Clear activity") } + static var sectionContainers: String { string("section.containers", defaultValue: "Containers") } + static var sectionImages: String { string("section.images", defaultValue: "Images") } + static var sectionBuild: String { string("section.build", defaultValue: "Build") } + static var sectionVolumes: String { string("section.volumes", defaultValue: "Volumes") } + static var sectionNetworks: String { string("section.networks", defaultValue: "Networks") } + static var sectionSystem: String { string("section.system", defaultValue: "System") } + static var sectionRegistries: String { string("section.registries", defaultValue: "Registries") } + static var sectionTemplates: String { string("section.templates", defaultValue: "Templates") } + static var sectionActivity: String { string("section.activity", defaultValue: "Activity") } + static var sectionSettings: String { string("section.settings", defaultValue: "Settings") } + static var sectionGroupWorkloads: String { string("sectionGroup.workloads", defaultValue: "Workloads") } + static var sectionGroupInfra: String { string("sectionGroup.infra", defaultValue: "Infra") } + static var sectionGroupSystem: String { string("sectionGroup.system", defaultValue: "System") } static var tint: String { string("common.tint", defaultValue: "Tint") } static var unread: String { string("common.unread", defaultValue: "Unread") } static var runtimeCore: String { string("runtime.core", defaultValue: "Core") } @@ -123,6 +136,217 @@ enum AppText { string("streamConsole.lineCount", defaultValue: "\(count) lines") } + static var paletteDockerHub: String { string("palette.dockerHub", defaultValue: "Docker Hub") } + static var paletteLocalImages: String { string("palette.localImages", defaultValue: "Local images") } + static var paletteSearchDockerHubPlaceholder: String { + string("palette.searchDockerHubPlaceholder", defaultValue: "Search Docker Hub...") + } + static var paletteFilterLocalImagesPlaceholder: String { + string("palette.filterLocalImagesPlaceholder", defaultValue: "Filter local images...") + } + static var paletteSearchOrRunPlaceholder: String { + string("palette.searchOrRunPlaceholder", defaultValue: "Search or run a command...") + } + + static var paletteCreateSubtitle: String { string("palette.subtitle.create", defaultValue: "create") } + static var paletteActionSubtitle: String { string("palette.subtitle.action", defaultValue: "action") } + static var paletteScopeSubtitle: String { string("palette.subtitle.scope", defaultValue: "scope") } + static var paletteUpdatesSubtitle: String { string("palette.subtitle.updates", defaultValue: "updates") } + static var paletteContainerSubtitle: String { string("palette.subtitle.container", defaultValue: "container") } + static var paletteSettingsSubtitle: String { string("palette.subtitle.settings", defaultValue: "settings") } + static var paletteToggleSubtitle: String { string("palette.subtitle.toggle", defaultValue: "toggle") } + static var paletteAppearanceSubtitle: String { string("palette.subtitle.appearance", defaultValue: "appearance") } + static var paletteLocalImageSubtitle: String { string("palette.subtitle.localImage", defaultValue: "local image") } + static var paletteImageSubtitle: String { string("palette.subtitle.image", defaultValue: "image") } + static var paletteImageTagSubtitle: String { string("palette.subtitle.imageTag", defaultValue: "image tag") } + static var paletteVolumeSubtitle: String { string("palette.subtitle.volume", defaultValue: "volume") } + static var paletteNetworkSubtitle: String { string("palette.subtitle.network", defaultValue: "network") } + static var paletteBuiltInNetworkSubtitle: String { + string("palette.subtitle.builtInNetwork", defaultValue: "built-in network") + } + static var palettePullNewerTagsSubtitle: String { + string("palette.subtitle.pullNewerTags", defaultValue: "pull newer tags") + } + static var paletteDoesNotRecreateContainersSubtitle: String { + string("palette.subtitle.doesNotRecreateContainers", defaultValue: "does not recreate containers") + } + static func paletteContainerCountSubtitle(_ count: Int) -> String { + string("palette.subtitle.containerCount", defaultValue: "\(count) containers") + } + static func paletteTagCountSubtitle(_ count: Int) -> String { + string("palette.subtitle.tagCount", defaultValue: "\(count) tag\(count == 1 ? "" : "s")") + } + + static var paletteRunContainer: String { string("palette.action.runContainer", defaultValue: "Run a container") } + static var paletteNewVolume: String { string("palette.action.newVolume", defaultValue: "New volume") } + static var paletteNewNetwork: String { string("palette.action.newNetwork", defaultValue: "New network") } + static var paletteImportCompose: String { string("palette.action.importCompose", defaultValue: "Import compose...") } + static var paletteRegistryLogin: String { string("palette.action.registryLogin", defaultValue: "Registry login") } + static var paletteSearchDockerHub: String { string("palette.action.searchDockerHub", defaultValue: "Search Docker Hub") } + static var paletteSearchLocalImages: String { string("palette.action.searchLocalImages", defaultValue: "Search local images") } + static var paletteRefresh: String { string("palette.action.refresh", defaultValue: "Refresh") } + static var paletteCheckAppUpdates: String { + string("palette.action.checkAppUpdates", defaultValue: "Check for app updates...") + } + static var paletteCheckAllImageUpdates: String { + string("palette.action.checkAllImageUpdates", defaultValue: "Check all image updates") + } + static var paletteUpdateAllImages: String { + string("palette.action.updateAllImages", defaultValue: "Update all images with available updates") + } + static var paletteCheckAllContainerImages: String { + string("palette.action.checkAllContainerImages", defaultValue: "Check all container images for updates") + } + static var palettePullContainerImageUpdates: String { + string("palette.action.pullContainerImageUpdates", defaultValue: "Pull available container image updates") + } + static var paletteLoadImageTar: String { string("palette.action.loadImageTar", defaultValue: "Load image tar...") } + static var palettePruneImages: String { string("palette.action.pruneImages", defaultValue: "Prune images...") } + static var paletteSystemLogs: String { string("palette.action.systemLogs", defaultValue: "System logs") } + static var paletteUnavailable: String { string("palette.unavailable", defaultValue: "Unavailable") } + + static func paletteSettingsTitle(_ page: String) -> String { + string("palette.settingsTitle", defaultValue: "\(page) Settings") + } + static var paletteShowAllContainers: String { + string("palette.action.showAllContainers", defaultValue: "Show All Containers") + } + static var paletteShowRunningContainersOnly: String { + string("palette.action.showRunningOnly", defaultValue: "Show Running Containers Only") + } + static var paletteHideMenuBarItem: String { + string("palette.action.hideMenuBarItem", defaultValue: "Hide Menu Bar Item") + } + static var paletteShowMenuBarItem: String { + string("palette.action.showMenuBarItem", defaultValue: "Show Menu Bar Item") + } + static var paletteHideCLIPreviews: String { + string("palette.action.hideCLIPreviews", defaultValue: "Hide CLI Previews") + } + static var paletteShowCLIPreviews: String { + string("palette.action.showCLIPreviews", defaultValue: "Show CLI Previews") + } + static var paletteHideInfoTips: String { string("palette.action.hideInfoTips", defaultValue: "Hide Info Tips") } + static var paletteShowInfoTips: String { string("palette.action.showInfoTips", defaultValue: "Show Info Tips") } + static func paletteRunImage(_ reference: String) -> String { + string("palette.action.runImage", defaultValue: "Run \(reference)") + } + static func paletteCheckImageUpdate(_ reference: String) -> String { + string("palette.action.checkImageUpdate", defaultValue: "Check update for \(reference)") + } + static func palettePullImageUpdate(_ reference: String) -> String { + string("palette.action.pullImageUpdate", defaultValue: "Pull update for \(reference)") + } + static func paletteUseVolume(_ name: String) -> String { + string("palette.action.useVolume", defaultValue: "Use volume \(name)") + } + static func paletteRunOnNetwork(_ name: String) -> String { + string("palette.action.runOnNetwork", defaultValue: "Run on network \(name)") + } + static func paletteEditContainer(_ name: String) -> String { + string("palette.action.editContainer", defaultValue: "Edit \(name)") + } + static func paletteUpdateContainerImage(_ name: String) -> String { + string("palette.action.updateContainerImage", defaultValue: "Update image for \(name)") + } + static func paletteStopContainer(_ name: String) -> String { + string("palette.action.stopContainer", defaultValue: "Stop \(name)") + } + static func paletteRestartContainer(_ name: String) -> String { + string("palette.action.restartContainer", defaultValue: "Restart \(name)") + } + static func paletteStartContainer(_ name: String) -> String { + string("palette.action.startContainer", defaultValue: "Start \(name)") + } + + static var paletteKindAction: String { string("palette.kind.action", defaultValue: "Action") } + static var paletteKindCreate: String { string("palette.kind.create", defaultValue: "Create") } + static var paletteKindNavigate: String { string("palette.kind.navigate", defaultValue: "Navigate") } + static var paletteKindSettings: String { string("palette.kind.settings", defaultValue: "Settings") } + static var paletteKindToggle: String { string("palette.kind.toggle", defaultValue: "Toggle") } + static var paletteKindImage: String { string("palette.kind.image", defaultValue: "Image") } + static var paletteKindContainer: String { string("palette.kind.container", defaultValue: "Container") } + static var paletteKindResource: String { string("palette.kind.resource", defaultValue: "Resource") } + static var paletteKindSearch: String { string("palette.kind.search", defaultValue: "Search") } + static var paletteSectionCreateSearch: String { + string("palette.section.createSearch", defaultValue: "Create & Search") + } + static var paletteSectionContainers: String { string("palette.section.containers", defaultValue: "Containers") } + static var paletteSectionImages: String { string("palette.section.images", defaultValue: "Images") } + static var paletteSectionVolumesNetworks: String { + string("palette.section.volumesNetworks", defaultValue: "Volumes & Networks") + } + static var paletteSectionActions: String { string("palette.section.actions", defaultValue: "Actions") } + + static var paletteCommands: String { string("palette.commands", defaultValue: "Commands") } + static var paletteBackToCommands: String { string("palette.backToCommands", defaultValue: "Back to commands") } + static func paletteSearchDockerHubFor(_ query: String) -> String { + string("palette.searchDockerHubFor", defaultValue: "Search Docker Hub for \(query)") + } + static func paletteMatchesCount(_ count: Int) -> String { + string("palette.matchesCount", defaultValue: "\(count) match\(count == 1 ? "" : "es")") + } + static func paletteLocalImagesCount(_ count: Int) -> String { + string("palette.localImagesCount", defaultValue: "\(count) local image\(count == 1 ? "" : "s")") + } + static var paletteSearching: String { string("palette.searching", defaultValue: "Searching...") } + static var paletteDockerHubUnreachable: String { + string("palette.dockerHubUnreachable", defaultValue: "Couldn't reach Docker Hub") + } + static var palettePopularImages: String { string("palette.popularImages", defaultValue: "Popular images") } + static func paletteResultsCount(_ count: Int) -> String { + string("palette.resultsCount", defaultValue: "\(count) result\(count == 1 ? "" : "s")") + } + static func paletteImagesCount(_ count: Int) -> String { + string("palette.imagesCount", defaultValue: "\(count) image\(count == 1 ? "" : "s")") + } + static var paletteNoMatches: String { string("palette.noMatches", defaultValue: "No matches") } + static var paletteNoMatchesDescription: String { + string("palette.noMatchesDescription", defaultValue: "Try a setting, image, container, network, or action.") + } + static var paletteSearchingDockerHub: String { + string("palette.searchingDockerHub", defaultValue: "Searching Docker Hub...") + } + static var paletteCouldNotSearchDockerHub: String { + string("palette.couldNotSearchDockerHub", defaultValue: "Couldn't search Docker Hub") + } + static var paletteTypeToSearchDockerHub: String { + string("palette.typeToSearchDockerHub", defaultValue: "Type to search Docker Hub") + } + static func paletteNoImagesFound(_ query: String) -> String { + string("palette.noImagesFound", defaultValue: "No images found for \"\(query)\"") + } + static var paletteKeyboardSelect: String { string("palette.keyboard.select", defaultValue: "Select") } + static var paletteKeyboardRun: String { string("palette.keyboard.run", defaultValue: "Run") } + static var paletteKeyboardClearScope: String { string("palette.keyboard.clearScope", defaultValue: "Clear scope") } + static var paletteOfficialImage: String { string("palette.officialImage", defaultValue: "Official image") } + + static var notificationContainerRestartedTitle: String { + string("notification.containerRestarted.title", defaultValue: "Container restarted") + } + static func notificationContainerRestartedBody(name: String, attempt: Int) -> String { + attempt > 1 + ? string("notification.containerRestarted.bodyWithAttempt", + defaultValue: "\(name) was restarted (attempt \(attempt)).") + : string("notification.containerRestarted.body", defaultValue: "\(name) was restarted.") + } + static var notificationContainerStoppedTitle: String { + string("notification.containerStopped.title", defaultValue: "Container stopped") + } + static func notificationContainerStoppedBody(_ name: String) -> String { + string("notification.containerStopped.body", defaultValue: "\(name) exited unexpectedly.") + } + static var notificationContainerUnhealthyTitle: String { + string("notification.containerUnhealthy.title", defaultValue: "Container unhealthy") + } + static func notificationContainerUnhealthyBody(_ name: String) -> String { + string("notification.containerUnhealthy.body", defaultValue: "\(name) failed its healthcheck.") + } + + static func activityPullingImage(_ reference: String) -> String { + string("activity.pullingImage", defaultValue: "Pulling \(reference)...") + } + static func restartedContainer(_ name: String, attempt: Int) -> String { string("container.restartedAttempt", defaultValue: "Restarted \(name) (attempt \(attempt))") } diff --git a/Sources/ContainedApp/Support/Notifier.swift b/Sources/ContainedApp/Support/Notifier.swift index 478373dc..d07ed1e5 100644 --- a/Sources/ContainedApp/Support/Notifier.swift +++ b/Sources/ContainedApp/Support/Notifier.swift @@ -11,20 +11,22 @@ final class Notifier { /// Post when the watchdog restarts a container. func containerRestarted(name: String, attempt: Int, enabled: Bool) { guard enabled else { return } - post(title: "Container restarted", - body: attempt > 1 ? "\(name) was restarted (attempt \(attempt))." : "\(name) was restarted.") + post(title: AppText.notificationContainerRestartedTitle, + body: AppText.notificationContainerRestartedBody(name: name, attempt: attempt)) } /// Post when a container exits unexpectedly and no restart policy applies. func containerExited(name: String, enabled: Bool) { guard enabled else { return } - post(title: "Container stopped", body: "\(name) exited unexpectedly.") + post(title: AppText.notificationContainerStoppedTitle, + body: AppText.notificationContainerStoppedBody(name)) } /// Post when an app-managed healthcheck flips a container to unhealthy. func containerUnhealthy(name: String, enabled: Bool) { guard enabled else { return } - post(title: "Container unhealthy", body: "\(name) failed its healthcheck.") + post(title: AppText.notificationContainerUnhealthyTitle, + body: AppText.notificationContainerUnhealthyBody(name)) } private func post(title: String, body: String) { From 932a70fea24d3221e7d43d5811d9f4d92a697c19 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 19:21:42 +0100 Subject: [PATCH 36/53] Route settings copy through app localization --- .../Features/Settings/SettingsView.swift | 18 ++- .../Features/Settings/Tabs/AboutTab.swift | 8 +- .../Settings/Tabs/AppearanceTab.swift | 71 ++++++----- .../Settings/Tabs/ExperimentalTab.swift | 36 +++--- .../Features/Settings/Tabs/GeneralTab.swift | 37 +++--- .../Settings/Tabs/RegistriesTab.swift | 4 +- .../Features/Settings/Tabs/RuntimeTab.swift | 28 ++--- .../Features/Settings/Tabs/UpdatesTab.swift | 24 ++-- .../Resources/Localizable.xcstrings | 114 ++++++++++++++++-- .../Support/AppLocalization.swift | 7 ++ 10 files changed, 235 insertions(+), 112 deletions(-) diff --git a/Sources/ContainedApp/Features/Settings/SettingsView.swift b/Sources/ContainedApp/Features/Settings/SettingsView.swift index 60a71b81..eab113b2 100644 --- a/Sources/ContainedApp/Features/Settings/SettingsView.swift +++ b/Sources/ContainedApp/Features/Settings/SettingsView.swift @@ -27,6 +27,18 @@ struct SettingsContent: View { var id: String { rawValue } + var title: String { + switch self { + case .appearance: AppText.sectionSettingsAppearance + case .general: AppText.sectionSettingsGeneral + case .runtime: AppText.sectionSettingsRuntime + case .registries: AppText.sectionSettingsRegistries + case .experimental: AppText.sectionSettingsExperimental + case .updates: AppText.sectionSettingsUpdates + case .about: AppText.sectionSettingsAbout + } + } + var systemImage: String { switch self { case .appearance: "paintpalette" @@ -78,8 +90,8 @@ struct SettingsContent: View { private var header: some View { PanelHeader(symbol: page.systemImage, - title: "Settings", - subtitle: page.rawValue) { + title: AppText.sectionSettings, + subtitle: page.title) { DesignActionGroup(headerActions) } } @@ -87,7 +99,7 @@ struct SettingsContent: View { private var headerActions: [DesignAction] { var actions = SettingsPage.allCases.map { item in DesignAction(systemName: item.systemImage, - help: item.rawValue, + help: item.title, tint: page == item ? .accentColor : nil) { page = item } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift index f66a62aa..6f36c921 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift @@ -25,13 +25,13 @@ struct AboutTab: View { } } - PanelSection(header: "Runtime") { - PanelRow(title: "Container CLI") { Text(app.cliVersion ?? "—").foregroundStyle(.secondary) } - PanelRow(title: "API server") { Text(app.systemStatus?.apiServerVersion ?? "—").foregroundStyle(.secondary) } + PanelSection(header: AppText.sectionSettingsRuntime) { + PanelRow(title: AppText.string("settings.about.containerCLI", defaultValue: "Container CLI")) { Text(app.cliVersion ?? "—").foregroundStyle(.secondary) } + PanelRow(title: AppText.string("settings.about.apiServer", defaultValue: "API server")) { Text(app.systemStatus?.apiServerVersion ?? "—").foregroundStyle(.secondary) } } PanelSection { - PanelRow(title: "Copyright") { Text("© 2026 Contained").foregroundStyle(.secondary) } + PanelRow(title: AppText.string("settings.about.copyright", defaultValue: "Copyright")) { Text("© 2026 Contained").foregroundStyle(.secondary) } } } } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift index 93008a71..5d82050d 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift @@ -9,55 +9,56 @@ struct AppearanceTab: View { var body: some View { LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: "Theme") { - PanelRow(title: "Appearance") { + PanelSection(header: AppText.string("settings.appearance.theme", defaultValue: "Theme")) { + PanelRow(title: AppText.string("settings.appearance.appearance", defaultValue: "Appearance")) { Picker("", selection: $settings.appearance) { ForEach(AppearanceMode.allCases) { Text($0.localizedDisplayName).tag($0) } } .pickerStyle(.segmented).labelsHidden().fixedSize() } - PanelRow(title: "Accent tint") { + PanelRow(title: AppText.string("settings.appearance.accentTint", defaultValue: "Accent tint")) { TintSelector(selection: $settings.accentTint) { $0.localizedDisplayName } } } - PanelSection(header: "Layout") { - PanelRow(title: "Card size") { + PanelSection(header: AppText.string("settings.appearance.layout", defaultValue: "Layout")) { + PanelRow(title: AppText.string("settings.appearance.cardSize", defaultValue: "Card size")) { Picker("", selection: $settings.density) { ForEach(CardDensity.allCases) { Text($0.localizedDisplayName).tag($0) } } .pickerStyle(.segmented).labelsHidden().fixedSize() } - PanelToggleRow(title: "Show info tips", isOn: $settings.showInfoTips) + PanelToggleRow(title: AppText.string("settings.appearance.showInfoTips", defaultValue: "Show info tips"), + isOn: $settings.showInfoTips) } - PanelSection(header: "Materials", - footer: "Glass options use Liquid Glass. Other options use macOS vibrancy and follow the window background.") { - PanelRow(title: "Main background material", - info: "Changes the material behind the main container grid.") { + PanelSection(header: AppText.string("settings.appearance.materials", defaultValue: "Materials"), + footer: AppText.string("settings.appearance.materials.footer", defaultValue: "Glass options use Liquid Glass. Other options use macOS vibrancy and follow the window background.")) { + PanelRow(title: AppText.string("settings.appearance.mainBackgroundMaterial", defaultValue: "Main background material"), + info: AppText.string("settings.appearance.mainBackgroundMaterial.info", defaultValue: "Changes the material behind the main container grid.")) { materialMenu($settings.windowMaterial) } - PanelRow(title: "Panel & sheet material", - info: "Changes floating panels, popovers, and sheets such as Settings and create/edit flows.") { + PanelRow(title: AppText.string("settings.appearance.panelSheetMaterial", defaultValue: "Panel & sheet material"), + info: AppText.string("settings.appearance.panelSheetMaterial.info", defaultValue: "Changes floating panels, popovers, and sheets such as Settings and create/edit flows.")) { materialMenu($settings.modalMaterial) } - PanelRow(title: "Card material", - info: "Changes all cards, including compact cards and expanded detail cards.") { + PanelRow(title: AppText.string("settings.appearance.cardMaterial", defaultValue: "Card material"), + info: AppText.string("settings.appearance.cardMaterial.info", defaultValue: "Changes all cards, including compact cards and expanded detail cards.")) { materialMenu($settings.cardMaterial) } - PanelRow(title: "Button material", - info: "Changes toolbar glass buttons and grouped icon controls.") { + PanelRow(title: AppText.string("settings.appearance.buttonMaterial", defaultValue: "Button material"), + info: AppText.string("settings.appearance.buttonMaterial.info", defaultValue: "Changes toolbar glass buttons and grouped icon controls.")) { materialMenu($settings.buttonMaterial) } } - PanelSection(header: "Button tint", - footer: "Button tint uses the same color layer model as card backgrounds, applied inside toolbar glass controls.", + PanelSection(header: AppText.string("settings.appearance.buttonTint", defaultValue: "Button tint"), + footer: AppText.string("settings.appearance.buttonTint.footer", defaultValue: "Button tint uses the same color layer model as card backgrounds, applied inside toolbar glass controls."), enabled: $settings.buttonTintEnabled) { - PanelRow(title: "Tint") { + PanelRow(title: AppText.tint) { TintSelector(selection: $settings.buttonTint) { $0.localizedDisplayName } } - PanelRow(title: "Opacity") { + PanelRow(title: AppText.string("settings.appearance.opacity", defaultValue: "Opacity")) { HStack(spacing: DesignTokens.Space.s) { Slider(value: $settings.buttonTintOpacity, in: 0.05...0.6) .frame(width: DesignTokens.FormWidth.compactSlider) @@ -66,11 +67,12 @@ struct AppearanceTab: View { .frame(width: DesignTokens.FormWidth.shortReadout) } } - PanelToggleRow(title: "Gradient", isOn: $settings.buttonTintGradient) + PanelToggleRow(title: AppText.string("settings.appearance.gradient", defaultValue: "Gradient"), + isOn: $settings.buttonTintGradient) if settings.buttonTintGradient { GradientAngleControl(angle: $settings.buttonTintGradientAngle, title: AppText.direction) } - PanelRow(title: "Blend mode") { + PanelRow(title: AppText.string("settings.appearance.blendMode", defaultValue: "Blend mode")) { Picker("", selection: $settings.buttonTintBlendMode) { ForEach(ColorLayerBlendMode.allCases) { mode in Text(mode.localizedDisplayName).tag(mode) @@ -100,33 +102,35 @@ private struct ImageDefaultStyleSection: View { private var style: Personalization { app.personalization.defaultImageStyle } var body: some View { - PanelSection(header: "Default image card style", - footer: "When on, image groups, image rows, and containers without their own style inherit this design. Specific image, image-group, tag, and container styles remain local overrides above this default.", + PanelSection(header: AppText.string("settings.appearance.defaultImageCardStyle", defaultValue: "Default image card style"), + footer: AppText.string("settings.appearance.defaultImageCardStyle.footer", defaultValue: "When on, image groups, image rows, and containers without their own style inherit this design. Specific image, image-group, tag, and container styles remain local overrides above this default."), enabled: $settings.imageDefaultStyleEnabled) { HStack(spacing: DesignTokens.Space.m) { DesignCardIconChip(symbol: style.symbol, tint: style.color) VStack(alignment: .leading, spacing: DesignTokens.DesignCard.compactTextSpacing) { - Text(style.displayName(fallback: "Image cards")) - Text("Inherited unless an image, group, tag, or container overrides it") + Text(style.displayName(fallback: AppText.string("settings.appearance.imageCards", defaultValue: "Image cards"))) + Text(AppText.string("settings.appearance.imageCards.inherited", defaultValue: "Inherited unless an image, group, tag, or container overrides it")) .font(.caption) .foregroundStyle(.secondary) } Spacer() } - PanelRow(title: "Color") { + PanelRow(title: AppText.string("settings.appearance.color", defaultValue: "Color")) { TintSelector(selection: styleBinding(\.tint)) { $0.localizedDisplayName } } - PanelToggleRow(title: "Custom icon", isOn: styleBinding(\.iconEnabled)) + PanelToggleRow(title: AppText.string("settings.appearance.customIcon", defaultValue: "Custom icon"), + isOn: styleBinding(\.iconEnabled)) if style.iconEnabled { - PanelRow(title: "Icon") { + PanelRow(title: AppText.string("settings.appearance.icon", defaultValue: "Icon")) { TextField("", text: styleBinding(\.icon), prompt: Text("SF Symbol, e.g. shippingbox.fill")) .textFieldStyle(.roundedBorder) .frame(width: DesignTokens.FormWidth.tintColorHex) } } - PanelToggleRow(title: "Color the card background", isOn: styleBinding(\.fillBackground)) + PanelToggleRow(title: AppText.string("settings.appearance.colorCardBackground", defaultValue: "Color the card background"), + isOn: styleBinding(\.fillBackground)) if style.fillBackground { - PanelRow(title: "Opacity") { + PanelRow(title: AppText.string("settings.appearance.opacity", defaultValue: "Opacity")) { HStack(spacing: DesignTokens.Space.s) { Slider(value: styleBinding(\.backgroundOpacity), in: 0.05...0.6) .frame(width: DesignTokens.FormWidth.compactSlider) @@ -135,11 +139,12 @@ private struct ImageDefaultStyleSection: View { .frame(width: DesignTokens.FormWidth.shortReadout) } } - PanelToggleRow(title: "Gradient", isOn: styleBinding(\.gradient)) + PanelToggleRow(title: AppText.string("settings.appearance.gradient", defaultValue: "Gradient"), + isOn: styleBinding(\.gradient)) if style.gradient { GradientAngleControl(angle: styleBinding(\.gradientAngle), title: AppText.direction) } - PanelRow(title: "Blend mode") { + PanelRow(title: AppText.string("settings.appearance.blendMode", defaultValue: "Blend mode")) { Picker("", selection: styleBinding(\.backgroundBlendMode)) { ForEach(ColorLayerBlendMode.allCases) { mode in Text(mode.localizedDisplayName).tag(mode) diff --git a/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift index 67119353..47b5e785 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift @@ -12,32 +12,32 @@ struct ExperimentalTab: View { var body: some View { LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: "Experimental", - footer: "These features are still being refined. They're off by default; enable any you want to try. You can turn them back off at any time.") { - PanelToggleRow(title: "Toolbar-first UI", - info: "Show the floating app toolbar. Navigation and edit/create presentation are controlled separately below.", + PanelSection(header: AppText.sectionSettingsExperimental, + footer: AppText.string("settings.experimental.footer", defaultValue: "These features are still being refined. They're off by default; enable any you want to try. You can turn them back off at any time.")) { + PanelToggleRow(title: AppText.string("settings.experimental.toolbarFirstUI", defaultValue: "Toolbar-first UI"), + info: AppText.string("settings.experimental.toolbarFirstUI.info", defaultValue: "Show the floating app toolbar. Navigation and edit/create presentation are controlled separately below."), isOn: $settings.experimentalToolbarUI) - PanelToggleRow(title: "Toolbar panel navigation", - info: "Open create/edit flows and page utilities in toolbar morph panels. When off, access points use classic pages and sheets.", + PanelToggleRow(title: AppText.string("settings.experimental.toolbarPanelNavigation", defaultValue: "Toolbar panel navigation"), + info: AppText.string("settings.experimental.toolbarPanelNavigation.info", defaultValue: "Open create/edit flows and page utilities in toolbar morph panels. When off, access points use classic pages and sheets."), isOn: $settings.experimentalPanelNavigation) .disabled(!settings.experimentalToolbarUI) - PanelToggleRow(title: "Sidebar navigation", - info: "Keep the sidebar visible in either shell. Turn this off for a page-only layout.", + PanelToggleRow(title: AppText.string("settings.experimental.sidebarNavigation", defaultValue: "Sidebar navigation"), + info: AppText.string("settings.experimental.sidebarNavigation.info", defaultValue: "Keep the sidebar visible in either shell. Turn this off for a page-only layout."), isOn: $settings.sidebarNavigationEnabled) - PanelToggleRow(title: "Command palette (⌘K)", - info: "The ⌘K command index: fuzzy search across every app, container, image, and resource action. Page search and menu commands work regardless of this setting.", + PanelToggleRow(title: AppText.string("settings.experimental.commandPalette", defaultValue: "Command palette (Command-K)"), + info: AppText.string("settings.experimental.commandPalette.info", defaultValue: "The Command-K command index: fuzzy search across every app, container, image, and resource action. Page search and menu commands work regardless of this setting."), isOn: $settings.commandPaletteEnabled) - PanelToggleRow(title: "Docker Hub search", - info: "Search registry images inline (creation “Search” path and the palette’s Hub scope). Requires network access to the registry.", + PanelToggleRow(title: AppText.string("settings.experimental.dockerHubSearch", defaultValue: "Docker Hub search"), + info: AppText.string("settings.experimental.dockerHubSearch.info", defaultValue: "Search registry images inline (creation Search path and the palette's Hub scope). Requires network access to the registry."), isOn: $settings.hubSearchEnabled) - PanelToggleRow(title: "Compose import", - info: "Import Docker Compose YAML — paste, file pick, or drag-and-drop — mapping each service with an image into a prefilled run.", + PanelToggleRow(title: AppText.string("settings.experimental.composeImport", defaultValue: "Compose import"), + info: AppText.string("settings.experimental.composeImport.info", defaultValue: "Import Docker Compose YAML - paste, file pick, or drag-and-drop - mapping each service with an image into a prefilled run."), isOn: $settings.composeImportEnabled) - PanelToggleRow(title: "Image build workspace", - info: "Build an image from a Dockerfile + build context, streaming the BuildKit log.", + PanelToggleRow(title: AppText.string("settings.experimental.imageBuildWorkspace", defaultValue: "Image build workspace"), + info: AppText.string("settings.experimental.imageBuildWorkspace.info", defaultValue: "Build an image from a Dockerfile + build context, streaming the BuildKit log."), isOn: $settings.imageBuildEnabled) - PanelToggleRow(title: "Keyboard shortcuts", - info: "Enable menu and command keyboard shortcuts. Off by default so the experimental surface stays opt-in.", + PanelToggleRow(title: AppText.string("settings.experimental.keyboardShortcuts", defaultValue: "Keyboard shortcuts"), + info: AppText.string("settings.experimental.keyboardShortcuts.info", defaultValue: "Enable menu and command keyboard shortcuts. Off by default so the experimental surface stays opt-in."), isOn: $settings.keyboardShortcutsEnabled) } } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift index 2775dd31..b9b580f3 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift @@ -11,21 +11,24 @@ struct GeneralTab: View { var body: some View { LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: "Startup") { - PanelToggleRow(title: "Launch at login", isOn: $settings.launchAtLogin) - PanelToggleRow(title: "Keep running in the menu bar", isOn: $settings.keepInMenuBar) + PanelSection(header: AppText.string("settings.general.startup", defaultValue: "Startup")) { + PanelToggleRow(title: AppText.string("settings.general.launchAtLogin", defaultValue: "Launch at login"), + isOn: $settings.launchAtLogin) + PanelToggleRow(title: AppText.string("settings.general.keepInMenuBar", defaultValue: "Keep running in the menu bar"), + isOn: $settings.keepInMenuBar) } - PanelSection(header: "Activity & alerts") { - PanelToggleRow(title: "System alert on container crash / restart", isOn: $settings.notifyOnCrash) - PanelToggleRow(title: "Show “Reveal CLI” on actions", - info: "Shows the exact `container ...` command for important actions. Useful when you are learning the CLI or want to verify what will run.", + PanelSection(header: AppText.string("settings.general.activityAlerts", defaultValue: "Activity & alerts")) { + PanelToggleRow(title: AppText.string("settings.general.notifyOnCrash", defaultValue: "System alert on container crash / restart"), + isOn: $settings.notifyOnCrash) + PanelToggleRow(title: AppText.string("settings.general.showRevealCLI", defaultValue: "Show Reveal CLI on actions"), + info: AppText.string("settings.general.showRevealCLI.info", defaultValue: "Shows the exact `container ...` command for important actions. Useful when you are learning the CLI or want to verify what will run."), isOn: $settings.revealCLI) } - PanelSection(header: "Data", - footer: "Live metrics use one low-priority runtime stream. The list refresh interval only controls background service, container list, and resource-cache polling. \(settings.statsNormalizationMode.footnote)") { - PanelRow(title: "List refresh interval") { + PanelSection(header: AppText.string("settings.general.data", defaultValue: "Data"), + footer: AppText.string("settings.general.data.footer", defaultValue: "Live metrics use one low-priority runtime stream. The list refresh interval only controls background service, container list, and resource-cache polling. \(settings.statsNormalizationMode.footnote)")) { + PanelRow(title: AppText.string("settings.general.listRefreshInterval", defaultValue: "List refresh interval")) { HStack(spacing: DesignTokens.Space.s) { Slider(value: $settings.refreshInterval, in: 1...10, step: 1) .frame(width: DesignTokens.FormWidth.compactSlider) @@ -34,7 +37,7 @@ struct GeneralTab: View { .frame(width: DesignTokens.FormWidth.refreshReadout, alignment: .trailing) } } - PanelRow(title: "Keep history for") { + PanelRow(title: AppText.string("settings.general.keepHistoryFor", defaultValue: "Keep history for")) { Picker("", selection: retentionBinding) { Text("1 day").tag(1) Text("7 days").tag(7) @@ -43,7 +46,7 @@ struct GeneralTab: View { } .labelsHidden().fixedSize() } - PanelRow(title: "Normalize stats") { + PanelRow(title: AppText.string("settings.general.normalizeStats", defaultValue: "Normalize stats")) { Picker("", selection: statsNormalizationBinding) { ForEach(StatsNormalizationMode.allCases) { mode in Text(mode.displayName).tag(mode) @@ -58,9 +61,9 @@ struct GeneralTab: View { ConfigTransferControls() } - PanelSection(header: "Logging", + PanelSection(header: AppText.string("settings.general.logging", defaultValue: "Logging"), footer: settings.loggingLevel.footnote) { - PanelRow(title: "Level") { + PanelRow(title: AppText.string("settings.general.loggingLevel", defaultValue: "Level")) { Picker("", selection: $settings.loggingLevel) { ForEach(AppLogLevel.allCases) { level in Text(level.displayName).tag(level) @@ -90,9 +93,9 @@ struct GeneralTab: View { } } - PanelSection(header: "Advanced") { - PanelField(label: "Container CLI path", - info: "Override the auto-detected `container` binary location.") { + PanelSection(header: AppText.string("settings.general.advanced", defaultValue: "Advanced")) { + PanelField(label: AppText.string("settings.general.containerCLIPath", defaultValue: "Container CLI path"), + info: AppText.string("settings.general.containerCLIPath.info", defaultValue: "Override the auto-detected `container` binary location.")) { TextField("", text: $settings.cliPathOverride, prompt: Text("/usr/local/bin/container")) .textFieldStyle(.roundedBorder) } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift index 74905134..62f93f3f 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift @@ -13,8 +13,8 @@ struct RegistriesTab: View { var body: some View { LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: "Signed-in registries", - footer: "Credentials are typed by you and piped to the CLI via stdin, so the password never lands in the process list. Contained doesn’t store it.") { + PanelSection(header: AppText.string("settings.registries.signedIn", defaultValue: "Signed-in registries"), + footer: AppText.string("settings.registries.footer", defaultValue: "Credentials are typed by you and piped to the CLI via stdin, so the password never lands in the process list. Contained doesn't store it.")) { if app.registries.isEmpty { Text("Not signed in to any registries.") .foregroundStyle(.secondary) diff --git a/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift index 583d5722..719568f3 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift @@ -17,16 +17,16 @@ struct RuntimeTab: View { var body: some View { LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: "Kernel", - footer: "Downloads and sets the recommended kernel as the default. May prompt for your administrator password — handled by the container CLI; Contained never sees it.") { - PanelRow(title: "Recommended kernel") { + PanelSection(header: AppText.string("settings.runtime.kernel", defaultValue: "Kernel"), + footer: AppText.string("settings.runtime.kernel.footer", defaultValue: "Downloads and sets the recommended kernel as the default. May prompt for your administrator password - handled by the container CLI; Contained never sees it.")) { + PanelRow(title: AppText.string("settings.runtime.recommendedKernel", defaultValue: "Recommended kernel")) { Button("Install…") { confirmingKernel = true } } revealCLIHint("container system kernel set --recommended") } - PanelSection(header: "Local DNS domains", - footer: "Creating or deleting a domain may prompt for your administrator password — handled by the container CLI.") { + PanelSection(header: AppText.string("settings.runtime.localDNSDomains", defaultValue: "Local DNS domains"), + footer: AppText.string("settings.runtime.localDNSDomains.footer", defaultValue: "Creating or deleting a domain may prompt for your administrator password - handled by the container CLI.")) { if dnsDomains.isEmpty { Text("No local DNS domains.") .foregroundStyle(.secondary) @@ -48,21 +48,21 @@ struct RuntimeTab: View { } if let props = app.properties { - PanelSection(header: "Runtime resources", - footer: "Read-only — machine resources are the denominator for machine-normalized stats. Defaults apply when a container or build doesn’t specify its own resources.") { + PanelSection(header: AppText.string("settings.runtime.resources", defaultValue: "Runtime resources"), + footer: AppText.string("settings.runtime.resources.footer", defaultValue: "Read-only - machine resources are the denominator for machine-normalized stats. Defaults apply when a container or build doesn't specify its own resources.")) { if let d = props.container { - if let c = d.cpus { PanelRow(title: "Default CPUs") { Text("\(c)").foregroundStyle(.secondary) } } - if let m = d.memory { PanelRow(title: "Default memory") { Text(m).foregroundStyle(.secondary) } } + if let c = d.cpus { PanelRow(title: AppText.string("settings.runtime.defaultCPUs", defaultValue: "Default CPUs")) { Text("\(c)").foregroundStyle(.secondary) } } + if let m = d.memory { PanelRow(title: AppText.string("settings.runtime.defaultMemory", defaultValue: "Default memory")) { Text(m).foregroundStyle(.secondary) } } } if let machine = props.machine { - if let c = machine.cpus { PanelRow(title: "Machine CPUs") { Text("\(c)").foregroundStyle(.secondary) } } - if let m = machine.memory { PanelRow(title: "Machine memory") { Text(m).foregroundStyle(.secondary) } } + if let c = machine.cpus { PanelRow(title: AppText.string("settings.runtime.machineCPUs", defaultValue: "Machine CPUs")) { Text("\(c)").foregroundStyle(.secondary) } } + if let m = machine.memory { PanelRow(title: AppText.string("settings.runtime.machineMemory", defaultValue: "Machine memory")) { Text(m).foregroundStyle(.secondary) } } } if let b = props.build { - if let img = b.image { PanelRow(title: "Builder image") { Text(img).foregroundStyle(.secondary) } } - if let r = b.rosetta { PanelRow(title: "Builder Rosetta") { Text(r ? "On" : "Off").foregroundStyle(.secondary) } } + if let img = b.image { PanelRow(title: AppText.string("settings.runtime.builderImage", defaultValue: "Builder image")) { Text(img).foregroundStyle(.secondary) } } + if let r = b.rosetta { PanelRow(title: AppText.string("settings.runtime.builderRosetta", defaultValue: "Builder Rosetta")) { Text(r ? "On" : "Off").foregroundStyle(.secondary) } } } - if let k = props.kernel, let path = k.binaryPath { PanelRow(title: "Kernel") { Text(path).foregroundStyle(.secondary) } } + if let k = props.kernel, let path = k.binaryPath { PanelRow(title: AppText.string("settings.runtime.kernel", defaultValue: "Kernel")) { Text(path).foregroundStyle(.secondary) } } } } } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift index c707862e..a26516b6 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift @@ -12,9 +12,9 @@ struct UpdatesTab: View { var body: some View { @Bindable var settings = app.settings LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: "Updates", - footer: "\(settings.updateChannel.footnote) Each channel has its own release feed; channels without a published build yet are dimmed and unselectable. Delivered via Sparkle once a signed build points at the feed; inert in development builds.") { - PanelRow(title: "Update channel") { + PanelSection(header: AppText.sectionSettingsUpdates, + footer: AppText.string("settings.updates.footer", defaultValue: "\(settings.updateChannel.footnote) Each channel has its own release feed; channels without a published build yet are dimmed and unselectable. Delivered via Sparkle once a signed build points at the feed; inert in development builds.")) { + PanelRow(title: AppText.string("settings.updates.channel", defaultValue: "Update channel")) { Menu(app.settings.updateChannel.displayName) { ForEach(UpdateChannel.allCases) { channel in Button { @@ -31,7 +31,7 @@ struct UpdatesTab: View { } .fixedSize() } - PanelToggleRow(title: "Automatically check for updates", + PanelToggleRow(title: AppText.string("settings.updates.automaticallyCheck", defaultValue: "Automatically check for updates"), isOn: Binding(get: { settings.appUpdateChecksEnabled }, set: { settings.appUpdateChecksEnabled = $0 @@ -47,9 +47,9 @@ struct UpdatesTab: View { .frame(maxWidth: .infinity, alignment: .leading) } - PanelSection(header: "Image updates", - footer: "Controls the background registry digest check cadence. Manual checks are always available from Images, System, and the toolbar.") { - PanelRow(title: "Check images") { + PanelSection(header: AppText.string("settings.updates.imageUpdates", defaultValue: "Image updates"), + footer: AppText.string("settings.updates.imageUpdates.footer", defaultValue: "Controls the background registry digest check cadence. Manual checks are always available from Images, System, and the toolbar.")) { + PanelRow(title: AppText.string("settings.updates.checkImages", defaultValue: "Check images")) { Picker("", selection: $settings.imageUpdateIntervalHours) { Text("Every hour").tag(1) Text("Every 3 hours").tag(3) @@ -63,7 +63,7 @@ struct UpdatesTab: View { } .task { app.updater.refreshChannelAvailability() } .sheet(isPresented: $showingCurrentNotes) { - ReleaseNotesView(title: "What’s New", + ReleaseNotesView(title: AppText.string("releaseNotes.whatsNew", defaultValue: "What's New"), html: app.updater.currentReleaseNotesHTML, onClose: { showingCurrentNotes = false }) } @@ -76,16 +76,16 @@ struct UpdatesTab: View { private var availableUpdateNotesLabel: String { if let version = app.updater.availableUpdateDisplayVersion { - return "What’s New in \(version)" + return AppText.string("releaseNotes.whatsNewInVersion", defaultValue: "What's New in \(version)") } - return "What’s New in Available Update" + return AppText.string("releaseNotes.whatsNewInAvailableUpdate", defaultValue: "What's New in Available Update") } private var availableUpdateNotesTitle: String { if let version = app.updater.availableUpdateDisplayVersion { - return "What’s New in \(version)" + return AppText.string("releaseNotes.whatsNewInVersion", defaultValue: "What's New in \(version)") } - return "Available Update" + return AppText.string("releaseNotes.availableUpdate", defaultValue: "Available Update") } private var channelBinding: Binding { diff --git a/Sources/ContainedApp/Resources/Localizable.xcstrings b/Sources/ContainedApp/Resources/Localizable.xcstrings index 4ea344a8..00caca41 100644 --- a/Sources/ContainedApp/Resources/Localizable.xcstrings +++ b/Sources/ContainedApp/Resources/Localizable.xcstrings @@ -33,12 +33,14 @@ "cardDensity.large": {}, "cardDensity.medium": {}, "cardDensity.small": {}, + "cleanup.cleanedStaleRows": {}, + "cli.selectContainerBinary": {}, "common.add": {}, "common.back": {}, "common.cancel": {}, + "common.choose": {}, "common.clear": {}, "common.clearSearch": {}, - "common.choose": {}, "common.close": {}, "common.completed": {}, "common.copied": {}, @@ -76,8 +78,6 @@ "container.restartedAttempt": {}, "container.unhealthy": {}, "containerCard.accessibilityLabel": {}, - "cleanup.cleanedStaleRows": {}, - "cli.selectContainerBinary": {}, "customize.accessibilityLabel": {}, "customize.cardColor": {}, "customize.imageStyleAccessibilityLabel": {}, @@ -118,8 +118,8 @@ "history.cleared": {}, "image.addTag": {}, "image.chooseTarArchive": {}, - "image.deleted": {}, "image.deleteTag": {}, + "image.deleted": {}, "image.loadImageTar": {}, "image.prune": {}, "image.pruneImages": {}, @@ -199,6 +199,9 @@ "palette.dockerHubUnreachable": {}, "palette.filterLocalImagesPlaceholder": {}, "palette.imagesCount": {}, + "palette.keyboard.clearScope": {}, + "palette.keyboard.run": {}, + "palette.keyboard.select": {}, "palette.kind.action": {}, "palette.kind.container": {}, "palette.kind.create": {}, @@ -208,9 +211,6 @@ "palette.kind.search": {}, "palette.kind.settings": {}, "palette.kind.toggle": {}, - "palette.keyboard.clearScope": {}, - "palette.keyboard.run": {}, - "palette.keyboard.select": {}, "palette.localImages": {}, "palette.localImagesCount": {}, "palette.matchesCount": {}, @@ -253,6 +253,10 @@ "palette.subtitle.volume": {}, "palette.typeToSearchDockerHub": {}, "palette.unavailable": {}, + "releaseNotes.availableUpdate": {}, + "releaseNotes.whatsNew": {}, + "releaseNotes.whatsNewInAvailableUpdate": {}, + "releaseNotes.whatsNewInVersion": {}, "restartPolicy.always": {}, "restartPolicy.no": {}, "restartPolicy.onFailure": {}, @@ -284,10 +288,102 @@ "service.restart": {}, "service.start": {}, "service.stop": {}, + "settings.about.apiServer": {}, + "settings.about.containerCLI": {}, + "settings.about.copyright": {}, + "settings.appearance.accentTint": {}, + "settings.appearance.appearance": {}, + "settings.appearance.blendMode": {}, + "settings.appearance.buttonMaterial": {}, + "settings.appearance.buttonMaterial.info": {}, + "settings.appearance.buttonTint": {}, + "settings.appearance.buttonTint.footer": {}, + "settings.appearance.cardMaterial": {}, + "settings.appearance.cardMaterial.info": {}, + "settings.appearance.cardSize": {}, + "settings.appearance.color": {}, + "settings.appearance.colorCardBackground": {}, + "settings.appearance.customIcon": {}, + "settings.appearance.defaultImageCardStyle": {}, + "settings.appearance.defaultImageCardStyle.footer": {}, + "settings.appearance.gradient": {}, + "settings.appearance.icon": {}, + "settings.appearance.imageCards": {}, + "settings.appearance.imageCards.inherited": {}, + "settings.appearance.layout": {}, + "settings.appearance.mainBackgroundMaterial": {}, + "settings.appearance.mainBackgroundMaterial.info": {}, + "settings.appearance.materials": {}, + "settings.appearance.materials.footer": {}, + "settings.appearance.opacity": {}, + "settings.appearance.panelSheetMaterial": {}, + "settings.appearance.panelSheetMaterial.info": {}, + "settings.appearance.showInfoTips": {}, + "settings.appearance.theme": {}, + "settings.experimental.commandPalette": {}, + "settings.experimental.commandPalette.info": {}, + "settings.experimental.composeImport": {}, + "settings.experimental.composeImport.info": {}, + "settings.experimental.dockerHubSearch": {}, + "settings.experimental.dockerHubSearch.info": {}, + "settings.experimental.footer": {}, + "settings.experimental.imageBuildWorkspace": {}, + "settings.experimental.imageBuildWorkspace.info": {}, + "settings.experimental.keyboardShortcuts": {}, + "settings.experimental.keyboardShortcuts.info": {}, + "settings.experimental.sidebarNavigation": {}, + "settings.experimental.sidebarNavigation.info": {}, + "settings.experimental.toolbarFirstUI": {}, + "settings.experimental.toolbarFirstUI.info": {}, + "settings.experimental.toolbarPanelNavigation": {}, + "settings.experimental.toolbarPanelNavigation.info": {}, + "settings.general.activityAlerts": {}, + "settings.general.advanced": {}, + "settings.general.containerCLIPath": {}, + "settings.general.containerCLIPath.info": {}, + "settings.general.data": {}, + "settings.general.data.footer": {}, + "settings.general.keepHistoryFor": {}, + "settings.general.keepInMenuBar": {}, + "settings.general.launchAtLogin": {}, + "settings.general.listRefreshInterval": {}, + "settings.general.logging": {}, + "settings.general.loggingLevel": {}, + "settings.general.normalizeStats": {}, + "settings.general.notifyOnCrash": {}, + "settings.general.showRevealCLI": {}, + "settings.general.showRevealCLI.info": {}, + "settings.general.startup": {}, + "settings.registries.footer": {}, + "settings.registries.signedIn": {}, + "settings.runtime.builderImage": {}, + "settings.runtime.builderRosetta": {}, + "settings.runtime.defaultCPUs": {}, + "settings.runtime.defaultMemory": {}, + "settings.runtime.kernel": {}, + "settings.runtime.kernel.footer": {}, + "settings.runtime.localDNSDomains": {}, + "settings.runtime.localDNSDomains.footer": {}, + "settings.runtime.machineCPUs": {}, + "settings.runtime.machineMemory": {}, + "settings.runtime.recommendedKernel": {}, + "settings.runtime.resources": {}, + "settings.runtime.resources.footer": {}, + "settings.updates.automaticallyCheck": {}, + "settings.updates.channel": {}, + "settings.updates.checkImages": {}, + "settings.updates.footer": {}, + "settings.updates.imageUpdates": {}, + "settings.updates.imageUpdates.footer": {}, "statsNormalization.container": {}, "statsNormalization.container.footnote": {}, "statsNormalization.machine": {}, "statsNormalization.machine.footnote": {}, + "status.errored": {}, + "status.running": {}, + "status.stopped": {}, + "status.stopping": {}, + "status.unknown": {}, "streamConsole.lineCount": {}, "system.storageCleanup": {}, "system.systemLogs": {}, @@ -358,9 +454,9 @@ "updates.containerImageNoun": {}, "updates.containerImagesTitle": {}, "updates.imageNoun": {}, - "updates.imagesTitle": {}, - "updates.imageUpdateAvailable": {}, "updates.imageUpToDate": {}, + "updates.imageUpdateAvailable": {}, + "updates.imagesTitle": {}, "updates.localDigestUnavailable": {}, "updates.noContainerImageUpdatesAvailable": {}, "updates.noContainerImagesToCheck": {}, diff --git a/Sources/ContainedApp/Support/AppLocalization.swift b/Sources/ContainedApp/Support/AppLocalization.swift index 5d143779..3a56112d 100644 --- a/Sources/ContainedApp/Support/AppLocalization.swift +++ b/Sources/ContainedApp/Support/AppLocalization.swift @@ -83,6 +83,13 @@ enum AppText { static var sectionTemplates: String { string("section.templates", defaultValue: "Templates") } static var sectionActivity: String { string("section.activity", defaultValue: "Activity") } static var sectionSettings: String { string("section.settings", defaultValue: "Settings") } + static var sectionSettingsAppearance: String { string("settings.page.appearance", defaultValue: "Appearance") } + static var sectionSettingsGeneral: String { string("settings.page.general", defaultValue: "General") } + static var sectionSettingsRuntime: String { string("settings.page.runtime", defaultValue: "Runtime") } + static var sectionSettingsRegistries: String { string("settings.page.registries", defaultValue: "Registries") } + static var sectionSettingsExperimental: String { string("settings.page.experimental", defaultValue: "Experimental") } + static var sectionSettingsUpdates: String { string("settings.page.updates", defaultValue: "Updates") } + static var sectionSettingsAbout: String { string("settings.page.about", defaultValue: "About") } static var sectionGroupWorkloads: String { string("sectionGroup.workloads", defaultValue: "Workloads") } static var sectionGroupInfra: String { string("sectionGroup.infra", defaultValue: "Infra") } static var sectionGroupSystem: String { string("sectionGroup.system", defaultValue: "System") } From d03099fd2aedf01119dbd53fdd6b05801fa9aa53 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 19:43:32 +0100 Subject: [PATCH 37/53] Route app UI copy through localization --- .../Containers/ContainerViewOptions.swift | 14 +- .../Containers/Creation/CreationFlow.swift | 68 +-- .../Containers/Creation/CreationPages.swift | 50 +-- .../Features/Containers/CustomizeSheet.swift | 83 ++-- .../Containers/CustomizeWidgetsPanel.swift | 66 +-- .../Features/Containers/RunSpec.swift | 7 +- .../Features/Containers/RunSpecForm.swift | 226 +++++----- .../Features/Containers/StatsTab.swift | 19 +- .../Features/Containers/TerminalTab.swift | 11 +- .../Features/Images/BuildWorkspaceView.swift | 44 +- .../Registries/RegistryLoginSheet.swift | 12 +- .../Features/System/SystemView.swift | 87 ++-- .../ContainedApp/History/ActivityView.swift | 32 +- .../Migration/DowngradeDecisionView.swift | 12 +- .../Navigation/ClassicShell.swift | 20 +- .../ContainedApp/Navigation/RootView.swift | 2 +- .../Navigation/ToolbarFilterOptions.swift | 44 +- .../ToolbarPanels/PaletteResultCard.swift | 10 +- .../ToolbarPanels/ToolbarImageGroupCard.swift | 55 +-- .../ToolbarPanels/ToolbarResourcePanels.swift | 8 +- .../ToolbarPanels/ToolbarUpdatesPanel.swift | 8 +- .../Navigation/ToolbarViewOptions.swift | 60 +-- .../Resources/Localizable.xcstrings | 388 ++++++++++++++++++ .../Stores/AppModel+ImageUpdates.swift | 3 +- 24 files changed, 900 insertions(+), 429 deletions(-) diff --git a/Sources/ContainedApp/Features/Containers/ContainerViewOptions.swift b/Sources/ContainedApp/Features/Containers/ContainerViewOptions.swift index 5df8e312..19d9e218 100644 --- a/Sources/ContainedApp/Features/Containers/ContainerViewOptions.swift +++ b/Sources/ContainedApp/Features/Containers/ContainerViewOptions.swift @@ -9,10 +9,10 @@ enum ContainerGrouping: String, CaseIterable, Identifiable, Codable, Sendable { /// Short noun shown in the toolbar subtitle and the menu ("by Network"). var title: String { switch self { - case .network: return "Network" - case .volume: return "Volume" - case .image: return "Image" - case .flat: return "Flat" + case .network: return AppText.string("filter.network", defaultValue: "Network") + case .volume: return AppText.string("filter.volume", defaultValue: "Volume") + case .image: return AppText.string("filter.image", defaultValue: "Image") + case .flat: return AppText.string("filter.flat", defaultValue: "Flat") } } @@ -34,9 +34,9 @@ enum ContainerSort: String, CaseIterable, Identifiable, Codable, Sendable { var title: String { switch self { - case .name: return "Name" - case .status: return "Status" - case .image: return "Image" + case .name: return AppText.string("filter.name", defaultValue: "Name") + case .status: return AppText.string("filter.status", defaultValue: "Status") + case .image: return AppText.string("filter.image", defaultValue: "Image") } } diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift index 45e6dfca..4826b31c 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift @@ -152,21 +152,21 @@ struct CreationFlow: View { gridScaffold { optionStack { optionRow { - box(symbol: "shippingbox", title: "Container", - subtitle: "Configure and run an image", + box(symbol: "shippingbox", title: AppText.string("creation.option.container", defaultValue: "Container"), + subtitle: AppText.string("creation.option.container.subtitle", defaultValue: "Configure and run an image"), matchedID: "creation-option-0") { go(.chooser) } - box(symbol: "hammer", title: "Build", - subtitle: "Build an image from a Dockerfile", + box(symbol: "hammer", title: AppText.sectionBuild, + subtitle: AppText.string("creation.option.build.subtitle", defaultValue: "Build an image from a Dockerfile"), matchedID: "creation-option-1", enabled: app.settings.imageBuildEnabled) { guard app.settings.imageBuildEnabled else { return } go(.build) } - box(symbol: "network", title: "Network", - subtitle: "Create a container network", + box(symbol: "network", title: AppText.string("filter.network", defaultValue: "Network"), + subtitle: AppText.string("creation.option.network.subtitle", defaultValue: "Create a container network"), matchedID: "creation-option-2") { go(.network) } - box(symbol: "externaldrive", title: "Volume", - subtitle: "Create persistent storage", + box(symbol: "externaldrive", title: AppText.string("filter.volume", defaultValue: "Volume"), + subtitle: AppText.string("creation.option.volume.subtitle", defaultValue: "Create persistent storage"), matchedID: "creation-option-3") { go(.volume) } } } @@ -178,31 +178,35 @@ struct CreationFlow: View { optionStack { optionRow { if app.settings.hubSearchEnabled { - box(symbol: "magnifyingglass", title: "Search", - subtitle: "Find an image on Docker Hub", + box(symbol: "magnifyingglass", title: AppText.string("common.search", defaultValue: "Search"), + subtitle: AppText.string("creation.option.search.subtitle", defaultValue: "Find an image on Docker Hub"), matchedID: "creation-option-0") { go(.search) } } - box(symbol: "square.stack.3d.up", title: "Local image", - subtitle: app.images.isEmpty ? "Choose from pulled images" : "Use an image already pulled", + box(symbol: "square.stack.3d.up", title: AppText.string("creation.option.localImage", defaultValue: "Local image"), + subtitle: app.images.isEmpty + ? AppText.string("creation.option.localImage.emptySubtitle", defaultValue: "Choose from pulled images") + : AppText.string("creation.option.localImage.subtitle", defaultValue: "Use an image already pulled"), matchedID: "creation-option-1") { go(.localImages) } - box(symbol: "slider.horizontal.3", title: "Start from scratch", - subtitle: "Configure manually", + box(symbol: "slider.horizontal.3", title: AppText.string("creation.option.scratch", defaultValue: "Start from scratch"), + subtitle: AppText.string("creation.option.scratch.subtitle", defaultValue: "Configure manually"), matchedID: "creation-option-2") { configure(with: RunSpec()) } } optionRow { - box(symbol: "shippingbox.and.arrow.backward", title: "Compose", - subtitle: "Paste YAML or choose a file", + box(symbol: "shippingbox.and.arrow.backward", title: AppText.string("creation.option.compose", defaultValue: "Compose"), + subtitle: AppText.string("creation.option.compose.subtitle", defaultValue: "Paste YAML or choose a file"), matchedID: "compose-option-0", enabled: app.settings.composeImportEnabled) { guard app.settings.composeImportEnabled else { return } go(.compose) } - box(symbol: "archivebox", title: "Image archive", - subtitle: "Load an image .tar") { selectImageArchive() } - box(symbol: "bookmark", title: "Templates", - subtitle: saved.isEmpty ? "None saved yet" : "Reuse a saved recipe", + box(symbol: "archivebox", title: AppText.string("creation.option.imageArchive", defaultValue: "Image archive"), + subtitle: AppText.string("creation.option.imageArchive.subtitle", defaultValue: "Load an image .tar")) { selectImageArchive() } + box(symbol: "bookmark", title: AppText.sectionTemplates, + subtitle: saved.isEmpty + ? AppText.string("creation.option.templates.emptySubtitle", defaultValue: "None saved yet") + : AppText.string("creation.option.templates.subtitle", defaultValue: "Reuse a saved recipe"), enabled: !saved.isEmpty) { go(.templates) } } } @@ -210,7 +214,7 @@ struct CreationFlow: View { } private var networkPage: some View { - pageScaffold(symbol: "network", title: "New network", subtitle: nil, + pageScaffold(symbol: "network", title: AppText.string("creation.network.new", defaultValue: "New network"), subtitle: nil, leading: resourceLeading, contentAlignment: .top) { CreationNetworkFields(name: $networkName, subnet: $networkSubnet, @@ -221,7 +225,7 @@ struct CreationFlow: View { } private var volumePage: some View { - pageScaffold(symbol: "externaldrive", title: "New volume", subtitle: nil, + pageScaffold(symbol: "externaldrive", title: AppText.string("creation.volume.new", defaultValue: "New volume"), subtitle: nil, leading: resourceLeading, contentAlignment: .top) { CreationVolumeFields(name: $volumeName, size: $volumeSize, @@ -231,7 +235,9 @@ struct CreationFlow: View { } private var buildPage: some View { - pageScaffold(symbol: "hammer", title: "Build an image", subtitle: "From a Dockerfile + build context", + pageScaffold(symbol: "hammer", + title: AppText.string("build.empty.title", defaultValue: "Build an image"), + subtitle: AppText.string("build.subtitle.context", defaultValue: "From a Dockerfile + build context"), leading: resourceLeading) { BuildWorkspaceView() } @@ -246,7 +252,9 @@ struct CreationFlow: View { } private var localImagesPage: some View { - pageScaffold(symbol: "square.stack.3d.up", title: "Choose a local image", subtitle: "Use an image already pulled", + pageScaffold(symbol: "square.stack.3d.up", + title: AppText.string("creation.localImages.choose", defaultValue: "Choose a local image"), + subtitle: AppText.string("creation.option.localImage.subtitle", defaultValue: "Use an image already pulled"), leading: .back { go(.chooser) }) { CreationLocalImagesContent(query: $localImageQuery) { picked in configure(with: picked) @@ -258,11 +266,11 @@ struct CreationFlow: View { gridScaffold { optionStack { optionRow { - box(symbol: "doc.plaintext", title: "Paste YAML", - subtitle: "Paste compose content", + box(symbol: "doc.plaintext", title: AppText.string("creation.compose.pasteYAML", defaultValue: "Paste YAML"), + subtitle: AppText.string("creation.compose.pasteYAML.subtitle", defaultValue: "Paste compose content"), matchedID: "compose-option-0") { go(.pasteCompose) } - box(symbol: "folder", title: "Select file", - subtitle: "Choose compose.yaml", + box(symbol: "folder", title: AppText.string("creation.compose.selectFile", defaultValue: "Select file"), + subtitle: AppText.string("creation.compose.selectFile.subtitle", defaultValue: "Choose compose.yaml"), matchedID: "compose-option-1") { selectComposeFile() } } } @@ -270,7 +278,9 @@ struct CreationFlow: View { } private var pasteComposePage: some View { - pageScaffold(symbol: "doc.plaintext", title: "Paste Compose", subtitle: "Services with images become prefilled containers", + pageScaffold(symbol: "doc.plaintext", + title: AppText.string("creation.compose.paste", defaultValue: "Paste Compose"), + subtitle: AppText.string("creation.compose.paste.subtitle", defaultValue: "Services with images become prefilled containers"), leading: .back { go(.compose) }) { CreationPastedComposeContent(text: $composeText, onImport: importPastedCompose) } diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift index c5bb2f7f..e5f5f5b7 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift @@ -14,26 +14,26 @@ struct CreationNetworkFields: View { title: networkName, subtitle: networkSubtitle, command: previewCommand) { - PanelSection(header: "Details", highlighted: hasValues) { - PanelField(label: "Name", - info: "A readable name used by containers with `--network`.", + PanelSection(header: AppText.string("creation.details", defaultValue: "Details"), highlighted: hasValues) { + PanelField(label: AppText.string("creation.name", defaultValue: "Name"), + info: AppText.string("creation.network.name.info", defaultValue: "A readable name used by containers with `--network`."), error: nameError) { TextField("", text: $name, prompt: Text("my-network")) .textFieldStyle(.roundedBorder) .onSubmit(submitIfReady) } - PanelField(label: "Subnet", - info: "Optional CIDR range for the network, for example `10.0.0.0/24`.") { + PanelField(label: AppText.string("creation.subnet", defaultValue: "Subnet"), + info: AppText.string("creation.network.subnet.info", defaultValue: "Optional CIDR range for the network, for example `10.0.0.0/24`.")) { TextField("", text: $subnet, prompt: Text("optional, e.g. 10.0.0.0/24")) .textFieldStyle(.roundedBorder) .onSubmit(submitIfReady) } - PanelToggleRow(title: "Internal only", - subtitle: "Restrict containers on this network from external access.", + PanelToggleRow(title: AppText.string("creation.network.internalOnly", defaultValue: "Internal only"), + subtitle: AppText.string("creation.network.internalOnly.subtitle", defaultValue: "Restrict containers on this network from external access."), isOn: $internalOnly) } } footer: { - CreationSubmitBar(title: "Create network", + CreationSubmitBar(title: AppText.string("creation.network.create", defaultValue: "Create network"), systemImage: "network.badge.plus", canSubmit: canSubmit, working: working, @@ -45,10 +45,10 @@ struct CreationNetworkFields: View { private var trimmedSubnet: String { subnet.trimmingCharacters(in: .whitespaces) } private var canSubmit: Bool { !trimmedName.isEmpty && !working } private var hasValues: Bool { !trimmedName.isEmpty || !trimmedSubnet.isEmpty || internalOnly } - private var nameError: String? { trimmedName.isEmpty ? "A network name is required." : nil } - private var networkName: String { trimmedName.isEmpty ? "New network" : trimmedName } + private var nameError: String? { trimmedName.isEmpty ? AppText.string("creation.network.name.required", defaultValue: "A network name is required.") : nil } + private var networkName: String { trimmedName.isEmpty ? AppText.string("creation.network.new", defaultValue: "New network") : trimmedName } private var networkSubtitle: String { - var parts = [internalOnly ? "internal" : "bridge"] + var parts = [internalOnly ? AppText.string("creation.network.mode.internal", defaultValue: "internal") : AppText.string("creation.network.mode.bridge", defaultValue: "bridge")] if !trimmedSubnet.isEmpty { parts.append(trimmedSubnet) } return parts.joined(separator: " · ") } @@ -75,23 +75,23 @@ struct CreationVolumeFields: View { title: volumeName, subtitle: volumeSubtitle, command: previewCommand) { - PanelSection(header: "Details", highlighted: hasValues) { - PanelField(label: "Name", - info: "A persistent storage name you can mount into containers.", + PanelSection(header: AppText.string("creation.details", defaultValue: "Details"), highlighted: hasValues) { + PanelField(label: AppText.string("creation.name", defaultValue: "Name"), + info: AppText.string("creation.volume.name.info", defaultValue: "A persistent storage name you can mount into containers."), error: nameError) { TextField("", text: $name, prompt: Text("my-volume")) .textFieldStyle(.roundedBorder) .onSubmit(submitIfReady) } - PanelField(label: "Size", - info: "Optional runtime-specific size hint, such as `10G`. Leave blank for default.") { + PanelField(label: AppText.string("creation.volume.size", defaultValue: "Size"), + info: AppText.string("creation.volume.size.info", defaultValue: "Optional runtime-specific size hint, such as `10G`. Leave blank for default.")) { TextField("", text: $size, prompt: Text("optional, e.g. 10G")) .textFieldStyle(.roundedBorder) .onSubmit(submitIfReady) } } } footer: { - CreationSubmitBar(title: "Create volume", + CreationSubmitBar(title: AppText.string("creation.volume.create", defaultValue: "Create volume"), systemImage: "externaldrive.badge.plus", canSubmit: canSubmit, working: working, @@ -103,9 +103,9 @@ struct CreationVolumeFields: View { private var trimmedSize: String { size.trimmingCharacters(in: .whitespaces) } private var canSubmit: Bool { !trimmedName.isEmpty && !working } private var hasValues: Bool { !trimmedName.isEmpty || !trimmedSize.isEmpty } - private var nameError: String? { trimmedName.isEmpty ? "A volume name is required." : nil } - private var volumeName: String { trimmedName.isEmpty ? "New volume" : trimmedName } - private var volumeSubtitle: String { trimmedSize.isEmpty ? "default size" : trimmedSize } + private var nameError: String? { trimmedName.isEmpty ? AppText.string("creation.volume.name.required", defaultValue: "A volume name is required.") : nil } + private var volumeName: String { trimmedName.isEmpty ? AppText.string("creation.volume.new", defaultValue: "New volume") : trimmedName } + private var volumeSubtitle: String { trimmedSize.isEmpty ? AppText.string("creation.volume.defaultSize", defaultValue: "default size") : trimmedSize } private var previewCommand: [String] { ContainerCommands.volumeCreate(name: trimmedName.isEmpty ? "" : trimmedName, size: trimmedSize.isEmpty ? nil : trimmedSize) @@ -127,7 +127,7 @@ struct CreationLocalImagesContent: View { DesignInputSurface { HStack(spacing: DesignTokens.Space.s) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField("Filter local images", text: $query) + TextField(AppText.string("creation.localImages.filter", defaultValue: "Filter local images"), text: $query) .textFieldStyle(.plain) if !query.isEmpty { Button { query = "" } label: { Image(systemName: "xmark.circle.fill") } @@ -138,9 +138,11 @@ struct CreationLocalImagesContent: View { if filteredLocalImages.isEmpty { ContentUnavailableView { - Label("No matching images", systemImage: "square.stack.3d.up") + Label(AppText.string("creation.localImages.noMatches", defaultValue: "No matching images"), systemImage: "square.stack.3d.up") } description: { - Text(query.isEmpty ? "Pull or build an image first." : "Try a different filter.") + Text(query.isEmpty + ? AppText.string("creation.localImages.empty", defaultValue: "Pull or build an image first.") + : AppText.string("creation.localImages.tryDifferentFilter", defaultValue: "Try a different filter.")) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { @@ -259,7 +261,7 @@ private struct CreationResourceForm: View { } subtitleAccessory: { EmptyView() } headerAccessory: { - DesignBadgeText(text: "new", font: .caption2.weight(.semibold)) + DesignBadgeText(text: AppText.string("creation.badge.new", defaultValue: "new"), font: .caption2.weight(.semibold)) } bodyContent: { EmptyView() } footerLeading: { diff --git a/Sources/ContainedApp/Features/Containers/CustomizeSheet.swift b/Sources/ContainedApp/Features/Containers/CustomizeSheet.swift index c135fa8c..734f9103 100644 --- a/Sources/ContainedApp/Features/Containers/CustomizeSheet.swift +++ b/Sources/ContainedApp/Features/Containers/CustomizeSheet.swift @@ -153,7 +153,7 @@ struct CustomizeSheet: View { } private var inheritanceSection: some View { - PanelSection(header: "Inheritance") { + PanelSection(header: AppText.string("customize.inheritance", defaultValue: "Inheritance")) { PanelToggleRow(title: overrideToggleTitle, subtitle: overrideToggleHint, isOn: overrideBinding) @@ -161,42 +161,43 @@ struct CustomizeSheet: View { } private var styleSection: some View { - PanelSection(header: "Style") { + PanelSection(header: AppText.string("customize.style", defaultValue: "Style")) { PanelField(label: nicknameLabel) { TextField("", text: $style.nickname, prompt: Text(nicknamePrompt)) .textFieldStyle(.roundedBorder) } - PanelToggleRow(title: "Custom icon", isOn: $style.iconEnabled) + PanelToggleRow(title: AppText.string("customize.customIcon", defaultValue: "Custom icon"), isOn: $style.iconEnabled) if style.iconEnabled { - PanelField(label: "Icon") { + PanelField(label: AppText.string("customize.icon", defaultValue: "Icon")) { TextField("", text: $style.icon, prompt: Text("SF Symbol, e.g. globe, bolt")) .textFieldStyle(.roundedBorder) } } else { - PanelRow(title: "Icon", subtitle: "Using the default icon") + PanelRow(title: AppText.string("customize.icon", defaultValue: "Icon"), + subtitle: AppText.string("customize.icon.defaultSubtitle", defaultValue: "Using the default icon")) } - PanelRow(title: "Color", - info: "App Accent follows the accent tint from Settings; other swatches pin this style.") { + PanelRow(title: AppText.string("customize.color", defaultValue: "Color"), + info: AppText.string("customize.color.info", defaultValue: "App Accent follows the accent tint from Settings; other swatches pin this style.")) { TintSelector(selection: $style.tint) { $0.localizedDisplayName } } } } private var statusSection: some View { - PanelSection(header: "Status") { - PanelToggleRow(title: "Show status indicator", isOn: $style.showStatusIndicator) + PanelSection(header: AppText.string("customize.status", defaultValue: "Status")) { + PanelToggleRow(title: AppText.string("customize.showStatusIndicator", defaultValue: "Show status indicator"), isOn: $style.showStatusIndicator) if style.showStatusIndicator { - PanelToggleRow(title: "Show icon", isOn: $style.showStatusIcon) - PanelToggleRow(title: "Show text", isOn: $style.showStatusText) + PanelToggleRow(title: AppText.string("customize.widget.showIcon", defaultValue: "Show icon"), isOn: $style.showStatusIcon) + PanelToggleRow(title: AppText.string("customize.widget.showText", defaultValue: "Show text"), isOn: $style.showStatusText) } } } private var backgroundSection: some View { - PanelSection(header: "Background") { - PanelToggleRow(title: "Color the card background", isOn: $style.fillBackground) + PanelSection(header: AppText.string("customize.background", defaultValue: "Background")) { + PanelToggleRow(title: AppText.string("customize.colorCardBackground", defaultValue: "Color the card background"), isOn: $style.fillBackground) if style.fillBackground { - PanelRow(title: "Opacity") { + PanelRow(title: AppText.string("customize.opacity", defaultValue: "Opacity")) { HStack(spacing: DesignTokens.Space.s) { Slider(value: $style.backgroundOpacity, in: 0.05...0.6) Text(Format.percent(style.backgroundOpacity)) @@ -204,11 +205,11 @@ struct CustomizeSheet: View { .frame(width: DesignTokens.FormWidth.shortReadout) } } - PanelToggleRow(title: "Gradient", isOn: $style.gradient) + PanelToggleRow(title: AppText.string("customize.gradient", defaultValue: "Gradient"), isOn: $style.gradient) if style.gradient { GradientAngleControl(angle: $style.gradientAngle, title: AppText.direction) } - PanelRow(title: "Blend mode") { + PanelRow(title: AppText.string("customize.blendMode", defaultValue: "Blend mode")) { Picker("", selection: $style.backgroundBlendMode) { ForEach(ColorLayerBlendMode.allCases) { mode in Text(mode.localizedDisplayName).tag(mode) @@ -223,18 +224,22 @@ struct CustomizeSheet: View { private var actionsSection: some View { PanelSection { - PanelRow(title: target.isImage ? "Reset image style" : "Reset", - subtitle: canReset ? "Remove the saved local override." : "No saved override to remove.") { + PanelRow(title: target.isImage + ? AppText.string("customize.resetImageStyle", defaultValue: "Reset image style") + : AppText.string("customize.reset", defaultValue: "Reset"), + subtitle: canReset + ? AppText.string("customize.reset.subtitle", defaultValue: "Remove the saved local override.") + : AppText.string("customize.reset.noOverride", defaultValue: "No saved override to remove.")) { Button(role: .destructive) { reset() } label: { - Label("Reset", systemImage: "arrow.counterclockwise") + Label(AppText.string("customize.reset", defaultValue: "Reset"), systemImage: "arrow.counterclockwise") } .disabled(!canReset) } if case .container = target { - PanelRow(title: "Apply to image", - subtitle: "Make this container style the default for future containers from the same image.") { + PanelRow(title: AppText.string("customize.applyToImage", defaultValue: "Apply to image"), + subtitle: AppText.string("customize.applyToImage.subtitle", defaultValue: "Make this container style the default for future containers from the same image.")) { Button { applyToImage() } label: { - Label("Apply", systemImage: "square.stack.3d.up") + Label(AppText.string("customize.apply", defaultValue: "Apply"), systemImage: "square.stack.3d.up") } .disabled(settingsDisabled) } @@ -250,39 +255,39 @@ struct CustomizeSheet: View { private var headerTitle: String { switch target { - case .container: return "Customize card" - case .volume: return "Customize volume" - case .image, .imageGroup, .imageTag: return "Customize image style" + case .container: return AppText.string("customize.header.card", defaultValue: "Customize card") + case .volume: return AppText.string("customize.header.volume", defaultValue: "Customize volume") + case .image, .imageGroup, .imageTag: return AppText.string("customize.header.imageStyle", defaultValue: "Customize image style") } } private var overrideToggleTitle: String { switch target { - case .container: return "Override image style" - case .image, .imageGroup: return "Override default image card design" - case .imageTag: return "Override group style" - case .volume: return "Override style" + case .container: return AppText.string("customize.override.imageStyle", defaultValue: "Override image style") + case .image, .imageGroup: return AppText.string("customize.override.defaultImageDesign", defaultValue: "Override default image card design") + case .imageTag: return AppText.string("customize.override.groupStyle", defaultValue: "Override group style") + case .volume: return AppText.string("customize.override.style", defaultValue: "Override style") } } private var overrideToggleHint: String { switch target { case .container: - return "Turn this on to customize only this container. Leave it off to inherit the image style." + return AppText.string("customize.override.containerHint", defaultValue: "Turn this on to customize only this container. Leave it off to inherit the image style.") case .image: - return "Turn this on to style containers from this exact image. Leave it off to inherit the Settings default." + return AppText.string("customize.override.imageHint", defaultValue: "Turn this on to style containers from this exact image. Leave it off to inherit the Settings default.") case .imageGroup: - return "Turn this on to style this image group. Leave it off to inherit the Settings default." + return AppText.string("customize.override.imageGroupHint", defaultValue: "Turn this on to style this image group. Leave it off to inherit the Settings default.") case .imageTag: - return "Turn this on to style only this tag. Leave it off to inherit the image group's style." + return AppText.string("customize.override.imageTagHint", defaultValue: "Turn this on to style only this tag. Leave it off to inherit the image group's style.") case .volume: return "" } } private var nicknameLabel: String { - if case .container = target { return "Nickname" } - return "Display name" + if case .container = target { return AppText.string("customize.nickname", defaultValue: "Nickname") } + return AppText.string("customize.displayName", defaultValue: "Display name") } private var nicknamePrompt: String { @@ -296,13 +301,13 @@ struct CustomizeSheet: View { private var imageSubtitle: String? { switch target { case .imageGroup: - return "Default for this local image group" + return AppText.string("customize.subtitle.imageGroupDefault", defaultValue: "Default for this local image group") case .imageTag: - return "Style for \(Format.shortImage(target.image))" + return AppText.string("customize.subtitle.imageTag", defaultValue: "Style for \(Format.shortImage(target.image))") case .image: - return "Default for every container from \(Format.shortImage(target.image))" + return AppText.string("customize.subtitle.imageDefault", defaultValue: "Default for every container from \(Format.shortImage(target.image))") case .volume: - return "Style for this volume" + return AppText.string("customize.subtitle.volume", defaultValue: "Style for this volume") case .container: return nil } diff --git a/Sources/ContainedApp/Features/Containers/CustomizeWidgetsPanel.swift b/Sources/ContainedApp/Features/Containers/CustomizeWidgetsPanel.swift index 33ec5592..ecfbefaf 100644 --- a/Sources/ContainedApp/Features/Containers/CustomizeWidgetsPanel.swift +++ b/Sources/ContainedApp/Features/Containers/CustomizeWidgetsPanel.swift @@ -42,12 +42,14 @@ struct CustomizeWidgetsPanel: View { } private var addWidgetSection: some View { - PanelSection(header: "Widgets", - footer: "\(activeWidgetIndices.count) of \(Personalization.widgetSlotCount) widgets") { - PanelRow(title: "Add widget", - subtitle: canAddWidget ? "Add another metric chip or chart to this card." : "All widget slots are in use.") { + PanelSection(header: AppText.string("customize.widgets", defaultValue: "Widgets"), + footer: AppText.string("customize.widgets.footer", defaultValue: "\(activeWidgetIndices.count) of \(Personalization.widgetSlotCount) widgets")) { + PanelRow(title: AppText.string("customize.addWidget", defaultValue: "Add widget"), + subtitle: canAddWidget + ? AppText.string("customize.addWidget.subtitle", defaultValue: "Add another metric chip or chart to this card.") + : AppText.string("customize.addWidget.slotsFull", defaultValue: "All widget slots are in use.")) { Button { addWidget() } label: { - Label("Add", systemImage: "plus") + Label(AppText.add, systemImage: "plus") } .disabled(!canAddWidget) } @@ -58,44 +60,45 @@ struct CustomizeWidgetsPanel: View { private func widgetOrderControls(_ index: Int) -> some View { let position = activeWidgetIndices.firstIndex(of: index) ?? 0 - return PanelRow(title: "Order", subtitle: "Move or remove this widget.") { + return PanelRow(title: AppText.string("customize.widget.order", defaultValue: "Order"), + subtitle: AppText.string("customize.widget.order.subtitle", defaultValue: "Move or remove this widget.")) { HStack(spacing: DesignTokens.Space.xs) { Button { moveWidget(index, by: -1) } label: { - Label("Move up", systemImage: "chevron.up").labelStyle(.iconOnly) + Label(AppText.string("customize.widget.moveUp", defaultValue: "Move up"), systemImage: "chevron.up").labelStyle(.iconOnly) } .buttonStyle(.borderless) .disabled(position == 0) - .help("Move widget up") + .help(AppText.string("customize.widget.moveUp.help", defaultValue: "Move widget up")) Button { moveWidget(index, by: 1) } label: { - Label("Move down", systemImage: "chevron.down").labelStyle(.iconOnly) + Label(AppText.string("customize.widget.moveDown", defaultValue: "Move down"), systemImage: "chevron.down").labelStyle(.iconOnly) } .buttonStyle(.borderless) .disabled(position >= activeWidgetIndices.count - 1) - .help("Move widget down") + .help(AppText.string("customize.widget.moveDown.help", defaultValue: "Move widget down")) Button(role: .destructive) { removeWidget(index) } label: { - Label("Remove", systemImage: "minus.circle").labelStyle(.iconOnly) + Label(AppText.string("common.remove", defaultValue: "Remove"), systemImage: "minus.circle").labelStyle(.iconOnly) } .buttonStyle(.borderless) - .help("Remove widget") + .help(AppText.string("customize.widget.remove.help", defaultValue: "Remove widget")) } } } @ViewBuilder private func widgetDisplayOptions(_ index: Int) -> some View { - widgetGroupLabel("Display", systemImage: "paintpalette") - PanelToggleRow(title: "Show icon", isOn: widgetBinding(index, \.showIcon)) + widgetGroupLabel(AppText.string("customize.widget.display", defaultValue: "Display"), systemImage: "paintpalette") + PanelToggleRow(title: AppText.string("customize.widget.showIcon", defaultValue: "Show icon"), isOn: widgetBinding(index, \.showIcon)) if style.widget(at: index).showIcon { - PanelField(label: "Icon") { + PanelField(label: AppText.string("customize.icon", defaultValue: "Icon")) { TextField("", text: widgetBinding(index, \.icon), prompt: Text(style.widget(at: index).metric.systemImage)) .textFieldStyle(.roundedBorder) } } - PanelToggleRow(title: "Show text", isOn: widgetBinding(index, \.showText)) - PanelRow(title: "Color") { + PanelToggleRow(title: AppText.string("customize.widget.showText", defaultValue: "Show text"), isOn: widgetBinding(index, \.showText)) + PanelRow(title: AppText.string("customize.color", defaultValue: "Color")) { TintSelector(optionalSelection: widgetBinding(index, \.tint), automaticLabel: AppText.cardColor) { $0.localizedDisplayName } } @@ -103,8 +106,8 @@ struct CustomizeWidgetsPanel: View { @ViewBuilder private func widgetDataOptions(_ index: Int) -> some View { - widgetGroupLabel("Data", systemImage: "waveform.path.ecg") - PanelRow(title: "Metric") { + widgetGroupLabel(AppText.string("customize.widget.data", defaultValue: "Data"), systemImage: "waveform.path.ecg") + PanelRow(title: AppText.string("customize.widget.metric", defaultValue: "Metric")) { Picker("", selection: widgetMetricBinding(index)) { ForEach(graphOptions) { Label(graphLabel($0), systemImage: $0.systemImage).tag($0) @@ -115,7 +118,7 @@ struct CustomizeWidgetsPanel: View { } if widgetStyle(index).requiresSecondaryMetric { if let fallback = secondaryMetricFallback(for: index) { - PanelRow(title: "Compare") { + PanelRow(title: AppText.string("customize.widget.compare", defaultValue: "Compare")) { Picker("", selection: widgetSecondaryMetricBinding(index, fallback: fallback)) { ForEach(graphOptions.filter { $0 != style.widget(at: index).metric }) { Label(graphLabel($0), systemImage: $0.systemImage).tag($0) @@ -125,7 +128,8 @@ struct CustomizeWidgetsPanel: View { .fixedSize() } } else { - PanelRow(title: "Compare", subtitle: "This graph needs a second metric.") + PanelRow(title: AppText.string("customize.widget.compare", defaultValue: "Compare"), + subtitle: AppText.string("customize.widget.compare.subtitle", defaultValue: "This graph needs a second metric.")) } } } @@ -133,8 +137,8 @@ struct CustomizeWidgetsPanel: View { @ViewBuilder private func widgetChartOptions(_ index: Int) -> some View { let chartStyle = widgetStyle(index) - widgetGroupLabel("Chart", systemImage: "chart.xyaxis.line") - PanelRow(title: "Type") { + widgetGroupLabel(AppText.string("customize.widget.chart", defaultValue: "Chart"), systemImage: "chart.xyaxis.line") + PanelRow(title: AppText.string("customize.widget.type", defaultValue: "Type")) { Picker("", selection: widgetStyleBinding(index)) { ForEach(GraphStyle.allCases) { Text($0.localizedDisplayName).tag($0) } } @@ -142,31 +146,31 @@ struct CustomizeWidgetsPanel: View { .fixedSize() } if chartStyle == .area { - PanelToggleRow(title: "Gradient fill", isOn: widgetBinding(index, \.areaUsesGradient)) + PanelToggleRow(title: AppText.string("customize.widget.gradientFill", defaultValue: "Gradient fill"), isOn: widgetBinding(index, \.areaUsesGradient)) } if chartStyle.usesLineOptions { - PanelRow(title: "Interpolation") { + PanelRow(title: AppText.string("customize.widget.interpolation", defaultValue: "Interpolation")) { Picker("", selection: widgetBinding(index, \.interpolation)) { ForEach(WidgetInterpolation.allCases) { Text($0.localizedDisplayName).tag($0) } } .labelsHidden() .fixedSize() } - sliderRow("Line width", + sliderRow(AppText.string("customize.widget.lineWidth", defaultValue: "Line width"), value: widgetBinding(index, \.lineWidth), range: 0.75...4, step: 0.25, readout: String(format: "%.1f", style.widget(at: index).lineWidth)) } if chartStyle.usesPointOptions { - sliderRow("Point size", + sliderRow(AppText.string("customize.widget.pointSize", defaultValue: "Point size"), value: widgetBinding(index, \.pointSize), range: 8...44, step: 1, readout: wholeNumberReadout(style.widget(at: index).pointSize)) } if chartStyle.usesBarOptions { - sliderRow("Bar width", + sliderRow(AppText.string("customize.widget.barWidth", defaultValue: "Bar width"), value: widgetBinding(index, \.barWidth), range: 2...14, step: 1, @@ -197,7 +201,7 @@ struct CustomizeWidgetsPanel: View { private func widgetTitle(for index: Int) -> String { let position = activeWidgetIndices.firstIndex(of: index) ?? 0 - return "Widget \(position + 1)" + return AppText.string("customize.widget.title", defaultValue: "Widget \(position + 1)") } private func graphLabel(_ metric: GraphMetric) -> String { @@ -205,8 +209,8 @@ struct CustomizeWidgetsPanel: View { return metric.displayName } switch metric { - case .diskRead: return "Read" - case .diskWrite: return "Write" + case .diskRead: return AppText.string("customize.widget.metric.read", defaultValue: "Read") + case .diskWrite: return AppText.string("customize.widget.metric.write", defaultValue: "Write") default: return metric.displayName } } diff --git a/Sources/ContainedApp/Features/Containers/RunSpec.swift b/Sources/ContainedApp/Features/Containers/RunSpec.swift index 837efc86..1381c65a 100644 --- a/Sources/ContainedApp/Features/Containers/RunSpec.swift +++ b/Sources/ContainedApp/Features/Containers/RunSpec.swift @@ -51,7 +51,8 @@ struct SocketMap: Identifiable, Hashable, Codable { private enum CodingKeys: String, CodingKey { case hostPath, containerPath } } -/// The complete state of the Create/Run form. Knows how to render itself as a `container run` argv. +/// The complete app-owned state of the Create/Run form. Runtime adapters translate the derived +/// `ContainerCreateRequest` into backend-specific commands. struct RunSpec: Codable { var runtimeKind: RuntimeKind? = .appleContainer var image = "" @@ -219,8 +220,8 @@ struct RunSpec: Codable { // Personalization is resolved from the local store by the edit sheet, not from labels. } - /// Build the `container run …` argument vector. Single source of truth for the live preview - /// and the actual execution. + /// Command-preview compatibility for the Apple runtime while the form still presents a shell + /// preview. The backend boundary remains the runtime-neutral `createRequest`. func arguments() -> [String] { ContainerCommands.run(createRequest) } diff --git a/Sources/ContainedApp/Features/Containers/RunSpecForm.swift b/Sources/ContainedApp/Features/Containers/RunSpecForm.swift index 865aeba6..be139f00 100644 --- a/Sources/ContainedApp/Features/Containers/RunSpecForm.swift +++ b/Sources/ContainedApp/Features/Containers/RunSpecForm.swift @@ -23,24 +23,24 @@ struct RunSpecForm: View { var body: some View { LazyVStack(spacing: DesignTokens.Space.l) { - Text("Blue sections contain explicit values from an import, edit, template, or manual change.") + Text(AppText.string("runSpec.importedValuesHint", defaultValue: "Blue sections contain explicit values from an import, edit, template, or manual change.")) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - PanelSection(header: "Essentials", highlighted: spec.hasGeneralOptions) { generalSection } - PanelSection(header: "Resources", highlighted: spec.hasResourceOptions) { resourcesSection } - PanelSection(header: "Networking", highlighted: spec.hasNetworkingOptions) { + PanelSection(header: AppText.string("runSpec.section.essentials", defaultValue: "Essentials"), highlighted: spec.hasGeneralOptions) { generalSection } + PanelSection(header: AppText.string("runSpec.section.resources", defaultValue: "Resources"), highlighted: spec.hasResourceOptions) { resourcesSection } + PanelSection(header: AppText.string("runSpec.section.networking", defaultValue: "Networking"), highlighted: spec.hasNetworkingOptions) { portsSection networkSection socketsSection } - PanelSection(header: "Storage", highlighted: spec.hasStorageOptions) { volumesSection } - PanelSection(header: "Environment", highlighted: spec.hasEnvironmentOptions) { environmentSection } - PanelSection(header: "App Managed", highlighted: spec.hasAppManagedOptions) { + PanelSection(header: AppText.string("runSpec.section.storage", defaultValue: "Storage"), highlighted: spec.hasStorageOptions) { volumesSection } + PanelSection(header: AppText.string("runSpec.section.environment", defaultValue: "Environment"), highlighted: spec.hasEnvironmentOptions) { environmentSection } + PanelSection(header: AppText.string("runSpec.section.appManaged", defaultValue: "App Managed"), highlighted: spec.hasAppManagedOptions) { restartSection healthSection } - PanelSection(header: "Appearance", highlighted: spec.hasPersonalizationOptions) { personalizationSection } + PanelSection(header: AppText.sectionSettingsAppearance, highlighted: spec.hasPersonalizationOptions) { personalizationSection } advancedOptionsSection } .onChange(of: spec.hasAdvancedOptions) { _, hasValues in if hasValues { advancedExpanded = true } } @@ -63,22 +63,23 @@ struct RunSpecForm: View { .fixedSize() .disabled(!app.runtimeCoreSelectorIsEnabled) } - PanelField(label: "Image", - info: "The container image to start, such as `nginx:latest`. If it is not on this Mac yet, Contained pulls it before running.", - error: spec.image.trimmingCharacters(in: .whitespaces).isEmpty ? "An image reference is required." : nil) { + PanelField(label: AppText.string("runSpec.image", defaultValue: "Image"), + info: AppText.string("runSpec.image.info", defaultValue: "The container image to start, such as `nginx:latest`. If it is not on this Mac yet, Contained pulls it before running."), + error: spec.image.trimmingCharacters(in: .whitespaces).isEmpty ? AppText.string("runSpec.image.required", defaultValue: "An image reference is required.") : nil) { TextField("", text: $spec.image, prompt: Text("e.g. nginx:latest")).textFieldStyle(.roundedBorder) } if imageDefaults != nil { - PanelRow(title: "Image defaults", - subtitle: "Fill empty command, entrypoint, user, working directory, and environment fields from the pulled image config.", - info: "Images can define default startup settings. Adopt copies those defaults into this form so you can see and edit them before running.") { + PanelRow(title: AppText.string("runSpec.imageDefaults", defaultValue: "Image defaults"), + subtitle: AppText.string("runSpec.imageDefaults.subtitle", defaultValue: "Fill empty command, entrypoint, user, working directory, and environment fields from the pulled image config."), + info: AppText.string("runSpec.imageDefaults.info", defaultValue: "Images can define default startup settings. Adopt copies those defaults into this form so you can see and edit them before running.")) { DesignTextActionButton(title: AppText.string("runSpec.adopt", defaultValue: "Adopt"), systemName: "wand.and.stars") { adoptImageDefaults() } } } - PanelRow(title: "Platform", info: "Use this only when an image supports more than one CPU type. Leave Default unless you specifically need arm64 or amd64.") { + PanelRow(title: AppText.string("runSpec.platform", defaultValue: "Platform"), + info: AppText.string("runSpec.platform.info", defaultValue: "Use this only when an image supports more than one CPU type. Leave Default unless you specifically need arm64 or amd64.")) { Picker("", selection: platformPresetBinding) { Text("Default").tag("") Text("Linux arm64").tag("linux/arm64") @@ -89,36 +90,40 @@ struct RunSpecForm: View { .labelsHidden().fixedSize() } if platformPresetBinding.wrappedValue == "custom" { - PanelField(label: "Custom platform", info: "Advanced platform value in `os/arch` form, for example `linux/arm64`.") { + PanelField(label: AppText.string("runSpec.customPlatform", defaultValue: "Custom platform"), + info: AppText.string("runSpec.customPlatform.info", defaultValue: "Advanced platform value in `os/arch` form, for example `linux/arm64`.")) { TextField("", text: $spec.platform, prompt: Text("os/arch[/variant]")).textFieldStyle(.roundedBorder) } } - PanelField(label: "Name", info: "Optional friendly runtime name. Leave it blank and the container runtime will generate one.") { + PanelField(label: AppText.string("runSpec.name", defaultValue: "Name"), + info: AppText.string("runSpec.name.info", defaultValue: "Optional friendly runtime name. Leave it blank and the container runtime will generate one.")) { TextField("", text: $spec.name, prompt: Text("optional")).textFieldStyle(.roundedBorder) } - PanelField(label: "Command", info: "Optional command to run instead of the image's normal startup command.") { + PanelField(label: AppText.string("runSpec.command", defaultValue: "Command"), + info: AppText.string("runSpec.command.info", defaultValue: "Optional command to run instead of the image's normal startup command.")) { TextField("", text: $spec.command, prompt: Text("override the default command (optional)")).textFieldStyle(.roundedBorder) } - PanelToggleRow(title: "Run in the background", - info: "Detached (-d): runs without attaching to its output.", isOn: $spec.detach) - PanelToggleRow(title: "Remove when stopped", - info: "Deletes the container record when it stops. Use volumes if you need data to survive.", isOn: $spec.removeOnExit) + PanelToggleRow(title: AppText.string("runSpec.detach", defaultValue: "Run in the background"), + info: AppText.string("runSpec.detach.info", defaultValue: "Detached (-d): runs without attaching to its output."), isOn: $spec.detach) + PanelToggleRow(title: AppText.string("runSpec.removeWhenStopped", defaultValue: "Remove when stopped"), + info: AppText.string("runSpec.removeWhenStopped.info", defaultValue: "Deletes the container record when it stops. Use volumes if you need data to survive."), isOn: $spec.removeOnExit) } } private var resourcesSection: some View { Group { - PanelRow(title: "CPUs", info: "Limit how much CPU the container can use. Default lets the runtime decide. This Mac has \(hostCPUs) cores.") { + PanelRow(title: AppText.string("runSpec.cpus", defaultValue: "CPUs"), + info: AppText.string("runSpec.cpus.info", defaultValue: "Limit how much CPU the container can use. Default lets the runtime decide. This Mac has \(hostCPUs) cores.")) { Picker("", selection: cpuBinding) { Text("Default").tag(0) ForEach(1...max(1, hostCPUs), id: \.self) { Text("\($0)").tag($0) } } .labelsHidden().fixedSize() } - PanelToggleRow(title: "Limit memory", - info: "Set a memory ceiling for the container. If it goes past the limit, the runtime may stop it.", isOn: memoryLimitBinding) + PanelToggleRow(title: AppText.string("runSpec.limitMemory", defaultValue: "Limit memory"), + info: AppText.string("runSpec.limitMemory.info", defaultValue: "Set a memory ceiling for the container. If it goes past the limit, the runtime may stop it."), isOn: memoryLimitBinding) if !spec.memory.isEmpty { - PanelField(label: "Memory") { + PanelField(label: AppText.string("runSpec.memory", defaultValue: "Memory")) { HStack(spacing: DesignTokens.Space.s) { Slider(value: memoryGBBinding, in: 0.5...max(0.5, maxMemoryGB), step: 0.5) Text(memoryReadout).monospacedDigit().frame(width: DesignTokens.FormWidth.memoryReadout) @@ -200,7 +205,7 @@ struct RunSpecForm: View { removeButton { spec.ports.removeAll { $0.id == port.id } } } } - addButton("Add port") { spec.ports.append(PortMap()) } + addButton(AppText.string("runSpec.addPort", defaultValue: "Add port")) { spec.ports.append(PortMap()) } } } @@ -219,7 +224,7 @@ struct RunSpecForm: View { } } } - addButton("Add volume") { spec.volumes.append(VolumeMap()) } + addButton(AppText.string("runSpec.addVolume", defaultValue: "Add volume")) { spec.volumes.append(VolumeMap()) } } } @@ -233,9 +238,9 @@ struct RunSpecForm: View { removeButton { spec.env.removeAll { $0.id == variable.id } } } } - addButton("Add variable") { spec.env.append(KeyValue()) } - stringList("Add env file", $spec.envFiles, prompt: "/path/to/.env", - info: "Read environment variables from a file (--env-file).") + addButton(AppText.string("runSpec.addVariable", defaultValue: "Add variable")) { spec.env.append(KeyValue()) } + stringList(AppText.string("runSpec.addEnvFile", defaultValue: "Add env file"), $spec.envFiles, prompt: "/path/to/.env", + info: AppText.string("runSpec.addEnvFile.info", defaultValue: "Read environment variables from a file (--env-file).")) } } @@ -250,7 +255,7 @@ struct RunSpecForm: View { TextField("Container socket path", text: $socket.containerPath).textFieldStyle(.roundedBorder) } } - addButton("Add socket") { spec.sockets.append(SocketMap()) } + addButton(AppText.string("runSpec.addSocket", defaultValue: "Add socket")) { spec.sockets.append(SocketMap()) } } } @@ -264,26 +269,29 @@ struct RunSpecForm: View { removeButton { spec.labels.removeAll { $0.id == label.id } } } } - addButton("Add label") { spec.labels.append(KeyValue()) } + addButton(AppText.string("runSpec.addLabel", defaultValue: "Add label")) { spec.labels.append(KeyValue()) } } } private var personalizationSection: some View { Group { - PanelField(label: "Nickname", info: "A display name for the card only. It does not rename the real container.") { + PanelField(label: AppText.string("runSpec.nickname", defaultValue: "Nickname"), + info: AppText.string("runSpec.nickname.info", defaultValue: "A display name for the card only. It does not rename the real container.")) { TextField("", text: $spec.personalization.nickname, prompt: Text("display name (optional)")).textFieldStyle(.roundedBorder) } - PanelField(label: "Icon", info: "An SF Symbol name for the card icon, such as `shippingbox` or `bolt`.") { + PanelField(label: AppText.string("runSpec.icon", defaultValue: "Icon"), + info: AppText.string("runSpec.icon.info", defaultValue: "An SF Symbol name for the card icon, such as `shippingbox` or `bolt`.")) { TextField("", text: $spec.personalization.icon, prompt: Text("SF Symbol, e.g. globe, bolt")).textFieldStyle(.roundedBorder) } - PanelRow(title: "Color", info: "Sets the card icon color. If background color is enabled, it also tints the glass card.") { + PanelRow(title: AppText.string("runSpec.color", defaultValue: "Color"), + info: AppText.string("runSpec.color.info", defaultValue: "Sets the card icon color. If background color is enabled, it also tints the glass card.")) { TintSelector(selection: $spec.personalization.tint) { $0.localizedDisplayName } } - PanelToggleRow(title: "Color the card background", - info: "Adds a soft color wash behind the glass. Turn it off for clear glass with only a colored icon.", + PanelToggleRow(title: AppText.string("runSpec.colorCardBackground", defaultValue: "Color the card background"), + info: AppText.string("runSpec.colorCardBackground.info", defaultValue: "Adds a soft color wash behind the glass. Turn it off for clear glass with only a colored icon."), isOn: $spec.personalization.fillBackground) if spec.personalization.fillBackground { - PanelField(label: "Opacity") { + PanelField(label: AppText.string("runSpec.opacity", defaultValue: "Opacity")) { HStack(spacing: DesignTokens.Space.s) { Slider(value: $spec.personalization.backgroundOpacity, in: 0.05...0.6) Text(Format.percent(spec.personalization.backgroundOpacity)) @@ -291,14 +299,14 @@ struct RunSpecForm: View { .frame(width: DesignTokens.FormWidth.shortReadout) } } - PanelToggleRow(title: "Gradient", - info: "Blends the color across the card instead of using one flat wash.", + PanelToggleRow(title: AppText.string("runSpec.gradient", defaultValue: "Gradient"), + info: AppText.string("runSpec.gradient.info", defaultValue: "Blends the color across the card instead of using one flat wash."), isOn: $spec.personalization.gradient) if spec.personalization.gradient { GradientAngleControl(angle: $spec.personalization.gradientAngle, title: AppText.direction) } - PanelRow(title: "Blend mode", - info: "Controls how the card color wash blends with the glass behind it.") { + PanelRow(title: AppText.string("runSpec.blendMode", defaultValue: "Blend mode"), + info: AppText.string("runSpec.blendMode.info", defaultValue: "Controls how the card color wash blends with the glass behind it.")) { Picker("", selection: $spec.personalization.backgroundBlendMode) { ForEach(ColorLayerBlendMode.allCases) { mode in Text(mode.localizedDisplayName).tag(mode) @@ -312,7 +320,8 @@ struct RunSpecForm: View { } private var restartSection: some View { - PanelRow(title: "Restart policy", info: "Contained restarts the container automatically based on this setting.") { + PanelRow(title: AppText.string("runSpec.restartPolicy", defaultValue: "Restart policy"), + info: AppText.string("runSpec.restartPolicy.info", defaultValue: "Contained restarts the container automatically based on this setting.")) { Picker("", selection: $spec.restart) { ForEach(RestartPolicy.allCases) { Text($0.localizedDisplayName).tag($0) } } @@ -322,11 +331,12 @@ struct RunSpecForm: View { private var healthSection: some View { Group { - PanelToggleRow(title: "Enable healthcheck", - info: "Contained probes the container on an interval (app-managed; the runtime has no native healthcheck).", + PanelToggleRow(title: AppText.string("runSpec.enableHealthcheck", defaultValue: "Enable healthcheck"), + info: AppText.string("runSpec.enableHealthcheck.info", defaultValue: "Contained probes the container on an interval (app-managed; the runtime has no native healthcheck)."), isOn: $spec.healthCheck.enabled) if spec.healthCheck.enabled { - PanelField(label: "Probe command", info: "Run inside the container via `sh -c`; a zero exit = healthy. Needs a shell in the image.") { + PanelField(label: AppText.string("runSpec.probeCommand", defaultValue: "Probe command"), + info: AppText.string("runSpec.probeCommand.info", defaultValue: "Run inside the container via `sh -c`; a zero exit = healthy. Needs a shell in the image.")) { TextField("", text: healthCommandBinding, prompt: Text("curl -f http://localhost/ || exit 1")).textFieldStyle(.roundedBorder) } Stepper("Interval: \(spec.healthCheck.intervalSeconds)s", @@ -352,30 +362,34 @@ struct RunSpecForm: View { @ViewBuilder private var runtimeSection: some View { Group { - PanelField(label: "Entrypoint", info: "Override the image's entrypoint program.") { + PanelField(label: AppText.string("runSpec.entrypoint", defaultValue: "Entrypoint"), + info: AppText.string("runSpec.entrypoint.info", defaultValue: "Override the image's entrypoint program.")) { TextField("", text: $spec.entrypoint, prompt: Text("optional")).textFieldStyle(.roundedBorder) } - PanelToggleRow(title: "Keep stdin open", - info: "Keep standard input open even when detached (--interactive).", isOn: $spec.interactive) - PanelToggleRow(title: "Allocate TTY", - info: "Allocate a terminal for the process (--tty).", isOn: $spec.tty) - PanelField(label: "Working directory", info: "Initial working directory inside the container (-w).") { + PanelToggleRow(title: AppText.string("runSpec.keepStdinOpen", defaultValue: "Keep stdin open"), + info: AppText.string("runSpec.keepStdinOpen.info", defaultValue: "Keep standard input open even when detached (--interactive)."), isOn: $spec.interactive) + PanelToggleRow(title: AppText.string("runSpec.allocateTTY", defaultValue: "Allocate TTY"), + info: AppText.string("runSpec.allocateTTY.info", defaultValue: "Allocate a terminal for the process (--tty)."), isOn: $spec.tty) + PanelField(label: AppText.string("runSpec.workingDirectory", defaultValue: "Working directory"), + info: AppText.string("runSpec.workingDirectory.info", defaultValue: "Initial working directory inside the container (-w).")) { TextField("", text: $spec.workingDir, prompt: Text("optional, e.g. /app")).textFieldStyle(.roundedBorder) } - PanelField(label: "User", info: "Run the process as this user (-u). Or set UID/GID below.") { + PanelField(label: AppText.string("runSpec.user", defaultValue: "User"), + info: AppText.string("runSpec.user.info", defaultValue: "Run the process as this user (-u). Or set UID/GID below.")) { TextField("", text: $spec.user, prompt: Text("name | uid[:gid]")).textFieldStyle(.roundedBorder) } - PanelField(label: "User ID", info: "Numeric user / group IDs (--uid / --gid).") { + PanelField(label: AppText.string("runSpec.userID", defaultValue: "User ID"), + info: AppText.string("runSpec.userID.info", defaultValue: "Numeric user / group IDs (--uid / --gid).")) { HStack { TextField("UID", text: $spec.uid).textFieldStyle(.roundedBorder).frame(width: DesignTokens.FormWidth.userID) TextField("GID", text: $spec.gid).textFieldStyle(.roundedBorder).frame(width: DesignTokens.FormWidth.userID) Spacer() } } - PanelToggleRow(title: "Set shared memory size", - info: "Size of /dev/shm (--shm-size).", isOn: shmLimitBinding) + PanelToggleRow(title: AppText.string("runSpec.setSharedMemorySize", defaultValue: "Set shared memory size"), + info: AppText.string("runSpec.setSharedMemorySize.info", defaultValue: "Size of /dev/shm (--shm-size)."), isOn: shmLimitBinding) if !spec.shmSize.isEmpty { - PanelField(label: "Shared memory") { + PanelField(label: AppText.string("runSpec.sharedMemory", defaultValue: "Shared memory")) { HStack(spacing: DesignTokens.Space.s) { Slider(value: shmGBBinding, in: 0.0625...max(0.0625, maxMemoryGB), step: 0.0625) Text(memoryReadout(spec.shmSize, fallbackGB: 0.0625)) @@ -385,39 +399,41 @@ struct RunSpecForm: View { } } - stringList("Add capability", $spec.capAdd, prompt: "CAP_NET_RAW or ALL", - info: "Add a Linux capability (--cap-add).") - stringList("Drop capability", $spec.capDrop, prompt: "CAP_NET_RAW or ALL", - info: "Drop a Linux capability (--cap-drop).") - PanelField(label: "Container ID file", info: "Write the new container ID to a file (--cidfile).") { + stringList(AppText.string("runSpec.addCapability", defaultValue: "Add capability"), $spec.capAdd, prompt: "CAP_NET_RAW or ALL", + info: AppText.string("runSpec.addCapability.info", defaultValue: "Add a Linux capability (--cap-add).")) + stringList(AppText.string("runSpec.dropCapability", defaultValue: "Drop capability"), $spec.capDrop, prompt: "CAP_NET_RAW or ALL", + info: AppText.string("runSpec.dropCapability.info", defaultValue: "Drop a Linux capability (--cap-drop).")) + PanelField(label: AppText.string("runSpec.containerIDFile", defaultValue: "Container ID file"), + info: AppText.string("runSpec.containerIDFile.info", defaultValue: "Write the new container ID to a file (--cidfile).")) { TextField("", text: $spec.cidFile, prompt: Text("optional path")).textFieldStyle(.roundedBorder) } - stringList("Add tmpfs mount", $spec.tmpfs, prompt: "/path", - info: "Mount a tmpfs at this path (--tmpfs).") - stringList("Add ulimit", $spec.ulimits, prompt: "nofile=1024:2048", - info: "Resource limit, type=soft[:hard] (--ulimit).") + stringList(AppText.string("runSpec.addTmpfsMount", defaultValue: "Add tmpfs mount"), $spec.tmpfs, prompt: "/path", + info: AppText.string("runSpec.addTmpfsMount.info", defaultValue: "Mount a tmpfs at this path (--tmpfs).")) + stringList(AppText.string("runSpec.addUlimit", defaultValue: "Add ulimit"), $spec.ulimits, prompt: "nofile=1024:2048", + info: AppText.string("runSpec.addUlimit.info", defaultValue: "Resource limit, type=soft[:hard] (--ulimit).")) } } @ViewBuilder private var securitySection: some View { Group { - PanelToggleRow(title: "Read-only filesystem", - info: "Mounts the container's root filesystem as read-only.", isOn: $spec.readOnly) - PanelToggleRow(title: "Use an init process", - info: "Runs a tiny init that forwards signals and cleans up zombie processes.", isOn: $spec.useInit) - PanelToggleRow(title: "Rosetta (x86 apps)", - info: "Lets the container run x86-64 binaries via Rosetta.", isOn: $spec.rosetta) - PanelToggleRow(title: "Forward SSH agent", - info: "Forwards your host SSH agent into the container.", isOn: $spec.ssh) - PanelToggleRow(title: "Expose virtualization", - info: "Exposes nested virtualization (needs host + guest support).", isOn: $spec.virtualization) + PanelToggleRow(title: AppText.string("runSpec.readOnlyFilesystem", defaultValue: "Read-only filesystem"), + info: AppText.string("runSpec.readOnlyFilesystem.info", defaultValue: "Mounts the container's root filesystem as read-only."), isOn: $spec.readOnly) + PanelToggleRow(title: AppText.string("runSpec.useInitProcess", defaultValue: "Use an init process"), + info: AppText.string("runSpec.useInitProcess.info", defaultValue: "Runs a tiny init that forwards signals and cleans up zombie processes."), isOn: $spec.useInit) + PanelToggleRow(title: AppText.string("runSpec.rosetta", defaultValue: "Rosetta (x86 apps)"), + info: AppText.string("runSpec.rosetta.info", defaultValue: "Lets the container run x86-64 binaries via Rosetta."), isOn: $spec.rosetta) + PanelToggleRow(title: AppText.string("runSpec.forwardSSHAgent", defaultValue: "Forward SSH agent"), + info: AppText.string("runSpec.forwardSSHAgent.info", defaultValue: "Forwards your host SSH agent into the container."), isOn: $spec.ssh) + PanelToggleRow(title: AppText.string("runSpec.exposeVirtualization", defaultValue: "Expose virtualization"), + info: AppText.string("runSpec.exposeVirtualization.info", defaultValue: "Exposes nested virtualization (needs host + guest support)."), isOn: $spec.virtualization) } } @ViewBuilder private var networkSection: some View { - PanelRow(title: "Network", info: "Attach the container to a network (--network).") { + PanelRow(title: AppText.string("runSpec.network", defaultValue: "Network"), + info: AppText.string("runSpec.network.info", defaultValue: "Attach the container to a network (--network).")) { Menu(networkMenuTitle) { Button { spec.network = "" @@ -449,16 +465,20 @@ struct RunSpecForm: View { @ViewBuilder private var fetchSection: some View { Group { - PanelField(label: "Runtime", info: "Runtime handler (--runtime).") { + PanelField(label: AppText.string("runSpec.runtime", defaultValue: "Runtime"), + info: AppText.string("runSpec.runtime.info", defaultValue: "Runtime handler (--runtime).")) { TextField("", text: $spec.runtime, prompt: Text("optional")).textFieldStyle(.roundedBorder) } - PanelField(label: "Init image", info: "Use a custom init image (--init-image).") { + PanelField(label: AppText.string("runSpec.initImage", defaultValue: "Init image"), + info: AppText.string("runSpec.initImage.info", defaultValue: "Use a custom init image (--init-image).")) { TextField("", text: $spec.initImage, prompt: Text("optional image")).textFieldStyle(.roundedBorder) } - PanelField(label: "Kernel", info: "Use a custom kernel path (--kernel).") { + PanelField(label: AppText.string("runSpec.kernel", defaultValue: "Kernel"), + info: AppText.string("runSpec.kernel.info", defaultValue: "Use a custom kernel path (--kernel).")) { TextField("", text: $spec.kernel, prompt: Text("optional path")).textFieldStyle(.roundedBorder) } - PanelRow(title: "Registry scheme", info: "Registry connection scheme for image fetches (--scheme).") { + PanelRow(title: AppText.string("runSpec.registryScheme", defaultValue: "Registry scheme"), + info: AppText.string("runSpec.registryScheme.info", defaultValue: "Registry connection scheme for image fetches (--scheme).")) { Picker("", selection: $spec.scheme) { Text("Default").tag("") Text("Auto").tag("auto") @@ -467,7 +487,8 @@ struct RunSpecForm: View { } .labelsHidden().fixedSize() } - PanelRow(title: "Progress", info: "Progress display mode for image fetches (--progress).") { + PanelRow(title: AppText.string("runSpec.progress", defaultValue: "Progress"), + info: AppText.string("runSpec.progress.info", defaultValue: "Progress display mode for image fetches (--progress).")) { Picker("", selection: $spec.progress) { Text("Default").tag("") Text("Auto").tag("auto") @@ -478,8 +499,8 @@ struct RunSpecForm: View { } .labelsHidden().fixedSize() } - PanelToggleRow(title: "Limit parallel downloads", - info: "Maximum concurrent image downloads (--max-concurrent-downloads).", isOn: maxDownloadsBinding) + PanelToggleRow(title: AppText.string("runSpec.limitParallelDownloads", defaultValue: "Limit parallel downloads"), + info: AppText.string("runSpec.limitParallelDownloads.info", defaultValue: "Maximum concurrent image downloads (--max-concurrent-downloads)."), isOn: maxDownloadsBinding) if !spec.maxConcurrentDownloads.isEmpty { Stepper("Max downloads: \(maxConcurrentDownloadsBinding.wrappedValue)", value: maxConcurrentDownloadsBinding, in: 1...16) @@ -490,18 +511,19 @@ struct RunSpecForm: View { @ViewBuilder private var dnsSection: some View { Group { - PanelToggleRow(title: "Disable DNS", - info: "Do not configure DNS inside the container (--no-dns).", isOn: $spec.noDNS) + PanelToggleRow(title: AppText.string("runSpec.disableDNS", defaultValue: "Disable DNS"), + info: AppText.string("runSpec.disableDNS.info", defaultValue: "Do not configure DNS inside the container (--no-dns)."), isOn: $spec.noDNS) if !spec.noDNS { - stringList("Add nameserver", $spec.dns, prompt: "1.1.1.1", - info: "DNS nameserver IP (--dns).") - PanelField(label: "Search domain", info: "Default DNS domain (--dns-domain).") { + stringList(AppText.string("runSpec.addNameserver", defaultValue: "Add nameserver"), $spec.dns, prompt: "1.1.1.1", + info: AppText.string("runSpec.addNameserver.info", defaultValue: "DNS nameserver IP (--dns).")) + PanelField(label: AppText.string("runSpec.searchDomain", defaultValue: "Search domain"), + info: AppText.string("runSpec.searchDomain.info", defaultValue: "Default DNS domain (--dns-domain).")) { TextField("", text: $spec.dnsDomain, prompt: Text("optional")).textFieldStyle(.roundedBorder) } - stringList("Add search domain", $spec.dnsSearch, prompt: "example.com", - info: "DNS search domain (--dns-search).") - stringList("Add DNS option", $spec.dnsOption, prompt: "ndots:2", - info: "DNS resolver option (--dns-option).") + stringList(AppText.string("runSpec.addSearchDomain", defaultValue: "Add search domain"), $spec.dnsSearch, prompt: "example.com", + info: AppText.string("runSpec.addSearchDomain.info", defaultValue: "DNS search domain (--dns-search).")) + stringList(AppText.string("runSpec.addDNSOption", defaultValue: "Add DNS option"), $spec.dnsOption, prompt: "ndots:2", + info: AppText.string("runSpec.addDNSOption.info", defaultValue: "DNS resolver option (--dns-option).")) } } } @@ -510,16 +532,16 @@ struct RunSpecForm: View { private var advancedOptionsSection: some View { // The header switch shows/hides the less-common run settings (Compose import and Edit flip it on // automatically when advanced values are present). - PanelSection(header: "Advanced Options", - footer: "Less-common run settings. Compose import and Edit reveal these automatically when advanced values are present.", + PanelSection(header: AppText.string("runSpec.section.advancedOptions", defaultValue: "Advanced Options"), + footer: AppText.string("runSpec.section.advancedOptions.footer", defaultValue: "Less-common run settings. Compose import and Edit reveal these automatically when advanced values are present."), highlighted: spec.hasAdvancedOptions, enabled: $advancedExpanded) { runtimeSection securitySection fetchSection dnsSection - stringList("Add mount", $spec.mounts, prompt: "type=bind,source=/host,target=/container", - info: "Raw mount spec for advanced mount types (--mount).") + stringList(AppText.string("runSpec.addMount", defaultValue: "Add mount"), $spec.mounts, prompt: "type=bind,source=/host,target=/container", + info: AppText.string("runSpec.addMount.info", defaultValue: "Raw mount spec for advanced mount types (--mount).")) labelsSection } } @@ -545,7 +567,7 @@ struct RunSpecForm: View { } private var networkMenuTitle: String { - spec.network.trimmingCharacters(in: .whitespaces).isEmpty ? "Default" : spec.network + spec.network.trimmingCharacters(in: .whitespaces).isEmpty ? AppText.string("runSpec.default", defaultValue: "Default") : spec.network } /// A repeatable single-string list editor (capabilities, DNS servers, tmpfs, ulimits…). @@ -584,7 +606,7 @@ struct RunSpecForm: View { Button { pickHostSource(into: source) } label: { - Label("Choose File or Folder…", systemImage: "folder") + Label(AppText.string("runSpec.chooseFileOrFolder", defaultValue: "Choose File or Folder..."), systemImage: "folder") } if !app.volumes.isEmpty { Divider() @@ -600,13 +622,13 @@ struct RunSpecForm: View { Button { ui.dispatch(.createVolume) } label: { - Label("Create New Volume…", systemImage: "plus") + Label(AppText.string("runSpec.createNewVolume", defaultValue: "Create New Volume..."), systemImage: "plus") } } label: { Image(systemName: "folder.badge.gearshape") } .buttonStyle(.borderless) - .help("Choose a host path, existing volume, or create a new volume") + .help(AppText.string("runSpec.sourcePicker.help", defaultValue: "Choose a host path, existing volume, or create a new volume")) .task { await app.refreshVolumes() } } diff --git a/Sources/ContainedApp/Features/Containers/StatsTab.swift b/Sources/ContainedApp/Features/Containers/StatsTab.swift index 26389176..34c9525c 100644 --- a/Sources/ContainedApp/Features/Containers/StatsTab.swift +++ b/Sources/ContainedApp/Features/Containers/StatsTab.swift @@ -24,8 +24,10 @@ struct StatsTab: View { Group { if snapshot.state != .running { ContentUnavailableView { - Label("Not running", systemImage: "chart.xyaxis.line") - } description: { Text("Start the container to see live resource usage.") } + Label(AppText.string("stats.notRunning", defaultValue: "Not running"), systemImage: "chart.xyaxis.line") + } description: { + Text(AppText.string("stats.notRunning.description", defaultValue: "Start the container to see live resource usage.")) + } } else if let delta { ContainerTabScaffold { LazyVGrid(columns: columns, spacing: DesignTokens.Space.m) { @@ -35,7 +37,10 @@ struct StatsTab: View { tile(.netTx, delta, "arrow.up.circle") tile(.diskRead, delta, "arrow.down.doc") tile(.diskWrite, delta, "arrow.up.doc") - DesignSparklineMetricTile(label: "Processes", value: "\(delta.numProcesses)", systemImage: "gearshape.2", tint: tint) + DesignSparklineMetricTile(label: AppText.string("stats.processes", defaultValue: "Processes"), + value: "\(delta.numProcesses)", + systemImage: "gearshape.2", + tint: tint) } processList } @@ -43,7 +48,9 @@ struct StatsTab: View { // Running but no sample yet (first tick pending). LazyVStack(spacing: DesignTokens.Space.m) { ProgressView() - Text("Collecting stats…").font(.callout).foregroundStyle(.secondary) + Text(AppText.string("stats.collecting", defaultValue: "Collecting stats...")) + .font(.callout) + .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -55,7 +62,7 @@ struct StatsTab: View { private var processList: some View { if !processes.isEmpty { DesignCardInsetSection { - Label("Processes", systemImage: "list.bullet.rectangle") + Label(AppText.string("stats.processes", defaultValue: "Processes"), systemImage: "list.bullet.rectangle") .font(.caption.weight(.semibold)).foregroundStyle(.secondary) Text(processes) .font(.system(.caption, design: .monospaced)) @@ -92,7 +99,7 @@ struct StatsTab: View { let memoryLimit = GraphMetric.memoryLimitBytes(for: delta, snapshot: snapshot, normalization: normalization) - return DesignSparklineMetricTile(label: "Memory \(Format.bytes(delta.memoryUsageBytes)) / \(Format.bytes(memoryLimit))", + return DesignSparklineMetricTile(label: AppText.string("stats.memory.detail", defaultValue: "Memory \(Format.bytes(delta.memoryUsageBytes)) / \(Format.bytes(memoryLimit))"), value: GraphMetric.memory.caption(from: delta, snapshot: snapshot, normalization: normalization), diff --git a/Sources/ContainedApp/Features/Containers/TerminalTab.swift b/Sources/ContainedApp/Features/Containers/TerminalTab.swift index 27aac11f..564fe0ea 100644 --- a/Sources/ContainedApp/Features/Containers/TerminalTab.swift +++ b/Sources/ContainedApp/Features/Containers/TerminalTab.swift @@ -62,7 +62,10 @@ struct TerminalTab: View { .labelsHidden() .frame(width: DesignTokens.FormWidth.shellPicker) .onChange(of: shell) { _, _ in reconnect() } - Text("exec into \(snapshot.id)").font(.caption).foregroundStyle(.secondary).lineLimit(1) + Text(AppText.string("terminal.execInto", defaultValue: "exec into \(snapshot.id)")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) Spacer() DesignActionGroup(DesignAction(systemName: "arrow.clockwise", help: AppText.reconnect) { reconnect() }) } @@ -71,10 +74,12 @@ struct TerminalTab: View { private func endedOverlay(code: Int32?) -> some View { DesignCardInsetSection(alignment: .center, padding: DesignTokens.Space.xl) { Image(systemName: "bolt.horizontal.circle").font(.largeTitle).foregroundStyle(.secondary) - Text(code == nil || code == 0 ? "Session ended" : "Session ended (exit \(code!))") + Text(code == nil || code == 0 + ? AppText.string("terminal.sessionEnded", defaultValue: "Session ended") + : AppText.string("terminal.sessionEndedWithExit", defaultValue: "Session ended (exit \(code!))")) .font(.headline) DesignActionGroup(DesignAction(systemName: "arrow.clockwise", - title: "Reconnect", + title: AppText.reconnect, help: AppText.reconnectTerminal, action: reconnect)) } diff --git a/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift b/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift index a21ee8b0..36219af2 100644 --- a/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift +++ b/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift @@ -43,9 +43,9 @@ struct BuildWorkspaceView: View { .padding(DesignTokens.Space.s) } else { ContentUnavailableView { - Label("Build an image", systemImage: "hammer") + Label(AppText.string("build.empty.title", defaultValue: "Build an image"), systemImage: "hammer") } description: { - Text("Choose a context folder and a tag, then Build. Output streams here.") + Text(AppText.string("build.empty.description", defaultValue: "Choose a context folder and a tag, then Build. Output streams here.")) } } } @@ -65,28 +65,28 @@ struct BuildWorkspaceView: View { } private var sourceSection: some View { - PanelSection(header: "Source") { - PanelField(label: "Context", - info: "The build context: the folder sent to the builder, usually your project root.") { + PanelSection(header: AppText.string("build.source", defaultValue: "Source")) { + PanelField(label: AppText.string("build.context", defaultValue: "Context"), + info: AppText.string("build.context.info", defaultValue: "The build context: the folder sent to the builder, usually your project root.")) { HStack { - Text(contextDir?.path ?? "Choose a folder...") + Text(contextDir?.path ?? AppText.string("build.chooseFolderPlaceholder", defaultValue: "Choose a folder...")) .foregroundStyle(contextDir == nil ? .secondary : .primary) .lineLimit(1) .truncationMode(.middle) Spacer() DesignActionGroup(DesignAction(systemName: "folder", - title: "Choose", + title: AppText.choose, help: AppText.chooseContextFolder, action: chooseFolder)) } } - PanelField(label: "Dockerfile", - info: "Path to the Dockerfile (-f). Relative to the context if not absolute.") { + PanelField(label: AppText.string("build.dockerfile", defaultValue: "Dockerfile"), + info: AppText.string("build.dockerfile.info", defaultValue: "Path to the Dockerfile (-f). Relative to the context if not absolute.")) { TextField("", text: $dockerfile, prompt: Text("optional, defaults to /Dockerfile")) .textFieldStyle(.roundedBorder) } - PanelField(label: "Tag", - info: "The resulting image reference (-t).") { + PanelField(label: AppText.string("build.tag", defaultValue: "Tag"), + info: AppText.string("build.tag.info", defaultValue: "The resulting image reference (-t).")) { TextField("", text: $tag, prompt: Text("name for the built image, e.g. myapp:latest")) .textFieldStyle(.roundedBorder) } @@ -94,16 +94,16 @@ struct BuildWorkspaceView: View { } private var optionsSection: some View { - PanelSection(header: "Options") { - PanelField(label: "Platform") { + PanelSection(header: AppText.string("build.options", defaultValue: "Options")) { + PanelField(label: AppText.string("build.platform", defaultValue: "Platform")) { TextField("", text: $platform, prompt: Text("optional, e.g. linux/arm64")) .textFieldStyle(.roundedBorder) } - PanelToggleRow(title: "No cache", - info: "Build every layer from scratch (--no-cache).", + PanelToggleRow(title: AppText.string("build.noCache", defaultValue: "No cache"), + info: AppText.string("build.noCache.info", defaultValue: "Build every layer from scratch (--no-cache)."), isOn: $noCache) ForEach($buildArgs) { $arg in - PanelField(label: "Build arg") { + PanelField(label: AppText.string("build.arg", defaultValue: "Build arg")) { HStack { TextField("KEY", text: $arg.key) .textFieldStyle(.roundedBorder) @@ -117,10 +117,12 @@ struct BuildWorkspaceView: View { } } } - PanelRow(title: "Build arguments", - subtitle: buildArgs.isEmpty ? "No build-time variables added." : "\(buildArgs.count) argument(s)") { + PanelRow(title: AppText.string("build.arguments", defaultValue: "Build arguments"), + subtitle: buildArgs.isEmpty + ? AppText.string("build.arguments.empty", defaultValue: "No build-time variables added.") + : AppText.string("build.arguments.count", defaultValue: "\(buildArgs.count) argument(s)")) { DesignActionGroup(DesignAction(systemName: "plus.circle", - title: "Add build arg", + title: AppText.string("build.addBuildArg.short", defaultValue: "Add build arg"), help: AppText.addBuildArgument) { buildArgs.append(KeyValue()) }) @@ -137,14 +139,14 @@ struct BuildWorkspaceView: View { .frame(maxWidth: .infinity) if building { DesignActionGroup(DesignAction(systemName: "xmark", - title: "Cancel", + title: AppText.cancel, help: AppText.cancelBuild, role: .destructive) { building = false }) } else { DesignActionGroup(DesignAction(systemName: "hammer.fill", - title: "Build", + title: AppText.string("build.build", defaultValue: "Build"), help: AppText.buildImage, isEnabled: canBuild, action: startBuild)) diff --git a/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift index 6253fc6f..6263cd95 100644 --- a/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift +++ b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift @@ -16,7 +16,9 @@ struct RegistryLoginSheet: View { var body: some View { VStack(spacing: 0) { - SheetHeader(title: "Registry login", cancelHelp: AppText.close, onCancel: { dismiss() }) { + SheetHeader(title: AppText.string("registry.login.title", defaultValue: "Registry login"), + cancelHelp: AppText.close, + onCancel: { dismiss() }) { if busy { ProgressView().controlSize(.small).frame(width: DesignTokens.IconSize.control, height: DesignTokens.IconSize.control) } else { @@ -30,18 +32,18 @@ struct RegistryLoginSheet: View { } } VStack(spacing: DesignTokens.Space.l) { - PanelSection(header: "Credentials") { - PanelField(label: "Server") { + PanelSection(header: AppText.string("registry.credentials", defaultValue: "Credentials")) { + PanelField(label: AppText.string("registry.server", defaultValue: "Server")) { TextField("", text: $server, prompt: Text("e.g. ghcr.io, docker.io")) .textContentType(.URL) .textFieldStyle(.roundedBorder) } - PanelField(label: "Username") { + PanelField(label: AppText.string("registry.username", defaultValue: "Username")) { TextField("", text: $username, prompt: Text("registry username")) .textContentType(.username) .textFieldStyle(.roundedBorder) } - PanelField(label: "Password") { + PanelField(label: AppText.string("registry.password", defaultValue: "Password")) { SecureField("", text: $password, prompt: Text("password or access token")) .textFieldStyle(.roundedBorder) } diff --git a/Sources/ContainedApp/Features/System/SystemView.swift b/Sources/ContainedApp/Features/System/SystemView.swift index 952f4bbc..88d35c5d 100644 --- a/Sources/ContainedApp/Features/System/SystemView.swift +++ b/Sources/ContainedApp/Features/System/SystemView.swift @@ -38,9 +38,17 @@ struct SystemContent: View { var subtitle: String { switch self { - case .engine: return "Container engine" - case .automation: return "Background work" - case .volumes: return "Named, temp, and path mounts" + case .engine: return AppText.string("system.page.engine.subtitle", defaultValue: "Container engine") + case .automation: return AppText.string("system.page.automation.subtitle", defaultValue: "Background work") + case .volumes: return AppText.string("system.page.volumes.subtitle", defaultValue: "Named, temp, and path mounts") + } + } + + var title: String { + switch self { + case .engine: return AppText.string("system.page.engine", defaultValue: "Engine") + case .automation: return AppText.string("system.page.automation", defaultValue: "Automation") + case .volumes: return AppText.sectionVolumes } } } @@ -80,10 +88,10 @@ struct SystemContent: View { var id: String { rawValue } var title: String { switch self { - case .containers: return "Remove all stopped containers?" - case .images: return "Remove unused images?" - case .volumes: return "Remove unused volumes?" - case .networks: return "Remove unused networks?" + case .containers: return AppText.string("cleanup.removeStoppedContainers.title", defaultValue: "Remove all stopped containers?") + case .images: return AppText.string("cleanup.removeUnusedImages.title", defaultValue: "Remove unused images?") + case .volumes: return AppText.string("cleanup.removeUnusedVolumes.title", defaultValue: "Remove unused volumes?") + case .networks: return AppText.string("cleanup.removeUnusedNetworks.title", defaultValue: "Remove unused networks?") } } } @@ -130,7 +138,7 @@ struct SystemContent: View { private var header: some View { PanelHeader(symbol: "gearshape.2", - title: "System", + title: AppText.sectionSystem, subtitle: activePage.subtitle) { HStack(spacing: DesignTokens.Toolbar.groupSpacing) { engineControls @@ -151,7 +159,7 @@ struct SystemContent: View { private var pageActions: [DesignAction] { SystemPage.allCases.map { item in DesignAction(systemName: item.systemImage, - help: item.rawValue, + help: item.title, tint: activePage == item ? .accentColor : nil) { setPage(item) } @@ -189,14 +197,14 @@ struct SystemContent: View { private var storageMenu: some View { Menu { Button { reclaimingAll = true } label: { - Label("Reclaim all", systemImage: "trash") + Label(AppText.string("cleanup.reclaimAll", defaultValue: "Reclaim all"), systemImage: "trash") } .disabled((app.diskUsage?.totalReclaimableBytes ?? 0) == 0) Divider() - Button { pruneTarget = .containers } label: { Label("Stopped containers", systemImage: "shippingbox") } - Button { pruneTarget = .images } label: { Label("Unused images", systemImage: "square.stack.3d.up") } - Button { pruneTarget = .volumes } label: { Label("Unused volumes", systemImage: "externaldrive") } - Button { pruneTarget = .networks } label: { Label("Unused networks", systemImage: "network") } + Button { pruneTarget = .containers } label: { Label(AppText.string("cleanup.stoppedContainers", defaultValue: "Stopped containers"), systemImage: "shippingbox") } + Button { pruneTarget = .images } label: { Label(AppText.string("cleanup.unusedImages", defaultValue: "Unused images"), systemImage: "square.stack.3d.up") } + Button { pruneTarget = .volumes } label: { Label(AppText.string("cleanup.unusedVolumes", defaultValue: "Unused volumes"), systemImage: "externaldrive") } + Button { pruneTarget = .networks } label: { Label(AppText.string("cleanup.unusedNetworks", defaultValue: "Unused networks"), systemImage: "network") } } label: { DesignMenuActionLabel(systemName: "trash", help: AppText.storageCleanup, @@ -217,18 +225,18 @@ struct SystemContent: View { private var volumesCard: some View { card { HStack { - Text("Volumes").font(.headline) + Text(AppText.sectionVolumes).font(.headline) DesignBadgeText(text: "\(volumeInventory.count)") Spacer() DesignActionGroup(DesignAction(systemName: "plus", - title: "New", + title: AppText.string("common.new", defaultValue: "New"), help: AppText.newVolume) { onClose() ui.dispatch(.createVolume) }) } if volumeInventory.isEmpty { - Text("No named volumes or container mounts found.") + Text(AppText.string("volume.inventory.empty", defaultValue: "No named volumes or container mounts found.")) .font(.callout).foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, DesignTokens.Space.xs) @@ -273,18 +281,18 @@ struct SystemContent: View { @ViewBuilder private func volumeMenu(_ entry: VolumeInventoryEntry) -> some View { - Button { copyToPasteboard(entry.source ?? entry.title) } label: { Label("Copy source", systemImage: "doc.on.doc") } + Button { copyToPasteboard(entry.source ?? entry.title) } label: { Label(AppText.string("volume.copySource", defaultValue: "Copy source"), systemImage: "doc.on.doc") } if let destination = entry.destination { - Button { copyToPasteboard(destination) } label: { Label("Copy destination", systemImage: "arrow.down.doc") } + Button { copyToPasteboard(destination) } label: { Label(AppText.string("volume.copyDestination", defaultValue: "Copy destination"), systemImage: "arrow.down.doc") } } else { - Button("Copy destination", systemImage: "arrow.down.doc") {} + Button(AppText.string("volume.copyDestination", defaultValue: "Copy destination"), systemImage: "arrow.down.doc") {} .disabled(true) } Divider() if let volume = entry.resource { - Button(role: .destructive) { deletingVolume = volume } label: { Label("Delete", systemImage: "trash") } + Button(role: .destructive) { deletingVolume = volume } label: { Label(AppText.delete, systemImage: "trash") } } else { - Button("Delete", systemImage: "trash", role: .destructive) {} + Button(AppText.delete, systemImage: "trash", role: .destructive) {} .disabled(true) } } @@ -301,19 +309,19 @@ struct SystemContent: View { private var automationCard: some View { card { - Text("Automation").font(.headline) + Text(AppText.string("system.page.automation", defaultValue: "Automation")).font(.headline) TimelineView(.periodic(from: .now, by: 1)) { context in automationRow(icon: "arrow.triangle.2.circlepath", - title: "Image update check", + title: AppText.string("automation.imageUpdateCheck", defaultValue: "Image update check"), detail: app.settings.imageUpdateChecksEnabled ? "\(backgroundTaskDetail(now: context.date)) · \(app.imageUpdateIntervalDescription)" - : "Paused", + : AppText.string("status.paused", defaultValue: "Paused"), isOn: settingBinding(\.imageUpdateChecksEnabled)) { if app.settings.imageUpdateChecksEnabled { Text(countdown(to: app.imageUpdateNextRunDate, now: context.date)) .font(.system(.caption, design: .monospaced).weight(.semibold)).monospacedDigit() DesignActionGroup(DesignAction(systemName: "arrow.triangle.2.circlepath", - title: "Run now", + title: AppText.string("common.runNow", defaultValue: "Run now"), help: AppText.runImageUpdateCheckNow) { Task { await app.runImageUpdateSweepNow() } }) @@ -322,13 +330,13 @@ struct SystemContent: View { } Divider() automationRow(icon: "arrow.down.app", - title: "App update check", + title: AppText.string("automation.appUpdateCheck", defaultValue: "App update check"), detail: app.updater.canCheckForUpdates - ? "Sparkle · \(app.settings.updateChannel.rawValue.capitalized) channel" - : "Unavailable in this build", + ? AppText.string("automation.appUpdateCheck.detail", defaultValue: "Sparkle · \(app.settings.updateChannel.rawValue.capitalized) channel") + : AppText.string("status.unavailableInBuild", defaultValue: "Unavailable in this build"), isOn: appUpdateBinding) { DesignActionGroup(DesignAction(systemName: "arrow.down.app", - title: "Check now", + title: AppText.string("common.checkNow", defaultValue: "Check now"), help: AppText.checkForUpdatesNow, isEnabled: app.updater.canCheckForUpdates && app.settings.appUpdateChecksEnabled) { @@ -337,17 +345,17 @@ struct SystemContent: View { } Divider() automationRow(icon: "arrow.clockwise.circle", - title: "Auto-restart crashed containers", + title: AppText.string("automation.autoRestart", defaultValue: "Auto-restart crashed containers"), detail: app.settings.autoRestartEnabled - ? "Restarts containers that exit unexpectedly" - : "Off", + ? AppText.string("automation.autoRestart.detail", defaultValue: "Restarts containers that exit unexpectedly") + : AppText.string("status.off", defaultValue: "Off"), isOn: settingBinding(\.autoRestartEnabled)) { EmptyView() } Divider() HStack(spacing: DesignTokens.Space.s) { Image(systemName: "dot.radiowaves.left.and.right") .foregroundStyle(.secondary) .frame(width: DesignTokens.IconSize.rowIconColumn) - Text("Refresh loop").font(.callout) + Text(AppText.string("automation.refreshLoop", defaultValue: "Refresh loop")).font(.callout) Spacer() Text(app.coordinator.isActive ? "Active" : "Paused") .font(.callout) @@ -440,7 +448,7 @@ struct SystemContent: View { HStack(spacing: DesignTokens.Space.s) { DesignStatusDot(color: app.serviceHealthy ? .green : .orange, size: DesignTokens.IconSize.serviceDot) - Text("Container engine").font(.headline) + Text(AppText.string("system.containerEngine", defaultValue: "Container engine")).font(.headline) DesignStatusBadge(text: app.serviceLabel, tint: app.serviceHealthy ? .green : .orange) Spacer(minLength: 0) @@ -450,9 +458,12 @@ struct SystemContent: View { } } HStack(spacing: DesignTokens.Space.s) { - DesignMetricTile(label: "Containers", value: "\(app.containers.running.count)", caption: "running") - DesignMetricTile(label: "Images", value: "\(app.images.count)") - DesignMetricTile(label: "Disk used", value: app.diskUsage.map { Format.bytes($0.totalSizeInBytes) } ?? "—") + DesignMetricTile(label: AppText.sectionContainers, + value: "\(app.containers.running.count)", + caption: AppText.string("status.running.lowercase", defaultValue: "running")) + DesignMetricTile(label: AppText.sectionImages, value: "\(app.images.count)") + DesignMetricTile(label: AppText.string("system.diskUsed", defaultValue: "Disk used"), + value: app.diskUsage.map { Format.bytes($0.totalSizeInBytes) } ?? "—") } if working { ProgressView().controlSize(.small) } } diff --git a/Sources/ContainedApp/History/ActivityView.swift b/Sources/ContainedApp/History/ActivityView.swift index e7333105..164f8f9e 100644 --- a/Sources/ContainedApp/History/ActivityView.swift +++ b/Sources/ContainedApp/History/ActivityView.swift @@ -41,9 +41,11 @@ struct ActivityContent: View { private var unreadCount: Int { events.lazy.filter { !$0.isRead }.count } private var subtitle: String { - let base = "\(filtered.count) event\(filtered.count == 1 ? "" : "s")" + let base = AppText.string("activity.subtitle.events", defaultValue: "\(filtered.count) event\(filtered.count == 1 ? "" : "s")") let scoped = ui.activityFilter == nil ? base : "\(base) · \(ui.activityFilter!.rawValue.capitalized)" - return unreadCount > 0 ? "\(scoped) · \(unreadCount) unread" : scoped + return unreadCount > 0 + ? AppText.string("activity.subtitle.unread", defaultValue: "\(scoped) · \(unreadCount) unread") + : scoped } /// Event kinds that actually appear in the current events — the only kinds worth offering as a @@ -60,8 +62,8 @@ struct ActivityContent: View { private var filterMenu: some View { @Bindable var ui = ui return Menu { - Picker("Filter", selection: $ui.activityFilter) { - Label("All events", systemImage: "tray.full").tag(EventKind?.none) + Picker(AppText.string("activity.filter", defaultValue: "Filter"), selection: $ui.activityFilter) { + Label(AppText.string("activity.filter.allEvents", defaultValue: "All events"), systemImage: "tray.full").tag(EventKind?.none) if !presentKinds.isEmpty { Divider() } ForEach(presentKinds, id: \.self) { kind in Label(kind.rawValue.capitalized, systemImage: kind.symbol).tag(EventKind?.some(kind)) @@ -71,8 +73,9 @@ struct ActivityContent: View { } label: { DesignMenuActionLabel(systemName: ui.activityFilter == nil ? "line.3.horizontal.decrease" : "line.3.horizontal.decrease.circle.fill", - help: ui.activityFilter == nil ? "Filter" - : "Filter: \(ui.activityFilter!.rawValue.capitalized)") + help: ui.activityFilter == nil + ? AppText.string("activity.filter", defaultValue: "Filter") + : AppText.string("activity.filter.current", defaultValue: "Filter: \(ui.activityFilter!.rawValue.capitalized)")) } .buttonStyle(.plain) .disabled(presentKinds.isEmpty) @@ -87,7 +90,7 @@ struct ActivityContent: View { if showsHeader { VStack(spacing: 0) { PanelHeader(symbol: "bell", - title: "Activity", + title: AppText.sectionActivity, subtitle: subtitle) { DesignActionCluster { filterMenu @@ -99,8 +102,9 @@ struct ActivityContent: View { } } content: { if filtered.isEmpty { - ContentUnavailableView("No activity", systemImage: "bell", - description: Text("Events from container lifecycle, the watchdog, and healthchecks land here.")) + ContentUnavailableView(AppText.string("activity.empty", defaultValue: "No activity"), + systemImage: "bell", + description: Text(AppText.string("activity.empty.description", defaultValue: "Events from container lifecycle, the watchdog, and healthchecks land here."))) .padding(.vertical, DesignTokens.Space.xl) } else { LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { @@ -118,13 +122,15 @@ struct ActivityContent: View { private var activityHeaderActions: [DesignAction] { var actions = [ DesignAction(systemName: "checkmark.circle", - help: ui.activityFilter == nil ? "Mark all as read" - : "Mark \(ui.activityFilter!.rawValue.capitalized) as read", + help: ui.activityFilter == nil + ? AppText.string("activity.markAllRead.help", defaultValue: "Mark all as read") + : AppText.string("activity.markFilteredRead.help", defaultValue: "Mark \(ui.activityFilter!.rawValue.capitalized) as read"), isEnabled: filteredUnreadCount > 0, action: markFilteredRead), DesignAction(systemName: "trash", - help: ui.activityFilter == nil ? "Clear activity" - : "Clear \(ui.activityFilter!.rawValue.capitalized) events", + help: ui.activityFilter == nil + ? AppText.clearActivity + : AppText.string("activity.clearFiltered.help", defaultValue: "Clear \(ui.activityFilter!.rawValue.capitalized) events"), role: .destructive, isEnabled: !filtered.isEmpty, action: clearFiltered) diff --git a/Sources/ContainedApp/Migration/DowngradeDecisionView.swift b/Sources/ContainedApp/Migration/DowngradeDecisionView.swift index edf41767..1e4b2c40 100644 --- a/Sources/ContainedApp/Migration/DowngradeDecisionView.swift +++ b/Sources/ContainedApp/Migration/DowngradeDecisionView.swift @@ -9,20 +9,20 @@ struct DowngradeDecisionView: View { var body: some View { LazyVStack(alignment: .leading, spacing: DesignTokens.Space.l) { - SheetHeader(title: "This data was created by a newer version", - subtitle: "Stored schema \(schemaVersion), this app supports \(StateMigrator.currentSchemaVersion).", + SheetHeader(title: AppText.string("downgrade.title", defaultValue: "This data was created by a newer version"), + subtitle: AppText.string("downgrade.subtitle", defaultValue: "Stored schema \(schemaVersion), this app supports \(StateMigrator.currentSchemaVersion)."), cancelHelp: AppText.quit, onCancel: onQuit) - Text("You can export a backup before resetting incompatible local data, try to keep what this build can still read, or quit and install the newer build again.") + Text(AppText.string("downgrade.description", defaultValue: "You can export a backup before resetting incompatible local data, try to keep what this build can still read, or quit and install the newer build again.")) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { - Button("Export Backup, Then Reset") { onExportAndReset() } + Button(AppText.string("downgrade.exportBackupThenReset", defaultValue: "Export Backup, Then Reset")) { onExportAndReset() } .buttonStyle(.borderedProminent) - Button("Try to Keep Readable Data") { onKeep() } - Button("Quit Contained", role: .cancel) { onQuit() } + Button(AppText.string("downgrade.keepReadableData", defaultValue: "Try to Keep Readable Data")) { onKeep() } + Button(AppText.string("downgrade.quitContained", defaultValue: "Quit Contained"), role: .cancel) { onQuit() } } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Sources/ContainedApp/Navigation/ClassicShell.swift b/Sources/ContainedApp/Navigation/ClassicShell.swift index 9195c32d..6b55656f 100644 --- a/Sources/ContainedApp/Navigation/ClassicShell.swift +++ b/Sources/ContainedApp/Navigation/ClassicShell.swift @@ -168,8 +168,8 @@ private struct ClassicSectionPage: View { private struct BuildPage: View { var body: some View { PageScaffold(symbol: "hammer", - title: "Build", - subtitle: "From a Dockerfile + build context") { + title: AppText.sectionBuild, + subtitle: AppText.string("build.subtitle.context", defaultValue: "From a Dockerfile + build context")) { EmptyView() } content: { BuildWorkspaceView() @@ -222,18 +222,18 @@ private struct NetworksPage: View { var body: some View { PageScaffold(symbol: "network", - title: "Networks", - subtitle: "\(sortedNetworks.count) network\(sortedNetworks.count == 1 ? "" : "s")") { + title: AppText.sectionNetworks, + subtitle: AppText.string("network.count", defaultValue: "\(sortedNetworks.count) network\(sortedNetworks.count == 1 ? "" : "s")")) { DesignActionGroup(DesignAction(systemName: "plus", - title: "New", + title: AppText.string("common.new", defaultValue: "New"), help: AppText.string("network.newNetwork.lowercase", defaultValue: "New network")) { ui.dispatch(.createNetwork) }) } content: { if sortedNetworks.isEmpty { - ContentUnavailableView("No networks", + ContentUnavailableView(AppText.string("network.empty", defaultValue: "No networks"), systemImage: "network", - description: Text("Create or refresh container networks to see them here.")) + description: Text(AppText.string("network.empty.description", defaultValue: "Create or refresh container networks to see them here."))) .frame(maxWidth: .infinity, minHeight: 280) } else { LazyVStack(spacing: DesignTokens.Space.s) { @@ -252,12 +252,12 @@ private struct NetworksPage: View { } } .task { await app.refreshNetworks() } - .confirmationDialog("Delete network \(deletingNetwork?.name ?? "")?", + .confirmationDialog(AppText.string("network.delete.confirmation", defaultValue: "Delete network \(deletingNetwork?.name ?? "")?"), isPresented: deleteNetworkBinding, presenting: deletingNetwork) { network in - Button("Delete", role: .destructive) { Task { await deleteNetwork(network) } } + Button(AppText.delete, role: .destructive) { Task { await deleteNetwork(network) } } } message: { _ in - Text("This removes the network. Containers must be detached first.") + Text(AppText.string("network.delete.message", defaultValue: "This removes the network. Containers must be detached first.")) } } diff --git a/Sources/ContainedApp/Navigation/RootView.swift b/Sources/ContainedApp/Navigation/RootView.swift index e3f53f83..53d67dbf 100644 --- a/Sources/ContainedApp/Navigation/RootView.swift +++ b/Sources/ContainedApp/Navigation/RootView.swift @@ -36,7 +36,7 @@ struct RootView: View { onQuit: { NSApplication.shared.terminate(nil) }) } .sheet(isPresented: whatsNewBinding) { - ReleaseNotesView(title: "What’s New", + ReleaseNotesView(title: AppText.string("releaseNotes.whatsNew", defaultValue: "What's New"), html: app.updater.currentReleaseNotesHTML, onClose: { app.updater.markWhatsNewSeen() }) } diff --git a/Sources/ContainedApp/Navigation/ToolbarFilterOptions.swift b/Sources/ContainedApp/Navigation/ToolbarFilterOptions.swift index 74c7a416..4ecc904c 100644 --- a/Sources/ContainedApp/Navigation/ToolbarFilterOptions.swift +++ b/Sources/ContainedApp/Navigation/ToolbarFilterOptions.swift @@ -3,9 +3,9 @@ enum ImageGrouping: String, CaseIterable, Identifiable { var id: String { rawValue } var title: String { switch self { - case .none: return "None" - case .registry: return "Registry" - case .status: return "Status" + case .none: return AppText.string("filter.none", defaultValue: "None") + case .registry: return AppText.string("filter.registry", defaultValue: "Registry") + case .status: return AppText.string("filter.status", defaultValue: "Status") } } var symbol: String { @@ -22,9 +22,9 @@ enum ImageSort: String, CaseIterable, Identifiable { var id: String { rawValue } var title: String { switch self { - case .status: return "Status" - case .name: return "Name" - case .tags: return "Tags" + case .status: return AppText.string("filter.status", defaultValue: "Status") + case .name: return AppText.string("filter.name", defaultValue: "Name") + case .tags: return AppText.string("image.tags", defaultValue: "Tags") } } var symbol: String { @@ -41,9 +41,9 @@ enum ImageFilter: String, CaseIterable, Identifiable { var id: String { rawValue } var title: String { switch self { - case .all: return "All images" - case .updates: return "Updates only" - case .errors: return "Errors only" + case .all: return AppText.string("filter.allImages", defaultValue: "All images") + case .updates: return AppText.string("filter.updatesOnly", defaultValue: "Updates only") + case .errors: return AppText.string("filter.errorsOnly", defaultValue: "Errors only") } } var symbol: String { @@ -58,7 +58,7 @@ enum ImageFilter: String, CaseIterable, Identifiable { enum TemplateGrouping: String, CaseIterable, Identifiable { case none, image var id: String { rawValue } - var title: String { self == .none ? "None" : "Image" } + var title: String { self == .none ? AppText.string("filter.none", defaultValue: "None") : AppText.string("filter.image", defaultValue: "Image") } var symbol: String { self == .none ? "bookmark" : "shippingbox" } } @@ -67,9 +67,9 @@ enum TemplateSort: String, CaseIterable, Identifiable { var id: String { rawValue } var title: String { switch self { - case .newest: return "Newest" - case .name: return "Name" - case .image: return "Image" + case .newest: return AppText.string("filter.newest", defaultValue: "Newest") + case .name: return AppText.string("filter.name", defaultValue: "Name") + case .image: return AppText.string("filter.image", defaultValue: "Image") } } var symbol: String { @@ -86,9 +86,9 @@ enum NetworkGrouping: String, CaseIterable, Identifiable { var id: String { rawValue } var title: String { switch self { - case .none: return "None" - case .kind: return "Kind" - case .mode: return "Mode" + case .none: return AppText.string("filter.none", defaultValue: "None") + case .kind: return AppText.string("filter.kind", defaultValue: "Kind") + case .mode: return AppText.string("filter.mode", defaultValue: "Mode") } } var symbol: String { @@ -105,9 +105,9 @@ enum NetworkSort: String, CaseIterable, Identifiable { var id: String { rawValue } var title: String { switch self { - case .name: return "Name" - case .mode: return "Mode" - case .plugin: return "Plugin" + case .name: return AppText.string("filter.name", defaultValue: "Name") + case .mode: return AppText.string("filter.mode", defaultValue: "Mode") + case .plugin: return AppText.string("filter.plugin", defaultValue: "Plugin") } } var symbol: String { @@ -124,9 +124,9 @@ enum NetworkFilter: String, CaseIterable, Identifiable { var id: String { rawValue } var title: String { switch self { - case .all: return "All networks" - case .custom: return "Custom only" - case .builtin: return "Built-in only" + case .all: return AppText.string("filter.allNetworks", defaultValue: "All networks") + case .custom: return AppText.string("filter.customOnly", defaultValue: "Custom only") + case .builtin: return AppText.string("filter.builtInOnly", defaultValue: "Built-in only") } } var symbol: String { diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift index f5cee298..0a58b7af 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift @@ -24,13 +24,15 @@ struct PaletteResultCard: View { case .volume(let volume): designCard(symbol: "externaldrive", title: volume.name, - subtitle: "Volume", - footer: "Use in a new run") + subtitle: AppText.string("palette.volume", defaultValue: "Volume"), + footer: AppText.string("palette.volume.footer", defaultValue: "Use in a new run")) case .network(let network): designCard(symbol: "network", title: network.name, - subtitle: network.isBuiltin ? "Built-in network" : "Network", - footer: "Run a container on this network") + subtitle: network.isBuiltin + ? AppText.string("palette.network.builtIn", defaultValue: "Built-in network") + : AppText.string("palette.network", defaultValue: "Network"), + footer: AppText.string("palette.network.footer", defaultValue: "Run a container on this network")) case .tint(let tint): tintCard(tint) } diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift index bc439aff..975c85db 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift @@ -147,10 +147,11 @@ struct ToolbarImageGroupCard: View { let image = group.images.first { $0.reference == reference } ?? primaryImage(group) let variant = image?.variants.first(where: \.isRunnable) ?? image?.variants.first let history = variant?.config?.history ?? [] - return imagePageBody(title: "History", subtitle: Format.shortImage(reference)) { + return imagePageBody(title: AppText.string("image.history", defaultValue: "History"), subtitle: Format.shortImage(reference)) { if history.isEmpty { - ContentUnavailableView("No history", systemImage: "clock", - description: Text("This image records no layer history.")) + ContentUnavailableView(AppText.string("image.history.empty", defaultValue: "No history"), + systemImage: "clock", + description: Text(AppText.string("image.history.empty.description", defaultValue: "This image records no layer history."))) .frame(maxWidth: .infinity, minHeight: 220) } else { DesignCardInsetSection { @@ -176,12 +177,12 @@ struct ToolbarImageGroupCard: View { } private func tagPage(_ source: String) -> some View { - imagePageBody(title: "Add tag", subtitle: Format.shortImage(source)) { + imagePageBody(title: AppText.addTag, subtitle: Format.shortImage(source)) { DesignCardInsetSection { - PanelField(label: "Source") { + PanelField(label: AppText.string("image.tag.source", defaultValue: "Source")) { Text(Format.shortImage(source)).foregroundStyle(.secondary) } - PanelField(label: "New reference") { + PanelField(label: AppText.string("image.tag.newReference", defaultValue: "New reference")) { TextField("", text: $tagTarget, prompt: Text("e.g. ghcr.io/me/app:v1")) .textFieldStyle(.roundedBorder) .onSubmit { submitTag(source: source) } @@ -201,7 +202,7 @@ struct ToolbarImageGroupCard: View { } private func pushPage(_ reference: String) -> some View { - imagePageBody(title: "Push image", subtitle: Format.shortImage(reference)) { + imagePageBody(title: AppText.string("image.pushImage", defaultValue: "Push image"), subtitle: Format.shortImage(reference)) { if pushStartedReference == reference, let client = app.client { StreamConsole(stream: { client.streamPush(reference) }, workingLabel: AppText.working, @@ -273,13 +274,13 @@ struct ToolbarImageGroupCard: View { onClose() ui.openSettings(to: .registries) } label: { - Label("Open Registries", systemImage: "key") + Label(AppText.string("registry.openRegistries", defaultValue: "Open Registries"), systemImage: "key") } case .tag: Button { withAnimation(spring) { page = .tag(reference) } } label: { - Label("Add Tag", systemImage: "tag") + Label(AppText.addTag, systemImage: "tag") } case .none: EmptyView() @@ -290,8 +291,8 @@ struct ToolbarImageGroupCard: View { private func pushState(for reference: String) -> PushState { guard app.client != nil else { - return PushState(title: "Runtime unavailable", - message: "Start the container service before pushing images.", + return PushState(title: AppText.string("image.push.runtimeUnavailable", defaultValue: "Runtime unavailable"), + message: AppText.string("image.push.runtimeUnavailable.message", defaultValue: "Start the container service before pushing images."), detail: nil, symbol: "exclamationmark.triangle", tint: .orange, @@ -302,9 +303,9 @@ struct ToolbarImageGroupCard: View { let registry = displayRegistry(parsed.registry) guard !parsed.isDigestReference else { - return PushState(title: "Tag required", - message: "Digest references cannot be pushed directly.", - detail: "Add a writable tag such as ghcr.io/me/app:v1, then push that tag.", + return PushState(title: AppText.string("image.push.tagRequired", defaultValue: "Tag required"), + message: AppText.string("image.push.tagRequired.message", defaultValue: "Digest references cannot be pushed directly."), + detail: AppText.string("image.push.tagRequired.detail", defaultValue: "Add a writable tag such as ghcr.io/me/app:v1, then push that tag."), symbol: "tag", tint: .orange, action: .tag) @@ -312,26 +313,26 @@ struct ToolbarImageGroupCard: View { if normalizedRegistryHost(parsed.registry) == "docker.io", parsed.repository.hasPrefix("library/") { - return PushState(title: "Writable namespace required", - message: "This tag points at Docker Hub's library namespace.", - detail: "Add a tag under a namespace you control before pushing.", + return PushState(title: AppText.string("image.push.namespaceRequired", defaultValue: "Writable namespace required"), + message: AppText.string("image.push.namespaceRequired.message", defaultValue: "This tag points at Docker Hub's library namespace."), + detail: AppText.string("image.push.namespaceRequired.detail", defaultValue: "Add a tag under a namespace you control before pushing."), symbol: "tag", tint: .orange, action: .tag) } guard let login = matchingRegistryLogin(for: parsed.registry) else { - return PushState(title: "Registry sign-in required", - message: "Sign in to \(registry) before pushing this image.", - detail: "Contained checks for a saved container registry login before starting a push.", + return PushState(title: AppText.string("image.push.signInRequired", defaultValue: "Registry sign-in required"), + message: AppText.string("image.push.signInRequired.message", defaultValue: "Sign in to \(registry) before pushing this image."), + detail: AppText.string("image.push.signInRequired.detail", defaultValue: "Contained checks for a saved container registry login before starting a push."), symbol: "key", tint: .orange, action: .openRegistries) } - return PushState(title: "Ready to push", - message: "Signed in to \(registry)\(login.username.map { " as \($0)" } ?? "").", - detail: "The registry will still enforce write permission for \(parsed.repository).", + return PushState(title: AppText.string("image.push.ready", defaultValue: "Ready to push"), + message: AppText.string("image.push.ready.message", defaultValue: "Signed in to \(registry)\(login.username.map { " as \($0)" } ?? "")."), + detail: AppText.string("image.push.ready.detail", defaultValue: "The registry will still enforce write permission for \(parsed.repository)."), symbol: "checkmark.circle.fill", tint: .green, action: .push) @@ -361,16 +362,16 @@ struct ToolbarImageGroupCard: View { let reference = primaryImage(group)?.reference ?? group.primaryReference return [ DesignCardPageControlItem(id: .tags, - title: "Tags", + title: AppText.string("image.tags", defaultValue: "Tags"), systemImage: "tag"), DesignCardPageControlItem(id: .history(reference), - title: "History", + title: AppText.string("image.history", defaultValue: "History"), systemImage: "clock.arrow.circlepath"), DesignCardPageControlItem(id: .tag(reference), - title: "Add Tag", + title: AppText.addTag, systemImage: "plus.circle"), DesignCardPageControlItem(id: .push(reference), - title: "Push", + title: AppText.push, systemImage: "arrow.up.circle") ] } diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift index bdd0ffaf..fcec9a4b 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift @@ -110,8 +110,8 @@ struct ToolbarTemplatesPanel: View { private var header: some View { PanelHeader(symbol: "bookmark", - title: "Templates", - subtitle: "\(saved.count) saved") { + title: AppText.sectionTemplates, + subtitle: AppText.string("template.savedCount", defaultValue: "\(saved.count) saved")) { if showClose { DesignActionGroup(DesignAction(systemName: "xmark", help: AppText.close, @@ -124,8 +124,8 @@ struct ToolbarTemplatesPanel: View { private var emptyCard: some View { DesignCard(size: .small, elevated: false, - title: "No templates", - subtitle: "Save a container's settings as a template from the create form.") { + title: AppText.string("template.empty", defaultValue: "No templates"), + subtitle: AppText.string("template.empty.subtitle", defaultValue: "Save a container's settings as a template from the create form.")) { DesignCardIconChip(symbol: "bookmark", tint: .secondary, backgroundOpacity: DesignTokens.DesignCard.iconEmphasisBackgroundOpacity) diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift index 672638d6..39a3b9bb 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift @@ -75,8 +75,8 @@ struct ToolbarUpdatesPanel: View { private var header: some View { PanelHeader(symbol: "square.stack.3d.up", - title: "Images", - subtitle: "\(imageGroups.count) local · \(updateCount) update\(updateCount == 1 ? "" : "s")") { + title: AppText.sectionImages, + subtitle: AppText.string("image.updates.subtitle", defaultValue: "\(imageGroups.count) local · \(updateCount) update\(updateCount == 1 ? "" : "s")")) { DesignActionGroup(imageHeaderActions) } } @@ -104,8 +104,8 @@ struct ToolbarUpdatesPanel: View { private var emptyCard: some View { DesignCard(size: .small, elevated: false, - title: "No images", - subtitle: "Pull or build an image to see it here") { + title: AppText.string("image.empty", defaultValue: "No images"), + subtitle: AppText.string("image.empty.subtitle", defaultValue: "Pull or build an image to see it here")) { DesignCardIconChip(symbol: "checkmark.circle.fill", tint: .green) } titleAccessory: { EmptyView() diff --git a/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift b/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift index a89be02c..fe054fbe 100644 --- a/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift +++ b/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift @@ -82,13 +82,13 @@ struct ToolbarViewOptions: View { var body: some View { @Bindable var ui = ui return DesignMenuButton { - Picker("Group by", selection: $ui.grouping) { + Picker(AppText.string("toolbar.groupBy", defaultValue: "Group by"), selection: $ui.grouping) { ForEach(ContainerGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) } } .pickerStyle(.inline) - Picker("Sort by", selection: $ui.sort) { + Picker(AppText.string("toolbar.sortBy", defaultValue: "Sort by"), selection: $ui.sort) { ForEach(ContainerSort.allCases) { sort in Label(sort.title, systemImage: sort.symbol).tag(sort) } @@ -96,23 +96,23 @@ struct ToolbarViewOptions: View { .pickerStyle(.inline) Divider() Toggle(isOn: $ui.runningOnly) { - Label("Running only", systemImage: "play.circle") + Label(AppText.string("filter.runningOnly", defaultValue: "Running only"), systemImage: "play.circle") } } labelContent: { labelContent } - .help("Container filters") + .help(AppText.string("toolbar.containerFilters", defaultValue: "Container filters")) } private var labelContent: some View { ToolbarTitleSubtitleLabel(symbol: ui.grouping.symbol, - title: "Containers", + title: AppText.sectionContainers, subtitle: subtitle) } private var subtitle: String { - var parts = ["by \(ui.grouping.title)"] - if ui.runningOnly { parts.append("running") } + var parts = [AppText.string("toolbar.groupedBy", defaultValue: "by \(ui.grouping.title)")] + if ui.runningOnly { parts.append(AppText.string("status.running.lowercase", defaultValue: "running")) } return parts.joined(separator: " · ") } } @@ -248,8 +248,8 @@ struct ToolbarPageFilterOptions: View { case .activity: @Bindable var ui = ui DesignMenuButton { - Picker("Filter", selection: $ui.activityFilter) { - Label("All events", systemImage: "tray.full").tag(EventKind?.none) + Picker(AppText.string("activity.filter", defaultValue: "Filter"), selection: $ui.activityFilter) { + Label(AppText.string("activity.filter.allEvents", defaultValue: "All events"), systemImage: "tray.full").tag(EventKind?.none) Divider() ForEach(EventKind.allCases, id: \.self) { kind in Label(kind.rawValue.capitalized, systemImage: kind.symbol).tag(EventKind?.some(kind)) @@ -259,7 +259,9 @@ struct ToolbarPageFilterOptions: View { } labelContent: { activityFilterLabel } - .help(ui.activityFilter == nil ? "Filter Activity" : "Filter: \(ui.activityFilter!.rawValue.capitalized)") + .help(ui.activityFilter == nil + ? AppText.string("activity.filterActivity", defaultValue: "Filter Activity") + : AppText.string("activity.filter.current", defaultValue: "Filter: \(ui.activityFilter!.rawValue.capitalized)")) default: EmptyView() } @@ -268,8 +270,8 @@ struct ToolbarPageFilterOptions: View { private var activityFilterLabel: some View { ToolbarTitleSubtitleLabel(symbol: ui.activityFilter == nil ? "line.3.horizontal.decrease" : "line.3.horizontal.decrease.circle.fill", - title: "Activity", - subtitle: ui.activityFilter?.rawValue.capitalized ?? "All events") + title: AppText.sectionActivity, + subtitle: ui.activityFilter?.rawValue.capitalized ?? AppText.string("activity.filter.allEvents", defaultValue: "All events")) } } @@ -279,20 +281,20 @@ private struct ImageViewOptions: View { var body: some View { @Bindable var ui = ui return DesignMenuButton { - Picker("Group by", selection: $ui.imageGrouping) { + Picker(AppText.string("toolbar.groupBy", defaultValue: "Group by"), selection: $ui.imageGrouping) { ForEach(ImageGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) } } .pickerStyle(.inline) - Picker("Sort by", selection: $ui.imageSort) { + Picker(AppText.string("toolbar.sortBy", defaultValue: "Sort by"), selection: $ui.imageSort) { ForEach(ImageSort.allCases) { sort in Label(sort.title, systemImage: sort.symbol).tag(sort) } } .pickerStyle(.inline) Divider() - Picker("Filter", selection: $ui.imageFilter) { + Picker(AppText.string("activity.filter", defaultValue: "Filter"), selection: $ui.imageFilter) { ForEach(ImageFilter.allCases) { filter in Label(filter.title, systemImage: filter.symbol).tag(filter) } @@ -300,14 +302,14 @@ private struct ImageViewOptions: View { .pickerStyle(.inline) } labelContent: { optionLabel(symbol: ui.imageGrouping.symbol, - title: "Images", + title: AppText.sectionImages, subtitle: imageSubtitle) } - .help("Image filters") + .help(AppText.string("toolbar.imageFilters", defaultValue: "Image filters")) } private var imageSubtitle: String { - var parts = ["by \(ui.imageGrouping.title)"] + var parts = [AppText.string("toolbar.groupedBy", defaultValue: "by \(ui.imageGrouping.title)")] if ui.imageFilter != .all { parts.append(ui.imageFilter.title) } return parts.joined(separator: " · ") } @@ -319,13 +321,13 @@ private struct TemplateViewOptions: View { var body: some View { @Bindable var ui = ui return DesignMenuButton { - Picker("Group by", selection: $ui.templateGrouping) { + Picker(AppText.string("toolbar.groupBy", defaultValue: "Group by"), selection: $ui.templateGrouping) { ForEach(TemplateGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) } } .pickerStyle(.inline) - Picker("Sort by", selection: $ui.templateSort) { + Picker(AppText.string("toolbar.sortBy", defaultValue: "Sort by"), selection: $ui.templateSort) { ForEach(TemplateSort.allCases) { sort in Label(sort.title, systemImage: sort.symbol).tag(sort) } @@ -333,10 +335,10 @@ private struct TemplateViewOptions: View { .pickerStyle(.inline) } labelContent: { optionLabel(symbol: ui.templateGrouping.symbol, - title: "Templates", - subtitle: "by \(ui.templateGrouping.title) · \(ui.templateSort.title)") + title: AppText.sectionTemplates, + subtitle: AppText.string("toolbar.groupSortSubtitle", defaultValue: "by \(ui.templateGrouping.title) · \(ui.templateSort.title)")) } - .help("Template grouping") + .help(AppText.string("toolbar.templateGrouping", defaultValue: "Template grouping")) } } @@ -346,20 +348,20 @@ private struct NetworkViewOptions: View { var body: some View { @Bindable var ui = ui return DesignMenuButton { - Picker("Group by", selection: $ui.networkGrouping) { + Picker(AppText.string("toolbar.groupBy", defaultValue: "Group by"), selection: $ui.networkGrouping) { ForEach(NetworkGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) } } .pickerStyle(.inline) - Picker("Sort by", selection: $ui.networkSort) { + Picker(AppText.string("toolbar.sortBy", defaultValue: "Sort by"), selection: $ui.networkSort) { ForEach(NetworkSort.allCases) { sort in Label(sort.title, systemImage: sort.symbol).tag(sort) } } .pickerStyle(.inline) Divider() - Picker("Filter", selection: $ui.networkFilter) { + Picker(AppText.string("activity.filter", defaultValue: "Filter"), selection: $ui.networkFilter) { ForEach(NetworkFilter.allCases) { filter in Label(filter.title, systemImage: filter.symbol).tag(filter) } @@ -367,14 +369,14 @@ private struct NetworkViewOptions: View { .pickerStyle(.inline) } labelContent: { optionLabel(symbol: ui.networkGrouping.symbol, - title: "Networks", + title: AppText.sectionNetworks, subtitle: networkSubtitle) } - .help("Network filters") + .help(AppText.string("toolbar.networkFilters", defaultValue: "Network filters")) } private var networkSubtitle: String { - var parts = ["by \(ui.networkGrouping.title)"] + var parts = [AppText.string("toolbar.groupedBy", defaultValue: "by \(ui.networkGrouping.title)")] if ui.networkFilter != .all { parts.append(ui.networkFilter.title) } return parts.joined(separator: " · ") } diff --git a/Sources/ContainedApp/Resources/Localizable.xcstrings b/Sources/ContainedApp/Resources/Localizable.xcstrings index 00caca41..4e72cc47 100644 --- a/Sources/ContainedApp/Resources/Localizable.xcstrings +++ b/Sources/ContainedApp/Resources/Localizable.xcstrings @@ -2,11 +2,28 @@ "sourceLanguage": "en", "strings": { "activity.clearActivity": {}, + "activity.clearFiltered.help": {}, + "activity.empty": {}, + "activity.empty.description": {}, + "activity.filter": {}, + "activity.filter.allEvents": {}, + "activity.filter.current": {}, + "activity.filterActivity": {}, "activity.markAllRead": {}, + "activity.markAllRead.help": {}, + "activity.markFilteredRead.help": {}, "activity.pullingImage": {}, + "activity.subtitle.events": {}, + "activity.subtitle.unread": {}, "appearance.dark": {}, "appearance.light": {}, "appearance.system": {}, + "automation.appUpdateCheck": {}, + "automation.appUpdateCheck.detail": {}, + "automation.autoRestart": {}, + "automation.autoRestart.detail": {}, + "automation.imageUpdateCheck": {}, + "automation.refreshLoop": {}, "backup.exported": {}, "backup.exportedAndReset": {}, "backup.importMode.merge": {}, @@ -24,20 +41,51 @@ "blendMode.overlay": {}, "blendMode.screen": {}, "blendMode.softLight": {}, + "build.addBuildArg.short": {}, "build.addBuildArgument": {}, + "build.arg": {}, + "build.arguments": {}, + "build.arguments.count": {}, + "build.arguments.empty": {}, + "build.build": {}, "build.buildImage": {}, "build.cancelBuild": {}, "build.chooseContextFolder": {}, "build.chooseContextFolderPanel": {}, + "build.chooseFolderPlaceholder": {}, + "build.context": {}, + "build.context.info": {}, + "build.dockerfile": {}, + "build.dockerfile.info": {}, + "build.empty.description": {}, + "build.empty.title": {}, + "build.noCache": {}, + "build.noCache.info": {}, + "build.options": {}, + "build.platform": {}, "build.removeBuildArgument": {}, + "build.source": {}, + "build.subtitle.context": {}, + "build.tag": {}, + "build.tag.info": {}, "cardDensity.large": {}, "cardDensity.medium": {}, "cardDensity.small": {}, "cleanup.cleanedStaleRows": {}, + "cleanup.reclaimAll": {}, + "cleanup.removeStoppedContainers.title": {}, + "cleanup.removeUnusedImages.title": {}, + "cleanup.removeUnusedNetworks.title": {}, + "cleanup.removeUnusedVolumes.title": {}, + "cleanup.stoppedContainers": {}, + "cleanup.unusedImages": {}, + "cleanup.unusedNetworks": {}, + "cleanup.unusedVolumes": {}, "cli.selectContainerBinary": {}, "common.add": {}, "common.back": {}, "common.cancel": {}, + "common.checkNow": {}, "common.choose": {}, "common.clear": {}, "common.clearSearch": {}, @@ -56,6 +104,7 @@ "common.follow": {}, "common.import": {}, "common.logIn": {}, + "common.new": {}, "common.parent": {}, "common.quit": {}, "common.reconnect": {}, @@ -64,7 +113,9 @@ "common.restart": {}, "common.retry": {}, "common.run": {}, + "common.runNow": {}, "common.save": {}, + "common.search": {}, "common.start": {}, "common.stop": {}, "common.tint": {}, @@ -78,9 +129,130 @@ "container.restartedAttempt": {}, "container.unhealthy": {}, "containerCard.accessibilityLabel": {}, + "creation.badge.new": {}, + "creation.compose.paste": {}, + "creation.compose.paste.subtitle": {}, + "creation.compose.pasteYAML": {}, + "creation.compose.pasteYAML.subtitle": {}, + "creation.compose.selectFile": {}, + "creation.compose.selectFile.subtitle": {}, + "creation.details": {}, + "creation.localImages.choose": {}, + "creation.localImages.empty": {}, + "creation.localImages.filter": {}, + "creation.localImages.noMatches": {}, + "creation.localImages.tryDifferentFilter": {}, + "creation.name": {}, + "creation.network.create": {}, + "creation.network.internalOnly": {}, + "creation.network.internalOnly.subtitle": {}, + "creation.network.mode.bridge": {}, + "creation.network.mode.internal": {}, + "creation.network.name.info": {}, + "creation.network.name.required": {}, + "creation.network.new": {}, + "creation.network.subnet.info": {}, + "creation.option.build.subtitle": {}, + "creation.option.compose": {}, + "creation.option.compose.subtitle": {}, + "creation.option.container": {}, + "creation.option.container.subtitle": {}, + "creation.option.imageArchive": {}, + "creation.option.imageArchive.subtitle": {}, + "creation.option.localImage": {}, + "creation.option.localImage.emptySubtitle": {}, + "creation.option.localImage.subtitle": {}, + "creation.option.network.subtitle": {}, + "creation.option.scratch": {}, + "creation.option.scratch.subtitle": {}, + "creation.option.search.subtitle": {}, + "creation.option.templates.emptySubtitle": {}, + "creation.option.templates.subtitle": {}, + "creation.option.volume.subtitle": {}, + "creation.subnet": {}, + "creation.volume.create": {}, + "creation.volume.defaultSize": {}, + "creation.volume.name.info": {}, + "creation.volume.name.required": {}, + "creation.volume.new": {}, + "creation.volume.size": {}, + "creation.volume.size.info": {}, "customize.accessibilityLabel": {}, + "customize.addWidget": {}, + "customize.addWidget.slotsFull": {}, + "customize.addWidget.subtitle": {}, + "customize.apply": {}, + "customize.applyToImage": {}, + "customize.applyToImage.subtitle": {}, + "customize.background": {}, + "customize.blendMode": {}, "customize.cardColor": {}, + "customize.color": {}, + "customize.color.info": {}, + "customize.colorCardBackground": {}, + "customize.customIcon": {}, + "customize.displayName": {}, + "customize.gradient": {}, + "customize.header.card": {}, + "customize.header.imageStyle": {}, + "customize.header.volume": {}, + "customize.icon": {}, + "customize.icon.defaultSubtitle": {}, "customize.imageStyleAccessibilityLabel": {}, + "customize.inheritance": {}, + "customize.nickname": {}, + "customize.opacity": {}, + "customize.override.containerHint": {}, + "customize.override.defaultImageDesign": {}, + "customize.override.groupStyle": {}, + "customize.override.imageGroupHint": {}, + "customize.override.imageHint": {}, + "customize.override.imageStyle": {}, + "customize.override.imageTagHint": {}, + "customize.override.style": {}, + "customize.reset": {}, + "customize.reset.noOverride": {}, + "customize.reset.subtitle": {}, + "customize.resetImageStyle": {}, + "customize.showStatusIndicator": {}, + "customize.status": {}, + "customize.style": {}, + "customize.subtitle.imageDefault": {}, + "customize.subtitle.imageGroupDefault": {}, + "customize.subtitle.imageTag": {}, + "customize.subtitle.volume": {}, + "customize.widget.barWidth": {}, + "customize.widget.chart": {}, + "customize.widget.compare": {}, + "customize.widget.compare.subtitle": {}, + "customize.widget.data": {}, + "customize.widget.display": {}, + "customize.widget.gradientFill": {}, + "customize.widget.interpolation": {}, + "customize.widget.lineWidth": {}, + "customize.widget.metric": {}, + "customize.widget.metric.read": {}, + "customize.widget.metric.write": {}, + "customize.widget.moveDown": {}, + "customize.widget.moveDown.help": {}, + "customize.widget.moveUp": {}, + "customize.widget.moveUp.help": {}, + "customize.widget.order": {}, + "customize.widget.order.subtitle": {}, + "customize.widget.pointSize": {}, + "customize.widget.remove.help": {}, + "customize.widget.showIcon": {}, + "customize.widget.showText": {}, + "customize.widget.title": {}, + "customize.widget.type": {}, + "customize.widgets": {}, + "customize.widgets.footer": {}, + "downgrade.description": {}, + "downgrade.exportBackupThenReset": {}, + "downgrade.keepReadableData": {}, + "downgrade.quitContained": {}, + "downgrade.subtitle": {}, + "downgrade.title": {}, "error.command.cliNotFound": {}, "error.command.decodingFailed": {}, "error.command.launchFailed": {}, @@ -102,6 +274,25 @@ "files.copyFromContainerPanel": {}, "files.copyIntoContainerPanel": {}, "files.copyIntoFolder": {}, + "filter.allImages": {}, + "filter.allNetworks": {}, + "filter.builtInOnly": {}, + "filter.customOnly": {}, + "filter.errorsOnly": {}, + "filter.flat": {}, + "filter.image": {}, + "filter.kind": {}, + "filter.mode": {}, + "filter.name": {}, + "filter.network": {}, + "filter.newest": {}, + "filter.none": {}, + "filter.plugin": {}, + "filter.registry": {}, + "filter.runningOnly": {}, + "filter.status": {}, + "filter.updatesOnly": {}, + "filter.volume": {}, "graphMetric.cpu": {}, "graphMetric.diskRead": {}, "graphMetric.diskWrite": {}, @@ -120,12 +311,36 @@ "image.chooseTarArchive": {}, "image.deleteTag": {}, "image.deleted": {}, + "image.empty": {}, + "image.empty.subtitle": {}, + "image.history": {}, + "image.history.empty": {}, + "image.history.empty.description": {}, "image.loadImageTar": {}, "image.prune": {}, "image.pruneImages": {}, "image.pullUpdate": {}, "image.push": {}, + "image.push.namespaceRequired": {}, + "image.push.namespaceRequired.detail": {}, + "image.push.namespaceRequired.message": {}, + "image.push.ready": {}, + "image.push.ready.detail": {}, + "image.push.ready.message": {}, + "image.push.runtimeUnavailable": {}, + "image.push.runtimeUnavailable.message": {}, + "image.push.signInRequired": {}, + "image.push.signInRequired.detail": {}, + "image.push.signInRequired.message": {}, + "image.push.tagRequired": {}, + "image.push.tagRequired.detail": {}, + "image.push.tagRequired.message": {}, + "image.pushImage": {}, "image.saveTarArchive": {}, + "image.tag.newReference": {}, + "image.tag.source": {}, + "image.tags": {}, + "image.updates.subtitle": {}, "imageDefaults.adopted": {}, "imageDefaults.alreadyRepresented": {}, "logging.category.compose": {}, @@ -147,7 +362,12 @@ "logging.level.verbose.footnote": {}, "menu.networkActions": {}, "menu.volumeActions": {}, + "network.count": {}, "network.created": {}, + "network.delete.confirmation": {}, + "network.delete.message": {}, + "network.empty": {}, + "network.empty.description": {}, "network.newNetwork": {}, "network.newNetwork.lowercase": {}, "network.refreshNetworks": {}, @@ -214,6 +434,9 @@ "palette.localImages": {}, "palette.localImagesCount": {}, "palette.matchesCount": {}, + "palette.network": {}, + "palette.network.builtIn": {}, + "palette.network.footer": {}, "palette.noImagesFound": {}, "palette.noMatches": {}, "palette.noMatchesDescription": {}, @@ -253,6 +476,14 @@ "palette.subtitle.volume": {}, "palette.typeToSearchDockerHub": {}, "palette.unavailable": {}, + "palette.volume": {}, + "palette.volume.footer": {}, + "registry.credentials": {}, + "registry.login.title": {}, + "registry.openRegistries": {}, + "registry.password": {}, + "registry.server": {}, + "registry.username": {}, "releaseNotes.availableUpdate": {}, "releaseNotes.whatsNew": {}, "releaseNotes.whatsNewInAvailableUpdate": {}, @@ -260,12 +491,135 @@ "restartPolicy.always": {}, "restartPolicy.no": {}, "restartPolicy.onFailure": {}, + "runSpec.addCapability": {}, + "runSpec.addCapability.info": {}, + "runSpec.addDNSOption": {}, + "runSpec.addDNSOption.info": {}, + "runSpec.addEnvFile": {}, + "runSpec.addEnvFile.info": {}, + "runSpec.addLabel": {}, + "runSpec.addMount": {}, + "runSpec.addMount.info": {}, + "runSpec.addNameserver": {}, + "runSpec.addNameserver.info": {}, + "runSpec.addPort": {}, + "runSpec.addSearchDomain": {}, + "runSpec.addSearchDomain.info": {}, + "runSpec.addSocket": {}, + "runSpec.addTmpfsMount": {}, + "runSpec.addTmpfsMount.info": {}, + "runSpec.addUlimit": {}, + "runSpec.addUlimit.info": {}, + "runSpec.addVariable": {}, + "runSpec.addVolume": {}, "runSpec.adopt": {}, + "runSpec.allocateTTY": {}, + "runSpec.allocateTTY.info": {}, + "runSpec.blendMode": {}, + "runSpec.blendMode.info": {}, + "runSpec.chooseFileOrFolder": {}, + "runSpec.color": {}, + "runSpec.color.info": {}, + "runSpec.colorCardBackground": {}, + "runSpec.colorCardBackground.info": {}, + "runSpec.command": {}, + "runSpec.command.info": {}, + "runSpec.containerIDFile": {}, + "runSpec.containerIDFile.info": {}, + "runSpec.cpus": {}, + "runSpec.cpus.info": {}, + "runSpec.createNewVolume": {}, + "runSpec.customPlatform": {}, + "runSpec.customPlatform.info": {}, + "runSpec.default": {}, + "runSpec.detach": {}, + "runSpec.detach.info": {}, + "runSpec.disableDNS": {}, + "runSpec.disableDNS.info": {}, + "runSpec.dropCapability": {}, + "runSpec.dropCapability.info": {}, + "runSpec.enableHealthcheck": {}, + "runSpec.enableHealthcheck.info": {}, + "runSpec.entrypoint": {}, + "runSpec.entrypoint.info": {}, + "runSpec.exposeVirtualization": {}, + "runSpec.exposeVirtualization.info": {}, + "runSpec.forwardSSHAgent": {}, + "runSpec.forwardSSHAgent.info": {}, + "runSpec.gradient": {}, + "runSpec.gradient.info": {}, + "runSpec.icon": {}, + "runSpec.icon.info": {}, + "runSpec.image": {}, + "runSpec.image.info": {}, + "runSpec.image.required": {}, + "runSpec.imageDefaults": {}, + "runSpec.imageDefaults.info": {}, + "runSpec.imageDefaults.subtitle": {}, + "runSpec.importedValuesHint": {}, + "runSpec.initImage": {}, + "runSpec.initImage.info": {}, + "runSpec.keepStdinOpen": {}, + "runSpec.keepStdinOpen.info": {}, + "runSpec.kernel": {}, + "runSpec.kernel.info": {}, + "runSpec.limitMemory": {}, + "runSpec.limitMemory.info": {}, + "runSpec.limitParallelDownloads": {}, + "runSpec.limitParallelDownloads.info": {}, + "runSpec.memory": {}, + "runSpec.name": {}, + "runSpec.name.info": {}, + "runSpec.network": {}, + "runSpec.network.info": {}, + "runSpec.nickname": {}, + "runSpec.nickname.info": {}, + "runSpec.opacity": {}, + "runSpec.platform": {}, + "runSpec.platform.info": {}, + "runSpec.probeCommand": {}, + "runSpec.probeCommand.info": {}, + "runSpec.progress": {}, + "runSpec.progress.info": {}, + "runSpec.readOnlyFilesystem": {}, + "runSpec.readOnlyFilesystem.info": {}, + "runSpec.registryScheme": {}, + "runSpec.registryScheme.info": {}, + "runSpec.removeWhenStopped": {}, + "runSpec.removeWhenStopped.info": {}, + "runSpec.restartPolicy": {}, + "runSpec.restartPolicy.info": {}, + "runSpec.rosetta": {}, + "runSpec.rosetta.info": {}, + "runSpec.runtime": {}, + "runSpec.runtime.info": {}, + "runSpec.searchDomain": {}, + "runSpec.searchDomain.info": {}, + "runSpec.section.advancedOptions": {}, + "runSpec.section.advancedOptions.footer": {}, + "runSpec.section.appManaged": {}, + "runSpec.section.environment": {}, + "runSpec.section.essentials": {}, + "runSpec.section.networking": {}, + "runSpec.section.resources": {}, + "runSpec.section.storage": {}, + "runSpec.setSharedMemorySize": {}, + "runSpec.setSharedMemorySize.info": {}, + "runSpec.sharedMemory": {}, + "runSpec.sourcePicker.help": {}, + "runSpec.useInitProcess": {}, + "runSpec.useInitProcess.info": {}, + "runSpec.user": {}, + "runSpec.user.info": {}, + "runSpec.userID": {}, + "runSpec.userID.info": {}, "runSpec.validation.chooseImage": {}, "runSpec.validation.completePortMappings": {}, "runSpec.validation.completeVolumeMounts": {}, "runSpec.validation.environmentNeedsNames": {}, "runSpec.validation.memoryFormat": {}, + "runSpec.workingDirectory": {}, + "runSpec.workingDirectory.info": {}, "runtime.core": {}, "runtime.core.subtitle": {}, "runtime.coreSelector.disabledReason": {}, @@ -375,21 +729,43 @@ "settings.updates.footer": {}, "settings.updates.imageUpdates": {}, "settings.updates.imageUpdates.footer": {}, + "stats.collecting": {}, + "stats.memory.detail": {}, + "stats.notRunning": {}, + "stats.notRunning.description": {}, + "stats.processes": {}, "statsNormalization.container": {}, "statsNormalization.container.footnote": {}, "statsNormalization.machine": {}, "statsNormalization.machine.footnote": {}, "status.errored": {}, + "status.off": {}, + "status.paused": {}, "status.running": {}, + "status.running.lowercase": {}, "status.stopped": {}, "status.stopping": {}, + "status.unavailableInBuild": {}, "status.unknown": {}, "streamConsole.lineCount": {}, + "system.containerEngine": {}, + "system.diskUsed": {}, + "system.page.automation": {}, + "system.page.automation.subtitle": {}, + "system.page.engine": {}, + "system.page.engine.subtitle": {}, + "system.page.volumes.subtitle": {}, "system.storageCleanup": {}, "system.systemLogs": {}, + "template.empty": {}, + "template.empty.subtitle": {}, "template.saveAsTemplate": {}, "template.saved": {}, + "template.savedCount": {}, + "terminal.execInto": {}, "terminal.reconnectTerminal": {}, + "terminal.sessionEnded": {}, + "terminal.sessionEndedWithExit": {}, "tint.amber": {}, "tint.amber.alias.gold": {}, "tint.amber.alias.honey": {}, @@ -443,6 +819,14 @@ "tint.teal.alias.mint": {}, "tint.teal.alias.seafoam": {}, "tint.teal.alias.turquoise": {}, + "toolbar.containerFilters": {}, + "toolbar.groupBy": {}, + "toolbar.groupSortSubtitle": {}, + "toolbar.groupedBy": {}, + "toolbar.imageFilters": {}, + "toolbar.networkFilters": {}, + "toolbar.sortBy": {}, + "toolbar.templateGrouping": {}, "updateChannel.beta": {}, "updateChannel.beta.footnote": {}, "updateChannel.nightly": {}, @@ -458,6 +842,7 @@ "updates.imageUpdateAvailable": {}, "updates.imagesTitle": {}, "updates.localDigestUnavailable": {}, + "updates.localDigestUnavailable.short": {}, "updates.noContainerImageUpdatesAvailable": {}, "updates.noContainerImagesToCheck": {}, "updates.noImageUpdatesAvailable": {}, @@ -468,7 +853,10 @@ "updates.unavailable": {}, "updates.updatedImage": {}, "updates.updatedItems": {}, + "volume.copyDestination": {}, + "volume.copySource": {}, "volume.created": {}, + "volume.inventory.empty": {}, "volume.newVolume": {}, "volume.refreshVolumes": {}, "widgetInterpolation.cardinal": {}, diff --git a/Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift b/Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift index ccd8b514..f225106a 100644 --- a/Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift +++ b/Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift @@ -97,7 +97,8 @@ extension AppModel { do { let remoteDigest = try await manifestClient.remoteDigest(for: reference) guard let localDigest, !localDigest.isEmpty else { - imageUpdates[key] = .failed(localDigest: nil, message: "Local digest unavailable") + imageUpdates[key] = .failed(localDigest: nil, + message: AppText.string("updates.localDigestUnavailable.short", defaultValue: "Local digest unavailable")) if notify { flash(AppText.imageLocalDigestUnavailable(Format.shortImage(reference))) } return } From ced38dd9446a2672c32503fe26074f393c4705cf Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 19:55:31 +0100 Subject: [PATCH 38/53] Expand preview support fixtures --- Packages/ContainedPreviewSupport/README.md | 15 +- .../ContainedPreviewSupport.md | 15 ++ .../PreviewSamples.swift | 199 +++++++++++++++++- .../PreviewSamplesTests.swift | 11 + .../ContainedApp/Previews/AppPreviews.swift | 27 ++- 5 files changed, 256 insertions(+), 11 deletions(-) diff --git a/Packages/ContainedPreviewSupport/README.md b/Packages/ContainedPreviewSupport/README.md index 4f290092..d9778c1e 100644 --- a/Packages/ContainedPreviewSupport/README.md +++ b/Packages/ContainedPreviewSupport/README.md @@ -5,8 +5,9 @@ and lightweight package tests. ## Owns -- Sample containers, images, runtime descriptors, stats, histories, and activity - states. +- Sample containers, images, volumes, networks, runtime descriptors, stats, + metric histories, card-style descriptors, widget descriptors, activity state + identifiers, and representative package errors. - Fixture values that packages and the app can reuse without spinning up the Apple `container` service. @@ -23,8 +24,18 @@ import ContainedPreviewSupport let container = PreviewSamples.webContainer let values = PreviewSamples.sparklineValues +let volume = PreviewSamples.volume +let network = PreviewSamples.network +let widgets = PreviewSamples.widgetConfigs +let errorCode = PreviewSamples.commandError.packageErrorCode ``` +App previews should map `PreviewCardStyleDescriptor` and +`PreviewWidgetDescriptor` into app-owned style/state types. The package keeps +those descriptors runtime-neutral so it does not depend on `Sources/ContainedApp`. +Preview activity fixtures describe state and resource identifiers; apps still +provide the localized copy shown around that state. + ## Build And Test ```sh diff --git a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md index 86b9f2fc..fd00fd92 100644 --- a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md +++ b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md @@ -7,3 +7,18 @@ Deterministic sample data for SwiftUI previews and package examples. `ContainedPreviewSupport` gives app and package previews stable data without launching a runtime service. It intentionally avoids localized strings and live process calls so previews remain fast and predictable. + +Use ``PreviewSamples`` for typed core/runtime fixtures: + +- Containers, images, volumes, and networks. +- Runtime descriptors for the current Apple adapter and future runtime shape. +- Live stats, metric-history samples, and graph source arrays. +- Runtime-neutral card-style and widget descriptors for app previews to map + into app-owned presentation state. +- Activity identifiers and package-error states for status, alert, and Activity + examples. + +The package does not import the app target. App previews should translate the +neutral descriptors into app-owned types such as local personalization or view +state at the preview boundary, including any localized copy shown around +activity state. diff --git a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/PreviewSamples.swift b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/PreviewSamples.swift index d20d594b..38ee1148 100644 --- a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/PreviewSamples.swift +++ b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/PreviewSamples.swift @@ -2,10 +2,131 @@ import Foundation import ContainedCore import ContainedRuntime +public struct PreviewMetricHistorySample: Equatable, Sendable, MetricHistorySample { + public var timestamp: Date + public var cpuFraction: Double + public var memoryBytes: Double + public var netRxBytesPerSec: Double + public var netTxBytesPerSec: Double + public var diskReadBytesPerSec: Double + public var diskWriteBytesPerSec: Double + + public init(timestamp: Date, + cpuFraction: Double, + memoryBytes: Double, + netRxBytesPerSec: Double, + netTxBytesPerSec: Double, + diskReadBytesPerSec: Double, + diskWriteBytesPerSec: Double) { + self.timestamp = timestamp + self.cpuFraction = cpuFraction + self.memoryBytes = memoryBytes + self.netRxBytesPerSec = netRxBytesPerSec + self.netTxBytesPerSec = netTxBytesPerSec + self.diskReadBytesPerSec = diskReadBytesPerSec + self.diskWriteBytesPerSec = diskWriteBytesPerSec + } +} + +public struct PreviewCardStyleDescriptor: Equatable, Sendable { + public var symbol: String + public var tintName: String + public var fillsBackground: Bool + public var backgroundOpacity: Double + public var usesGradient: Bool + + public init(symbol: String, + tintName: String, + fillsBackground: Bool, + backgroundOpacity: Double, + usesGradient: Bool) { + self.symbol = symbol + self.tintName = tintName + self.fillsBackground = fillsBackground + self.backgroundOpacity = backgroundOpacity + self.usesGradient = usesGradient + } +} + +public struct PreviewWidgetDescriptor: Equatable, Sendable { + public var metric: GraphMetric + public var secondaryMetric: GraphMetric? + public var style: String + public var icon: String + public var tintName: String? + public var showsText: Bool + + public init(metric: GraphMetric, + secondaryMetric: GraphMetric? = nil, + style: String, + icon: String, + tintName: String? = nil, + showsText: Bool = true) { + self.metric = metric + self.secondaryMetric = secondaryMetric + self.style = style + self.icon = icon + self.tintName = tintName + self.showsText = showsText + } +} + +public enum PreviewActivityKind: String, CaseIterable, Sendable { + case lifecycle + case image + case compose + case system + case alert +} + +public struct PreviewActivityEvent: Equatable, Sendable { + public var timestamp: Date + public var containerID: String? + public var kind: PreviewActivityKind + public var subjectID: String? + public var isRead: Bool + + public init(timestamp: Date, + containerID: String?, + kind: PreviewActivityKind, + subjectID: String?, + isRead: Bool = false) { + self.timestamp = timestamp + self.containerID = containerID + self.kind = kind + self.subjectID = subjectID + self.isRead = isRead + } +} + +public struct PreviewActivityProgress: Equatable, Sendable { + public var kind: PreviewActivityKind + public var subjectID: String + public var detailID: String? + public var fraction: Double + + public init(kind: PreviewActivityKind, + subjectID: String, + detailID: String? = nil, + fraction: Double) { + self.kind = kind + self.subjectID = subjectID + self.detailID = detailID + self.fraction = fraction + } +} + public enum PreviewSamples { public static let now = Date(timeIntervalSinceReferenceDate: 790_000_000) public static let appleRuntime = RuntimeDescriptor.appleContainer + public static let dockerRuntime = RuntimeDescriptor( + kind: .dockerCompatible, + displayName: "Docker-compatible", + executableName: "docker", + capabilities: [] + ) + public static let runtimes = [appleRuntime, dockerRuntime] public static let webContainer = ContainerSnapshot.placeholder( id: "preview-web", @@ -42,6 +163,18 @@ public enum PreviewSamples { 66_000, 72_000, 68_000, 80_000, 92_000, 88_000 ] + public static let metricHistory: [PreviewMetricHistorySample] = sparklineValues.enumerated().map { offset, value in + PreviewMetricHistorySample( + timestamp: now.addingTimeInterval(Double(offset) * 30), + cpuFraction: value, + memoryBytes: 260_000_000 + Double(offset) * 6_000_000, + netRxBytesPerSec: networkSamples[offset % networkSamples.count], + netTxBytesPerSec: networkSamples[(offset + 3) % networkSamples.count] * 0.45, + diskReadBytesPerSec: 4_000 + Double(offset * 320), + diskWriteBytesPerSec: 8_000 + Double(offset * 420) + ) + } + public static let image = decode(ImageResource.self, from: """ { "configuration": { @@ -78,6 +211,69 @@ public enum PreviewSamples { public static let imageGroup = LocalImageTagGroup.group(containing: image, in: [image]) + public static let volume = decode(VolumeResource.self, from: """ + { + "configuration": { + "name": "preview-data", + "source": "/Users/preview/.contained/volumes/preview-data", + "format": "apfs", + "sizeInBytes": 10737418240, + "creationDate": "2026-07-01T12:10:00Z", + "labels": { "contained.stack": "preview" } + } + } + """) + + public static let network = decode(NetworkResource.self, from: """ + { + "id": "preview-network", + "configuration": { + "name": "preview-network", + "mode": "nat", + "plugin": "builtin", + "creationDate": "2026-07-01T12:12:00Z", + "labels": { "contained.stack": "preview" }, + "options": { "variant": "preview" } + }, + "status": { + "ipv4Gateway": "10.42.0.1", + "ipv4Subnet": "10.42.0.0/24", + "ipv6Subnet": null + } + } + """) + + public static let cardStyle = PreviewCardStyleDescriptor( + symbol: "shippingbox.fill", + tintName: "azure", + fillsBackground: true, + backgroundOpacity: 0.16, + usesGradient: true + ) + + public static let widgetConfigs: [PreviewWidgetDescriptor] = [ + PreviewWidgetDescriptor(metric: .cpu, style: "area", icon: "cpu", tintName: "azure"), + PreviewWidgetDescriptor(metric: .memory, style: "area", icon: "memorychip", tintName: "teal"), + PreviewWidgetDescriptor(metric: .netRx, secondaryMetric: .netTx, style: "multiLine", icon: "arrow.down.circle") + ] + + public static let activityEvents: [PreviewActivityEvent] = [ + PreviewActivityEvent(timestamp: now, containerID: webContainer.id, kind: .lifecycle, subjectID: webContainer.id), + PreviewActivityEvent(timestamp: now.addingTimeInterval(45), containerID: webContainer.id, kind: .image, subjectID: image.reference, isRead: true), + PreviewActivityEvent(timestamp: now.addingTimeInterval(90), containerID: nil, kind: .alert, subjectID: "runtime-warning") + ] + + public static let activityStatus = PreviewActivityProgress(kind: .image, + subjectID: image.reference, + detailID: "sha256:preview", + fraction: 0.62) + + public static let unsupportedCapabilityError = UnsupportedRuntimeCapability(kind: .dockerCompatible, + capability: .coreMigration) + public static let commandError = CommandError.nonZeroExit(code: 42, + stderr: "preview failure", + command: "container preview") + public static let createRequest: ContainerCreateRequest = { var request = ContainerCreateRequest() request.runtimeKind = .appleContainer @@ -94,9 +290,6 @@ public enum PreviewSamples { request.useInit = true return request }() - - public static let activityTitle = "Preview activity" - public static let activityDetail = "Deterministic preview data" } private func decode(_ type: T.Type, from json: String) -> T { diff --git a/Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift b/Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift index 6bdb9820..92dc7af0 100644 --- a/Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift +++ b/Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift @@ -8,5 +8,16 @@ struct PreviewSamplesTests { #expect(PreviewSamples.image.reference == "docker.io/library/nginx:latest") #expect(!PreviewSamples.sparklineValues.isEmpty) #expect(PreviewSamples.createRequest.image == PreviewSamples.image.reference) + #expect(PreviewSamples.volume.name == "preview-data") + #expect(PreviewSamples.network.status?.ipv4Subnet == "10.42.0.0/24") + #expect(PreviewSamples.metricHistory.count == PreviewSamples.sparklineValues.count) + #expect(PreviewSamples.runtimes.map(\.kind).contains(.dockerCompatible)) + #expect(PreviewSamples.cardStyle.symbol == "shippingbox.fill") + #expect(PreviewSamples.widgetConfigs.contains { $0.metric == .memory }) + #expect(PreviewSamples.activityEvents.contains { !$0.isRead }) + #expect(PreviewSamples.activityStatus.subjectID == PreviewSamples.image.reference) + #expect(PreviewSamples.activityStatus.fraction == 0.62) + #expect(PreviewSamples.unsupportedCapabilityError.packageErrorCode == "unsupportedRuntimeCapability") + #expect(PreviewSamples.commandError.packageErrorContext["code"] == "42") } } diff --git a/Sources/ContainedApp/Previews/AppPreviews.swift b/Sources/ContainedApp/Previews/AppPreviews.swift index 40a8dfdc..60d1f2aa 100644 --- a/Sources/ContainedApp/Previews/AppPreviews.swift +++ b/Sources/ContainedApp/Previews/AppPreviews.swift @@ -43,13 +43,28 @@ private extension PreviewSamples { private extension Personalization { static var previewContainer: Personalization { + let sample = PreviewSamples.cardStyle var style = Personalization() - style.nickname = "Preview Web" - style.icon = "shippingbox.fill" - style.tint = .azure - style.fillBackground = true - style.backgroundOpacity = 0.16 - style.widgets = WidgetConfiguration.defaultWidgets() + style.nickname = PreviewSamples.webContainer.displayName + style.icon = sample.symbol + style.tint = DesignTint.parse(sample.tintName) + style.fillBackground = sample.fillsBackground + style.backgroundOpacity = sample.backgroundOpacity + style.gradient = sample.usesGradient + style.widgets = PreviewSamples.widgetConfigs.map(WidgetConfiguration.preview) return style } } + +private extension WidgetConfiguration { + static func preview(_ descriptor: PreviewWidgetDescriptor) -> WidgetConfiguration { + WidgetConfiguration( + metric: descriptor.metric, + secondaryMetric: descriptor.secondaryMetric, + tint: descriptor.tintName.map(DesignTint.parse), + icon: descriptor.icon, + style: GraphStyle(rawValue: descriptor.style) ?? .area, + showText: descriptor.showsText + ) + } +} From 2490b98564cf3edd08df4c42f590b17396f43803 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Thu, 2 Jul 2026 19:59:20 +0100 Subject: [PATCH 39/53] Organize preview support samples --- Packages/ContainedPreviewSupport/README.md | 5 +++++ .../ContainedPreviewSupport.docc/ContainedPreviewSupport.md | 4 ++++ .../{ => Samples}/PreviewSamples.swift | 0 3 files changed, 9 insertions(+) rename Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/{ => Samples}/PreviewSamples.swift (100%) diff --git a/Packages/ContainedPreviewSupport/README.md b/Packages/ContainedPreviewSupport/README.md index d9778c1e..38a4c814 100644 --- a/Packages/ContainedPreviewSupport/README.md +++ b/Packages/ContainedPreviewSupport/README.md @@ -17,6 +17,11 @@ and lightweight package tests. - Production app state or persistence. - Live runtime calls. +## Source Layout + +- `Sources/ContainedPreviewSupport/Samples` contains deterministic fixture + models and values used by package examples and app previews. + ## Example ```swift diff --git a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md index fd00fd92..1633d4c7 100644 --- a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md +++ b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md @@ -22,3 +22,7 @@ The package does not import the app target. App previews should translate the neutral descriptors into app-owned types such as local personalization or view state at the preview boundary, including any localized copy shown around activity state. + +The `Samples` source folder contains the deterministic fixture models and +values. Keep new preview data there unless it becomes a reusable pure model that +belongs in `ContainedCore` or `ContainedRuntime`. diff --git a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/PreviewSamples.swift b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/Samples/PreviewSamples.swift similarity index 100% rename from Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/PreviewSamples.swift rename to Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/Samples/PreviewSamples.swift From 898f1af65924a7a8a38e255d537bd64b4dcb3abf Mon Sep 17 00:00:00 2001 From: tdeverx Date: Fri, 3 Jul 2026 00:59:27 +0100 Subject: [PATCH 40/53] Finalize foundational package architecture --- AGENTS.md | 21 +- CHANGELOG.md | 8 +- CODEOWNERS | 7 +- .../contents.xcworkspacedata | 13 +- Package.swift | 21 +- Packages/AppleContainerRuntime/Package.swift | 32 -- Packages/AppleContainerRuntime/README.md | 39 -- .../AppleContainerRuntime.md | 12 - .../AppleContainerRuntimeTests/Fixtures.swift | 38 -- .../Fixtures/df.json | 20 - .../Fixtures/image-inspect.json | 526 ------------------ .../Fixtures/images-error.txt | 1 - .../Fixtures/inspect.json | 119 ---- .../Fixtures/list-current.json | 1 - .../Fixtures/list.json | 1 - .../Fixtures/networks.json | 1 - .../Fixtures/stats-table.txt | 9 - .../Fixtures/stats.json | 1 - .../Fixtures/status.json | 1 - .../Fixtures/volumes.json | 1 - Packages/ContainedCore/Package.swift | 12 + Packages/ContainedCore/README.md | 103 +++- .../ContainedCore/Command/Error.swift} | 3 +- .../ContainedCore/Command/Preview.swift | 21 + .../Command/PreviewBuilders.swift | 34 ++ .../ContainedCore/Command/Runner.swift} | 0 .../Sources/ContainedCore/Compose/API.swift | 7 + .../ContainedCore/Compose/ImportExport.swift} | 40 +- .../YAML/Project.swift} | 0 .../ContainedCore.docc/ContainedCore.md | 71 ++- .../CreateRequest.swift} | 0 .../ContainedCore/Container/Document.swift | 40 ++ .../{Models => Container}/HealthCheck.swift | 0 .../JSONDecoding.swift} | 0 .../{Models => Container}/RestartPolicy.swift | 0 .../Snapshot.swift} | 7 +- .../Sources/ContainedCore/Core.swift | 123 ++++ .../ContainedCore/Field/Provenance.swift | 17 + .../{Models => Image}/ImageResource.swift | 0 .../LocalTagGroup.swift} | 0 .../UpdateStatus.swift} | 0 .../{Models => Metrics}/ContainerStats.swift | 0 .../{Models => Metrics}/GraphMetric.swift | 0 .../Normalization.swift} | 0 .../ContainedCore/Migration/Plan.swift | 25 + .../{Models => Network}/NetworkResource.swift | 0 .../Orchestration/CoreOrchestrator.swift | 346 ++++++++++++ .../{Models => Registry}/HubSearch.swift | 0 .../Login.swift} | 0 .../ManifestClient.swift} | 0 .../RegistryImageReference.swift | 0 .../RuntimeKind.swift => Runtime/Kind.swift} | 0 .../Runtime}/RuntimeDescriptor.swift | 7 +- .../{Models => Runtime}/RuntimeStatus.swift | 0 .../AppleContainer/Client/Client.swift} | 102 ++-- .../Command}/ContainerCommands.swift | 90 +-- .../Metrics/StatsTableParser.swift} | 9 +- .../AppleContainer/Support/CLILocator.swift} | 10 +- .../Translation/CreateTranslator.swift} | 12 +- .../ContainedCore/Schema/Version.swift | 11 + .../Properties.swift} | 0 .../SystemStatus.swift} | 0 .../{Models => Volume}/VolumeResource.swift | 0 .../AppleContainerFixtures.swift | 137 +++++ .../Fixtures/Generic/GenericFixtures.swift | 55 ++ .../Core/Fixtures/Namespace.swift | 11 + .../CoreFixturesTests.swift | 23 + .../ContainedCoreTests/DecodingTests.swift | 7 +- .../Tests/ContainedCoreTests/Fixtures.swift | 21 + .../ContainedCoreTests/Fixtures/inspect.json | 3 +- .../Fixtures/list-current.json | 2 +- .../ContainedCoreTests/Fixtures/list.json | 2 +- .../Runtime}/RuntimeDescriptorTests.swift | 5 +- .../AppleContainer/AdapterTests.swift} | 10 +- Packages/ContainedDesignSystem/Package.swift | 17 - Packages/ContainedDesignSystem/README.md | 379 ------------- .../ContainedDesignSystem.md | 364 ------------ .../Controls/DesignActions.swift | 334 ----------- .../Controls/InfoButton.swift | 49 -- .../Controls/TintSelector.swift | 78 --- .../DataViz/CommandPreviewBar.swift | 48 -- .../Feedback/StreamConsole.swift | 114 ---- .../Previews/DesignSystemPreviews.swift | 87 --- .../Support/MicroPrimitives.swift | 246 -------- .../Tokens/DesignTokens.swift | 200 ------- .../DesignCardLayoutPolicyTests.swift | 22 - Packages/ContainedNavigation/Package.swift | 25 - Packages/ContainedNavigation/README.md | 177 ------ .../ContainedNavigation.md | 110 ---- .../ContainedPreviewSupport/Package.swift | 27 - Packages/ContainedPreviewSupport/README.md | 49 -- .../ContainedPreviewSupport.md | 28 - .../Samples/PreviewSamples.swift | 303 ---------- .../PreviewSamplesTests.swift | 23 - Packages/ContainedRuntime/Package.swift | 28 - Packages/ContainedRuntime/README.md | 46 -- .../ContainedRuntime.docc/ContainedRuntime.md | 20 - Packages/ContainedUI/Package.swift | 17 + Packages/ContainedUI/README.md | 161 ++++++ .../Sources/ContainedUI/Action/Buttons.swift | 100 ++++ .../Sources/ContainedUI/Action/Groups.swift | 152 +++++ .../Sources/ContainedUI/Action/Item.swift | 31 ++ .../ContainedUI/Action/SelectionBar.swift | 37 ++ .../ContainedUI/Card/CardChrome.swift} | 126 ++--- .../ContainedUI/Card/CardScaffold.swift} | 70 +-- .../ContainedUI/Card/CardSurface.swift} | 60 +- .../ContainedUI/Card/CardViewModifiers.swift | 14 + .../ContainedUI/Chart/MetricTile.swift | 36 ++ .../ContainedUI/Chart/Sparkline.swift} | 21 +- .../Chart/SparklineMetricTile.swift} | 12 +- .../ContainedUI/Command/PreviewBar.swift | 50 ++ .../Console/ConsoleViewModifiers.swift | 11 + .../ContainedUI/Console/StreamConsole.swift | 116 ++++ .../ContainedUI.docc/ContainedUI.md | 70 +++ .../Sources/ContainedUI/Control/Chips.swift | 49 ++ .../Control/ControlViewModifiers.swift | 13 + .../Control}/GradientAngleControl.swift | 20 +- .../ContainedUI/Control/InfoButton.swift | 51 ++ .../ContainedUI/Control/KeyboardHints.swift | 39 ++ .../ContainedUI/Control/ListControls.swift | 235 ++++++++ .../ContainedUI/Control/MaterialButton.swift} | 64 +-- .../ContainedUI/Control/OptionTile.swift} | 16 +- .../ContainedUI/Control/RowMenu.swift} | 4 +- .../ContainedUI/Control/SearchField.swift | 44 ++ .../ContainedUI/Control/TintSelector.swift | 80 +++ .../Control}/ToolbarControls.swift | 46 +- .../ContainedUI/Panel}/PageScaffold.swift | 4 +- .../ContainedUI/Panel/PanelScaffold.swift} | 6 +- .../ContainedUI/Panel}/PanelSection.swift | 54 +- .../Panel/PanelSheetTitleBar.swift} | 10 +- .../ContainedUI/Panel/PanelTitleBar.swift} | 20 +- .../ContainedUI/Previews/UIPreviews.swift | 89 +++ .../State}/ActivityStatusView.swift | 16 +- .../Sources/ContainedUI/State/Badges.swift | 40 ++ .../ContainedUI/State/ContentStates.swift | 335 +++++++++++ .../ContainedUI/State/ErrorBanner.swift} | 14 +- .../ContainedUI/State/StatusBanner.swift | 18 + .../ContainedUI}/Support/Clipboard.swift | 0 .../ContainedUI/Support/NestedAPI.swift | 141 +++++ .../ContainedUI/Surface/ContentSurface.swift | 29 + .../ContainedUI/Surface}/ExteriorShadow.swift | 0 .../ContainedUI/Surface/InputSurface.swift | 27 + .../Surface/MaterialSurface.swift} | 20 +- .../Surface}/VisualEffectBackground.swift | 2 +- .../Sources/ContainedUI}/Theme/Theme.swift | 37 +- .../Sources/ContainedUI/Tokens/UI.swift | 473 ++++++++++++++++ .../CardLayoutPolicyTests.swift | 22 + .../SparklineScalingTests.swift} | 4 +- Packages/ContainedUX/Package.swift | 25 + Packages/ContainedUX/README.md | 121 ++++ .../ContainedUX.docc/ContainedUX.md | 44 ++ .../Measurement/SourceFrames.swift} | 8 +- .../Sources/ContainedUX/Morph/Expander.swift} | 108 ++-- .../ContainedUX/Morph/SingleSurface.swift} | 34 +- .../Previews/NavigationPreviews.swift | 36 +- .../SafeArea/SafeAreaManager.swift} | 34 +- .../ContainedUX/Sources/ContainedUX/UX.swift | 38 ++ .../SingleSurfaceTests.swift} | 4 +- README.md | 20 +- .../AppModel+Configuration.swift | 2 +- .../AppModel+ImageUpdates.swift | 1 - .../AppModel+ResourceStyles.swift | 0 .../{Stores => App}/AppModel.swift | 79 +-- .../ContainedApp/{ => App}/ContainedApp.swift | 9 +- .../Activity}/ActivityView.swift | 34 +- .../Activity}/ContainerHistoryTab.swift | 68 +-- .../Containers/{ => Card}/ContainerCard.swift | 57 +- .../{ => Card}/ContainersGridView.swift | 114 ++-- .../{ => Creation}/ComposeImport.swift | 3 +- .../Containers/Creation/CreationFlow.swift | 36 +- .../Containers/Creation/CreationPages.swift | 89 ++- .../ContainerCustomizeButton.swift | 4 +- .../ContainerViewOptions.swift | 0 .../{ => Customization}/CustomizeSheet.swift | 64 +-- .../CustomizeWidgetsPanel.swift | 54 +- .../ContainerConfigureView.swift | 54 +- .../{ => Details}/ContainerEditSheet.swift | 4 +- .../{ => Details}/ContainerOverviewTab.swift | 14 +- .../{ => Details}/ContainerTabScaffold.swift | 8 +- .../Containers/{ => Details}/FilesTab.swift | 56 +- .../Containers/{ => Details}/LogsTab.swift | 48 +- .../Containers/{ => Details}/StatsTab.swift | 39 +- .../{ => Details}/TerminalTab.swift | 55 +- .../Containers/{ => Form}/RunSpec.swift | 9 +- .../Containers/{ => Form}/RunSpecForm.swift | 169 +++--- .../{ => Form}/RunSpecFormSupport.swift | 0 .../Containers/Store}/ContainersStore.swift | 38 +- .../Containers/Store}/RestartWatchdog.swift | 3 +- .../{ => Build}/BuildWorkspaceView.swift | 62 +-- .../{ => Registry}/RegistryImageSearch.swift | 81 ++- .../Images/{ => Style}/ImageStyleButton.swift | 6 +- .../Features/Onboarding/BootstrapView.swift | 58 +- .../Features/Palette/CommandPalette.swift | 12 +- .../Registries/RegistryLoginSheet.swift | 31 +- .../Features/Settings/ReleaseNotesView.swift | 6 +- .../Features/Settings/SettingsView.swift | 22 +- .../Settings/Store}/SettingsBackup.swift | 56 +- .../Settings/Store}/SettingsStore.swift | 38 +- .../Features/Settings/Tabs/AboutTab.swift | 28 +- .../Settings/Tabs/AppearanceTab.swift | 93 ++-- .../Settings/Tabs/ExperimentalTab.swift | 22 +- .../Features/Settings/Tabs/GeneralTab.swift | 46 +- .../Settings/Tabs/RegistriesTab.swift | 22 +- .../Features/Settings/Tabs/RuntimeTab.swift | 49 +- .../Features/Settings/Tabs/UpdatesTab.swift | 14 +- .../{ => Transfer}/ConfigTransfer.swift | 4 +- .../System/Components/SystemLogsSheet.swift | 20 +- .../System/Store}/RefreshCoordinator.swift | 0 .../Features/System/SystemView.swift | 140 ++--- .../Migration/DowngradeDecisionView.swift | 14 +- .../MenuBar/MenuBarContent.swift | 67 +-- .../Navigation/{ => Shell}/ClassicShell.swift | 63 ++- .../Navigation/{ => Shell}/RootView.swift | 37 +- .../Navigation/{ => State}/AppSection.swift | 0 .../{ => State}/PendingAction.swift | 0 .../Navigation/{ => State}/UIState.swift | 145 ++--- .../Navigation/{ => Toolbar}/AppToolbar.swift | 221 ++++---- .../Panels}/PaletteResultCard.swift | 121 ++-- .../Panels}/ToolbarCommandPalette.swift | 180 +++--- .../Panels}/ToolbarImageGroupCard.swift | 171 +++--- .../Panels}/ToolbarResourcePanels.swift | 45 +- .../Panels}/ToolbarSearchSource.swift | 29 +- .../Panels}/ToolbarUpdatesPanel.swift | 34 +- .../{ => Toolbar}/ToolbarFilterOptions.swift | 0 .../{ => Toolbar}/ToolbarViewOptions.swift | 72 +-- .../History/HistoryModels.swift | 0 .../History/HistoryStore.swift | 0 .../{ => Persistence}/History/Template.swift | 0 .../Personalization.swift | 47 +- .../PersonalizationStore.swift | 17 - .../WidgetConfiguration.swift | 20 +- .../Error}/AppErrorPresentation.swift | 5 +- .../Formatting}/Formatting.swift | 0 .../Links}/Links.swift | 0 .../Localization}/AppLocalization.swift | 24 +- .../ContainedApp/Previews/AppPreviews.swift | 70 --- Sources/ContainedApp/Resources/CHANGELOG.md | 8 +- .../Resources/Localizable.xcstrings | 2 +- .../Health}/HealthMonitor.swift | 3 +- .../Logging}/AppLogging.swift | 0 .../Notifications}/Notifier.swift | 0 .../Platform}/Platform.swift | 0 .../Updates}/UpdateChannel.swift | 0 .../Updates}/UpdaterController.swift | 0 .../ContainersStoreRefreshTests.swift | 8 +- .../PreviewFixtureMappingTests.swift | 42 ++ Tests/ContainedAppTests/RunSpecTests.swift | 46 +- .../UpdaterControllerTests.swift | 6 +- appcast.xml | 4 +- .../20260701-design-system-package.md | 2 +- .../unreleased/20260701-navigation-package.md | 2 +- .../20260701-runtime-abstraction.md | 2 +- changes/unreleased/20260702-card-anatomy.md | 4 +- .../20260702-refresh-stats-performance.md | 2 +- .../20260702-standalone-packages-xcode.md | 8 +- docs/app/Localization.md | 13 +- docs/architecture/Architecture.md | 62 +-- docs/architecture/Design-System.md | 145 ++--- docs/architecture/Runtime-Adapters.md | 145 +++-- docs/development/Contributing.md | 31 +- docs/features/Command-Palette.md | 8 +- docs/release/Release.md | 4 +- docs/wiki/README.md | 2 +- docs/wiki/_Sidebar.md | 3 +- 264 files changed, 6119 insertions(+), 6326 deletions(-) delete mode 100644 Packages/AppleContainerRuntime/Package.swift delete mode 100644 Packages/AppleContainerRuntime/README.md delete mode 100644 Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/AppleContainerRuntime.docc/AppleContainerRuntime.md delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures.swift delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/df.json delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/image-inspect.json delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/images-error.txt delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/inspect.json delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list-current.json delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list.json delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/networks.json delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats-table.txt delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats.json delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/status.json delete mode 100644 Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/volumes.json rename Packages/{ContainedRuntime/Sources/ContainedRuntime/Errors/CommandError.swift => ContainedCore/Sources/ContainedCore/Command/Error.swift} (94%) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Command/Preview.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift rename Packages/{ContainedRuntime/Sources/ContainedRuntime/Support/CommandRunner.swift => ContainedCore/Sources/ContainedCore/Command/Runner.swift} (100%) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Compose/API.swift rename Packages/{ContainedRuntime/Sources/ContainedRuntime/Translation/RuntimeTranslation.swift => ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift} (52%) rename Packages/ContainedCore/Sources/ContainedCore/{Models/ComposeProject.swift => Compose/YAML/Project.swift} (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models/ContainerCreateRequest.swift => Container/CreateRequest.swift} (100%) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift rename Packages/ContainedCore/Sources/ContainedCore/{Models => Container}/HealthCheck.swift (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models/ContainerJSON.swift => Container/JSONDecoding.swift} (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models => Container}/RestartPolicy.swift (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models/Container.swift => Container/Snapshot.swift} (95%) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Core.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift rename Packages/ContainedCore/Sources/ContainedCore/{Models => Image}/ImageResource.swift (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models/LocalImageTagGroup.swift => Image/LocalTagGroup.swift} (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models/ImageUpdateStatus.swift => Image/UpdateStatus.swift} (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models => Metrics}/ContainerStats.swift (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models => Metrics}/GraphMetric.swift (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models/StatsNormalization.swift => Metrics/Normalization.swift} (100%) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Migration/Plan.swift rename Packages/ContainedCore/Sources/ContainedCore/{Models => Network}/NetworkResource.swift (100%) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Orchestration/CoreOrchestrator.swift rename Packages/ContainedCore/Sources/ContainedCore/{Models => Registry}/HubSearch.swift (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models/RegistryLogin.swift => Registry/Login.swift} (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Support/RegistryManifestClient.swift => Registry/ManifestClient.swift} (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models => Registry}/RegistryImageReference.swift (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models/RuntimeKind.swift => Runtime/Kind.swift} (100%) rename Packages/{ContainedRuntime/Sources/ContainedRuntime/Clients => ContainedCore/Sources/ContainedCore/Runtime}/RuntimeDescriptor.swift (98%) rename Packages/ContainedCore/Sources/ContainedCore/{Models => Runtime}/RuntimeStatus.swift (100%) rename Packages/{AppleContainerRuntime/Sources/AppleContainerRuntime/Clients/AppleContainerClient.swift => ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Client/Client.swift} (65%) rename Packages/ContainedCore/Sources/ContainedCore/{Commands => Runtimes/AppleContainer/Command}/ContainerCommands.swift (70%) rename Packages/{AppleContainerRuntime/Sources/AppleContainerRuntime/Parsing/ContainerStatsTableParser.swift => ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Metrics/StatsTableParser.swift} (96%) rename Packages/{AppleContainerRuntime/Sources/AppleContainerRuntime/Support/AppleContainerCLILocator.swift => ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Support/CLILocator.swift} (83%) rename Packages/{AppleContainerRuntime/Sources/AppleContainerRuntime/Translation/AppleContainerCreateTranslator.swift => ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift} (94%) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Schema/Version.swift rename Packages/ContainedCore/Sources/ContainedCore/{Models/SystemProperties.swift => System/Properties.swift} (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models/SystemInfo.swift => System/SystemStatus.swift} (100%) rename Packages/ContainedCore/Sources/ContainedCore/{Models => Volume}/VolumeResource.swift (100%) create mode 100644 Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/AppleContainer/AppleContainerFixtures.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Generic/GenericFixtures.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Namespace.swift create mode 100644 Packages/ContainedCore/Tests/ContainedCoreFixturesTests/CoreFixturesTests.swift rename Packages/{ContainedRuntime/Tests/ContainedRuntimeTests => ContainedCore/Tests/ContainedCoreTests/Runtime}/RuntimeDescriptorTests.swift (98%) rename Packages/{AppleContainerRuntime/Tests/AppleContainerRuntimeTests/AppleContainerRuntimeTests.swift => ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift} (97%) delete mode 100644 Packages/ContainedDesignSystem/Package.swift delete mode 100644 Packages/ContainedDesignSystem/README.md delete mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md delete mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignActions.swift delete mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/InfoButton.swift delete mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/TintSelector.swift delete mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/CommandPreviewBar.swift delete mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/StreamConsole.swift delete mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Previews/DesignSystemPreviews.swift delete mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Support/MicroPrimitives.swift delete mode 100644 Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens/DesignTokens.swift delete mode 100644 Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/DesignCardLayoutPolicyTests.swift delete mode 100644 Packages/ContainedNavigation/Package.swift delete mode 100644 Packages/ContainedNavigation/README.md delete mode 100644 Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md delete mode 100644 Packages/ContainedPreviewSupport/Package.swift delete mode 100644 Packages/ContainedPreviewSupport/README.md delete mode 100644 Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md delete mode 100644 Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/Samples/PreviewSamples.swift delete mode 100644 Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift delete mode 100644 Packages/ContainedRuntime/Package.swift delete mode 100644 Packages/ContainedRuntime/README.md delete mode 100644 Packages/ContainedRuntime/Sources/ContainedRuntime/ContainedRuntime.docc/ContainedRuntime.md create mode 100644 Packages/ContainedUI/Package.swift create mode 100644 Packages/ContainedUI/README.md create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift => ContainedUI/Sources/ContainedUI/Card/CardChrome.swift} (80%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCard.swift => ContainedUI/Sources/ContainedUI/Card/CardScaffold.swift} (83%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift => ContainedUI/Sources/ContainedUI/Card/CardSurface.swift} (89%) create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Card/CardViewModifiers.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/LiveSparkline.swift => ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift} (95%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/DesignSparklineMetricTile.swift => ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift} (82%) create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Command/PreviewBar.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Console/ConsoleViewModifiers.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Console/StreamConsole.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Control/ControlViewModifiers.swift rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Controls => ContainedUI/Sources/ContainedUI/Control}/GradientAngleControl.swift (70%) create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Control/InfoButton.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassButton.swift => ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift} (75%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignOptionTile.swift => ContainedUI/Sources/ContainedUI/Control/OptionTile.swift} (86%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignRowMenu.swift => ContainedUI/Sources/ContainedUI/Control/RowMenu.swift} (87%) create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Controls => ContainedUI/Sources/ContainedUI/Control}/ToolbarControls.swift (80%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Panels => ContainedUI/Sources/ContainedUI/Panel}/PageScaffold.swift (92%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/DesignPanelScaffold.swift => ContainedUI/Sources/ContainedUI/Panel/PanelScaffold.swift} (91%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Panels => ContainedUI/Sources/ContainedUI/Panel}/PanelSection.swift (83%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/SheetHeader.swift => ContainedUI/Sources/ContainedUI/Panel/PanelSheetTitleBar.swift} (85%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelHeader.swift => ContainedUI/Sources/ContainedUI/Panel/PanelTitleBar.swift} (66%) create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Previews/UIPreviews.swift rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback => ContainedUI/Sources/ContainedUI/State}/ActivityStatusView.swift (83%) create mode 100644 Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ErrorToast.swift => ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift} (56%) create mode 100644 Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem => ContainedUI/Sources/ContainedUI}/Support/Clipboard.swift (100%) create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Support/NestedAPI.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces => ContainedUI/Sources/ContainedUI/Surface}/ExteriorShadow.swift (100%) create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/GlassSurface.swift => ContainedUI/Sources/ContainedUI/Surface/MaterialSurface.swift} (91%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces => ContainedUI/Sources/ContainedUI/Surface}/VisualEffectBackground.swift (96%) rename Packages/{ContainedDesignSystem/Sources/ContainedDesignSystem => ContainedUI/Sources/ContainedUI}/Theme/Theme.swift (87%) create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Tokens/UI.swift create mode 100644 Packages/ContainedUI/Tests/ContainedUITests/CardLayoutPolicyTests.swift rename Packages/{ContainedDesignSystem/Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift => ContainedUI/Tests/ContainedUITests/SparklineScalingTests.swift} (94%) create mode 100644 Packages/ContainedUX/Package.swift create mode 100644 Packages/ContainedUX/README.md create mode 100644 Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md rename Packages/{ContainedNavigation/Sources/ContainedNavigation/Measurement/MorphSourceFrames.swift => ContainedUX/Sources/ContainedUX/Measurement/SourceFrames.swift} (88%) rename Packages/{ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingExpander.swift => ContainedUX/Sources/ContainedUX/Morph/Expander.swift} (79%) rename Packages/{ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingSingleSurface.swift => ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift} (87%) rename Packages/{ContainedNavigation/Sources/ContainedNavigation => ContainedUX/Sources/ContainedUX}/Previews/NavigationPreviews.swift (56%) rename Packages/{ContainedNavigation/Sources/ContainedNavigation/SafeAreas/MorphSafeAreaManager.swift => ContainedUX/Sources/ContainedUX/SafeArea/SafeAreaManager.swift} (63%) create mode 100644 Packages/ContainedUX/Sources/ContainedUX/UX.swift rename Packages/{ContainedNavigation/Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift => ContainedUX/Tests/ContainedUXTests/SingleSurfaceTests.swift} (94%) rename Sources/ContainedApp/{Stores => App}/AppModel+Configuration.swift (97%) rename Sources/ContainedApp/{Stores => App}/AppModel+ImageUpdates.swift (99%) rename Sources/ContainedApp/{Stores => App}/AppModel+ResourceStyles.swift (100%) rename Sources/ContainedApp/{Stores => App}/AppModel.swift (92%) rename Sources/ContainedApp/{ => App}/ContainedApp.swift (97%) rename Sources/ContainedApp/{History => Features/Activity}/ActivityView.swift (84%) rename Sources/ContainedApp/{History => Features/Activity}/ContainerHistoryTab.swift (80%) rename Sources/ContainedApp/Features/Containers/{ => Card}/ContainerCard.swift (91%) rename Sources/ContainedApp/Features/Containers/{ => Card}/ContainersGridView.swift (85%) rename Sources/ContainedApp/Features/Containers/{ => Creation}/ComposeImport.swift (97%) rename Sources/ContainedApp/Features/Containers/{ => Customization}/ContainerCustomizeButton.swift (87%) rename Sources/ContainedApp/Features/Containers/{ => Customization}/ContainerViewOptions.swift (100%) rename Sources/ContainedApp/Features/Containers/{ => Customization}/CustomizeSheet.swift (86%) rename Sources/ContainedApp/Features/Containers/{ => Customization}/CustomizeWidgetsPanel.swift (84%) rename Sources/ContainedApp/Features/Containers/{ => Details}/ContainerConfigureView.swift (81%) rename Sources/ContainedApp/Features/Containers/{ => Details}/ContainerEditSheet.swift (91%) rename Sources/ContainedApp/Features/Containers/{ => Details}/ContainerOverviewTab.swift (85%) rename Sources/ContainedApp/Features/Containers/{ => Details}/ContainerTabScaffold.swift (88%) rename Sources/ContainedApp/Features/Containers/{ => Details}/FilesTab.swift (71%) rename Sources/ContainedApp/Features/Containers/{ => Details}/LogsTab.swift (67%) rename Sources/ContainedApp/Features/Containers/{ => Details}/StatsTab.swift (71%) rename Sources/ContainedApp/Features/Containers/{ => Details}/TerminalTab.swift (73%) rename Sources/ContainedApp/Features/Containers/{ => Form}/RunSpec.swift (97%) rename Sources/ContainedApp/Features/Containers/{ => Form}/RunSpecForm.swift (79%) rename Sources/ContainedApp/Features/Containers/{ => Form}/RunSpecFormSupport.swift (100%) rename Sources/ContainedApp/{Stores => Features/Containers/Store}/ContainersStore.swift (92%) rename Sources/ContainedApp/{Stores => Features/Containers/Store}/RestartWatchdog.swift (97%) rename Sources/ContainedApp/Features/Images/{ => Build}/BuildWorkspaceView.swift (75%) rename Sources/ContainedApp/Features/Images/{ => Registry}/RegistryImageSearch.swift (74%) rename Sources/ContainedApp/Features/Images/{ => Style}/ImageStyleButton.swift (92%) rename Sources/ContainedApp/{Stores => Features/Settings/Store}/SettingsBackup.swift (87%) rename Sources/ContainedApp/{Stores => Features/Settings/Store}/SettingsStore.swift (89%) rename Sources/ContainedApp/Features/Settings/{ => Transfer}/ConfigTransfer.swift (96%) rename Sources/ContainedApp/{Stores => Features/System/Store}/RefreshCoordinator.swift (100%) rename Sources/ContainedApp/{Features => Navigation}/MenuBar/MenuBarContent.swift (79%) rename Sources/ContainedApp/Navigation/{ => Shell}/ClassicShell.swift (86%) rename Sources/ContainedApp/Navigation/{ => Shell}/RootView.swift (90%) rename Sources/ContainedApp/Navigation/{ => State}/AppSection.swift (100%) rename Sources/ContainedApp/Navigation/{ => State}/PendingAction.swift (100%) rename Sources/ContainedApp/Navigation/{ => State}/UIState.swift (72%) rename Sources/ContainedApp/Navigation/{ => Toolbar}/AppToolbar.swift (68%) rename Sources/ContainedApp/Navigation/{ToolbarPanels => Toolbar/Panels}/PaletteResultCard.swift (68%) rename Sources/ContainedApp/Navigation/{ToolbarPanels => Toolbar/Panels}/ToolbarCommandPalette.swift (69%) rename Sources/ContainedApp/Navigation/{ToolbarPanels => Toolbar/Panels}/ToolbarImageGroupCard.swift (83%) rename Sources/ContainedApp/Navigation/{ToolbarPanels => Toolbar/Panels}/ToolbarResourcePanels.swift (84%) rename Sources/ContainedApp/Navigation/{ToolbarPanels => Toolbar/Panels}/ToolbarSearchSource.swift (59%) rename Sources/ContainedApp/Navigation/{ToolbarPanels => Toolbar/Panels}/ToolbarUpdatesPanel.swift (86%) rename Sources/ContainedApp/Navigation/{ => Toolbar}/ToolbarFilterOptions.swift (100%) rename Sources/ContainedApp/Navigation/{ => Toolbar}/ToolbarViewOptions.swift (85%) rename Sources/ContainedApp/{ => Persistence}/History/HistoryModels.swift (100%) rename Sources/ContainedApp/{ => Persistence}/History/HistoryStore.swift (100%) rename Sources/ContainedApp/{ => Persistence}/History/Template.swift (100%) rename Sources/ContainedApp/{Support => Personalization}/Personalization.swift (76%) rename Sources/ContainedApp/{Support => Personalization}/PersonalizationStore.swift (91%) rename Sources/ContainedApp/{Support => Personalization}/WidgetConfiguration.swift (87%) rename Sources/ContainedApp/{Support => Presentation/Error}/AppErrorPresentation.swift (96%) rename Sources/ContainedApp/{Support => Presentation/Formatting}/Formatting.swift (100%) rename Sources/ContainedApp/{Support => Presentation/Links}/Links.swift (100%) rename Sources/ContainedApp/{Support => Presentation/Localization}/AppLocalization.swift (98%) delete mode 100644 Sources/ContainedApp/Previews/AppPreviews.swift rename Sources/ContainedApp/{Support => Services/Health}/HealthMonitor.swift (98%) rename Sources/ContainedApp/{Support => Services/Logging}/AppLogging.swift (100%) rename Sources/ContainedApp/{Support => Services/Notifications}/Notifier.swift (100%) rename Sources/ContainedApp/{Support => Services/Platform}/Platform.swift (100%) rename Sources/ContainedApp/{Support => Services/Updates}/UpdateChannel.swift (100%) rename Sources/ContainedApp/{Support => Services/Updates}/UpdaterController.swift (100%) create mode 100644 Tests/ContainedAppTests/PreviewFixtureMappingTests.swift diff --git a/AGENTS.md b/AGENTS.md index f66751d2..54010c8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,13 @@ This file is the working contract for coding agents in this repository. Follow i - Local reusable packages live under `Packages/` and are consumed by the root SwiftPM package and the native Xcode app target. - `Contained.xcworkspace` is the Xcode entry point. `Contained.xcodeproj` contains a native macOS app target that links the `ContainedApp` package product and builds/runs `Contained.app` directly from Xcode. - SwiftPM remains the CI, release, packaging, signing, notarization, and appcast source of truth. Keep `Package.swift`, `swift build`, `swift test`, and `scripts/bundle.sh` working. -- `Packages/ContainedCore/Sources/ContainedCore` is pure/testable logic. Keep SwiftUI, app state, Sparkle, and persistence out of it. -- `Packages/ContainedRuntime/Sources/ContainedRuntime` is the shared runtime contract module. Keep it adapter-neutral so future runtimes can conform without changing app stores or views. -- `Packages/AppleContainerRuntime/Sources/AppleContainerRuntime` is the Apple `container` adapter. Future Docker-compatible, Podman, Lima-backed, remote, or other engines should be sibling adapter packages rather than switches in the app. +- `Packages/ContainedCore/Sources/ContainedCore` is the single backend/orchestration package. It owns pure models, runtime descriptors/capabilities, command execution, Compose import/export semantics, Apple `container` adapter internals, metrics, import/export planning, and typed display-neutral package errors. Keep SwiftUI, app state, Sparkle, SwiftTerm, localization resources, and persistence out of it. +- `ContainedCore` exposes app-facing backend APIs through `Core.*` namespaces. `Core.Orchestrator` is the only backend object the app should own. Runtime adapters live inside Core under adapter folders so future Docker-compatible, Podman, Lima-backed, remote, or other engines can plug in without becoming app switches. - `Sources/ContainedApp` is the app implementation: SwiftUI screens, app-specific presentation mappings, navigation, stores, history, settings, localization, and update support. - `Sources/Contained` is only the tiny SwiftPM executable launcher. -- `Packages/ContainedDesignSystem` is the reusable SwiftUI/AppKit design-system package. Keep app state, stores, Sparkle, SwiftData, persistence, and feature routing out of it. -- `Packages/ContainedNavigation` is the reusable navigation/layout package. Keep app sections, toolbar panels, stores, and concrete routing state in `Sources/ContainedApp`. -- `Packages/ContainedPreviewSupport` is deterministic fixture data for package examples and SwiftUI previews. Keep live runtime calls and localized copy out of it. +- `Packages/ContainedUI` is the reusable SwiftUI/AppKit design-system package. Keep app state, stores, Sparkle, SwiftData, persistence, and feature routing out of it. +- `Packages/ContainedUX` is the reusable navigation/layout package. Keep app sections, toolbar panels, stores, and concrete routing state in `Sources/ContainedApp`. +- `Packages/ContainedCore` also exposes a separate `ContainedCoreFixtures` product for deterministic dev/test sample data under `Core.Fixtures.*`. Normal app, debug bundle, release, notarized, and non-notarized distributable builds must not depend on or link that fixture product. - `docs/` is structured by audience and ownership. User-facing behavior or workflow changes should update the matching page under `docs/app`, `docs/features`, `docs/development`, `docs/architecture`, or `docs/release`. - Package docs live beside each package as README + DocC. Keep package examples working and app-supplied strings explicit. - Keep directory names intentional: SwiftPM-owned folders stay `Sources` and `Tests`, Swift source domain folders use PascalCase, and repo infrastructure uses lowercase names such as `docs` and `scripts`. @@ -54,16 +53,17 @@ This file is the working contract for coding agents in this repository. Follow i ## Design And UI Rules -- Reuse app-facing design-system routes before adding local styling: `PanelHeader`, `PanelSection`, `PanelField`, `DesignCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignToggleButton`, `DesignSelectionActionBar`, `CommandPreviewBar`, `TintSelector`, and `DesignTokens`. -- Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or micro-chrome constants. Add or extend a `ContainedDesignSystem` token/primitive first, then consume it from the app. Low-level package composition pieces such as card shell/header/page-rail assembly, glass button groups, and glass surface modifiers are package-internal and should not be reintroduced in `Sources/ContainedApp`. +- Reuse app-facing `ContainedUI` routes before adding local styling: `UI.Card.Scaffold`, `UI.Panel.Scaffold`, `UI.Panel.Header`, `UI.Panel.Section`, `UI.Panel.Field`, `UI.Action.Group`, `UI.Action.TextButton`, `UI.Action.ToggleButton`, `UI.Action.SelectionBar`, `UI.Surface.Content`, `UI.Surface.Input`, `UI.Control.TintSelector`, and `UI.Chart.Sparkline`. +- Do not add app-local spacing, padding, radius, shadow, material, opacity, material button styles, or micro-chrome constants. Add or extend a `ContainedUI` primitive first, then consume it from the app through nested element routes such as `UI.Panel.Padding.top`, `UI.Card.Radius.container`, and `UI.Toolbar.Size.controlHeight`. `UI.Tokens` is the raw token source for `ContainedUI` internals; `ContainedUX` and `Sources/ContainedApp` use contextual element tokens. +- Low-level package composition pieces such as card shell/header/page-rail assembly, material button groups, and material surface modifiers are package-internal and should not be reintroduced in `Sources/ContainedApp`. - Keep the classic sidebar fallback working. Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements. - Prefer native macOS/Liquid Glass behavior over custom chrome when the system primitive fits. - Do not make broad visual changes without a product reason. ## Coding Rules -- Keep Apple `container` CLI actions behind `ContainerCommands` and `AppleContainerRuntime`; do not assemble argv inline in SwiftUI. App stores should depend on `any ContainerRuntimeClient` where backend choice matters. -- Put pure decision logic in `ContainedCore` with focused tests. +- Keep Apple `container` CLI actions behind `ContainedCore` adapter internals and Core command-preview routes; do not assemble argv inline in SwiftUI. App stores should call `Core.Orchestrator`, not adapter clients or runtime protocols. +- Put pure decision logic and backend orchestration in `ContainedCore` with focused tests. - Keep localization owned by `Sources/ContainedApp`. Packages should receive app-supplied labels/help/accessibility strings and should not add English UI defaults or localized resource bundles. Use `AppText` for reusable app copy @@ -78,6 +78,7 @@ This file is the working contract for coding agents in this repository. Follow i - Keep helper scripts in `scripts/` and prefer hyphenated file names for multi-word shell scripts. - Keep comments human and useful. Explain surprising intent, not obvious syntax. - Debug-only tools, menus, and diagnostics must be guarded with `#if CONTAINED_DEBUG_TOOLS`; SwiftPM defines it only for debug builds so release bundles exclude that code. +- Fixture-backed preview/test helpers must be guarded with `CONTAINED_CORE_FIXTURES` and live in fixture, test, preview, or sandbox-only targets. Do not use plain `DEBUG` for sample data inclusion. - Avoid large file reshuffles unless they reduce real complexity or match existing ownership boundaries. ## Verification diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ce7985..7645a55b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,7 @@ #### Personalization & Design System - Local-only personalization for containers, image groups, image tags, and volumes, including nickname, icon, tint, background, graph/widget choices, and inheritance from image or app defaults. -- Shared Liquid Glass design system primitives: `DesignCard`, `DesignContentSurface`, `DesignInputSurface`, `DesignOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. +- Shared Liquid Glass design system primitives: `UI.Card.Scaffold`, `UI.Surface.Content`, `UI.Surface.Input`, `UI.Control.OptionTile`, `UI.Panel.Scaffold`, `UI.Panel.Header`, `UI.Panel.Section`, `UI.Panel.Row`, `UI.Panel.Field`, `UI.Command.PreviewBar`, `UI.Control.InfoButton`, `UI.Control.TintSelector`, `UI.Console.Stream`, `UI.State.ActivityStatusIndicator`, and toolbar controls. - Accessibility-aware custom visual effects and motion handling, including Reduce Transparency and Reduce Motion support where the app supplies custom glass or animation. - Shared page and panel scaffolds so sidebar pages, sheets, and toolbar morphs can reuse content without duplicate layouts. @@ -93,9 +93,9 @@ ### Technical -- Swift Package layout with a pure `ContainedCore` library for CLI command builders, JSON models, compose parsing, decision helpers, and service logic, plus a `Contained` SwiftUI executable for UI, stores, Sparkle, SwiftData, and migration. +- Swift Package layout with `ContainedCore` for backend orchestration, `ContainedUI` for visual primitives, `ContainedUX` for interaction infrastructure, `ContainedApp` for app-owned SwiftUI/persistence/localization policy, and a tiny `Contained` executable launcher. - `ContainerCommands` is the single source of truth for `container` argv construction and is covered by golden tests. -- `ContainedRuntime` defines the shared runtime contract, while `AppleContainerRuntime` exposes typed async methods over real `container --format json` output and typed stats streams. +- `ContainedCore` now owns the shared runtime contract, Core orchestrator, Apple `container` adapter internals, typed async runtime methods, and typed stats streams. - `RunSpec` is the single source of truth for Run/Edit form state, validation, CLI preview, and actual execution. - `AppModel` owns bootstrap, client wiring, stores, refresh coordination, image updates, service lifecycle, config transfer, and resource-style lookup through focused extensions. - `UIState`, `AppSection`, toolbar option enums, and pending actions centralize navigation, filters, morph routing, and classic fallback routing. @@ -105,6 +105,6 @@ ### Migration Notes -- Saved local container, image, and volume styles are preserved and migrated away from legacy decorative `contained.*` labels where possible. +- Saved local container, image, and volume styles are kept in local app storage. - Local settings, personalization, health checks, templates, and activity history can be exported before resetting data created by a newer app schema. - Activity events created before unread tracking are treated as unread on first launch. diff --git a/CODEOWNERS b/CODEOWNERS index 9e0c3aed..a84cae50 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -3,11 +3,8 @@ # Core app areas. /Packages/ContainedCore/ @tdeverx -/Packages/ContainedRuntime/ @tdeverx -/Packages/AppleContainerRuntime/ @tdeverx -/Packages/ContainedDesignSystem/ @tdeverx -/Packages/ContainedNavigation/ @tdeverx -/Packages/ContainedPreviewSupport/ @tdeverx +/Packages/ContainedUI/ @tdeverx +/Packages/ContainedUX/ @tdeverx /Sources/Contained/ @tdeverx /Sources/ContainedApp/ @tdeverx /Tests/ @tdeverx diff --git a/Contained.xcworkspace/contents.xcworkspacedata b/Contained.xcworkspace/contents.xcworkspacedata index 243ba30c..0cc90ad5 100644 --- a/Contained.xcworkspace/contents.xcworkspacedata +++ b/Contained.xcworkspace/contents.xcworkspacedata @@ -8,18 +8,9 @@ location = "group:Packages/ContainedCore/Package.swift"> + location = "group:Packages/ContainedUI/Package.swift"> - - - - - - + location = "group:Packages/ContainedUX/Package.swift"> diff --git a/Package.swift b/Package.swift index 930a21e1..5b679f8f 100644 --- a/Package.swift +++ b/Package.swift @@ -11,11 +11,8 @@ let package = Package( ], dependencies: [ .package(path: "Packages/ContainedCore"), - .package(path: "Packages/ContainedRuntime"), - .package(path: "Packages/AppleContainerRuntime"), - .package(path: "Packages/ContainedDesignSystem"), - .package(path: "Packages/ContainedNavigation"), - .package(path: "Packages/ContainedPreviewSupport"), + .package(path: "Packages/ContainedUI"), + .package(path: "Packages/ContainedUX"), // Mature VT100/xterm emulator + PTY host for the in-container terminal. AppKit-backed, // bridged through NSViewRepresentable in the app target. .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0"), @@ -29,11 +26,8 @@ let package = Package( name: "ContainedApp", dependencies: [ .product(name: "ContainedCore", package: "ContainedCore"), - .product(name: "ContainedRuntime", package: "ContainedRuntime"), - .product(name: "AppleContainerRuntime", package: "AppleContainerRuntime"), - .product(name: "ContainedDesignSystem", package: "ContainedDesignSystem"), - .product(name: "ContainedNavigation", package: "ContainedNavigation"), - .product(name: "ContainedPreviewSupport", package: "ContainedPreviewSupport"), + .product(name: "ContainedUI", package: "ContainedUI"), + .product(name: "ContainedUX", package: "ContainedUX"), .product(name: "SwiftTerm", package: "SwiftTerm"), .product(name: "Sparkle", package: "Sparkle"), ], @@ -49,15 +43,14 @@ let package = Package( dependencies: ["ContainedApp"], path: "Sources/Contained" ), - // Tests for app-target value types (RunSpec form state and runtime-translated create mapping). - // Imports the shared app module with @testable. + // Tests for app-owned value types, runtime mapping, and fixture-to-presentation mapping. + // Imports the shared app module with @testable and keeps fixtures out of the app target. .testTarget( name: "ContainedAppTests", dependencies: [ "ContainedApp", .product(name: "ContainedCore", package: "ContainedCore"), - .product(name: "ContainedRuntime", package: "ContainedRuntime"), - .product(name: "AppleContainerRuntime", package: "AppleContainerRuntime"), + .product(name: "ContainedCoreFixtures", package: "ContainedCore"), ], path: "Tests/ContainedAppTests" ), diff --git a/Packages/AppleContainerRuntime/Package.swift b/Packages/AppleContainerRuntime/Package.swift deleted file mode 100644 index 10c0ea1c..00000000 --- a/Packages/AppleContainerRuntime/Package.swift +++ /dev/null @@ -1,32 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let package = Package( - name: "AppleContainerRuntime", - platforms: [.macOS(.v26)], - products: [ - .library(name: "AppleContainerRuntime", targets: ["AppleContainerRuntime"]), - ], - dependencies: [ - .package(path: "../ContainedCore"), - .package(path: "../ContainedRuntime"), - ], - targets: [ - .target( - name: "AppleContainerRuntime", - dependencies: [ - .product(name: "ContainedCore", package: "ContainedCore"), - .product(name: "ContainedRuntime", package: "ContainedRuntime"), - ] - ), - .testTarget( - name: "AppleContainerRuntimeTests", - dependencies: [ - "AppleContainerRuntime", - .product(name: "ContainedCore", package: "ContainedCore"), - .product(name: "ContainedRuntime", package: "ContainedRuntime"), - ], - resources: [.copy("Fixtures")] - ), - ] -) diff --git a/Packages/AppleContainerRuntime/README.md b/Packages/AppleContainerRuntime/README.md deleted file mode 100644 index cdf37a0c..00000000 --- a/Packages/AppleContainerRuntime/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# AppleContainerRuntime - -AppleContainerRuntime is the concrete adapter for Apple's `container` CLI. It -conforms to `ContainerRuntimeClient` and keeps Apple-specific translation out of -the app. - -## Owns - -- `AppleContainerClient`, the current `container` runtime client. -- Apple CLI discovery through `AppleContainerCLILocator`. -- Apple create/import/default translation from `ContainerCreateRequest`. -- Apple stats-table parsing for `container stats --format table`. - -## Does Not Own - -- SwiftUI views or app stores. -- Localized copy or app error presentation. -- Docker-compatible, Podman, Lima, remote, or future runtime behavior. - -Future engines should be sibling packages that conform to -`ContainerRuntimeClient` rather than switches inside the app. - -## Example - -```swift -import AppleContainerRuntime -import ContainedCore - -let client = AppleContainerClient() -let preview = try client.previewCreateCommand(for: ContainerCreateRequest()) -print(preview.command) -``` - -## Build And Test - -```sh -swift build --package-path Packages/AppleContainerRuntime -swift test --package-path Packages/AppleContainerRuntime -``` diff --git a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/AppleContainerRuntime.docc/AppleContainerRuntime.md b/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/AppleContainerRuntime.docc/AppleContainerRuntime.md deleted file mode 100644 index 42ae6baf..00000000 --- a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/AppleContainerRuntime.docc/AppleContainerRuntime.md +++ /dev/null @@ -1,12 +0,0 @@ -# ``AppleContainerRuntime`` - -Apple `container` adapter for the shared runtime contract. - -## Overview - -This package owns Apple-specific process execution, decode behavior, create and -Compose translation, and stats parsing. It depends on `ContainedCore` for pure -models and on `ContainedRuntime` for the adapter protocol. - -The app should talk to this package through `ContainerRuntimeClient`. SwiftUI -views should not assemble Apple CLI argv directly. diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures.swift b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures.swift deleted file mode 100644 index 3cb86854..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation -import ContainedRuntime - -/// Loads a captured CLI fixture from the test bundle. -enum Fixture { - static func data(_ name: String, ext: String = "json") throws -> Data { - guard let url = Bundle.module.url(forResource: name, withExtension: ext, subdirectory: "Fixtures") else { - throw FixtureError.notFound("\(name).\(ext)") - } - return try Data(contentsOf: url) - } - - static func string(_ name: String, ext: String = "txt") throws -> String { - String(decoding: try data(name, ext: ext), as: UTF8.self) - } - - enum FixtureError: Error { case notFound(String) } -} - -/// A `CommandRunning` that replays canned output / errors with no runtime daemon. -struct MockCommandRunner: CommandRunning { - var result: Result - var streamChunks: [String] = [] - - func run(_ arguments: [String], - stdin: Data?, - priority: CommandExecutionPriority) async throws -> Data { - try result.get() - } - - func stream(_ arguments: [String], priority: CommandExecutionPriority) -> AsyncThrowingStream { - let chunks = streamChunks - return AsyncThrowingStream { continuation in - for chunk in chunks { continuation.yield(chunk) } - continuation.finish() - } - } -} diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/df.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/df.json deleted file mode 100644 index 308b06ce..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/df.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "containers" : { - "active" : 0, - "reclaimable" : 4074598400, - "sizeInBytes" : 4074598400, - "total" : 3 - }, - "images" : { - "active" : 3, - "reclaimable" : 1991077888, - "sizeInBytes" : 6629847040, - "total" : 11 - }, - "volumes" : { - "active" : 0, - "reclaimable" : 0, - "sizeInBytes" : 0, - "total" : 0 - } -} diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/image-inspect.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/image-inspect.json deleted file mode 100644 index 8143f75d..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/image-inspect.json +++ /dev/null @@ -1,526 +0,0 @@ -[ - { - "configuration" : { - "creationDate" : "2026-06-16T00:00:15Z", - "descriptor" : { - "digest" : "sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b", - "mediaType" : "application\/vnd.oci.image.index.v1+json", - "size" : 9218 - }, - "name" : "docker.io\/library\/alpine:latest" - }, - "id" : "28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b", - "variants" : [ - { - "config" : { - "architecture" : "amd64", - "config" : { - "Cmd" : [ - "\/bin\/sh" - ], - "Env" : [ - "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" - ], - "WorkingDir" : "\/" - }, - "created" : "2026-06-16T00:01:29.967161902Z", - "history" : [ - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:01:29.967161902Z", - "created_by" : "ADD alpine-minirootfs-3.24.1-x86_64.tar.gz \/ # buildkit" - }, - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:01:29.967161902Z", - "created_by" : "CMD [\"\/bin\/sh\"]", - "empty_layer" : true - } - ], - "os" : "linux", - "rootfs" : { - "diff_ids" : [ - "sha256:34884abbe92863fce933ed7c39c0e045631af0ed86d5cc0dfbdf9fdca426ce3c" - ], - "type" : "layers" - } - }, - "digest" : "sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f", - "platform" : { - "architecture" : "amd64", - "os" : "linux" - }, - "size" : 3848024 - }, - { - "config" : { - "architecture" : "unknown", - "config" : { - - }, - "os" : "unknown", - "rootfs" : { - "diff_ids" : [ - "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", - "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" - ], - "type" : "layers" - } - }, - "digest" : "sha256:f21951a6120df0f5f9329311202f7869e7b70120f4748632d58753cff662b126", - "platform" : { - "architecture" : "unknown", - "os" : "unknown" - }, - "size" : 86390 - }, - { - "config" : { - "architecture" : "arm", - "config" : { - "Cmd" : [ - "\/bin\/sh" - ], - "Env" : [ - "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" - ], - "WorkingDir" : "\/" - }, - "created" : "2026-06-16T00:00:25.329026823Z", - "history" : [ - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:00:25.329026823Z", - "created_by" : "ADD alpine-minirootfs-3.24.1-armhf.tar.gz \/ # buildkit" - }, - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:00:25.329026823Z", - "created_by" : "CMD [\"\/bin\/sh\"]", - "empty_layer" : true - } - ], - "os" : "linux", - "rootfs" : { - "diff_ids" : [ - "sha256:78ed6782dc1bda42a805a54b6a8cf65a497ca4e409bc5bfe00e38ec85bdf826d" - ], - "type" : "layers" - }, - "variant" : "v6" - }, - "digest" : "sha256:bc301c70d7e7b929e5d3251d08ecc2e2894c22580958f79298052ed73ada4be0", - "platform" : { - "architecture" : "arm", - "os" : "linux", - "variant" : "v6" - }, - "size" : 3555096 - }, - { - "config" : { - "architecture" : "unknown", - "config" : { - - }, - "os" : "unknown", - "rootfs" : { - "diff_ids" : [ - "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", - "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" - ], - "type" : "layers" - } - }, - "digest" : "sha256:9ebfa72c35ca370f63e20c92dfa3ac11b67f753b193cc345aa2b23f89241227c", - "platform" : { - "architecture" : "unknown", - "os" : "unknown" - }, - "size" : 86118 - }, - { - "config" : { - "architecture" : "arm", - "config" : { - "Cmd" : [ - "\/bin\/sh" - ], - "Env" : [ - "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" - ], - "WorkingDir" : "\/" - }, - "created" : "2026-06-16T00:00:26.526765088Z", - "history" : [ - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:00:26.526765088Z", - "created_by" : "ADD alpine-minirootfs-3.24.1-armv7.tar.gz \/ # buildkit" - }, - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:00:26.526765088Z", - "created_by" : "CMD [\"\/bin\/sh\"]", - "empty_layer" : true - } - ], - "os" : "linux", - "rootfs" : { - "diff_ids" : [ - "sha256:1e30d7d82ee088ef08d915b4394ec1aa51a54d871dcd0770dc7503bee836e489" - ], - "type" : "layers" - }, - "variant" : "v7" - }, - "digest" : "sha256:48bf253520b161ff0f7bd9c6b2aded4126fa8ee2bc29386580b2a8a0322a1742", - "platform" : { - "architecture" : "arm", - "os" : "linux", - "variant" : "v7" - }, - "size" : 3262261 - }, - { - "config" : { - "architecture" : "unknown", - "config" : { - - }, - "os" : "unknown", - "rootfs" : { - "diff_ids" : [ - "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", - "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" - ], - "type" : "layers" - } - }, - "digest" : "sha256:b47a7a1760540077a1aefc00795604cd7e1b2acf556312118e10806f9a58d072", - "platform" : { - "architecture" : "unknown", - "os" : "unknown" - }, - "size" : 86390 - }, - { - "config" : { - "architecture" : "arm64", - "config" : { - "Cmd" : [ - "\/bin\/sh" - ], - "Env" : [ - "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" - ], - "WorkingDir" : "\/" - }, - "created" : "2026-06-16T00:01:20.474100947Z", - "history" : [ - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:01:20.474100947Z", - "created_by" : "ADD alpine-minirootfs-3.24.1-aarch64.tar.gz \/ # buildkit" - }, - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:01:20.474100947Z", - "created_by" : "CMD [\"\/bin\/sh\"]", - "empty_layer" : true - } - ], - "os" : "linux", - "rootfs" : { - "diff_ids" : [ - "sha256:b2848c02ac6ff53d265469b5b30f649f335e546a83330cd8916d54e65e640409" - ], - "type" : "layers" - }, - "variant" : "v8" - }, - "digest" : "sha256:e7a1a92a5bfeee40966aea60f0796b0e7917cc35591542701834f03a68fa3d18", - "platform" : { - "architecture" : "arm64", - "os" : "linux", - "variant" : "v8" - }, - "size" : 4184689 - }, - { - "config" : { - "architecture" : "unknown", - "config" : { - - }, - "os" : "unknown", - "rootfs" : { - "diff_ids" : [ - "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", - "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" - ], - "type" : "layers" - } - }, - "digest" : "sha256:d9dc32c63a23ac682a41ab2eae01051d2a4fbe472eefd109faf97be63a5216e5", - "platform" : { - "architecture" : "unknown", - "os" : "unknown" - }, - "size" : 86390 - }, - { - "config" : { - "architecture" : "386", - "config" : { - "Cmd" : [ - "\/bin\/sh" - ], - "Env" : [ - "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" - ], - "WorkingDir" : "\/" - }, - "created" : "2026-06-16T00:01:19.360099979Z", - "history" : [ - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:01:19.360099979Z", - "created_by" : "ADD alpine-minirootfs-3.24.1-x86.tar.gz \/ # buildkit" - }, - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:01:19.360099979Z", - "created_by" : "CMD [\"\/bin\/sh\"]", - "empty_layer" : true - } - ], - "os" : "linux", - "rootfs" : { - "diff_ids" : [ - "sha256:20868c90c269dd93125a9ddea2bec8b326ccfc8229d85a7456ee028a165b29b1" - ], - "type" : "layers" - } - }, - "digest" : "sha256:6f5908cdf811d574b30ec394e405ef74ee293bed5af1620a5187d604604a90a8", - "platform" : { - "architecture" : "386", - "os" : "linux" - }, - "size" : 3671765 - }, - { - "config" : { - "architecture" : "unknown", - "config" : { - - }, - "os" : "unknown", - "rootfs" : { - "diff_ids" : [ - "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", - "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" - ], - "type" : "layers" - } - }, - "digest" : "sha256:bf1792d3b17c3493e465c13b8357c5f60c2acc36cb69aab0813d4e7f1995ac2b", - "platform" : { - "architecture" : "unknown", - "os" : "unknown" - }, - "size" : 86390 - }, - { - "config" : { - "architecture" : "ppc64le", - "config" : { - "Cmd" : [ - "\/bin\/sh" - ], - "Env" : [ - "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" - ], - "WorkingDir" : "\/" - }, - "created" : "2026-06-16T00:00:15.017984356Z", - "history" : [ - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:00:15.017984356Z", - "created_by" : "ADD alpine-minirootfs-3.24.1-ppc64le.tar.gz \/ # buildkit" - }, - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:00:15.017984356Z", - "created_by" : "CMD [\"\/bin\/sh\"]", - "empty_layer" : true - } - ], - "os" : "linux", - "rootfs" : { - "diff_ids" : [ - "sha256:8c55296752dfcaf17e3fa825036cfe5050c55402025b508314048e741c9a5f16" - ], - "type" : "layers" - } - }, - "digest" : "sha256:a30366c2d2645b131e92b797b39357db29f197966c660db2767f143353000d6f", - "platform" : { - "architecture" : "ppc64le", - "os" : "linux" - }, - "size" : 3815039 - }, - { - "config" : { - "architecture" : "unknown", - "config" : { - - }, - "os" : "unknown", - "rootfs" : { - "diff_ids" : [ - "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", - "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" - ], - "type" : "layers" - } - }, - "digest" : "sha256:7a21929832908e662272c6e3333f68c662e3854ad4956e49d995251dcd9ccbfe", - "platform" : { - "architecture" : "unknown", - "os" : "unknown" - }, - "size" : 86390 - }, - { - "config" : { - "architecture" : "riscv64", - "config" : { - "Cmd" : [ - "\/bin\/sh" - ], - "Env" : [ - "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" - ], - "WorkingDir" : "\/" - }, - "created" : "2026-06-16T05:59:15.56408535Z", - "history" : [ - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T05:59:15.56408535Z", - "created_by" : "ADD alpine-minirootfs-3.24.1-riscv64.tar.gz \/ # buildkit" - }, - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T05:59:15.56408535Z", - "created_by" : "CMD [\"\/bin\/sh\"]", - "empty_layer" : true - } - ], - "os" : "linux", - "rootfs" : { - "diff_ids" : [ - "sha256:d806e99d43aeb11f79a974b2623809f37ead271ba59a954bd643342d3ae87e71" - ], - "type" : "layers" - } - }, - "digest" : "sha256:20a26477b54fb521bc8f633ea0af1f8f9b3dac5661739f857b381b117226919b", - "platform" : { - "architecture" : "riscv64", - "os" : "linux" - }, - "size" : 3575994 - }, - { - "config" : { - "architecture" : "unknown", - "config" : { - - }, - "os" : "unknown", - "rootfs" : { - "diff_ids" : [ - "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", - "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" - ], - "type" : "layers" - } - }, - "digest" : "sha256:83b3fe59c5ee68a257a6e179c792b9bd5b0aceb02ceb2813f14eef469a020c00", - "platform" : { - "architecture" : "unknown", - "os" : "unknown" - }, - "size" : 86390 - }, - { - "config" : { - "architecture" : "s390x", - "config" : { - "Cmd" : [ - "\/bin\/sh" - ], - "Env" : [ - "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin" - ], - "WorkingDir" : "\/" - }, - "created" : "2026-06-16T00:00:21.879382071Z", - "history" : [ - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:00:21.879382071Z", - "created_by" : "ADD alpine-minirootfs-3.24.1-s390x.tar.gz \/ # buildkit" - }, - { - "comment" : "buildkit.dockerfile.v0", - "created" : "2026-06-16T00:00:21.879382071Z", - "created_by" : "CMD [\"\/bin\/sh\"]", - "empty_layer" : true - } - ], - "os" : "linux", - "rootfs" : { - "diff_ids" : [ - "sha256:ff89eb082f59748c49390592d343caad75d5a0f38be634c4a5ff87b55add7c8d" - ], - "type" : "layers" - } - }, - "digest" : "sha256:4eea4e45f63fc6b38e5f653120aaf8a7c19043706f6d76070821495371e3017b", - "platform" : { - "architecture" : "s390x", - "os" : "linux" - }, - "size" : 3710951 - }, - { - "config" : { - "architecture" : "unknown", - "config" : { - - }, - "os" : "unknown", - "rootfs" : { - "diff_ids" : [ - "sha256:f5124fb579e27b9769a8ce0158cb650168246572098b5095ecbf8e605488afb6", - "sha256:56dceff11b3396976a334a95c6a2816e051a060123c719b35e4fc5104a962f7f" - ], - "type" : "layers" - } - }, - "digest" : "sha256:4a411cfbe9fc8bbe39859f835711f280d91a856a28a6ca01ef82ca298170c727", - "platform" : { - "architecture" : "unknown", - "os" : "unknown" - }, - "size" : 86390 - } - ] - } -] diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/images-error.txt b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/images-error.txt deleted file mode 100644 index 8aa851b0..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/images-error.txt +++ /dev/null @@ -1 +0,0 @@ -Error: content with digest sha256:666991717c0d26a211b400b09ad45ac84a9f64af8a08c2c64a54884e76d98954 diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/inspect.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/inspect.json deleted file mode 100644 index f73f540e..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/inspect.json +++ /dev/null @@ -1,119 +0,0 @@ -[ - { - "configuration" : { - "capAdd" : [ - - ], - "capDrop" : [ - - ], - "creationDate" : "2026-06-24T10:16:58Z", - "dns" : { - "nameservers" : [ - - ], - "options" : [ - - ], - "searchDomains" : [ - - ] - }, - "id" : "fixture-web", - "image" : { - "descriptor" : { - "digest" : "sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b", - "mediaType" : "application\/vnd.oci.image.index.v1+json", - "size" : 9218 - }, - "reference" : "docker.io\/library\/alpine:latest" - }, - "initProcess" : { - "arguments" : [ - "600" - ], - "environment" : [ - "PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin", - "FOO=bar" - ], - "executable" : "sleep", - "rlimits" : [ - - ], - "supplementalGroups" : [ - - ], - "terminal" : false, - "user" : { - "id" : { - "gid" : 0, - "uid" : 0 - } - }, - "workingDirectory" : "\/" - }, - "labels" : { - "contained.icon" : "globe", - "contained.tint" : "teal" - }, - "mounts" : [ - - ], - "networks" : [ - { - "network" : "default", - "options" : { - "hostname" : "fixture-web", - "mtu" : 1280 - } - } - ], - "platform" : { - "architecture" : "arm64", - "os" : "linux" - }, - "publishedPorts" : [ - { - "containerPort" : 80, - "count" : 1, - "hostAddress" : "0.0.0.0", - "hostPort" : 18080, - "proto" : "tcp" - } - ], - "publishedSockets" : [ - - ], - "readOnly" : false, - "resources" : { - "cpuOverhead" : 1, - "cpus" : 4, - "memoryInBytes" : 1073741824 - }, - "rosetta" : false, - "runtimeHandler" : "container-runtime-linux", - "ssh" : false, - "sysctls" : { - - }, - "useInit" : false, - "virtualization" : false - }, - "id" : "fixture-web", - "status" : { - "networks" : [ - { - "hostname" : "fixture-web", - "ipv4Address" : "192.168.64.3\/24", - "ipv4Gateway" : "192.168.64.1", - "ipv6Address" : "fdfd:fa52:b619:2ef7:fcb1:66ff:fe77:68e1\/64", - "macAddress" : "fe:b1:66:77:68:e1", - "mtu" : 1280, - "network" : "default" - } - ], - "startedDate" : "2026-06-24T10:16:59Z", - "state" : "running" - } - } -] diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list-current.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list-current.json deleted file mode 100644 index bf64c211..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list-current.json +++ /dev/null @@ -1 +0,0 @@ -[{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"ddns-latest","image":{"descriptor":{"digest":"sha256:19bd73d8aea9641dc329ec18ae693b2b9c33ff7cdc007f368266ce584446f995","mediaType":"application\/vnd.docker.distribution.manifest.list.v2+json","size":1108},"reference":"docker.io\/example\/ddns:latest"},"initProcess":{"arguments":[],"environment":["ZONE=example.com","RRTYPE=A","API_KEY=example-token-redacted","PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","CRON=*\/5\t*\t*\t*\t*"],"executable":"\/init","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"raw":{"userString":""}},"workingDirectory":""},"labels":{},"mounts":[],"networks":[{"network":"default","options":{"hostname":"ddns-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"ddns-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"nginx-proxy-manager-latest","image":{"descriptor":{"digest":"sha256:2aa69b382a384b676c0d4f1d6f2eac40ecd478fcf7af1cfb3f9f1d3cd0c81e12","mediaType":"application\/vnd.oci.image.index.v1+json","size":1609},"reference":"docker.io\/jc21\/nginx-proxy-manager:latest"},"initProcess":{"arguments":[],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","NODE_ENV=production","TZ=UTC"],"executable":"\/init","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/app"},"labels":{},"mounts":[{"destination":"\/data","options":[],"source":"\/srv\/nginx\/data\/","type":{"virtiofs":{}}},{"destination":"\/etc\/letsencrypt","options":[],"source":"\/srv\/nginx\/letsencrypt\/","type":{"virtiofs":{}}}],"networks":[{"network":"default","options":{"hostname":"nginx-proxy-manager-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":80,"proto":"tcp"},{"containerPort":81,"count":1,"hostAddress":"0.0.0.0","hostPort":81,"proto":"tcp"},{"containerPort":443,"count":1,"hostAddress":"0.0.0.0","hostPort":443,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"nginx-proxy-manager-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"app-latest","image":{"descriptor":{"digest":"sha256:b35ba0461c4a1033d117ac1e5968fd4cbe777899e4cbfbdeaf3d10a42a0eb7e9","mediaType":"application\/vnd.docker.distribution.manifest.list.v2+json","size":685},"reference":"docker.io\/example\/app:latest"},"initProcess":{"arguments":["npm","start"],"environment":["TZ=UTC","NODE_ENV=production","PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin"],"executable":"docker-entrypoint.sh","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"raw":{"userString":"node:node"}},"workingDirectory":"\/app"},"labels":{},"mounts":[{"destination":"\/app\/config","options":[],"source":"\/srv\/app\/config\/","type":{"virtiofs":{}}}],"networks":[{"network":"default","options":{"hostname":"app-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":5055,"count":1,"hostAddress":"0.0.0.0","hostPort":5055,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"app-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"2026-06-24T10:16:58Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"fixture-web","image":{"descriptor":{"digest":"sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b","mediaType":"application\/vnd.oci.image.index.v1+json","size":9218},"reference":"docker.io\/library\/alpine:latest"},"initProcess":{"arguments":["600"],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","FOO=bar"],"executable":"sleep","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/"},"labels":{"contained.icon":"globe","contained.tint":"teal"},"mounts":[],"networks":[{"network":"default","options":{"hostname":"fixture-web","mtu":1280}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":18080,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":4,"memoryInBytes":1073741824},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"fixture-web","status":{"networks":[],"startedDate":"2026-06-24T10:16:59Z","state":"stopped"}}] diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list.json deleted file mode 100644 index 05161683..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/list.json +++ /dev/null @@ -1 +0,0 @@ -[{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"2026-06-24T10:16:58Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"fixture-web","image":{"descriptor":{"digest":"sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b","mediaType":"application\/vnd.oci.image.index.v1+json","size":9218},"reference":"docker.io\/library\/alpine:latest"},"initProcess":{"arguments":["600"],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","FOO=bar"],"executable":"sleep","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/"},"labels":{"contained.icon":"globe","contained.tint":"teal"},"mounts":[],"networks":[{"network":"default","options":{"hostname":"fixture-web","mtu":1280}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":18080,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":4,"memoryInBytes":1073741824},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"fixture-web","status":{"networks":[{"hostname":"fixture-web","ipv4Address":"192.168.64.3\/24","ipv4Gateway":"192.168.64.1","ipv6Address":"fdfd:fa52:b619:2ef7:fcb1:66ff:fe77:68e1\/64","macAddress":"fe:b1:66:77:68:e1","mtu":1280,"network":"default"}],"startedDate":"2026-06-24T10:16:59Z","state":"running"}}] diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/networks.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/networks.json deleted file mode 100644 index 03d08faf..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/networks.json +++ /dev/null @@ -1 +0,0 @@ -[{"configuration":{"creationDate":"2026-04-01T07:12:13Z","labels":{"com.apple.container.resource.role":"builtin"},"mode":"nat","name":"default","options":{"variant":"reserved"},"plugin":"container-network-vmnet"},"id":"default","status":{"ipv4Gateway":"192.168.64.1","ipv4Subnet":"192.168.64.0\/24","ipv6Subnet":"fdfd:fa52:b619:2ef7::\/64"}}] diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats-table.txt b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats-table.txt deleted file mode 100644 index 85cb21e6..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats-table.txt +++ /dev/null @@ -1,9 +0,0 @@ -[?1049h[?25lContainer ID Cpu % Memory Usage Net Rx/Tx Block I/O Pids -Container ID Cpu % Memory Usage Net Rx/Tx Block I/O Pids -buildkit 0.00% 103.19 MiB / 2.00 GiB 475.54 KiB / 0.59 KiB 59.17 MiB / 24.00 KiB 17 -sonarrhd 0.07% 276.57 MiB / 1.00 GiB 2.37 MiB / 3.68 MiB 142.96 MiB / 700 KiB 26 -Container ID Cpu % Memory Usage Net Rx/Tx Block I/O Pids -buildkit 0.00% 103.19 MiB / 2.00 GiB 475.54 KiB / 0.59 KiB 59.17 MiB / 24.00 KiB 17 -sonarrhd 0.06% 276.57 MiB / 1.00 GiB 2.37 MiB / 3.68 MiB 142.96 MiB / 700 KiB 26 -error collecting stats: CancellationError() -[?25h[?1049l diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats.json deleted file mode 100644 index f9f10451..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/stats.json +++ /dev/null @@ -1 +0,0 @@ -[{"blockReadBytes":2154496,"blockWriteBytes":0,"cpuUsageUsec":1827,"id":"fixture-web","memoryLimitBytes":1073741824,"memoryUsageBytes":2322432,"networkRxBytes":9548,"networkTxBytes":516,"numProcesses":1}] diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/status.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/status.json deleted file mode 100644 index 8a43ec11..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/status.json +++ /dev/null @@ -1 +0,0 @@ -{"apiServerAppName":"container-apiserver","apiServerBuild":"release","apiServerCommit":"ee848e3ebfd7c73b04dd419683be54fb450b8779","apiServerVersion":"container-apiserver version 1.0.0 (build: release, commit: ee848e3)","appRoot":"\/Users\/admin\/Library\/Application Support\/com.apple.container\/","installRoot":"\/usr\/local\/","status":"running"} diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/volumes.json b/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/volumes.json deleted file mode 100644 index fe51488c..00000000 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures/volumes.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/Packages/ContainedCore/Package.swift b/Packages/ContainedCore/Package.swift index 9e66e67c..0a4e4b7c 100644 --- a/Packages/ContainedCore/Package.swift +++ b/Packages/ContainedCore/Package.swift @@ -6,6 +6,7 @@ let package = Package( platforms: [.macOS(.v26)], products: [ .library(name: "ContainedCore", targets: ["ContainedCore"]), + .library(name: "ContainedCoreFixtures", targets: ["ContainedCoreFixtures"]), ], dependencies: [ .package(url: "https://github.com/jpsim/Yams.git", from: "6.2.2"), @@ -15,10 +16,21 @@ let package = Package( name: "ContainedCore", dependencies: [.product(name: "Yams", package: "Yams")] ), + .target( + name: "ContainedCoreFixtures", + dependencies: ["ContainedCore"], + swiftSettings: [ + .define("CONTAINED_CORE_FIXTURES"), + ] + ), .testTarget( name: "ContainedCoreTests", dependencies: ["ContainedCore"], resources: [.copy("Fixtures")] ), + .testTarget( + name: "ContainedCoreFixturesTests", + dependencies: ["ContainedCoreFixtures"] + ), ] ) diff --git a/Packages/ContainedCore/README.md b/Packages/ContainedCore/README.md index 7cbc644e..a61bd812 100644 --- a/Packages/ContainedCore/README.md +++ b/Packages/ContainedCore/README.md @@ -1,41 +1,112 @@ # ContainedCore -ContainedCore is the pure model and command-building package for Contained. It -contains no SwiftUI, app state, Sparkle, persistence, or presentation code. +`ContainedCore` is Contained's standalone backend/orchestration package. It +contains no SwiftUI, app state, Sparkle, SwiftTerm, localization resources, +persistence, or presentation policy. ## Owns -- Runtime-neutral models such as `ContainerSnapshot`, `ContainerCreateRequest`, - `RuntimeKind`, resources, stats, and system information. -- Apple `container` command builders in `ContainerCommands`. -- Compose parsing and ordering helpers. -- Display-neutral package errors through `ContainedPackageError`. -- Pure metric and normalization helpers used by app widgets and history. +- `Core.Orchestrator`, the app-facing backend facade. +- `Core.Runtime` descriptors, capabilities, selected-runtime checks, and typed + unsupported-operation errors. +- `Core.Container` semantic create/edit/import/export models. +- `Core.Compose` import/export plans and Compose YAML parsing/writing internals. +- `Core.Command` command previews, command execution, and host invocations. +- Core-internal runtime adapters, beginning with Apple container. +- Metrics, stats normalization, decoded resources, registry helpers, and + display-neutral package errors. +- A separate `ContainedCoreFixtures` product for deterministic semantic samples + used by tests, previews, and sandbox-only targets. ## Does Not Own - Localized strings or user-facing copy. -- SwiftUI views, stores, routing, or settings. -- Runtime execution or process launching. -- Apple-specific create/import translation beyond argv builders. +- SwiftUI views, app routing, settings, stores, or Activity presentation. +- UI/UX packages, Sparkle, SwiftTerm, SwiftData, or app persistence. -## Example +## Runtime Example ```swift import ContainedCore -var request = ContainerCreateRequest() +let result = await Core.Orchestrator.bootstrap( + configuration: Core.Configuration( + appleContainer: .init(cliPathOverride: nil) + ) +) + +let core: Core.Orchestrator +switch result { +case .ready(let orchestrator, _, _), + .unsupported(let orchestrator, _, _): + core = orchestrator +case .cliMissing: + throw Core.Error.Command.cliNotFound(searched: ["PATH"]) +} + +let descriptors = core.availableRuntimeDescriptors +``` + +## Create Preview Example + +```swift +var request = Core.Container.CreateRequest() +request.runtimeKind = .appleContainer request.name = "web" request.image = "nginx:latest" -request.runtimeKind = .appleContainer -request.ports = [.init(hostPort: "8080", containerPort: "80", proto: "tcp")] +request.ports = [.init(hostPort: "8080", containerPort: "80")] + +let preview = try core.previewCreateCommand(for: request) +let command = preview.command +``` + +## Compose Example + +```swift +let project = try Core.Compose.parse(composeText, projectName: "stack") +let plan = try core.translateCompose(project, baseDirectory: composeDirectory) +let requests = plan.items.map(\.request) +``` + +Compose is a Core-level interchange format. `Core.Compose.YAML` is the only +place that imports Yams; no public Core API exposes Yams types. -let command = ContainerCommands.run(request) +## Migration Planning Example + +```swift +let document = Core.Container.Document( + canonical: .init(createRequest: request) +) + +let plan = try core.planMigration(document, to: .dockerCompatible) +if !plan.isAvailable { + // The app maps the typed reason/context to localized Activity or alert copy. +} ``` +## Fixtures + +Core fixtures are available only by depending on the separate +`ContainedCoreFixtures` product. They are not linked by the normal app target or +distributable bundles. + +```swift +import ContainedCore +import ContainedCoreFixtures + +let container = Core.Fixtures.AppleContainer.webContainer +let history = Core.Fixtures.Generic.metricHistory +``` + +Use `Core.Fixtures.AppleContainer.*` for Apple-container-specific samples and +`Core.Fixtures.Generic.*` only for runtime-neutral values. App previews and UI +tests map these semantic samples into app-owned localization, presentation, and +view state. + ## Build And Test ```sh swift build --package-path Packages/ContainedCore swift test --package-path Packages/ContainedCore +swift build --package-path Packages/ContainedCore --product ContainedCoreFixtures ``` diff --git a/Packages/ContainedRuntime/Sources/ContainedRuntime/Errors/CommandError.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/Error.swift similarity index 94% rename from Packages/ContainedRuntime/Sources/ContainedRuntime/Errors/CommandError.swift rename to Packages/ContainedCore/Sources/ContainedCore/Command/Error.swift index 28764803..fa7dd545 100644 --- a/Packages/ContainedRuntime/Sources/ContainedRuntime/Errors/CommandError.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/Error.swift @@ -1,5 +1,4 @@ import Foundation -import ContainedCore /// A typed error for everything that can go wrong invoking the `container` CLI. public enum CommandError: ContainedPackageError, Equatable { @@ -12,7 +11,7 @@ public enum CommandError: ContainedPackageError, Equatable { /// The process could not be launched at all. case launchFailed(underlying: String) - public var packageName: String { "ContainedRuntime" } + public var packageName: String { "ContainedCore" } public var packageErrorCode: String { switch self { diff --git a/Packages/ContainedCore/Sources/ContainedCore/Command/Preview.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/Preview.swift new file mode 100644 index 00000000..02c0c553 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/Preview.swift @@ -0,0 +1,21 @@ +import Foundation + +public struct RuntimeCommandPreview: Equatable, Sendable { + public var command: [String] + public var warnings: [String] + + public init(command: [String], warnings: [String] = []) { + self.command = command + self.warnings = warnings + } +} + +public struct CommandInvocation: Equatable, Sendable { + public var executableURL: URL + public var arguments: [String] + + public init(executableURL: URL, arguments: [String]) { + self.executableURL = executableURL + self.arguments = arguments + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift new file mode 100644 index 00000000..a4f3c044 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift @@ -0,0 +1,34 @@ +import Foundation + +public extension Core.Command { + static func runPreview(for request: ContainerCreateRequest) -> [String] { + ContainerCommands.run(request) + } + + static func buildPreview(context: String, + tag: String? = nil, + dockerfile: String? = nil, + buildArgs: [String: String] = [:], + noCache: Bool = false, + platform: String? = nil) -> [String] { + ContainerCommands.build(context: context, + tag: tag, + dockerfile: dockerfile, + buildArgs: buildArgs, + noCache: noCache, + platform: platform) + } + + static func networkCreatePreview(name: String, + subnet: String? = nil, + internalOnly: Bool = false) -> [String] { + ContainerCommands.networkCreate(name: name, + subnet: subnet, + internalOnly: internalOnly) + } + + static func volumeCreatePreview(name: String, + size: String? = nil) -> [String] { + ContainerCommands.volumeCreate(name: name, size: size) + } +} diff --git a/Packages/ContainedRuntime/Sources/ContainedRuntime/Support/CommandRunner.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/Runner.swift similarity index 100% rename from Packages/ContainedRuntime/Sources/ContainedRuntime/Support/CommandRunner.swift rename to Packages/ContainedCore/Sources/ContainedCore/Command/Runner.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Compose/API.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/API.swift new file mode 100644 index 00000000..bd044ca8 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/API.swift @@ -0,0 +1,7 @@ +import Foundation + +public extension Core.Compose { + static func parse(_ yaml: String, projectName: String) throws -> Project { + try ComposeParser.parse(yaml, projectName: projectName) + } +} diff --git a/Packages/ContainedRuntime/Sources/ContainedRuntime/Translation/RuntimeTranslation.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift similarity index 52% rename from Packages/ContainedRuntime/Sources/ContainedRuntime/Translation/RuntimeTranslation.swift rename to Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift index ecb4fb2b..c857d019 100644 --- a/Packages/ContainedRuntime/Sources/ContainedRuntime/Translation/RuntimeTranslation.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift @@ -1,14 +1,10 @@ import Foundation -import ContainedCore -public struct RuntimeCommandPreview: Equatable, Sendable { - public var command: [String] - public var warnings: [String] - - public init(command: [String], warnings: [String] = []) { - self.command = command - self.warnings = warnings - } +public enum ComposeDialect: String, Codable, Equatable, Hashable, Sendable { + case generic + case dockerCompose + case podmanCompose + case nerdctlCompose } public struct RuntimeComposeImportPlan: Equatable, Sendable { @@ -40,26 +36,16 @@ public struct RuntimeComposeImportItem: Equatable, Sendable { } } -public enum RuntimeCoreSwitchUnavailableReason: String, Equatable, Sendable { - case exportImportUnsupported -} - -public struct RuntimeCoreSwitchPlan: Equatable, Sendable { +public struct ComposeExportPlan: Equatable, Sendable { + public var dialect: ComposeDialect + public var warnings: [String] public var isAvailable: Bool - public var unavailableReason: RuntimeCoreSwitchUnavailableReason? - public var context: [String: String] - public var source: RuntimeKind - public var target: RuntimeKind? - public init(isAvailable: Bool, - unavailableReason: RuntimeCoreSwitchUnavailableReason?, - context: [String: String] = [:], - source: RuntimeKind, - target: RuntimeKind?) { + public init(dialect: ComposeDialect = .generic, + warnings: [String] = [], + isAvailable: Bool = false) { + self.dialect = dialect + self.warnings = warnings self.isAvailable = isAvailable - self.unavailableReason = unavailableReason - self.context = context - self.source = source - self.target = target } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/ComposeProject.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/ComposeProject.swift rename to Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md b/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md index 60d03542..c8f40f4f 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md +++ b/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md @@ -1,37 +1,64 @@ # ``ContainedCore`` -Pure models, command builders, compose parsing, and display-neutral error -metadata shared by Contained packages and the app. +Backend orchestration, runtime translation, command execution, Compose +interchange, metrics, and display-neutral errors for Contained. ## Overview -`ContainedCore` is intentionally UI-free. It can be imported by runtime -adapters, preview fixtures, tests, or future host apps without bringing in -SwiftUI, Sparkle, SwiftData, stores, or localization resources. +`ContainedCore` exposes a nested `Core.*` API, matching the `UI.*` and `UX.*` +package style. App code talks to ``Core/Orchestrator``. Runtime adapters live +inside Core, beginning with the Apple container adapter, so the app does not +create adapter clients or assemble backend argv. -Use it for: +Use Core for: -- runtime-neutral container create and recreate requests -- decoded Apple `container` JSON models -- Compose project parsing -- Apple `container` argv builders -- stats normalization helpers -- stable package error metadata +- runtime descriptors and capabilities +- canonical container create/edit/import/export models +- command previews and host command invocations +- Compose import/export plans +- image defaults and registry helpers +- stats snapshots, metric normalization, and history inputs +- typed display-neutral package errors -The app remains responsible for localized presentation and user-visible error -copy. +`ContainedCore` does not import SwiftUI, Sparkle, SwiftTerm, ContainedUI, or +ContainedUX. It owns no localized resources; the app maps Core errors and +technical identifiers to user-facing copy. + +Deterministic dev/test samples live in the separate `ContainedCoreFixtures` +product. Import that product only from tests, previews, or sandbox-only targets; +normal app and distributable bundle targets must not link it. + +## Compose + +Compose belongs to `Core.Compose` because it is a cross-runtime interchange +format. Yams is internal to `Core.Compose.YAML`; public APIs expose Core models +and plans rather than Yams types. ## Example ```swift import ContainedCore -let kind = RuntimeKind(rawValue: "docker-compatible") -let context = StatsNormalizationContext( - mode: .container, - containerCPUCores: 2, - containerMemoryLimitBytes: 2_147_483_648, - hostCPUCores: 10, - hostMemoryBytes: 34_359_738_368 -) +var request = Core.Container.CreateRequest() +request.runtimeKind = .appleContainer +request.name = "web" +request.image = "nginx:latest" + +let core = Core.Orchestrator.testing(runner: PreviewRunner()) +let preview = try core.previewCreateCommand(for: request) ``` + +## Fixtures + +```swift +import ContainedCore +import ContainedCoreFixtures + +let container = Core.Fixtures.AppleContainer.webContainer +let history = Core.Fixtures.Generic.metricHistory +``` + +Use `Core.Fixtures.AppleContainer.*` for Apple-container-specific samples and +`Core.Fixtures.Generic.*` only for runtime-neutral values. The app owns any +mapping from these semantic samples into localized labels, SwiftUI preview +state, personalization, or widget settings. diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/ContainerCreateRequest.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/ContainerCreateRequest.swift rename to Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift new file mode 100644 index 00000000..aa72167d --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct ContainerSpec: Codable, Equatable, Sendable { + public var createRequest: ContainerCreateRequest + + public init(createRequest: ContainerCreateRequest = ContainerCreateRequest()) { + self.createRequest = createRequest + } +} + +public struct RuntimeProjection: Codable, Equatable, Sendable { + public var kind: RuntimeKind + public var schemaVersion: CoreSchemaVersion + public var preservedFields: [String: String] + public var unsupportedFields: [String] + + public init(kind: RuntimeKind, + schemaVersion: CoreSchemaVersion = .current, + preservedFields: [String: String] = [:], + unsupportedFields: [String] = []) { + self.kind = kind + self.schemaVersion = schemaVersion + self.preservedFields = preservedFields + self.unsupportedFields = unsupportedFields + } +} + +public struct ContainerDocument: Codable, Equatable, Sendable { + public var canonical: ContainerSpec + public var projections: [RuntimeKind: RuntimeProjection] + public var provenance: RuntimeFieldProvenanceMap + + public init(canonical: ContainerSpec = ContainerSpec(), + projections: [RuntimeKind: RuntimeProjection] = [:], + provenance: RuntimeFieldProvenanceMap = RuntimeFieldProvenanceMap()) { + self.canonical = canonical + self.projections = projections + self.provenance = provenance + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/HealthCheck.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/HealthCheck.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/HealthCheck.swift rename to Packages/ContainedCore/Sources/ContainedCore/Container/HealthCheck.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/ContainerJSON.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/JSONDecoding.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/ContainerJSON.swift rename to Packages/ContainedCore/Sources/ContainedCore/Container/JSONDecoding.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/RestartPolicy.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/RestartPolicy.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/RestartPolicy.swift rename to Packages/ContainedCore/Sources/ContainedCore/Container/RestartPolicy.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/Container.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift similarity index 95% rename from Packages/ContainedCore/Sources/ContainedCore/Models/Container.swift rename to Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift index b0d3262c..6df8bfe7 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Models/Container.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift @@ -13,15 +13,10 @@ public struct ContainerSnapshot: Codable, Sendable, Identifiable, Hashable { public var image: String { configuration.image.reference } public var startedDate: Date? { status.startedDate } - /// Legacy personalization labels, kept only so older containers can migrate into local storage. - public var tintLabel: String? { configuration.labels["contained.tint"] } - public var iconLabel: String? { configuration.labels["contained.icon"] } - public var nicknameLabel: String? { configuration.labels["contained.nickname"] } /// Functional restart policy label consumed by the app-managed watchdog. public var restartLabel: String? { configuration.labels["contained.restart"] } - /// Legacy display name fallback. Newer UI resolves nicknames through `PersonalizationStore`. - public var displayName: String { nicknameLabel ?? id } + public var displayName: String { id } /// A synthetic snapshot for previews and image-level customization (styling an image's default /// before any container from it exists). Encodes a minimal payload first so unusual image or diff --git a/Packages/ContainedCore/Sources/ContainedCore/Core.swift b/Packages/ContainedCore/Sources/ContainedCore/Core.swift new file mode 100644 index 00000000..358a60ac --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Core.swift @@ -0,0 +1,123 @@ +import Foundation + +/// Root namespace for Contained's backend, runtime, import/export, and metric systems. +public enum Core {} + +public extension Core { + enum Runtime {} + enum Container {} + enum Image {} + enum Compose {} + enum Export {} + enum Migration {} + enum Registry {} + enum Network {} + enum Volume {} + enum System {} + enum Metrics {} + enum Command {} + enum Error {} + enum Field {} + enum Schema {} +} + +public extension Core.Runtime { + typealias Kind = RuntimeKind + typealias Descriptor = RuntimeDescriptor + typealias Capability = RuntimeCapability + typealias SystemAction = RuntimeSystemAction + typealias UnsupportedCapability = UnsupportedRuntimeCapability +} + +public extension Core.Container { + typealias CreateRequest = ContainerCreateRequest + typealias CreateResult = ContainerCreateResult + typealias KeyValue = ContainerCreateKeyValue + typealias Port = ContainerCreatePort + typealias VolumeMount = ContainerCreateVolume + typealias Socket = ContainerCreateSocket + typealias ImageDefaults = ContainerImageDefaults + typealias Snapshot = ContainerSnapshot + typealias RuntimeState = ContainerRuntimeState + typealias Configuration = ContainerConfiguration + typealias RuntimeStatus = ContainedCore.RuntimeStatus + typealias Document = ContainerDocument + typealias Spec = ContainerSpec +} + +public extension Core.Command { + typealias Preview = RuntimeCommandPreview + typealias Invocation = CommandInvocation + typealias Runner = CommandRunner + typealias Running = CommandRunning + typealias ExecutionPriority = CommandExecutionPriority +} + +public extension Core.Compose { + typealias Project = ComposeProject + typealias Service = ComposeService + typealias ImportPlan = RuntimeComposeImportPlan + typealias ImportItem = RuntimeComposeImportItem + typealias ExportPlan = ComposeExportPlan + typealias Dialect = ComposeDialect +} + +public extension Core.Image { + typealias Resource = ImageResource + typealias Configuration = ImageConfiguration + typealias Variant = ImageVariant + typealias UpdateStatus = ImageUpdateStatus + typealias UpdateState = ImageUpdateState +} + +public extension Core.Registry { + typealias Login = RegistryLogin + typealias ImageReference = RegistryImageReference + typealias ManifestClient = RegistryManifestClient +} + +public extension Core.Network { + typealias Resource = NetworkResource + typealias Configuration = NetworkConfiguration + typealias Status = NetworkStatus +} + +public extension Core.Volume { + typealias Resource = VolumeResource + typealias Configuration = VolumeConfiguration +} + +public extension Core.System { + typealias Status = SystemStatus + typealias Properties = SystemProperties + typealias DiskUsage = ContainedCore.DiskUsage +} + +public extension Core.Metrics { + typealias GraphMetric = ContainedCore.GraphMetric + typealias ContainerStats = ContainedCore.ContainerStats + typealias RuntimeStatsSnapshot = ContainedCore.RuntimeStatsSnapshot + typealias StatsDelta = ContainedCore.StatsDelta + typealias NormalizationMode = StatsNormalizationMode + typealias NormalizationContext = StatsNormalizationContext + typealias HistorySample = MetricHistorySample +} + +public extension Core.Migration { + typealias Plan = RuntimeCoreSwitchPlan + typealias UnavailableReason = RuntimeCoreSwitchUnavailableReason +} + +public extension Core.Error { + typealias PackageError = ContainedPackageError + typealias Command = CommandError +} + +public extension Core.Field { + typealias Path = RuntimeFieldPath + typealias ProvenanceMap = RuntimeFieldProvenanceMap +} + +public extension Core.Schema { + typealias Version = CoreSchemaVersion +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift b/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift new file mode 100644 index 00000000..f2a69b4a --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct RuntimeFieldPath: Codable, Equatable, Hashable, Sendable { + public var rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } +} + +public struct RuntimeFieldProvenanceMap: Codable, Equatable, Sendable { + public var fields: [RuntimeFieldPath: RuntimeKind] + + public init(fields: [RuntimeFieldPath: RuntimeKind] = [:]) { + self.fields = fields + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/ImageResource.swift b/Packages/ContainedCore/Sources/ContainedCore/Image/ImageResource.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/ImageResource.swift rename to Packages/ContainedCore/Sources/ContainedCore/Image/ImageResource.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/LocalImageTagGroup.swift b/Packages/ContainedCore/Sources/ContainedCore/Image/LocalTagGroup.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/LocalImageTagGroup.swift rename to Packages/ContainedCore/Sources/ContainedCore/Image/LocalTagGroup.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/ImageUpdateStatus.swift b/Packages/ContainedCore/Sources/ContainedCore/Image/UpdateStatus.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/ImageUpdateStatus.swift rename to Packages/ContainedCore/Sources/ContainedCore/Image/UpdateStatus.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/ContainerStats.swift b/Packages/ContainedCore/Sources/ContainedCore/Metrics/ContainerStats.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/ContainerStats.swift rename to Packages/ContainedCore/Sources/ContainedCore/Metrics/ContainerStats.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/GraphMetric.swift b/Packages/ContainedCore/Sources/ContainedCore/Metrics/GraphMetric.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/GraphMetric.swift rename to Packages/ContainedCore/Sources/ContainedCore/Metrics/GraphMetric.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/StatsNormalization.swift b/Packages/ContainedCore/Sources/ContainedCore/Metrics/Normalization.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/StatsNormalization.swift rename to Packages/ContainedCore/Sources/ContainedCore/Metrics/Normalization.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Migration/Plan.swift b/Packages/ContainedCore/Sources/ContainedCore/Migration/Plan.swift new file mode 100644 index 00000000..9f5e0165 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Migration/Plan.swift @@ -0,0 +1,25 @@ +import Foundation + +public enum RuntimeCoreSwitchUnavailableReason: String, Equatable, Sendable { + case exportImportUnsupported +} + +public struct RuntimeCoreSwitchPlan: Equatable, Sendable { + public var isAvailable: Bool + public var unavailableReason: RuntimeCoreSwitchUnavailableReason? + public var context: [String: String] + public var source: RuntimeKind + public var target: RuntimeKind? + + public init(isAvailable: Bool, + unavailableReason: RuntimeCoreSwitchUnavailableReason?, + context: [String: String] = [:], + source: RuntimeKind, + target: RuntimeKind?) { + self.isAvailable = isAvailable + self.unavailableReason = unavailableReason + self.context = context + self.source = source + self.target = target + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/NetworkResource.swift b/Packages/ContainedCore/Sources/ContainedCore/Network/NetworkResource.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/NetworkResource.swift rename to Packages/ContainedCore/Sources/ContainedCore/Network/NetworkResource.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Orchestration/CoreOrchestrator.swift b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/CoreOrchestrator.swift new file mode 100644 index 00000000..e502b2ae --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/CoreOrchestrator.swift @@ -0,0 +1,346 @@ +import Foundation + +public extension Core { + struct Configuration: Sendable { + public var defaultRuntime: RuntimeKind + public var appleContainer: AppleContainerConfiguration + + public init(defaultRuntime: RuntimeKind = .appleContainer, + appleContainer: AppleContainerConfiguration = AppleContainerConfiguration()) { + self.defaultRuntime = defaultRuntime + self.appleContainer = appleContainer + } + } + + struct AppleContainerConfiguration: Sendable { + public var cliPathOverride: String? + + public init(cliPathOverride: String? = nil) { + self.cliPathOverride = cliPathOverride + } + } +} + +public extension Core { + struct Orchestrator: Sendable, Equatable { + public enum Bootstrap: Sendable, Equatable { + case cliMissing + case unsupported(orchestrator: Core.Orchestrator, cliURL: URL, version: String) + case ready(orchestrator: Core.Orchestrator, cliURL: URL, version: String?) + } + + private let client: AppleContainerClient + public let cliURL: URL + public let defaultRuntime: RuntimeKind + + public static func == (lhs: Core.Orchestrator, rhs: Core.Orchestrator) -> Bool { + lhs.cliURL == rhs.cliURL && lhs.defaultRuntime == rhs.defaultRuntime + } + + public static func live(configuration: Core.Configuration = Core.Configuration()) -> Core.Orchestrator? { + guard let url = AppleContainerCLILocator.locate(override: configuration.appleContainer.cliPathOverride) else { + return nil + } + return Core.Orchestrator(cliURL: url, + defaultRuntime: configuration.defaultRuntime, + client: AppleContainerClient(runner: CommandRunner(executableURL: url))) + } + + public static func testing(runner: any CommandRunning, + cliURL: URL = URL(fileURLWithPath: "/usr/bin/container"), + defaultRuntime: RuntimeKind = .appleContainer) -> Core.Orchestrator { + Core.Orchestrator(cliURL: cliURL, + defaultRuntime: defaultRuntime, + client: AppleContainerClient(runner: runner)) + } + + public static func bootstrap(configuration: Core.Configuration = Core.Configuration()) async -> Bootstrap { + guard let orchestrator = live(configuration: configuration) else { return .cliMissing } + let runner = CommandRunner(executableURL: orchestrator.cliURL) + let versionData = try? await runner.run(ContainerCommands.version) + let version = versionData.map { String(decoding: $0, as: UTF8.self) } + .flatMap(AppleContainerCLILocator.parseVersion) + if let version, !AppleContainerCLILocator.isSupported(version) { + return .unsupported(orchestrator: orchestrator, + cliURL: orchestrator.cliURL, + version: version) + } + return .ready(orchestrator: orchestrator, + cliURL: orchestrator.cliURL, + version: version) + } + + init(cliURL: URL, defaultRuntime: RuntimeKind, client: AppleContainerClient) { + self.cliURL = cliURL + self.defaultRuntime = defaultRuntime + self.client = client + } + + public var descriptor: RuntimeDescriptor { client.descriptor } + + public var availableRuntimeDescriptors: [RuntimeDescriptor] { + [.appleContainer] + } + + public var runtimeCoreSelectorIsEnabled: Bool { + availableRuntimeDescriptors.count > 1 + } + + public func descriptor(for kind: RuntimeKind) -> RuntimeDescriptor { + availableRuntimeDescriptors.first { $0.kind == kind } ?? .appleContainer + } + + public func supportsRuntime(_ kind: RuntimeKind, capability: RuntimeCapability = .containers) -> Bool { + availableRuntimeDescriptors.first { $0.kind == kind }?.supports(capability) == true + } + + private func requireRuntime(_ kind: RuntimeKind, + capability: RuntimeCapability) throws -> AppleContainerClient { + guard client.descriptor.kind == kind else { + throw UnsupportedRuntimeCapability(kind: kind, capability: capability) + } + try client.descriptor.require(capability) + return client + } + + public func listContainers(all: Bool = true) async throws -> [ContainerSnapshot] { + try await client.listContainers(all: all) + } + + public func stats(ids: [String] = []) async throws -> [ContainerStats] { + try await client.stats(ids: ids) + } + + public func streamStats(ids: [String] = []) -> AsyncThrowingStream<[RuntimeStatsSnapshot], Swift.Error> { + client.streamStats(ids: ids) + } + + public func diskUsage() async throws -> DiskUsage { + try await client.diskUsage() + } + + public func systemProperties() async throws -> SystemProperties { + try await client.systemProperties() + } + + public func dnsDomains() async throws -> [String] { + try await client.dnsDomains() + } + + @discardableResult public func createDNSDomain(_ domain: String) async throws -> Data { + try await client.createDNSDomain(domain) + } + + @discardableResult public func deleteDNSDomain(_ domain: String) async throws -> Data { + try await client.deleteDNSDomain(domain) + } + + @discardableResult public func setRecommendedKernel() async throws -> Data { + try await client.setRecommendedKernel() + } + + public func execCapture(_ id: String, _ command: [String]) async throws -> String { + try await client.execCapture(id, command) + } + + @discardableResult public func copy(source: String, destination: String) async throws -> Data { + try await client.copy(source: source, destination: destination) + } + + public func terminalInvocation(containerID: String, shell: String) throws -> CommandInvocation { + CommandInvocation(executableURL: cliURL, + arguments: ContainerCommands.execInteractive(containerID, shell: shell)) + } + + public func streamSystemLogs(follow: Bool, last: Int? = 500) -> AsyncThrowingStream { + client.streamSystemLogs(follow: follow, last: last) + } + + public func systemStatus() async throws -> SystemStatus { + try await client.systemStatus() + } + + public func previewCreateCommand(for request: ContainerCreateRequest) throws -> RuntimeCommandPreview { + try requireRuntime(request.runtimeKind, capability: .containers).previewCreateCommand(for: request) + } + + @discardableResult public func createContainer(_ request: ContainerCreateRequest) async throws -> ContainerCreateResult { + try await requireRuntime(request.runtimeKind, capability: .containers).createContainer(request) + } + + @discardableResult public func recreateContainer(originalID: String, + request: ContainerCreateRequest) async throws -> ContainerCreateResult { + let runtime = try requireRuntime(request.runtimeKind, capability: .containers) + _ = try? await runtime.stop([originalID]) + _ = try await runtime.deleteContainers([originalID], force: true) + return try await runtime.createContainer(request) + } + + public func translateCompose(_ project: ComposeProject, + baseDirectory: URL?, + runtimeKind: RuntimeKind = .appleContainer) throws -> RuntimeComposeImportPlan { + try requireRuntime(runtimeKind, capability: .composeImport) + .translateCompose(project, baseDirectory: baseDirectory) + } + + public func imageDefaults(for request: ContainerCreateRequest, + in images: [ImageResource]) throws -> ContainerImageDefaults? { + try requireRuntime(request.runtimeKind, capability: .containers) + .imageDefaults(for: request, in: images) + } + + public func planMigration(_ document: ContainerDocument, + to target: RuntimeKind?) throws -> RuntimeCoreSwitchPlan { + let source = document.canonical.createRequest.runtimeKind + return try requireRuntime(source, capability: .coreMigration) + .coreSwitchPlan(for: document.canonical.createRequest.effectiveName ?? "", to: target.map(descriptor(for:))) + } + + public func coreSwitchPlan(for containerID: String, + source: RuntimeKind = .appleContainer, + to target: RuntimeDescriptor?) throws -> RuntimeCoreSwitchPlan { + try requireRuntime(source, capability: .coreMigration).coreSwitchPlan(for: containerID, to: target) + } + + public func networks() async throws -> [NetworkResource] { + try await client.networks() + } + + public func volumes() async throws -> [VolumeResource] { + try await client.volumes() + } + + public func images() async throws -> [ImageResource] { + try await client.images() + } + + public func inspectImage(_ ref: String) async throws -> [ImageResource] { + try await client.inspectImage(ref) + } + + public func streamLogs(id: String, + follow: Bool = true, + tail: Int? = 200, + boot: Bool = false) -> AsyncThrowingStream { + client.streamLogs(id: id, follow: follow, tail: tail, boot: boot) + } + + public func streamPull(_ ref: String, + platform: String? = nil) -> AsyncThrowingStream { + client.streamPull(ref, platform: platform) + } + + public func streamBuild(context: String, + tag: String? = nil, + dockerfile: String? = nil, + buildArgs: [String: String] = [:], + noCache: Bool = false, + platform: String? = nil) -> AsyncThrowingStream { + client.streamBuild(context: context, + tag: tag, + dockerfile: dockerfile, + buildArgs: buildArgs, + noCache: noCache, + platform: platform) + } + + public func streamPush(_ ref: String, + platform: String? = nil) -> AsyncThrowingStream { + client.streamPush(ref, platform: platform) + } + + @discardableResult public func runContainer(arguments: [String]) async throws -> Data { + try await client.runContainer(arguments: arguments) + } + + @discardableResult public func performSystemAction(_ action: RuntimeSystemAction) async throws -> Data { + try await client.performSystemAction(action) + } + + public func registries() async throws -> [RegistryLogin] { + try await client.registries() + } + + @discardableResult public func registryLogin(server: String, + username: String, + password: String) async throws -> Data { + try await client.registryLogin(server: server, username: username, password: password) + } + + @discardableResult public func registryLogout(server: String) async throws -> Data { + try await client.registryLogout(server: server) + } + + @discardableResult public func deleteImages(_ refs: [String]) async throws -> Data { + try await client.deleteImages(refs) + } + + @discardableResult public func tagImage(source: String, target: String) async throws -> Data { + try await client.tagImage(source: source, target: target) + } + + @discardableResult public func saveImages(_ refs: [String], to output: String) async throws -> Data { + try await client.saveImages(refs, to: output) + } + + @discardableResult public func loadImages(from input: String) async throws -> Data { + try await client.loadImages(from: input) + } + + @discardableResult public func exportContainer(_ id: String, to output: String) async throws -> Data { + try await client.exportContainer(id, to: output) + } + + @discardableResult public func pruneImages(all: Bool = false) async throws -> Data { + try await client.pruneImages(all: all) + } + + @discardableResult public func start(_ ids: [String]) async throws -> Data { + try await client.start(ids) + } + + @discardableResult public func stop(_ ids: [String]) async throws -> Data { + try await client.stop(ids) + } + + @discardableResult public func deleteContainers(_ ids: [String], force: Bool) async throws -> Data { + try await client.deleteContainers(ids, force: force) + } + + @discardableResult public func pruneContainers() async throws -> Data { + try await client.pruneContainers() + } + + @discardableResult public func pruneVolumes() async throws -> Data { + try await client.pruneVolumes() + } + + @discardableResult public func pruneNetworks() async throws -> Data { + try await client.pruneNetworks() + } + + @discardableResult public func createVolume(name: String, + size: String? = nil, + labels: [String: String] = [:]) async throws -> Data { + try await client.createVolume(name: name, size: size, labels: labels) + } + + @discardableResult public func deleteVolumes(_ names: [String]) async throws -> Data { + try await client.deleteVolumes(names) + } + + @discardableResult public func createNetwork(name: String, + subnet: String? = nil, + internalOnly: Bool = false, + labels: [String: String] = [:]) async throws -> Data { + try await client.createNetwork(name: name, + subnet: subnet, + internalOnly: internalOnly, + labels: labels) + } + + @discardableResult public func deleteNetworks(_ names: [String]) async throws -> Data { + try await client.deleteNetworks(names) + } + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/HubSearch.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/HubSearch.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/HubSearch.swift rename to Packages/ContainedCore/Sources/ContainedCore/Registry/HubSearch.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/RegistryLogin.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/Login.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/RegistryLogin.swift rename to Packages/ContainedCore/Sources/ContainedCore/Registry/Login.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Support/RegistryManifestClient.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/ManifestClient.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Support/RegistryManifestClient.swift rename to Packages/ContainedCore/Sources/ContainedCore/Registry/ManifestClient.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/RegistryImageReference.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/RegistryImageReference.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/RegistryImageReference.swift rename to Packages/ContainedCore/Sources/ContainedCore/Registry/RegistryImageReference.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/RuntimeKind.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/RuntimeKind.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift diff --git a/Packages/ContainedRuntime/Sources/ContainedRuntime/Clients/RuntimeDescriptor.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeDescriptor.swift similarity index 98% rename from Packages/ContainedRuntime/Sources/ContainedRuntime/Clients/RuntimeDescriptor.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeDescriptor.swift index e9d43580..00136bee 100644 --- a/Packages/ContainedRuntime/Sources/ContainedRuntime/Clients/RuntimeDescriptor.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeDescriptor.swift @@ -1,5 +1,4 @@ import Foundation -import ContainedCore public struct RuntimeCapability: OptionSet, Equatable, Sendable { public let rawValue: UInt64 @@ -93,7 +92,7 @@ public struct UnsupportedRuntimeCapability: Error, Equatable, Sendable { } extension UnsupportedRuntimeCapability: ContainedPackageError { - public var packageName: String { "ContainedRuntime" } + public var packageName: String { "ContainedCore" } public var packageErrorCode: String { "unsupportedRuntimeCapability" } public var packageErrorContext: [String: String] { [ @@ -108,7 +107,7 @@ public enum RuntimeSystemAction: String, CaseIterable, Sendable { case stop } -public protocol ContainerRuntimeClient: Sendable { +protocol ContainerRuntimeClient: Sendable { var descriptor: RuntimeDescriptor { get } func listContainers(all: Bool) async throws -> [ContainerSnapshot] @@ -163,7 +162,7 @@ public protocol ContainerRuntimeClient: Sendable { @discardableResult func deleteNetworks(_ names: [String]) async throws -> Data } -public extension ContainerRuntimeClient { +extension ContainerRuntimeClient { func listContainers() async throws -> [ContainerSnapshot] { try await listContainers(all: true) } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/RuntimeStatus.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeStatus.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/RuntimeStatus.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeStatus.swift diff --git a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Clients/AppleContainerClient.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Client/Client.swift similarity index 65% rename from Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Clients/AppleContainerClient.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Client/Client.swift index 271632e2..38a3989f 100644 --- a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Clients/AppleContainerClient.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Client/Client.swift @@ -1,24 +1,22 @@ import Foundation -import ContainedCore -import ContainedRuntime /// Typed facade over a `CommandRunning`. Returns decoded models; maps decode failures to /// `CommandError.decodingFailed` so callers handle one error type. -public struct AppleContainerClient: Sendable { - public let runner: any CommandRunning - public var descriptor: RuntimeDescriptor { .appleContainer } +struct AppleContainerClient: Sendable { + let runner: any CommandRunning + var descriptor: RuntimeDescriptor { .appleContainer } - public init(runner: any CommandRunning) { + init(runner: any CommandRunning) { self.runner = runner } // MARK: Reads - public func listContainers(all: Bool = true) async throws -> [ContainerSnapshot] { + func listContainers(all: Bool = true) async throws -> [ContainerSnapshot] { try await decode([ContainerSnapshot].self, ContainerCommands.list(all: all), "list") } - public func stats(ids: [String] = []) async throws -> [ContainerStats] { + func stats(ids: [String] = []) async throws -> [ContainerStats] { try await decode([ContainerStats].self, ContainerCommands.stats(ids: ids), "stats", @@ -29,7 +27,7 @@ public struct AppleContainerClient: Sendable { runner.stream(ContainerCommands.statsTableStream(ids: ids), priority: .utility) } - public func streamStats(ids: [String] = []) -> AsyncThrowingStream<[RuntimeStatsSnapshot], Error> { + func streamStats(ids: [String] = []) -> AsyncThrowingStream<[RuntimeStatsSnapshot], Error> { let source = statsTableStream(ids: ids) return AsyncThrowingStream { continuation in let task = Task(priority: .utility) { @@ -51,80 +49,80 @@ public struct AppleContainerClient: Sendable { } } - public func diskUsage() async throws -> DiskUsage { + func diskUsage() async throws -> DiskUsage { try await decode(DiskUsage.self, ContainerCommands.systemDF, "system df") } - public func systemProperties() async throws -> SystemProperties { + func systemProperties() async throws -> SystemProperties { try await decode(SystemProperties.self, ContainerCommands.systemPropertyList, "system property list") } /// List local DNS domains (`system dns list`). Returns domain names. - public func dnsDomains() async throws -> [String] { + func dnsDomains() async throws -> [String] { try await decode([String].self, ContainerCommands.systemDNSList, "system dns list") } - @discardableResult public func createDNSDomain(_ domain: String) async throws -> Data { + @discardableResult func createDNSDomain(_ domain: String) async throws -> Data { try await runner.run(ContainerCommands.systemDNSCreate(domain)) } - @discardableResult public func deleteDNSDomain(_ domain: String) async throws -> Data { + @discardableResult func deleteDNSDomain(_ domain: String) async throws -> Data { try await runner.run(ContainerCommands.systemDNSDelete(domain)) } /// Install the recommended kernel (`system kernel set --recommended`). - @discardableResult public func setRecommendedKernel() async throws -> Data { + @discardableResult func setRecommendedKernel() async throws -> Data { try await runner.run(ContainerCommands.systemKernelSetRecommended) } /// Capture the output of a one-shot `exec` (no TTY) — e.g. `ps`, `ls -la`. - public func execCapture(_ id: String, _ command: [String]) async throws -> String { + func execCapture(_ id: String, _ command: [String]) async throws -> String { let data = try await runner.run(ContainerCommands.exec(id, command)) return String(decoding: data, as: UTF8.self) } /// Copy between host and container. Paths are `container-id:path` or local. - @discardableResult public func copy(source: String, destination: String) async throws -> Data { + @discardableResult func copy(source: String, destination: String) async throws -> Data { try await runner.run(ContainerCommands.copy(source: source, destination: destination)) } /// Stream `system logs` (service logs). With `follow`, runs until cancelled. - public func streamSystemLogs(follow: Bool, last: Int? = 500) -> AsyncThrowingStream { + func streamSystemLogs(follow: Bool, last: Int? = 500) -> AsyncThrowingStream { runner.stream(ContainerCommands.systemLogs(follow: follow, last: last)) } - public func systemStatus() async throws -> SystemStatus { + func systemStatus() async throws -> SystemStatus { try await decode(SystemStatus.self, ContainerCommands.systemStatus, "system status") } - public func previewCreateCommand(for request: ContainerCreateRequest) throws -> RuntimeCommandPreview { + func previewCreateCommand(for request: ContainerCreateRequest) throws -> RuntimeCommandPreview { AppleContainerCreateTranslator.preview(for: request) } - @discardableResult public func createContainer(_ request: ContainerCreateRequest) async throws -> ContainerCreateResult { + @discardableResult func createContainer(_ request: ContainerCreateRequest) async throws -> ContainerCreateResult { let data = try await runner.run(ContainerCommands.run(request)) return AppleContainerCreateTranslator.result(from: data, request: request) } - public func translateCompose(_ project: ComposeProject, baseDirectory: URL?) throws -> RuntimeComposeImportPlan { + func translateCompose(_ project: ComposeProject, baseDirectory: URL?) throws -> RuntimeComposeImportPlan { AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: baseDirectory) } - public func imageDefaults(for request: ContainerCreateRequest, + func imageDefaults(for request: ContainerCreateRequest, in images: [ImageResource]) throws -> ContainerImageDefaults? { AppleContainerCreateTranslator.imageDefaults(for: request, in: images) } - public func networks() async throws -> [NetworkResource] { + func networks() async throws -> [NetworkResource] { try await decode([NetworkResource].self, ContainerCommands.networkList(), "network list") } - public func volumes() async throws -> [VolumeResource] { + func volumes() async throws -> [VolumeResource] { try await decode([VolumeResource].self, ContainerCommands.volumeList(), "volume list") } - public func images() async throws -> [ImageResource] { + func images() async throws -> [ImageResource] { try await decode([ImageResource].self, ContainerCommands.imageList(), "image list") } - public func inspectImage(_ ref: String) async throws -> [ImageResource] { + func inspectImage(_ ref: String) async throws -> [ImageResource] { try await decode([ImageResource].self, ContainerCommands.imageInspect([ref]), "image inspect") } @@ -132,18 +130,18 @@ public struct AppleContainerClient: Sendable { /// Stream a container's logs. The CLI emits merged stdout/stderr; lines arrive as they're produced. /// Cancelling the consuming task terminates the child process (no leaked `logs -f`). - public func streamLogs(id: String, follow: Bool = true, tail: Int? = 200, boot: Bool = false) + func streamLogs(id: String, follow: Bool = true, tail: Int? = 200, boot: Bool = false) -> AsyncThrowingStream { runner.stream(ContainerCommands.logs(id, follow: follow, tail: tail, boot: boot)) } /// Stream `image pull --progress plain` output as it downloads. - public func streamPull(_ ref: String, platform: String? = nil) -> AsyncThrowingStream { + func streamPull(_ ref: String, platform: String? = nil) -> AsyncThrowingStream { runner.stream(ContainerCommands.imagePull(ref, platform: platform)) } /// Stream `container build --progress plain` (BuildKit log). - public func streamBuild(context: String, tag: String? = nil, dockerfile: String? = nil, + func streamBuild(context: String, tag: String? = nil, dockerfile: String? = nil, buildArgs: [String: String] = [:], noCache: Bool = false, platform: String? = nil) -> AsyncThrowingStream { runner.stream(ContainerCommands.build(context: context, tag: tag, dockerfile: dockerfile, @@ -151,91 +149,91 @@ public struct AppleContainerClient: Sendable { } /// Stream `image push --progress plain` to a logged-in registry. - public func streamPush(_ ref: String, platform: String? = nil) -> AsyncThrowingStream { + func streamPush(_ ref: String, platform: String? = nil) -> AsyncThrowingStream { runner.stream(ContainerCommands.imagePush(ref, platform: platform)) } - @discardableResult public func runContainer(arguments: [String]) async throws -> Data { + @discardableResult func runContainer(arguments: [String]) async throws -> Data { try await runner.run(arguments) } - @discardableResult public func performSystemAction(_ action: RuntimeSystemAction) async throws -> Data { + @discardableResult func performSystemAction(_ action: RuntimeSystemAction) async throws -> Data { try await runner.run(["system", action.rawValue]) } // MARK: Registries - public func registries() async throws -> [RegistryLogin] { + func registries() async throws -> [RegistryLogin] { try await decode([RegistryLogin].self, ContainerCommands.registryList(), "registry list") } /// Log in to `server` as `username`, piping `password` via stdin (never in argv). - @discardableResult public func registryLogin(server: String, username: String, password: String) async throws -> Data { + @discardableResult func registryLogin(server: String, username: String, password: String) async throws -> Data { try await runner.run(ContainerCommands.registryLogin(server: server, username: username), stdin: Data(password.utf8)) } - @discardableResult public func registryLogout(server: String) async throws -> Data { + @discardableResult func registryLogout(server: String) async throws -> Data { try await runner.run(ContainerCommands.registryLogout(server: server)) } // MARK: Image writes - @discardableResult public func deleteImages(_ refs: [String]) async throws -> Data { + @discardableResult func deleteImages(_ refs: [String]) async throws -> Data { try await runner.run(ContainerCommands.imageDelete(refs)) } - @discardableResult public func tagImage(source: String, target: String) async throws -> Data { + @discardableResult func tagImage(source: String, target: String) async throws -> Data { try await runner.run(ContainerCommands.imageTag(source: source, target: target)) } - @discardableResult public func saveImages(_ refs: [String], to output: String) async throws -> Data { + @discardableResult func saveImages(_ refs: [String], to output: String) async throws -> Data { try await runner.run(ContainerCommands.imageSave(refs: refs, output: output)) } - @discardableResult public func loadImages(from input: String) async throws -> Data { + @discardableResult func loadImages(from input: String) async throws -> Data { try await runner.run(ContainerCommands.imageLoad(input: input)) } /// Export a container's filesystem as a tar archive (not an OCI image). - @discardableResult public func exportContainer(_ id: String, to output: String) async throws -> Data { + @discardableResult func exportContainer(_ id: String, to output: String) async throws -> Data { try await runner.run(ContainerCommands.containerExport(id, output: output)) } - @discardableResult public func pruneImages(all: Bool = false) async throws -> Data { + @discardableResult func pruneImages(all: Bool = false) async throws -> Data { try await runner.run(ContainerCommands.imagePrune(all: all)) } // MARK: Lifecycle (fire-and-forget; throw on failure) - @discardableResult public func start(_ ids: [String]) async throws -> Data { + @discardableResult func start(_ ids: [String]) async throws -> Data { try await runner.run(ContainerCommands.start(ids)) } - @discardableResult public func stop(_ ids: [String]) async throws -> Data { + @discardableResult func stop(_ ids: [String]) async throws -> Data { try await runner.run(ContainerCommands.stop(ids)) } - @discardableResult public func deleteContainers(_ ids: [String], force: Bool) async throws -> Data { + @discardableResult func deleteContainers(_ ids: [String], force: Bool) async throws -> Data { try await runner.run(ContainerCommands.deleteContainers(ids, force: force)) } - @discardableResult public func pruneContainers() async throws -> Data { + @discardableResult func pruneContainers() async throws -> Data { try await runner.run(ContainerCommands.containerPrune()) } - @discardableResult public func pruneVolumes() async throws -> Data { + @discardableResult func pruneVolumes() async throws -> Data { try await runner.run(ContainerCommands.volumePrune()) } - @discardableResult public func pruneNetworks() async throws -> Data { + @discardableResult func pruneNetworks() async throws -> Data { try await runner.run(ContainerCommands.networkPrune()) } // MARK: Infra writes - @discardableResult public func createVolume(name: String, size: String? = nil, + @discardableResult func createVolume(name: String, size: String? = nil, labels: [String: String] = [:]) async throws -> Data { try await runner.run(ContainerCommands.volumeCreate(name: name, size: size, labels: labels)) } - @discardableResult public func deleteVolumes(_ names: [String]) async throws -> Data { + @discardableResult func deleteVolumes(_ names: [String]) async throws -> Data { try await runner.run(ContainerCommands.volumeDelete(names)) } - @discardableResult public func createNetwork(name: String, subnet: String? = nil, internalOnly: Bool = false, + @discardableResult func createNetwork(name: String, subnet: String? = nil, internalOnly: Bool = false, labels: [String: String] = [:]) async throws -> Data { try await runner.run(ContainerCommands.networkCreate(name: name, subnet: subnet, internalOnly: internalOnly, labels: labels)) } - @discardableResult public func deleteNetworks(_ names: [String]) async throws -> Data { + @discardableResult func deleteNetworks(_ names: [String]) async throws -> Data { try await runner.run(ContainerCommands.networkDelete(names)) } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Commands/ContainerCommands.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift similarity index 70% rename from Packages/ContainedCore/Sources/ContainedCore/Commands/ContainerCommands.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift index effc94d5..db39aacc 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Commands/ContainerCommands.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift @@ -2,10 +2,10 @@ import Foundation /// Pure builders for `container` argument vectors. Kept side-effect-free so golden tests can assert /// the exact argv each UI action produces ("Reveal CLI" reads from the same source of truth). -public enum ContainerCommands { +enum ContainerCommands { static let jsonFormat = ["--format", "json"] - public enum StatsFormat: String, Sendable { + enum StatsFormat: String, Sendable { case json case table case toml @@ -14,50 +14,50 @@ public enum ContainerCommands { // MARK: Containers - public static func list(all: Bool) -> [String] { + static func list(all: Bool) -> [String] { var args = ["list"] if all { args.append("--all") } return args + jsonFormat } - public static func stats(ids: [String] = [], noStream: Bool = true, format: StatsFormat = .json) -> [String] { + static func stats(ids: [String] = [], noStream: Bool = true, format: StatsFormat = .json) -> [String] { var args = ["stats"] if noStream { args.append("--no-stream") } return args + ["--format", format.rawValue] + ids } - public static func statsTableStream(ids: [String] = []) -> [String] { + static func statsTableStream(ids: [String] = []) -> [String] { stats(ids: ids, noStream: false, format: .table) } - public static func start(_ ids: [String]) -> [String] { ["start"] + ids } - public static func stop(_ ids: [String], signal: String? = nil, time: Int? = nil) -> [String] { + static func start(_ ids: [String]) -> [String] { ["start"] + ids } + static func stop(_ ids: [String], signal: String? = nil, time: Int? = nil) -> [String] { var args = ["stop"] if let signal { args += ["--signal", signal] } if let time { args += ["--time", String(time)] } return args + ids } - public static func deleteContainers(_ ids: [String], force: Bool) -> [String] { + static func deleteContainers(_ ids: [String], force: Bool) -> [String] { var args = ["delete"] if force { args.append("--force") } return args + ids } /// `container prune` — remove all stopped containers. - public static func containerPrune() -> [String] { ["prune"] } + static func containerPrune() -> [String] { ["prune"] } /// `container exec ` (no TTY) — for one-shot captures like `ps`, `ls`. - public static func exec(_ id: String, _ command: [String]) -> [String] { ["exec", id] + command } + static func exec(_ id: String, _ command: [String]) -> [String] { ["exec", id] + command } /// `container exec --interactive --tty ` — for hosted terminal sessions. - public static func execInteractive(_ id: String, shell: String) -> [String] { + static func execInteractive(_ id: String, shell: String) -> [String] { ["exec", "--interactive", "--tty", id, shell] } /// `container export --output ` — export a container's filesystem as a tar archive. /// Note: this is a filesystem tarball, **not** an OCI image (the runtime has no `commit`). - public static func containerExport(_ id: String, output: String) -> [String] { + static func containerExport(_ id: String, output: String) -> [String] { ["export", "--output", output, id] } /// `container copy ` — paths are `container-id:path` or local. - public static func copy(source: String, destination: String) -> [String] { ["copy", source, destination] } - public static func logs(_ id: String, follow: Bool = false, tail: Int? = nil, boot: Bool = false) -> [String] { + static func copy(source: String, destination: String) -> [String] { ["copy", source, destination] } + static func logs(_ id: String, follow: Bool = false, tail: Int? = nil, boot: Bool = false) -> [String] { var args = ["logs"] if follow { args.append("--follow") } if boot { args.append("--boot") } @@ -66,7 +66,7 @@ public enum ContainerCommands { return args } - public static func run(_ request: ContainerCreateRequest) -> [String] { + static func run(_ request: ContainerCreateRequest) -> [String] { var args = ["run"] if request.detach { args.append("--detach") } if request.removeOnExit { args.append("--rm") } @@ -122,26 +122,26 @@ public enum ContainerCommands { // MARK: Images - public static func imageList() -> [String] { ["image", "list"] + jsonFormat } - public static func imageInspect(_ refs: [String]) -> [String] { ["image", "inspect"] + refs } - public static func imageDelete(_ refs: [String]) -> [String] { ["image", "delete"] + refs } - public static func imageTag(source: String, target: String) -> [String] { ["image", "tag", source, target] } - public static func imagePrune(all: Bool = false) -> [String] { + static func imageList() -> [String] { ["image", "list"] + jsonFormat } + static func imageInspect(_ refs: [String]) -> [String] { ["image", "inspect"] + refs } + static func imageDelete(_ refs: [String]) -> [String] { ["image", "delete"] + refs } + static func imageTag(source: String, target: String) -> [String] { ["image", "tag", source, target] } + static func imagePrune(all: Bool = false) -> [String] { var args = ["image", "prune"] if all { args.append("--all") } return args } /// `image save -o ` — export image(s) to an OCI archive. - public static func imageSave(refs: [String], output: String) -> [String] { + static func imageSave(refs: [String], output: String) -> [String] { ["image", "save"] + refs + ["--output", output] } /// `image load -i ` — import images from an archive. - public static func imageLoad(input: String) -> [String] { + static func imageLoad(input: String) -> [String] { ["image", "load", "--input", input] } /// `image pull [--platform os/arch] --progress plain ` — plain progress is line-streamable. - public static func imagePull(_ ref: String, platform: String? = nil) -> [String] { + static func imagePull(_ ref: String, platform: String? = nil) -> [String] { var args = ["image", "pull", "--progress", "plain"] if let platform, !platform.isEmpty { args += ["--platform", platform] } args.append(ref) @@ -152,7 +152,7 @@ public enum ContainerCommands { /// `container build [-f Dockerfile] [-t tag] [--build-arg k=v] [--no-cache] [--platform p] /// --progress plain ` — plain progress streams the BuildKit log line by line. - public static func build(context: String, tag: String? = nil, dockerfile: String? = nil, + static func build(context: String, tag: String? = nil, dockerfile: String? = nil, buildArgs: [String: String] = [:], noCache: Bool = false, platform: String? = nil) -> [String] { var args = ["build", "--progress", "plain"] @@ -167,11 +167,11 @@ public enum ContainerCommands { // MARK: Infra - public static func networkList() -> [String] { ["network", "list"] + jsonFormat } - public static func volumeList() -> [String] { ["volume", "list"] + jsonFormat } + static func networkList() -> [String] { ["network", "list"] + jsonFormat } + static func volumeList() -> [String] { ["volume", "list"] + jsonFormat } /// `volume create [--label k=v ...] [-s size] ` - public static func volumeCreate(name: String, size: String? = nil, labels: [String: String] = [:]) -> [String] { + static func volumeCreate(name: String, size: String? = nil, labels: [String: String] = [:]) -> [String] { var args = ["volume", "create"] for (k, v) in labels.sorted(by: { $0.key < $1.key }) { args += ["--label", "\(k)=\(v)"] } if let size, !size.isEmpty { args += ["-s", size] } @@ -179,11 +179,11 @@ public enum ContainerCommands { return args } /// `volume delete ` - public static func volumeDelete(_ names: [String]) -> [String] { ["volume", "delete"] + names } - public static func volumePrune() -> [String] { ["volume", "prune"] } + static func volumeDelete(_ names: [String]) -> [String] { ["volume", "delete"] + names } + static func volumePrune() -> [String] { ["volume", "prune"] } /// `network create [--internal] [--label k=v ...] [--subnet ] ` - public static func networkCreate(name: String, subnet: String? = nil, internalOnly: Bool = false, + static func networkCreate(name: String, subnet: String? = nil, internalOnly: Bool = false, labels: [String: String] = [:]) -> [String] { var args = ["network", "create"] if internalOnly { args.append("--internal") } @@ -193,11 +193,11 @@ public enum ContainerCommands { return args } /// `network delete ` - public static func networkDelete(_ names: [String]) -> [String] { ["network", "delete"] + names } - public static func networkPrune() -> [String] { ["network", "prune"] } + static func networkDelete(_ names: [String]) -> [String] { ["network", "delete"] + names } + static func networkPrune() -> [String] { ["network", "prune"] } /// `image push [--platform p] --progress plain ` — streamable push to a logged-in registry. - public static func imagePush(_ ref: String, platform: String? = nil) -> [String] { + static func imagePush(_ ref: String, platform: String? = nil) -> [String] { var args = ["image", "push", "--progress", "plain"] if let platform, !platform.isEmpty { args += ["--platform", platform] } args.append(ref) @@ -206,21 +206,21 @@ public enum ContainerCommands { // MARK: Registries - public static func registryList() -> [String] { ["registry", "list"] + jsonFormat } + static func registryList() -> [String] { ["registry", "list"] + jsonFormat } /// `registry login --username --password-stdin ` — password is piped via stdin. - public static func registryLogin(server: String, username: String) -> [String] { + static func registryLogin(server: String, username: String) -> [String] { ["registry", "login", "--username", username, "--password-stdin", server] } - public static func registryLogout(server: String) -> [String] { ["registry", "logout", server] } + static func registryLogout(server: String) -> [String] { ["registry", "logout", server] } // MARK: System - public static let systemStatus = ["system", "status"] + jsonFormat - public static let systemDF = ["system", "df"] + jsonFormat - public static let systemPropertyList = ["system", "property", "list"] + jsonFormat - public static let version = ["--version"] + static let systemStatus = ["system", "status"] + jsonFormat + static let systemDF = ["system", "df"] + jsonFormat + static let systemPropertyList = ["system", "property", "list"] + jsonFormat + static let version = ["--version"] /// `system logs [--follow] [--last N]` — service logs (plain text, not JSON). - public static func systemLogs(follow: Bool = false, last: Int? = nil) -> [String] { + static func systemLogs(follow: Bool = false, last: Int? = nil) -> [String] { var args = ["system", "logs"] if follow { args.append("--follow") } if let last { args += ["--last", String(last)] } @@ -229,11 +229,11 @@ public enum ContainerCommands { // MARK: System — kernel & DNS (privileged; may trigger a system sudo prompt handled by the CLI) - public static let systemDNSList = ["system", "dns", "list"] + jsonFormat + static let systemDNSList = ["system", "dns", "list"] + jsonFormat /// `system dns create ` — must run as administrator (the CLI prompts). - public static func systemDNSCreate(_ domain: String) -> [String] { ["system", "dns", "create", domain] } + static func systemDNSCreate(_ domain: String) -> [String] { ["system", "dns", "create", domain] } /// `system dns delete ` — must run as administrator (the CLI prompts). - public static func systemDNSDelete(_ domain: String) -> [String] { ["system", "dns", "delete", domain] } + static func systemDNSDelete(_ domain: String) -> [String] { ["system", "dns", "delete", domain] } /// `system kernel set --recommended` — download + install the recommended kernel. - public static let systemKernelSetRecommended = ["system", "kernel", "set", "--recommended"] + static let systemKernelSetRecommended = ["system", "kernel", "set", "--recommended"] } diff --git a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Parsing/ContainerStatsTableParser.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Metrics/StatsTableParser.swift similarity index 96% rename from Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Parsing/ContainerStatsTableParser.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Metrics/StatsTableParser.swift index 19a06de9..8f640379 100644 --- a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Parsing/ContainerStatsTableParser.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Metrics/StatsTableParser.swift @@ -1,19 +1,18 @@ import Foundation -import ContainedCore /// Parses the ANSI table emitted by `container stats --format table`. /// /// Apple container currently streams live stats only in table mode. Structured formats are static, /// so this parser is intentionally small, dependency-free, and isolated behind the runtime client /// boundary until Apple exposes a stable structured streaming surface. -public struct ContainerStatsTableParser: Sendable { +struct ContainerStatsTableParser: Sendable { private static let clearScreen = "\u{001B}[H\u{001B}[J" private static let columns = ["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"] private var buffer = "" private var lastEmittedFrame: String? - public init() {} + init() {} public mutating func append(_ chunk: String) -> [RuntimeStatsSnapshot] { buffer += chunk @@ -24,7 +23,7 @@ public struct ContainerStatsTableParser: Sendable { return snapshots } - public static func parseLatestFrame(in output: String) -> [RuntimeStatsSnapshot] { + static func parseLatestFrame(in output: String) -> [RuntimeStatsSnapshot] { guard let frame = latestParseableFrame(in: output), let snapshots = parseFrame(frame) else { return [] @@ -32,7 +31,7 @@ public struct ContainerStatsTableParser: Sendable { return snapshots } - public static func parseFrame(_ frame: String) -> [RuntimeStatsSnapshot]? { + static func parseFrame(_ frame: String) -> [RuntimeStatsSnapshot]? { let lines = stripANSI(from: frame) .components(separatedBy: .newlines) .map { String($0) } diff --git a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Support/AppleContainerCLILocator.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Support/CLILocator.swift similarity index 83% rename from Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Support/AppleContainerCLILocator.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Support/CLILocator.swift index 91d998dc..739a330f 100644 --- a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Support/AppleContainerCLILocator.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Support/CLILocator.swift @@ -2,14 +2,14 @@ import Foundation /// Finds the `container` binary and reports its version. The app is not sandboxed, so it can read /// these well-known install locations directly. -public enum AppleContainerCLILocator { - public static let defaultCandidates = [ +enum AppleContainerCLILocator { + static let defaultCandidates = [ "/usr/local/bin/container", "/opt/homebrew/bin/container", ] /// Resolve the CLI URL, honoring a user override first, then the standard locations. - public static func locate(override: String? = nil, + static func locate(override: String? = nil, candidates: [String] = defaultCandidates, fileManager: FileManager = .default) -> URL? { if let override, !override.isEmpty, fileManager.isExecutableFile(atPath: override) { @@ -22,7 +22,7 @@ public enum AppleContainerCLILocator { } /// Parse the semantic version out of `container --version` output, e.g. "1.0.0". - public static func parseVersion(_ output: String) -> String? { + static func parseVersion(_ output: String) -> String? { // Matches the first dotted numeric triple in a string like // "container CLI version 1.0.0 (build: release, commit: ee848e3)". guard let range = output.range(of: #"\d+\.\d+\.\d+"#, options: .regularExpression) else { return nil } @@ -30,7 +30,7 @@ public enum AppleContainerCLILocator { } /// True when a version string is in the supported 1.0.x line. - public static func isSupported(_ version: String?) -> Bool { + static func isSupported(_ version: String?) -> Bool { guard let version else { return false } return version.hasPrefix("1.0.") } diff --git a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Translation/AppleContainerCreateTranslator.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift similarity index 94% rename from Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Translation/AppleContainerCreateTranslator.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift index fb7e0d6c..9efbc3d5 100644 --- a/Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/Translation/AppleContainerCreateTranslator.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift @@ -1,13 +1,11 @@ import Foundation -import ContainedCore -import ContainedRuntime -public enum AppleContainerCreateTranslator { - public static func preview(for request: ContainerCreateRequest) -> RuntimeCommandPreview { +enum AppleContainerCreateTranslator { + static func preview(for request: ContainerCreateRequest) -> RuntimeCommandPreview { RuntimeCommandPreview(command: ContainerCommands.run(request)) } - public static func result(from data: Data, request: ContainerCreateRequest) -> ContainerCreateResult { + static func result(from data: Data, request: ContainerCreateRequest) -> ContainerCreateResult { let output = String(decoding: data, as: UTF8.self) let printedID = output .components(separatedBy: .newlines) @@ -16,7 +14,7 @@ public enum AppleContainerCreateTranslator { return ContainerCreateResult(id: request.effectiveName ?? printedID, output: output) } - public static func composePlan(for project: ComposeProject, + static func composePlan(for project: ComposeProject, baseDirectory: URL?) -> RuntimeComposeImportPlan { let items = project.services.compactMap { service -> RuntimeComposeImportItem? in guard service.image != nil else { return nil } @@ -28,7 +26,7 @@ public enum AppleContainerCreateTranslator { return RuntimeComposeImportPlan(items: items, warnings: project.warnings) } - public static func imageDefaults(for request: ContainerCreateRequest, + static func imageDefaults(for request: ContainerCreateRequest, in images: [ImageResource]) -> ContainerImageDefaults? { guard let image = matchingImage(for: request.image, in: images) else { return nil } let runnable = image.variants.filter(\.isRunnable) diff --git a/Packages/ContainedCore/Sources/ContainedCore/Schema/Version.swift b/Packages/ContainedCore/Sources/ContainedCore/Schema/Version.swift new file mode 100644 index 00000000..41e01268 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/Version.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct CoreSchemaVersion: Codable, Equatable, Hashable, Sendable { + public var rawValue: Int + + public init(_ rawValue: Int = 1) { + self.rawValue = rawValue + } + + public static let current = CoreSchemaVersion() +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/SystemProperties.swift b/Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/SystemProperties.swift rename to Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/SystemInfo.swift b/Packages/ContainedCore/Sources/ContainedCore/System/SystemStatus.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/SystemInfo.swift rename to Packages/ContainedCore/Sources/ContainedCore/System/SystemStatus.swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Models/VolumeResource.swift b/Packages/ContainedCore/Sources/ContainedCore/Volume/VolumeResource.swift similarity index 100% rename from Packages/ContainedCore/Sources/ContainedCore/Models/VolumeResource.swift rename to Packages/ContainedCore/Sources/ContainedCore/Volume/VolumeResource.swift diff --git a/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/AppleContainer/AppleContainerFixtures.swift b/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/AppleContainer/AppleContainerFixtures.swift new file mode 100644 index 00000000..72c10def --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/AppleContainer/AppleContainerFixtures.swift @@ -0,0 +1,137 @@ +import Foundation +import ContainedCore + +public extension Core.Fixtures.AppleContainer { + static let runtimeDescriptor = Core.Runtime.Descriptor.appleContainer + static let runtimes = [runtimeDescriptor] + + static let webContainer = ContainerSnapshot.placeholder( + id: "preview-web", + image: "docker.io/library/nginx:latest", + state: .running + ) + + static let workerContainer = ContainerSnapshot.placeholder( + id: "preview-worker", + image: "ghcr.io/example/worker:nightly", + state: .stopped + ) + + static let stats = StatsDelta( + id: "preview-web", + cpuCoreFraction: 0.62, + memoryUsageBytes: 420_000_000, + memoryLimitBytes: 1_073_741_824, + netRxBytesPerSec: 186_000, + netTxBytesPerSec: 72_000, + blockReadBytesPerSec: 8_400, + blockWriteBytesPerSec: 16_800, + numProcesses: 9 + ) + + static let image = decode(ImageResource.self, from: """ + { + "configuration": { + "name": "docker.io/library/nginx:latest", + "descriptor": { + "digest": "sha256:previewnginx", + "mediaType": "application/vnd.oci.image.index.v1+json", + "size": 146120 + }, + "creationDate": "2026-07-01T12:00:00Z" + }, + "id": "sha256:previewnginx", + "variants": [ + { + "digest": "sha256:previewnginx-arm64", + "size": 48120000, + "platform": { "architecture": "arm64", "os": "linux" }, + "config": { + "architecture": "arm64", + "os": "linux", + "created": "2026-07-01T12:00:00Z", + "config": { + "Cmd": ["nginx", "-g", "daemon off;"], + "Entrypoint": ["/docker-entrypoint.sh"], + "Env": ["NGINX_VERSION=preview"], + "WorkingDir": "/", + "User": "101" + } + } + } + ] + } + """) + + static let imageGroup = LocalImageTagGroup.group(containing: image, in: [image]) + + static let volume = decode(VolumeResource.self, from: """ + { + "configuration": { + "name": "preview-data", + "source": "/Users/preview/.contained/volumes/preview-data", + "format": "apfs", + "sizeInBytes": 10737418240, + "creationDate": "2026-07-01T12:10:00Z", + "labels": { "contained.stack": "preview" } + } + } + """) + + static let network = decode(NetworkResource.self, from: """ + { + "id": "preview-network", + "configuration": { + "name": "preview-network", + "mode": "nat", + "plugin": "builtin", + "creationDate": "2026-07-01T12:12:00Z", + "labels": { "contained.stack": "preview" }, + "options": { "variant": "preview" } + }, + "status": { + "ipv4Gateway": "10.42.0.1", + "ipv4Subnet": "10.42.0.0/24", + "ipv6Subnet": null + } + } + """) + + static let unsupportedCapabilityError = Core.Runtime.UnsupportedCapability( + kind: .appleContainer, + capability: .coreMigration + ) + + static let commandError = CommandError.nonZeroExit( + code: 42, + stderr: "preview failure", + command: "container preview" + ) + + static let createRequest: ContainerCreateRequest = { + var request = ContainerCreateRequest() + request.runtimeKind = .appleContainer + request.image = image.reference + request.platform = "linux/arm64" + request.name = "preview-web" + request.command = ["nginx", "-g", "daemon off;"] + request.env = [ContainerCreateKeyValue(key: "ENV", value: "preview")] + request.labels = [ContainerCreateKeyValue(key: "contained.stack", value: "preview")] + request.ports = [ContainerCreatePort(hostPort: "8080", containerPort: "80")] + request.cpus = "2" + request.memory = "1g" + request.workingDir = "/" + request.useInit = true + return request + }() +} + +private func decode(_ type: T.Type, from json: String) -> T { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + do { + return try decoder.decode(T.self, from: Data(json.utf8)) + } catch { + preconditionFailure("Invalid core fixture for \(T.self): \(error)") + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Generic/GenericFixtures.swift b/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Generic/GenericFixtures.swift new file mode 100644 index 00000000..2542b28c --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Generic/GenericFixtures.swift @@ -0,0 +1,55 @@ +import Foundation +import ContainedCore + +public extension Core.Fixtures.Generic { + struct MetricHistory: Equatable, Sendable, ContainedCore.MetricHistorySample { + public var timestamp: Date + public var cpuFraction: Double + public var memoryBytes: Double + public var netRxBytesPerSec: Double + public var netTxBytesPerSec: Double + public var diskReadBytesPerSec: Double + public var diskWriteBytesPerSec: Double + + public init(timestamp: Date, + cpuFraction: Double, + memoryBytes: Double, + netRxBytesPerSec: Double, + netTxBytesPerSec: Double, + diskReadBytesPerSec: Double, + diskWriteBytesPerSec: Double) { + self.timestamp = timestamp + self.cpuFraction = cpuFraction + self.memoryBytes = memoryBytes + self.netRxBytesPerSec = netRxBytesPerSec + self.netTxBytesPerSec = netTxBytesPerSec + self.diskReadBytesPerSec = diskReadBytesPerSec + self.diskWriteBytesPerSec = diskWriteBytesPerSec + } + } + + static let now = Date(timeIntervalSinceReferenceDate: 790_000_000) + + static let sparklineValues: [Double] = [ + 0.12, 0.16, 0.18, 0.25, 0.22, 0.31, 0.38, 0.35, + 0.44, 0.48, 0.43, 0.52, 0.57, 0.54, 0.61, 0.58, + 0.66, 0.62, 0.70, 0.68, 0.74, 0.71, 0.78, 0.76, + ] + + static let networkSamples: [Double] = [ + 18_000, 24_000, 22_000, 46_000, 42_000, 54_000, + 66_000, 72_000, 68_000, 80_000, 92_000, 88_000, + ] + + static let metricHistory: [MetricHistory] = sparklineValues.enumerated().map { offset, value in + MetricHistory( + timestamp: now.addingTimeInterval(Double(offset) * 30), + cpuFraction: value, + memoryBytes: 260_000_000 + Double(offset) * 6_000_000, + netRxBytesPerSec: networkSamples[offset % networkSamples.count], + netTxBytesPerSec: networkSamples[(offset + 3) % networkSamples.count] * 0.45, + diskReadBytesPerSec: 4_000 + Double(offset * 320), + diskWriteBytesPerSec: 8_000 + Double(offset * 420) + ) + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Namespace.swift b/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Namespace.swift new file mode 100644 index 00000000..f07f5f9d --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Namespace.swift @@ -0,0 +1,11 @@ +import Foundation +import ContainedCore + +public extension Core { + enum Fixtures {} +} + +public extension Core.Fixtures { + enum AppleContainer {} + enum Generic {} +} diff --git a/Packages/ContainedCore/Tests/ContainedCoreFixturesTests/CoreFixturesTests.swift b/Packages/ContainedCore/Tests/ContainedCoreFixturesTests/CoreFixturesTests.swift new file mode 100644 index 00000000..240266a8 --- /dev/null +++ b/Packages/ContainedCore/Tests/ContainedCoreFixturesTests/CoreFixturesTests.swift @@ -0,0 +1,23 @@ +import Testing +import ContainedCore +import ContainedCoreFixtures + +@Suite("Core fixtures") +struct CoreFixturesTests { + @Test func appleContainerFixturesAreDeterministicAndUsable() { + #expect(Core.Fixtures.AppleContainer.webContainer.id == "preview-web") + #expect(Core.Fixtures.AppleContainer.image.reference == "docker.io/library/nginx:latest") + #expect(Core.Fixtures.AppleContainer.createRequest.image == Core.Fixtures.AppleContainer.image.reference) + #expect(Core.Fixtures.AppleContainer.volume.name == "preview-data") + #expect(Core.Fixtures.AppleContainer.network.status?.ipv4Subnet == "10.42.0.0/24") + #expect(Core.Fixtures.AppleContainer.runtimes.map(\.kind) == [.appleContainer]) + #expect(Core.Fixtures.AppleContainer.unsupportedCapabilityError.packageErrorCode == "unsupportedRuntimeCapability") + #expect(Core.Fixtures.AppleContainer.commandError.packageErrorContext["code"] == "42") + } + + @Test func genericMetricFixturesAreStable() { + #expect(!Core.Fixtures.Generic.sparklineValues.isEmpty) + #expect(Core.Fixtures.Generic.metricHistory.count == Core.Fixtures.Generic.sparklineValues.count) + #expect(Core.Fixtures.Generic.metricHistory.first?.cpuFraction == Core.Fixtures.Generic.sparklineValues.first) + } +} diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift index 177a5a4d..bb9ee632 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift @@ -29,16 +29,13 @@ struct DecodingTests { #expect(c.configuration.initProcess.environment.contains("FOO=bar")) #expect(c.configuration.publishedPorts.first?.hostPort == 18080) #expect(c.configuration.publishedPorts.first?.containerPort == 80) - // Legacy personalization labels still decode so older containers can migrate locally. - #expect(c.tintLabel == "teal") - #expect(c.iconLabel == "globe") + #expect(c.configuration.labels.isEmpty) #expect(c.startedDate != nil) #expect(c.status.networks.first?.ipv4Address == "192.168.64.3/24") } @Test func decodesMultiContainerListWithVirtiofsMounts() throws { - // Live output with pre-existing containers exposed an enum-as-object mount `type` - // ({"virtiofs":{}}) that the initial String model couldn't decode. + // Live output can represent mount `type` as an enum-like object such as {"virtiofs":{}}. let snapshots = try ContainerJSON.decode([ContainerSnapshot].self, from: try Fixture.data("list-current")) #expect(snapshots.count == 4) let npm = try #require(snapshots.first { $0.id == "nginx-proxy-manager-latest" }) diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift index 1e157ca2..9d49b773 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift @@ -17,3 +17,24 @@ enum Fixture { enum FixtureError: Error { case notFound(String) } } + +/// A `CommandRunning` that replays canned output/errors with no runtime daemon. +struct MockCommandRunner: CommandRunning { + var result: Result + var streamChunks: [String] = [] + + func run(_ arguments: [String], + stdin: Data?, + priority: CommandExecutionPriority) async throws -> Data { + try result.get() + } + + func stream(_ arguments: [String], + priority: CommandExecutionPriority) -> AsyncThrowingStream { + let chunks = streamChunks + return AsyncThrowingStream { continuation in + for chunk in chunks { continuation.yield(chunk) } + continuation.finish() + } + } +} diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/inspect.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/inspect.json index f73f540e..09ff4aac 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/inspect.json +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/inspect.json @@ -53,8 +53,7 @@ "workingDirectory" : "\/" }, "labels" : { - "contained.icon" : "globe", - "contained.tint" : "teal" + }, "mounts" : [ diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list-current.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list-current.json index bf64c211..8c4ebf36 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list-current.json +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list-current.json @@ -1 +1 @@ -[{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"ddns-latest","image":{"descriptor":{"digest":"sha256:19bd73d8aea9641dc329ec18ae693b2b9c33ff7cdc007f368266ce584446f995","mediaType":"application\/vnd.docker.distribution.manifest.list.v2+json","size":1108},"reference":"docker.io\/example\/ddns:latest"},"initProcess":{"arguments":[],"environment":["ZONE=example.com","RRTYPE=A","API_KEY=example-token-redacted","PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","CRON=*\/5\t*\t*\t*\t*"],"executable":"\/init","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"raw":{"userString":""}},"workingDirectory":""},"labels":{},"mounts":[],"networks":[{"network":"default","options":{"hostname":"ddns-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"ddns-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"nginx-proxy-manager-latest","image":{"descriptor":{"digest":"sha256:2aa69b382a384b676c0d4f1d6f2eac40ecd478fcf7af1cfb3f9f1d3cd0c81e12","mediaType":"application\/vnd.oci.image.index.v1+json","size":1609},"reference":"docker.io\/jc21\/nginx-proxy-manager:latest"},"initProcess":{"arguments":[],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","NODE_ENV=production","TZ=UTC"],"executable":"\/init","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/app"},"labels":{},"mounts":[{"destination":"\/data","options":[],"source":"\/srv\/nginx\/data\/","type":{"virtiofs":{}}},{"destination":"\/etc\/letsencrypt","options":[],"source":"\/srv\/nginx\/letsencrypt\/","type":{"virtiofs":{}}}],"networks":[{"network":"default","options":{"hostname":"nginx-proxy-manager-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":80,"proto":"tcp"},{"containerPort":81,"count":1,"hostAddress":"0.0.0.0","hostPort":81,"proto":"tcp"},{"containerPort":443,"count":1,"hostAddress":"0.0.0.0","hostPort":443,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"nginx-proxy-manager-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"app-latest","image":{"descriptor":{"digest":"sha256:b35ba0461c4a1033d117ac1e5968fd4cbe777899e4cbfbdeaf3d10a42a0eb7e9","mediaType":"application\/vnd.docker.distribution.manifest.list.v2+json","size":685},"reference":"docker.io\/example\/app:latest"},"initProcess":{"arguments":["npm","start"],"environment":["TZ=UTC","NODE_ENV=production","PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin"],"executable":"docker-entrypoint.sh","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"raw":{"userString":"node:node"}},"workingDirectory":"\/app"},"labels":{},"mounts":[{"destination":"\/app\/config","options":[],"source":"\/srv\/app\/config\/","type":{"virtiofs":{}}}],"networks":[{"network":"default","options":{"hostname":"app-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":5055,"count":1,"hostAddress":"0.0.0.0","hostPort":5055,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"app-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"2026-06-24T10:16:58Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"fixture-web","image":{"descriptor":{"digest":"sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b","mediaType":"application\/vnd.oci.image.index.v1+json","size":9218},"reference":"docker.io\/library\/alpine:latest"},"initProcess":{"arguments":["600"],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","FOO=bar"],"executable":"sleep","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/"},"labels":{"contained.icon":"globe","contained.tint":"teal"},"mounts":[],"networks":[{"network":"default","options":{"hostname":"fixture-web","mtu":1280}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":18080,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":4,"memoryInBytes":1073741824},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"fixture-web","status":{"networks":[],"startedDate":"2026-06-24T10:16:59Z","state":"stopped"}}] +[{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"ddns-latest","image":{"descriptor":{"digest":"sha256:19bd73d8aea9641dc329ec18ae693b2b9c33ff7cdc007f368266ce584446f995","mediaType":"application\/vnd.docker.distribution.manifest.list.v2+json","size":1108},"reference":"docker.io\/example\/ddns:latest"},"initProcess":{"arguments":[],"environment":["ZONE=example.com","RRTYPE=A","API_KEY=example-token-redacted","PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","CRON=*\/5\t*\t*\t*\t*"],"executable":"\/init","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"raw":{"userString":""}},"workingDirectory":""},"labels":{},"mounts":[],"networks":[{"network":"default","options":{"hostname":"ddns-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"ddns-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"nginx-proxy-manager-latest","image":{"descriptor":{"digest":"sha256:2aa69b382a384b676c0d4f1d6f2eac40ecd478fcf7af1cfb3f9f1d3cd0c81e12","mediaType":"application\/vnd.oci.image.index.v1+json","size":1609},"reference":"docker.io\/jc21\/nginx-proxy-manager:latest"},"initProcess":{"arguments":[],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","NODE_ENV=production","TZ=UTC"],"executable":"\/init","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/app"},"labels":{},"mounts":[{"destination":"\/data","options":[],"source":"\/srv\/nginx\/data\/","type":{"virtiofs":{}}},{"destination":"\/etc\/letsencrypt","options":[],"source":"\/srv\/nginx\/letsencrypt\/","type":{"virtiofs":{}}}],"networks":[{"network":"default","options":{"hostname":"nginx-proxy-manager-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":80,"proto":"tcp"},{"containerPort":81,"count":1,"hostAddress":"0.0.0.0","hostPort":81,"proto":"tcp"},{"containerPort":443,"count":1,"hostAddress":"0.0.0.0","hostPort":443,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"nginx-proxy-manager-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"1970-01-01T00:00:00Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"app-latest","image":{"descriptor":{"digest":"sha256:b35ba0461c4a1033d117ac1e5968fd4cbe777899e4cbfbdeaf3d10a42a0eb7e9","mediaType":"application\/vnd.docker.distribution.manifest.list.v2+json","size":685},"reference":"docker.io\/example\/app:latest"},"initProcess":{"arguments":["npm","start"],"environment":["TZ=UTC","NODE_ENV=production","PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin"],"executable":"docker-entrypoint.sh","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"raw":{"userString":"node:node"}},"workingDirectory":"\/app"},"labels":{},"mounts":[{"destination":"\/app\/config","options":[],"source":"\/srv\/app\/config\/","type":{"virtiofs":{}}}],"networks":[{"network":"default","options":{"hostname":"app-latest"}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":5055,"count":1,"hostAddress":"0.0.0.0","hostPort":5055,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":2,"memoryInBytes":2147483648},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"app-latest","status":{"networks":[],"state":"stopped"}},{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"2026-06-24T10:16:58Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"fixture-web","image":{"descriptor":{"digest":"sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b","mediaType":"application\/vnd.oci.image.index.v1+json","size":9218},"reference":"docker.io\/library\/alpine:latest"},"initProcess":{"arguments":["600"],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","FOO=bar"],"executable":"sleep","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/"},"labels":{},"mounts":[],"networks":[{"network":"default","options":{"hostname":"fixture-web","mtu":1280}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":18080,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":4,"memoryInBytes":1073741824},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"fixture-web","status":{"networks":[],"startedDate":"2026-06-24T10:16:59Z","state":"stopped"}}] diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list.json index 05161683..6d069fad 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list.json +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list.json @@ -1 +1 @@ -[{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"2026-06-24T10:16:58Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"fixture-web","image":{"descriptor":{"digest":"sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b","mediaType":"application\/vnd.oci.image.index.v1+json","size":9218},"reference":"docker.io\/library\/alpine:latest"},"initProcess":{"arguments":["600"],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","FOO=bar"],"executable":"sleep","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/"},"labels":{"contained.icon":"globe","contained.tint":"teal"},"mounts":[],"networks":[{"network":"default","options":{"hostname":"fixture-web","mtu":1280}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":18080,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":4,"memoryInBytes":1073741824},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"fixture-web","status":{"networks":[{"hostname":"fixture-web","ipv4Address":"192.168.64.3\/24","ipv4Gateway":"192.168.64.1","ipv6Address":"fdfd:fa52:b619:2ef7:fcb1:66ff:fe77:68e1\/64","macAddress":"fe:b1:66:77:68:e1","mtu":1280,"network":"default"}],"startedDate":"2026-06-24T10:16:59Z","state":"running"}}] +[{"configuration":{"capAdd":[],"capDrop":[],"creationDate":"2026-06-24T10:16:58Z","dns":{"nameservers":[],"options":[],"searchDomains":[]},"id":"fixture-web","image":{"descriptor":{"digest":"sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b","mediaType":"application\/vnd.oci.image.index.v1+json","size":9218},"reference":"docker.io\/library\/alpine:latest"},"initProcess":{"arguments":["600"],"environment":["PATH=\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin","FOO=bar"],"executable":"sleep","rlimits":[],"supplementalGroups":[],"terminal":false,"user":{"id":{"gid":0,"uid":0}},"workingDirectory":"\/"},"labels":{},"mounts":[],"networks":[{"network":"default","options":{"hostname":"fixture-web","mtu":1280}}],"platform":{"architecture":"arm64","os":"linux"},"publishedPorts":[{"containerPort":80,"count":1,"hostAddress":"0.0.0.0","hostPort":18080,"proto":"tcp"}],"publishedSockets":[],"readOnly":false,"resources":{"cpuOverhead":1,"cpus":4,"memoryInBytes":1073741824},"rosetta":false,"runtimeHandler":"container-runtime-linux","ssh":false,"sysctls":{},"useInit":false,"virtualization":false},"id":"fixture-web","status":{"networks":[{"hostname":"fixture-web","ipv4Address":"192.168.64.3\/24","ipv4Gateway":"192.168.64.1","ipv6Address":"fdfd:fa52:b619:2ef7:fcb1:66ff:fe77:68e1\/64","macAddress":"fe:b1:66:77:68:e1","mtu":1280,"network":"default"}],"startedDate":"2026-06-24T10:16:59Z","state":"running"}}] diff --git a/Packages/ContainedRuntime/Tests/ContainedRuntimeTests/RuntimeDescriptorTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift similarity index 98% rename from Packages/ContainedRuntime/Tests/ContainedRuntimeTests/RuntimeDescriptorTests.swift rename to Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift index a633e6f5..9cf404d5 100644 --- a/Packages/ContainedRuntime/Tests/ContainedRuntimeTests/RuntimeDescriptorTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift @@ -1,7 +1,6 @@ import Foundation import Testing -import ContainedCore -@testable import ContainedRuntime +@testable import ContainedCore @Suite("Runtime descriptor contracts") struct RuntimeDescriptorTests { @@ -25,7 +24,7 @@ struct RuntimeDescriptorTests { capability: .imageBuild ) - #expect(error.packageName == "ContainedRuntime") + #expect(error.packageName == "ContainedCore") #expect(error.packageErrorCode == "unsupportedRuntimeCapability") #expect(error.packageErrorContext["kind"] == RuntimeKind.dockerCompatible.rawValue) #expect(error.packageErrorContext["capability"] == String(RuntimeCapability.imageBuild.rawValue)) diff --git a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/AppleContainerRuntimeTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift similarity index 97% rename from Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/AppleContainerRuntimeTests.swift rename to Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift index ce34d2a0..d5a59065 100644 --- a/Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/AppleContainerRuntimeTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift @@ -1,11 +1,9 @@ import Foundation import Testing -import ContainedCore -import ContainedRuntime -import AppleContainerRuntime +@testable import ContainedCore @Suite("Runtime adapter boundary") -struct AppleContainerRuntimeTests { +struct AppleContainerAdapterTests { @Test func runtimeKindAcceptsFutureAdapters() throws { let descriptor = RuntimeDescriptor(kind: RuntimeKind(rawValue: "future-engine"), displayName: "Future Engine", @@ -21,7 +19,7 @@ struct AppleContainerRuntimeTests { do { try descriptor.require(.imageBuild) } catch let error as UnsupportedRuntimeCapability { - #expect(error.packageName == "ContainedRuntime") + #expect(error.packageName == "ContainedCore") #expect(error.packageErrorCode == "unsupportedRuntimeCapability") #expect(error.packageErrorContext["kind"] == "future-engine") } @@ -30,7 +28,7 @@ struct AppleContainerRuntimeTests { @Test func commandErrorsExposePackageCodesAndContext() { let error = CommandError.nonZeroExit(code: 42, stderr: "boom", command: "container list") - #expect(error.packageName == "ContainedRuntime") + #expect(error.packageName == "ContainedCore") #expect(error.packageErrorCode == "nonZeroExit") #expect(error.packageErrorContext["code"] == "42") #expect(error.packageErrorContext["stderr"] == "boom") diff --git a/Packages/ContainedDesignSystem/Package.swift b/Packages/ContainedDesignSystem/Package.swift deleted file mode 100644 index 84f60448..00000000 --- a/Packages/ContainedDesignSystem/Package.swift +++ /dev/null @@ -1,17 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let package = Package( - name: "ContainedDesignSystem", - platforms: [.macOS(.v26)], - products: [ - .library(name: "ContainedDesignSystem", targets: ["ContainedDesignSystem"]), - ], - targets: [ - .target(name: "ContainedDesignSystem"), - .testTarget( - name: "ContainedDesignSystemTests", - dependencies: ["ContainedDesignSystem"] - ), - ] -) diff --git a/Packages/ContainedDesignSystem/README.md b/Packages/ContainedDesignSystem/README.md deleted file mode 100644 index e2aa1f8c..00000000 --- a/Packages/ContainedDesignSystem/README.md +++ /dev/null @@ -1,379 +0,0 @@ -# ContainedDesignSystem - -`ContainedDesignSystem` is the local Swift package that owns Contained's reusable -SwiftUI/AppKit visual language. - -Use it for app-agnostic UI primitives: tokens, glass surfaces, panel/page/sheet -scaffolds, toolbar controls, design-card chrome, sparklines, JSON/stream -surfaces, color controls, clipboard helpers, and small chrome such as badges, -keycaps, status dots, metric tiles, terminal surfaces, and selection overlays. - -Do not put app state, stores, SwiftData models, Sparkle wiring, routing, runtime -models, or feature-specific business rules in this package. App code should pass -plain values into package views instead. - -This package also does not own localized resources or English UI defaults. When -a primitive needs visible text, accessibility copy, help, display names, or -error/failure messages, the app supplies those strings through parameters. The -app target owns localization keys and English fallbacks. - -## Importing - -From the root app package: - -```swift -.product(name: "ContainedDesignSystem", package: "ContainedDesignSystem") -``` - -From Swift code: - -```swift -import SwiftUI -import ContainedDesignSystem -``` - -This package currently depends only on platform frameworks available to a macOS -26 SwiftUI app. - -## What Belongs Here - -- `DesignTokens` for spacing, radius, toolbar, panel, icon, form, chart, badge, - keycap, card, terminal, and menu-bar constants. -- `WindowMaterial`, `DesignTint`, `ColorLayerBlendMode`, and root environment - values for shared material/tint policy. -- Named surface routes such as `DesignContentSurface`, `DesignInputSurface`, - `DesignPanelScaffold`, and toolbar controls. Low-level glass modifiers and - visual-effect bridges are package implementation details. -- `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `SheetHeader`, and - `PageScaffold` for app-neutral scaffolding. -- `DesignActionGroup`, `DesignActionCluster`, `DesignInputCluster`, - `DesignTextActionButton`, `DesignToggleButton`, `DesignSelectionActionBar`, - `DesignStatusBanner`, and toolbar controls for package-owned command chrome. - Low-level glass button groups are package implementation details behind these - named controls. -- `DesignOptionStack` and `DesignOptionTile` for option grids and creation-style - choice lists. -- `DesignCard`, `DesignCardPages`, `DesignCardFooterChip`, - `DesignCardFooterButton`, `DesignCardWidgetGroup`, `DesignCardInsetSection`, - and other `DesignCard*` pieces for repeated card layouts and card-local controls. - App code should use `DesignCard`; card shell, header, and page-rail assembly is - package-owned. - Use `designCardFloatingControls` and `designCardProgressOverlay` for - card overlays instead of app-local `.overlay` recipes. -- `ActivityStatusView` with `ActivityStatusPresentation`, where callers provide - plain status text/progress instead of app model objects. -- `DesignContentSurface` and `DesignInputSurface` for non-card content and - input surfaces. Feature code should use these named routes instead of calling - the lower-level surface modifiers directly. -- `LiveSparkline`, `GraphStyle`, `WidgetInterpolation`, and `SparklineScale` - for Swift Charts-backed live graph widgets. Use `.fraction` for values that - already live on a 0...1 scale, and `.normalized` for byte/rate series that - should fill the compact chart. -- Micro-primitives such as `DesignStatusDot`, `DesignStatusBadge`, - `DesignKeyCap`, `DesignKeyboardHint`, `DesignTintSwatch`, and - `DesignMetricTile`. - -## App Root Setup - -Seed package environment values once at the shell/root instead of restyling -individual views: - -```swift -struct AppRoot: View { - var body: some View { - DesignSystemExample() - .tint(DesignTint.azure.color) - .environment(\.modalMaterial, WindowMaterial.sheet) - .environment(\.buttonMaterial, WindowMaterial.glassClear) - .environment(\.cardMaterial, WindowMaterial.glassRegular) - .environment(\.buttonTintStyle, DesignButtonTintStyle(enabled: true, - tint: .azure, - opacity: 0.18)) - .environment(\.designSystemShowsInfoTips, true) - .environment(\.pageScaffoldUsesToolbarChrome, false) - .environment(\.pageScaffoldBottomClearance, 0) - } -} -``` - -## Example - -```swift -import SwiftUI -import ContainedDesignSystem - -struct DesignSystemExample: View { - @State private var tint = DesignTint.azure - - var body: some View { - PageScaffold(symbol: "shippingbox", - title: "Containers", - subtitle: "Local runtime") { - VStack(spacing: DesignTokens.Space.l) { - PanelSection(header: "Appearance") { - PanelRow(title: "Accent") { - TintSelector(selection: $tint, labelForTint: tintName) - } - PanelRow(title: "Shortcut") { - DesignKeyboardHint("return", "Open") - } - } - - DesignCard(size: .small, - elevated: false, - title: "web", - subtitle: "nginx:latest") { - DesignCardIconChip(symbol: "shippingbox.fill", - tint: tint.color) - } titleAccessory: { - EmptyView() - } subtitleAccessory: { - EmptyView() - } headerAccessory: { - DesignListRowChevron() - } bodyContent: { - EmptyView() - } footerLeading: { - EmptyView() - } footerActions: { - EmptyView() - } widget: { - EmptyView() - } - .selectionFill() - - ActivityStatusView( - activity: ActivityStatusPresentation(title: "Pulling image", - detail: "nginx:latest", - fraction: 0.42), - style: .expanded - ) - } - } - .environment(\.cardMaterial, .glassRegular) - .environment(\.buttonMaterial, .glassClear) - .environment(\.designSystemShowsInfoTips, true) - } - - private func tintName(_ tint: DesignTint) -> String { - switch tint { - case .multicolor: return "App Accent" - case .graphite: return "Graphite" - case .azure: return "Azure" - case .teal: return "Teal" - case .coral: return "Coral" - case .indigo: return "Indigo" - case .green: return "Green" - case .amber: return "Amber" - case .pink: return "Pink" - } - } -} -``` - -## Panel Scaffold Example - -Use `DesignPanelScaffold` when a host already owns presentation, size, and -placement but needs shared fixed chrome, lazy scrolling content, and an optional -pinned footer: - -```swift -DesignPanelScaffold(width: DesignTokens.PanelSize.settings.width) { - PanelHeader(symbol: "gearshape", - title: "Settings", - subtitle: "Appearance") { - DesignActionGroup(DesignAction(systemName: "xmark", - help: "Close", - isCancel: true) {}) - } - Divider() -} content: { - PanelSection(header: "Theme") { - PanelRow(title: "Accent") { - TintSelector(selection: .constant(.azure), - labelForTint: { _ in "Azure" }) - } - } - .padding(DesignTokens.Space.s) -} -``` - -## Design Card Controls - -Keep card-local controls in the package. Feature views provide plain values and -actions instead of assembling headers, footer groups, or expanded-card page rails: - -`DesignCard` owns card anatomy: - -- the header is always sticky and visible -- page controls are declared through `DesignCardPages`, stay mounted in the - header trailing slot, and use `controlsReveal` for visibility -- the body appears only when the card is expanded -- the widget is sticky for `.large` cards and becomes body content for `.medium` -- the footer is sticky for `.medium` and `.large` cards and becomes body content for `.small` - -```swift -struct CardControlsExample: View { - @State private var page = "overview" - @State private var metric = "cpu" - - private let pages = [ - DesignCardPageControlItem(id: "overview", - title: "Overview", - systemImage: "rectangle.grid.1x2"), - DesignCardPageControlItem(id: "logs", - title: "Logs", - systemImage: "text.alignleft") - ] - - var body: some View { - DesignCard(size: .large, - title: "web", - subtitle: "nginx:latest", - pages: DesignCardPages(items: pages, - selection: page, - tint: .accentColor, - closeLabel: "Close", - onSelect: { page = $0 }, - onClose: {})) { - DesignCardIconChip(symbol: "shippingbox.fill") - } titleAccessory: { - EmptyView() - } subtitleAccessory: { - EmptyView() - } headerAccessory: { - EmptyView() - } bodyContent: { - DesignCardInsetSection(title: "Details") { - DesignCardSubtitleText(text: "Ready") - } - } footerLeading: { - DesignCardFooterChip(isSelected: metric == "cpu", - tint: .accentColor, - help: "CPU", - action: { metric = "cpu" }) { - Image(systemName: "cpu") - } text: { - DesignCardMetricText(text: "12%") - } - } footerActions: { - DesignCardFooterButton(systemName: "play.fill", - help: "Start", - tint: .accentColor) {} - } widget: { - LiveSparkline(samples: [0, 0.12, 0.18], - color: .accentColor, - scale: .fraction) - .frame(height: DesignTokens.DesignCard.sparklineHeight) - } - } -} -``` - -For standalone empty states or input chrome, keep the surface choice in this -package too: - -```swift -DesignContentSurface(minHeight: 220) { - ContentUnavailableView("No matches", systemImage: "magnifyingglass") -} - -DesignInputSurface { - HStack { - Image(systemName: "magnifyingglass") - TextField("Search", text: $query) - .textFieldStyle(.plain) - } -} -``` - -## Action and Toolbar Controls - -Feature views should pass action intent into package controls. Do not restate -glass button styles, capsule surfaces, hover treatment, or toolbar search chrome -in the app target. - -```swift -DesignActionGroup([ - DesignAction(systemName: "doc.on.doc", help: "Copy") { - copyToPasteboard(output) - }, - DesignAction(systemName: "trash", - help: "Clear", - role: .destructive) { - clear() - } -]) - -DesignActionCluster { - Menu { - Button("All") {} - } label: { - DesignMenuActionLabel(systemName: "line.3.horizontal.decrease", - help: "Filter") - } - DesignActionItems([ - DesignAction(systemName: "checkmark.circle", help: "Mark read") {} - ]) -} - -DesignInputCluster { - Image(systemName: "magnifyingglass") - TextField("Search", text: $query) - .textFieldStyle(.plain) -} - -DesignTextActionButton(title: "Import", - systemName: "arrow.down.doc", - prominence: .prominent, - isEnabled: canImport) { - importArchive() -} - -DesignToggleButton(isOn: $following, - title: "Follow", - systemName: "arrow.down.to.line") - -DesignSelectionActionBar(count: selection.count, actions: [ - DesignAction(systemName: "play.fill", title: "Start") { startSelection() }, - DesignAction(systemName: "trash", - title: "Delete", - role: .destructive) { deleteSelection() } -]) -``` - -Toolbar-specific controls follow the same rule: - -```swift -DesignToolbarSearchField(text: $query, - prompt: "Search this page", - focused: $focused, - onClear: { query = "" }) { - DesignKeyboardHint("command", "K") -} -``` - -## Documentation - -- DocC landing page: - `Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md` -- App-level guidance: - `../../docs/architecture/Design-System.md` -- Navigation package: - `../ContainedNavigation/README.md` - -## Verification - -Build and test the package by itself: - -```sh -swift build --package-path Packages/ContainedDesignSystem -swift test --package-path Packages/ContainedDesignSystem -``` - -Build it through the app graph: - -```sh -swift build -swift test -``` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md deleted file mode 100644 index 61bab11a..00000000 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md +++ /dev/null @@ -1,364 +0,0 @@ -# ``ContainedDesignSystem`` - -Reusable SwiftUI/AppKit visual primitives for Contained. - -## Overview - -`ContainedDesignSystem` owns app-agnostic visual policy: spacing, padding, -radius, material, tint, glass surfaces, panel/page/sheet scaffolds, toolbar -controls, design-card chrome, sparklines, JSON/stream surfaces, color controls, -clipboard helpers, and small chrome such as badges, keycaps, status dots, metric -tiles, terminal surfaces, and selection overlays. - -Do not add app state, stores, SwiftData models, Sparkle wiring, routing, runtime -models, or feature-specific business rules here. Convert app/domain state into -plain values before passing it to package views. - -The executable app owns localization. Package controls that need visible text, -help, accessibility labels, display names, or error/failure messages take those -strings from the caller instead of shipping English defaults or localized -resources here. - -## Configure Shared Policy Once - -Set material and shell policy near the app root: - -```swift -struct AppRoot: View { - var body: some View { - DesignSystemExample() - .tint(DesignTint.azure.color) - .environment(\.modalMaterial, WindowMaterial.sheet) - .environment(\.buttonMaterial, WindowMaterial.glassClear) - .environment(\.cardMaterial, WindowMaterial.glassRegular) - .environment(\.buttonTintStyle, DesignButtonTintStyle(enabled: true, - tint: .azure)) - .environment(\.designSystemShowsInfoTips, true) - .environment(\.pageScaffoldUsesToolbarChrome, false) - .environment(\.pageScaffoldBottomClearance, 0) - } -} -``` - -## Example - -```swift -import SwiftUI -import ContainedDesignSystem - -struct DesignSystemExample: View { - @State private var tint = DesignTint.azure - - var body: some View { - PageScaffold(symbol: "shippingbox", - title: "Containers", - subtitle: "Local runtime") { - VStack(spacing: DesignTokens.Space.l) { - PanelSection(header: "Appearance") { - PanelRow(title: "Accent") { - TintSelector(selection: $tint, labelForTint: tintName) - } - PanelRow(title: "Shortcut") { - DesignKeyboardHint("return", "Open") - } - } - - DesignCard(size: .small, - elevated: false, - title: "web", - subtitle: "nginx:latest") { - DesignCardIconChip(symbol: "shippingbox.fill", - tint: tint.color) - } titleAccessory: { - EmptyView() - } subtitleAccessory: { - EmptyView() - } headerAccessory: { - DesignListRowChevron() - } bodyContent: { - EmptyView() - } footerLeading: { - EmptyView() - } footerActions: { - EmptyView() - } widget: { - EmptyView() - } - .selectionFill() - } - } - .environment(\.cardMaterial, .glassRegular) - .environment(\.buttonMaterial, .glassClear) - } - - private func tintName(_ tint: DesignTint) -> String { - switch tint { - case .multicolor: return "App Accent" - case .graphite: return "Graphite" - case .azure: return "Azure" - case .teal: return "Teal" - case .coral: return "Coral" - case .indigo: return "Indigo" - case .green: return "Green" - case .amber: return "Amber" - case .pink: return "Pink" - } - } -} -``` - -## Panel Scaffolds - -Use `DesignPanelScaffold` when a host already owns presentation, size, and -placement but needs shared fixed chrome, lazy scrolling content, and an optional -pinned footer: - -```swift -DesignPanelScaffold(width: DesignTokens.PanelSize.settings.width) { - PanelHeader(symbol: "gearshape", - title: "Settings", - subtitle: "Appearance") { - DesignActionGroup(DesignAction(systemName: "xmark", - help: "Close", - isCancel: true) {}) - } - Divider() -} content: { - PanelSection(header: "Theme") { - PanelRow(title: "Accent") { - TintSelector(selection: .constant(.azure), - labelForTint: { _ in "Azure" }) - } - } - .padding(DesignTokens.Space.s) -} -``` - -## Design Card Controls - -Use `DesignCard` for cards. Feature views pass plain titles, subtitles, page -IDs, labels, metric strings, and actions; the package owns header/body/widget/footer -placement: - -`DesignCard` owns card anatomy: - -- the header is always sticky and visible -- page controls are declared through `DesignCardPages`, stay mounted in the - header trailing slot, and use `controlsReveal` for visibility -- the body appears only when the card is expanded -- the widget is sticky for `.large` cards and becomes body content for `.medium` -- the footer is sticky for `.medium` and `.large` cards and becomes body content for `.small` - -```swift -struct CardControlsExample: View { - @State private var page = "overview" - @State private var metric = "cpu" - - private let pages = [ - DesignCardPageControlItem(id: "overview", - title: "Overview", - systemImage: "rectangle.grid.1x2"), - DesignCardPageControlItem(id: "logs", - title: "Logs", - systemImage: "text.alignleft") - ] - - var body: some View { - DesignCard(size: .large, - title: "web", - subtitle: "nginx:latest", - pages: DesignCardPages(items: pages, - selection: page, - tint: .accentColor, - closeLabel: "Close", - onSelect: { page = $0 }, - onClose: {})) { - DesignCardIconChip(symbol: "shippingbox.fill") - } titleAccessory: { - EmptyView() - } subtitleAccessory: { - EmptyView() - } headerAccessory: { - EmptyView() - } bodyContent: { - DesignCardInsetSection(title: "Details") { - DesignCardSubtitleText(text: "Ready") - } - } footerLeading: { - DesignCardFooterChip(isSelected: metric == "cpu", - tint: .accentColor, - help: "CPU", - action: { metric = "cpu" }) { - Image(systemName: "cpu") - } text: { - DesignCardMetricText(text: "12%") - } - } footerActions: { - DesignCardFooterButton(systemName: "play.fill", - help: "Start", - tint: .accentColor) {} - } widget: { - LiveSparkline(samples: [0, 0.12, 0.18], - color: .accentColor, - scale: .fraction) - .frame(height: DesignTokens.DesignCard.sparklineHeight) - } - } -} -``` - -Use package-owned surfaces for standalone empty states and input chrome: - -```swift -DesignContentSurface(minHeight: 220) { - ContentUnavailableView("No matches", systemImage: "magnifyingglass") -} - -DesignInputSurface { - HStack { - Image(systemName: "magnifyingglass") - TextField("Search", text: $query) - .textFieldStyle(.plain) - } -} -``` - -## Action and Toolbar Controls - -Feature views pass action intent into package-owned controls. The package owns -glass button grouping, hover, tint, destructive/cancel treatment, toggle chrome, -selection-bar capsules, status banners, and toolbar search shape. - -```swift -DesignActionGroup([ - DesignAction(systemName: "doc.on.doc", help: "Copy") { - copyToPasteboard(output) - }, - DesignAction(systemName: "trash", - help: "Clear", - role: .destructive) { - clear() - } -]) - -DesignActionCluster { - Menu { - Button("All") {} - } label: { - DesignMenuActionLabel(systemName: "line.3.horizontal.decrease", - help: "Filter") - } - DesignActionItems([ - DesignAction(systemName: "checkmark.circle", help: "Mark read") {} - ]) -} - -DesignInputCluster { - Image(systemName: "magnifyingglass") - TextField("Search", text: $query) - .textFieldStyle(.plain) -} - -DesignTextActionButton(title: "Import", - systemName: "arrow.down.doc", - prominence: .prominent, - isEnabled: canImport) { - importArchive() -} - -DesignToggleButton(isOn: $following, - title: "Follow", - systemName: "arrow.down.to.line") -``` - -## Topics - -### DesignTokens and Theme - -- ``DesignTokens`` -- ``WindowMaterial`` -- ``DesignTint`` -- ``ColorLayerBlendMode`` -- ``DesignButtonTintStyle`` -- ``DesignActionCluster`` -- ``DesignInputCluster`` - -### Surfaces and Scaffolds - -- ``DesignContentSurface`` -- ``DesignInputSurface`` -- ``DesignPanelScaffold`` -- ``PageScaffold`` -- ``PanelHeader`` -- ``PanelSection`` -- ``PanelRow`` -- ``PanelField`` -- ``PanelToggleRow`` -- ``SheetHeader`` - -### Toolbar Controls - -- ``DesignAction`` -- ``DesignActionGroup`` -- ``DesignActionItems`` -- ``DesignMenuActionLabel`` -- ``DesignTextActionButton`` -- ``DesignTextActionProminence`` -- ``DesignToggleButton`` -- ``DesignSelectionActionBar`` -- ``DesignStatusBanner`` -- ``DesignProgressActionCapsule`` -- ``DesignMenuButton`` -- ``DesignToolbarSearchField`` -- ``DesignToolbarVanitySlot`` -- ``DesignToolbarStatusButton`` -- ``DesignToolbarActionCluster`` -- ``DesignRowMenu`` -- ``DesignOptionStack`` -- ``DesignOptionTile`` - -### Design Cards - -- ``DesignCard`` -- ``DesignCardPages`` -- ``DesignCardNoPage`` -- ``DesignCardTextStyle`` -- ``DesignCardIconChip`` -- ``DesignBadgeText`` -- ``DesignCardFooterMini`` -- ``DesignCardWidgetGroup`` -- ``DesignCardFooterChip`` -- ``DesignCardFooterButton`` -- ``DesignCardInsetSection`` -- ``DesignCardPageControlItem`` -- ``View/designCardFloatingControls(when:controls:)`` -- ``View/designCardProgressOverlay(when:)`` -- ``DesignCardTitleText`` -- ``DesignCardSubtitleText`` -- ``DesignCardMonospacedSubtitleText`` -- ``DesignCardMetricText`` - -### Data Display and Micro Chrome - -- ``ActivityStatusView`` -- ``ActivityStatusPresentation`` -- ``LiveSparkline`` for Swift Charts-backed live graph widgets -- ``GraphStyle`` -- ``WidgetInterpolation`` -- ``SparklineScale`` -- ``DesignSparklineMetricTile`` -- ``DesignStatusDot`` -- ``DesignStatusBadge`` -- ``DesignContentSurface`` -- ``DesignInputSurface`` -- ``DesignKeyCap`` -- ``DesignKeyboardHint`` -- ``DesignTintSwatch`` -- ``DesignMetricTile`` - -### Utilities - -- ``TintSelector`` -- ``GradientAngleControl`` -- ``StreamConsole`` -- ``copyToPasteboard(_:)`` diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignActions.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignActions.swift deleted file mode 100644 index ec05c79f..00000000 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignActions.swift +++ /dev/null @@ -1,334 +0,0 @@ -import SwiftUI - -/// Semantic action description for package-owned glass action chrome. -public struct DesignAction { - public var systemName: String - public var title: String? - public var help: String - public var role: ButtonRole? - public var tint: Color? - public var isCancel: Bool - public var isEnabled: Bool - public var action: () -> Void - - public init(systemName: String, - title: String? = nil, - help: String? = nil, - role: ButtonRole? = nil, - tint: Color? = nil, - isCancel: Bool = false, - isEnabled: Bool = true, - action: @escaping () -> Void) { - self.systemName = systemName - self.title = title - self.help = help ?? title ?? "" - self.role = role - self.tint = tint - self.isCancel = isCancel - self.isEnabled = isEnabled - self.action = action - } -} - -/// Source-of-truth glass action group. -/// -/// Feature views provide action descriptions; the design system owns button grouping, sizing, -/// selection tinting, hover treatment, accessibility labels, and cancel/destructive behavior. -public struct DesignActionGroup: View { - public var actions: [DesignAction] - public var spacing: CGFloat - public var height: CGFloat - public var minWidth: CGFloat? - public var singleItem: Bool? - public var interactive: Bool - - public init(_ actions: [DesignAction], - spacing: CGFloat = 0, - height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight, - minWidth: CGFloat? = nil, - singleItem: Bool? = nil, - interactive: Bool = true) { - self.actions = actions - self.spacing = spacing - self.height = height - self.minWidth = minWidth - self.singleItem = singleItem - self.interactive = interactive - } - - public init(_ action: DesignAction, - height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight, - minWidth: CGFloat? = nil, - interactive: Bool = true) { - self.init([action], - height: height, - minWidth: minWidth, - singleItem: true, - interactive: interactive) - } - - public var body: some View { - GlassButton(spacing: spacing, - height: height, - minWidth: minWidth, - singleItem: singleItem ?? (actions.count == 1), - interactive: interactive) { - DesignActionItems(actions) - } - } -} - -/// Package-owned glass cluster for mixed content, such as a menu plus action items. -public struct DesignActionCluster: View { - public var spacing: CGFloat - public var height: CGFloat - public var minWidth: CGFloat? - public var singleItem: Bool? - public var interactive: Bool - @ViewBuilder public var content: () -> Content - - public init(spacing: CGFloat = 0, - height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight, - minWidth: CGFloat? = nil, - singleItem: Bool? = nil, - interactive: Bool = true, - @ViewBuilder content: @escaping () -> Content) { - self.spacing = spacing - self.height = height - self.minWidth = minWidth - self.singleItem = singleItem - self.interactive = interactive - self.content = content - } - - public var body: some View { - GlassButton(spacing: spacing, - height: height, - minWidth: minWidth, - singleItem: singleItem ?? false, - interactive: interactive) { - content() - } - } -} - -/// Package-owned input cluster for search fields and compact inline controls. -public struct DesignInputCluster: View { - @ViewBuilder public var content: () -> Content - - public init(@ViewBuilder content: @escaping () -> Content) { - self.content = content - } - - public var body: some View { - GlassButton(singleItem: true) { - GlassButtonInputItem { - content() - } - } - } -} - -/// Package-owned action item renderer for mixed groups that also contain menus or status labels. -public struct DesignActionItems: View { - public var actions: [DesignAction] - - public init(_ actions: [DesignAction]) { - self.actions = actions - } - - public var body: some View { - ForEach(Array(actions.enumerated()), id: \.offset) { _, item in - GlassButtonItem(role: item.role, - tint: item.tint, - help: item.help, - isCancel: item.isCancel, - isIcon: item.title == nil, - action: item.action) { - if let title = item.title { - Label(title, systemImage: item.systemName) - } else { - Image(systemName: item.systemName) - } - } - .disabled(!item.isEnabled) - } - } -} - -/// Semantic label for menus embedded in glass action groups. -public struct DesignMenuActionLabel: View { - public var systemName: String - public var help: String - public var role: ButtonRole? - public var tint: Color? - - public init(systemName: String, - help: String, - role: ButtonRole? = nil, - tint: Color? = nil) { - self.systemName = systemName - self.help = help - self.role = role - self.tint = tint - } - - public var body: some View { - GlassButtonItem(systemName: systemName, - role: role, - tint: tint, - help: help) - } -} - -/// Package-owned progress capsule for action slots that are temporarily busy. -public struct DesignProgressActionCapsule: View { - public var controlSize: ControlSize - - public init(controlSize: ControlSize = .small) { - self.controlSize = controlSize - } - - public var body: some View { - GlassButton(singleItem: true) { - ProgressView() - .controlSize(controlSize) - .frame(width: DesignTokens.Toolbar.buttonItemHeight, - height: DesignTokens.Toolbar.buttonItemHeight) - } - } -} - -/// Prominence levels for package-owned text action buttons. -public enum DesignTextActionProminence { - case standard - case prominent -} - -/// Package-owned text action button for command rows and form footers. -public struct DesignTextActionButton: View { - public var title: String - public var systemName: String - public var help: String - public var role: ButtonRole? - public var prominence: DesignTextActionProminence - public var controlSize: ControlSize - public var isEnabled: Bool - public var action: () -> Void - - public init(title: String, - systemName: String, - help: String? = nil, - role: ButtonRole? = nil, - prominence: DesignTextActionProminence = .standard, - controlSize: ControlSize = .regular, - isEnabled: Bool = true, - action: @escaping () -> Void) { - self.title = title - self.systemName = systemName - self.help = help ?? title - self.role = role - self.prominence = prominence - self.controlSize = controlSize - self.isEnabled = isEnabled - self.action = action - } - - public var body: some View { - switch prominence { - case .standard: - Button(role: role, action: action) { - Label(title, systemImage: systemName) - } - .buttonStyle(.glass) - .controlSize(controlSize) - .help(help) - .disabled(!isEnabled) - case .prominent: - Button(role: role, action: action) { - Label(title, systemImage: systemName) - } - .buttonStyle(.glassProminent) - .controlSize(controlSize) - .help(help) - .disabled(!isEnabled) - } - } -} - -/// Package-owned toggle button used when a binary command belongs in toolbar or panel chrome. -public struct DesignToggleButton: View { - @Binding public var isOn: Bool - public var title: String - public var systemName: String - - public init(isOn: Binding, - title: String, - systemName: String) { - self._isOn = isOn - self.title = title - self.systemName = systemName - } - - public var body: some View { - Toggle(isOn: $isOn) { - Label(title, systemImage: systemName) - } - .toggleStyle(.button) - .buttonStyle(.glass) - .buttonBorderShape(.capsule) - } -} - -/// Package-owned floating selection action bar. -public struct DesignSelectionActionBar: View { - public var count: Int - public var countLabel: (Int) -> String - public var actions: [DesignAction] - - public init(count: Int, - countLabel: @escaping (Int) -> String, - actions: [DesignAction]) { - self.count = count - self.countLabel = countLabel - self.actions = actions - } - - public var body: some View { - HStack(spacing: DesignTokens.Space.m) { - Text(countLabel(count)) - .font(.callout.weight(.medium)) - Divider() - .frame(height: 16) - ForEach(Array(actions.enumerated()), id: \.offset) { _, item in - DesignTextActionButton(title: item.title ?? item.help, - systemName: item.systemName, - help: item.help, - role: item.role, - prominence: .standard, - isEnabled: item.isEnabled, - action: item.action) - } - } - .padding(.horizontal, DesignTokens.Space.l) - .padding(.vertical, DesignTokens.Space.s) - .glassCapsuleSurface(shadow: false) - } -} - -/// Package-owned transient banner chrome. -public struct DesignStatusBanner: View { - public var text: String - - public init(_ text: String) { - self.text = text - } - - public var body: some View { - Text(text) - .font(.callout.weight(.medium)) - .padding(.horizontal, DesignTokens.Space.l) - .padding(.vertical, DesignTokens.Space.s) - .glassCapsuleSurface(shadow: false) - } -} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/InfoButton.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/InfoButton.swift deleted file mode 100644 index 26c278fa..00000000 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/InfoButton.swift +++ /dev/null @@ -1,49 +0,0 @@ -import SwiftUI - -public extension EnvironmentValues { - @Entry var designSystemShowsInfoTips = true -} - -/// A small `info.circle` button that reveals help text in a popover. Replaces hover-only tooltips so -/// the guidance is always discoverable (tap, not hover) and reachable by VoiceOver / keyboard. The -/// popover wraps to as many lines as the text needs (it never truncates) and can be turned off by the -/// host through the `designSystemShowsInfoTips` environment value. -public struct InfoButton: View { - public let text: String - public var visible = true - @Environment(\.modalMaterial) private var modalMaterial - @Environment(\.designSystemShowsInfoTips) private var showsInfoTips - @State private var showing = false - - public init(_ text: String, visible: Bool = true) { - self.text = text - self.visible = visible - } - - public var body: some View { - if showsInfoTips { - Button { showing = true } label: { - Image(systemName: "info.circle") - .font(.caption) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .opacity(visible || showing ? 1 : 0) - .allowsHitTesting(visible || showing) - .help(text) // hover still works as a bonus for mouse users - .accessibilityLabel(text) - .popover(isPresented: $showing, arrowEdge: .trailing) { - Text(.init(text)) // Markdown-aware so tips can use **bold** / `code` - .font(.callout) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .frame(width: 300) - .padding(DesignTokens.Space.m) - .background { - VisualEffectBackground(material: modalMaterial.nsMaterial, blendingMode: .withinWindow) - } - .presentationBackground(.clear) - } - } - } -} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/TintSelector.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/TintSelector.swift deleted file mode 100644 index 67d773f0..00000000 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/TintSelector.swift +++ /dev/null @@ -1,78 +0,0 @@ -import SwiftUI - -/// A row of colored swatches for picking a `DesignTint` — each shows its actual color, the selected -/// one gets a ring. -public struct TintSelector: View { - private let selection: Binding - private let automaticLabel: String? - private let labelForTint: (DesignTint) -> String - - public init(selection: Binding, - labelForTint: @escaping (DesignTint) -> String) { - self.selection = Binding( - get: { selection.wrappedValue }, - set: { if let newValue = $0 { selection.wrappedValue = newValue } } - ) - self.automaticLabel = nil - self.labelForTint = labelForTint - } - - public init(optionalSelection: Binding, - automaticLabel: String, - labelForTint: @escaping (DesignTint) -> String) { - self.selection = optionalSelection - self.automaticLabel = automaticLabel - self.labelForTint = labelForTint - } - - public var body: some View { - HStack(spacing: DesignTokens.Space.s) { - if let automaticLabel { - Button { selection.wrappedValue = nil } label: { automaticSwatch } - .buttonStyle(.plain) - .help(automaticLabel) - .accessibilityLabel(automaticLabel) - .accessibilityAddTraits(selection.wrappedValue == nil ? .isSelected : []) - } - ForEach(DesignTint.allCases) { tint in - let label = labelForTint(tint) - Button { selection.wrappedValue = tint } label: { swatch(tint) } - .buttonStyle(.plain) - .help(label) - .accessibilityLabel(label) - .accessibilityAddTraits(selection.wrappedValue == tint ? .isSelected : []) - } - } - } - - private var automaticSwatch: some View { - ZStack { - Circle().fill(Color.secondary.opacity(0.18)).frame(width: 22, height: 22) - Image(systemName: "rectangle.on.rectangle") - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - Circle() - .strokeBorder(selection.wrappedValue == nil ? Color.primary : Color.secondary.opacity(0.35), - lineWidth: selection.wrappedValue == nil ? 2 : 1) - .frame(width: 24, height: 24) - } - .frame(width: 26, height: 26) - } - - private func swatch(_ tint: DesignTint) -> some View { - ZStack { - Circle().fill(tint.color).frame(width: 22, height: 22) - // Mark the "follow the host accent" option so it reads as automatic, not a fixed color. - if tint.followsAccent { - Image(systemName: "link") - .font(.caption2.weight(.bold)) - .foregroundStyle(.white) - } - Circle() - .strokeBorder(selection.wrappedValue == tint ? Color.primary : Color.secondary.opacity(0.35), - lineWidth: selection.wrappedValue == tint ? 2 : 1) - .frame(width: 24, height: 24) - } - .frame(width: 26, height: 26) - } -} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/CommandPreviewBar.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/CommandPreviewBar.swift deleted file mode 100644 index 1c0652f4..00000000 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/CommandPreviewBar.swift +++ /dev/null @@ -1,48 +0,0 @@ -import SwiftUI - -/// The signature "Reveal CLI" strip: shows the exact `container …` command an action will run, -/// copyable to the clipboard. Drives user trust and learning. -public struct CommandPreviewBar: View { - public let command: [String] - public var copyHelp: String - public var copiedAccessibilityLabel: String - @State private var copied = false - - private var rendered: String { (["container"] + command).joined(separator: " ") } - - public init(command: [String], - copyHelp: String, - copiedAccessibilityLabel: String) { - self.command = command - self.copyHelp = copyHelp - self.copiedAccessibilityLabel = copiedAccessibilityLabel - } - - public var body: some View { - HStack(spacing: DesignTokens.Space.s) { - Image(systemName: "terminal") - .foregroundStyle(.primary) - ScrollView(.horizontal, showsIndicators: false) { - Text(rendered) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .lineLimit(1) - } - Spacer(minLength: DesignTokens.Space.s) - Button { - copyToPasteboard(rendered) - withAnimation { copied = true } - Task { try? await Task.sleep(for: .seconds(1.4)); withAnimation { copied = false } } - } label: { - Image(systemName: copied ? "checkmark" : "doc.on.doc") - .foregroundStyle(copied ? .green : .secondary) - } - .buttonStyle(.plain) - .help(copyHelp) - .accessibilityLabel(copied ? copiedAccessibilityLabel : copyHelp) - } - .padding(.horizontal, DesignTokens.Space.s) - .padding(.vertical, DesignTokens.Space.s) - .glassSurface(.regular, cornerRadius: DesignTokens.Radius.card, shadow: false) - } -} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/StreamConsole.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/StreamConsole.swift deleted file mode 100644 index febd2615..00000000 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/StreamConsole.swift +++ /dev/null @@ -1,114 +0,0 @@ -import SwiftUI - -/// A scrolling console that consumes a one-shot streaming command (pull / build `--progress plain`) -/// to completion, auto-scrolling and reporting success/failure. Shared by the pull and build flows. -public struct StreamConsole: View { - /// Factory so the stream starts with the view's `.task` (and cancels on disappear). - public let stream: () -> AsyncThrowingStream - public var workingLabel: String - public var completedLabel: String - public var lineCountLabel: (Int) -> String - public var copyLogHelp: String - public var failureLabel: (Error) -> String - public var onComplete: (Bool) -> Void = { _ in } - - enum RunState: Equatable { case running, done, failed(String) } - - @State private var lines: [String] = [] - @State private var carry = "" - @State private var state: RunState = .running - private let maxLines = 8000 - private let bottomID = "console-bottom" - - public init(stream: @escaping () -> AsyncThrowingStream, - workingLabel: String, - completedLabel: String, - lineCountLabel: @escaping (Int) -> String, - copyLogHelp: String, - failureLabel: @escaping (Error) -> String, - onComplete: @escaping (Bool) -> Void = { _ in }) { - self.stream = stream - self.workingLabel = workingLabel - self.completedLabel = completedLabel - self.lineCountLabel = lineCountLabel - self.copyLogHelp = copyLogHelp - self.failureLabel = failureLabel - self.onComplete = onComplete - } - - public var body: some View { - VStack(spacing: 0) { - statusBar - Divider() - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 1) { - ForEach(Array(lines.enumerated()), id: \.offset) { _, line in - Text(line.isEmpty ? " " : line) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - Color.clear.frame(height: 1).id(bottomID) - } - .padding(DesignTokens.Space.s) - } - .scrollEdgeEffectStyle(.soft, for: .all) - .onChange(of: lines.count) { _, _ in proxy.scrollTo(bottomID, anchor: .bottom) } - } - .padding(DesignTokens.Space.s) - .background(.black.opacity(0.22), - in: RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous)) - .padding(DesignTokens.Space.s) - } - .task { await consume() } - } - - private var statusBar: some View { - HStack(spacing: DesignTokens.Space.s) { - switch state { - case .running: - ProgressView().controlSize(.small) - Text(workingLabel).foregroundStyle(.secondary) - case .done: - Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - Text(completedLabel).foregroundStyle(.secondary) - case .failed(let message): - Image(systemName: "xmark.octagon.fill").foregroundStyle(.red) - Text(message).foregroundStyle(.secondary).lineLimit(1) - } - Spacer() - Text(lineCountLabel(lines.count)).font(.caption).foregroundStyle(.secondary).monospacedDigit() - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "doc.on.doc", help: copyLogHelp) { - copyToPasteboard(lines.joined(separator: "\n")) - } - } - } - .font(.callout) - .padding(DesignTokens.Space.s) - } - - private func consume() async { - do { - for try await chunk in stream() { ingest(chunk) } - if !carry.isEmpty { lines.append(carry); carry = "" } - state = .done - onComplete(true) - } catch is CancellationError { - // View dismissed mid-stream; nothing to report. - } catch { - if !carry.isEmpty { lines.append(carry); carry = "" } - state = .failed(failureLabel(error)) - onComplete(false) - } - } - - private func ingest(_ chunk: String) { - let combined = carry + chunk - guard let lastNewline = combined.lastIndex(of: "\n") else { carry = combined; return } - carry = String(combined[combined.index(after: lastNewline)...]) - lines.append(contentsOf: combined[.. maxLines { lines.removeFirst(lines.count - maxLines) } - } -} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Previews/DesignSystemPreviews.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Previews/DesignSystemPreviews.swift deleted file mode 100644 index 70a61574..00000000 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Previews/DesignSystemPreviews.swift +++ /dev/null @@ -1,87 +0,0 @@ -import SwiftUI - -#Preview("Design Card") { - DesignCardPreview() - .padding(DesignTokens.Space.xl) - .frame(width: 420) - .environment(\.cardMaterial, .glassRegular) - .environment(\.buttonMaterial, .glassClear) -} - -#Preview("Design Controls") { - VStack(alignment: .leading, spacing: DesignTokens.Space.l) { - PanelSection(header: "Controls") { - PanelRow(title: "Tint") { - TintSelector(selection: .constant(.azure)) { tint in - tint.rawValue.capitalized - } - } - PanelRow(title: "Actions") { - DesignActionGroup([ - DesignAction(systemName: "play.fill", help: "Start") {}, - DesignAction(systemName: "stop.fill", help: "Stop", role: .destructive) {} - ]) - } - } - - CommandPreviewBar(command: ["container", "run", "--name", "preview-web", "nginx"], - copyHelp: "Copy command", - copiedAccessibilityLabel: "Copied") - } - .padding(DesignTokens.Space.xl) - .frame(width: 520) - .environment(\.buttonMaterial, .glassClear) -} - -private struct DesignCardPreview: View { - @State private var page = "overview" - - private let pages = [ - DesignCardPageControlItem(id: "overview", title: "Overview", systemImage: "rectangle.grid.1x2"), - DesignCardPageControlItem(id: "stats", title: "Stats", systemImage: "chart.xyaxis.line"), - ] - - var body: some View { - DesignCard(size: .large, - isExpanded: true, - title: "preview-web", - subtitle: "docker.io/library/nginx:latest", - pages: DesignCardPages(items: pages, - selection: page, - tint: .accentColor, - closeLabel: "Close", - onSelect: { page = $0 }, - onClose: {})) { - DesignCardIconChip(symbol: "shippingbox.fill", tint: .accentColor) - } titleAccessory: { - DesignBadgeText(text: "Running") - } subtitleAccessory: { - EmptyView() - } headerAccessory: { - EmptyView() - } bodyContent: { - DesignCardInsetSection(title: "Live") { - LiveSparkline(samples: [0.1, 0.2, 0.18, 0.4, 0.34, 0.55], - color: .accentColor, - scale: .fraction) - .frame(height: DesignTokens.DesignCard.sparklineHeight) - } - } footerLeading: { - DesignCardFooterChip(isSelected: true, tint: .accentColor, help: "CPU", action: {}) { - Image(systemName: "cpu") - } text: { - DesignCardMetricText(text: "42%") - } - } footerActions: { - DesignCardFooterButton(systemName: "play.fill", help: "Start", tint: .accentColor) {} - } widget: { - DesignCardWidgetGroup { - DesignCardFooterMini { - Image(systemName: "memorychip") - } text: { - DesignCardMetricText(text: "420 MB") - } - } - } - } -} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Support/MicroPrimitives.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Support/MicroPrimitives.swift deleted file mode 100644 index 19f478c7..00000000 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Support/MicroPrimitives.swift +++ /dev/null @@ -1,246 +0,0 @@ -import SwiftUI - -public struct DesignStatusDot: View { - public var color: Color - public var size: CGFloat - - public init(color: Color, size: CGFloat = DesignTokens.IconSize.statusDot) { - self.color = color - self.size = size - } - - public var body: some View { - Circle() - .fill(color) - .frame(width: size, height: size) - } -} - -public struct DesignStatusBadge: View { - public var text: String - public var tint: Color - public var font: Font - - public init(text: String, - tint: Color, - font: Font = .caption.weight(.medium)) { - self.text = text - self.tint = tint - self.font = font - } - - public var body: some View { - Text(text) - .font(font) - .foregroundStyle(tint) - .padding(.horizontal, DesignTokens.Badge.horizontalPadding) - .padding(.vertical, DesignTokens.Badge.verticalPadding) - .background(tint.opacity(DesignTokens.Badge.statusOpacity), in: Capsule()) - } -} - -public struct DesignKeyCap: View { - public var text: String - - public init(_ text: String) { - self.text = text - } - - public var body: some View { - Text(text) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, DesignTokens.Keyboard.keyHorizontalPadding) - .padding(.vertical, DesignTokens.Keyboard.keyVerticalPadding) - .background(.quaternary, - in: RoundedRectangle(cornerRadius: DesignTokens.Radius.keyCap, - style: .continuous)) - } -} - -public struct DesignKeyboardHint: View { - public var key: String - public var label: String - - public init(_ key: String, _ label: String) { - self.key = key - self.label = label - } - - public var body: some View { - HStack(spacing: DesignTokens.Space.xs) { - DesignKeyCap(key) - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - } - } -} - -public struct DesignScopeChipLabel: View { - public var symbol: String - public var title: String - - public init(symbol: String, title: String) { - self.symbol = symbol - self.title = title - } - - public var body: some View { - HStack(spacing: DesignTokens.Space.xs) { - Image(systemName: symbol) - .font(.caption2) - Text(title) - .font(.caption.weight(.semibold)) - Image(systemName: "xmark") - .font(.caption2.weight(.bold)) - } - .padding(.horizontal, DesignTokens.Space.s) - .padding(.vertical, DesignTokens.Badge.scopeVerticalPadding) - .background(Color.accentColor.opacity(DesignTokens.Badge.accentOpacity), - in: Capsule(style: .continuous)) - .foregroundStyle(Color.accentColor) - } -} - -public struct DesignTintSwatch: View { - public var color: Color - public var followsAccent: Bool - - public init(color: Color, followsAccent: Bool = false) { - self.color = color - self.followsAccent = followsAccent - } - - public var body: some View { - ZStack { - Circle().fill(color) - if followsAccent { - Image(systemName: "link") - .font(.caption2.weight(.bold)) - .foregroundStyle(.white) - } - } - .frame(width: DesignTokens.IconSize.chip, height: DesignTokens.IconSize.chip) - } -} - -public struct DesignMetricTile: View { - public var label: String - public var value: String - public var caption: String? - - public init(label: String, value: String, caption: String? = nil) { - self.label = label - self.value = value - self.caption = caption - } - - public var body: some View { - VStack(alignment: .leading, spacing: DesignTokens.Space.xxs) { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Space.xs) { - Text(value) - .font(.title3.weight(.medium)) - if let caption { - Text(caption) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, DesignTokens.Space.m) - .padding(.vertical, DesignTokens.Space.s) - .background(DesignMaterial.toolbarHoverFill, - in: RoundedRectangle(cornerRadius: DesignTokens.Radius.control, - style: .continuous)) - } -} - -/// A package-owned content surface for empty states and grouped panel content. -public struct DesignContentSurface: View { - public var elevated: Bool - public var minHeight: CGFloat? - public var alignment: Alignment - public var padding: CGFloat - @ViewBuilder public var content: () -> Content - - public init(elevated: Bool = false, - minHeight: CGFloat? = nil, - alignment: Alignment = .center, - padding: CGFloat = DesignTokens.Space.s, - @ViewBuilder content: @escaping () -> Content) { - self.elevated = elevated - self.minHeight = minHeight - self.alignment = alignment - self.padding = padding - self.content = content - } - - public var body: some View { - content() - .padding(padding) - .frame(maxWidth: .infinity, minHeight: minHeight, alignment: alignment) - .glassSurface(.regular, cornerRadius: DesignTokens.Radius.card, shadow: elevated) - } -} - -/// A package-owned surface for compact inline controls such as search fields and text editors. -public struct DesignInputSurface: View { - public var horizontalPadding: CGFloat - public var verticalPadding: CGFloat - public var minHeight: CGFloat? - @ViewBuilder public var content: () -> Content - - public init(horizontalPadding: CGFloat = DesignTokens.Space.m, - verticalPadding: CGFloat = DesignTokens.Space.s, - minHeight: CGFloat? = nil, - @ViewBuilder content: @escaping () -> Content) { - self.horizontalPadding = horizontalPadding - self.verticalPadding = verticalPadding - self.minHeight = minHeight - self.content = content - } - - public var body: some View { - content() - .padding(.horizontal, horizontalPadding) - .padding(.vertical, verticalPadding) - .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .leading) - .glassSurface(.thin, cornerRadius: DesignTokens.Radius.control) - } -} - -public extension View { - @ViewBuilder - func designCardSelectionOverlay(when isSelected: Bool) -> some View { - overlay { - if isSelected { - RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous) - .fill(DesignMaterial.toolbarHoverFill) - .allowsHitTesting(false) - } - } - } - - func terminalSurfaceChrome() -> some View { - padding(DesignTokens.Space.s) - .background(.black.opacity(DesignTokens.Terminal.surfaceOpacity), - in: RoundedRectangle(cornerRadius: DesignTokens.Radius.card, - style: .continuous)) - .padding(DesignTokens.Space.s) - } - - func subtleTileBackground() -> some View { - background(.quaternary.opacity(DesignTokens.InlineControl.subtleTileOpacity), - in: RoundedRectangle(cornerRadius: DesignTokens.Radius.control, - style: .continuous)) - } - - func toolbarControlContentShape() -> some View { - contentShape(Capsule(style: .continuous)) - } -} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens/DesignTokens.swift b/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens/DesignTokens.swift deleted file mode 100644 index b1a5ca9a..00000000 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Tokens/DesignTokens.swift +++ /dev/null @@ -1,200 +0,0 @@ -import SwiftUI - -/// Design tokens — the single source of truth for spacing, radii, and type used across the app. -public enum DesignTokens { - public enum Radius { - /// Radius delta between nested glass levels: sheet -> card -> control -> key cap. - public static let step: CGFloat = 6 - public static let control: CGFloat = 10 - public static let card: CGFloat = 16 - public static let sheet: CGFloat = 22 - public static let keyCap: CGFloat = control - step - public static let iconChip: CGFloat = control - - /// Radius for a shape inset inside a parent with the same corner center. - public static func inset(from outer: CGFloat, by inset: CGFloat) -> CGFloat { - max(0, outer - inset) - } - } - - public enum Space { - public static let hairline: CGFloat = 1 - public static let xxs: CGFloat = 2 - public static let xs: CGFloat = 4 - public static let s: CGFloat = 8 - public static let m: CGFloat = 12 - public static let l: CGFloat = 16 - public static let xl: CGFloat = 24 - public static let xxl: CGFloat = 32 - } - - public enum CardSize { - // Generous max widths so the adaptive grid stretches the fitted columns to fill the row, - // rather than capping them tightly and leaving trailing dead space on wide windows. - public static let compactMin: CGFloat = 230 - public static let compactMax: CGFloat = 400 - public static let largeMin: CGFloat = 300 - public static let largeMax: CGFloat = 520 - } - - /// Canonical sheet dimensions — pass to `.frame(DesignTokens.SheetSize.form)`. Replaces ad-hoc - /// `width:height:` literals so every sheet snaps to one of a few sizes. - public enum SheetSize { - public static let small = CGSize(width: 420, height: 280) // confirmations, short forms - public static let form = CGSize(width: 560, height: 680) // the run/edit form - public static let console = CGSize(width: 560, height: 540) // streamed-progress / logs - public static let inspector = CGSize(width: 600, height: 560) // Dense detail/history pages - public static let releaseNotes = CGSize(width: 620, height: 520) - public static let wide = CGSize(width: 720, height: 560) // build workspace - public static let dialogWidth: CGFloat = 460 - } - - /// Morph-panel dimensions shared by toolbar origins and panel content. - public enum PanelSize { - // Global floor applied in MorphGeometry.fittedSize — panels never shrink below these or exceed - // the available window area (handled separately via margin clamping). The height floor is tiny - // so content-hugging panels can collapse close to their header when there's little to show. - public static let minWidth: CGFloat = 300 - public static let minHeight: CGFloat = 50 - - public static let add = CGSize(width: 440, height: 300) - public static let palette = CGSize(width: 560, height: 480) - public static let updatesOrigin = CGSize(width: 440, height: 300) - public static let images = CGSize(width: 520, height: 520) - public static let imageDetail = CGSize(width: 560, height: 520) - public static let imageTag = CGSize(width: 560, height: 360) - public static let activityOrigin = CGSize(width: 460, height: 360) - public static let activity = CGSize(width: 560, height: 520) - public static let templatesOrigin = CGSize(width: 440, height: 300) - public static let templates = CGSize(width: 460, height: 480) - public static let system = CGSize(width: 580, height: 600) - public static let settings = CGSize(width: 560, height: 560) - } - - /// Icon-button / chip dimensions used across menus and headers. - public enum IconSize { - public static let statusDot: CGFloat = 8 - public static let serviceDot: CGFloat = 9 - public static let rowIconColumn: CGFloat = 20 - public static let rowMenu: CGFloat = 22 // ellipsis row menus - public static let control: CGFloat = 28 // sheet-header circle buttons - public static let chip: CGFloat = 30 // small status chips - public static let headerChip: CGFloat = 34 // detail-header chips - public static let appIcon: CGFloat = 56 - } - - /// Fixed widths for compact form controls where stable alignment matters more than fluid sizing. - public enum FormWidth { - public static let shortReadout: CGFloat = 44 - public static let memoryReadout: CGFloat = 64 - public static let port: CGFloat = 70 - public static let containerPort: CGFloat = 80 - public static let userID: CGFloat = 90 - public static let shellPicker: CGFloat = 140 - public static let compactSlider: CGFloat = 140 - public static let networkName: CGFloat = 180 - public static let tintColorHex: CGFloat = 220 - public static let refreshReadout: CGFloat = 32 - } - - public enum DesignCard { - public static let padding: CGFloat = 10 - public static let compactTextSpacing: CGFloat = Space.hairline - public static let detailTextSpacing: CGFloat = Space.xxs - public static let footerDividerHeight: CGFloat = Space.l - public static let sparklineHeight: CGFloat = 58 - public static let iconBackgroundOpacity: Double = 0.16 - public static let iconSelectedBackgroundOpacity: Double = 0.24 - public static let iconEmphasisBackgroundOpacity: Double = 0.22 - public static let plainFillOpacity: Double = 0.18 - public static let selectedSubtleFillOpacity: Double = 0.10 - public static let selectedResourceFillOpacity: Double = 0.12 - public static let selectedTintFillOpacity: Double = 0.18 - public static let selectedPersonalizedFillOpacity: Double = 0.14 - } - - public enum Chart { - public static let height: CGFloat = 140 - public static let axisDesiredCount = 4 - public static let areaOpacity: Double = 0.30 - public static let emptyHeight: CGFloat = 200 - } - - public enum Badge { - public static let compactHorizontalPadding: CGFloat = 7 - public static let horizontalPadding: CGFloat = Space.s - public static let verticalPadding: CGFloat = Space.xxs - public static let scopeVerticalPadding: CGFloat = 3 - public static let accentOpacity: Double = 0.16 - public static let statusOpacity: Double = 0.14 - } - - public enum Keyboard { - public static let keyHorizontalPadding: CGFloat = 5 - public static let keyVerticalPadding: CGFloat = Space.xxs - } - - public enum Terminal { - public static let surfaceOpacity: Double = 0.22 - public static let nativeBackgroundOpacity: CGFloat = 0.82 - public static let nativeForegroundWhite: CGFloat = 0.92 - public static let fontSize: CGFloat = 12 - } - - public enum InlineControl { - public static let gradientDial: CGFloat = 36 - public static let gradientReadout: CGFloat = 40 - public static let gradientKnob: CGFloat = 7 - public static let gradientKnobInset: CGFloat = Space.xs - public static let gradientStrokeOpacity: Double = 0.4 - public static let subtleTileOpacity: Double = 0.25 - } - - public enum MenuBar { - public static let width: CGFloat = 340 - public static let titleWidth: CGFloat = 78 - public static let padding: CGFloat = 14 - } - - /// The app toolbar band — custom (non-native) controls sized to macOS 26 Liquid Glass toolbar - /// proportions (tuned against Finder). `controlHeight` is shared by every band element (glass - /// button groups and the search field) so they align on one baseline; `groupRadius` is the - /// concentric capsule for them. Glyphs are a touch smaller than the capsule with horizontal glass - /// padding around them, matching the airy native look. - public enum Toolbar { - // Exact spec: controls are 36pt tall (length hugs content), with 8pt of padding around the band - // (horizontal, top — matched below — and between groups), so the band is 8 + 36 + 8 = 52. - public static let band: CGFloat = 52 // title-bar band height - public static let controlHeight: CGFloat = 36 // glass groups + search field share this height - // Button glyphs use `.headline` + `.imageScale(.large)` (see ToolbarControls) so they scale - // with Dynamic Type — no fixed point size token. - public static let iconInnerPadding: CGFloat = 4 // padding around the glyph inside the 28 item - public static let buttonItemHeight: CGFloat = 28 - public static var iconContentWidth: CGFloat { buttonItemHeight - iconInnerPadding * 2 } - public static var statusLabelTrailingPadding: CGFloat { iconInnerPadding * 2 } - public static let buttonGroupHeight: CGFloat = 36 - public static let outerPadding: CGFloat = 8 // band inset from the window edges - // Space reserved when custom toolbar chrome needs to mirror the traffic-light cluster. - public static let leadingInset: CGFloat = 80 - public static let trafficLightsWidth: CGFloat = 82 // close/min/zoom cluster width for reserved toolbar slots - public static let groupPaddingH: CGFloat = 0 // horizontal glass margin inside a group - public static let groupSpacing: CGFloat = 8 // spacing between buttons / groups - public static let searchMaxWidth: CGFloat = 380 - // Search field internals. - public static let searchInnerPadding: CGFloat = iconInnerPadding * 2 // matches glass button edge inset - public static let searchIconGap: CGFloat = 6 // gap between icon and text - public static let searchOpenHeaderHeight: CGFloat = 48 // taller header row once the palette expands - // The search icon + text use the semantic `.body` style (13pt on macOS; text adds medium weight), - // so they scale with Dynamic Type — no fixed point size tokens. - /// Padding above the controls (and matched below) — the controls sit on the native toolbar line. - public static let topPadding: CGFloat = 8 - public static var groupRadius: CGFloat { controlHeight / 2 } // concentric capsule - } -} - -public extension View { - /// Apply a canonical sheet size from `DesignTokens.SheetSize`. - func frame(_ size: CGSize) -> some View { - frame(width: size.width, height: size.height) - } -} diff --git a/Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/DesignCardLayoutPolicyTests.swift b/Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/DesignCardLayoutPolicyTests.swift deleted file mode 100644 index 7f8144e7..00000000 --- a/Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/DesignCardLayoutPolicyTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Testing -@testable import ContainedDesignSystem - -@Suite("Design card layout policy") -struct DesignCardLayoutPolicyTests { - @Test func sizeControlsStickyAndEmbeddedCardSlots() { - #expect(DesignCardSize.small.keepsFooterSticky == false) - #expect(DesignCardSize.small.embedsFooterInBody == true) - #expect(DesignCardSize.small.keepsWidgetSticky == false) - #expect(DesignCardSize.small.embedsWidgetInBody == false) - - #expect(DesignCardSize.medium.keepsFooterSticky == true) - #expect(DesignCardSize.medium.embedsFooterInBody == false) - #expect(DesignCardSize.medium.keepsWidgetSticky == false) - #expect(DesignCardSize.medium.embedsWidgetInBody == true) - - #expect(DesignCardSize.large.keepsFooterSticky == true) - #expect(DesignCardSize.large.embedsFooterInBody == false) - #expect(DesignCardSize.large.keepsWidgetSticky == true) - #expect(DesignCardSize.large.embedsWidgetInBody == false) - } -} diff --git a/Packages/ContainedNavigation/Package.swift b/Packages/ContainedNavigation/Package.swift deleted file mode 100644 index a2cc9dc9..00000000 --- a/Packages/ContainedNavigation/Package.swift +++ /dev/null @@ -1,25 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let package = Package( - name: "ContainedNavigation", - platforms: [.macOS(.v26)], - products: [ - .library(name: "ContainedNavigation", targets: ["ContainedNavigation"]), - ], - dependencies: [ - .package(path: "../ContainedDesignSystem"), - ], - targets: [ - .target( - name: "ContainedNavigation", - dependencies: [ - .product(name: "ContainedDesignSystem", package: "ContainedDesignSystem"), - ] - ), - .testTarget( - name: "ContainedNavigationTests", - dependencies: ["ContainedNavigation"] - ), - ] -) diff --git a/Packages/ContainedNavigation/README.md b/Packages/ContainedNavigation/README.md deleted file mode 100644 index c71fb43b..00000000 --- a/Packages/ContainedNavigation/README.md +++ /dev/null @@ -1,177 +0,0 @@ -# ContainedNavigation - -`ContainedNavigation` is the local Swift package that owns reusable navigation -and layout infrastructure for Contained's window chrome. - -It depends on `ContainedDesignSystem` for tokens and visual primitives. It does -not own app sections, stores, toolbar panel content, `UIState`, routes, or -feature decisions. - -It also does not own localized resources or English UI defaults. Host apps pass -their own titles, help text, accessibility labels, and panel copy into the -navigation/design-system views they compose. - -## Importing - -From the root app package: - -```swift -.product(name: "ContainedNavigation", package: "ContainedNavigation") -``` - -From Swift code: - -```swift -import SwiftUI -import ContainedDesignSystem -import ContainedNavigation -``` - -## What Belongs Here - -- `MorphSafeAreaManager`, `MorphSafeAreaPolicy`, and the `morphSafeAreas` environment - value for generic top/bottom toolbar safe-area contracts. -- `MorphGeometry`, `MorphTarget`, and `MorphPanelPlacement` for target rects - that clamp to a safe area. -- `MorphingExpander` for the reusable grow/shrink panel shell. -- `MorphingSingleSurface` and `MorphingSingleSurfaceExpander` for card-like - overlays that grow from one existing source slot into one target rect without - a handoff panel. -- `MorphSourceFrameReader` and `MorphSourceFramesKey` for measuring source - frames in a named coordinate space without app-local preference keys. - -Keep concrete panel contents in the app target. For example, Images, Templates, -System, Settings, Activity, and Command Palette panels are app features that use -this package; they do not live in this package. -Use `DesignPanelScaffold` from `ContainedDesignSystem` for panel anatomy; -`ContainedNavigation` performs the morph and placement around that visual body. - -## Example - -This example uses a fixed `originFrame` to stay compact. Production apps usually -measure the toolbar button frame with a `GeometryReader` or preference key and -pass that measured rect into `MorphingExpander`. - -```swift -import SwiftUI -import ContainedDesignSystem -import ContainedNavigation - -struct NavigationPackageExample: View { - @State private var isPresented = false - - private let originFrame = CGRect(x: 24, - y: 24, - width: DesignTokens.Toolbar.buttonGroupHeight, - height: DesignTokens.Toolbar.buttonGroupHeight) - - var body: some View { - ZStack(alignment: .topLeading) { - DesignActionGroup(DesignAction(systemName: "plus", help: "Add") { - isPresented = true - }) - .padding(DesignTokens.Space.l) - - MorphingExpander(isPresented: $isPresented, - originFrame: originFrame, - target: .centered(size: DesignTokens.PanelSize.add)) { - DesignPanelScaffold(width: DesignTokens.PanelSize.add.width) { - PanelHeader(symbol: "plus", - title: "Add", - subtitle: "Choose a starting point") { - DesignActionGroup(DesignAction(systemName: "xmark", - help: "Close", - isCancel: true) { - isPresented = false - }) - } - Divider() - } content: { - VStack(spacing: DesignTokens.Space.s) { - DesignOptionTile(symbol: "play.rectangle", - title: "Run a container", - subtitle: "Start from an image") { - isPresented = false - } - DesignOptionTile(symbol: "square.stack.3d.up", - title: "Use an existing image", - subtitle: "Pick from local images") { - isPresented = false - } - } - .padding(DesignTokens.Space.s) - } - } - } - .environment(\.morphSafeAreas, - MorphSafeAreaManager(topToolbarHeight: DesignTokens.Toolbar.band, - bottomToolbarHeight: DesignTokens.Toolbar.band)) - } -} -``` - -For a card-like detail surface, keep the compact source laid out in its grid -slot, hide that source while selected, and render one promoted overlay through -`MorphingSingleSurface`: - -```swift -MorphingSingleSurface(source: sourceFrame, - target: detailFrame, - progress: isExpanded ? 1 : 0) { - DetailCard() -} -``` - -When the card needs the same open/close lifecycle as a morph panel but already -draws its own surface, use `MorphingSingleSurfaceExpander` instead of -`MorphingExpander`: - -```swift -MorphingSingleSurfaceExpander(isPresented: $isPresented, - originFrame: sourceFrame, - target: .anchored(size: DesignTokens.PanelSize.imageDetail)) { - ExpandedDesignCard() -} -``` - -## Safe-Area Policies - -Use `MorphTarget.centered(size:)` for modal-like work, such as creation -details. Use `MorphTarget.anchored(size:)` when a panel should grow from and -stay near its source control. Both paths use `MorphSafeAreaManager` to avoid -toolbar bands and system insets. - -For real toolbar sources, measure source controls in the same named coordinate -space that hosts the morph overlay: - -```swift -Button("Open") { isPresented = true } - .background(MorphSourceFrameReader("settings", - coordinateSpaceName: "toolbar")) - .onPreferenceChange(MorphSourceFramesKey.self) { frames = $0 } -``` - -## Documentation - -- DocC landing page: - `Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md` -- Design package: - `../ContainedDesignSystem/README.md` -- App architecture: - `../../docs/architecture/Architecture.md` - -## Verification - -Build and test the package by itself: - -```sh -swift build --package-path Packages/ContainedNavigation -swift test --package-path Packages/ContainedNavigation -``` - -Build it through the app graph: - -```sh -swift build -swift test -``` diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md b/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md deleted file mode 100644 index 0974d749..00000000 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/ContainedNavigation.docc/ContainedNavigation.md +++ /dev/null @@ -1,110 +0,0 @@ -# ``ContainedNavigation`` - -Reusable navigation and layout infrastructure for Contained's window chrome. - -## Overview - -`ContainedNavigation` owns generic safe-area and morph-panel mechanics. It uses -`ContainedDesignSystem` for tokens and visual primitives, but it does not own app -routes, stores, toolbar panel contents, or feature decisions. - -Host apps own localized strings. This package provides layout behavior and -accepts caller-supplied titles, help text, accessibility labels, and panel copy -through the views it composes. - -Use this package when a view needs reusable layout behavior such as a panel that -grows from a toolbar source and clamps to app safe areas. Pair it with -`DesignPanelScaffold` from `ContainedDesignSystem` when the promoted surface -needs fixed chrome above scrollable content. - -Card-like detail views can use `MorphingSingleSurface` when the source and -destination are the same conceptual surface rather than a toolbar-to-panel -handoff. Keep the source laid out, hide it while selected, and render one -promoted overlay from the measured source frame to the target frame. - -## Example - -```swift -import SwiftUI -import ContainedDesignSystem -import ContainedNavigation - -struct NavigationPackageExample: View { - @State private var isPresented = false - - private let originFrame = CGRect(x: 24, - y: 24, - width: DesignTokens.Toolbar.buttonGroupHeight, - height: DesignTokens.Toolbar.buttonGroupHeight) - - var body: some View { - ZStack(alignment: .topLeading) { - DesignActionGroup(DesignAction(systemName: "plus", help: "Add") { - isPresented = true - }) - .padding(DesignTokens.Space.l) - - MorphingExpander(isPresented: $isPresented, - originFrame: originFrame, - target: .centered(size: DesignTokens.PanelSize.add)) { - DesignPanelScaffold(width: DesignTokens.PanelSize.add.width) { - PanelHeader(symbol: "plus", - title: "Add", - subtitle: "Choose a starting point") { - DesignActionGroup(DesignAction(systemName: "xmark", - help: "Close", - isCancel: true) { - isPresented = false - }) - } - Divider() - } content: { - VStack(spacing: DesignTokens.Space.s) { - Text("Panel content") - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(DesignTokens.Space.s) - } - } - } - .environment(\.morphSafeAreas, - MorphSafeAreaManager(topToolbarHeight: DesignTokens.Toolbar.band, - bottomToolbarHeight: DesignTokens.Toolbar.band)) - } -} -``` - -Measure real toolbar sources with `MorphSourceFrameReader` in the same named -coordinate space that hosts the morph overlay: - -```swift -Button("Open") { isPresented = true } - .background(MorphSourceFrameReader("settings", - coordinateSpaceName: "toolbar")) - .onPreferenceChange(MorphSourceFramesKey.self) { frames = $0 } -``` - -## Topics - -### Safe Areas - -- ``MorphSafeAreaManager`` -- ``MorphSafeAreaPolicy`` -- ``MorphToolbarSafeAreaExclusion`` -- ``MorphSafeAreaPadding`` - -### Morph Targets and Geometry - -- ``MorphTarget`` -- ``MorphPanelPlacement`` -- ``MorphGeometry`` - -### Morph Presentation - -- ``MorphingExpander`` -- ``MorphingSingleSurface`` -- ``MorphingSingleSurfaceExpander`` -- ``MorphFrame`` -- ``MorphSourceFrameReader`` -- ``MorphSourceFramesKey`` -- ``GlobalBackdropStyle`` diff --git a/Packages/ContainedPreviewSupport/Package.swift b/Packages/ContainedPreviewSupport/Package.swift deleted file mode 100644 index 743eb19f..00000000 --- a/Packages/ContainedPreviewSupport/Package.swift +++ /dev/null @@ -1,27 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let package = Package( - name: "ContainedPreviewSupport", - platforms: [.macOS(.v26)], - products: [ - .library(name: "ContainedPreviewSupport", targets: ["ContainedPreviewSupport"]), - ], - dependencies: [ - .package(path: "../ContainedCore"), - .package(path: "../ContainedRuntime"), - ], - targets: [ - .target( - name: "ContainedPreviewSupport", - dependencies: [ - .product(name: "ContainedCore", package: "ContainedCore"), - .product(name: "ContainedRuntime", package: "ContainedRuntime"), - ] - ), - .testTarget( - name: "ContainedPreviewSupportTests", - dependencies: ["ContainedPreviewSupport"] - ), - ] -) diff --git a/Packages/ContainedPreviewSupport/README.md b/Packages/ContainedPreviewSupport/README.md deleted file mode 100644 index 38a4c814..00000000 --- a/Packages/ContainedPreviewSupport/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# ContainedPreviewSupport - -ContainedPreviewSupport provides deterministic fixtures for previews, examples, -and lightweight package tests. - -## Owns - -- Sample containers, images, volumes, networks, runtime descriptors, stats, - metric histories, card-style descriptors, widget descriptors, activity state - identifiers, and representative package errors. -- Fixture values that packages and the app can reuse without spinning up the - Apple `container` service. - -## Does Not Own - -- Localized strings. -- Production app state or persistence. -- Live runtime calls. - -## Source Layout - -- `Sources/ContainedPreviewSupport/Samples` contains deterministic fixture - models and values used by package examples and app previews. - -## Example - -```swift -import ContainedPreviewSupport - -let container = PreviewSamples.webContainer -let values = PreviewSamples.sparklineValues -let volume = PreviewSamples.volume -let network = PreviewSamples.network -let widgets = PreviewSamples.widgetConfigs -let errorCode = PreviewSamples.commandError.packageErrorCode -``` - -App previews should map `PreviewCardStyleDescriptor` and -`PreviewWidgetDescriptor` into app-owned style/state types. The package keeps -those descriptors runtime-neutral so it does not depend on `Sources/ContainedApp`. -Preview activity fixtures describe state and resource identifiers; apps still -provide the localized copy shown around that state. - -## Build And Test - -```sh -swift build --package-path Packages/ContainedPreviewSupport -swift test --package-path Packages/ContainedPreviewSupport -``` diff --git a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md deleted file mode 100644 index 1633d4c7..00000000 --- a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/ContainedPreviewSupport.docc/ContainedPreviewSupport.md +++ /dev/null @@ -1,28 +0,0 @@ -# ``ContainedPreviewSupport`` - -Deterministic sample data for SwiftUI previews and package examples. - -## Overview - -`ContainedPreviewSupport` gives app and package previews stable data without -launching a runtime service. It intentionally avoids localized strings and live -process calls so previews remain fast and predictable. - -Use ``PreviewSamples`` for typed core/runtime fixtures: - -- Containers, images, volumes, and networks. -- Runtime descriptors for the current Apple adapter and future runtime shape. -- Live stats, metric-history samples, and graph source arrays. -- Runtime-neutral card-style and widget descriptors for app previews to map - into app-owned presentation state. -- Activity identifiers and package-error states for status, alert, and Activity - examples. - -The package does not import the app target. App previews should translate the -neutral descriptors into app-owned types such as local personalization or view -state at the preview boundary, including any localized copy shown around -activity state. - -The `Samples` source folder contains the deterministic fixture models and -values. Keep new preview data there unless it becomes a reusable pure model that -belongs in `ContainedCore` or `ContainedRuntime`. diff --git a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/Samples/PreviewSamples.swift b/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/Samples/PreviewSamples.swift deleted file mode 100644 index 38ee1148..00000000 --- a/Packages/ContainedPreviewSupport/Sources/ContainedPreviewSupport/Samples/PreviewSamples.swift +++ /dev/null @@ -1,303 +0,0 @@ -import Foundation -import ContainedCore -import ContainedRuntime - -public struct PreviewMetricHistorySample: Equatable, Sendable, MetricHistorySample { - public var timestamp: Date - public var cpuFraction: Double - public var memoryBytes: Double - public var netRxBytesPerSec: Double - public var netTxBytesPerSec: Double - public var diskReadBytesPerSec: Double - public var diskWriteBytesPerSec: Double - - public init(timestamp: Date, - cpuFraction: Double, - memoryBytes: Double, - netRxBytesPerSec: Double, - netTxBytesPerSec: Double, - diskReadBytesPerSec: Double, - diskWriteBytesPerSec: Double) { - self.timestamp = timestamp - self.cpuFraction = cpuFraction - self.memoryBytes = memoryBytes - self.netRxBytesPerSec = netRxBytesPerSec - self.netTxBytesPerSec = netTxBytesPerSec - self.diskReadBytesPerSec = diskReadBytesPerSec - self.diskWriteBytesPerSec = diskWriteBytesPerSec - } -} - -public struct PreviewCardStyleDescriptor: Equatable, Sendable { - public var symbol: String - public var tintName: String - public var fillsBackground: Bool - public var backgroundOpacity: Double - public var usesGradient: Bool - - public init(symbol: String, - tintName: String, - fillsBackground: Bool, - backgroundOpacity: Double, - usesGradient: Bool) { - self.symbol = symbol - self.tintName = tintName - self.fillsBackground = fillsBackground - self.backgroundOpacity = backgroundOpacity - self.usesGradient = usesGradient - } -} - -public struct PreviewWidgetDescriptor: Equatable, Sendable { - public var metric: GraphMetric - public var secondaryMetric: GraphMetric? - public var style: String - public var icon: String - public var tintName: String? - public var showsText: Bool - - public init(metric: GraphMetric, - secondaryMetric: GraphMetric? = nil, - style: String, - icon: String, - tintName: String? = nil, - showsText: Bool = true) { - self.metric = metric - self.secondaryMetric = secondaryMetric - self.style = style - self.icon = icon - self.tintName = tintName - self.showsText = showsText - } -} - -public enum PreviewActivityKind: String, CaseIterable, Sendable { - case lifecycle - case image - case compose - case system - case alert -} - -public struct PreviewActivityEvent: Equatable, Sendable { - public var timestamp: Date - public var containerID: String? - public var kind: PreviewActivityKind - public var subjectID: String? - public var isRead: Bool - - public init(timestamp: Date, - containerID: String?, - kind: PreviewActivityKind, - subjectID: String?, - isRead: Bool = false) { - self.timestamp = timestamp - self.containerID = containerID - self.kind = kind - self.subjectID = subjectID - self.isRead = isRead - } -} - -public struct PreviewActivityProgress: Equatable, Sendable { - public var kind: PreviewActivityKind - public var subjectID: String - public var detailID: String? - public var fraction: Double - - public init(kind: PreviewActivityKind, - subjectID: String, - detailID: String? = nil, - fraction: Double) { - self.kind = kind - self.subjectID = subjectID - self.detailID = detailID - self.fraction = fraction - } -} - -public enum PreviewSamples { - public static let now = Date(timeIntervalSinceReferenceDate: 790_000_000) - - public static let appleRuntime = RuntimeDescriptor.appleContainer - public static let dockerRuntime = RuntimeDescriptor( - kind: .dockerCompatible, - displayName: "Docker-compatible", - executableName: "docker", - capabilities: [] - ) - public static let runtimes = [appleRuntime, dockerRuntime] - - public static let webContainer = ContainerSnapshot.placeholder( - id: "preview-web", - image: "docker.io/library/nginx:latest", - state: .running - ) - - public static let workerContainer = ContainerSnapshot.placeholder( - id: "preview-worker", - image: "ghcr.io/example/worker:nightly", - state: .stopped - ) - - public static let stats = StatsDelta( - id: "preview-web", - cpuCoreFraction: 0.62, - memoryUsageBytes: 420_000_000, - memoryLimitBytes: 1_073_741_824, - netRxBytesPerSec: 186_000, - netTxBytesPerSec: 72_000, - blockReadBytesPerSec: 8_400, - blockWriteBytesPerSec: 16_800, - numProcesses: 9 - ) - - public static let sparklineValues: [Double] = [ - 0.12, 0.16, 0.18, 0.25, 0.22, 0.31, 0.38, 0.35, - 0.44, 0.48, 0.43, 0.52, 0.57, 0.54, 0.61, 0.58, - 0.66, 0.62, 0.70, 0.68, 0.74, 0.71, 0.78, 0.76 - ] - - public static let networkSamples: [Double] = [ - 18_000, 24_000, 22_000, 46_000, 42_000, 54_000, - 66_000, 72_000, 68_000, 80_000, 92_000, 88_000 - ] - - public static let metricHistory: [PreviewMetricHistorySample] = sparklineValues.enumerated().map { offset, value in - PreviewMetricHistorySample( - timestamp: now.addingTimeInterval(Double(offset) * 30), - cpuFraction: value, - memoryBytes: 260_000_000 + Double(offset) * 6_000_000, - netRxBytesPerSec: networkSamples[offset % networkSamples.count], - netTxBytesPerSec: networkSamples[(offset + 3) % networkSamples.count] * 0.45, - diskReadBytesPerSec: 4_000 + Double(offset * 320), - diskWriteBytesPerSec: 8_000 + Double(offset * 420) - ) - } - - public static let image = decode(ImageResource.self, from: """ - { - "configuration": { - "name": "docker.io/library/nginx:latest", - "descriptor": { - "digest": "sha256:previewnginx", - "mediaType": "application/vnd.oci.image.index.v1+json", - "size": 146120 - }, - "creationDate": "2026-07-01T12:00:00Z" - }, - "id": "sha256:previewnginx", - "variants": [ - { - "digest": "sha256:previewnginx-arm64", - "size": 48120000, - "platform": { "architecture": "arm64", "os": "linux" }, - "config": { - "architecture": "arm64", - "os": "linux", - "created": "2026-07-01T12:00:00Z", - "config": { - "Cmd": ["nginx", "-g", "daemon off;"], - "Entrypoint": ["/docker-entrypoint.sh"], - "Env": ["NGINX_VERSION=preview"], - "WorkingDir": "/", - "User": "101" - } - } - } - ] - } - """) - - public static let imageGroup = LocalImageTagGroup.group(containing: image, in: [image]) - - public static let volume = decode(VolumeResource.self, from: """ - { - "configuration": { - "name": "preview-data", - "source": "/Users/preview/.contained/volumes/preview-data", - "format": "apfs", - "sizeInBytes": 10737418240, - "creationDate": "2026-07-01T12:10:00Z", - "labels": { "contained.stack": "preview" } - } - } - """) - - public static let network = decode(NetworkResource.self, from: """ - { - "id": "preview-network", - "configuration": { - "name": "preview-network", - "mode": "nat", - "plugin": "builtin", - "creationDate": "2026-07-01T12:12:00Z", - "labels": { "contained.stack": "preview" }, - "options": { "variant": "preview" } - }, - "status": { - "ipv4Gateway": "10.42.0.1", - "ipv4Subnet": "10.42.0.0/24", - "ipv6Subnet": null - } - } - """) - - public static let cardStyle = PreviewCardStyleDescriptor( - symbol: "shippingbox.fill", - tintName: "azure", - fillsBackground: true, - backgroundOpacity: 0.16, - usesGradient: true - ) - - public static let widgetConfigs: [PreviewWidgetDescriptor] = [ - PreviewWidgetDescriptor(metric: .cpu, style: "area", icon: "cpu", tintName: "azure"), - PreviewWidgetDescriptor(metric: .memory, style: "area", icon: "memorychip", tintName: "teal"), - PreviewWidgetDescriptor(metric: .netRx, secondaryMetric: .netTx, style: "multiLine", icon: "arrow.down.circle") - ] - - public static let activityEvents: [PreviewActivityEvent] = [ - PreviewActivityEvent(timestamp: now, containerID: webContainer.id, kind: .lifecycle, subjectID: webContainer.id), - PreviewActivityEvent(timestamp: now.addingTimeInterval(45), containerID: webContainer.id, kind: .image, subjectID: image.reference, isRead: true), - PreviewActivityEvent(timestamp: now.addingTimeInterval(90), containerID: nil, kind: .alert, subjectID: "runtime-warning") - ] - - public static let activityStatus = PreviewActivityProgress(kind: .image, - subjectID: image.reference, - detailID: "sha256:preview", - fraction: 0.62) - - public static let unsupportedCapabilityError = UnsupportedRuntimeCapability(kind: .dockerCompatible, - capability: .coreMigration) - public static let commandError = CommandError.nonZeroExit(code: 42, - stderr: "preview failure", - command: "container preview") - - public static let createRequest: ContainerCreateRequest = { - var request = ContainerCreateRequest() - request.runtimeKind = .appleContainer - request.image = image.reference - request.platform = "linux/arm64" - request.name = "preview-web" - request.command = ["nginx", "-g", "daemon off;"] - request.env = [ContainerCreateKeyValue(key: "ENV", value: "preview")] - request.labels = [ContainerCreateKeyValue(key: "contained.stack", value: "preview")] - request.ports = [ContainerCreatePort(hostPort: "8080", containerPort: "80")] - request.cpus = "2" - request.memory = "1g" - request.workingDir = "/" - request.useInit = true - return request - }() -} - -private func decode(_ type: T.Type, from json: String) -> T { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - do { - return try decoder.decode(T.self, from: Data(json.utf8)) - } catch { - preconditionFailure("Invalid preview fixture for \(T.self): \(error)") - } -} diff --git a/Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift b/Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift deleted file mode 100644 index 92dc7af0..00000000 --- a/Packages/ContainedPreviewSupport/Tests/ContainedPreviewSupportTests/PreviewSamplesTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Testing -import ContainedPreviewSupport - -@Suite("Preview samples") -struct PreviewSamplesTests { - @Test func samplesAreDeterministicAndUsable() { - #expect(PreviewSamples.webContainer.id == "preview-web") - #expect(PreviewSamples.image.reference == "docker.io/library/nginx:latest") - #expect(!PreviewSamples.sparklineValues.isEmpty) - #expect(PreviewSamples.createRequest.image == PreviewSamples.image.reference) - #expect(PreviewSamples.volume.name == "preview-data") - #expect(PreviewSamples.network.status?.ipv4Subnet == "10.42.0.0/24") - #expect(PreviewSamples.metricHistory.count == PreviewSamples.sparklineValues.count) - #expect(PreviewSamples.runtimes.map(\.kind).contains(.dockerCompatible)) - #expect(PreviewSamples.cardStyle.symbol == "shippingbox.fill") - #expect(PreviewSamples.widgetConfigs.contains { $0.metric == .memory }) - #expect(PreviewSamples.activityEvents.contains { !$0.isRead }) - #expect(PreviewSamples.activityStatus.subjectID == PreviewSamples.image.reference) - #expect(PreviewSamples.activityStatus.fraction == 0.62) - #expect(PreviewSamples.unsupportedCapabilityError.packageErrorCode == "unsupportedRuntimeCapability") - #expect(PreviewSamples.commandError.packageErrorContext["code"] == "42") - } -} diff --git a/Packages/ContainedRuntime/Package.swift b/Packages/ContainedRuntime/Package.swift deleted file mode 100644 index f17c48d3..00000000 --- a/Packages/ContainedRuntime/Package.swift +++ /dev/null @@ -1,28 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let package = Package( - name: "ContainedRuntime", - platforms: [.macOS(.v26)], - products: [ - .library(name: "ContainedRuntime", targets: ["ContainedRuntime"]), - ], - dependencies: [ - .package(path: "../ContainedCore"), - ], - targets: [ - .target( - name: "ContainedRuntime", - dependencies: [ - .product(name: "ContainedCore", package: "ContainedCore"), - ] - ), - .testTarget( - name: "ContainedRuntimeTests", - dependencies: [ - "ContainedRuntime", - .product(name: "ContainedCore", package: "ContainedCore"), - ] - ), - ] -) diff --git a/Packages/ContainedRuntime/README.md b/Packages/ContainedRuntime/README.md deleted file mode 100644 index 1f16b1e3..00000000 --- a/Packages/ContainedRuntime/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# ContainedRuntime - -ContainedRuntime defines the backend contract used by the app and concrete -runtime adapters. It is adapter-neutral and owns no SwiftUI or display policy. - -## Owns - -- `ContainerRuntimeClient`, the async operation protocol for runtime adapters. -- `RuntimeDescriptor` and `RuntimeCapability`, used by the app to decide what a - selected runtime can support. -- Runtime translation result models for create, Compose import, image defaults, - and future core switching. -- `CommandRunner` and `CommandError` for CLI-backed adapters. -- Display-neutral error and unavailable-reason codes that the app maps to - localized alerts, toasts, and Activity entries. - -## Does Not Own - -- Apple-, Docker-, Podman-, Lima-, or remote-specific policy. -- UI routes, app settings, or localization. -- User-facing error messages. - -## Example - -```swift -import ContainedCore -import ContainedRuntime - -let descriptor = RuntimeDescriptor( - kind: RuntimeKind(rawValue: "future-runtime"), - displayName: "Future runtime", - executableName: "future", - capabilities: [.containers, .composeImport] -) - -if descriptor.supports(.composeImport) { - // Enable a runtime-specific Compose import route. -} -``` - -## Build And Test - -```sh -swift build --package-path Packages/ContainedRuntime -swift test --package-path Packages/ContainedRuntime -``` diff --git a/Packages/ContainedRuntime/Sources/ContainedRuntime/ContainedRuntime.docc/ContainedRuntime.md b/Packages/ContainedRuntime/Sources/ContainedRuntime/ContainedRuntime.docc/ContainedRuntime.md deleted file mode 100644 index e2dfc5bd..00000000 --- a/Packages/ContainedRuntime/Sources/ContainedRuntime/ContainedRuntime.docc/ContainedRuntime.md +++ /dev/null @@ -1,20 +0,0 @@ -# ``ContainedRuntime`` - -Adapter-neutral runtime contracts for container engines. - -## Overview - -`ContainedRuntime` is the boundary between app stores and concrete engine -adapters. Stores depend on `any ContainerRuntimeClient`; adapter packages -implement the protocol and advertise capabilities with `RuntimeDescriptor`. - -Use this package to: - -- describe a runtime and its capabilities -- translate shared create/import models into runtime-specific plans -- report unsupported operations as display-neutral typed errors -- return unavailable operation reasons as stable codes instead of UI copy -- run CLI-backed commands through `CommandRunner` - -The app chooses which runtime to use for each container or import item, then maps -typed package errors into localized alerts, toasts, and Activity entries. diff --git a/Packages/ContainedUI/Package.swift b/Packages/ContainedUI/Package.swift new file mode 100644 index 00000000..6859329f --- /dev/null +++ b/Packages/ContainedUI/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "ContainedUI", + platforms: [.macOS(.v26)], + products: [ + .library(name: "ContainedUI", targets: ["ContainedUI"]), + ], + targets: [ + .target(name: "ContainedUI"), + .testTarget( + name: "ContainedUITests", + dependencies: ["ContainedUI"] + ), + ] +) diff --git a/Packages/ContainedUI/README.md b/Packages/ContainedUI/README.md new file mode 100644 index 00000000..91df9cbb --- /dev/null +++ b/Packages/ContainedUI/README.md @@ -0,0 +1,161 @@ +# ContainedUI + +`ContainedUI` owns Contained's reusable visual system. It contains app-neutral +SwiftUI/AppKit primitives, contextual tokens, materials, cards, panels, +controls, feedback states, chart widgets, and small visual affordances. + +It does not own app routes, stores, persistence, Sparkle, runtime policy, or +localized resources. Host apps supply all visible strings, help text, +accessibility labels, and display copy. + +## Importing + +```swift +.product(name: "ContainedUI", package: "ContainedUI") +``` + +```swift +import SwiftUI +import ContainedUI +``` + +## Public API Shape + +Use the nested `UI.*` surface from app and package examples: + +- `UI.Card.*` for card anatomy, page controls, metric chips, and card-local sections. +- `UI.Panel.*` for panel scaffolds, title bars, fields, rows, and sheet title bars. +- `UI.Action.*` for command groups, action items, selection bars, toggles, and progress capsules. +- `UI.Control.*` for option tiles, input/content surfaces, search fields, menus, tint controls, and keyboard hints. +- `UI.Toolbar.*` for toolbar slots and controls. +- `UI.State.*` for empty/loading/error/status presentation. +- `UI.Chart.*` for Swift Charts-backed sparklines and metric tiles. +- `UI.Theme.*` for materials, tint, appearance, color blending, and button tint policy. + +## Tokens + +UI.Tokens is the small raw primitive set used inside the package. `ContainedUI` +components may read raw tokens directly so one primitive change can affect every +element that mirrors it. App and `ContainedUX` code should use contextual +element tokens: + +```swift +UI.Panel.Padding.top +UI.Panel.Spacing.section +UI.Card.Padding.body +UI.Card.Radius.container +UI.Toolbar.Size.controlHeight +UI.Chart.Size.height +``` + +Contextual tokens mirror `UI.Tokens` unless a component intentionally diverges. +When a contextual token diverges, the declaration must include a short inline +reason so future contributors understand why the element owns a different value. + +## Root Setup + +Seed shared material and tint policy once near the app shell: + +```swift +struct AppRoot: View { + let tint = UI.Theme.Tint.azure + + var body: some View { + RootContent() + .tint(tint.color) + .environment(\.modalMaterial, UI.Theme.WindowMaterial.sheet) + .environment(\.buttonMaterial, UI.Theme.WindowMaterial.glassClear) + .environment(\.cardMaterial, UI.Theme.WindowMaterial.glassRegular) + .environment(\.buttonTintStyle, + UI.Theme.ButtonTintStyle(enabled: true, + tint: tint, + opacity: 0.18)) + .environment(\.designSystemShowsInfoTips, true) + .environment(\.pageScaffoldUsesToolbarChrome, false) + .environment(\.pageScaffoldBottomClearance, 0) + } +} +``` + +## Card Example + +```swift +struct ContainerSummaryCard: View { + @State private var page = "overview" + + private let pages = [ + UI.Card.Page(id: "overview", title: "Overview", systemImage: "rectangle.grid.1x2"), + UI.Card.Page(id: "stats", title: "Stats", systemImage: "chart.xyaxis.line") + ] + + var body: some View { + UI.Card.Scaffold(size: .large, + isExpanded: true, + title: "web", + subtitle: "Running", + pages: UI.Card.Pages(items: pages, + selection: page, + tint: .accentColor, + closeLabel: "Close", + onSelect: { page = $0 }, + onClose: {})) { + UI.Card.IconChip(symbol: "shippingbox.fill", tint: .accentColor) + } titleAccessory: { + UI.Badge.Text(text: "Apple") + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + UI.Card.InsetSection(title: "Details") { + UI.Card.SubtitleText(text: "Port 8080 -> 80") + } + } footerLeading: { + UI.Card.FooterChip(isSelected: true, tint: .accentColor, help: "CPU", action: {}) { + Image(systemName: "cpu") + } text: { + UI.Card.MetricText(text: "12%") + } + } footerActions: { + UI.Card.FooterButton(systemName: "play.fill", help: "Start") {} + } widget: { + UI.Chart.Sparkline(samples: [0, 0.12, 0.18], + color: .accentColor, + scale: .fraction) + .frame(height: UI.Card.Metric.sparklineHeight) + } + } +} +``` + +## Panel Example + +```swift +UI.Panel.Scaffold(width: UI.Panel.Size.settings.width) { + UI.Panel.Header(symbol: "gearshape", + title: "Settings", + subtitle: "App preferences") { + UI.Action.Group(UI.Action.Item(systemName: "xmark", help: "Close") {}) + } +} content: { + UI.Panel.Section(header: "General") { + UI.Panel.Row(title: "Launch at login") { + Toggle("", isOn: .constant(true)).labelsHidden() + } + } + .padding(UI.Panel.Padding.compact) +} +``` + +## Previews + +Package-local SwiftUI previews are guarded by `CONTAINED_UI_PREVIEWS`. Normal +app builds do not define that flag, so sample preview values are not linked into +debug, release, notarized, or non-notarized app bundles. + +## Verification + +```sh +swift build --package-path Packages/ContainedUI +swift test --package-path Packages/ContainedUI +``` diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift new file mode 100644 index 00000000..2a47b56a --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift @@ -0,0 +1,100 @@ +import SwiftUI + +/// Package-owned progress capsule for action slots that are temporarily busy. +public struct ActionProgressCapsule: View { + public var controlSize: ControlSize + + public init(controlSize: ControlSize = .small) { + self.controlSize = controlSize + } + + public var body: some View { + MaterialButton(singleItem: true) { + ProgressView() + .controlSize(controlSize) + .frame(width: UI.Tokens.Toolbar.buttonItemHeight, + height: UI.Tokens.Toolbar.buttonItemHeight) + } + } +} + +/// Prominence levels for package-owned text action buttons. +public enum ActionTextProminence { + case standard + case prominent +} + +/// Package-owned text action button for command rows and form footers. +public struct ActionTextButton: View { + public var title: String + public var systemName: String + public var help: String + public var role: ButtonRole? + public var prominence: ActionTextProminence + public var controlSize: ControlSize + public var isEnabled: Bool + public var action: () -> Void + + public init(title: String, + systemName: String, + help: String? = nil, + role: ButtonRole? = nil, + prominence: ActionTextProminence = .standard, + controlSize: ControlSize = .regular, + isEnabled: Bool = true, + action: @escaping () -> Void) { + self.title = title + self.systemName = systemName + self.help = help ?? title + self.role = role + self.prominence = prominence + self.controlSize = controlSize + self.isEnabled = isEnabled + self.action = action + } + + public var body: some View { + switch prominence { + case .standard: + Button(role: role, action: action) { + Label(title, systemImage: systemName) + } + .buttonStyle(.glass) + .controlSize(controlSize) + .help(help) + .disabled(!isEnabled) + case .prominent: + Button(role: role, action: action) { + Label(title, systemImage: systemName) + } + .buttonStyle(.glassProminent) + .controlSize(controlSize) + .help(help) + .disabled(!isEnabled) + } + } +} + +/// Package-owned toggle button used when a binary command belongs in toolbar or panel chrome. +public struct ActionToggleButton: View { + @Binding public var isOn: Bool + public var title: String + public var systemName: String + + public init(isOn: Binding, + title: String, + systemName: String) { + self._isOn = isOn + self.title = title + self.systemName = systemName + } + + public var body: some View { + Toggle(isOn: $isOn) { + Label(title, systemImage: systemName) + } + .toggleStyle(.button) + .buttonStyle(.glass) + .buttonBorderShape(.capsule) + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift new file mode 100644 index 00000000..84ac2b28 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift @@ -0,0 +1,152 @@ +import SwiftUI + +/// Source-of-truth glass action group. +/// +/// Feature views provide action descriptions; the design system owns button grouping, sizing, +/// selection tinting, hover treatment, accessibility labels, and cancel/destructive behavior. +public struct ActionGroup: View { + public var actions: [ActionItem] + public var spacing: CGFloat + public var height: CGFloat + public var minWidth: CGFloat? + public var singleItem: Bool? + public var interactive: Bool + + public init(_ actions: [ActionItem], + spacing: CGFloat = 0, + height: CGFloat = UI.Tokens.Toolbar.buttonGroupHeight, + minWidth: CGFloat? = nil, + singleItem: Bool? = nil, + interactive: Bool = true) { + self.actions = actions + self.spacing = spacing + self.height = height + self.minWidth = minWidth + self.singleItem = singleItem + self.interactive = interactive + } + + public init(_ action: ActionItem, + height: CGFloat = UI.Tokens.Toolbar.buttonGroupHeight, + minWidth: CGFloat? = nil, + interactive: Bool = true) { + self.init([action], + height: height, + minWidth: minWidth, + singleItem: true, + interactive: interactive) + } + + public var body: some View { + MaterialButton(spacing: spacing, + height: height, + minWidth: minWidth, + singleItem: singleItem ?? (actions.count == 1), + interactive: interactive) { + ActionItems(actions) + } + } +} + +/// Package-owned glass cluster for mixed content, such as a menu plus action items. +public struct ActionCluster: View { + public var spacing: CGFloat + public var height: CGFloat + public var minWidth: CGFloat? + public var singleItem: Bool? + public var interactive: Bool + @ViewBuilder public var content: () -> Content + + public init(spacing: CGFloat = 0, + height: CGFloat = UI.Tokens.Toolbar.buttonGroupHeight, + minWidth: CGFloat? = nil, + singleItem: Bool? = nil, + interactive: Bool = true, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.height = height + self.minWidth = minWidth + self.singleItem = singleItem + self.interactive = interactive + self.content = content + } + + public var body: some View { + MaterialButton(spacing: spacing, + height: height, + minWidth: minWidth, + singleItem: singleItem ?? false, + interactive: interactive) { + content() + } + } +} + +/// Package-owned input cluster for search fields and compact inline controls. +public struct InputCluster: View { + @ViewBuilder public var content: () -> Content + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + MaterialButton(singleItem: true) { + MaterialButtonInputItem { + content() + } + } + } +} + +/// Package-owned action item renderer for mixed groups that also contain menus or status labels. +public struct ActionItems: View { + public var actions: [ActionItem] + + public init(_ actions: [ActionItem]) { + self.actions = actions + } + + public var body: some View { + ForEach(Array(actions.enumerated()), id: \.offset) { _, item in + MaterialButtonItem(role: item.role, + tint: item.tint, + help: item.help, + isCancel: item.isCancel, + isIcon: item.title == nil, + action: item.action) { + if let title = item.title { + Label(title, systemImage: item.systemName) + } else { + Image(systemName: item.systemName) + } + } + .disabled(!item.isEnabled) + } + } +} + +/// Semantic label for menus embedded in glass action groups. +public struct ActionMenuLabel: View { + public var systemName: String + public var help: String + public var role: ButtonRole? + public var tint: Color? + + public init(systemName: String, + help: String, + role: ButtonRole? = nil, + tint: Color? = nil) { + self.systemName = systemName + self.help = help + self.role = role + self.tint = tint + } + + public var body: some View { + MaterialButtonItem(systemName: systemName, + role: role, + tint: tint, + help: help) + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift new file mode 100644 index 00000000..427e0237 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// Semantic action description for package-owned glass action chrome. +public struct ActionItem { + public var systemName: String + public var title: String? + public var help: String + public var role: ButtonRole? + public var tint: Color? + public var isCancel: Bool + public var isEnabled: Bool + public var action: () -> Void + + public init(systemName: String, + title: String? = nil, + help: String? = nil, + role: ButtonRole? = nil, + tint: Color? = nil, + isCancel: Bool = false, + isEnabled: Bool = true, + action: @escaping () -> Void) { + self.systemName = systemName + self.title = title + self.help = help ?? title ?? "" + self.role = role + self.tint = tint + self.isCancel = isCancel + self.isEnabled = isEnabled + self.action = action + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift new file mode 100644 index 00000000..420d8426 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// Package-owned floating selection action bar. +public struct ActionSelectionBar: View { + public var count: Int + public var countLabel: (Int) -> String + public var actions: [ActionItem] + + public init(count: Int, + countLabel: @escaping (Int) -> String, + actions: [ActionItem]) { + self.count = count + self.countLabel = countLabel + self.actions = actions + } + + public var body: some View { + HStack(spacing: UI.Tokens.Space.m) { + Text(countLabel(count)) + .font(.callout.weight(.medium)) + Divider() + .frame(height: 16) + ForEach(Array(actions.enumerated()), id: \.offset) { _, item in + ActionTextButton(title: item.title ?? item.help, + systemName: item.systemName, + help: item.help, + role: item.role, + prominence: .standard, + isEnabled: item.isEnabled, + action: item.action) + } + } + .padding(.horizontal, UI.Tokens.Space.l) + .padding(.vertical, UI.Tokens.Space.s) + .materialCapsuleSurface(shadow: false) + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/CardChrome.swift similarity index 80% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift rename to Packages/ContainedUI/Sources/ContainedUI/Card/CardChrome.swift index 35901624..c5584ffc 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardChrome.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/CardChrome.swift @@ -2,15 +2,15 @@ import SwiftUI /// A reusable three-part card header: leading accessory, fill/truncate text block, and trailing /// button rail. This keeps the container/image cards using the same top-aligned chrome structure. -struct DesignCardHeader: View { +struct CardHeader: View { var spacing: CGFloat var padding: CGFloat @ViewBuilder var leading: () -> Leading @ViewBuilder var content: () -> Content @ViewBuilder var trailing: () -> Trailing - init(spacing: CGFloat = DesignTokens.DesignCard.padding, - padding: CGFloat = DesignTokens.DesignCard.padding, + init(spacing: CGFloat = UI.Tokens.Card.padding, + padding: CGFloat = UI.Tokens.Card.padding, @ViewBuilder leading: @escaping () -> Leading, @ViewBuilder content: @escaping () -> Content, @ViewBuilder trailing: @escaping () -> Trailing) { @@ -40,16 +40,16 @@ struct DesignCardHeader: View { } } -/// Stable title/subtitle lane for `DesignCardHeader`. +/// Stable title/subtitle lane for `CardHeader`. /// /// Use this for card header text so the title and metadata stay anchored to the leading chip while /// expanded-card controls appear, disappear, or change page selection. -struct DesignCardHeaderTextBlock: View { +struct CardHeaderTextBlock: View { var spacing: CGFloat @ViewBuilder var title: () -> Title @ViewBuilder var subtitle: () -> Subtitle - init(spacing: CGFloat = DesignTokens.DesignCard.compactTextSpacing, + init(spacing: CGFloat = UI.Tokens.Card.compactTextSpacing, @ViewBuilder title: @escaping () -> Title, @ViewBuilder subtitle: @escaping () -> Subtitle) { self.spacing = spacing @@ -71,8 +71,8 @@ struct DesignCardHeaderTextBlock: View { } } -extension DesignCardHeaderTextBlock where Subtitle == EmptyView { - init(spacing: CGFloat = DesignTokens.DesignCard.compactTextSpacing, +extension CardHeaderTextBlock where Subtitle == EmptyView { + init(spacing: CGFloat = UI.Tokens.Card.compactTextSpacing, @ViewBuilder title: @escaping () -> Title) { self.init(spacing: spacing, title: title) { EmptyView() @@ -81,12 +81,12 @@ extension DesignCardHeaderTextBlock where Subtitle == EmptyView { } /// A small reusable footer mini: optional icon + optional text, aligned on one baseline. -public struct DesignCardFooterMini: View { +public struct CardFooterMini: View { public var spacing: CGFloat @ViewBuilder public var icon: () -> Icon @ViewBuilder public var text: () -> TextContent - public init(spacing: CGFloat = DesignTokens.Space.xs, + public init(spacing: CGFloat = UI.Tokens.Space.xs, @ViewBuilder icon: @escaping () -> Icon, @ViewBuilder text: @escaping () -> TextContent) { self.spacing = spacing @@ -104,11 +104,11 @@ public struct DesignCardFooterMini: View { } } -/// A flat inset section for content that lives inside an expanded `DesignCard`. +/// A flat inset section for content that lives inside an expanded `CardScaffold`. /// /// Use this for charts, process lists, read-only fields, and terminal overlays inside a card body. /// It intentionally avoids creating a second card-shaped glass surface inside the parent card. -public struct DesignCardInsetSection: View { +public struct CardInsetSection: View { public var title: String? public var alignment: HorizontalAlignment public var spacing: CGFloat @@ -117,8 +117,8 @@ public struct DesignCardInsetSection: View { public init(title: String? = nil, alignment: HorizontalAlignment = .leading, - spacing: CGFloat = DesignTokens.Space.s, - padding: CGFloat = DesignTokens.Space.s, + spacing: CGFloat = UI.Tokens.Space.s, + padding: CGFloat = UI.Tokens.Space.s, @ViewBuilder content: @escaping () -> Content) { self.title = title self.alignment = alignment @@ -128,19 +128,19 @@ public struct DesignCardInsetSection: View { } public var body: some View { - VStack(alignment: alignment, spacing: DesignTokens.Space.s) { + VStack(alignment: alignment, spacing: UI.Tokens.Space.s) { if let title { Text(title) .font(.headline) - .padding(.leading, DesignTokens.Space.xs) + .padding(.leading, UI.Tokens.Space.xs) } LazyVStack(alignment: alignment, spacing: spacing) { content() } .padding(padding) .frame(maxWidth: .infinity, alignment: Alignment(horizontal: alignment, vertical: .center)) - .background(DesignMaterial.toolbarHoverFill, - in: RoundedRectangle(cornerRadius: DesignTokens.Radius.control, + .background(ThemeMaterial.toolbarHoverFill, + in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.control, style: .continuous)) } .frame(maxWidth: .infinity, alignment: Alignment(horizontal: alignment, vertical: .center)) @@ -148,7 +148,7 @@ public struct DesignCardInsetSection: View { } /// A selectable footer chip for card widgets, filters, and compact tab-like metadata. -public struct DesignCardFooterChip: View { +public struct CardFooterChip: View { public var isSelected: Bool public var tint: Color public var help: String @@ -172,7 +172,7 @@ public struct DesignCardFooterChip: View { public var body: some View { Button(action: action) { - DesignCardFooterMini { + CardFooterMini { icon() } text: { text() @@ -186,7 +186,7 @@ public struct DesignCardFooterChip: View { } /// Shared icon-only action used by design-card footers. -public struct DesignCardFooterButton: View { +public struct CardFooterButton: View { public var systemName: String public var help: String public var tint: Color? @@ -207,7 +207,7 @@ public struct DesignCardFooterButton: View { public var body: some View { Button(role: role, action: action) { - DesignCardFooterMini { + CardFooterMini { Image(systemName: systemName).font(.body) } text: { EmptyView() @@ -226,7 +226,7 @@ public struct DesignCardFooterButton: View { } } -public struct DesignCardPageControlItem: Identifiable, Hashable { +public struct CardPage: Identifiable, Hashable { public var id: ID public var title: String public var systemImage: String @@ -239,8 +239,8 @@ public struct DesignCardPageControlItem: Identifiable, Hashable { } /// Shared expanded-card page rail with page icons plus a close affordance. -struct DesignCardPageControls: View { - var items: [DesignCardPageControlItem] +struct CardPageControls: View { + var items: [CardPage] var selection: ID var tint: Color var controlsReveal: Double @@ -248,7 +248,7 @@ struct DesignCardPageControls: View { var onSelect: (ID) -> Void var onClose: () -> Void - init(items: [DesignCardPageControlItem], + init(items: [CardPage], selection: ID, tint: Color, controlsReveal: Double = 1, @@ -265,9 +265,9 @@ struct DesignCardPageControls: View { } var body: some View { - GlassButton(singleItem: false) { + MaterialButton(singleItem: false) { ForEach(items) { item in - GlassButtonItem(tint: selection == item.id ? tint : nil, + MaterialButtonItem(tint: selection == item.id ? tint : nil, help: item.title, isIcon: true, action: { onSelect(item.id) }) { @@ -275,7 +275,7 @@ struct DesignCardPageControls: View { .opacity(selection == item.id ? 1 : 0.62) } } - GlassButtonItem(systemName: "xmark", help: closeLabel, action: onClose) + MaterialButtonItem(systemName: "xmark", help: closeLabel, action: onClose) } .opacity(controlsReveal) .allowsHitTesting(controlsReveal > 0.01) @@ -284,7 +284,7 @@ struct DesignCardPageControls: View { } /// A reusable footer item band that hugs its content and anchors either left or right. -public struct DesignCardFooterGroup: View { +public struct CardFooterGroup: View { public enum Alignment { case leading, trailing } @@ -294,7 +294,7 @@ public struct DesignCardFooterGroup: View { @ViewBuilder public var content: () -> Content public init(alignment: Alignment = .leading, - spacing: CGFloat = DesignTokens.DesignCard.padding, + spacing: CGFloat = UI.Tokens.Card.padding, @ViewBuilder content: @escaping () -> Content) { self.alignment = alignment self.spacing = spacing @@ -309,11 +309,11 @@ public struct DesignCardFooterGroup: View { } /// A horizontal group for content in a card widget band. -public struct DesignCardWidgetGroup: View { +public struct CardWidgetGroup: View { public var spacing: CGFloat @ViewBuilder public var content: () -> Content - public init(spacing: CGFloat = DesignTokens.DesignCard.padding, + public init(spacing: CGFloat = UI.Tokens.Card.padding, @ViewBuilder content: @escaping () -> Content) { self.spacing = spacing self.content = content @@ -329,7 +329,7 @@ public struct DesignCardWidgetGroup: View { } /// A reusable footer band with a left group, right group, and optional widget stacked above them. -struct DesignCardFooter: View { +struct CardFooter: View { var showWidget: Bool var actionsVisible: Bool var spacing: CGFloat @@ -342,10 +342,10 @@ struct DesignCardFooter: View { init(showWidget: Bool = false, actionsVisible: Bool = true, - spacing: CGFloat = DesignTokens.DesignCard.padding, - horizontalPadding: CGFloat = DesignTokens.DesignCard.padding, + spacing: CGFloat = UI.Tokens.Card.padding, + horizontalPadding: CGFloat = UI.Tokens.Card.padding, topPadding: CGFloat = 0, - bottomPadding: CGFloat = DesignTokens.DesignCard.padding, + bottomPadding: CGFloat = UI.Tokens.Card.padding, @ViewBuilder leading: @escaping () -> Leading, @ViewBuilder trailing: @escaping () -> Trailing, @ViewBuilder widget: @escaping () -> Widget) { @@ -366,10 +366,10 @@ struct DesignCardFooter: View { widget() } HStack(spacing: spacing) { - DesignCardFooterGroup(alignment: .leading, spacing: spacing) { + CardFooterGroup(alignment: .leading, spacing: spacing) { leading() } - DesignCardFooterGroup(alignment: .trailing, spacing: spacing) { + CardFooterGroup(alignment: .trailing, spacing: spacing) { trailing() } .opacity(actionsVisible ? 1 : 0) @@ -384,7 +384,7 @@ struct DesignCardFooter: View { } } -public struct DesignCardTitleText: View { +public struct CardTitleText: View { public let text: String public init(text: String) { @@ -398,7 +398,7 @@ public struct DesignCardTitleText: View { } } -public struct DesignCardSubtitleText: View { +public struct CardSubtitleText: View { public let text: String public init(text: String) { @@ -413,7 +413,7 @@ public struct DesignCardSubtitleText: View { } } -public struct DesignCardMonospacedSubtitleText: View { +public struct CardMonospacedSubtitleText: View { public let text: String public init(text: String) { @@ -428,7 +428,7 @@ public struct DesignCardMonospacedSubtitleText: View { } } -public struct DesignCardMonospacedTitleText: View { +public struct CardMonospacedTitleText: View { public let text: String public init(text: String) { @@ -442,7 +442,7 @@ public struct DesignCardMonospacedTitleText: View { } } -public struct DesignCardIconChip: View { +public struct CardIconChip: View { public var symbol: String public var tint: Color public var symbolFont: Font @@ -451,7 +451,7 @@ public struct DesignCardIconChip: View { public init(symbol: String, tint: Color = .secondary, symbolFont: Font = .title3, - backgroundOpacity: Double = DesignTokens.DesignCard.iconBackgroundOpacity) { + backgroundOpacity: Double = UI.Tokens.Card.iconBackgroundOpacity) { self.symbol = symbol self.tint = tint self.symbolFont = symbolFont @@ -462,14 +462,14 @@ public struct DesignCardIconChip: View { Image(systemName: symbol) .font(symbolFont) .foregroundStyle(tint) - .frame(width: DesignTokens.IconSize.chip, height: DesignTokens.IconSize.chip) + .frame(width: UI.Tokens.IconSize.chip, height: UI.Tokens.IconSize.chip) .background(tint.opacity(backgroundOpacity), - in: RoundedRectangle(cornerRadius: DesignTokens.Radius.iconChip, style: .continuous)) + in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.iconChip, style: .continuous)) } } /// Small capsule count/state badge used in section headers and compact metadata rows. -public struct DesignBadgeText: View { +public struct BadgeText: View { public let text: String public var font: Font public var foreground: Color @@ -486,14 +486,14 @@ public struct DesignBadgeText: View { Text(text) .font(font) .foregroundStyle(foreground) - .padding(.horizontal, DesignTokens.Space.s) - .padding(.vertical, DesignTokens.Badge.verticalPadding) + .padding(.horizontal, UI.Tokens.Space.s) + .padding(.vertical, UI.Tokens.Badge.verticalPadding) .background(.quaternary, in: Capsule()) } } /// Flat selectable row for lists inside panels and sheets. -public struct DesignListRow: View { +public struct ListRow: View { public var symbol: String public var tint: Color public var title: String @@ -516,12 +516,12 @@ public struct DesignListRow: View { } public var body: some View { - HStack(spacing: DesignTokens.Space.s) { + HStack(spacing: UI.Tokens.Space.s) { Image(systemName: symbol) .font(.callout) .foregroundStyle(tint) - .frame(width: DesignTokens.IconSize.rowMenu) - VStack(alignment: .leading, spacing: DesignTokens.DesignCard.compactTextSpacing) { + .frame(width: UI.Tokens.IconSize.rowMenu) + VStack(alignment: .leading, spacing: UI.Tokens.Card.compactTextSpacing) { Text(title) .font(.callout.weight(.medium)) .lineLimit(1) @@ -532,18 +532,18 @@ public struct DesignListRow: View { .lineLimit(1) } } - Spacer(minLength: DesignTokens.Space.s) + Spacer(minLength: UI.Tokens.Space.s) accessory() } - .padding(.horizontal, DesignTokens.Space.m) - .padding(.vertical, DesignTokens.Space.s) + .padding(.horizontal, UI.Tokens.Space.m) + .padding(.vertical, UI.Tokens.Space.s) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) - .glassSurface(.ultraThin, cornerRadius: DesignTokens.Radius.control) + .materialSurface(.ultraThin, cornerRadius: UI.Tokens.Radius.control) } } -public struct DesignListRowChevron: View { +public struct ListRowChevron: View { public init() {} public var body: some View { @@ -553,7 +553,7 @@ public struct DesignListRowChevron: View { } } -public extension DesignListRow where Accessory == DesignListRowChevron { +public extension ListRow where Accessory == ListRowChevron { init(symbol: String, tint: Color = .accentColor, title: String, subtitle: String?, monospacedSubtitle: Bool = true) { self.init(symbol: symbol, @@ -561,12 +561,12 @@ public extension DesignListRow where Accessory == DesignListRowChevron { title: title, subtitle: subtitle, monospacedSubtitle: monospacedSubtitle) { - DesignListRowChevron() + ListRowChevron() } } } -public struct DesignCardMetricText: View { +public struct CardMetricText: View { public let text: String public init(text: String) { @@ -591,7 +591,7 @@ public extension View { if isVisible { controls() .fixedSize(horizontal: true, vertical: false) - .padding(DesignTokens.Space.s) + .padding(UI.Tokens.Space.s) .zIndex(1) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCard.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/CardScaffold.swift similarity index 83% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCard.swift rename to Packages/ContainedUI/Sources/ContainedUI/Card/CardScaffold.swift index 66a77b33..3183a993 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCard.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/CardScaffold.swift @@ -1,19 +1,19 @@ import SwiftUI -/// Text rendering style for the built-in `DesignCard` title and subtitle lanes. -public enum DesignCardTextStyle { +/// Text rendering style for the built-in `CardScaffold` title and subtitle lanes. +public enum CardTextStyle { case standard case monospaced } -/// Sentinel page type used by `DesignCard` when a card has no page controls. -public enum DesignCardNoPage: Hashable { +/// Sentinel page type used by `CardScaffold` when a card has no page controls. +public enum CardNoPage: Hashable { case none } -/// Typed page-control configuration for `DesignCard`. -public struct DesignCardPages { - public var items: [DesignCardPageControlItem] +/// Typed page-control configuration for `CardScaffold`. +public struct CardPages { + public var items: [CardPage] public var selection: ID public var tint: Color public var controlsReveal: Double @@ -21,7 +21,7 @@ public struct DesignCardPages { public var onSelect: (ID) -> Void public var onClose: () -> Void - public init(items: [DesignCardPageControlItem], + public init(items: [CardPage], selection: ID, tint: Color, controlsReveal: Double = 1, @@ -42,10 +42,10 @@ public struct DesignCardPages { /// /// Feature code supplies semantic title/subtitle data plus optional slots; this view owns how those /// inputs become sticky header chrome, expanded body content, widgets, and footer controls. -public struct DesignCard: View { - public var size: DesignCardSize + public var size: CardSize public var isExpanded: Bool public var cornerRadiusOverride: CGFloat? public var controlsVisible: Bool @@ -56,14 +56,14 @@ public struct DesignCard Void public var title: String public var subtitle: String? - public var titleStyle: DesignCardTextStyle - public var subtitleStyle: DesignCardTextStyle - public var pages: DesignCardPages? + public var titleStyle: CardTextStyle + public var subtitleStyle: CardTextStyle + public var pages: CardPages? @ViewBuilder public var icon: () -> Icon @ViewBuilder public var titleAccessory: () -> TitleAccessory @ViewBuilder public var subtitleAccessory: () -> SubtitleAccessory @@ -75,7 +75,7 @@ public struct DesignCard Void = {}, title: String, subtitle: String? = nil, - titleStyle: DesignCardTextStyle = .standard, - subtitleStyle: DesignCardTextStyle = .standard, - pages: DesignCardPages?, + titleStyle: CardTextStyle = .standard, + subtitleStyle: CardTextStyle = .standard, + pages: CardPages?, @ViewBuilder icon: @escaping () -> Icon, @ViewBuilder titleAccessory: @escaping () -> TitleAccessory, @ViewBuilder subtitleAccessory: @escaping () -> SubtitleAccessory, @@ -138,7 +138,7 @@ public struct DesignCard some View { switch subtitleStyle { case .standard: - DesignCardSubtitleText(text: text) + CardSubtitleText(text: text) case .monospaced: - DesignCardMonospacedSubtitleText(text: text) + CardMonospacedSubtitleText(text: text) } } @@ -225,8 +225,8 @@ public struct DesignCard Void = {}, title: String, subtitle: String? = nil, - titleStyle: DesignCardTextStyle = .standard, - subtitleStyle: DesignCardTextStyle = .standard, + titleStyle: CardTextStyle = .standard, + subtitleStyle: CardTextStyle = .standard, @ViewBuilder icon: @escaping () -> Icon, @ViewBuilder titleAccessory: @escaping () -> TitleAccessory, @ViewBuilder subtitleAccessory: @escaping () -> SubtitleAccessory, diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/CardSurface.swift similarity index 89% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift rename to Packages/ContainedUI/Sources/ContainedUI/Card/CardSurface.swift index 8853a316..2b997db1 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Cards/DesignCardSurface.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/CardSurface.swift @@ -1,6 +1,6 @@ import SwiftUI -public enum DesignCardSize { +public enum CardSize { case small, medium, large /// Footer actions stay visible in compact/expanded chrome for medium and large cards. @@ -16,11 +16,11 @@ public enum DesignCardSize { public var showsWidget: Bool { keepsWidgetSticky } } -public enum DesignCardExpandedMetrics { +public enum CardExpandedMetrics { public static let maxWidth: CGFloat = 760 } -public struct DesignCardSizePicker: View { +public struct CardSizePicker: View { @Binding var selection: CardDensity public var title: String public var labelForDensity: (CardDensity) -> String @@ -44,9 +44,9 @@ public struct DesignCardSizePicker: View { } } -struct DesignCardSurface: View { - var size: DesignCardSize + var size: CardSize var isExpanded = false var cornerRadiusOverride: CGFloat? var controlsVisible = true @@ -60,7 +60,7 @@ struct DesignCardSurface Void = {} @@ -80,7 +80,7 @@ struct DesignCardSurface Void = {}, @ViewBuilder header: @escaping () -> Header, @@ -128,10 +128,10 @@ struct DesignCardSurface some View { - DesignCardFooter(actionsVisible: showActions) { + CardFooter(actionsVisible: showActions) { footerLeading() } trailing: { footerActions() @@ -219,8 +219,8 @@ struct DesignCardSurface some View { @@ -314,15 +314,15 @@ private struct DesignCardMaterialSurface: ViewModifier { } private extension View { - func designCardMaterial(_ material: WindowMaterial, + func designCardMaterial(_ material: ThemeWindowMaterial, cornerRadius: CGFloat, shadow: Bool, fill: Color?, fillOpacity: Double, gradient: Bool, gradientAngle: Double, - blendMode: ColorLayerBlendMode) -> some View { - modifier(DesignCardMaterialSurface(material: material, + blendMode: ThemeColorBlendMode) -> some View { + modifier(CardMaterialSurface(material: material, cornerRadius: cornerRadius, shadow: shadow, fill: fill, @@ -333,15 +333,15 @@ private extension View { } } -extension DesignCardSurface where BodyContent == EmptyView, FooterLeading == EmptyView, +extension CardSurface where BodyContent == EmptyView, FooterLeading == EmptyView, FooterActions == EmptyView, Widget == EmptyView { - init(size: DesignCardSize = .small, + init(size: CardSize = .small, isSelected: Bool = false, fill: Color? = nil, fillOpacity: Double = 0.18, gradient: Bool = false, gradientAngle: Double = 135, - blendMode: ColorLayerBlendMode = .softLight, + blendMode: ThemeColorBlendMode = .softLight, elevated: Bool = true, onTap: @escaping () -> Void = {}, @ViewBuilder header: @escaping () -> Header) { @@ -362,8 +362,8 @@ extension DesignCardSurface where BodyContent == EmptyView, FooterLeading == Emp } } -extension DesignCardSurface where BodyContent == EmptyView, Widget == EmptyView { - init(size: DesignCardSize, +extension CardSurface where BodyContent == EmptyView, Widget == EmptyView { + init(size: CardSize, isExpanded: Bool = false, controlsVisible: Bool = true, isSelected: Bool = false, @@ -371,7 +371,7 @@ extension DesignCardSurface where BodyContent == EmptyView, Widget == EmptyView fillOpacity: Double = 0.18, gradient: Bool = false, gradientAngle: Double = 135, - blendMode: ColorLayerBlendMode = .softLight, + blendMode: ThemeColorBlendMode = .softLight, elevated: Bool = true, onTap: @escaping () -> Void = {}, @ViewBuilder header: @escaping () -> Header, @@ -396,8 +396,8 @@ extension DesignCardSurface where BodyContent == EmptyView, Widget == EmptyView } } -extension DesignCardSurface where Widget == EmptyView { - init(size: DesignCardSize, +extension CardSurface where Widget == EmptyView { + init(size: CardSize, isExpanded: Bool = false, controlsVisible: Bool = true, isSelected: Bool = false, @@ -405,7 +405,7 @@ extension DesignCardSurface where Widget == EmptyView { fillOpacity: Double = 0.18, gradient: Bool = false, gradientAngle: Double = 135, - blendMode: ColorLayerBlendMode = .softLight, + blendMode: ThemeColorBlendMode = .softLight, elevated: Bool = true, onTap: @escaping () -> Void = {}, @ViewBuilder header: @escaping () -> Header, diff --git a/Packages/ContainedUI/Sources/ContainedUI/Card/CardViewModifiers.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/CardViewModifiers.swift new file mode 100644 index 00000000..05e810e1 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/CardViewModifiers.swift @@ -0,0 +1,14 @@ +import SwiftUI + +public extension View { + @ViewBuilder + func designCardSelectionOverlay(when isSelected: Bool) -> some View { + overlay { + if isSelected { + RoundedRectangle(cornerRadius: UI.Tokens.Radius.card, style: .continuous) + .fill(ThemeMaterial.toolbarHoverFill) + .allowsHitTesting(false) + } + } + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift b/Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift new file mode 100644 index 00000000..d6cd0b18 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift @@ -0,0 +1,36 @@ +import SwiftUI + +public struct MetricTile: View { + public var label: String + public var value: String + public var caption: String? + + public init(label: String, value: String, caption: String? = nil) { + self.label = label + self.value = value + self.caption = caption + } + + public var body: some View { + VStack(alignment: .leading, spacing: UI.Tokens.Space.xxs) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + HStack(alignment: .firstTextBaseline, spacing: UI.Tokens.Space.xs) { + Text(value) + .font(.title3.weight(.medium)) + if let caption { + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, UI.Tokens.Space.m) + .padding(.vertical, UI.Tokens.Space.s) + .background(ThemeMaterial.toolbarHoverFill, + in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.control, + style: .continuous)) + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/LiveSparkline.swift b/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift similarity index 95% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/LiveSparkline.swift rename to Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift index 6a5bbea4..0723fb79 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/LiveSparkline.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift @@ -1,6 +1,25 @@ import SwiftUI import Charts +public enum ChartStyle { + public static func primaryLine(_ mark: LineMark) -> some ChartContent { + mark.foregroundStyle(Color.accentColor) + .interpolationMethod(.monotone) + } + + public static func primaryArea(_ mark: AreaMark) -> some ChartContent { + mark.foregroundStyle(Color.accentColor.opacity(UI.Tokens.Chart.areaOpacity)) + } + + public static func successLine(_ mark: LineMark) -> some ChartContent { + mark.foregroundStyle(Color.green) + } + + public static func warningLine(_ mark: LineMark) -> some ChartContent { + mark.foregroundStyle(Color.orange) + } +} + public enum GraphStyle: String, CaseIterable, Identifiable, Codable, Sendable { case area case line @@ -67,7 +86,7 @@ public enum SparklineScale: String, CaseIterable, Identifiable, Codable, Sendabl /// A compact Swift Charts renderer for card widgets. Byte/rate metrics can be normalized /// independently, while pre-normalized fraction metrics can stay anchored to the 0...100% domain. -public struct LiveSparkline: View { +public struct SparklineView: View { private static let maximumPlottedSamples = 24 public var samples: [Double] diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/DesignSparklineMetricTile.swift b/Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift similarity index 82% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/DesignSparklineMetricTile.swift rename to Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift index fbc844dc..3434c12d 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/DataViz/DesignSparklineMetricTile.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift @@ -1,7 +1,7 @@ import SwiftUI /// A dashboard summary tile: muted label, large value, optional symbol and sparkline. -public struct DesignSparklineMetricTile: View { +public struct SparklineMetricTile: View { public let label: String public let value: String public var systemImage: String? = nil @@ -24,8 +24,8 @@ public struct DesignSparklineMetricTile: View { } public var body: some View { - VStack(alignment: .leading, spacing: DesignTokens.Space.s) { - HStack(spacing: DesignTokens.Space.s) { + VStack(alignment: .leading, spacing: UI.Tokens.Space.s) { + HStack(spacing: UI.Tokens.Space.s) { if let systemImage { Image(systemName: systemImage) .font(.body.weight(.medium)) @@ -40,12 +40,12 @@ public struct DesignSparklineMetricTile: View { .font(.title.weight(.semibold)) .contentTransition(.numericText()) if let samples { - LiveSparkline(samples: samples, color: tint, scale: sparklineScale) + SparklineView(samples: samples, color: tint, scale: sparklineScale) .frame(height: 22) } } - .padding(DesignTokens.Space.l) + .padding(UI.Tokens.Space.l) .frame(maxWidth: .infinity, alignment: .leading) - .glassSurface(.regular, cornerRadius: DesignTokens.Radius.card, fill: tint, fillOpacity: 0.10) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card, fill: tint, fillOpacity: 0.10) } } diff --git a/Packages/ContainedUI/Sources/ContainedUI/Command/PreviewBar.swift b/Packages/ContainedUI/Sources/ContainedUI/Command/PreviewBar.swift new file mode 100644 index 00000000..ba49fc70 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Command/PreviewBar.swift @@ -0,0 +1,50 @@ +import SwiftUI + +/// The signature "Reveal CLI" strip: shows the exact `container …` command an action will run, +/// copyable to the clipboard. Drives user trust and learning. +public extension UI.Command { + struct PreviewBar: View { + public let command: [String] + public var copyHelp: String + public var copiedAccessibilityLabel: String + @State private var copied = false + + private var rendered: String { (["container"] + command).joined(separator: " ") } + + public init(command: [String], + copyHelp: String, + copiedAccessibilityLabel: String) { + self.command = command + self.copyHelp = copyHelp + self.copiedAccessibilityLabel = copiedAccessibilityLabel + } + + public var body: some View { + HStack(spacing: UI.Tokens.Space.s) { + Image(systemName: "terminal") + .foregroundStyle(.primary) + ScrollView(.horizontal, showsIndicators: false) { + Text(rendered) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(1) + } + Spacer(minLength: UI.Tokens.Space.s) + Button { + copyToPasteboard(rendered) + withAnimation { copied = true } + Task { try? await Task.sleep(for: .seconds(1.4)); withAnimation { copied = false } } + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .foregroundStyle(copied ? .green : .secondary) + } + .buttonStyle(.plain) + .help(copyHelp) + .accessibilityLabel(copied ? copiedAccessibilityLabel : copyHelp) + } + .padding(.horizontal, UI.Tokens.Space.s) + .padding(.vertical, UI.Tokens.Space.s) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card, shadow: false) + } + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Console/ConsoleViewModifiers.swift b/Packages/ContainedUI/Sources/ContainedUI/Console/ConsoleViewModifiers.swift new file mode 100644 index 00000000..9f8ec857 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Console/ConsoleViewModifiers.swift @@ -0,0 +1,11 @@ +import SwiftUI + +public extension View { + func terminalSurfaceChrome() -> some View { + padding(UI.Tokens.Space.s) + .background(.black.opacity(UI.Tokens.Terminal.surfaceOpacity), + in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.card, + style: .continuous)) + .padding(UI.Tokens.Space.s) + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Console/StreamConsole.swift b/Packages/ContainedUI/Sources/ContainedUI/Console/StreamConsole.swift new file mode 100644 index 00000000..c5e45cfc --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Console/StreamConsole.swift @@ -0,0 +1,116 @@ +import SwiftUI + +/// A scrolling console that consumes a one-shot streaming command (pull / build `--progress plain`) +/// to completion, auto-scrolling and reporting success/failure. Shared by the pull and build flows. +public extension UI.Console { + struct Stream: View { + /// Factory so the stream starts with the view's `.task` (and cancels on disappear). + public let stream: () -> AsyncThrowingStream + public var workingLabel: String + public var completedLabel: String + public var lineCountLabel: (Int) -> String + public var copyLogHelp: String + public var failureLabel: (Error) -> String + public var onComplete: (Bool) -> Void = { _ in } + + enum RunState: Equatable { case running, done, failed(String) } + + @State private var lines: [String] = [] + @State private var carry = "" + @State private var state: RunState = .running + private let maxLines = 8000 + private let bottomID = "console-bottom" + + public init(stream: @escaping () -> AsyncThrowingStream, + workingLabel: String, + completedLabel: String, + lineCountLabel: @escaping (Int) -> String, + copyLogHelp: String, + failureLabel: @escaping (Error) -> String, + onComplete: @escaping (Bool) -> Void = { _ in }) { + self.stream = stream + self.workingLabel = workingLabel + self.completedLabel = completedLabel + self.lineCountLabel = lineCountLabel + self.copyLogHelp = copyLogHelp + self.failureLabel = failureLabel + self.onComplete = onComplete + } + + public var body: some View { + VStack(spacing: 0) { + statusBar + Divider() + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(line.isEmpty ? " " : line) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + Color.clear.frame(height: 1).id(bottomID) + } + .padding(UI.Tokens.Space.s) + } + .scrollEdgeEffectStyle(.soft, for: .all) + .onChange(of: lines.count) { _, _ in proxy.scrollTo(bottomID, anchor: .bottom) } + } + .padding(UI.Tokens.Space.s) + .background(.black.opacity(0.22), + in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.card, style: .continuous)) + .padding(UI.Tokens.Space.s) + } + .task { await consume() } + } + + private var statusBar: some View { + HStack(spacing: UI.Tokens.Space.s) { + switch state { + case .running: + ProgressView().controlSize(.small) + Text(workingLabel).foregroundStyle(.secondary) + case .done: + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + Text(completedLabel).foregroundStyle(.secondary) + case .failed(let message): + Image(systemName: "xmark.octagon.fill").foregroundStyle(.red) + Text(message).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Text(lineCountLabel(lines.count)).font(.caption).foregroundStyle(.secondary).monospacedDigit() + MaterialButton(singleItem: true) { + MaterialButtonItem(systemName: "doc.on.doc", help: copyLogHelp) { + copyToPasteboard(lines.joined(separator: "\n")) + } + } + } + .font(.callout) + .padding(UI.Tokens.Space.s) + } + + private func consume() async { + do { + for try await chunk in stream() { ingest(chunk) } + if !carry.isEmpty { lines.append(carry); carry = "" } + state = .done + onComplete(true) + } catch is CancellationError { + // View dismissed mid-stream; nothing to report. + } catch { + if !carry.isEmpty { lines.append(carry); carry = "" } + state = .failed(failureLabel(error)) + onComplete(false) + } + } + + private func ingest(_ chunk: String) { + let combined = carry + chunk + guard let lastNewline = combined.lastIndex(of: "\n") else { carry = combined; return } + carry = String(combined[combined.index(after: lastNewline)...]) + lines.append(contentsOf: combined[.. maxLines { lines.removeFirst(lines.count - maxLines) } + } + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md b/Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md new file mode 100644 index 00000000..598dbc9f --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md @@ -0,0 +1,70 @@ +# ``ContainedUI`` + +Reusable visual building blocks for Contained and related macOS SwiftUI apps. + +## Overview + +`ContainedUI` exposes a nested `UI.*` API for app-neutral cards, panels, +actions, controls, toolbar controls, state views, chart widgets, materials, and +tokens. The package receives strings from the host app and does not ship +localized resources. + +Use contextual tokens first: + +```swift +UI.Panel.Padding.top +UI.Panel.Spacing.section +UI.Card.Radius.container +UI.Toolbar.Size.controlHeight +``` + +UI.Tokens is the raw primitive namespace for `ContainedUI` internals. UI +components may use raw tokens directly so one primitive change can flow through +every element that mirrors it. `ContainedUX` and app code use contextual element tokens +such as `UI.Panel.Padding.top` or `UI.Toolbar.Size.controlHeight`. +Contextual tokens mirror raw defaults unless their declaration explains an +intentional divergence. + +## Topics + +### Namespaces + +- `UI.Card` +- `UI.Panel` +- `UI.Action` +- `UI.Control` +- `UI.Toolbar` +- `UI.State` +- `UI.Chart` +- `UI.Theme` +- `UI.Tokens` + +### Examples + +```swift +UI.Action.Group([ + UI.Action.Item(systemName: "doc.on.doc", help: "Copy") { copy() }, + UI.Action.Item(systemName: "trash", title: "Delete", role: .destructive) { delete() } +]) +``` + +```swift +UI.Panel.Scaffold(width: UI.Panel.Size.settings.width) { + UI.Panel.Header(symbol: "gearshape", title: "Settings", subtitle: "Preferences") { + UI.Action.Group(UI.Action.Item(systemName: "xmark", help: "Close") {}) + } +} content: { + UI.Panel.Section(header: "Appearance") { + UI.Panel.Row(title: "Accent") { + UI.Control.TintSelector(selection: $tint, labelForTint: label) + } + } +} +``` + +```swift +UI.Chart.Sparkline(samples: samples, + color: .accentColor, + scale: .fraction) + .frame(height: UI.Card.Metric.sparklineHeight) +``` diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift new file mode 100644 index 00000000..e159d4ef --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift @@ -0,0 +1,49 @@ +import SwiftUI + +public struct ScopeChipLabel: View { + public var symbol: String + public var title: String + + public init(symbol: String, title: String) { + self.symbol = symbol + self.title = title + } + + public var body: some View { + HStack(spacing: UI.Tokens.Space.xs) { + Image(systemName: symbol) + .font(.caption2) + Text(title) + .font(.caption.weight(.semibold)) + Image(systemName: "xmark") + .font(.caption2.weight(.bold)) + } + .padding(.horizontal, UI.Tokens.Space.s) + .padding(.vertical, UI.Tokens.Badge.scopeVerticalPadding) + .background(Color.accentColor.opacity(UI.Tokens.Badge.accentOpacity), + in: Capsule(style: .continuous)) + .foregroundStyle(Color.accentColor) + } +} + +public struct TintSwatch: View { + public var color: Color + public var followsAccent: Bool + + public init(color: Color, followsAccent: Bool = false) { + self.color = color + self.followsAccent = followsAccent + } + + public var body: some View { + ZStack { + Circle().fill(color) + if followsAccent { + Image(systemName: "link") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + } + } + .frame(width: UI.Tokens.IconSize.chip, height: UI.Tokens.IconSize.chip) + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/ControlViewModifiers.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/ControlViewModifiers.swift new file mode 100644 index 00000000..0521dee8 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/ControlViewModifiers.swift @@ -0,0 +1,13 @@ +import SwiftUI + +public extension View { + func subtleTileBackground() -> some View { + background(.quaternary.opacity(UI.Tokens.InlineControl.subtleTileOpacity), + in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.control, + style: .continuous)) + } + + func toolbarControlContentShape() -> some View { + contentShape(Capsule(style: .continuous)) + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GradientAngleControl.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift similarity index 70% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GradientAngleControl.swift rename to Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift index 1f3d5073..c4e152cb 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GradientAngleControl.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift @@ -12,14 +12,14 @@ public struct GradientAngleControl: View { public var body: some View { LabeledContent(title) { - HStack(spacing: DesignTokens.Space.m) { + HStack(spacing: UI.Tokens.Space.m) { AngleDial(angle: $angle) - .frame(width: DesignTokens.InlineControl.gradientDial, - height: DesignTokens.InlineControl.gradientDial) + .frame(width: UI.Tokens.InlineControl.gradientDial, + height: UI.Tokens.InlineControl.gradientDial) Slider(value: $angle, in: 0...360, step: 1) Text("\(Int(angle))°") .monospacedDigit() - .frame(width: DesignTokens.InlineControl.gradientReadout) + .frame(width: UI.Tokens.InlineControl.gradientReadout) } } } @@ -34,16 +34,16 @@ private struct AngleDial: View { let radius = min(geo.size.width, geo.size.height) / 2 let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2) let radians = angle * .pi / 180 - let knob = CGPoint(x: center.x + cos(radians) * (radius - DesignTokens.InlineControl.gradientKnobInset), - y: center.y + sin(radians) * (radius - DesignTokens.InlineControl.gradientKnobInset)) + let knob = CGPoint(x: center.x + cos(radians) * (radius - UI.Tokens.InlineControl.gradientKnobInset), + y: center.y + sin(radians) * (radius - UI.Tokens.InlineControl.gradientKnobInset)) ZStack { Circle() - .strokeBorder(.secondary.opacity(DesignTokens.InlineControl.gradientStrokeOpacity), - lineWidth: DesignTokens.Space.hairline) + .strokeBorder(.secondary.opacity(UI.Tokens.InlineControl.gradientStrokeOpacity), + lineWidth: UI.Tokens.Space.hairline) Circle() .fill(.tint) - .frame(width: DesignTokens.InlineControl.gradientKnob, - height: DesignTokens.InlineControl.gradientKnob) + .frame(width: UI.Tokens.InlineControl.gradientKnob, + height: UI.Tokens.InlineControl.gradientKnob) .position(knob) } .contentShape(Rectangle()) diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/InfoButton.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/InfoButton.swift new file mode 100644 index 00000000..6522a3ad --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/InfoButton.swift @@ -0,0 +1,51 @@ +import SwiftUI + +public extension EnvironmentValues { + @Entry var designSystemShowsInfoTips = true +} + +/// A small `info.circle` button that reveals help text in a popover. Replaces hover-only tooltips so +/// the guidance is always discoverable (tap, not hover) and reachable by VoiceOver / keyboard. The +/// popover wraps to as many lines as the text needs (it never truncates) and can be turned off by the +/// host through the `designSystemShowsInfoTips` environment value. +public extension UI.Control { + struct InfoButton: View { + public let text: String + public var visible = true + @Environment(\.modalMaterial) private var modalMaterial + @Environment(\.designSystemShowsInfoTips) private var showsInfoTips + @State private var showing = false + + public init(_ text: String, visible: Bool = true) { + self.text = text + self.visible = visible + } + + public var body: some View { + if showsInfoTips { + Button { showing = true } label: { + Image(systemName: "info.circle") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .opacity(visible || showing ? 1 : 0) + .allowsHitTesting(visible || showing) + .help(text) // hover still works as a bonus for mouse users + .accessibilityLabel(text) + .popover(isPresented: $showing, arrowEdge: .trailing) { + Text(.init(text)) // Markdown-aware so tips can use **bold** / `code` + .font(.callout) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 300) + .padding(UI.Tokens.Space.m) + .background { + VisualEffectBackground(material: modalMaterial.nsMaterial, blendingMode: .withinWindow) + } + .presentationBackground(.clear) + } + } + } + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift new file mode 100644 index 00000000..1dee55ce --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift @@ -0,0 +1,39 @@ +import SwiftUI + +public struct KeyCap: View { + public var text: String + + public init(_ text: String) { + self.text = text + } + + public var body: some View { + Text(text) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, UI.Tokens.Keyboard.keyHorizontalPadding) + .padding(.vertical, UI.Tokens.Keyboard.keyVerticalPadding) + .background(.quaternary, + in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.keyCap, + style: .continuous)) + } +} + +public struct KeyboardHint: View { + public var key: String + public var label: String + + public init(_ key: String, _ label: String) { + self.key = key + self.label = label + } + + public var body: some View { + HStack(spacing: UI.Tokens.Space.xs) { + KeyCap(key) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift new file mode 100644 index 00000000..7f03102a --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift @@ -0,0 +1,235 @@ +import SwiftUI + +public struct ListStack: View { + public var spacing: CGFloat + public var padding: CGFloat + @ViewBuilder public var content: () -> Content + + public init(spacing: CGFloat = UI.Tokens.Space.s, + padding: CGFloat = UI.Tokens.Space.s, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.padding = padding + self.content = content + } + + public var body: some View { + LazyVStack(alignment: .leading, spacing: spacing) { + content() + } + .padding(padding) + } +} + +public struct ListSection: View { + public var title: String + public var spacing: CGFloat + @ViewBuilder public var content: () -> Content + + public init(_ title: String, + spacing: CGFloat = UI.Tokens.Space.s, + @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.spacing = spacing + self.content = content + } + + public var body: some View { + LazyVStack(alignment: .leading, spacing: spacing) { + SectionLabel(title) + content() + } + } +} + +public struct MetadataRow: View { + public var systemImage: String + public var title: String + public var subtitle: String? + public var isMonospaced: Bool + public var tint: Color + public var action: (() -> Void)? + @ViewBuilder public var accessory: () -> Accessory + + public init(systemImage: String, + title: String, + subtitle: String? = nil, + isMonospaced: Bool = false, + tint: Color = .secondary, + action: (() -> Void)? = nil, + @ViewBuilder accessory: @escaping () -> Accessory) { + self.systemImage = systemImage + self.title = title + self.subtitle = subtitle + self.isMonospaced = isMonospaced + self.tint = tint + self.action = action + self.accessory = accessory + } + + public var body: some View { + HStack(spacing: UI.Tokens.Space.m) { + Image(systemName: systemImage) + .foregroundStyle(tint) + .frame(width: UI.Tokens.IconSize.rowIconColumn) + VStack(alignment: .leading, spacing: UI.Tokens.Card.compactTextSpacing) { + Text(title) + .font(isMonospaced ? .system(.callout, design: .monospaced) : .callout) + .lineLimit(1) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: UI.Tokens.Space.s) + accessory() + } + .padding(.vertical, UI.Tokens.Space.s) + .contentShape(Rectangle()) + .onTapGesture { + action?() + } + } +} + +public extension MetadataRow where Accessory == EmptyView { + init(systemImage: String, + title: String, + subtitle: String? = nil, + isMonospaced: Bool = false, + tint: Color = .secondary, + action: (() -> Void)? = nil) { + self.init(systemImage: systemImage, + title: title, + subtitle: subtitle, + isMonospaced: isMonospaced, + tint: tint, + action: action) { + EmptyView() + } + } +} + +public struct MetadataBadgeRow: View { + public var systemImage: String + public var title: String + public var badge: String? + public var subtitle: String? + public var isMonospaced: Bool + public var tint: Color + @ViewBuilder public var accessory: () -> Accessory + + public init(systemImage: String, + title: String, + badge: String? = nil, + subtitle: String? = nil, + isMonospaced: Bool = false, + tint: Color = .secondary, + @ViewBuilder accessory: @escaping () -> Accessory) { + self.systemImage = systemImage + self.title = title + self.badge = badge + self.subtitle = subtitle + self.isMonospaced = isMonospaced + self.tint = tint + self.accessory = accessory + } + + public var body: some View { + HStack(spacing: UI.Tokens.Space.m) { + Image(systemName: systemImage) + .foregroundStyle(tint) + .frame(width: UI.Tokens.IconSize.rowIconColumn) + VStack(alignment: .leading, spacing: UI.Tokens.Card.compactTextSpacing) { + HStack(spacing: UI.Tokens.Space.xs) { + Text(title) + .font(isMonospaced ? .system(.callout, design: .monospaced) : .callout) + .lineLimit(1) + if let badge { + BadgeText(text: badge) + } + } + if let subtitle { + Text(subtitle) + .designSecondaryCaption() + .lineLimit(1) + } + } + Spacer(minLength: UI.Tokens.Space.s) + accessory() + } + .padding(.vertical, UI.Tokens.Space.s) + } +} + +public struct KeyValueRow: View { + public var label: String + public var value: String + public var valueMonospaced: Bool + public var selectsValue: Bool + + public init(label: String, + value: String, + valueMonospaced: Bool = true, + selectsValue: Bool = false) { + self.label = label + self.value = value + self.valueMonospaced = valueMonospaced + self.selectsValue = selectsValue + } + + public var body: some View { + HStack(alignment: .top) { + Text(label) + .designSecondaryCallout() + Spacer(minLength: UI.Tokens.Space.m) + valueText + .multilineTextAlignment(.trailing) + } + .font(.callout) + } + + @ViewBuilder + private var valueText: some View { + let text = Text(value) + if valueMonospaced { + if selectsValue { + text.font(.system(.body, design: .monospaced)).textSelection(.enabled) + } else { + text.font(.system(.body, design: .monospaced)) + } + } else if selectsValue { + text.textSelection(.enabled) + } else { + text + } + } +} + +public struct CompactInfoRow: View { + public var title: String + public var value: String + public var titleWidth: CGFloat + + public init(_ title: String, + value: String, + titleWidth: CGFloat = UI.Tokens.MenuBar.titleWidth) { + self.title = title + self.value = value + self.titleWidth = titleWidth + } + + public var body: some View { + HStack(alignment: .firstTextBaseline, spacing: UI.Tokens.Card.padding) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: titleWidth, alignment: .leading) + Text(value) + .font(.caption) + Spacer(minLength: 0) + } + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassButton.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift similarity index 75% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassButton.swift rename to Packages/ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift index 31bffb69..dc76a1c9 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/GlassButton.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift @@ -1,13 +1,13 @@ import SwiftUI -private struct GlassButtonItemHoverEnabledKey: EnvironmentKey { +private struct MaterialButtonItemHoverEnabledKey: EnvironmentKey { static let defaultValue = true } private extension EnvironmentValues { - var glassButtonItemHoverEnabled: Bool { - get { self[GlassButtonItemHoverEnabledKey.self] } - set { self[GlassButtonItemHoverEnabledKey.self] = newValue } + var materialButtonItemHoverEnabled: Bool { + get { self[MaterialButtonItemHoverEnabledKey.self] } + set { self[MaterialButtonItemHoverEnabledKey.self] = newValue } } } @@ -23,20 +23,20 @@ private struct OptionalAccessibilityLabel: ViewModifier { } } -public struct DesignButtonTintStyle: Equatable, Sendable { +public struct ButtonTintStyle: Equatable, Sendable { public var enabled = false - public var tint: DesignTint = .multicolor + public var tint: ThemeTint = .multicolor public var opacity = 0.18 public var gradient = true public var gradientAngle = 135.0 - public var blendMode: ColorLayerBlendMode = .softLight + public var blendMode: ThemeColorBlendMode = .softLight public init(enabled: Bool = false, - tint: DesignTint = .multicolor, + tint: ThemeTint = .multicolor, opacity: Double = 0.18, gradient: Bool = true, gradientAngle: Double = 135.0, - blendMode: ColorLayerBlendMode = .softLight) { + blendMode: ThemeColorBlendMode = .softLight) { self.enabled = enabled self.tint = tint self.opacity = opacity @@ -45,12 +45,12 @@ public struct DesignButtonTintStyle: Equatable, Sendable { self.blendMode = blendMode } - public static let disabled = DesignButtonTintStyle() + public static let disabled = ButtonTintStyle() } -/// A reusable glass button item: an icon or text button with the shared 28pt inner height and 4pt -/// padding. Place it inside `GlassButton` to get the full 36pt glass capsule. -struct GlassButtonItem: View { +/// A reusable material button item: an icon or text button with the shared 28pt inner height and +/// 4pt padding. Place it inside `MaterialButton` to get the full 36pt material capsule. +struct MaterialButtonItem: View { var role: ButtonRole? = nil var tint: Color? = nil var help: String = "" @@ -61,7 +61,7 @@ struct GlassButtonItem: View { @ViewBuilder var label: () -> Label @State private var hovering = false - @Environment(\.glassButtonItemHoverEnabled) private var hoverEnabled + @Environment(\.materialButtonItemHoverEnabled) private var hoverEnabled @Environment(\.colorScheme) private var colorScheme private var itemForegroundStyle: AnyShapeStyle { @@ -87,15 +87,15 @@ struct GlassButtonItem: View { label() .font(.body.weight(.medium)) .foregroundStyle(itemForegroundStyle) - .padding(DesignTokens.Toolbar.iconInnerPadding) - .frame(width: isIcon ? DesignTokens.Toolbar.buttonItemHeight : nil, - height: DesignTokens.Toolbar.buttonItemHeight) + .padding(UI.Tokens.Toolbar.iconInnerPadding) + .frame(width: isIcon ? UI.Tokens.Toolbar.buttonItemHeight : nil, + height: UI.Tokens.Toolbar.buttonItemHeight) .contentShape(Rectangle()) .background { Capsule(style: .continuous) .fill( hoverEnabled && hovering && !isLabel - ? DesignMaterial.toolbarInteractiveHoverFill(for: colorScheme) + ? ThemeMaterial.toolbarInteractiveHoverFill(for: colorScheme) : .clear ) } @@ -122,13 +122,13 @@ struct GlassButtonItem: View { } } -/// Input content that occupies the same 28pt inner lane as `GlassButtonItem`, but leaves hover/pressed -/// treatment to the enclosing `GlassButton` container. -struct GlassButtonInputItem: View { - var spacing = DesignTokens.Toolbar.searchIconGap +/// Input content that occupies the same 28pt inner lane as `MaterialButtonItem`, but leaves hover/pressed +/// treatment to the enclosing `MaterialButton` container. +struct MaterialButtonInputItem: View { + var spacing = UI.Tokens.Toolbar.searchIconGap @ViewBuilder var content: () -> Content - init(spacing: CGFloat = DesignTokens.Toolbar.searchIconGap, + init(spacing: CGFloat = UI.Tokens.Toolbar.searchIconGap, @ViewBuilder content: @escaping () -> Content) { self.spacing = spacing self.content = content @@ -137,13 +137,13 @@ struct GlassButtonInputItem: View { var body: some View { HStack(spacing: spacing) { content() } .font(.body.weight(.medium)) - .padding(DesignTokens.Toolbar.iconInnerPadding) - .frame(height: DesignTokens.Toolbar.buttonItemHeight) + .padding(UI.Tokens.Toolbar.iconInnerPadding) + .frame(height: UI.Tokens.Toolbar.buttonItemHeight) .contentShape(Rectangle()) } } -extension GlassButtonItem where Label == Image { +extension MaterialButtonItem where Label == Image { init(systemName: String, role: ButtonRole? = nil, tint: Color? = nil, help: String = "", isCancel: Bool = false, isLabel: Bool = false, action: (() -> Void)? = nil) { self.role = role @@ -159,9 +159,9 @@ extension GlassButtonItem where Label == Image { /// A pill that groups related glass button items under one shared interactive-glass capsule. This /// is the morph target for compact button groups across the app. -struct GlassButton: View { +struct MaterialButton: View { var spacing: CGFloat = 0 - var height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight + var height: CGFloat = UI.Tokens.Toolbar.buttonGroupHeight var minWidth: CGFloat? = nil var singleItem: Bool = false /// Set `false` for a static glass container (no hover treatment) — e.g. vanity toolbar chrome. @@ -169,7 +169,7 @@ struct GlassButton: View { @ViewBuilder var content: () -> Content init(spacing: CGFloat = 0, - height: CGFloat = DesignTokens.Toolbar.buttonGroupHeight, + height: CGFloat = UI.Tokens.Toolbar.buttonGroupHeight, minWidth: CGFloat? = nil, singleItem: Bool = false, interactive: Bool = true, @@ -189,7 +189,7 @@ struct GlassButton: View { var body: some View { let shape = Capsule(style: .continuous) HStack(spacing: spacing) { content() } - .padding(.horizontal, DesignTokens.Toolbar.iconInnerPadding) + .padding(.horizontal, UI.Tokens.Toolbar.iconInnerPadding) .frame(height: height) .frame(minWidth: minWidth) .background { @@ -197,12 +197,12 @@ struct GlassButton: View { Capsule(style: .continuous) .fill( hovering - ? DesignMaterial.toolbarInteractiveHoverFill(for: colorScheme) + ? ThemeMaterial.toolbarInteractiveHoverFill(for: colorScheme) : .clear ) } } - .environment(\.glassButtonItemHoverEnabled, !singleItem && interactive) + .environment(\.materialButtonItemHoverEnabled, !singleItem && interactive) .onHover { if interactive { hovering = $0 } } .background { tintLayer(in: shape) } .toolbarControlMaterial(in: shape) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignOptionTile.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift similarity index 86% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignOptionTile.swift rename to Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift index 875038b3..00fc68a0 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignOptionTile.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift @@ -1,10 +1,10 @@ import SwiftUI -public struct DesignOptionStack: View { +public struct OptionStack: View { public var spacing: CGFloat @ViewBuilder public var content: () -> Content - public init(spacing: CGFloat = DesignTokens.Space.s, + public init(spacing: CGFloat = UI.Tokens.Space.s, @ViewBuilder content: @escaping () -> Content) { self.spacing = spacing self.content = content @@ -19,7 +19,7 @@ public struct DesignOptionStack: View { } } -public struct DesignOptionTile: View { +public struct OptionTile: View { public static let defaultHeight: CGFloat = 100 public let symbol: String @@ -54,13 +54,13 @@ public struct DesignOptionTile: View { guard enabled else { return } action() } label: { - VStack(alignment: .leading, spacing: DesignTokens.Space.xs) { + VStack(alignment: .leading, spacing: UI.Tokens.Space.xs) { Image(systemName: symbol) .font(.title3) .foregroundStyle(enabled ? Color.accentColor : Color.secondary) .frame(width: 24, height: 24, alignment: .leading) - Spacer(minLength: DesignTokens.Space.s) + Spacer(minLength: UI.Tokens.Space.s) VStack(alignment: .leading, spacing: 2) { Text(title) @@ -76,10 +76,10 @@ public struct DesignOptionTile: View { .accessibilityHidden(subtitle == nil) } } - .padding(DesignTokens.Space.m) + .padding(UI.Tokens.Space.m) .frame(maxWidth: .infinity, minHeight: height, maxHeight: height, alignment: .leading) - .designOptionTileSurface(cornerRadius: DesignTokens.Radius.card, interactive: enabled) - .contentShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous)) + .designOptionTileSurface(cornerRadius: UI.Tokens.Radius.card, interactive: enabled) + .contentShape(RoundedRectangle(cornerRadius: UI.Tokens.Radius.card, style: .continuous)) .optionalMatchedGeometry(id: matchedID, namespace: matchedNamespace) } .buttonStyle(.plain) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignRowMenu.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift similarity index 87% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignRowMenu.swift rename to Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift index 6993a3a7..4a34bc13 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/DesignRowMenu.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift @@ -4,7 +4,7 @@ import SwiftUI /// detail headers. /// Centralizes the styling chain and the VoiceOver label so icon-only menus are consistently /// accessible. -public struct DesignRowMenu: View { +public struct RowMenu: View { public var systemImage: String public var accessibilityLabel: String @ViewBuilder public var content: () -> Content @@ -23,7 +23,7 @@ public struct DesignRowMenu: View { } label: { Image(systemName: systemImage) .font(.body.weight(.medium)) - .frame(width: DesignTokens.IconSize.rowMenu, height: DesignTokens.IconSize.rowMenu) + .frame(width: UI.Tokens.IconSize.rowMenu, height: UI.Tokens.IconSize.rowMenu) } .menuStyle(.button) .buttonStyle(.glass) diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift new file mode 100644 index 00000000..b8bacbb2 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift @@ -0,0 +1,44 @@ +import SwiftUI + +public struct SearchField: View { + @Binding public var text: String + public var prompt: String + public var clearLabel: String + public var isSearching: Bool + public var onSubmit: () -> Void + + public init(text: Binding, + prompt: String, + clearLabel: String, + isSearching: Bool = false, + onSubmit: @escaping () -> Void = {}) { + self._text = text + self.prompt = prompt + self.clearLabel = clearLabel + self.isSearching = isSearching + self.onSubmit = onSubmit + } + + public var body: some View { + InputCluster { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField(prompt, text: $text) + .textFieldStyle(.plain) + .onSubmit(onSubmit) + if isSearching { + ProgressView().controlSize(.small) + } else if !text.isEmpty { + Button { text = "" } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(clearLabel) + .accessibilityLabel(clearLabel) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift new file mode 100644 index 00000000..bffbad16 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift @@ -0,0 +1,80 @@ +import SwiftUI + +/// A row of colored swatches for picking a `ThemeTint` — each shows its actual color, the selected +/// one gets a ring. +public extension UI.Control { + struct TintSelector: View { + private let selection: Binding + private let automaticLabel: String? + private let labelForTint: (ThemeTint) -> String + + public init(selection: Binding, + labelForTint: @escaping (ThemeTint) -> String) { + self.selection = Binding( + get: { selection.wrappedValue }, + set: { if let newValue = $0 { selection.wrappedValue = newValue } } + ) + self.automaticLabel = nil + self.labelForTint = labelForTint + } + + public init(optionalSelection: Binding, + automaticLabel: String, + labelForTint: @escaping (ThemeTint) -> String) { + self.selection = optionalSelection + self.automaticLabel = automaticLabel + self.labelForTint = labelForTint + } + + public var body: some View { + HStack(spacing: UI.Tokens.Space.s) { + if let automaticLabel { + Button { selection.wrappedValue = nil } label: { automaticSwatch } + .buttonStyle(.plain) + .help(automaticLabel) + .accessibilityLabel(automaticLabel) + .accessibilityAddTraits(selection.wrappedValue == nil ? .isSelected : []) + } + ForEach(ThemeTint.allCases) { tint in + let label = labelForTint(tint) + Button { selection.wrappedValue = tint } label: { swatch(tint) } + .buttonStyle(.plain) + .help(label) + .accessibilityLabel(label) + .accessibilityAddTraits(selection.wrappedValue == tint ? .isSelected : []) + } + } + } + + private var automaticSwatch: some View { + ZStack { + Circle().fill(Color.secondary.opacity(0.18)).frame(width: 22, height: 22) + Image(systemName: "rectangle.on.rectangle") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + Circle() + .strokeBorder(selection.wrappedValue == nil ? Color.primary : Color.secondary.opacity(0.35), + lineWidth: selection.wrappedValue == nil ? 2 : 1) + .frame(width: 24, height: 24) + } + .frame(width: 26, height: 26) + } + + private func swatch(_ tint: ThemeTint) -> some View { + ZStack { + Circle().fill(tint.color).frame(width: 22, height: 22) + // Mark the "follow the host accent" option so it reads as automatic, not a fixed color. + if tint.followsAccent { + Image(systemName: "link") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + } + Circle() + .strokeBorder(selection.wrappedValue == tint ? Color.primary : Color.secondary.opacity(0.35), + lineWidth: selection.wrappedValue == tint ? 2 : 1) + .frame(width: 24, height: 24) + } + .frame(width: 26, height: 26) + } + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/ToolbarControls.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/ToolbarControls.swift similarity index 80% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/ToolbarControls.swift rename to Packages/ContainedUI/Sources/ContainedUI/Control/ToolbarControls.swift index e632511f..324d2216 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Controls/ToolbarControls.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/ToolbarControls.swift @@ -1,10 +1,10 @@ import SwiftUI -/// Design-system controls for the app toolbar band, sized from `DesignTokens.Toolbar` to macOS 26 +/// UI package controls for the app toolbar band, sized from `UI.Tokens.Toolbar` to macOS 26 /// toolbar proportions. Centralizing them here keeps the toolbar, creation tiles -/// (`DesignOptionTile`), and future band controls visually consistent. +/// (`OptionTile`), and future band controls visually consistent. -public struct DesignMenuButton: View { +public struct MenuButton: View { @ViewBuilder public var menuContent: () -> MenuContent @ViewBuilder public var labelContent: () -> LabelContent @@ -18,7 +18,7 @@ public struct DesignMenuButton: View { Menu { menuContent() } label: { - GlassButton(singleItem: true) { + MaterialButton(singleItem: true) { labelContent() } } @@ -29,7 +29,7 @@ public struct DesignMenuButton: View { } } -public struct DesignToolbarSearchField: View { +public struct ToolbarSearchField: View { @Binding public var text: String public var prompt: String public var clearSearchLabel: String @@ -55,8 +55,8 @@ public struct DesignToolbarSearchField: View { } public var body: some View { - GlassButton(singleItem: true) { - GlassButtonInputItem { + MaterialButton(singleItem: true) { + MaterialButtonInputItem { Image(systemName: "magnifyingglass") .font(.body) .foregroundStyle(.secondary) @@ -85,12 +85,12 @@ public struct DesignToolbarSearchField: View { } /// Package-owned empty toolbar slot for stable morph origins and vanity chrome. -public struct DesignToolbarVanitySlot: View { +public struct ToolbarVanitySlot: View { public var minWidth: CGFloat public var interactive: Bool @ViewBuilder public var content: () -> Content - public init(minWidth: CGFloat = DesignTokens.Toolbar.trafficLightsWidth, + public init(minWidth: CGFloat = UI.Tokens.Toolbar.trafficLightsWidth, interactive: Bool = false, @ViewBuilder content: @escaping () -> Content = { Color.clear }) { self.minWidth = minWidth @@ -99,7 +99,7 @@ public struct DesignToolbarVanitySlot: View { } public var body: some View { - GlassButton(minWidth: minWidth, singleItem: true, interactive: interactive) { + MaterialButton(minWidth: minWidth, singleItem: true, interactive: interactive) { content() } .fixedSize(horizontal: true, vertical: false) @@ -107,7 +107,7 @@ public struct DesignToolbarVanitySlot: View { } /// Package-owned toolbar button for custom status content. -public struct DesignToolbarStatusButton: View { +public struct ToolbarStatusButton: View { public var help: String public var action: () -> Void @ViewBuilder public var content: () -> Content @@ -121,8 +121,8 @@ public struct DesignToolbarStatusButton: View { } public var body: some View { - GlassButton(singleItem: true) { - GlassButtonItem(help: help, action: action) { + MaterialButton(singleItem: true) { + MaterialButtonItem(help: help, action: action) { content() } } @@ -130,7 +130,7 @@ public struct DesignToolbarStatusButton: View { } /// Package-owned glass shell for toolbar clusters that mix action items and status/menu items. -public struct DesignToolbarActionCluster: View { +public struct ToolbarActionCluster: View { public var spacing: CGFloat @ViewBuilder public var content: () -> Content @@ -141,7 +141,7 @@ public struct DesignToolbarActionCluster: View { } public var body: some View { - GlassButton(spacing: spacing) { + MaterialButton(spacing: spacing) { content() } } @@ -149,7 +149,7 @@ public struct DesignToolbarActionCluster: View { /// A toolbar-styled menu trigger that uses the shared toolbar icon lane while keeping native menu /// behavior. -public struct DesignToolbarMenuButton: View { +public struct ToolbarMenuButton: View { public let systemName: String public var help: String @ViewBuilder public var content: () -> Content @@ -166,8 +166,8 @@ public struct DesignToolbarMenuButton: View { } label: { Image(systemName: systemName) .font(.body.weight(.medium)) - .padding(DesignTokens.Toolbar.iconInnerPadding) - .frame(height: DesignTokens.Toolbar.buttonItemHeight) + .padding(UI.Tokens.Toolbar.iconInnerPadding) + .frame(height: UI.Tokens.Toolbar.buttonItemHeight) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -180,7 +180,7 @@ public struct DesignToolbarMenuButton: View { /// Shared two-line toolbar label used by page switchers and filter menus. /// The second line is always secondary so status/filter copy stays visually subordinate. -public struct ToolbarTitleSubtitleLabel: View { +public struct ToolbarTitleSubtitle: View { public let symbol: String public let title: String public let subtitle: String @@ -194,11 +194,11 @@ public struct ToolbarTitleSubtitleLabel: View { } public var body: some View { - HStack(spacing: DesignTokens.Toolbar.searchIconGap) { + HStack(spacing: UI.Tokens.Toolbar.searchIconGap) { Image(systemName: symbol) .font(.body) .foregroundStyle(.secondary) - .frame(width: DesignTokens.Toolbar.buttonItemHeight - DesignTokens.Toolbar.iconInnerPadding * 2) + .frame(width: UI.Tokens.Toolbar.buttonItemHeight - UI.Tokens.Toolbar.iconInnerPadding * 2) VStack(alignment: .leading, spacing: 0) { Text(title) .font(.caption.weight(.semibold)) @@ -214,8 +214,8 @@ public struct ToolbarTitleSubtitleLabel: View { } } .lineLimit(1) - .padding(.trailing, DesignTokens.Toolbar.iconInnerPadding * 2) - .frame(height: DesignTokens.Toolbar.buttonGroupHeight) + .padding(.trailing, UI.Tokens.Toolbar.iconInnerPadding * 2) + .frame(height: UI.Tokens.Toolbar.buttonGroupHeight) .contentShape(Rectangle()) } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PageScaffold.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift similarity index 92% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PageScaffold.swift rename to Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift index 67eec5c6..5a448a9c 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PageScaffold.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift @@ -32,7 +32,7 @@ public struct PageScaffold: View { public var body: some View { VStack(spacing: 0) { if !usesToolbarChrome { - PanelHeader(symbol: symbol, title: title, subtitle: subtitle) { + PanelTitleBar(symbol: symbol, title: title, subtitle: subtitle) { actions() } Divider() @@ -42,7 +42,7 @@ public struct PageScaffold: View { LazyVStack(spacing: 0) { content() .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(usesToolbarChrome ? DesignTokens.Space.s : DesignTokens.Space.l) + .padding(usesToolbarChrome ? UI.Tokens.Space.s : UI.Tokens.Space.l) if usesToolbarChrome && bottomClearance > 0 { Color.clear .frame(height: bottomClearance) diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/DesignPanelScaffold.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelScaffold.swift similarity index 91% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/DesignPanelScaffold.swift rename to Packages/ContainedUI/Sources/ContainedUI/Panel/PanelScaffold.swift index ba931759..15e9472b 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/DesignPanelScaffold.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelScaffold.swift @@ -3,14 +3,14 @@ import SwiftUI /// A reusable panel body: fixed chrome above a scrollable content area and an optional pinned footer. /// /// The panel fills the fixed size its presentation host gives it (for example, -/// `DesignTokens.PanelSize.*`). The inner `ScrollView` fills that area and scrolls; it does not measure +/// `UI.Tokens.PanelSize.*`). The inner `ScrollView` fills that area and scrolls; it does not measure /// its content. This matters for performance: an earlier version measured the scroll content's natural /// height, which forced long lazy lists to realize on open. /// /// Pass `scrolls: false` for content that brings **its own** scroll view (search results, build /// workspace, the paged run form). In that mode the scaffold doesn't wrap the content in a `ScrollView`, /// so scroll views are not double-nested. -public struct DesignPanelScaffold: View { +public struct PanelScaffold: View { /// The expected host width. The scaffold still expands to the width assigned by its presentation host. public var width: CGFloat public var scrollEdgeStyle: ScrollEdgeEffectStyle = .soft @@ -58,7 +58,7 @@ public struct DesignPanelScaffold: Vi } } -public extension DesignPanelScaffold where Footer == EmptyView { +public extension PanelScaffold where Footer == EmptyView { init(width: CGFloat, scrollEdgeStyle: ScrollEdgeEffectStyle = .soft, scrolls: Bool = true, diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelSection.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSection.swift similarity index 83% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelSection.swift rename to Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSection.swift index 09c7f959..2f798200 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelSection.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSection.swift @@ -18,10 +18,10 @@ private extension EnvironmentValues { /// Supports two header affordances: `collapsible` (a chevron that folds the card away) and an `enabled` /// binding (a switch in the header that disables/hides the body — used for opt-in sections like the /// per-card customization blocks). -public struct PanelSection: View { +public struct PanelSectionView: View { public var header: String? = nil public var footer: String? = nil - public var rowSpacing: CGFloat = DesignTokens.Space.m + public var rowSpacing: CGFloat = UI.Tokens.Space.m public var collapsible: Bool = false /// Subtle blue treatment for sections containing explicit non-default values. public var highlighted: Bool = false @@ -40,7 +40,7 @@ public struct PanelSection: View { public init(header: String? = nil, footer: String? = nil, - rowSpacing: CGFloat = DesignTokens.Space.m, + rowSpacing: CGFloat = UI.Tokens.Space.m, collapsible: Bool = false, highlighted: Bool = false, enabled: Binding? = nil, @@ -55,27 +55,27 @@ public struct PanelSection: View { } public var body: some View { - VStack(alignment: .leading, spacing: DesignTokens.Space.s) { + VStack(alignment: .leading, spacing: UI.Tokens.Space.s) { if hasHeader { headerRow } if !bodyHidden { LazyVStack(alignment: .leading, spacing: rowSpacing) { content() } .environment(\.panelSectionHighlighted, highlighted) - .padding(DesignTokens.Space.m) + .padding(UI.Tokens.Space.m) .frame(maxWidth: .infinity, alignment: .leading) - .glassSurface(.regular, cornerRadius: DesignTokens.Radius.card, shadow: false) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card, shadow: false) .overlay { if highlighted { - RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous) + RoundedRectangle(cornerRadius: UI.Tokens.Radius.card, style: .continuous) .stroke(Color.accentColor.opacity(0.38), lineWidth: 1) } } if let footer { - // Markdown-aware so footers can use **bold** / `code`, like the old Form footers. + // Markdown-aware so footers can use **bold** and `code` without custom rows. Text(.init(footer)).font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - .padding(.leading, DesignTokens.Space.xs) + .padding(.leading, UI.Tokens.Space.xs) } } } @@ -84,7 +84,7 @@ public struct PanelSection: View { @ViewBuilder private var headerRow: some View { - HStack(spacing: DesignTokens.Space.s) { + HStack(spacing: UI.Tokens.Space.s) { if collapsible { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) @@ -92,7 +92,7 @@ public struct PanelSection: View { .rotationEffect(.degrees(collapsed ? 0 : 90)) } if let header { - HStack(spacing: DesignTokens.Space.xs) { + HStack(spacing: UI.Tokens.Space.xs) { if highlighted { Circle() .fill(Color.accentColor) @@ -103,12 +103,12 @@ public struct PanelSection: View { .foregroundStyle(highlighted ? Color.accentColor : Color.primary) } } - Spacer(minLength: DesignTokens.Space.s) + Spacer(minLength: UI.Tokens.Space.s) if let enabled { Toggle("", isOn: enabled).labelsHidden().toggleStyle(.switch) } } - .padding(.leading, DesignTokens.Space.xs) + .padding(.leading, UI.Tokens.Space.xs) .contentShape(Rectangle()) .onTapGesture { guard collapsible else { return } @@ -119,7 +119,7 @@ public struct PanelSection: View { /// A single settings row: a leading title (+ optional subtitle), optional info next to that title, /// and a trailing control. Set `error` to tint the title red and show a red caption beneath. -public struct PanelRow: View { +public struct PanelRowView: View { public var title: String public var subtitle: String? = nil public var info: String? = nil @@ -148,18 +148,18 @@ public struct PanelRow: View { public var body: some View { VStack(alignment: .leading, spacing: 2) { - HStack(spacing: DesignTokens.Space.m) { + HStack(spacing: UI.Tokens.Space.m) { VStack(alignment: .leading, spacing: 1) { - HStack(spacing: DesignTokens.Space.xs) { + HStack(spacing: UI.Tokens.Space.xs) { Text(title).foregroundStyle(labelColor) - if let info { InfoButton(info, visible: labelHovering) } + if let info { UI.Control.InfoButton(info, visible: labelHovering) } } if let subtitle { Text(subtitle).font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } - Spacer(minLength: DesignTokens.Space.m) + Spacer(minLength: UI.Tokens.Space.m) trailing() } .contentShape(Rectangle()) @@ -172,14 +172,14 @@ public struct PanelRow: View { } } -public extension PanelRow where Trailing == EmptyView { +public extension PanelRowView where Trailing == EmptyView { init(title: String, subtitle: String? = nil, info: String? = nil, error: String? = nil) { self.init(title: title, subtitle: subtitle, info: info, error: error) { EmptyView() } } } /// A switch row — the common Toggle case, rendered label-left / switch-right like a grouped Form. -public struct PanelToggleRow: View { +public struct PanelToggleRowView: View { public var title: String public var subtitle: String? = nil public var info: String? = nil @@ -199,17 +199,17 @@ public struct PanelToggleRow: View { } public var body: some View { - PanelRow(title: title, subtitle: subtitle, info: info, error: error) { + PanelRowView(title: title, subtitle: subtitle, info: info, error: error) { Toggle("", isOn: $isOn).labelsHidden().toggleStyle(.switch) } } } /// A labeled form field: a leading label, optional info next to that label, and an expanding control. -/// The form-row counterpart to `PanelRow` (which hugs its trailing +/// The form-row counterpart to `PanelRowView` (which hugs its trailing /// control); here the control fills the remaining width like a grouped Form field. `error` tints the /// label red and shows a red caption beneath. -public struct PanelField: View { +public struct PanelFieldView: View { public var label: String public var info: String? = nil public var error: String? = nil @@ -238,11 +238,11 @@ public struct PanelField: View { public var body: some View { VStack(alignment: .leading, spacing: 2) { - HStack(spacing: DesignTokens.Space.m) { - HStack(spacing: DesignTokens.Space.xs) { + HStack(spacing: UI.Tokens.Space.m) { + HStack(spacing: UI.Tokens.Space.xs) { Text(label) .foregroundStyle(labelColor) - if let info { InfoButton(info, visible: labelHovering) } + if let info { UI.Control.InfoButton(info, visible: labelHovering) } } .frame(width: labelWidth, alignment: .leading) control().frame(maxWidth: .infinity) @@ -251,7 +251,7 @@ public struct PanelField: View { .onHover { labelHovering = $0 } if let error { Text(error).font(.caption).foregroundStyle(.red) - .padding(.leading, labelWidth + DesignTokens.Space.m) + .padding(.leading, labelWidth + UI.Tokens.Space.m) } } } diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/SheetHeader.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSheetTitleBar.swift similarity index 85% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/SheetHeader.swift rename to Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSheetTitleBar.swift index 68cd8379..5b776720 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/SheetHeader.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSheetTitleBar.swift @@ -4,7 +4,7 @@ import SwiftUI /// button, and a trailing slot for confirm/primary actions or a progress spinner. Replaces the /// hand-rolled header `HStack` + `GlassCircleButton` chain repeated across every sheet, so spacing, /// padding, and the cancel affordance stay consistent. -public struct SheetHeader: View { +public struct PanelSheetTitleBar: View { public let title: String public var subtitle: String? = nil public var cancelIcon: String = "xmark" @@ -35,16 +35,16 @@ public struct SheetHeader: View { } } Spacer() - GlassButton(singleItem: true) { - GlassButtonItem(systemName: cancelIcon, help: cancelHelp, isCancel: true, action: onCancel) + MaterialButton(singleItem: true) { + MaterialButtonItem(systemName: cancelIcon, help: cancelHelp, isCancel: true, action: onCancel) } trailing() } - .padding(DesignTokens.Space.l) + .padding(UI.Tokens.Space.l) } } -public extension SheetHeader where Trailing == EmptyView { +public extension PanelSheetTitleBar where Trailing == EmptyView { /// Header with only a cancel/close button (no primary action). init(title: String, subtitle: String? = nil, cancelIcon: String = "xmark", cancelHelp: String, onCancel: @escaping () -> Void) { diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelHeader.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelTitleBar.swift similarity index 66% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelHeader.swift rename to Packages/ContainedUI/Sources/ContainedUI/Panel/PanelTitleBar.swift index c393e5bc..214f08b7 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Panels/PanelHeader.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelTitleBar.swift @@ -1,18 +1,18 @@ import SwiftUI /// Standard in-window panel header for toolbar morphs and embedded panels. -public struct PanelHeader: View { +public struct PanelTitleBar: View { public let symbol: String public let title: String public var subtitle: String? - public var padding: CGFloat = DesignTokens.Space.s + public var padding: CGFloat = UI.Tokens.Space.s public var leadingReserve: CGFloat = 0 @ViewBuilder var trailing: () -> Trailing public init(symbol: String, title: String, subtitle: String? = nil, - padding: CGFloat = DesignTokens.Space.s, + padding: CGFloat = UI.Tokens.Space.s, leadingReserve: CGFloat = 0, @ViewBuilder trailing: @escaping () -> Trailing) { self.symbol = symbol @@ -24,14 +24,14 @@ public struct PanelHeader: View { } public var body: some View { - HStack(alignment: .center, spacing: DesignTokens.Space.s) { + HStack(alignment: .center, spacing: UI.Tokens.Space.s) { if leadingReserve > 0 { Color.clear - .frame(width: leadingReserve, height: DesignTokens.Toolbar.buttonGroupHeight) + .frame(width: leadingReserve, height: UI.Tokens.Toolbar.buttonGroupHeight) } - GlassButtonItem(systemName: symbol, help: title, isLabel: true) - .frame(width: DesignTokens.Toolbar.buttonGroupHeight, - height: DesignTokens.Toolbar.buttonGroupHeight, + MaterialButtonItem(systemName: symbol, help: title, isLabel: true) + .frame(width: UI.Tokens.Toolbar.buttonGroupHeight, + height: UI.Tokens.Toolbar.buttonGroupHeight, alignment: .center) VStack(alignment: .leading, spacing: 1) { Text(title) @@ -45,11 +45,11 @@ public struct PanelHeader: View { } } .frame(maxWidth: .infinity, - minHeight: DesignTokens.Toolbar.buttonGroupHeight, + minHeight: UI.Tokens.Toolbar.buttonGroupHeight, alignment: .leading) trailing() } - .frame(minHeight: DesignTokens.Toolbar.buttonGroupHeight) + .frame(minHeight: UI.Tokens.Toolbar.buttonGroupHeight) .padding(padding) } } diff --git a/Packages/ContainedUI/Sources/ContainedUI/Previews/UIPreviews.swift b/Packages/ContainedUI/Sources/ContainedUI/Previews/UIPreviews.swift new file mode 100644 index 00000000..3cb26395 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Previews/UIPreviews.swift @@ -0,0 +1,89 @@ +#if CONTAINED_UI_PREVIEWS +import SwiftUI + +#Preview("Card") { + CardPreview() + .padding(UI.Tokens.Space.xl) + .frame(width: 420) + .environment(\.cardMaterial, .glassRegular) + .environment(\.buttonMaterial, .glassClear) +} + +#Preview("Controls") { + VStack(alignment: .leading, spacing: UI.Tokens.Space.l) { + UI.Panel.Section(header: "Controls") { + UI.Panel.Row(title: "Tint") { + UI.Control.TintSelector(selection: .constant(.azure)) { tint in + tint.rawValue.capitalized + } + } + UI.Panel.Row(title: "Actions") { + UI.Action.Group([ + UI.Action.Item(systemName: "play.fill", help: "Start") {}, + UI.Action.Item(systemName: "stop.fill", help: "Stop", role: .destructive) {} + ]) + } + } + + UI.Command.PreviewBar(command: ["container", "run", "--name", "preview-web", "nginx"], + copyHelp: "Copy command", + copiedAccessibilityLabel: "Copied") + } + .padding(UI.Tokens.Space.xl) + .frame(width: 520) + .environment(\.buttonMaterial, .glassClear) +} + +private struct CardPreview: View { + @State private var page = "overview" + + private let pages = [ + UI.Card.Page(id: "overview", title: "Overview", systemImage: "rectangle.grid.1x2"), + UI.Card.Page(id: "stats", title: "Stats", systemImage: "chart.xyaxis.line"), + ] + + var body: some View { + UI.Card.Scaffold(size: .large, + isExpanded: true, + title: "preview-web", + subtitle: "docker.io/library/nginx:latest", + pages: UI.Card.Pages(items: pages, + selection: page, + tint: .accentColor, + closeLabel: "Close", + onSelect: { page = $0 }, + onClose: {})) { + UI.Card.IconChip(symbol: "shippingbox.fill", tint: .accentColor) + } titleAccessory: { + UI.Badge.Text(text: "Running") + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + UI.Card.InsetSection(title: "Live") { + UI.Chart.Sparkline(samples: [0.1, 0.2, 0.18, 0.4, 0.34, 0.55], + color: .accentColor, + scale: .fraction) + .frame(height: UI.Tokens.Card.sparklineHeight) + } + } footerLeading: { + UI.Card.FooterChip(isSelected: true, tint: .accentColor, help: "CPU", action: {}) { + Image(systemName: "cpu") + } text: { + UI.Card.MetricText(text: "42%") + } + } footerActions: { + UI.Card.FooterButton(systemName: "play.fill", help: "Start", tint: .accentColor) {} + } widget: { + UI.Card.WidgetGroup { + UI.Card.FooterMini { + Image(systemName: "memorychip") + } text: { + UI.Card.MetricText(text: "420 MB") + } + } + } + } +} +#endif diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ActivityStatusView.swift b/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift similarity index 83% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ActivityStatusView.swift rename to Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift index aa24b09d..b462ecef 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ActivityStatusView.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift @@ -1,6 +1,6 @@ import SwiftUI -public struct ActivityStatusPresentation: Equatable, Sendable { +public struct ActivityStatus: Equatable, Sendable { public var title: String public var detail: String public var fraction: Double? @@ -19,13 +19,13 @@ public struct ActivityStatusPresentation: Equatable, Sendable { /// /// `.inline` is the compact one-line form sized for a toolbar capsule; `.expanded` is the taller card /// with a linear progress bar and the streaming detail line. -public struct ActivityStatusView: View { +public struct ActivityStatusIndicator: View { public enum Style { case inline, expanded } - public let activity: ActivityStatusPresentation + public let activity: ActivityStatus public var style: Style = .inline - public init(activity: ActivityStatusPresentation, style: Style = .inline) { + public init(activity: ActivityStatus, style: Style = .inline) { self.activity = activity self.style = style } @@ -38,11 +38,11 @@ public struct ActivityStatusView: View { } private var inlineBody: some View { - HStack(spacing: DesignTokens.Toolbar.searchIconGap) { + HStack(spacing: UI.Tokens.Toolbar.searchIconGap) { ProgressView() .controlSize(.small) .scaleEffect(0.8) - .frame(width: DesignTokens.Toolbar.buttonItemHeight - DesignTokens.Toolbar.iconInnerPadding * 2) + .frame(width: UI.Tokens.Toolbar.buttonItemHeight - UI.Tokens.Toolbar.iconInnerPadding * 2) Text(activity.title) .lineLimit(1) .truncationMode(.tail) @@ -54,12 +54,12 @@ public struct ActivityStatusView: View { .monospacedDigit() } } - .padding(.trailing, DesignTokens.Toolbar.iconInnerPadding * 2) + .padding(.trailing, UI.Tokens.Toolbar.iconInnerPadding * 2) } private var expandedBody: some View { VStack(alignment: .leading, spacing: 6) { - HStack(spacing: DesignTokens.Space.s) { + HStack(spacing: UI.Tokens.Space.s) { ProgressView().controlSize(.small) Text(activity.title).font(.callout.weight(.medium)) Spacer(minLength: 0) diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift b/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift new file mode 100644 index 00000000..ef8eb44b --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift @@ -0,0 +1,40 @@ +import SwiftUI + +public struct StatusDot: View { + public var color: Color + public var size: CGFloat + + public init(color: Color, size: CGFloat = UI.Tokens.IconSize.statusDot) { + self.color = color + self.size = size + } + + public var body: some View { + Circle() + .fill(color) + .frame(width: size, height: size) + } +} + +public struct StatusBadge: View { + public var text: String + public var tint: Color + public var font: Font + + public init(text: String, + tint: Color, + font: Font = .caption.weight(.medium)) { + self.text = text + self.tint = tint + self.font = font + } + + public var body: some View { + Text(text) + .font(font) + .foregroundStyle(tint) + .padding(.horizontal, UI.Tokens.Badge.horizontalPadding) + .padding(.vertical, UI.Tokens.Badge.verticalPadding) + .background(tint.opacity(UI.Tokens.Badge.statusOpacity), in: Capsule()) + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift b/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift new file mode 100644 index 00000000..56430b1e --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift @@ -0,0 +1,335 @@ +import SwiftUI + +public enum StateTone { + case primary + case neutral + case tertiary + case info + case accent + case warning + case error + case success + + public var color: Color { + switch self { + case .primary: return .primary + case .neutral: return .secondary + case .tertiary: return Color.secondary.opacity(0.65) + case .info: return .blue + case .accent: return .accentColor + case .warning: return .orange + case .error: return .red + case .success: return .green + } + } +} + +public enum SymbolSize { + case caption2 + case caption + case callout + case body + case title3 + case title + case state + + var font: Font { + switch self { + case .caption2: return .caption2 + case .caption: return .caption + case .callout: return .callout + case .body: return .body + case .title3: return .title3 + case .title: return .title2 + case .state: return .largeTitle + } + } +} + +public struct SymbolView: View { + public var systemName: String + public var tone: StateTone + public var tint: Color? + public var size: SymbolSize + public var frameWidth: CGFloat? + + public init(systemName: String, + tone: StateTone = .neutral, + tint: Color? = nil, + size: SymbolSize = .callout, + frameWidth: CGFloat? = nil) { + self.systemName = systemName + self.tone = tone + self.tint = tint + self.size = size + self.frameWidth = frameWidth + } + + public var body: some View { + Image(systemName: systemName) + .font(size.font) + .foregroundStyle(tint ?? tone.color) + .frame(width: frameWidth) + } +} + +public struct StatusText: View { + public var text: String + public var tone: StateTone + public var style: Font + + public init(_ text: String, + tone: StateTone = .neutral, + style: Font = .callout) { + self.text = text + self.tone = tone + self.style = style + } + + public var body: some View { + Text(text) + .font(style) + .foregroundStyle(tone.color) + } +} + +public struct EmptyState: View { + public var title: String + public var systemImage: String + public var description: String? + public var tone: StateTone + public var minHeight: CGFloat? + public var padding: CGFloat + + public init(_ title: String, + systemImage: String, + description: String? = nil, + tone: StateTone = .neutral, + minHeight: CGFloat? = nil, + padding: CGFloat = UI.Tokens.Space.xl) { + self.title = title + self.systemImage = systemImage + self.description = description + self.tone = tone + self.minHeight = minHeight + self.padding = padding + } + + public var body: some View { + VStack(spacing: UI.Tokens.Space.s) { + Image(systemName: systemImage) + .font(.title2) + .foregroundStyle(tone.color) + Text(title) + .font(.callout.weight(.medium)) + if let description { + Text(description) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, minHeight: minHeight) + .padding(padding) + } +} + +public struct HeroState: View { + public var systemImage: String + public var title: String + public var message: String + @ViewBuilder public var actions: () -> Actions + + public init(systemImage: String, + title: String, + message: String, + @ViewBuilder actions: @escaping () -> Actions) { + self.systemImage = systemImage + self.title = title + self.message = message + self.actions = actions + } + + public var body: some View { + VStack(spacing: UI.Tokens.Space.l) { + Image(systemName: systemImage) + .font(.system(size: UI.Tokens.IconSize.appIcon - UI.Tokens.Space.xs)) + .foregroundStyle(.tint) + Text(title).font(.title2.weight(.semibold)) + Text(message) + .designSecondaryCallout() + .multilineTextAlignment(.center) + .frame(maxWidth: 420) + actions() + } + .padding(UI.Tokens.Space.xxl) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +public struct LoadingState: View { + public var title: String + public var minHeight: CGFloat? + public var padding: CGFloat + + public init(_ title: String, + minHeight: CGFloat? = nil, + padding: CGFloat = UI.Tokens.Space.xl) { + self.title = title + self.minHeight = minHeight + self.padding = padding + } + + public var body: some View { + VStack(spacing: UI.Tokens.Space.s) { + ProgressView() + Text(title) + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minHeight: minHeight) + .padding(padding) + } +} + +public struct ProgressIndicator: View { + public var controlSize: ControlSize + public var frameSize: CGFloat? + + public init(controlSize: ControlSize = .small, frameSize: CGFloat? = nil) { + self.controlSize = controlSize + self.frameSize = frameSize + } + + public var body: some View { + ProgressView() + .controlSize(controlSize) + .frame(width: frameSize, height: frameSize) + } +} + +public struct SectionLabel: View { + public var title: String + + public init(_ title: String) { + self.title = title + } + + public var body: some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } +} + +public struct InlineStatus: View { + public var title: String + public var systemImage: String? + public var isWorking: Bool + public var tone: StateTone + + public init(_ title: String, + systemImage: String? = nil, + isWorking: Bool = false, + tone: StateTone = .neutral) { + self.title = title + self.systemImage = systemImage + self.isWorking = isWorking + self.tone = tone + } + + public var body: some View { + HStack(spacing: UI.Tokens.Toolbar.searchIconGap) { + if isWorking { + ProgressView().controlSize(.small) + } else if let systemImage { + Image(systemName: systemImage) + .foregroundStyle(tone.color) + } + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +public extension View { + func designSectionLabelStyle() -> some View { + font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + func designSubheadlineLabelStyle() -> some View { + font(.subheadline.weight(.semibold)) + } + + func designHeadlineLabelStyle() -> some View { + font(.headline) + } + + func designSearchTextStyle() -> some View { + font(.body.weight(.medium)) + } + + func designTitleLabelStyle() -> some View { + font(.title3.weight(.semibold)) + } + + func designSecondaryValueStyle() -> some View { + foregroundStyle(.secondary) + } + + func designMonospacedCaption() -> some View { + font(.system(.caption, design: .monospaced)) + } + + func designSecondaryMonospacedCaption() -> some View { + font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + func designSecondaryMonospacedDigitCaption() -> some View { + font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + + func designSecondaryMonospacedDigitHeadline() -> some View { + font(.headline.monospacedDigit()) + .foregroundStyle(.secondary) + } + + func designMonospacedCallout() -> some View { + font(.system(.callout, design: .monospaced)) + } + + func designSecondaryCaption() -> some View { + font(.caption) + .foregroundStyle(.secondary) + } + + func designSecondaryCallout() -> some View { + font(.callout) + .foregroundStyle(.secondary) + } + + func designTertiaryCaption() -> some View { + font(.caption) + .foregroundStyle(.tertiary) + } + + func designTertiaryCaption2() -> some View { + font(.caption2) + .foregroundStyle(.tertiary) + } + + func designStatusStyle(_ tone: StateTone) -> some View { + foregroundStyle(tone.color) + } + + func designStateIconStyle() -> some View { + font(.largeTitle) + .foregroundStyle(.secondary) + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ErrorToast.swift b/Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift similarity index 56% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ErrorToast.swift rename to Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift index a71ff03c..3aaaa3d9 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Feedback/ErrorToast.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift @@ -1,8 +1,8 @@ import SwiftUI /// A transient error banner: a warning glyph + message on a glass surface, sliding up from the bottom. -/// Used as a bottom overlay to surface a store's `errorMessage` without a blocking alert. -public struct ErrorToast: View { +/// Used as a bottom overlay to surface caller-supplied error copy without a blocking alert. +public struct ErrorBannerView: View { public let message: String public init(message: String) { @@ -10,14 +10,14 @@ public struct ErrorToast: View { } public var body: some View { - HStack(spacing: DesignTokens.Space.s) { + HStack(spacing: UI.Tokens.Space.s) { Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red) Text(message).font(.callout).lineLimit(2) } - .padding(.horizontal, DesignTokens.Space.l) - .padding(.vertical, DesignTokens.Space.m) - .glassSurface(.regular, cornerRadius: DesignTokens.Radius.control) - .padding(DesignTokens.Space.l) + .padding(.horizontal, UI.Tokens.Space.l) + .padding(.vertical, UI.Tokens.Space.m) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.control) + .padding(UI.Tokens.Space.l) .transition(.move(edge: .bottom).combined(with: .opacity)) } } diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift b/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift new file mode 100644 index 00000000..b1a7b4da --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift @@ -0,0 +1,18 @@ +import SwiftUI + +/// Package-owned transient banner chrome. +public struct StatusBanner: View { + public var text: String + + public init(_ text: String) { + self.text = text + } + + public var body: some View { + Text(text) + .font(.callout.weight(.medium)) + .padding(.horizontal, UI.Tokens.Space.l) + .padding(.vertical, UI.Tokens.Space.s) + .materialCapsuleSurface(shadow: false) + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Support/Clipboard.swift b/Packages/ContainedUI/Sources/ContainedUI/Support/Clipboard.swift similarity index 100% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Support/Clipboard.swift rename to Packages/ContainedUI/Sources/ContainedUI/Support/Clipboard.swift diff --git a/Packages/ContainedUI/Sources/ContainedUI/Support/NestedAPI.swift b/Packages/ContainedUI/Sources/ContainedUI/Support/NestedAPI.swift new file mode 100644 index 00000000..e35c0c89 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Support/NestedAPI.swift @@ -0,0 +1,141 @@ +import SwiftUI + +public extension UI.Card { + typealias Scaffold = CardScaffold + typealias Size = CardSize + typealias Density = CardDensity + typealias SizePicker = CardSizePicker + typealias TextStyle = CardTextStyle + typealias NoPage = CardNoPage + typealias Pages = CardPages + typealias Page = CardPage + typealias InsetSection = CardInsetSection + typealias FooterChip = CardFooterChip + typealias FooterButton = CardFooterButton + typealias FooterGroup = CardFooterGroup + typealias FooterMini = CardFooterMini + typealias WidgetGroup = CardWidgetGroup + typealias IconChip = CardIconChip + typealias MetricText = CardMetricText + typealias TitleText = CardTitleText + typealias SubtitleText = CardSubtitleText + typealias MonospacedTitleText = CardMonospacedTitleText + typealias MonospacedSubtitleText = CardMonospacedSubtitleText +} + +public extension UI.Panel { + typealias Scaffold = PanelScaffold + typealias PageScaffold = ContainedUI.PageScaffold + typealias Header = PanelTitleBar + typealias SheetTitleBar = PanelSheetTitleBar + typealias Section = PanelSectionView + typealias Row = PanelRowView + typealias ToggleRow = PanelToggleRowView + typealias Field = PanelFieldView +} + +public extension UI.Action { + typealias Item = ActionItem + typealias Group = ActionGroup + typealias Cluster = ActionCluster + typealias Items = ActionItems + typealias MenuLabel = ActionMenuLabel + typealias ProgressCapsule = ActionProgressCapsule + typealias TextButton = ActionTextButton + typealias TextProminence = ActionTextProminence + typealias ToggleButton = ActionToggleButton + typealias SelectionBar = ActionSelectionBar +} + +public extension UI.Control { + typealias InputCluster = ContainedUI.InputCluster + typealias OptionStack = ContainedUI.OptionStack + typealias OptionTile = ContainedUI.OptionTile + typealias RowMenu = ContainedUI.RowMenu + typealias SearchField = ContainedUI.SearchField + typealias MenuButton = ContainedUI.MenuButton + typealias GradientAngle = GradientAngleControl + typealias TintSwatch = ContainedUI.TintSwatch +} + +public extension UI.Toolbar { + typealias SearchField = ToolbarSearchField + typealias VanitySlot = ToolbarVanitySlot + typealias StatusButton = ToolbarStatusButton + typealias ActionCluster = ToolbarActionCluster + typealias MenuButton = ToolbarMenuButton + typealias TitleSubtitle = ToolbarTitleSubtitle +} + +public extension UI.Command { + typealias Preview = PreviewBar +} + +public extension UI.Chart { + typealias Style = ChartStyle + typealias GraphStyle = ContainedUI.GraphStyle + typealias Interpolation = WidgetInterpolation + typealias Scale = SparklineScale + typealias Sparkline = SparklineView + typealias MetricTile = SparklineMetricTile + typealias SampleBuffer = ContainedUI.SampleBuffer +} + +public extension UI.Surface { + typealias Content = ContentSurface + typealias Input = InputSurface +} + +public extension UI.State { + typealias Tone = StateTone + typealias StatusText = ContainedUI.StatusText + typealias Empty = EmptyState + typealias Hero = HeroState + typealias Loading = LoadingState + typealias ProgressIndicator = ContainedUI.ProgressIndicator + typealias SectionLabel = ContainedUI.SectionLabel + typealias InlineStatus = ContainedUI.InlineStatus + typealias ErrorBanner = ErrorBannerView + typealias Banner = StatusBanner + typealias ActivityStatus = ContainedUI.ActivityStatus + typealias ActivityStatusIndicator = ContainedUI.ActivityStatusIndicator +} + +public extension UI.Symbol { + typealias Size = SymbolSize + typealias Image = SymbolView +} + +public extension UI.List { + typealias Stack = ListStack + typealias Section = ListSection + typealias Row = ListRow + typealias RowChevron = ListRowChevron + typealias MetadataRow = ContainedUI.MetadataRow + typealias MetadataBadgeRow = ContainedUI.MetadataBadgeRow + typealias KeyValueRow = ContainedUI.KeyValueRow + typealias CompactInfoRow = ContainedUI.CompactInfoRow +} + +public extension UI.Badge { + typealias Text = BadgeText + typealias Status = StatusBadge + typealias Dot = StatusDot + typealias ScopeLabel = ScopeChipLabel +} + +public extension UI.Theme { + typealias Tint = ThemeTint + typealias Material = ThemeMaterial + typealias WindowMaterial = ThemeWindowMaterial + typealias Appearance = ThemeAppearanceMode + typealias ColorBlendMode = ThemeColorBlendMode + typealias ButtonTintStyle = ContainedUI.ButtonTintStyle + typealias BackgroundLayer = ContentBackgroundLayer +} + +public extension UI.Control { + typealias KeyCap = ContainedUI.KeyCap + typealias KeyboardHint = ContainedUI.KeyboardHint + typealias MetricTile = ContainedUI.MetricTile +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift new file mode 100644 index 00000000..9cfeb407 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// A package-owned content surface for empty states and grouped panel content. +public struct ContentSurface: View { + public var elevated: Bool + public var minHeight: CGFloat? + public var alignment: Alignment + public var padding: CGFloat + @ViewBuilder public var content: () -> Content + + public init(elevated: Bool = false, + minHeight: CGFloat? = nil, + alignment: Alignment = .center, + padding: CGFloat = UI.Tokens.Space.s, + @ViewBuilder content: @escaping () -> Content) { + self.elevated = elevated + self.minHeight = minHeight + self.alignment = alignment + self.padding = padding + self.content = content + } + + public var body: some View { + content() + .padding(padding) + .frame(maxWidth: .infinity, minHeight: minHeight, alignment: alignment) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card, shadow: elevated) + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/ExteriorShadow.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/ExteriorShadow.swift similarity index 100% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/ExteriorShadow.swift rename to Packages/ContainedUI/Sources/ContainedUI/Surface/ExteriorShadow.swift diff --git a/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift new file mode 100644 index 00000000..bf94b644 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// A package-owned surface for compact inline controls such as search fields and text editors. +public struct InputSurface: View { + public var horizontalPadding: CGFloat + public var verticalPadding: CGFloat + public var minHeight: CGFloat? + @ViewBuilder public var content: () -> Content + + public init(horizontalPadding: CGFloat = UI.Tokens.Space.m, + verticalPadding: CGFloat = UI.Tokens.Space.s, + minHeight: CGFloat? = nil, + @ViewBuilder content: @escaping () -> Content) { + self.horizontalPadding = horizontalPadding + self.verticalPadding = verticalPadding + self.minHeight = minHeight + self.content = content + } + + public var body: some View { + content() + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .leading) + .materialSurface(.thin, cornerRadius: UI.Tokens.Radius.control) + } +} diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/GlassSurface.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/MaterialSurface.swift similarity index 91% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/GlassSurface.swift rename to Packages/ContainedUI/Sources/ContainedUI/Surface/MaterialSurface.swift index b469c3b5..f0db76fe 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/GlassSurface.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/MaterialSurface.swift @@ -2,7 +2,7 @@ import SwiftUI /// Centralized Liquid Glass surface: real `.glassEffect()` plus a soft shadow that lifts the /// element off the backdrop, and an optional colored (optionally gradient) wash behind the glass. -struct GlassSurface: ViewModifier { +struct MaterialSurface: ViewModifier { enum Level { case regular, thin, ultraThin } var level: Level var cornerRadius: CGFloat @@ -18,7 +18,7 @@ struct GlassSurface: ViewModifier { @Environment(\.colorScheme) private var colorScheme init(level: Level = .regular, - cornerRadius: CGFloat = DesignTokens.Radius.card, + cornerRadius: CGFloat = UI.Tokens.Radius.card, glass: Glass = .regular, shadow: Bool = true, fill: Color? = nil, @@ -94,8 +94,8 @@ struct GlassSurface: ViewModifier { /// Capsule variant for transient bars and compact floating controls that need the same glass rules /// without pretending they are rounded cards. -struct GlassCapsuleSurface: ViewModifier { - var level: GlassSurface.Level +struct MaterialCapsuleSurface: ViewModifier { + var level: MaterialSurface.Level var glass: Glass var shadow: Bool var fill: Color? @@ -103,7 +103,7 @@ struct GlassCapsuleSurface: ViewModifier { @Environment(\.colorScheme) private var colorScheme - init(level: GlassSurface.Level = .regular, + init(level: MaterialSurface.Level = .regular, glass: Glass = .regular, shadow: Bool = true, fill: Color? = nil, @@ -149,25 +149,25 @@ struct GlassCapsuleSurface: ViewModifier { } extension View { - func glassSurface(_ level: GlassSurface.Level = .regular, - cornerRadius: CGFloat = DesignTokens.Radius.card, + func materialSurface(_ level: MaterialSurface.Level = .regular, + cornerRadius: CGFloat = UI.Tokens.Radius.card, glass: Glass = .regular, shadow: Bool = true, fill: Color? = nil, fillOpacity: Double = 0.18, gradient: Bool = false, gradientAngle: Double = 135) -> some View { - modifier(GlassSurface(level: level, cornerRadius: cornerRadius, glass: glass, + modifier(MaterialSurface(level: level, cornerRadius: cornerRadius, glass: glass, shadow: shadow, fill: fill, fillOpacity: fillOpacity, gradient: gradient, gradientAngle: gradientAngle)) } - func glassCapsuleSurface(_ level: GlassSurface.Level = .regular, + func materialCapsuleSurface(_ level: MaterialSurface.Level = .regular, glass: Glass = .regular, shadow: Bool = true, fill: Color? = nil, fillOpacity: Double = 0.18) -> some View { - modifier(GlassCapsuleSurface(level: level, + modifier(MaterialCapsuleSurface(level: level, glass: glass, shadow: shadow, fill: fill, diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift similarity index 96% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift rename to Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift index e438c04f..f8e8338b 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Surfaces/VisualEffectBackground.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift @@ -30,7 +30,7 @@ struct VisualEffectBackground: NSViewRepresentable { /// Stable root-owned backing for the detail column. Pages render above this layer instead of /// applying their own window material. Translucency is always on — legibility under low-contrast /// wallpapers is left to the OS "Reduce transparency" accessibility setting. -public struct DesignContentBackgroundLayer: View { +public struct ContentBackgroundLayer: View { public var material: NSVisualEffectView.Material public init(material: NSVisualEffectView.Material = .fullScreenUI) { diff --git a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme/Theme.swift b/Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift similarity index 87% rename from Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme/Theme.swift rename to Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift index 801e8db2..bcda55c2 100644 --- a/Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/Theme/Theme.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift @@ -3,7 +3,7 @@ import AppKit /// Material/elevation constants for reusable design surfaces. Keep glass, shadow, and stroke choices /// here so collapsed controls and expanded panels do not drift into near-duplicates. -public enum DesignMaterial { +public enum ThemeMaterial { public static let toolbarHoverFill = Color.white.opacity(0.1) public static func toolbarInteractiveHoverFill(for colorScheme: ColorScheme) -> Color { Color.white.opacity(colorScheme == .light ? 0.2 : 0.1) @@ -17,7 +17,7 @@ public enum DesignMaterial { /// A curated color, used consistently for host accent choices and per-surface personalization. /// `.multicolor` follows `Color.accentColor`, so package callers can decide whether that means a /// global accent, a scoped accent, or the platform default. -public enum DesignTint: String, CaseIterable, Identifiable, Codable, Sendable { +public enum ThemeTint: String, CaseIterable, Identifiable, Codable, Sendable { case multicolor, graphite, azure, teal, coral, indigo, green, amber, pink public var id: String { rawValue } @@ -39,14 +39,9 @@ public enum DesignTint: String, CaseIterable, Identifiable, Codable, Sendable { } } - /// Parse a legacy `contained.tint` label value, falling back to multicolor. - public static func parse(_ raw: String?) -> DesignTint { - guard let raw, let tint = DesignTint(rawValue: raw.lowercased()) else { return .multicolor } - return tint - } } -public enum ColorLayerBlendMode: String, CaseIterable, Identifiable, Codable, Sendable { +public enum ThemeColorBlendMode: String, CaseIterable, Identifiable, Codable, Sendable { case normal, softLight, overlay, multiply, screen public var id: String { rawValue } @@ -62,7 +57,7 @@ public enum ColorLayerBlendMode: String, CaseIterable, Identifiable, Codable, Se } } -public enum AppearanceMode: String, CaseIterable, Identifiable, Codable, Sendable { +public enum ThemeAppearanceMode: String, CaseIterable, Identifiable, Codable, Sendable { case system, light, dark public var id: String { rawValue } public var colorScheme: ColorScheme? { @@ -88,7 +83,7 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Codable, Sendabl public enum CardDensity: String, CaseIterable, Identifiable, Codable, Sendable { case small, medium, large public var id: String { rawValue } - public var resourceSize: DesignCardSize { + public var resourceSize: CardSize { switch self { case .small: return .small case .medium: return .medium @@ -107,7 +102,7 @@ public enum CardDensity: String, CaseIterable, Identifiable, Codable, Sendable { /// The behind-window vibrancy material used for the main content area. A curated, ordered subset of /// `NSVisualEffectView.Material` (lightest → most opaque) so the picker reads sensibly. -public enum WindowMaterial: String, CaseIterable, Identifiable, Codable, Sendable { +public enum ThemeWindowMaterial: String, CaseIterable, Identifiable, Codable, Sendable { // Liquid Glass options (rendered with `.glassEffect`, not an `NSVisualEffectView`). case glassClear, glassRegular // System vibrancy materials. @@ -154,13 +149,13 @@ public enum WindowMaterial: String, CaseIterable, Identifiable, Codable, Sendabl public extension EnvironmentValues { /// The user-chosen modal material, seeded at the app root and inherited by presented sheets. - @Entry var modalMaterial: WindowMaterial = .sheet + @Entry var modalMaterial: ThemeWindowMaterial = .sheet /// The user-chosen toolbar-control (button) material, seeded at the app root. - @Entry var buttonMaterial: WindowMaterial = .glassClear + @Entry var buttonMaterial: ThemeWindowMaterial = .glassClear /// The user-chosen design-card material, seeded at the app root. - @Entry var cardMaterial: WindowMaterial = .glassRegular + @Entry var cardMaterial: ThemeWindowMaterial = .glassRegular /// Optional color/gradient wash layered into toolbar button groups. - @Entry var buttonTintStyle: DesignButtonTintStyle = .disabled + @Entry var buttonTintStyle: ButtonTintStyle = .disabled } private struct SheetMaterial: ViewModifier { @@ -195,7 +190,7 @@ public extension View { private struct FloatingPanelMaterial: AnimatableModifier { @Environment(\.modalMaterial) private var material - var cornerRadius = DesignTokens.Radius.sheet + var cornerRadius = UI.Tokens.Radius.sheet var showsShadow = true nonisolated var animatableData: CGFloat { @@ -209,9 +204,9 @@ private struct FloatingPanelMaterial: AnimatableModifier { .background { if showsShadow { ExteriorShadow(cornerRadius: cornerRadius, - color: DesignMaterial.floatingPanelShadow, - radius: DesignMaterial.floatingPanelShadowRadius, - y: DesignMaterial.floatingPanelShadowY) + color: ThemeMaterial.floatingPanelShadow, + radius: ThemeMaterial.floatingPanelShadowRadius, + y: ThemeMaterial.floatingPanelShadowY) } } .background { @@ -224,7 +219,7 @@ private struct FloatingPanelMaterial: AnimatableModifier { } .clipShape(shape) .overlay { - shape.strokeBorder(DesignMaterial.floatingPanelStroke, lineWidth: 1) + shape.strokeBorder(ThemeMaterial.floatingPanelStroke, lineWidth: 1) } } } @@ -249,7 +244,7 @@ private struct ToolbarControlMaterial: ViewModifier { public extension View { /// In-window floating panel material. Unlike `.sheet`, this samples the live app content instead /// of the dimmed system-modal backdrop, so thin materials actually read thin. - func floatingPanelMaterial(cornerRadius: CGFloat = DesignTokens.Radius.sheet, + func floatingPanelMaterial(cornerRadius: CGFloat = UI.Tokens.Radius.sheet, showsShadow: Bool = true) -> some View { modifier(FloatingPanelMaterial(cornerRadius: cornerRadius, showsShadow: showsShadow)) } diff --git a/Packages/ContainedUI/Sources/ContainedUI/Tokens/UI.swift b/Packages/ContainedUI/Sources/ContainedUI/Tokens/UI.swift new file mode 100644 index 00000000..d8a7164a --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Tokens/UI.swift @@ -0,0 +1,473 @@ +import SwiftUI + +/// Root namespace for Contained's reusable visual system. +public enum UI {} + +public extension UI { + enum Card {} + enum Panel {} + enum Toolbar {} + enum Control {} + enum Action {} + enum Command {} + enum State {} + enum Symbol {} + enum Chart {} + enum List {} + enum Surface {} + enum Theme {} + enum Badge {} + enum Form {} + enum Layout {} + enum Console {} + enum MenuBar {} +} + +public extension UI { +/// Minimal raw tokens shared by the visual system. +/// +/// Prefer contextual routes such as `UI.Panel.Padding.top` or +/// `UI.Card.Radius.container` from app code. Raw tokens stay here so package +/// elements can mirror the same primitive defaults without duplicating values. +enum Tokens { + public enum Radius { + /// Radius delta between nested glass levels: sheet -> card -> control -> key cap. + public static let step: CGFloat = 6 + public static let control: CGFloat = 10 + public static let card: CGFloat = 16 + public static let sheet: CGFloat = 22 + public static let keyCap: CGFloat = control - step + public static let iconChip: CGFloat = control + + /// Radius for a shape inset inside a parent with the same corner center. + public static func inset(from outer: CGFloat, by inset: CGFloat) -> CGFloat { + max(0, outer - inset) + } + } + + public enum Space { + public static let hairline: CGFloat = 1 + public static let xxs: CGFloat = 2 + public static let xs: CGFloat = 4 + public static let s: CGFloat = 8 + public static let m: CGFloat = 12 + public static let l: CGFloat = 16 + public static let xl: CGFloat = 24 + public static let xxl: CGFloat = 32 + } + + public enum CardSize { + // Generous max widths so the adaptive grid stretches the fitted columns to fill the row, + // rather than capping them tightly and leaving trailing dead space on wide windows. + public static let compactMin: CGFloat = 230 + public static let compactMax: CGFloat = 400 + public static let largeMin: CGFloat = 300 + public static let largeMax: CGFloat = 520 + } + + /// Canonical sheet dimensions — expose through `UI.Panel.SheetSize` for app and UX use. Replaces ad-hoc + /// `width:height:` literals so every sheet snaps to one of a few sizes. + public enum SheetSize { + public static let small = CGSize(width: 420, height: 280) // confirmations, short forms + public static let form = CGSize(width: 560, height: 680) // the run/edit form + public static let console = CGSize(width: 560, height: 540) // streamed-progress / logs + public static let inspector = CGSize(width: 600, height: 560) // Dense detail/history pages + public static let releaseNotes = CGSize(width: 620, height: 520) + public static let wide = CGSize(width: 720, height: 560) // build workspace + public static let dialogWidth: CGFloat = 460 + } + + /// Morph-panel dimensions shared by toolbar origins and panel content. + public enum PanelSize { + // Global floor applied in UX.Morph.Geometry.fittedSize — panels never shrink below these or exceed + // the available window area (handled separately via margin clamping). The height floor is tiny + // so content-hugging panels can collapse close to their header when there's little to show. + public static let minWidth: CGFloat = 300 + public static let minHeight: CGFloat = 50 + + public static let add = CGSize(width: 440, height: 300) + public static let palette = CGSize(width: 560, height: 480) + public static let updatesOrigin = CGSize(width: 440, height: 300) + public static let images = CGSize(width: 520, height: 520) + public static let imageDetail = CGSize(width: 560, height: 520) + public static let imageTag = CGSize(width: 560, height: 360) + public static let activityOrigin = CGSize(width: 460, height: 360) + public static let activity = CGSize(width: 560, height: 520) + public static let templatesOrigin = CGSize(width: 440, height: 300) + public static let templates = CGSize(width: 460, height: 480) + public static let system = CGSize(width: 580, height: 600) + public static let settings = CGSize(width: 560, height: 560) + } + + /// Icon-button / chip dimensions used across menus and headers. + public enum IconSize { + public static let statusDot: CGFloat = 8 + public static let serviceDot: CGFloat = 9 + public static let rowIconColumn: CGFloat = 20 + public static let rowMenu: CGFloat = 22 // ellipsis row menus + public static let control: CGFloat = 28 // sheet-header circle buttons + public static let chip: CGFloat = 30 // small status chips + public static let headerChip: CGFloat = 34 // detail-header chips + public static let appIcon: CGFloat = 56 + } + + /// Fixed widths for compact form controls where stable alignment matters more than fluid sizing. + public enum FormWidth { + public static let shortReadout: CGFloat = 44 + public static let memoryReadout: CGFloat = 64 + public static let port: CGFloat = 70 + public static let containerPort: CGFloat = 80 + public static let userID: CGFloat = 90 + public static let shellPicker: CGFloat = 140 + public static let compactSlider: CGFloat = 140 + public static let networkName: CGFloat = 180 + public static let tintColorHex: CGFloat = 220 + public static let refreshReadout: CGFloat = 32 + } + + public enum Card { + public static let padding: CGFloat = 10 + public static let compactTextSpacing: CGFloat = Space.hairline + public static let detailTextSpacing: CGFloat = Space.xxs + public static let footerDividerHeight: CGFloat = Space.l + public static let sparklineHeight: CGFloat = 58 + public static let iconBackgroundOpacity: Double = 0.16 + public static let iconSelectedBackgroundOpacity: Double = 0.24 + public static let iconEmphasisBackgroundOpacity: Double = 0.22 + public static let plainFillOpacity: Double = 0.18 + public static let selectedSubtleFillOpacity: Double = 0.10 + public static let selectedResourceFillOpacity: Double = 0.12 + public static let selectedTintFillOpacity: Double = 0.18 + public static let selectedPersonalizedFillOpacity: Double = 0.14 + } + + public enum Chart { + public static let height: CGFloat = 140 + public static let axisDesiredCount = 4 + public static let areaOpacity: Double = 0.30 + public static let emptyHeight: CGFloat = 200 + } + + public enum Badge { + public static let compactHorizontalPadding: CGFloat = 7 + public static let horizontalPadding: CGFloat = Space.s + public static let verticalPadding: CGFloat = Space.xxs + public static let scopeVerticalPadding: CGFloat = 3 + public static let accentOpacity: Double = 0.16 + public static let statusOpacity: Double = 0.14 + } + + public enum Keyboard { + public static let keyHorizontalPadding: CGFloat = 5 + public static let keyVerticalPadding: CGFloat = Space.xxs + } + + public enum Terminal { + public static let surfaceOpacity: Double = 0.22 + public static let nativeBackgroundOpacity: CGFloat = 0.82 + public static let nativeForegroundWhite: CGFloat = 0.92 + public static let fontSize: CGFloat = 12 + } + + public enum InlineControl { + public static let gradientDial: CGFloat = 36 + public static let gradientReadout: CGFloat = 40 + public static let gradientKnob: CGFloat = 7 + public static let gradientKnobInset: CGFloat = Space.xs + public static let gradientStrokeOpacity: Double = 0.4 + public static let subtleTileOpacity: Double = 0.25 + } + + public enum MenuBar { + public static let width: CGFloat = 340 + public static let titleWidth: CGFloat = 78 + public static let padding: CGFloat = 14 + } + + /// The app toolbar band — custom (non-native) controls sized to macOS 26 Liquid Glass toolbar + /// proportions (tuned against Finder). `controlHeight` is shared by every band element (glass + /// button groups and the search field) so they align on one baseline; `groupRadius` is the + /// concentric capsule for them. Glyphs are a touch smaller than the capsule with horizontal glass + /// padding around them, matching the airy native look. + public enum Toolbar { + // Exact spec: controls are 36pt tall (length hugs content), with 8pt of padding around the band + // (horizontal, top — matched below — and between groups), so the band is 8 + 36 + 8 = 52. + public static let band: CGFloat = 52 // title-bar band height + public static let controlHeight: CGFloat = 36 // glass groups + search field share this height + // Button glyphs use `.headline` + `.imageScale(.large)` (see ToolbarControls) so they scale + // with Dynamic Type — no fixed point size token. + public static let iconInnerPadding: CGFloat = 4 // padding around the glyph inside the 28 item + public static let buttonItemHeight: CGFloat = 28 + public static var iconContentWidth: CGFloat { buttonItemHeight - iconInnerPadding * 2 } + public static var statusLabelTrailingPadding: CGFloat { iconInnerPadding * 2 } + public static let buttonGroupHeight: CGFloat = 36 + public static let outerPadding: CGFloat = 8 // band inset from the window edges + // Space reserved when custom toolbar chrome needs to mirror the traffic-light cluster. + public static let leadingInset: CGFloat = 80 + public static let trafficLightsWidth: CGFloat = 82 // close/min/zoom cluster width for reserved toolbar slots + public static let groupPaddingH: CGFloat = 0 // horizontal glass margin inside a group + public static let groupSpacing: CGFloat = 8 // spacing between buttons / groups + public static let searchMaxWidth: CGFloat = 380 + // Search field internals. + public static let searchInnerPadding: CGFloat = iconInnerPadding * 2 // matches glass button edge inset + public static let searchIconGap: CGFloat = 6 // gap between icon and text + public static let searchOpenHeaderHeight: CGFloat = 48 // taller header row once the palette expands + // The search icon + text use the semantic `.body` style (13pt on macOS; text adds medium weight), + // so they scale with Dynamic Type — no fixed point size tokens. + /// Padding above the controls (and matched below) — the controls sit on the native toolbar line. + public static let topPadding: CGFloat = 8 + public static var groupRadius: CGFloat { controlHeight / 2 } // concentric capsule + } +} +} + +public extension UI.Panel { + /// Panel padding mirrors the global spacing scale so all panel chrome can diverge in one place. + enum Padding { + public static let top = UI.Layout.Spacing.l + public static let bottom = UI.Layout.Spacing.l + public static let leading = UI.Layout.Spacing.l + public static let trailing = UI.Layout.Spacing.l + public static let all = UI.Layout.Spacing.l + public static let compact = UI.Layout.Spacing.s + } + + /// Panel spacing mirrors the global spacing scale used by stacked sections and rows. + enum Spacing { + public static let row = UI.Layout.Spacing.m + public static let section = UI.Layout.Spacing.l + public static let compact = UI.Layout.Spacing.s + public static let hairline = UI.Layout.Spacing.hairline + } + + /// Panel radii mirror the global sheet radius because floating panels share the sheet silhouette. + enum Radius { + public static let surface = UI.Tokens.Radius.sheet + } + + /// Panel and sheet sizes mirror the raw size defaults used by morph targets. + enum Size { + public static let minWidth = UI.Tokens.PanelSize.minWidth + public static let minHeight = UI.Tokens.PanelSize.minHeight + public static let add = UI.Tokens.PanelSize.add + public static let palette = UI.Tokens.PanelSize.palette + public static let updatesOrigin = UI.Tokens.PanelSize.updatesOrigin + public static let images = UI.Tokens.PanelSize.images + public static let imageDetail = UI.Tokens.PanelSize.imageDetail + public static let imageTag = UI.Tokens.PanelSize.imageTag + public static let activityOrigin = UI.Tokens.PanelSize.activityOrigin + public static let activity = UI.Tokens.PanelSize.activity + public static let templatesOrigin = UI.Tokens.PanelSize.templatesOrigin + public static let templates = UI.Tokens.PanelSize.templates + public static let system = UI.Tokens.PanelSize.system + public static let settings = UI.Tokens.PanelSize.settings + } + + /// Modal sheet sizes mirror the raw canonical sheet dimensions. + enum SheetSize { + public static let small = UI.Tokens.SheetSize.small + public static let form = UI.Tokens.SheetSize.form + public static let console = UI.Tokens.SheetSize.console + public static let inspector = UI.Tokens.SheetSize.inspector + public static let releaseNotes = UI.Tokens.SheetSize.releaseNotes + public static let wide = UI.Tokens.SheetSize.wide + public static let dialogWidth = UI.Tokens.SheetSize.dialogWidth + } +} + +public extension UI.Card { + /// Card padding mirrors the compact card rhythm. The raw value is intentionally denser than + /// panel padding so compact cards keep their existing content-hugging silhouette. + enum Padding { + public static let content = UI.Tokens.Card.padding + public static let body = UI.Layout.Spacing.s + public static let widget = UI.Tokens.Card.padding + } + + /// Card spacing mirrors compact card internals and footer/widget grouping. + enum Spacing { + public static let compactText = UI.Tokens.Card.compactTextSpacing + public static let detailText = UI.Tokens.Card.detailTextSpacing + public static let footer = UI.Tokens.Card.padding + public static let widget = UI.Tokens.Card.padding + } + + /// Card radii mirror raw radius defaults. Expanded cards intentionally use the sheet radius. + enum Radius { + public static let container = UI.Tokens.Radius.card + public static let expanded = UI.Tokens.Radius.sheet + public static let control = UI.Tokens.Radius.control + } + + /// Card metrics mirror repeated card chrome values. + enum Metric { + public static let footerDividerHeight = UI.Tokens.Card.footerDividerHeight + public static let sparklineHeight = UI.Tokens.Card.sparklineHeight + public static let iconBackgroundOpacity = UI.Tokens.Card.iconBackgroundOpacity + public static let iconSelectedBackgroundOpacity = UI.Tokens.Card.iconSelectedBackgroundOpacity + public static let iconEmphasisBackgroundOpacity = UI.Tokens.Card.iconEmphasisBackgroundOpacity + public static let plainFillOpacity = UI.Tokens.Card.plainFillOpacity + public static let selectedSubtleFillOpacity = UI.Tokens.Card.selectedSubtleFillOpacity + public static let selectedResourceFillOpacity = UI.Tokens.Card.selectedResourceFillOpacity + public static let selectedTintFillOpacity = UI.Tokens.Card.selectedTintFillOpacity + public static let selectedPersonalizedFillOpacity = UI.Tokens.Card.selectedPersonalizedFillOpacity + } + + /// Card grid sizing mirrors adaptive grid defaults for repeated card collections. + enum Grid { + public static let compactMin = UI.Tokens.CardSize.compactMin + public static let compactMax = UI.Tokens.CardSize.compactMax + public static let largeMin = UI.Tokens.CardSize.largeMin + public static let largeMax = UI.Tokens.CardSize.largeMax + } +} + +public extension UI.Toolbar { + /// Toolbar sizing mirrors the macOS Liquid Glass toolbar rhythm. + enum Size { + public static let band = UI.Tokens.Toolbar.band + public static let controlHeight = UI.Tokens.Toolbar.controlHeight + public static let buttonItemHeight = UI.Tokens.Toolbar.buttonItemHeight + public static let buttonGroupHeight = UI.Tokens.Toolbar.buttonGroupHeight + public static let searchMaxWidth = UI.Tokens.Toolbar.searchMaxWidth + public static let searchOpenHeaderHeight = UI.Tokens.Toolbar.searchOpenHeaderHeight + public static var iconContentWidth: CGFloat { UI.Tokens.Toolbar.iconContentWidth } + public static var groupRadius: CGFloat { UI.Tokens.Toolbar.groupRadius } + } + + /// Toolbar spacing mirrors the raw toolbar band spacing. + enum Spacing { + public static let outerPadding = UI.Tokens.Toolbar.outerPadding + public static let groupSpacing = UI.Tokens.Toolbar.groupSpacing + public static let groupPaddingH = UI.Tokens.Toolbar.groupPaddingH + public static let searchIconGap = UI.Tokens.Toolbar.searchIconGap + public static let searchInnerPadding = UI.Tokens.Toolbar.searchInnerPadding + public static let iconInnerPadding = UI.Tokens.Toolbar.iconInnerPadding + public static let topPadding = UI.Tokens.Toolbar.topPadding + } + + /// Toolbar placement values mirror traffic-light-aware raw defaults. + enum Placement { + public static let leadingInset = UI.Tokens.Toolbar.leadingInset + public static let trafficLightsWidth = UI.Tokens.Toolbar.trafficLightsWidth + public static var statusLabelTrailingPadding: CGFloat { UI.Tokens.Toolbar.statusLabelTrailingPadding } + } +} + +public extension UI.Chart { + /// Chart sizing mirrors compact and expanded chart defaults. + enum Size { + public static let height = UI.Tokens.Chart.height + public static let emptyHeight = UI.Tokens.Chart.emptyHeight + } + + /// Chart rendering mirrors reusable chart defaults. + enum Rendering { + public static let axisDesiredCount = UI.Tokens.Chart.axisDesiredCount + public static let areaOpacity = UI.Tokens.Chart.areaOpacity + } +} + +public extension UI.Badge { + /// Badge padding mirrors repeated capsule label defaults. + enum Padding { + public static let compactHorizontal = UI.Tokens.Badge.compactHorizontalPadding + public static let horizontal = UI.Tokens.Badge.horizontalPadding + public static let vertical = UI.Tokens.Badge.verticalPadding + public static let scopeVertical = UI.Tokens.Badge.scopeVerticalPadding + } + + /// Badge opacity mirrors reusable status/accent fill defaults. + enum Opacity { + public static let accent = UI.Tokens.Badge.accentOpacity + public static let status = UI.Tokens.Badge.statusOpacity + } +} + +public extension UI.Form { + /// Form widths mirror fixed-width controls used for stable alignment. + enum Width { + public static let shortReadout = UI.Tokens.FormWidth.shortReadout + public static let memoryReadout = UI.Tokens.FormWidth.memoryReadout + public static let port = UI.Tokens.FormWidth.port + public static let containerPort = UI.Tokens.FormWidth.containerPort + public static let userID = UI.Tokens.FormWidth.userID + public static let shellPicker = UI.Tokens.FormWidth.shellPicker + public static let compactSlider = UI.Tokens.FormWidth.compactSlider + public static let networkName = UI.Tokens.FormWidth.networkName + public static let tintColorHex = UI.Tokens.FormWidth.tintColorHex + public static let refreshReadout = UI.Tokens.FormWidth.refreshReadout + } +} + +public extension UI.Layout { + /// Generic layout spacing mirrors the raw spacing scale for app composition with no element owner. + enum Spacing { + public static let hairline = UI.Tokens.Space.hairline + public static let xxs = UI.Tokens.Space.xxs + public static let xs = UI.Tokens.Space.xs + public static let s = UI.Tokens.Space.s + public static let m = UI.Tokens.Space.m + public static let l = UI.Tokens.Space.l + public static let xl = UI.Tokens.Space.xl + public static let xxl = UI.Tokens.Space.xxl + } +} + +public extension UI.Control { + /// Control sizing mirrors reusable icon/control defaults. + enum Size { + public static let statusDot = UI.Tokens.IconSize.statusDot + public static let serviceDot = UI.Tokens.IconSize.serviceDot + public static let rowIconColumn = UI.Tokens.IconSize.rowIconColumn + public static let rowMenu = UI.Tokens.IconSize.rowMenu + public static let control = UI.Tokens.IconSize.control + public static let chip = UI.Tokens.IconSize.chip + public static let headerChip = UI.Tokens.IconSize.headerChip + public static let appIcon = UI.Tokens.IconSize.appIcon + } + + /// Inline-control sizing mirrors repeated compact editor defaults. + enum InlineSize { + public static let gradientDial = UI.Tokens.InlineControl.gradientDial + public static let gradientReadout = UI.Tokens.InlineControl.gradientReadout + public static let gradientKnob = UI.Tokens.InlineControl.gradientKnob + public static let gradientKnobInset = UI.Tokens.InlineControl.gradientKnobInset + } + + /// Inline-control opacity mirrors repeated compact editor defaults. + enum Opacity { + public static let gradientStroke = UI.Tokens.InlineControl.gradientStrokeOpacity + public static let subtleTile = UI.Tokens.InlineControl.subtleTileOpacity + } +} + +public extension UI.Console { + /// Console metrics mirror terminal surface defaults. + enum Metric { + public static let surfaceOpacity = UI.Tokens.Terminal.surfaceOpacity + public static let nativeBackgroundOpacity = UI.Tokens.Terminal.nativeBackgroundOpacity + public static let nativeForegroundWhite = UI.Tokens.Terminal.nativeForegroundWhite + public static let fontSize = UI.Tokens.Terminal.fontSize + } +} + +public extension UI.MenuBar { + /// Menu-bar dimensions mirror the compact popover defaults. + enum Size { + public static let width = UI.Tokens.MenuBar.width + public static let titleWidth = UI.Tokens.MenuBar.titleWidth + } + + /// Menu-bar padding mirrors the compact popover inset. + enum Padding { + public static let all = UI.Tokens.MenuBar.padding + } +} + +public extension View { + /// Apply a canonical sheet size from `UI.Panel.SheetSize`. + func frame(_ size: CGSize) -> some View { + frame(width: size.width, height: size.height) + } +} diff --git a/Packages/ContainedUI/Tests/ContainedUITests/CardLayoutPolicyTests.swift b/Packages/ContainedUI/Tests/ContainedUITests/CardLayoutPolicyTests.swift new file mode 100644 index 00000000..f80a00f4 --- /dev/null +++ b/Packages/ContainedUI/Tests/ContainedUITests/CardLayoutPolicyTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import ContainedUI + +@Suite("Card layout layout policy") +struct CardLayoutPolicyTests { + @Test func sizeControlsStickyAndEmbeddedCardSlots() { + #expect(UI.Card.Size.small.keepsFooterSticky == false) + #expect(UI.Card.Size.small.embedsFooterInBody == true) + #expect(UI.Card.Size.small.keepsWidgetSticky == false) + #expect(UI.Card.Size.small.embedsWidgetInBody == false) + + #expect(UI.Card.Size.medium.keepsFooterSticky == true) + #expect(UI.Card.Size.medium.embedsFooterInBody == false) + #expect(UI.Card.Size.medium.keepsWidgetSticky == false) + #expect(UI.Card.Size.medium.embedsWidgetInBody == true) + + #expect(UI.Card.Size.large.keepsFooterSticky == true) + #expect(UI.Card.Size.large.embedsFooterInBody == false) + #expect(UI.Card.Size.large.keepsWidgetSticky == true) + #expect(UI.Card.Size.large.embedsWidgetInBody == false) + } +} diff --git a/Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift b/Packages/ContainedUI/Tests/ContainedUITests/SparklineScalingTests.swift similarity index 94% rename from Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift rename to Packages/ContainedUI/Tests/ContainedUITests/SparklineScalingTests.swift index 0bfc46d8..689cdf07 100644 --- a/Packages/ContainedDesignSystem/Tests/ContainedDesignSystemTests/LiveSparklineScalingTests.swift +++ b/Packages/ContainedUI/Tests/ContainedUITests/SparklineScalingTests.swift @@ -1,8 +1,8 @@ import Testing -@testable import ContainedDesignSystem +@testable import ContainedUI @Suite("Live sparkline scaling") -struct LiveSparklineScalingTests { +struct SparklineScalingTests { @Test func paddedWindowSanitizesInvalidSamples() { let window = SparklineSeriesScaling.paddedWindow([.nan, -1, .infinity, 2], capacity: 6) diff --git a/Packages/ContainedUX/Package.swift b/Packages/ContainedUX/Package.swift new file mode 100644 index 00000000..14271720 --- /dev/null +++ b/Packages/ContainedUX/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "ContainedUX", + platforms: [.macOS(.v26)], + products: [ + .library(name: "ContainedUX", targets: ["ContainedUX"]), + ], + dependencies: [ + .package(path: "../ContainedUI"), + ], + targets: [ + .target( + name: "ContainedUX", + dependencies: [ + .product(name: "ContainedUI", package: "ContainedUI"), + ] + ), + .testTarget( + name: "ContainedUXTests", + dependencies: ["ContainedUX"] + ), + ] +) diff --git a/Packages/ContainedUX/README.md b/Packages/ContainedUX/README.md new file mode 100644 index 00000000..90f6559e --- /dev/null +++ b/Packages/ContainedUX/README.md @@ -0,0 +1,121 @@ +# ContainedUX + +`ContainedUX` owns reusable interaction infrastructure: morph geometry, +safe-area policy, source-frame measurement, panel placement, and single-surface +expansion behavior. + +It depends on `ContainedUI` for visual primitives and contextual element tokens. +`ContainedUX` uses routes such as `UI.Panel.Size`, `UI.Panel.Radius`, +`UI.Toolbar.Size`, and `UI.Layout.Spacing`; it does not read raw `UI.Tokens`. +It does not own app sections, stores, toolbar panel contents, `UIState`, feature +routing, or localized copy. + +## Importing + +```swift +.product(name: "ContainedUX", package: "ContainedUX") +``` + +```swift +import SwiftUI +import ContainedUI +import ContainedUX +``` + +## Public API Shape + +- `UX.Morph.*` owns morph target geometry, grow/shrink expanders, and single-surface transitions. +- `UX.Panel.*` owns panel placement and backdrop policy. +- `UX.SafeArea.*` owns toolbar-aware safe-area measurement and environment policy. +- `UX.Measurement.*` owns source-frame collection for morph origins. +- `ContainedUI` owns visual anatomy such as `UI.Panel.Scaffold`; `ContainedUX` moves and places that anatomy. + +## Panel Morph Example + +```swift +struct AddPanelHost: View { + @State private var isPresented = false + let originFrame: CGRect + + var body: some View { + ZStack(alignment: .topLeading) { + UI.Action.Group(UI.Action.Item(systemName: "plus", help: "Add") { + isPresented = true + }) + .padding(UI.Layout.Spacing.l) + + UX.Morph.Expander(isPresented: $isPresented, + originFrame: originFrame, + target: .centered(size: UI.Panel.Size.add)) { + UI.Panel.Scaffold(width: UI.Panel.Size.add.width) { + UI.Panel.Header(symbol: "plus", + title: "Add", + subtitle: "Choose a starting point") { + UI.Action.Group(UI.Action.Item(systemName: "xmark", + help: "Close", + isCancel: true) { + isPresented = false + }) + } + } content: { + UI.Control.OptionTile(symbol: "play.rectangle", + title: "Run a container", + subtitle: "Start from an image") { + isPresented = false + } + .padding(UI.Panel.Padding.compact) + } + } + } + .environment(\.morphSafeAreaManager, + UX.SafeArea.Manager(topToolbarHeight: UI.Toolbar.Size.band, + bottomToolbarHeight: UI.Toolbar.Size.band)) + } +} +``` + +## Single Surface Example + +Use `UX.Morph.SingleSurface` when an existing card, row, or tile should expand +as one surface without handing off to a separate panel: + +```swift +UX.Morph.SingleSurface(source: sourceFrame, + target: detailFrame, + progress: isExpanded ? 1 : 0) { + UI.Card.Scaffold(size: .large, + isExpanded: isExpanded, + title: "web", + subtitle: "Running", + pages: nil) { + UI.Card.IconChip(symbol: "shippingbox.fill") + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() + } +} +``` + +## Previews + +Package-local SwiftUI previews are guarded by `CONTAINED_UX_PREVIEWS`. Normal +app builds do not define that flag, so package preview sample values are kept out +of debug, release, notarized, and non-notarized app bundles. + +## Verification + +```sh +swift build --package-path Packages/ContainedUX +swift test --package-path Packages/ContainedUX +``` diff --git a/Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md b/Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md new file mode 100644 index 00000000..f579b119 --- /dev/null +++ b/Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md @@ -0,0 +1,44 @@ +# ``ContainedUX`` + +Reusable morphing, placement, safe-area, and measurement infrastructure. + +## Overview + +`ContainedUX` exposes nested `UX.*` routes. `UX` decides how surfaces move and +where they fit; `ContainedUI` decides how surfaces look; the app decides which +surface opens and why. + +`ContainedUX` consumes contextual `ContainedUI` element tokens such as +`UI.Panel.Size`, `UI.Panel.Radius`, `UI.Toolbar.Size`, and `UI.Layout.Spacing`. +Raw `UI.Tokens` stay inside `ContainedUI`. + +```swift +UX.Morph.Expander(isPresented: $isPresented, + originFrame: originFrame, + target: .anchored(size: UI.Panel.Size.imageDetail)) { + UI.Panel.Scaffold(width: UI.Panel.Size.imageDetail.width) { + UI.Panel.Header(symbol: "photo", title: "Images", subtitle: nil) { + UI.Action.Group(UI.Action.Item(systemName: "xmark", help: "Close") { + isPresented = false + }) + } + } content: { + content() + } +} +``` + +## Topics + +### Namespaces + +- `UX.Morph` +- `UX.Panel` +- `UX.SafeArea` +- `UX.Measurement` + +### Related Visual Package + +- `UI.Panel.Scaffold` +- `UI.Card.Scaffold` +- `UI.Toolbar.Size` diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/Measurement/MorphSourceFrames.swift b/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrames.swift similarity index 88% rename from Packages/ContainedNavigation/Sources/ContainedNavigation/Measurement/MorphSourceFrames.swift rename to Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrames.swift index 5f1a0865..f08b4efe 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/Measurement/MorphSourceFrames.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrames.swift @@ -1,6 +1,6 @@ import SwiftUI -public struct MorphSourceFrameReader: View { +public struct SourceFrameReader: View { public var ids: [ID] public var coordinateSpaceName: String @@ -18,14 +18,14 @@ public struct MorphSourceFrameReader: View { GeometryReader { proxy in let frame = proxy.frame(in: .named(coordinateSpaceName)) Color.clear.preference( - key: MorphSourceFramesKey.self, + key: SourceFramesKey.self, value: Dictionary(uniqueKeysWithValues: ids.map { ($0, frame) }) ) } } } -public struct MorphSourceFramesKey: PreferenceKey { +public struct SourceFramesKey: PreferenceKey { public static var defaultValue: [ID: CGRect] { [:] } public static func reduce(value: inout [ID: CGRect], @@ -46,7 +46,7 @@ public extension Dictionary where Value == CGRect { public extension CGRect { var isUsableForMorph: Bool { - MorphGeometry.isUsableFrame(self) + MorphGeometryEngine.isUsableFrame(self) } func isClose(to other: CGRect, tolerance: CGFloat = 0.5) -> Bool { diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingExpander.swift b/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift similarity index 79% rename from Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingExpander.swift rename to Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift index 319c9f9a..f596aae6 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingExpander.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift @@ -1,19 +1,19 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI -public enum MorphPanelPlacement: Equatable, Sendable { +public enum PanelPlacement: Equatable, Sendable { case anchored case centered } -public struct MorphTarget { - public var placement: MorphPanelPlacement - public var safeArea: MorphSafeAreaPolicy +public struct MorphTargetConfig { + public var placement: PanelPlacement + public var safeArea: SafeAreaPolicy public var margin: CGFloat public var proposedSize: (CGRect) -> CGSize - public init(placement: MorphPanelPlacement, - safeArea: MorphSafeAreaPolicy, + public init(placement: PanelPlacement, + safeArea: SafeAreaPolicy, margin: CGFloat, proposedSize: @escaping (CGRect) -> CGSize) { self.placement = placement @@ -23,27 +23,27 @@ public struct MorphTarget { } public static func anchored(size: CGSize, - safeArea: MorphSafeAreaPolicy = .toolbarChrome, - margin: CGFloat = MorphGeometry.defaultMargin) -> MorphTarget { - MorphTarget(placement: .anchored, + safeArea: SafeAreaPolicy = .toolbarChrome, + margin: CGFloat = MorphGeometryEngine.defaultMargin) -> MorphTargetConfig { + MorphTargetConfig(placement: .anchored, safeArea: safeArea, margin: margin, proposedSize: { _ in size }) } public static func centered(size: CGSize, - safeArea: MorphSafeAreaPolicy = .content, - margin: CGFloat = MorphGeometry.defaultMargin) -> MorphTarget { - MorphTarget(placement: .centered, + safeArea: SafeAreaPolicy = .content, + margin: CGFloat = MorphGeometryEngine.defaultMargin) -> MorphTargetConfig { + MorphTargetConfig(placement: .centered, safeArea: safeArea, margin: margin, proposedSize: { _ in size }) } - public static func centered(safeArea: MorphSafeAreaPolicy = .content, - margin: CGFloat = MorphGeometry.defaultMargin, - proposedSize: @escaping (CGRect) -> CGSize) -> MorphTarget { - MorphTarget(placement: .centered, + public static func centered(safeArea: SafeAreaPolicy = .content, + margin: CGFloat = MorphGeometryEngine.defaultMargin, + proposedSize: @escaping (CGRect) -> CGSize) -> MorphTargetConfig { + MorphTargetConfig(placement: .centered, safeArea: safeArea, margin: margin, proposedSize: proposedSize) @@ -51,11 +51,11 @@ public struct MorphTarget { public func rect(origin: CGRect, in container: CGSize, - safeAreas: MorphSafeAreaManager, + safeAreaManager: SafeAreaManager, proposedSize overrideSize: CGSize? = nil, - placement overridePlacement: MorphPanelPlacement? = nil) -> CGRect { - let bounds = safeAreas.bounds(in: container, policy: safeArea) - return MorphGeometry.targetRect(origin: origin, + placement overridePlacement: PanelPlacement? = nil) -> CGRect { + let bounds = safeAreaManager.bounds(in: container, policy: safeArea) + return MorphGeometryEngine.targetRect(origin: origin, proposedSize: overrideSize ?? proposedSize(bounds), bounds: bounds, placement: overridePlacement ?? placement, @@ -63,21 +63,21 @@ public struct MorphTarget { } } -public struct GlobalBackdropStyle: OptionSet, Equatable, Sendable { +public struct PanelBackdropStyle: OptionSet, Equatable, Sendable { public let rawValue: Int public init(rawValue: Int) { self.rawValue = rawValue } - public static let dim = GlobalBackdropStyle(rawValue: 1 << 0) - public static let blur = GlobalBackdropStyle(rawValue: 1 << 1) - public static let blurAndDim: GlobalBackdropStyle = [.blur, .dim] + public static let dim = PanelBackdropStyle(rawValue: 1 << 0) + public static let blur = PanelBackdropStyle(rawValue: 1 << 1) + public static let blurAndDim: PanelBackdropStyle = [.blur, .dim] } -public enum MorphGeometry { - public static let defaultMargin: CGFloat = DesignTokens.Space.l - public static let centeredTopMargin: CGFloat = DesignTokens.Space.xxl * 2 +public enum MorphGeometryEngine { + public static let defaultMargin: CGFloat = UI.Layout.Spacing.l + public static let centeredTopMargin: CGFloat = UI.Layout.Spacing.xxl * 2 public static func fittedSize(_ proposed: CGSize, in container: CGSize, margin: CGFloat = defaultMargin) -> CGSize { @@ -88,12 +88,12 @@ public enum MorphGeometry { margin: CGFloat = defaultMargin) -> CGSize { let maxWidth = max(1, bounds.width - margin * 2) let maxHeight = max(1, bounds.height - margin * 2) - return CGSize(width: min(max(DesignTokens.PanelSize.minWidth, proposed.width), maxWidth), - height: min(max(DesignTokens.PanelSize.minHeight, proposed.height), maxHeight)) + return CGSize(width: min(max(UI.Panel.Size.minWidth, proposed.width), maxWidth), + height: min(max(UI.Panel.Size.minHeight, proposed.height), maxHeight)) } public static func targetRect(origin: CGRect, proposedSize: CGSize, container: CGSize, - placement: MorphPanelPlacement, + placement: PanelPlacement, margin: CGFloat = defaultMargin) -> CGRect { targetRect(origin: origin, proposedSize: proposedSize, @@ -103,7 +103,7 @@ public enum MorphGeometry { } public static func targetRect(origin: CGRect, proposedSize: CGSize, bounds: CGRect, - placement: MorphPanelPlacement, + placement: PanelPlacement, margin: CGFloat = defaultMargin) -> CGRect { let size = fittedSize(proposedSize, in: bounds, margin: margin) switch placement { @@ -145,25 +145,25 @@ extension CGSize { } } -/// A centered glass panel that **grows from an origin slot** (e.g. a toolbar button) over a +/// A centered material panel that **grows from an origin slot** (e.g. a toolbar button) over a /// dimmed/blurred backdrop, then shrinks back into it on close — the same in-place grow the container /// cards use for their detail panel, hoisted into a reusable primitive. /// /// Mount it inside a **window-spanning** `ZStack` whose coordinate space matches the one `originFrame` /// was measured in (so the grow starts from the real button location). It owns the open/close spring; /// the parent just toggles `isPresented` and supplies the slot frame + panel content. -public struct MorphingExpander: View { +public struct MorphExpander: View { /// Bound presence. The expander animates the close itself, then flips this to `false` on completion. @Binding var isPresented: Bool /// The slot the panel grows out of / collapses back into, in this view's coordinate space. let originFrame: CGRect - var target: MorphTarget - var backdropStyle: GlobalBackdropStyle = .dim + var target: MorphTargetConfig + var backdropStyle: PanelBackdropStyle = .dim var showsBackdrop = true var showsPanelShadow = true var closeRequestToken = 0 - var sourceCornerRadius = DesignTokens.Toolbar.groupRadius - var targetCornerRadius = DesignTokens.Radius.sheet + var sourceCornerRadius = UI.Toolbar.Size.groupRadius + var targetCornerRadius = UI.Panel.Radius.surface var onBackdropTap: (() -> Void)? var onExpansionChange: ((Bool) -> Void)? @ViewBuilder var content: () -> Content @@ -173,21 +173,21 @@ public struct MorphingExpander: View { /// content reports a new desired size via `.morphPanelSize(...)`. This is what lets a paged panel /// resize and re-center as it moves between sections. @State private var liveSize: CGSize? - @State private var livePlacement: MorphPanelPlacement? + @State private var livePlacement: PanelPlacement? @Namespace private var shellNamespace @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.morphSafeAreas) private var safeAreas + @Environment(\.morphSafeAreaManager) private var safeAreaManager private var spring: Animation { .spring(response: 0.42, dampingFraction: 0.86) } public init(isPresented: Binding, originFrame: CGRect, - target: MorphTarget = .centered(size: CGSize(width: 460, height: 440)), - backdropStyle: GlobalBackdropStyle = .dim, + target: MorphTargetConfig = .centered(size: CGSize(width: 460, height: 440)), + backdropStyle: PanelBackdropStyle = .dim, showsBackdrop: Bool = true, showsPanelShadow: Bool = true, closeRequestToken: Int = 0, - sourceCornerRadius: CGFloat = DesignTokens.Toolbar.groupRadius, - targetCornerRadius: CGFloat = DesignTokens.Radius.sheet, + sourceCornerRadius: CGFloat = UI.Toolbar.Size.groupRadius, + targetCornerRadius: CGFloat = UI.Panel.Radius.surface, onBackdropTap: (() -> Void)? = nil, onExpansionChange: ((Bool) -> Void)? = nil, @ViewBuilder content: @escaping () -> Content) { @@ -209,7 +209,7 @@ public struct MorphingExpander: View { GeometryReader { geo in let target = targetRect(in: geo.size) let source = originFrame.isUsableForMorph ? originFrame : target - let rect = MorphFrame(source: source, + let rect = MorphFrameGeometry(source: source, target: target, progress: expanded ? 1 : 0).rect let cornerRadius = expanded ? targetCornerRadius : sourceCornerRadius @@ -280,7 +280,7 @@ public struct MorphingExpander: View { private func targetRect(in size: CGSize) -> CGRect { target.rect(origin: originFrame, in: size, - safeAreas: safeAreas, + safeAreaManager: safeAreaManager, proposedSize: liveSize, placement: livePlacement) } @@ -293,7 +293,7 @@ public struct MorphingExpander: View { } public extension View { - func globalBackdrop(style: GlobalBackdropStyle, + func globalBackdrop(style: PanelBackdropStyle, progress: Double, dimOpacity: Double = 0.28) -> some View { self @@ -316,7 +316,7 @@ public extension View { } private struct MorphPanelShell: View { - var cornerRadius = DesignTokens.Radius.sheet + var cornerRadius = UI.Panel.Radius.surface var showsShadow = true var body: some View { @@ -325,7 +325,7 @@ private struct MorphPanelShell: View { } } -/// Hosted content reports its desired panel size up to the enclosing `MorphingExpander`, which animates +/// Hosted content reports its desired panel size up to the enclosing `MorphExpander`, which animates /// the panel to it — so a paged panel can resize and re-center between sections. struct MorphPanelSizeKey: PreferenceKey { static let defaultValue: CGSize? = nil @@ -335,21 +335,21 @@ struct MorphPanelSizeKey: PreferenceKey { } struct MorphPanelPlacementKey: PreferenceKey { - static let defaultValue: MorphPanelPlacement? = nil - static func reduce(value: inout MorphPanelPlacement?, - nextValue: () -> MorphPanelPlacement?) { + static let defaultValue: PanelPlacement? = nil + static func reduce(value: inout PanelPlacement?, + nextValue: () -> PanelPlacement?) { if let next = nextValue() { value = next } } } public extension View { - /// Declare the desired size of the panel hosting this content (read by `MorphingExpander`). + /// Declare the desired size of the panel hosting this content (read by `MorphExpander`). func morphPanelSize(_ size: CGSize) -> some View { preference(key: MorphPanelSizeKey.self, value: size) } /// Declare whether the hosting morph panel should stay near its source slot or move to center. - func morphPanelPlacement(_ placement: MorphPanelPlacement) -> some View { + func morphPanelPlacement(_ placement: PanelPlacement) -> some View { preference(key: MorphPanelPlacementKey.self, value: placement) } } diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingSingleSurface.swift b/Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift similarity index 87% rename from Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingSingleSurface.swift rename to Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift index 96e53807..15a555bd 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/Morphing/MorphingSingleSurface.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift @@ -1,6 +1,6 @@ import SwiftUI -public struct MorphFrame: Equatable, Sendable { +public struct MorphFrameGeometry: Equatable, Sendable { public var source: CGRect public var target: CGRect public var progress: CGFloat @@ -8,7 +8,7 @@ public struct MorphFrame: Equatable, Sendable { public init(source: CGRect, target: CGRect, progress: CGFloat) { self.source = source self.target = target - self.progress = MorphGeometry.clampedProgress(progress) + self.progress = MorphGeometryEngine.clampedProgress(progress) } public var rect: CGRect { @@ -18,9 +18,9 @@ public struct MorphFrame: Equatable, Sendable { /// Hosts one promoted surface while it grows from an existing slot into a larger target rect. /// -/// This is the single-card version of the rect motion used by `MorphingExpander`: callers keep the +/// This is the single-card version of the rect motion used by `MorphExpander`: callers keep the /// source view laid out in place, hide it while selected, and render one overlay through this helper. -public struct MorphingSingleSurface: View { +public struct MorphSingleSurface: View { public var source: CGRect public var target: CGRect public var progress: CGFloat @@ -40,23 +40,23 @@ public struct MorphingSingleSurface: View { } public var body: some View { - let rect = MorphFrame(source: source, target: target, progress: progress).rect + let rect = MorphFrameGeometry(source: source, target: target, progress: progress).rect content() .frame(width: max(rect.width, 1), height: max(rect.height, 1), alignment: alignment) .position(x: rect.midX, y: rect.midY) } } -/// Hosts one promoted card-like surface with the same lifecycle as `MorphingExpander`, but without +/// Hosts one promoted card-like surface with the same lifecycle as `MorphExpander`, but without /// drawing a separate panel shell around the content. /// /// Use this when the promoted content is already its own visual surface, such as an expanded design -/// card. Panel contents should still use `MorphingExpander`. -public struct MorphingSingleSurfaceExpander: View { +/// card. Panel contents should still use `MorphExpander`. +public struct MorphSingleSurfaceExpander: View { @Binding var isPresented: Bool public var originFrame: CGRect - public var target: MorphTarget - public var backdropStyle: GlobalBackdropStyle + public var target: MorphTargetConfig + public var backdropStyle: PanelBackdropStyle public var showsBackdrop: Bool public var closeRequestToken: Int public var onBackdropTap: (() -> Void)? @@ -65,15 +65,15 @@ public struct MorphingSingleSurfaceExpander: View { @State private var expanded = false @State private var liveSize: CGSize? - @State private var livePlacement: MorphPanelPlacement? + @State private var livePlacement: PanelPlacement? @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.morphSafeAreas) private var safeAreas + @Environment(\.morphSafeAreaManager) private var safeAreaManager private var spring: Animation { .spring(response: 0.42, dampingFraction: 0.86) } public init(isPresented: Binding, originFrame: CGRect, - target: MorphTarget, - backdropStyle: GlobalBackdropStyle = .dim, + target: MorphTargetConfig, + backdropStyle: PanelBackdropStyle = .dim, showsBackdrop: Bool = true, closeRequestToken: Int = 0, onBackdropTap: (() -> Void)? = nil, @@ -142,7 +142,7 @@ public struct MorphingSingleSurfaceExpander: View { private func targetRect(in size: CGSize) -> CGRect { target.rect(origin: originFrame, in: size, - safeAreas: safeAreas, + safeAreaManager: safeAreaManager, proposedSize: liveSize, placement: livePlacement) } @@ -159,7 +159,7 @@ public struct MorphingSingleSurfaceExpander: View { } } -public extension MorphGeometry { +public extension MorphGeometryEngine { static func clampedProgress(_ progress: CGFloat) -> CGFloat { min(max(progress.isFinite ? progress : 0, 0), 1) } @@ -190,6 +190,6 @@ public extension MorphGeometry { public extension CGRect { func morphInterpolated(to target: CGRect, progress: CGFloat) -> CGRect { - MorphGeometry.interpolatedRect(from: self, to: target, progress: progress) + MorphGeometryEngine.interpolatedRect(from: self, to: target, progress: progress) } } diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift b/Packages/ContainedUX/Sources/ContainedUX/Previews/NavigationPreviews.swift similarity index 56% rename from Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift rename to Packages/ContainedUX/Sources/ContainedUX/Previews/NavigationPreviews.swift index f9bd0fcc..86d451d2 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/Previews/NavigationPreviews.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Previews/NavigationPreviews.swift @@ -1,12 +1,13 @@ +#if CONTAINED_UX_PREVIEWS import SwiftUI -import ContainedDesignSystem +import ContainedUI #Preview("Morph Panel") { NavigationPreview() .frame(width: 720, height: 520) - .environment(\.morphSafeAreas, - MorphSafeAreaManager(topToolbarHeight: DesignTokens.Toolbar.band, - bottomToolbarHeight: DesignTokens.Toolbar.band)) + .environment(\.morphSafeAreaManager, + UX.SafeArea.Manager(topToolbarHeight: UI.Toolbar.Size.band, + bottomToolbarHeight: UI.Toolbar.Size.band)) .environment(\.buttonMaterial, .glassClear) } @@ -15,41 +16,42 @@ private struct NavigationPreview: View { private let origin = CGRect(x: 24, y: 24, - width: DesignTokens.Toolbar.buttonGroupHeight, - height: DesignTokens.Toolbar.buttonGroupHeight) + width: UI.Toolbar.Size.buttonGroupHeight, + height: UI.Toolbar.Size.buttonGroupHeight) var body: some View { ZStack(alignment: .topLeading) { - DesignActionGroup(DesignAction(systemName: "plus", help: "Open") { + UI.Action.Group(UI.Action.Item(systemName: "plus", help: "Open") { isPresented = true }) - .padding(DesignTokens.Space.l) + .padding(UI.Layout.Spacing.l) - MorphingExpander(isPresented: $isPresented, + UX.Morph.Expander(isPresented: $isPresented, originFrame: origin, - target: .centered(size: DesignTokens.PanelSize.add)) { - DesignPanelScaffold(width: DesignTokens.PanelSize.add.width) { - PanelHeader(symbol: "plus", + target: .centered(size: UI.Panel.Size.add)) { + UI.Panel.Scaffold(width: UI.Panel.Size.add.width) { + UI.Panel.Header(symbol: "plus", title: "Preview panel", subtitle: "Reusable morph layout") { - DesignActionGroup(DesignAction(systemName: "xmark", + UI.Action.Group(UI.Action.Item(systemName: "xmark", help: "Close", isCancel: true) { isPresented = false }) } } content: { - VStack(spacing: DesignTokens.Space.s) { - DesignOptionTile(symbol: "shippingbox", + VStack(spacing: UI.Layout.Spacing.s) { + UI.Control.OptionTile(symbol: "shippingbox", title: "Primary item", subtitle: "Start from a template") {} - DesignOptionTile(symbol: "square.stack.3d.up", + UI.Control.OptionTile(symbol: "square.stack.3d.up", title: "Image", subtitle: "Use a local image") {} } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) } } } } } +#endif diff --git a/Packages/ContainedNavigation/Sources/ContainedNavigation/SafeAreas/MorphSafeAreaManager.swift b/Packages/ContainedUX/Sources/ContainedUX/SafeArea/SafeAreaManager.swift similarity index 63% rename from Packages/ContainedNavigation/Sources/ContainedNavigation/SafeAreas/MorphSafeAreaManager.swift rename to Packages/ContainedUX/Sources/ContainedUX/SafeArea/SafeAreaManager.swift index e11f143f..8184db24 100644 --- a/Packages/ContainedNavigation/Sources/ContainedNavigation/SafeAreas/MorphSafeAreaManager.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/SafeArea/SafeAreaManager.swift @@ -1,43 +1,43 @@ import SwiftUI -public struct MorphToolbarSafeAreaExclusion: OptionSet, Equatable, Sendable { +public struct ToolbarSafeAreaExclusion: OptionSet, Equatable, Sendable { public let rawValue: Int public init(rawValue: Int) { self.rawValue = rawValue } - public static let top = MorphToolbarSafeAreaExclusion(rawValue: 1 << 0) - public static let bottom = MorphToolbarSafeAreaExclusion(rawValue: 1 << 1) - public static let both: MorphToolbarSafeAreaExclusion = [.top, .bottom] + public static let top = ToolbarSafeAreaExclusion(rawValue: 1 << 0) + public static let bottom = ToolbarSafeAreaExclusion(rawValue: 1 << 1) + public static let both: ToolbarSafeAreaExclusion = [.top, .bottom] } -public enum MorphSafeAreaPadding: CGFloat, Equatable, Sendable { +public enum SafeAreaPadding: CGFloat, Equatable, Sendable { case none = 0 case small = 8 case medium = 16 case large = 24 } -public struct MorphSafeAreaPolicy: Equatable, Sendable { - public var excluding: MorphToolbarSafeAreaExclusion - public var padding: MorphSafeAreaPadding +public struct SafeAreaPolicy: Equatable, Sendable { + public var excluding: ToolbarSafeAreaExclusion + public var padding: SafeAreaPadding public var includesSystemInsets: Bool - public init(excluding: MorphToolbarSafeAreaExclusion = .both, - padding: MorphSafeAreaPadding = .small, + public init(excluding: ToolbarSafeAreaExclusion = .both, + padding: SafeAreaPadding = .small, includesSystemInsets: Bool = true) { self.excluding = excluding self.padding = padding self.includesSystemInsets = includesSystemInsets } - public static let fullBleed = MorphSafeAreaPolicy(excluding: [], padding: .none) - public static let toolbarChrome = MorphSafeAreaPolicy(excluding: [], padding: .small) - public static let content = MorphSafeAreaPolicy(excluding: .both, padding: .medium) + public static let fullBleed = SafeAreaPolicy(excluding: [], padding: .none) + public static let toolbarChrome = SafeAreaPolicy(excluding: [], padding: .small) + public static let content = SafeAreaPolicy(excluding: .both, padding: .medium) } -public struct MorphSafeAreaManager: Equatable, Sendable { +public struct SafeAreaManager: Equatable, Sendable { public var system: EdgeInsets public var topToolbarHeight: CGFloat public var bottomToolbarHeight: CGFloat @@ -54,7 +54,7 @@ public struct MorphSafeAreaManager: Equatable, Sendable { self.init(system: system, topToolbarHeight: toolbarHeight, bottomToolbarHeight: 0) } - public func insets(_ policy: MorphSafeAreaPolicy = .content) -> EdgeInsets { + public func insets(_ policy: SafeAreaPolicy = .content) -> EdgeInsets { let padding = policy.padding.rawValue let systemInsets = policy.includesSystemInsets ? system : EdgeInsets() // On an edge that excludes its toolbar, the band *is* the inset — the padding doesn't stack on @@ -65,7 +65,7 @@ public struct MorphSafeAreaManager: Equatable, Sendable { trailing: systemInsets.trailing + padding) } - public func bounds(in size: CGSize, policy: MorphSafeAreaPolicy = .content) -> CGRect { + public func bounds(in size: CGSize, policy: SafeAreaPolicy = .content) -> CGRect { let safeInsets = insets(policy) return CGRect(x: safeInsets.leading, y: safeInsets.top, @@ -75,5 +75,5 @@ public struct MorphSafeAreaManager: Equatable, Sendable { } public extension EnvironmentValues { - @Entry var morphSafeAreas = MorphSafeAreaManager() + @Entry var morphSafeAreaManager = SafeAreaManager() } diff --git a/Packages/ContainedUX/Sources/ContainedUX/UX.swift b/Packages/ContainedUX/Sources/ContainedUX/UX.swift new file mode 100644 index 00000000..ffe7872f --- /dev/null +++ b/Packages/ContainedUX/Sources/ContainedUX/UX.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// Root namespace for reusable interaction, placement, and morphing systems. +public enum UX {} + +public extension UX { + enum Toolbar {} + enum Panel {} + enum Morph {} + enum SafeArea {} + enum Measurement {} +} + +public extension UX.Panel { + typealias Placement = PanelPlacement + typealias BackdropStyle = PanelBackdropStyle +} + +public extension UX.Morph { + typealias Target = MorphTargetConfig + typealias Geometry = MorphGeometryEngine + typealias Frame = MorphFrameGeometry + typealias Expander = MorphExpander + typealias SingleSurface = MorphSingleSurface + typealias SingleSurfaceExpander = MorphSingleSurfaceExpander +} + +public extension UX.SafeArea { + typealias ToolbarExclusion = ToolbarSafeAreaExclusion + typealias Padding = SafeAreaPadding + typealias Policy = SafeAreaPolicy + typealias Manager = SafeAreaManager +} + +public extension UX.Measurement { + typealias SourceFrameReader = ContainedUX.SourceFrameReader + typealias SourceFramesKey = ContainedUX.SourceFramesKey +} diff --git a/Packages/ContainedNavigation/Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift b/Packages/ContainedUX/Tests/ContainedUXTests/SingleSurfaceTests.swift similarity index 94% rename from Packages/ContainedNavigation/Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift rename to Packages/ContainedUX/Tests/ContainedUXTests/SingleSurfaceTests.swift index 99580581..245fcf99 100644 --- a/Packages/ContainedNavigation/Tests/ContainedNavigationTests/MorphingSingleSurfaceTests.swift +++ b/Packages/ContainedUX/Tests/ContainedUXTests/SingleSurfaceTests.swift @@ -1,9 +1,9 @@ import CoreGraphics import Testing -@testable import ContainedNavigation +@testable import ContainedUX @Suite("Single surface morph geometry") -struct MorphingSingleSurfaceTests { +struct SingleSurfaceTests { @Test func interpolatesBetweenSourceAndTarget() { let source = CGRect(x: 10, y: 20, width: 100, height: 60) let target = CGRect(x: 40, y: 80, width: 220, height: 300) diff --git a/README.md b/README.md index fb3cf684..772d8fea 100644 --- a/README.md +++ b/README.md @@ -95,16 +95,20 @@ instead. The root package contains the app launcher and app implementation, then consumes standalone local packages: -- [`ContainedCore`](Packages/ContainedCore/README.md): pure models, runtime-neutral create/recreate request fields, Apple `container` argv builders, decoders, compose parsing, metric normalization, and package error metadata. -- [`ContainedRuntime`](Packages/ContainedRuntime/README.md): shared runtime contracts, descriptors, capabilities, translation plans, command errors, and command execution primitives. -- [`AppleContainerRuntime`](Packages/AppleContainerRuntime/README.md): the current Apple `container` adapter, including translation from shared create/import models to Apple CLI commands. Future Docker-compatible, Podman, Lima-backed, remote, or other runtime engines should be sibling adapter packages. -- [`ContainedDesignSystem`](Packages/ContainedDesignSystem/README.md): reusable SwiftUI/AppKit visual primitives, tokens, spacing, material, cards, panels, controls, feedback, and data visualization. -- [`ContainedNavigation`](Packages/ContainedNavigation/README.md): reusable safe-area, morphing, measurement, and panel-host infrastructure. -- [`ContainedPreviewSupport`](Packages/ContainedPreviewSupport/README.md): deterministic fixtures for package examples and SwiftUI previews. -- `ContainedApp`: SwiftUI app shell, navigation, feature views, stores, history, settings, Sparkle support, app state migration, app-specific presentation mappings, and localization. +- [`ContainedCore`](Packages/ContainedCore/README.md): the backend/orchestration package. It exposes `Core.*` APIs for runtime descriptors/capabilities, canonical container models, command previews, Compose import/export semantics, Apple `container` adapter internals, metrics, typed errors, and future runtime migration planning. +- [`ContainedUI`](Packages/ContainedUI/README.md): reusable SwiftUI/AppKit visual primitives, tokens, spacing, material, cards, panels, controls, feedback, and data visualization. +- [`ContainedUX`](Packages/ContainedUX/README.md): reusable safe-area, morphing, measurement, and panel-host infrastructure. +- `ContainedApp`: SwiftUI app shell, navigation, feature views, stores, history, settings, Sparkle support, app state migration, app-specific presentation mappings, localization, and the join point between Core/UI/UX. - `Contained`: tiny SwiftPM executable launcher used by command-line builds and bundle scripts. -Integration is intentionally CLI-based rather than private-framework based. Personalization and app-managed metadata stay local to Contained so the user's container resources remain clean when used directly from the terminal. +Ownership shorthand: UI owns visuals, UX owns interaction/morph/panel movement, +Core owns backend orchestration, and ContainedApp joins those packages with +localization, persistence, settings, routing, and feature policy. +Core also exposes a separate `ContainedCoreFixtures` product for deterministic +test/preview data under `Core.Fixtures.*`; normal app and distributable bundle +targets do not link it. + +Integration is intentionally CLI-based rather than private-framework based. The app talks to `Core.Orchestrator`; Core owns adapter-specific argv/process details. Personalization and app-managed metadata stay local to Contained so the user's container resources remain clean when used directly from the terminal. Reusable packages expose display-neutral errors with stable package codes/context; the app owns localized messages, alerts, and Activity history presentation. ## License diff --git a/Sources/ContainedApp/Stores/AppModel+Configuration.swift b/Sources/ContainedApp/App/AppModel+Configuration.swift similarity index 97% rename from Sources/ContainedApp/Stores/AppModel+Configuration.swift rename to Sources/ContainedApp/App/AppModel+Configuration.swift index 3a9c61e7..e286c9fe 100644 --- a/Sources/ContainedApp/Stores/AppModel+Configuration.swift +++ b/Sources/ContainedApp/App/AppModel+Configuration.swift @@ -67,7 +67,7 @@ extension AppModel { liveImageRefs: liveImageRefs) let checks = healthChecks.purgeOrphans(liveContainerIDs: liveContainerIDs) let history = historyStore.purgeOrphans(liveContainerIDs: liveContainerIDs) - flash(AppText.cleanedStaleRows(personalizations + checks + history.events + history.metrics)) + flash(AppText.cleanedOrphanedRows(personalizations + checks + history.events + history.metrics)) } private func apply(envelope: AppStateEnvelope, selected: Set, replace: Bool) throws { diff --git a/Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift b/Sources/ContainedApp/App/AppModel+ImageUpdates.swift similarity index 99% rename from Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift rename to Sources/ContainedApp/App/AppModel+ImageUpdates.swift index f225106a..61fbda13 100644 --- a/Sources/ContainedApp/Stores/AppModel+ImageUpdates.swift +++ b/Sources/ContainedApp/App/AppModel+ImageUpdates.swift @@ -1,6 +1,5 @@ import SwiftUI import ContainedCore -import ContainedRuntime private enum ImageUpdateSummaryScope { case localImages diff --git a/Sources/ContainedApp/Stores/AppModel+ResourceStyles.swift b/Sources/ContainedApp/App/AppModel+ResourceStyles.swift similarity index 100% rename from Sources/ContainedApp/Stores/AppModel+ResourceStyles.swift rename to Sources/ContainedApp/App/AppModel+ResourceStyles.swift diff --git a/Sources/ContainedApp/Stores/AppModel.swift b/Sources/ContainedApp/App/AppModel.swift similarity index 92% rename from Sources/ContainedApp/Stores/AppModel.swift rename to Sources/ContainedApp/App/AppModel.swift index d3323a9e..c5e67357 100644 --- a/Sources/ContainedApp/Stores/AppModel.swift +++ b/Sources/ContainedApp/App/AppModel.swift @@ -1,10 +1,8 @@ import SwiftUI import ContainedCore -import ContainedRuntime -import AppleContainerRuntime import OSLog -/// Root app state: locates the CLI, owns the typed client and the feature stores, and tracks the +/// Root app state: locates the CLI through Core, owns the backend orchestrator and feature stores, and tracks the /// service/CLI bootstrap status that gates plugin-dependent screens. @MainActor @Observable @@ -33,7 +31,7 @@ final class AppModel { let manifestClient = RegistryManifestClient() private(set) var bootstrap: Bootstrap = .checking - private(set) var client: (any ContainerRuntimeClient)? + private(set) var client: Core.Orchestrator? /// Resolved path to the `container` binary — needed to spawn the terminal's `exec` process. private(set) var cliURL: URL? private(set) var systemStatus: SystemStatus? @@ -96,7 +94,7 @@ final class AppModel { ?? ProcessInfo.processInfo.physicalMemory ) } - var availableRuntimeDescriptors: [RuntimeDescriptor] { + var availableRuntimeDescriptors: [Core.Runtime.Descriptor] { [.appleContainer] } var runtimeCoreSelectorIsEnabled: Bool { @@ -168,26 +166,32 @@ final class AppModel { func bootstrapIfNeeded() async { logger.record("Checking container CLI", category: .system, severity: .debug) - guard let url = AppleContainerCLILocator.locate(override: settings.cliPathOverride) else { + let configuration = Core.Configuration(appleContainer: .init(cliPathOverride: settings.cliPathOverride)) + let result = await Core.Orchestrator.bootstrap(configuration: configuration) + if case .cliMissing = result { bootstrap = .cliMissing logger.record("Container CLI missing", category: .system, severity: .error) return } - let runner = CommandRunner(executableURL: url) - let client = AppleContainerClient(runner: runner) - self.client = client - self.cliURL = url - containers.client = client - - // Version check. - if let versionData = try? await runner.run(ContainerCommands.version) { - let raw = String(decoding: versionData, as: UTF8.self) - cliVersion = AppleContainerCLILocator.parseVersion(raw) - if let v = cliVersion, !AppleContainerCLILocator.isSupported(v) { - bootstrap = .unsupported(version: v) - logger.record("Unsupported container CLI version \(v)", category: .system, severity: .error) - return - } + + switch result { + case .cliMissing: + bootstrap = .cliMissing + logger.record("Container CLI missing", category: .system, severity: .error) + return + case .unsupported(let orchestrator, let url, let version): + client = orchestrator + cliURL = url + cliVersion = version + containers.client = orchestrator + bootstrap = .unsupported(version: version) + logger.record("Unsupported container CLI version \(version)", category: .system, severity: .error) + return + case .ready(let orchestrator, let url, let version): + client = orchestrator + cliURL = url + cliVersion = version + containers.client = orchestrator } await refreshSystem() @@ -228,12 +232,12 @@ final class AppModel { } } - func runtimeDescriptor(for kind: RuntimeKind) -> RuntimeDescriptor { + func runtimeDescriptor(for kind: Core.Runtime.Kind) -> Core.Runtime.Descriptor { availableRuntimeDescriptors.first { $0.kind == kind } ?? .appleContainer } - func runtimeClient(for kind: RuntimeKind) -> (any ContainerRuntimeClient)? { - guard let client, client.descriptor.kind == kind else { return nil } + func core(for kind: Core.Runtime.Kind) -> Core.Orchestrator? { + guard let client, client.supportsRuntime(kind) else { return nil } return client } @@ -255,9 +259,6 @@ final class AppModel { await refreshDiskUsage() // throttled; the System panel can force a fresh read await containers.refresh() updateContainerStatsStream() - // One-time: import legacy contained.* card styles into the local store, now that we - // no longer write personalization labels. - personalization.migrateLegacyLabelsIfNeeded(containers.snapshots) } else { bootstrap = .serviceStopped stopContainerStatsStream() @@ -296,7 +297,7 @@ final class AppModel { /// Keep it warm app-wide, but throttle background ticks to avoid spawning a process each poll. private static let imagesThrottle: TimeInterval = 15 private var lastImagesDate: Date? - func refreshImagesIfStale(force: Bool = false) async { + func refreshImagesIfNeeded(force: Bool = false) async { guard let client, bootstrap == .ready else { return } if !force, let last = lastImagesDate, Date().timeIntervalSince(last) < Self.imagesThrottle { return } let started = Date() @@ -304,7 +305,7 @@ final class AppModel { lastImagesDate = Date() let elapsed = Date().timeIntervalSince(started) if elapsed >= 0.75 || force { - let mode = force ? "force" : "stale" + let mode = force ? "force" : "scheduled" diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, "Image refresh \(mode, privacy: .public) finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s with \(self.images.count, privacy: .public) image(s)") } @@ -317,12 +318,12 @@ final class AppModel { } func previewCreateCommand(for spec: RunSpec) -> [String] { - (try? runtimeClient(for: spec.effectiveRuntimeKind)?.previewCreateCommand(for: spec.createRequest).command) + (try? core(for: spec.effectiveRuntimeKind)?.previewCreateCommand(for: spec.createRequest).command) ?? spec.arguments() } func imageDefaults(for spec: RunSpec) -> ContainerImageDefaults? { - guard let client = runtimeClient(for: spec.effectiveRuntimeKind) else { return nil } + guard let client = core(for: spec.effectiveRuntimeKind) else { return nil } return try? client.imageDefaults(for: spec.createRequest, in: images) } @@ -350,7 +351,7 @@ final class AppModel { await refreshNetworks() // Keep the image list warm app-wide (throttled), so the toolbar Images panel and the // update badges populate without first opening the Images panel. - await refreshImagesIfStale() + await refreshImagesIfNeeded() await checkImageUpdatesIfNeeded() let elapsed = Date().timeIntervalSince(started) if elapsed >= 0.75 { @@ -469,8 +470,8 @@ final class AppModel { /// nothing while the image silently downloads. Attaches local style + healthcheck on success. @discardableResult func createContainer(_ spec: RunSpec) async -> String? { - guard runtimeClient(for: spec.effectiveRuntimeKind) != nil else { - let error = UnsupportedRuntimeCapability(kind: spec.effectiveRuntimeKind, capability: .containers) + guard core(for: spec.effectiveRuntimeKind) != nil else { + let error = Core.Runtime.UnsupportedCapability(kind: spec.effectiveRuntimeKind, capability: .containers) createError = error.appDisplayMessage logger.recordFailure("Create requested unavailable runtime", error: error, @@ -501,8 +502,8 @@ final class AppModel { /// deleting the current container so an unavailable image does not strand the edit flow. @discardableResult func recreateContainer(originalID: String, spec: RunSpec) async -> String? { - guard runtimeClient(for: spec.effectiveRuntimeKind) != nil else { - let error = UnsupportedRuntimeCapability(kind: spec.effectiveRuntimeKind, capability: .containers) + guard core(for: spec.effectiveRuntimeKind) != nil else { + let error = Core.Runtime.UnsupportedCapability(kind: spec.effectiveRuntimeKind, capability: .containers) flash(error.appDisplayMessage) logger.recordFailure("Recreate requested unavailable runtime", error: error, @@ -542,7 +543,7 @@ final class AppModel { category: .image, severity: .error) } else { - await refreshImagesIfStale(force: true) + await refreshImagesIfNeeded(force: true) flash(AppText.loadedFile(url.lastPathComponent)) logger.record("Loaded image archive \(url.lastPathComponent)", category: .image) } @@ -610,7 +611,7 @@ final class AppModel { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { activity?.detail = trimmed } } - await refreshImagesIfStale(force: true) + await refreshImagesIfNeeded(force: true) logger.record("Pulled \(Format.shortImage(reference))", category: .image) return true } catch { @@ -675,7 +676,7 @@ final class AppModel { /// feedback, optionally resets the restart watchdog, runs each typed runtime action in order, /// then re-reads service status. Failures are intentionally ignored because `refreshSystem` /// reports the resulting state regardless. - private func runServiceLifecycle(_ actions: [RuntimeSystemAction], resetWatchdog: Bool) async { + private func runServiceLifecycle(_ actions: [Core.Runtime.SystemAction], resetWatchdog: Bool) async { guard let client else { return } bootstrap = .checking if resetWatchdog { watchdog.reset() } diff --git a/Sources/ContainedApp/ContainedApp.swift b/Sources/ContainedApp/App/ContainedApp.swift similarity index 97% rename from Sources/ContainedApp/ContainedApp.swift rename to Sources/ContainedApp/App/ContainedApp.swift index b3fcfc9c..f2495170 100644 --- a/Sources/ContainedApp/ContainedApp.swift +++ b/Sources/ContainedApp/App/ContainedApp.swift @@ -1,8 +1,7 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import AppKit import ContainedCore -import AppleContainerRuntime public struct ContainedApplication: App { @State private var app = AppModel() @@ -116,7 +115,7 @@ public struct ContainedApplication: App { CommandGroup(replacing: .toolbar) { Toggle("Show Running Only", isOn: runningOnlyBinding) Picker("Card Size", selection: cardSizeBinding) { - ForEach(CardDensity.allCases) { Text($0.localizedDisplayName).tag($0) } + ForEach(UI.Card.Density.allCases) { Text($0.localizedDisplayName).tag($0) } } Divider() Button("Reload") { app.coordinator.wake() } @@ -187,7 +186,7 @@ public struct ContainedApplication: App { Binding(get: { app.settings.keepInMenuBar }, set: { app.settings.keepInMenuBar = $0 }) } - private var cardSizeBinding: Binding { + private var cardSizeBinding: Binding { Binding(get: { app.settings.density }, set: { app.settings.density = $0 }) } @@ -236,7 +235,7 @@ public struct ContainedApplication: App { /// Reveal the resolved `container` binary in Finder (honoring the CLI-path override). private func revealCLIBinary() { - guard let url = AppleContainerCLILocator.locate(override: app.settings.cliPathOverride) else { return } + guard let url = app.client?.cliURL else { return } NSWorkspace.shared.activateFileViewerSelecting([url]) } diff --git a/Sources/ContainedApp/History/ActivityView.swift b/Sources/ContainedApp/Features/Activity/ActivityView.swift similarity index 84% rename from Sources/ContainedApp/History/ActivityView.swift rename to Sources/ContainedApp/Features/Activity/ActivityView.swift index 164f8f9e..1a4a90f5 100644 --- a/Sources/ContainedApp/History/ActivityView.swift +++ b/Sources/ContainedApp/Features/Activity/ActivityView.swift @@ -1,6 +1,6 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import SwiftData /// System-wide activity log: every recorded event across all containers, newest first, filterable @@ -10,7 +10,7 @@ struct ActivityView: View { var body: some View { ActivityContent(showClose: true) { dismiss() } - .frame(DesignTokens.SheetSize.wide) + .frame(UI.Panel.SheetSize.wide) .sheetMaterial() } } @@ -71,7 +71,7 @@ struct ActivityContent: View { } .pickerStyle(.inline) } label: { - DesignMenuActionLabel(systemName: ui.activityFilter == nil ? "line.3.horizontal.decrease" + UI.Action.MenuLabel(systemName: ui.activityFilter == nil ? "line.3.horizontal.decrease" : "line.3.horizontal.decrease.circle.fill", help: ui.activityFilter == nil ? AppText.string("activity.filter", defaultValue: "Filter") @@ -86,15 +86,15 @@ struct ActivityContent: View { } var body: some View { - DesignPanelScaffold(width: DesignTokens.PanelSize.activity.width) { + UI.Panel.Scaffold(width: UI.Panel.Size.activity.width) { if showsHeader { VStack(spacing: 0) { - PanelHeader(symbol: "bell", + UI.Panel.Header(symbol: "bell", title: AppText.sectionActivity, subtitle: subtitle) { - DesignActionCluster { + UI.Action.Cluster { filterMenu - DesignActionItems(activityHeaderActions) + UI.Action.Items(activityHeaderActions) } } Divider() @@ -102,32 +102,30 @@ struct ActivityContent: View { } } content: { if filtered.isEmpty { - ContentUnavailableView(AppText.string("activity.empty", defaultValue: "No activity"), - systemImage: "bell", - description: Text(AppText.string("activity.empty.description", defaultValue: "Events from container lifecycle, the watchdog, and healthchecks land here."))) - .padding(.vertical, DesignTokens.Space.xl) + UI.State.Empty(AppText.string("activity.empty", defaultValue: "No activity"), + systemImage: "bell", + description: AppText.string("activity.empty.description", defaultValue: "Events from container lifecycle, the watchdog, and healthchecks land here.")) } else { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { + UI.List.Stack { ForEach(filtered) { event in EventRow(event: event, elevated: elevated, isUnread: !event.isRead) } } - .padding(DesignTokens.Space.s) } } // Once the user has seen the panel, the events are read — clears the toolbar badge on dismiss. .onDisappear(perform: markAllRead) } - private var activityHeaderActions: [DesignAction] { + private var activityHeaderActions: [UI.Action.Item] { var actions = [ - DesignAction(systemName: "checkmark.circle", + UI.Action.Item(systemName: "checkmark.circle", help: ui.activityFilter == nil ? AppText.string("activity.markAllRead.help", defaultValue: "Mark all as read") : AppText.string("activity.markFilteredRead.help", defaultValue: "Mark \(ui.activityFilter!.rawValue.capitalized) as read"), isEnabled: filteredUnreadCount > 0, action: markFilteredRead), - DesignAction(systemName: "trash", + UI.Action.Item(systemName: "trash", help: ui.activityFilter == nil ? AppText.clearActivity : AppText.string("activity.clearFiltered.help", defaultValue: "Clear \(ui.activityFilter!.rawValue.capitalized) events"), @@ -136,7 +134,7 @@ struct ActivityContent: View { action: clearFiltered) ] if showClose { - actions.append(DesignAction(systemName: "xmark", + actions.append(UI.Action.Item(systemName: "xmark", help: AppText.close, isCancel: true, action: onClose)) diff --git a/Sources/ContainedApp/History/ContainerHistoryTab.swift b/Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift similarity index 80% rename from Sources/ContainedApp/History/ContainerHistoryTab.swift rename to Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift index 14c12973..5b756350 100644 --- a/Sources/ContainedApp/History/ContainerHistoryTab.swift +++ b/Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import SwiftData import Charts import ContainedCore @@ -31,7 +31,7 @@ struct ContainerHistoryTab: View { var body: some View { ContainerTabScaffold { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { Picker("Range", selection: $range) { ForEach(HistoryRange.allCases) { Text($0.rawValue).tag($0) } } @@ -71,46 +71,50 @@ private struct ContainerHistoryWindow: View { snapshot: snapshot, normalization: normalization) - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.l) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.l) { if chartPoints.isEmpty { - ContentUnavailableView("No history yet", systemImage: "chart.xyaxis.line", - description: Text("Resource samples accumulate while the container runs.")) - .frame(maxWidth: .infinity, minHeight: 200) + UI.State.Empty(AppText.string("history.empty", defaultValue: "No history yet"), + systemImage: "chart.xyaxis.line", + description: AppText.string("history.empty.description", defaultValue: "Resource samples accumulate while the container runs."), + minHeight: UI.Chart.Size.emptyHeight) } else { chartCard("CPU", unit: percentUnit) { Chart(chartPoints) { point in - LineMark(x: .value("Time", point.timestamp), - y: .value("CPU", point.cpuPercent)) - .foregroundStyle(Color.accentColor) - .interpolationMethod(.monotone) + UI.Chart.Style.primaryLine( + LineMark(x: .value("Time", point.timestamp), + y: .value("CPU", point.cpuPercent)) + ) } .percentHistoryScale() } chartCard("Memory", unit: percentUnit) { Chart(chartPoints) { point in - AreaMark(x: .value("Time", point.timestamp), - y: .value("Memory", point.memoryPercent)) - .foregroundStyle(Color.accentColor.opacity(DesignTokens.Chart.areaOpacity)) + UI.Chart.Style.primaryArea( + AreaMark(x: .value("Time", point.timestamp), + y: .value("Memory", point.memoryPercent)) + ) } .percentHistoryScale() } chartCard("Network", unit: "KB/s") { Chart(chartPoints) { point in - LineMark(x: .value("Time", point.timestamp), - y: .value("Rx", point.netRxKBPerSec), - series: .value("Dir", "Rx")) - .foregroundStyle(.green) - LineMark(x: .value("Time", point.timestamp), - y: .value("Tx", point.netTxKBPerSec), - series: .value("Dir", "Tx")) - .foregroundStyle(.orange) + UI.Chart.Style.successLine( + LineMark(x: .value("Time", point.timestamp), + y: .value("Rx", point.netRxKBPerSec), + series: .value("Dir", "Rx")) + ) + UI.Chart.Style.warningLine( + LineMark(x: .value("Time", point.timestamp), + y: .value("Tx", point.netTxKBPerSec), + series: .value("Dir", "Tx")) + ) } } } if !events.isEmpty { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { - Text("Events").font(.headline) + UI.List.Stack(padding: 0) { + Text(AppText.string("history.events", defaultValue: "Events")).designHeadlineLabelStyle() ForEach(events.prefix(50)) { event in EventRow(event: event) } @@ -122,13 +126,13 @@ private struct ContainerHistoryWindow: View { private func chartCard(_ title: String, unit: String, @ViewBuilder chart: @escaping () -> C) -> some View { ContainerTabSection { HStack { - Text(title).font(.subheadline.weight(.semibold)) + Text(title).designSubheadlineLabelStyle() Spacer() - Text(unit).font(.caption).foregroundStyle(.secondary) + Text(unit).designSecondaryCaption() } chart() - .frame(height: DesignTokens.Chart.height) - .chartXAxis { AxisMarks(values: .automatic(desiredCount: DesignTokens.Chart.axisDesiredCount)) } + .frame(height: UI.Chart.Size.height) + .chartXAxis { AxisMarks(values: .automatic(desiredCount: UI.Chart.Rendering.axisDesiredCount)) } .transaction { transaction in transaction.animation = nil } } } @@ -212,21 +216,21 @@ struct EventRow: View { @Environment(\.modelContext) private var modelContext var body: some View { - DesignCard(size: .small, + UI.Card.Scaffold(size: .small, isSelected: isUnread, elevated: elevated, title: event.message, subtitle: subtitle) { - DesignCardIconChip(symbol: event.kind.symbol, + UI.Card.IconChip(symbol: event.kind.symbol, tint: event.kind.tint, - backgroundOpacity: DesignTokens.DesignCard.iconEmphasisBackgroundOpacity) + backgroundOpacity: UI.Card.Metric.iconEmphasisBackgroundOpacity) } titleAccessory: { EmptyView() } subtitleAccessory: { - DesignBadgeText(text: event.kind.rawValue.capitalized) + UI.Badge.Text(text: event.kind.rawValue.capitalized) } headerAccessory: { if isUnread { - DesignStatusDot(color: .accentColor) + UI.Badge.Dot(color: .accentColor) .accessibilityLabel(AppText.unread) } else { EmptyView() diff --git a/Sources/ContainedApp/Features/Containers/ContainerCard.swift b/Sources/ContainedApp/Features/Containers/Card/ContainerCard.swift similarity index 91% rename from Sources/ContainedApp/Features/Containers/ContainerCard.swift rename to Sources/ContainedApp/Features/Containers/Card/ContainerCard.swift index f0d06d43..e4946199 100644 --- a/Sources/ContainedApp/Features/Containers/ContainerCard.swift +++ b/Sources/ContainedApp/Features/Containers/Card/ContainerCard.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore /// A personalized clear-glass card for one container. The same component renders both the compact @@ -8,12 +8,12 @@ struct ContainerCard: View { let snapshot: ContainerSnapshot var style: Personalization var hasStyleOverride: Bool = true - var density: CardDensity + var density: UI.Card.Density var stats: StatsDelta? var statsNormalization: StatsNormalizationContext = .containerSpecific /// Every metric's recent history, so the footer's widget chips can flip the graph instantly /// without borrowing another metric's samples. - var histories: [GraphMetric: SampleBuffer] = [:] + var histories: [GraphMetric: UI.Chart.SampleBuffer] = [:] var isBusy: Bool var hasImageUpdate: Bool = false var isExpanded: Bool = false @@ -86,7 +86,7 @@ struct ContainerCard: View { requested: activeWidget.secondaryMetric, options: GraphMetric.allCases) } - private var cardSize: DesignCardSize { density.resourceSize } + private var cardSize: UI.Card.Size { density.resourceSize } var body: some View { Group { @@ -128,7 +128,7 @@ struct ContainerCard: View { } private var cardSurface: some View { - DesignCard(size: cardSize, + UI.Card.Scaffold(size: cardSize, isExpanded: isExpanded, cornerRadiusOverride: cornerRadiusOverride, controlsVisible: controlsVisible, @@ -162,7 +162,7 @@ struct ContainerCard: View { } footerActions: { footerActions } widget: { - LiveSparkline(samples: histories[activeWidget.metric]?.values ?? [], + UI.Chart.Sparkline(samples: histories[activeWidget.metric]?.values ?? [], comparisonSamples: activeWidgetComparisonMetric.flatMap { histories[$0]?.values } ?? [], color: activeWidgetColor, lineWidth: activeWidget.lineWidth, @@ -174,13 +174,13 @@ struct ContainerCard: View { scale: sparklineScale(for: activeWidget.metric), comparisonScale: activeWidgetComparisonMetric.map(sparklineScale(for:))) .frame(maxWidth: .infinity) - .frame(height: DesignTokens.DesignCard.sparklineHeight) + .frame(height: UI.Card.Metric.sparklineHeight) } .designCardProgressOverlay(when: isBusy) } - private var cardPages: DesignCardPages { - DesignCardPages(items: pageControlItems, + private var cardPages: UI.Card.Pages { + UI.Card.Pages(items: pageControlItems, selection: tab, tint: tint, controlsReveal: isExpanded && controlsVisible ? 1 : 0, @@ -197,16 +197,16 @@ struct ContainerCard: View { } private var statusChip: some View { - DesignCardFooterMini { + UI.Card.FooterMini { if styleForDisplay.showStatusIcon { - Image(systemName: statusSymbol) - .font(.caption2) - .foregroundStyle(statusColor) + UI.Symbol.Image(systemName: statusSymbol, + tone: statusTone, + size: .caption2) } } text: { if styleForDisplay.showStatusText { - DesignCardMetricText(text: statusLabel) - .foregroundStyle(.secondary) + UI.Card.MetricText(text: statusLabel) + .designSecondaryValueStyle() } } .help(statusHelp) @@ -247,13 +247,13 @@ struct ContainerCard: View { } } - private var statusColor: Color { + private var statusTone: UI.State.Tone { switch statusState { - case .online: return .green - case .restarting: return .orange - case .stopped: return .secondary - case .unknown: return .gray - case .failed: return .red + case .online: return .success + case .restarting: return .warning + case .stopped: return .neutral + case .unknown: return .tertiary + case .failed: return .error } } @@ -295,9 +295,9 @@ struct ContainerCard: View { Button(role: .destructive) { onDelete() } label: { Label("Delete", systemImage: "trash") } } - private var pageControlItems: [DesignCardPageControlItem] { + private var pageControlItems: [UI.Card.Page] { Tab.allCases.map { item in - DesignCardPageControlItem(id: item, + UI.Card.Page(id: item, title: item.rawValue, systemImage: item.systemImage) } @@ -345,7 +345,7 @@ struct ContainerCard: View { private func widgetChip(_ index: Int) -> some View { let widget = styleForDisplay.widget(at: index) let active = index == activeWidgetIndex - return DesignCardFooterChip(isSelected: active, + return UI.Card.FooterChip(isSelected: active, tint: widget.tint?.color ?? tint, help: widget.metric.displayName, action: { @@ -356,11 +356,12 @@ struct ContainerCard: View { } }) { if widget.showIcon { - Image(systemName: widget.resolvedSystemImage).font(.caption2) + UI.Symbol.Image(systemName: widget.resolvedSystemImage, + size: .caption2) } } text: { if widget.showText { - DesignCardMetricText(text: stats.map { + UI.Card.MetricText(text: stats.map { widget.metric.chipCaption(from: $0, snapshot: snapshot, normalization: statsNormalization) @@ -369,7 +370,7 @@ struct ContainerCard: View { } } - private func sparklineScale(for metric: GraphMetric) -> SparklineScale { + private func sparklineScale(for metric: GraphMetric) -> UI.Chart.Scale { switch metric { case .cpu, .memory: return .fraction case .netRx, .netTx, .diskRead, .diskWrite: return .normalized @@ -393,7 +394,7 @@ struct ContainerCard: View { private func footerAction(_ systemName: String, help: String, tint: Color? = nil, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { - DesignCardFooterButton(systemName: systemName, + UI.Card.FooterButton(systemName: systemName, help: help, tint: tint, role: role, diff --git a/Sources/ContainedApp/Features/Containers/ContainersGridView.swift b/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift similarity index 85% rename from Sources/ContainedApp/Features/Containers/ContainersGridView.swift rename to Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift index f8fd1101..a40411f3 100644 --- a/Sources/ContainedApp/Features/Containers/ContainersGridView.swift +++ b/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift @@ -1,9 +1,8 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import AppKit import ContainedCore -import ContainedRuntime /// The Containers screen: a responsive grid of personalized glass cards. Density and the running /// filter live in the background context menu and menu commands; tapping a card grows it in place @@ -11,7 +10,7 @@ import ContainedRuntime struct ContainersGridView: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - @Environment(\.morphSafeAreas) private var safeAreas + @Environment(\.morphSafeAreaManager) private var safeAreaManager @State private var detail: ContainerSnapshot? @State private var deleting: ContainerSnapshot? @@ -147,23 +146,23 @@ struct ContainersGridView: View { } private var columns: [GridItem] { - return [GridItem(.adaptive(minimum: DesignTokens.CardSize.largeMin, maximum: DesignTokens.CardSize.largeMax), - spacing: DesignTokens.Space.m)] + return [GridItem(.adaptive(minimum: UI.Card.Grid.largeMin, maximum: UI.Card.Grid.largeMax), + spacing: UI.Layout.Spacing.m)] } private var filtered: [ContainerSnapshot] { store.snapshots.filter { snapshot in (!ui.runningOnly || snapshot.state == .running) && - (ui.searchText.isEmpty - || snapshot.displayName.localizedCaseInsensitiveContains(ui.searchText) - || snapshot.image.localizedCaseInsensitiveContains(ui.searchText)) + (ui.search.text.isEmpty + || snapshot.displayName.localizedCaseInsensitiveContains(ui.search.text) + || snapshot.image.localizedCaseInsensitiveContains(ui.search.text)) } } var body: some View { @Bindable var ui = ui return GeometryReader { viewport in - let scrollBounds = safeAreas.bounds(in: viewport.size, policy: .content) + let scrollBounds = safeAreaManager.bounds(in: viewport.size, policy: .content) ZStack { ScrollView { ZStack(alignment: .top) { @@ -174,17 +173,17 @@ struct ContainersGridView: View { .frame(maxWidth: .infinity, minHeight: scrollBounds.height) .contentShape(Rectangle()) .onTapGesture(count: 2) { zoomFrontWindow() } - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.l) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.l) { ForEach(groups) { group in groupSection(group) } Color.clear - .frame(height: DesignTokens.Toolbar.band) + .frame(height: UI.Toolbar.Size.band) } - .padding(.horizontal, DesignTokens.Space.l) + .padding(.horizontal, UI.Layout.Spacing.l) } } - .contentMargins(.top, ui.toolbarUIEnabled ? 0 : DesignTokens.Toolbar.band, for: .scrollContent) + .contentMargins(.top, ui.toolbarUIEnabled ? 0 : UI.Toolbar.Size.band, for: .scrollContent) if detail != nil { Color.clear @@ -197,7 +196,7 @@ struct ContainersGridView: View { if let detail { let target = cardDetailTarget.rect(origin: .zero, in: viewport.size, - safeAreas: cardDetailSafeAreas) + safeAreaManager: cardDetailSafeAreaManager) let source = cardFrames[detail.id].flatMap { $0.isUsableForMorph ? $0 : nil } ?? target let rect = expanded ? target : source expandedCard(detail) @@ -209,7 +208,7 @@ struct ContainersGridView: View { .coordinateSpace(.named("grid")) } .overlay(alignment: .bottom) { - if selecting && !selection.isEmpty { batchBar } else if let message = store.errorMessage { ErrorToast(message: message) } + if selecting && !selection.isEmpty { batchBar } else if let message = store.errorMessage { UI.State.ErrorBanner(message: message) } } .overlay { if store.snapshots.isEmpty && app.networks.isEmpty { emptyState } @@ -234,8 +233,8 @@ struct ContainersGridView: View { } message: { _ in Text("This removes the network. Containers must be detached first.") } .refreshable { await store.refresh() } // Report the in-page search count so the toolbar can escalate an empty search into the palette. - .onAppear { ui.pageResultCount = filtered.count } - .onChange(of: filtered.count) { _, count in ui.pageResultCount = count } + .onAppear { ui.search.pageResultCount = filtered.count } + .onChange(of: filtered.count) { _, count in ui.search.pageResultCount = count } .onChange(of: store.snapshots.map(\.id)) { _, ids in selectedWidgetIndices = selectedWidgetIndices.filter { ids.contains($0.key) } } @@ -246,17 +245,16 @@ struct ContainersGridView: View { @ViewBuilder private func groupSection(_ group: ContainerGroup) -> some View { let collapsed = collapsedNetworks.contains(group.name) - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { sectionHeader(group, collapsed: collapsed) if !collapsed { if group.containers.isEmpty { - Text(ui.grouping == .network ? "No containers on this network." : "No containers.") - .font(.callout) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, DesignTokens.Space.s) + UI.State.Empty(ui.grouping == .network ? "No containers on this network." : "No containers.", + systemImage: group.symbol, + tone: .tertiary, + padding: UI.Layout.Spacing.s) } else { - LazyVGrid(columns: columns, spacing: DesignTokens.Space.m) { + LazyVGrid(columns: columns, spacing: UI.Layout.Spacing.m) { ForEach(group.containers) { snapshot in gridCard(snapshot) } @@ -267,26 +265,24 @@ struct ContainersGridView: View { } private func sectionHeader(_ group: ContainerGroup, collapsed: Bool) -> some View { - HStack(spacing: DesignTokens.Space.s) { + HStack(spacing: UI.Layout.Spacing.s) { Button { toggleCollapsed(group.name) } label: { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) + UI.Symbol.Image(systemName: "chevron.right", size: .caption) .rotationEffect(.degrees(collapsed ? 0 : 90)) } .buttonStyle(.plain) - Image(systemName: group.symbol).font(.callout).foregroundStyle(.secondary) - Text(group.name).font(.headline) - DesignBadgeText(text: "\(group.containers.count)") + UI.Symbol.Image(systemName: group.symbol) + Text(group.name).designHeadlineLabelStyle() + UI.Badge.Text(text: "\(group.containers.count)") if group.isBuiltin { - DesignBadgeText(text: "builtin", font: .caption2.weight(.medium)) + UI.Badge.Text(text: "builtin", font: .caption2.weight(.medium)) } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, DesignTokens.Space.xs) - .padding(.vertical, DesignTokens.Space.xs) + .padding(.horizontal, UI.Layout.Spacing.xs) + .padding(.vertical, UI.Layout.Spacing.xs) .contextMenu { if let resource = group.resource { networkMenu(resource) } } } @@ -357,7 +353,7 @@ struct ContainersGridView: View { // starts (expanded → false), finishing before the shrink animation does. containerCard(snapshot, isExpanded: true, - cornerRadiusOverride: expanded ? DesignTokens.Radius.sheet : DesignTokens.Radius.card, + cornerRadiusOverride: expanded ? UI.Card.Radius.expanded : UI.Card.Radius.container, controlsVisible: expanded) {} } @@ -405,26 +401,26 @@ struct ContainersGridView: View { } } - private var cardDetailTarget: MorphTarget { + private var cardDetailTarget: UX.Morph.Target { .centered(safeArea: cardDetailSafeAreaPolicy, margin: 0) { bounds in panelSize(in: bounds.size) } } - private var cardDetailSafeAreaPolicy: MorphSafeAreaPolicy { - let toolbarExclusion: MorphToolbarSafeAreaExclusion = ui.toolbarUIEnabled ? .bottom : .both - return MorphSafeAreaPolicy(excluding: toolbarExclusion, padding: .none, includesSystemInsets: false) + private var cardDetailSafeAreaPolicy: UX.SafeArea.Policy { + let toolbarExclusion: UX.SafeArea.ToolbarExclusion = ui.toolbarUIEnabled ? .bottom : .both + return UX.SafeArea.Policy(excluding: toolbarExclusion, padding: .none, includesSystemInsets: false) } - private var cardDetailSafeAreas: MorphSafeAreaManager { - guard ui.toolbarUIEnabled else { return safeAreas } - return MorphSafeAreaManager(system: safeAreas.system, + private var cardDetailSafeAreaManager: UX.SafeArea.Manager { + guard ui.toolbarUIEnabled else { return safeAreaManager } + return UX.SafeArea.Manager(system: safeAreaManager.system, topToolbarHeight: AppToolbar.bandHeight, bottomToolbarHeight: AppToolbar.bandHeight) } private func panelSize(in available: CGSize) -> CGSize { - let fitted = MorphGeometry.fittedSize( + let fitted = UX.Morph.Geometry.fittedSize( CGSize(width: max(available.width * 0.62, 680), height: 620), in: available, margin: 0 @@ -451,20 +447,20 @@ struct ContainersGridView: View { } private var batchBar: some View { - DesignSelectionActionBar(count: selection.count, + UI.Action.SelectionBar(count: selection.count, countLabel: AppText.selectedCount, actions: [ - DesignAction(systemName: "play.fill", title: AppText.start) { + UI.Action.Item(systemName: "play.fill", title: AppText.start) { batch { await store.start($0) } }, - DesignAction(systemName: "stop.fill", title: AppText.stop) { + UI.Action.Item(systemName: "stop.fill", title: AppText.stop) { batch { await store.stop($0) } }, - DesignAction(systemName: "trash", title: AppText.delete, role: .destructive) { + UI.Action.Item(systemName: "trash", title: AppText.delete, role: .destructive) { batch { await store.remove($0, force: true) } } ]) - .padding(.bottom, DesignTokens.Space.l) + .padding(.bottom, UI.Layout.Spacing.l) } private func toggle(_ id: String) { @@ -506,12 +502,18 @@ struct ContainersGridView: View { @ViewBuilder private var emptyState: some View { - ContentUnavailableView { - Label("No containers", systemImage: "shippingbox") - } description: { - Text(ui.runningOnly ? "No running containers." : "Run a container to see it here.") - } actions: { - Button("Run a container") { ui.openCreationPanel(entry: .chooser) } + VStack(spacing: UI.Layout.Spacing.m) { + UI.State.Empty(AppText.string("containers.empty", defaultValue: "No containers"), + systemImage: "shippingbox", + description: ui.runningOnly + ? AppText.string("containers.empty.runningOnly", defaultValue: "No running containers.") + : AppText.string("containers.empty.description", defaultValue: "Run a container to see it here."), + padding: 0) + UI.Action.TextButton(title: AppText.string("containers.empty.run", defaultValue: "Run a container"), + systemName: "plus", + prominence: .prominent) { + ui.openCreationPanel(entry: .chooser) + } } } } @@ -521,7 +523,7 @@ private struct ContainerCardMetricsRenderer: View { let snapshot: ContainerSnapshot let style: Personalization let hasStyleOverride: Bool - let density: CardDensity + let density: UI.Card.Density let statsNormalization: StatsNormalizationContext let selectedWidgetIndex: Binding let isBusy: Bool diff --git a/Sources/ContainedApp/Features/Containers/ComposeImport.swift b/Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift similarity index 97% rename from Sources/ContainedApp/Features/Containers/ComposeImport.swift rename to Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift index 5d33be5d..36048d65 100644 --- a/Sources/ContainedApp/Features/Containers/ComposeImport.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift @@ -1,6 +1,5 @@ import AppKit import ContainedCore -import ContainedRuntime /// Import a `compose.yaml` without a dedicated page: pick the file, translate each service with an /// image into a `RunSpec`, pull the images, then open a prefilled New-Container editor per service @@ -35,7 +34,7 @@ enum ComposeImport { static func importText(_ text: String, projectName: String = "pasted", baseDirectory: URL? = nil, app: AppModel, ui: UIState) { do { - let parsed = try ComposeParser.parse(text, projectName: projectName) + let parsed = try Core.Compose.parse(text, projectName: projectName) guard let client = app.client else { app.flash(AppText.containerRuntimeNotReady) app.logger.record("Compose import \(parsed.name) could not start because no runtime is available", diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift index 4826b31c..9e3550ba 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift @@ -1,6 +1,6 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import SwiftData import AppKit import ContainedCore @@ -73,8 +73,8 @@ struct CreationFlow: View { @Namespace private var tileNamespace private var springAnim: Animation { .spring(response: 0.42, dampingFraction: 0.86) } - private var optionPageHeight: CGFloat { DesignOptionTile.defaultHeight + (DesignTokens.Space.s * 2) } - private var twoRowOptionPageHeight: CGFloat { optionPageHeight + DesignOptionTile.defaultHeight + DesignTokens.Space.s } + private var optionPageHeight: CGFloat { UI.Control.OptionTile.defaultHeight + (UI.Layout.Spacing.s * 2) } + private var twoRowOptionPageHeight: CGFloat { optionPageHeight + UI.Control.OptionTile.defaultHeight + UI.Layout.Spacing.s } private var menuSize: CGSize { CGSize(width: 760, height: optionPageHeight) } private var chooserSize: CGSize { CGSize(width: 640, height: twoRowOptionPageHeight) } @@ -330,16 +330,16 @@ struct CreationFlow: View { // These pages own their own scrolling (search results, build workspace, template lists), so the // scaffold runs in non-scrolling mode — unified chrome without nesting scroll views. Size is set // by `CreationFlow.body`'s `morphPanelSize(size(for:))`. - DesignPanelScaffold(width: 0, scrolls: false) { + UI.Panel.Scaffold(width: 0, scrolls: false) { VStack(spacing: 0) { - PanelHeader(symbol: symbol, title: title, subtitle: subtitle) { - DesignActionGroup(leadingAction(leading)) + UI.Panel.Header(symbol: symbol, title: title, subtitle: subtitle) { + UI.Action.Group(leadingAction(leading)) } Divider() } } content: { content() - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: contentAlignment) } } @@ -347,39 +347,39 @@ struct CreationFlow: View { private func contentOnlyScaffold(contentAlignment: Alignment = .topLeading, @ViewBuilder content: @escaping () -> C) -> some View { content() - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: contentAlignment) } - private func leadingAction(_ leading: Leading) -> DesignAction { + private func leadingAction(_ leading: Leading) -> UI.Action.Item { switch leading { case .close: - return DesignAction(systemName: "xmark", help: AppText.cancel, isCancel: true) { onClose() } + return UI.Action.Item(systemName: "xmark", help: AppText.cancel, isCancel: true) { onClose() } case .back(let action): - return DesignAction(systemName: "chevron.left", help: AppText.back, action: action) + return UI.Action.Item(systemName: "chevron.left", help: AppText.back, action: action) } } private func gridScaffold(@ViewBuilder content: () -> C) -> some View { content() - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func optionStack(@ViewBuilder content: @escaping () -> C) -> some View { - DesignOptionStack(spacing: DesignTokens.Space.s) { + UI.Control.OptionStack(spacing: UI.Layout.Spacing.s) { content() } } private func optionRow(@ViewBuilder content: () -> C) -> some View { - HStack(spacing: DesignTokens.Space.s) { content() } + HStack(spacing: UI.Layout.Spacing.s) { content() } } private func box(symbol: String, title: String, subtitle: String? = nil, matchedID: String? = nil, enabled: Bool = true, action: @escaping () -> Void) -> some View { - DesignOptionTile(symbol: symbol, title: title, subtitle: subtitle, + UI.Control.OptionTile(symbol: symbol, title: title, subtitle: subtitle, enabled: enabled, matchedID: matchedID, matchedNamespace: matchedID == nil ? nil : tileNamespace, @@ -447,11 +447,11 @@ struct CreationFlow: View { case .network: return PanelSize.resource case .volume: return PanelSize.resource case .build: return PanelSize.build - case .configure: return DesignTokens.SheetSize.form + case .configure: return UI.Panel.SheetSize.form } } - private func placement(for page: Page) -> MorphPanelPlacement { + private func placement(for page: Page) -> UX.Panel.Placement { .anchored } diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift index e5f5f5b7..c0a1dcbc 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore struct CreationNetworkFields: View { @@ -14,21 +14,21 @@ struct CreationNetworkFields: View { title: networkName, subtitle: networkSubtitle, command: previewCommand) { - PanelSection(header: AppText.string("creation.details", defaultValue: "Details"), highlighted: hasValues) { - PanelField(label: AppText.string("creation.name", defaultValue: "Name"), + UI.Panel.Section(header: AppText.string("creation.details", defaultValue: "Details"), highlighted: hasValues) { + UI.Panel.Field(label: AppText.string("creation.name", defaultValue: "Name"), info: AppText.string("creation.network.name.info", defaultValue: "A readable name used by containers with `--network`."), error: nameError) { TextField("", text: $name, prompt: Text("my-network")) .textFieldStyle(.roundedBorder) .onSubmit(submitIfReady) } - PanelField(label: AppText.string("creation.subnet", defaultValue: "Subnet"), + UI.Panel.Field(label: AppText.string("creation.subnet", defaultValue: "Subnet"), info: AppText.string("creation.network.subnet.info", defaultValue: "Optional CIDR range for the network, for example `10.0.0.0/24`.")) { TextField("", text: $subnet, prompt: Text("optional, e.g. 10.0.0.0/24")) .textFieldStyle(.roundedBorder) .onSubmit(submitIfReady) } - PanelToggleRow(title: AppText.string("creation.network.internalOnly", defaultValue: "Internal only"), + UI.Panel.ToggleRow(title: AppText.string("creation.network.internalOnly", defaultValue: "Internal only"), subtitle: AppText.string("creation.network.internalOnly.subtitle", defaultValue: "Restrict containers on this network from external access."), isOn: $internalOnly) } @@ -53,7 +53,7 @@ struct CreationNetworkFields: View { return parts.joined(separator: " · ") } private var previewCommand: [String] { - ContainerCommands.networkCreate(name: trimmedName.isEmpty ? "" : trimmedName, + Core.Command.networkCreatePreview(name: trimmedName.isEmpty ? "" : trimmedName, subnet: trimmedSubnet.isEmpty ? nil : trimmedSubnet, internalOnly: internalOnly) } @@ -75,15 +75,15 @@ struct CreationVolumeFields: View { title: volumeName, subtitle: volumeSubtitle, command: previewCommand) { - PanelSection(header: AppText.string("creation.details", defaultValue: "Details"), highlighted: hasValues) { - PanelField(label: AppText.string("creation.name", defaultValue: "Name"), + UI.Panel.Section(header: AppText.string("creation.details", defaultValue: "Details"), highlighted: hasValues) { + UI.Panel.Field(label: AppText.string("creation.name", defaultValue: "Name"), info: AppText.string("creation.volume.name.info", defaultValue: "A persistent storage name you can mount into containers."), error: nameError) { TextField("", text: $name, prompt: Text("my-volume")) .textFieldStyle(.roundedBorder) .onSubmit(submitIfReady) } - PanelField(label: AppText.string("creation.volume.size", defaultValue: "Size"), + UI.Panel.Field(label: AppText.string("creation.volume.size", defaultValue: "Size"), info: AppText.string("creation.volume.size.info", defaultValue: "Optional runtime-specific size hint, such as `10G`. Leave blank for default.")) { TextField("", text: $size, prompt: Text("optional, e.g. 10G")) .textFieldStyle(.roundedBorder) @@ -107,7 +107,7 @@ struct CreationVolumeFields: View { private var volumeName: String { trimmedName.isEmpty ? AppText.string("creation.volume.new", defaultValue: "New volume") : trimmedName } private var volumeSubtitle: String { trimmedSize.isEmpty ? AppText.string("creation.volume.defaultSize", defaultValue: "default size") : trimmedSize } private var previewCommand: [String] { - ContainerCommands.volumeCreate(name: trimmedName.isEmpty ? "" : trimmedName, + Core.Command.volumeCreatePreview(name: trimmedName.isEmpty ? "" : trimmedName, size: trimmedSize.isEmpty ? nil : trimmedSize) } @@ -123,31 +123,20 @@ struct CreationLocalImagesContent: View { var onSelect: (RunSpec) -> Void var body: some View { - LazyVStack(spacing: DesignTokens.Space.m) { - DesignInputSurface { - HStack(spacing: DesignTokens.Space.s) { - Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField(AppText.string("creation.localImages.filter", defaultValue: "Filter local images"), text: $query) - .textFieldStyle(.plain) - if !query.isEmpty { - Button { query = "" } label: { Image(systemName: "xmark.circle.fill") } - .buttonStyle(.plain).foregroundStyle(.secondary) - } - } - } + LazyVStack(spacing: UI.Layout.Spacing.m) { + UI.Control.SearchField(text: $query, + prompt: AppText.string("creation.localImages.filter", defaultValue: "Filter local images"), + clearLabel: AppText.clear) if filteredLocalImages.isEmpty { - ContentUnavailableView { - Label(AppText.string("creation.localImages.noMatches", defaultValue: "No matching images"), systemImage: "square.stack.3d.up") - } description: { - Text(query.isEmpty - ? AppText.string("creation.localImages.empty", defaultValue: "Pull or build an image first.") - : AppText.string("creation.localImages.tryDifferentFilter", defaultValue: "Try a different filter.")) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + UI.State.Empty(AppText.string("creation.localImages.noMatches", defaultValue: "No matching images"), + systemImage: "square.stack.3d.up", + description: query.isEmpty + ? AppText.string("creation.localImages.empty", defaultValue: "Pull or build an image first.") + : AppText.string("creation.localImages.tryDifferentFilter", defaultValue: "Try a different filter.")) } else { ScrollView { - LazyVStack(spacing: DesignTokens.Space.xs) { + LazyVStack(spacing: UI.Layout.Spacing.xs) { ForEach(filteredLocalImages) { image in CreationLocalImageRow(image: image) { onSelect(RecommendedImage.spec(for: image.reference)) @@ -158,7 +147,7 @@ struct CreationLocalImagesContent: View { } } } - .task { await app.refreshImagesIfStale() } + .task { await app.refreshImagesIfNeeded() } } private var filteredLocalImages: [ContainedCore.ImageResource] { @@ -176,18 +165,18 @@ struct CreationPastedComposeContent: View { var onImport: () -> Void var body: some View { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.m) { - DesignInputSurface(horizontalPadding: DesignTokens.Space.s, - verticalPadding: DesignTokens.Space.s, + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.m) { + UI.Surface.Input(horizontalPadding: UI.Layout.Spacing.s, + verticalPadding: UI.Layout.Spacing.s, minHeight: 260) { TextEditor(text: $text) - .font(.system(.callout, design: .monospaced)) + .designMonospacedCallout() .scrollContentBackground(.hidden) } HStack { Spacer() - DesignTextActionButton(title: AppText.string("common.import", defaultValue: "Import"), + UI.Action.TextButton(title: AppText.string("common.import", defaultValue: "Import"), systemName: "arrow.down.doc", prominence: .prominent, isEnabled: !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) { @@ -204,7 +193,7 @@ struct CreationTemplatesContent: View { var body: some View { ScrollView { - LazyVStack(spacing: DesignTokens.Space.s) { + LazyVStack(spacing: UI.Layout.Spacing.s) { ForEach(templates) { template in CreationChoiceCard(symbol: "bookmark", title: template.name, @@ -226,17 +215,17 @@ private struct CreationSubmitBar: View { var action: () -> Void var body: some View { - HStack(spacing: DesignTokens.Space.s) { + HStack(spacing: UI.Layout.Spacing.s) { Spacer() - if working { ProgressView().controlSize(.small) } - DesignTextActionButton(title: title, + if working { UI.State.ProgressIndicator() } + UI.Action.TextButton(title: title, systemName: systemImage, prominence: .prominent, isEnabled: canSubmit && !working) { action() } } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) .background(.clear) } } @@ -250,18 +239,18 @@ private struct CreationResourceForm: View { @ViewBuilder var footer: () -> Footer var body: some View { - LazyVStack(spacing: DesignTokens.Space.m) { - DesignCard(size: .small, + LazyVStack(spacing: UI.Layout.Spacing.m) { + UI.Card.Scaffold(size: .small, elevated: false, title: title, subtitle: subtitle) { - DesignCardIconChip(symbol: symbol, tint: .accentColor) + UI.Card.IconChip(symbol: symbol, tint: .accentColor) } titleAccessory: { EmptyView() } subtitleAccessory: { EmptyView() } headerAccessory: { - DesignBadgeText(text: AppText.string("creation.badge.new", defaultValue: "new"), font: .caption2.weight(.semibold)) + UI.Badge.Text(text: AppText.string("creation.badge.new", defaultValue: "new"), font: .caption2.weight(.semibold)) } bodyContent: { EmptyView() } footerLeading: { @@ -274,7 +263,7 @@ private struct CreationResourceForm: View { fields() - CommandPreviewBar(command: command, + UI.Command.PreviewBar(command: command, copyHelp: AppText.copyCommand, copiedAccessibilityLabel: AppText.copied) .frame(maxWidth: .infinity) @@ -309,19 +298,19 @@ private struct CreationChoiceCard: View { var action: () -> Void var body: some View { - DesignCard(size: .small, + UI.Card.Scaffold(size: .small, elevated: false, onTap: action, title: title, subtitle: subtitle, subtitleStyle: .monospaced) { - DesignCardIconChip(symbol: symbol, tint: .accentColor) + UI.Card.IconChip(symbol: symbol, tint: .accentColor) } titleAccessory: { EmptyView() } subtitleAccessory: { EmptyView() } headerAccessory: { - DesignListRowChevron() + UI.List.RowChevron() } bodyContent: { EmptyView() } footerLeading: { diff --git a/Sources/ContainedApp/Features/Containers/ContainerCustomizeButton.swift b/Sources/ContainedApp/Features/Containers/Customization/ContainerCustomizeButton.swift similarity index 87% rename from Sources/ContainedApp/Features/Containers/ContainerCustomizeButton.swift rename to Sources/ContainedApp/Features/Containers/Customization/ContainerCustomizeButton.swift index 5c7fa4f7..73928ddf 100644 --- a/Sources/ContainedApp/Features/Containers/ContainerCustomizeButton.swift +++ b/Sources/ContainedApp/Features/Containers/Customization/ContainerCustomizeButton.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore /// Container identity chip that turns into the customization affordance on hover. The customize @@ -14,7 +14,7 @@ struct ContainerCustomizeButton: View { var body: some View { Button(action: onTap) { - DesignCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, + UI.Card.IconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, tint: style.color) } .buttonStyle(.plain) diff --git a/Sources/ContainedApp/Features/Containers/ContainerViewOptions.swift b/Sources/ContainedApp/Features/Containers/Customization/ContainerViewOptions.swift similarity index 100% rename from Sources/ContainedApp/Features/Containers/ContainerViewOptions.swift rename to Sources/ContainedApp/Features/Containers/Customization/ContainerViewOptions.swift diff --git a/Sources/ContainedApp/Features/Containers/CustomizeSheet.swift b/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift similarity index 86% rename from Sources/ContainedApp/Features/Containers/CustomizeSheet.swift rename to Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift index 734f9103..2ed34206 100644 --- a/Sources/ContainedApp/Features/Containers/CustomizeSheet.swift +++ b/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore /// Edits local card personalization for containers, images, image groups, tags, and volumes. @@ -116,7 +116,7 @@ struct CustomizeSheet: View { header Divider() ScrollView { - LazyVStack(spacing: DesignTokens.Space.l) { + LazyVStack(spacing: UI.Layout.Spacing.l) { if target.supportsInheritance { inheritanceSection } editableSection { styleSection } if case .container = target { @@ -130,8 +130,8 @@ struct CustomizeSheet: View { editableSection { backgroundSection } actionsSection } - .padding(.horizontal, DesignTokens.Space.l) - .padding(.vertical, DesignTokens.Space.m) + .padding(.horizontal, UI.Layout.Spacing.l) + .padding(.vertical, UI.Layout.Spacing.m) } .scrollEdgeEffectStyle(.soft, for: .all) } @@ -142,76 +142,76 @@ struct CustomizeSheet: View { } private var header: some View { - PanelHeader(symbol: "paintbrush.pointed", + UI.Panel.Header(symbol: "paintbrush.pointed", title: headerTitle, subtitle: imageSubtitle) { - DesignActionGroup([ - DesignAction(systemName: "checkmark", help: AppText.save) { save() }, - DesignAction(systemName: "xmark", help: AppText.close, isCancel: true) { dismiss() } + UI.Action.Group([ + UI.Action.Item(systemName: "checkmark", help: AppText.save) { save() }, + UI.Action.Item(systemName: "xmark", help: AppText.close, isCancel: true) { dismiss() } ]) } } private var inheritanceSection: some View { - PanelSection(header: AppText.string("customize.inheritance", defaultValue: "Inheritance")) { - PanelToggleRow(title: overrideToggleTitle, + UI.Panel.Section(header: AppText.string("customize.inheritance", defaultValue: "Inheritance")) { + UI.Panel.ToggleRow(title: overrideToggleTitle, subtitle: overrideToggleHint, isOn: overrideBinding) } } private var styleSection: some View { - PanelSection(header: AppText.string("customize.style", defaultValue: "Style")) { - PanelField(label: nicknameLabel) { + UI.Panel.Section(header: AppText.string("customize.style", defaultValue: "Style")) { + UI.Panel.Field(label: nicknameLabel) { TextField("", text: $style.nickname, prompt: Text(nicknamePrompt)) .textFieldStyle(.roundedBorder) } - PanelToggleRow(title: AppText.string("customize.customIcon", defaultValue: "Custom icon"), isOn: $style.iconEnabled) + UI.Panel.ToggleRow(title: AppText.string("customize.customIcon", defaultValue: "Custom icon"), isOn: $style.iconEnabled) if style.iconEnabled { - PanelField(label: AppText.string("customize.icon", defaultValue: "Icon")) { + UI.Panel.Field(label: AppText.string("customize.icon", defaultValue: "Icon")) { TextField("", text: $style.icon, prompt: Text("SF Symbol, e.g. globe, bolt")) .textFieldStyle(.roundedBorder) } } else { - PanelRow(title: AppText.string("customize.icon", defaultValue: "Icon"), + UI.Panel.Row(title: AppText.string("customize.icon", defaultValue: "Icon"), subtitle: AppText.string("customize.icon.defaultSubtitle", defaultValue: "Using the default icon")) } - PanelRow(title: AppText.string("customize.color", defaultValue: "Color"), + UI.Panel.Row(title: AppText.string("customize.color", defaultValue: "Color"), info: AppText.string("customize.color.info", defaultValue: "App Accent follows the accent tint from Settings; other swatches pin this style.")) { - TintSelector(selection: $style.tint) { $0.localizedDisplayName } + UI.Control.TintSelector(selection: $style.tint) { $0.localizedDisplayName } } } } private var statusSection: some View { - PanelSection(header: AppText.string("customize.status", defaultValue: "Status")) { - PanelToggleRow(title: AppText.string("customize.showStatusIndicator", defaultValue: "Show status indicator"), isOn: $style.showStatusIndicator) + UI.Panel.Section(header: AppText.string("customize.status", defaultValue: "Status")) { + UI.Panel.ToggleRow(title: AppText.string("customize.showStatusIndicator", defaultValue: "Show status indicator"), isOn: $style.showStatusIndicator) if style.showStatusIndicator { - PanelToggleRow(title: AppText.string("customize.widget.showIcon", defaultValue: "Show icon"), isOn: $style.showStatusIcon) - PanelToggleRow(title: AppText.string("customize.widget.showText", defaultValue: "Show text"), isOn: $style.showStatusText) + UI.Panel.ToggleRow(title: AppText.string("customize.widget.showIcon", defaultValue: "Show icon"), isOn: $style.showStatusIcon) + UI.Panel.ToggleRow(title: AppText.string("customize.widget.showText", defaultValue: "Show text"), isOn: $style.showStatusText) } } } private var backgroundSection: some View { - PanelSection(header: AppText.string("customize.background", defaultValue: "Background")) { - PanelToggleRow(title: AppText.string("customize.colorCardBackground", defaultValue: "Color the card background"), isOn: $style.fillBackground) + UI.Panel.Section(header: AppText.string("customize.background", defaultValue: "Background")) { + UI.Panel.ToggleRow(title: AppText.string("customize.colorCardBackground", defaultValue: "Color the card background"), isOn: $style.fillBackground) if style.fillBackground { - PanelRow(title: AppText.string("customize.opacity", defaultValue: "Opacity")) { - HStack(spacing: DesignTokens.Space.s) { + UI.Panel.Row(title: AppText.string("customize.opacity", defaultValue: "Opacity")) { + HStack(spacing: UI.Layout.Spacing.s) { Slider(value: $style.backgroundOpacity, in: 0.05...0.6) Text(Format.percent(style.backgroundOpacity)) .monospacedDigit() - .frame(width: DesignTokens.FormWidth.shortReadout) + .frame(width: UI.Form.Width.shortReadout) } } - PanelToggleRow(title: AppText.string("customize.gradient", defaultValue: "Gradient"), isOn: $style.gradient) + UI.Panel.ToggleRow(title: AppText.string("customize.gradient", defaultValue: "Gradient"), isOn: $style.gradient) if style.gradient { GradientAngleControl(angle: $style.gradientAngle, title: AppText.direction) } - PanelRow(title: AppText.string("customize.blendMode", defaultValue: "Blend mode")) { + UI.Panel.Row(title: AppText.string("customize.blendMode", defaultValue: "Blend mode")) { Picker("", selection: $style.backgroundBlendMode) { - ForEach(ColorLayerBlendMode.allCases) { mode in + ForEach(UI.Theme.ColorBlendMode.allCases) { mode in Text(mode.localizedDisplayName).tag(mode) } } @@ -223,8 +223,8 @@ struct CustomizeSheet: View { } private var actionsSection: some View { - PanelSection { - PanelRow(title: target.isImage + UI.Panel.Section { + UI.Panel.Row(title: target.isImage ? AppText.string("customize.resetImageStyle", defaultValue: "Reset image style") : AppText.string("customize.reset", defaultValue: "Reset"), subtitle: canReset @@ -236,7 +236,7 @@ struct CustomizeSheet: View { .disabled(!canReset) } if case .container = target { - PanelRow(title: AppText.string("customize.applyToImage", defaultValue: "Apply to image"), + UI.Panel.Row(title: AppText.string("customize.applyToImage", defaultValue: "Apply to image"), subtitle: AppText.string("customize.applyToImage.subtitle", defaultValue: "Make this container style the default for future containers from the same image.")) { Button { applyToImage() } label: { Label(AppText.string("customize.apply", defaultValue: "Apply"), systemImage: "square.stack.3d.up") diff --git a/Sources/ContainedApp/Features/Containers/CustomizeWidgetsPanel.swift b/Sources/ContainedApp/Features/Containers/Customization/CustomizeWidgetsPanel.swift similarity index 84% rename from Sources/ContainedApp/Features/Containers/CustomizeWidgetsPanel.swift rename to Sources/ContainedApp/Features/Containers/Customization/CustomizeWidgetsPanel.swift index ecfbefaf..bee5fab1 100644 --- a/Sources/ContainedApp/Features/Containers/CustomizeWidgetsPanel.swift +++ b/Sources/ContainedApp/Features/Containers/Customization/CustomizeWidgetsPanel.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore /// Widget-specific controls for `CustomizeSheet`, split out so the sheet owns the target/persistence @@ -18,7 +18,7 @@ struct CustomizeWidgetsPanel: View { } var body: some View { - LazyVStack(spacing: DesignTokens.Space.l) { + LazyVStack(spacing: UI.Layout.Spacing.l) { ForEach(activeWidgetIndices, id: \.self) { index in widgetSection(index) } @@ -28,7 +28,7 @@ struct CustomizeWidgetsPanel: View { } private func widgetSection(_ index: Int) -> some View { - PanelSection(header: widgetTitle(for: index)) { + UI.Panel.Section(header: widgetTitle(for: index)) { widgetOrderControls(index) Divider() widgetDisplayOptions(index) @@ -42,9 +42,9 @@ struct CustomizeWidgetsPanel: View { } private var addWidgetSection: some View { - PanelSection(header: AppText.string("customize.widgets", defaultValue: "Widgets"), + UI.Panel.Section(header: AppText.string("customize.widgets", defaultValue: "Widgets"), footer: AppText.string("customize.widgets.footer", defaultValue: "\(activeWidgetIndices.count) of \(Personalization.widgetSlotCount) widgets")) { - PanelRow(title: AppText.string("customize.addWidget", defaultValue: "Add widget"), + UI.Panel.Row(title: AppText.string("customize.addWidget", defaultValue: "Add widget"), subtitle: canAddWidget ? AppText.string("customize.addWidget.subtitle", defaultValue: "Add another metric chip or chart to this card.") : AppText.string("customize.addWidget.slotsFull", defaultValue: "All widget slots are in use.")) { @@ -60,9 +60,9 @@ struct CustomizeWidgetsPanel: View { private func widgetOrderControls(_ index: Int) -> some View { let position = activeWidgetIndices.firstIndex(of: index) ?? 0 - return PanelRow(title: AppText.string("customize.widget.order", defaultValue: "Order"), + return UI.Panel.Row(title: AppText.string("customize.widget.order", defaultValue: "Order"), subtitle: AppText.string("customize.widget.order.subtitle", defaultValue: "Move or remove this widget.")) { - HStack(spacing: DesignTokens.Space.xs) { + HStack(spacing: UI.Layout.Spacing.xs) { Button { moveWidget(index, by: -1) } label: { Label(AppText.string("customize.widget.moveUp", defaultValue: "Move up"), systemImage: "chevron.up").labelStyle(.iconOnly) } @@ -89,17 +89,17 @@ struct CustomizeWidgetsPanel: View { @ViewBuilder private func widgetDisplayOptions(_ index: Int) -> some View { widgetGroupLabel(AppText.string("customize.widget.display", defaultValue: "Display"), systemImage: "paintpalette") - PanelToggleRow(title: AppText.string("customize.widget.showIcon", defaultValue: "Show icon"), isOn: widgetBinding(index, \.showIcon)) + UI.Panel.ToggleRow(title: AppText.string("customize.widget.showIcon", defaultValue: "Show icon"), isOn: widgetBinding(index, \.showIcon)) if style.widget(at: index).showIcon { - PanelField(label: AppText.string("customize.icon", defaultValue: "Icon")) { + UI.Panel.Field(label: AppText.string("customize.icon", defaultValue: "Icon")) { TextField("", text: widgetBinding(index, \.icon), prompt: Text(style.widget(at: index).metric.systemImage)) .textFieldStyle(.roundedBorder) } } - PanelToggleRow(title: AppText.string("customize.widget.showText", defaultValue: "Show text"), isOn: widgetBinding(index, \.showText)) - PanelRow(title: AppText.string("customize.color", defaultValue: "Color")) { - TintSelector(optionalSelection: widgetBinding(index, \.tint), + UI.Panel.ToggleRow(title: AppText.string("customize.widget.showText", defaultValue: "Show text"), isOn: widgetBinding(index, \.showText)) + UI.Panel.Row(title: AppText.string("customize.color", defaultValue: "Color")) { + UI.Control.TintSelector(optionalSelection: widgetBinding(index, \.tint), automaticLabel: AppText.cardColor) { $0.localizedDisplayName } } } @@ -107,7 +107,7 @@ struct CustomizeWidgetsPanel: View { @ViewBuilder private func widgetDataOptions(_ index: Int) -> some View { widgetGroupLabel(AppText.string("customize.widget.data", defaultValue: "Data"), systemImage: "waveform.path.ecg") - PanelRow(title: AppText.string("customize.widget.metric", defaultValue: "Metric")) { + UI.Panel.Row(title: AppText.string("customize.widget.metric", defaultValue: "Metric")) { Picker("", selection: widgetMetricBinding(index)) { ForEach(graphOptions) { Label(graphLabel($0), systemImage: $0.systemImage).tag($0) @@ -118,7 +118,7 @@ struct CustomizeWidgetsPanel: View { } if widgetStyle(index).requiresSecondaryMetric { if let fallback = secondaryMetricFallback(for: index) { - PanelRow(title: AppText.string("customize.widget.compare", defaultValue: "Compare")) { + UI.Panel.Row(title: AppText.string("customize.widget.compare", defaultValue: "Compare")) { Picker("", selection: widgetSecondaryMetricBinding(index, fallback: fallback)) { ForEach(graphOptions.filter { $0 != style.widget(at: index).metric }) { Label(graphLabel($0), systemImage: $0.systemImage).tag($0) @@ -128,7 +128,7 @@ struct CustomizeWidgetsPanel: View { .fixedSize() } } else { - PanelRow(title: AppText.string("customize.widget.compare", defaultValue: "Compare"), + UI.Panel.Row(title: AppText.string("customize.widget.compare", defaultValue: "Compare"), subtitle: AppText.string("customize.widget.compare.subtitle", defaultValue: "This graph needs a second metric.")) } } @@ -138,20 +138,20 @@ struct CustomizeWidgetsPanel: View { private func widgetChartOptions(_ index: Int) -> some View { let chartStyle = widgetStyle(index) widgetGroupLabel(AppText.string("customize.widget.chart", defaultValue: "Chart"), systemImage: "chart.xyaxis.line") - PanelRow(title: AppText.string("customize.widget.type", defaultValue: "Type")) { + UI.Panel.Row(title: AppText.string("customize.widget.type", defaultValue: "Type")) { Picker("", selection: widgetStyleBinding(index)) { - ForEach(GraphStyle.allCases) { Text($0.localizedDisplayName).tag($0) } + ForEach(UI.Chart.GraphStyle.allCases) { Text($0.localizedDisplayName).tag($0) } } .labelsHidden() .fixedSize() } if chartStyle == .area { - PanelToggleRow(title: AppText.string("customize.widget.gradientFill", defaultValue: "Gradient fill"), isOn: widgetBinding(index, \.areaUsesGradient)) + UI.Panel.ToggleRow(title: AppText.string("customize.widget.gradientFill", defaultValue: "Gradient fill"), isOn: widgetBinding(index, \.areaUsesGradient)) } if chartStyle.usesLineOptions { - PanelRow(title: AppText.string("customize.widget.interpolation", defaultValue: "Interpolation")) { + UI.Panel.Row(title: AppText.string("customize.widget.interpolation", defaultValue: "Interpolation")) { Picker("", selection: widgetBinding(index, \.interpolation)) { - ForEach(WidgetInterpolation.allCases) { Text($0.localizedDisplayName).tag($0) } + ForEach(UI.Chart.Interpolation.allCases) { Text($0.localizedDisplayName).tag($0) } } .labelsHidden() .fixedSize() @@ -183,20 +183,18 @@ struct CustomizeWidgetsPanel: View { range: ClosedRange, step: Double, readout: String) -> some View { - PanelRow(title: title) { - HStack(spacing: DesignTokens.Space.s) { + UI.Panel.Row(title: title) { + HStack(spacing: UI.Layout.Spacing.s) { Slider(value: value, in: range, step: step) Text(readout) .monospacedDigit() - .frame(width: DesignTokens.FormWidth.shortReadout) + .frame(width: UI.Form.Width.shortReadout) } } } private func widgetGroupLabel(_ title: String, systemImage: String) -> some View { - Label(title, systemImage: systemImage) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) + UI.State.InlineStatus(title, systemImage: systemImage) } private func widgetTitle(for index: Int) -> String { @@ -246,7 +244,7 @@ struct CustomizeWidgetsPanel: View { return graphOptions.first { !activeMetrics.contains($0) } ?? graphOptions.first ?? .cpu } - private func widgetStyle(_ index: Int) -> GraphStyle { + private func widgetStyle(_ index: Int) -> UI.Chart.GraphStyle { style.widget(at: index).style } @@ -283,7 +281,7 @@ struct CustomizeWidgetsPanel: View { } } - private func widgetStyleBinding(_ index: Int) -> Binding { + private func widgetStyleBinding(_ index: Int) -> Binding { Binding { style.widget(at: index).style } set: { newValue in diff --git a/Sources/ContainedApp/Features/Containers/ContainerConfigureView.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift similarity index 81% rename from Sources/ContainedApp/Features/Containers/ContainerConfigureView.swift rename to Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift index 57c369d1..ae71b871 100644 --- a/Sources/ContainedApp/Features/Containers/ContainerConfigureView.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift @@ -1,6 +1,6 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import SwiftData import ContainedCore @@ -44,7 +44,7 @@ struct ContainerConfigureView: View { private var isEdit: Bool { if case .edit = mode { return true }; return false } var body: some View { - DesignPanelScaffold(width: DesignTokens.SheetSize.form.width) { + UI.Panel.Scaffold(width: UI.Panel.SheetSize.form.width) { VStack(spacing: 0) { header Divider() @@ -52,13 +52,13 @@ struct ContainerConfigureView: View { } } content: { RunSpecForm(spec: $spec) - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) } footer: { if app.settings.revealCLI { - CommandPreviewBar(command: app.previewCreateCommand(for: spec), + UI.Command.PreviewBar(command: app.previewCreateCommand(for: spec), copyHelp: AppText.copyCommand, copiedAccessibilityLabel: AppText.copied) - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity) } } @@ -79,22 +79,22 @@ struct ContainerConfigureView: View { } private var header: some View { - PanelHeader(symbol: isEdit ? "slider.horizontal.3" : "play.fill", + UI.Panel.Header(symbol: isEdit ? "slider.horizontal.3" : "play.fill", title: isEdit ? "Edit container" : "Run a container", subtitle: isEdit ? "Replaces the existing container with your edits" : nil) { - HStack(spacing: DesignTokens.Space.s) { - DesignActionGroup(leadingAction) + HStack(spacing: UI.Layout.Spacing.s) { + UI.Action.Group(leadingAction) if working { - DesignProgressActionCapsule() + UI.Action.ProgressCapsule() } else { - DesignActionGroup([ - DesignAction(systemName: "bookmark", + UI.Action.Group([ + UI.Action.Item(systemName: "bookmark", help: AppText.saveAsTemplate, isEnabled: spec.isRunnable) { templateName = spec.name.isEmpty ? Format.shortImage(spec.image) : spec.name savingTemplate = true }, - DesignAction(systemName: isEdit ? "checkmark" : "play.fill", + UI.Action.Item(systemName: isEdit ? "checkmark" : "play.fill", help: isEdit ? "Save" : "Create", isEnabled: spec.isRunnable) { if isEdit { confirming = true } else { create() } @@ -106,12 +106,12 @@ struct ContainerConfigureView: View { } } - private var leadingAction: DesignAction { + private var leadingAction: UI.Action.Item { switch leading { case .cancel(let action): - return DesignAction(systemName: "xmark", help: AppText.cancel, isCancel: true, action: action) + return UI.Action.Item(systemName: "xmark", help: AppText.cancel, isCancel: true, action: action) case .back(let action): - return DesignAction(systemName: "chevron.left", help: AppText.back, action: action) + return UI.Action.Item(systemName: "chevron.left", help: AppText.back, action: action) } } @@ -120,25 +120,25 @@ struct ContainerConfigureView: View { let messages = spec.validationMessages let warnings = preflightWarnings if !messages.isEmpty || !warnings.isEmpty || runError != nil { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.xs) { - // Blocking issues (gate the run button) in secondary; pre-flight warnings in orange; - // the run/pull failure in red. + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.xs) { ForEach(messages, id: \.self) { message in - Label(message, systemImage: "exclamationmark.circle") - .font(.caption).foregroundStyle(.secondary) + UI.State.InlineStatus(message, + systemImage: "exclamationmark.circle") } ForEach(warnings, id: \.self) { warning in - Label(warning, systemImage: "exclamationmark.triangle") - .font(.caption).foregroundStyle(.orange) + UI.State.InlineStatus(warning, + systemImage: "exclamationmark.triangle", + tone: .warning) } if let runError { - Label(runError, systemImage: "xmark.octagon") - .font(.caption).foregroundStyle(.red) + UI.State.InlineStatus(runError, + systemImage: "xmark.octagon", + tone: .error) } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, DesignTokens.Space.l) - .padding(.bottom, DesignTokens.Space.s) + .padding(.horizontal, UI.Layout.Spacing.l) + .padding(.bottom, UI.Layout.Spacing.s) } } diff --git a/Sources/ContainedApp/Features/Containers/ContainerEditSheet.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift similarity index 91% rename from Sources/ContainedApp/Features/Containers/ContainerEditSheet.swift rename to Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift index ce03118c..a42b50ea 100644 --- a/Sources/ContainedApp/Features/Containers/ContainerEditSheet.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore /// The container Create/Edit form presented as a modal sheet. The form body lives in the shared @@ -17,7 +17,7 @@ struct ContainerEditSheet: View { var body: some View { ContainerConfigureView(mode: mode, leading: .cancel { dismiss() }, onFinished: { dismiss() }) - .frame(DesignTokens.SheetSize.form) + .frame(UI.Panel.SheetSize.form) .sheetMaterial() } } diff --git a/Sources/ContainedApp/Features/Containers/ContainerOverviewTab.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerOverviewTab.swift similarity index 85% rename from Sources/ContainedApp/Features/Containers/ContainerOverviewTab.swift rename to Sources/ContainedApp/Features/Containers/Details/ContainerOverviewTab.swift index a45c2850..fabeb460 100644 --- a/Sources/ContainedApp/Features/Containers/ContainerOverviewTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerOverviewTab.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore /// The Overview tab of the container detail: grouped, read-only configuration (general, resources, @@ -10,7 +10,7 @@ struct ContainerOverviewTab: View { var body: some View { ContainerTabScaffold { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.m) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.m) { section("General") { row("Image", snapshot.image) row("Platform", config.platform.display) @@ -40,7 +40,7 @@ struct ContainerOverviewTab: View { if !config.initProcess.environment.isEmpty { section("Environment") { ForEach(config.initProcess.environment, id: \.self) { env in - Text(env).font(.system(.caption, design: .monospaced)) + Text(env).designMonospacedCaption() .frame(maxWidth: .infinity, alignment: .leading).lineLimit(1) } } @@ -63,12 +63,6 @@ struct ContainerOverviewTab: View { } private func row(_ label: String, _ value: String) -> some View { - HStack(alignment: .top) { - Text(label).foregroundStyle(.secondary) - Spacer() - Text(value).multilineTextAlignment(.trailing).textSelection(.enabled) - .font(.system(.body, design: .monospaced)) - } - .font(.callout) + UI.List.KeyValueRow(label: label, value: value, selectsValue: true) } } diff --git a/Sources/ContainedApp/Features/Containers/ContainerTabScaffold.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerTabScaffold.swift similarity index 88% rename from Sources/ContainedApp/Features/Containers/ContainerTabScaffold.swift rename to Sources/ContainedApp/Features/Containers/Details/ContainerTabScaffold.swift index a3ff8787..4dd6a2ad 100644 --- a/Sources/ContainedApp/Features/Containers/ContainerTabScaffold.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerTabScaffold.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI /// Shared body scaffolding for expanded container-card pages. /// Keeps tab content at the same 8pt inset as the rest of the panel surfaces. @@ -11,7 +11,7 @@ struct ContainerTabScaffold: View { ScrollView(axes) { LazyVStack(alignment: .leading, spacing: 0) { content() - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -27,7 +27,7 @@ struct ContainerToolTabScaffold: View { var body: some View { VStack(spacing: 0) { chrome() - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity, alignment: .leading) Divider() content() @@ -42,7 +42,7 @@ struct ContainerTabSection: View { @ViewBuilder var content: () -> Content var body: some View { - DesignCardInsetSection(title: title) { + UI.Card.InsetSection(title: title) { content() } } diff --git a/Sources/ContainedApp/Features/Containers/FilesTab.swift b/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift similarity index 71% rename from Sources/ContainedApp/Features/Containers/FilesTab.swift rename to Sources/ContainedApp/Features/Containers/Details/FilesTab.swift index a5640f28..ce4d0d7b 100644 --- a/Sources/ContainedApp/Features/Containers/FilesTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift @@ -1,8 +1,7 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import AppKit import ContainedCore -import ContainedRuntime /// Browse a running container's filesystem (`exec ls -1ap`) and copy files in/out with the native /// `container cp`. AppKit bridge (flagged): `NSOpenPanel`/`NSSavePanel` for host file selection. @@ -17,9 +16,9 @@ struct FilesTab: View { var body: some View { if snapshot.state != .running { - ContentUnavailableView { - Label("Not running", systemImage: "folder") - } description: { Text("Start the container to browse its files.") } + UI.State.Empty(AppText.string("files.notRunning", defaultValue: "Not running"), + systemImage: "folder", + description: AppText.string("files.notRunning.description", defaultValue: "Start the container to browse its files.")) } else { ContainerToolTabScaffold { pathBar @@ -31,33 +30,34 @@ struct FilesTab: View { } private var pathBar: some View { - HStack(spacing: DesignTokens.Space.s) { - DesignActionGroup(DesignAction(systemName: "chevron.up", + HStack(spacing: UI.Layout.Spacing.s) { + UI.Action.Group(UI.Action.Item(systemName: "chevron.up", help: AppText.parent, isEnabled: path != "/") { goUp() }) - Text(path).font(.system(.callout, design: .monospaced)).lineLimit(1).truncationMode(.middle) + Text(path).designMonospacedCallout().lineLimit(1).truncationMode(.middle) Spacer() - if loading { ProgressView().controlSize(.small) } - DesignActionGroup(DesignAction(systemName: "square.and.arrow.down", + if loading { UI.State.InlineStatus(AppText.string("files.loading", defaultValue: "loading"), isWorking: true) } + UI.Action.Group(UI.Action.Item(systemName: "square.and.arrow.down", help: AppText.string("files.copyIntoFolder", defaultValue: "Copy a file into this folder")) { copyIn() }) - DesignActionGroup(DesignAction(systemName: "arrow.clockwise", help: AppText.refresh) { Task { await load() } }) + UI.Action.Group(UI.Action.Item(systemName: "arrow.clockwise", help: AppText.refresh) { Task { await load() } }) } } @ViewBuilder private var listing: some View { if let error { - ContentUnavailableView { - Label("Couldn't read folder", systemImage: "exclamationmark.triangle") - } description: { Text(error) } + UI.State.Empty(AppText.string("files.error.title", defaultValue: "Couldn't read folder"), + systemImage: "exclamationmark.triangle", + description: error, + tone: .error) } else { ScrollView { - LazyVStack(spacing: DesignTokens.Space.hairline) { + LazyVStack(spacing: UI.Layout.Spacing.hairline) { ForEach(entries, id: \.self) { entry in row(entry) } } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) } .scrollEdgeEffectStyle(.soft, for: .all) } @@ -66,23 +66,21 @@ struct FilesTab: View { private func row(_ entry: String) -> some View { let isDir = entry.hasSuffix("/") let name = isDir ? String(entry.dropLast()) : entry - return HStack(spacing: DesignTokens.Space.s) { - Image(systemName: isDir ? "folder.fill" : "doc") - .foregroundStyle(isDir ? Color.accentColor : Color.secondary) - .frame(width: DesignTokens.IconSize.rowMenu) - Text(name).font(.system(.callout, design: .monospaced)) - Spacer() + return UI.List.MetadataRow(systemImage: isDir ? "folder.fill" : "doc", + title: name, + isMonospaced: true, + tint: isDir ? .accentColor : .secondary, + action: isDir ? { path = joined(name) + "/" } : nil) { if isDir { - Image(systemName: "chevron.right").font(.caption).foregroundStyle(.secondary) + UI.List.RowChevron() } else { - Button { copyOut(name) } label: { Image(systemName: "square.and.arrow.up") } - .buttonStyle(.plain).foregroundStyle(.secondary).help("Copy to host") + Button { copyOut(name) } label: { + UI.Symbol.Image(systemName: "square.and.arrow.up") + } + .buttonStyle(.plain) + .help(AppText.string("files.copyToHost", defaultValue: "Copy to host")) } } - .padding(.vertical, DesignTokens.Space.xs) - .padding(.horizontal, DesignTokens.Space.s) - .contentShape(Rectangle()) - .onTapGesture { if isDir { path = joined(name) + "/" } } } // MARK: Actions diff --git a/Sources/ContainedApp/Features/Containers/LogsTab.swift b/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift similarity index 67% rename from Sources/ContainedApp/Features/Containers/LogsTab.swift rename to Sources/ContainedApp/Features/Containers/Details/LogsTab.swift index 1c790a86..9b0655a6 100644 --- a/Sources/ContainedApp/Features/Containers/LogsTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift @@ -1,7 +1,6 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore -import ContainedRuntime /// Live container logs via `container logs --follow`. The stream is tied to this view's lifetime /// (`.task(id:)`), so leaving the tab cancels it and terminates the child process (SIGTERM). @@ -30,20 +29,17 @@ struct LogsTab: View { } private var controls: some View { - HStack(spacing: DesignTokens.Space.m) { - DesignToggleButton(isOn: $following, title: AppText.follow, systemName: "arrow.down.to.line") + HStack(spacing: UI.Layout.Spacing.m) { + UI.Action.ToggleButton(isOn: $following, title: AppText.follow, systemName: "arrow.down.to.line") if streaming { - HStack(spacing: DesignTokens.Toolbar.searchIconGap) { - ProgressView().controlSize(.small) - Text("streaming").font(.caption).foregroundStyle(.secondary) - } + UI.State.InlineStatus(AppText.string("logs.streaming", defaultValue: "streaming"), isWorking: true) } Spacer() - Text("\(lines.count) lines").font(.caption).foregroundStyle(.secondary).monospacedDigit() - DesignActionGroup(DesignAction(systemName: "doc.on.doc", help: AppText.copyAll) { + Text(AppText.lineCount(lines.count)).designSecondaryCaption().monospacedDigit() + UI.Action.Group(UI.Action.Item(systemName: "doc.on.doc", help: AppText.copyAll) { copyToPasteboard(lines.joined(separator: "\n")) }) - DesignActionGroup(DesignAction(systemName: "trash", + UI.Action.Group(UI.Action.Item(systemName: "trash", help: AppText.clear, role: .destructive, isEnabled: !lines.isEmpty) { @@ -55,31 +51,31 @@ struct LogsTab: View { @ViewBuilder private var logBody: some View { if let failed { - ContentUnavailableView { - Label("Couldn't read logs", systemImage: "exclamationmark.triangle") - } description: { Text(failed) } + UI.State.Empty(AppText.string("logs.error.title", defaultValue: "Couldn't read logs"), + systemImage: "exclamationmark.triangle", + description: failed, + tone: .error) } else if lines.isEmpty { - // Before any output: "connecting" while the stream is live, "no output" once it ended. - ContentUnavailableView { - Label(streaming ? "Waiting for output" : "No output", - systemImage: streaming ? "dot.radiowaves.left.and.right" : "text.alignleft") - } description: { - Text(streaming ? "Streaming — this container hasn't logged anything yet." - : "This container hasn't produced any logs.") - } + UI.State.Empty(streaming + ? AppText.string("logs.waiting", defaultValue: "Waiting for output") + : AppText.string("logs.empty", defaultValue: "No output"), + systemImage: streaming ? "dot.radiowaves.left.and.right" : "text.alignleft", + description: streaming + ? AppText.string("logs.waiting.description", defaultValue: "Streaming - this container hasn't logged anything yet.") + : AppText.string("logs.empty.description", defaultValue: "This container hasn't produced any logs.")) } else { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.hairline) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.hairline) { ForEach(Array(lines.enumerated()), id: \.offset) { _, line in Text(line.isEmpty ? " " : line) - .font(.system(.caption, design: .monospaced)) + .designMonospacedCaption() .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } - Color.clear.frame(height: DesignTokens.Space.hairline).id(bottomID) + Color.clear.frame(height: UI.Layout.Spacing.hairline).id(bottomID) } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) } .scrollEdgeEffectStyle(.soft, for: .all) .onChange(of: lines.count) { _, _ in diff --git a/Sources/ContainedApp/Features/Containers/StatsTab.swift b/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift similarity index 71% rename from Sources/ContainedApp/Features/Containers/StatsTab.swift rename to Sources/ContainedApp/Features/Containers/Details/StatsTab.swift index 34c9525c..0fdf5359 100644 --- a/Sources/ContainedApp/Features/Containers/StatsTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore /// Live resource stats for one container. Reads the deltas the `RefreshCoordinator` already polls @@ -12,32 +12,30 @@ struct StatsTab: View { private var metrics: ContainerMetricsState { app.containers.metricsState(for: snapshot.id) } private var delta: StatsDelta? { metrics.stats } - private var history: [GraphMetric: SampleBuffer] { metrics.historyByMetric } + private var history: [GraphMetric: UI.Chart.SampleBuffer] { metrics.historyByMetric } private var normalization: StatsNormalizationContext { app.statsNormalizationContext } private var tint: Color { app.containerStyle(for: snapshot).color } - private let columns = [GridItem(.adaptive(minimum: 200), spacing: DesignTokens.Space.m)] + private let columns = [GridItem(.adaptive(minimum: 200), spacing: UI.Layout.Spacing.m)] var body: some View { Group { if snapshot.state != .running { - ContentUnavailableView { - Label(AppText.string("stats.notRunning", defaultValue: "Not running"), systemImage: "chart.xyaxis.line") - } description: { - Text(AppText.string("stats.notRunning.description", defaultValue: "Start the container to see live resource usage.")) - } + UI.State.Empty(AppText.string("stats.notRunning", defaultValue: "Not running"), + systemImage: "chart.xyaxis.line", + description: AppText.string("stats.notRunning.description", defaultValue: "Start the container to see live resource usage.")) } else if let delta { ContainerTabScaffold { - LazyVGrid(columns: columns, spacing: DesignTokens.Space.m) { + LazyVGrid(columns: columns, spacing: UI.Layout.Spacing.m) { tile(.cpu, delta, "cpu") memoryTile(delta) tile(.netRx, delta, "arrow.down.circle") tile(.netTx, delta, "arrow.up.circle") tile(.diskRead, delta, "arrow.down.doc") tile(.diskWrite, delta, "arrow.up.doc") - DesignSparklineMetricTile(label: AppText.string("stats.processes", defaultValue: "Processes"), + UI.Chart.MetricTile(label: AppText.string("stats.processes", defaultValue: "Processes"), value: "\(delta.numProcesses)", systemImage: "gearshape.2", tint: tint) @@ -45,14 +43,7 @@ struct StatsTab: View { processList } } else { - // Running but no sample yet (first tick pending). - LazyVStack(spacing: DesignTokens.Space.m) { - ProgressView() - Text(AppText.string("stats.collecting", defaultValue: "Collecting stats...")) - .font(.callout) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + UI.State.Loading(AppText.string("stats.collecting", defaultValue: "Collecting stats...")) } } .task(id: snapshot.id) { await refreshVisibleProcesses() } @@ -61,11 +52,11 @@ struct StatsTab: View { @ViewBuilder private var processList: some View { if !processes.isEmpty { - DesignCardInsetSection { + UI.Card.InsetSection { Label(AppText.string("stats.processes", defaultValue: "Processes"), systemImage: "list.bullet.rectangle") - .font(.caption.weight(.semibold)).foregroundStyle(.secondary) + .designSectionLabelStyle() Text(processes) - .font(.system(.caption, design: .monospaced)) + .designMonospacedCaption() .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } @@ -87,7 +78,7 @@ struct StatsTab: View { } private func tile(_ metric: GraphMetric, _ delta: StatsDelta, _ symbol: String) -> some View { - DesignSparklineMetricTile(label: metric.displayName, + UI.Chart.MetricTile(label: metric.displayName, value: metric.caption(from: delta, snapshot: snapshot, normalization: normalization), systemImage: symbol, tint: tint, @@ -99,7 +90,7 @@ struct StatsTab: View { let memoryLimit = GraphMetric.memoryLimitBytes(for: delta, snapshot: snapshot, normalization: normalization) - return DesignSparklineMetricTile(label: AppText.string("stats.memory.detail", defaultValue: "Memory \(Format.bytes(delta.memoryUsageBytes)) / \(Format.bytes(memoryLimit))"), + return UI.Chart.MetricTile(label: AppText.string("stats.memory.detail", defaultValue: "Memory \(Format.bytes(delta.memoryUsageBytes)) / \(Format.bytes(memoryLimit))"), value: GraphMetric.memory.caption(from: delta, snapshot: snapshot, normalization: normalization), @@ -109,7 +100,7 @@ struct StatsTab: View { sparklineScale: sparklineScale(for: .memory)) } - private func sparklineScale(for metric: GraphMetric) -> SparklineScale { + private func sparklineScale(for metric: GraphMetric) -> UI.Chart.Scale { switch metric { case .cpu, .memory: return .fraction case .netRx, .netTx, .diskRead, .diskWrite: return .normalized diff --git a/Sources/ContainedApp/Features/Containers/TerminalTab.swift b/Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift similarity index 73% rename from Sources/ContainedApp/Features/Containers/TerminalTab.swift rename to Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift index 564fe0ea..9387e656 100644 --- a/Sources/ContainedApp/Features/Containers/TerminalTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import AppKit import SwiftTerm import ContainedCore @@ -26,20 +26,19 @@ struct TerminalTab: View { var body: some View { if snapshot.state != .running { - ContentUnavailableView { - Label("Not running", systemImage: "terminal") - } description: { - Text("Start the container to open a shell.") - } - } else if let url = app.cliURL { + UI.State.Empty(AppText.string("terminal.notRunning", defaultValue: "Not running"), + systemImage: "terminal", + description: AppText.string("terminal.notRunning.description", defaultValue: "Start the container to open a shell.")) + } else if let client = app.client, + let invocation = try? client.terminalInvocation(containerID: snapshot.id, shell: shell) { ContainerToolTabScaffold { controls } content: { ZStack { - TerminalSurface(executableURL: url, containerID: snapshot.id, shell: shell) { code in + TerminalSurface(invocation: invocation) { code in ended = Ended(code: code) } - // Recreating the view tears down the old exec. Include container/shell so rapid + // Recreating the view tears down the exec. Include container/shell so rapid // card switches cannot reuse a terminal process for a different target. .id("\(snapshot.id)-\(shell)-\(session)") .terminalSurfaceChrome() @@ -49,36 +48,36 @@ struct TerminalTab: View { } } } else { - ContentUnavailableView("Terminal unavailable", systemImage: "terminal", - description: Text("The container CLI path couldn't be resolved.")) + UI.State.Empty(AppText.string("terminal.unavailable", defaultValue: "Terminal unavailable"), + systemImage: "terminal", + description: AppText.string("terminal.unavailable.description", defaultValue: "The container CLI path couldn't be resolved.")) } } private var controls: some View { - HStack(spacing: DesignTokens.Space.m) { + HStack(spacing: UI.Layout.Spacing.m) { Picker("Shell", selection: $shell) { ForEach(shells, id: \.self) { Text($0).tag($0) } } .labelsHidden() - .frame(width: DesignTokens.FormWidth.shellPicker) + .frame(width: UI.Form.Width.shellPicker) .onChange(of: shell) { _, _ in reconnect() } Text(AppText.string("terminal.execInto", defaultValue: "exec into \(snapshot.id)")) - .font(.caption) - .foregroundStyle(.secondary) + .designSecondaryCaption() .lineLimit(1) Spacer() - DesignActionGroup(DesignAction(systemName: "arrow.clockwise", help: AppText.reconnect) { reconnect() }) + UI.Action.Group(UI.Action.Item(systemName: "arrow.clockwise", help: AppText.reconnect) { reconnect() }) } } private func endedOverlay(code: Int32?) -> some View { - DesignCardInsetSection(alignment: .center, padding: DesignTokens.Space.xl) { - Image(systemName: "bolt.horizontal.circle").font(.largeTitle).foregroundStyle(.secondary) + UI.Card.InsetSection(alignment: .center, padding: UI.Layout.Spacing.xl) { + Image(systemName: "bolt.horizontal.circle").designStateIconStyle() Text(code == nil || code == 0 ? AppText.string("terminal.sessionEnded", defaultValue: "Session ended") : AppText.string("terminal.sessionEndedWithExit", defaultValue: "Session ended (exit \(code!))")) - .font(.headline) - DesignActionGroup(DesignAction(systemName: "arrow.clockwise", + .designHeadlineLabelStyle() + UI.Action.Group(UI.Action.Item(systemName: "arrow.clockwise", title: AppText.reconnect, help: AppText.reconnectTerminal, action: reconnect)) @@ -93,11 +92,9 @@ struct TerminalTab: View { /// `NSViewRepresentable` wrapper around SwiftTerm's `LocalProcessTerminalView`, which owns the PTY /// and child process. Tearing down the view (`dismantleNSView`) terminates the `exec` child and -/// escalates stale `container exec --tty` children that do not exit from SwiftTerm's SIGTERM. +/// escalates lingering `container exec --tty` children that do not exit from SwiftTerm's SIGTERM. struct TerminalSurface: NSViewRepresentable { - let executableURL: URL - let containerID: String - let shell: String + let invocation: Core.Command.Invocation var onExit: (Int32?) -> Void = { _ in } func makeCoordinator() -> Coordinator { Coordinator(onExit: onExit) } @@ -105,9 +102,9 @@ struct TerminalSurface: NSViewRepresentable { func makeNSView(context: Context) -> LocalProcessTerminalView { let view = LocalProcessTerminalView(frame: .zero) view.processDelegate = context.coordinator - view.font = NSFont.monospacedSystemFont(ofSize: DesignTokens.Terminal.fontSize, weight: .regular) - view.nativeBackgroundColor = NSColor.black.withAlphaComponent(DesignTokens.Terminal.nativeBackgroundOpacity) - view.nativeForegroundColor = NSColor(white: DesignTokens.Terminal.nativeForegroundWhite, alpha: 1) + view.font = NSFont.monospacedSystemFont(ofSize: UI.Console.Metric.fontSize, weight: .regular) + view.nativeBackgroundColor = NSColor.black.withAlphaComponent(UI.Console.Metric.nativeBackgroundOpacity) + view.nativeForegroundColor = NSColor(white: UI.Console.Metric.nativeForegroundWhite, alpha: 1) // PTY is provided by SwiftTerm; the command builder requests a TTY inside the container // and keeps stdin attached. We must inherit the *host* environment @@ -117,8 +114,8 @@ struct TerminalSurface: NSViewRepresentable { env["TERM"] = "xterm-256color" env["LANG"] = env["LANG"] ?? "en_US.UTF-8" env["COLORTERM"] = "truecolor" - view.startProcess(executable: executableURL.path, - args: ContainerCommands.execInteractive(containerID, shell: shell), + view.startProcess(executable: invocation.executableURL.path, + args: invocation.arguments, environment: env.map { "\($0.key)=\($0.value)" }, execName: nil) return view } diff --git a/Sources/ContainedApp/Features/Containers/RunSpec.swift b/Sources/ContainedApp/Features/Containers/Form/RunSpec.swift similarity index 97% rename from Sources/ContainedApp/Features/Containers/RunSpec.swift rename to Sources/ContainedApp/Features/Containers/Form/RunSpec.swift index 1381c65a..e7f42a04 100644 --- a/Sources/ContainedApp/Features/Containers/RunSpec.swift +++ b/Sources/ContainedApp/Features/Containers/Form/RunSpec.swift @@ -54,7 +54,7 @@ struct SocketMap: Identifiable, Hashable, Codable { /// The complete app-owned state of the Create/Run form. Runtime adapters translate the derived /// `ContainerCreateRequest` into backend-specific commands. struct RunSpec: Codable { - var runtimeKind: RuntimeKind? = .appleContainer + var runtimeKind: Core.Runtime.Kind? = .appleContainer var image = "" var platform = "" var name = "" @@ -220,10 +220,9 @@ struct RunSpec: Codable { // Personalization is resolved from the local store by the edit sheet, not from labels. } - /// Command-preview compatibility for the Apple runtime while the form still presents a shell - /// preview. The backend boundary remains the runtime-neutral `createRequest`. + /// Command preview for the Apple runtime, derived from the runtime-neutral create request. func arguments() -> [String] { - ContainerCommands.run(createRequest) + Core.Command.runPreview(for: createRequest) } var createRequest: ContainerCreateRequest { @@ -329,7 +328,7 @@ struct RunSpec: Codable { if let healthCheck { self.healthCheck = healthCheck } } - var effectiveRuntimeKind: RuntimeKind { + var effectiveRuntimeKind: Core.Runtime.Kind { runtimeKind ?? .appleContainer } } diff --git a/Sources/ContainedApp/Features/Containers/RunSpecForm.swift b/Sources/ContainedApp/Features/Containers/Form/RunSpecForm.swift similarity index 79% rename from Sources/ContainedApp/Features/Containers/RunSpecForm.swift rename to Sources/ContainedApp/Features/Containers/Form/RunSpecForm.swift index be139f00..f35fa8c2 100644 --- a/Sources/ContainedApp/Features/Containers/RunSpecForm.swift +++ b/Sources/ContainedApp/Features/Containers/Form/RunSpecForm.swift @@ -1,12 +1,12 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import AppKit import ContainedCore /// The shared container Create/Edit form body: progressive-disclosure sections mapping the `run` /// flags. Reused by `ContainerEditSheet` for both new and edit modes. Built from the unified -/// `PanelSection` glass-card primitives (not `Form`) so it lives inside the shared `DesignPanelScaffold` +/// `UI.Panel.Section` glass-card primitives (not `Form`) so it lives inside the shared `UI.Panel.Scaffold` /// and measures/scrolls consistently. Field guidance is delivered through tappable `info.circle` /// popovers, not hover tooltips. struct RunSpecForm: View { @@ -22,37 +22,36 @@ struct RunSpecForm: View { } var body: some View { - LazyVStack(spacing: DesignTokens.Space.l) { + LazyVStack(spacing: UI.Layout.Spacing.l) { Text(AppText.string("runSpec.importedValuesHint", defaultValue: "Blue sections contain explicit values from an import, edit, template, or manual change.")) - .font(.caption) - .foregroundStyle(.secondary) + .designSecondaryCaption() .frame(maxWidth: .infinity, alignment: .leading) - PanelSection(header: AppText.string("runSpec.section.essentials", defaultValue: "Essentials"), highlighted: spec.hasGeneralOptions) { generalSection } - PanelSection(header: AppText.string("runSpec.section.resources", defaultValue: "Resources"), highlighted: spec.hasResourceOptions) { resourcesSection } - PanelSection(header: AppText.string("runSpec.section.networking", defaultValue: "Networking"), highlighted: spec.hasNetworkingOptions) { + UI.Panel.Section(header: AppText.string("runSpec.section.essentials", defaultValue: "Essentials"), highlighted: spec.hasGeneralOptions) { generalSection } + UI.Panel.Section(header: AppText.string("runSpec.section.resources", defaultValue: "Resources"), highlighted: spec.hasResourceOptions) { resourcesSection } + UI.Panel.Section(header: AppText.string("runSpec.section.networking", defaultValue: "Networking"), highlighted: spec.hasNetworkingOptions) { portsSection networkSection socketsSection } - PanelSection(header: AppText.string("runSpec.section.storage", defaultValue: "Storage"), highlighted: spec.hasStorageOptions) { volumesSection } - PanelSection(header: AppText.string("runSpec.section.environment", defaultValue: "Environment"), highlighted: spec.hasEnvironmentOptions) { environmentSection } - PanelSection(header: AppText.string("runSpec.section.appManaged", defaultValue: "App Managed"), highlighted: spec.hasAppManagedOptions) { + UI.Panel.Section(header: AppText.string("runSpec.section.storage", defaultValue: "Storage"), highlighted: spec.hasStorageOptions) { volumesSection } + UI.Panel.Section(header: AppText.string("runSpec.section.environment", defaultValue: "Environment"), highlighted: spec.hasEnvironmentOptions) { environmentSection } + UI.Panel.Section(header: AppText.string("runSpec.section.appManaged", defaultValue: "App Managed"), highlighted: spec.hasAppManagedOptions) { restartSection healthSection } - PanelSection(header: AppText.sectionSettingsAppearance, highlighted: spec.hasPersonalizationOptions) { personalizationSection } + UI.Panel.Section(header: AppText.sectionSettingsAppearance, highlighted: spec.hasPersonalizationOptions) { personalizationSection } advancedOptionsSection } .onChange(of: spec.hasAdvancedOptions) { _, hasValues in if hasValues { advancedExpanded = true } } .task(id: spec.normalizedImageReference) { guard !spec.image.trimmingCharacters(in: .whitespaces).isEmpty else { return } - await app.refreshImagesIfStale() + await app.refreshImagesIfNeeded() } } private var generalSection: some View { Group { - PanelRow(title: AppText.runtimeCore, + UI.Panel.Row(title: AppText.runtimeCore, subtitle: app.runtimeCoreSelectorIsEnabled ? AppText.runtimeCoreSubtitle : app.runtimeCoreSelectorDisabledReason) { Picker("", selection: runtimeKindBinding) { ForEach(app.availableRuntimeDescriptors, id: \.kind) { descriptor in @@ -63,22 +62,22 @@ struct RunSpecForm: View { .fixedSize() .disabled(!app.runtimeCoreSelectorIsEnabled) } - PanelField(label: AppText.string("runSpec.image", defaultValue: "Image"), + UI.Panel.Field(label: AppText.string("runSpec.image", defaultValue: "Image"), info: AppText.string("runSpec.image.info", defaultValue: "The container image to start, such as `nginx:latest`. If it is not on this Mac yet, Contained pulls it before running."), error: spec.image.trimmingCharacters(in: .whitespaces).isEmpty ? AppText.string("runSpec.image.required", defaultValue: "An image reference is required.") : nil) { TextField("", text: $spec.image, prompt: Text("e.g. nginx:latest")).textFieldStyle(.roundedBorder) } if imageDefaults != nil { - PanelRow(title: AppText.string("runSpec.imageDefaults", defaultValue: "Image defaults"), + UI.Panel.Row(title: AppText.string("runSpec.imageDefaults", defaultValue: "Image defaults"), subtitle: AppText.string("runSpec.imageDefaults.subtitle", defaultValue: "Fill empty command, entrypoint, user, working directory, and environment fields from the pulled image config."), info: AppText.string("runSpec.imageDefaults.info", defaultValue: "Images can define default startup settings. Adopt copies those defaults into this form so you can see and edit them before running.")) { - DesignTextActionButton(title: AppText.string("runSpec.adopt", defaultValue: "Adopt"), + UI.Action.TextButton(title: AppText.string("runSpec.adopt", defaultValue: "Adopt"), systemName: "wand.and.stars") { adoptImageDefaults() } } } - PanelRow(title: AppText.string("runSpec.platform", defaultValue: "Platform"), + UI.Panel.Row(title: AppText.string("runSpec.platform", defaultValue: "Platform"), info: AppText.string("runSpec.platform.info", defaultValue: "Use this only when an image supports more than one CPU type. Leave Default unless you specifically need arm64 or amd64.")) { Picker("", selection: platformPresetBinding) { Text("Default").tag("") @@ -90,29 +89,29 @@ struct RunSpecForm: View { .labelsHidden().fixedSize() } if platformPresetBinding.wrappedValue == "custom" { - PanelField(label: AppText.string("runSpec.customPlatform", defaultValue: "Custom platform"), + UI.Panel.Field(label: AppText.string("runSpec.customPlatform", defaultValue: "Custom platform"), info: AppText.string("runSpec.customPlatform.info", defaultValue: "Advanced platform value in `os/arch` form, for example `linux/arm64`.")) { TextField("", text: $spec.platform, prompt: Text("os/arch[/variant]")).textFieldStyle(.roundedBorder) } } - PanelField(label: AppText.string("runSpec.name", defaultValue: "Name"), + UI.Panel.Field(label: AppText.string("runSpec.name", defaultValue: "Name"), info: AppText.string("runSpec.name.info", defaultValue: "Optional friendly runtime name. Leave it blank and the container runtime will generate one.")) { TextField("", text: $spec.name, prompt: Text("optional")).textFieldStyle(.roundedBorder) } - PanelField(label: AppText.string("runSpec.command", defaultValue: "Command"), + UI.Panel.Field(label: AppText.string("runSpec.command", defaultValue: "Command"), info: AppText.string("runSpec.command.info", defaultValue: "Optional command to run instead of the image's normal startup command.")) { TextField("", text: $spec.command, prompt: Text("override the default command (optional)")).textFieldStyle(.roundedBorder) } - PanelToggleRow(title: AppText.string("runSpec.detach", defaultValue: "Run in the background"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.detach", defaultValue: "Run in the background"), info: AppText.string("runSpec.detach.info", defaultValue: "Detached (-d): runs without attaching to its output."), isOn: $spec.detach) - PanelToggleRow(title: AppText.string("runSpec.removeWhenStopped", defaultValue: "Remove when stopped"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.removeWhenStopped", defaultValue: "Remove when stopped"), info: AppText.string("runSpec.removeWhenStopped.info", defaultValue: "Deletes the container record when it stops. Use volumes if you need data to survive."), isOn: $spec.removeOnExit) } } private var resourcesSection: some View { Group { - PanelRow(title: AppText.string("runSpec.cpus", defaultValue: "CPUs"), + UI.Panel.Row(title: AppText.string("runSpec.cpus", defaultValue: "CPUs"), info: AppText.string("runSpec.cpus.info", defaultValue: "Limit how much CPU the container can use. Default lets the runtime decide. This Mac has \(hostCPUs) cores.")) { Picker("", selection: cpuBinding) { Text("Default").tag(0) @@ -120,13 +119,13 @@ struct RunSpecForm: View { } .labelsHidden().fixedSize() } - PanelToggleRow(title: AppText.string("runSpec.limitMemory", defaultValue: "Limit memory"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.limitMemory", defaultValue: "Limit memory"), info: AppText.string("runSpec.limitMemory.info", defaultValue: "Set a memory ceiling for the container. If it goes past the limit, the runtime may stop it."), isOn: memoryLimitBinding) if !spec.memory.isEmpty { - PanelField(label: AppText.string("runSpec.memory", defaultValue: "Memory")) { - HStack(spacing: DesignTokens.Space.s) { + UI.Panel.Field(label: AppText.string("runSpec.memory", defaultValue: "Memory")) { + HStack(spacing: UI.Layout.Spacing.s) { Slider(value: memoryGBBinding, in: 0.5...max(0.5, maxMemoryGB), step: 0.5) - Text(memoryReadout).monospacedDigit().frame(width: DesignTokens.FormWidth.memoryReadout) + Text(memoryReadout).monospacedDigit().frame(width: UI.Form.Width.memoryReadout) } } } @@ -169,7 +168,7 @@ struct RunSpecForm: View { app.imageDefaults(for: spec) } - private var runtimeKindBinding: Binding { + private var runtimeKindBinding: Binding { Binding(get: { spec.effectiveRuntimeKind }, set: { spec.runtimeKind = $0 }) } @@ -196,11 +195,11 @@ struct RunSpecForm: View { Group { ForEach($spec.ports) { $port in HStack { - TextField("Host", text: $port.hostPort).textFieldStyle(.roundedBorder).frame(width: DesignTokens.FormWidth.port) - Image(systemName: "arrow.right").foregroundStyle(.secondary) - TextField("Container", text: $port.containerPort).textFieldStyle(.roundedBorder).frame(width: DesignTokens.FormWidth.containerPort) + TextField("Host", text: $port.hostPort).textFieldStyle(.roundedBorder).frame(width: UI.Form.Width.port) + UI.Symbol.Image(systemName: "arrow.right") + TextField("Container", text: $port.containerPort).textFieldStyle(.roundedBorder).frame(width: UI.Form.Width.containerPort) Picker("", selection: $port.proto) { Text("tcp").tag("tcp"); Text("udp").tag("udp") } - .labelsHidden().frame(width: DesignTokens.FormWidth.port) + .labelsHidden().frame(width: UI.Form.Width.port) Spacer() removeButton { spec.ports.removeAll { $0.id == port.id } } } @@ -212,7 +211,7 @@ struct RunSpecForm: View { private var volumesSection: some View { Group { ForEach($spec.volumes) { $vol in - LazyVStack(spacing: DesignTokens.Space.xs) { + LazyVStack(spacing: UI.Layout.Spacing.xs) { HStack { sourcePicker(source: $vol.source) TextField("Source (host path or volume)", text: $vol.source).textFieldStyle(.roundedBorder) @@ -233,7 +232,7 @@ struct RunSpecForm: View { ForEach($spec.env) { $variable in HStack { TextField("KEY", text: $variable.key).textFieldStyle(.roundedBorder) - Text("=").foregroundStyle(.secondary) + UI.State.StatusText("=") TextField("value", text: $variable.value).textFieldStyle(.roundedBorder) removeButton { spec.env.removeAll { $0.id == variable.id } } } @@ -247,7 +246,7 @@ struct RunSpecForm: View { private var socketsSection: some View { Group { ForEach($spec.sockets) { $socket in - LazyVStack(spacing: DesignTokens.Space.xs) { + LazyVStack(spacing: UI.Layout.Spacing.xs) { HStack { TextField("Host socket path", text: $socket.hostPath).textFieldStyle(.roundedBorder) removeButton { spec.sockets.removeAll { $0.id == socket.id } } @@ -264,7 +263,7 @@ struct RunSpecForm: View { ForEach($spec.labels) { $label in HStack { TextField("KEY", text: $label.key).textFieldStyle(.roundedBorder) - Text("=").foregroundStyle(.secondary) + UI.State.StatusText("=") TextField("value", text: $label.value).textFieldStyle(.roundedBorder) removeButton { spec.labels.removeAll { $0.id == label.id } } } @@ -275,40 +274,40 @@ struct RunSpecForm: View { private var personalizationSection: some View { Group { - PanelField(label: AppText.string("runSpec.nickname", defaultValue: "Nickname"), + UI.Panel.Field(label: AppText.string("runSpec.nickname", defaultValue: "Nickname"), info: AppText.string("runSpec.nickname.info", defaultValue: "A display name for the card only. It does not rename the real container.")) { TextField("", text: $spec.personalization.nickname, prompt: Text("display name (optional)")).textFieldStyle(.roundedBorder) } - PanelField(label: AppText.string("runSpec.icon", defaultValue: "Icon"), + UI.Panel.Field(label: AppText.string("runSpec.icon", defaultValue: "Icon"), info: AppText.string("runSpec.icon.info", defaultValue: "An SF Symbol name for the card icon, such as `shippingbox` or `bolt`.")) { TextField("", text: $spec.personalization.icon, prompt: Text("SF Symbol, e.g. globe, bolt")).textFieldStyle(.roundedBorder) } - PanelRow(title: AppText.string("runSpec.color", defaultValue: "Color"), + UI.Panel.Row(title: AppText.string("runSpec.color", defaultValue: "Color"), info: AppText.string("runSpec.color.info", defaultValue: "Sets the card icon color. If background color is enabled, it also tints the glass card.")) { - TintSelector(selection: $spec.personalization.tint) { $0.localizedDisplayName } + UI.Control.TintSelector(selection: $spec.personalization.tint) { $0.localizedDisplayName } } - PanelToggleRow(title: AppText.string("runSpec.colorCardBackground", defaultValue: "Color the card background"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.colorCardBackground", defaultValue: "Color the card background"), info: AppText.string("runSpec.colorCardBackground.info", defaultValue: "Adds a soft color wash behind the glass. Turn it off for clear glass with only a colored icon."), isOn: $spec.personalization.fillBackground) if spec.personalization.fillBackground { - PanelField(label: AppText.string("runSpec.opacity", defaultValue: "Opacity")) { - HStack(spacing: DesignTokens.Space.s) { + UI.Panel.Field(label: AppText.string("runSpec.opacity", defaultValue: "Opacity")) { + HStack(spacing: UI.Layout.Spacing.s) { Slider(value: $spec.personalization.backgroundOpacity, in: 0.05...0.6) Text(Format.percent(spec.personalization.backgroundOpacity)) .monospacedDigit() - .frame(width: DesignTokens.FormWidth.shortReadout) + .frame(width: UI.Form.Width.shortReadout) } } - PanelToggleRow(title: AppText.string("runSpec.gradient", defaultValue: "Gradient"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.gradient", defaultValue: "Gradient"), info: AppText.string("runSpec.gradient.info", defaultValue: "Blends the color across the card instead of using one flat wash."), isOn: $spec.personalization.gradient) if spec.personalization.gradient { GradientAngleControl(angle: $spec.personalization.gradientAngle, title: AppText.direction) } - PanelRow(title: AppText.string("runSpec.blendMode", defaultValue: "Blend mode"), + UI.Panel.Row(title: AppText.string("runSpec.blendMode", defaultValue: "Blend mode"), info: AppText.string("runSpec.blendMode.info", defaultValue: "Controls how the card color wash blends with the glass behind it.")) { Picker("", selection: $spec.personalization.backgroundBlendMode) { - ForEach(ColorLayerBlendMode.allCases) { mode in + ForEach(UI.Theme.ColorBlendMode.allCases) { mode in Text(mode.localizedDisplayName).tag(mode) } } @@ -320,7 +319,7 @@ struct RunSpecForm: View { } private var restartSection: some View { - PanelRow(title: AppText.string("runSpec.restartPolicy", defaultValue: "Restart policy"), + UI.Panel.Row(title: AppText.string("runSpec.restartPolicy", defaultValue: "Restart policy"), info: AppText.string("runSpec.restartPolicy.info", defaultValue: "Contained restarts the container automatically based on this setting.")) { Picker("", selection: $spec.restart) { ForEach(RestartPolicy.allCases) { Text($0.localizedDisplayName).tag($0) } @@ -331,11 +330,11 @@ struct RunSpecForm: View { private var healthSection: some View { Group { - PanelToggleRow(title: AppText.string("runSpec.enableHealthcheck", defaultValue: "Enable healthcheck"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.enableHealthcheck", defaultValue: "Enable healthcheck"), info: AppText.string("runSpec.enableHealthcheck.info", defaultValue: "Contained probes the container on an interval (app-managed; the runtime has no native healthcheck)."), isOn: $spec.healthCheck.enabled) if spec.healthCheck.enabled { - PanelField(label: AppText.string("runSpec.probeCommand", defaultValue: "Probe command"), + UI.Panel.Field(label: AppText.string("runSpec.probeCommand", defaultValue: "Probe command"), info: AppText.string("runSpec.probeCommand.info", defaultValue: "Run inside the container via `sh -c`; a zero exit = healthy. Needs a shell in the image.")) { TextField("", text: healthCommandBinding, prompt: Text("curl -f http://localhost/ || exit 1")).textFieldStyle(.roundedBorder) } @@ -362,39 +361,39 @@ struct RunSpecForm: View { @ViewBuilder private var runtimeSection: some View { Group { - PanelField(label: AppText.string("runSpec.entrypoint", defaultValue: "Entrypoint"), + UI.Panel.Field(label: AppText.string("runSpec.entrypoint", defaultValue: "Entrypoint"), info: AppText.string("runSpec.entrypoint.info", defaultValue: "Override the image's entrypoint program.")) { TextField("", text: $spec.entrypoint, prompt: Text("optional")).textFieldStyle(.roundedBorder) } - PanelToggleRow(title: AppText.string("runSpec.keepStdinOpen", defaultValue: "Keep stdin open"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.keepStdinOpen", defaultValue: "Keep stdin open"), info: AppText.string("runSpec.keepStdinOpen.info", defaultValue: "Keep standard input open even when detached (--interactive)."), isOn: $spec.interactive) - PanelToggleRow(title: AppText.string("runSpec.allocateTTY", defaultValue: "Allocate TTY"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.allocateTTY", defaultValue: "Allocate TTY"), info: AppText.string("runSpec.allocateTTY.info", defaultValue: "Allocate a terminal for the process (--tty)."), isOn: $spec.tty) - PanelField(label: AppText.string("runSpec.workingDirectory", defaultValue: "Working directory"), + UI.Panel.Field(label: AppText.string("runSpec.workingDirectory", defaultValue: "Working directory"), info: AppText.string("runSpec.workingDirectory.info", defaultValue: "Initial working directory inside the container (-w).")) { TextField("", text: $spec.workingDir, prompt: Text("optional, e.g. /app")).textFieldStyle(.roundedBorder) } - PanelField(label: AppText.string("runSpec.user", defaultValue: "User"), + UI.Panel.Field(label: AppText.string("runSpec.user", defaultValue: "User"), info: AppText.string("runSpec.user.info", defaultValue: "Run the process as this user (-u). Or set UID/GID below.")) { TextField("", text: $spec.user, prompt: Text("name | uid[:gid]")).textFieldStyle(.roundedBorder) } - PanelField(label: AppText.string("runSpec.userID", defaultValue: "User ID"), + UI.Panel.Field(label: AppText.string("runSpec.userID", defaultValue: "User ID"), info: AppText.string("runSpec.userID.info", defaultValue: "Numeric user / group IDs (--uid / --gid).")) { HStack { - TextField("UID", text: $spec.uid).textFieldStyle(.roundedBorder).frame(width: DesignTokens.FormWidth.userID) - TextField("GID", text: $spec.gid).textFieldStyle(.roundedBorder).frame(width: DesignTokens.FormWidth.userID) + TextField("UID", text: $spec.uid).textFieldStyle(.roundedBorder).frame(width: UI.Form.Width.userID) + TextField("GID", text: $spec.gid).textFieldStyle(.roundedBorder).frame(width: UI.Form.Width.userID) Spacer() } } - PanelToggleRow(title: AppText.string("runSpec.setSharedMemorySize", defaultValue: "Set shared memory size"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.setSharedMemorySize", defaultValue: "Set shared memory size"), info: AppText.string("runSpec.setSharedMemorySize.info", defaultValue: "Size of /dev/shm (--shm-size)."), isOn: shmLimitBinding) if !spec.shmSize.isEmpty { - PanelField(label: AppText.string("runSpec.sharedMemory", defaultValue: "Shared memory")) { - HStack(spacing: DesignTokens.Space.s) { + UI.Panel.Field(label: AppText.string("runSpec.sharedMemory", defaultValue: "Shared memory")) { + HStack(spacing: UI.Layout.Spacing.s) { Slider(value: shmGBBinding, in: 0.0625...max(0.0625, maxMemoryGB), step: 0.0625) Text(memoryReadout(spec.shmSize, fallbackGB: 0.0625)) .monospacedDigit() - .frame(width: DesignTokens.FormWidth.memoryReadout) + .frame(width: UI.Form.Width.memoryReadout) } } } @@ -403,7 +402,7 @@ struct RunSpecForm: View { info: AppText.string("runSpec.addCapability.info", defaultValue: "Add a Linux capability (--cap-add).")) stringList(AppText.string("runSpec.dropCapability", defaultValue: "Drop capability"), $spec.capDrop, prompt: "CAP_NET_RAW or ALL", info: AppText.string("runSpec.dropCapability.info", defaultValue: "Drop a Linux capability (--cap-drop).")) - PanelField(label: AppText.string("runSpec.containerIDFile", defaultValue: "Container ID file"), + UI.Panel.Field(label: AppText.string("runSpec.containerIDFile", defaultValue: "Container ID file"), info: AppText.string("runSpec.containerIDFile.info", defaultValue: "Write the new container ID to a file (--cidfile).")) { TextField("", text: $spec.cidFile, prompt: Text("optional path")).textFieldStyle(.roundedBorder) } @@ -417,22 +416,22 @@ struct RunSpecForm: View { @ViewBuilder private var securitySection: some View { Group { - PanelToggleRow(title: AppText.string("runSpec.readOnlyFilesystem", defaultValue: "Read-only filesystem"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.readOnlyFilesystem", defaultValue: "Read-only filesystem"), info: AppText.string("runSpec.readOnlyFilesystem.info", defaultValue: "Mounts the container's root filesystem as read-only."), isOn: $spec.readOnly) - PanelToggleRow(title: AppText.string("runSpec.useInitProcess", defaultValue: "Use an init process"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.useInitProcess", defaultValue: "Use an init process"), info: AppText.string("runSpec.useInitProcess.info", defaultValue: "Runs a tiny init that forwards signals and cleans up zombie processes."), isOn: $spec.useInit) - PanelToggleRow(title: AppText.string("runSpec.rosetta", defaultValue: "Rosetta (x86 apps)"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.rosetta", defaultValue: "Rosetta (x86 apps)"), info: AppText.string("runSpec.rosetta.info", defaultValue: "Lets the container run x86-64 binaries via Rosetta."), isOn: $spec.rosetta) - PanelToggleRow(title: AppText.string("runSpec.forwardSSHAgent", defaultValue: "Forward SSH agent"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.forwardSSHAgent", defaultValue: "Forward SSH agent"), info: AppText.string("runSpec.forwardSSHAgent.info", defaultValue: "Forwards your host SSH agent into the container."), isOn: $spec.ssh) - PanelToggleRow(title: AppText.string("runSpec.exposeVirtualization", defaultValue: "Expose virtualization"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.exposeVirtualization", defaultValue: "Expose virtualization"), info: AppText.string("runSpec.exposeVirtualization.info", defaultValue: "Exposes nested virtualization (needs host + guest support)."), isOn: $spec.virtualization) } } @ViewBuilder private var networkSection: some View { - PanelRow(title: AppText.string("runSpec.network", defaultValue: "Network"), + UI.Panel.Row(title: AppText.string("runSpec.network", defaultValue: "Network"), info: AppText.string("runSpec.network.info", defaultValue: "Attach the container to a network (--network).")) { Menu(networkMenuTitle) { Button { @@ -457,7 +456,7 @@ struct RunSpecForm: View { } .fixedSize() TextField("", text: $spec.network, prompt: Text("custom network")).textFieldStyle(.roundedBorder) - .frame(width: DesignTokens.FormWidth.networkName) + .frame(width: UI.Form.Width.networkName) } .task { await app.refreshNetworks() } } @@ -465,19 +464,19 @@ struct RunSpecForm: View { @ViewBuilder private var fetchSection: some View { Group { - PanelField(label: AppText.string("runSpec.runtime", defaultValue: "Runtime"), + UI.Panel.Field(label: AppText.string("runSpec.runtime", defaultValue: "Runtime"), info: AppText.string("runSpec.runtime.info", defaultValue: "Runtime handler (--runtime).")) { TextField("", text: $spec.runtime, prompt: Text("optional")).textFieldStyle(.roundedBorder) } - PanelField(label: AppText.string("runSpec.initImage", defaultValue: "Init image"), + UI.Panel.Field(label: AppText.string("runSpec.initImage", defaultValue: "Init image"), info: AppText.string("runSpec.initImage.info", defaultValue: "Use a custom init image (--init-image).")) { TextField("", text: $spec.initImage, prompt: Text("optional image")).textFieldStyle(.roundedBorder) } - PanelField(label: AppText.string("runSpec.kernel", defaultValue: "Kernel"), + UI.Panel.Field(label: AppText.string("runSpec.kernel", defaultValue: "Kernel"), info: AppText.string("runSpec.kernel.info", defaultValue: "Use a custom kernel path (--kernel).")) { TextField("", text: $spec.kernel, prompt: Text("optional path")).textFieldStyle(.roundedBorder) } - PanelRow(title: AppText.string("runSpec.registryScheme", defaultValue: "Registry scheme"), + UI.Panel.Row(title: AppText.string("runSpec.registryScheme", defaultValue: "Registry scheme"), info: AppText.string("runSpec.registryScheme.info", defaultValue: "Registry connection scheme for image fetches (--scheme).")) { Picker("", selection: $spec.scheme) { Text("Default").tag("") @@ -487,7 +486,7 @@ struct RunSpecForm: View { } .labelsHidden().fixedSize() } - PanelRow(title: AppText.string("runSpec.progress", defaultValue: "Progress"), + UI.Panel.Row(title: AppText.string("runSpec.progress", defaultValue: "Progress"), info: AppText.string("runSpec.progress.info", defaultValue: "Progress display mode for image fetches (--progress).")) { Picker("", selection: $spec.progress) { Text("Default").tag("") @@ -499,7 +498,7 @@ struct RunSpecForm: View { } .labelsHidden().fixedSize() } - PanelToggleRow(title: AppText.string("runSpec.limitParallelDownloads", defaultValue: "Limit parallel downloads"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.limitParallelDownloads", defaultValue: "Limit parallel downloads"), info: AppText.string("runSpec.limitParallelDownloads.info", defaultValue: "Maximum concurrent image downloads (--max-concurrent-downloads)."), isOn: maxDownloadsBinding) if !spec.maxConcurrentDownloads.isEmpty { Stepper("Max downloads: \(maxConcurrentDownloadsBinding.wrappedValue)", @@ -511,12 +510,12 @@ struct RunSpecForm: View { @ViewBuilder private var dnsSection: some View { Group { - PanelToggleRow(title: AppText.string("runSpec.disableDNS", defaultValue: "Disable DNS"), + UI.Panel.ToggleRow(title: AppText.string("runSpec.disableDNS", defaultValue: "Disable DNS"), info: AppText.string("runSpec.disableDNS.info", defaultValue: "Do not configure DNS inside the container (--no-dns)."), isOn: $spec.noDNS) if !spec.noDNS { stringList(AppText.string("runSpec.addNameserver", defaultValue: "Add nameserver"), $spec.dns, prompt: "1.1.1.1", info: AppText.string("runSpec.addNameserver.info", defaultValue: "DNS nameserver IP (--dns).")) - PanelField(label: AppText.string("runSpec.searchDomain", defaultValue: "Search domain"), + UI.Panel.Field(label: AppText.string("runSpec.searchDomain", defaultValue: "Search domain"), info: AppText.string("runSpec.searchDomain.info", defaultValue: "Default DNS domain (--dns-domain).")) { TextField("", text: $spec.dnsDomain, prompt: Text("optional")).textFieldStyle(.roundedBorder) } @@ -532,7 +531,7 @@ struct RunSpecForm: View { private var advancedOptionsSection: some View { // The header switch shows/hides the less-common run settings (Compose import and Edit flip it on // automatically when advanced values are present). - PanelSection(header: AppText.string("runSpec.section.advancedOptions", defaultValue: "Advanced Options"), + UI.Panel.Section(header: AppText.string("runSpec.section.advancedOptions", defaultValue: "Advanced Options"), footer: AppText.string("runSpec.section.advancedOptions.footer", defaultValue: "Less-common run settings. Compose import and Edit reveal these automatically when advanced values are present."), highlighted: spec.hasAdvancedOptions, enabled: $advancedExpanded) { @@ -581,22 +580,22 @@ struct RunSpecForm: View { removeButton { list.wrappedValue.remove(at: idx) } } } - HStack(spacing: DesignTokens.Space.s) { + HStack(spacing: UI.Layout.Spacing.s) { addButton(addTitle) { list.wrappedValue.append("") } - InfoButton(info) + UI.Control.InfoButton(info) Spacer() } } private func addButton(_ title: String, action: @escaping () -> Void) -> some View { - DesignActionGroup(DesignAction(systemName: "plus.circle", + UI.Action.Group(UI.Action.Item(systemName: "plus.circle", title: title, help: title, action: action)) } private func removeButton(action: @escaping () -> Void) -> some View { - DesignActionGroup(DesignAction(systemName: "minus.circle.fill", + UI.Action.Group(UI.Action.Item(systemName: "minus.circle.fill", help: AppText.string("common.remove", defaultValue: "Remove"), action: action)) } diff --git a/Sources/ContainedApp/Features/Containers/RunSpecFormSupport.swift b/Sources/ContainedApp/Features/Containers/Form/RunSpecFormSupport.swift similarity index 100% rename from Sources/ContainedApp/Features/Containers/RunSpecFormSupport.swift rename to Sources/ContainedApp/Features/Containers/Form/RunSpecFormSupport.swift diff --git a/Sources/ContainedApp/Stores/ContainersStore.swift b/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift similarity index 92% rename from Sources/ContainedApp/Stores/ContainersStore.swift rename to Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift index 2de4608d..b14cc0e0 100644 --- a/Sources/ContainedApp/Stores/ContainersStore.swift +++ b/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift @@ -1,10 +1,9 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import OSLog import ContainedCore -import ContainedRuntime -/// Owns the container list and derived live stats. Lifecycle actions run through the client and +/// Owns the container list and derived live stats. Lifecycle actions run through the Core orchestrator and /// trigger a refresh. Stats arrive from the app-wide runtime stream and are converted into deltas /// for cards, expanded panels, history, and restart/health context. @MainActor @@ -12,10 +11,10 @@ import ContainedRuntime final class ContainerMetricsState { let id: String var stats: StatsDelta? - var historyByMetric: [GraphMetric: SampleBuffer] + var historyByMetric: [GraphMetric: UI.Chart.SampleBuffer] private(set) var revision = 0 - init(id: String, stats: StatsDelta? = nil, historyByMetric: [GraphMetric: SampleBuffer] = [:]) { + init(id: String, stats: StatsDelta? = nil, historyByMetric: [GraphMetric: UI.Chart.SampleBuffer] = [:]) { self.id = id self.stats = stats self.historyByMetric = historyByMetric @@ -25,7 +24,7 @@ final class ContainerMetricsState { historyByMetric[metric]?.values ?? [] } - func update(stats: StatsDelta?, historyByMetric: [GraphMetric: SampleBuffer]) { + func update(stats: StatsDelta?, historyByMetric: [GraphMetric: UI.Chart.SampleBuffer]) { var changed = false if self.stats != stats { self.stats = stats @@ -49,7 +48,7 @@ final class ContainersStore { var statsByID: [String: StatsDelta] = [:] /// Per-container, per-metric sparkline history. @ObservationIgnored - var historyByID: [String: [GraphMetric: SampleBuffer]] = [:] + var historyByID: [String: [GraphMetric: UI.Chart.SampleBuffer]] = [:] @ObservationIgnored private(set) var statsRevision = 0 var errorMessage: String? @@ -59,7 +58,7 @@ final class ContainersStore { @ObservationIgnored private var metricsStates: [String: ContainerMetricsState] = [:] @ObservationIgnored private var statsNormalizationContext: StatsNormalizationContext = .containerSpecific - var client: (any ContainerRuntimeClient)? + var client: Core.Orchestrator? private var lastStreamedStats: [String: RuntimeStatsSnapshot] = [:] private var lastStreamedStatsDate: Date? @@ -210,11 +209,11 @@ final class ContainersStore { private func record(_ delta: StatsDelta, snapshot: ContainerSnapshot?, stats: inout [String: StatsDelta], - history: inout [String: [GraphMetric: SampleBuffer]]) { + history: inout [String: [GraphMetric: UI.Chart.SampleBuffer]]) { stats[delta.id] = delta var metrics = history[delta.id] ?? [:] for metric in GraphMetric.allCases { - var buffer = metrics[metric] ?? SampleBuffer() + var buffer = metrics[metric] ?? UI.Chart.SampleBuffer() buffer.append(metric.value(from: delta, snapshot: snapshot, normalization: statsNormalizationContext)) metrics[metric] = buffer } @@ -224,11 +223,11 @@ final class ContainersStore { private func rebuildDisplayHistories() { let snapshotsByID = Dictionary(snapshots.map { ($0.id, $0) }, uniquingKeysWith: { current, _ in current }) let runningSet = Set(running.map(\.id)) - var rebuilt: [String: [GraphMetric: SampleBuffer]] = [:] + var rebuilt: [String: [GraphMetric: UI.Chart.SampleBuffer]] = [:] for (id, delta) in statsByID where runningSet.contains(id) { - var metrics: [GraphMetric: SampleBuffer] = [:] + var metrics: [GraphMetric: UI.Chart.SampleBuffer] = [:] for metric in GraphMetric.allCases { - var buffer = SampleBuffer() + var buffer = UI.Chart.SampleBuffer() buffer.append(metric.value(from: delta, snapshot: snapshotsByID[id], normalization: statsNormalizationContext)) @@ -273,9 +272,6 @@ final class ContainersStore { severity: .info) diagnosticLogger.notice("Run started from creation flow") do { - guard client.descriptor.kind == spec.effectiveRuntimeKind else { - throw UnsupportedRuntimeCapability(kind: spec.effectiveRuntimeKind, capability: .containers) - } let result = try await client.createContainer(spec.createRequest) performHaptic() await refresh() @@ -298,9 +294,8 @@ final class ContainersStore { } } - /// Recreate macro: tear down `originalID` and run `spec` in its place (container config is - /// immutable, so "editing" means delete + re-run). Returns true on success. On failure the old - /// container is already gone — the error is surfaced for the caller to show. + /// Recreate macro: tear down `originalID` and run `spec` in its place. Container config is + /// immutable, so edits become a replacement run. Errors are surfaced for the caller to show. @discardableResult func recreate(originalID: String, spec: RunSpec) async -> Bool { guard let client else { return false } @@ -311,9 +306,6 @@ final class ContainersStore { logger?.record("Recreating \(originalID)", category: .lifecycle, containerID: originalID) diagnosticLogger.notice("Recreate started for \(originalID, privacy: .public)") do { - guard client.descriptor.kind == spec.effectiveRuntimeKind else { - throw UnsupportedRuntimeCapability(kind: spec.effectiveRuntimeKind, capability: .containers) - } _ = try await client.recreateContainer(originalID: originalID, request: spec.createRequest) performHaptic() await refresh() @@ -341,7 +333,7 @@ final class ContainersStore { private func act(_ id: String, verb: String, - _ body: @escaping (any ContainerRuntimeClient) async throws -> Void) async { + _ body: @escaping (Core.Orchestrator) async throws -> Void) async { guard let client else { return } busyIDs.insert(id) defer { busyIDs.remove(id) } diff --git a/Sources/ContainedApp/Stores/RestartWatchdog.swift b/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift similarity index 97% rename from Sources/ContainedApp/Stores/RestartWatchdog.swift rename to Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift index 27c77318..ab2dffe1 100644 --- a/Sources/ContainedApp/Stores/RestartWatchdog.swift +++ b/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift @@ -1,6 +1,5 @@ import Foundation import ContainedCore -import ContainedRuntime /// App-managed restart policy. The `container` CLI has no native `--restart`, so on each refresh /// tick we diff container states and re-issue `start` for containers that crashed (transitioned @@ -25,7 +24,7 @@ final class RestartWatchdog { /// Evaluate the latest snapshots against the previous tick and act on crashes. func evaluate(snapshots: [ContainerSnapshot], store: ContainersStore, - client: any ContainerRuntimeClient, + client: Core.Orchestrator, now: Date = Date()) async { var restarts: [(ContainerSnapshot, Int)] = [] diff --git a/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift b/Sources/ContainedApp/Features/Images/Build/BuildWorkspaceView.swift similarity index 75% rename from Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift rename to Sources/ContainedApp/Features/Images/Build/BuildWorkspaceView.swift index 36219af2..c46a0ede 100644 --- a/Sources/ContainedApp/Features/Images/BuildWorkspaceView.swift +++ b/Sources/ContainedApp/Features/Images/Build/BuildWorkspaceView.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import AppKit import ContainedCore @@ -26,7 +26,7 @@ struct BuildWorkspaceView: View { form Divider() if building, let context = contextDir, let client = app.client { - StreamConsole(stream: { + UI.Console.Stream(stream: { client.streamBuild(context: context.path, tag: tag.trimmingCharacters(in: .whitespaces), dockerfile: dockerfile.isEmpty ? nil : dockerfile, @@ -38,54 +38,52 @@ struct BuildWorkspaceView: View { lineCountLabel: AppText.lineCount, copyLogHelp: AppText.copyLog, failureLabel: AppErrorPresentation.message, - onComplete: { ok in if ok { Task { await app.refreshImagesIfStale(force: true) } } }) + onComplete: { ok in if ok { Task { await app.refreshImagesIfNeeded(force: true) } } }) .id(run) - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) } else { - ContentUnavailableView { - Label(AppText.string("build.empty.title", defaultValue: "Build an image"), systemImage: "hammer") - } description: { - Text(AppText.string("build.empty.description", defaultValue: "Choose a context folder and a tag, then Build. Output streams here.")) - } + UI.State.Empty(AppText.string("build.empty.title", defaultValue: "Build an image"), + systemImage: "hammer", + description: AppText.string("build.empty.description", defaultValue: "Choose a context folder and a tag, then Build. Output streams here.")) } } } private var form: some View { ScrollView { - LazyVStack(spacing: DesignTokens.Space.l) { + LazyVStack(spacing: UI.Layout.Spacing.l) { sourceSection optionsSection commandSection } - .padding(DesignTokens.Space.l) + .padding(UI.Layout.Spacing.l) } .scrollEdgeEffectStyle(.soft, for: .all) .frame(maxHeight: 420) } private var sourceSection: some View { - PanelSection(header: AppText.string("build.source", defaultValue: "Source")) { - PanelField(label: AppText.string("build.context", defaultValue: "Context"), + UI.Panel.Section(header: AppText.string("build.source", defaultValue: "Source")) { + UI.Panel.Field(label: AppText.string("build.context", defaultValue: "Context"), info: AppText.string("build.context.info", defaultValue: "The build context: the folder sent to the builder, usually your project root.")) { HStack { - Text(contextDir?.path ?? AppText.string("build.chooseFolderPlaceholder", defaultValue: "Choose a folder...")) - .foregroundStyle(contextDir == nil ? .secondary : .primary) + UI.State.StatusText(contextDir?.path ?? AppText.string("build.chooseFolderPlaceholder", defaultValue: "Choose a folder..."), + tone: contextDir == nil ? .neutral : .primary) .lineLimit(1) .truncationMode(.middle) Spacer() - DesignActionGroup(DesignAction(systemName: "folder", + UI.Action.Group(UI.Action.Item(systemName: "folder", title: AppText.choose, help: AppText.chooseContextFolder, action: chooseFolder)) } } - PanelField(label: AppText.string("build.dockerfile", defaultValue: "Dockerfile"), + UI.Panel.Field(label: AppText.string("build.dockerfile", defaultValue: "Dockerfile"), info: AppText.string("build.dockerfile.info", defaultValue: "Path to the Dockerfile (-f). Relative to the context if not absolute.")) { TextField("", text: $dockerfile, prompt: Text("optional, defaults to /Dockerfile")) .textFieldStyle(.roundedBorder) } - PanelField(label: AppText.string("build.tag", defaultValue: "Tag"), + UI.Panel.Field(label: AppText.string("build.tag", defaultValue: "Tag"), info: AppText.string("build.tag.info", defaultValue: "The resulting image reference (-t).")) { TextField("", text: $tag, prompt: Text("name for the built image, e.g. myapp:latest")) .textFieldStyle(.roundedBorder) @@ -94,34 +92,34 @@ struct BuildWorkspaceView: View { } private var optionsSection: some View { - PanelSection(header: AppText.string("build.options", defaultValue: "Options")) { - PanelField(label: AppText.string("build.platform", defaultValue: "Platform")) { + UI.Panel.Section(header: AppText.string("build.options", defaultValue: "Options")) { + UI.Panel.Field(label: AppText.string("build.platform", defaultValue: "Platform")) { TextField("", text: $platform, prompt: Text("optional, e.g. linux/arm64")) .textFieldStyle(.roundedBorder) } - PanelToggleRow(title: AppText.string("build.noCache", defaultValue: "No cache"), + UI.Panel.ToggleRow(title: AppText.string("build.noCache", defaultValue: "No cache"), info: AppText.string("build.noCache.info", defaultValue: "Build every layer from scratch (--no-cache)."), isOn: $noCache) ForEach($buildArgs) { $arg in - PanelField(label: AppText.string("build.arg", defaultValue: "Build arg")) { + UI.Panel.Field(label: AppText.string("build.arg", defaultValue: "Build arg")) { HStack { TextField("KEY", text: $arg.key) .textFieldStyle(.roundedBorder) - Text("=").foregroundStyle(.secondary) + UI.State.StatusText("=") TextField("value", text: $arg.value) .textFieldStyle(.roundedBorder) - DesignActionGroup(DesignAction(systemName: "minus.circle.fill", + UI.Action.Group(UI.Action.Item(systemName: "minus.circle.fill", help: AppText.removeBuildArgument) { buildArgs.removeAll { $0.id == arg.id } }) } } } - PanelRow(title: AppText.string("build.arguments", defaultValue: "Build arguments"), + UI.Panel.Row(title: AppText.string("build.arguments", defaultValue: "Build arguments"), subtitle: buildArgs.isEmpty ? AppText.string("build.arguments.empty", defaultValue: "No build-time variables added.") : AppText.string("build.arguments.count", defaultValue: "\(buildArgs.count) argument(s)")) { - DesignActionGroup(DesignAction(systemName: "plus.circle", + UI.Action.Group(UI.Action.Item(systemName: "plus.circle", title: AppText.string("build.addBuildArg.short", defaultValue: "Add build arg"), help: AppText.addBuildArgument) { buildArgs.append(KeyValue()) @@ -131,21 +129,21 @@ struct BuildWorkspaceView: View { } private var commandSection: some View { - PanelSection { - HStack(spacing: DesignTokens.Space.s) { - CommandPreviewBar(command: previewCommand, + UI.Panel.Section { + HStack(spacing: UI.Layout.Spacing.s) { + UI.Command.PreviewBar(command: previewCommand, copyHelp: AppText.copyCommand, copiedAccessibilityLabel: AppText.copied) .frame(maxWidth: .infinity) if building { - DesignActionGroup(DesignAction(systemName: "xmark", + UI.Action.Group(UI.Action.Item(systemName: "xmark", title: AppText.cancel, help: AppText.cancelBuild, role: .destructive) { building = false }) } else { - DesignActionGroup(DesignAction(systemName: "hammer.fill", + UI.Action.Group(UI.Action.Item(systemName: "hammer.fill", title: AppText.string("build.build", defaultValue: "Build"), help: AppText.buildImage, isEnabled: canBuild, @@ -160,7 +158,7 @@ struct BuildWorkspaceView: View { } private var previewCommand: [String] { - ContainerCommands.build(context: contextDir?.path ?? "", + Core.Command.buildPreview(context: contextDir?.path ?? "", tag: tag.isEmpty ? nil : tag, dockerfile: dockerfile.isEmpty ? nil : dockerfile, buildArgs: argsDict, noCache: noCache, diff --git a/Sources/ContainedApp/Features/Images/RegistryImageSearch.swift b/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift similarity index 74% rename from Sources/ContainedApp/Features/Images/RegistryImageSearch.swift rename to Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift index f7eea2c5..67cdc7fa 100644 --- a/Sources/ContainedApp/Features/Images/RegistryImageSearch.swift +++ b/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore /// Reusable Docker Hub image search. Before the user types it offers ready-to-run **starters** and a @@ -24,7 +24,7 @@ struct RegistryImageSearch: View { private var trimmedQuery: String { query.trimmingCharacters(in: .whitespaces) } var body: some View { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.m) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.m) { searchField if trimmedQuery.isEmpty { idleSuggestions @@ -39,25 +39,18 @@ struct RegistryImageSearch: View { // MARK: Search field private var searchField: some View { - DesignInputCluster { - Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField("Search Docker Hub…", text: $query) - .textFieldStyle(.plain) - .onSubmit { searchNow() } - if searching { ProgressView().controlSize(.small) } - else if !query.isEmpty { - Button { query = "" } label: { Image(systemName: "xmark.circle.fill") } - .buttonStyle(.plain).foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) + UI.Control.SearchField(text: $query, + prompt: AppText.string("imageSearch.prompt", defaultValue: "Search Docker Hub..."), + clearLabel: AppText.clear, + isSearching: searching, + onSubmit: searchNow) } // MARK: Idle — starters + popular private var idleSuggestions: some View { ScrollView { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.l) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.l) { suggestionSection("Starters") { ForEach(BuiltinTemplate.all, id: \.name) { item in quickPick(symbol: item.symbol, title: item.name, @@ -74,22 +67,21 @@ struct RegistryImageSearch: View { } } } - .padding(.bottom, DesignTokens.Space.s) + .padding(.bottom, UI.Layout.Spacing.s) } } - private func suggestionSection(_ title: String, @ViewBuilder content: () -> C) -> some View { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { - Text(title).font(.caption.weight(.semibold)).foregroundStyle(.secondary) - LazyVGrid(columns: [GridItem(.adaptive(minimum: 150, maximum: 240), spacing: DesignTokens.Space.s)], - spacing: DesignTokens.Space.s) { content() } + private func suggestionSection(_ title: String, @ViewBuilder content: @escaping () -> C) -> some View { + UI.List.Section(title) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150, maximum: 240), spacing: UI.Layout.Spacing.s)], + spacing: UI.Layout.Spacing.s) { content() } } } private func quickPick(symbol: String, title: String, subtitle: String, action: @escaping () -> Void) -> some View { choiceCard(symbol: symbol, title: title, subtitle: subtitle, action: action) { - DesignListRowChevron() + UI.List.RowChevron() } .accessibilityAddTraits(.isButton) } @@ -99,28 +91,28 @@ struct RegistryImageSearch: View { @ViewBuilder private var resultsList: some View { if results.isEmpty { - LazyVStack(spacing: DesignTokens.Space.s) { - if searching { - ProgressView() - Text("Searching Docker Hub…").font(.callout).foregroundStyle(.secondary) - } else if let errorMessage { - Image(systemName: "wifi.exclamationmark").font(.title2).foregroundStyle(.orange) - Text("Couldn't search Docker Hub").font(.callout.weight(.medium)) - Text(errorMessage).font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center) - DesignTextActionButton(title: AppText.string("common.retry", defaultValue: "Retry"), + if searching { + UI.State.Loading(AppText.string("imageSearch.searching", defaultValue: "Searching Docker Hub...")) + } else if let errorMessage { + VStack(spacing: UI.Layout.Spacing.s) { + UI.State.Empty(AppText.string("imageSearch.error.title", defaultValue: "Couldn't search Docker Hub"), + systemImage: "wifi.exclamationmark", + description: errorMessage, + tone: .warning, + padding: UI.Layout.Spacing.s) + UI.Action.TextButton(title: AppText.string("common.retry", defaultValue: "Retry"), systemName: "arrow.clockwise") { searchNow() } - } else { - Image(systemName: "magnifyingglass").font(.title2).foregroundStyle(.tertiary) - Text("No images found for “\(trimmedQuery)”").font(.callout).foregroundStyle(.secondary) } + .padding(UI.Layout.Spacing.xl) + } else { + UI.State.Empty(AppText.string("imageSearch.empty", defaultValue: "No images found for \(trimmedQuery)"), + systemImage: "magnifyingglass") } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(DesignTokens.Space.xl) } else { ScrollView { - LazyVStack(spacing: DesignTokens.Space.xs) { + LazyVStack(spacing: UI.Layout.Spacing.xs) { ForEach(results) { result in resultRow(result) .accessibilityAddTraits(.isButton) @@ -135,14 +127,13 @@ struct RegistryImageSearch: View { title: result.repoName, subtitle: result.shortDescription?.isEmpty == false ? result.shortDescription : nil, action: { onSelect(RecommendedImage.spec(for: result.pullReference)) }) { - HStack(spacing: DesignTokens.Space.s) { + HStack(spacing: UI.Layout.Spacing.s) { if result.isOfficial { - Image(systemName: "checkmark.seal.fill").font(.caption2).foregroundStyle(.blue) + UI.Symbol.Image(systemName: "checkmark.seal.fill", + tone: .info, + size: .caption2) } - Label("\(result.starCount)", systemImage: "star.fill") - .font(.caption) - .foregroundStyle(.secondary) - .labelStyle(.titleAndIcon) + UI.State.InlineStatus("\(result.starCount)", systemImage: "star.fill") } } } @@ -152,12 +143,12 @@ struct RegistryImageSearch: View { subtitle: String?, action: @escaping () -> Void, @ViewBuilder accessory: @escaping () -> Accessory) -> some View { - DesignCard(size: .small, + UI.Card.Scaffold(size: .small, elevated: false, onTap: action, title: title, subtitle: subtitle) { - DesignCardIconChip(symbol: symbol, tint: .accentColor) + UI.Card.IconChip(symbol: symbol, tint: .accentColor) } titleAccessory: { EmptyView() } subtitleAccessory: { diff --git a/Sources/ContainedApp/Features/Images/ImageStyleButton.swift b/Sources/ContainedApp/Features/Images/Style/ImageStyleButton.swift similarity index 92% rename from Sources/ContainedApp/Features/Images/ImageStyleButton.swift rename to Sources/ContainedApp/Features/Images/Style/ImageStyleButton.swift index 6b98181c..2a011d89 100644 --- a/Sources/ContainedApp/Features/Images/ImageStyleButton.swift +++ b/Sources/ContainedApp/Features/Images/Style/ImageStyleButton.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore /// Image row identity chip. If the image has a saved default style, this displays it and opens the @@ -16,7 +16,7 @@ struct ImageStyleButton: View { var body: some View { Button { showingCustomize = true } label: { - DesignCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, + UI.Card.IconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, tint: style.color) } .buttonStyle(.plain) @@ -46,7 +46,7 @@ struct CardStyleButton: View { var body: some View { Button { showingCustomize = true } label: { - DesignCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, + UI.Card.IconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, tint: style.color) } .buttonStyle(.plain) diff --git a/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift b/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift index e1f93b1c..d56f48d5 100644 --- a/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift +++ b/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import AppKit import ContainedCore @@ -10,49 +10,49 @@ struct BootstrapView: View { @State private var starting = false var body: some View { - VStack(spacing: DesignTokens.Space.l) { - Image(systemName: icon) - .font(.system(size: 52)) - .foregroundStyle(.tint) - Text(title).font(.title2.weight(.semibold)) - Text(message) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 420) - + UI.State.Hero(systemImage: icon, title: title, message: message) { actions } - .padding(DesignTokens.Space.xxl) - .frame(maxWidth: .infinity, maxHeight: .infinity) } @ViewBuilder private var actions: some View { switch app.bootstrap { case .serviceStopped: - Button { + UI.Action.TextButton(title: starting ? AppText.string("bootstrap.starting", defaultValue: "Starting...") : AppText.string("bootstrap.startService", defaultValue: "Start container service"), + systemName: "play.circle", + prominence: .prominent, + isEnabled: !starting) { Task { starting = true; await app.startService(); starting = false } - } label: { - Label(starting ? "Starting…" : "Start container service", systemImage: "play.circle") - .padding(.horizontal, DesignTokens.Space.s) } - .buttonStyle(.borderedProminent) - .disabled(starting) case .cliMissing: - HStack(spacing: DesignTokens.Space.m) { - Button { openReleases() } label: { Label("Get the CLI", systemImage: "arrow.down.circle") } - .buttonStyle(.borderedProminent) - Button { locateCLI() } label: { Label("Locate binary…", systemImage: "folder") } + HStack(spacing: UI.Layout.Spacing.m) { + UI.Action.TextButton(title: AppText.string("bootstrap.getCLI", defaultValue: "Get the CLI"), + systemName: "arrow.down.circle", + prominence: .prominent, + action: openReleases) + UI.Action.TextButton(title: AppText.string("bootstrap.locateBinary", defaultValue: "Locate binary..."), + systemName: "folder", + action: locateCLI) + } + UI.Action.TextButton(title: AppText.string("common.tryAgain", defaultValue: "Try again"), + systemName: "arrow.clockwise") { + Task { await app.retryBootstrap() } } - Button("Try again") { Task { await app.retryBootstrap() } }.buttonStyle(.link) case .unsupported: - HStack(spacing: DesignTokens.Space.m) { - Button("Continue anyway") { Task { await app.continueUnsupported() } } - .buttonStyle(.borderedProminent) - Button("Try again") { Task { await app.retryBootstrap() } } + HStack(spacing: UI.Layout.Spacing.m) { + UI.Action.TextButton(title: AppText.string("bootstrap.continueAnyway", defaultValue: "Continue anyway"), + systemName: "arrow.right", + prominence: .prominent) { + Task { await app.continueUnsupported() } + } + UI.Action.TextButton(title: AppText.string("common.tryAgain", defaultValue: "Try again"), + systemName: "arrow.clockwise") { + Task { await app.retryBootstrap() } + } } case .checking: - ProgressView() + UI.State.ProgressIndicator() case .ready: EmptyView() } diff --git a/Sources/ContainedApp/Features/Palette/CommandPalette.swift b/Sources/ContainedApp/Features/Palette/CommandPalette.swift index 79ab995e..c3f4d667 100644 --- a/Sources/ContainedApp/Features/Palette/CommandPalette.swift +++ b/Sources/ContainedApp/Features/Palette/CommandPalette.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore /// A palette search scope. Pins a chip to the search field and searches in-place instead of leaving @@ -96,14 +96,14 @@ struct PaletteItem: Identifiable { keywords: ["registry", "pull", "dockerhub", "image"], kind: .search, keepsPaletteOpen: true, icon: "globe", tint: .accentColor) { - ui.paletteScope = .dockerHub + ui.search.scope = .dockerHub }) } items.append(PaletteItem(title: AppText.paletteSearchLocalImages, subtitle: AppText.paletteScopeSubtitle, keywords: ["image", "tag", "local", "filter"], kind: .search, keepsPaletteOpen: true, icon: "square.stack.3d.up", tint: .accentColor) { - ui.paletteScope = .localImages + ui.search.scope = .localImages }) // Page / global actions. items.append(PaletteItem(title: AppText.paletteRefresh, @@ -272,8 +272,8 @@ struct PaletteItem: Identifiable { tint: .secondary) { app.settings.showInfoTips.toggle() }) - for tint in DesignTint.allCases { - items.append(PaletteItem(title: AppText.setDesignTintTitle(tint.localizedDisplayName), + for tint in UI.Theme.Tint.allCases { + items.append(PaletteItem(title: AppText.setThemeTintTitle(tint.localizedDisplayName), subtitle: AppText.paletteAppearanceSubtitle, keywords: ["accent", "color", "theme", "tint", tint.rawValue] + tint.localizedSearchAliases, kind: .settings, @@ -446,7 +446,7 @@ enum PaletteItemVisual { case imageTag(String, groupID: String) case volume(VolumeResource) case network(NetworkResource) - case tint(DesignTint) + case tint(UI.Theme.Tint) } private extension SettingsStore { diff --git a/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift index 6263cd95..be8b77d9 100644 --- a/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift +++ b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift @@ -1,7 +1,6 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore -import ContainedRuntime /// Sign in to a registry. The user types their own credentials; the password is sent via stdin. /// Registry credential management lives in Settings → Registries; this sheet is launched from that tab. @@ -16,13 +15,13 @@ struct RegistryLoginSheet: View { var body: some View { VStack(spacing: 0) { - SheetHeader(title: AppText.string("registry.login.title", defaultValue: "Registry login"), + UI.Panel.SheetTitleBar(title: AppText.string("registry.login.title", defaultValue: "Registry login"), cancelHelp: AppText.close, onCancel: { dismiss() }) { if busy { - ProgressView().controlSize(.small).frame(width: DesignTokens.IconSize.control, height: DesignTokens.IconSize.control) + UI.State.ProgressIndicator(frameSize: UI.Control.Size.control) } else { - DesignActionGroup(DesignAction(systemName: "checkmark", + UI.Action.Group(UI.Action.Item(systemName: "checkmark", help: AppText.logIn, isEnabled: !server.trimmingCharacters(in: .whitespaces).isEmpty && !username.trimmingCharacters(in: .whitespaces).isEmpty @@ -31,35 +30,35 @@ struct RegistryLoginSheet: View { }) } } - VStack(spacing: DesignTokens.Space.l) { - PanelSection(header: AppText.string("registry.credentials", defaultValue: "Credentials")) { - PanelField(label: AppText.string("registry.server", defaultValue: "Server")) { + VStack(spacing: UI.Layout.Spacing.l) { + UI.Panel.Section(header: AppText.string("registry.credentials", defaultValue: "Credentials")) { + UI.Panel.Field(label: AppText.string("registry.server", defaultValue: "Server")) { TextField("", text: $server, prompt: Text("e.g. ghcr.io, docker.io")) .textContentType(.URL) .textFieldStyle(.roundedBorder) } - PanelField(label: AppText.string("registry.username", defaultValue: "Username")) { + UI.Panel.Field(label: AppText.string("registry.username", defaultValue: "Username")) { TextField("", text: $username, prompt: Text("registry username")) .textContentType(.username) .textFieldStyle(.roundedBorder) } - PanelField(label: AppText.string("registry.password", defaultValue: "Password")) { + UI.Panel.Field(label: AppText.string("registry.password", defaultValue: "Password")) { SecureField("", text: $password, prompt: Text("password or access token")) .textFieldStyle(.roundedBorder) } } if let error { - PanelSection { - Label(error, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) - .font(.caption) + UI.Panel.Section { + UI.State.InlineStatus(error, + systemImage: "exclamationmark.triangle", + tone: .error) .fixedSize(horizontal: false, vertical: true) } } } - .padding(DesignTokens.Space.l) + .padding(UI.Layout.Spacing.l) } - .frame(DesignTokens.SheetSize.small) + .frame(UI.Panel.SheetSize.small) .sheetMaterial() } diff --git a/Sources/ContainedApp/Features/Settings/ReleaseNotesView.swift b/Sources/ContainedApp/Features/Settings/ReleaseNotesView.swift index c1d24c66..314c3144 100644 --- a/Sources/ContainedApp/Features/Settings/ReleaseNotesView.swift +++ b/Sources/ContainedApp/Features/Settings/ReleaseNotesView.swift @@ -1,6 +1,6 @@ import SwiftUI import WebKit -import ContainedDesignSystem +import ContainedUI struct ReleaseNotesView: View { var title: String @@ -9,13 +9,13 @@ struct ReleaseNotesView: View { var body: some View { VStack(spacing: 0) { - SheetHeader(title: title, cancelHelp: AppText.done) { + UI.Panel.SheetTitleBar(title: title, cancelHelp: AppText.done) { onClose?() } Divider() HTMLView(html: html) } - .frame(DesignTokens.SheetSize.releaseNotes) + .frame(UI.Panel.SheetSize.releaseNotes) } } diff --git a/Sources/ContainedApp/Features/Settings/SettingsView.swift b/Sources/ContainedApp/Features/Settings/SettingsView.swift index eab113b2..9bc18ed7 100644 --- a/Sources/ContainedApp/Features/Settings/SettingsView.swift +++ b/Sources/ContainedApp/Features/Settings/SettingsView.swift @@ -1,14 +1,14 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import AppKit import ContainedCore -/// App preferences. Six sections, each built from the same `PanelSection` glass-card model so spacing, +/// App preferences. Six sections, each built from the same `UI.Panel.Section` glass-card model so spacing, /// headers, and explanatory footers stay consistent: Appearance (theme + glass), General (behavior, /// data, CLI), Runtime, Registries, Updates, and About. /// -/// Hosted in the toolbar Settings morph panel via the shared `DesignPanelScaffold`. +/// Hosted in the toolbar Settings morph panel via the shared `UI.Panel.Scaffold`. /// Sections switch via a header menu rather than a `TabView`. struct SettingsContent: View { @Environment(AppModel.self) private var app @@ -63,7 +63,7 @@ struct SettingsContent: View { var body: some View { @Bindable var settings = app.settings - DesignPanelScaffold(width: DesignTokens.PanelSize.settings.width) { + UI.Panel.Scaffold(width: UI.Panel.Size.settings.width) { if showsHeader { VStack(spacing: 0) { header @@ -72,7 +72,7 @@ struct SettingsContent: View { } } content: { sectionBody(settings: settings) - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) } .morphPanelPlacement(.centered) .onAppear { consumeRequestedPage() } @@ -89,23 +89,23 @@ struct SettingsContent: View { } private var header: some View { - PanelHeader(symbol: page.systemImage, + UI.Panel.Header(symbol: page.systemImage, title: AppText.sectionSettings, subtitle: page.title) { - DesignActionGroup(headerActions) + UI.Action.Group(headerActions) } } - private var headerActions: [DesignAction] { + private var headerActions: [UI.Action.Item] { var actions = SettingsPage.allCases.map { item in - DesignAction(systemName: item.systemImage, + UI.Action.Item(systemName: item.systemImage, help: item.title, tint: page == item ? .accentColor : nil) { page = item } } if let onClose { - actions.append(DesignAction(systemName: "xmark", help: AppText.close, isCancel: true, action: onClose)) + actions.append(UI.Action.Item(systemName: "xmark", help: AppText.close, isCancel: true, action: onClose)) } return actions } diff --git a/Sources/ContainedApp/Stores/SettingsBackup.swift b/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift similarity index 87% rename from Sources/ContainedApp/Stores/SettingsBackup.swift rename to Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift index 4c8b11c5..e878bcb4 100644 --- a/Sources/ContainedApp/Stores/SettingsBackup.swift +++ b/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift @@ -1,22 +1,22 @@ import Foundation -import ContainedDesignSystem +import ContainedUI import ContainedCore /// Portable snapshot of user preferences for config export/import. struct SettingsBackup: Codable, Equatable { - var accentTint: DesignTint - var appearance: AppearanceMode - var density: CardDensity - var windowMaterial: WindowMaterial - var modalMaterial: WindowMaterial - var buttonMaterial: WindowMaterial + var accentTint: UI.Theme.Tint + var appearance: UI.Theme.Appearance + var density: UI.Card.Density + var windowMaterial: UI.Theme.WindowMaterial + var modalMaterial: UI.Theme.WindowMaterial + var buttonMaterial: UI.Theme.WindowMaterial var buttonTintEnabled: Bool - var buttonTint: DesignTint + var buttonTint: UI.Theme.Tint var buttonTintOpacity: Double var buttonTintGradient: Bool var buttonTintGradientAngle: Double - var buttonTintBlendMode: ColorLayerBlendMode - var cardMaterial: WindowMaterial + var buttonTintBlendMode: UI.Theme.ColorBlendMode + var cardMaterial: UI.Theme.WindowMaterial var showInfoTips: Bool var imageDefaultStyleEnabled: Bool var keepInMenuBar: Bool @@ -56,19 +56,19 @@ struct SettingsBackup: Codable, Equatable { case experimentalPanelNavigation, sidebarNavigationEnabled } - init(accentTint: DesignTint, - appearance: AppearanceMode, - density: CardDensity, - windowMaterial: WindowMaterial, - modalMaterial: WindowMaterial, - buttonMaterial: WindowMaterial, + init(accentTint: UI.Theme.Tint, + appearance: UI.Theme.Appearance, + density: UI.Card.Density, + windowMaterial: UI.Theme.WindowMaterial, + modalMaterial: UI.Theme.WindowMaterial, + buttonMaterial: UI.Theme.WindowMaterial, buttonTintEnabled: Bool = false, - buttonTint: DesignTint = .multicolor, + buttonTint: UI.Theme.Tint = .multicolor, buttonTintOpacity: Double = 0.18, buttonTintGradient: Bool = true, buttonTintGradientAngle: Double = Personalization.defaultGradientAngle, - buttonTintBlendMode: ColorLayerBlendMode = .softLight, - cardMaterial: WindowMaterial, + buttonTintBlendMode: UI.Theme.ColorBlendMode = .softLight, + cardMaterial: UI.Theme.WindowMaterial, showInfoTips: Bool, imageDefaultStyleEnabled: Bool, keepInMenuBar: Bool, @@ -136,21 +136,21 @@ struct SettingsBackup: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - accentTint = try container.decode(DesignTint.self, forKey: .accentTint) - appearance = try container.decode(AppearanceMode.self, forKey: .appearance) - density = try container.decode(CardDensity.self, forKey: .density) - windowMaterial = try container.decode(WindowMaterial.self, forKey: .windowMaterial) - modalMaterial = try container.decode(WindowMaterial.self, forKey: .modalMaterial) - buttonMaterial = try container.decodeIfPresent(WindowMaterial.self, forKey: .buttonMaterial) ?? .glassClear + accentTint = try container.decode(UI.Theme.Tint.self, forKey: .accentTint) + appearance = try container.decode(UI.Theme.Appearance.self, forKey: .appearance) + density = try container.decode(UI.Card.Density.self, forKey: .density) + windowMaterial = try container.decode(UI.Theme.WindowMaterial.self, forKey: .windowMaterial) + modalMaterial = try container.decode(UI.Theme.WindowMaterial.self, forKey: .modalMaterial) + buttonMaterial = try container.decodeIfPresent(UI.Theme.WindowMaterial.self, forKey: .buttonMaterial) ?? .glassClear buttonTintEnabled = try container.decodeIfPresent(Bool.self, forKey: .buttonTintEnabled) ?? false - buttonTint = try container.decodeIfPresent(DesignTint.self, forKey: .buttonTint) ?? .multicolor + buttonTint = try container.decodeIfPresent(UI.Theme.Tint.self, forKey: .buttonTint) ?? .multicolor buttonTintOpacity = try container.decodeIfPresent(Double.self, forKey: .buttonTintOpacity) ?? 0.18 buttonTintGradient = try container.decodeIfPresent(Bool.self, forKey: .buttonTintGradient) ?? true buttonTintGradientAngle = try container.decodeIfPresent(Double.self, forKey: .buttonTintGradientAngle) ?? Personalization.defaultGradientAngle - buttonTintBlendMode = try container.decodeIfPresent(ColorLayerBlendMode.self, forKey: .buttonTintBlendMode) + buttonTintBlendMode = try container.decodeIfPresent(UI.Theme.ColorBlendMode.self, forKey: .buttonTintBlendMode) ?? .softLight - cardMaterial = try container.decodeIfPresent(WindowMaterial.self, forKey: .cardMaterial) ?? .glassRegular + cardMaterial = try container.decodeIfPresent(UI.Theme.WindowMaterial.self, forKey: .cardMaterial) ?? .glassRegular showInfoTips = try container.decodeIfPresent(Bool.self, forKey: .showInfoTips) ?? true imageDefaultStyleEnabled = try container.decodeIfPresent(Bool.self, forKey: .imageDefaultStyleEnabled) ?? true keepInMenuBar = try container.decodeIfPresent(Bool.self, forKey: .keepInMenuBar) ?? true diff --git a/Sources/ContainedApp/Stores/SettingsStore.swift b/Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift similarity index 89% rename from Sources/ContainedApp/Stores/SettingsStore.swift rename to Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift index 0b24a8f8..b21cdb04 100644 --- a/Sources/ContainedApp/Stores/SettingsStore.swift +++ b/Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ServiceManagement import ContainedCore @@ -7,24 +7,24 @@ import ContainedCore @MainActor @Observable final class SettingsStore { - var accentTint: DesignTint { didSet { defaults.set(accentTint.rawValue, forKey: Keys.tint) } } - var appearance: AppearanceMode { didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) } } - var density: CardDensity { didSet { defaults.set(density.rawValue, forKey: Keys.density) } } + var accentTint: UI.Theme.Tint { didSet { defaults.set(accentTint.rawValue, forKey: Keys.tint) } } + var appearance: UI.Theme.Appearance { didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) } } + var density: UI.Card.Density { didSet { defaults.set(density.rawValue, forKey: Keys.density) } } /// Behind-window vibrancy material for the main content area. - var windowMaterial: WindowMaterial { didSet { defaults.set(windowMaterial.rawValue, forKey: Keys.windowMaterial) } } + var windowMaterial: UI.Theme.WindowMaterial { didSet { defaults.set(windowMaterial.rawValue, forKey: Keys.windowMaterial) } } /// Material behind modal sheets. - var modalMaterial: WindowMaterial { didSet { defaults.set(modalMaterial.rawValue, forKey: Keys.modalMaterial) } } + var modalMaterial: UI.Theme.WindowMaterial { didSet { defaults.set(modalMaterial.rawValue, forKey: Keys.modalMaterial) } } /// Material for toolbar control surfaces (glass buttons / search field). - var buttonMaterial: WindowMaterial { didSet { defaults.set(buttonMaterial.rawValue, forKey: Keys.buttonMaterial) } } + var buttonMaterial: UI.Theme.WindowMaterial { didSet { defaults.set(buttonMaterial.rawValue, forKey: Keys.buttonMaterial) } } /// Optional color wash applied inside toolbar glass buttons. var buttonTintEnabled: Bool { didSet { defaults.set(buttonTintEnabled, forKey: Keys.buttonTintEnabled) } } - var buttonTint: DesignTint { didSet { defaults.set(buttonTint.rawValue, forKey: Keys.buttonTint) } } + var buttonTint: UI.Theme.Tint { didSet { defaults.set(buttonTint.rawValue, forKey: Keys.buttonTint) } } var buttonTintOpacity: Double { didSet { defaults.set(buttonTintOpacity, forKey: Keys.buttonTintOpacity) } } var buttonTintGradient: Bool { didSet { defaults.set(buttonTintGradient, forKey: Keys.buttonTintGradient) } } var buttonTintGradientAngle: Double { didSet { defaults.set(buttonTintGradientAngle, forKey: Keys.buttonTintGradientAngle) } } - var buttonTintBlendMode: ColorLayerBlendMode { didSet { defaults.set(buttonTintBlendMode.rawValue, forKey: Keys.buttonTintBlendMode) } } + var buttonTintBlendMode: UI.Theme.ColorBlendMode { didSet { defaults.set(buttonTintBlendMode.rawValue, forKey: Keys.buttonTintBlendMode) } } /// Material for cards, both compact and expanded. - var cardMaterial: WindowMaterial { didSet { defaults.set(cardMaterial.rawValue, forKey: Keys.cardMaterial) } } + var cardMaterial: UI.Theme.WindowMaterial { didSet { defaults.set(cardMaterial.rawValue, forKey: Keys.cardMaterial) } } /// Show the info.circle help popovers throughout the app. var showInfoTips: Bool { didSet { defaults.set(showInfoTips, forKey: Keys.showInfoTips) } } /// Let images without their own style inherit the default card design edited in Settings. @@ -102,19 +102,19 @@ final class SettingsStore { init(defaults: UserDefaults = .standard) { self.defaults = defaults - accentTint = DesignTint(rawValue: defaults.string(forKey: Keys.tint) ?? "") ?? .multicolor - appearance = AppearanceMode(rawValue: defaults.string(forKey: Keys.appearance) ?? "") ?? .system - density = CardDensity(stored: defaults.string(forKey: Keys.density)) - windowMaterial = WindowMaterial(rawValue: defaults.string(forKey: Keys.windowMaterial) ?? "") ?? .fullScreenUI - modalMaterial = WindowMaterial(rawValue: defaults.string(forKey: Keys.modalMaterial) ?? "") ?? .sheet - buttonMaterial = WindowMaterial(rawValue: defaults.string(forKey: Keys.buttonMaterial) ?? "") ?? .glassClear + accentTint = UI.Theme.Tint(rawValue: defaults.string(forKey: Keys.tint) ?? "") ?? .multicolor + appearance = UI.Theme.Appearance(rawValue: defaults.string(forKey: Keys.appearance) ?? "") ?? .system + density = UI.Card.Density(stored: defaults.string(forKey: Keys.density)) + windowMaterial = UI.Theme.WindowMaterial(rawValue: defaults.string(forKey: Keys.windowMaterial) ?? "") ?? .fullScreenUI + modalMaterial = UI.Theme.WindowMaterial(rawValue: defaults.string(forKey: Keys.modalMaterial) ?? "") ?? .sheet + buttonMaterial = UI.Theme.WindowMaterial(rawValue: defaults.string(forKey: Keys.buttonMaterial) ?? "") ?? .glassClear buttonTintEnabled = defaults.object(forKey: Keys.buttonTintEnabled) as? Bool ?? false - buttonTint = DesignTint(rawValue: defaults.string(forKey: Keys.buttonTint) ?? "") ?? .multicolor + buttonTint = UI.Theme.Tint(rawValue: defaults.string(forKey: Keys.buttonTint) ?? "") ?? .multicolor buttonTintOpacity = defaults.object(forKey: Keys.buttonTintOpacity) as? Double ?? 0.18 buttonTintGradient = defaults.object(forKey: Keys.buttonTintGradient) as? Bool ?? true buttonTintGradientAngle = defaults.object(forKey: Keys.buttonTintGradientAngle) as? Double ?? Personalization.defaultGradientAngle - buttonTintBlendMode = ColorLayerBlendMode(rawValue: defaults.string(forKey: Keys.buttonTintBlendMode) ?? "") ?? .softLight - cardMaterial = WindowMaterial(rawValue: defaults.string(forKey: Keys.cardMaterial) ?? "") ?? .glassRegular + buttonTintBlendMode = UI.Theme.ColorBlendMode(rawValue: defaults.string(forKey: Keys.buttonTintBlendMode) ?? "") ?? .softLight + cardMaterial = UI.Theme.WindowMaterial(rawValue: defaults.string(forKey: Keys.cardMaterial) ?? "") ?? .glassRegular showInfoTips = defaults.object(forKey: Keys.showInfoTips) as? Bool ?? true imageDefaultStyleEnabled = defaults.object(forKey: Keys.imageDefaultStyleEnabled) as? Bool ?? true keepInMenuBar = defaults.object(forKey: Keys.keepInMenuBar) as? Bool ?? true diff --git a/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift index 6f36c921..e1682b8c 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import AppKit import ContainedCore @@ -9,29 +9,29 @@ struct AboutTab: View { @Environment(AppModel.self) private var app var body: some View { - LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection { - HStack(spacing: DesignTokens.Space.m) { + LazyVStack(spacing: UI.Layout.Spacing.l) { + UI.Panel.Section { + HStack(spacing: UI.Layout.Spacing.m) { Image(nsImage: NSApp.applicationIconImage) .resizable() - .frame(width: DesignTokens.IconSize.appIcon, height: DesignTokens.IconSize.appIcon) - VStack(alignment: .leading, spacing: DesignTokens.Space.xxs) { - Text("Contained").font(.title3.weight(.semibold)) - Text("Version \(appVersion)").font(.callout).foregroundStyle(.secondary) + .frame(width: UI.Control.Size.appIcon, height: UI.Control.Size.appIcon) + VStack(alignment: .leading, spacing: UI.Layout.Spacing.xxs) { + Text("Contained").designTitleLabelStyle() + Text("Version \(appVersion)").designSecondaryCallout() Text("A native macOS UI for Apple’s container runtime.") - .font(.caption).foregroundStyle(.secondary) + .designSecondaryCaption() } Spacer() } } - PanelSection(header: AppText.sectionSettingsRuntime) { - PanelRow(title: AppText.string("settings.about.containerCLI", defaultValue: "Container CLI")) { Text(app.cliVersion ?? "—").foregroundStyle(.secondary) } - PanelRow(title: AppText.string("settings.about.apiServer", defaultValue: "API server")) { Text(app.systemStatus?.apiServerVersion ?? "—").foregroundStyle(.secondary) } + UI.Panel.Section(header: AppText.sectionSettingsRuntime) { + UI.Panel.Row(title: AppText.string("settings.about.containerCLI", defaultValue: "Container CLI")) { Text(app.cliVersion ?? "—").designSecondaryValueStyle() } + UI.Panel.Row(title: AppText.string("settings.about.apiServer", defaultValue: "API server")) { Text(app.systemStatus?.apiServerVersion ?? "—").designSecondaryValueStyle() } } - PanelSection { - PanelRow(title: AppText.string("settings.about.copyright", defaultValue: "Copyright")) { Text("© 2026 Contained").foregroundStyle(.secondary) } + UI.Panel.Section { + UI.Panel.Row(title: AppText.string("settings.about.copyright", defaultValue: "Copyright")) { Text("© 2026 Contained").designSecondaryValueStyle() } } } } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift index 5d82050d..ee7b4346 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore // MARK: - Appearance @@ -8,73 +8,73 @@ struct AppearanceTab: View { @Bindable var settings: SettingsStore var body: some View { - LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: AppText.string("settings.appearance.theme", defaultValue: "Theme")) { - PanelRow(title: AppText.string("settings.appearance.appearance", defaultValue: "Appearance")) { + LazyVStack(spacing: UI.Layout.Spacing.l) { + UI.Panel.Section(header: AppText.string("settings.appearance.theme", defaultValue: "Theme")) { + UI.Panel.Row(title: AppText.string("settings.appearance.appearance", defaultValue: "Appearance")) { Picker("", selection: $settings.appearance) { - ForEach(AppearanceMode.allCases) { Text($0.localizedDisplayName).tag($0) } + ForEach(UI.Theme.Appearance.allCases) { Text($0.localizedDisplayName).tag($0) } } .pickerStyle(.segmented).labelsHidden().fixedSize() } - PanelRow(title: AppText.string("settings.appearance.accentTint", defaultValue: "Accent tint")) { - TintSelector(selection: $settings.accentTint) { $0.localizedDisplayName } + UI.Panel.Row(title: AppText.string("settings.appearance.accentTint", defaultValue: "Accent tint")) { + UI.Control.TintSelector(selection: $settings.accentTint) { $0.localizedDisplayName } } } - PanelSection(header: AppText.string("settings.appearance.layout", defaultValue: "Layout")) { - PanelRow(title: AppText.string("settings.appearance.cardSize", defaultValue: "Card size")) { + UI.Panel.Section(header: AppText.string("settings.appearance.layout", defaultValue: "Layout")) { + UI.Panel.Row(title: AppText.string("settings.appearance.cardSize", defaultValue: "Card size")) { Picker("", selection: $settings.density) { - ForEach(CardDensity.allCases) { Text($0.localizedDisplayName).tag($0) } + ForEach(UI.Card.Density.allCases) { Text($0.localizedDisplayName).tag($0) } } .pickerStyle(.segmented).labelsHidden().fixedSize() } - PanelToggleRow(title: AppText.string("settings.appearance.showInfoTips", defaultValue: "Show info tips"), + UI.Panel.ToggleRow(title: AppText.string("settings.appearance.showInfoTips", defaultValue: "Show info tips"), isOn: $settings.showInfoTips) } - PanelSection(header: AppText.string("settings.appearance.materials", defaultValue: "Materials"), + UI.Panel.Section(header: AppText.string("settings.appearance.materials", defaultValue: "Materials"), footer: AppText.string("settings.appearance.materials.footer", defaultValue: "Glass options use Liquid Glass. Other options use macOS vibrancy and follow the window background.")) { - PanelRow(title: AppText.string("settings.appearance.mainBackgroundMaterial", defaultValue: "Main background material"), + UI.Panel.Row(title: AppText.string("settings.appearance.mainBackgroundMaterial", defaultValue: "Main background material"), info: AppText.string("settings.appearance.mainBackgroundMaterial.info", defaultValue: "Changes the material behind the main container grid.")) { materialMenu($settings.windowMaterial) } - PanelRow(title: AppText.string("settings.appearance.panelSheetMaterial", defaultValue: "Panel & sheet material"), + UI.Panel.Row(title: AppText.string("settings.appearance.panelSheetMaterial", defaultValue: "Panel & sheet material"), info: AppText.string("settings.appearance.panelSheetMaterial.info", defaultValue: "Changes floating panels, popovers, and sheets such as Settings and create/edit flows.")) { materialMenu($settings.modalMaterial) } - PanelRow(title: AppText.string("settings.appearance.cardMaterial", defaultValue: "Card material"), + UI.Panel.Row(title: AppText.string("settings.appearance.cardMaterial", defaultValue: "Card material"), info: AppText.string("settings.appearance.cardMaterial.info", defaultValue: "Changes all cards, including compact cards and expanded detail cards.")) { materialMenu($settings.cardMaterial) } - PanelRow(title: AppText.string("settings.appearance.buttonMaterial", defaultValue: "Button material"), + UI.Panel.Row(title: AppText.string("settings.appearance.buttonMaterial", defaultValue: "Button material"), info: AppText.string("settings.appearance.buttonMaterial.info", defaultValue: "Changes toolbar glass buttons and grouped icon controls.")) { materialMenu($settings.buttonMaterial) } } - PanelSection(header: AppText.string("settings.appearance.buttonTint", defaultValue: "Button tint"), + UI.Panel.Section(header: AppText.string("settings.appearance.buttonTint", defaultValue: "Button tint"), footer: AppText.string("settings.appearance.buttonTint.footer", defaultValue: "Button tint uses the same color layer model as card backgrounds, applied inside toolbar glass controls."), enabled: $settings.buttonTintEnabled) { - PanelRow(title: AppText.tint) { - TintSelector(selection: $settings.buttonTint) { $0.localizedDisplayName } + UI.Panel.Row(title: AppText.tint) { + UI.Control.TintSelector(selection: $settings.buttonTint) { $0.localizedDisplayName } } - PanelRow(title: AppText.string("settings.appearance.opacity", defaultValue: "Opacity")) { - HStack(spacing: DesignTokens.Space.s) { + UI.Panel.Row(title: AppText.string("settings.appearance.opacity", defaultValue: "Opacity")) { + HStack(spacing: UI.Layout.Spacing.s) { Slider(value: $settings.buttonTintOpacity, in: 0.05...0.6) - .frame(width: DesignTokens.FormWidth.compactSlider) + .frame(width: UI.Form.Width.compactSlider) Text(Format.percent(settings.buttonTintOpacity)) .monospacedDigit() - .frame(width: DesignTokens.FormWidth.shortReadout) + .frame(width: UI.Form.Width.shortReadout) } } - PanelToggleRow(title: AppText.string("settings.appearance.gradient", defaultValue: "Gradient"), + UI.Panel.ToggleRow(title: AppText.string("settings.appearance.gradient", defaultValue: "Gradient"), isOn: $settings.buttonTintGradient) if settings.buttonTintGradient { GradientAngleControl(angle: $settings.buttonTintGradientAngle, title: AppText.direction) } - PanelRow(title: AppText.string("settings.appearance.blendMode", defaultValue: "Blend mode")) { + UI.Panel.Row(title: AppText.string("settings.appearance.blendMode", defaultValue: "Blend mode")) { Picker("", selection: $settings.buttonTintBlendMode) { - ForEach(ColorLayerBlendMode.allCases) { mode in + ForEach(UI.Theme.ColorBlendMode.allCases) { mode in Text(mode.localizedDisplayName).tag(mode) } } @@ -87,9 +87,9 @@ struct AppearanceTab: View { } } - private func materialMenu(_ binding: Binding) -> some View { + private func materialMenu(_ binding: Binding) -> some View { Picker("", selection: binding) { - ForEach(WindowMaterial.allCases) { Text($0.localizedDisplayName).tag($0) } + ForEach(UI.Theme.WindowMaterial.allCases) { Text($0.localizedDisplayName).tag($0) } } .labelsHidden().fixedSize() } @@ -102,51 +102,50 @@ private struct ImageDefaultStyleSection: View { private var style: Personalization { app.personalization.defaultImageStyle } var body: some View { - PanelSection(header: AppText.string("settings.appearance.defaultImageCardStyle", defaultValue: "Default image card style"), + UI.Panel.Section(header: AppText.string("settings.appearance.defaultImageCardStyle", defaultValue: "Default image card style"), footer: AppText.string("settings.appearance.defaultImageCardStyle.footer", defaultValue: "When on, image groups, image rows, and containers without their own style inherit this design. Specific image, image-group, tag, and container styles remain local overrides above this default."), enabled: $settings.imageDefaultStyleEnabled) { - HStack(spacing: DesignTokens.Space.m) { - DesignCardIconChip(symbol: style.symbol, tint: style.color) - VStack(alignment: .leading, spacing: DesignTokens.DesignCard.compactTextSpacing) { + HStack(spacing: UI.Layout.Spacing.m) { + UI.Card.IconChip(symbol: style.symbol, tint: style.color) + VStack(alignment: .leading, spacing: UI.Card.Spacing.compactText) { Text(style.displayName(fallback: AppText.string("settings.appearance.imageCards", defaultValue: "Image cards"))) Text(AppText.string("settings.appearance.imageCards.inherited", defaultValue: "Inherited unless an image, group, tag, or container overrides it")) - .font(.caption) - .foregroundStyle(.secondary) + .designSecondaryCaption() } Spacer() } - PanelRow(title: AppText.string("settings.appearance.color", defaultValue: "Color")) { - TintSelector(selection: styleBinding(\.tint)) { $0.localizedDisplayName } + UI.Panel.Row(title: AppText.string("settings.appearance.color", defaultValue: "Color")) { + UI.Control.TintSelector(selection: styleBinding(\.tint)) { $0.localizedDisplayName } } - PanelToggleRow(title: AppText.string("settings.appearance.customIcon", defaultValue: "Custom icon"), + UI.Panel.ToggleRow(title: AppText.string("settings.appearance.customIcon", defaultValue: "Custom icon"), isOn: styleBinding(\.iconEnabled)) if style.iconEnabled { - PanelRow(title: AppText.string("settings.appearance.icon", defaultValue: "Icon")) { + UI.Panel.Row(title: AppText.string("settings.appearance.icon", defaultValue: "Icon")) { TextField("", text: styleBinding(\.icon), prompt: Text("SF Symbol, e.g. shippingbox.fill")) .textFieldStyle(.roundedBorder) - .frame(width: DesignTokens.FormWidth.tintColorHex) + .frame(width: UI.Form.Width.tintColorHex) } } - PanelToggleRow(title: AppText.string("settings.appearance.colorCardBackground", defaultValue: "Color the card background"), + UI.Panel.ToggleRow(title: AppText.string("settings.appearance.colorCardBackground", defaultValue: "Color the card background"), isOn: styleBinding(\.fillBackground)) if style.fillBackground { - PanelRow(title: AppText.string("settings.appearance.opacity", defaultValue: "Opacity")) { - HStack(spacing: DesignTokens.Space.s) { + UI.Panel.Row(title: AppText.string("settings.appearance.opacity", defaultValue: "Opacity")) { + HStack(spacing: UI.Layout.Spacing.s) { Slider(value: styleBinding(\.backgroundOpacity), in: 0.05...0.6) - .frame(width: DesignTokens.FormWidth.compactSlider) + .frame(width: UI.Form.Width.compactSlider) Text(Format.percent(style.backgroundOpacity)) .monospacedDigit() - .frame(width: DesignTokens.FormWidth.shortReadout) + .frame(width: UI.Form.Width.shortReadout) } } - PanelToggleRow(title: AppText.string("settings.appearance.gradient", defaultValue: "Gradient"), + UI.Panel.ToggleRow(title: AppText.string("settings.appearance.gradient", defaultValue: "Gradient"), isOn: styleBinding(\.gradient)) if style.gradient { GradientAngleControl(angle: styleBinding(\.gradientAngle), title: AppText.direction) } - PanelRow(title: AppText.string("settings.appearance.blendMode", defaultValue: "Blend mode")) { + UI.Panel.Row(title: AppText.string("settings.appearance.blendMode", defaultValue: "Blend mode")) { Picker("", selection: styleBinding(\.backgroundBlendMode)) { - ForEach(ColorLayerBlendMode.allCases) { mode in + ForEach(UI.Theme.ColorBlendMode.allCases) { mode in Text(mode.localizedDisplayName).tag(mode) } } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift index 47b5e785..b7bee25d 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore // MARK: - Experimental @@ -11,32 +11,32 @@ struct ExperimentalTab: View { @Bindable var settings: SettingsStore var body: some View { - LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: AppText.sectionSettingsExperimental, + LazyVStack(spacing: UI.Layout.Spacing.l) { + UI.Panel.Section(header: AppText.sectionSettingsExperimental, footer: AppText.string("settings.experimental.footer", defaultValue: "These features are still being refined. They're off by default; enable any you want to try. You can turn them back off at any time.")) { - PanelToggleRow(title: AppText.string("settings.experimental.toolbarFirstUI", defaultValue: "Toolbar-first UI"), + UI.Panel.ToggleRow(title: AppText.string("settings.experimental.toolbarFirstUI", defaultValue: "Toolbar-first UI"), info: AppText.string("settings.experimental.toolbarFirstUI.info", defaultValue: "Show the floating app toolbar. Navigation and edit/create presentation are controlled separately below."), isOn: $settings.experimentalToolbarUI) - PanelToggleRow(title: AppText.string("settings.experimental.toolbarPanelNavigation", defaultValue: "Toolbar panel navigation"), + UI.Panel.ToggleRow(title: AppText.string("settings.experimental.toolbarPanelNavigation", defaultValue: "Toolbar panel navigation"), info: AppText.string("settings.experimental.toolbarPanelNavigation.info", defaultValue: "Open create/edit flows and page utilities in toolbar morph panels. When off, access points use classic pages and sheets."), isOn: $settings.experimentalPanelNavigation) .disabled(!settings.experimentalToolbarUI) - PanelToggleRow(title: AppText.string("settings.experimental.sidebarNavigation", defaultValue: "Sidebar navigation"), + UI.Panel.ToggleRow(title: AppText.string("settings.experimental.sidebarNavigation", defaultValue: "Sidebar navigation"), info: AppText.string("settings.experimental.sidebarNavigation.info", defaultValue: "Keep the sidebar visible in either shell. Turn this off for a page-only layout."), isOn: $settings.sidebarNavigationEnabled) - PanelToggleRow(title: AppText.string("settings.experimental.commandPalette", defaultValue: "Command palette (Command-K)"), + UI.Panel.ToggleRow(title: AppText.string("settings.experimental.commandPalette", defaultValue: "Command palette (Command-K)"), info: AppText.string("settings.experimental.commandPalette.info", defaultValue: "The Command-K command index: fuzzy search across every app, container, image, and resource action. Page search and menu commands work regardless of this setting."), isOn: $settings.commandPaletteEnabled) - PanelToggleRow(title: AppText.string("settings.experimental.dockerHubSearch", defaultValue: "Docker Hub search"), + UI.Panel.ToggleRow(title: AppText.string("settings.experimental.dockerHubSearch", defaultValue: "Docker Hub search"), info: AppText.string("settings.experimental.dockerHubSearch.info", defaultValue: "Search registry images inline (creation Search path and the palette's Hub scope). Requires network access to the registry."), isOn: $settings.hubSearchEnabled) - PanelToggleRow(title: AppText.string("settings.experimental.composeImport", defaultValue: "Compose import"), + UI.Panel.ToggleRow(title: AppText.string("settings.experimental.composeImport", defaultValue: "Compose import"), info: AppText.string("settings.experimental.composeImport.info", defaultValue: "Import Docker Compose YAML - paste, file pick, or drag-and-drop - mapping each service with an image into a prefilled run."), isOn: $settings.composeImportEnabled) - PanelToggleRow(title: AppText.string("settings.experimental.imageBuildWorkspace", defaultValue: "Image build workspace"), + UI.Panel.ToggleRow(title: AppText.string("settings.experimental.imageBuildWorkspace", defaultValue: "Image build workspace"), info: AppText.string("settings.experimental.imageBuildWorkspace.info", defaultValue: "Build an image from a Dockerfile + build context, streaming the BuildKit log."), isOn: $settings.imageBuildEnabled) - PanelToggleRow(title: AppText.string("settings.experimental.keyboardShortcuts", defaultValue: "Keyboard shortcuts"), + UI.Panel.ToggleRow(title: AppText.string("settings.experimental.keyboardShortcuts", defaultValue: "Keyboard shortcuts"), info: AppText.string("settings.experimental.keyboardShortcuts.info", defaultValue: "Enable menu and command keyboard shortcuts. Off by default so the experimental surface stays opt-in."), isOn: $settings.keyboardShortcutsEnabled) } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift index b9b580f3..4eec4458 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore // MARK: - General @@ -10,34 +10,34 @@ struct GeneralTab: View { @State private var confirmingClear = false var body: some View { - LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: AppText.string("settings.general.startup", defaultValue: "Startup")) { - PanelToggleRow(title: AppText.string("settings.general.launchAtLogin", defaultValue: "Launch at login"), + LazyVStack(spacing: UI.Layout.Spacing.l) { + UI.Panel.Section(header: AppText.string("settings.general.startup", defaultValue: "Startup")) { + UI.Panel.ToggleRow(title: AppText.string("settings.general.launchAtLogin", defaultValue: "Launch at login"), isOn: $settings.launchAtLogin) - PanelToggleRow(title: AppText.string("settings.general.keepInMenuBar", defaultValue: "Keep running in the menu bar"), + UI.Panel.ToggleRow(title: AppText.string("settings.general.keepInMenuBar", defaultValue: "Keep running in the menu bar"), isOn: $settings.keepInMenuBar) } - PanelSection(header: AppText.string("settings.general.activityAlerts", defaultValue: "Activity & alerts")) { - PanelToggleRow(title: AppText.string("settings.general.notifyOnCrash", defaultValue: "System alert on container crash / restart"), + UI.Panel.Section(header: AppText.string("settings.general.activityAlerts", defaultValue: "Activity & alerts")) { + UI.Panel.ToggleRow(title: AppText.string("settings.general.notifyOnCrash", defaultValue: "System alert on container crash / restart"), isOn: $settings.notifyOnCrash) - PanelToggleRow(title: AppText.string("settings.general.showRevealCLI", defaultValue: "Show Reveal CLI on actions"), + UI.Panel.ToggleRow(title: AppText.string("settings.general.showRevealCLI", defaultValue: "Show Reveal CLI on actions"), info: AppText.string("settings.general.showRevealCLI.info", defaultValue: "Shows the exact `container ...` command for important actions. Useful when you are learning the CLI or want to verify what will run."), isOn: $settings.revealCLI) } - PanelSection(header: AppText.string("settings.general.data", defaultValue: "Data"), + UI.Panel.Section(header: AppText.string("settings.general.data", defaultValue: "Data"), footer: AppText.string("settings.general.data.footer", defaultValue: "Live metrics use one low-priority runtime stream. The list refresh interval only controls background service, container list, and resource-cache polling. \(settings.statsNormalizationMode.footnote)")) { - PanelRow(title: AppText.string("settings.general.listRefreshInterval", defaultValue: "List refresh interval")) { - HStack(spacing: DesignTokens.Space.s) { + UI.Panel.Row(title: AppText.string("settings.general.listRefreshInterval", defaultValue: "List refresh interval")) { + HStack(spacing: UI.Layout.Spacing.s) { Slider(value: $settings.refreshInterval, in: 1...10, step: 1) - .frame(width: DesignTokens.FormWidth.compactSlider) + .frame(width: UI.Form.Width.compactSlider) Text("\(Int(settings.refreshInterval))s") .monospacedDigit() - .frame(width: DesignTokens.FormWidth.refreshReadout, alignment: .trailing) + .frame(width: UI.Form.Width.refreshReadout, alignment: .trailing) } } - PanelRow(title: AppText.string("settings.general.keepHistoryFor", defaultValue: "Keep history for")) { + UI.Panel.Row(title: AppText.string("settings.general.keepHistoryFor", defaultValue: "Keep history for")) { Picker("", selection: retentionBinding) { Text("1 day").tag(1) Text("7 days").tag(7) @@ -46,7 +46,7 @@ struct GeneralTab: View { } .labelsHidden().fixedSize() } - PanelRow(title: AppText.string("settings.general.normalizeStats", defaultValue: "Normalize stats")) { + UI.Panel.Row(title: AppText.string("settings.general.normalizeStats", defaultValue: "Normalize stats")) { Picker("", selection: statsNormalizationBinding) { ForEach(StatsNormalizationMode.allCases) { mode in Text(mode.displayName).tag(mode) @@ -61,9 +61,9 @@ struct GeneralTab: View { ConfigTransferControls() } - PanelSection(header: AppText.string("settings.general.logging", defaultValue: "Logging"), + UI.Panel.Section(header: AppText.string("settings.general.logging", defaultValue: "Logging"), footer: settings.loggingLevel.footnote) { - PanelRow(title: AppText.string("settings.general.loggingLevel", defaultValue: "Level")) { + UI.Panel.Row(title: AppText.string("settings.general.loggingLevel", defaultValue: "Level")) { Picker("", selection: $settings.loggingLevel) { ForEach(AppLogLevel.allCases) { level in Text(level.displayName).tag(level) @@ -73,18 +73,16 @@ struct GeneralTab: View { .labelsHidden() .fixedSize() } - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { - Text("Write to").font(.caption).foregroundStyle(.secondary) + UI.List.Section(AppText.string("settings.logging.writeTo", defaultValue: "Write to")) { ForEach(AppLogDestination.allCases) { destination in Toggle(destination.displayName, isOn: setBinding(destination, in: \.enabledLogDestinations)) .toggleStyle(.checkbox) } } - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { - Text("Categories").font(.caption).foregroundStyle(.secondary) + UI.List.Section(AppText.string("settings.logging.categories", defaultValue: "Categories")) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), alignment: .leading)], alignment: .leading, - spacing: DesignTokens.Space.s) { + spacing: UI.Layout.Spacing.s) { ForEach(AppLogCategory.allCases) { category in Toggle(category.displayName, isOn: setBinding(category, in: \.enabledLogCategories)) .toggleStyle(.checkbox) @@ -93,8 +91,8 @@ struct GeneralTab: View { } } - PanelSection(header: AppText.string("settings.general.advanced", defaultValue: "Advanced")) { - PanelField(label: AppText.string("settings.general.containerCLIPath", defaultValue: "Container CLI path"), + UI.Panel.Section(header: AppText.string("settings.general.advanced", defaultValue: "Advanced")) { + UI.Panel.Field(label: AppText.string("settings.general.containerCLIPath", defaultValue: "Container CLI path"), info: AppText.string("settings.general.containerCLIPath.info", defaultValue: "Override the auto-detected `container` binary location.")) { TextField("", text: $settings.cliPathOverride, prompt: Text("/usr/local/bin/container")) .textFieldStyle(.roundedBorder) diff --git a/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift index 62f93f3f..02c7cfc7 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift @@ -1,7 +1,6 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore -import ContainedRuntime // MARK: - Registries @@ -12,23 +11,18 @@ struct RegistriesTab: View { @State private var loggingOut: RegistryLogin? var body: some View { - LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: AppText.string("settings.registries.signedIn", defaultValue: "Signed-in registries"), + LazyVStack(spacing: UI.Layout.Spacing.l) { + UI.Panel.Section(header: AppText.string("settings.registries.signedIn", defaultValue: "Signed-in registries"), footer: AppText.string("settings.registries.footer", defaultValue: "Credentials are typed by you and piped to the CLI via stdin, so the password never lands in the process list. Contained doesn't store it.")) { if app.registries.isEmpty { Text("Not signed in to any registries.") - .foregroundStyle(.secondary) + .designSecondaryValueStyle() .frame(maxWidth: .infinity, alignment: .leading) } else { ForEach(app.registries) { login in - HStack { - VStack(alignment: .leading, spacing: DesignTokens.Space.xxs) { - Text(login.host) - if let user = login.username { - Text("as \(user)").font(.caption).foregroundStyle(.secondary) - } - } - Spacer() + UI.List.MetadataRow(systemImage: "key", + title: login.host, + subtitle: login.username.map { AppText.string("settings.registries.username", defaultValue: "as \($0)") }) { Button("Log Out", role: .destructive) { loggingOut = login } } .contextMenu { @@ -40,7 +34,7 @@ struct RegistriesTab: View { } } - PanelSection { + UI.Panel.Section { Button("Log In to Registry…") { loggingIn = true } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift index 719568f3..8748e92c 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore // MARK: - Runtime @@ -16,26 +16,26 @@ struct RuntimeTab: View { @State private var deletingDomain: String? var body: some View { - LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: AppText.string("settings.runtime.kernel", defaultValue: "Kernel"), + LazyVStack(spacing: UI.Layout.Spacing.l) { + UI.Panel.Section(header: AppText.string("settings.runtime.kernel", defaultValue: "Kernel"), footer: AppText.string("settings.runtime.kernel.footer", defaultValue: "Downloads and sets the recommended kernel as the default. May prompt for your administrator password - handled by the container CLI; Contained never sees it.")) { - PanelRow(title: AppText.string("settings.runtime.recommendedKernel", defaultValue: "Recommended kernel")) { + UI.Panel.Row(title: AppText.string("settings.runtime.recommendedKernel", defaultValue: "Recommended kernel")) { Button("Install…") { confirmingKernel = true } } revealCLIHint("container system kernel set --recommended") } - PanelSection(header: AppText.string("settings.runtime.localDNSDomains", defaultValue: "Local DNS domains"), + UI.Panel.Section(header: AppText.string("settings.runtime.localDNSDomains", defaultValue: "Local DNS domains"), footer: AppText.string("settings.runtime.localDNSDomains.footer", defaultValue: "Creating or deleting a domain may prompt for your administrator password - handled by the container CLI.")) { if dnsDomains.isEmpty { Text("No local DNS domains.") - .foregroundStyle(.secondary) + .designSecondaryValueStyle() .frame(maxWidth: .infinity, alignment: .leading) } else { ForEach(dnsDomains, id: \.self) { domain in - HStack { - Text(domain).font(.system(.callout, design: .monospaced)) - Spacer() + UI.List.MetadataRow(systemImage: "network", + title: domain, + isMonospaced: true) { Button(role: .destructive) { deletingDomain = domain } label: { Image(systemName: "trash") } @@ -48,21 +48,21 @@ struct RuntimeTab: View { } if let props = app.properties { - PanelSection(header: AppText.string("settings.runtime.resources", defaultValue: "Runtime resources"), + UI.Panel.Section(header: AppText.string("settings.runtime.resources", defaultValue: "Runtime resources"), footer: AppText.string("settings.runtime.resources.footer", defaultValue: "Read-only - machine resources are the denominator for machine-normalized stats. Defaults apply when a container or build doesn't specify its own resources.")) { if let d = props.container { - if let c = d.cpus { PanelRow(title: AppText.string("settings.runtime.defaultCPUs", defaultValue: "Default CPUs")) { Text("\(c)").foregroundStyle(.secondary) } } - if let m = d.memory { PanelRow(title: AppText.string("settings.runtime.defaultMemory", defaultValue: "Default memory")) { Text(m).foregroundStyle(.secondary) } } + if let c = d.cpus { UI.Panel.Row(title: AppText.string("settings.runtime.defaultCPUs", defaultValue: "Default CPUs")) { Text("\(c)").designSecondaryValueStyle() } } + if let m = d.memory { UI.Panel.Row(title: AppText.string("settings.runtime.defaultMemory", defaultValue: "Default memory")) { Text(m).designSecondaryValueStyle() } } } if let machine = props.machine { - if let c = machine.cpus { PanelRow(title: AppText.string("settings.runtime.machineCPUs", defaultValue: "Machine CPUs")) { Text("\(c)").foregroundStyle(.secondary) } } - if let m = machine.memory { PanelRow(title: AppText.string("settings.runtime.machineMemory", defaultValue: "Machine memory")) { Text(m).foregroundStyle(.secondary) } } + if let c = machine.cpus { UI.Panel.Row(title: AppText.string("settings.runtime.machineCPUs", defaultValue: "Machine CPUs")) { Text("\(c)").designSecondaryValueStyle() } } + if let m = machine.memory { UI.Panel.Row(title: AppText.string("settings.runtime.machineMemory", defaultValue: "Machine memory")) { Text(m).designSecondaryValueStyle() } } } if let b = props.build { - if let img = b.image { PanelRow(title: AppText.string("settings.runtime.builderImage", defaultValue: "Builder image")) { Text(img).foregroundStyle(.secondary) } } - if let r = b.rosetta { PanelRow(title: AppText.string("settings.runtime.builderRosetta", defaultValue: "Builder Rosetta")) { Text(r ? "On" : "Off").foregroundStyle(.secondary) } } + if let img = b.image { UI.Panel.Row(title: AppText.string("settings.runtime.builderImage", defaultValue: "Builder image")) { Text(img).designSecondaryValueStyle() } } + if let r = b.rosetta { UI.Panel.Row(title: AppText.string("settings.runtime.builderRosetta", defaultValue: "Builder Rosetta")) { Text(r ? "On" : "Off").designSecondaryValueStyle() } } } - if let k = props.kernel, let path = k.binaryPath { PanelRow(title: AppText.string("settings.runtime.kernel", defaultValue: "Kernel")) { Text(path).foregroundStyle(.secondary) } } + if let k = props.kernel, let path = k.binaryPath { UI.Panel.Row(title: AppText.string("settings.runtime.kernel", defaultValue: "Kernel")) { Text(path).designSecondaryValueStyle() } } } } } @@ -89,13 +89,14 @@ struct RuntimeTab: View { @ViewBuilder private func revealCLIHint(_ command: String) -> some View { if app.settings.revealCLI { - HStack(spacing: DesignTokens.Space.s) { - Image(systemName: "terminal").foregroundStyle(.secondary) - Text(command).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary) - .lineLimit(1).truncationMode(.middle) - Spacer() - Button { copyToPasteboard(command) } label: { Image(systemName: "doc.on.doc") } - .buttonStyle(.borderless).foregroundStyle(.secondary).help("Copy command") + UI.List.MetadataRow(systemImage: "terminal", + title: command, + isMonospaced: true) { + Button { copyToPasteboard(command) } label: { + UI.Symbol.Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help(AppText.copyCommand) } } } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift index a26516b6..696c9f74 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore // MARK: - Updates @@ -11,10 +11,10 @@ struct UpdatesTab: View { var body: some View { @Bindable var settings = app.settings - LazyVStack(spacing: DesignTokens.Space.l) { - PanelSection(header: AppText.sectionSettingsUpdates, + LazyVStack(spacing: UI.Layout.Spacing.l) { + UI.Panel.Section(header: AppText.sectionSettingsUpdates, footer: AppText.string("settings.updates.footer", defaultValue: "\(settings.updateChannel.footnote) Each channel has its own release feed; channels without a published build yet are dimmed and unselectable. Delivered via Sparkle once a signed build points at the feed; inert in development builds.")) { - PanelRow(title: AppText.string("settings.updates.channel", defaultValue: "Update channel")) { + UI.Panel.Row(title: AppText.string("settings.updates.channel", defaultValue: "Update channel")) { Menu(app.settings.updateChannel.displayName) { ForEach(UpdateChannel.allCases) { channel in Button { @@ -31,7 +31,7 @@ struct UpdatesTab: View { } .fixedSize() } - PanelToggleRow(title: AppText.string("settings.updates.automaticallyCheck", defaultValue: "Automatically check for updates"), + UI.Panel.ToggleRow(title: AppText.string("settings.updates.automaticallyCheck", defaultValue: "Automatically check for updates"), isOn: Binding(get: { settings.appUpdateChecksEnabled }, set: { settings.appUpdateChecksEnabled = $0 @@ -47,9 +47,9 @@ struct UpdatesTab: View { .frame(maxWidth: .infinity, alignment: .leading) } - PanelSection(header: AppText.string("settings.updates.imageUpdates", defaultValue: "Image updates"), + UI.Panel.Section(header: AppText.string("settings.updates.imageUpdates", defaultValue: "Image updates"), footer: AppText.string("settings.updates.imageUpdates.footer", defaultValue: "Controls the background registry digest check cadence. Manual checks are always available from Images, System, and the toolbar.")) { - PanelRow(title: AppText.string("settings.updates.checkImages", defaultValue: "Check images")) { + UI.Panel.Row(title: AppText.string("settings.updates.checkImages", defaultValue: "Check images")) { Picker("", selection: $settings.imageUpdateIntervalHours) { Text("Every hour").tag(1) Text("Every 3 hours").tag(3) diff --git a/Sources/ContainedApp/Features/Settings/ConfigTransfer.swift b/Sources/ContainedApp/Features/Settings/Transfer/ConfigTransfer.swift similarity index 96% rename from Sources/ContainedApp/Features/Settings/ConfigTransfer.swift rename to Sources/ContainedApp/Features/Settings/Transfer/ConfigTransfer.swift index da64eaf2..542cee3f 100644 --- a/Sources/ContainedApp/Features/Settings/ConfigTransfer.swift +++ b/Sources/ContainedApp/Features/Settings/Transfer/ConfigTransfer.swift @@ -1,7 +1,7 @@ import SwiftUI import AppKit import UniformTypeIdentifiers -import ContainedDesignSystem +import ContainedUI enum ConfigImportMode: String, CaseIterable, Identifiable { case merge = "Merge" @@ -27,7 +27,7 @@ struct ConfigTransferControls: View { @State private var importMode: ConfigImportMode = .merge var body: some View { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { ForEach(AppStateSection.allCases) { section in Toggle(section.displayName, isOn: binding(for: section)) .toggleStyle(.checkbox) diff --git a/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift b/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift index 1553771a..deca2606 100644 --- a/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift +++ b/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore struct SystemLogsSheet: View { @@ -10,31 +10,31 @@ struct SystemLogsSheet: View { var body: some View { VStack(spacing: 0) { - HStack(spacing: DesignTokens.Space.m) { - Text("System logs").font(.headline) - DesignToggleButton(isOn: $follow, title: AppText.follow, systemName: "arrow.down.to.line") + HStack(spacing: UI.Layout.Spacing.m) { + Text("System logs").designHeadlineLabelStyle() + UI.Action.ToggleButton(isOn: $follow, title: AppText.follow, systemName: "arrow.down.to.line") .onChange(of: follow) { _, _ in session += 1 } Spacer() - DesignActionGroup(DesignAction(systemName: "xmark", + UI.Action.Group(UI.Action.Item(systemName: "xmark", help: AppText.close, isCancel: true) { dismiss() }) } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) if let client = app.client { - StreamConsole(stream: { client.streamSystemLogs(follow: follow, last: 500) }, + UI.Console.Stream(stream: { client.streamSystemLogs(follow: follow, last: 500) }, workingLabel: AppText.working, completedLabel: AppText.completed, lineCountLabel: AppText.lineCount, copyLogHelp: AppText.copyLog, failureLabel: AppErrorPresentation.message) .id(session) - .padding(.horizontal, DesignTokens.Space.s) - .padding(.bottom, DesignTokens.Space.s) + .padding(.horizontal, UI.Layout.Spacing.s) + .padding(.bottom, UI.Layout.Spacing.s) } } - .frame(DesignTokens.SheetSize.wide) + .frame(UI.Panel.SheetSize.wide) .sheetMaterial() } } diff --git a/Sources/ContainedApp/Stores/RefreshCoordinator.swift b/Sources/ContainedApp/Features/System/Store/RefreshCoordinator.swift similarity index 100% rename from Sources/ContainedApp/Stores/RefreshCoordinator.swift rename to Sources/ContainedApp/Features/System/Store/RefreshCoordinator.swift diff --git a/Sources/ContainedApp/Features/System/SystemView.swift b/Sources/ContainedApp/Features/System/SystemView.swift index 88d35c5d..e16c5e81 100644 --- a/Sources/ContainedApp/Features/System/SystemView.swift +++ b/Sources/ContainedApp/Features/System/SystemView.swift @@ -1,8 +1,7 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import ContainedCore -import ContainedRuntime /// System overview content: service status + controls, volumes, `system df` disk usage, a Prune /// Center, and a system-logs viewer. Hosted header-less in the toolbar System morph panel. Daemon @@ -97,7 +96,7 @@ struct SystemContent: View { } var body: some View { - DesignPanelScaffold(width: DesignTokens.PanelSize.system.width) { + UI.Panel.Scaffold(width: UI.Panel.Size.system.width) { if showsHeader { VStack(spacing: 0) { header @@ -105,14 +104,14 @@ struct SystemContent: View { } } } content: { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.l) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.l) { switch activePage { case .engine: engineStatusCard case .automation: automationCard case .volumes: volumesCard } } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) } .task { await app.refreshSystemResources() } .confirmationDialog("Delete volume \(deletingVolume?.name ?? "")?", @@ -131,22 +130,22 @@ struct SystemContent: View { /// A consistent design-system section card. private func card(@ViewBuilder _ content: @escaping () -> Content) -> some View { - DesignContentSurface(elevated: elevated, alignment: .leading) { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.m) { content() } + UI.Surface.Content(elevated: elevated, alignment: .leading) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.m) { content() } } } private var header: some View { - PanelHeader(symbol: "gearshape.2", + UI.Panel.Header(symbol: "gearshape.2", title: AppText.sectionSystem, subtitle: activePage.subtitle) { - HStack(spacing: DesignTokens.Toolbar.groupSpacing) { + HStack(spacing: UI.Toolbar.Spacing.groupSpacing) { engineControls - DesignActionCluster { - DesignActionItems(pageActions) + UI.Action.Cluster { + UI.Action.Items(pageActions) storageMenu if showClose { - DesignActionItems([DesignAction(systemName: "xmark", + UI.Action.Items([UI.Action.Item(systemName: "xmark", help: AppText.close, isCancel: true, action: onClose)]) @@ -156,9 +155,9 @@ struct SystemContent: View { } } - private var pageActions: [DesignAction] { + private var pageActions: [UI.Action.Item] { SystemPage.allCases.map { item in - DesignAction(systemName: item.systemImage, + UI.Action.Item(systemName: item.systemImage, help: item.title, tint: activePage == item ? .accentColor : nil) { setPage(item) @@ -167,9 +166,9 @@ struct SystemContent: View { } private var engineControls: some View { - DesignActionGroup([ + UI.Action.Group([ servicePowerAction, - DesignAction(systemName: "arrow.clockwise", + UI.Action.Item(systemName: "arrow.clockwise", help: AppText.restartService, isEnabled: !working) { run { await app.restartService() } @@ -177,16 +176,16 @@ struct SystemContent: View { ]) } - private var servicePowerAction: DesignAction { + private var servicePowerAction: UI.Action.Item { if app.serviceHealthy { - return DesignAction(systemName: "stop.fill", + return UI.Action.Item(systemName: "stop.fill", help: AppText.stopService, role: .destructive, isEnabled: !working) { run { await app.stopService() } } } else { - return DesignAction(systemName: "play.fill", + return UI.Action.Item(systemName: "play.fill", help: AppText.startService, isEnabled: !working) { run { await app.startService() } @@ -206,7 +205,7 @@ struct SystemContent: View { Button { pruneTarget = .volumes } label: { Label(AppText.string("cleanup.unusedVolumes", defaultValue: "Unused volumes"), systemImage: "externaldrive") } Button { pruneTarget = .networks } label: { Label(AppText.string("cleanup.unusedNetworks", defaultValue: "Unused networks"), systemImage: "network") } } label: { - DesignMenuActionLabel(systemName: "trash", + UI.Action.MenuLabel(systemName: "trash", help: AppText.storageCleanup, role: .destructive) } @@ -225,10 +224,10 @@ struct SystemContent: View { private var volumesCard: some View { card { HStack { - Text(AppText.sectionVolumes).font(.headline) - DesignBadgeText(text: "\(volumeInventory.count)") + Text(AppText.sectionVolumes).designHeadlineLabelStyle() + UI.Badge.Text(text: "\(volumeInventory.count)") Spacer() - DesignActionGroup(DesignAction(systemName: "plus", + UI.Action.Group(UI.Action.Item(systemName: "plus", title: AppText.string("common.new", defaultValue: "New"), help: AppText.newVolume) { onClose() @@ -236,10 +235,9 @@ struct SystemContent: View { }) } if volumeInventory.isEmpty { - Text(AppText.string("volume.inventory.empty", defaultValue: "No named volumes or container mounts found.")) - .font(.callout).foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, DesignTokens.Space.xs) + UI.State.Empty(AppText.string("volume.inventory.empty", defaultValue: "No named volumes or container mounts found."), + systemImage: "externaldrive", + padding: UI.Layout.Spacing.xs) } else { LazyVStack(spacing: 0) { ForEach(Array(volumeInventory.enumerated()), id: \.element.id) { index, entry in @@ -252,30 +250,19 @@ struct SystemContent: View { } private func volumeRow(_ entry: VolumeInventoryEntry) -> some View { - HStack(spacing: DesignTokens.Space.m) { - Image(systemName: entry.kind.symbol) - .foregroundStyle(.secondary) - .frame(width: DesignTokens.IconSize.rowIconColumn) - VStack(alignment: .leading, spacing: DesignTokens.DesignCard.compactTextSpacing) { - HStack(spacing: DesignTokens.Space.xs) { - Text(entry.title).font(.system(.callout, design: .monospaced)).lineLimit(1) - DesignBadgeText(text: entry.kind.rawValue) - } - if let subtitle = SystemVolumeInventory.rowSubtitle(entry) { - Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) - } - } - Spacer(minLength: DesignTokens.Space.s) + UI.List.MetadataBadgeRow(systemImage: entry.kind.symbol, + title: entry.title, + badge: entry.kind.rawValue, + subtitle: SystemVolumeInventory.rowSubtitle(entry), + isMonospaced: true) { if !entry.containers.isEmpty { - Text("\(entry.containers.count)") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) + UI.Card.MetricText(text: "\(entry.containers.count)") + .designSecondaryValueStyle() } - DesignRowMenu(accessibilityLabel: AppText.string("menu.volumeActions", defaultValue: "Volume actions")) { + UI.Control.RowMenu(accessibilityLabel: AppText.string("menu.volumeActions", defaultValue: "Volume actions")) { volumeMenu(entry) } } - .padding(.vertical, DesignTokens.Space.s) .contextMenu { volumeMenu(entry) } } @@ -309,7 +296,7 @@ struct SystemContent: View { private var automationCard: some View { card { - Text(AppText.string("system.page.automation", defaultValue: "Automation")).font(.headline) + Text(AppText.string("system.page.automation", defaultValue: "Automation")).designHeadlineLabelStyle() TimelineView(.periodic(from: .now, by: 1)) { context in automationRow(icon: "arrow.triangle.2.circlepath", title: AppText.string("automation.imageUpdateCheck", defaultValue: "Image update check"), @@ -319,8 +306,9 @@ struct SystemContent: View { isOn: settingBinding(\.imageUpdateChecksEnabled)) { if app.settings.imageUpdateChecksEnabled { Text(countdown(to: app.imageUpdateNextRunDate, now: context.date)) - .font(.system(.caption, design: .monospaced).weight(.semibold)).monospacedDigit() - DesignActionGroup(DesignAction(systemName: "arrow.triangle.2.circlepath", + .designSecondaryMonospacedCaption() + .monospacedDigit() + UI.Action.Group(UI.Action.Item(systemName: "arrow.triangle.2.circlepath", title: AppText.string("common.runNow", defaultValue: "Run now"), help: AppText.runImageUpdateCheckNow) { Task { await app.runImageUpdateSweepNow() } @@ -335,7 +323,7 @@ struct SystemContent: View { ? AppText.string("automation.appUpdateCheck.detail", defaultValue: "Sparkle · \(app.settings.updateChannel.rawValue.capitalized) channel") : AppText.string("status.unavailableInBuild", defaultValue: "Unavailable in this build"), isOn: appUpdateBinding) { - DesignActionGroup(DesignAction(systemName: "arrow.down.app", + UI.Action.Group(UI.Action.Item(systemName: "arrow.down.app", title: AppText.string("common.checkNow", defaultValue: "Check now"), help: AppText.checkForUpdatesNow, isEnabled: app.updater.canCheckForUpdates @@ -351,31 +339,21 @@ struct SystemContent: View { : AppText.string("status.off", defaultValue: "Off"), isOn: settingBinding(\.autoRestartEnabled)) { EmptyView() } Divider() - HStack(spacing: DesignTokens.Space.s) { - Image(systemName: "dot.radiowaves.left.and.right") - .foregroundStyle(.secondary) - .frame(width: DesignTokens.IconSize.rowIconColumn) - Text(AppText.string("automation.refreshLoop", defaultValue: "Refresh loop")).font(.callout) - Spacer() - Text(app.coordinator.isActive ? "Active" : "Paused") - .font(.callout) - .foregroundStyle(app.coordinator.isActive ? .green : .secondary) + UI.List.MetadataRow(systemImage: "dot.radiowaves.left.and.right", + title: AppText.string("automation.refreshLoop", defaultValue: "Refresh loop")) { + UI.State.StatusText(app.coordinator.isActive ? "Active" : "Paused", + tone: app.coordinator.isActive ? .success : .neutral) } } } private func automationRow(icon: String, title: String, detail: String, isOn: Binding, - @ViewBuilder trailing: () -> Trailing) -> some View { - HStack(spacing: DesignTokens.Space.m) { - Image(systemName: icon).font(.title3) - .foregroundStyle(isOn.wrappedValue ? Color.accentColor : .secondary) - .frame(width: DesignTokens.IconSize.rowIconColumn) - VStack(alignment: .leading, spacing: DesignTokens.Space.xxs) { - Text(title).font(.callout) - Text(detail).font(.caption).foregroundStyle(.secondary) - } - Spacer(minLength: DesignTokens.Space.s) + @ViewBuilder trailing: @escaping () -> Trailing) -> some View { + UI.List.MetadataRow(systemImage: icon, + title: title, + subtitle: detail, + tint: isOn.wrappedValue ? .accentColor : .secondary) { trailing() Toggle("", isOn: isOn).labelsHidden().controlSize(.mini) } @@ -445,27 +423,27 @@ struct SystemContent: View { private var engineStatusCard: some View { card { - HStack(spacing: DesignTokens.Space.s) { - DesignStatusDot(color: app.serviceHealthy ? .green : .orange, - size: DesignTokens.IconSize.serviceDot) - Text(AppText.string("system.containerEngine", defaultValue: "Container engine")).font(.headline) - DesignStatusBadge(text: app.serviceLabel, + HStack(spacing: UI.Layout.Spacing.s) { + UI.Badge.Dot(color: app.serviceHealthy ? .green : .orange, + size: UI.Control.Size.serviceDot) + Text(AppText.string("system.containerEngine", defaultValue: "Container engine")).designHeadlineLabelStyle() + UI.Badge.Status(text: app.serviceLabel, tint: app.serviceHealthy ? .green : .orange) Spacer(minLength: 0) if let version = app.systemStatus?.apiServerVersion { - Text(version).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary) + Text(version).designSecondaryMonospacedCaption() .textSelection(.enabled) } } - HStack(spacing: DesignTokens.Space.s) { - DesignMetricTile(label: AppText.sectionContainers, + HStack(spacing: UI.Layout.Spacing.s) { + UI.Control.MetricTile(label: AppText.sectionContainers, value: "\(app.containers.running.count)", caption: AppText.string("status.running.lowercase", defaultValue: "running")) - DesignMetricTile(label: AppText.sectionImages, value: "\(app.images.count)") - DesignMetricTile(label: AppText.string("system.diskUsed", defaultValue: "Disk used"), + UI.Control.MetricTile(label: AppText.sectionImages, value: "\(app.images.count)") + UI.Control.MetricTile(label: AppText.string("system.diskUsed", defaultValue: "Disk used"), value: app.diskUsage.map { Format.bytes($0.totalSizeInBytes) } ?? "—") } - if working { ProgressView().controlSize(.small) } + if working { UI.State.ProgressIndicator() } } } diff --git a/Sources/ContainedApp/Migration/DowngradeDecisionView.swift b/Sources/ContainedApp/Migration/DowngradeDecisionView.swift index 1e4b2c40..f006bb7b 100644 --- a/Sources/ContainedApp/Migration/DowngradeDecisionView.swift +++ b/Sources/ContainedApp/Migration/DowngradeDecisionView.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI struct DowngradeDecisionView: View { let schemaVersion: Int @@ -8,17 +8,17 @@ struct DowngradeDecisionView: View { var onQuit: () -> Void var body: some View { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.l) { - SheetHeader(title: AppText.string("downgrade.title", defaultValue: "This data was created by a newer version"), + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.l) { + UI.Panel.SheetTitleBar(title: AppText.string("downgrade.title", defaultValue: "This data was created by a newer version"), subtitle: AppText.string("downgrade.subtitle", defaultValue: "Stored schema \(schemaVersion), this app supports \(StateMigrator.currentSchemaVersion)."), cancelHelp: AppText.quit, onCancel: onQuit) Text(AppText.string("downgrade.description", defaultValue: "You can export a backup before resetting incompatible local data, try to keep what this build can still read, or quit and install the newer build again.")) - .foregroundStyle(.secondary) + .designSecondaryValueStyle() .fixedSize(horizontal: false, vertical: true) - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { Button(AppText.string("downgrade.exportBackupThenReset", defaultValue: "Export Backup, Then Reset")) { onExportAndReset() } .buttonStyle(.borderedProminent) Button(AppText.string("downgrade.keepReadableData", defaultValue: "Try to Keep Readable Data")) { onKeep() } @@ -26,7 +26,7 @@ struct DowngradeDecisionView: View { } .frame(maxWidth: .infinity, alignment: .leading) } - .padding(DesignTokens.Space.l) - .frame(width: DesignTokens.SheetSize.dialogWidth) + .padding(UI.Layout.Spacing.l) + .frame(width: UI.Panel.SheetSize.dialogWidth) } } diff --git a/Sources/ContainedApp/Features/MenuBar/MenuBarContent.swift b/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift similarity index 79% rename from Sources/ContainedApp/Features/MenuBar/MenuBarContent.swift rename to Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift index 6d8d01cc..63fa30c4 100644 --- a/Sources/ContainedApp/Features/MenuBar/MenuBarContent.swift +++ b/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift @@ -1,8 +1,7 @@ import SwiftUI import AppKit import ContainedCore -import AppleContainerRuntime -import ContainedDesignSystem +import ContainedUI /// The menu shown by the menu-bar extra: a compact command surface with service status, running /// containers, live resource counts, and the same creation / navigation affordances as the app menu. @@ -30,7 +29,7 @@ struct MenuBarContent: View { } var body: some View { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.m) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.m) { header Divider() @@ -146,50 +145,48 @@ struct MenuBarContent: View { footerRow } - .padding(DesignTokens.MenuBar.padding) - .frame(width: DesignTokens.MenuBar.width) + .padding(UI.MenuBar.Padding.all) + .frame(width: UI.MenuBar.Size.width) } @ViewBuilder private var header: some View { - VStack(alignment: .leading, spacing: DesignTokens.Toolbar.searchIconGap) { + VStack(alignment: .leading, spacing: UI.Toolbar.Spacing.searchIconGap) { HStack(alignment: .firstTextBaseline) { Label("Contained", systemImage: app.serviceHealthy ? "shippingbox.fill" : "shippingbox") - .font(.headline) + .designHeadlineLabelStyle() Spacer(minLength: 0) Text("\(store.running.count)") - .font(.headline.monospacedDigit()) - .foregroundStyle(.secondary) + .designSecondaryMonospacedDigitHeadline() } - HStack(spacing: DesignTokens.DesignCard.padding) { - Label(app.serviceLabel, systemImage: app.serviceHealthy ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") - .foregroundStyle(app.serviceHealthy ? .green : .secondary) - Text(app.settings.updateChannel.displayName) - .foregroundStyle(.secondary) + HStack(spacing: UI.Card.Padding.content) { + UI.State.InlineStatus(app.serviceLabel, + systemImage: app.serviceHealthy ? "checkmark.circle.fill" : "exclamationmark.triangle.fill", + tone: app.serviceHealthy ? .success : .neutral) + UI.State.StatusText(app.settings.updateChannel.displayName, + style: .caption) Spacer(minLength: 0) if unreadActivityCount > 0 { - Label("\(unreadActivityCount) unread", systemImage: "bell.badge") - .foregroundStyle(.secondary) + UI.State.InlineStatus("\(unreadActivityCount) unread", + systemImage: "bell.badge") } } - .font(.caption) } } @ViewBuilder private var infoGrid: some View { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { - infoRow("Containers", value: "\(store.running.count) running · \(stopped.count) stopped") - infoRow("Resources", value: "\(app.images.count) images · \(app.volumes.count) volumes · \(app.networks.count) networks") - infoRow("Bootstrap", value: cliLabel) - infoRow("Activity", value: unreadActivityCount > 0 ? "\(unreadActivityCount) unread" : "All caught up") + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { + UI.List.CompactInfoRow("Containers", value: "\(store.running.count) running · \(stopped.count) stopped") + UI.List.CompactInfoRow("Resources", value: "\(app.images.count) images · \(app.volumes.count) volumes · \(app.networks.count) networks") + UI.List.CompactInfoRow("Bootstrap", value: cliLabel) + UI.List.CompactInfoRow("Activity", value: unreadActivityCount > 0 ? "\(unreadActivityCount) unread" : "All caught up") } - .font(.caption) } private var actionStrip: some View { - HStack(spacing: DesignTokens.Space.s) { + HStack(spacing: UI.Layout.Spacing.s) { miniAction("Open", systemImage: "app") miniAction("Run", systemImage: "plus") { route(.runContainer) } miniAction("Activity", systemImage: unreadActivityCount > 0 ? "bell.badge" : "bell") { route(.activityHistory) } @@ -198,7 +195,7 @@ struct MenuBarContent: View { } private var footerRow: some View { - HStack(spacing: DesignTokens.Space.s) { + HStack(spacing: UI.Layout.Spacing.s) { Button("Open Contained") { activate() } Spacer(minLength: 0) Button("Quit") { NSApplication.shared.terminate(nil) } @@ -219,18 +216,6 @@ struct MenuBarContent: View { .buttonStyle(.borderless) } - @ViewBuilder - private func infoRow(_ title: String, value: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: DesignTokens.DesignCard.padding) { - Text(title) - .foregroundStyle(.secondary) - .frame(width: DesignTokens.MenuBar.titleWidth, alignment: .leading) - Text(value) - .foregroundStyle(.primary) - Spacer(minLength: 0) - } - } - @ViewBuilder private func disabledPlaceholder(_ text: String) -> some View { Button(text) { } @@ -239,9 +224,9 @@ struct MenuBarContent: View { @ViewBuilder private var statusItem: some View { - Label(app.serviceLabel, - systemImage: app.serviceHealthy ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") - .foregroundStyle(app.serviceHealthy ? .green : .secondary) + UI.State.InlineStatus(app.serviceLabel, + systemImage: app.serviceHealthy ? "checkmark.circle.fill" : "exclamationmark.triangle.fill", + tone: app.serviceHealthy ? .success : .neutral) } /// Bring the main window to the front. @@ -284,7 +269,7 @@ struct MenuBarContent: View { /// Reveal the resolved `container` binary in Finder (honoring the CLI-path override). private func revealCLIBinary() { - guard let url = AppleContainerCLILocator.locate(override: app.settings.cliPathOverride) else { return } + guard let url = app.client?.cliURL else { return } NSWorkspace.shared.activateFileViewerSelecting([url]) } } diff --git a/Sources/ContainedApp/Navigation/ClassicShell.swift b/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift similarity index 86% rename from Sources/ContainedApp/Navigation/ClassicShell.swift rename to Sources/ContainedApp/Navigation/Shell/ClassicShell.swift index 6b55656f..4ad28ade 100644 --- a/Sources/ContainedApp/Navigation/ClassicShell.swift +++ b/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift @@ -1,9 +1,8 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import SwiftData import ContainedCore -import ContainedRuntime struct ClassicShell: View { @Environment(AppModel.self) private var app @@ -42,15 +41,15 @@ struct ClassicShell: View { // The custom toolbar belongs to the detail column, not the whole split view. Mounting // it here keeps the sidebar outside the toolbar safe-area bands. AppToolbar() - .environment(\.morphSafeAreas, toolbarSafeAreas) + .environment(\.morphSafeAreaManager, toolbarSafeAreaManager) .ignoresSafeArea(.container, edges: .vertical) } } - .environment(\.morphSafeAreas, MorphSafeAreaManager(system: EdgeInsets())) + .environment(\.morphSafeAreaManager, UX.SafeArea.Manager(system: EdgeInsets())) } private var detailPage: some View { - let insets = toolbarSafeAreas.insets(MorphSafeAreaPolicy(excluding: .top, padding: .none)) + let insets = toolbarSafeAreaManager.insets(UX.SafeArea.Policy(excluding: .top, padding: .none)) return ClassicSectionPage(section: ui.selectedSection) .frame(maxWidth: .infinity, maxHeight: .infinity) // Body-only padding from the same custom safe-area measurer used by morph panels. @@ -58,8 +57,8 @@ struct ClassicShell: View { .ignoresSafeArea(.container, edges: .vertical) } - private var toolbarSafeAreas: MorphSafeAreaManager { - MorphSafeAreaManager(system: EdgeInsets(), + private var toolbarSafeAreaManager: UX.SafeArea.Manager { + UX.SafeArea.Manager(system: EdgeInsets(), topToolbarHeight: AppToolbar.bandHeight, bottomToolbarHeight: AppToolbar.bandHeight) } @@ -94,8 +93,7 @@ private struct AppSidebar: View { Spacer() if let badge = badge(for: section) { Text(badge) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) + .designSecondaryMonospacedDigitCaption() } } } icon: { @@ -224,24 +222,24 @@ private struct NetworksPage: View { PageScaffold(symbol: "network", title: AppText.sectionNetworks, subtitle: AppText.string("network.count", defaultValue: "\(sortedNetworks.count) network\(sortedNetworks.count == 1 ? "" : "s")")) { - DesignActionGroup(DesignAction(systemName: "plus", + UI.Action.Group(UI.Action.Item(systemName: "plus", title: AppText.string("common.new", defaultValue: "New"), help: AppText.string("network.newNetwork.lowercase", defaultValue: "New network")) { ui.dispatch(.createNetwork) }) } content: { if sortedNetworks.isEmpty { - ContentUnavailableView(AppText.string("network.empty", defaultValue: "No networks"), - systemImage: "network", - description: Text(AppText.string("network.empty.description", defaultValue: "Create or refresh container networks to see them here."))) - .frame(maxWidth: .infinity, minHeight: 280) + UI.State.Empty(AppText.string("network.empty", defaultValue: "No networks"), + systemImage: "network", + description: AppText.string("network.empty.description", defaultValue: "Create or refresh container networks to see them here."), + minHeight: 280) } else { - LazyVStack(spacing: DesignTokens.Space.s) { + LazyVStack(spacing: UI.Layout.Spacing.s) { ForEach(Array(networkSections.enumerated()), id: \.offset) { _, section in if ui.networkGrouping != .none { - DesignBadgeText(text: section.title, font: .caption.weight(.semibold)) + UI.Badge.Text(text: section.title, font: .caption.weight(.semibold)) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, DesignTokens.Space.xs) + .padding(.horizontal, UI.Layout.Spacing.xs) } ForEach(section.networks) { network in networkRow(network) @@ -262,28 +260,29 @@ private struct NetworksPage: View { } private func networkRow(_ network: NetworkResource) -> some View { - DesignCard(size: .medium, + UI.Card.Scaffold(size: .medium, elevated: false, title: network.name, subtitle: networkSubtitle(network)) { - DesignCardIconChip(symbol: network.isBuiltin ? "network.badge.shield.half.filled" : "network", + UI.Card.IconChip(symbol: network.isBuiltin ? "network.badge.shield.half.filled" : "network", tint: network.isBuiltin ? .secondary : .accentColor) } titleAccessory: { EmptyView() } subtitleAccessory: { EmptyView() } headerAccessory: { - DesignRowMenu(accessibilityLabel: AppText.string("menu.networkActions", defaultValue: "Network actions")) { + UI.Control.RowMenu(accessibilityLabel: AppText.string("menu.networkActions", defaultValue: "Network actions")) { networkMenu(network) } } bodyContent: { EmptyView() } footerLeading: { if network.isBuiltin { - DesignCardFooterMini { - Image(systemName: "network.badge.shield.half.filled").font(.caption2) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "network.badge.shield.half.filled", + size: .caption2) } text: { - DesignCardMetricText(text: "Built-in") + UI.Card.MetricText(text: "Built-in") } } } footerActions: { @@ -367,9 +366,9 @@ private struct ImagesPage: View { onClose: {}) if let detail, presented { - MorphingSingleSurfaceExpander(isPresented: detailBinding, + UX.Morph.SingleSurfaceExpander(isPresented: detailBinding, originFrame: usableSourceFrame ?? fallbackSourceFrame(in: viewport.size), - target: .anchored(size: DesignTokens.PanelSize.imageDetail, + target: .anchored(size: UI.Panel.Size.imageDetail, safeArea: imageDetailSafeAreaPolicy, margin: 16), backdropStyle: .dim, @@ -381,7 +380,7 @@ private struct ImagesPage: View { onTap: {}, onClose: closeDetail) } - .environment(\.morphSafeAreas, imageDetailSafeAreas) + .environment(\.morphSafeAreaManager, imageDetailSafeAreaManager) .zIndex(10) } } @@ -391,13 +390,13 @@ private struct ImagesPage: View { private let pageImageSpace = "imagesPage" - private var imageDetailSafeAreaPolicy: MorphSafeAreaPolicy { - ui.toolbarUIEnabled ? MorphSafeAreaPolicy(excluding: .both, padding: .small) : .content + private var imageDetailSafeAreaPolicy: UX.SafeArea.Policy { + ui.toolbarUIEnabled ? UX.SafeArea.Policy(excluding: .both, padding: .small) : .content } - private var imageDetailSafeAreas: MorphSafeAreaManager { - guard ui.toolbarUIEnabled else { return MorphSafeAreaManager(system: EdgeInsets()) } - return MorphSafeAreaManager(system: EdgeInsets(), + private var imageDetailSafeAreaManager: UX.SafeArea.Manager { + guard ui.toolbarUIEnabled else { return UX.SafeArea.Manager(system: EdgeInsets()) } + return UX.SafeArea.Manager(system: EdgeInsets(), topToolbarHeight: AppToolbar.bandHeight, bottomToolbarHeight: AppToolbar.bandHeight) } diff --git a/Sources/ContainedApp/Navigation/RootView.swift b/Sources/ContainedApp/Navigation/Shell/RootView.swift similarity index 90% rename from Sources/ContainedApp/Navigation/RootView.swift rename to Sources/ContainedApp/Navigation/Shell/RootView.swift index 53d67dbf..648033b4 100644 --- a/Sources/ContainedApp/Navigation/RootView.swift +++ b/Sources/ContainedApp/Navigation/Shell/RootView.swift @@ -1,9 +1,9 @@ import SwiftUI -import ContainedNavigation +import ContainedUX import AppKit import UniformTypeIdentifiers import ContainedCore -import ContainedDesignSystem +import ContainedUI /// The app shell. Fresh installs use the classic sidebar; the experimental toolbar shell adds morph /// panels, command palette routing, and page-background overflow actions on top of the same app state. @@ -23,8 +23,9 @@ struct RootView: View { @Bindable var settings = app.settings @Bindable var ui = ui rootShell(settings: settings) - .sheet(isPresented: $ui.showRunSheet, onDismiss: { ui.prefillSpec = nil; ui.advancePrefillQueue() }) { - ContainerEditSheet(mode: .new(prefill: ui.prefillSpec)) + .sheet(isPresented: $ui.prefill.showRunSheet, + onDismiss: { ui.prefill.currentSpec = nil; ui.advancePrefillQueue() }) { + ContainerEditSheet(mode: .new(prefill: ui.prefill.currentSpec)) } .sheet(item: $ui.editSheetSnapshot) { snapshot in ContainerEditSheet(mode: .edit(snapshot, onComplete: {})) @@ -73,17 +74,17 @@ struct RootView: View { return false } // Long-running operations (image pulls, etc.) now surface in the bottom-left status capsule - // (see `AppToolbar` → `ActivityStatusView`); only transient banners float at the bottom. + // (see `AppToolbar` → `UI.State.ActivityStatusIndicator`); only transient banners float at the bottom. .overlay(alignment: .bottom) { bannerView - .padding(.bottom, DesignTokens.Space.l) + .padding(.bottom, UI.Layout.Spacing.l) } .animation(reduceMotion ? nil : .smooth(duration: 0.25), value: app.banner) .animation(reduceMotion ? nil : .smooth(duration: 0.25), value: app.activity) .tint(settings.accentTint.color) .environment(\.modalMaterial, settings.modalMaterial) .environment(\.buttonMaterial, settings.buttonMaterial) - .environment(\.buttonTintStyle, DesignButtonTintStyle(enabled: settings.buttonTintEnabled, + .environment(\.buttonTintStyle, UI.Theme.ButtonTintStyle(enabled: settings.buttonTintEnabled, tint: settings.buttonTint, opacity: settings.buttonTintOpacity, gradient: settings.buttonTintGradient, @@ -105,18 +106,18 @@ struct RootView: View { .onChange(of: settings.experimentalToolbarUI) { _, enabled in ui.toolbarUIEnabled = enabled ui.panelNavigationEnabled = settings.usesPanelNavigation - if !enabled { ui.activeMorph = nil } + if !enabled { ui.toolbar.activeMorph = nil } ui.ensureSelectedSectionIsNavigable() updateContainerStatsVisibility() } .onChange(of: settings.experimentalPanelNavigation) { _, _ in ui.panelNavigationEnabled = settings.usesPanelNavigation - if !settings.usesPanelNavigation { ui.activeMorph = nil } + if !settings.usesPanelNavigation { ui.toolbar.activeMorph = nil } ui.ensureSelectedSectionIsNavigable() updateContainerStatsVisibility() } .onChange(of: ui.selectedSection) { _, _ in updateContainerStatsVisibility() } - .onChange(of: ui.activeMorph) { _, _ in updateContainerStatsVisibility() } + .onChange(of: ui.toolbar.activeMorph) { _, _ in updateContainerStatsVisibility() } .onChange(of: settings.imageBuildEnabled) { _, enabled in if !enabled, ui.selectedSection == .build { ui.navigate(to: .images) @@ -143,7 +144,7 @@ struct RootView: View { private func toolbarShell(settings: SettingsStore) -> some View { GeometryReader { _ in ZStack { - DesignContentBackgroundLayer(material: settings.windowMaterial.nsMaterial) + UI.Theme.BackgroundLayer(material: settings.windowMaterial.nsMaterial) toolbarContent } } @@ -158,11 +159,11 @@ struct RootView: View { private func classicShell(settings: SettingsStore) -> some View { ZStack { - DesignContentBackgroundLayer(material: settings.windowMaterial.nsMaterial) + UI.Theme.BackgroundLayer(material: settings.windowMaterial.nsMaterial) content .ignoresSafeArea(.container, edges: .vertical) } - .environment(\.morphSafeAreas, MorphSafeAreaManager(system: EdgeInsets())) + .environment(\.morphSafeAreaManager, UX.SafeArea.Manager(system: EdgeInsets())) .contextMenu { backgroundMenu() } } @@ -185,7 +186,7 @@ struct RootView: View { Divider() Toggle(isOn: $ui.runningOnly) { Label("Show Running Only", systemImage: "play.circle") } Picker(selection: $settings.density) { - ForEach(CardDensity.allCases) { Text($0.localizedDisplayName).tag($0) } + ForEach(UI.Card.Density.allCases) { Text($0.localizedDisplayName).tag($0) } } label: { Label("Card Size", systemImage: "square.grid.2x2") } Divider() Button { openSectionOrMorph(.images, morph: .updates) } label: { Label("Images", systemImage: "square.stack.3d.up") } @@ -207,7 +208,7 @@ struct RootView: View { } private func updateContainerStatsVisibility() { - app.setContainerStatsVisible(ui.selectedSection == .containers && ui.activeMorph == nil) + app.setContainerStatsVisible(ui.selectedSection == .containers && ui.toolbar.activeMorph == nil) } private func openSectionOrMorph(_ section: AppSection, morph: UIState.ToolbarMorph) { @@ -221,7 +222,7 @@ struct RootView: View { /// Force (or release, for `.system`) the app-wide AppKit appearance. Setting `NSApplication.appearance` /// directly — rather than relying only on `.preferredColorScheme` — makes "System" re-sync to the /// live OS theme even after the app was pinned to Light/Dark. - private func applyAppearance(_ mode: AppearanceMode) { + private func applyAppearance(_ mode: UI.Theme.Appearance) { NSApplication.shared.appearance = mode.nsAppearance } @@ -239,7 +240,7 @@ struct RootView: View { private func pruneImages(all: Bool) async { guard let client = app.client else { return } if let error = await app.captured({ _ = try await client.pruneImages(all: all) }) { app.flash(error) } - await app.refreshImagesIfStale(force: true) + await app.refreshImagesIfNeeded(force: true) } /// Toggle the front window between its zoomed (filled) and restored size — emulates the @@ -251,7 +252,7 @@ struct RootView: View { @ViewBuilder private var bannerView: some View { if let banner = app.banner { - DesignStatusBanner(banner) + UI.State.Banner(banner) .transition(.move(edge: .bottom).combined(with: .opacity)) } } diff --git a/Sources/ContainedApp/Navigation/AppSection.swift b/Sources/ContainedApp/Navigation/State/AppSection.swift similarity index 100% rename from Sources/ContainedApp/Navigation/AppSection.swift rename to Sources/ContainedApp/Navigation/State/AppSection.swift diff --git a/Sources/ContainedApp/Navigation/PendingAction.swift b/Sources/ContainedApp/Navigation/State/PendingAction.swift similarity index 100% rename from Sources/ContainedApp/Navigation/PendingAction.swift rename to Sources/ContainedApp/Navigation/State/PendingAction.swift diff --git a/Sources/ContainedApp/Navigation/UIState.swift b/Sources/ContainedApp/Navigation/State/UIState.swift similarity index 72% rename from Sources/ContainedApp/Navigation/UIState.swift rename to Sources/ContainedApp/Navigation/State/UIState.swift index d852e45a..fe25ebdd 100644 --- a/Sources/ContainedApp/Navigation/UIState.swift +++ b/Sources/ContainedApp/Navigation/State/UIState.swift @@ -23,8 +23,7 @@ final class UIState { } struct SearchPresentation { - /// Filter text applied by the section list views. The in-window search affordance is being - /// reworked; until it returns this stays empty (so every filter is a no-op pass-through). + /// Filter text applied by section list views and the command palette. var text = "" var pageResultCount: Int? var paletteIndex = 0 @@ -72,88 +71,6 @@ final class UIState { var pendingAction: PendingAction? var editSheetSnapshot: ContainerSnapshot? - // MARK: Compatibility accessors - - var searchText: String { - get { search.text } - set { search.text = newValue } - } - - var showRunSheet: Bool { - get { prefill.showRunSheet } - set { prefill.showRunSheet = newValue } - } - - var creationEntry: CreationEntry { - get { creation.entry } - set { creation.entry = newValue } - } - - var creationPrefillSpec: RunSpec? { - get { creation.prefillSpec } - set { creation.prefillSpec = newValue } - } - - var creationEditSnapshot: ContainerSnapshot? { - get { creation.editSnapshot } - set { creation.editSnapshot = newValue } - } - - var creationSearchQuery: String { - get { creation.searchQuery } - set { creation.searchQuery = newValue } - } - - var creationReturnEntry: CreationEntry? { - get { creation.returnEntry } - set { creation.returnEntry = newValue } - } - - private(set) var creationRequestToken: Int { - get { creation.requestToken } - set { creation.requestToken = newValue } - } - - var activeMorph: ToolbarMorph? { - get { toolbar.activeMorph } - set { toolbar.activeMorph = newValue } - } - - private(set) var morphCloseRequestToken: Int { - get { toolbar.closeRequestToken } - set { toolbar.closeRequestToken = newValue } - } - - var pageResultCount: Int? { - get { search.pageResultCount } - set { search.pageResultCount = newValue } - } - - var paletteIndex: Int { - get { search.paletteIndex } - set { search.paletteIndex = newValue } - } - - var paletteScope: PaletteScope? { - get { search.scope } - set { search.scope = newValue } - } - - var prefillSpec: RunSpec? { - get { prefill.currentSpec } - set { prefill.currentSpec = newValue } - } - - var prefillQueue: [RunSpec] { - get { prefill.queue } - set { prefill.queue = newValue } - } - - private(set) var searchFocusToken: Int { - get { search.focusToken } - set { search.focusToken = newValue } - } - // MARK: Actions /// Open the Settings panel and navigate to a specific page in one call. @@ -163,12 +80,12 @@ final class UIState { navigate(to: .settings) return } - if activeMorph != .settings { activeMorph = .settings } + if toolbar.activeMorph != .settings { toolbar.activeMorph = .settings } } func navigate(to section: AppSection) { selectedSection = section - if activeMorph != nil { requestMorphClose() } + if toolbar.activeMorph != nil { requestMorphClose() } } func ensureSelectedSectionIsNavigable() { @@ -206,16 +123,16 @@ final class UIState { /// Toggle a toolbar morph panel (open it, or close it if already open). func toggleMorph(_ morph: ToolbarMorph) { - if activeMorph == morph { + if toolbar.activeMorph == morph { requestMorphClose(morph) } else { - activeMorph = morph + toolbar.activeMorph = morph } } func requestMorphClose(_ morph: ToolbarMorph? = nil) { - guard let activeMorph, morph == nil || activeMorph == morph else { return } - morphCloseRequestToken &+= 1 + guard let activeMorph = toolbar.activeMorph, morph == nil || activeMorph == morph else { return } + toolbar.closeRequestToken &+= 1 } /// Run an action by opening the right creation page, morph panel, or global sheet. @@ -254,7 +171,7 @@ final class UIState { case .build: openCreationPanel(entry: .build) case .activityHistory: - activeMorph = .activity + toolbar.activeMorph = .activity case .loadImage, .pruneImages, .systemLogs: pendingAction = action case .registryLogin: @@ -282,13 +199,13 @@ final class UIState { } return } - creationEntry = entry - creationPrefillSpec = spec - creationEditSnapshot = nil - creationReturnEntry = returnEntry - creationSearchQuery = searchQuery - creationRequestToken &+= 1 - activeMorph = .add + creation.entry = entry + creation.prefillSpec = spec + creation.editSnapshot = nil + creation.returnEntry = returnEntry + creation.searchQuery = searchQuery + creation.requestToken &+= 1 + toolbar.activeMorph = .add } func openCreationPanel(prefill spec: RunSpec, @@ -306,23 +223,23 @@ final class UIState { editSheetSnapshot = snapshot return } - creationEntry = .configure - creationPrefillSpec = nil - creationEditSnapshot = snapshot - creationReturnEntry = nil - creationRequestToken &+= 1 - activeMorph = .add + creation.entry = .configure + creation.prefillSpec = nil + creation.editSnapshot = snapshot + creation.returnEntry = nil + creation.requestToken &+= 1 + toolbar.activeMorph = .add } /// Bumped by Cmd-F to focus the toolbar page-search field (without opening the command palette). func focusSearch() { - if activeMorph != nil { + if toolbar.activeMorph != nil { requestMorphClose() DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { [weak self] in - self?.searchFocusToken &+= 1 + self?.search.focusToken &+= 1 } } else { - searchFocusToken &+= 1 + search.focusToken &+= 1 } } @@ -335,7 +252,7 @@ final class UIState { presentCreate(spec) return } - prefillQueue = [] + prefill.queue = [] openCreationPanel(prefill: spec, returningTo: returnEntry, searchQuery: searchQuery) } @@ -344,14 +261,14 @@ final class UIState { presentCreate(spec) return } - prefillQueue = [] + prefill.queue = [] openCreationPanel(prefill: spec) } /// Open the New-Container window prefilled with `spec`. func presentCreate(_ spec: RunSpec) { - prefillSpec = spec - showRunSheet = true + prefill.currentSpec = spec + prefill.showRunSheet = true } /// Open the New-Container window for each queued spec in turn (compose import). Pulls each image @@ -359,7 +276,7 @@ final class UIState { /// editor is the creation panel when panel navigation is enabled, otherwise the classic sheet. func beginPrefillQueue(_ specs: [RunSpec], using app: AppModel) { guard let first = specs.first else { return } - prefillQueue = Array(specs.dropFirst()) + prefill.queue = Array(specs.dropFirst()) Task { for spec in specs { _ = await app.ensureImage(spec.image) } presentNextPrefill(first) @@ -368,8 +285,8 @@ final class UIState { /// Advance to the next queued prefill when a New-Container editor closes. No-op when drained. func advancePrefillQueue() { - guard !prefillQueue.isEmpty else { return } - let next = prefillQueue.removeFirst() + guard !prefill.queue.isEmpty else { return } + let next = prefill.queue.removeFirst() // Re-present on the next runloop so the previous sheet/panel finishes dismissing first. DispatchQueue.main.async { self.presentNextPrefill(next) } } diff --git a/Sources/ContainedApp/Navigation/AppToolbar.swift b/Sources/ContainedApp/Navigation/Toolbar/AppToolbar.swift similarity index 68% rename from Sources/ContainedApp/Navigation/AppToolbar.swift rename to Sources/ContainedApp/Navigation/Toolbar/AppToolbar.swift index b5d71f77..997fe627 100644 --- a/Sources/ContainedApp/Navigation/AppToolbar.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/AppToolbar.swift @@ -1,6 +1,6 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import SwiftData import ContainedCore @@ -12,12 +12,12 @@ import ContainedCore /// region, the bottom band floats above the detail body, and the sidebar stays outside the custom /// toolbar safe-area contract. /// The add `+`, search field, and bottom toolbar controls all grow through the same -/// `MorphingExpander` shell from their measured toolbar slots. Control sizing and source radius come -/// from `DesignTokens.Toolbar` / `ToolbarControls`. +/// `UX.Morph.Expander` shell from their measured toolbar slots. Control sizing and source radius come +/// from `UI.Toolbar` / `UI.Toolbar controls`. struct AppToolbar: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - @Environment(\.morphSafeAreas) private var safeAreas + @Environment(\.morphSafeAreaManager) private var safeAreaManager @State private var slots: [UIState.ToolbarMorph: CGRect] = [:] @State private var addSoftDismiss: (() -> Void)? @@ -29,9 +29,9 @@ struct AppToolbar: View { static let space = "appToolbar" /// Title-bar band height. The toolbar lives in the detail column (no traffic lights there), so the - /// leading inset is just normal padding. Sourced from `DesignTokens.Toolbar` so the band, the safe-area + /// leading inset is just normal padding. Sourced from `UI.Toolbar` so the band, the safe-area /// manager, and the controls all agree. - static let bandHeight: CGFloat = DesignTokens.Toolbar.band + static let bandHeight: CGFloat = UI.Toolbar.Size.band var body: some View { ZStack(alignment: .top) { @@ -39,35 +39,35 @@ struct AppToolbar: View { .zIndex(40) VStack(spacing: 0) { topToolbarRow - .frame(height: DesignTokens.Toolbar.controlHeight) + .frame(height: UI.Toolbar.Size.controlHeight) .padding(.top, rowTopInset) // centered on the traffic-light line Spacer(minLength: 0) .allowsHitTesting(false) bottomToolbarRow - .frame(height: DesignTokens.Toolbar.controlHeight) + .frame(height: UI.Toolbar.Size.controlHeight) .padding(.bottom, bottomRowInset) } .zIndex(100) addMorphLayer - .zIndex(ui.activeMorph == .add ? 300 : 0) + .zIndex(ui.toolbar.activeMorph == .add ? 300 : 0) paletteMorphLayer - .zIndex(ui.activeMorph == .palette ? 300 : 0) + .zIndex(ui.toolbar.activeMorph == .palette ? 300 : 0) updatesMorphLayer - .zIndex(ui.activeMorph == .updates ? 300 : 0) + .zIndex(ui.toolbar.activeMorph == .updates ? 300 : 0) activityMorphLayer - .zIndex(ui.activeMorph == .activity ? 300 : 0) + .zIndex(ui.toolbar.activeMorph == .activity ? 300 : 0) templatesMorphLayer - .zIndex(ui.activeMorph == .templates ? 300 : 0) + .zIndex(ui.toolbar.activeMorph == .templates ? 300 : 0) systemMorphLayer - .zIndex(ui.activeMorph == .system ? 300 : 0) + .zIndex(ui.toolbar.activeMorph == .system ? 300 : 0) settingsMorphLayer - .zIndex(ui.activeMorph == .settings ? 300 : 0) + .zIndex(ui.toolbar.activeMorph == .settings ? 300 : 0) toolbarImageDetailLayer .zIndex(toolbarImageDetail == nil ? 0 : 350) } .coordinateSpace(.named(Self.space)) - .onPreferenceChange(MorphSourceFramesKey.self) { updateSlots($0) } - .onChange(of: ui.activeMorph) { _, morph in + .onPreferenceChange(UX.Measurement.SourceFramesKey.self) { updateSlots($0) } + .onChange(of: ui.toolbar.activeMorph) { _, morph in if morph == nil { setMorphBackdropExpanded(false) } } } @@ -76,7 +76,7 @@ struct AppToolbar: View { return Color.clear .globalBackdrop(style: .dim, progress: morphBackdropExpanded ? 1 : 0, dimOpacity: 0.28) .contentShape(Rectangle()) - .allowsHitTesting(ui.activeMorph != nil) + .allowsHitTesting(ui.toolbar.activeMorph != nil) .onTapGesture { backdropTapped() } .animation(.spring(response: 0.42, dampingFraction: 0.86), value: morphBackdropExpanded) } @@ -84,17 +84,17 @@ struct AppToolbar: View { // MARK: Top Row private var topToolbarRow: some View { - HStack(spacing: DesignTokens.Toolbar.groupSpacing) { + HStack(spacing: UI.Toolbar.Spacing.groupSpacing) { if !isSidebarOpen { settingsZone ToolbarPageSwitcher() } ToolbarPageContextOptions() - Spacer(minLength: DesignTokens.Space.m) + Spacer(minLength: UI.Layout.Spacing.m) searchZone } - .padding(.leading, DesignTokens.Toolbar.outerPadding) - .padding(.trailing, DesignTokens.Toolbar.outerPadding) + .padding(.leading, UI.Toolbar.Spacing.outerPadding) + .padding(.trailing, UI.Toolbar.Spacing.outerPadding) .frame(maxWidth: .infinity) } @@ -102,8 +102,8 @@ struct AppToolbar: View { /// it's vanity chrome that owns the `.settings` morph slot so the Settings panel (opened via ⌘, or /// the menu) has a frame to grow from. private var settingsZone: some View { - DesignToolbarVanitySlot() - .opacity(ui.activeMorph == .settings ? 0 : 1) + UI.Toolbar.VanitySlot() + .opacity(ui.toolbar.activeMorph == .settings ? 0 : 1) .background(singleSlotReader(.settings)) } @@ -113,8 +113,8 @@ struct AppToolbar: View { private var searchZone: some View { ToolbarSearchSource() - .frame(width: DesignTokens.Toolbar.searchMaxWidth, height: DesignTokens.Toolbar.controlHeight) - .opacity(ui.activeMorph == .palette ? 0 : 1) + .frame(width: UI.Toolbar.Size.searchMaxWidth, height: UI.Toolbar.Size.controlHeight) + .opacity(ui.toolbar.activeMorph == .palette ? 0 : 1) .background(singleSlotReader(.palette)) } @@ -122,12 +122,12 @@ struct AppToolbar: View { private var paletteMorphLayer: some View { // Render-level backstop: with the experimental palette disabled, never present it even if some // activation path slips through. Keeps the gate airtight from a single place. - if ui.activeMorph == .palette, app.settings.commandPaletteEnabled { - MorphingExpander(isPresented: paletteMorphBinding, + if ui.toolbar.activeMorph == .palette, app.settings.commandPaletteEnabled { + UX.Morph.Expander(isPresented: paletteMorphBinding, originFrame: slots[.palette] ?? .zero, - target: toolbarMorphTarget(for: .palette, size: DesignTokens.PanelSize.palette), + target: toolbarMorphTarget(for: .palette, size: UI.Panel.Size.palette), showsBackdrop: false, - closeRequestToken: ui.morphCloseRequestToken, + closeRequestToken: ui.toolbar.closeRequestToken, onExpansionChange: setMorphBackdropExpanded) { ToolbarCommandPalette { ui.requestMorphClose(.palette) } } @@ -137,49 +137,48 @@ struct AppToolbar: View { // MARK: Bottom Row private var bottomToolbarRow: some View { - HStack(spacing: DesignTokens.Toolbar.groupSpacing) { + HStack(spacing: UI.Toolbar.Spacing.groupSpacing) { systemStatusButton ToolbarPageFilterOptions() - Spacer(minLength: DesignTokens.Space.m) + Spacer(minLength: UI.Layout.Spacing.m) bottomActionGroup } - .padding(.horizontal, DesignTokens.Toolbar.outerPadding) + .padding(.horizontal, UI.Toolbar.Spacing.outerPadding) .frame(maxWidth: .infinity) } private var systemStatusButton: some View { - DesignToolbarStatusButton(help: app.activity?.title ?? "System \(app.serviceLabel)", + UI.Toolbar.StatusButton(help: app.activity?.title ?? "System \(app.serviceLabel)", action: { openGlobalSectionOrPanel(.system, morph: .system) }) { if let activity = app.activity { - ActivityStatusView(activity: ActivityStatusPresentation(title: activity.title, + UI.State.ActivityStatusIndicator(activity: UI.State.ActivityStatus(title: activity.title, detail: activity.detail, fraction: activity.fraction), style: .inline) } else { - HStack(spacing: DesignTokens.Toolbar.searchIconGap) { - Image(systemName: systemStatusIcon) - .foregroundStyle(systemStatusColor) - .frame(width: DesignTokens.Toolbar.iconContentWidth) - Text(app.serviceLabel) - .foregroundStyle(.secondary) - .padding(.trailing, DesignTokens.Toolbar.statusLabelTrailingPadding) + HStack(spacing: UI.Toolbar.Spacing.searchIconGap) { + UI.Symbol.Image(systemName: systemStatusIcon, + tone: systemStatusTone, + frameWidth: UI.Toolbar.Size.iconContentWidth) + UI.State.StatusText(app.serviceLabel) + .padding(.trailing, UI.Toolbar.Placement.statusLabelTrailingPadding) } } } - .opacity(ui.activeMorph == .system ? 0 : 1) + .opacity(ui.toolbar.activeMorph == .system ? 0 : 1) .background(singleSlotReader(.system)) .animation(.spring(response: 0.4, dampingFraction: 0.85), value: app.activity != nil) } private var bottomActionGroup: some View { - HStack(spacing: DesignTokens.Toolbar.groupSpacing) { - DesignToolbarActionCluster { - DesignActionItems([ - DesignAction(systemName: "plus", help: AppText.add) { ui.openCreationPanel() }, - DesignAction(systemName: "shippingbox", help: AppText.string("section.images", defaultValue: "Images")) { + HStack(spacing: UI.Toolbar.Spacing.groupSpacing) { + UI.Toolbar.ActionCluster { + UI.Action.Items([ + UI.Action.Item(systemName: "plus", help: AppText.add) { ui.openCreationPanel() }, + UI.Action.Item(systemName: "shippingbox", help: AppText.string("section.images", defaultValue: "Images")) { openGlobalSectionOrPanel(.images, morph: .updates) }, - DesignAction(systemName: "bookmark", help: AppText.string("section.templates", defaultValue: "Templates")) { + UI.Action.Item(systemName: "bookmark", help: AppText.string("section.templates", defaultValue: "Templates")) { openGlobalSectionOrPanel(.templates, morph: .templates) } ]) @@ -194,42 +193,42 @@ struct AppToolbar: View { @ViewBuilder private var addMorphLayer: some View { - if ui.activeMorph == .add { - MorphingExpander(isPresented: addMorphBinding, originFrame: slots[.add] ?? .zero, - target: toolbarMorphTarget(for: .add, size: DesignTokens.PanelSize.add), + if ui.toolbar.activeMorph == .add { + UX.Morph.Expander(isPresented: addMorphBinding, originFrame: slots[.add] ?? .zero, + target: toolbarMorphTarget(for: .add, size: UI.Panel.Size.add), showsBackdrop: false, - closeRequestToken: ui.morphCloseRequestToken, + closeRequestToken: ui.toolbar.closeRequestToken, onBackdropTap: addSoftDismiss, onExpansionChange: setMorphBackdropExpanded) { - CreationFlow(start: CreationFlow.Start(ui.creationEntry), + CreationFlow(start: CreationFlow.Start(ui.creation.entry), onClose: { addSoftDismiss = nil - ui.creationPrefillSpec = nil - ui.creationEditSnapshot = nil - ui.creationReturnEntry = nil + ui.creation.prefillSpec = nil + ui.creation.editSnapshot = nil + ui.creation.returnEntry = nil ui.requestMorphClose(.add) DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) { ui.advancePrefillQueue() } }, - prefill: ui.creationPrefillSpec, - editSnapshot: ui.creationEditSnapshot, - searchQuery: ui.creationSearchQuery, - returnEntry: ui.creationReturnEntry, + prefill: ui.creation.prefillSpec, + editSnapshot: ui.creation.editSnapshot, + searchQuery: ui.creation.searchQuery, + returnEntry: ui.creation.returnEntry, onSoftDismissChange: { addSoftDismiss = $0 }) - .id(ui.creationRequestToken) + .id(ui.creation.requestToken) } } } @ViewBuilder private var updatesMorphLayer: some View { - if ui.activeMorph == .updates { - MorphingExpander(isPresented: morphBinding(.updates), + if ui.toolbar.activeMorph == .updates { + UX.Morph.Expander(isPresented: morphBinding(.updates), originFrame: slots[.updates] ?? .zero, - target: toolbarMorphTarget(for: .updates, size: DesignTokens.PanelSize.images), + target: toolbarMorphTarget(for: .updates, size: UI.Panel.Size.images), showsBackdrop: false, - closeRequestToken: ui.morphCloseRequestToken, + closeRequestToken: ui.toolbar.closeRequestToken, onExpansionChange: setMorphBackdropExpanded) { ToolbarUpdatesPanel(hiddenImageGroupID: toolbarImageDetailPresented ? toolbarImageDetail?.id : nil, onOpenImage: openToolbarImageDetail) { @@ -242,7 +241,7 @@ struct AppToolbar: View { @ViewBuilder private var toolbarImageDetailLayer: some View { if let detail = toolbarImageDetail, toolbarImageDetailPresented { - MorphingSingleSurfaceExpander(isPresented: toolbarImageDetailBinding, + UX.Morph.SingleSurfaceExpander(isPresented: toolbarImageDetailBinding, originFrame: usableToolbarImageSource ?? .zero, target: .anchored(size: toolbarImageDetailSize, safeArea: toolbarMorphSafeArea(for: .updates), @@ -261,12 +260,12 @@ struct AppToolbar: View { @ViewBuilder private var activityMorphLayer: some View { - if ui.activeMorph == .activity { - MorphingExpander(isPresented: morphBinding(.activity), + if ui.toolbar.activeMorph == .activity { + UX.Morph.Expander(isPresented: morphBinding(.activity), originFrame: slots[.activity] ?? .zero, - target: toolbarMorphTarget(for: .activity, size: DesignTokens.PanelSize.activity), + target: toolbarMorphTarget(for: .activity, size: UI.Panel.Size.activity), showsBackdrop: false, - closeRequestToken: ui.morphCloseRequestToken, + closeRequestToken: ui.toolbar.closeRequestToken, onExpansionChange: setMorphBackdropExpanded) { ToolbarActivityPanel { ui.requestMorphClose(.activity) @@ -277,12 +276,12 @@ struct AppToolbar: View { @ViewBuilder private var templatesMorphLayer: some View { - if ui.activeMorph == .templates { - MorphingExpander(isPresented: morphBinding(.templates), + if ui.toolbar.activeMorph == .templates { + UX.Morph.Expander(isPresented: morphBinding(.templates), originFrame: slots[.templates] ?? .zero, - target: toolbarMorphTarget(for: .templates, size: DesignTokens.PanelSize.templates), + target: toolbarMorphTarget(for: .templates, size: UI.Panel.Size.templates), showsBackdrop: false, - closeRequestToken: ui.morphCloseRequestToken, + closeRequestToken: ui.toolbar.closeRequestToken, onExpansionChange: setMorphBackdropExpanded) { ToolbarTemplatesPanel { ui.requestMorphClose(.templates) @@ -293,12 +292,12 @@ struct AppToolbar: View { @ViewBuilder private var systemMorphLayer: some View { - if ui.activeMorph == .system { - MorphingExpander(isPresented: morphBinding(.system), + if ui.toolbar.activeMorph == .system { + UX.Morph.Expander(isPresented: morphBinding(.system), originFrame: slots[.system] ?? .zero, - target: toolbarMorphTarget(for: .system, size: DesignTokens.PanelSize.system), + target: toolbarMorphTarget(for: .system, size: UI.Panel.Size.system), showsBackdrop: false, - closeRequestToken: ui.morphCloseRequestToken, + closeRequestToken: ui.toolbar.closeRequestToken, onExpansionChange: setMorphBackdropExpanded) { ToolbarSystemPanel { ui.requestMorphClose(.system) } } @@ -307,12 +306,12 @@ struct AppToolbar: View { @ViewBuilder private var settingsMorphLayer: some View { - if ui.activeMorph == .settings { - MorphingExpander(isPresented: morphBinding(.settings), + if ui.toolbar.activeMorph == .settings { + UX.Morph.Expander(isPresented: morphBinding(.settings), originFrame: slots[.settings] ?? .zero, - target: toolbarMorphTarget(for: .settings, size: DesignTokens.PanelSize.settings), + target: toolbarMorphTarget(for: .settings, size: UI.Panel.Size.settings), showsBackdrop: false, - closeRequestToken: ui.morphCloseRequestToken, + closeRequestToken: ui.toolbar.closeRequestToken, onExpansionChange: setMorphBackdropExpanded) { ToolbarSettingsPanel { ui.requestMorphClose(.settings) } } @@ -320,29 +319,29 @@ struct AppToolbar: View { } private var addMorphBinding: Binding { - Binding(get: { ui.activeMorph == .add }, set: { - if !$0 { addSoftDismiss = nil; ui.activeMorph = nil } + Binding(get: { ui.toolbar.activeMorph == .add }, set: { + if !$0 { addSoftDismiss = nil; ui.toolbar.activeMorph = nil } }) } private func morphBinding(_ morph: UIState.ToolbarMorph) -> Binding { - Binding(get: { ui.activeMorph == morph }, set: { - if !$0 { ui.activeMorph = nil } + Binding(get: { ui.toolbar.activeMorph == morph }, set: { + if !$0 { ui.toolbar.activeMorph = nil } }) } private var paletteMorphBinding: Binding { - Binding(get: { ui.activeMorph == .palette }, set: { + Binding(get: { ui.toolbar.activeMorph == .palette }, set: { if !$0 { - ui.searchText = "" - ui.paletteScope = nil - ui.activeMorph = nil + ui.search.text = "" + ui.search.scope = nil + ui.toolbar.activeMorph = nil } }) } private func backdropTapped() { - if ui.activeMorph == .add, let addSoftDismiss { + if ui.toolbar.activeMorph == .add, let addSoftDismiss { addSoftDismiss() } else { ui.requestMorphClose() @@ -372,27 +371,27 @@ struct AppToolbar: View { /// Report one shared frame (the cluster capsule) as the morph origin for several morphs at once. private func clusterSlotReader(_ morphs: [UIState.ToolbarMorph]) -> some View { - MorphSourceFrameReader(morphs, coordinateSpaceName: Self.space) + UX.Measurement.SourceFrameReader(morphs, coordinateSpaceName: Self.space) } private func singleSlotReader(_ morph: UIState.ToolbarMorph) -> some View { - MorphSourceFrameReader(morph, coordinateSpaceName: Self.space) + UX.Measurement.SourceFrameReader(morph, coordinateSpaceName: Self.space) } /// Safe area for a morph panel. Bottom-row panels clear the top toolbar; top-row panels clear the /// bottom. Settings is special: it grows from the vanity slot behind the traffic lights and must /// clear *both* bands so the panel starts fully below the native titlebar chrome. - private func toolbarMorphSafeArea(for morph: UIState.ToolbarMorph) -> MorphSafeAreaPolicy { + private func toolbarMorphSafeArea(for morph: UIState.ToolbarMorph) -> UX.SafeArea.Policy { switch morph { - case .settings: MorphSafeAreaPolicy(excluding: .both, padding: .small) - case .palette: MorphSafeAreaPolicy(excluding: .bottom, padding: .small) - default: MorphSafeAreaPolicy(excluding: .top, padding: .small) + case .settings: UX.SafeArea.Policy(excluding: .both, padding: .small) + case .palette: UX.SafeArea.Policy(excluding: .bottom, padding: .small) + default: UX.SafeArea.Policy(excluding: .top, padding: .small) } } private func toolbarMorphTarget(for morph: UIState.ToolbarMorph, size: CGSize, - placement: MorphPanelPlacement = .anchored) -> MorphTarget { + placement: UX.Panel.Placement = .anchored) -> UX.Morph.Target { let safeArea = toolbarMorphSafeArea(for: morph) switch placement { case .anchored: @@ -402,22 +401,22 @@ struct AppToolbar: View { } } - private var rowTopInset: CGFloat { DesignTokens.Toolbar.topPadding } + private var rowTopInset: CGFloat { UI.Toolbar.Spacing.topPadding } private var bottomRowInset: CGFloat { - max(DesignTokens.Toolbar.outerPadding, safeAreas.system.bottom + DesignTokens.Toolbar.outerPadding) + max(UI.Toolbar.Spacing.outerPadding, safeAreaManager.system.bottom + UI.Toolbar.Spacing.outerPadding) } - private var systemStatusColor: Color { + private var systemStatusTone: UI.State.Tone { switch app.serviceLabel { case "Running": - .green + .success case "Checking…": - .blue + .info case "Stopped": - .orange + .warning default: - .red + .error } } @@ -435,7 +434,7 @@ struct AppToolbar: View { } private var isBottomGroupMorphActive: Bool { - switch ui.activeMorph { + switch ui.toolbar.activeMorph { case .add, .updates, .templates, .activity: true case .palette, .system, .settings, nil: @@ -453,7 +452,7 @@ struct AppToolbar: View { } private var toolbarImageDetailSize: CGSize { - DesignTokens.PanelSize.imageDetail + UI.Panel.Size.imageDetail } private func currentToolbarImageGroup(_ group: LocalImageTagGroup) -> LocalImageTagGroup { @@ -489,8 +488,8 @@ private struct ActivityToolbarButton: View { var body: some View { let count = unread.count let hasUnread = count > 0 - return DesignActionItems([ - DesignAction(systemName: hasUnread ? "bell.fill" : "bell", + return UI.Action.Items([ + UI.Action.Item(systemName: hasUnread ? "bell.fill" : "bell", help: hasUnread ? "Activity — \(count) unread" : "Activity", tint: hasUnread ? app.settings.accentTint.color : .white) { if ui.panelNavigationEnabled { diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/PaletteResultCard.swift similarity index 68% rename from Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift rename to Sources/ContainedApp/Navigation/Toolbar/Panels/PaletteResultCard.swift index 0a58b7af..a57631d0 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/PaletteResultCard.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import AppKit import ContainedCore @@ -39,21 +39,21 @@ struct PaletteResultCard: View { } private var plainCard: some View { - DesignCard(size: .small, + UI.Card.Scaffold(size: .small, isSelected: selected, fill: nil, - fillOpacity: selected ? DesignTokens.DesignCard.selectedSubtleFillOpacity : DesignTokens.DesignCard.plainFillOpacity, + fillOpacity: selected ? UI.Card.Metric.selectedSubtleFillOpacity : UI.Card.Metric.plainFillOpacity, elevated: false, onTap: action, title: item.title, subtitle: item.subtitle) { - DesignCardIconChip(symbol: item.icon, + UI.Card.IconChip(symbol: item.icon, tint: item.tint, backgroundOpacity: selected - ? DesignTokens.DesignCard.iconSelectedBackgroundOpacity - : DesignTokens.DesignCard.iconBackgroundOpacity) + ? UI.Card.Metric.iconSelectedBackgroundOpacity + : UI.Card.Metric.iconBackgroundOpacity) } titleAccessory: { - DesignBadgeText(text: item.kind.localizedTitle, + UI.Badge.Text(text: item.kind.localizedTitle, font: .caption2.weight(.semibold), foreground: selected ? .accentColor : .secondary) } subtitleAccessory: { @@ -76,11 +76,11 @@ struct PaletteResultCard: View { private func containerCard(_ snapshot: ContainerSnapshot) -> some View { let style = app.containerStyle(for: snapshot) let name = style.displayName(fallback: snapshot.id) - let cardSize: DesignCardSize = snapshot.state == .running ? .large : .medium - return DesignCard(size: cardSize, + let cardSize: UI.Card.Size = snapshot.state == .running ? .large : .medium + return UI.Card.Scaffold(size: cardSize, isSelected: selected, fill: style.fillBackground ? style.color : nil, - fillOpacity: selected ? DesignTokens.DesignCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, + fillOpacity: selected ? UI.Card.Metric.selectedPersonalizedFillOpacity : style.backgroundOpacity, gradient: style.gradient, gradientAngle: style.gradientAngle, blendMode: style.backgroundBlendMode, @@ -89,13 +89,13 @@ struct PaletteResultCard: View { title: name, subtitle: Format.shortImage(snapshot.image), subtitleStyle: .monospaced) { - DesignCardIconChip(symbol: style.symbol, + UI.Card.IconChip(symbol: style.symbol, tint: style.color, backgroundOpacity: selected - ? DesignTokens.DesignCard.iconSelectedBackgroundOpacity - : DesignTokens.DesignCard.iconBackgroundOpacity) + ? UI.Card.Metric.iconSelectedBackgroundOpacity + : UI.Card.Metric.iconBackgroundOpacity) } titleAccessory: { - DesignBadgeText(text: snapshot.state.rawValue.capitalized, + UI.Badge.Text(text: snapshot.state.rawValue.capitalized, font: .caption2.weight(.semibold), foreground: snapshot.state == .running ? .green : .secondary) } subtitleAccessory: { @@ -125,10 +125,10 @@ struct PaletteResultCard: View { private func imageTagCard(_ reference: String, groupID: String) -> some View { let style = app.imageGroupStyle(forID: groupID) - return DesignCard(size: .medium, + return UI.Card.Scaffold(size: .medium, isSelected: selected, fill: style.fillBackground ? style.color : nil, - fillOpacity: selected ? DesignTokens.DesignCard.selectedPersonalizedFillOpacity : style.backgroundOpacity, + fillOpacity: selected ? UI.Card.Metric.selectedPersonalizedFillOpacity : style.backgroundOpacity, gradient: style.gradient, gradientAngle: style.gradientAngle, blendMode: style.backgroundBlendMode, @@ -137,13 +137,13 @@ struct PaletteResultCard: View { title: Format.shortImage(reference), subtitle: repositoryTitle(reference), titleStyle: .monospaced) { - DesignCardIconChip(symbol: "tag", + UI.Card.IconChip(symbol: "tag", tint: style.color, backgroundOpacity: selected - ? DesignTokens.DesignCard.iconSelectedBackgroundOpacity - : DesignTokens.DesignCard.iconBackgroundOpacity) + ? UI.Card.Metric.iconSelectedBackgroundOpacity + : UI.Card.Metric.iconBackgroundOpacity) } titleAccessory: { - DesignBadgeText(text: "Tag", font: .caption2.weight(.semibold)) + UI.Badge.Text(text: "Tag", font: .caption2.weight(.semibold)) } subtitleAccessory: { EmptyView() } headerAccessory: { @@ -151,13 +151,13 @@ struct PaletteResultCard: View { } bodyContent: { EmptyView() } footerLeading: { - DesignCardFooterMini { - Image(systemName: style.symbol) - .font(.caption2) - .foregroundStyle(style.color) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: style.symbol, + tint: style.color, + size: .caption2) } text: { - DesignCardMetricText(text: "Image") - .foregroundStyle(.secondary) + UI.Card.MetricText(text: "Image") + .designSecondaryValueStyle() } } footerActions: { accessory @@ -169,21 +169,21 @@ struct PaletteResultCard: View { } private func designCard(symbol: String, title: String, subtitle: String, footer: String) -> some View { - DesignCard(size: .small, + UI.Card.Scaffold(size: .small, isSelected: selected, fill: nil, - fillOpacity: selected ? DesignTokens.DesignCard.selectedResourceFillOpacity : DesignTokens.DesignCard.plainFillOpacity, + fillOpacity: selected ? UI.Card.Metric.selectedResourceFillOpacity : UI.Card.Metric.plainFillOpacity, elevated: false, onTap: action, title: title, subtitle: footer) { - DesignCardIconChip(symbol: symbol, + UI.Card.IconChip(symbol: symbol, tint: item.tint, backgroundOpacity: selected - ? DesignTokens.DesignCard.iconSelectedBackgroundOpacity - : DesignTokens.DesignCard.iconBackgroundOpacity) + ? UI.Card.Metric.iconSelectedBackgroundOpacity + : UI.Card.Metric.iconBackgroundOpacity) } titleAccessory: { - DesignBadgeText(text: subtitle, font: .caption2.weight(.semibold)) + UI.Badge.Text(text: subtitle, font: .caption2.weight(.semibold)) } subtitleAccessory: { EmptyView() } headerAccessory: { @@ -201,18 +201,18 @@ struct PaletteResultCard: View { .accessibilityAddTraits(selected ? .isSelected : []) } - private func tintCard(_ tint: DesignTint) -> some View { - DesignCard(size: .small, + private func tintCard(_ tint: UI.Theme.Tint) -> some View { + UI.Card.Scaffold(size: .small, isSelected: selected, fill: tint.color, - fillOpacity: selected ? DesignTokens.DesignCard.selectedTintFillOpacity : DesignTokens.DesignCard.selectedSubtleFillOpacity, + fillOpacity: selected ? UI.Card.Metric.selectedTintFillOpacity : UI.Card.Metric.selectedSubtleFillOpacity, elevated: false, onTap: action, title: tint.localizedDisplayName, subtitle: item.title) { - DesignTintSwatch(color: tint.color, followsAccent: tint.followsAccent) + UI.Control.TintSwatch(color: tint.color, followsAccent: tint.followsAccent) } titleAccessory: { - DesignBadgeText(text: app.settings.accentTint == tint ? AppText.current : AppText.tint, + UI.Badge.Text(text: app.settings.accentTint == tint ? AppText.current : AppText.tint, font: .caption2.weight(.semibold), foreground: app.settings.accentTint == tint ? .accentColor : .secondary) } subtitleAccessory: { @@ -233,21 +233,21 @@ struct PaletteResultCard: View { } private func containerPaletteWidget(_ snapshot: ContainerSnapshot) -> some View { - DesignCardWidgetGroup { - DesignCardFooterMini { - Image(systemName: "clock").font(.caption2) + UI.Card.WidgetGroup { + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "clock", size: .caption2) } text: { - DesignCardMetricText(text: Format.uptime(since: snapshot.startedDate)) + UI.Card.MetricText(text: Format.uptime(since: snapshot.startedDate)) } - DesignCardFooterMini { - Image(systemName: "network").font(.caption2) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "network", size: .caption2) } text: { - DesignCardMetricText(text: "\(snapshot.status.networks.count)") + UI.Card.MetricText(text: "\(snapshot.status.networks.count)") } - DesignCardFooterMini { - Image(systemName: "shippingbox").font(.caption2) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "shippingbox", size: .caption2) } text: { - DesignCardMetricText(text: Format.shortImage(snapshot.image)) + UI.Card.MetricText(text: Format.shortImage(snapshot.image)) } } } @@ -257,13 +257,13 @@ struct PaletteResultCard: View { switch item.accessory { case .run: if selected { - Image(systemName: "return") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - .frame(width: DesignTokens.IconSize.chip, height: DesignTokens.IconSize.chip) + UI.Symbol.Image(systemName: "return", + tone: .tertiary, + size: .caption) + .frame(width: UI.Control.Size.chip, height: UI.Control.Size.chip) } else { - DesignListRowChevron() - .frame(width: DesignTokens.IconSize.chip, height: DesignTokens.IconSize.chip) + UI.List.RowChevron() + .frame(width: UI.Control.Size.chip, height: UI.Control.Size.chip) } case .toggle(let isOn, let set): Toggle("", isOn: Binding { @@ -275,8 +275,7 @@ struct PaletteResultCard: View { .toggleStyle(.switch) case .disabled(let reason): Text(reason) - .font(.caption) - .foregroundStyle(.tertiary) + .designTertiaryCaption() } } @@ -296,13 +295,13 @@ struct PaletteResultCard: View { } private func containerStatus(_ snapshot: ContainerSnapshot) -> some View { - DesignCardFooterMini { - Image(systemName: snapshot.state == .running ? "circle.fill" : "circle") - .font(.caption2) - .foregroundStyle(snapshot.state == .running ? .green : .secondary) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: snapshot.state == .running ? "circle.fill" : "circle", + tone: snapshot.state == .running ? .success : .neutral, + size: .caption2) } text: { - DesignCardMetricText(text: snapshot.state.rawValue.capitalized) - .foregroundStyle(.secondary) + UI.Card.MetricText(text: snapshot.state.rawValue.capitalized) + .designSecondaryValueStyle() } } } diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift similarity index 69% rename from Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift rename to Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift index 7d264002..7a85a434 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift @@ -1,6 +1,6 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import SwiftData import AppKit import ContainedCore @@ -27,7 +27,7 @@ private struct IndexedPaletteSection: Identifiable { let rows: [IndexedPaletteRow] } -/// The expanded command palette content hosted inside `MorphingExpander`. +/// The expanded command palette content hosted inside `UX.Morph.Expander`. struct ToolbarCommandPalette: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui @@ -38,9 +38,9 @@ struct ToolbarCommandPalette: View { @State private var hubSearching = false @State private var hubError: String? - private var isOpen: Bool { ui.activeMorph == .palette } - private var scope: PaletteScope? { ui.paletteScope } - private var trimmedQuery: String { ui.searchText.trimmingCharacters(in: .whitespacesAndNewlines) } + private var isOpen: Bool { ui.toolbar.activeMorph == .palette } + private var scope: PaletteScope? { ui.search.scope } + private var trimmedQuery: String { ui.search.text.trimmingCharacters(in: .whitespacesAndNewlines) } /// Live `@State` would normally back this, but the section model recomputes cheaply each render. private var sections: [PaletteSection] { @@ -74,14 +74,14 @@ struct ToolbarCommandPalette: View { private var hubSearchKey: String { "\(scope == .dockerHub)|\(trimmedQuery)" } var body: some View { - DesignPanelScaffold(width: DesignTokens.PanelSize.palette.width, scrolls: false) { + UI.Panel.Scaffold(width: UI.Panel.Size.palette.width, scrolls: false) { VStack(spacing: 0) { - VStack(spacing: DesignTokens.Space.xs) { + VStack(spacing: UI.Layout.Spacing.xs) { fieldRow - .frame(height: DesignTokens.Toolbar.searchOpenHeaderHeight) + .frame(height: UI.Toolbar.Size.searchOpenHeaderHeight) inlineSearchRow } - .padding(.bottom, DesignTokens.Space.s) + .padding(.bottom, UI.Layout.Spacing.s) Divider() } } content: { @@ -89,30 +89,30 @@ struct ToolbarCommandPalette: View { } footer: { footerBar } - .morphPanelSize(DesignTokens.PanelSize.palette) + .morphPanelSize(UI.Panel.Size.palette) .morphPanelPlacement(.anchored) .onAppear { - ui.paletteIndex = 0 + ui.search.paletteIndex = 0 } .task(id: isOpen) { await focusSearchField() } .task(id: hubSearchKey) { await runHubSearch() } - .onChange(of: ui.searchText) { _, _ in ui.paletteIndex = 0 } - .onChange(of: ui.paletteScope) { _, _ in ui.paletteIndex = 0 } + .onChange(of: ui.search.text) { _, _ in ui.search.paletteIndex = 0 } + .onChange(of: ui.search.scope) { _, _ in ui.search.paletteIndex = 0 } .onChange(of: flatItems.count) { _, _ in clampSelection() } } private var fieldRow: some View { @Bindable var ui = ui - return HStack(spacing: DesignTokens.Toolbar.searchIconGap) { - Image(systemName: scope?.symbol ?? "magnifyingglass") - .font(.body) - .foregroundStyle(scope == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.accentColor)) + return HStack(spacing: UI.Toolbar.Spacing.searchIconGap) { + UI.Symbol.Image(systemName: scope?.symbol ?? "magnifyingglass", + tone: scope == nil ? .neutral : .accent, + size: .body) if let scope { scopeChip(scope) } - TextField(scope?.placeholder ?? AppText.paletteSearchOrRunPlaceholder, text: $ui.searchText) + TextField(scope?.placeholder ?? AppText.paletteSearchOrRunPlaceholder, text: $ui.search.text) .textFieldStyle(.plain) - .font(.body).fontWeight(.medium) + .designSearchTextStyle() .focused($focused) .onSubmit { onSubmit() } .onKeyPress(.downArrow) { guard isOpen else { return .ignored }; move(1); return .handled } @@ -120,25 +120,27 @@ struct ToolbarCommandPalette: View { .onKeyPress(.escape) { guard isOpen else { return .ignored }; escape(); return .handled } // Backspace on an empty field pops the active scope chip (like removing a token). .onKeyPress(.delete) { - guard isOpen, scope != nil, ui.searchText.isEmpty else { return .ignored } - ui.paletteScope = nil + guard isOpen, scope != nil, ui.search.text.isEmpty else { return .ignored } + ui.search.scope = nil return .handled } - if !ui.searchText.isEmpty { - Button { ui.searchText = "" } label: { Image(systemName: "xmark.circle.fill") } - .buttonStyle(.plain).foregroundStyle(.secondary) + if !ui.search.text.isEmpty { + Button { ui.search.text = "" } label: { + UI.Symbol.Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) .help(AppText.clearSearch).accessibilityLabel(AppText.clearSearch) } else { - Text("esc").font(.caption2).fontWeight(.medium).foregroundStyle(.tertiary) + UI.Control.KeyCap("esc") } } - .padding(.horizontal, DesignTokens.Space.l) + .padding(.horizontal, UI.Layout.Spacing.l) } /// The pinned scope token shown in the search field. private func scopeChip(_ scope: PaletteScope) -> some View { - Button { ui.paletteScope = nil } label: { - DesignScopeChipLabel(symbol: scope.symbol, title: scope.title) + Button { ui.search.scope = nil } label: { + UI.Badge.ScopeLabel(symbol: scope.symbol, title: scope.title) } .buttonStyle(.plain) .help(AppText.removeScopeAccessibility(scope.title)) @@ -147,37 +149,33 @@ struct ToolbarCommandPalette: View { } private var inlineSearchRow: some View { - HStack(spacing: DesignTokens.Space.s) { + HStack(spacing: UI.Layout.Spacing.s) { if let scope { - DesignBadgeText(text: scopeCountText(scope)) + UI.Badge.Text(text: scopeCountText(scope)) Spacer() - Button { ui.paletteScope = nil } label: { - Label(AppText.paletteCommands, systemImage: "command") - .labelStyle(.titleAndIcon) + UI.Action.TextButton(title: AppText.paletteCommands, + systemName: "command", + help: AppText.paletteBackToCommands, + controlSize: .small) { + ui.search.scope = nil } - .buttonStyle(.plain) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .help(AppText.paletteBackToCommands) } else { - DesignBadgeText(text: AppText.paletteMatchesCount(flatItems.count)) - DesignBadgeText(text: AppText.paletteLocalImagesCount(localImageMatches)) + UI.Badge.Text(text: AppText.paletteMatchesCount(flatItems.count)) + UI.Badge.Text(text: AppText.paletteLocalImagesCount(localImageMatches)) Spacer() if !trimmedQuery.isEmpty { // "Hit search on a search entry" — pins the Docker Hub scope and keeps the typed // query, searching in-place inside the same panel area. - Button { ui.paletteScope = .dockerHub } label: { - Label(AppText.paletteSearchDockerHub, systemImage: "globe") - .labelStyle(.titleAndIcon) + UI.Action.TextButton(title: AppText.paletteSearchDockerHub, + systemName: "globe", + help: AppText.paletteSearchDockerHubFor(trimmedQuery), + controlSize: .small) { + ui.search.scope = .dockerHub } - .buttonStyle(.plain) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .help(AppText.paletteSearchDockerHubFor(trimmedQuery)) } } } - .padding(.horizontal, DesignTokens.Space.l) + .padding(.horizontal, UI.Layout.Spacing.l) } private func scopeCountText(_ scope: PaletteScope) -> String { @@ -197,7 +195,7 @@ struct ToolbarCommandPalette: View { private var resultsList: some View { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { let indexed = indexedSections // one evaluation → stable positional indices if indexed.isEmpty { emptyState @@ -208,26 +206,24 @@ struct ToolbarCommandPalette: View { } ForEach(section.rows) { row in PaletteResultCard(item: row.item, - selected: row.index == ui.paletteIndex, - action: { ui.paletteIndex = row.index; run(row.item) }) + selected: row.index == ui.search.paletteIndex, + action: { ui.search.paletteIndex = row.index; run(row.item) }) .id(row.index) .contentShape(Rectangle()) } } } } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) } - .onChange(of: ui.paletteIndex) { _, new in proxy.scrollTo(new, anchor: .center) } + .onChange(of: ui.search.paletteIndex) { _, new in proxy.scrollTo(new, anchor: .center) } } } private func sectionHeader(_ title: String) -> some View { - Text(title.uppercased()) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.tertiary) - .padding(.horizontal, DesignTokens.Space.xs) - .padding(.top, DesignTokens.Space.xs) + UI.State.SectionLabel(title.uppercased()) + .padding(.horizontal, UI.Layout.Spacing.xs) + .padding(.top, UI.Layout.Spacing.xs) .frame(maxWidth: .infinity, alignment: .leading) } @@ -236,57 +232,55 @@ struct ToolbarCommandPalette: View { if scope == .dockerHub { dockerHubPlaceholder } else { - DesignContentSurface(minHeight: 260) { - ContentUnavailableView { - Label(AppText.paletteNoMatches, systemImage: "magnifyingglass") - } description: { - Text(AppText.paletteNoMatchesDescription) - } + UI.Surface.Content(minHeight: 260) { + UI.State.Empty(AppText.paletteNoMatches, + systemImage: "magnifyingglass", + description: AppText.paletteNoMatchesDescription, + padding: 0) } } } private var dockerHubPlaceholder: some View { - DesignContentSurface(minHeight: 260) { - LazyVStack(spacing: DesignTokens.Space.s) { - if hubSearching { - ProgressView() - Text(AppText.paletteSearchingDockerHub).font(.callout).foregroundStyle(.secondary) - } else if let hubError { - Image(systemName: "wifi.exclamationmark").font(.title2).foregroundStyle(.orange) - Text(AppText.paletteCouldNotSearchDockerHub).font(.callout.weight(.medium)) - Text(hubError).font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center) - } else { - Image(systemName: "magnifyingglass").font(.title2).foregroundStyle(.tertiary) - Text(trimmedQuery.isEmpty ? AppText.paletteTypeToSearchDockerHub : AppText.paletteNoImagesFound(trimmedQuery)) - .font(.callout).foregroundStyle(.secondary) - } + UI.Surface.Content(minHeight: 260) { + if hubSearching { + UI.State.Loading(AppText.paletteSearchingDockerHub, padding: 0) + } else if let hubError { + UI.State.Empty(AppText.paletteCouldNotSearchDockerHub, + systemImage: "wifi.exclamationmark", + description: hubError, + tone: .warning, + padding: 0) + } else { + UI.State.Empty(trimmedQuery.isEmpty ? AppText.paletteTypeToSearchDockerHub : AppText.paletteNoImagesFound(trimmedQuery), + systemImage: "magnifyingglass", + padding: 0) } } } private var footerBar: some View { - HStack(spacing: DesignTokens.Space.m) { + HStack(spacing: UI.Layout.Spacing.m) { keyboardHint("↑↓", AppText.paletteKeyboardSelect) keyboardHint("return", AppText.paletteKeyboardRun) keyboardHint("esc", scope == nil ? AppText.close : AppText.paletteKeyboardClearScope) Spacer() if let selected = selectedItem { - DesignBadgeText(text: selected.kind.localizedTitle) + UI.Badge.Text(text: selected.kind.localizedTitle) } } - .padding(.horizontal, DesignTokens.Space.l) - .padding(.vertical, DesignTokens.Space.s) + .padding(.horizontal, UI.Layout.Spacing.l) + .padding(.vertical, UI.Layout.Spacing.s) } private var selectedItem: PaletteItem? { - guard flatItems.indices.contains(ui.paletteIndex) else { return nil } - return flatItems[ui.paletteIndex] + guard flatItems.indices.contains(ui.search.paletteIndex) else { return nil } + return flatItems[ui.search.paletteIndex] } /// The sections evaluated once with a running flat index per row. Rendering from this (rather than /// re-deriving an index from `PaletteItem.id`, which is a fresh UUID per evaluation) guarantees only - /// the row at `paletteIndex` is highlighted. + /// the selected palette row is highlighted. private var indexedSections: [IndexedPaletteSection] { var flat = 0 return sections.enumerated().map { offset, section in @@ -299,7 +293,7 @@ struct ToolbarCommandPalette: View { } private func keyboardHint(_ key: String, _ label: String) -> some View { - DesignKeyboardHint(key, label) + UI.Control.KeyboardHint(key, label) } // MARK: Sections @@ -366,20 +360,20 @@ struct ToolbarCommandPalette: View { private func move(_ delta: Int) { guard !flatItems.isEmpty else { return } - ui.paletteIndex = min(max(0, ui.paletteIndex + delta), flatItems.count - 1) + ui.search.paletteIndex = min(max(0, ui.search.paletteIndex + delta), flatItems.count - 1) } private func clampSelection() { if flatItems.isEmpty { - ui.paletteIndex = 0 + ui.search.paletteIndex = 0 } else { - ui.paletteIndex = min(max(0, ui.paletteIndex), flatItems.count - 1) + ui.search.paletteIndex = min(max(0, ui.search.paletteIndex), flatItems.count - 1) } } private func runSelected() { - guard flatItems.indices.contains(ui.paletteIndex) else { return } - run(flatItems[ui.paletteIndex]) + guard flatItems.indices.contains(ui.search.paletteIndex) else { return } + run(flatItems[ui.search.paletteIndex]) } private func run(_ item: PaletteItem) { @@ -390,14 +384,14 @@ struct ToolbarCommandPalette: View { /// Escape pops the scope first (one level), then closes the palette. private func escape() { if scope != nil { - ui.paletteScope = nil + ui.search.scope = nil } else { close() } } private func close() { - ui.paletteScope = nil + ui.search.scope = nil onClose() } diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift similarity index 83% rename from Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift rename to Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift index 975c85db..3641cfcc 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift @@ -1,10 +1,9 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import SwiftData import AppKit import ContainedCore -import ContainedRuntime struct ToolbarImageGroupCard: View { @Environment(AppModel.self) private var app @@ -43,7 +42,7 @@ struct ToolbarImageGroupCard: View { var message: String var detail: String? var symbol: String - var tint: Color + var tone: UI.State.Tone var action: PushAction } @@ -85,7 +84,7 @@ struct ToolbarImageGroupCard: View { let image = primaryImage(group) let status = app.imageUpdateStatus(for: group.primaryReference) let resolved = app.imageGroupStyle(for: group) - return DesignCard(size: .medium, + return UI.Card.Scaffold(size: .medium, isExpanded: isExpanded, fill: resolved.fillBackground ? resolved.color : nil, fillOpacity: resolved.backgroundOpacity, @@ -149,24 +148,24 @@ struct ToolbarImageGroupCard: View { let history = variant?.config?.history ?? [] return imagePageBody(title: AppText.string("image.history", defaultValue: "History"), subtitle: Format.shortImage(reference)) { if history.isEmpty { - ContentUnavailableView(AppText.string("image.history.empty", defaultValue: "No history"), - systemImage: "clock", - description: Text(AppText.string("image.history.empty.description", defaultValue: "This image records no layer history."))) - .frame(maxWidth: .infinity, minHeight: 220) + UI.State.Empty(AppText.string("image.history.empty", defaultValue: "No history"), + systemImage: "clock", + description: AppText.string("image.history.empty.description", defaultValue: "This image records no layer history."), + minHeight: 220) } else { - DesignCardInsetSection { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { + UI.Card.InsetSection { + UI.List.Stack { ForEach(Array(history.enumerated()), id: \.offset) { _, entry in - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.xxs) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.xxs) { Text(entry.createdBy ?? entry.comment ?? "—") - .font(.system(.caption, design: .monospaced)) + .designMonospacedCaption() .frame(maxWidth: .infinity, alignment: .leading) if let created = entry.created { Text(created.formatted(date: .abbreviated, time: .shortened)) - .font(.caption2).foregroundStyle(.secondary) + .designSecondaryCaption() } } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity, alignment: .leading) .subtleTileBackground() } @@ -178,11 +177,11 @@ struct ToolbarImageGroupCard: View { private func tagPage(_ source: String) -> some View { imagePageBody(title: AppText.addTag, subtitle: Format.shortImage(source)) { - DesignCardInsetSection { - PanelField(label: AppText.string("image.tag.source", defaultValue: "Source")) { - Text(Format.shortImage(source)).foregroundStyle(.secondary) + UI.Card.InsetSection { + UI.Panel.Field(label: AppText.string("image.tag.source", defaultValue: "Source")) { + Text(Format.shortImage(source)).designSecondaryValueStyle() } - PanelField(label: AppText.string("image.tag.newReference", defaultValue: "New reference")) { + UI.Panel.Field(label: AppText.string("image.tag.newReference", defaultValue: "New reference")) { TextField("", text: $tagTarget, prompt: Text("e.g. ghcr.io/me/app:v1")) .textFieldStyle(.roundedBorder) .onSubmit { submitTag(source: source) } @@ -190,8 +189,8 @@ struct ToolbarImageGroupCard: View { } HStack { Spacer() - if tagBusy { ProgressView().controlSize(.small) } - DesignTextActionButton(title: AppText.addTag, + if tagBusy { UI.State.ProgressIndicator() } + UI.Action.TextButton(title: AppText.addTag, systemName: "checkmark", prominence: .prominent, isEnabled: !tagTarget.trimmingCharacters(in: .whitespaces).isEmpty && !tagBusy) { @@ -204,7 +203,7 @@ struct ToolbarImageGroupCard: View { private func pushPage(_ reference: String) -> some View { imagePageBody(title: AppText.string("image.pushImage", defaultValue: "Push image"), subtitle: Format.shortImage(reference)) { if pushStartedReference == reference, let client = app.client { - StreamConsole(stream: { client.streamPush(reference) }, + UI.Console.Stream(stream: { client.streamPush(reference) }, workingLabel: AppText.working, completedLabel: AppText.completed, lineCountLabel: AppText.lineCount, @@ -221,17 +220,17 @@ struct ToolbarImageGroupCard: View { private func imagePageBody(title: String, subtitle: String?, @ViewBuilder content: @escaping () -> C) -> some View { ScrollView { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { - VStack(alignment: .leading, spacing: DesignTokens.DesignCard.compactTextSpacing) { - DesignCardTitleText(text: title) + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { + VStack(alignment: .leading, spacing: UI.Card.Spacing.compactText) { + UI.Card.TitleText(text: title) if let subtitle { - DesignCardMonospacedSubtitleText(text: subtitle) + UI.Card.MonospacedSubtitleText(text: subtitle) } } - .padding(.horizontal, DesignTokens.Space.s) + .padding(.horizontal, UI.Layout.Spacing.s) content() } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity, alignment: .leading) } .scrollEdgeEffectStyle(.soft, for: .all) @@ -239,48 +238,44 @@ struct ToolbarImageGroupCard: View { private func pushReadiness(_ reference: String) -> some View { let readiness = pushState(for: reference) - return DesignCardInsetSection { - HStack(alignment: .top, spacing: DesignTokens.Space.s) { - Image(systemName: readiness.symbol) - .font(.title3) - .foregroundStyle(readiness.tint) - .frame(width: DesignTokens.IconSize.rowIconColumn) - VStack(alignment: .leading, spacing: DesignTokens.Space.xs) { + return UI.Card.InsetSection { + HStack(alignment: .top, spacing: UI.Layout.Spacing.s) { + UI.Symbol.Image(systemName: readiness.symbol, + tone: readiness.tone, + size: .title3, + frameWidth: UI.Control.Size.rowIconColumn) + VStack(alignment: .leading, spacing: UI.Layout.Spacing.xs) { Text(readiness.title) - .font(.headline) + .designHeadlineLabelStyle() Text(readiness.message) - .font(.callout) - .foregroundStyle(.secondary) + .designSecondaryCallout() if let detail = readiness.detail { Text(detail) - .font(.caption) - .foregroundStyle(.secondary) + .designSecondaryCaption() } } - Spacer(minLength: DesignTokens.Space.s) + Spacer(minLength: UI.Layout.Spacing.s) } HStack { Spacer() switch readiness.action { case .push: - DesignTextActionButton(title: AppText.push, + UI.Action.TextButton(title: AppText.push, systemName: "arrow.up.circle", prominence: .prominent, isEnabled: app.client != nil) { confirmingPushReference = reference } case .openRegistries: - Button { + UI.Action.TextButton(title: AppText.string("registry.openRegistries", defaultValue: "Open Registries"), + systemName: "key") { onClose() ui.openSettings(to: .registries) - } label: { - Label(AppText.string("registry.openRegistries", defaultValue: "Open Registries"), systemImage: "key") } case .tag: - Button { + UI.Action.TextButton(title: AppText.addTag, + systemName: "tag") { withAnimation(spring) { page = .tag(reference) } - } label: { - Label(AppText.addTag, systemImage: "tag") } case .none: EmptyView() @@ -295,7 +290,7 @@ struct ToolbarImageGroupCard: View { message: AppText.string("image.push.runtimeUnavailable.message", defaultValue: "Start the container service before pushing images."), detail: nil, symbol: "exclamationmark.triangle", - tint: .orange, + tone: .warning, action: .none) } @@ -307,7 +302,7 @@ struct ToolbarImageGroupCard: View { message: AppText.string("image.push.tagRequired.message", defaultValue: "Digest references cannot be pushed directly."), detail: AppText.string("image.push.tagRequired.detail", defaultValue: "Add a writable tag such as ghcr.io/me/app:v1, then push that tag."), symbol: "tag", - tint: .orange, + tone: .warning, action: .tag) } @@ -317,7 +312,7 @@ struct ToolbarImageGroupCard: View { message: AppText.string("image.push.namespaceRequired.message", defaultValue: "This tag points at Docker Hub's library namespace."), detail: AppText.string("image.push.namespaceRequired.detail", defaultValue: "Add a tag under a namespace you control before pushing."), symbol: "tag", - tint: .orange, + tone: .warning, action: .tag) } @@ -326,7 +321,7 @@ struct ToolbarImageGroupCard: View { message: AppText.string("image.push.signInRequired.message", defaultValue: "Sign in to \(registry) before pushing this image."), detail: AppText.string("image.push.signInRequired.detail", defaultValue: "Contained checks for a saved container registry login before starting a push."), symbol: "key", - tint: .orange, + tone: .warning, action: .openRegistries) } @@ -334,7 +329,7 @@ struct ToolbarImageGroupCard: View { message: AppText.string("image.push.ready.message", defaultValue: "Signed in to \(registry)\(login.username.map { " as \($0)" } ?? "")."), detail: AppText.string("image.push.ready.detail", defaultValue: "The registry will still enforce write permission for \(parsed.repository)."), symbol: "checkmark.circle.fill", - tint: .green, + tone: .success, action: .push) } @@ -346,7 +341,7 @@ struct ToolbarImageGroupCard: View { Task { do { _ = try await client.tagImage(source: source, target: target) - await app.refreshImagesIfStale(force: true) + await app.refreshImagesIfNeeded(force: true) tagBusy = false tagTarget = "" withAnimation(spring) { page = .tags } @@ -358,26 +353,26 @@ struct ToolbarImageGroupCard: View { } } - private var imagePageControlItems: [DesignCardPageControlItem] { + private var imagePageControlItems: [UI.Card.Page] { let reference = primaryImage(group)?.reference ?? group.primaryReference return [ - DesignCardPageControlItem(id: .tags, + UI.Card.Page(id: .tags, title: AppText.string("image.tags", defaultValue: "Tags"), systemImage: "tag"), - DesignCardPageControlItem(id: .history(reference), + UI.Card.Page(id: .history(reference), title: AppText.string("image.history", defaultValue: "History"), systemImage: "clock.arrow.circlepath"), - DesignCardPageControlItem(id: .tag(reference), + UI.Card.Page(id: .tag(reference), title: AppText.addTag, systemImage: "plus.circle"), - DesignCardPageControlItem(id: .push(reference), + UI.Card.Page(id: .push(reference), title: AppText.push, systemImage: "arrow.up.circle") ] } - private var imagePages: DesignCardPages { - DesignCardPages(items: imagePageControlItems, + private var imagePages: UI.Card.Pages { + UI.Card.Pages(items: imagePageControlItems, selection: page, tint: resolvedImageTint, controlsReveal: isExpanded ? 1 : 0, @@ -403,22 +398,20 @@ struct ToolbarImageGroupCard: View { } private func imageFooterInfo(_ status: ImageUpdateStatus) -> some View { - DesignCardFooterMini { - Image(systemName: updateSymbol(status.state)) - .font(.caption) - .foregroundStyle(updateTint(status.state)) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: updateSymbol(status.state), + tone: updateTone(status.state), + size: .caption) } text: { - DesignCardSubtitleText(text: updateFooterText(status)) + UI.Card.SubtitleText(text: updateFooterText(status)) } } private func imageFooterTagCount(_ group: LocalImageTagGroup) -> some View { - DesignCardFooterMini { - Image(systemName: "tag") - .font(.caption) - .foregroundStyle(.secondary) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "tag", size: .caption) } text: { - DesignCardMetricText(text: "\(group.references.count)") + UI.Card.MetricText(text: "\(group.references.count)") } } @@ -443,18 +436,18 @@ struct ToolbarImageGroupCard: View { } private func tagList(_ group: LocalImageTagGroup) -> some View { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { Text("Tags") - .font(.headline) - .padding(.leading, DesignTokens.Space.xs) + .designHeadlineLabelStyle() + .padding(.leading, UI.Layout.Spacing.xs) ScrollView(.vertical) { - LazyVStack(spacing: DesignTokens.Space.s) { + LazyVStack(spacing: UI.Layout.Spacing.s) { ForEach(group.references, id: \.self) { reference in tagRow(reference, in: group) .frame(maxWidth: .infinity) } } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) } .scrollEdgeEffectStyle(.soft, for: .all) } @@ -462,7 +455,7 @@ struct ToolbarImageGroupCard: View { private func tagRow(_ reference: String, in group: LocalImageTagGroup) -> some View { let style = app.imageStyle(for: reference) - return DesignCard(size: .medium, + return UI.Card.Scaffold(size: .medium, fill: style.fillBackground ? style.color : nil, fillOpacity: style.backgroundOpacity, gradient: style.gradient, @@ -484,10 +477,10 @@ struct ToolbarImageGroupCard: View { } bodyContent: { EmptyView() } footerLeading: { - DesignCardFooterMini { - Image(systemName: "tag").font(.caption2) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "tag", size: .caption2) } text: { - DesignCardMetricText(text: "Local tag") + UI.Card.MetricText(text: "Local tag") } } footerActions: { footerAction("play", help: AppText.run) { @@ -515,7 +508,7 @@ struct ToolbarImageGroupCard: View { private func footerAction(_ systemName: String, help: String, tint: Color? = nil, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { - DesignCardFooterButton(systemName: systemName, + UI.Card.FooterButton(systemName: systemName, help: help, tint: tint, role: role, @@ -553,7 +546,7 @@ struct ToolbarImageGroupCard: View { } private func imageChip(_ style: Personalization) -> some View { - DesignCardIconChip(symbol: style.symbol, tint: style.color) + UI.Card.IconChip(symbol: style.symbol, tint: style.color) } private func updateSymbol(_ state: ImageUpdateState) -> String { @@ -566,12 +559,12 @@ struct ToolbarImageGroupCard: View { } } - private func updateTint(_ state: ImageUpdateState) -> Color { + private func updateTone(_ state: ImageUpdateState) -> UI.State.Tone { switch state { - case .current: return .green - case .updateAvailable, .error: return .orange - case .checking: return .blue - case .unknown: return .secondary + case .current: return .success + case .updateAvailable, .error: return .warning + case .checking: return .info + case .unknown: return .neutral } } @@ -650,7 +643,7 @@ struct ToolbarImageGroupCard: View { guard let client = app.client else { return } do { _ = try await client.deleteImages([reference]) - await app.refreshImagesIfStale(force: true) + await app.refreshImagesIfNeeded(force: true) app.flash(AppText.deletedImage(Format.shortImage(reference))) deletingReference = nil } catch let error as CommandError { app.flash(error.appDisplayMessage) } @@ -659,7 +652,7 @@ struct ToolbarImageGroupCard: View { private func prune(all: Bool) async { guard let client = app.client else { return } - do { _ = try await client.pruneImages(all: all); await app.refreshImagesIfStale(force: true) } + do { _ = try await client.pruneImages(all: all); await app.refreshImagesIfNeeded(force: true) } catch let error as CommandError { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarResourcePanels.swift similarity index 84% rename from Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift rename to Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarResourcePanels.swift index fcec9a4b..1a757ca9 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarResourcePanels.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarResourcePanels.swift @@ -1,6 +1,6 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import SwiftData import AppKit import ContainedCore @@ -83,7 +83,7 @@ struct ToolbarTemplatesPanel: View { } var body: some View { - DesignPanelScaffold(width: DesignTokens.PanelSize.templates.width) { + UI.Panel.Scaffold(width: UI.Panel.Size.templates.width) { if showsHeader { VStack(alignment: .leading, spacing: 0) { header @@ -91,29 +91,29 @@ struct ToolbarTemplatesPanel: View { } } } content: { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { if sortedTemplates.isEmpty { emptyCard } else { ForEach(Array(templateSections.enumerated()), id: \.offset) { _, section in if ui.templateGrouping != .none { - DesignBadgeText(text: section.title, font: .caption.weight(.semibold)) - .padding(.horizontal, DesignTokens.Space.xs) + UI.Badge.Text(text: section.title, font: .caption.weight(.semibold)) + .padding(.horizontal, UI.Layout.Spacing.xs) } ForEach(section.templates) { template in templateCard(template) } } } } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) } } private var header: some View { - PanelHeader(symbol: "bookmark", + UI.Panel.Header(symbol: "bookmark", title: AppText.sectionTemplates, subtitle: AppText.string("template.savedCount", defaultValue: "\(saved.count) saved")) { if showClose { - DesignActionGroup(DesignAction(systemName: "xmark", + UI.Action.Group(UI.Action.Item(systemName: "xmark", help: AppText.close, isCancel: true, action: onClose)) @@ -122,13 +122,13 @@ struct ToolbarTemplatesPanel: View { } private var emptyCard: some View { - DesignCard(size: .small, + UI.Card.Scaffold(size: .small, elevated: false, title: AppText.string("template.empty", defaultValue: "No templates"), subtitle: AppText.string("template.empty.subtitle", defaultValue: "Save a container's settings as a template from the create form.")) { - DesignCardIconChip(symbol: "bookmark", + UI.Card.IconChip(symbol: "bookmark", tint: .secondary, - backgroundOpacity: DesignTokens.DesignCard.iconEmphasisBackgroundOpacity) + backgroundOpacity: UI.Card.Metric.iconEmphasisBackgroundOpacity) } titleAccessory: { EmptyView() } subtitleAccessory: { @@ -147,13 +147,13 @@ struct ToolbarTemplatesPanel: View { } private func templateCard(_ template: Template) -> some View { - DesignCard(size: .medium, + UI.Card.Scaffold(size: .medium, elevated: false, onTap: { use(template) }, title: template.name, subtitle: Format.shortImage(template.spec?.image ?? "—"), subtitleStyle: .monospaced) { - DesignCardIconChip(symbol: "bookmark.fill", tint: .accentColor) + UI.Card.IconChip(symbol: "bookmark.fill", tint: .accentColor) } titleAccessory: { EmptyView() } subtitleAccessory: { @@ -161,28 +161,27 @@ struct ToolbarTemplatesPanel: View { } headerAccessory: { // Chevron affordance: tapping the card hands off to the create morph (parity with the // image cards that grow into the morph detail). - DesignListRowChevron() + UI.List.RowChevron() } bodyContent: { EmptyView() } footerLeading: { - DesignCardFooterMini { - Image(systemName: "bookmark").font(.caption2) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "bookmark", size: .caption2) } text: { - DesignCardMetricText(text: "Saved run configuration") + UI.Card.MetricText(text: "Saved run configuration") } } footerActions: { Button(role: .destructive) { delete(template) } label: { - DesignCardFooterMini { - Image(systemName: "trash").font(.body) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "trash", tone: .error, size: .body) } text: { EmptyView() } } .buttonStyle(.plain) - .foregroundStyle(.red) .help("Delete") .accessibilityLabel(AppText.delete) - DesignTextActionButton(title: AppText.string("common.use", defaultValue: "Use"), + UI.Action.TextButton(title: AppText.string("common.use", defaultValue: "Use"), systemName: "plus.circle", prominence: .prominent, controlSize: .small) { @@ -216,4 +215,4 @@ struct ToolbarTemplatesPanel: View { } /// The collapsed toolbar search source. It owns the measured `.palette` slot; the expanded command -/// surface is rendered by `MorphingExpander`, so the source can hide while the panel owns the glass. +/// surface is rendered by `UX.Morph.Expander`, so the source can hide while the panel owns the glass. diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarSearchSource.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarSearchSource.swift similarity index 59% rename from Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarSearchSource.swift rename to Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarSearchSource.swift index ef8aaeba..21fffae9 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarSearchSource.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarSearchSource.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI struct ToolbarSearchSource: View { @Environment(AppModel.self) private var app @@ -12,25 +12,22 @@ struct ToolbarSearchSource: View { var body: some View { @Bindable var ui = ui - return DesignToolbarSearchField(text: $ui.searchText, + return UI.Toolbar.SearchField(text: $ui.search.text, prompt: paletteEnabled ? "Search this page, or ⌘K for commands" : "Search this page", clearSearchLabel: AppText.clearSearch, focused: $focused, - onSubmit: { if paletteEnabled { ui.activeMorph = .palette } }, - onClear: { ui.searchText = "" }) { + onSubmit: { if paletteEnabled { ui.toolbar.activeMorph = .palette } }, + onClear: { ui.search.text = "" }) { if paletteEnabled { - Text("⌘K") - .font(.caption2) - .fontWeight(.medium) - .foregroundStyle(.tertiary) + UI.Control.KeyCap("⌘K") } } - .onChange(of: ui.searchText) { _, _ in escalateIfEmpty() } - .onChange(of: ui.pageResultCount) { _, _ in escalateIfEmpty() } - .onChange(of: ui.searchFocusToken) { _, _ in focused = true } - .onChange(of: ui.activeMorph) { _, morph in + .onChange(of: ui.search.text) { _, _ in escalateIfEmpty() } + .onChange(of: ui.search.pageResultCount) { _, _ in escalateIfEmpty() } + .onChange(of: ui.search.focusToken) { _, _ in focused = true } + .onChange(of: ui.toolbar.activeMorph) { _, morph in if morph != nil { focused = false } } .onExitCommand { focused = false } @@ -41,10 +38,10 @@ struct ToolbarSearchSource: View { } private func escalateIfEmpty() { - guard paletteEnabled, ui.activeMorph == nil else { return } - let query = ui.searchText.trimmingCharacters(in: .whitespaces) - if query.count >= 2, ui.pageResultCount == 0 { - ui.activeMorph = .palette + guard paletteEnabled, ui.toolbar.activeMorph == nil else { return } + let query = ui.search.text.trimmingCharacters(in: .whitespaces) + if query.count >= 2, ui.search.pageResultCount == 0 { + ui.toolbar.activeMorph = .palette } } } diff --git a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarUpdatesPanel.swift similarity index 86% rename from Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift rename to Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarUpdatesPanel.swift index 39a3b9bb..ff5915fe 100644 --- a/Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarUpdatesPanel.swift @@ -1,6 +1,6 @@ import SwiftUI -import ContainedNavigation -import ContainedDesignSystem +import ContainedUX +import ContainedUI import SwiftData import AppKit import ContainedCore @@ -45,7 +45,7 @@ struct ToolbarUpdatesPanel: View { } var body: some View { - DesignPanelScaffold(width: DesignTokens.PanelSize.images.width) { + UI.Panel.Scaffold(width: UI.Panel.Size.images.width) { if showsHeader { VStack(alignment: .leading, spacing: 0) { header @@ -53,14 +53,14 @@ struct ToolbarUpdatesPanel: View { } } } content: { - LazyVStack(alignment: .leading, spacing: DesignTokens.Space.s) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { if imageGroups.isEmpty { emptyCard } else { ForEach(Array(imageSections.enumerated()), id: \.offset) { _, section in if ui.imageGrouping != .none { - DesignBadgeText(text: section.title, font: .caption.weight(.semibold)) - .padding(.horizontal, DesignTokens.Space.xs) + UI.Badge.Text(text: section.title, font: .caption.weight(.semibold)) + .padding(.horizontal, UI.Layout.Spacing.xs) } ForEach(section.groups) { group in imageRow(group) @@ -68,45 +68,45 @@ struct ToolbarUpdatesPanel: View { } } } - .padding(DesignTokens.Space.s) + .padding(UI.Layout.Spacing.s) } - .task { await app.refreshImagesIfStale() } + .task { await app.refreshImagesIfNeeded() } } private var header: some View { - PanelHeader(symbol: "square.stack.3d.up", + UI.Panel.Header(symbol: "square.stack.3d.up", title: AppText.sectionImages, subtitle: AppText.string("image.updates.subtitle", defaultValue: "\(imageGroups.count) local · \(updateCount) update\(updateCount == 1 ? "" : "s")")) { - DesignActionGroup(imageHeaderActions) + UI.Action.Group(imageHeaderActions) } } - private var imageHeaderActions: [DesignAction] { + private var imageHeaderActions: [UI.Action.Item] { var actions = [ - DesignAction(systemName: "square.and.arrow.down", help: AppText.loadImageTar) { + UI.Action.Item(systemName: "square.and.arrow.down", help: AppText.loadImageTar) { ui.dispatch(.loadImage) onClose() }, - DesignAction(systemName: "arrow.triangle.2.circlepath", help: AppText.checkForUpdates) { + UI.Action.Item(systemName: "arrow.triangle.2.circlepath", help: AppText.checkForUpdates) { Task { await app.runImageUpdateSweepNow() } }, - DesignAction(systemName: "trash", help: AppText.pruneImages, role: .destructive) { + UI.Action.Item(systemName: "trash", help: AppText.pruneImages, role: .destructive) { ui.dispatch(.pruneImages) onClose() } ] if showClose { - actions.append(DesignAction(systemName: "xmark", help: AppText.close, isCancel: true, action: onClose)) + actions.append(UI.Action.Item(systemName: "xmark", help: AppText.close, isCancel: true, action: onClose)) } return actions } private var emptyCard: some View { - DesignCard(size: .small, + UI.Card.Scaffold(size: .small, elevated: false, title: AppText.string("image.empty", defaultValue: "No images"), subtitle: AppText.string("image.empty.subtitle", defaultValue: "Pull or build an image to see it here")) { - DesignCardIconChip(symbol: "checkmark.circle.fill", tint: .green) + UI.Card.IconChip(symbol: "checkmark.circle.fill", tint: .green) } titleAccessory: { EmptyView() } subtitleAccessory: { diff --git a/Sources/ContainedApp/Navigation/ToolbarFilterOptions.swift b/Sources/ContainedApp/Navigation/Toolbar/ToolbarFilterOptions.swift similarity index 100% rename from Sources/ContainedApp/Navigation/ToolbarFilterOptions.swift rename to Sources/ContainedApp/Navigation/Toolbar/ToolbarFilterOptions.swift diff --git a/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift b/Sources/ContainedApp/Navigation/Toolbar/ToolbarViewOptions.swift similarity index 85% rename from Sources/ContainedApp/Navigation/ToolbarViewOptions.swift rename to Sources/ContainedApp/Navigation/Toolbar/ToolbarViewOptions.swift index fe054fbe..62be57f9 100644 --- a/Sources/ContainedApp/Navigation/ToolbarViewOptions.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/ToolbarViewOptions.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import SwiftData import ContainedCore @@ -12,7 +12,7 @@ struct ToolbarPageSwitcher: View { @Query private var templates: [Template] var body: some View { - DesignMenuButton { + UI.Control.MenuButton { ForEach(AppSectionGroup.allCases) { group in let sections = AppSection.navigableSections(panelNavigationEnabled: ui.panelNavigationEnabled) .filter { $0.group == group && ($0 != .build || app.settings.imageBuildEnabled) } @@ -35,7 +35,7 @@ struct ToolbarPageSwitcher: View { } private var labelContent: some View { - ToolbarTitleSubtitleLabel(symbol: ui.selectedSection.symbol, + UI.Toolbar.TitleSubtitle(symbol: ui.selectedSection.symbol, title: ui.selectedSection.title, subtitle: pageSubtitle) } @@ -81,7 +81,7 @@ struct ToolbarViewOptions: View { var body: some View { @Bindable var ui = ui - return DesignMenuButton { + return UI.Control.MenuButton { Picker(AppText.string("toolbar.groupBy", defaultValue: "Group by"), selection: $ui.grouping) { ForEach(ContainerGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) @@ -105,7 +105,7 @@ struct ToolbarViewOptions: View { } private var labelContent: some View { - ToolbarTitleSubtitleLabel(symbol: ui.grouping.symbol, + UI.Toolbar.TitleSubtitle(symbol: ui.grouping.symbol, title: AppText.sectionContainers, subtitle: subtitle) } @@ -128,14 +128,14 @@ struct ToolbarPageContextOptions: View { case .containers: EmptyView() case .images: - DesignActionGroup([ - DesignAction(systemName: "square.and.arrow.down", help: AppText.loadImageTar) { + UI.Action.Group([ + UI.Action.Item(systemName: "square.and.arrow.down", help: AppText.loadImageTar) { ui.dispatch(.loadImage) }, - DesignAction(systemName: "arrow.triangle.2.circlepath", help: AppText.checkForUpdates) { + UI.Action.Item(systemName: "arrow.triangle.2.circlepath", help: AppText.checkForUpdates) { Task { await app.runImageUpdateSweepNow() } }, - DesignAction(systemName: "trash", help: AppText.pruneImages, role: .destructive) { + UI.Action.Item(systemName: "trash", help: AppText.pruneImages, role: .destructive) { ui.dispatch(.pruneImages) } ]) @@ -143,48 +143,48 @@ struct ToolbarPageContextOptions: View { case .build: EmptyView() case .networks: - DesignActionGroup([ - DesignAction(systemName: "plus", help: AppText.newNetwork) { + UI.Action.Group([ + UI.Action.Item(systemName: "plus", help: AppText.newNetwork) { ui.dispatch(.createNetwork) }, - DesignAction(systemName: "arrow.clockwise", help: AppText.refreshNetworks) { + UI.Action.Item(systemName: "arrow.clockwise", help: AppText.refreshNetworks) { Task { await app.refreshNetworks() } } ]) .help("\(app.networks.count) network\(app.networks.count == 1 ? "" : "s")") case .volumes: - DesignActionGroup([ - DesignAction(systemName: "plus", help: AppText.newVolume) { + UI.Action.Group([ + UI.Action.Item(systemName: "plus", help: AppText.newVolume) { ui.dispatch(.createVolume) }, - DesignAction(systemName: "arrow.clockwise", help: AppText.refreshVolumes) { + UI.Action.Item(systemName: "arrow.clockwise", help: AppText.refreshVolumes) { Task { await app.refreshSystemResources() } } ]) .help("\(app.volumes.count) volume\(app.volumes.count == 1 ? "" : "s")") case .system: - HStack(spacing: DesignTokens.Toolbar.groupSpacing) { - DesignActionGroup(serviceActions) - DesignActionGroup(systemPageActions + [ - DesignAction(systemName: "text.alignleft", help: AppText.systemLogs) { + HStack(spacing: UI.Toolbar.Spacing.groupSpacing) { + UI.Action.Group(serviceActions) + UI.Action.Group(systemPageActions + [ + UI.Action.Item(systemName: "text.alignleft", help: AppText.systemLogs) { ui.dispatch(.systemLogs) } ]) } case .activity: - DesignActionGroup([ - DesignAction(systemName: "checkmark.circle", help: AppText.markAllRead) { + UI.Action.Group([ + UI.Action.Item(systemName: "checkmark.circle", help: AppText.markAllRead) { app.historyStore.markAllEventsRead() }, - DesignAction(systemName: "trash", help: AppText.clearActivity, role: .destructive) { + UI.Action.Item(systemName: "trash", help: AppText.clearActivity, role: .destructive) { app.historyStore.clearEvents() } ]) case .registries: EmptyView() case .settings: - DesignActionGroup(SettingsContent.SettingsPage.allCases.map { page in - DesignAction(systemName: page.systemImage, + UI.Action.Group(SettingsContent.SettingsPage.allCases.map { page in + UI.Action.Item(systemName: page.systemImage, help: page.rawValue, tint: ui.settingsPage == page ? .accentColor : nil) { ui.settingsPage = page @@ -196,25 +196,25 @@ struct ToolbarPageContextOptions: View { } } - private var serviceActions: [DesignAction] { + private var serviceActions: [UI.Action.Item] { let power = app.serviceHealthy - ? DesignAction(systemName: "stop.fill", help: AppText.stopService, role: .destructive) { + ? UI.Action.Item(systemName: "stop.fill", help: AppText.stopService, role: .destructive) { Task { await app.stopService() } } - : DesignAction(systemName: "play.fill", help: AppText.startService) { + : UI.Action.Item(systemName: "play.fill", help: AppText.startService) { Task { await app.startService() } } return [ power, - DesignAction(systemName: "arrow.clockwise", help: AppText.restartService) { + UI.Action.Item(systemName: "arrow.clockwise", help: AppText.restartService) { Task { await app.restartService() } } ] } - private var systemPageActions: [DesignAction] { + private var systemPageActions: [UI.Action.Item] { SystemContent.SystemPage.allCases.map { page in - DesignAction(systemName: page.systemImage, + UI.Action.Item(systemName: page.systemImage, help: page.rawValue, tint: ui.systemPage == page ? .accentColor : nil) { ui.systemPage = page @@ -247,7 +247,7 @@ struct ToolbarPageFilterOptions: View { NetworkViewOptions() case .activity: @Bindable var ui = ui - DesignMenuButton { + UI.Control.MenuButton { Picker(AppText.string("activity.filter", defaultValue: "Filter"), selection: $ui.activityFilter) { Label(AppText.string("activity.filter.allEvents", defaultValue: "All events"), systemImage: "tray.full").tag(EventKind?.none) Divider() @@ -268,7 +268,7 @@ struct ToolbarPageFilterOptions: View { } private var activityFilterLabel: some View { - ToolbarTitleSubtitleLabel(symbol: ui.activityFilter == nil ? "line.3.horizontal.decrease" + UI.Toolbar.TitleSubtitle(symbol: ui.activityFilter == nil ? "line.3.horizontal.decrease" : "line.3.horizontal.decrease.circle.fill", title: AppText.sectionActivity, subtitle: ui.activityFilter?.rawValue.capitalized ?? AppText.string("activity.filter.allEvents", defaultValue: "All events")) @@ -280,7 +280,7 @@ private struct ImageViewOptions: View { var body: some View { @Bindable var ui = ui - return DesignMenuButton { + return UI.Control.MenuButton { Picker(AppText.string("toolbar.groupBy", defaultValue: "Group by"), selection: $ui.imageGrouping) { ForEach(ImageGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) @@ -320,7 +320,7 @@ private struct TemplateViewOptions: View { var body: some View { @Bindable var ui = ui - return DesignMenuButton { + return UI.Control.MenuButton { Picker(AppText.string("toolbar.groupBy", defaultValue: "Group by"), selection: $ui.templateGrouping) { ForEach(TemplateGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) @@ -347,7 +347,7 @@ private struct NetworkViewOptions: View { var body: some View { @Bindable var ui = ui - return DesignMenuButton { + return UI.Control.MenuButton { Picker(AppText.string("toolbar.groupBy", defaultValue: "Group by"), selection: $ui.networkGrouping) { ForEach(NetworkGrouping.allCases) { grouping in Label(grouping.title, systemImage: grouping.symbol).tag(grouping) @@ -383,5 +383,5 @@ private struct NetworkViewOptions: View { } @MainActor private func optionLabel(symbol: String, title: String, subtitle: String) -> some View { - ToolbarTitleSubtitleLabel(symbol: symbol, title: title, subtitle: subtitle) + UI.Toolbar.TitleSubtitle(symbol: symbol, title: title, subtitle: subtitle) } diff --git a/Sources/ContainedApp/History/HistoryModels.swift b/Sources/ContainedApp/Persistence/History/HistoryModels.swift similarity index 100% rename from Sources/ContainedApp/History/HistoryModels.swift rename to Sources/ContainedApp/Persistence/History/HistoryModels.swift diff --git a/Sources/ContainedApp/History/HistoryStore.swift b/Sources/ContainedApp/Persistence/History/HistoryStore.swift similarity index 100% rename from Sources/ContainedApp/History/HistoryStore.swift rename to Sources/ContainedApp/Persistence/History/HistoryStore.swift diff --git a/Sources/ContainedApp/History/Template.swift b/Sources/ContainedApp/Persistence/History/Template.swift similarity index 100% rename from Sources/ContainedApp/History/Template.swift rename to Sources/ContainedApp/Persistence/History/Template.swift diff --git a/Sources/ContainedApp/Support/Personalization.swift b/Sources/ContainedApp/Personalization/Personalization.swift similarity index 76% rename from Sources/ContainedApp/Support/Personalization.swift rename to Sources/ContainedApp/Personalization/Personalization.swift index af94afa7..a283c0d7 100644 --- a/Sources/ContainedApp/Support/Personalization.swift +++ b/Sources/ContainedApp/Personalization/Personalization.swift @@ -1,5 +1,5 @@ import SwiftUI -import ContainedDesignSystem +import ContainedUI import ContainedCore import AppKit @@ -8,11 +8,11 @@ import AppKit /// the container as labels), keyed by container id (per-container override) or image reference /// (image-level default). struct Personalization: Codable, Hashable, Sendable { - /// Bump this whenever the stored shape changes so old records can be upgraded on load. + /// Bump this whenever the stored shape changes so saved records normalize on load. static let schemaVersion = 2 var schemaVersion: Int = Self.schemaVersion - var tint: DesignTint = .multicolor + var tint: UI.Theme.Tint = .multicolor var iconEnabled: Bool = true var icon: String = "" // SF Symbol name; empty = default var nickname: String = "" @@ -20,7 +20,7 @@ struct Personalization: Codable, Hashable, Sendable { var backgroundOpacity: Double = Self.defaultBackgroundOpacity var gradient: Bool = true var gradientAngle: Double = Self.defaultGradientAngle // degrees, 0 = leading→trailing, clockwise - var backgroundBlendMode: ColorLayerBlendMode = .softLight + var backgroundBlendMode: UI.Theme.ColorBlendMode = .softLight var widgets: [WidgetConfiguration] = WidgetConfiguration.defaultWidgets() var showStatusIndicator: Bool = true var showStatusIcon: Bool = true @@ -43,7 +43,7 @@ struct Personalization: Codable, Hashable, Sendable { } } - var graphStyle: GraphStyle { + var graphStyle: UI.Chart.GraphStyle { get { widgets.first(where: { $0.enabled })?.style ?? widgets.first?.style ?? .area } set { if widgets.indices.contains(0) { @@ -80,7 +80,7 @@ struct Personalization: Codable, Hashable, Sendable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 0 - tint = try container.decodeIfPresent(DesignTint.self, forKey: .tint) ?? .multicolor + tint = try container.decodeIfPresent(UI.Theme.Tint.self, forKey: .tint) ?? .multicolor iconEnabled = try container.decodeIfPresent(Bool.self, forKey: .iconEnabled) ?? true icon = try container.decodeIfPresent(String.self, forKey: .icon) ?? "" nickname = try container.decodeIfPresent(String.self, forKey: .nickname) ?? "" @@ -90,7 +90,7 @@ struct Personalization: Codable, Hashable, Sendable { gradient = try container.decodeIfPresent(Bool.self, forKey: .gradient) ?? true gradientAngle = try container.decodeIfPresent(Double.self, forKey: .gradientAngle) ?? Self.defaultGradientAngle - backgroundBlendMode = try container.decodeIfPresent(ColorLayerBlendMode.self, forKey: .backgroundBlendMode) + backgroundBlendMode = try container.decodeIfPresent(UI.Theme.ColorBlendMode.self, forKey: .backgroundBlendMode) ?? .softLight showStatusIndicator = try container.decodeIfPresent(Bool.self, forKey: .showStatusIndicator) ?? true showStatusIcon = try container.decodeIfPresent(Bool.self, forKey: .showStatusIcon) ?? true @@ -100,7 +100,7 @@ struct Personalization: Codable, Hashable, Sendable { widgets = Self.normalizedWidgets(decodedWidgets) } else { let metric = try container.decodeIfPresent(GraphMetric.self, forKey: .graphMetric) ?? .cpu - let style = try container.decodeIfPresent(GraphStyle.self, forKey: .graphStyle) ?? .area + let style = try container.decodeIfPresent(UI.Chart.GraphStyle.self, forKey: .graphStyle) ?? .area widgets = Self.normalizedWidgets([ WidgetConfiguration(enabled: true, metric: metric, style: style), WidgetConfiguration(enabled: true, metric: .memory, style: .area), @@ -131,37 +131,6 @@ struct Personalization: Codable, Hashable, Sendable { try container.encode(graphStyle, forKey: .graphStyle) } - /// Build from a container's legacy `contained.*` labels — used **only** for the one-time import - /// of styles created by older versions that injected labels. Nothing writes these labels anymore. - init(migratingLabels labels: [String: String]) { - schemaVersion = Self.schemaVersion - tint = DesignTint.parse(labels["contained.tint"]) - iconEnabled = true - icon = labels["contained.icon"] ?? "" - nickname = labels["contained.nickname"] ?? "" - fillBackground = labels["contained.bg"] == "1" - backgroundOpacity = labels["contained.bgOpacity"].flatMap(Double.init) ?? Self.defaultBackgroundOpacity - gradient = labels["contained.gradient"] == "1" - gradientAngle = labels["contained.bgAngle"].flatMap(Double.init) ?? Self.defaultGradientAngle - backgroundBlendMode = .softLight - showStatusIndicator = true - showStatusIcon = true - showStatusText = true - let metric = labels["contained.graph"].flatMap(GraphMetric.init) ?? .cpu - let style = labels["contained.graphStyle"].flatMap(GraphStyle.init) ?? .area - widgets = Self.normalizedWidgets([ - WidgetConfiguration(enabled: true, metric: metric, style: style), - WidgetConfiguration(enabled: true, metric: .memory, style: .area), - WidgetConfiguration(enabled: true, metric: .netRx, style: .area), - WidgetConfiguration(enabled: true, metric: .netTx, style: .area) - ]) - } - - /// True if a label set carries any legacy `contained.*` personalization worth importing. - static func hasLegacyLabels(_ labels: [String: String]) -> Bool { - labels.keys.contains { $0.hasPrefix("contained.") && $0 != "contained.restart" && $0 != "contained.stack" } - } - mutating func normalizeWidgets() { widgets = Self.normalizedWidgets(widgets) } diff --git a/Sources/ContainedApp/Support/PersonalizationStore.swift b/Sources/ContainedApp/Personalization/PersonalizationStore.swift similarity index 91% rename from Sources/ContainedApp/Support/PersonalizationStore.swift rename to Sources/ContainedApp/Personalization/PersonalizationStore.swift index 14f5fd2c..547cc500 100644 --- a/Sources/ContainedApp/Support/PersonalizationStore.swift +++ b/Sources/ContainedApp/Personalization/PersonalizationStore.swift @@ -17,7 +17,6 @@ final class PersonalizationStore { static let imageDefaults = "personalizationImageDefaults" static let volumeStyles = "personalizationVolumeStyles" static let defaultImageStyle = "personalizationDefaultImageStyle" - static let migrated = "personalizationLabelsMigrated" } init(defaults: UserDefaults = .standard) { @@ -182,22 +181,6 @@ final class PersonalizationStore { persist(Keys.volumeStyles, volumeStyles) } - // MARK: One-time migration - - /// Import legacy `contained.*` styles from existing containers into per-container overrides, once. - /// Runs after the first container refresh so older users keep their card styles when we stop - /// writing labels. - func migrateLegacyLabelsIfNeeded(_ snapshots: [ContainerSnapshot]) { - guard !defaults.bool(forKey: Keys.migrated) else { return } - for snapshot in snapshots where Personalization.hasLegacyLabels(snapshot.configuration.labels) { - if overrides[snapshot.id] == nil { - overrides[snapshot.id] = Personalization(migratingLabels: snapshot.configuration.labels) - } - } - persist(Keys.overrides, overrides) - defaults.set(true, forKey: Keys.migrated) - } - private static func persist(_ defaults: UserDefaults, _ key: String, _ value: [String: Personalization]) { if let data = try? JSONEncoder().encode(value) { defaults.set(data, forKey: key) } } diff --git a/Sources/ContainedApp/Support/WidgetConfiguration.swift b/Sources/ContainedApp/Personalization/WidgetConfiguration.swift similarity index 87% rename from Sources/ContainedApp/Support/WidgetConfiguration.swift rename to Sources/ContainedApp/Personalization/WidgetConfiguration.swift index 7b77d0ee..04df4e0c 100644 --- a/Sources/ContainedApp/Support/WidgetConfiguration.swift +++ b/Sources/ContainedApp/Personalization/WidgetConfiguration.swift @@ -1,5 +1,5 @@ import AppKit -import ContainedDesignSystem +import ContainedUI import ContainedCore struct WidgetConfiguration: Codable, Hashable, Sendable { @@ -9,11 +9,11 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { var enabled: Bool = true var metric: GraphMetric = .cpu var secondaryMetric: GraphMetric? - var tint: DesignTint? + var tint: UI.Theme.Tint? var icon: String = "" - var style: GraphStyle = .area + var style: UI.Chart.GraphStyle = .area var areaUsesGradient = true - var interpolation: WidgetInterpolation = .linear + var interpolation: UI.Chart.Interpolation = .linear var lineWidth: Double = 1.5 var pointSize: Double = 18 var barWidth: Double = 4 @@ -30,11 +30,11 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { init(enabled: Bool = true, metric: GraphMetric = .cpu, secondaryMetric: GraphMetric? = nil, - tint: DesignTint? = nil, + tint: UI.Theme.Tint? = nil, icon: String = "", - style: GraphStyle = .area, + style: UI.Chart.GraphStyle = .area, areaUsesGradient: Bool = true, - interpolation: WidgetInterpolation = .linear, + interpolation: UI.Chart.Interpolation = .linear, lineWidth: Double = 1.5, pointSize: Double = 18, barWidth: Double = 4, @@ -63,11 +63,11 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true metric = try container.decodeIfPresent(GraphMetric.self, forKey: .metric) ?? .cpu secondaryMetric = try container.decodeIfPresent(GraphMetric.self, forKey: .secondaryMetric) - tint = try container.decodeIfPresent(DesignTint.self, forKey: .tint) + tint = try container.decodeIfPresent(UI.Theme.Tint.self, forKey: .tint) icon = try container.decodeIfPresent(String.self, forKey: .icon) ?? "" - style = try container.decodeIfPresent(GraphStyle.self, forKey: .style) ?? .area + style = try container.decodeIfPresent(UI.Chart.GraphStyle.self, forKey: .style) ?? .area areaUsesGradient = try container.decodeIfPresent(Bool.self, forKey: .areaUsesGradient) ?? true - let decodedInterpolation = try container.decodeIfPresent(WidgetInterpolation.self, forKey: .interpolation) + let decodedInterpolation = try container.decodeIfPresent(UI.Chart.Interpolation.self, forKey: .interpolation) interpolation = decodedInterpolation ?? .linear if decodedSchemaVersion < 5, interpolation == .catmullRom { interpolation = .linear diff --git a/Sources/ContainedApp/Support/AppErrorPresentation.swift b/Sources/ContainedApp/Presentation/Error/AppErrorPresentation.swift similarity index 96% rename from Sources/ContainedApp/Support/AppErrorPresentation.swift rename to Sources/ContainedApp/Presentation/Error/AppErrorPresentation.swift index 817a79f4..13f6e115 100644 --- a/Sources/ContainedApp/Support/AppErrorPresentation.swift +++ b/Sources/ContainedApp/Presentation/Error/AppErrorPresentation.swift @@ -1,13 +1,12 @@ import Foundation import ContainedCore -import ContainedRuntime enum AppErrorPresentation { static func message(for error: Error) -> String { switch error { case let error as CommandError: return message(for: error) - case let error as UnsupportedRuntimeCapability: + case let error as Core.Runtime.UnsupportedCapability: return message(for: error) case let error as RegistryManifestError: return message(for: error) @@ -70,7 +69,7 @@ enum AppErrorPresentation { } } - private static func message(for error: UnsupportedRuntimeCapability) -> String { + private static func message(for error: Core.Runtime.UnsupportedCapability) -> String { AppText.string( "error.runtime.unsupportedCapability", defaultValue: "The selected runtime does not support this operation." diff --git a/Sources/ContainedApp/Support/Formatting.swift b/Sources/ContainedApp/Presentation/Formatting/Formatting.swift similarity index 100% rename from Sources/ContainedApp/Support/Formatting.swift rename to Sources/ContainedApp/Presentation/Formatting/Formatting.swift diff --git a/Sources/ContainedApp/Support/Links.swift b/Sources/ContainedApp/Presentation/Links/Links.swift similarity index 100% rename from Sources/ContainedApp/Support/Links.swift rename to Sources/ContainedApp/Presentation/Links/Links.swift diff --git a/Sources/ContainedApp/Support/AppLocalization.swift b/Sources/ContainedApp/Presentation/Localization/AppLocalization.swift similarity index 98% rename from Sources/ContainedApp/Support/AppLocalization.swift rename to Sources/ContainedApp/Presentation/Localization/AppLocalization.swift index 3a56112d..eddfa3b4 100644 --- a/Sources/ContainedApp/Support/AppLocalization.swift +++ b/Sources/ContainedApp/Presentation/Localization/AppLocalization.swift @@ -1,6 +1,6 @@ import Foundation import ContainedCore -import ContainedDesignSystem +import ContainedUI /// App-owned user-facing copy. Packages receive resolved strings from here; they do not own /// localized resources or English defaults. @@ -418,8 +418,8 @@ enum AppText { string("backup.imported", defaultValue: "Imported backup") } - static func cleanedStaleRows(_ count: Int) -> String { - string("cleanup.cleanedStaleRows", defaultValue: "Cleaned \(count) stale row(s)") + static func cleanedOrphanedRows(_ count: Int) -> String { + string("cleanup.cleanedOrphanedRows", defaultValue: "Cleaned \(count) orphaned row(s)") } static func savedTemplate(_ name: String) -> String { @@ -552,12 +552,12 @@ enum AppText { string("palette.removeScopeAccessibilityLabel", defaultValue: "Remove \(scope) scope") } - static func setDesignTintTitle(_ tintName: String) -> String { - string("palette.setDesignTint", defaultValue: "Set app tint to \(tintName)") + static func setThemeTintTitle(_ tintName: String) -> String { + string("palette.setThemeTint", defaultValue: "Set app tint to \(tintName)") } } -extension DesignTint { +extension UI.Theme.Tint { var localizedDisplayName: String { switch self { case .multicolor: return AppText.string("tint.multicolor", defaultValue: "App Accent") @@ -649,7 +649,7 @@ extension DesignTint { } } -extension AppearanceMode { +extension UI.Theme.Appearance { var localizedDisplayName: String { switch self { case .system: return AppText.string("appearance.system", defaultValue: "System") @@ -659,7 +659,7 @@ extension AppearanceMode { } } -extension CardDensity { +extension UI.Card.Density { var localizedDisplayName: String { switch self { case .small: return AppText.string("cardDensity.small", defaultValue: "Small") @@ -669,7 +669,7 @@ extension CardDensity { } } -extension ColorLayerBlendMode { +extension UI.Theme.ColorBlendMode { var localizedDisplayName: String { switch self { case .normal: return AppText.string("blendMode.normal", defaultValue: "Normal") @@ -681,7 +681,7 @@ extension ColorLayerBlendMode { } } -extension WindowMaterial { +extension UI.Theme.WindowMaterial { var localizedDisplayName: String { switch self { case .glassClear: return AppText.string("windowMaterial.glassClear", defaultValue: "Glass (Clear)") @@ -704,7 +704,7 @@ extension WindowMaterial { } } -extension GraphStyle { +extension UI.Chart.GraphStyle { var localizedDisplayName: String { switch self { case .area: return AppText.string("graphStyle.area", defaultValue: "Area") @@ -718,7 +718,7 @@ extension GraphStyle { } } -extension WidgetInterpolation { +extension UI.Chart.Interpolation { var localizedDisplayName: String { switch self { case .linear: return AppText.string("widgetInterpolation.linear", defaultValue: "Linear") diff --git a/Sources/ContainedApp/Previews/AppPreviews.swift b/Sources/ContainedApp/Previews/AppPreviews.swift deleted file mode 100644 index 60d1f2aa..00000000 --- a/Sources/ContainedApp/Previews/AppPreviews.swift +++ /dev/null @@ -1,70 +0,0 @@ -import SwiftUI -import ContainedCore -import ContainedDesignSystem -import ContainedPreviewSupport - -#Preview("Container Card") { - ContainerCard(snapshot: PreviewSamples.webContainer, - style: .previewContainer, - density: .large, - stats: PreviewSamples.stats, - histories: [.cpu: PreviewSamples.sparklineBuffer], - isBusy: false, - isExpanded: true, - onTap: {}, - onStart: {}, - onStop: {}, - onRestart: {}, - onDelete: {}) - .padding(DesignTokens.Space.xl) - .frame(width: 520) - .environment(\.cardMaterial, .glassRegular) - .environment(\.buttonMaterial, .glassClear) -} - -#Preview("General Settings") { - GeneralTab(settings: SettingsStore()) - .padding(DesignTokens.Space.xl) - .frame(width: 560) - .environment(AppModel()) - .environment(\.buttonMaterial, .glassClear) - .environment(\.cardMaterial, .glassRegular) -} - -private extension PreviewSamples { - static var sparklineBuffer: SampleBuffer { - var buffer = SampleBuffer() - for value in sparklineValues { - buffer.append(value) - } - return buffer - } -} - -private extension Personalization { - static var previewContainer: Personalization { - let sample = PreviewSamples.cardStyle - var style = Personalization() - style.nickname = PreviewSamples.webContainer.displayName - style.icon = sample.symbol - style.tint = DesignTint.parse(sample.tintName) - style.fillBackground = sample.fillsBackground - style.backgroundOpacity = sample.backgroundOpacity - style.gradient = sample.usesGradient - style.widgets = PreviewSamples.widgetConfigs.map(WidgetConfiguration.preview) - return style - } -} - -private extension WidgetConfiguration { - static func preview(_ descriptor: PreviewWidgetDescriptor) -> WidgetConfiguration { - WidgetConfiguration( - metric: descriptor.metric, - secondaryMetric: descriptor.secondaryMetric, - tint: descriptor.tintName.map(DesignTint.parse), - icon: descriptor.icon, - style: GraphStyle(rawValue: descriptor.style) ?? .area, - showText: descriptor.showsText - ) - } -} diff --git a/Sources/ContainedApp/Resources/CHANGELOG.md b/Sources/ContainedApp/Resources/CHANGELOG.md index 02ce7985..7645a55b 100644 --- a/Sources/ContainedApp/Resources/CHANGELOG.md +++ b/Sources/ContainedApp/Resources/CHANGELOG.md @@ -68,7 +68,7 @@ #### Personalization & Design System - Local-only personalization for containers, image groups, image tags, and volumes, including nickname, icon, tint, background, graph/widget choices, and inheritance from image or app defaults. -- Shared Liquid Glass design system primitives: `DesignCard`, `DesignContentSurface`, `DesignInputSurface`, `DesignOptionTile`, `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls. +- Shared Liquid Glass design system primitives: `UI.Card.Scaffold`, `UI.Surface.Content`, `UI.Surface.Input`, `UI.Control.OptionTile`, `UI.Panel.Scaffold`, `UI.Panel.Header`, `UI.Panel.Section`, `UI.Panel.Row`, `UI.Panel.Field`, `UI.Command.PreviewBar`, `UI.Control.InfoButton`, `UI.Control.TintSelector`, `UI.Console.Stream`, `UI.State.ActivityStatusIndicator`, and toolbar controls. - Accessibility-aware custom visual effects and motion handling, including Reduce Transparency and Reduce Motion support where the app supplies custom glass or animation. - Shared page and panel scaffolds so sidebar pages, sheets, and toolbar morphs can reuse content without duplicate layouts. @@ -93,9 +93,9 @@ ### Technical -- Swift Package layout with a pure `ContainedCore` library for CLI command builders, JSON models, compose parsing, decision helpers, and service logic, plus a `Contained` SwiftUI executable for UI, stores, Sparkle, SwiftData, and migration. +- Swift Package layout with `ContainedCore` for backend orchestration, `ContainedUI` for visual primitives, `ContainedUX` for interaction infrastructure, `ContainedApp` for app-owned SwiftUI/persistence/localization policy, and a tiny `Contained` executable launcher. - `ContainerCommands` is the single source of truth for `container` argv construction and is covered by golden tests. -- `ContainedRuntime` defines the shared runtime contract, while `AppleContainerRuntime` exposes typed async methods over real `container --format json` output and typed stats streams. +- `ContainedCore` now owns the shared runtime contract, Core orchestrator, Apple `container` adapter internals, typed async runtime methods, and typed stats streams. - `RunSpec` is the single source of truth for Run/Edit form state, validation, CLI preview, and actual execution. - `AppModel` owns bootstrap, client wiring, stores, refresh coordination, image updates, service lifecycle, config transfer, and resource-style lookup through focused extensions. - `UIState`, `AppSection`, toolbar option enums, and pending actions centralize navigation, filters, morph routing, and classic fallback routing. @@ -105,6 +105,6 @@ ### Migration Notes -- Saved local container, image, and volume styles are preserved and migrated away from legacy decorative `contained.*` labels where possible. +- Saved local container, image, and volume styles are kept in local app storage. - Local settings, personalization, health checks, templates, and activity history can be exported before resetting data created by a newer app schema. - Activity events created before unread tracking are treated as unread on first launch. diff --git a/Sources/ContainedApp/Resources/Localizable.xcstrings b/Sources/ContainedApp/Resources/Localizable.xcstrings index 4e72cc47..37d88016 100644 --- a/Sources/ContainedApp/Resources/Localizable.xcstrings +++ b/Sources/ContainedApp/Resources/Localizable.xcstrings @@ -71,7 +71,7 @@ "cardDensity.large": {}, "cardDensity.medium": {}, "cardDensity.small": {}, - "cleanup.cleanedStaleRows": {}, + "cleanup.cleanedOrphanedRows": {}, "cleanup.reclaimAll": {}, "cleanup.removeStoppedContainers.title": {}, "cleanup.removeUnusedImages.title": {}, diff --git a/Sources/ContainedApp/Support/HealthMonitor.swift b/Sources/ContainedApp/Services/Health/HealthMonitor.swift similarity index 98% rename from Sources/ContainedApp/Support/HealthMonitor.swift rename to Sources/ContainedApp/Services/Health/HealthMonitor.swift index f63b0de8..43fec011 100644 --- a/Sources/ContainedApp/Support/HealthMonitor.swift +++ b/Sources/ContainedApp/Services/Health/HealthMonitor.swift @@ -1,6 +1,5 @@ import SwiftUI import ContainedCore -import ContainedRuntime /// Local store of per-container healthchecks (keyed by container id), persisted to UserDefaults. /// Migrated to SwiftData in WS7, alongside personalization. @@ -69,7 +68,7 @@ final class HealthMonitor { func evaluate(snapshots: [ContainerSnapshot], store: HealthCheckStore, - client: any ContainerRuntimeClient, + client: Core.Orchestrator, now: Date = Date()) async { let running = Dictionary(snapshots.filter { $0.state == .running }.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) diff --git a/Sources/ContainedApp/Support/AppLogging.swift b/Sources/ContainedApp/Services/Logging/AppLogging.swift similarity index 100% rename from Sources/ContainedApp/Support/AppLogging.swift rename to Sources/ContainedApp/Services/Logging/AppLogging.swift diff --git a/Sources/ContainedApp/Support/Notifier.swift b/Sources/ContainedApp/Services/Notifications/Notifier.swift similarity index 100% rename from Sources/ContainedApp/Support/Notifier.swift rename to Sources/ContainedApp/Services/Notifications/Notifier.swift diff --git a/Sources/ContainedApp/Support/Platform.swift b/Sources/ContainedApp/Services/Platform/Platform.swift similarity index 100% rename from Sources/ContainedApp/Support/Platform.swift rename to Sources/ContainedApp/Services/Platform/Platform.swift diff --git a/Sources/ContainedApp/Support/UpdateChannel.swift b/Sources/ContainedApp/Services/Updates/UpdateChannel.swift similarity index 100% rename from Sources/ContainedApp/Support/UpdateChannel.swift rename to Sources/ContainedApp/Services/Updates/UpdateChannel.swift diff --git a/Sources/ContainedApp/Support/UpdaterController.swift b/Sources/ContainedApp/Services/Updates/UpdaterController.swift similarity index 100% rename from Sources/ContainedApp/Support/UpdaterController.swift rename to Sources/ContainedApp/Services/Updates/UpdaterController.swift diff --git a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift index 276cc3c4..46eb9e61 100644 --- a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift +++ b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift @@ -1,8 +1,6 @@ import Foundation import Testing import ContainedCore -import ContainedRuntime -import AppleContainerRuntime @testable import ContainedApp @Suite("Container stats streaming") @@ -11,7 +9,7 @@ struct ContainersStoreRefreshTests { @Test func refreshDoesNotRunStatsCommand() async { let runner = RecordingRunner() let store = ContainersStore() - store.client = AppleContainerClient(runner: runner) + store.client = Core.Orchestrator.testing(runner: runner) await store.refresh() @@ -22,7 +20,7 @@ struct ContainersStoreRefreshTests { @Test func streamedStatsUpdateEveryFrameWithoutAppThrottle() async { let runner = RecordingRunner() let store = ContainersStore() - store.client = AppleContainerClient(runner: runner) + store.client = Core.Orchestrator.testing(runner: runner) let clock = TestClock(Date(timeIntervalSinceReferenceDate: 1_000)) store.now = { clock.date } @@ -54,7 +52,7 @@ struct ContainersStoreRefreshTests { @Test func streamedStatsClampTinyIntervalsForCounterRates() async { let runner = RecordingRunner() let store = ContainersStore() - store.client = AppleContainerClient(runner: runner) + store.client = Core.Orchestrator.testing(runner: runner) let clock = TestClock(Date(timeIntervalSinceReferenceDate: 1_000)) store.now = { clock.date } diff --git a/Tests/ContainedAppTests/PreviewFixtureMappingTests.swift b/Tests/ContainedAppTests/PreviewFixtureMappingTests.swift new file mode 100644 index 00000000..9927b65e --- /dev/null +++ b/Tests/ContainedAppTests/PreviewFixtureMappingTests.swift @@ -0,0 +1,42 @@ +import Testing +import ContainedCore +import ContainedCoreFixtures +import ContainedUI +@testable import ContainedApp + +@Suite("Preview fixture mappings") +struct PreviewFixtureMappingTests { + @Test func coreFixturesCanFeedAppOwnedPresentationState() { + let snapshot = Core.Fixtures.AppleContainer.webContainer + let style = Personalization.fixture(for: snapshot) + + #expect(style.nickname == snapshot.displayName) + #expect(style.icon == "shippingbox.fill") + #expect(style.widgets.map(\.metric) == [.cpu, .memory]) + } +} + +private extension Personalization { + static func fixture(for snapshot: ContainerSnapshot) -> Personalization { + var style = Personalization() + style.nickname = snapshot.displayName + style.icon = "shippingbox.fill" + style.tint = .azure + style.fillBackground = true + style.backgroundOpacity = 0.16 + style.gradient = true + style.widgets = [ + WidgetConfiguration(metric: .cpu, + tint: .azure, + icon: "cpu", + style: .area, + showText: true), + WidgetConfiguration(metric: .memory, + tint: .teal, + icon: "memorychip", + style: .area, + showText: true), + ] + return style + } +} diff --git a/Tests/ContainedAppTests/RunSpecTests.swift b/Tests/ContainedAppTests/RunSpecTests.swift index 0dea04f9..ac8fb024 100644 --- a/Tests/ContainedAppTests/RunSpecTests.swift +++ b/Tests/ContainedAppTests/RunSpecTests.swift @@ -1,7 +1,6 @@ import Foundation import Testing import ContainedCore -import AppleContainerRuntime @testable import ContainedApp @Suite("RunSpec create requests + runtime mapping") @@ -115,7 +114,7 @@ struct RunSpecTests { #expect(subsequence(["--publish-socket", "/tmp/app.sock:/run/app.sock"], in: args)) #expect(subsequence(["--env", "KEY=val"], in: args)) #expect(subsequence(["--label", "team=infra"], in: args)) - // restart policy round-trips through the contained.restart label, but personalization never does + // Restart policy round-trips through the contained.restart label; personalization stays local. #expect(args.contains { $0.hasPrefix("contained.restart=") }) #expect(!args.contains { $0.hasPrefix("contained.tint") || $0.hasPrefix("contained.icon") }) } @@ -138,8 +137,8 @@ struct RunSpecTests { interval: 10s retries: 5 """ - let project = try ComposeParser.parse(yaml, projectName: "demo") - let plan = AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: nil) + let project = try Core.Compose.parse(yaml, projectName: "demo") + let plan = try core.translateCompose(project, baseDirectory: nil) let item = try #require(plan.items.first) let spec = RunSpec(request: item.request, healthCheck: item.healthCheck) #expect(spec.image == "postgres:16") @@ -154,7 +153,7 @@ struct RunSpecTests { #expect(spec.healthCheck.retries == 5) } - @Test func composeImportResolvesRelativeBindMountsFromComposeDirectory() { + @Test func composeImportResolvesRelativeBindMountsFromComposeDirectory() throws { let yaml = """ services: app: @@ -163,8 +162,8 @@ struct RunSpecTests { - "../configs/bazarr:/config" """ let base = URL(filePath: "/Volumes/Vault/.Docker/compose", directoryHint: .isDirectory) - let project = try! ComposeParser.parse(yaml, projectName: "demo") - let resolved = AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: base) + let project = try! Core.Compose.parse(yaml, projectName: "demo") + let resolved = try core.translateCompose(project, baseDirectory: base) .items .first? .request @@ -231,11 +230,9 @@ struct RunSpecTests { retries: 3 """ - let project = try ComposeParser.parse(yaml, projectName: "demo") - let plan = AppleContainerCreateTranslator.composePlan( - for: project, - baseDirectory: URL(filePath: "/opt/stacks/demo", directoryHint: .isDirectory) - ) + let project = try Core.Compose.parse(yaml, projectName: "demo") + let plan = try core.translateCompose(project, + baseDirectory: URL(filePath: "/opt/stacks/demo", directoryHint: .isDirectory)) let item = try #require(plan.items.first) let spec = RunSpec(request: item.request, healthCheck: item.healthCheck) let args = spec.arguments() @@ -303,7 +300,8 @@ struct RunSpecTests { var spec = RunSpec() spec.image = "alpine" - let defaults = try #require(AppleContainerCreateTranslator.imageDefaults(for: spec.createRequest, in: images)) + let maybeDefaults = try core.imageDefaults(for: spec.createRequest, in: images) + let defaults = try #require(maybeDefaults) let applied = spec.adoptImageDefaults(from: defaults) #expect(applied >= 3) @@ -324,7 +322,8 @@ struct RunSpecTests { spec.workingDir = "/app" spec.env = [KeyValue(key: "PATH", value: "/custom")] - let defaults = try #require(AppleContainerCreateTranslator.imageDefaults(for: spec.createRequest, in: images)) + let maybeDefaults = try core.imageDefaults(for: spec.createRequest, in: images) + let defaults = try #require(maybeDefaults) _ = spec.adoptImageDefaults(from: defaults) #expect(spec.command == "custom") @@ -430,11 +429,28 @@ struct RunSpecTests { .deletingLastPathComponent() let repositoryRoot = testsDirectory.deletingLastPathComponent() let packageFixtures = repositoryRoot - .appending(path: "Packages/AppleContainerRuntime/Tests/AppleContainerRuntimeTests/Fixtures", + .appending(path: "Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures", directoryHint: .isDirectory) if FileManager.default.fileExists(atPath: packageFixtures.path) { return packageFixtures } return testsDirectory.appending(path: "ContainedCoreTests/Fixtures", directoryHint: .isDirectory) } + + private var core: Core.Orchestrator { + Core.Orchestrator.testing(runner: RunSpecTestRunner()) + } +} + +private struct RunSpecTestRunner: CommandRunning { + func run(_ arguments: [String], + stdin: Data?, + priority: CommandExecutionPriority) async throws -> Data { + Data() + } + + func stream(_ arguments: [String], + priority: CommandExecutionPriority) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } } diff --git a/Tests/ContainedAppTests/UpdaterControllerTests.swift b/Tests/ContainedAppTests/UpdaterControllerTests.swift index 05fdbb25..0d22abc7 100644 --- a/Tests/ContainedAppTests/UpdaterControllerTests.swift +++ b/Tests/ContainedAppTests/UpdaterControllerTests.swift @@ -415,9 +415,9 @@ struct UpdaterControllerTests { Contained - old beta item + previous beta item 120 - 1.0.0-beta.120+old + 1.0.0-beta.120+previous 1.0.0-nightly.119+def456 @@ -442,7 +442,7 @@ struct UpdaterControllerTests { let promotedBuildOccurrences = merged.components(separatedBy: "120").count - 1 #expect(promotedBuildOccurrences == 1) #expect(merged.contains("1.0.0-beta.120+abc123")) - #expect(!merged.contains("old beta item")) + #expect(!merged.contains("previous beta item")) #expect(merged.contains("1.0.0-nightly.119+def456")) } } diff --git a/appcast.xml b/appcast.xml index 8c33bc71..52a07b12 100644 --- a/appcast.xml +++ b/appcast.xml @@ -77,7 +77,7 @@

Personalization & Design System

  • Local-only personalization for containers, image groups, image tags, and volumes, including nickname, icon, tint, background, graph/widget choices, and inheritance from image or app defaults.
  • -
  • Shared Liquid Glass design system primitives: `ResourceGlassCard`, `GlassSurface`, `GlassButton`, `GlassOptionTile`, `MorphPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `CommandPreviewBar`, `InfoButton`, `TintSelector`, `StreamConsole`, `ActivityStatusView`, and toolbar controls.
  • +
  • Shared Liquid Glass design system primitives: `UI.Card.Scaffold`, `UI.Theme.Material`, `UI.Action.Group`, `UI.Control.OptionTile`, `UX.Morph.Expander`, `UI.Panel.Header`, `UI.Panel.Section`, `UI.Panel.Row`, `UI.Panel.Field`, `UI.Command.PreviewBar`, `UI.Control.InfoButton`, `UI.Control.TintSelector`, `UI.Console.Stream`, `UI.State.ActivityStatusIndicator`, and toolbar controls.
  • Accessibility-aware custom visual effects and motion handling, including Reduce Transparency and Reduce Motion support where the app supplies custom glass or animation.
  • Shared page and panel scaffolds so sidebar pages, sheets, and toolbar morphs can reuse content without duplicate layouts.
@@ -122,4 +122,4 @@
- \ No newline at end of file + diff --git a/changes/unreleased/20260701-design-system-package.md b/changes/unreleased/20260701-design-system-package.md index 1a4cf50f..55ee1817 100644 --- a/changes/unreleased/20260701-design-system-package.md +++ b/changes/unreleased/20260701-design-system-package.md @@ -1 +1 @@ -- Extracted the shared Liquid Glass design system into a local `ContainedDesignSystem` Swift package, including tokens, panel/page/sheet scaffolds, resource-card chrome, toolbar controls, sparklines, JSON/stream surfaces, gradient controls, clipboard helper, reusable micro-primitives for badges, keycaps, status dots, metric tiles, terminal chrome, and selection overlays, plus package-local README and DocC entry-point documentation. +- Extracted the shared Liquid Glass design system into a local `ContainedUI` Swift package, including tokens, panel/page/sheet scaffolds, resource-card chrome, toolbar controls, sparklines, JSON/stream surfaces, gradient controls, clipboard helper, reusable micro-primitives for badges, keycaps, status dots, metric tiles, terminal chrome, and selection overlays, plus package-local README and DocC entry-point documentation. diff --git a/changes/unreleased/20260701-navigation-package.md b/changes/unreleased/20260701-navigation-package.md index 33fa81a9..f2b737be 100644 --- a/changes/unreleased/20260701-navigation-package.md +++ b/changes/unreleased/20260701-navigation-package.md @@ -1 +1 @@ -- Started the reusable navigation package by moving toolbar safe-area policy, morph target geometry, morph expander, and morph panel scaffolding into `ContainedNavigation`, with package-local README and DocC entry-point documentation. +- Started the reusable navigation package by moving toolbar safe-area policy, morph target geometry, morph expander, and morph panel scaffolding into `ContainedUX`, with package-local README and DocC entry-point documentation. diff --git a/changes/unreleased/20260701-runtime-abstraction.md b/changes/unreleased/20260701-runtime-abstraction.md index dd8a55a6..883963ab 100644 --- a/changes/unreleased/20260701-runtime-abstraction.md +++ b/changes/unreleased/20260701-runtime-abstraction.md @@ -1 +1 @@ -- Split runtime contracts into `ContainedRuntime` and the current Apple `container` adapter into `AppleContainerRuntime`, with open-ended runtime identifiers, typed stats streams, runtime-neutral create/import models, adapter-owned Compose/image-default translation, and disabled per-container core selection so future Docker-compatible or other engine adapters can be added as sibling modules. +- Consolidate runtime contracts, the Core orchestrator, and the current Apple `container` adapter into `ContainedCore`, with open-ended runtime identifiers, typed stats streams, runtime-neutral create/import models, Core-owned Compose/image-default translation, and disabled per-container core selection so future Docker-compatible or other engine adapters can be added inside Core. diff --git a/changes/unreleased/20260702-card-anatomy.md b/changes/unreleased/20260702-card-anatomy.md index 17ca7629..76c56361 100644 --- a/changes/unreleased/20260702-card-anatomy.md +++ b/changes/unreleased/20260702-card-anatomy.md @@ -1,5 +1,5 @@ -- Centralized resource-card anatomy in `ContainedDesignSystem`: headers stay visible, expanded bodies own density-specific embedded content, large widgets remain sticky, medium widgets move into the body, and small footers move into the body. +- Centralized resource-card anatomy in `ContainedUI`: headers stay visible, expanded bodies own density-specific embedded content, large widgets remain sticky, medium widgets move into the body, and small footers move into the body. - Routed remaining card widget/footer grouping through packaged card primitives, moved expanded page controls into the shared header trailing slot, kept that slot layout-stable during expansion, and updated design-system docs with the sticky/body slot contract. - Added a packaged sticky header text lane so card titles and subtitles stay anchored with the leading icon chip while expanded controls and page selections change. -- Introduced `DesignCard` as the app-facing resource-card API, including typed page configuration and named icon/title/subtitle/header/body/widget/footer slots, and migrated app cards away from direct `DesignCardSurface`/header composition. +- Introduced `UI.Card.Scaffold` as the app-facing resource-card API, including typed page configuration and named icon/title/subtitle/header/body/widget/footer slots, and migrated app cards away from direct `card surface internals`/header composition. - Added semantic design-system action, toggle, toolbar, selection bar, progress, and status banner routes, then migrated app command chrome away from direct glass button/surface styling. diff --git a/changes/unreleased/20260702-refresh-stats-performance.md b/changes/unreleased/20260702-refresh-stats-performance.md index a1932467..28fe232c 100644 --- a/changes/unreleased/20260702-refresh-stats-performance.md +++ b/changes/unreleased/20260702-refresh-stats-performance.md @@ -18,7 +18,7 @@ - Removed user-facing JSON Inspect pages and row actions from containers, images, networks, and volumes; image details now use expanded-card pages for Tags, History, Add Tag, and Push. - Made mini CPU/memory chip percentages preserve tiny sub-1% values so real streamed CPU changes do not disappear behind whole-percent rounding. - Made CPU/memory percentage readouts consistent across cards, live stats, history charts, and mini chips: whole percentages stay clean, while only sub-1% values show the decimals needed. -- Hardened embedded terminal teardown so rapid card/tab switching does not leave stale `container exec --tty` children behind. +- Hardened embedded terminal teardown so rapid card/tab switching cleans up `container exec --tty` children. - Coalesced image-panel appearance refreshes so page/panel navigation does not force duplicate image-list reloads while users click through the UI. - Reduced runtime layout churn by replacing the container grid's bound card-frame preference with coalesced frame tracking and clamping morph panel geometry before it reaches SwiftUI frames. - Skipped empty changelog sections in the in-app What's New parser so nightly builds do not render a blank "Changes Since Last Nightly" block before full release notes. diff --git a/changes/unreleased/20260702-standalone-packages-xcode.md b/changes/unreleased/20260702-standalone-packages-xcode.md index 35c5cf7c..3faba4a1 100644 --- a/changes/unreleased/20260702-standalone-packages-xcode.md +++ b/changes/unreleased/20260702-standalone-packages-xcode.md @@ -1,6 +1,6 @@ -- Moved core, runtime, Apple adapter, design-system, navigation, and preview fixtures into standalone local packages with package-local docs, DocC landing pages, and tests. +- Consolidated backend orchestration into `ContainedCore`, moved visuals into `ContainedUI`, moved interaction infrastructure into `ContainedUX`, and kept preview fixture data as non-shipping Core fixture surfaces with package-local docs, DocC landing pages, and tests. - Split the SwiftUI app implementation into `ContainedApp` with a tiny SwiftPM launcher, while keeping app-owned localization, stores, Sparkle, SwiftData, settings, and feature routing out of reusable packages. -- Replaced the legacy Xcode shell wrapper with a checked-in native macOS app target that links `ContainedApp`, builds/runs `Contained.app` directly, and includes an app test bundle for SwiftUI-focused functional checks. -- Reorganized docs into app, feature, architecture, development, and release sections, updated package boundary guidance, and removed stale flat-wiki and wrapper-build wording. -- Tightened final boundary leaks by routing terminal `exec` through `ContainerCommands`, making service lifecycle actions typed runtime values, and removing app-specific wording from reusable package comments/previews. +- Added a checked-in native macOS app target that links `ContainedApp`, builds/runs `Contained.app` directly, and includes an app test bundle for SwiftUI-focused functional checks. +- Reorganized docs into app, feature, architecture, development, and release sections with current package boundary guidance. +- Tightened final boundary leaks by routing terminal `exec` through Core command surfaces, making service lifecycle actions typed runtime values, and keeping app-specific wording out of reusable package comments/previews. - Updated ownership and release-note enforcement so `Packages/**` changes are covered by repository housekeeping after the package split. diff --git a/docs/app/Localization.md b/docs/app/Localization.md index edf35a1a..c244698a 100644 --- a/docs/app/Localization.md +++ b/docs/app/Localization.md @@ -6,11 +6,10 @@ localization-ready APIs. ## Ownership - `Sources/ContainedApp` owns all user-facing strings and the localization catalog. -- `ContainedDesignSystem` and `ContainedNavigation` are building-block packages: +- `ContainedUI` and `ContainedUX` are building-block packages: they own structure and visuals, not app copy. -- `ContainedCore`, `ContainedRuntime`, and runtime adapters should stay - language-free except for stable technical identifiers, raw values, and command - output. +- `ContainedCore` should stay language-free except for stable technical + identifiers, raw values, package error codes, and backend command output. If a package component needs visible text, add an explicit parameter instead of adding an English default in the package. Examples include action help, close @@ -30,7 +29,7 @@ it to a known typed case. Use `AppText` for reusable app-owned labels and dynamic templates: ```swift -DesignToolbarSearchField(text: $query, +UI.Toolbar.SearchField(text: $query, prompt: "Search this page", clearSearchLabel: AppText.clearSearch, focused: $focused, @@ -38,11 +37,11 @@ DesignToolbarSearchField(text: $query, EmptyView() } -TintSelector(selection: $settings.accentTint) { +UI.Control.TintSelector(selection: $settings.accentTint) { $0.localizedDisplayName } -DesignSelectionActionBar(count: selection.count, +UI.Action.SelectionBar(count: selection.count, countLabel: AppText.selectedCount, actions: actions) diff --git a/docs/architecture/Architecture.md b/docs/architecture/Architecture.md index a1bc3e75..9c46b4b1 100644 --- a/docs/architecture/Architecture.md +++ b/docs/architecture/Architecture.md @@ -2,32 +2,34 @@ Contained is a SwiftUI-native macOS app that wraps Apple's `container` CLI. It shells out to public CLI commands, usually with `--format json`, and decodes typed models. Visible container stats are the exception: Apple container only streams stats in table mode, so Contained parses that public table stream behind the same runtime boundary. There is no private API or daemon. -``` - SwiftUI Views ──> @Observable Stores ──> ContainerRuntimeClient ──> Runtime Adapter - (Features/*) (AppModel, …) (ContainedRuntime) (AppleContainerRuntime) - ^ │ │ │ - └──── ContainedDesignSystem ───────────────────┘ ▼ - └──────── decoded models / argv builders (ContainedCore) +```text + SwiftUI Views ──> @Observable Stores ──> Core.Orchestrator ──> Core runtime adapters + (Features/*) (AppModel, …) (ContainedCore) (Runtimes/AppleContainer, future engines) + ^ │ │ + └──── ContainedUI + ContainedUX ───────────────┘ ``` ## Targets -- **`ContainedCore`** — pure, testable logic: models, open-ended `RuntimeKind`, runtime-neutral create/recreate request fields, JSON decoding, compose parsing, Apple `container` argv builders, and ordering/decision helpers. Depends only on Yams. No SwiftUI. -- **`ContainedRuntime`** — shared runtime contracts: `ContainerRuntimeClient`, `RuntimeDescriptor`, `RuntimeCapability`, translation plans, `CommandError`, and command execution primitives. It is adapter-neutral and should not contain Apple-, Docker-, or UI-specific policy. -- **`AppleContainerRuntime`** — the current Apple `container` adapter: `AppleContainerClient`, `AppleContainerCLILocator`, `CommandRunner` usage, Apple create/import/default translation, and the Apple stats-table parser. Future runtime engines should be sibling adapter targets that conform to `ContainerRuntimeClient`. -- **`ContainedDesignSystem`** — a local reusable Swift package for app-agnostic SwiftUI/AppKit visual primitives. It must not depend on stores, Sparkle, SwiftData, app routing, or feature modules. -- **`ContainedNavigation`** — a local reusable Swift package for navigation and layout infrastructure that should not own app-specific routing. It currently owns toolbar safe-area policy/measurement primitives. -- **`ContainedApp`** — the shared SwiftUI app implementation: views, `@Observable` stores, app-specific presentation mappings, localization, navigation, and the SwiftData history stack. Depends on `ContainedCore`, `ContainedRuntime`, `AppleContainerRuntime`, `ContainedDesignSystem`, `ContainedNavigation`, `ContainedPreviewSupport`, SwiftTerm, and Sparkle. +- **`ContainedCore`** — the single backend/orchestration package. It owns `Core.*` namespaces for runtime descriptors/capabilities, canonical container models, command previews, command execution, Compose import/export semantics, Apple `container` adapter internals, metrics, typed display-neutral errors, and future migration/export planning. It depends on Foundation and Yams only. No SwiftUI. +- **`ContainedUI`** — a local reusable Swift package for app-agnostic SwiftUI/AppKit visual primitives. It must not depend on stores, Sparkle, SwiftData, app routing, or feature modules. +- **`ContainedUX`** — a local reusable Swift package for navigation and layout infrastructure that should not own app-specific routing. It currently owns toolbar safe-area policy/measurement primitives. +- **`ContainedApp`** — the shared SwiftUI app implementation: views, `@Observable` stores, app-specific presentation mappings, localization, navigation, and the SwiftData history stack. Depends on `ContainedCore`, `ContainedUI`, `ContainedUX`, SwiftTerm, and Sparkle. +- **`ContainedCoreFixtures`** — a separate dev/test product inside the Core package that exposes deterministic semantic samples under `Core.Fixtures.*`. Normal app, debug bundle, release, notarized, and non-notarized distributable targets must not link it. - **`Contained`** — the tiny SwiftPM executable launcher used by command-line builds and bundle scripts. +Ownership shorthand: UI owns visuals, UX owns interaction/morph/panel movement, +Core owns backend orchestration, and ContainedApp joins those packages with +localization, persistence, settings, routing, and feature policy. + `ContainedApp` owns localization. Reusable packages do not ship localized resources or English UI defaults; app code supplies user-facing text through package parameters and routes reusable enum labels/dynamic templates through -`AppText`. `ContainedCore`, `ContainedRuntime`, and adapter targets stay -language-free unless they expose technical identifiers such as raw values, -runtime descriptors, or command output. +`AppText`. `ContainedCore` stays language-free unless it exposes technical +identifiers such as raw values, runtime descriptors, package error codes, or +backend command output. -Package errors follow the same ownership boundary. Core/runtime packages expose +Package errors follow the same ownership boundary. Core exposes stable codes and compact context through `ContainedPackageError`; the app maps those failures through `AppErrorPresentation` and `AppText` before showing toasts, inline errors, alerts, or Activity history. Arbitrary backend stderr is @@ -37,11 +39,8 @@ case. Package-local docs: - [`Packages/ContainedCore/README.md`](../../Packages/ContainedCore/README.md) -- [`Packages/ContainedRuntime/README.md`](../../Packages/ContainedRuntime/README.md) -- [`Packages/AppleContainerRuntime/README.md`](../../Packages/AppleContainerRuntime/README.md) -- [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) -- [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md) -- [`Packages/ContainedPreviewSupport/README.md`](../../Packages/ContainedPreviewSupport/README.md) +- [`Packages/ContainedUI/README.md`](../../Packages/ContainedUI/README.md) +- [`Packages/ContainedUX/README.md`](../../Packages/ContainedUX/README.md) `Contained.xcworkspace` is the Xcode entry point. It contains a checked-in native `Contained.xcodeproj` app target with a tiny Xcode launcher in @@ -52,20 +51,19 @@ native `ContainedAppTests` bundle; local package schemes come from their package manifests. SwiftPM remains the source of truth for CI, package tests, release bundles, signing, notarization, and appcast scripts. -## Runtime wrapper +## Core Runtime Wrapper -- **`ContainerCommands`** — pure argv builders, side-effect-free so golden tests assert the exact arguments (the "Reveal CLI" affordances read from the same source of truth). -- **`CommandRunner`** — shared command-execution primitive used by CLI-backed adapters. It runs one-shot commands (`run`) or streaming commands (`stream`, an `AsyncThrowingStream`) at the requested priority. Passwords are piped via `--password-stdin`, never argv. -- **`ContainedPackageError`** — display-neutral error metadata shared by reusable packages. It gives the app a package name, stable code, and context without forcing packages to own localized copy. -- **`ContainerCreateRequest`** — runtime-neutral create/recreate fields used by the app form and adapter import/default translation. It carries the intended `RuntimeKind` per container so future adapters can fill the same global run/edit form without making the core choice app-wide. -- **`AppleContainerClient`** — the Apple `container` implementation of `ContainerRuntimeClient`; returns decoded models and maps decode failures to a single `CommandError`. -- **`ContainerStatsTableParser`** — Apple-adapter parser for the ANSI table emitted by `container stats --format table`. It converts table frames into runtime-agnostic snapshots inside `AppleContainerRuntime`. -- **`ContainerRuntimeClient`** — the backend-facing operation contract. `RuntimeDescriptor`, open-ended `RuntimeKind`, and `RuntimeCapability` advertise what a selected runtime can do before adapter-specific UI routes enable a command. See [Runtime Adapters](Runtime-Adapters.md). +- **`Core.Orchestrator`** — the only backend object app stores own. It bootstraps the Apple CLI today, exposes available runtime descriptors, routes selected-runtime operations, and returns typed command invocations for host-owned UI integrations such as SwiftTerm. +- **`Core.Runtime.Kind` / `Core.Runtime.Descriptor` / `Core.Runtime.Capability`** — open runtime identifiers and support metadata. Future engines register descriptors inside Core; the app reads capabilities instead of switching on backend names. +- **`Core.Container.CreateRequest`** — runtime-neutral create/recreate fields used by the app form and adapter import/default translation. It carries the intended runtime per container so the core choice is not app-global. +- **`Core.Compose`** — Core-level interchange semantics for Compose import/export. Yams is internal to `Core.Compose.YAML`; public APIs expose Core models and typed plans, never Yams types. +- **`Runtimes/AppleContainer`** — Core-internal Apple adapter implementation. It owns CLI discovery, command execution, Apple create/import/default translation, command builders, and the Apple stats-table parser. +- **`Core.Error.PackageError`** — display-neutral error metadata shared by reusable packages. It gives the app a package name, stable code, and context without forcing packages to own localized copy. ## Stores (app) -- **`AppModel`** — root state: selects/bootstrap the runtime adapter, owns the runtime client + feature stores, tracks bootstrap status, wires logging/updating, and runs the per-tick coordination. Focused extensions own image/resource style lookup, image-update sweeps, and configuration import/export. -- **`ContainersStore`** — the container list, live stats deltas, streamed stats conversion, and lifecycle actions against `any ContainerRuntimeClient`. +- **`AppModel`** — root state: bootstraps `Core.Orchestrator`, owns feature stores, tracks bootstrap status, wires logging/updating, and runs the per-tick coordination. Focused extensions own image/resource style lookup, image-update sweeps, and configuration import/export. +- **`ContainersStore`** — the container list, live stats deltas, streamed stats conversion, and lifecycle actions against `Core.Orchestrator`. - **`RefreshCoordinator`** — adaptive polling for service/list refreshes. Stats are maintained app-wide by one utility-priority runtime stats stream for the running containers, so normal refreshes and lifecycle actions relist containers without forcing vanity stats. - **`RestartWatchdog`** — app-managed restart policy (`container` has no native `--restart`); diffs states each tick and re-issues `start` with backoff. - **`HealthMonitor`** — app-managed healthchecks: interval-gated `exec` probes with consecutive-failure tracking. @@ -76,7 +74,7 @@ bundles, signing, notarization, and appcast scripts. ## Design system -Liquid Glass helpers and reusable primitives include `DesignPanelScaffold`, `PanelHeader`, `PanelSection`, `PanelRow`, `PanelField`, `DesignCard`, `DesignCardInsetSection`, `DesignActionGroup`, `DesignTextActionButton`, `DesignToggleButton`, `DesignSelectionActionBar`, `DesignStatusBanner`, `DesignContentSurface`, `DesignInputSurface`, `CommandPreviewBar`, `InfoButton`, `DesignStatusBadge`, `DesignKeyCap`, `LiveSparkline`, and `DesignTokens` groups for toolbar, panel, spacing, radius, icon sizing, design cards, badges, charts, terminal chrome, and form widths. `ContainedDesignSystem` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, glass button styles, or micro-chrome recipes. App-side cards use `DesignCard`; card shell/header/page-rail assembly and low-level glass button/surface routes are package-internal. App-state-aware mappings such as runtime status and graph metric extraction stay in `ContainedApp` until they can cross the boundary without depending on app/core policy. Use the package READMEs for import instructions and copy-pasteable examples, and see [Design System](Design-System.md) for app-level conventions. +Liquid Glass helpers and reusable primitives include `UI.Panel.Scaffold`, `UI.Panel.Header`, `UI.Panel.Section`, `UI.Panel.Row`, `UI.Panel.Field`, `UI.Card.Scaffold`, `UI.Card.InsetSection`, `UI.Action.Group`, `UI.Action.TextButton`, `UI.Action.ToggleButton`, `UI.Action.SelectionBar`, `UI.State.Banner`, `UI.Surface.Content`, `UI.Surface.Input`, `UI.Command.PreviewBar`, `UI.Control.InfoButton`, `UI.Badge.Status`, `UI.Control.KeyCap`, `UI.Chart.Sparkline`, and `UI.Tokens` groups for toolbar, panel, spacing, radius, icon sizing, design cards, badges, charts, terminal chrome, and form widths. `ContainedUI` owns app-agnostic visual tokens and primitives; feature code should not introduce local spacing, radius, material, shadow, opacity, surface modifiers, glass button styles, or micro-chrome recipes. App-side cards use `UI.Card.Scaffold`; card shell/header/page-rail assembly and low-level glass button/surface routes are package-internal. App-state-aware mappings such as runtime status and graph metric extraction stay in `ContainedApp` until they can cross the boundary without depending on app/core policy. Use the package READMEs for import instructions and copy-pasteable examples, and see [Design System](Design-System.md) for app-level conventions. ## Local-only personalization diff --git a/docs/architecture/Design-System.md b/docs/architecture/Design-System.md index 79c9dafd..51499bbe 100644 --- a/docs/architecture/Design-System.md +++ b/docs/architecture/Design-System.md @@ -3,16 +3,16 @@ Contained's UI is built around a small Liquid Glass design system. Prefer these components before adding one-off surfaces. -App-agnostic SwiftUI/AppKit primitives live in the local `ContainedDesignSystem` +App-agnostic SwiftUI/AppKit primitives live in the local `ContainedUI` package under `Packages/`. App-specific views, stores, settings, routing, localization, and domain presentation mappings stay in `ContainedApp` until they have a clean reusable boundary. Package-local docs: -- [`Packages/ContainedDesignSystem/README.md`](../../Packages/ContainedDesignSystem/README.md) -- [`ContainedDesignSystem` DocC landing page](../../Packages/ContainedDesignSystem/Sources/ContainedDesignSystem/ContainedDesignSystem.docc/ContainedDesignSystem.md) -- [`Packages/ContainedNavigation/README.md`](../../Packages/ContainedNavigation/README.md) +- [`Packages/ContainedUI/README.md`](../../Packages/ContainedUI/README.md) +- [`ContainedUI` DocC landing page](../../Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md) +- [`Packages/ContainedUX/README.md`](../../Packages/ContainedUX/README.md) The package owns the shared tokens, visual-effect background bridge, exterior shadow, glass surface modifier, panel/page/sheet scaffolds, toolbar controls, @@ -35,8 +35,8 @@ Guidelines: - user-facing labels, help text, accessibility labels, picker names, page names, and empty-state copy are supplied by `Sources/ContainedApp` - package APIs that need words take app-supplied strings or semantic item - titles, such as `DesignCardPages.closeLabel`, - `DesignToolbarSearchField.clearSearchLabel`, and `TintSelector`'s + titles, such as `UI.Card.Pages.closeLabel`, + `UI.Toolbar.SearchField.clearSearchLabel`, and `UI.Control.TintSelector`'s `labelForTint` - app-owned enum labels and dynamic templates flow through `AppText`, which uses `String(localized:defaultValue:bundle:)` with English fallbacks today @@ -63,28 +63,28 @@ package genuinely owns standalone user-facing copy. Style ownership: - `Personalization` is the resolved card style. -- `WidgetConfiguration` owns app-side metric-widget schema. `GraphStyle` and - `WidgetInterpolation` live in the design package as graph rendering options. -- `PersonalizationStore` owns persistence, inheritance, backup, and legacy - `contained.*` label migration. +- `WidgetConfiguration` owns app-side metric-widget schema. `UI.Chart.GraphStyle` and + `UI.Chart.Interpolation` live in the design package as graph rendering options. +- `PersonalizationStore` owns persistence, inheritance, backup, and local-only + style resolution. ## Panel scaffolding -Use `DesignPanelScaffold` for toolbar panels. It provides the shared chrome, +Use `UI.Panel.Scaffold` for toolbar panels. It provides the shared chrome, content, and footer structure used by Images, Templates, Activity, System, Settings, and the Command Palette. Guidelines: - keep panels anchored to their toolbar source when possible -- use `PanelHeader` for titled panels -- omit `PanelHeader` when the primary control is itself the header, such as the +- use `UI.Panel.Header` for titled panels +- omit `UI.Panel.Header` when the primary control is itself the header, such as the Command Palette search field - keep footer hints compact and secondary ## Settings-style editors -Use `PanelSection`, `PanelRow`, `PanelField`, and `PanelToggleRow` for dense +Use `UI.Panel.Section`, `UI.Panel.Row`, `UI.Panel.Field`, and `PanelToggleRow` for dense settings and editor surfaces inside glass panels. This keeps Customize, Run/Edit, registry login, image build, and Settings aligned on one row rhythm and one info-button placement model. @@ -96,7 +96,7 @@ Guidelines: trailing info buttons - split repeated editors into focused subviews when the parent sheet also owns persistence or presentation state -- use `SheetHeader` for modal sheets and `PanelHeader` for in-window morph +- use `UI.Panel.SheetTitleBar` for modal sheets and `UI.Panel.Header` for in-window morph panels or embedded panel pages ## Toolbar shell @@ -108,7 +108,7 @@ or fall back to classic pages and sheets. `AppToolbar` is mounted inside the `NavigationSplitView` detail column by `ClassicShell`, not across the whole split view. The detail body receives top -padding from `MorphSafeAreaManager`, while the sidebar and bottom page edge keep +padding from `UX.SafeArea.Manager`, while the sidebar and bottom page edge keep native split-view layout. Scrollable page interiors add bottom scroll-content clearance for the floating toolbar, so the last row can move above it without lifting the page itself. Toolbar page actions live in the top row to the left of @@ -138,51 +138,51 @@ safe-area contract as morph panels, clearing both top and bottom toolbar bands. Use package-owned semantic controls for command chrome: -- `DesignActionGroup` and `DesignActionItems` for icon action groups -- `DesignActionCluster` for mixed menu/action capsules -- `DesignInputCluster` for inline search/input lanes -- `DesignTextActionButton` for labeled standard or prominent actions -- `DesignToggleButton` for toggle buttons in toolbar or panel chrome -- `DesignSelectionActionBar` for floating selection bars -- `DesignStatusBanner` for transient bottom banners -- `DesignToolbarSearchField`, `DesignMenuButton`, - `DesignToolbarStatusButton`, `DesignToolbarActionCluster`, and - `DesignToolbarVanitySlot` for toolbar-specific slots - -Feature views cannot call the package-internal `GlassButton`, `GlassButtonItem`, -`GlassButtonInputItem`, `glassSurface`, or `glassCapsuleSurface` routes. They +- `UI.Action.Group` and `UI.Action.Items` for icon action groups +- `UI.Action.Cluster` for mixed menu/action capsules +- `UI.Control.InputCluster` for inline search/input lanes +- `UI.Action.TextButton` for labeled standard or prominent actions +- `UI.Action.ToggleButton` for toggle buttons in toolbar or panel chrome +- `UI.Action.SelectionBar` for floating selection bars +- `UI.State.Banner` for transient bottom banners +- `UI.Toolbar.SearchField`, `UI.Control.MenuButton`, + `UI.Toolbar.StatusButton`, `UI.Toolbar.ActionCluster`, and + `UI.Toolbar.VanitySlot` for toolbar-specific slots + +Feature views cannot call the package-internal `material button internals`, `material button internalsItem`, +`material button internalsInputItem`, `materialSurface`, or `materialCapsuleSurface` routes. They also should not use `.buttonStyle(.glass/.glassProminent)` directly. If a view needs a new command shape, add a named design-system route and then consume it from the app. ## Design cards -Use `DesignCard` for containers, images, tags, volumes, networks, and +Use `UI.Card.Scaffold` for containers, images, tags, volumes, networks, and palette result cards. Recommended inputs and package pieces: -- `DesignCardPages` for expanded-card page rails -- `DesignCardIconChip` for icons and symbols -- `DesignCardTextStyle` for standard versus monospaced title/subtitle text -- `DesignBadgeText` for compact state or kind labels -- `DesignCardFooterMini` for small footer actions and metrics -- `DesignCardWidgetGroup` for horizontal widget metadata -- `DesignCardFooterChip` and `DesignCardFooterButton` for card-local controls -- `DesignCardInsetSection` for charts, lists, and read-only groups inside an +- `UI.Card.Pages` for expanded-card page rails +- `UI.Card.IconChip` for icons and symbols +- `UI.Card.TextStyle` for standard versus monospaced title/subtitle text +- `UI.Badge.Text` for compact state or kind labels +- `UI.Card.FooterMini` for small footer actions and metrics +- `UI.Card.WidgetGroup` for horizontal widget metadata +- `UI.Card.FooterChip` and `UI.Card.FooterButton` for card-local controls +- `UI.Card.InsetSection` for charts, lists, and read-only groups inside an expanded card body - `designCardFloatingControls` and `designCardProgressOverlay` for card-owned overlays -- `DesignStatusDot`, `DesignStatusBadge`, `DesignKeyCap`, and - `DesignKeyboardHint` for micro chrome +- `UI.Badge.Dot`, `UI.Badge.Status`, `UI.Control.KeyCap`, and + `UI.Control.KeyboardHint` for micro chrome Use `isSelected` instead of inventing a second selection ring. Use `elevated: false` for cards inside already-elevated morph panels. -`DesignCard` owns the card anatomy: +`UI.Card.Scaffold` owns the card anatomy: - the header is always visible and stays outside the expanding body -- page controls are declared with `DesignCardPages`, stay mounted in the header +- page controls are declared with `UI.Card.Pages`, stay mounted in the header trailing slot, and use `controlsReveal` instead of app-local overlays or conditional trailing views - the body appears only while expanded @@ -191,13 +191,13 @@ false` for cards inside already-elevated morph panels. - footers stay sticky on `.medium` and `.large` cards and move into the expanded body on `.small` -`DesignCardSurface`, `DesignCardHeader`, and `DesignCardPageControls` are -package-internal composition pieces used by `DesignCard`. +`card surface internals`, `card header internals`, and `card page-control internals` are +package-internal composition pieces used by `UI.Card.Scaffold`. -Do not create a second `DesignCard` or direct surface modifier inside an +Do not create a second `UI.Card.Scaffold` or direct surface modifier inside an expanded card body unless the nested object is itself an independent resource card, such as an image tag row. In-card content should go through -`DesignCardInsetSection`. +`UI.Card.InsetSection`. ## Palette visual results @@ -212,31 +212,34 @@ The palette should not degrade rich app objects into plain text. Use Plain rows are reserved for generic actions such as refresh or opening a page. -## DesignTokens - -Use `DesignTokens` for spacing, radius, toolbar dimensions, panel sizes, icon sizes, -form widths, chart sizes, badge/keycap insets, design-card opacities, terminal -chrome, and shadows. Feature views should not call low-level surface modifiers -or glass button styles; use named package routes such as `DesignCard`, -`PanelSection`, `DesignContentSurface`, `DesignInputSurface`, -`DesignActionGroup`, `DesignActionCluster`, `DesignInputCluster`, -`DesignTextActionButton`, and `DesignCardInsetSection`. -If a new visual value appears, add a token or package primitive before using it -in the app. - -Important groups: - -- `DesignTokens.Toolbar` for toolbar band and control sizing -- `DesignTokens.PanelSize` for morph target sizes -- `DesignTokens.Space` for layout rhythm -- `DesignTokens.Radius` for card and control rounding -- `DesignTokens.IconSize` for chips and toolbar controls -- `DesignTokens.DesignCard`, `DesignTokens.Badge`, `DesignTokens.Keyboard`, `DesignTokens.Chart`, - `DesignTokens.FormWidth`, and `DesignTokens.Terminal` for smaller repeated chrome values - -Feature views can choose semantic content, domain colors, and app data, but -should not create app-local spacing, padding, radius, shadow, material, opacity, -badge, keycap, status-dot, or terminal-surface recipes. +## Tokens + +`UI.Tokens` is the minimal raw token source for `ContainedUI` internals. UI +components may read those raw tokens directly so one primitive change can flow +through every visual element that mirrors it. + +App-facing and UX-facing code should use contextual element tokens instead: + +- `UI.Toolbar.Size` for toolbar band and control sizing +- `UI.Panel.Size`, `UI.Panel.Padding`, `UI.Panel.Spacing`, and `UI.Panel.Radius` + for panels and morph targets +- `UI.Card.Padding`, `UI.Card.Spacing`, `UI.Card.Radius`, and `UI.Card.Metric` + for card anatomy +- `UI.Layout.Spacing` only when layout rhythm has no more specific element owner +- `UI.Control.Size`, `UI.Badge.Padding`, `UI.Form.Width`, `UI.Chart.Size`, and + similar element namespaces for repeated smaller chrome values + +Contextual tokens mirror `UI.Tokens` by default. If an element needs its own +value, the declaration must include a short inline comment explaining why that +token intentionally diverges from the raw default. + +Feature views should not call low-level surface modifiers, material button +styles, or raw `UI.Tokens`; use named package routes such as +`UI.Card.Scaffold`, `UI.Panel.Section`, `UI.Surface.Content`, +`UI.Surface.Input`, `UI.Action.Group`, `UI.Action.Cluster`, +`UI.Control.InputCluster`, `UI.Action.TextButton`, and `UI.Card.InsetSection`. +If a new visual value appears, add or extend a contextual element token or +package primitive before using it in the app. ## Verification diff --git a/docs/architecture/Runtime-Adapters.md b/docs/architecture/Runtime-Adapters.md index 1d8ec7d7..cc8e03e9 100644 --- a/docs/architecture/Runtime-Adapters.md +++ b/docs/architecture/Runtime-Adapters.md @@ -1,93 +1,74 @@ -# Runtime Adapters - -Contained's app-facing runtime boundary is split into shared contracts and -concrete adapters: - -- `ContainedRuntime` owns `ContainerRuntimeClient`, `RuntimeDescriptor`, - `RuntimeCapability`, runtime translation plans, `CommandError`, and command - execution primitives. -- `AppleContainerRuntime` owns the current Apple `container` CLI adapter: - `AppleContainerClient`, `AppleContainerCLILocator`, Apple create/import/default - translation, and the Apple table stats parser. -- `ContainedCore` owns pure models, JSON decoding, compose parsing, decision - helpers, open-ended `RuntimeKind`, runtime-neutral create/recreate request - fields, and `ContainerCommands` argv builders. -- `Sources/ContainedApp` owns app state, settings, stores, SwiftUI, SwiftData, and - app-specific presentation mapping. +# Runtime Orchestration + +Contained's app-facing backend boundary is `ContainedCore`. + +- `Core.Orchestrator` is the only backend object app stores own. +- `Core.Runtime` owns runtime descriptors, capabilities, selected-runtime checks, and unsupported-operation errors. +- `Core.Compose` owns Compose as a cross-runtime interchange format. Yams is internal to `Core.Compose.YAML`. +- `Core.Container` owns canonical create/edit/import/export models. +- `Core.Command` owns command previews, process execution, and host invocations. +- Core-internal adapters, beginning with `Runtimes/AppleContainer`, translate canonical models to backend-specific behavior. + +The app owns settings, routing, persistence, localization, Activity presentation, +and user decisions. It does not create adapter clients, call Apple CLI locators, +or assemble backend argv. ## Adapter Shape -Runtime adapters are sibling SwiftPM targets. The current adapter is -`AppleContainerRuntime`; future engines such as Docker-compatible, Podman, -Lima-backed, remote, or other runtimes should be added as new adapter targets -that conform to `ContainerRuntimeClient`. +Runtime adapters are folders inside `ContainedCore`, not standalone app +dependencies. The current adapter is Apple container. Future engines such as +Docker-compatible, Podman, Lima-backed, remote, or other runtimes should be +added as sibling adapter folders under Core and registered with +`Core.Orchestrator`. + +Do not add backend `switch` statements to SwiftUI views or stores. Stores call +Core. Core decides which adapter handles a selected runtime and returns typed +errors or unavailable plans when a capability is missing. + +`Core.Runtime.Kind` is an open raw-value type, not a closed enum. New adapters +can define stable identifiers without forcing app-store or SwiftUI changes. Use +`Core.Runtime.Capability` and `Core.Runtime.Descriptor` to advertise support +before a UI route enables a command. -Do not add backend `switch` statements to SwiftUI views or stores. Stores should -depend on `any ContainerRuntimeClient`, while bootstrap/configuration chooses the -concrete adapter. +## Create, Import, Export, And Core Choice -`RuntimeKind` is an open raw-value type, not a closed enum. New adapters can -define their own stable identifiers without editing the shared runtime package. -Use `RuntimeCapability` and `RuntimeDescriptor` to advertise support before a UI -route enables a command. +The global Run/Edit form is app-owned form state, but it round-trips through +`Core.Container.CreateRequest`, a runtime-neutral model. Each request carries +its intended runtime, so the core choice is per-container or per-import item +rather than a global app setting. -## Create, Import, And Core Choice +Core translates into and out of the shared model: -The global Run/Edit form is app-owned form state, but it now round-trips through -`ContainerCreateRequest`, a runtime-neutral model in `ContainedCore`. Each -request carries its intended `RuntimeKind`, so the core choice is per-container -or per-import item rather than a global app setting. +- `previewCreateCommand(for:)` returns the command preview for the selected runtime. +- `createContainer(_:)` and `recreateContainer(originalID:request:)` create from shared fields. +- `translateCompose(_:baseDirectory:runtimeKind:)` turns parsed Compose projects into standardized create requests plus warnings. +- `imageDefaults(for:in:)` lets the selected runtime provide image-specific defaults for the same form fields. +- `planMigration(_:to:)` and `coreSwitchPlan(for:source:to:)` describe future export/import migration before the app enables a cross-core swap. -The UI currently shows Apple container as the only core and disables the picker -until another runtime descriptor is registered. The disabled control is still +The UI currently shows Apple container as the only enabled core and disables the +picker until another runtime descriptor is registered. The disabled control is intentional: it proves where future Docker-compatible or other adapters will plug in without making Apple-specific fields the app/backend boundary. -Adapters translate into and out of the shared model: - -- `previewCreateCommand(for:)` returns the command preview for the selected - runtime. -- `createContainer(_:)` and `recreateContainer(originalID:request:)` create from - the shared fields. -- `translateCompose(_:baseDirectory:)` turns parsed Compose projects into one - or more standardized create requests plus warnings. -- `imageDefaults(for:in:)` lets an adapter provide image-specific defaults for - the same form fields. -- `coreSwitchPlan(for:to:)` describes future export/import migration before the - app enables a cross-core swap. - -`ContainerCommands` remains the Apple argv source of truth. The Apple adapter -uses it to translate `ContainerCreateRequest` into `container run` today. Future -adapters should implement their own translator without adding backend `switch` -statements to SwiftUI views. - -## Error Boundary - -Runtime and core packages throw typed errors. They expose stable package names, -error codes, and machine-readable context through `ContainedPackageError`; they -do not decide how those failures are displayed to users. - -The app target maps package errors through `AppErrorPresentation` and `AppText`. -That keeps toast copy, inline messages, alerts, and Activity history wording in -`Sources/ContainedApp`, while reusable packages remain suitable for other hosts. -Backend stderr is not translated wholesale; non-zero CLI output is preserved as -runtime-provided detail unless the adapter maps it to a known typed case. - -When adding a new adapter or package error, prefer a specific error case with a -stable `packageErrorCode` over throwing a preformatted English sentence. - -## Stats - -The shared runtime protocol exposes typed `RuntimeStatsSnapshot` batches from -`streamStats(ids:)`. Apple `container` currently provides live stats only as an -ANSI table stream, so `AppleContainerRuntime` parses that table internally. -Future adapters should publish the same snapshot shape from their own native -source, such as an engine API stream, without leaking transport details into the -app. - -## Low-Level Commands - -`runContainer(arguments:)` remains available for compatibility and direct -Apple-container affordances, but Run/Edit creation should use -`ContainerCreateRequest` through the selected runtime client. Do not assemble -backend argv in SwiftUI or stores. +## Compose + +Compose is a Core-level interchange format, not a Docker-only package boundary. +Docker, Podman, and nerdctl-style engines may support native Compose execution +or export later. Apple container does not execute Compose natively, but Core can +parse Compose and translate services into Apple container create specs. + +Dialect differences belong under `Core.Compose.Dialect`; YAML parsing/writing +belongs under `Core.Compose.YAML` and remains the only place that imports Yams. +Public APIs expose Core models and typed plans, never Yams types. + +## Errors And Stats + +Core throws typed display-neutral errors with stable package codes/context. The +app maps them through `AppErrorPresentation` and `AppText` before showing +toasts, inline messages, alerts, or Activity history. + +Core exposes typed `Core.Metrics.RuntimeStatsSnapshot` batches from +`streamStats(ids:)`. Apple container currently provides live stats only as an +ANSI table stream, so the Apple adapter parses that table internally. Future +adapters should publish the same snapshot shape from their own native source +without leaking transport details into the app. diff --git a/docs/development/Contributing.md b/docs/development/Contributing.md index e0f989a2..a714e7e5 100644 --- a/docs/development/Contributing.md +++ b/docs/development/Contributing.md @@ -31,7 +31,7 @@ Labels are intentionally short and color-coded. Use one type label (`bug`, `design`, `navigation`, `backend`, `docker`, `release`, or `repo`, then add a status like `triage`, `planned`, `backlog`, `up-next`, `in-progress`, `needs-info`, `needs-design`, `released`, `blocked`, or `wont-fix`. Broad -changes to old issues should be previewed before they are applied. Issue bodies +changes to existing issues should be previewed before they are applied. Issue bodies should only be rewritten by maintainers or when the original reporter has explicitly allowed it. @@ -55,16 +55,14 @@ enforced without breaking appcast publishing. ## Layout ``` -Packages/ContainedCore/Sources/ContainedCore/ pure logic — models, decoding, compose, argv builders (no SwiftUI) -Packages/ContainedRuntime/Sources/ContainedRuntime/ shared runtime contracts and capabilities -Packages/AppleContainerRuntime/Sources/AppleContainerRuntime/ Apple container runtime adapter +Packages/ContainedCore/Sources/ContainedCore/ backend orchestration — Core.*, adapters, compose, metrics, typed errors (no SwiftUI) Sources/ContainedApp/ the SwiftUI app Presentation/ app-owned labels, icons, formatting, and localization mapping Features// one folder per sidebar domain - Navigation/ Stores/ Support/ History/ -Packages/ContainedDesignSystem/ reusable SwiftUI/AppKit visual primitives and tokens -Packages/ContainedNavigation/ reusable navigation/layout infrastructure -Packages/ContainedPreviewSupport/ deterministic fixtures for previews/examples + Navigation/ Services/ Personalization/ Persistence/ +Packages/ContainedUI/ reusable SwiftUI/AppKit visual primitives and tokens +Packages/ContainedUX/ reusable navigation/layout infrastructure +Packages/ContainedCore/Sources/ContainedCoreFixtures/ semantic fixtures for preview/test/sandbox targets only Sources/Contained/ tiny SwiftPM executable launcher Xcode/Contained/ tiny native Xcode app launcher and Info.plist Contained.xcworkspace/ Xcode entry point @@ -84,7 +82,8 @@ appcast.xml Sparkle feed at the root of each release branch - **Agents start at `AGENTS.md`.** Coding agents should read the root agent guide before editing; it summarizes branch, update, release-note, design-system, and verification rules. - **Directory names are intentional.** SwiftPM folders stay `Sources` and `Tests`, Swift source domains use PascalCase, and repository infrastructure uses lowercase names such as `docs` and `scripts`. Put helper scripts in `scripts/` and use hyphenated names for multi-word shell scripts. -- **Reusable packages live under `Packages/`.** Keep app-agnostic design primitives, tokens, spacing, material, opacity, and micro-chrome in `ContainedDesignSystem`; keep app state, stores, Sparkle, SwiftData, persistence, and feature routing in `Sources/ContainedApp`. +- **Reusable packages live under `Packages/`.** Keep app-agnostic design primitives, tokens, spacing, material, opacity, and micro-chrome in `ContainedUI`; keep app state, stores, Sparkle, SwiftData, persistence, and feature routing in `Sources/ContainedApp`. +- **Fixtures are Core-owned and non-shipping.** `ContainedCoreFixtures` exposes deterministic semantic samples under `Core.Fixtures.*` for tests, previews, and sandbox-only targets. Normal app targets and distributable bundles must not depend on it. - **The app owns localization.** Reusable packages should not introduce user-facing English defaults or localized resource bundles. If a package component needs text, add an explicit parameter and pass app-owned strings @@ -99,19 +98,19 @@ appcast.xml Sparkle feed at the root of each release branch - **Package docs live with the package.** Keep package-local import/setup/examples in each `Packages//README.md`, with DocC landing pages under each target's `.docc` catalog. Keep app-level architecture and workflow guidance under `docs/`. - **Xcode opens the workspace.** `Contained.xcworkspace` points at the native `Contained.xcodeproj` and local package manifests. The Xcode target links the root package's `ContainedApp` product and builds/runs a real `Contained.app`; SwiftPM remains the release, CI, bundle, signing, notarization, and appcast source of truth. - **Use Xcode for functional SwiftUI loops.** The shared `Contained` scheme builds/runs the app and runs `ContainedAppTests`; `ContainedAppTests` is the focused app-test scheme; package schemes come from the package manifests; `ContainedPreviews` is reserved for preview-oriented development. -- **Navigation infrastructure belongs in `ContainedNavigation` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in `Sources/ContainedApp` until they can cross the boundary without app policy. -- **Every Apple `container` CLI action goes through a `ContainerCommands` builder** + `AppleContainerRuntime`, with a golden-argv test. The UI never assembles argv inline — this keeps "Reveal CLI" honest. -- **Runtime-facing code should depend on `ContainerRuntimeClient` where a backend choice matters.** The Apple `container` implementation remains the default adapter; future Docker-compatible, Podman, Lima-backed, remote, or other runtimes should be sibling adapter targets that advertise capability differences through `RuntimeDescriptor`. Create/import flows should translate through `ContainerCreateRequest` and carry `RuntimeKind` per container, not as a global app setting. -- **Pure decision logic is factored into `ContainedCore`** (`RestartDecision`, `HealthDecision`, compose ordering) and unit-tested without spawning processes. +- **Navigation infrastructure belongs in `ContainedUX` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in `Sources/ContainedApp` until they can cross the boundary without app policy. +- **Every backend action goes through `ContainedCore`.** Apple `container` argv builders and adapter clients are Core internals with golden tests. The UI never assembles argv inline; app stores call `Core.Orchestrator`. +- **Runtime-facing code should use `Core.*` namespaces.** The Apple `container` implementation is the only enabled adapter today. Future Docker-compatible, Podman, Lima-backed, remote, or other runtimes should be sibling adapter folders inside Core and advertise capability differences through `Core.Runtime.Descriptor`. Create/import flows should translate through `Core.Container.CreateRequest` and carry `Core.Runtime.Kind` per container, not as a global app setting. +- **Pure decision logic is factored into `ContainedCore`** (`RestartDecision`, `HealthDecision`, compose ordering, runtime translation) and unit-tested without spawning processes. - **No `contained.*` personalization labels.** Card styles and healthchecks live in local stores. Only `contained.restart` and `contained.stack` are written (they must round-trip through the container). - **Never put secrets or personal data in test fixtures.** Fixtures are captured CLI output — scrub tokens, domains, and paths before committing. (`.gitignore` blocks signing material; push protection is on.) -- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer app-facing design routes such as `PanelHeader`, `PanelSection`, `DesignPanelScaffold`, `DesignCard`, `DesignActionGroup`, `DesignTextActionButton`, `DesignToggleButton`, `CommandPreviewBar`, and `DesignTokens`. Do not add app-local spacing, padding, radius, shadow, material, opacity, glass button styles, or badge/keycap/status-dot recipes; add them to `ContainedDesignSystem` first. -- **Gate debug-only tools at compile time.** Use `#if CONTAINED_DEBUG_TOOLS` for debug menus, diagnostics, fixtures, or local-only inspection surfaces. SwiftPM defines that flag only for debug builds, so release bundles exclude the code instead of merely hiding it at runtime. +- **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer app-facing design routes such as `UI.Panel.Header`, `UI.Panel.Section`, `UI.Panel.Scaffold`, `UI.Card.Scaffold`, `UI.Action.Group`, `UI.Action.TextButton`, `UI.Action.ToggleButton`, `UI.Command.PreviewBar`, and contextual element tokens. Do not add app-local spacing, padding, radius, shadow, material, opacity, material button styles, or badge/keycap/status-dot recipes; add them to `ContainedUI` first. +- **Gate debug-only tools at compile time.** Use `#if CONTAINED_DEBUG_TOOLS` for debug menus, diagnostics, or local-only inspection surfaces. Fixture-backed samples use `CONTAINED_CORE_FIXTURES` in fixture/test/preview/sandbox-only targets, never plain `DEBUG`. SwiftPM defines `CONTAINED_DEBUG_TOOLS` only for debug builds, so release bundles exclude that code instead of merely hiding it at runtime. - **Keep the sidebar fallback working.** Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements for the classic shell. - **Sync docs with behavior.** If behavior, settings, routes, or user-facing wording changes, update the matching page under `docs/app`, `docs/features`, `docs/development`, `docs/architecture`, or `docs/release` and keep README links current. - **Preserve update build numbers.** `scripts/version-info.sh` is the single build-number source of truth; beta/stable workflows must pass the retained `BUILD` into `scripts/bundle.sh` and merge promoted appcast items into the nightly feed. - **Keep code scanning intentional.** `.github/workflows/codeql.yml` is the repository-owned CodeQL setup. GitHub Actions workflow analysis runs on PRs and pushes that touch source, scripts, workflows, package files, or tests, plus a weekly scheduled baseline. Swift analysis is scheduled/manual because Swift CodeQL currently takes too long to be a healthy per-PR gate. Appcast-only, docs-only, changelog-resource-only, and change-fragment-only commits are ignored so generated release feed commits do not burn macOS scan minutes. -- **Write release notes at the right level.** Keep `CHANGELOG.md` curated and version-level: use the base version section, such as `## [1.0.0]`, for durable user-facing release notes. Put PR/build deltas in `changes/unreleased/` fragments by default, not in `CHANGELOG.md` as a running implementation inventory. Use `changes/beta/` or `changes/nightly/` only for channel-specific notes. `scripts/collect-changes.sh` can compile those fragments for a directory or git range. When no explicit `CHANGES`/`CHANGES_DIR` source is provided, Beta/Nightly notes first try the previous matching appcast item plus the changelog/change-fragment git delta, then fall back to channel sections and `Unreleased` only as compatibility fallbacks. Stable ships full notes only; Beta/Nightly ship channel changes plus full notes. +- **Write release notes at the right level.** Keep `CHANGELOG.md` curated and version-level: use the base version section, such as `## [1.0.0]`, for durable user-facing release notes. Put PR/build deltas in `changes/unreleased/` fragments by default, not in `CHANGELOG.md` as a running implementation inventory. Use `changes/beta/` or `changes/nightly/` only for channel-specific notes. `scripts/collect-changes.sh` can compile those fragments for a directory or git range. When no explicit `CHANGES` or `CHANGES_DIR` source is provided, Beta/Nightly notes first try the previous matching appcast item plus the changelog/change-fragment git delta, then fall back to channel sections and `Unreleased` only when no channel item exists. Stable ships full notes only; Beta/Nightly ship channel changes plus full notes. - **Let CI check invariants, not fix them.** `scripts/ci-validate.sh` checks bundled changelog sync, shell syntax, workflow YAML syntax, Stable/Beta/Nightly release-note ordering, and PR release-note coverage when given a base ref. If `CHANGELOG.md` changes, run `./scripts/sync-changelog-resource.sh` locally and commit the bundled resource; CI uses `--check` so drift fails loudly. - **Use `no-release-note` narrowly.** PR CI accepts the label only through `NO_RELEASE_NOTE=1`; reserve it for docs/meta/dependency-only maintenance that does not change shipped behavior, scripts, workflows, tests, or source. Dependabot applies it automatically to grouped dependency update PRs. - **Use `wiki-approved` for direct wiki-impacting changes only when a maintainer has reviewed the docs impact.** The wiki sync automation prototype is tracked separately in issue #26 and should not be assumed to exist until that issue is resolved. diff --git a/docs/features/Command-Palette.md b/docs/features/Command-Palette.md index 623fa9d9..3dbbbe00 100644 --- a/docs/features/Command-Palette.md +++ b/docs/features/Command-Palette.md @@ -29,7 +29,7 @@ resource panel, it should be discoverable from `CommandPalette.swift`. Results should use the design system rather than custom row chrome: -- use `DesignCard` for every result card +- use `UI.Card.Scaffold` for every result card - pass badges, chevrons, return hints, and unread dots through the card's named accessory slots instead of building custom headers - use `PaletteItemVisual` for anything that can be represented visually @@ -62,11 +62,11 @@ results. - `Sources/ContainedApp/Features/Palette/CommandPalette.swift` owns the indexed actions and search fields. - `Sources/ContainedApp/Features/Palette/PaletteSearch.swift` owns scoring. -- `Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarSearchSource.swift` owns the +- `Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarSearchSource.swift` owns the toolbar search field (and the empty-query escalation into the palette). -- `Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift` owns +- `Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift` owns the visual panel and keyboard handling. -- `Sources/ContainedApp/Navigation/ToolbarPanels/PaletteResultCard.swift` owns +- `Sources/ContainedApp/Navigation/Toolbar/Panels/PaletteResultCard.swift` owns per-result card rendering. - `Tests/ContainedAppTests/PaletteSearchTests.swift` locks in fuzzy matching and ranking expectations. diff --git a/docs/release/Release.md b/docs/release/Release.md index 6fa3f3e2..2ee968f4 100644 --- a/docs/release/Release.md +++ b/docs/release/Release.md @@ -50,7 +50,7 @@ Prefer one committed fragment per PR or user-facing change, not one file per com CHANGES=updates/changes.md CHANNEL=beta VERSION_VALUE="$VERSION" ./scripts/release-body.sh ``` -Keep `Unreleased` above released version sections for compatibility, but prefer +Keep `Unreleased` above released version sections for release tooling, but prefer `changes/unreleased/` fragments for current PR/build notes. If no explicit `CHANGES` or `CHANGES_DIR` source is supplied, `scripts/changes-since-release.sh` first reads the previous matching appcast item, extracts its commit SHA, and @@ -99,7 +99,7 @@ Then: `.github/workflows/beta.yml` and `.github/workflows/stable.yml` build promoted branches, retain the build number for the matching nightly commit when available, write their own branch appcast, and merge the promoted appcast item into the nightly feed. They upsert GitHub release assets on reruns so a retry refreshes the same tag instead of failing on an existing release. All workflows ask `scripts/version-info.sh` for the build number. -After appcast generation, workflows validate the branch feed before committing it. Beta and Stable workflows validate the promoted nightly feed inside the temporary nightly worktree before pushing the appcast-only `[skip ci]` commit. +After appcast generation, workflows validate the branch feed before committing it. Beta and Stable workflows validate the promoted nightly feed inside the scratch nightly worktree before pushing the appcast-only `[skip ci]` commit. ## Notes diff --git a/docs/wiki/README.md b/docs/wiki/README.md index a87c827c..c69504b6 100644 --- a/docs/wiki/README.md +++ b/docs/wiki/README.md @@ -1,6 +1,6 @@ # Wiki Mirror Note -The old flat wiki mirror moved into structured docs: +The wiki mirror is maintained through structured docs: - [`docs/app`](../app) - [`docs/features`](../features) diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 01c1cde3..80cfd384 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -1,7 +1,6 @@ # Contained -This compatibility sidebar points to the structured docs that replaced the old -flat wiki mirror. +This sidebar points to the maintained structured docs. - [Home](/docs/app/Home.md) - [Features](/docs/features/Features.md) From 67f83a6504521bb7708b681a8aa543d29120f30f Mon Sep 17 00:00:00 2001 From: tdeverx Date: Fri, 3 Jul 2026 02:21:52 +0100 Subject: [PATCH 41/53] Refactor Contained package boundaries and UI primitives --- .github/ISSUE_TEMPLATE/backend_runtime.yml | 16 +- .github/pull_request_template.md | 1 + .github/workflows/beta.yml | 2 + .github/workflows/codeql.yml | 4 + .github/workflows/nightly.yml | 2 + .github/workflows/pr.yml | 2 +- .github/workflows/stable.yml | 2 + AGENTS.md | 3 +- CHANGELOG.md | 2 +- .../Sources/ContainedCore/Command/Error.swift | 5 +- .../ContainedCore/Command/Preview.swift | 7 +- .../Command/PreviewBuilders.swift | 2 +- .../ContainedCore/Command/Runner.swift | 32 ++-- .../Sources/ContainedCore/Compose/API.swift | 2 +- .../ContainedCore/Compose/ImportExport.swift | 31 ++-- .../ContainedCore/Compose/YAML/Project.swift | 63 ++++---- .../Container/CreateRequest.swift | 35 +++-- .../ContainedCore/Container/Document.swift | 38 +++-- .../ContainedCore/Container/HealthCheck.swift | 13 +- .../Container/JSONDecoding.swift | 5 +- .../Container/RestartPolicy.swift | 11 +- .../ContainedCore/Container/Snapshot.swift | 45 +++--- .../Sources/ContainedCore/Core.swift | 101 ------------- ...dPackageError.swift => PackageError.swift} | 7 +- .../ContainedCore/Field/Provenance.swift | 11 +- ...ageResource.swift => Image+Resource.swift} | 27 ++-- .../ContainedCore/Image/LocalTagGroup.swift | 15 +- .../ContainedCore/Image/UpdateStatus.swift | 23 +-- .../ContainedCore/Metrics/GraphMetric.swift | 37 ++--- .../ContainedCore/Metrics/Normalization.swift | 19 ++- .../{ContainerStats.swift => Stats.swift} | 31 ++-- .../ContainedCore/Migration/Plan.swift | 19 ++- ...kResource.swift => Network+Resource.swift} | 13 +- ...eOrchestrator.swift => Orchestrator.swift} | 80 +++++----- .../ContainedCore/Registry/HubSearch.swift | 15 +- ...geReference.swift => ImageReference.swift} | 9 +- .../ContainedCore/Registry/Login.swift | 5 +- .../Registry/ManifestClient.swift | 29 ++-- ...ntimeDescriptor.swift => Descriptor.swift} | 139 ++++++++--------- .../Sources/ContainedCore/Runtime/Kind.swift | 9 +- .../{RuntimeStatus.swift => Status.swift} | 7 +- .../AppleContainer/Client/Client.swift | 70 ++++----- .../Command/ContainerCommands.swift | 2 +- .../Metrics/StatsTableParser.swift | 12 +- .../Translation/CreateTranslator.swift | 52 +++---- .../ContainedCore/Schema/Version.swift | 7 +- .../ContainedCore/System/Properties.swift | 5 +- .../ContainedCore/System/SystemStatus.swift | 7 +- ...meResource.swift => Volume+Resource.swift} | 9 +- .../AppleContainerFixtures.swift | 26 ++-- .../Fixtures/Generic/GenericFixtures.swift | 2 +- .../ContainedCoreTests/CommandTests.swift | 42 +++--- .../ContainedCoreTests/DecodingTests.swift | 34 ++--- .../Tests/ContainedCoreTests/Fixtures.swift | 10 +- .../ImageWorkflowTests.swift | 34 ++--- .../Runtime/RuntimeDescriptorTests.swift | 42 +++--- .../RuntimeWorkflowTests.swift | 34 ++--- .../AppleContainer/AdapterTests.swift | 20 +-- .../Sources/ContainedUI/Action/Buttons.swift | 14 +- .../Sources/ContainedUI/Action/Groups.swift | 28 ++-- .../Sources/ContainedUI/Action/Item.swift | 4 +- .../ContainedUI/Action/SelectionBar.swift | 22 +-- ...CardScaffold.swift => Card+Scaffold.swift} | 62 ++++---- .../Card/{CardChrome.swift => Chrome.swift} | 70 +++++---- .../Card/{CardSurface.swift => Surface.swift} | 52 +++---- ...iewModifiers.swift => ViewModifiers.swift} | 2 +- .../ContainedUI/Chart/MetricTile.swift | 6 +- .../Sources/ContainedUI/Chart/Sparkline.swift | 38 ++--- .../Chart/SparklineMetricTile.swift | 10 +- .../Sources/ContainedUI/Control/Chips.swift | 10 +- .../Control/GradientAngleControl.swift | 4 +- .../ContainedUI/Control/KeyboardHints.swift | 8 +- .../ContainedUI/Control/ListControls.swift | 22 +-- .../ContainedUI/Control/MaterialButton.swift | 18 ++- .../ContainedUI/Control/OptionTile.swift | 6 +- .../Sources/ContainedUI/Control/RowMenu.swift | 4 +- .../ContainedUI/Control/SearchField.swift | 7 +- .../ContainedUI/Control/TintSelector.swift | 20 +-- .../ContainedUI/Control/ToolbarControls.swift | 18 ++- .../ContainedUI/Panel/PageScaffold.swift | 8 +- ...nelScaffold.swift => Panel+Scaffold.swift} | 6 +- .../{PanelSection.swift => Section.swift} | 18 ++- ...heetTitleBar.swift => SheetTitleBar.swift} | 6 +- .../{PanelTitleBar.swift => TitleBar.swift} | 4 +- .../State/ActivityStatusView.swift | 10 +- .../Sources/ContainedUI/State/Badges.swift | 8 +- .../ContainedUI/State/ContentStates.swift | 56 +++---- .../ContainedUI/State/ErrorBanner.swift | 4 +- .../ContainedUI/State/StatusBanner.swift | 4 +- .../ContainedUI/Support/NestedAPI.swift | 141 ------------------ .../ContainedUI/Surface/ContentSurface.swift | 8 +- .../ContainedUI/Surface/InputSurface.swift | 4 +- .../Surface/VisualEffectBackground.swift | 4 +- .../Sources/ContainedUI/Theme/Theme.swift | 39 +++-- ...ceFrames.swift => SourceFrameReader.swift} | 10 +- .../Sources/ContainedUX/Morph/Expander.swift | 91 +++++------ .../ContainedUX/Morph/SingleSurface.swift | 32 ++-- .../ContainedUX/Panel/BackdropStyle.swift | 13 ++ .../Sources/ContainedUX/Panel/Placement.swift | 8 + .../{SafeAreaManager.swift => Manager.swift} | 36 ++--- .../ContainedUX/Sources/ContainedUX/UX.swift | 26 ---- README.md | 80 +++++----- .../App/AppModel+Configuration.swift | 2 +- .../App/AppModel+ImageUpdates.swift | 20 +-- .../App/AppModel+ResourceStyles.swift | 14 +- Sources/ContainedApp/App/AppModel.swift | 32 ++-- Sources/ContainedApp/App/ContainedApp.swift | 4 +- .../Activity/ContainerHistoryTab.swift | 12 +- .../Containers/Card/ContainerCard.swift | 16 +- .../Containers/Card/ContainersGridView.swift | 48 +++--- .../Containers/Creation/CreationFlow.swift | 4 +- .../Containers/Creation/CreationPages.swift | 4 +- .../ContainerCustomizeButton.swift | 2 +- .../Customization/CustomizeSheet.swift | 16 +- .../Customization/CustomizeWidgetsPanel.swift | 12 +- .../Details/ContainerConfigureView.swift | 2 +- .../Details/ContainerEditSheet.swift | 2 +- .../Details/ContainerOverviewTab.swift | 4 +- .../Containers/Details/FilesTab.swift | 8 +- .../Features/Containers/Details/LogsTab.swift | 4 +- .../Containers/Details/StatsTab.swift | 18 +-- .../Containers/Details/TerminalTab.swift | 2 +- .../Features/Containers/Form/RunSpec.swift | 29 ++-- .../Containers/Form/RunSpecForm.swift | 6 +- .../Containers/Form/RunSpecFormSupport.swift | 2 +- .../Containers/Store/ContainersStore.swift | 46 +++--- .../Containers/Store/RestartWatchdog.swift | 16 +- .../Images/Registry/RegistryImageSearch.swift | 8 +- .../Features/Palette/CommandPalette.swift | 8 +- .../Registries/RegistryLoginSheet.swift | 2 +- .../Settings/Store/SettingsBackup.swift | 6 +- .../Settings/Store/SettingsStore.swift | 4 +- .../Settings/Tabs/AppearanceTab.swift | 4 +- .../Features/Settings/Tabs/GeneralTab.swift | 4 +- .../Settings/Tabs/RegistriesTab.swift | 6 +- .../Components/SystemVolumeInventory.swift | 18 +-- .../Features/System/SystemView.swift | 6 +- .../Navigation/MenuBar/MenuBarContent.swift | 4 +- .../Navigation/Shell/ClassicShell.swift | 28 ++-- .../Navigation/State/UIState.swift | 6 +- .../Navigation/Toolbar/AppToolbar.swift | 6 +- .../Toolbar/Panels/PaletteResultCard.swift | 12 +- .../Panels/ToolbarCommandPalette.swift | 4 +- .../Panels/ToolbarImageGroupCard.swift | 44 +++--- .../Toolbar/Panels/ToolbarUpdatesPanel.swift | 26 ++-- .../Persistence/History/HistoryModels.swift | 4 +- .../Persistence/History/HistoryStore.swift | 2 +- .../Personalization/Personalization.swift | 4 +- .../Personalization/WidgetConfiguration.swift | 12 +- .../Error/AppErrorPresentation.swift | 14 +- .../GraphMetricPresentation.swift | 16 +- .../Localization/AppLocalization.swift | 2 +- .../Presentation/StatusPresentation.swift | 2 +- Sources/ContainedApp/Resources/CHANGELOG.md | 2 +- .../Services/Health/HealthMonitor.swift | 24 +-- .../AppErrorPresentationTests.swift | 4 +- .../ContainersStoreRefreshTests.swift | 94 ++++++------ .../PreviewFixtureMappingTests.swift | 2 +- Tests/ContainedAppTests/RunSpecTests.swift | 12 +- .../SystemVolumeInventoryTests.swift | 4 +- docs/README.md | 20 ++- docs/app/Localization.md | 2 +- docs/architecture/Architecture.md | 2 +- docs/development/Contributing.md | 14 +- docs/development/Documentation-Map.md | 60 ++++++++ docs/features/Compose-Import.md | 2 +- docs/features/Run-Edit-Form.md | 2 +- docs/wiki/File-Map.md | 35 +++++ docs/wiki/README.md | 29 ++-- docs/wiki/_Sidebar.md | 60 ++++---- scripts/build-and-run.sh | 2 + scripts/changes-since-release.sh | 1 + scripts/ci-validate.sh | 117 +++++++++++++++ scripts/release-body.sh | 1 + scripts/release-notes.sh | 1 + scripts/require-release-note.sh | 1 + scripts/validate-appcast.sh | 1 + scripts/validate-bundle.sh | 1 + scripts/version-info.sh | 1 + 179 files changed, 1823 insertions(+), 1616 deletions(-) rename Packages/ContainedCore/Sources/ContainedCore/Errors/{ContainedPackageError.swift => PackageError.swift} (77%) rename Packages/ContainedCore/Sources/ContainedCore/Image/{ImageResource.swift => Image+Resource.swift} (75%) rename Packages/ContainedCore/Sources/ContainedCore/Metrics/{ContainerStats.swift => Stats.swift} (84%) rename Packages/ContainedCore/Sources/ContainedCore/Network/{NetworkResource.swift => Network+Resource.swift} (82%) rename Packages/ContainedCore/Sources/ContainedCore/Orchestration/{CoreOrchestrator.swift => Orchestrator.swift} (80%) rename Packages/ContainedCore/Sources/ContainedCore/Registry/{RegistryImageReference.swift => ImageReference.swift} (93%) rename Packages/ContainedCore/Sources/ContainedCore/Runtime/{RuntimeDescriptor.swift => Descriptor.swift} (54%) rename Packages/ContainedCore/Sources/ContainedCore/Runtime/{RuntimeStatus.swift => Status.swift} (79%) rename Packages/ContainedCore/Sources/ContainedCore/Volume/{VolumeResource.swift => Volume+Resource.swift} (86%) rename Packages/ContainedUI/Sources/ContainedUI/Card/{CardScaffold.swift => Card+Scaffold.swift} (85%) rename Packages/ContainedUI/Sources/ContainedUI/Card/{CardChrome.swift => Chrome.swift} (93%) rename Packages/ContainedUI/Sources/ContainedUI/Card/{CardSurface.swift => Surface.swift} (92%) rename Packages/ContainedUI/Sources/ContainedUI/Card/{CardViewModifiers.swift => ViewModifiers.swift} (84%) rename Packages/ContainedUI/Sources/ContainedUI/Panel/{PanelScaffold.swift => Panel+Scaffold.swift} (94%) rename Packages/ContainedUI/Sources/ContainedUI/Panel/{PanelSection.swift => Section.swift} (96%) rename Packages/ContainedUI/Sources/ContainedUI/Panel/{PanelSheetTitleBar.swift => SheetTitleBar.swift} (93%) rename Packages/ContainedUI/Sources/ContainedUI/Panel/{PanelTitleBar.swift => TitleBar.swift} (96%) delete mode 100644 Packages/ContainedUI/Sources/ContainedUI/Support/NestedAPI.swift rename Packages/ContainedUX/Sources/ContainedUX/Measurement/{SourceFrames.swift => SourceFrameReader.swift} (86%) create mode 100644 Packages/ContainedUX/Sources/ContainedUX/Panel/BackdropStyle.swift create mode 100644 Packages/ContainedUX/Sources/ContainedUX/Panel/Placement.swift rename Packages/ContainedUX/Sources/ContainedUX/SafeArea/{SafeAreaManager.swift => Manager.swift} (64%) create mode 100644 docs/development/Documentation-Map.md create mode 100644 docs/wiki/File-Map.md diff --git a/.github/ISSUE_TEMPLATE/backend_runtime.yml b/.github/ISSUE_TEMPLATE/backend_runtime.yml index 584143ff..65430019 100644 --- a/.github/ISSUE_TEMPLATE/backend_runtime.yml +++ b/.github/ISSUE_TEMPLATE/backend_runtime.yml @@ -1,5 +1,5 @@ name: Backend or runtime work -description: Apple container, Docker compatibility, command abstraction, compose, or runtime behavior +description: Apple container, Docker-compatible runtimes, command orchestration, Compose, or runtime behavior labels: ["feature", "backend", "triage"] body: - type: textarea @@ -7,7 +7,7 @@ body: attributes: label: Runtime area description: What backend, command, data model, or runtime behavior is involved? - placeholder: Apple container, Docker, compose, images, networks, volumes, command building... + placeholder: Apple container, Docker-compatible runtime, Compose, images, networks, volumes, command previews... validations: required: true @@ -25,8 +25,8 @@ body: label: Research / design checklist description: What should be answered before implementation starts? placeholder: | - - [ ] Inventory affected ContainerCommands and ContainerClient call sites - - [ ] Identify Apple container compatibility requirements + - [ ] Inventory affected Core.Orchestrator operations and adapter internals + - [ ] Identify Apple container runtime requirements - [ ] Note Docker or future-backend differences validations: required: false @@ -37,8 +37,8 @@ body: label: Implementation checklist description: What likely needs to change? placeholder: | - - [ ] Keep CLI actions behind ContainerCommands and ContainerClient - - [ ] Put pure decisions in ContainedCore + - [ ] Keep CLI actions behind ContainedCore adapter internals + - [ ] Route app-owned backend work through Core.Orchestrator - [ ] Add focused tests validations: required: false @@ -48,7 +48,7 @@ body: attributes: label: Acceptance criteria placeholder: | - - [ ] Existing Apple container behavior stays compatible + - [ ] Existing Apple container behavior stays intact - [ ] SwiftUI does not assemble backend argv inline - [ ] Unsupported backend capabilities are handled clearly validations: @@ -57,7 +57,7 @@ body: - type: textarea id: related attributes: - label: Related links, blockers, or compatibility notes + label: Related links, blockers, or runtime notes description: Issues, docs, commands, migration concerns, backend-specific notes, known blockers, or a suggested milestone. validations: required: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e6e965f7..d25935fe 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -32,6 +32,7 @@ - [ ] Added or updated a release/change note, or applied the `no-release-note` label for docs/meta/dependency-only maintenance - [ ] Updated `docs` for user-facing behavior or workflow changes, or this PR does not need docs +- [ ] Updated `docs/wiki/File-Map.md` and `docs/wiki/_Sidebar.md` when docs/package docs should appear in the wiki - [ ] Synced `Sources/ContainedApp/Resources/CHANGELOG.md` when `CHANGELOG.md` changed (`./scripts/sync-changelog-resource.sh --check` passes) ## Update Safety diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 2feb7de8..e38210b8 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -17,6 +17,8 @@ on: paths-ignore: - 'docs/**' - 'README.md' + - 'Packages/*/README.md' + - 'Packages/*/Sources/*/*.docc/**' - 'CODE_OF_CONDUCT.md' - '.github/CONTRIBUTING.md' - '.github/assets/**' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 194cab2e..003828e5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,6 +9,8 @@ on: paths-ignore: - 'docs/**' - 'README.md' + - 'Packages/*/README.md' + - 'Packages/*/Sources/*/*.docc/**' - 'CHANGELOG.md' - 'CODE_OF_CONDUCT.md' - '.github/**/*.md' @@ -25,6 +27,8 @@ on: paths-ignore: - 'docs/**' - 'README.md' + - 'Packages/*/README.md' + - 'Packages/*/Sources/*/*.docc/**' - 'CHANGELOG.md' - 'CODE_OF_CONDUCT.md' - '.github/**/*.md' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5c0e1490..91ebf012 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -16,6 +16,8 @@ on: paths-ignore: - 'docs/**' - 'README.md' + - 'Packages/*/README.md' + - 'Packages/*/Sources/*/*.docc/**' - 'CODE_OF_CONDUCT.md' - '.github/CONTRIBUTING.md' - '.github/assets/**' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b360ff24..6107ed3c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -33,7 +33,7 @@ jobs: while IFS= read -r file; do [ -n "$file" ] || continue case "$file" in - docs/**|README.md|CODE_OF_CONDUCT.md|.github/CONTRIBUTING.md|.github/assets/**|.github/ISSUE_TEMPLATE/**|LICENSE|NOTICE|SECURITY.md|SUPPORT.md|CODEOWNERS|.github/dependabot.yml|.gitignore|appcast.xml) + docs/**|README.md|Packages/*/README.md|Packages/*/Sources/*/*.docc/**|CODE_OF_CONDUCT.md|.github/CONTRIBUTING.md|.github/assets/**|.github/ISSUE_TEMPLATE/**|LICENSE|NOTICE|SECURITY.md|SUPPORT.md|CODEOWNERS|.github/dependabot.yml|.gitignore|appcast.xml) ;; *) material=true diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml index f6f0552b..1c176405 100644 --- a/.github/workflows/stable.yml +++ b/.github/workflows/stable.yml @@ -18,6 +18,8 @@ on: paths-ignore: - 'docs/**' - 'README.md' + - 'Packages/*/README.md' + - 'Packages/*/Sources/*/*.docc/**' - 'CODE_OF_CONDUCT.md' - '.github/CONTRIBUTING.md' - '.github/assets/**' diff --git a/AGENTS.md b/AGENTS.md index 54010c8d..c9e45795 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ This file is the working contract for coding agents in this repository. Follow i - `Packages/ContainedUX` is the reusable navigation/layout package. Keep app sections, toolbar panels, stores, and concrete routing state in `Sources/ContainedApp`. - `Packages/ContainedCore` also exposes a separate `ContainedCoreFixtures` product for deterministic dev/test sample data under `Core.Fixtures.*`. Normal app, debug bundle, release, notarized, and non-notarized distributable builds must not depend on or link that fixture product. - `docs/` is structured by audience and ownership. User-facing behavior or workflow changes should update the matching page under `docs/app`, `docs/features`, `docs/development`, `docs/architecture`, or `docs/release`. +- `docs/wiki/` stores the sync contract for the separate GitHub wiki repo: keep `File-Map.md` and `_Sidebar.md` aligned with maintained docs and package docs. - Package docs live beside each package as README + DocC. Keep package examples working and app-supplied strings explicit. - Keep directory names intentional: SwiftPM-owned folders stay `Sources` and `Tests`, Swift source domain folders use PascalCase, and repo infrastructure uses lowercase names such as `docs` and `scripts`. @@ -70,7 +71,7 @@ This file is the working contract for coding agents in this repository. Follow i and dynamic templates; plain SwiftUI literals are acceptable when SwiftUI keeps them localization-ready. - Keep package errors display-neutral. Reusable targets should throw typed errors - with stable codes/context, preferably `ContainedPackageError`, while + with stable codes/context, preferably `Core.Error.PackageError`, while `Sources/ContainedApp` maps them through `AppErrorPresentation`/`AppText` before showing toasts, alerts, inline errors, or Activity entries. Preserve arbitrary backend stderr as runtime detail rather than pretending to localize it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7645a55b..e50471c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,7 +94,7 @@ ### Technical - Swift Package layout with `ContainedCore` for backend orchestration, `ContainedUI` for visual primitives, `ContainedUX` for interaction infrastructure, `ContainedApp` for app-owned SwiftUI/persistence/localization policy, and a tiny `Contained` executable launcher. -- `ContainerCommands` is the single source of truth for `container` argv construction and is covered by golden tests. +- `Core.Command` owns command previews and runtime invocation construction, with adapter-specific argv covered by golden tests. - `ContainedCore` now owns the shared runtime contract, Core orchestrator, Apple `container` adapter internals, typed async runtime methods, and typed stats streams. - `RunSpec` is the single source of truth for Run/Edit form state, validation, CLI preview, and actual execution. - `AppModel` owns bootstrap, client wiring, stores, refresh coordination, image updates, service lifecycle, config transfer, and resource-style lookup through focused extensions. diff --git a/Packages/ContainedCore/Sources/ContainedCore/Command/Error.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/Error.swift index fa7dd545..244ddd5b 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Command/Error.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/Error.swift @@ -1,7 +1,8 @@ import Foundation /// A typed error for everything that can go wrong invoking the `container` CLI. -public enum CommandError: ContainedPackageError, Equatable { +public extension Core.Command { +enum Error: Core.Error.PackageError, Equatable { /// The `container` binary could not be located on disk. case cliNotFound(searched: [String]) /// The process launched but exited non-zero. `stderr` is the trimmed error text. @@ -35,3 +36,5 @@ public enum CommandError: ContainedPackageError, Equatable { } } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Command/Preview.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/Preview.swift index 02c0c553..6ab29d59 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Command/Preview.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/Preview.swift @@ -1,6 +1,7 @@ import Foundation -public struct RuntimeCommandPreview: Equatable, Sendable { +public extension Core.Command { +struct Preview: Equatable, Sendable { public var command: [String] public var warnings: [String] @@ -10,7 +11,7 @@ public struct RuntimeCommandPreview: Equatable, Sendable { } } -public struct CommandInvocation: Equatable, Sendable { +struct Invocation: Equatable, Sendable { public var executableURL: URL public var arguments: [String] @@ -19,3 +20,5 @@ public struct CommandInvocation: Equatable, Sendable { self.arguments = arguments } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift index a4f3c044..930b2d98 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift @@ -1,7 +1,7 @@ import Foundation public extension Core.Command { - static func runPreview(for request: ContainerCreateRequest) -> [String] { + static func runPreview(for request: Core.Container.CreateRequest) -> [String] { ContainerCommands.run(request) } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Command/Runner.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/Runner.swift index eee36d58..44e63ae8 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Command/Runner.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/Runner.swift @@ -1,6 +1,7 @@ import Foundation -public enum CommandExecutionPriority: Sendable { +public extension Core.Command { +enum ExecutionPriority: Sendable { case userInitiated case utility case background @@ -23,19 +24,21 @@ public enum CommandExecutionPriority: Sendable { } /// Abstraction over `container` CLI execution so stores can be tested against a mock with no daemon. -public protocol CommandRunning: Sendable { - /// Run a command to completion. Returns stdout `Data` on success; throws `CommandError` on +protocol Running: Sendable { + /// Run a command to completion. Returns stdout `Data` on success; throws `Core.Command.Error` on /// launch failure or non-zero exit (carrying stderr). func run(_ arguments: [String], stdin: Data?, - priority: CommandExecutionPriority) async throws -> Data + priority: Core.Command.ExecutionPriority) async throws -> Data /// Stream a long-running command's merged stdout+stderr as it arrives. Cancelling the consuming /// task (or finishing the stream) terminates the child process — no leaked `logs -f`/`stats`. - func stream(_ arguments: [String], priority: CommandExecutionPriority) -> AsyncThrowingStream + func stream(_ arguments: [String], priority: Core.Command.ExecutionPriority) -> AsyncThrowingStream } -public extension CommandRunning { +} + +public extension Core.Command.Running { func run(_ arguments: [String]) async throws -> Data { try await run(arguments, stdin: nil, priority: .userInitiated) } @@ -44,13 +47,14 @@ public extension CommandRunning { try await run(arguments, stdin: stdin, priority: .userInitiated) } - func stream(_ arguments: [String]) -> AsyncThrowingStream { + func stream(_ arguments: [String]) -> AsyncThrowingStream { stream(arguments, priority: .userInitiated) } } +public extension Core.Command { /// Concrete runner backed by `Foundation.Process`. -public final class CommandRunner: CommandRunning { +final class Runner: Running { public let executableURL: URL public init(executableURL: URL) { @@ -67,7 +71,7 @@ public final class CommandRunner: CommandRunning { public func run(_ arguments: [String], stdin: Data?, - priority: CommandExecutionPriority) async throws -> Data { + priority: Core.Command.ExecutionPriority) async throws -> Data { let executableURL = self.executableURL return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: priority.dispatchQoS).async { @@ -86,7 +90,7 @@ public final class CommandRunner: CommandRunning { do { try process.run() } catch { - continuation.resume(throwing: CommandError.launchFailed(underlying: error.localizedDescription)) + continuation.resume(throwing: Core.Command.Error.launchFailed(underlying: error.localizedDescription)) return } @@ -119,7 +123,7 @@ public final class CommandRunner: CommandRunning { } else { let stderr = String(decoding: errBox.data, as: UTF8.self) .trimmingCharacters(in: .whitespacesAndNewlines) - continuation.resume(throwing: CommandError.nonZeroExit( + continuation.resume(throwing: Core.Command.Error.nonZeroExit( code: process.terminationStatus, stderr: stderr, command: arguments.joined(separator: " ") @@ -129,7 +133,7 @@ public final class CommandRunner: CommandRunning { } } - public func stream(_ arguments: [String], priority: CommandExecutionPriority) -> AsyncThrowingStream { + public func stream(_ arguments: [String], priority: Core.Command.ExecutionPriority) -> AsyncThrowingStream { let executableURL = self.executableURL return AsyncThrowingStream { continuation in // Process/FileHandle aren't Sendable; box them so the @Sendable onTermination closure @@ -166,7 +170,7 @@ public final class CommandRunner: CommandRunning { do { try process.run() } catch { - continuation.finish(throwing: CommandError.launchFailed(underlying: error.localizedDescription)) + continuation.finish(throwing: Core.Command.Error.launchFailed(underlying: error.localizedDescription)) } } } @@ -197,3 +201,5 @@ private final class DataBox: @unchecked Sendable { lock.withLock { storage = data } } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Compose/API.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/API.swift index bd044ca8..d66a3b9e 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Compose/API.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/API.swift @@ -2,6 +2,6 @@ import Foundation public extension Core.Compose { static func parse(_ yaml: String, projectName: String) throws -> Project { - try ComposeParser.parse(yaml, projectName: projectName) + try Core.Compose.Parser.parse(yaml, projectName: projectName) } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift index c857d019..e52ed509 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift @@ -1,47 +1,48 @@ import Foundation -public enum ComposeDialect: String, Codable, Equatable, Hashable, Sendable { +public extension Core.Compose { +enum Dialect: String, Codable, Equatable, Hashable, Sendable { case generic case dockerCompose case podmanCompose case nerdctlCompose } -public struct RuntimeComposeImportPlan: Equatable, Sendable { - public var items: [RuntimeComposeImportItem] +struct ImportPlan: Equatable, Sendable { + public var items: [Core.Compose.ImportItem] public var warnings: [String] - public init(requests: [ContainerCreateRequest], warnings: [String] = []) { - self.items = requests.map { RuntimeComposeImportItem(request: $0) } + public init(requests: [Core.Container.CreateRequest], warnings: [String] = []) { + self.items = requests.map { Core.Compose.ImportItem(request: $0) } self.warnings = warnings } - public init(items: [RuntimeComposeImportItem], warnings: [String] = []) { + public init(items: [Core.Compose.ImportItem], warnings: [String] = []) { self.items = items self.warnings = warnings } - public var requests: [ContainerCreateRequest] { + public var requests: [Core.Container.CreateRequest] { items.map(\.request) } } -public struct RuntimeComposeImportItem: Equatable, Sendable { - public var request: ContainerCreateRequest - public var healthCheck: HealthCheck? +struct ImportItem: Equatable, Sendable { + public var request: Core.Container.CreateRequest + public var healthCheck: Core.Container.HealthCheck? - public init(request: ContainerCreateRequest, healthCheck: HealthCheck? = nil) { + public init(request: Core.Container.CreateRequest, healthCheck: Core.Container.HealthCheck? = nil) { self.request = request self.healthCheck = healthCheck } } -public struct ComposeExportPlan: Equatable, Sendable { - public var dialect: ComposeDialect +struct ExportPlan: Equatable, Sendable { + public var dialect: Core.Compose.Dialect public var warnings: [String] public var isAvailable: Bool - public init(dialect: ComposeDialect = .generic, + public init(dialect: Core.Compose.Dialect = .generic, warnings: [String] = [], isAvailable: Bool = false) { self.dialect = dialect @@ -49,3 +50,5 @@ public struct ComposeExportPlan: Equatable, Sendable { self.isAvailable = isAvailable } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift index 28c76e14..7f57f1d9 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift @@ -3,15 +3,16 @@ import Yams /// A parsed `compose.yaml`, reduced to the subset Contained can prefill into Run specs. Anything not /// translated is recorded in `warnings` so the user knows exactly what to wire up by hand. -public struct ComposeProject: Sendable, Hashable, Identifiable { +public extension Core.Compose { +struct Project: Sendable, Hashable, Identifiable { public let name: String - public let services: [ComposeService] + public let services: [Core.Compose.Service] public let warnings: [String] public var id: String { name } } /// One compose service, normalized to the fields that map onto `container run`. -public struct ComposeService: Sendable, Hashable, Identifiable { +struct Service: Sendable, Hashable, Identifiable { /// The services-map key — what `depends_on` references (distinct from `name`/`container_name`). public let key: String public let name: String @@ -41,8 +42,8 @@ public struct ComposeService: Sendable, Hashable, Identifiable { public let dnsOptions: [String] public let tmpfs: [String] public let ulimits: [String] - public let dependsOn: [ComposeDependency] - public let healthcheck: ComposeHealthcheck? + public let dependsOn: [Core.Compose.Dependency] + public let healthcheck: Core.Compose.Healthcheck? public var id: String { key } @@ -54,7 +55,7 @@ public struct ComposeService: Sendable, Hashable, Identifiable { interactive: Bool = false, tty: Bool = false, capAdd: [String] = [], capDrop: [String] = [], dns: [String] = [], dnsSearch: [String] = [], dnsOptions: [String] = [], tmpfs: [String] = [], ulimits: [String] = [], - dependsOn: [ComposeDependency], healthcheck: ComposeHealthcheck?) { + dependsOn: [Core.Compose.Dependency], healthcheck: Core.Compose.Healthcheck?) { self.key = key; self.name = name; self.image = image; self.platform = platform; self.command = command self.entrypoint = entrypoint; self.workingDir = workingDir; self.user = user; self.cpus = cpus self.memory = memory; self.ports = ports; self.volumes = volumes; self.environment = environment @@ -68,22 +69,22 @@ public struct ComposeService: Sendable, Hashable, Identifiable { } /// A `depends_on` edge with its start condition. -public struct ComposeDependency: Sendable, Hashable { +struct Dependency: Sendable, Hashable { public let service: String // the depended-on service key - public let condition: ComposeCondition - public init(service: String, condition: ComposeCondition) { + public let condition: Core.Compose.Condition + public init(service: String, condition: Core.Compose.Condition) { self.service = service; self.condition = condition } } -public enum ComposeCondition: String, Sendable, Hashable { +enum Condition: String, Sendable, Hashable { case started = "service_started" case healthy = "service_healthy" case completed = "service_completed_successfully" } /// A parsed compose `healthcheck:` block (the subset Contained can run as an `exec` probe). -public struct ComposeHealthcheck: Sendable, Hashable { +struct Healthcheck: Sendable, Hashable { public let test: [String] // the probe argv (CMD-SHELL flattened to sh -c form) public let intervalSeconds: Int public let retries: Int @@ -92,7 +93,7 @@ public struct ComposeHealthcheck: Sendable, Hashable { } } -public enum ComposeError: ContainedPackageError, Equatable { +enum Error: Core.Error.PackageError, Equatable { case invalid(String) public var packageName: String { "ContainedCore" } @@ -104,11 +105,11 @@ public enum ComposeError: ContainedPackageError, Equatable { } } -/// Dependency ordering for a stack launch. Pure + testable (factored like `RestartDecision`). -public enum ComposeOrder { +/// Dependency ordering for a stack launch. Pure + testable (factored like `Core.Container.RestartDecision`). +enum Order { /// Topologically sort services by `depends_on` (dependencies first). On a cycle, returns the /// declared order with `cycle == true` so the caller can warn and fall back gracefully. - public static func sorted(_ services: [ComposeService]) -> (order: [String], cycle: Bool) { + public static func sorted(_ services: [Core.Compose.Service]) -> (order: [String], cycle: Bool) { let keys = services.map(\.key) let known = Set(keys) var edges: [String: [String]] = [:] @@ -134,12 +135,12 @@ public enum ComposeOrder { } } -public enum ComposeParser { +enum Parser { /// Parse compose YAML text. `projectName` defaults from the file's parent folder. - public static func parse(_ yaml: String, projectName: String) throws -> ComposeProject { + public static func parse(_ yaml: String, projectName: String) throws -> Core.Compose.Project { let loaded: Any? - do { loaded = try Yams.load(yaml: yaml) } catch { throw ComposeError.invalid(String(describing: error)) } - guard let root = loaded as? [String: Any] else { throw ComposeError.invalid("Top level is not a mapping.") } + do { loaded = try Yams.load(yaml: yaml) } catch { throw Core.Compose.Error.invalid(String(describing: error)) } + guard let root = loaded as? [String: Any] else { throw Core.Compose.Error.invalid("Top level is not a mapping.") } var warnings: [String] = [] // Top-level keys we don't translate. @@ -148,16 +149,16 @@ public enum ComposeParser { } guard let servicesMap = root["services"] as? [String: Any] else { - throw ComposeError.invalid("No `services` section found.") + throw Core.Compose.Error.invalid("No `services` section found.") } - var services: [ComposeService] = [] + var services: [Core.Compose.Service] = [] for name in servicesMap.keys.sorted() { guard let body = servicesMap[name] as? [String: Any] else { continue } services.append(service(name: name, body: body, warnings: &warnings)) } let resolvedName = (root["name"] as? String) ?? projectName - return ComposeProject(name: resolvedName, services: services, warnings: warnings) + return Core.Compose.Project(name: resolvedName, services: services, warnings: warnings) } private static let supportedKeys: Set = @@ -167,7 +168,7 @@ public enum ComposeParser { "tty", "cap_add", "cap_drop", "dns", "dns_search", "dns_opt", "tmpfs", "ulimits", "network_mode", "networks"] - private static func service(name: String, body: [String: Any], warnings: inout [String]) -> ComposeService { + private static func service(name: String, body: [String: Any], warnings: inout [String]) -> Core.Compose.Service { for key in body.keys where !supportedKeys.contains(key) { warnings.append("`\(name).\(key)` isn't translated.") } @@ -175,7 +176,7 @@ public enum ComposeParser { if image == nil, body["build"] != nil { warnings.append("`\(name)` uses `build:` — build the image first, then set its tag here.") } - return ComposeService( + return Core.Compose.Service( key: name, name: (body["container_name"] as? String) ?? name, image: image, @@ -211,15 +212,15 @@ public enum ComposeParser { /// Parse `depends_on` in both the short list form (`[a, b]` → start order) and the long mapping /// form (`{a: {condition: service_healthy}}`). - private static func dependencies(_ value: Any?) -> [ComposeDependency] { + private static func dependencies(_ value: Any?) -> [Core.Compose.Dependency] { if let list = value as? [Any] { - return list.compactMap { $0 as? String }.map { ComposeDependency(service: $0, condition: .started) } + return list.compactMap { $0 as? String }.map { Core.Compose.Dependency(service: $0, condition: .started) } } if let map = value as? [String: Any] { return map.keys.sorted().map { service in let condition = (map[service] as? [String: Any])?["condition"] as? String - return ComposeDependency(service: service, - condition: condition.flatMap(ComposeCondition.init) ?? .started) + return Core.Compose.Dependency(service: service, + condition: condition.flatMap(Core.Compose.Condition.init) ?? .started) } } return [] @@ -227,7 +228,7 @@ public enum ComposeParser { /// Parse a `healthcheck:` block. `test` accepts `["CMD-SHELL", ""]`, `["CMD", a, b]`, or a /// bare string; we normalize to an `exec` argv. - private static func healthcheck(_ value: Any?) -> ComposeHealthcheck? { + private static func healthcheck(_ value: Any?) -> Core.Compose.Healthcheck? { guard let map = value as? [String: Any] else { return nil } if (map["disable"] as? Bool) == true { return nil } let test: [String] @@ -243,7 +244,7 @@ public enum ComposeParser { guard !test.isEmpty else { return nil } let interval = duration(map["interval"]) ?? 30 let retries = (map["retries"] as? Int) ?? 3 - return ComposeHealthcheck(test: test, intervalSeconds: interval, retries: retries) + return Core.Compose.Healthcheck(test: test, intervalSeconds: interval, retries: retries) } /// Parse a compose duration like "30s", "1m30s", or a bare number of seconds. @@ -416,3 +417,5 @@ public enum ComposeParser { } } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift index 9c4abb86..e7ad8bbf 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift @@ -1,6 +1,7 @@ import Foundation -public struct ContainerCreateKeyValue: Codable, Equatable, Hashable, Sendable, Identifiable { +public extension Core.Container { +struct KeyValue: Codable, Equatable, Hashable, Sendable, Identifiable { public var id = UUID() public var key: String public var value: String @@ -20,7 +21,7 @@ public struct ContainerCreateKeyValue: Codable, Equatable, Hashable, Sendable, I } } -public struct ContainerCreatePort: Codable, Equatable, Hashable, Sendable, Identifiable { +struct Port: Codable, Equatable, Hashable, Sendable, Identifiable { public var id = UUID() public var hostPort: String public var containerPort: String @@ -48,7 +49,7 @@ public struct ContainerCreatePort: Codable, Equatable, Hashable, Sendable, Ident } } -public struct ContainerCreateVolume: Codable, Equatable, Hashable, Sendable, Identifiable { +struct VolumeMount: Codable, Equatable, Hashable, Sendable, Identifiable { public var id = UUID() public var source: String public var target: String @@ -76,7 +77,7 @@ public struct ContainerCreateVolume: Codable, Equatable, Hashable, Sendable, Ide } } -public struct ContainerCreateSocket: Codable, Equatable, Hashable, Sendable, Identifiable { +struct Socket: Codable, Equatable, Hashable, Sendable, Identifiable { public var id = UUID() public var hostPath: String public var containerPath: String @@ -100,18 +101,18 @@ public struct ContainerCreateSocket: Codable, Equatable, Hashable, Sendable, Ide } } -public struct ContainerImageDefaults: Codable, Equatable, Sendable { +struct ImageDefaults: Codable, Equatable, Sendable { public var command: [String] public var entrypoint: [String] public var workingDirectory: String? public var user: String? - public var environment: [ContainerCreateKeyValue] + public var environment: [Core.Container.KeyValue] public init(command: [String] = [], entrypoint: [String] = [], workingDirectory: String? = nil, user: String? = nil, - environment: [ContainerCreateKeyValue] = []) { + environment: [Core.Container.KeyValue] = []) { self.command = command self.entrypoint = entrypoint self.workingDirectory = workingDirectory @@ -120,7 +121,7 @@ public struct ContainerImageDefaults: Codable, Equatable, Sendable { } } -public struct ContainerCreateResult: Codable, Equatable, Sendable { +struct CreateResult: Codable, Equatable, Sendable { public var id: String? public var output: String @@ -130,8 +131,8 @@ public struct ContainerCreateResult: Codable, Equatable, Sendable { } } -public struct ContainerCreateRequest: Codable, Equatable, Sendable { - public var runtimeKind: RuntimeKind = .appleContainer +struct CreateRequest: Codable, Equatable, Sendable { + public var runtimeKind: Core.Runtime.Kind = .appleContainer public var image = "" public var platform = "" public var name = "" @@ -143,14 +144,14 @@ public struct ContainerCreateRequest: Codable, Equatable, Sendable { public var tty = false public var cpus = "" public var memory = "" - public var env: [ContainerCreateKeyValue] = [] + public var env: [Core.Container.KeyValue] = [] public var envFiles: [String] = [] - public var ports: [ContainerCreatePort] = [] - public var volumes: [ContainerCreateVolume] = [] + public var ports: [Core.Container.Port] = [] + public var volumes: [Core.Container.VolumeMount] = [] public var mounts: [String] = [] - public var sockets: [ContainerCreateSocket] = [] - public var labels: [ContainerCreateKeyValue] = [] - public var restart: RestartPolicy = .no + public var sockets: [Core.Container.Socket] = [] + public var labels: [Core.Container.KeyValue] = [] + public var restart: Core.Container.RestartPolicy = .no public var readOnly = false public var useInit = false public var rosetta = false @@ -196,3 +197,5 @@ public struct ContainerCreateRequest: Codable, Equatable, Sendable { return result.map { "\($0.key)=\($0.value)" }.sorted() } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift index aa72167d..4b676003 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift @@ -1,21 +1,25 @@ import Foundation -public struct ContainerSpec: Codable, Equatable, Sendable { - public var createRequest: ContainerCreateRequest +public extension Core.Container { +struct Spec: Codable, Equatable, Sendable { + public var createRequest: Core.Container.CreateRequest - public init(createRequest: ContainerCreateRequest = ContainerCreateRequest()) { + public init(createRequest: Core.Container.CreateRequest = Core.Container.CreateRequest()) { self.createRequest = createRequest } } -public struct RuntimeProjection: Codable, Equatable, Sendable { - public var kind: RuntimeKind - public var schemaVersion: CoreSchemaVersion +} + +public extension Core.Runtime { +struct Projection: Codable, Equatable, Sendable { + public var kind: Core.Runtime.Kind + public var schemaVersion: Core.Schema.Version public var preservedFields: [String: String] public var unsupportedFields: [String] - public init(kind: RuntimeKind, - schemaVersion: CoreSchemaVersion = .current, + public init(kind: Core.Runtime.Kind, + schemaVersion: Core.Schema.Version = .current, preservedFields: [String: String] = [:], unsupportedFields: [String] = []) { self.kind = kind @@ -24,17 +28,21 @@ public struct RuntimeProjection: Codable, Equatable, Sendable { self.unsupportedFields = unsupportedFields } } +} -public struct ContainerDocument: Codable, Equatable, Sendable { - public var canonical: ContainerSpec - public var projections: [RuntimeKind: RuntimeProjection] - public var provenance: RuntimeFieldProvenanceMap +public extension Core.Container { +struct Document: Codable, Equatable, Sendable { + public var canonical: Core.Container.Spec + public var projections: [Core.Runtime.Kind: Core.Runtime.Projection] + public var provenance: Core.Field.ProvenanceMap - public init(canonical: ContainerSpec = ContainerSpec(), - projections: [RuntimeKind: RuntimeProjection] = [:], - provenance: RuntimeFieldProvenanceMap = RuntimeFieldProvenanceMap()) { + public init(canonical: Core.Container.Spec = Core.Container.Spec(), + projections: [Core.Runtime.Kind: Core.Runtime.Projection] = [:], + provenance: Core.Field.ProvenanceMap = Core.Field.ProvenanceMap()) { self.canonical = canonical self.projections = projections self.provenance = provenance } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/HealthCheck.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/HealthCheck.swift index 14312107..35d3c129 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Container/HealthCheck.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/HealthCheck.swift @@ -3,7 +3,8 @@ import Foundation /// An app-managed container healthcheck. The `container` CLI has no native healthcheck, so Contained /// runs `exec` probes on an interval (same philosophy as the restart watchdog). Stored locally, /// keyed by container id — never injected as labels. -public struct HealthCheck: Codable, Sendable, Hashable { +public extension Core.Container { +struct HealthCheck: Codable, Sendable, Hashable { /// The probe command run inside the container (argv). A zero exit = healthy. public var command: [String] public var intervalSeconds: Int @@ -22,17 +23,19 @@ public struct HealthCheck: Codable, Sendable, Hashable { } /// The observed health of a container under an app-managed check. -public enum HealthStatus: String, Sendable, Hashable { +enum HealthStatus: String, Sendable, Hashable { case unknown // no check, or not yet probed case healthy case unhealthy } -/// Pure decision logic for the health monitor — factored out (like `RestartDecision`) so the +/// Pure decision logic for the health monitor — factored out (like `Core.Container.RestartDecision`) so the /// failure-counting policy is unit-testable without spawning processes. -public enum HealthDecision { +enum HealthDecision { /// A container is unhealthy once consecutive probe failures reach the retry budget. - public static func status(consecutiveFailures: Int, retries: Int) -> HealthStatus { + public static func status(consecutiveFailures: Int, retries: Int) -> Core.Container.HealthStatus { consecutiveFailures >= max(1, retries) ? .unhealthy : .healthy } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/JSONDecoding.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/JSONDecoding.swift index 33e21961..ce48fddf 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Container/JSONDecoding.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/JSONDecoding.swift @@ -6,7 +6,8 @@ import Foundation /// `2026-06-24T10:16:58Z` (no fractional seconds) while embedded OCI image-config dates look like /// `2026-06-16T00:01:29.967161902Z` (nanosecond precision). We accept both, plus a couple of /// other lenient fallbacks, so decoding never fails on a date. -public enum ContainerJSON { +public extension Core.Container { +enum JSON { public static let decoder: JSONDecoder = { let decoder = JSONDecoder() @@ -46,3 +47,5 @@ public enum ContainerJSON { try decoder.decode(type, from: data) } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/RestartPolicy.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/RestartPolicy.swift index 48da3014..6d4e6867 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Container/RestartPolicy.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/RestartPolicy.swift @@ -2,20 +2,21 @@ import Foundation /// App-managed restart policy (the CLI has no native `--restart`); persisted as the /// `contained.restart` label so it round-trips through the runtime. -public enum RestartPolicy: String, CaseIterable, Identifiable, Codable, Sendable { +public extension Core.Container { +enum RestartPolicy: String, CaseIterable, Identifiable, Codable, Sendable { case no, onFailure = "on-failure", always public var id: String { rawValue } /// Parse a label value into a policy (`nil`/unknown → `.no`). public init(label: String?) { - self = label.flatMap(RestartPolicy.init(rawValue:)) ?? .no + self = label.flatMap(Core.Container.RestartPolicy.init(rawValue:)) ?? .no } } /// Pure decision logic for the app-managed `RestartWatchdog`, factored out so it is unit-testable /// without a live daemon. The watchdog calls this for every container that transitions /// `running → stopped` on a refresh tick. -public enum RestartDecision { +enum RestartDecision { /// Should the watchdog restart a container that just stopped? /// /// - Parameters: @@ -25,7 +26,7 @@ public enum RestartDecision { /// - exitCode: the process exit code if known. The `list --format json` snapshot does **not** /// carry an exit code, so this is usually `nil`; an unknown exit is treated as a failure /// (only a *known* clean `0` exit suppresses an `on-failure` restart). - public static func shouldRestart(policy: RestartPolicy, userInitiated: Bool, exitCode: Int32? = nil) -> Bool { + public static func shouldRestart(policy: Core.Container.RestartPolicy, userInitiated: Bool, exitCode: Int32? = nil) -> Bool { guard !userInitiated else { return false } switch policy { case .no: return false @@ -44,3 +45,5 @@ public enum RestartDecision { return min(raw, cap) } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift index 6df8bfe7..83c3c688 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift @@ -4,12 +4,13 @@ import Foundation /// /// Shape verified against captured fixtures: a top-level object with `configuration`, a duplicated /// `id`, and a `status` object that nests `state`, `networks`, and `startedDate`. -public struct ContainerSnapshot: Codable, Sendable, Identifiable, Hashable { - public let configuration: ContainerConfiguration +public extension Core.Container { +struct Snapshot: Codable, Sendable, Identifiable, Hashable { + public let configuration: Core.Container.Configuration public let id: String - public let status: ContainerRuntimeState + public let status: Core.Container.RuntimeState - public var state: RuntimeStatus { status.state } + public var state: Core.Runtime.Status { status.state } public var image: String { configuration.image.reference } public var startedDate: Date? { status.startedDate } @@ -22,7 +23,7 @@ public struct ContainerSnapshot: Codable, Sendable, Identifiable, Hashable { /// before any container from it exists). Encodes a minimal payload first so unusual image or /// volume names are escaped safely before decoding through the same defaults as real snapshots. public static func placeholder(id: String, image: String, - state: RuntimeStatus = .running) -> ContainerSnapshot { + state: Core.Runtime.Status = .running) -> Core.Container.Snapshot { let payload = PlaceholderSnapshotPayload( id: id, status: .init(state: state.rawValue), @@ -30,7 +31,7 @@ public struct ContainerSnapshot: Codable, Sendable, Identifiable, Hashable { ) do { let data = try JSONEncoder().encode(payload) - return try JSONDecoder().decode(ContainerSnapshot.self, from: data) + return try JSONDecoder().decode(Core.Container.Snapshot.self, from: data) } catch { preconditionFailure("Invalid placeholder snapshot: \(error)") } @@ -60,21 +61,21 @@ private struct PlaceholderSnapshotPayload: Encodable { } /// The `status` object inside a snapshot. -public struct ContainerRuntimeState: Codable, Sendable, Hashable { - public let state: RuntimeStatus +struct RuntimeState: Codable, Sendable, Hashable { + public let state: Core.Runtime.Status public let networks: [NetworkInterfaceStatus] public let startedDate: Date? public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - self.state = try c.decodeIfPresent(RuntimeStatus.self, forKey: .state) ?? .unknown + self.state = try c.decodeIfPresent(Core.Runtime.Status.self, forKey: .state) ?? .unknown self.networks = try c.decodeIfPresent([NetworkInterfaceStatus].self, forKey: .networks) ?? [] self.startedDate = try c.decodeIfPresent(Date.self, forKey: .startedDate) } } /// Runtime networking info (`status.networks[]`). -public struct NetworkInterfaceStatus: Codable, Sendable, Hashable { +struct NetworkInterfaceStatus: Codable, Sendable, Hashable { public let network: String public let hostname: String? public let ipv4Address: String? @@ -85,7 +86,7 @@ public struct NetworkInterfaceStatus: Codable, Sendable, Hashable { } /// The persistent `configuration` of a container. -public struct ContainerConfiguration: Codable, Sendable, Hashable { +struct Configuration: Codable, Sendable, Hashable { public let id: String public let image: ImageReference public let initProcess: ProcessConfiguration @@ -138,18 +139,18 @@ public struct ContainerConfiguration: Codable, Sendable, Hashable { } } -public struct ImageReference: Codable, Sendable, Hashable { +struct ImageReference: Codable, Sendable, Hashable { public let reference: String public let descriptor: Descriptor? } -public struct Descriptor: Codable, Sendable, Hashable { +struct Descriptor: Codable, Sendable, Hashable { public let digest: String public let mediaType: String? public let size: Int? } -public struct ProcessConfiguration: Codable, Sendable, Hashable { +struct ProcessConfiguration: Codable, Sendable, Hashable { public let executable: String? public let arguments: [String] public let environment: [String] @@ -166,7 +167,7 @@ public struct ProcessConfiguration: Codable, Sendable, Hashable { } } -public struct ResourceConfiguration: Codable, Sendable, Hashable { +struct ResourceConfiguration: Codable, Sendable, Hashable { public let cpus: Int public let memoryInBytes: UInt64 public let cpuOverhead: Int? @@ -175,7 +176,7 @@ public struct ResourceConfiguration: Codable, Sendable, Hashable { public static let `default` = ResourceConfiguration(cpus: 4, memoryInBytes: 1_073_741_824, cpuOverhead: 1, storage: nil) } -public struct Platform: Codable, Sendable, Hashable { +struct Platform: Codable, Sendable, Hashable { public let architecture: String public let os: String public let variant: String? @@ -187,7 +188,7 @@ public struct Platform: Codable, Sendable, Hashable { } } -public struct PublishedPort: Codable, Sendable, Hashable { +struct PublishedPort: Codable, Sendable, Hashable { public let containerPort: Int public let hostPort: Int public let hostAddress: String? @@ -197,13 +198,13 @@ public struct PublishedPort: Codable, Sendable, Hashable { public var display: String { "\(hostPort)→\(containerPort)" } } -public struct PublishedSocket: Codable, Sendable, Hashable { +struct PublishedSocket: Codable, Sendable, Hashable { public let hostPath: String? public let containerPath: String? } /// `configuration.networks[]` — the requested attachment (distinct from the runtime status network). -public struct NetworkAttachment: Codable, Sendable, Hashable { +struct NetworkAttachment: Codable, Sendable, Hashable { public let network: String public let options: Options? @@ -213,7 +214,7 @@ public struct NetworkAttachment: Codable, Sendable, Hashable { } } -public struct DNSConfiguration: Codable, Sendable, Hashable { +struct DNSConfiguration: Codable, Sendable, Hashable { public let nameservers: [String] public let searchDomains: [String] public let options: [String] @@ -228,7 +229,7 @@ public struct DNSConfiguration: Codable, Sendable, Hashable { } } -public struct Mount: Codable, Sendable, Hashable { +struct Mount: Codable, Sendable, Hashable { public let type: String? public let source: String? public let destination: String? @@ -258,6 +259,8 @@ public struct Mount: Codable, Sendable, Hashable { } /// A CodingKey usable for arbitrary/dynamic JSON object keys. +} + struct DynamicCodingKey: CodingKey { var stringValue: String var intValue: Int? diff --git a/Packages/ContainedCore/Sources/ContainedCore/Core.swift b/Packages/ContainedCore/Sources/ContainedCore/Core.swift index 358a60ac..4eb65676 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Core.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Core.swift @@ -20,104 +20,3 @@ public extension Core { enum Field {} enum Schema {} } - -public extension Core.Runtime { - typealias Kind = RuntimeKind - typealias Descriptor = RuntimeDescriptor - typealias Capability = RuntimeCapability - typealias SystemAction = RuntimeSystemAction - typealias UnsupportedCapability = UnsupportedRuntimeCapability -} - -public extension Core.Container { - typealias CreateRequest = ContainerCreateRequest - typealias CreateResult = ContainerCreateResult - typealias KeyValue = ContainerCreateKeyValue - typealias Port = ContainerCreatePort - typealias VolumeMount = ContainerCreateVolume - typealias Socket = ContainerCreateSocket - typealias ImageDefaults = ContainerImageDefaults - typealias Snapshot = ContainerSnapshot - typealias RuntimeState = ContainerRuntimeState - typealias Configuration = ContainerConfiguration - typealias RuntimeStatus = ContainedCore.RuntimeStatus - typealias Document = ContainerDocument - typealias Spec = ContainerSpec -} - -public extension Core.Command { - typealias Preview = RuntimeCommandPreview - typealias Invocation = CommandInvocation - typealias Runner = CommandRunner - typealias Running = CommandRunning - typealias ExecutionPriority = CommandExecutionPriority -} - -public extension Core.Compose { - typealias Project = ComposeProject - typealias Service = ComposeService - typealias ImportPlan = RuntimeComposeImportPlan - typealias ImportItem = RuntimeComposeImportItem - typealias ExportPlan = ComposeExportPlan - typealias Dialect = ComposeDialect -} - -public extension Core.Image { - typealias Resource = ImageResource - typealias Configuration = ImageConfiguration - typealias Variant = ImageVariant - typealias UpdateStatus = ImageUpdateStatus - typealias UpdateState = ImageUpdateState -} - -public extension Core.Registry { - typealias Login = RegistryLogin - typealias ImageReference = RegistryImageReference - typealias ManifestClient = RegistryManifestClient -} - -public extension Core.Network { - typealias Resource = NetworkResource - typealias Configuration = NetworkConfiguration - typealias Status = NetworkStatus -} - -public extension Core.Volume { - typealias Resource = VolumeResource - typealias Configuration = VolumeConfiguration -} - -public extension Core.System { - typealias Status = SystemStatus - typealias Properties = SystemProperties - typealias DiskUsage = ContainedCore.DiskUsage -} - -public extension Core.Metrics { - typealias GraphMetric = ContainedCore.GraphMetric - typealias ContainerStats = ContainedCore.ContainerStats - typealias RuntimeStatsSnapshot = ContainedCore.RuntimeStatsSnapshot - typealias StatsDelta = ContainedCore.StatsDelta - typealias NormalizationMode = StatsNormalizationMode - typealias NormalizationContext = StatsNormalizationContext - typealias HistorySample = MetricHistorySample -} - -public extension Core.Migration { - typealias Plan = RuntimeCoreSwitchPlan - typealias UnavailableReason = RuntimeCoreSwitchUnavailableReason -} - -public extension Core.Error { - typealias PackageError = ContainedPackageError - typealias Command = CommandError -} - -public extension Core.Field { - typealias Path = RuntimeFieldPath - typealias ProvenanceMap = RuntimeFieldProvenanceMap -} - -public extension Core.Schema { - typealias Version = CoreSchemaVersion -} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Errors/ContainedPackageError.swift b/Packages/ContainedCore/Sources/ContainedCore/Errors/PackageError.swift similarity index 77% rename from Packages/ContainedCore/Sources/ContainedCore/Errors/ContainedPackageError.swift rename to Packages/ContainedCore/Sources/ContainedCore/Errors/PackageError.swift index 2f1fa8a0..fb74c60c 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Errors/ContainedPackageError.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Errors/PackageError.swift @@ -4,12 +4,15 @@ import Foundation /// /// Packages throw typed errors with stable codes and context. Apps decide how to /// localize, display, alert, or record those errors. -public protocol ContainedPackageError: Error, Sendable { +public extension Core.Error { +protocol PackageError: Error, Sendable { var packageName: String { get } var packageErrorCode: String { get } var packageErrorContext: [String: String] { get } } -public extension ContainedPackageError { +} + +public extension Core.Error.PackageError { var packageErrorContext: [String: String] { [:] } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift b/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift index f2a69b4a..612faea0 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift @@ -1,6 +1,7 @@ import Foundation -public struct RuntimeFieldPath: Codable, Equatable, Hashable, Sendable { +public extension Core.Field { +struct Path: Codable, Equatable, Hashable, Sendable { public var rawValue: String public init(_ rawValue: String) { @@ -8,10 +9,12 @@ public struct RuntimeFieldPath: Codable, Equatable, Hashable, Sendable { } } -public struct RuntimeFieldProvenanceMap: Codable, Equatable, Sendable { - public var fields: [RuntimeFieldPath: RuntimeKind] +struct ProvenanceMap: Codable, Equatable, Sendable { + public var fields: [Core.Field.Path: Core.Runtime.Kind] - public init(fields: [RuntimeFieldPath: RuntimeKind] = [:]) { + public init(fields: [Core.Field.Path: Core.Runtime.Kind] = [:]) { self.fields = fields } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Image/ImageResource.swift b/Packages/ContainedCore/Sources/ContainedCore/Image/Image+Resource.swift similarity index 75% rename from Packages/ContainedCore/Sources/ContainedCore/Image/ImageResource.swift rename to Packages/ContainedCore/Sources/ContainedCore/Image/Image+Resource.swift index 33ce2659..cb0cc9c0 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Image/ImageResource.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Image/Image+Resource.swift @@ -1,42 +1,43 @@ import Foundation /// One element of `container image inspect` (and the intended shape of `image list`, which can -/// currently fail wholesale when a single content blob is missing — see `CommandError`). -public struct ImageResource: Codable, Sendable, Identifiable, Hashable { - public let configuration: ImageConfiguration +/// currently fail wholesale when a single content blob is missing — see `Core.Command.Error`). +public extension Core.Image { +struct Resource: Codable, Sendable, Identifiable, Hashable { + public let configuration: Core.Image.Configuration public let id: String - public let variants: [ImageVariant] + public let variants: [Core.Image.Variant] public var reference: String { configuration.name } public var digest: String? { configuration.descriptor?.digest } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - configuration = try c.decode(ImageConfiguration.self, forKey: .configuration) + configuration = try c.decode(Core.Image.Configuration.self, forKey: .configuration) id = try c.decode(String.self, forKey: .id) - variants = try c.decodeIfPresent([ImageVariant].self, forKey: .variants) ?? [] + variants = try c.decodeIfPresent([Core.Image.Variant].self, forKey: .variants) ?? [] } } -public struct ImageConfiguration: Codable, Sendable, Hashable { +struct Configuration: Codable, Sendable, Hashable { public let name: String - public let descriptor: Descriptor? + public let descriptor: Core.Container.Descriptor? public let creationDate: Date? } /// A per-platform variant within a (usually multi-arch) image index. -public struct ImageVariant: Codable, Sendable, Hashable, Identifiable { +struct Variant: Codable, Sendable, Hashable, Identifiable { public let digest: String public let size: Int? - public let platform: Platform - public let config: VariantConfig? + public let platform: Core.Container.Platform + public let config: Core.Image.VariantConfig? public var id: String { digest } /// "unknown/unknown" variants are attestation/SBOM blobs, not runnable images. public var isRunnable: Bool { platform.os != "unknown" && platform.architecture != "unknown" } } -public struct VariantConfig: Codable, Sendable, Hashable { +struct VariantConfig: Codable, Sendable, Hashable { public let architecture: String? public let os: String? public let created: Date? @@ -84,3 +85,5 @@ public struct VariantConfig: Codable, Sendable, Hashable { } } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Image/LocalTagGroup.swift b/Packages/ContainedCore/Sources/ContainedCore/Image/LocalTagGroup.swift index f2c9efaf..df6108b9 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Image/LocalTagGroup.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Image/LocalTagGroup.swift @@ -1,21 +1,22 @@ import Foundation -public struct LocalImageTagGroup: Identifiable, Sendable, Hashable { +public extension Core.Image { +struct LocalTagGroup: Identifiable, Sendable, Hashable { public let id: String public let digest: String? public let references: [String] - public let images: [ImageResource] + public let images: [Core.Image.Resource] public var primaryReference: String { references.first ?? id } - public static func groups(for images: [ImageResource]) -> [LocalImageTagGroup] { + public static func groups(for images: [Core.Image.Resource]) -> [Core.Image.LocalTagGroup] { let buckets = Dictionary(grouping: images) { image in image.digest ?? image.id } return buckets.map { key, images in let references = images.map(\.reference) .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - return LocalImageTagGroup( + return Core.Image.LocalTagGroup( id: key, digest: images.compactMap(\.digest).first, references: references, @@ -25,9 +26,11 @@ public struct LocalImageTagGroup: Identifiable, Sendable, Hashable { .sorted { $0.primaryReference.localizedCaseInsensitiveCompare($1.primaryReference) == .orderedAscending } } - public static func group(containing image: ImageResource, in images: [ImageResource]) -> LocalImageTagGroup { + public static func group(containing image: Core.Image.Resource, in images: [Core.Image.Resource]) -> Core.Image.LocalTagGroup { groups(for: images).first { $0.images.contains(image) } - ?? LocalImageTagGroup(id: image.digest ?? image.id, digest: image.digest, + ?? Core.Image.LocalTagGroup(id: image.digest ?? image.id, digest: image.digest, references: [image.reference], images: [image]) } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Image/UpdateStatus.swift b/Packages/ContainedCore/Sources/ContainedCore/Image/UpdateStatus.swift index f7c95689..a2fba26d 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Image/UpdateStatus.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Image/UpdateStatus.swift @@ -1,6 +1,7 @@ import Foundation -public enum ImageUpdateState: String, Sendable, Codable, Equatable { +public extension Core.Image { +enum UpdateState: String, Sendable, Codable, Equatable { case unknown case checking case current @@ -8,14 +9,14 @@ public enum ImageUpdateState: String, Sendable, Codable, Equatable { case error } -public struct ImageUpdateStatus: Sendable, Codable, Equatable { - public var state: ImageUpdateState +struct UpdateStatus: Sendable, Codable, Equatable { + public var state: Core.Image.UpdateState public var localDigest: String? public var remoteDigest: String? public var checkedAt: Date? public var message: String? - public init(state: ImageUpdateState = .unknown, localDigest: String? = nil, + public init(state: Core.Image.UpdateState = .unknown, localDigest: String? = nil, remoteDigest: String? = nil, checkedAt: Date? = nil, message: String? = nil) { self.state = state self.localDigest = localDigest @@ -24,12 +25,12 @@ public struct ImageUpdateStatus: Sendable, Codable, Equatable { self.message = message } - public static func checking(localDigest: String?) -> ImageUpdateStatus { - ImageUpdateStatus(state: .checking, localDigest: localDigest) + public static func checking(localDigest: String?) -> Core.Image.UpdateStatus { + Core.Image.UpdateStatus(state: .checking, localDigest: localDigest) } - public static func resolved(localDigest: String?, remoteDigest: String, checkedAt: Date = Date()) -> ImageUpdateStatus { - ImageUpdateStatus( + public static func resolved(localDigest: String?, remoteDigest: String, checkedAt: Date = Date()) -> Core.Image.UpdateStatus { + Core.Image.UpdateStatus( state: localDigest == remoteDigest ? .current : .updateAvailable, localDigest: localDigest, remoteDigest: remoteDigest, @@ -37,7 +38,9 @@ public struct ImageUpdateStatus: Sendable, Codable, Equatable { ) } - public static func failed(localDigest: String?, message: String, checkedAt: Date = Date()) -> ImageUpdateStatus { - ImageUpdateStatus(state: .error, localDigest: localDigest, checkedAt: checkedAt, message: message) + public static func failed(localDigest: String?, message: String, checkedAt: Date = Date()) -> Core.Image.UpdateStatus { + Core.Image.UpdateStatus(state: .error, localDigest: localDigest, checkedAt: checkedAt, message: message) } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Metrics/GraphMetric.swift b/Packages/ContainedCore/Sources/ContainedCore/Metrics/GraphMetric.swift index 6f7f3c7f..e060a36a 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Metrics/GraphMetric.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Metrics/GraphMetric.swift @@ -1,12 +1,13 @@ /// A runtime-neutral metric that can be graphed from live or persisted container stats. -public enum GraphMetric: String, CaseIterable, Identifiable, Codable, Hashable, Sendable { +public extension Core.Metrics { +enum GraphMetric: String, CaseIterable, Identifiable, Codable, Hashable, Sendable { case cpu, memory, netRx, netTx, diskRead, diskWrite public var id: String { rawValue } - public func value(from delta: StatsDelta, - snapshot: ContainerSnapshot? = nil, - normalization: StatsNormalizationContext = .containerSpecific) -> Double { + public func value(from delta: Core.Metrics.StatsDelta, + snapshot: Core.Container.Snapshot? = nil, + normalization: Core.Metrics.NormalizationContext = .containerSpecific) -> Double { switch self { case .cpu: return Self.cpuFraction(from: delta, snapshot: snapshot, normalization: normalization) case .memory: return Self.memoryFraction(from: delta, snapshot: snapshot, normalization: normalization) @@ -17,9 +18,9 @@ public enum GraphMetric: String, CaseIterable, Identifiable, Codable, Hashable, } } - public func value(from sample: any MetricHistorySample, - snapshot: ContainerSnapshot? = nil, - normalization: StatsNormalizationContext = .containerSpecific, + public func value(from sample: any Core.Metrics.HistorySample, + snapshot: Core.Container.Snapshot? = nil, + normalization: Core.Metrics.NormalizationContext = .containerSpecific, memoryFallbackBytes: UInt64 = 0) -> Double { switch self { case .cpu: @@ -39,23 +40,23 @@ public enum GraphMetric: String, CaseIterable, Identifiable, Codable, Hashable, } } - public static func cpuFraction(from delta: StatsDelta, - snapshot: ContainerSnapshot?, - normalization: StatsNormalizationContext = .containerSpecific) -> Double { + public static func cpuFraction(from delta: Core.Metrics.StatsDelta, + snapshot: Core.Container.Snapshot?, + normalization: Core.Metrics.NormalizationContext = .containerSpecific) -> Double { sanitized(delta.cpuCoreFraction) / normalization.cpuLimit(for: snapshot) } - public static func memoryFraction(from delta: StatsDelta, - snapshot: ContainerSnapshot?, - normalization: StatsNormalizationContext = .containerSpecific) -> Double { + public static func memoryFraction(from delta: Core.Metrics.StatsDelta, + snapshot: Core.Container.Snapshot?, + normalization: Core.Metrics.NormalizationContext = .containerSpecific) -> Double { let limit = memoryLimitBytes(for: delta, snapshot: snapshot, normalization: normalization) guard limit > 0 else { return 0 } return sanitized(Double(delta.memoryUsageBytes) / Double(limit)) } - public static func memoryLimitBytes(for delta: StatsDelta, - snapshot: ContainerSnapshot?, - normalization: StatsNormalizationContext = .containerSpecific) -> UInt64 { + public static func memoryLimitBytes(for delta: Core.Metrics.StatsDelta, + snapshot: Core.Container.Snapshot?, + normalization: Core.Metrics.NormalizationContext = .containerSpecific) -> UInt64 { normalization.memoryLimitBytes(for: delta, snapshot: snapshot) } @@ -65,7 +66,7 @@ public enum GraphMetric: String, CaseIterable, Identifiable, Codable, Hashable, } } -public protocol MetricHistorySample { +protocol HistorySample { var cpuFraction: Double { get } var memoryBytes: Double { get } var netRxBytesPerSec: Double { get } @@ -73,3 +74,5 @@ public protocol MetricHistorySample { var diskReadBytesPerSec: Double { get } var diskWriteBytesPerSec: Double { get } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Metrics/Normalization.swift b/Packages/ContainedCore/Sources/ContainedCore/Metrics/Normalization.swift index 9812cd84..842a8bcb 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Metrics/Normalization.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Metrics/Normalization.swift @@ -1,20 +1,21 @@ import Foundation -public enum StatsNormalizationMode: String, CaseIterable, Identifiable, Codable, Hashable, Sendable { +public extension Core.Metrics { +enum NormalizationMode: String, CaseIterable, Identifiable, Codable, Hashable, Sendable { case container case machine = "global" public var id: String { rawValue } } -public struct StatsNormalizationContext: Equatable, Sendable { - public var mode: StatsNormalizationMode +struct NormalizationContext: Equatable, Sendable { + public var mode: Core.Metrics.NormalizationMode public var machineCPUs: Int? public var machineMemoryBytes: UInt64? - public static let containerSpecific = StatsNormalizationContext(mode: .container) + public static let containerSpecific = Core.Metrics.NormalizationContext(mode: .container) - public init(mode: StatsNormalizationMode = .container, + public init(mode: Core.Metrics.NormalizationMode = .container, machineCPUs: Int? = nil, machineMemoryBytes: UInt64? = nil) { self.mode = mode @@ -22,7 +23,7 @@ public struct StatsNormalizationContext: Equatable, Sendable { self.machineMemoryBytes = machineMemoryBytes } - public func cpuLimit(for snapshot: ContainerSnapshot?) -> Double { + public func cpuLimit(for snapshot: Core.Container.Snapshot?) -> Double { switch mode { case .container: return max(Double(snapshot?.configuration.resources.cpus ?? 1), 1) @@ -31,7 +32,7 @@ public struct StatsNormalizationContext: Equatable, Sendable { } } - public func memoryLimitBytes(for snapshot: ContainerSnapshot?, fallback: UInt64 = 0) -> UInt64 { + public func memoryLimitBytes(for snapshot: Core.Container.Snapshot?, fallback: UInt64 = 0) -> UInt64 { switch mode { case .container: let configuredLimit = snapshot?.configuration.resources.memoryInBytes ?? 0 @@ -44,7 +45,9 @@ public struct StatsNormalizationContext: Equatable, Sendable { } } - public func memoryLimitBytes(for delta: StatsDelta, snapshot: ContainerSnapshot?) -> UInt64 { + public func memoryLimitBytes(for delta: Core.Metrics.StatsDelta, snapshot: Core.Container.Snapshot?) -> UInt64 { memoryLimitBytes(for: snapshot, fallback: delta.memoryLimitBytes) } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Metrics/ContainerStats.swift b/Packages/ContainedCore/Sources/ContainedCore/Metrics/Stats.swift similarity index 84% rename from Packages/ContainedCore/Sources/ContainedCore/Metrics/ContainerStats.swift rename to Packages/ContainedCore/Sources/ContainedCore/Metrics/Stats.swift index cc28c27b..989ed27a 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Metrics/ContainerStats.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Metrics/Stats.swift @@ -3,8 +3,9 @@ import Foundation /// One element of `container stats --format json`. /// /// IMPORTANT: every byte/usec field is a **cumulative** counter since container start. CPU percent -/// and per-interval throughput must be computed as deltas between two samples — see `StatsDelta`. -public struct ContainerStats: Codable, Sendable, Identifiable, Hashable { +/// and per-interval throughput must be computed as deltas between two samples — see `Core.Metrics.StatsDelta`. +public extension Core.Metrics { +struct ContainerStats: Codable, Sendable, Identifiable, Hashable { public let id: String public let cpuUsageUsec: UInt64? public let memoryUsageBytes: UInt64? @@ -25,9 +26,9 @@ public struct ContainerStats: Codable, Sendable, Identifiable, Hashable { /// /// Apple container's live table stream reports CPU as an already-computed percent and reports /// memory/network/block values as current cumulative counters. Keeping this separate from -/// `ContainerStats` lets future runtime adapters, including Docker Engine API streams, publish the +/// `Core.Metrics.ContainerStats` lets future runtime adapters, including Docker Engine API streams, publish the /// same shape without pretending they came from Apple container's JSON schema. -public struct RuntimeStatsSnapshot: Sendable, Identifiable, Hashable { +struct RuntimeStatsSnapshot: Sendable, Identifiable, Hashable { public let id: String public let cpuCoreFraction: Double? public let memoryUsageBytes: UInt64? @@ -59,8 +60,8 @@ public struct RuntimeStatsSnapshot: Sendable, Identifiable, Hashable { } } -/// A computed delta between two `ContainerStats` samples, the form the UI actually graphs. -public struct StatsDelta: Sendable, Hashable { +/// A computed delta between two `Core.Metrics.ContainerStats` samples, the form the UI actually graphs. +struct StatsDelta: Sendable, Hashable { public let id: String /// CPU usage as a fraction of one core over the interval (can exceed 1.0 on multi-core load). public let cpuCoreFraction: Double @@ -91,8 +92,8 @@ public struct StatsDelta: Sendable, Hashable { } /// Deterministic sample values for previews and package examples. - public static func sample(id: String = "preview") -> StatsDelta { - StatsDelta(id: id, cpuCoreFraction: 0.42, memoryUsageBytes: 384_000_000, memoryLimitBytes: 1_073_741_824, + public static func sample(id: String = "preview") -> Core.Metrics.StatsDelta { + Core.Metrics.StatsDelta(id: id, cpuCoreFraction: 0.42, memoryUsageBytes: 384_000_000, memoryLimitBytes: 1_073_741_824, netRxBytesPerSec: 124_000, netTxBytesPerSec: 48_000, blockReadBytesPerSec: 0, blockWriteBytesPerSec: 12_000, numProcesses: 7) } @@ -108,7 +109,7 @@ public struct StatsDelta: Sendable, Hashable { } /// Compute a delta from a previous sample taken `interval` seconds earlier. - public static func between(previous: ContainerStats, current: ContainerStats, interval: TimeInterval) -> StatsDelta { + public static func between(previous: Core.Metrics.ContainerStats, current: Core.Metrics.ContainerStats, interval: TimeInterval) -> Core.Metrics.StatsDelta { let dt = max(interval, 0.001) func rate(_ a: UInt64?, _ b: UInt64?) -> Double { guard let a, let b, b >= a else { return 0 } @@ -119,7 +120,7 @@ public struct StatsDelta: Sendable, Hashable { guard let a = previous.cpuUsageUsec, let b = current.cpuUsageUsec, b >= a else { return 0 } return Double(b - a) / (dt * 1_000_000) }() - return StatsDelta( + return Core.Metrics.StatsDelta( id: current.id, cpuCoreFraction: cpu, memoryUsageBytes: current.memoryUsageBytes ?? 0, @@ -136,16 +137,16 @@ public struct StatsDelta: Sendable, Hashable { /// /// CPU is already a point-in-time fraction in streaming table/API sources. Throughput metrics /// are still cumulative counters, so they need the previous streamed snapshot and interval. - public static func from(snapshot: RuntimeStatsSnapshot, - previous: RuntimeStatsSnapshot?, - interval: TimeInterval) -> StatsDelta { + public static func from(snapshot: Core.Metrics.RuntimeStatsSnapshot, + previous: Core.Metrics.RuntimeStatsSnapshot?, + interval: TimeInterval) -> Core.Metrics.StatsDelta { let dt = max(interval, 0.001) func rate(_ previous: UInt64?, _ current: UInt64?) -> Double { guard let previous, let current, current >= previous else { return 0 } return Double(current - previous) / dt } - return StatsDelta( + return Core.Metrics.StatsDelta( id: snapshot.id, cpuCoreFraction: snapshot.cpuCoreFraction ?? 0, memoryUsageBytes: snapshot.memoryUsageBytes ?? 0, @@ -158,3 +159,5 @@ public struct StatsDelta: Sendable, Hashable { ) } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Migration/Plan.swift b/Packages/ContainedCore/Sources/ContainedCore/Migration/Plan.swift index 9f5e0165..cceba49a 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Migration/Plan.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Migration/Plan.swift @@ -1,21 +1,22 @@ import Foundation -public enum RuntimeCoreSwitchUnavailableReason: String, Equatable, Sendable { +public extension Core.Migration { +enum UnavailableReason: String, Equatable, Sendable { case exportImportUnsupported } -public struct RuntimeCoreSwitchPlan: Equatable, Sendable { +struct Plan: Equatable, Sendable { public var isAvailable: Bool - public var unavailableReason: RuntimeCoreSwitchUnavailableReason? + public var unavailableReason: Core.Migration.UnavailableReason? public var context: [String: String] - public var source: RuntimeKind - public var target: RuntimeKind? + public var source: Core.Runtime.Kind + public var target: Core.Runtime.Kind? public init(isAvailable: Bool, - unavailableReason: RuntimeCoreSwitchUnavailableReason?, + unavailableReason: Core.Migration.UnavailableReason?, context: [String: String] = [:], - source: RuntimeKind, - target: RuntimeKind?) { + source: Core.Runtime.Kind, + target: Core.Runtime.Kind?) { self.isAvailable = isAvailable self.unavailableReason = unavailableReason self.context = context @@ -23,3 +24,5 @@ public struct RuntimeCoreSwitchPlan: Equatable, Sendable { self.target = target } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Network/NetworkResource.swift b/Packages/ContainedCore/Sources/ContainedCore/Network/Network+Resource.swift similarity index 82% rename from Packages/ContainedCore/Sources/ContainedCore/Network/NetworkResource.swift rename to Packages/ContainedCore/Sources/ContainedCore/Network/Network+Resource.swift index f920c463..7e1a7c5c 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Network/NetworkResource.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Network/Network+Resource.swift @@ -1,10 +1,11 @@ import Foundation /// One element of `container network list --format json` (and `network inspect`). -public struct NetworkResource: Codable, Sendable, Identifiable, Hashable { - public let configuration: NetworkConfiguration +public extension Core.Network { +struct Resource: Codable, Sendable, Identifiable, Hashable { + public let configuration: Core.Network.Configuration public let id: String - public let status: NetworkStatus? + public let status: Core.Network.Status? public var name: String { configuration.name } public var labels: [String: String] { configuration.labels } @@ -12,7 +13,7 @@ public struct NetworkResource: Codable, Sendable, Identifiable, Hashable { public var isBuiltin: Bool { labels["com.apple.container.resource.role"] == "builtin" } } -public struct NetworkConfiguration: Codable, Sendable, Hashable { +struct Configuration: Codable, Sendable, Hashable { public let name: String public let mode: String? public let plugin: String? @@ -35,8 +36,10 @@ public struct NetworkConfiguration: Codable, Sendable, Hashable { } } -public struct NetworkStatus: Codable, Sendable, Hashable { +struct Status: Codable, Sendable, Hashable { public let ipv4Gateway: String? public let ipv4Subnet: String? public let ipv6Subnet: String? } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Orchestration/CoreOrchestrator.swift b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift similarity index 80% rename from Packages/ContainedCore/Sources/ContainedCore/Orchestration/CoreOrchestrator.swift rename to Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift index e502b2ae..5b68d0d2 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Orchestration/CoreOrchestrator.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift @@ -2,10 +2,10 @@ import Foundation public extension Core { struct Configuration: Sendable { - public var defaultRuntime: RuntimeKind + public var defaultRuntime: Core.Runtime.Kind public var appleContainer: AppleContainerConfiguration - public init(defaultRuntime: RuntimeKind = .appleContainer, + public init(defaultRuntime: Core.Runtime.Kind = .appleContainer, appleContainer: AppleContainerConfiguration = AppleContainerConfiguration()) { self.defaultRuntime = defaultRuntime self.appleContainer = appleContainer @@ -31,7 +31,7 @@ public extension Core { private let client: AppleContainerClient public let cliURL: URL - public let defaultRuntime: RuntimeKind + public let defaultRuntime: Core.Runtime.Kind public static func == (lhs: Core.Orchestrator, rhs: Core.Orchestrator) -> Bool { lhs.cliURL == rhs.cliURL && lhs.defaultRuntime == rhs.defaultRuntime @@ -43,12 +43,12 @@ public extension Core { } return Core.Orchestrator(cliURL: url, defaultRuntime: configuration.defaultRuntime, - client: AppleContainerClient(runner: CommandRunner(executableURL: url))) + client: AppleContainerClient(runner: Core.Command.Runner(executableURL: url))) } - public static func testing(runner: any CommandRunning, + public static func testing(runner: any Core.Command.Running, cliURL: URL = URL(fileURLWithPath: "/usr/bin/container"), - defaultRuntime: RuntimeKind = .appleContainer) -> Core.Orchestrator { + defaultRuntime: Core.Runtime.Kind = .appleContainer) -> Core.Orchestrator { Core.Orchestrator(cliURL: cliURL, defaultRuntime: defaultRuntime, client: AppleContainerClient(runner: runner)) @@ -56,7 +56,7 @@ public extension Core { public static func bootstrap(configuration: Core.Configuration = Core.Configuration()) async -> Bootstrap { guard let orchestrator = live(configuration: configuration) else { return .cliMissing } - let runner = CommandRunner(executableURL: orchestrator.cliURL) + let runner = Core.Command.Runner(executableURL: orchestrator.cliURL) let versionData = try? await runner.run(ContainerCommands.version) let version = versionData.map { String(decoding: $0, as: UTF8.self) } .flatMap(AppleContainerCLILocator.parseVersion) @@ -70,15 +70,15 @@ public extension Core { version: version) } - init(cliURL: URL, defaultRuntime: RuntimeKind, client: AppleContainerClient) { + init(cliURL: URL, defaultRuntime: Core.Runtime.Kind, client: AppleContainerClient) { self.cliURL = cliURL self.defaultRuntime = defaultRuntime self.client = client } - public var descriptor: RuntimeDescriptor { client.descriptor } + public var descriptor: Core.Runtime.Descriptor { client.descriptor } - public var availableRuntimeDescriptors: [RuntimeDescriptor] { + public var availableRuntimeDescriptors: [Core.Runtime.Descriptor] { [.appleContainer] } @@ -86,40 +86,40 @@ public extension Core { availableRuntimeDescriptors.count > 1 } - public func descriptor(for kind: RuntimeKind) -> RuntimeDescriptor { + public func descriptor(for kind: Core.Runtime.Kind) -> Core.Runtime.Descriptor { availableRuntimeDescriptors.first { $0.kind == kind } ?? .appleContainer } - public func supportsRuntime(_ kind: RuntimeKind, capability: RuntimeCapability = .containers) -> Bool { + public func supportsRuntime(_ kind: Core.Runtime.Kind, capability: Core.Runtime.Capability = .containers) -> Bool { availableRuntimeDescriptors.first { $0.kind == kind }?.supports(capability) == true } - private func requireRuntime(_ kind: RuntimeKind, - capability: RuntimeCapability) throws -> AppleContainerClient { + private func requireRuntime(_ kind: Core.Runtime.Kind, + capability: Core.Runtime.Capability) throws -> AppleContainerClient { guard client.descriptor.kind == kind else { - throw UnsupportedRuntimeCapability(kind: kind, capability: capability) + throw Core.Runtime.UnsupportedCapability(kind: kind, capability: capability) } try client.descriptor.require(capability) return client } - public func listContainers(all: Bool = true) async throws -> [ContainerSnapshot] { + public func listContainers(all: Bool = true) async throws -> [Core.Container.Snapshot] { try await client.listContainers(all: all) } - public func stats(ids: [String] = []) async throws -> [ContainerStats] { + public func stats(ids: [String] = []) async throws -> [Core.Metrics.ContainerStats] { try await client.stats(ids: ids) } - public func streamStats(ids: [String] = []) -> AsyncThrowingStream<[RuntimeStatsSnapshot], Swift.Error> { + public func streamStats(ids: [String] = []) -> AsyncThrowingStream<[Core.Metrics.RuntimeStatsSnapshot], Swift.Error> { client.streamStats(ids: ids) } - public func diskUsage() async throws -> DiskUsage { + public func diskUsage() async throws -> Core.System.DiskUsage { try await client.diskUsage() } - public func systemProperties() async throws -> SystemProperties { + public func systemProperties() async throws -> Core.System.Properties { try await client.systemProperties() } @@ -147,8 +147,8 @@ public extension Core { try await client.copy(source: source, destination: destination) } - public func terminalInvocation(containerID: String, shell: String) throws -> CommandInvocation { - CommandInvocation(executableURL: cliURL, + public func terminalInvocation(containerID: String, shell: String) throws -> Core.Command.Invocation { + Core.Command.Invocation(executableURL: cliURL, arguments: ContainerCommands.execInteractive(containerID, shell: shell)) } @@ -156,65 +156,65 @@ public extension Core { client.streamSystemLogs(follow: follow, last: last) } - public func systemStatus() async throws -> SystemStatus { + public func systemStatus() async throws -> Core.System.Status { try await client.systemStatus() } - public func previewCreateCommand(for request: ContainerCreateRequest) throws -> RuntimeCommandPreview { + public func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview { try requireRuntime(request.runtimeKind, capability: .containers).previewCreateCommand(for: request) } - @discardableResult public func createContainer(_ request: ContainerCreateRequest) async throws -> ContainerCreateResult { + @discardableResult public func createContainer(_ request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { try await requireRuntime(request.runtimeKind, capability: .containers).createContainer(request) } @discardableResult public func recreateContainer(originalID: String, - request: ContainerCreateRequest) async throws -> ContainerCreateResult { + request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { let runtime = try requireRuntime(request.runtimeKind, capability: .containers) _ = try? await runtime.stop([originalID]) _ = try await runtime.deleteContainers([originalID], force: true) return try await runtime.createContainer(request) } - public func translateCompose(_ project: ComposeProject, + public func translateCompose(_ project: Core.Compose.Project, baseDirectory: URL?, - runtimeKind: RuntimeKind = .appleContainer) throws -> RuntimeComposeImportPlan { + runtimeKind: Core.Runtime.Kind = .appleContainer) throws -> Core.Compose.ImportPlan { try requireRuntime(runtimeKind, capability: .composeImport) .translateCompose(project, baseDirectory: baseDirectory) } - public func imageDefaults(for request: ContainerCreateRequest, - in images: [ImageResource]) throws -> ContainerImageDefaults? { + public func imageDefaults(for request: Core.Container.CreateRequest, + in images: [Core.Image.Resource]) throws -> Core.Container.ImageDefaults? { try requireRuntime(request.runtimeKind, capability: .containers) .imageDefaults(for: request, in: images) } - public func planMigration(_ document: ContainerDocument, - to target: RuntimeKind?) throws -> RuntimeCoreSwitchPlan { + public func planMigration(_ document: Core.Container.Document, + to target: Core.Runtime.Kind?) throws -> Core.Migration.Plan { let source = document.canonical.createRequest.runtimeKind return try requireRuntime(source, capability: .coreMigration) .coreSwitchPlan(for: document.canonical.createRequest.effectiveName ?? "", to: target.map(descriptor(for:))) } public func coreSwitchPlan(for containerID: String, - source: RuntimeKind = .appleContainer, - to target: RuntimeDescriptor?) throws -> RuntimeCoreSwitchPlan { + source: Core.Runtime.Kind = .appleContainer, + to target: Core.Runtime.Descriptor?) throws -> Core.Migration.Plan { try requireRuntime(source, capability: .coreMigration).coreSwitchPlan(for: containerID, to: target) } - public func networks() async throws -> [NetworkResource] { + public func networks() async throws -> [Core.Network.Resource] { try await client.networks() } - public func volumes() async throws -> [VolumeResource] { + public func volumes() async throws -> [Core.Volume.Resource] { try await client.volumes() } - public func images() async throws -> [ImageResource] { + public func images() async throws -> [Core.Image.Resource] { try await client.images() } - public func inspectImage(_ ref: String) async throws -> [ImageResource] { + public func inspectImage(_ ref: String) async throws -> [Core.Image.Resource] { try await client.inspectImage(ref) } @@ -253,11 +253,11 @@ public extension Core { try await client.runContainer(arguments: arguments) } - @discardableResult public func performSystemAction(_ action: RuntimeSystemAction) async throws -> Data { + @discardableResult public func performSystemAction(_ action: Core.Runtime.SystemAction) async throws -> Data { try await client.performSystemAction(action) } - public func registries() async throws -> [RegistryLogin] { + public func registries() async throws -> [Core.Registry.Login] { try await client.registries() } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Registry/HubSearch.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/HubSearch.swift index c91bfc75..53d44943 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Registry/HubSearch.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Registry/HubSearch.swift @@ -1,7 +1,8 @@ import Foundation /// One Docker Hub repository search result. -public struct HubSearchResult: Decodable, Sendable, Identifiable, Hashable { +public extension Core.Registry { +struct HubSearchResult: Decodable, Sendable, Identifiable, Hashable { public let repoName: String public let shortDescription: String? public let starCount: Int @@ -25,13 +26,13 @@ public struct HubSearchResult: Decodable, Sendable, Identifiable, Hashable { } /// The top-level shape of the Docker Hub search response. -public struct HubSearchResponse: Decodable, Sendable { - public let results: [HubSearchResult] +struct HubSearchResponse: Decodable, Sendable { + public let results: [Core.Registry.HubSearchResult] } /// Docker Hub search endpoint helpers. Centralizes URL construction, response validation, and /// decoding so toolbar search and the full image picker cannot drift. -public enum HubSearch { +enum HubSearch { public static func url(query: String, pageSize: Int = 25) -> URL? { let trimmed = query.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, @@ -45,12 +46,14 @@ public enum HubSearch { public static func results(query: String, pageSize: Int = 25, - session: URLSession = .shared) async throws -> [HubSearchResult] { + session: URLSession = .shared) async throws -> [Core.Registry.HubSearchResult] { guard let url = url(query: query, pageSize: pageSize) else { return [] } let (data, response) = try await session.data(from: url) if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { throw URLError(.badServerResponse) } - return try JSONDecoder().decode(HubSearchResponse.self, from: data).results + return try JSONDecoder().decode(Core.Registry.HubSearchResponse.self, from: data).results } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Registry/RegistryImageReference.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/ImageReference.swift similarity index 93% rename from Packages/ContainedCore/Sources/ContainedCore/Registry/RegistryImageReference.swift rename to Packages/ContainedCore/Sources/ContainedCore/Registry/ImageReference.swift index 24189a14..b9755178 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Registry/RegistryImageReference.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Registry/ImageReference.swift @@ -1,6 +1,7 @@ import Foundation -public struct RegistryImageReference: Sendable, Hashable { +public extension Core.Registry { +struct ImageReference: Sendable, Hashable { public let original: String public let registry: String public let repository: String @@ -19,7 +20,7 @@ public struct RegistryImageReference: Sendable, Hashable { return "\(displayRegistry)/\(repository)\(separator)\(reference)" } - public static func parse(_ raw: String) -> RegistryImageReference { + public static func parse(_ raw: String) -> Core.Registry.ImageReference { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let digestSplit = trimmed.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) let namePart = String(digestSplit.first ?? "") @@ -61,7 +62,7 @@ public struct RegistryImageReference: Sendable, Hashable { registry = rawRegistry } - return RegistryImageReference( + return Core.Registry.ImageReference( original: trimmed, registry: registry, repository: repository, @@ -74,3 +75,5 @@ public struct RegistryImageReference: Sendable, Hashable { parse(raw).normalizedKey } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Registry/Login.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/Login.swift index 0280a7f0..4d1da783 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Registry/Login.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Registry/Login.swift @@ -2,7 +2,8 @@ import Foundation /// One entry from `registry list --format json`. The on-disk shape isn't documented (the list is /// empty until you log in), so decode leniently across the likely key spellings for host/user. -public struct RegistryLogin: Codable, Sendable, Identifiable, Hashable { +public extension Core.Registry { +struct Login: Codable, Sendable, Identifiable, Hashable { public let host: String public let username: String? public let created: Date? @@ -36,3 +37,5 @@ public struct RegistryLogin: Codable, Sendable, Identifiable, Hashable { modified = date(["modified", "modifiedAt", "updated"]) } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Registry/ManifestClient.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/ManifestClient.swift index 29f0103f..df53a385 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Registry/ManifestClient.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Registry/ManifestClient.swift @@ -1,6 +1,7 @@ import Foundation -public enum RegistryManifestError: ContainedPackageError, Equatable { +public extension Core.Registry { +enum ManifestError: Core.Error.PackageError, Equatable { case invalidResponse case unauthorized case notFound @@ -29,7 +30,7 @@ public enum RegistryManifestError: ContainedPackageError, Equatable { } } -public struct RegistryManifestClient: Sendable { +struct ManifestClient: Sendable { private let session: URLSession public init(session: URLSession = .shared) { @@ -37,11 +38,11 @@ public struct RegistryManifestClient: Sendable { } public func remoteDigest(for imageRef: String) async throws -> String { - let ref = RegistryImageReference.parse(imageRef) + let ref = Core.Registry.ImageReference.parse(imageRef) return try await remoteDigest(for: ref) } - public func remoteDigest(for ref: RegistryImageReference) async throws -> String { + public func remoteDigest(for ref: Core.Registry.ImageReference) async throws -> String { let initial = try await manifestResponse(for: ref, bearerToken: nil) if initial.status == 401, let challenge = BearerChallenge(header: initial.authHeader) { let token = try await token(for: challenge, fallbackScope: ref.authScope) @@ -50,7 +51,7 @@ public struct RegistryManifestClient: Sendable { return try digest(from: initial) } - private func manifestResponse(for ref: RegistryImageReference, bearerToken: String?) async throws -> ManifestResponse { + private func manifestResponse(for ref: Core.Registry.ImageReference, bearerToken: String?) async throws -> ManifestResponse { var request = URLRequest(url: ref.manifestURL) request.httpMethod = "HEAD" request.setValue(Self.acceptHeader, forHTTPHeaderField: "Accept") @@ -58,7 +59,7 @@ public struct RegistryManifestClient: Sendable { request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } let (_, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { throw RegistryManifestError.invalidResponse } + guard let http = response as? HTTPURLResponse else { throw Core.Registry.ManifestError.invalidResponse } return ManifestResponse( status: http.statusCode, digest: http.value(forHTTPHeaderField: "Docker-Content-Digest"), @@ -69,14 +70,14 @@ public struct RegistryManifestClient: Sendable { private func digest(from response: ManifestResponse) throws -> String { switch response.status { case 200..<300: - guard let digest = response.digest, !digest.isEmpty else { throw RegistryManifestError.missingDigest } + guard let digest = response.digest, !digest.isEmpty else { throw Core.Registry.ManifestError.missingDigest } return digest case 401: - throw RegistryManifestError.unauthorized + throw Core.Registry.ManifestError.unauthorized case 404: - throw RegistryManifestError.notFound + throw Core.Registry.ManifestError.notFound default: - throw RegistryManifestError.httpStatus(response.status) + throw Core.Registry.ManifestError.httpStatus(response.status) } } @@ -88,14 +89,14 @@ public struct RegistryManifestClient: Sendable { } items.append(URLQueryItem(name: "scope", value: challenge.scope ?? fallbackScope)) components?.queryItems = items - guard let url = components?.url else { throw RegistryManifestError.tokenUnavailable } + guard let url = components?.url else { throw Core.Registry.ManifestError.tokenUnavailable } let (data, response) = try await session.data(from: url) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { - throw RegistryManifestError.tokenUnavailable + throw Core.Registry.ManifestError.tokenUnavailable } let decoded = try JSONDecoder().decode(TokenResponse.self, from: data) guard let token = decoded.token ?? decoded.accessToken, !token.isEmpty else { - throw RegistryManifestError.tokenUnavailable + throw Core.Registry.ManifestError.tokenUnavailable } return token } @@ -176,3 +177,5 @@ private struct BearerChallenge { return result } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeDescriptor.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Descriptor.swift similarity index 54% rename from Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeDescriptor.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtime/Descriptor.swift index 00136bee..26bbb60e 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeDescriptor.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Descriptor.swift @@ -1,32 +1,33 @@ import Foundation -public struct RuntimeCapability: OptionSet, Equatable, Sendable { +public extension Core.Runtime { +struct Capability: OptionSet, Equatable, Sendable { public let rawValue: UInt64 public init(rawValue: UInt64) { self.rawValue = rawValue } - public static let containers = RuntimeCapability(rawValue: 1 << 0) - public static let images = RuntimeCapability(rawValue: 1 << 1) - public static let imageBuild = RuntimeCapability(rawValue: 1 << 2) - public static let imagePush = RuntimeCapability(rawValue: 1 << 3) - public static let imageArchive = RuntimeCapability(rawValue: 1 << 4) - public static let registries = RuntimeCapability(rawValue: 1 << 5) - public static let networks = RuntimeCapability(rawValue: 1 << 6) - public static let volumes = RuntimeCapability(rawValue: 1 << 7) - public static let systemStatus = RuntimeCapability(rawValue: 1 << 8) - public static let systemLogs = RuntimeCapability(rawValue: 1 << 9) - public static let systemProperties = RuntimeCapability(rawValue: 1 << 10) - public static let dnsManagement = RuntimeCapability(rawValue: 1 << 11) - public static let kernelManagement = RuntimeCapability(rawValue: 1 << 12) - public static let exec = RuntimeCapability(rawValue: 1 << 13) - public static let copy = RuntimeCapability(rawValue: 1 << 14) - public static let containerExport = RuntimeCapability(rawValue: 1 << 15) - public static let composeImport = RuntimeCapability(rawValue: 1 << 16) - public static let coreMigration = RuntimeCapability(rawValue: 1 << 17) - - public static let appleContainer: RuntimeCapability = [ + public static let containers = Core.Runtime.Capability(rawValue: 1 << 0) + public static let images = Core.Runtime.Capability(rawValue: 1 << 1) + public static let imageBuild = Core.Runtime.Capability(rawValue: 1 << 2) + public static let imagePush = Core.Runtime.Capability(rawValue: 1 << 3) + public static let imageArchive = Core.Runtime.Capability(rawValue: 1 << 4) + public static let registries = Core.Runtime.Capability(rawValue: 1 << 5) + public static let networks = Core.Runtime.Capability(rawValue: 1 << 6) + public static let volumes = Core.Runtime.Capability(rawValue: 1 << 7) + public static let systemStatus = Core.Runtime.Capability(rawValue: 1 << 8) + public static let systemLogs = Core.Runtime.Capability(rawValue: 1 << 9) + public static let systemProperties = Core.Runtime.Capability(rawValue: 1 << 10) + public static let dnsManagement = Core.Runtime.Capability(rawValue: 1 << 11) + public static let kernelManagement = Core.Runtime.Capability(rawValue: 1 << 12) + public static let exec = Core.Runtime.Capability(rawValue: 1 << 13) + public static let copy = Core.Runtime.Capability(rawValue: 1 << 14) + public static let containerExport = Core.Runtime.Capability(rawValue: 1 << 15) + public static let composeImport = Core.Runtime.Capability(rawValue: 1 << 16) + public static let coreMigration = Core.Runtime.Capability(rawValue: 1 << 17) + + public static let appleContainer: Core.Runtime.Capability = [ .containers, .images, .imageBuild, @@ -47,33 +48,33 @@ public struct RuntimeCapability: OptionSet, Equatable, Sendable { ] } -public struct RuntimeDescriptor: Equatable, Sendable { - public var kind: RuntimeKind +struct Descriptor: Equatable, Sendable { + public var kind: Core.Runtime.Kind public var displayName: String public var executableName: String? - public var capabilities: RuntimeCapability + public var capabilities: Core.Runtime.Capability - public init(kind: RuntimeKind, + public init(kind: Core.Runtime.Kind, displayName: String, executableName: String? = nil, - capabilities: RuntimeCapability) { + capabilities: Core.Runtime.Capability) { self.kind = kind self.displayName = displayName self.executableName = executableName self.capabilities = capabilities } - public func supports(_ capability: RuntimeCapability) -> Bool { + public func supports(_ capability: Core.Runtime.Capability) -> Bool { capabilities.isSuperset(of: capability) } - public func require(_ capability: RuntimeCapability) throws { + public func require(_ capability: Core.Runtime.Capability) throws { guard supports(capability) else { - throw UnsupportedRuntimeCapability(kind: kind, capability: capability) + throw Core.Runtime.UnsupportedCapability(kind: kind, capability: capability) } } - public static let appleContainer = RuntimeDescriptor( + public static let appleContainer = Core.Runtime.Descriptor( kind: .appleContainer, displayName: "Apple container", executableName: "container", @@ -81,17 +82,19 @@ public struct RuntimeDescriptor: Equatable, Sendable { ) } -public struct UnsupportedRuntimeCapability: Error, Equatable, Sendable { - public var kind: RuntimeKind - public var capability: RuntimeCapability +struct UnsupportedCapability: Error, Equatable, Sendable { + public var kind: Core.Runtime.Kind + public var capability: Core.Runtime.Capability - public init(kind: RuntimeKind, capability: RuntimeCapability) { + public init(kind: Core.Runtime.Kind, capability: Core.Runtime.Capability) { self.kind = kind self.capability = capability } } -extension UnsupportedRuntimeCapability: ContainedPackageError { +} + +extension Core.Runtime.UnsupportedCapability: Core.Error.PackageError { public var packageName: String { "ContainedCore" } public var packageErrorCode: String { "unsupportedRuntimeCapability" } public var packageErrorContext: [String: String] { @@ -102,19 +105,21 @@ extension UnsupportedRuntimeCapability: ContainedPackageError { } } -public enum RuntimeSystemAction: String, CaseIterable, Sendable { +public extension Core.Runtime { +enum SystemAction: String, CaseIterable, Sendable { case start case stop } +} protocol ContainerRuntimeClient: Sendable { - var descriptor: RuntimeDescriptor { get } + var descriptor: Core.Runtime.Descriptor { get } - func listContainers(all: Bool) async throws -> [ContainerSnapshot] - func stats(ids: [String]) async throws -> [ContainerStats] - func streamStats(ids: [String]) -> AsyncThrowingStream<[RuntimeStatsSnapshot], Error> - func diskUsage() async throws -> DiskUsage - func systemProperties() async throws -> SystemProperties + func listContainers(all: Bool) async throws -> [Core.Container.Snapshot] + func stats(ids: [String]) async throws -> [Core.Metrics.ContainerStats] + func streamStats(ids: [String]) -> AsyncThrowingStream<[Core.Metrics.RuntimeStatsSnapshot], Error> + func diskUsage() async throws -> Core.System.DiskUsage + func systemProperties() async throws -> Core.System.Properties func dnsDomains() async throws -> [String] @discardableResult func createDNSDomain(_ domain: String) async throws -> Data @discardableResult func deleteDNSDomain(_ domain: String) async throws -> Data @@ -122,16 +127,16 @@ protocol ContainerRuntimeClient: Sendable { func execCapture(_ id: String, _ command: [String]) async throws -> String @discardableResult func copy(source: String, destination: String) async throws -> Data func streamSystemLogs(follow: Bool, last: Int?) -> AsyncThrowingStream - func previewCreateCommand(for request: ContainerCreateRequest) throws -> RuntimeCommandPreview - @discardableResult func createContainer(_ request: ContainerCreateRequest) async throws -> ContainerCreateResult - func translateCompose(_ project: ComposeProject, baseDirectory: URL?) throws -> RuntimeComposeImportPlan - func imageDefaults(for request: ContainerCreateRequest, in images: [ImageResource]) throws -> ContainerImageDefaults? - func coreSwitchPlan(for containerID: String, to target: RuntimeDescriptor?) throws -> RuntimeCoreSwitchPlan - func systemStatus() async throws -> SystemStatus - func networks() async throws -> [NetworkResource] - func volumes() async throws -> [VolumeResource] - func images() async throws -> [ImageResource] - func inspectImage(_ ref: String) async throws -> [ImageResource] + func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview + @discardableResult func createContainer(_ request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult + func translateCompose(_ project: Core.Compose.Project, baseDirectory: URL?) throws -> Core.Compose.ImportPlan + func imageDefaults(for request: Core.Container.CreateRequest, in images: [Core.Image.Resource]) throws -> Core.Container.ImageDefaults? + func coreSwitchPlan(for containerID: String, to target: Core.Runtime.Descriptor?) throws -> Core.Migration.Plan + func systemStatus() async throws -> Core.System.Status + func networks() async throws -> [Core.Network.Resource] + func volumes() async throws -> [Core.Volume.Resource] + func images() async throws -> [Core.Image.Resource] + func inspectImage(_ ref: String) async throws -> [Core.Image.Resource] func streamLogs(id: String, follow: Bool, tail: Int?, boot: Bool) -> AsyncThrowingStream func streamPull(_ ref: String, platform: String?) -> AsyncThrowingStream func streamBuild(context: String, tag: String?, dockerfile: String?, @@ -139,8 +144,8 @@ protocol ContainerRuntimeClient: Sendable { platform: String?) -> AsyncThrowingStream func streamPush(_ ref: String, platform: String?) -> AsyncThrowingStream @discardableResult func runContainer(arguments: [String]) async throws -> Data - @discardableResult func performSystemAction(_ action: RuntimeSystemAction) async throws -> Data - func registries() async throws -> [RegistryLogin] + @discardableResult func performSystemAction(_ action: Core.Runtime.SystemAction) async throws -> Data + func registries() async throws -> [Core.Registry.Login] @discardableResult func registryLogin(server: String, username: String, password: String) async throws -> Data @discardableResult func registryLogout(server: String) async throws -> Data @discardableResult func deleteImages(_ refs: [String]) async throws -> Data @@ -163,15 +168,15 @@ protocol ContainerRuntimeClient: Sendable { } extension ContainerRuntimeClient { - func listContainers() async throws -> [ContainerSnapshot] { + func listContainers() async throws -> [Core.Container.Snapshot] { try await listContainers(all: true) } - func stats() async throws -> [ContainerStats] { + func stats() async throws -> [Core.Metrics.ContainerStats] { try await stats(ids: []) } - func streamStats() -> AsyncThrowingStream<[RuntimeStatsSnapshot], Error> { + func streamStats() -> AsyncThrowingStream<[Core.Metrics.RuntimeStatsSnapshot], Error> { streamStats(ids: []) } @@ -191,31 +196,31 @@ extension ContainerRuntimeClient { streamPush(ref, platform: nil) } - func previewCreateCommand(for request: ContainerCreateRequest) throws -> RuntimeCommandPreview { - throw UnsupportedRuntimeCapability(kind: descriptor.kind, capability: .containers) + func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview { + throw Core.Runtime.UnsupportedCapability(kind: descriptor.kind, capability: .containers) } - @discardableResult func createContainer(_ request: ContainerCreateRequest) async throws -> ContainerCreateResult { - throw UnsupportedRuntimeCapability(kind: descriptor.kind, capability: .containers) + @discardableResult func createContainer(_ request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { + throw Core.Runtime.UnsupportedCapability(kind: descriptor.kind, capability: .containers) } @discardableResult func recreateContainer(originalID: String, - request: ContainerCreateRequest) async throws -> ContainerCreateResult { + request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { _ = try? await stop([originalID]) _ = try await deleteContainers([originalID], force: true) return try await createContainer(request) } - func translateCompose(_ project: ComposeProject, baseDirectory: URL? = nil) throws -> RuntimeComposeImportPlan { - throw UnsupportedRuntimeCapability(kind: descriptor.kind, capability: .composeImport) + func translateCompose(_ project: Core.Compose.Project, baseDirectory: URL? = nil) throws -> Core.Compose.ImportPlan { + throw Core.Runtime.UnsupportedCapability(kind: descriptor.kind, capability: .composeImport) } - func imageDefaults(for request: ContainerCreateRequest, in images: [ImageResource]) throws -> ContainerImageDefaults? { + func imageDefaults(for request: Core.Container.CreateRequest, in images: [Core.Image.Resource]) throws -> Core.Container.ImageDefaults? { nil } - func coreSwitchPlan(for containerID: String, to target: RuntimeDescriptor?) throws -> RuntimeCoreSwitchPlan { - RuntimeCoreSwitchPlan( + func coreSwitchPlan(for containerID: String, to target: Core.Runtime.Descriptor?) throws -> Core.Migration.Plan { + Core.Migration.Plan( isAvailable: false, unavailableReason: .exportImportUnsupported, context: [ diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift index 4af5d73a..6e6b2d09 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift @@ -5,13 +5,16 @@ import Foundation /// This is intentionally open-ended rather than a closed enum. Apple `container` is the first /// adapter, Docker-compatible engines are an obvious future adapter, and the app should also be able /// to host runtimes that do not exist yet without editing stored app state. -public struct RuntimeKind: RawRepresentable, Codable, Equatable, Hashable, Sendable { +public extension Core.Runtime { +struct Kind: RawRepresentable, Codable, Equatable, Hashable, Sendable { public var rawValue: String public init(rawValue: String) { self.rawValue = rawValue } - public static let appleContainer = RuntimeKind(rawValue: "apple-container") - public static let dockerCompatible = RuntimeKind(rawValue: "docker-compatible") + public static let appleContainer = Kind(rawValue: "apple-container") + public static let dockerCompatible = Kind(rawValue: "docker-compatible") +} + } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeStatus.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Status.swift similarity index 79% rename from Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeStatus.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtime/Status.swift index ed180709..b7f91941 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeStatus.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Status.swift @@ -3,7 +3,8 @@ import Foundation /// The real runtime state reported by `container` (verified against `list --format json`, /// where it appears as `status.state`). The runtime has exactly these states — there is no /// `paused` or `crashed`. "Errored" is a *derived* UI concept, not a runtime state. -public enum RuntimeStatus: String, Codable, Sendable, CaseIterable { +public extension Core.Runtime { +enum Status: String, Codable, Sendable, CaseIterable { case unknown case stopped case running @@ -12,6 +13,8 @@ public enum RuntimeStatus: String, Codable, Sendable, CaseIterable { /// Unknown/forward-compatible values decode to `.unknown` rather than throwing. public init(from decoder: Decoder) throws { let raw = try decoder.singleValueContainer().decode(String.self) - self = RuntimeStatus(rawValue: raw) ?? .unknown + self = Core.Runtime.Status(rawValue: raw) ?? .unknown } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Client/Client.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Client/Client.swift index 38a3989f..03f140d1 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Client/Client.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Client/Client.swift @@ -1,23 +1,23 @@ import Foundation -/// Typed facade over a `CommandRunning`. Returns decoded models; maps decode failures to -/// `CommandError.decodingFailed` so callers handle one error type. +/// Typed facade over a `Core.Command.Running`. Returns decoded models; maps decode failures to +/// `Core.Command.Error.decodingFailed` so callers handle one error type. struct AppleContainerClient: Sendable { - let runner: any CommandRunning - var descriptor: RuntimeDescriptor { .appleContainer } + let runner: any Core.Command.Running + var descriptor: Core.Runtime.Descriptor { .appleContainer } - init(runner: any CommandRunning) { + init(runner: any Core.Command.Running) { self.runner = runner } // MARK: Reads - func listContainers(all: Bool = true) async throws -> [ContainerSnapshot] { - try await decode([ContainerSnapshot].self, ContainerCommands.list(all: all), "list") + func listContainers(all: Bool = true) async throws -> [Core.Container.Snapshot] { + try await decode([Core.Container.Snapshot].self, ContainerCommands.list(all: all), "list") } - func stats(ids: [String] = []) async throws -> [ContainerStats] { - try await decode([ContainerStats].self, + func stats(ids: [String] = []) async throws -> [Core.Metrics.ContainerStats] { + try await decode([Core.Metrics.ContainerStats].self, ContainerCommands.stats(ids: ids), "stats", priority: .utility) @@ -27,7 +27,7 @@ struct AppleContainerClient: Sendable { runner.stream(ContainerCommands.statsTableStream(ids: ids), priority: .utility) } - func streamStats(ids: [String] = []) -> AsyncThrowingStream<[RuntimeStatsSnapshot], Error> { + func streamStats(ids: [String] = []) -> AsyncThrowingStream<[Core.Metrics.RuntimeStatsSnapshot], Error> { let source = statsTableStream(ids: ids) return AsyncThrowingStream { continuation in let task = Task(priority: .utility) { @@ -49,12 +49,12 @@ struct AppleContainerClient: Sendable { } } - func diskUsage() async throws -> DiskUsage { - try await decode(DiskUsage.self, ContainerCommands.systemDF, "system df") + func diskUsage() async throws -> Core.System.DiskUsage { + try await decode(Core.System.DiskUsage.self, ContainerCommands.systemDF, "system df") } - func systemProperties() async throws -> SystemProperties { - try await decode(SystemProperties.self, ContainerCommands.systemPropertyList, "system property list") + func systemProperties() async throws -> Core.System.Properties { + try await decode(Core.System.Properties.self, ContainerCommands.systemPropertyList, "system property list") } /// List local DNS domains (`system dns list`). Returns domain names. @@ -88,42 +88,42 @@ struct AppleContainerClient: Sendable { runner.stream(ContainerCommands.systemLogs(follow: follow, last: last)) } - func systemStatus() async throws -> SystemStatus { - try await decode(SystemStatus.self, ContainerCommands.systemStatus, "system status") + func systemStatus() async throws -> Core.System.Status { + try await decode(Core.System.Status.self, ContainerCommands.systemStatus, "system status") } - func previewCreateCommand(for request: ContainerCreateRequest) throws -> RuntimeCommandPreview { + func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview { AppleContainerCreateTranslator.preview(for: request) } - @discardableResult func createContainer(_ request: ContainerCreateRequest) async throws -> ContainerCreateResult { + @discardableResult func createContainer(_ request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { let data = try await runner.run(ContainerCommands.run(request)) return AppleContainerCreateTranslator.result(from: data, request: request) } - func translateCompose(_ project: ComposeProject, baseDirectory: URL?) throws -> RuntimeComposeImportPlan { + func translateCompose(_ project: Core.Compose.Project, baseDirectory: URL?) throws -> Core.Compose.ImportPlan { AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: baseDirectory) } - func imageDefaults(for request: ContainerCreateRequest, - in images: [ImageResource]) throws -> ContainerImageDefaults? { + func imageDefaults(for request: Core.Container.CreateRequest, + in images: [Core.Image.Resource]) throws -> Core.Container.ImageDefaults? { AppleContainerCreateTranslator.imageDefaults(for: request, in: images) } - func networks() async throws -> [NetworkResource] { - try await decode([NetworkResource].self, ContainerCommands.networkList(), "network list") + func networks() async throws -> [Core.Network.Resource] { + try await decode([Core.Network.Resource].self, ContainerCommands.networkList(), "network list") } - func volumes() async throws -> [VolumeResource] { - try await decode([VolumeResource].self, ContainerCommands.volumeList(), "volume list") + func volumes() async throws -> [Core.Volume.Resource] { + try await decode([Core.Volume.Resource].self, ContainerCommands.volumeList(), "volume list") } - func images() async throws -> [ImageResource] { - try await decode([ImageResource].self, ContainerCommands.imageList(), "image list") + func images() async throws -> [Core.Image.Resource] { + try await decode([Core.Image.Resource].self, ContainerCommands.imageList(), "image list") } - func inspectImage(_ ref: String) async throws -> [ImageResource] { - try await decode([ImageResource].self, ContainerCommands.imageInspect([ref]), "image inspect") + func inspectImage(_ ref: String) async throws -> [Core.Image.Resource] { + try await decode([Core.Image.Resource].self, ContainerCommands.imageInspect([ref]), "image inspect") } // MARK: Streaming @@ -157,14 +157,14 @@ struct AppleContainerClient: Sendable { try await runner.run(arguments) } - @discardableResult func performSystemAction(_ action: RuntimeSystemAction) async throws -> Data { + @discardableResult func performSystemAction(_ action: Core.Runtime.SystemAction) async throws -> Data { try await runner.run(["system", action.rawValue]) } // MARK: Registries - func registries() async throws -> [RegistryLogin] { - try await decode([RegistryLogin].self, ContainerCommands.registryList(), "registry list") + func registries() async throws -> [Core.Registry.Login] { + try await decode([Core.Registry.Login].self, ContainerCommands.registryList(), "registry list") } /// Log in to `server` as `username`, piping `password` via stdin (never in argv). @@ -242,12 +242,12 @@ struct AppleContainerClient: Sendable { private func decode(_ type: T.Type, _ args: [String], _ name: String, - priority: CommandExecutionPriority = .userInitiated) async throws -> T { + priority: Core.Command.ExecutionPriority = .userInitiated) async throws -> T { let data = try await runner.run(args, stdin: nil, priority: priority) do { - return try ContainerJSON.decode(type, from: data) + return try Core.Container.JSON.decode(type, from: data) } catch { - throw CommandError.decodingFailed(underlying: String(describing: error), command: name) + throw Core.Command.Error.decodingFailed(underlying: String(describing: error), command: name) } } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift index db39aacc..88475537 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift @@ -66,7 +66,7 @@ enum ContainerCommands { return args } - static func run(_ request: ContainerCreateRequest) -> [String] { + static func run(_ request: Core.Container.CreateRequest) -> [String] { var args = ["run"] if request.detach { args.append("--detach") } if request.removeOnExit { args.append("--rm") } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Metrics/StatsTableParser.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Metrics/StatsTableParser.swift index 8f640379..8ba03eaa 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Metrics/StatsTableParser.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Metrics/StatsTableParser.swift @@ -14,7 +14,7 @@ struct ContainerStatsTableParser: Sendable { init() {} - public mutating func append(_ chunk: String) -> [RuntimeStatsSnapshot] { + public mutating func append(_ chunk: String) -> [Core.Metrics.RuntimeStatsSnapshot] { buffer += chunk guard let frame = Self.latestParseableFrame(in: buffer), frame != lastEmittedFrame else { return [] } guard let snapshots = Self.parseFrame(frame), !snapshots.isEmpty else { return [] } @@ -23,7 +23,7 @@ struct ContainerStatsTableParser: Sendable { return snapshots } - static func parseLatestFrame(in output: String) -> [RuntimeStatsSnapshot] { + static func parseLatestFrame(in output: String) -> [Core.Metrics.RuntimeStatsSnapshot] { guard let frame = latestParseableFrame(in: output), let snapshots = parseFrame(frame) else { return [] @@ -31,7 +31,7 @@ struct ContainerStatsTableParser: Sendable { return snapshots } - static func parseFrame(_ frame: String) -> [RuntimeStatsSnapshot]? { + static func parseFrame(_ frame: String) -> [Core.Metrics.RuntimeStatsSnapshot]? { let lines = stripANSI(from: frame) .components(separatedBy: .newlines) .map { String($0) } @@ -44,7 +44,7 @@ struct ContainerStatsTableParser: Sendable { } guard starts.count == columns.count else { return nil } - var snapshots: [RuntimeStatsSnapshot] = [] + var snapshots: [Core.Metrics.RuntimeStatsSnapshot] = [] for row in lines.dropFirst(headerIndex + 1) { let trimmed = row.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("error collecting stats") { continue } @@ -74,7 +74,7 @@ struct ContainerStatsTableParser: Sendable { columns.allSatisfy { line.contains($0) } } - private static func parseRow(_ row: String, starts: [Int]) -> RuntimeStatsSnapshot? { + private static func parseRow(_ row: String, starts: [Int]) -> Core.Metrics.RuntimeStatsSnapshot? { let fields = starts.enumerated().map { index, start in let end = index + 1 < starts.count ? starts[index + 1] : nil return field(in: row, start: start, end: end) @@ -86,7 +86,7 @@ struct ContainerStatsTableParser: Sendable { let network = parseBytePair(fields[3]) let block = parseBytePair(fields[4]) - return RuntimeStatsSnapshot( + return Core.Metrics.RuntimeStatsSnapshot( id: id, cpuCoreFraction: parseCPU(fields[1]), memoryUsageBytes: memory.first, diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift index 9efbc3d5..a73b33bb 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift @@ -1,33 +1,33 @@ import Foundation enum AppleContainerCreateTranslator { - static func preview(for request: ContainerCreateRequest) -> RuntimeCommandPreview { - RuntimeCommandPreview(command: ContainerCommands.run(request)) + static func preview(for request: Core.Container.CreateRequest) -> Core.Command.Preview { + Core.Command.Preview(command: ContainerCommands.run(request)) } - static func result(from data: Data, request: ContainerCreateRequest) -> ContainerCreateResult { + static func result(from data: Data, request: Core.Container.CreateRequest) -> Core.Container.CreateResult { let output = String(decoding: data, as: UTF8.self) let printedID = output .components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespaces) } .last(where: { !$0.isEmpty }) - return ContainerCreateResult(id: request.effectiveName ?? printedID, output: output) + return Core.Container.CreateResult(id: request.effectiveName ?? printedID, output: output) } - static func composePlan(for project: ComposeProject, - baseDirectory: URL?) -> RuntimeComposeImportPlan { - let items = project.services.compactMap { service -> RuntimeComposeImportItem? in + static func composePlan(for project: Core.Compose.Project, + baseDirectory: URL?) -> Core.Compose.ImportPlan { + let items = project.services.compactMap { service -> Core.Compose.ImportItem? in guard service.image != nil else { return nil } - return RuntimeComposeImportItem( + return Core.Compose.ImportItem( request: createRequest(for: service, projectName: project.name, baseDirectory: baseDirectory), healthCheck: healthCheck(for: service) ) } - return RuntimeComposeImportPlan(items: items, warnings: project.warnings) + return Core.Compose.ImportPlan(items: items, warnings: project.warnings) } - static func imageDefaults(for request: ContainerCreateRequest, - in images: [ImageResource]) -> ContainerImageDefaults? { + static func imageDefaults(for request: Core.Container.CreateRequest, + in images: [Core.Image.Resource]) -> Core.Container.ImageDefaults? { guard let image = matchingImage(for: request.image, in: images) else { return nil } let runnable = image.variants.filter(\.isRunnable) let platformMatch = runnable.first { variant in @@ -39,7 +39,7 @@ enum AppleContainerCreateTranslator { let hostMatch = runnable.first { $0.platform.os == "linux" && $0.platform.architecture == "amd64" } #endif guard let config = (platformMatch ?? hostMatch ?? runnable.first)?.config?.config else { return nil } - return ContainerImageDefaults( + return Core.Container.ImageDefaults( command: config.cmd ?? [], entrypoint: config.entrypoint ?? [], workingDirectory: config.workingDir, @@ -48,10 +48,10 @@ enum AppleContainerCreateTranslator { ) } - private static func createRequest(for service: ComposeService, + private static func createRequest(for service: Core.Compose.Service, projectName: String, - baseDirectory: URL?) -> ContainerCreateRequest { - var request = ContainerCreateRequest() + baseDirectory: URL?) -> Core.Container.CreateRequest { + var request = Core.Container.CreateRequest() request.runtimeKind = .appleContainer request.image = service.image ?? "" request.platform = service.platform ?? "" @@ -61,7 +61,7 @@ enum AppleContainerCreateTranslator { request.detach = true request.interactive = service.interactive request.tty = service.tty - request.restart = RestartPolicy(label: service.restart) + request.restart = Core.Container.RestartPolicy(label: service.restart) request.cpus = service.cpus ?? "" request.memory = service.memory ?? "" request.readOnly = service.readOnly @@ -81,13 +81,13 @@ enum AppleContainerCreateTranslator { request.env = service.environment.compactMap(keyValue) request.envFiles = service.envFiles request.labels = service.labels.compactMap(keyValue) - request.labels.append(ContainerCreateKeyValue(key: "contained.stack", value: projectName)) + request.labels.append(Core.Container.KeyValue(key: "contained.stack", value: projectName)) return request } - private static func healthCheck(for service: ComposeService) -> HealthCheck? { + private static func healthCheck(for service: Core.Compose.Service) -> Core.Container.HealthCheck? { guard let healthcheck = service.healthcheck else { return nil } - return HealthCheck(command: healthcheck.test, + return Core.Container.HealthCheck(command: healthcheck.test, intervalSeconds: healthcheck.intervalSeconds, retries: healthcheck.retries, enabled: true) @@ -98,7 +98,7 @@ enum AppleContainerCreateTranslator { return command.split(separator: " ").map(String.init) } - private static func portMap(_ spec: String) -> ContainerCreatePort? { + private static func portMap(_ spec: String) -> Core.Container.Port? { var raw = spec let proto: String if let slash = raw.lastIndex(of: "/") { @@ -112,28 +112,28 @@ enum AppleContainerCreateTranslator { let host = parts.dropLast().joined(separator: ":") let container = parts[parts.count - 1] guard !host.isEmpty, !container.isEmpty else { return nil } - return ContainerCreatePort(hostPort: host, containerPort: container, proto: proto) + return Core.Container.Port(hostPort: host, containerPort: container, proto: proto) } - private static func volumeMap(_ spec: String, baseDirectory: URL?) -> ContainerCreateVolume? { + private static func volumeMap(_ spec: String, baseDirectory: URL?) -> Core.Container.VolumeMount? { let parts = spec.split(separator: ":", maxSplits: 2).map(String.init) guard parts.count > 1 else { return nil } var source = parts.first ?? "" if let baseDirectory, source.hasPrefix("./") || source.hasPrefix("../") { source = baseDirectory.appending(path: source).standardizedFileURL.path } - return ContainerCreateVolume(source: source, + return Core.Container.VolumeMount(source: source, target: parts.count > 1 ? parts[1] : "", readOnly: parts.count > 2 && parts[2] == "ro") } - private static func keyValue(_ entry: String) -> ContainerCreateKeyValue? { + private static func keyValue(_ entry: String) -> Core.Container.KeyValue? { guard let eq = entry.firstIndex(of: "=") else { return nil } - return ContainerCreateKeyValue(key: String(entry[.. ImageResource? { + private static func matchingImage(for reference: String, in images: [Core.Image.Resource]) -> Core.Image.Resource? { let target = normalizedImageReference(reference) return images.first { normalizedImageReference($0.reference) == target } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Schema/Version.swift b/Packages/ContainedCore/Sources/ContainedCore/Schema/Version.swift index 41e01268..d25dddf8 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Schema/Version.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/Version.swift @@ -1,11 +1,14 @@ import Foundation -public struct CoreSchemaVersion: Codable, Equatable, Hashable, Sendable { +public extension Core.Schema { +struct Version: Codable, Equatable, Hashable, Sendable { public var rawValue: Int public init(_ rawValue: Int = 1) { self.rawValue = rawValue } - public static let current = CoreSchemaVersion() + public static let current = Core.Schema.Version() +} + } diff --git a/Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift b/Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift index ac5e23a9..e10922dc 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift @@ -2,7 +2,8 @@ import Foundation /// `system property list --format json` — the daemon's default configuration (builder + default /// container resources + kernel). Decoded leniently; unknown/empty sections are tolerated. -public struct SystemProperties: Codable, Sendable, Hashable { +public extension Core.System { +struct Properties: Codable, Sendable, Hashable { public let build: Build? public let container: Defaults? public let machine: Defaults? @@ -31,3 +32,5 @@ public struct SystemProperties: Codable, Sendable, Hashable { kernel = try c.decodeIfPresent(Kernel.self, forKey: .kernel) } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/System/SystemStatus.swift b/Packages/ContainedCore/Sources/ContainedCore/System/SystemStatus.swift index 22eb0c82..382f75d8 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/System/SystemStatus.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/System/SystemStatus.swift @@ -1,7 +1,8 @@ import Foundation /// `container system df --format json`. -public struct DiskUsage: Codable, Sendable, Hashable { +public extension Core.System { +struct DiskUsage: Codable, Sendable, Hashable { public let containers: Category public let images: Category public let volumes: Category @@ -18,7 +19,7 @@ public struct DiskUsage: Codable, Sendable, Hashable { } /// `container system status --format json`. -public struct SystemStatus: Codable, Sendable, Hashable { +struct Status: Codable, Sendable, Hashable { public let status: String public let appRoot: String? public let installRoot: String? @@ -29,3 +30,5 @@ public struct SystemStatus: Codable, Sendable, Hashable { public var isRunning: Bool { status.lowercased() == "running" } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Volume/VolumeResource.swift b/Packages/ContainedCore/Sources/ContainedCore/Volume/Volume+Resource.swift similarity index 86% rename from Packages/ContainedCore/Sources/ContainedCore/Volume/VolumeResource.swift rename to Packages/ContainedCore/Sources/ContainedCore/Volume/Volume+Resource.swift index 21cbdee2..12efa719 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Volume/VolumeResource.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Volume/Volume+Resource.swift @@ -5,14 +5,15 @@ import Foundation /// The local environment has no volumes yet (fixture is `[]`), so this is modeled leniently from /// the `apple/container` source layout (`configuration` + computed `name`/`labels`). Fields are /// confirmed/expanded once a real volume fixture is captured. -public struct VolumeResource: Codable, Sendable, Identifiable, Hashable { - public let configuration: VolumeConfiguration +public extension Core.Volume { +struct Resource: Codable, Sendable, Identifiable, Hashable { + public let configuration: Core.Volume.Configuration public var id: String { configuration.name } public var name: String { configuration.name } public var labels: [String: String] { configuration.labels } } -public struct VolumeConfiguration: Codable, Sendable, Hashable { +struct Configuration: Codable, Sendable, Hashable { public let name: String public let source: String? public let format: String? @@ -30,3 +31,5 @@ public struct VolumeConfiguration: Codable, Sendable, Hashable { labels = try c.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/AppleContainer/AppleContainerFixtures.swift b/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/AppleContainer/AppleContainerFixtures.swift index 72c10def..50a0688c 100644 --- a/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/AppleContainer/AppleContainerFixtures.swift +++ b/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/AppleContainer/AppleContainerFixtures.swift @@ -5,19 +5,19 @@ public extension Core.Fixtures.AppleContainer { static let runtimeDescriptor = Core.Runtime.Descriptor.appleContainer static let runtimes = [runtimeDescriptor] - static let webContainer = ContainerSnapshot.placeholder( + static let webContainer = Core.Container.Snapshot.placeholder( id: "preview-web", image: "docker.io/library/nginx:latest", state: .running ) - static let workerContainer = ContainerSnapshot.placeholder( + static let workerContainer = Core.Container.Snapshot.placeholder( id: "preview-worker", image: "ghcr.io/example/worker:nightly", state: .stopped ) - static let stats = StatsDelta( + static let stats = Core.Metrics.StatsDelta( id: "preview-web", cpuCoreFraction: 0.62, memoryUsageBytes: 420_000_000, @@ -29,7 +29,7 @@ public extension Core.Fixtures.AppleContainer { numProcesses: 9 ) - static let image = decode(ImageResource.self, from: """ + static let image = decode(Core.Image.Resource.self, from: """ { "configuration": { "name": "docker.io/library/nginx:latest", @@ -63,9 +63,9 @@ public extension Core.Fixtures.AppleContainer { } """) - static let imageGroup = LocalImageTagGroup.group(containing: image, in: [image]) + static let imageGroup = Core.Image.LocalTagGroup.group(containing: image, in: [image]) - static let volume = decode(VolumeResource.self, from: """ + static let volume = decode(Core.Volume.Resource.self, from: """ { "configuration": { "name": "preview-data", @@ -78,7 +78,7 @@ public extension Core.Fixtures.AppleContainer { } """) - static let network = decode(NetworkResource.self, from: """ + static let network = decode(Core.Network.Resource.self, from: """ { "id": "preview-network", "configuration": { @@ -102,22 +102,22 @@ public extension Core.Fixtures.AppleContainer { capability: .coreMigration ) - static let commandError = CommandError.nonZeroExit( + static let commandError = Core.Command.Error.nonZeroExit( code: 42, stderr: "preview failure", command: "container preview" ) - static let createRequest: ContainerCreateRequest = { - var request = ContainerCreateRequest() + static let createRequest: Core.Container.CreateRequest = { + var request = Core.Container.CreateRequest() request.runtimeKind = .appleContainer request.image = image.reference request.platform = "linux/arm64" request.name = "preview-web" request.command = ["nginx", "-g", "daemon off;"] - request.env = [ContainerCreateKeyValue(key: "ENV", value: "preview")] - request.labels = [ContainerCreateKeyValue(key: "contained.stack", value: "preview")] - request.ports = [ContainerCreatePort(hostPort: "8080", containerPort: "80")] + request.env = [Core.Container.KeyValue(key: "ENV", value: "preview")] + request.labels = [Core.Container.KeyValue(key: "contained.stack", value: "preview")] + request.ports = [Core.Container.Port(hostPort: "8080", containerPort: "80")] request.cpus = "2" request.memory = "1g" request.workingDir = "/" diff --git a/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Generic/GenericFixtures.swift b/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Generic/GenericFixtures.swift index 2542b28c..5c272efd 100644 --- a/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Generic/GenericFixtures.swift +++ b/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/Generic/GenericFixtures.swift @@ -2,7 +2,7 @@ import Foundation import ContainedCore public extension Core.Fixtures.Generic { - struct MetricHistory: Equatable, Sendable, ContainedCore.MetricHistorySample { + struct MetricHistory: Equatable, Sendable, Core.Metrics.HistorySample { public var timestamp: Date public var cpuFraction: Double public var memoryBytes: Double diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift index 900bb93c..bb053011 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift @@ -59,7 +59,7 @@ struct CommandTests { interval: 10s retries: 5 """ - let project = try ComposeParser.parse(yaml, projectName: "demo") + let project = try Core.Compose.Parser.parse(yaml, projectName: "demo") let web = project.services.first { $0.key == "web" } let db = project.services.first { $0.key == "db" } #expect(web?.dependsOn.first?.service == "db") @@ -68,43 +68,43 @@ struct CommandTests { #expect(db?.healthcheck?.intervalSeconds == 10) #expect(db?.healthcheck?.retries == 5) // db must launch before web - let (order, cycle) = ComposeOrder.sorted(project.services) + let (order, cycle) = Core.Compose.Order.sorted(project.services) #expect(!cycle) #expect(order.firstIndex(of: "db")! < order.firstIndex(of: "web")!) } @Test func composeCycleFallsBack() { - let a = ComposeService(key: "a", name: "a", image: "x", platform: nil, command: nil, ports: [], volumes: [], + let a = Core.Compose.Service(key: "a", name: "a", image: "x", platform: nil, command: nil, ports: [], volumes: [], environment: [], restart: nil, - dependsOn: [ComposeDependency(service: "b", condition: .started)], healthcheck: nil) - let b = ComposeService(key: "b", name: "b", image: "x", platform: nil, command: nil, ports: [], volumes: [], + dependsOn: [Core.Compose.Dependency(service: "b", condition: .started)], healthcheck: nil) + let b = Core.Compose.Service(key: "b", name: "b", image: "x", platform: nil, command: nil, ports: [], volumes: [], environment: [], restart: nil, - dependsOn: [ComposeDependency(service: "a", condition: .started)], healthcheck: nil) - let (order, cycle) = ComposeOrder.sorted([a, b]) + dependsOn: [Core.Compose.Dependency(service: "a", condition: .started)], healthcheck: nil) + let (order, cycle) = Core.Compose.Order.sorted([a, b]) #expect(cycle) #expect(order == ["a", "b"]) // declared order on cycle } @Test func healthDecision() { - #expect(HealthDecision.status(consecutiveFailures: 0, retries: 3) == .healthy) - #expect(HealthDecision.status(consecutiveFailures: 2, retries: 3) == .healthy) - #expect(HealthDecision.status(consecutiveFailures: 3, retries: 3) == .unhealthy) - #expect(HealthDecision.status(consecutiveFailures: 5, retries: 3) == .unhealthy) + #expect(Core.Container.HealthDecision.status(consecutiveFailures: 0, retries: 3) == .healthy) + #expect(Core.Container.HealthDecision.status(consecutiveFailures: 2, retries: 3) == .healthy) + #expect(Core.Container.HealthDecision.status(consecutiveFailures: 3, retries: 3) == .unhealthy) + #expect(Core.Container.HealthDecision.status(consecutiveFailures: 5, retries: 3) == .unhealthy) // retries floored at 1 so a zero/negative budget can't make it permanently healthy - #expect(HealthDecision.status(consecutiveFailures: 1, retries: 0) == .unhealthy) + #expect(Core.Container.HealthDecision.status(consecutiveFailures: 1, retries: 0) == .unhealthy) } @Test func hubSearchURL() { - let url = HubSearch.url(query: "nginx", pageSize: 10) + let url = Core.Registry.HubSearch.url(query: "nginx", pageSize: 10) #expect(url?.absoluteString == "https://hub.docker.com/v2/search/repositories/?query=nginx&page_size=10") - #expect(HubSearch.url(query: " ") == nil) // blank query → no request + #expect(Core.Registry.HubSearch.url(query: " ") == nil) // blank query → no request } @Test func hubSearchDecodes() throws { let json = """ {"results":[{"repo_name":"library/nginx","short_description":"web server","star_count":18000,"is_official":true,"is_automated":false}]} """ - let decoded = try JSONDecoder().decode(HubSearchResponse.self, from: Data(json.utf8)) + let decoded = try JSONDecoder().decode(Core.Registry.HubSearchResponse.self, from: Data(json.utf8)) #expect(decoded.results.first?.repoName == "library/nginx") #expect(decoded.results.first?.isOfficial == true) #expect(decoded.results.first?.starCount == 18000) @@ -112,11 +112,11 @@ struct CommandTests { } @Test func statsDeltaComputesCPUFraction() { - let prev = ContainerStats(id: "x", cpuUsageUsec: 1_000_000, memoryUsageBytes: 100, memoryLimitBytes: 1000, + let prev = Core.Metrics.ContainerStats(id: "x", cpuUsageUsec: 1_000_000, memoryUsageBytes: 100, memoryLimitBytes: 1000, blockReadBytes: 0, blockWriteBytes: 0, networkRxBytes: 0, networkTxBytes: 0, numProcesses: 1) - let curr = ContainerStats(id: "x", cpuUsageUsec: 1_500_000, memoryUsageBytes: 200, memoryLimitBytes: 1000, + let curr = Core.Metrics.ContainerStats(id: "x", cpuUsageUsec: 1_500_000, memoryUsageBytes: 200, memoryLimitBytes: 1000, blockReadBytes: 0, blockWriteBytes: 1024, networkRxBytes: 2048, networkTxBytes: 0, numProcesses: 2) - let delta = StatsDelta.between(previous: prev, current: curr, interval: 1.0) + let delta = Core.Metrics.StatsDelta.between(previous: prev, current: curr, interval: 1.0) // 0.5s of CPU over 1s wall = 0.5 cores. #expect(abs(delta.cpuCoreFraction - 0.5) < 0.0001) #expect(delta.memoryFraction == 0.2) @@ -126,18 +126,18 @@ struct CommandTests { } @Test func statsDeltaConvertsRuntimeSnapshotRates() { - let previous = RuntimeStatsSnapshot(id: "x", cpuCoreFraction: 0.1, + let previous = Core.Metrics.RuntimeStatsSnapshot(id: "x", cpuCoreFraction: 0.1, memoryUsageBytes: 100, memoryLimitBytes: 1000, blockReadBytes: 1_000, blockWriteBytes: 2_000, networkRxBytes: 3_000, networkTxBytes: 4_000, numProcesses: 1) - let current = RuntimeStatsSnapshot(id: "x", cpuCoreFraction: 0.42, + let current = Core.Metrics.RuntimeStatsSnapshot(id: "x", cpuCoreFraction: 0.42, memoryUsageBytes: 200, memoryLimitBytes: 1000, blockReadBytes: 1_500, blockWriteBytes: 2_800, networkRxBytes: 4_000, networkTxBytes: 4_400, numProcesses: 3) - let delta = StatsDelta.from(snapshot: current, previous: previous, interval: 2) + let delta = Core.Metrics.StatsDelta.from(snapshot: current, previous: previous, interval: 2) #expect(delta.cpuCoreFraction == 0.42) #expect(delta.memoryFraction == 0.2) #expect(delta.blockReadBytesPerSec == 250) diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift index bb9ee632..135eaeda 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift @@ -6,19 +6,19 @@ import Testing struct DecodingTests { @Test func placeholderSnapshotDecodes() { - let s = ContainerSnapshot.placeholder(id: "nginx", image: "nginx:latest") + let s = Core.Container.Snapshot.placeholder(id: "nginx", image: "nginx:latest") #expect(s.id == "nginx") #expect(s.image == "nginx:latest") #expect(s.state == .running) - let stopped = ContainerSnapshot.placeholder(id: "x", image: "redis:7", state: .stopped) + let stopped = Core.Container.Snapshot.placeholder(id: "x", image: "redis:7", state: .stopped) #expect(stopped.state == .stopped) - let quoted = ContainerSnapshot.placeholder(id: #"weird "id""#, image: #"repo/"quoted":tag"#) + let quoted = Core.Container.Snapshot.placeholder(id: #"weird "id""#, image: #"repo/"quoted":tag"#) #expect(quoted.id == #"weird "id""#) #expect(quoted.image == #"repo/"quoted":tag"#) } @Test func decodesContainerList() throws { - let snapshots = try ContainerJSON.decode([ContainerSnapshot].self, from: try Fixture.data("list")) + let snapshots = try Core.Container.JSON.decode([Core.Container.Snapshot].self, from: try Fixture.data("list")) try #require(snapshots.count == 1) let c = snapshots[0] #expect(c.id == "fixture-web") @@ -36,7 +36,7 @@ struct DecodingTests { @Test func decodesMultiContainerListWithVirtiofsMounts() throws { // Live output can represent mount `type` as an enum-like object such as {"virtiofs":{}}. - let snapshots = try ContainerJSON.decode([ContainerSnapshot].self, from: try Fixture.data("list-current")) + let snapshots = try Core.Container.JSON.decode([Core.Container.Snapshot].self, from: try Fixture.data("list-current")) #expect(snapshots.count == 4) let npm = try #require(snapshots.first { $0.id == "nginx-proxy-manager-latest" }) #expect(npm.configuration.mounts.count == 2) @@ -45,13 +45,13 @@ struct DecodingTests { } @Test func decodesInspectMatchesList() throws { - let inspected = try ContainerJSON.decode([ContainerSnapshot].self, from: try Fixture.data("inspect")) + let inspected = try Core.Container.JSON.decode([Core.Container.Snapshot].self, from: try Fixture.data("inspect")) #expect(inspected.first?.id == "fixture-web") #expect(inspected.first?.configuration.platform.architecture == "arm64") } @Test func decodesStats() throws { - let stats = try ContainerJSON.decode([ContainerStats].self, from: try Fixture.data("stats")) + let stats = try Core.Container.JSON.decode([Core.Metrics.ContainerStats].self, from: try Fixture.data("stats")) let s = try #require(stats.first) #expect(s.id == "fixture-web") #expect(s.memoryLimitBytes == 1_073_741_824) @@ -60,7 +60,7 @@ struct DecodingTests { } @Test func decodesDiskUsage() throws { - let df = try ContainerJSON.decode(DiskUsage.self, from: try Fixture.data("df")) + let df = try Core.Container.JSON.decode(Core.System.DiskUsage.self, from: try Fixture.data("df")) #expect(df.images.total == 11) #expect(df.containers.total == 3) #expect(df.volumes.sizeInBytes == 0) @@ -68,7 +68,7 @@ struct DecodingTests { } @Test func decodesSystemStatus() throws { - let status = try ContainerJSON.decode(SystemStatus.self, from: try Fixture.data("status")) + let status = try Core.Container.JSON.decode(Core.System.Status.self, from: try Fixture.data("status")) #expect(status.isRunning) #expect(status.apiServerVersion?.contains("1.0.0") == true) } @@ -81,7 +81,7 @@ struct DecodingTests { "build": { "cpus": 2, "memory": "2048mb" } } """.utf8) - let properties = try ContainerJSON.decode(SystemProperties.self, from: data) + let properties = try Core.Container.JSON.decode(Core.System.Properties.self, from: data) #expect(properties.container?.cpus == 4) #expect(properties.container?.memory == "1gb") @@ -90,7 +90,7 @@ struct DecodingTests { } @Test func decodesNetworks() throws { - let nets = try ContainerJSON.decode([NetworkResource].self, from: try Fixture.data("networks")) + let nets = try Core.Container.JSON.decode([Core.Network.Resource].self, from: try Fixture.data("networks")) let def = try #require(nets.first) #expect(def.name == "default") #expect(def.isBuiltin) @@ -98,12 +98,12 @@ struct DecodingTests { } @Test func decodesEmptyVolumes() throws { - let vols = try ContainerJSON.decode([VolumeResource].self, from: try Fixture.data("volumes")) + let vols = try Core.Container.JSON.decode([Core.Volume.Resource].self, from: try Fixture.data("volumes")) #expect(vols.isEmpty) } @Test func decodesMultiArchImage() throws { - let images = try ContainerJSON.decode([ImageResource].self, from: try Fixture.data("image-inspect")) + let images = try Core.Container.JSON.decode([Core.Image.Resource].self, from: try Fixture.data("image-inspect")) let img = try #require(images.first) #expect(img.reference == "docker.io/library/alpine:latest") #expect(img.variants.count > 1) @@ -118,14 +118,14 @@ struct DecodingTests { } @Test func handlesDatesWithAndWithoutFractionalSeconds() throws { - #expect(ContainerJSON.parseDate("2026-06-24T10:16:58Z") != nil) - #expect(ContainerJSON.parseDate("2026-06-16T00:01:29.967161902Z") != nil) - #expect(ContainerJSON.parseDate("not-a-date") == nil) + #expect(Core.Container.JSON.parseDate("2026-06-24T10:16:58Z") != nil) + #expect(Core.Container.JSON.parseDate("2026-06-16T00:01:29.967161902Z") != nil) + #expect(Core.Container.JSON.parseDate("not-a-date") == nil) } @Test func unknownRuntimeStatusFallsBack() throws { let data = Data(#"{"state":"frobnicating","networks":[]}"#.utf8) - let s = try ContainerJSON.decode(ContainerRuntimeState.self, from: data) + let s = try Core.Container.JSON.decode(Core.Container.RuntimeState.self, from: data) #expect(s.state == .unknown) } } diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift index 9d49b773..5e5adab2 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift @@ -18,19 +18,19 @@ enum Fixture { enum FixtureError: Error { case notFound(String) } } -/// A `CommandRunning` that replays canned output/errors with no runtime daemon. -struct MockCommandRunner: CommandRunning { - var result: Result +/// A `Core.Command.Running` that replays canned output/errors with no runtime daemon. +struct MockCommandRunner: Core.Command.Running { + var result: Result var streamChunks: [String] = [] func run(_ arguments: [String], stdin: Data?, - priority: CommandExecutionPriority) async throws -> Data { + priority: Core.Command.ExecutionPriority) async throws -> Data { try result.get() } func stream(_ arguments: [String], - priority: CommandExecutionPriority) -> AsyncThrowingStream { + priority: Core.Command.ExecutionPriority) -> AsyncThrowingStream { let chunks = streamChunks return AsyncThrowingStream { continuation in for chunk in chunks { continuation.yield(chunk) } diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/ImageWorkflowTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/ImageWorkflowTests.swift index f9a06668..90da7438 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/ImageWorkflowTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/ImageWorkflowTests.swift @@ -5,19 +5,19 @@ import Testing @Suite("Image workflow helpers", .serialized) struct ImageWorkflowTests { @Test func registryReferenceNormalization() { - let official = RegistryImageReference.parse("nginx") + let official = Core.Registry.ImageReference.parse("nginx") #expect(official.registry == "registry-1.docker.io") #expect(official.repository == "library/nginx") #expect(official.reference == "latest") #expect(official.normalizedKey == "docker.io/library/nginx:latest") - let namespaced = RegistryImageReference.parse("docker.io/tdeverx/app:nightly") + let namespaced = Core.Registry.ImageReference.parse("docker.io/tdeverx/app:nightly") #expect(namespaced.registry == "registry-1.docker.io") #expect(namespaced.repository == "tdeverx/app") #expect(namespaced.reference == "nightly") #expect(namespaced.normalizedKey == "docker.io/tdeverx/app:nightly") - let custom = RegistryImageReference.parse("ghcr.io/acme/app@sha256:abc") + let custom = Core.Registry.ImageReference.parse("ghcr.io/acme/app@sha256:abc") #expect(custom.registry == "ghcr.io") #expect(custom.repository == "acme/app") #expect(custom.reference == "sha256:abc") @@ -26,10 +26,10 @@ struct ImageWorkflowTests { } @Test func imageUpdateStatusTransitions() { - #expect(ImageUpdateStatus.checking(localDigest: "sha256:a").state == .checking) - #expect(ImageUpdateStatus.resolved(localDigest: "sha256:a", remoteDigest: "sha256:a").state == .current) - #expect(ImageUpdateStatus.resolved(localDigest: "sha256:a", remoteDigest: "sha256:b").state == .updateAvailable) - let failed = ImageUpdateStatus.failed(localDigest: "sha256:a", message: "boom") + #expect(Core.Image.UpdateStatus.checking(localDigest: "sha256:a").state == .checking) + #expect(Core.Image.UpdateStatus.resolved(localDigest: "sha256:a", remoteDigest: "sha256:a").state == .current) + #expect(Core.Image.UpdateStatus.resolved(localDigest: "sha256:a", remoteDigest: "sha256:b").state == .updateAvailable) + let failed = Core.Image.UpdateStatus.failed(localDigest: "sha256:a", message: "boom") #expect(failed.state == .error) #expect(failed.message == "boom") } @@ -55,8 +55,8 @@ struct ImageWorkflowTests { } ] """ - let images = try JSONDecoder().decode([ImageResource].self, from: Data(json.utf8)) - let groups = LocalImageTagGroup.groups(for: images) + let images = try JSONDecoder().decode([Core.Image.Resource].self, from: Data(json.utf8)) + let groups = Core.Image.LocalTagGroup.groups(for: images) #expect(groups.count == 1) #expect(groups.first?.references == ["docker.io/library/alpine:latest", "localhost/alpine:test"]) } @@ -70,7 +70,7 @@ struct ImageWorkflowTests { {"results":[{"repo_name":"library/nginx","short_description":"web server","star_count":18000,"is_official":true,"is_automated":false}]} """) } - let results = try await HubSearch.results(query: "nginx", session: session) + let results = try await Core.Registry.HubSearch.results(query: "nginx", session: session) #expect(results.map(\.pullReference) == ["nginx"]) } @@ -82,7 +82,7 @@ struct ImageWorkflowTests { "Docker-Content-Digest": "sha256:remote", ]) } - let digest = try await RegistryManifestClient(session: session).remoteDigest(for: "nginx") + let digest = try await Core.Registry.ManifestClient(session: session).remoteDigest(for: "nginx") #expect(digest == "sha256:remote") } @@ -106,7 +106,7 @@ struct ImageWorkflowTests { "Docker-Content-Digest": "sha256:after-auth", ]) } - let digest = try await RegistryManifestClient(session: session).remoteDigest(for: "registry.example.test/team/app:1") + let digest = try await Core.Registry.ManifestClient(session: session).remoteDigest(for: "registry.example.test/team/app:1") #expect(digest == "sha256:after-auth") #expect(state.manifestHits == 2) } @@ -115,18 +115,18 @@ struct ImageWorkflowTests { let missingDigest = Self.session { request in Self.response(url: request.url!, status: 200) } - await #expect(throws: RegistryManifestError.missingDigest) { - _ = try await RegistryManifestClient(session: missingDigest).remoteDigest(for: "nginx") + await #expect(throws: Core.Registry.ManifestError.missingDigest) { + _ = try await Core.Registry.ManifestClient(session: missingDigest).remoteDigest(for: "nginx") } let notFound = Self.session { request in Self.response(url: request.url!, status: 404) } - await #expect(throws: RegistryManifestError.notFound) { - _ = try await RegistryManifestClient(session: notFound).remoteDigest(for: "nginx") + await #expect(throws: Core.Registry.ManifestError.notFound) { + _ = try await Core.Registry.ManifestClient(session: notFound).remoteDigest(for: "nginx") } - let status = RegistryManifestError.httpStatus(500) + let status = Core.Registry.ManifestError.httpStatus(500) #expect(status.packageName == "ContainedCore") #expect(status.packageErrorCode == "registryHTTPStatus") #expect(status.packageErrorContext["status"] == "500") diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift index 9cf404d5..d239aaca 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift @@ -5,8 +5,8 @@ import Testing @Suite("Runtime descriptor contracts") struct RuntimeDescriptorTests { @Test func openRuntimeKindsCanAdvertiseCapabilities() throws { - let descriptor = RuntimeDescriptor( - kind: RuntimeKind(rawValue: "future-runtime"), + let descriptor = Core.Runtime.Descriptor( + kind: Core.Runtime.Kind(rawValue: "future-runtime"), displayName: "Future runtime", executableName: "future", capabilities: [.containers, .composeImport] @@ -19,21 +19,21 @@ struct RuntimeDescriptorTests { } @Test func unsupportedCapabilityIsDisplayNeutralPackageError() { - let error = UnsupportedRuntimeCapability( + let error = Core.Runtime.UnsupportedCapability( kind: .dockerCompatible, capability: .imageBuild ) #expect(error.packageName == "ContainedCore") #expect(error.packageErrorCode == "unsupportedRuntimeCapability") - #expect(error.packageErrorContext["kind"] == RuntimeKind.dockerCompatible.rawValue) - #expect(error.packageErrorContext["capability"] == String(RuntimeCapability.imageBuild.rawValue)) + #expect(error.packageErrorContext["kind"] == Core.Runtime.Kind.dockerCompatible.rawValue) + #expect(error.packageErrorContext["capability"] == String(Core.Runtime.Capability.imageBuild.rawValue)) } @Test func defaultCoreSwitchPlanIsDisplayNeutral() throws { let runtime = UnavailableRuntime( - descriptor: RuntimeDescriptor( - kind: RuntimeKind(rawValue: "future-runtime"), + descriptor: Core.Runtime.Descriptor( + kind: Core.Runtime.Kind(rawValue: "future-runtime"), displayName: "Future runtime", executableName: "future", capabilities: [.containers] @@ -45,20 +45,20 @@ struct RuntimeDescriptorTests { #expect(!plan.isAvailable) #expect(plan.unavailableReason == .exportImportUnsupported) #expect(plan.context["source"] == "future-runtime") - #expect(plan.context["target"] == RuntimeKind.appleContainer.rawValue) + #expect(plan.context["target"] == Core.Runtime.Kind.appleContainer.rawValue) } } private struct UnavailableRuntime: ContainerRuntimeClient { - let descriptor: RuntimeDescriptor + let descriptor: Core.Runtime.Descriptor - func listContainers(all: Bool) async throws -> [ContainerSnapshot] { [] } - func stats(ids: [String]) async throws -> [ContainerStats] { [] } - func streamStats(ids: [String]) -> AsyncThrowingStream<[RuntimeStatsSnapshot], Error> { + func listContainers(all: Bool) async throws -> [Core.Container.Snapshot] { [] } + func stats(ids: [String]) async throws -> [Core.Metrics.ContainerStats] { [] } + func streamStats(ids: [String]) -> AsyncThrowingStream<[Core.Metrics.RuntimeStatsSnapshot], Error> { AsyncThrowingStream { $0.finish() } } - func diskUsage() async throws -> DiskUsage { throw TestStubError.unused } - func systemProperties() async throws -> SystemProperties { throw TestStubError.unused } + func diskUsage() async throws -> Core.System.DiskUsage { throw TestStubError.unused } + func systemProperties() async throws -> Core.System.Properties { throw TestStubError.unused } func dnsDomains() async throws -> [String] { [] } func createDNSDomain(_ domain: String) async throws -> Data { throw TestStubError.unused } func deleteDNSDomain(_ domain: String) async throws -> Data { throw TestStubError.unused } @@ -68,11 +68,11 @@ private struct UnavailableRuntime: ContainerRuntimeClient { func streamSystemLogs(follow: Bool, last: Int?) -> AsyncThrowingStream { AsyncThrowingStream { $0.finish() } } - func systemStatus() async throws -> SystemStatus { throw TestStubError.unused } - func networks() async throws -> [NetworkResource] { [] } - func volumes() async throws -> [VolumeResource] { [] } - func images() async throws -> [ImageResource] { [] } - func inspectImage(_ ref: String) async throws -> [ImageResource] { [] } + func systemStatus() async throws -> Core.System.Status { throw TestStubError.unused } + func networks() async throws -> [Core.Network.Resource] { [] } + func volumes() async throws -> [Core.Volume.Resource] { [] } + func images() async throws -> [Core.Image.Resource] { [] } + func inspectImage(_ ref: String) async throws -> [Core.Image.Resource] { [] } func streamLogs(id: String, follow: Bool, tail: Int?, boot: Bool) -> AsyncThrowingStream { AsyncThrowingStream { $0.finish() } } @@ -88,8 +88,8 @@ private struct UnavailableRuntime: ContainerRuntimeClient { AsyncThrowingStream { $0.finish() } } func runContainer(arguments: [String]) async throws -> Data { throw TestStubError.unused } - func performSystemAction(_ action: RuntimeSystemAction) async throws -> Data { throw TestStubError.unused } - func registries() async throws -> [RegistryLogin] { [] } + func performSystemAction(_ action: Core.Runtime.SystemAction) async throws -> Data { throw TestStubError.unused } + func registries() async throws -> [Core.Registry.Login] { [] } func registryLogin(server: String, username: String, password: String) async throws -> Data { throw TestStubError.unused } func registryLogout(server: String) async throws -> Data { throw TestStubError.unused } func deleteImages(_ refs: [String]) async throws -> Data { throw TestStubError.unused } diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift index ebd8ca07..530c1c2b 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift @@ -83,7 +83,7 @@ struct RuntimeWorkflowTests { networks: default: {} """ - let project = try ComposeParser.parse(yaml, projectName: "demo") + let project = try Core.Compose.Parser.parse(yaml, projectName: "demo") #expect(project.services.count == 2) let web = project.services.first { $0.name == "web" } #expect(web?.image == "nginx:latest") @@ -101,31 +101,31 @@ struct RuntimeWorkflowTests { // MARK: Restart watchdog decision logic @Test func restartPolicyParsing() { - #expect(RestartPolicy(label: "always") == .always) - #expect(RestartPolicy(label: "on-failure") == .onFailure) - #expect(RestartPolicy(label: nil) == .no) - #expect(RestartPolicy(label: "unless-stopped") == .no) - #expect(RestartPolicy(label: "garbage") == .no) + #expect(Core.Container.RestartPolicy(label: "always") == .always) + #expect(Core.Container.RestartPolicy(label: "on-failure") == .onFailure) + #expect(Core.Container.RestartPolicy(label: nil) == .no) + #expect(Core.Container.RestartPolicy(label: "unless-stopped") == .no) + #expect(Core.Container.RestartPolicy(label: "garbage") == .no) } @Test func watchdogDecision() { // User-initiated stops are never auto-restarted. - #expect(!RestartDecision.shouldRestart(policy: .always, userInitiated: true)) + #expect(!Core.Container.RestartDecision.shouldRestart(policy: .always, userInitiated: true)) // .no never restarts. - #expect(!RestartDecision.shouldRestart(policy: .no, userInitiated: false)) + #expect(!Core.Container.RestartDecision.shouldRestart(policy: .no, userInitiated: false)) // .always restarts any crash. - #expect(RestartDecision.shouldRestart(policy: .always, userInitiated: false)) + #expect(Core.Container.RestartDecision.shouldRestart(policy: .always, userInitiated: false)) // .onFailure: unknown exit treated as failure; known 0 suppressed; nonzero restarts. - #expect(RestartDecision.shouldRestart(policy: .onFailure, userInitiated: false, exitCode: nil)) - #expect(!RestartDecision.shouldRestart(policy: .onFailure, userInitiated: false, exitCode: 0)) - #expect(RestartDecision.shouldRestart(policy: .onFailure, userInitiated: false, exitCode: 137)) + #expect(Core.Container.RestartDecision.shouldRestart(policy: .onFailure, userInitiated: false, exitCode: nil)) + #expect(!Core.Container.RestartDecision.shouldRestart(policy: .onFailure, userInitiated: false, exitCode: 0)) + #expect(Core.Container.RestartDecision.shouldRestart(policy: .onFailure, userInitiated: false, exitCode: 137)) } @Test func watchdogBackoffGrowsAndCaps() { - #expect(RestartDecision.backoff(attempt: 0) == 0) - #expect(RestartDecision.backoff(attempt: 1) == 2) - #expect(RestartDecision.backoff(attempt: 2) == 4) - #expect(RestartDecision.backoff(attempt: 3) == 8) - #expect(RestartDecision.backoff(attempt: 10) == 60) // capped + #expect(Core.Container.RestartDecision.backoff(attempt: 0) == 0) + #expect(Core.Container.RestartDecision.backoff(attempt: 1) == 2) + #expect(Core.Container.RestartDecision.backoff(attempt: 2) == 4) + #expect(Core.Container.RestartDecision.backoff(attempt: 3) == 8) + #expect(Core.Container.RestartDecision.backoff(attempt: 10) == 60) // capped } } diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift index d5a59065..3578aa0a 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift @@ -5,7 +5,7 @@ import Testing @Suite("Runtime adapter boundary") struct AppleContainerAdapterTests { @Test func runtimeKindAcceptsFutureAdapters() throws { - let descriptor = RuntimeDescriptor(kind: RuntimeKind(rawValue: "future-engine"), + let descriptor = Core.Runtime.Descriptor(kind: Core.Runtime.Kind(rawValue: "future-engine"), displayName: "Future Engine", capabilities: [.containers]) @@ -13,12 +13,12 @@ struct AppleContainerAdapterTests { #expect(descriptor.executableName == nil) #expect(descriptor.supports(.containers)) #expect(!descriptor.supports(.imageBuild)) - #expect(throws: UnsupportedRuntimeCapability.self) { + #expect(throws: Core.Runtime.UnsupportedCapability.self) { try descriptor.require(.imageBuild) } do { try descriptor.require(.imageBuild) - } catch let error as UnsupportedRuntimeCapability { + } catch let error as Core.Runtime.UnsupportedCapability { #expect(error.packageName == "ContainedCore") #expect(error.packageErrorCode == "unsupportedRuntimeCapability") #expect(error.packageErrorContext["kind"] == "future-engine") @@ -26,7 +26,7 @@ struct AppleContainerAdapterTests { } @Test func commandErrorsExposePackageCodesAndContext() { - let error = CommandError.nonZeroExit(code: 42, stderr: "boom", command: "container list") + let error = Core.Command.Error.nonZeroExit(code: 42, stderr: "boom", command: "container list") #expect(error.packageName == "ContainedCore") #expect(error.packageErrorCode == "nonZeroExit") @@ -47,7 +47,7 @@ struct AppleContainerAdapterTests { } @Test func appleRuntimeDescriptorAdvertisesCurrentCapabilities() throws { - let descriptor = RuntimeDescriptor.appleContainer + let descriptor = Core.Runtime.Descriptor.appleContainer #expect(descriptor.kind == .appleContainer) #expect(descriptor.displayName == "Apple container") @@ -60,7 +60,7 @@ struct AppleContainerAdapterTests { } @Test func appleCreateTranslatorBuildsPreviewAndResult() { - var request = ContainerCreateRequest() + var request = Core.Container.CreateRequest() request.image = "nginx:latest" request.name = "web" request.cpus = "2" @@ -95,7 +95,7 @@ struct AppleContainerAdapterTests { test: ["CMD", "curl", "-f", "http://localhost:8080"] retries: 2 """ - let project = try ComposeParser.parse(yaml, projectName: "demo") + let project = try Core.Compose.Parser.parse(yaml, projectName: "demo") let base = URL(filePath: "/opt/stacks/demo", directoryHint: .isDirectory) let plan = AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: base) let item = try #require(plan.items.first) @@ -134,7 +134,7 @@ struct AppleContainerAdapterTests { let bad = MockCommandRunner(result: .success(Data("Error: content with digest sha256:...".utf8))) let client = AppleContainerClient(runner: bad) - await #expect(throws: CommandError.self) { + await #expect(throws: Core.Command.Error.self) { _ = try await client.listContainers() } } @@ -145,7 +145,7 @@ struct AppleContainerAdapterTests { command: "list"))) let client = AppleContainerClient(runner: failing) - await #expect(throws: CommandError.self) { + await #expect(throws: Core.Command.Error.self) { _ = try await client.listContainers() } } @@ -188,7 +188,7 @@ struct AppleContainerAdapterTests { let stream = try Fixture.string("stats-table") let runner = MockCommandRunner(result: .success(Data()), streamChunks: [stream]) let runtime: any ContainerRuntimeClient = AppleContainerClient(runner: runner) - var received: [[RuntimeStatsSnapshot]] = [] + var received: [[Core.Metrics.RuntimeStatsSnapshot]] = [] for try await samples in runtime.streamStats(ids: ["buildkit", "sonarrhd"]) { received.append(samples) diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift index 2a47b56a..857e5f38 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift @@ -1,7 +1,8 @@ import SwiftUI /// Package-owned progress capsule for action slots that are temporarily busy. -public struct ActionProgressCapsule: View { +public extension UI.Action { +struct ProgressCapsule: View { public var controlSize: ControlSize public init(controlSize: ControlSize = .small) { @@ -19,18 +20,18 @@ public struct ActionProgressCapsule: View { } /// Prominence levels for package-owned text action buttons. -public enum ActionTextProminence { +enum TextProminence { case standard case prominent } /// Package-owned text action button for command rows and form footers. -public struct ActionTextButton: View { +struct TextButton: View { public var title: String public var systemName: String public var help: String public var role: ButtonRole? - public var prominence: ActionTextProminence + public var prominence: UI.Action.TextProminence public var controlSize: ControlSize public var isEnabled: Bool public var action: () -> Void @@ -39,7 +40,7 @@ public struct ActionTextButton: View { systemName: String, help: String? = nil, role: ButtonRole? = nil, - prominence: ActionTextProminence = .standard, + prominence: UI.Action.TextProminence = .standard, controlSize: ControlSize = .regular, isEnabled: Bool = true, action: @escaping () -> Void) { @@ -76,7 +77,7 @@ public struct ActionTextButton: View { } /// Package-owned toggle button used when a binary command belongs in toolbar or panel chrome. -public struct ActionToggleButton: View { +struct ToggleButton: View { @Binding public var isOn: Bool public var title: String public var systemName: String @@ -98,3 +99,4 @@ public struct ActionToggleButton: View { .buttonBorderShape(.capsule) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift index 84ac2b28..7fdd8f65 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift @@ -4,15 +4,16 @@ import SwiftUI /// /// Feature views provide action descriptions; the design system owns button grouping, sizing, /// selection tinting, hover treatment, accessibility labels, and cancel/destructive behavior. -public struct ActionGroup: View { - public var actions: [ActionItem] +public extension UI.Action { +struct Group: View { + public var actions: [UI.Action.Item] public var spacing: CGFloat public var height: CGFloat public var minWidth: CGFloat? public var singleItem: Bool? public var interactive: Bool - public init(_ actions: [ActionItem], + public init(_ actions: [UI.Action.Item], spacing: CGFloat = 0, height: CGFloat = UI.Tokens.Toolbar.buttonGroupHeight, minWidth: CGFloat? = nil, @@ -26,7 +27,7 @@ public struct ActionGroup: View { self.interactive = interactive } - public init(_ action: ActionItem, + public init(_ action: UI.Action.Item, height: CGFloat = UI.Tokens.Toolbar.buttonGroupHeight, minWidth: CGFloat? = nil, interactive: Bool = true) { @@ -43,13 +44,13 @@ public struct ActionGroup: View { minWidth: minWidth, singleItem: singleItem ?? (actions.count == 1), interactive: interactive) { - ActionItems(actions) + UI.Action.Items(actions) } } } /// Package-owned glass cluster for mixed content, such as a menu plus action items. -public struct ActionCluster: View { +struct Cluster: View { public var spacing: CGFloat public var height: CGFloat public var minWidth: CGFloat? @@ -81,9 +82,11 @@ public struct ActionCluster: View { } } } +} /// Package-owned input cluster for search fields and compact inline controls. -public struct InputCluster: View { +public extension UI.Control { +struct InputCluster: View { @ViewBuilder public var content: () -> Content public init(@ViewBuilder content: @escaping () -> Content) { @@ -98,12 +101,14 @@ public struct InputCluster: View { } } } +} /// Package-owned action item renderer for mixed groups that also contain menus or status labels. -public struct ActionItems: View { - public var actions: [ActionItem] +public extension UI.Action { +struct Items: View { + public var actions: [UI.Action.Item] - public init(_ actions: [ActionItem]) { + public init(_ actions: [UI.Action.Item]) { self.actions = actions } @@ -127,7 +132,7 @@ public struct ActionItems: View { } /// Semantic label for menus embedded in glass action groups. -public struct ActionMenuLabel: View { +struct MenuLabel: View { public var systemName: String public var help: String public var role: ButtonRole? @@ -150,3 +155,4 @@ public struct ActionMenuLabel: View { help: help) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift index 427e0237..bf20ec70 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift @@ -1,7 +1,8 @@ import SwiftUI /// Semantic action description for package-owned glass action chrome. -public struct ActionItem { +public extension UI.Action { +struct Item { public var systemName: String public var title: String? public var help: String @@ -29,3 +30,4 @@ public struct ActionItem { self.action = action } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift index 420d8426..aa727e70 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift @@ -1,14 +1,15 @@ import SwiftUI /// Package-owned floating selection action bar. -public struct ActionSelectionBar: View { +public extension UI.Action { +struct SelectionBar: View { public var count: Int public var countLabel: (Int) -> String - public var actions: [ActionItem] + public var actions: [UI.Action.Item] public init(count: Int, countLabel: @escaping (Int) -> String, - actions: [ActionItem]) { + actions: [UI.Action.Item]) { self.count = count self.countLabel = countLabel self.actions = actions @@ -21,13 +22,13 @@ public struct ActionSelectionBar: View { Divider() .frame(height: 16) ForEach(Array(actions.enumerated()), id: \.offset) { _, item in - ActionTextButton(title: item.title ?? item.help, - systemName: item.systemName, - help: item.help, - role: item.role, - prominence: .standard, - isEnabled: item.isEnabled, - action: item.action) + UI.Action.TextButton(title: item.title ?? item.help, + systemName: item.systemName, + help: item.help, + role: item.role, + prominence: .standard, + isEnabled: item.isEnabled, + action: item.action) } } .padding(.horizontal, UI.Tokens.Space.l) @@ -35,3 +36,4 @@ public struct ActionSelectionBar: View { .materialCapsuleSurface(shadow: false) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Card/CardScaffold.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/Card+Scaffold.swift similarity index 85% rename from Packages/ContainedUI/Sources/ContainedUI/Card/CardScaffold.swift rename to Packages/ContainedUI/Sources/ContainedUI/Card/Card+Scaffold.swift index 3183a993..845e48e4 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Card/CardScaffold.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/Card+Scaffold.swift @@ -1,19 +1,20 @@ import SwiftUI -/// Text rendering style for the built-in `CardScaffold` title and subtitle lanes. -public enum CardTextStyle { +public extension UI.Card { +/// Text rendering style for the built-in `UI.Card.Scaffold` title and subtitle lanes. +enum TextStyle { case standard case monospaced } -/// Sentinel page type used by `CardScaffold` when a card has no page controls. -public enum CardNoPage: Hashable { +/// Sentinel page type used by `UI.Card.Scaffold` when a card has no page controls. +enum NoPage: Hashable { case none } -/// Typed page-control configuration for `CardScaffold`. -public struct CardPages { - public var items: [CardPage] +/// Typed page-control configuration for `UI.Card.Scaffold`. +struct Pages { + public var items: [UI.Card.Page] public var selection: ID public var tint: Color public var controlsReveal: Double @@ -21,7 +22,7 @@ public struct CardPages { public var onSelect: (ID) -> Void public var onClose: () -> Void - public init(items: [CardPage], + public init(items: [UI.Card.Page], selection: ID, tint: Color, controlsReveal: Double = 1, @@ -42,10 +43,10 @@ public struct CardPages { /// /// Feature code supplies semantic title/subtitle data plus optional slots; this view owns how those /// inputs become sticky header chrome, expanded body content, widgets, and footer controls. -public struct CardScaffold: View { - public var size: CardSize +struct Scaffold: View { + public var size: UI.Card.Size public var isExpanded: Bool public var cornerRadiusOverride: CGFloat? public var controlsVisible: Bool @@ -56,14 +57,14 @@ public struct CardScaffold Void public var title: String public var subtitle: String? - public var titleStyle: CardTextStyle - public var subtitleStyle: CardTextStyle - public var pages: CardPages? + public var titleStyle: UI.Card.TextStyle + public var subtitleStyle: UI.Card.TextStyle + public var pages: UI.Card.Pages? @ViewBuilder public var icon: () -> Icon @ViewBuilder public var titleAccessory: () -> TitleAccessory @ViewBuilder public var subtitleAccessory: () -> SubtitleAccessory @@ -75,7 +76,7 @@ public struct CardScaffold Void = {}, title: String, subtitle: String? = nil, - titleStyle: CardTextStyle = .standard, - subtitleStyle: CardTextStyle = .standard, - pages: CardPages?, + titleStyle: UI.Card.TextStyle = .standard, + subtitleStyle: UI.Card.TextStyle = .standard, + pages: UI.Card.Pages?, @ViewBuilder icon: @escaping () -> Icon, @ViewBuilder titleAccessory: @escaping () -> TitleAccessory, @ViewBuilder subtitleAccessory: @escaping () -> SubtitleAccessory, @@ -204,9 +205,9 @@ public struct CardScaffold some View { switch subtitleStyle { case .standard: - CardSubtitleText(text: text) + UI.Card.SubtitleText(text: text) case .monospaced: - CardMonospacedSubtitleText(text: text) + UI.Card.MonospacedSubtitleText(text: text) } } @@ -224,9 +225,10 @@ public struct CardScaffold Void = {}, title: String, subtitle: String? = nil, - titleStyle: CardTextStyle = .standard, - subtitleStyle: CardTextStyle = .standard, + titleStyle: UI.Card.TextStyle = .standard, + subtitleStyle: UI.Card.TextStyle = .standard, @ViewBuilder icon: @escaping () -> Icon, @ViewBuilder titleAccessory: @escaping () -> TitleAccessory, @ViewBuilder subtitleAccessory: @escaping () -> SubtitleAccessory, diff --git a/Packages/ContainedUI/Sources/ContainedUI/Card/CardChrome.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/Chrome.swift similarity index 93% rename from Packages/ContainedUI/Sources/ContainedUI/Card/CardChrome.swift rename to Packages/ContainedUI/Sources/ContainedUI/Card/Chrome.swift index c5584ffc..08fc9cce 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Card/CardChrome.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/Chrome.swift @@ -21,7 +21,7 @@ struct CardHeader: View { self.trailing = trailing } - var body: some View { + public var body: some View { HStack(alignment: .top, spacing: spacing) { leading() .fixedSize(horizontal: true, vertical: false) @@ -57,7 +57,7 @@ struct CardHeaderTextBlock: View { self.subtitle = subtitle } - var body: some View { + public var body: some View { VStack(alignment: .leading, spacing: spacing) { title() subtitle() @@ -80,8 +80,9 @@ extension CardHeaderTextBlock where Subtitle == EmptyView { } } +public extension UI.Card { /// A small reusable footer mini: optional icon + optional text, aligned on one baseline. -public struct CardFooterMini: View { +struct FooterMini: View { public var spacing: CGFloat @ViewBuilder public var icon: () -> Icon @ViewBuilder public var text: () -> TextContent @@ -104,11 +105,11 @@ public struct CardFooterMini: View { } } -/// A flat inset section for content that lives inside an expanded `CardScaffold`. +/// A flat inset section for content that lives inside an expanded `UI.Card.Scaffold`. /// /// Use this for charts, process lists, read-only fields, and terminal overlays inside a card body. /// It intentionally avoids creating a second card-shaped glass surface inside the parent card. -public struct CardInsetSection: View { +struct InsetSection: View { public var title: String? public var alignment: HorizontalAlignment public var spacing: CGFloat @@ -139,7 +140,7 @@ public struct CardInsetSection: View { } .padding(padding) .frame(maxWidth: .infinity, alignment: Alignment(horizontal: alignment, vertical: .center)) - .background(ThemeMaterial.toolbarHoverFill, + .background(UI.Theme.Material.toolbarHoverFill, in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.control, style: .continuous)) } @@ -148,7 +149,7 @@ public struct CardInsetSection: View { } /// A selectable footer chip for card widgets, filters, and compact tab-like metadata. -public struct CardFooterChip: View { +struct FooterChip: View { public var isSelected: Bool public var tint: Color public var help: String @@ -172,7 +173,7 @@ public struct CardFooterChip: View { public var body: some View { Button(action: action) { - CardFooterMini { + FooterMini { icon() } text: { text() @@ -186,7 +187,7 @@ public struct CardFooterChip: View { } /// Shared icon-only action used by design-card footers. -public struct CardFooterButton: View { +struct FooterButton: View { public var systemName: String public var help: String public var tint: Color? @@ -207,7 +208,7 @@ public struct CardFooterButton: View { public var body: some View { Button(role: role, action: action) { - CardFooterMini { + FooterMini { Image(systemName: systemName).font(.body) } text: { EmptyView() @@ -226,7 +227,7 @@ public struct CardFooterButton: View { } } -public struct CardPage: Identifiable, Hashable { +struct Page: Identifiable, Hashable { public var id: ID public var title: String public var systemImage: String @@ -240,7 +241,7 @@ public struct CardPage: Identifiable, Hashable { /// Shared expanded-card page rail with page icons plus a close affordance. struct CardPageControls: View { - var items: [CardPage] + var items: [Page] var selection: ID var tint: Color var controlsReveal: Double @@ -248,7 +249,7 @@ struct CardPageControls: View { var onSelect: (ID) -> Void var onClose: () -> Void - init(items: [CardPage], + init(items: [Page], selection: ID, tint: Color, controlsReveal: Double = 1, @@ -264,7 +265,7 @@ struct CardPageControls: View { self.onClose = onClose } - var body: some View { + public var body: some View { MaterialButton(singleItem: false) { ForEach(items) { item in MaterialButtonItem(tint: selection == item.id ? tint : nil, @@ -284,7 +285,7 @@ struct CardPageControls: View { } /// A reusable footer item band that hugs its content and anchors either left or right. -public struct CardFooterGroup: View { +struct FooterGroup: View { public enum Alignment { case leading, trailing } @@ -309,7 +310,7 @@ public struct CardFooterGroup: View { } /// A horizontal group for content in a card widget band. -public struct CardWidgetGroup: View { +struct WidgetGroup: View { public var spacing: CGFloat @ViewBuilder public var content: () -> Content @@ -360,16 +361,16 @@ struct CardFooter: View { self.widget = widget } - var body: some View { + public var body: some View { VStack(alignment: .leading, spacing: 0) { if showWidget { widget() } HStack(spacing: spacing) { - CardFooterGroup(alignment: .leading, spacing: spacing) { + FooterGroup(alignment: .leading, spacing: spacing) { leading() } - CardFooterGroup(alignment: .trailing, spacing: spacing) { + FooterGroup(alignment: .trailing, spacing: spacing) { trailing() } .opacity(actionsVisible ? 1 : 0) @@ -384,7 +385,7 @@ struct CardFooter: View { } } -public struct CardTitleText: View { +struct TitleText: View { public let text: String public init(text: String) { @@ -398,7 +399,7 @@ public struct CardTitleText: View { } } -public struct CardSubtitleText: View { +struct SubtitleText: View { public let text: String public init(text: String) { @@ -413,7 +414,7 @@ public struct CardSubtitleText: View { } } -public struct CardMonospacedSubtitleText: View { +struct MonospacedSubtitleText: View { public let text: String public init(text: String) { @@ -428,7 +429,7 @@ public struct CardMonospacedSubtitleText: View { } } -public struct CardMonospacedTitleText: View { +struct MonospacedTitleText: View { public let text: String public init(text: String) { @@ -442,7 +443,7 @@ public struct CardMonospacedTitleText: View { } } -public struct CardIconChip: View { +struct IconChip: View { public var symbol: String public var tint: Color public var symbolFont: Font @@ -467,9 +468,11 @@ public struct CardIconChip: View { in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.iconChip, style: .continuous)) } } +} /// Small capsule count/state badge used in section headers and compact metadata rows. -public struct BadgeText: View { +public extension UI.Badge { +struct Text: View { public let text: String public var font: Font public var foreground: Color @@ -483,7 +486,7 @@ public struct BadgeText: View { } public var body: some View { - Text(text) + SwiftUI.Text(text) .font(font) .foregroundStyle(foreground) .padding(.horizontal, UI.Tokens.Space.s) @@ -491,9 +494,11 @@ public struct BadgeText: View { .background(.quaternary, in: Capsule()) } } +} /// Flat selectable row for lists inside panels and sheets. -public struct ListRow: View { +public extension UI.List { +struct Row: View { public var symbol: String public var tint: Color public var title: String @@ -543,7 +548,7 @@ public struct ListRow: View { } } -public struct ListRowChevron: View { +struct RowChevron: View { public init() {} public var body: some View { @@ -552,8 +557,9 @@ public struct ListRowChevron: View { .foregroundStyle(.tertiary) } } +} -public extension ListRow where Accessory == ListRowChevron { +public extension UI.List.Row where Accessory == UI.List.RowChevron { init(symbol: String, tint: Color = .accentColor, title: String, subtitle: String?, monospacedSubtitle: Bool = true) { self.init(symbol: symbol, @@ -561,12 +567,13 @@ public extension ListRow where Accessory == ListRowChevron { title: title, subtitle: subtitle, monospacedSubtitle: monospacedSubtitle) { - ListRowChevron() + UI.List.RowChevron() } } } -public struct CardMetricText: View { +public extension UI.Card { +struct MetricText: View { public let text: String public init(text: String) { @@ -580,6 +587,7 @@ public struct CardMetricText: View { .contentTransition(.numericText()) } } +} public extension View { @ViewBuilder diff --git a/Packages/ContainedUI/Sources/ContainedUI/Card/CardSurface.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/Surface.swift similarity index 92% rename from Packages/ContainedUI/Sources/ContainedUI/Card/CardSurface.swift rename to Packages/ContainedUI/Sources/ContainedUI/Card/Surface.swift index 2b997db1..b6b13973 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Card/CardSurface.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/Surface.swift @@ -1,6 +1,7 @@ import SwiftUI -public enum CardSize { +public extension UI.Card { +enum Size { case small, medium, large /// Footer actions stay visible in compact/expanded chrome for medium and large cards. @@ -16,18 +17,18 @@ public enum CardSize { public var showsWidget: Bool { keepsWidgetSticky } } -public enum CardExpandedMetrics { +enum ExpandedMetrics { public static let maxWidth: CGFloat = 760 } -public struct CardSizePicker: View { - @Binding var selection: CardDensity +struct SizePicker: View { + @Binding var selection: UI.Card.Density public var title: String - public var labelForDensity: (CardDensity) -> String + public var labelForDensity: (UI.Card.Density) -> String - public init(selection: Binding, + public init(selection: Binding, title: String, - labelForDensity: @escaping (CardDensity) -> String) { + labelForDensity: @escaping (UI.Card.Density) -> String) { self._selection = selection self.title = title self.labelForDensity = labelForDensity @@ -35,7 +36,7 @@ public struct CardSizePicker: View { public var body: some View { Picker(title, selection: $selection) { - ForEach(CardDensity.allCases) { density in + ForEach(UI.Card.Density.allCases) { density in Text(labelForDensity(density)).tag(density) } } @@ -43,10 +44,11 @@ public struct CardSizePicker: View { .frame(width: 230) } } +} struct CardSurface: View { - var size: CardSize + var size: UI.Card.Size var isExpanded = false var cornerRadiusOverride: CGFloat? var controlsVisible = true @@ -60,7 +62,7 @@ struct CardSurface Void = {} @@ -80,7 +82,7 @@ struct CardSurface Void = {}, @ViewBuilder header: @escaping () -> Header, @@ -131,7 +133,7 @@ struct CardSurface some View { - CardFooter(actionsVisible: showActions) { + UI.Card.CardFooter(actionsVisible: showActions) { footerLeading() } trailing: { footerActions() @@ -249,14 +251,14 @@ struct CardSurface some View { @@ -314,14 +316,14 @@ private struct CardMaterialSurface: ViewModifier { } private extension View { - func designCardMaterial(_ material: ThemeWindowMaterial, + func designCardMaterial(_ material: UI.Theme.WindowMaterial, cornerRadius: CGFloat, shadow: Bool, fill: Color?, fillOpacity: Double, gradient: Bool, gradientAngle: Double, - blendMode: ThemeColorBlendMode) -> some View { + blendMode: UI.Theme.ColorBlendMode) -> some View { modifier(CardMaterialSurface(material: material, cornerRadius: cornerRadius, shadow: shadow, @@ -335,13 +337,13 @@ private extension View { extension CardSurface where BodyContent == EmptyView, FooterLeading == EmptyView, FooterActions == EmptyView, Widget == EmptyView { - init(size: CardSize = .small, + init(size: UI.Card.Size = .small, isSelected: Bool = false, fill: Color? = nil, fillOpacity: Double = 0.18, gradient: Bool = false, gradientAngle: Double = 135, - blendMode: ThemeColorBlendMode = .softLight, + blendMode: UI.Theme.ColorBlendMode = .softLight, elevated: Bool = true, onTap: @escaping () -> Void = {}, @ViewBuilder header: @escaping () -> Header) { @@ -363,7 +365,7 @@ extension CardSurface where BodyContent == EmptyView, FooterLeading == EmptyView } extension CardSurface where BodyContent == EmptyView, Widget == EmptyView { - init(size: CardSize, + init(size: UI.Card.Size, isExpanded: Bool = false, controlsVisible: Bool = true, isSelected: Bool = false, @@ -371,7 +373,7 @@ extension CardSurface where BodyContent == EmptyView, Widget == EmptyView { fillOpacity: Double = 0.18, gradient: Bool = false, gradientAngle: Double = 135, - blendMode: ThemeColorBlendMode = .softLight, + blendMode: UI.Theme.ColorBlendMode = .softLight, elevated: Bool = true, onTap: @escaping () -> Void = {}, @ViewBuilder header: @escaping () -> Header, @@ -397,7 +399,7 @@ extension CardSurface where BodyContent == EmptyView, Widget == EmptyView { } extension CardSurface where Widget == EmptyView { - init(size: CardSize, + init(size: UI.Card.Size, isExpanded: Bool = false, controlsVisible: Bool = true, isSelected: Bool = false, @@ -405,7 +407,7 @@ extension CardSurface where Widget == EmptyView { fillOpacity: Double = 0.18, gradient: Bool = false, gradientAngle: Double = 135, - blendMode: ThemeColorBlendMode = .softLight, + blendMode: UI.Theme.ColorBlendMode = .softLight, elevated: Bool = true, onTap: @escaping () -> Void = {}, @ViewBuilder header: @escaping () -> Header, diff --git a/Packages/ContainedUI/Sources/ContainedUI/Card/CardViewModifiers.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/ViewModifiers.swift similarity index 84% rename from Packages/ContainedUI/Sources/ContainedUI/Card/CardViewModifiers.swift rename to Packages/ContainedUI/Sources/ContainedUI/Card/ViewModifiers.swift index 05e810e1..1f41a80f 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Card/CardViewModifiers.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/ViewModifiers.swift @@ -6,7 +6,7 @@ public extension View { overlay { if isSelected { RoundedRectangle(cornerRadius: UI.Tokens.Radius.card, style: .continuous) - .fill(ThemeMaterial.toolbarHoverFill) + .fill(UI.Theme.Material.toolbarHoverFill) .allowsHitTesting(false) } } diff --git a/Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift b/Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift index d6cd0b18..949f9d11 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift @@ -1,6 +1,7 @@ import SwiftUI -public struct MetricTile: View { +public extension UI.Control { +struct MetricTile: View { public var label: String public var value: String public var caption: String? @@ -29,8 +30,9 @@ public struct MetricTile: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, UI.Tokens.Space.m) .padding(.vertical, UI.Tokens.Space.s) - .background(ThemeMaterial.toolbarHoverFill, + .background(UI.Theme.Material.toolbarHoverFill, in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.control, style: .continuous)) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift b/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift index 0723fb79..4407ea2d 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift @@ -1,7 +1,8 @@ import SwiftUI import Charts -public enum ChartStyle { +public extension UI.Chart { +enum Style { public static func primaryLine(_ mark: LineMark) -> some ChartContent { mark.foregroundStyle(Color.accentColor) .interpolationMethod(.monotone) @@ -20,7 +21,7 @@ public enum ChartStyle { } } -public enum GraphStyle: String, CaseIterable, Identifiable, Codable, Sendable { +enum GraphStyle: String, CaseIterable, Identifiable, Codable, Sendable { case area case line case bar @@ -70,14 +71,14 @@ public enum GraphStyle: String, CaseIterable, Identifiable, Codable, Sendable { } } -public enum WidgetInterpolation: String, CaseIterable, Identifiable, Codable, Sendable { +enum Interpolation: String, CaseIterable, Identifiable, Codable, Sendable { case linear, catmullRom, cardinal, monotone, stepStart, stepCenter, stepEnd public var id: String { rawValue } } -public enum SparklineScale: String, CaseIterable, Identifiable, Codable, Sendable { +enum Scale: String, CaseIterable, Identifiable, Codable, Sendable { case normalized case fraction @@ -86,32 +87,32 @@ public enum SparklineScale: String, CaseIterable, Identifiable, Codable, Sendabl /// A compact Swift Charts renderer for card widgets. Byte/rate metrics can be normalized /// independently, while pre-normalized fraction metrics can stay anchored to the 0...100% domain. -public struct SparklineView: View { +struct Sparkline: View { private static let maximumPlottedSamples = 24 public var samples: [Double] public var comparisonSamples: [Double] = [] public var color: Color = .accentColor public var lineWidth: CGFloat = 1.5 - public var style: GraphStyle = .area + public var style: UI.Chart.GraphStyle = .area public var areaUsesGradient = true - public var interpolation: WidgetInterpolation = .linear + public var interpolation: UI.Chart.Interpolation = .linear public var pointSize: CGFloat = 18 public var barWidth: CGFloat = 4 - public var scale: SparklineScale = .normalized - public var comparisonScale: SparklineScale = .normalized + public var scale: UI.Chart.Scale = .normalized + public var comparisonScale: UI.Chart.Scale = .normalized public init(samples: [Double], comparisonSamples: [Double] = [], color: Color = .accentColor, lineWidth: CGFloat = 1.5, - style: GraphStyle = .area, + style: UI.Chart.GraphStyle = .area, areaUsesGradient: Bool = true, - interpolation: WidgetInterpolation = .linear, + interpolation: UI.Chart.Interpolation = .linear, pointSize: CGFloat = 18, barWidth: CGFloat = 4, - scale: SparklineScale = .normalized, - comparisonScale: SparklineScale? = nil) { + scale: UI.Chart.Scale = .normalized, + comparisonScale: UI.Chart.Scale? = nil) { self.samples = samples self.comparisonSamples = comparisonSamples self.color = color @@ -264,7 +265,7 @@ public struct SparklineView: View { } } - private func chartPoints(for values: [Double], scale: SparklineScale) -> [ChartPoint] { + private func chartPoints(for values: [Double], scale: UI.Chart.Scale) -> [ChartPoint] { let plotted = plottedSamples(values) let startIndex = Self.maximumPlottedSamples - plotted.count let scaled = SparklineSeriesScaling.scaled(plotted, mode: scale) @@ -277,6 +278,7 @@ public struct SparklineView: View { SparklineSeriesScaling.paddedWindow(values, capacity: Self.maximumPlottedSamples) } } +} enum SparklineSeriesScaling { private static let minimumCeiling = 0.0001 @@ -296,7 +298,7 @@ enum SparklineSeriesScaling { values.map { min(max(sanitizedSample($0), 0), 1) } } - static func scaled(_ values: [Double], mode: SparklineScale) -> [Double] { + static func scaled(_ values: [Double], mode: UI.Chart.Scale) -> [Double] { switch mode { case .normalized: return normalized(values) case .fraction: return fractions(values) @@ -333,7 +335,7 @@ private struct ChartRangePoint: Identifiable { var id: Int { index } } -private extension WidgetInterpolation { +private extension UI.Chart.Interpolation { var method: InterpolationMethod { switch self { case .linear: return .linear @@ -348,7 +350,8 @@ private extension WidgetInterpolation { } /// A fixed-size ring buffer for sparkline history. -public struct SampleBuffer: Sendable, Equatable { +public extension UI.Chart { +struct SampleBuffer: Sendable, Equatable { public private(set) var values: [Double] = [] public let capacity: Int @@ -359,3 +362,4 @@ public struct SampleBuffer: Sendable, Equatable { if values.count > capacity { values.removeFirst(values.count - capacity) } } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift b/Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift index 3434c12d..8f681445 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift @@ -1,20 +1,21 @@ import SwiftUI /// A dashboard summary tile: muted label, large value, optional symbol and sparkline. -public struct SparklineMetricTile: View { +public extension UI.Chart { +struct MetricTile: View { public let label: String public let value: String public var systemImage: String? = nil public var tint: Color = .accentColor public var samples: [Double]? = nil - public var sparklineScale: SparklineScale = .normalized + public var sparklineScale: UI.Chart.Scale = .normalized public init(label: String, value: String, systemImage: String? = nil, tint: Color = .accentColor, samples: [Double]? = nil, - sparklineScale: SparklineScale = .normalized) { + sparklineScale: UI.Chart.Scale = .normalized) { self.label = label self.value = value self.systemImage = systemImage @@ -40,7 +41,7 @@ public struct SparklineMetricTile: View { .font(.title.weight(.semibold)) .contentTransition(.numericText()) if let samples { - SparklineView(samples: samples, color: tint, scale: sparklineScale) + UI.Chart.Sparkline(samples: samples, color: tint, scale: sparklineScale) .frame(height: 22) } } @@ -49,3 +50,4 @@ public struct SparklineMetricTile: View { .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card, fill: tint, fillOpacity: 0.10) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift index e159d4ef..962143cf 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift @@ -1,6 +1,7 @@ import SwiftUI -public struct ScopeChipLabel: View { +public extension UI.Badge { +struct ScopeLabel: View { public var symbol: String public var title: String @@ -13,7 +14,7 @@ public struct ScopeChipLabel: View { HStack(spacing: UI.Tokens.Space.xs) { Image(systemName: symbol) .font(.caption2) - Text(title) + SwiftUI.Text(title) .font(.caption.weight(.semibold)) Image(systemName: "xmark") .font(.caption2.weight(.bold)) @@ -25,8 +26,10 @@ public struct ScopeChipLabel: View { .foregroundStyle(Color.accentColor) } } +} -public struct TintSwatch: View { +public extension UI.Control { +struct TintSwatch: View { public var color: Color public var followsAccent: Bool @@ -47,3 +50,4 @@ public struct TintSwatch: View { .frame(width: UI.Tokens.IconSize.chip, height: UI.Tokens.IconSize.chip) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift index c4e152cb..534c0603 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift @@ -1,7 +1,8 @@ import SwiftUI /// A 360° gradient-direction control: a draggable dial plus a degree readout. -public struct GradientAngleControl: View { +public extension UI.Control { +struct GradientAngle: View { @Binding var angle: Double public var title: String @@ -24,6 +25,7 @@ public struct GradientAngleControl: View { } } } +} /// A small dial knob whose pointer reflects the gradient angle; drag to set. private struct AngleDial: View { diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift index 1dee55ce..54d072a5 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift @@ -1,6 +1,7 @@ import SwiftUI -public struct KeyCap: View { +public extension UI.Control { +struct KeyCap: View { public var text: String public init(_ text: String) { @@ -19,7 +20,7 @@ public struct KeyCap: View { } } -public struct KeyboardHint: View { +struct KeyboardHint: View { public var key: String public var label: String @@ -30,10 +31,11 @@ public struct KeyboardHint: View { public var body: some View { HStack(spacing: UI.Tokens.Space.xs) { - KeyCap(key) + UI.Control.KeyCap(key) Text(label) .font(.caption) .foregroundStyle(.secondary) } } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift index 7f03102a..96ee1fee 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift @@ -1,6 +1,7 @@ import SwiftUI -public struct ListStack: View { +public extension UI.List { +struct Stack: View { public var spacing: CGFloat public var padding: CGFloat @ViewBuilder public var content: () -> Content @@ -21,7 +22,7 @@ public struct ListStack: View { } } -public struct ListSection: View { +struct Section: View { public var title: String public var spacing: CGFloat @ViewBuilder public var content: () -> Content @@ -36,13 +37,13 @@ public struct ListSection: View { public var body: some View { LazyVStack(alignment: .leading, spacing: spacing) { - SectionLabel(title) + UI.State.SectionLabel(title) content() } } } -public struct MetadataRow: View { +struct MetadataRow: View { public var systemImage: String public var title: String public var subtitle: String? @@ -93,8 +94,9 @@ public struct MetadataRow: View { } } } +} -public extension MetadataRow where Accessory == EmptyView { +public extension UI.List.MetadataRow where Accessory == EmptyView { init(systemImage: String, title: String, subtitle: String? = nil, @@ -112,7 +114,8 @@ public extension MetadataRow where Accessory == EmptyView { } } -public struct MetadataBadgeRow: View { +public extension UI.List { +struct MetadataBadgeRow: View { public var systemImage: String public var title: String public var badge: String? @@ -148,7 +151,7 @@ public struct MetadataBadgeRow: View { .font(isMonospaced ? .system(.callout, design: .monospaced) : .callout) .lineLimit(1) if let badge { - BadgeText(text: badge) + UI.Badge.Text(text: badge) } } if let subtitle { @@ -164,7 +167,7 @@ public struct MetadataBadgeRow: View { } } -public struct KeyValueRow: View { +struct KeyValueRow: View { public var label: String public var value: String public var valueMonospaced: Bool @@ -208,7 +211,7 @@ public struct KeyValueRow: View { } } -public struct CompactInfoRow: View { +struct CompactInfoRow: View { public var title: String public var value: String public var titleWidth: CGFloat @@ -233,3 +236,4 @@ public struct CompactInfoRow: View { } } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift index dc76a1c9..27e71d6c 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift @@ -23,20 +23,21 @@ private struct OptionalAccessibilityLabel: ViewModifier { } } -public struct ButtonTintStyle: Equatable, Sendable { +public extension UI.Theme { +struct ButtonTintStyle: Equatable, Sendable { public var enabled = false - public var tint: ThemeTint = .multicolor + public var tint: UI.Theme.Tint = .multicolor public var opacity = 0.18 public var gradient = true public var gradientAngle = 135.0 - public var blendMode: ThemeColorBlendMode = .softLight + public var blendMode: UI.Theme.ColorBlendMode = .softLight public init(enabled: Bool = false, - tint: ThemeTint = .multicolor, + tint: UI.Theme.Tint = .multicolor, opacity: Double = 0.18, gradient: Bool = true, gradientAngle: Double = 135.0, - blendMode: ThemeColorBlendMode = .softLight) { + blendMode: UI.Theme.ColorBlendMode = .softLight) { self.enabled = enabled self.tint = tint self.opacity = opacity @@ -45,7 +46,8 @@ public struct ButtonTintStyle: Equatable, Sendable { self.blendMode = blendMode } - public static let disabled = ButtonTintStyle() + public static let disabled = UI.Theme.ButtonTintStyle() +} } /// A reusable material button item: an icon or text button with the shared 28pt inner height and @@ -95,7 +97,7 @@ struct MaterialButtonItem: View { Capsule(style: .continuous) .fill( hoverEnabled && hovering && !isLabel - ? ThemeMaterial.toolbarInteractiveHoverFill(for: colorScheme) + ? UI.Theme.Material.toolbarInteractiveHoverFill(for: colorScheme) : .clear ) } @@ -197,7 +199,7 @@ struct MaterialButton: View { Capsule(style: .continuous) .fill( hovering - ? ThemeMaterial.toolbarInteractiveHoverFill(for: colorScheme) + ? UI.Theme.Material.toolbarInteractiveHoverFill(for: colorScheme) : .clear ) } diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift index 00fc68a0..c4a92dad 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift @@ -1,6 +1,7 @@ import SwiftUI -public struct OptionStack: View { +public extension UI.Control { +struct OptionStack: View { public var spacing: CGFloat @ViewBuilder public var content: () -> Content @@ -19,7 +20,7 @@ public struct OptionStack: View { } } -public struct OptionTile: View { +struct OptionTile: View { public static let defaultHeight: CGFloat = 100 public let symbol: String @@ -89,6 +90,7 @@ public struct OptionTile: View { .accessibilityHint(subtitle ?? "") } } +} private struct OptionalMatchedGeometry: ViewModifier { var id: String? diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift index 4a34bc13..8475d496 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift @@ -4,7 +4,8 @@ import SwiftUI /// detail headers. /// Centralizes the styling chain and the VoiceOver label so icon-only menus are consistently /// accessible. -public struct RowMenu: View { +public extension UI.Control { +struct RowMenu: View { public var systemImage: String public var accessibilityLabel: String @ViewBuilder public var content: () -> Content @@ -33,3 +34,4 @@ public struct RowMenu: View { .accessibilityLabel(accessibilityLabel) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift index b8bacbb2..9e6ab6a7 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift @@ -1,6 +1,7 @@ import SwiftUI -public struct SearchField: View { +public extension UI.Control { +struct SearchField: View { @Binding public var text: String public var prompt: String public var clearLabel: String @@ -20,7 +21,7 @@ public struct SearchField: View { } public var body: some View { - InputCluster { + UI.Control.InputCluster { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) TextField(prompt, text: $text) @@ -41,4 +42,4 @@ public struct SearchField: View { .frame(maxWidth: .infinity, alignment: .leading) } } - +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift index bffbad16..d3af4446 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift @@ -1,16 +1,16 @@ import SwiftUI -/// A row of colored swatches for picking a `ThemeTint` — each shows its actual color, the selected +/// A row of colored swatches for picking a `UI.Theme.Tint` — each shows its actual color, the selected /// one gets a ring. public extension UI.Control { struct TintSelector: View { - private let selection: Binding + private let selection: Binding private let automaticLabel: String? - private let labelForTint: (ThemeTint) -> String + private let labelForTint: (UI.Theme.Tint) -> String - public init(selection: Binding, - labelForTint: @escaping (ThemeTint) -> String) { - self.selection = Binding( + public init(selection: Binding, + labelForTint: @escaping (UI.Theme.Tint) -> String) { + self.selection = Binding( get: { selection.wrappedValue }, set: { if let newValue = $0 { selection.wrappedValue = newValue } } ) @@ -18,9 +18,9 @@ public extension UI.Control { self.labelForTint = labelForTint } - public init(optionalSelection: Binding, + public init(optionalSelection: Binding, automaticLabel: String, - labelForTint: @escaping (ThemeTint) -> String) { + labelForTint: @escaping (UI.Theme.Tint) -> String) { self.selection = optionalSelection self.automaticLabel = automaticLabel self.labelForTint = labelForTint @@ -35,7 +35,7 @@ public extension UI.Control { .accessibilityLabel(automaticLabel) .accessibilityAddTraits(selection.wrappedValue == nil ? .isSelected : []) } - ForEach(ThemeTint.allCases) { tint in + ForEach(UI.Theme.Tint.allCases) { tint in let label = labelForTint(tint) Button { selection.wrappedValue = tint } label: { swatch(tint) } .buttonStyle(.plain) @@ -60,7 +60,7 @@ public extension UI.Control { .frame(width: 26, height: 26) } - private func swatch(_ tint: ThemeTint) -> some View { + private func swatch(_ tint: UI.Theme.Tint) -> some View { ZStack { Circle().fill(tint.color).frame(width: 22, height: 22) // Mark the "follow the host accent" option so it reads as automatic, not a fixed color. diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/ToolbarControls.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/ToolbarControls.swift index 324d2216..86da6e20 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/ToolbarControls.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/ToolbarControls.swift @@ -4,7 +4,8 @@ import SwiftUI /// toolbar proportions. Centralizing them here keeps the toolbar, creation tiles /// (`OptionTile`), and future band controls visually consistent. -public struct MenuButton: View { +public extension UI.Control { +struct MenuButton: View { @ViewBuilder public var menuContent: () -> MenuContent @ViewBuilder public var labelContent: () -> LabelContent @@ -28,8 +29,10 @@ public struct MenuButton: View { .fixedSize(horizontal: true, vertical: false) } } +} -public struct ToolbarSearchField: View { +public extension UI.Toolbar { +struct SearchField: View { @Binding public var text: String public var prompt: String public var clearSearchLabel: String @@ -85,7 +88,7 @@ public struct ToolbarSearchField: View { } /// Package-owned empty toolbar slot for stable morph origins and vanity chrome. -public struct ToolbarVanitySlot: View { +struct VanitySlot: View { public var minWidth: CGFloat public var interactive: Bool @ViewBuilder public var content: () -> Content @@ -107,7 +110,7 @@ public struct ToolbarVanitySlot: View { } /// Package-owned toolbar button for custom status content. -public struct ToolbarStatusButton: View { +struct StatusButton: View { public var help: String public var action: () -> Void @ViewBuilder public var content: () -> Content @@ -130,7 +133,7 @@ public struct ToolbarStatusButton: View { } /// Package-owned glass shell for toolbar clusters that mix action items and status/menu items. -public struct ToolbarActionCluster: View { +struct ActionCluster: View { public var spacing: CGFloat @ViewBuilder public var content: () -> Content @@ -149,7 +152,7 @@ public struct ToolbarActionCluster: View { /// A toolbar-styled menu trigger that uses the shared toolbar icon lane while keeping native menu /// behavior. -public struct ToolbarMenuButton: View { +struct MenuButton: View { public let systemName: String public var help: String @ViewBuilder public var content: () -> Content @@ -180,7 +183,7 @@ public struct ToolbarMenuButton: View { /// Shared two-line toolbar label used by page switchers and filter menus. /// The second line is always secondary so status/filter copy stays visually subordinate. -public struct ToolbarTitleSubtitle: View { +struct TitleSubtitle: View { public let symbol: String public let title: String public let subtitle: String @@ -219,3 +222,4 @@ public struct ToolbarTitleSubtitle: View { .contentShape(Rectangle()) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift index 5a448a9c..041b5aef 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift @@ -5,7 +5,8 @@ public extension EnvironmentValues { @Entry var pageScaffoldBottomClearance: CGFloat = 0 } -public struct PageScaffold: View { +public extension UI.Panel { +struct PageScaffold: View { public let symbol: String public let title: String public let subtitle: String @@ -32,7 +33,7 @@ public struct PageScaffold: View { public var body: some View { VStack(spacing: 0) { if !usesToolbarChrome { - PanelTitleBar(symbol: symbol, title: title, subtitle: subtitle) { + UI.Panel.Header(symbol: symbol, title: title, subtitle: subtitle) { actions() } Divider() @@ -57,8 +58,9 @@ public struct PageScaffold: View { } } } +} -public extension PageScaffold where Actions == EmptyView { +public extension UI.Panel.PageScaffold where Actions == EmptyView { init(symbol: String, title: String, subtitle: String, diff --git a/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelScaffold.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/Panel+Scaffold.swift similarity index 94% rename from Packages/ContainedUI/Sources/ContainedUI/Panel/PanelScaffold.swift rename to Packages/ContainedUI/Sources/ContainedUI/Panel/Panel+Scaffold.swift index 15e9472b..a76aa3e8 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelScaffold.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/Panel+Scaffold.swift @@ -10,7 +10,8 @@ import SwiftUI /// Pass `scrolls: false` for content that brings **its own** scroll view (search results, build /// workspace, the paged run form). In that mode the scaffold doesn't wrap the content in a `ScrollView`, /// so scroll views are not double-nested. -public struct PanelScaffold: View { +public extension UI.Panel { +struct Scaffold: View { /// The expected host width. The scaffold still expands to the width assigned by its presentation host. public var width: CGFloat public var scrollEdgeStyle: ScrollEdgeEffectStyle = .soft @@ -57,8 +58,9 @@ public struct PanelScaffold: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } +} -public extension PanelScaffold where Footer == EmptyView { +public extension UI.Panel.Scaffold where Footer == EmptyView { init(width: CGFloat, scrollEdgeStyle: ScrollEdgeEffectStyle = .soft, scrolls: Bool = true, diff --git a/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSection.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/Section.swift similarity index 96% rename from Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSection.swift rename to Packages/ContainedUI/Sources/ContainedUI/Panel/Section.swift index 2f798200..8c5fbd15 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSection.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/Section.swift @@ -18,7 +18,8 @@ private extension EnvironmentValues { /// Supports two header affordances: `collapsible` (a chevron that folds the card away) and an `enabled` /// binding (a switch in the header that disables/hides the body — used for opt-in sections like the /// per-card customization blocks). -public struct PanelSectionView: View { +public extension UI.Panel { +struct Section: View { public var header: String? = nil public var footer: String? = nil public var rowSpacing: CGFloat = UI.Tokens.Space.m @@ -119,7 +120,7 @@ public struct PanelSectionView: View { /// A single settings row: a leading title (+ optional subtitle), optional info next to that title, /// and a trailing control. Set `error` to tint the title red and show a red caption beneath. -public struct PanelRowView: View { +struct Row: View { public var title: String public var subtitle: String? = nil public var info: String? = nil @@ -171,15 +172,17 @@ public struct PanelRowView: View { .frame(maxWidth: .infinity) } } +} -public extension PanelRowView where Trailing == EmptyView { +public extension UI.Panel.Row where Trailing == EmptyView { init(title: String, subtitle: String? = nil, info: String? = nil, error: String? = nil) { self.init(title: title, subtitle: subtitle, info: info, error: error) { EmptyView() } } } /// A switch row — the common Toggle case, rendered label-left / switch-right like a grouped Form. -public struct PanelToggleRowView: View { +public extension UI.Panel { +struct ToggleRow: View { public var title: String public var subtitle: String? = nil public var info: String? = nil @@ -199,17 +202,17 @@ public struct PanelToggleRowView: View { } public var body: some View { - PanelRowView(title: title, subtitle: subtitle, info: info, error: error) { + UI.Panel.Row(title: title, subtitle: subtitle, info: info, error: error) { Toggle("", isOn: $isOn).labelsHidden().toggleStyle(.switch) } } } /// A labeled form field: a leading label, optional info next to that label, and an expanding control. -/// The form-row counterpart to `PanelRowView` (which hugs its trailing +/// The form-row counterpart to `UI.Panel.Row` (which hugs its trailing /// control); here the control fills the remaining width like a grouped Form field. `error` tints the /// label red and shows a red caption beneath. -public struct PanelFieldView: View { +struct Field: View { public var label: String public var info: String? = nil public var error: String? = nil @@ -256,3 +259,4 @@ public struct PanelFieldView: View { } } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSheetTitleBar.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/SheetTitleBar.swift similarity index 93% rename from Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSheetTitleBar.swift rename to Packages/ContainedUI/Sources/ContainedUI/Panel/SheetTitleBar.swift index 5b776720..315880cb 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelSheetTitleBar.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/SheetTitleBar.swift @@ -4,7 +4,8 @@ import SwiftUI /// button, and a trailing slot for confirm/primary actions or a progress spinner. Replaces the /// hand-rolled header `HStack` + `GlassCircleButton` chain repeated across every sheet, so spacing, /// padding, and the cancel affordance stay consistent. -public struct PanelSheetTitleBar: View { +public extension UI.Panel { +struct SheetTitleBar: View { public let title: String public var subtitle: String? = nil public var cancelIcon: String = "xmark" @@ -43,8 +44,9 @@ public struct PanelSheetTitleBar: View { .padding(UI.Tokens.Space.l) } } +} -public extension PanelSheetTitleBar where Trailing == EmptyView { +public extension UI.Panel.SheetTitleBar where Trailing == EmptyView { /// Header with only a cancel/close button (no primary action). init(title: String, subtitle: String? = nil, cancelIcon: String = "xmark", cancelHelp: String, onCancel: @escaping () -> Void) { diff --git a/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelTitleBar.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/TitleBar.swift similarity index 96% rename from Packages/ContainedUI/Sources/ContainedUI/Panel/PanelTitleBar.swift rename to Packages/ContainedUI/Sources/ContainedUI/Panel/TitleBar.swift index 214f08b7..ce271260 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Panel/PanelTitleBar.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/TitleBar.swift @@ -1,7 +1,8 @@ import SwiftUI /// Standard in-window panel header for toolbar morphs and embedded panels. -public struct PanelTitleBar: View { +public extension UI.Panel { +struct Header: View { public let symbol: String public let title: String public var subtitle: String? @@ -53,3 +54,4 @@ public struct PanelTitleBar: View { .padding(padding) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift b/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift index b462ecef..6832899e 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift @@ -1,6 +1,7 @@ import SwiftUI -public struct ActivityStatus: Equatable, Sendable { +public extension UI.State { +struct ActivityStatus: Equatable, Sendable { public var title: String public var detail: String public var fraction: Double? @@ -19,13 +20,13 @@ public struct ActivityStatus: Equatable, Sendable { /// /// `.inline` is the compact one-line form sized for a toolbar capsule; `.expanded` is the taller card /// with a linear progress bar and the streaming detail line. -public struct ActivityStatusIndicator: View { +struct ActivityStatusIndicator: View { public enum Style { case inline, expanded } - public let activity: ActivityStatus + public let activity: UI.State.ActivityStatus public var style: Style = .inline - public init(activity: ActivityStatus, style: Style = .inline) { + public init(activity: UI.State.ActivityStatus, style: Style = .inline) { self.activity = activity self.style = style } @@ -87,3 +88,4 @@ public struct ActivityStatusIndicator: View { return "\(Int((fraction * 100).rounded()))%" } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift b/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift index ef8eb44b..4874a66f 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift @@ -1,6 +1,7 @@ import SwiftUI -public struct StatusDot: View { +public extension UI.Badge { +struct Dot: View { public var color: Color public var size: CGFloat @@ -16,7 +17,7 @@ public struct StatusDot: View { } } -public struct StatusBadge: View { +struct Status: View { public var text: String public var tint: Color public var font: Font @@ -30,7 +31,7 @@ public struct StatusBadge: View { } public var body: some View { - Text(text) + SwiftUI.Text(text) .font(font) .foregroundStyle(tint) .padding(.horizontal, UI.Tokens.Badge.horizontalPadding) @@ -38,3 +39,4 @@ public struct StatusBadge: View { .background(tint.opacity(UI.Tokens.Badge.statusOpacity), in: Capsule()) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift b/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift index 56430b1e..9e35624b 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift @@ -1,6 +1,7 @@ import SwiftUI -public enum StateTone { +public extension UI.State { +enum Tone { case primary case neutral case tertiary @@ -23,8 +24,10 @@ public enum StateTone { } } } +} -public enum SymbolSize { +public extension UI.Symbol { +enum Size { case caption2 case caption case callout @@ -46,17 +49,17 @@ public enum SymbolSize { } } -public struct SymbolView: View { +struct Image: View { public var systemName: String - public var tone: StateTone + public var tone: UI.State.Tone public var tint: Color? - public var size: SymbolSize + public var size: UI.Symbol.Size public var frameWidth: CGFloat? public init(systemName: String, - tone: StateTone = .neutral, + tone: UI.State.Tone = .neutral, tint: Color? = nil, - size: SymbolSize = .callout, + size: UI.Symbol.Size = .callout, frameWidth: CGFloat? = nil) { self.systemName = systemName self.tone = tone @@ -66,20 +69,22 @@ public struct SymbolView: View { } public var body: some View { - Image(systemName: systemName) + SwiftUI.Image(systemName: systemName) .font(size.font) .foregroundStyle(tint ?? tone.color) .frame(width: frameWidth) } } +} -public struct StatusText: View { +public extension UI.State { +struct StatusText: View { public var text: String - public var tone: StateTone + public var tone: UI.State.Tone public var style: Font public init(_ text: String, - tone: StateTone = .neutral, + tone: UI.State.Tone = .neutral, style: Font = .callout) { self.text = text self.tone = tone @@ -93,18 +98,18 @@ public struct StatusText: View { } } -public struct EmptyState: View { +struct Empty: View { public var title: String public var systemImage: String public var description: String? - public var tone: StateTone + public var tone: UI.State.Tone public var minHeight: CGFloat? public var padding: CGFloat public init(_ title: String, systemImage: String, description: String? = nil, - tone: StateTone = .neutral, + tone: UI.State.Tone = .neutral, minHeight: CGFloat? = nil, padding: CGFloat = UI.Tokens.Space.xl) { self.title = title @@ -117,7 +122,7 @@ public struct EmptyState: View { public var body: some View { VStack(spacing: UI.Tokens.Space.s) { - Image(systemName: systemImage) + SwiftUI.Image(systemName: systemImage) .font(.title2) .foregroundStyle(tone.color) Text(title) @@ -135,7 +140,7 @@ public struct EmptyState: View { } } -public struct HeroState: View { +struct Hero: View { public var systemImage: String public var title: String public var message: String @@ -153,7 +158,7 @@ public struct HeroState: View { public var body: some View { VStack(spacing: UI.Tokens.Space.l) { - Image(systemName: systemImage) + SwiftUI.Image(systemName: systemImage) .font(.system(size: UI.Tokens.IconSize.appIcon - UI.Tokens.Space.xs)) .foregroundStyle(.tint) Text(title).font(.title2.weight(.semibold)) @@ -168,7 +173,7 @@ public struct HeroState: View { } } -public struct LoadingState: View { +struct Loading: View { public var title: String public var minHeight: CGFloat? public var padding: CGFloat @@ -194,7 +199,7 @@ public struct LoadingState: View { } } -public struct ProgressIndicator: View { +struct ProgressIndicator: View { public var controlSize: ControlSize public var frameSize: CGFloat? @@ -210,7 +215,7 @@ public struct ProgressIndicator: View { } } -public struct SectionLabel: View { +struct SectionLabel: View { public var title: String public init(_ title: String) { @@ -224,16 +229,16 @@ public struct SectionLabel: View { } } -public struct InlineStatus: View { +struct InlineStatus: View { public var title: String public var systemImage: String? public var isWorking: Bool - public var tone: StateTone + public var tone: UI.State.Tone public init(_ title: String, systemImage: String? = nil, isWorking: Bool = false, - tone: StateTone = .neutral) { + tone: UI.State.Tone = .neutral) { self.title = title self.systemImage = systemImage self.isWorking = isWorking @@ -245,7 +250,7 @@ public struct InlineStatus: View { if isWorking { ProgressView().controlSize(.small) } else if let systemImage { - Image(systemName: systemImage) + SwiftUI.Image(systemName: systemImage) .foregroundStyle(tone.color) } Text(title) @@ -254,6 +259,7 @@ public struct InlineStatus: View { } } } +} public extension View { func designSectionLabelStyle() -> some View { @@ -324,7 +330,7 @@ public extension View { .foregroundStyle(.tertiary) } - func designStatusStyle(_ tone: StateTone) -> some View { + func designStatusStyle(_ tone: UI.State.Tone) -> some View { foregroundStyle(tone.color) } diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift b/Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift index 3aaaa3d9..9bff30b3 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift @@ -2,7 +2,8 @@ import SwiftUI /// A transient error banner: a warning glyph + message on a glass surface, sliding up from the bottom. /// Used as a bottom overlay to surface caller-supplied error copy without a blocking alert. -public struct ErrorBannerView: View { +public extension UI.State { +struct ErrorBanner: View { public let message: String public init(message: String) { @@ -21,3 +22,4 @@ public struct ErrorBannerView: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift b/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift index b1a7b4da..fd21cf76 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift @@ -1,7 +1,8 @@ import SwiftUI /// Package-owned transient banner chrome. -public struct StatusBanner: View { +public extension UI.State { +struct Banner: View { public var text: String public init(_ text: String) { @@ -16,3 +17,4 @@ public struct StatusBanner: View { .materialCapsuleSurface(shadow: false) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Support/NestedAPI.swift b/Packages/ContainedUI/Sources/ContainedUI/Support/NestedAPI.swift deleted file mode 100644 index e35c0c89..00000000 --- a/Packages/ContainedUI/Sources/ContainedUI/Support/NestedAPI.swift +++ /dev/null @@ -1,141 +0,0 @@ -import SwiftUI - -public extension UI.Card { - typealias Scaffold = CardScaffold - typealias Size = CardSize - typealias Density = CardDensity - typealias SizePicker = CardSizePicker - typealias TextStyle = CardTextStyle - typealias NoPage = CardNoPage - typealias Pages = CardPages - typealias Page = CardPage - typealias InsetSection = CardInsetSection - typealias FooterChip = CardFooterChip - typealias FooterButton = CardFooterButton - typealias FooterGroup = CardFooterGroup - typealias FooterMini = CardFooterMini - typealias WidgetGroup = CardWidgetGroup - typealias IconChip = CardIconChip - typealias MetricText = CardMetricText - typealias TitleText = CardTitleText - typealias SubtitleText = CardSubtitleText - typealias MonospacedTitleText = CardMonospacedTitleText - typealias MonospacedSubtitleText = CardMonospacedSubtitleText -} - -public extension UI.Panel { - typealias Scaffold = PanelScaffold - typealias PageScaffold = ContainedUI.PageScaffold - typealias Header = PanelTitleBar - typealias SheetTitleBar = PanelSheetTitleBar - typealias Section = PanelSectionView - typealias Row = PanelRowView - typealias ToggleRow = PanelToggleRowView - typealias Field = PanelFieldView -} - -public extension UI.Action { - typealias Item = ActionItem - typealias Group = ActionGroup - typealias Cluster = ActionCluster - typealias Items = ActionItems - typealias MenuLabel = ActionMenuLabel - typealias ProgressCapsule = ActionProgressCapsule - typealias TextButton = ActionTextButton - typealias TextProminence = ActionTextProminence - typealias ToggleButton = ActionToggleButton - typealias SelectionBar = ActionSelectionBar -} - -public extension UI.Control { - typealias InputCluster = ContainedUI.InputCluster - typealias OptionStack = ContainedUI.OptionStack - typealias OptionTile = ContainedUI.OptionTile - typealias RowMenu = ContainedUI.RowMenu - typealias SearchField = ContainedUI.SearchField - typealias MenuButton = ContainedUI.MenuButton - typealias GradientAngle = GradientAngleControl - typealias TintSwatch = ContainedUI.TintSwatch -} - -public extension UI.Toolbar { - typealias SearchField = ToolbarSearchField - typealias VanitySlot = ToolbarVanitySlot - typealias StatusButton = ToolbarStatusButton - typealias ActionCluster = ToolbarActionCluster - typealias MenuButton = ToolbarMenuButton - typealias TitleSubtitle = ToolbarTitleSubtitle -} - -public extension UI.Command { - typealias Preview = PreviewBar -} - -public extension UI.Chart { - typealias Style = ChartStyle - typealias GraphStyle = ContainedUI.GraphStyle - typealias Interpolation = WidgetInterpolation - typealias Scale = SparklineScale - typealias Sparkline = SparklineView - typealias MetricTile = SparklineMetricTile - typealias SampleBuffer = ContainedUI.SampleBuffer -} - -public extension UI.Surface { - typealias Content = ContentSurface - typealias Input = InputSurface -} - -public extension UI.State { - typealias Tone = StateTone - typealias StatusText = ContainedUI.StatusText - typealias Empty = EmptyState - typealias Hero = HeroState - typealias Loading = LoadingState - typealias ProgressIndicator = ContainedUI.ProgressIndicator - typealias SectionLabel = ContainedUI.SectionLabel - typealias InlineStatus = ContainedUI.InlineStatus - typealias ErrorBanner = ErrorBannerView - typealias Banner = StatusBanner - typealias ActivityStatus = ContainedUI.ActivityStatus - typealias ActivityStatusIndicator = ContainedUI.ActivityStatusIndicator -} - -public extension UI.Symbol { - typealias Size = SymbolSize - typealias Image = SymbolView -} - -public extension UI.List { - typealias Stack = ListStack - typealias Section = ListSection - typealias Row = ListRow - typealias RowChevron = ListRowChevron - typealias MetadataRow = ContainedUI.MetadataRow - typealias MetadataBadgeRow = ContainedUI.MetadataBadgeRow - typealias KeyValueRow = ContainedUI.KeyValueRow - typealias CompactInfoRow = ContainedUI.CompactInfoRow -} - -public extension UI.Badge { - typealias Text = BadgeText - typealias Status = StatusBadge - typealias Dot = StatusDot - typealias ScopeLabel = ScopeChipLabel -} - -public extension UI.Theme { - typealias Tint = ThemeTint - typealias Material = ThemeMaterial - typealias WindowMaterial = ThemeWindowMaterial - typealias Appearance = ThemeAppearanceMode - typealias ColorBlendMode = ThemeColorBlendMode - typealias ButtonTintStyle = ContainedUI.ButtonTintStyle - typealias BackgroundLayer = ContentBackgroundLayer -} - -public extension UI.Control { - typealias KeyCap = ContainedUI.KeyCap - typealias KeyboardHint = ContainedUI.KeyboardHint - typealias MetricTile = ContainedUI.MetricTile -} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift index 9cfeb407..a945aa95 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift @@ -1,18 +1,19 @@ import SwiftUI /// A package-owned content surface for empty states and grouped panel content. -public struct ContentSurface: View { +public extension UI.Surface { +struct Content: View { public var elevated: Bool public var minHeight: CGFloat? public var alignment: Alignment public var padding: CGFloat - @ViewBuilder public var content: () -> Content + @ViewBuilder public var content: () -> ContentView public init(elevated: Bool = false, minHeight: CGFloat? = nil, alignment: Alignment = .center, padding: CGFloat = UI.Tokens.Space.s, - @ViewBuilder content: @escaping () -> Content) { + @ViewBuilder content: @escaping () -> ContentView) { self.elevated = elevated self.minHeight = minHeight self.alignment = alignment @@ -27,3 +28,4 @@ public struct ContentSurface: View { .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card, shadow: elevated) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift index bf94b644..fed607ea 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift @@ -1,7 +1,8 @@ import SwiftUI /// A package-owned surface for compact inline controls such as search fields and text editors. -public struct InputSurface: View { +public extension UI.Surface { +struct Input: View { public var horizontalPadding: CGFloat public var verticalPadding: CGFloat public var minHeight: CGFloat? @@ -25,3 +26,4 @@ public struct InputSurface: View { .materialSurface(.thin, cornerRadius: UI.Tokens.Radius.control) } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift index f8e8338b..def1d624 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift @@ -30,7 +30,8 @@ struct VisualEffectBackground: NSViewRepresentable { /// Stable root-owned backing for the detail column. Pages render above this layer instead of /// applying their own window material. Translucency is always on — legibility under low-contrast /// wallpapers is left to the OS "Reduce transparency" accessibility setting. -public struct ContentBackgroundLayer: View { +public extension UI.Theme { +struct BackgroundLayer: View { public var material: NSVisualEffectView.Material public init(material: NSVisualEffectView.Material = .fullScreenUI) { @@ -42,3 +43,4 @@ public struct ContentBackgroundLayer: View { .ignoresSafeArea() } } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift b/Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift index bcda55c2..b426684f 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift @@ -1,9 +1,10 @@ import SwiftUI import AppKit +public extension UI.Theme { /// Material/elevation constants for reusable design surfaces. Keep glass, shadow, and stroke choices /// here so collapsed controls and expanded panels do not drift into near-duplicates. -public enum ThemeMaterial { +enum Material { public static let toolbarHoverFill = Color.white.opacity(0.1) public static func toolbarInteractiveHoverFill(for colorScheme: ColorScheme) -> Color { Color.white.opacity(colorScheme == .light ? 0.2 : 0.1) @@ -17,7 +18,7 @@ public enum ThemeMaterial { /// A curated color, used consistently for host accent choices and per-surface personalization. /// `.multicolor` follows `Color.accentColor`, so package callers can decide whether that means a /// global accent, a scoped accent, or the platform default. -public enum ThemeTint: String, CaseIterable, Identifiable, Codable, Sendable { +enum Tint: String, CaseIterable, Identifiable, Codable, Sendable { case multicolor, graphite, azure, teal, coral, indigo, green, amber, pink public var id: String { rawValue } @@ -41,7 +42,7 @@ public enum ThemeTint: String, CaseIterable, Identifiable, Codable, Sendable { } -public enum ThemeColorBlendMode: String, CaseIterable, Identifiable, Codable, Sendable { +enum ColorBlendMode: String, CaseIterable, Identifiable, Codable, Sendable { case normal, softLight, overlay, multiply, screen public var id: String { rawValue } @@ -57,7 +58,7 @@ public enum ThemeColorBlendMode: String, CaseIterable, Identifiable, Codable, Se } } -public enum ThemeAppearanceMode: String, CaseIterable, Identifiable, Codable, Sendable { +enum Appearance: String, CaseIterable, Identifiable, Codable, Sendable { case system, light, dark public var id: String { rawValue } public var colorScheme: ColorScheme? { @@ -80,10 +81,13 @@ public enum ThemeAppearanceMode: String, CaseIterable, Identifiable, Codable, Se } } -public enum CardDensity: String, CaseIterable, Identifiable, Codable, Sendable { +} + +public extension UI.Card { +enum Density: String, CaseIterable, Identifiable, Codable, Sendable { case small, medium, large public var id: String { rawValue } - public var resourceSize: CardSize { + public var resourceSize: UI.Card.Size { switch self { case .small: return .small case .medium: return .medium @@ -95,14 +99,16 @@ public enum CardDensity: String, CaseIterable, Identifiable, Codable, Sendable { if raw == "compact" { self = .medium } else { - self = CardDensity(rawValue: raw ?? "") ?? .medium + self = UI.Card.Density(rawValue: raw ?? "") ?? .medium } } } +} +public extension UI.Theme { /// The behind-window vibrancy material used for the main content area. A curated, ordered subset of /// `NSVisualEffectView.Material` (lightest → most opaque) so the picker reads sensibly. -public enum ThemeWindowMaterial: String, CaseIterable, Identifiable, Codable, Sendable { +enum WindowMaterial: String, CaseIterable, Identifiable, Codable, Sendable { // Liquid Glass options (rendered with `.glassEffect`, not an `NSVisualEffectView`). case glassClear, glassRegular // System vibrancy materials. @@ -146,16 +152,17 @@ public enum ThemeWindowMaterial: String, CaseIterable, Identifiable, Codable, Se } } } +} public extension EnvironmentValues { /// The user-chosen modal material, seeded at the app root and inherited by presented sheets. - @Entry var modalMaterial: ThemeWindowMaterial = .sheet + @Entry var modalMaterial: UI.Theme.WindowMaterial = .sheet /// The user-chosen toolbar-control (button) material, seeded at the app root. - @Entry var buttonMaterial: ThemeWindowMaterial = .glassClear + @Entry var buttonMaterial: UI.Theme.WindowMaterial = .glassClear /// The user-chosen design-card material, seeded at the app root. - @Entry var cardMaterial: ThemeWindowMaterial = .glassRegular + @Entry var cardMaterial: UI.Theme.WindowMaterial = .glassRegular /// Optional color/gradient wash layered into toolbar button groups. - @Entry var buttonTintStyle: ButtonTintStyle = .disabled + @Entry var buttonTintStyle: UI.Theme.ButtonTintStyle = .disabled } private struct SheetMaterial: ViewModifier { @@ -204,9 +211,9 @@ private struct FloatingPanelMaterial: AnimatableModifier { .background { if showsShadow { ExteriorShadow(cornerRadius: cornerRadius, - color: ThemeMaterial.floatingPanelShadow, - radius: ThemeMaterial.floatingPanelShadowRadius, - y: ThemeMaterial.floatingPanelShadowY) + color: UI.Theme.Material.floatingPanelShadow, + radius: UI.Theme.Material.floatingPanelShadowRadius, + y: UI.Theme.Material.floatingPanelShadowY) } } .background { @@ -219,7 +226,7 @@ private struct FloatingPanelMaterial: AnimatableModifier { } .clipShape(shape) .overlay { - shape.strokeBorder(ThemeMaterial.floatingPanelStroke, lineWidth: 1) + shape.strokeBorder(UI.Theme.Material.floatingPanelStroke, lineWidth: 1) } } } diff --git a/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrames.swift b/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrameReader.swift similarity index 86% rename from Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrames.swift rename to Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrameReader.swift index f08b4efe..7c0b48b4 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrames.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrameReader.swift @@ -1,6 +1,7 @@ import SwiftUI -public struct SourceFrameReader: View { +public extension UX.Measurement { +struct SourceFrameReader: View { public var ids: [ID] public var coordinateSpaceName: String @@ -18,14 +19,14 @@ public struct SourceFrameReader: View { GeometryReader { proxy in let frame = proxy.frame(in: .named(coordinateSpaceName)) Color.clear.preference( - key: SourceFramesKey.self, + key: UX.Measurement.SourceFramesKey.self, value: Dictionary(uniqueKeysWithValues: ids.map { ($0, frame) }) ) } } } -public struct SourceFramesKey: PreferenceKey { +struct SourceFramesKey: PreferenceKey { public static var defaultValue: [ID: CGRect] { [:] } public static func reduce(value: inout [ID: CGRect], @@ -33,6 +34,7 @@ public struct SourceFramesKey: PreferenceKey { value.merge(nextValue()) { _, new in new } } } +} public extension Dictionary where Value == CGRect { func isClose(to other: [Key: CGRect], tolerance: CGFloat = 0.5) -> Bool { @@ -46,7 +48,7 @@ public extension Dictionary where Value == CGRect { public extension CGRect { var isUsableForMorph: Bool { - MorphGeometryEngine.isUsableFrame(self) + UX.Morph.Geometry.isUsableFrame(self) } func isClose(to other: CGRect, tolerance: CGFloat = 0.5) -> Bool { diff --git a/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift b/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift index f596aae6..0bec79bb 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift @@ -1,19 +1,15 @@ import SwiftUI import ContainedUI -public enum PanelPlacement: Equatable, Sendable { - case anchored - case centered -} - -public struct MorphTargetConfig { - public var placement: PanelPlacement - public var safeArea: SafeAreaPolicy +public extension UX.Morph { +struct Target { + public var placement: UX.Panel.Placement + public var safeArea: UX.SafeArea.Policy public var margin: CGFloat public var proposedSize: (CGRect) -> CGSize - public init(placement: PanelPlacement, - safeArea: SafeAreaPolicy, + public init(placement: UX.Panel.Placement, + safeArea: UX.SafeArea.Policy, margin: CGFloat, proposedSize: @escaping (CGRect) -> CGSize) { self.placement = placement @@ -23,27 +19,27 @@ public struct MorphTargetConfig { } public static func anchored(size: CGSize, - safeArea: SafeAreaPolicy = .toolbarChrome, - margin: CGFloat = MorphGeometryEngine.defaultMargin) -> MorphTargetConfig { - MorphTargetConfig(placement: .anchored, + safeArea: UX.SafeArea.Policy = .toolbarChrome, + margin: CGFloat = UX.Morph.Geometry.defaultMargin) -> UX.Morph.Target { + UX.Morph.Target(placement: .anchored, safeArea: safeArea, margin: margin, proposedSize: { _ in size }) } public static func centered(size: CGSize, - safeArea: SafeAreaPolicy = .content, - margin: CGFloat = MorphGeometryEngine.defaultMargin) -> MorphTargetConfig { - MorphTargetConfig(placement: .centered, + safeArea: UX.SafeArea.Policy = .content, + margin: CGFloat = UX.Morph.Geometry.defaultMargin) -> UX.Morph.Target { + UX.Morph.Target(placement: .centered, safeArea: safeArea, margin: margin, proposedSize: { _ in size }) } - public static func centered(safeArea: SafeAreaPolicy = .content, - margin: CGFloat = MorphGeometryEngine.defaultMargin, - proposedSize: @escaping (CGRect) -> CGSize) -> MorphTargetConfig { - MorphTargetConfig(placement: .centered, + public static func centered(safeArea: UX.SafeArea.Policy = .content, + margin: CGFloat = UX.Morph.Geometry.defaultMargin, + proposedSize: @escaping (CGRect) -> CGSize) -> UX.Morph.Target { + UX.Morph.Target(placement: .centered, safeArea: safeArea, margin: margin, proposedSize: proposedSize) @@ -51,11 +47,11 @@ public struct MorphTargetConfig { public func rect(origin: CGRect, in container: CGSize, - safeAreaManager: SafeAreaManager, + safeAreaManager: UX.SafeArea.Manager, proposedSize overrideSize: CGSize? = nil, - placement overridePlacement: PanelPlacement? = nil) -> CGRect { + placement overridePlacement: UX.Panel.Placement? = nil) -> CGRect { let bounds = safeAreaManager.bounds(in: container, policy: safeArea) - return MorphGeometryEngine.targetRect(origin: origin, + return UX.Morph.Geometry.targetRect(origin: origin, proposedSize: overrideSize ?? proposedSize(bounds), bounds: bounds, placement: overridePlacement ?? placement, @@ -63,19 +59,7 @@ public struct MorphTargetConfig { } } -public struct PanelBackdropStyle: OptionSet, Equatable, Sendable { - public let rawValue: Int - - public init(rawValue: Int) { - self.rawValue = rawValue - } - - public static let dim = PanelBackdropStyle(rawValue: 1 << 0) - public static let blur = PanelBackdropStyle(rawValue: 1 << 1) - public static let blurAndDim: PanelBackdropStyle = [.blur, .dim] -} - -public enum MorphGeometryEngine { +enum Geometry { public static let defaultMargin: CGFloat = UI.Layout.Spacing.l public static let centeredTopMargin: CGFloat = UI.Layout.Spacing.xxl * 2 @@ -93,7 +77,7 @@ public enum MorphGeometryEngine { } public static func targetRect(origin: CGRect, proposedSize: CGSize, container: CGSize, - placement: PanelPlacement, + placement: UX.Panel.Placement, margin: CGFloat = defaultMargin) -> CGRect { targetRect(origin: origin, proposedSize: proposedSize, @@ -103,7 +87,7 @@ public enum MorphGeometryEngine { } public static func targetRect(origin: CGRect, proposedSize: CGSize, bounds: CGRect, - placement: PanelPlacement, + placement: UX.Panel.Placement, margin: CGFloat = defaultMargin) -> CGRect { let size = fittedSize(proposedSize, in: bounds, margin: margin) switch placement { @@ -138,6 +122,7 @@ public enum MorphGeometryEngine { return CGRect(x: x, y: y, width: width, height: height) } } +} extension CGSize { var isUsableForMorphPanel: Bool { @@ -145,6 +130,7 @@ extension CGSize { } } +public extension UX.Morph { /// A centered material panel that **grows from an origin slot** (e.g. a toolbar button) over a /// dimmed/blurred backdrop, then shrinks back into it on close — the same in-place grow the container /// cards use for their detail panel, hoisted into a reusable primitive. @@ -152,13 +138,13 @@ extension CGSize { /// Mount it inside a **window-spanning** `ZStack` whose coordinate space matches the one `originFrame` /// was measured in (so the grow starts from the real button location). It owns the open/close spring; /// the parent just toggles `isPresented` and supplies the slot frame + panel content. -public struct MorphExpander: View { +struct Expander: View { /// Bound presence. The expander animates the close itself, then flips this to `false` on completion. @Binding var isPresented: Bool /// The slot the panel grows out of / collapses back into, in this view's coordinate space. let originFrame: CGRect - var target: MorphTargetConfig - var backdropStyle: PanelBackdropStyle = .dim + var target: UX.Morph.Target + var backdropStyle: UX.Panel.BackdropStyle = .dim var showsBackdrop = true var showsPanelShadow = true var closeRequestToken = 0 @@ -173,7 +159,7 @@ public struct MorphExpander: View { /// content reports a new desired size via `.morphPanelSize(...)`. This is what lets a paged panel /// resize and re-center as it moves between sections. @State private var liveSize: CGSize? - @State private var livePlacement: PanelPlacement? + @State private var livePlacement: UX.Panel.Placement? @Namespace private var shellNamespace @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.morphSafeAreaManager) private var safeAreaManager @@ -181,8 +167,8 @@ public struct MorphExpander: View { public init(isPresented: Binding, originFrame: CGRect, - target: MorphTargetConfig = .centered(size: CGSize(width: 460, height: 440)), - backdropStyle: PanelBackdropStyle = .dim, + target: UX.Morph.Target = .centered(size: CGSize(width: 460, height: 440)), + backdropStyle: UX.Panel.BackdropStyle = .dim, showsBackdrop: Bool = true, showsPanelShadow: Bool = true, closeRequestToken: Int = 0, @@ -209,7 +195,7 @@ public struct MorphExpander: View { GeometryReader { geo in let target = targetRect(in: geo.size) let source = originFrame.isUsableForMorph ? originFrame : target - let rect = MorphFrameGeometry(source: source, + let rect = UX.Morph.Frame(source: source, target: target, progress: expanded ? 1 : 0).rect let cornerRadius = expanded ? targetCornerRadius : sourceCornerRadius @@ -291,9 +277,10 @@ public struct MorphExpander: View { withAnimation(spring) { expanded = false } completion: { isPresented = false } } } +} public extension View { - func globalBackdrop(style: PanelBackdropStyle, + func globalBackdrop(style: UX.Panel.BackdropStyle, progress: Double, dimOpacity: Double = 0.28) -> some View { self @@ -325,7 +312,7 @@ private struct MorphPanelShell: View { } } -/// Hosted content reports its desired panel size up to the enclosing `MorphExpander`, which animates +/// Hosted content reports its desired panel size up to the enclosing `UX.Morph.Expander`, which animates /// the panel to it — so a paged panel can resize and re-center between sections. struct MorphPanelSizeKey: PreferenceKey { static let defaultValue: CGSize? = nil @@ -335,21 +322,21 @@ struct MorphPanelSizeKey: PreferenceKey { } struct MorphPanelPlacementKey: PreferenceKey { - static let defaultValue: PanelPlacement? = nil - static func reduce(value: inout PanelPlacement?, - nextValue: () -> PanelPlacement?) { + static let defaultValue: UX.Panel.Placement? = nil + static func reduce(value: inout UX.Panel.Placement?, + nextValue: () -> UX.Panel.Placement?) { if let next = nextValue() { value = next } } } public extension View { - /// Declare the desired size of the panel hosting this content (read by `MorphExpander`). + /// Declare the desired size of the panel hosting this content (read by `UX.Morph.Expander`). func morphPanelSize(_ size: CGSize) -> some View { preference(key: MorphPanelSizeKey.self, value: size) } /// Declare whether the hosting morph panel should stay near its source slot or move to center. - func morphPanelPlacement(_ placement: PanelPlacement) -> some View { + func morphPanelPlacement(_ placement: UX.Panel.Placement) -> some View { preference(key: MorphPanelPlacementKey.self, value: placement) } } diff --git a/Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift b/Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift index 15a555bd..c14490f5 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift @@ -1,6 +1,7 @@ import SwiftUI -public struct MorphFrameGeometry: Equatable, Sendable { +public extension UX.Morph { +struct Frame: Equatable, Sendable { public var source: CGRect public var target: CGRect public var progress: CGFloat @@ -8,7 +9,7 @@ public struct MorphFrameGeometry: Equatable, Sendable { public init(source: CGRect, target: CGRect, progress: CGFloat) { self.source = source self.target = target - self.progress = MorphGeometryEngine.clampedProgress(progress) + self.progress = UX.Morph.Geometry.clampedProgress(progress) } public var rect: CGRect { @@ -18,9 +19,9 @@ public struct MorphFrameGeometry: Equatable, Sendable { /// Hosts one promoted surface while it grows from an existing slot into a larger target rect. /// -/// This is the single-card version of the rect motion used by `MorphExpander`: callers keep the +/// This is the single-card version of the rect motion used by `UX.Morph.Expander`: callers keep the /// source view laid out in place, hide it while selected, and render one overlay through this helper. -public struct MorphSingleSurface: View { +struct SingleSurface: View { public var source: CGRect public var target: CGRect public var progress: CGFloat @@ -40,23 +41,23 @@ public struct MorphSingleSurface: View { } public var body: some View { - let rect = MorphFrameGeometry(source: source, target: target, progress: progress).rect + let rect = UX.Morph.Frame(source: source, target: target, progress: progress).rect content() .frame(width: max(rect.width, 1), height: max(rect.height, 1), alignment: alignment) .position(x: rect.midX, y: rect.midY) } } -/// Hosts one promoted card-like surface with the same lifecycle as `MorphExpander`, but without +/// Hosts one promoted card-like surface with the same lifecycle as `UX.Morph.Expander`, but without /// drawing a separate panel shell around the content. /// /// Use this when the promoted content is already its own visual surface, such as an expanded design -/// card. Panel contents should still use `MorphExpander`. -public struct MorphSingleSurfaceExpander: View { +/// card. Panel contents should still use `UX.Morph.Expander`. +struct SingleSurfaceExpander: View { @Binding var isPresented: Bool public var originFrame: CGRect - public var target: MorphTargetConfig - public var backdropStyle: PanelBackdropStyle + public var target: UX.Morph.Target + public var backdropStyle: UX.Panel.BackdropStyle public var showsBackdrop: Bool public var closeRequestToken: Int public var onBackdropTap: (() -> Void)? @@ -65,15 +66,15 @@ public struct MorphSingleSurfaceExpander: View { @State private var expanded = false @State private var liveSize: CGSize? - @State private var livePlacement: PanelPlacement? + @State private var livePlacement: UX.Panel.Placement? @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.morphSafeAreaManager) private var safeAreaManager private var spring: Animation { .spring(response: 0.42, dampingFraction: 0.86) } public init(isPresented: Binding, originFrame: CGRect, - target: MorphTargetConfig, - backdropStyle: PanelBackdropStyle = .dim, + target: UX.Morph.Target, + backdropStyle: UX.Panel.BackdropStyle = .dim, showsBackdrop: Bool = true, closeRequestToken: Int = 0, onBackdropTap: (() -> Void)? = nil, @@ -158,8 +159,9 @@ public struct MorphSingleSurfaceExpander: View { } } } +} -public extension MorphGeometryEngine { +public extension UX.Morph.Geometry { static func clampedProgress(_ progress: CGFloat) -> CGFloat { min(max(progress.isFinite ? progress : 0, 0), 1) } @@ -190,6 +192,6 @@ public extension MorphGeometryEngine { public extension CGRect { func morphInterpolated(to target: CGRect, progress: CGFloat) -> CGRect { - MorphGeometryEngine.interpolatedRect(from: self, to: target, progress: progress) + UX.Morph.Geometry.interpolatedRect(from: self, to: target, progress: progress) } } diff --git a/Packages/ContainedUX/Sources/ContainedUX/Panel/BackdropStyle.swift b/Packages/ContainedUX/Sources/ContainedUX/Panel/BackdropStyle.swift new file mode 100644 index 00000000..99226199 --- /dev/null +++ b/Packages/ContainedUX/Sources/ContainedUX/Panel/BackdropStyle.swift @@ -0,0 +1,13 @@ +public extension UX.Panel { +struct BackdropStyle: OptionSet, Equatable, Sendable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let dim = BackdropStyle(rawValue: 1 << 0) + public static let blur = BackdropStyle(rawValue: 1 << 1) + public static let blurAndDim: BackdropStyle = [.blur, .dim] +} +} diff --git a/Packages/ContainedUX/Sources/ContainedUX/Panel/Placement.swift b/Packages/ContainedUX/Sources/ContainedUX/Panel/Placement.swift new file mode 100644 index 00000000..14081c4c --- /dev/null +++ b/Packages/ContainedUX/Sources/ContainedUX/Panel/Placement.swift @@ -0,0 +1,8 @@ +import CoreGraphics + +public extension UX.Panel { +enum Placement: Equatable, Sendable { + case anchored + case centered +} +} diff --git a/Packages/ContainedUX/Sources/ContainedUX/SafeArea/SafeAreaManager.swift b/Packages/ContainedUX/Sources/ContainedUX/SafeArea/Manager.swift similarity index 64% rename from Packages/ContainedUX/Sources/ContainedUX/SafeArea/SafeAreaManager.swift rename to Packages/ContainedUX/Sources/ContainedUX/SafeArea/Manager.swift index 8184db24..a434adab 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/SafeArea/SafeAreaManager.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/SafeArea/Manager.swift @@ -1,43 +1,44 @@ import SwiftUI -public struct ToolbarSafeAreaExclusion: OptionSet, Equatable, Sendable { +public extension UX.SafeArea { +struct ToolbarExclusion: OptionSet, Equatable, Sendable { public let rawValue: Int public init(rawValue: Int) { self.rawValue = rawValue } - public static let top = ToolbarSafeAreaExclusion(rawValue: 1 << 0) - public static let bottom = ToolbarSafeAreaExclusion(rawValue: 1 << 1) - public static let both: ToolbarSafeAreaExclusion = [.top, .bottom] + public static let top = UX.SafeArea.ToolbarExclusion(rawValue: 1 << 0) + public static let bottom = UX.SafeArea.ToolbarExclusion(rawValue: 1 << 1) + public static let both: UX.SafeArea.ToolbarExclusion = [.top, .bottom] } -public enum SafeAreaPadding: CGFloat, Equatable, Sendable { +enum Padding: CGFloat, Equatable, Sendable { case none = 0 case small = 8 case medium = 16 case large = 24 } -public struct SafeAreaPolicy: Equatable, Sendable { - public var excluding: ToolbarSafeAreaExclusion - public var padding: SafeAreaPadding +struct Policy: Equatable, Sendable { + public var excluding: UX.SafeArea.ToolbarExclusion + public var padding: UX.SafeArea.Padding public var includesSystemInsets: Bool - public init(excluding: ToolbarSafeAreaExclusion = .both, - padding: SafeAreaPadding = .small, + public init(excluding: UX.SafeArea.ToolbarExclusion = .both, + padding: UX.SafeArea.Padding = .small, includesSystemInsets: Bool = true) { self.excluding = excluding self.padding = padding self.includesSystemInsets = includesSystemInsets } - public static let fullBleed = SafeAreaPolicy(excluding: [], padding: .none) - public static let toolbarChrome = SafeAreaPolicy(excluding: [], padding: .small) - public static let content = SafeAreaPolicy(excluding: .both, padding: .medium) + public static let fullBleed = UX.SafeArea.Policy(excluding: [], padding: .none) + public static let toolbarChrome = UX.SafeArea.Policy(excluding: [], padding: .small) + public static let content = UX.SafeArea.Policy(excluding: .both, padding: .medium) } -public struct SafeAreaManager: Equatable, Sendable { +struct Manager: Equatable, Sendable { public var system: EdgeInsets public var topToolbarHeight: CGFloat public var bottomToolbarHeight: CGFloat @@ -54,7 +55,7 @@ public struct SafeAreaManager: Equatable, Sendable { self.init(system: system, topToolbarHeight: toolbarHeight, bottomToolbarHeight: 0) } - public func insets(_ policy: SafeAreaPolicy = .content) -> EdgeInsets { + public func insets(_ policy: UX.SafeArea.Policy = .content) -> EdgeInsets { let padding = policy.padding.rawValue let systemInsets = policy.includesSystemInsets ? system : EdgeInsets() // On an edge that excludes its toolbar, the band *is* the inset — the padding doesn't stack on @@ -65,7 +66,7 @@ public struct SafeAreaManager: Equatable, Sendable { trailing: systemInsets.trailing + padding) } - public func bounds(in size: CGSize, policy: SafeAreaPolicy = .content) -> CGRect { + public func bounds(in size: CGSize, policy: UX.SafeArea.Policy = .content) -> CGRect { let safeInsets = insets(policy) return CGRect(x: safeInsets.leading, y: safeInsets.top, @@ -73,7 +74,8 @@ public struct SafeAreaManager: Equatable, Sendable { height: max(1, size.height - safeInsets.top - safeInsets.bottom)) } } +} public extension EnvironmentValues { - @Entry var morphSafeAreaManager = SafeAreaManager() + @Entry var morphSafeAreaManager = UX.SafeArea.Manager() } diff --git a/Packages/ContainedUX/Sources/ContainedUX/UX.swift b/Packages/ContainedUX/Sources/ContainedUX/UX.swift index ffe7872f..0a16ce3a 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/UX.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/UX.swift @@ -10,29 +10,3 @@ public extension UX { enum SafeArea {} enum Measurement {} } - -public extension UX.Panel { - typealias Placement = PanelPlacement - typealias BackdropStyle = PanelBackdropStyle -} - -public extension UX.Morph { - typealias Target = MorphTargetConfig - typealias Geometry = MorphGeometryEngine - typealias Frame = MorphFrameGeometry - typealias Expander = MorphExpander - typealias SingleSurface = MorphSingleSurface - typealias SingleSurfaceExpander = MorphSingleSurfaceExpander -} - -public extension UX.SafeArea { - typealias ToolbarExclusion = ToolbarSafeAreaExclusion - typealias Padding = SafeAreaPadding - typealias Policy = SafeAreaPolicy - typealias Manager = SafeAreaManager -} - -public extension UX.Measurement { - typealias SourceFrameReader = ContainedUX.SourceFrameReader - typealias SourceFramesKey = ContainedUX.SourceFramesKey -} diff --git a/README.md b/README.md index 772d8fea..8261a3ed 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@

Contained

-Contained is a native macOS control surface for Apple's [`container`](https://github.com/apple/container) CLI. It gives containers, images, volumes, networks, registries, logs, templates, and app-managed health/restart behavior a Mac-first SwiftUI interface without hiding the underlying command line. +

+ A native macOS control surface for Apple's container CLI. +

Contained running containers @@ -21,12 +23,14 @@ Contained is a native macOS control surface for Apple's [`container`](https://gi ## What It Does +Contained gives containers, images, volumes, networks, registries, logs, templates, app-managed health, and restart behavior a Mac-first SwiftUI interface while keeping the underlying command line visible. + - Run, edit, stop, restart, inspect, and delete containers. -- Browse rich Liquid Glass cards with local-only tint, icon, nickname, and graph personalization. +- Browse Liquid Glass cards with local-only tint, icon, nickname, and graph personalization. - Manage images, tags, updates, archives, volumes, networks, registry credentials, templates, activity history, and system resources. - Import Compose files into editable run forms instead of launching opaque stacks. -- Reveal the exact `container` CLI command before privileged run/edit operations. -- Optionally enable the floating toolbar, morph panels, command palette, Docker Hub search, image build workspace, keyboard shortcuts, and Compose import from Settings -> Experimental. +- Preview the exact `container` command before privileged run/edit operations. +- Try experimental toolbar panels, morph surfaces, command palette, Docker Hub search, image build workspace, keyboard shortcuts, and Compose import from Settings. ## Install @@ -42,13 +46,12 @@ Requirements: ## Build -Contained has two supported development entry points that share the same package -graph: +Contained has two supported development entry points over the same package graph. -- Xcode: `Contained.xcworkspace` contains a native macOS app target that builds - and runs `Contained.app` directly for SwiftUI iteration. -- SwiftPM: `Package.swift`, `swift build`, `swift test`, and `scripts/bundle.sh` - remain the CI, release, packaging, signing, notarization, and appcast path. +| Path | Use it for | +| --- | --- | +| `Contained.xcworkspace` | Native Xcode build/run, SwiftUI previews, and manual UI iteration | +| `Package.swift` | CI, tests, bundle generation, signing, notarization, release notes, and appcasts | ```sh open Contained.xcworkspace @@ -64,52 +67,45 @@ Maintainers use `scripts/release.sh` and `scripts/appcast.sh` for signing, notar ## Documentation -Maintained docs live in [`docs`](docs) beside the code so architecture and -workflow changes can be reviewed with implementation changes: +Start with the [documentation index](docs/README.md). The most-used pages are: - App: [Home](docs/app/Home.md), [Installation](docs/app/Installation.md), [Keyboard Shortcuts](docs/app/Keyboard-Shortcuts.md), [Troubleshooting](docs/app/Troubleshooting.md), [Updates](docs/app/Updates.md), [System Settings](docs/app/System-Settings.md) - Features: [Feature Overview](docs/features/Features.md), [Containers](docs/features/Containers.md), [Images](docs/features/Images.md), [Resources](docs/features/Resources.md), [Creation Workflow](docs/features/Creation-Workflow.md), [Run / Edit Form](docs/features/Run-Edit-Form.md), [Compose Import](docs/features/Compose-Import.md), [Command Palette](docs/features/Command-Palette.md) - Architecture: [Architecture](docs/architecture/Architecture.md), [Runtime Adapters](docs/architecture/Runtime-Adapters.md), [Design System](docs/architecture/Design-System.md) -- Development: [Contributing](docs/development/Contributing.md), [Issues and Discussions](docs/development/Issues-and-Discussions.md), [Localization](docs/app/Localization.md) +- Development: [Contributing](docs/development/Contributing.md), [Issues and Discussions](docs/development/Issues-and-Discussions.md), [Documentation Map](docs/development/Documentation-Map.md), [Localization](docs/app/Localization.md) - Release: [Release Runbook](docs/release/Release.md) -Each local package also has its own README and DocC landing page under -`Packages//`. - -## Contributing And Support +Package docs live beside each local package: -Start with the [docs](docs) and -[Troubleshooting](docs/app/Troubleshooting.md). -Use [Discussions Q&A](https://github.com/tdeverx/contained-app/discussions/categories/q-a) -for setup help and questions, and -[open an issue](https://github.com/tdeverx/contained-app/issues/new/choose) for -actionable bugs, crashes, regressions, or tracked feature work. +- [ContainedCore](Packages/ContainedCore/README.md) +- [ContainedUI](Packages/ContainedUI/README.md) +- [ContainedUX](Packages/ContainedUX/README.md) -Please read the [contributing guide](docs/development/Contributing.md) -before opening a larger PR. Do not post vulnerabilities publicly; use -[private vulnerability reporting](https://github.com/tdeverx/contained-app/security/advisories/new) -instead. +The checked-in [wiki map](docs/wiki/README.md) explains how maintained docs map to the separate GitHub wiki repository. ## Architecture -The root package contains the app launcher and app implementation, then consumes -standalone local packages: +The root package contains a tiny SwiftPM launcher and the shared app implementation, then consumes standalone local packages. -- [`ContainedCore`](Packages/ContainedCore/README.md): the backend/orchestration package. It exposes `Core.*` APIs for runtime descriptors/capabilities, canonical container models, command previews, Compose import/export semantics, Apple `container` adapter internals, metrics, typed errors, and future runtime migration planning. -- [`ContainedUI`](Packages/ContainedUI/README.md): reusable SwiftUI/AppKit visual primitives, tokens, spacing, material, cards, panels, controls, feedback, and data visualization. -- [`ContainedUX`](Packages/ContainedUX/README.md): reusable safe-area, morphing, measurement, and panel-host infrastructure. -- `ContainedApp`: SwiftUI app shell, navigation, feature views, stores, history, settings, Sparkle support, app state migration, app-specific presentation mappings, localization, and the join point between Core/UI/UX. -- `Contained`: tiny SwiftPM executable launcher used by command-line builds and bundle scripts. +| Owner | Responsibility | +| --- | --- | +| [ContainedCore](Packages/ContainedCore/README.md) | Backend orchestration through `Core.*`: runtime descriptors, canonical container models, command previews, Compose import/export, Apple `container` adapter internals, metrics, typed errors, and migration planning | +| [ContainedUI](Packages/ContainedUI/README.md) | Visual system through `UI.*`: tokens, materials, cards, panels, controls, state views, and charts | +| [ContainedUX](Packages/ContainedUX/README.md) | Interaction infrastructure through `UX.*`: safe areas, morphing, source measurement, and panel placement | +| `ContainedApp` | SwiftUI shell, navigation, feature views, stores, history, settings, Sparkle, presentation mapping, localization, and app policy | +| `Contained` | SwiftPM executable launcher used by command-line builds and bundle scripts | + +Integration is CLI-based rather than private-framework based. The app talks to `Core.Orchestrator`; Core owns adapter-specific argv and process details. Personalization and app-managed metadata stay local to Contained so the user's container resources remain clean when used directly from the terminal. + +Core also exposes a separate `ContainedCoreFixtures` product for deterministic test/preview data under `Core.Fixtures.*`. Normal app and distributable bundle targets do not link it. + +## Contributing And Support -Ownership shorthand: UI owns visuals, UX owns interaction/morph/panel movement, -Core owns backend orchestration, and ContainedApp joins those packages with -localization, persistence, settings, routing, and feature policy. -Core also exposes a separate `ContainedCoreFixtures` product for deterministic -test/preview data under `Core.Fixtures.*`; normal app and distributable bundle -targets do not link it. +Read the [contributing guide](docs/development/Contributing.md) before opening a larger PR. -Integration is intentionally CLI-based rather than private-framework based. The app talks to `Core.Orchestrator`; Core owns adapter-specific argv/process details. Personalization and app-managed metadata stay local to Contained so the user's container resources remain clean when used directly from the terminal. -Reusable packages expose display-neutral errors with stable package codes/context; the app owns localized messages, alerts, and Activity history presentation. +- Use [Discussions Q&A](https://github.com/tdeverx/contained-app/discussions/categories/q-a) for setup help and questions. +- Use [issues](https://github.com/tdeverx/contained-app/issues/new/choose) for actionable bugs, crashes, regressions, and tracked feature work. +- Use [private vulnerability reporting](https://github.com/tdeverx/contained-app/security/advisories/new) for security issues. ## License diff --git a/Sources/ContainedApp/App/AppModel+Configuration.swift b/Sources/ContainedApp/App/AppModel+Configuration.swift index e286c9fe..ad75d0a1 100644 --- a/Sources/ContainedApp/App/AppModel+Configuration.swift +++ b/Sources/ContainedApp/App/AppModel+Configuration.swift @@ -81,7 +81,7 @@ extension AppModel { personalization.applyBackup(try value.decode(PersonalizationBackup.self), replace: replace) } if selected.contains(.healthChecks), let value = envelope.sections[.healthChecks] { - healthChecks.applyBackup(try value.decode([String: HealthCheck].self), replace: replace) + healthChecks.applyBackup(try value.decode([String: Core.Container.HealthCheck].self), replace: replace) } if selected.contains(.templates), let value = envelope.sections[.templates] { historyStore.applyTemplates(try value.decode([TemplateSnapshot].self), replace: replace) diff --git a/Sources/ContainedApp/App/AppModel+ImageUpdates.swift b/Sources/ContainedApp/App/AppModel+ImageUpdates.swift index 61fbda13..a4fd8a7d 100644 --- a/Sources/ContainedApp/App/AppModel+ImageUpdates.swift +++ b/Sources/ContainedApp/App/AppModel+ImageUpdates.swift @@ -32,14 +32,14 @@ extension AppModel { // MARK: Status lookup /// The tracked update status for an image reference (defaults to an empty/unknown status). - func imageUpdateStatus(for reference: String) -> ImageUpdateStatus { - imageUpdates[imageUpdateKey(reference)] ?? ImageUpdateStatus() + func imageUpdateStatus(for reference: String) -> Core.Image.UpdateStatus { + imageUpdates[imageUpdateKey(reference)] ?? Core.Image.UpdateStatus() } /// The normalized dictionary key for a reference, so `nginx` and `docker.io/library/nginx:latest` /// map to the same tracked status. func imageUpdateKey(_ reference: String) -> String { - RegistryImageReference.normalizedKey(reference) + Core.Registry.ImageReference.normalizedKey(reference) } // MARK: Sweeps @@ -101,7 +101,7 @@ extension AppModel { if notify { flash(AppText.imageLocalDigestUnavailable(Format.shortImage(reference))) } return } - let status = ImageUpdateStatus.resolved(localDigest: localDigest, remoteDigest: remoteDigest) + let status = Core.Image.UpdateStatus.resolved(localDigest: localDigest, remoteDigest: remoteDigest) imageUpdates[key] = status if notify { switch status.state { @@ -148,7 +148,7 @@ extension AppModel { do { images = try await client.images() imagesError = nil - } catch let error as CommandError { + } catch let error as Core.Command.Error { imagesError = error.appDisplayMessage return } catch { @@ -220,17 +220,17 @@ extension AppModel { // MARK: Persistence - static func loadImageUpdates(defaults: UserDefaults = .standard) -> [String: ImageUpdateStatus] { + static func loadImageUpdates(defaults: UserDefaults = .standard) -> [String: Core.Image.UpdateStatus] { guard let data = defaults.data(forKey: imageUpdatesKey), - let decoded = try? JSONDecoder().decode([String: ImageUpdateStatus].self, from: data) else { + let decoded = try? JSONDecoder().decode([String: Core.Image.UpdateStatus].self, from: data) else { return [:] } // Never persist a transient "checking" state; restore it as unknown. - return decoded.mapValues { $0.state == .checking ? ImageUpdateStatus() : $0 } + return decoded.mapValues { $0.state == .checking ? Core.Image.UpdateStatus() : $0 } } - static func saveImageUpdates(_ updates: [String: ImageUpdateStatus], defaults: UserDefaults = .standard) { - let stable = updates.mapValues { $0.state == .checking ? ImageUpdateStatus() : $0 } + static func saveImageUpdates(_ updates: [String: Core.Image.UpdateStatus], defaults: UserDefaults = .standard) { + let stable = updates.mapValues { $0.state == .checking ? Core.Image.UpdateStatus() : $0 } if let data = try? JSONEncoder().encode(stable) { defaults.set(data, forKey: imageUpdatesKey) } diff --git a/Sources/ContainedApp/App/AppModel+ResourceStyles.swift b/Sources/ContainedApp/App/AppModel+ResourceStyles.swift index e9341e1e..6a39dfd7 100644 --- a/Sources/ContainedApp/App/AppModel+ResourceStyles.swift +++ b/Sources/ContainedApp/App/AppModel+ResourceStyles.swift @@ -2,11 +2,11 @@ import Foundation import ContainedCore extension AppModel { - func localImageGroups() -> [LocalImageTagGroup] { + func localImageGroups() -> [Core.Image.LocalTagGroup] { if let imageGroupsCache { return imageGroupsCache } - let groups = LocalImageTagGroup.groups(for: images) + let groups = Core.Image.LocalTagGroup.groups(for: images) imageGroupsCache = groups return groups } @@ -20,7 +20,7 @@ extension AppModel { return personalization.imageDefault(for: reference, groupID: groupID) ?? defaultImageStyle } - func imageGroupStyle(for group: LocalImageTagGroup) -> Personalization { + func imageGroupStyle(for group: Core.Image.LocalTagGroup) -> Personalization { personalization.imageGroupDefault(for: group.id) ?? defaultImageStyle } @@ -35,7 +35,7 @@ extension AppModel { return style } - func containerStyle(for snapshot: ContainerSnapshot) -> Personalization { + func containerStyle(for snapshot: Core.Container.Snapshot) -> Personalization { let groupID = imageGroupID(containing: snapshot.image) return personalization.resolved(id: snapshot.id, image: snapshot.image, @@ -44,14 +44,14 @@ extension AppModel { } /// Containers that mount the named volume. Used by volume cards to aggregate I/O activity. - func containersMounting(volume name: String) -> [ContainerSnapshot] { + func containersMounting(volume name: String) -> [Core.Container.Snapshot] { containers.snapshots.filter { snapshot in snapshot.configuration.mounts.contains { $0.source == name } } } /// Current block read/write rate for a volume, summed across every container mounting it. - func volumeIORate(for name: String, metric: GraphMetric) -> Double { + func volumeIORate(for name: String, metric: Core.Metrics.GraphMetric) -> Double { containersMounting(volume: name).reduce(0) { total, snapshot in total + (containers.metricsState(for: snapshot.id).stats.map { metric.value(from: $0, snapshot: snapshot, normalization: statsNormalizationContext) @@ -60,7 +60,7 @@ extension AppModel { } /// Read/write sparkline series for a volume. Series are right-aligned so recent samples line up. - func volumeIOHistory(for name: String, metric: GraphMetric) -> [Double] { + func volumeIOHistory(for name: String, metric: Core.Metrics.GraphMetric) -> [Double] { let series = containersMounting(volume: name).compactMap { snapshot in containers.metricsState(for: snapshot.id).historyByMetric[metric]?.values } diff --git a/Sources/ContainedApp/App/AppModel.swift b/Sources/ContainedApp/App/AppModel.swift index c5e67357..0ca951bb 100644 --- a/Sources/ContainedApp/App/AppModel.swift +++ b/Sources/ContainedApp/App/AppModel.swift @@ -28,14 +28,14 @@ final class AppModel { let migrator = StateMigrator() let logger: AppLogger /// Shared with `AppModel+ImageUpdates.swift` (Swift extensions in other files need ≥ internal). - let manifestClient = RegistryManifestClient() + let manifestClient = Core.Registry.ManifestClient() private(set) var bootstrap: Bootstrap = .checking private(set) var client: Core.Orchestrator? /// Resolved path to the `container` binary — needed to spawn the terminal's `exec` process. private(set) var cliURL: URL? - private(set) var systemStatus: SystemStatus? - private(set) var diskUsage: DiskUsage? + private(set) var systemStatus: Core.System.Status? + private(set) var diskUsage: Core.System.DiskUsage? private(set) var cliVersion: String? @ObservationIgnored private var containerStatsVisible = true @ObservationIgnored private var containerStatsStreamTask: Task? @@ -45,22 +45,22 @@ final class AppModel { @ObservationIgnored let diagnosticLogger = Logger(subsystem: "app.contained.Contained", category: "diagnostic") // Resource caches shared by toolbar panels, creation pages, and the container grid. - private(set) var volumes: [VolumeResource] = [] - private(set) var networks: [NetworkResource] = [] - private(set) var registries: [RegistryLogin] = [] - private(set) var properties: SystemProperties? + private(set) var volumes: [Core.Volume.Resource] = [] + private(set) var networks: [Core.Network.Resource] = [] + private(set) var registries: [Core.Registry.Login] = [] + private(set) var properties: Core.System.Properties? // `images`/`imagesError`/`imageUpdates` are written by both this file and the image-update sweep // in `AppModel+ImageUpdates.swift`, so their setters can't be `private(set)`. - var images: [ContainedCore.ImageResource] = [] { + var images: [Core.Image.Resource] = [] { didSet { imageGroupsCache = nil imageGroupIDByReferenceCache.removeAll(keepingCapacity: true) } } - @ObservationIgnored var imageGroupsCache: [LocalImageTagGroup]? + @ObservationIgnored var imageGroupsCache: [Core.Image.LocalTagGroup]? @ObservationIgnored var imageGroupIDByReferenceCache: [String: String] = [:] var imagesError: String? - var imageUpdates: [String: ImageUpdateStatus] = [:] { + var imageUpdates: [String: Core.Image.UpdateStatus] = [:] { didSet { Self.saveImageUpdates(imageUpdates) } } /// Transient watchdog/crash banner text (auto-cleared). @@ -86,8 +86,8 @@ final class AppModel { var imageUpdateIntervalDescription: String { "Every \(settings.imageUpdateIntervalHours) hour\(settings.imageUpdateIntervalHours == 1 ? "" : "s")" } - var statsNormalizationContext: StatsNormalizationContext { - StatsNormalizationContext( + var statsNormalizationContext: Core.Metrics.NormalizationContext { + Core.Metrics.NormalizationContext( mode: settings.statsNormalizationMode, machineCPUs: properties?.machine?.cpus ?? ProcessInfo.processInfo.activeProcessorCount, machineMemoryBytes: Format.memoryBytes(fromSpec: properties?.machine?.memory) @@ -159,7 +159,7 @@ final class AppModel { containerID: snapshot.id) self.notifier.containerUnhealthy(name: name, enabled: settings.notifyOnCrash) // Hand off to the restart policy (once per unhealthy transition, so it can't spin). - let policy = RestartPolicy(label: snapshot.configuration.labels["contained.restart"]) + let policy = Core.Container.RestartPolicy(label: snapshot.configuration.labels["contained.restart"]) if policy != .no { Task { await self.containers.restart(snapshot.id) } } } } @@ -221,7 +221,7 @@ final class AppModel { if visible { coordinator.wake() } } - func setStatsNormalizationMode(_ mode: StatsNormalizationMode) { + func setStatsNormalizationMode(_ mode: Core.Metrics.NormalizationMode) { guard settings.statsNormalizationMode != mode else { return } settings.statsNormalizationMode = mode applyStatsNormalizationContext() @@ -312,7 +312,7 @@ final class AppModel { } /// Run a throwing CLI action, returning a user-facing error string on failure (nil on success). - /// Collapses the repeated `do / catch CommandError / catch` blocks across the stores and sheets. + /// Collapses the repeated `do / catch Core.Command.Error / catch` blocks across the stores and sheets. func captured(_ work: () async throws -> Void) async -> String? { await capturedError(work)?.appDisplayMessage } @@ -322,7 +322,7 @@ final class AppModel { ?? spec.arguments() } - func imageDefaults(for spec: RunSpec) -> ContainerImageDefaults? { + func imageDefaults(for spec: RunSpec) -> Core.Container.ImageDefaults? { guard let client = core(for: spec.effectiveRuntimeKind) else { return nil } return try? client.imageDefaults(for: spec.createRequest, in: images) } diff --git a/Sources/ContainedApp/App/ContainedApp.swift b/Sources/ContainedApp/App/ContainedApp.swift index f2495170..79e861b0 100644 --- a/Sources/ContainedApp/App/ContainedApp.swift +++ b/Sources/ContainedApp/App/ContainedApp.swift @@ -39,8 +39,8 @@ public struct ContainedApplication: App { Button("Check for Updates…") { app.updater.checkForUpdates() } .disabled(!app.updater.canCheckForUpdates) } - // Settings now lives in the toolbar morph panel (no separate Settings window), so ⌘, - // opens that instead of the standard Settings scene. + // Route Settings through the app shell so the keyboard shortcut and toolbar panel + // open the same surface. CommandGroup(replacing: .appSettings) { Button("Settings…") { openSettings() } .keyboardShortcutIfEnabled(app.settings.keyboardShortcutsEnabled, diff --git a/Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift b/Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift index 5b756350..4417356b 100644 --- a/Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift +++ b/Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift @@ -23,7 +23,7 @@ enum HistoryRange: String, CaseIterable, Identifiable { /// log — the long-term counterpart to the live sparklines. Backed by SwiftData via `@Query`. struct ContainerHistoryTab: View { @Environment(AppModel.self) private var app - let snapshot: ContainerSnapshot + let snapshot: Core.Container.Snapshot @State private var range: HistoryRange = .day /// Window start, recomputed only when the range changes (not per render) so the windowed `@Query` /// inside `ContainerHistoryWindow` isn't rebuilt on every layout pass. @@ -51,12 +51,12 @@ struct ContainerHistoryTab: View { /// into the SwiftData `@Query` predicates, so only the visible range is fetched — not the container's /// entire retained history (which an unbounded query then re-filtered on every render). private struct ContainerHistoryWindow: View { - private let snapshot: ContainerSnapshot - private let normalization: StatsNormalizationContext + private let snapshot: Core.Container.Snapshot + private let normalization: Core.Metrics.NormalizationContext @Query private var samples: [MetricSample] @Query private var events: [EventRecord] - init(snapshot: ContainerSnapshot, cutoff: Date, normalization: StatsNormalizationContext) { + init(snapshot: Core.Container.Snapshot, cutoff: Date, normalization: Core.Metrics.NormalizationContext) { self.snapshot = snapshot self.normalization = normalization let containerID = snapshot.id @@ -154,8 +154,8 @@ struct HistoryChartPoint: Identifiable, Equatable { let netTxKBPerSec: Double static func points(from samples: [MetricSampleSnapshot], - snapshot: ContainerSnapshot, - normalization: StatsNormalizationContext) -> [HistoryChartPoint] { + snapshot: Core.Container.Snapshot, + normalization: Core.Metrics.NormalizationContext) -> [HistoryChartPoint] { guard !samples.isEmpty else { return [] } let memoryFallbackBytes = samples.reduce(UInt64(0)) { current, sample in diff --git a/Sources/ContainedApp/Features/Containers/Card/ContainerCard.swift b/Sources/ContainedApp/Features/Containers/Card/ContainerCard.swift index e4946199..f1846694 100644 --- a/Sources/ContainedApp/Features/Containers/Card/ContainerCard.swift +++ b/Sources/ContainedApp/Features/Containers/Card/ContainerCard.swift @@ -5,15 +5,15 @@ import ContainedCore /// A personalized clear-glass card for one container. The same component renders both the compact /// grid card and the centered expanded detail card. struct ContainerCard: View { - let snapshot: ContainerSnapshot + let snapshot: Core.Container.Snapshot var style: Personalization var hasStyleOverride: Bool = true var density: UI.Card.Density - var stats: StatsDelta? - var statsNormalization: StatsNormalizationContext = .containerSpecific + var stats: Core.Metrics.StatsDelta? + var statsNormalization: Core.Metrics.NormalizationContext = .containerSpecific /// Every metric's recent history, so the footer's widget chips can flip the graph instantly /// without borrowing another metric's samples. - var histories: [GraphMetric: UI.Chart.SampleBuffer] = [:] + var histories: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer] = [:] var isBusy: Bool var hasImageUpdate: Bool = false var isExpanded: Bool = false @@ -33,7 +33,7 @@ struct ContainerCard: View { var onToggleSelected: () -> Void = {} var onEndSelecting: () -> Void = {} /// App-managed healthcheck status (drives the heart badge). - var health: HealthStatus = .unknown + var health: Core.Container.HealthStatus = .unknown /// Multi-select mode: tapping toggles selection instead of opening the detail. var selecting: Bool = false var isSelected: Bool = false @@ -81,10 +81,10 @@ struct ContainerCard: View { } private var activeWidget: WidgetConfiguration { styleForDisplay.widget(at: activeWidgetIndex) } private var activeWidgetColor: Color { activeWidget.tint?.color ?? tint } - private var activeWidgetComparisonMetric: GraphMetric? { + private var activeWidgetComparisonMetric: Core.Metrics.GraphMetric? { activeWidget.style.resolvedSecondaryMetric(primary: activeWidget.metric, requested: activeWidget.secondaryMetric, - options: GraphMetric.allCases) + options: Core.Metrics.GraphMetric.allCases) } private var cardSize: UI.Card.Size { density.resourceSize } @@ -370,7 +370,7 @@ struct ContainerCard: View { } } - private func sparklineScale(for metric: GraphMetric) -> UI.Chart.Scale { + private func sparklineScale(for metric: Core.Metrics.GraphMetric) -> UI.Chart.Scale { switch metric { case .cpu, .memory: return .fraction case .netRx, .netTx, .diskRead, .diskWrite: return .normalized diff --git a/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift b/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift index a40411f3..0bd9f121 100644 --- a/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift +++ b/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift @@ -12,8 +12,8 @@ struct ContainersGridView: View { @Environment(UIState.self) private var ui @Environment(\.morphSafeAreaManager) private var safeAreaManager - @State private var detail: ContainerSnapshot? - @State private var deleting: ContainerSnapshot? + @State private var detail: Core.Container.Snapshot? + @State private var deleting: Core.Container.Snapshot? @State private var selecting = false @State private var selection: Set = [] /// Drives the in-place grow: false = card sits in its grid slot, true = promoted to the centered @@ -26,7 +26,7 @@ struct ContainersGridView: View { // Each network is a collapsible section of the containers attached to it. @State private var collapsedNetworks: Set = [] - @State private var deletingNetwork: NetworkResource? + @State private var deletingNetwork: Core.Network.Resource? private let detailSpring = Animation.spring(response: 0.42, dampingFraction: 0.86) @@ -37,14 +37,14 @@ struct ContainersGridView: View { private struct ContainerGroup: Identifiable { let name: String let symbol: String - let resource: NetworkResource? - let containers: [ContainerSnapshot] + let resource: Core.Network.Resource? + let containers: [Core.Container.Snapshot] let isBuiltin: Bool var id: String { name } } /// The network names a container is attached to (requested config ∪ runtime status). - private func networkNames(_ snapshot: ContainerSnapshot) -> [String] { + private func networkNames(_ snapshot: Core.Container.Snapshot) -> [String] { let names = snapshot.configuration.networks.map(\.network) + snapshot.status.networks.map(\.network) return Array(Set(names)).sorted() } @@ -65,7 +65,7 @@ struct ContainersGridView: View { let byNetworkName = Dictionary(app.networks.map { ($0.name, $0) }, uniquingKeysWith: { a, _ in a }) let defaultName = app.networks.first { $0.isBuiltin }?.name ?? "default" - var buckets: [String: [ContainerSnapshot]] = [:] + var buckets: [String: [Core.Container.Snapshot]] = [:] for network in app.networks { buckets[network.name] = [] } buckets[defaultName, default: []] = buckets[defaultName] ?? [] @@ -91,7 +91,7 @@ struct ContainersGridView: View { private var volumeGroups: [ContainerGroup] { let noVolume = "No volume" - var buckets: [String: [ContainerSnapshot]] = [:] + var buckets: [String: [Core.Container.Snapshot]] = [:] for snapshot in filtered { let volumes = Set(snapshot.configuration.mounts.compactMap { mount -> String? in guard let source = mount.source, !source.isEmpty else { return nil } @@ -114,7 +114,7 @@ struct ContainersGridView: View { } private var imageGroups: [ContainerGroup] { - var buckets: [String: [ContainerSnapshot]] = [:] + var buckets: [String: [Core.Container.Snapshot]] = [:] for snapshot in filtered { buckets[Format.shortImage(snapshot.image), default: []].append(snapshot) } @@ -126,7 +126,7 @@ struct ContainersGridView: View { } /// Order a bucket of containers by the chosen sort. - private func sorted(_ containers: [ContainerSnapshot]) -> [ContainerSnapshot] { + private func sorted(_ containers: [Core.Container.Snapshot]) -> [Core.Container.Snapshot] { switch ui.sort { case .name: return containers.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } @@ -150,7 +150,7 @@ struct ContainersGridView: View { spacing: UI.Layout.Spacing.m)] } - private var filtered: [ContainerSnapshot] { + private var filtered: [Core.Container.Snapshot] { store.snapshots.filter { snapshot in (!ui.runningOnly || snapshot.state == .running) && (ui.search.text.isEmpty @@ -287,7 +287,7 @@ struct ContainersGridView: View { } @ViewBuilder - private func networkMenu(_ resource: NetworkResource) -> some View { + private func networkMenu(_ resource: Core.Network.Resource) -> some View { Button { copyToPasteboard(resource.name) } label: { Label("Copy Name", systemImage: "doc.on.doc") } if !resource.isBuiltin { Divider() @@ -308,15 +308,15 @@ struct ContainersGridView: View { Binding(get: { deletingNetwork != nil }, set: { if !$0 { deletingNetwork = nil } }) } - private func deleteNetwork(_ network: NetworkResource) async { + private func deleteNetwork(_ network: Core.Network.Resource) async { guard let client = app.client else { return } do { _ = try await client.deleteNetworks([network.name]); await app.refreshNetworks() } - catch let error as CommandError { app.flash(error.appDisplayMessage) } + catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } @ViewBuilder - private func gridCard(_ snapshot: ContainerSnapshot) -> some View { + private func gridCard(_ snapshot: Core.Container.Snapshot) -> some View { let selected = detail?.id == snapshot.id compactCard(snapshot) // Stays laid out (so the slot is reserved and its frame keeps publishing) but invisible @@ -342,13 +342,13 @@ struct ContainersGridView: View { cardFrames[id] = frame } - private func compactCard(_ snapshot: ContainerSnapshot) -> some View { + private func compactCard(_ snapshot: Core.Container.Snapshot) -> some View { containerCard(snapshot, isExpanded: false) { selecting ? toggle(snapshot.id) : openDetail(snapshot) } } - private func expandedCard(_ snapshot: ContainerSnapshot) -> some View { + private func expandedCard(_ snapshot: Core.Container.Snapshot) -> some View { // `controlsVisible: expanded` so the footer buttons + close fade out as soon as a close // starts (expanded → false), finishing before the shrink animation does. containerCard(snapshot, @@ -357,7 +357,7 @@ struct ContainersGridView: View { controlsVisible: expanded) {} } - private func containerCard(_ snapshot: ContainerSnapshot, isExpanded: Bool, + private func containerCard(_ snapshot: Core.Container.Snapshot, isExpanded: Bool, cornerRadiusOverride: CGFloat? = nil, controlsVisible: Bool = true, onTap: @escaping () -> Void) -> some View { @@ -430,7 +430,7 @@ struct ContainersGridView: View { return CGSize(width: width, height: height) } - private func openDetail(_ snapshot: ContainerSnapshot) { + private func openDetail(_ snapshot: Core.Container.Snapshot) { // Render the card at its slot first (expanded == false), then spring it open on the next // runloop so the grow has a real starting frame to animate from. detail = snapshot @@ -486,7 +486,7 @@ struct ContainersGridView: View { } } - private func updateContainer(_ snapshot: ContainerSnapshot) { + private func updateContainer(_ snapshot: Core.Container.Snapshot) { Task { if await app.pullImageUpdate(snapshot.image) { ui.openCreationPanel(editing: snapshot) @@ -494,7 +494,7 @@ struct ContainersGridView: View { } } - private func customizeName(_ snapshot: ContainerSnapshot?) -> String { + private func customizeName(_ snapshot: Core.Container.Snapshot?) -> String { guard let snapshot else { return "" } return app.containerStyle(for: snapshot) .displayName(fallback: snapshot.id) @@ -520,11 +520,11 @@ struct ContainersGridView: View { private struct ContainerCardMetricsRenderer: View { let metrics: ContainerMetricsState - let snapshot: ContainerSnapshot + let snapshot: Core.Container.Snapshot let style: Personalization let hasStyleOverride: Bool let density: UI.Card.Density - let statsNormalization: StatsNormalizationContext + let statsNormalization: Core.Metrics.NormalizationContext let selectedWidgetIndex: Binding let isBusy: Bool let hasImageUpdate: Bool @@ -542,7 +542,7 @@ private struct ContainerCardMetricsRenderer: View { let onSelectMultiple: () -> Void let onToggleSelected: () -> Void let onEndSelecting: () -> Void - let health: HealthStatus + let health: Core.Container.HealthStatus let selecting: Bool let isSelected: Bool diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift index 9e3550ba..de2ef8e3 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift @@ -36,7 +36,7 @@ struct CreationFlow: View { @Query(sort: \Template.createdAt, order: .reverse) private var saved: [Template] let start: Start - let editSnapshot: ContainerSnapshot? + let editSnapshot: Core.Container.Snapshot? /// Close the host (dismiss the sheet / collapse the morph panel). var onClose: () -> Void var onSoftDismissChange: (((() -> Void)?) -> Void)? @@ -90,7 +90,7 @@ struct CreationFlow: View { init(start: Start, onClose: @escaping () -> Void, prefill: RunSpec? = nil, - editSnapshot: ContainerSnapshot? = nil, + editSnapshot: Core.Container.Snapshot? = nil, searchQuery: String = "", returnEntry: UIState.CreationEntry? = nil, onSoftDismissChange: (((() -> Void)?) -> Void)? = nil) { diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift index c0a1dcbc..232002e4 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift @@ -150,7 +150,7 @@ struct CreationLocalImagesContent: View { .task { await app.refreshImagesIfNeeded() } } - private var filteredLocalImages: [ContainedCore.ImageResource] { + private var filteredLocalImages: [Core.Image.Resource] { let images = app.images .filter { $0.variants.contains(where: \.isRunnable) || $0.variants.isEmpty } .sorted { $0.reference.localizedCaseInsensitiveCompare($1.reference) == .orderedAscending } @@ -274,7 +274,7 @@ private struct CreationResourceForm: View { } private struct CreationLocalImageRow: View { - let image: ContainedCore.ImageResource + let image: Core.Image.Resource var onSelect: () -> Void var body: some View { diff --git a/Sources/ContainedApp/Features/Containers/Customization/ContainerCustomizeButton.swift b/Sources/ContainedApp/Features/Containers/Customization/ContainerCustomizeButton.swift index 73928ddf..5e5ba53b 100644 --- a/Sources/ContainedApp/Features/Containers/Customization/ContainerCustomizeButton.swift +++ b/Sources/ContainedApp/Features/Containers/Customization/ContainerCustomizeButton.swift @@ -6,7 +6,7 @@ import ContainedCore /// popover is owned by `ContainerCard` and anchored to the whole card (not this chip), so the live /// card itself serves as the preview — this button just triggers it. struct ContainerCustomizeButton: View { - let snapshot: ContainerSnapshot + let snapshot: Core.Container.Snapshot let style: Personalization var onTap: () -> Void diff --git a/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift b/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift index 2ed34206..55bede31 100644 --- a/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift +++ b/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift @@ -11,7 +11,7 @@ struct CustomizeSheet: View { } enum Target: Identifiable, Hashable { - case container(ContainerSnapshot) + case container(Core.Container.Snapshot) case image(reference: String) case imageGroup(id: String, reference: String) case imageTag(reference: String, groupID: String?) @@ -51,7 +51,7 @@ struct CustomizeSheet: View { } } - var previewSnapshot: ContainerSnapshot { + var previewSnapshot: Core.Container.Snapshot { switch self { case .container(let snapshot): return snapshot case .image(let reference), .imageGroup(_, let reference), .imageTag(let reference, _): @@ -73,7 +73,7 @@ struct CustomizeSheet: View { @State private var overridesInheritedStyle: Bool @State private var loaded: Bool - init(snapshot: ContainerSnapshot, presentation: Presentation = .popover) { + init(snapshot: Core.Container.Snapshot, presentation: Presentation = .popover) { self.init(snapshot: snapshot, presentation: presentation, initialStyle: nil, @@ -81,7 +81,7 @@ struct CustomizeSheet: View { onDraftChange: nil) } - init(snapshot: ContainerSnapshot, + init(snapshot: Core.Container.Snapshot, presentation: Presentation = .popover, initialStyle: Personalization? = nil, initiallyOverridesInheritedStyle: Bool? = nil, @@ -207,7 +207,7 @@ struct CustomizeSheet: View { } UI.Panel.ToggleRow(title: AppText.string("customize.gradient", defaultValue: "Gradient"), isOn: $style.gradient) if style.gradient { - GradientAngleControl(angle: $style.gradientAngle, title: AppText.direction) + UI.Control.GradientAngle(angle: $style.gradientAngle, title: AppText.direction) } UI.Panel.Row(title: AppText.string("customize.blendMode", defaultValue: "Blend mode")) { Picker("", selection: $style.backgroundBlendMode) { @@ -247,10 +247,10 @@ struct CustomizeSheet: View { } } - private static let volumeMetrics: [GraphMetric] = [.diskRead, .diskWrite] - private var graphOptions: [GraphMetric] { + private static let volumeMetrics: [Core.Metrics.GraphMetric] = [.diskRead, .diskWrite] + private var graphOptions: [Core.Metrics.GraphMetric] { if case .volume = target { return Self.volumeMetrics } - return GraphMetric.allCases + return Core.Metrics.GraphMetric.allCases } private var headerTitle: String { diff --git a/Sources/ContainedApp/Features/Containers/Customization/CustomizeWidgetsPanel.swift b/Sources/ContainedApp/Features/Containers/Customization/CustomizeWidgetsPanel.swift index bee5fab1..cb22f3cd 100644 --- a/Sources/ContainedApp/Features/Containers/Customization/CustomizeWidgetsPanel.swift +++ b/Sources/ContainedApp/Features/Containers/Customization/CustomizeWidgetsPanel.swift @@ -6,7 +6,7 @@ import ContainedCore /// workflow while this view owns per-widget ordering, metric, and chart editing. struct CustomizeWidgetsPanel: View { @Binding var style: Personalization - let graphOptions: [GraphMetric] + let graphOptions: [Core.Metrics.GraphMetric] let settingsDisabled: Bool private var activeWidgetIndices: [Int] { @@ -202,7 +202,7 @@ struct CustomizeWidgetsPanel: View { return AppText.string("customize.widget.title", defaultValue: "Widget \(position + 1)") } - private func graphLabel(_ metric: GraphMetric) -> String { + private func graphLabel(_ metric: Core.Metrics.GraphMetric) -> String { guard graphOptions.allSatisfy({ $0 == .diskRead || $0 == .diskWrite }) else { return metric.displayName } @@ -239,7 +239,7 @@ struct CustomizeWidgetsPanel: View { style.widgets.swapAt(index, indices[targetPosition]) } - private func nextWidgetMetric() -> GraphMetric { + private func nextWidgetMetric() -> Core.Metrics.GraphMetric { let activeMetrics = Set(activeWidgetIndices.map { style.widget(at: $0).metric }) return graphOptions.first { !activeMetrics.contains($0) } ?? graphOptions.first ?? .cpu } @@ -248,7 +248,7 @@ struct CustomizeWidgetsPanel: View { style.widget(at: index).style } - private func secondaryMetricFallback(for index: Int) -> GraphMetric? { + private func secondaryMetricFallback(for index: Int) -> Core.Metrics.GraphMetric? { let widget = style.widget(at: index) return widget.style.resolvedSecondaryMetric(primary: widget.metric, requested: widget.secondaryMetric, @@ -266,7 +266,7 @@ struct CustomizeWidgetsPanel: View { } } - private func widgetMetricBinding(_ index: Int) -> Binding { + private func widgetMetricBinding(_ index: Int) -> Binding { Binding { style.widget(at: index).metric } set: { newValue in @@ -294,7 +294,7 @@ struct CustomizeWidgetsPanel: View { } } - private func widgetSecondaryMetricBinding(_ index: Int, fallback: GraphMetric) -> Binding { + private func widgetSecondaryMetricBinding(_ index: Int, fallback: Core.Metrics.GraphMetric) -> Binding { Binding { let widget = style.widget(at: index) return widget.style.resolvedSecondaryMetric(primary: widget.metric, diff --git a/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift index ae71b871..bb89a615 100644 --- a/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift @@ -188,7 +188,7 @@ struct ContainerConfigureView: View { case .edit(let snapshot, _): // Pull the current style + healthcheck from the local stores so edits start from what's set. spec.personalization = app.containerStyle(for: snapshot) - spec.healthCheck = app.healthChecks.check(for: snapshot.id) ?? HealthCheck() + spec.healthCheck = app.healthChecks.check(for: snapshot.id) ?? Core.Container.HealthCheck() } } diff --git a/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift index a42b50ea..669d2f90 100644 --- a/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift @@ -9,7 +9,7 @@ import ContainedCore struct ContainerEditSheet: View { enum Mode { case new(prefill: RunSpec?) - case edit(ContainerSnapshot, onComplete: () -> Void) + case edit(Core.Container.Snapshot, onComplete: () -> Void) } @Environment(\.dismiss) private var dismiss diff --git a/Sources/ContainedApp/Features/Containers/Details/ContainerOverviewTab.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerOverviewTab.swift index fabeb460..cae5e2f5 100644 --- a/Sources/ContainedApp/Features/Containers/Details/ContainerOverviewTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerOverviewTab.swift @@ -5,8 +5,8 @@ import ContainedCore /// The Overview tab of the container detail: grouped, read-only configuration (general, resources, /// ports, mounts, environment, labels). struct ContainerOverviewTab: View { - let snapshot: ContainerSnapshot - private var config: ContainerConfiguration { snapshot.configuration } + let snapshot: Core.Container.Snapshot + private var config: Core.Container.Configuration { snapshot.configuration } var body: some View { ContainerTabScaffold { diff --git a/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift b/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift index ce4d0d7b..b8824d70 100644 --- a/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift @@ -7,7 +7,7 @@ import ContainedCore /// `container cp`. AppKit bridge (flagged): `NSOpenPanel`/`NSSavePanel` for host file selection. struct FilesTab: View { @Environment(AppModel.self) private var app - let snapshot: ContainerSnapshot + let snapshot: Core.Container.Snapshot @State private var path = "/" @State private var entries: [String] = [] @@ -96,7 +96,7 @@ struct FilesTab: View { entries = out.split(separator: "\n").map(String.init) .filter { $0 != "./" && $0 != "../" && !$0.isEmpty } .sorted { ($0.hasSuffix("/") ? 0 : 1, $0.lowercased()) < ($1.hasSuffix("/") ? 0 : 1, $1.lowercased()) } - } catch let e as CommandError { error = e.appDisplayMessage } + } catch let e as Core.Command.Error { error = e.appDisplayMessage } catch { self.error = error.appDisplayMessage } } @@ -120,7 +120,7 @@ struct FilesTab: View { do { _ = try await app.client?.copy(source: "\(snapshot.id):\(joined(name))", destination: dest.path) app.flash(AppText.copiedFileToHost(name)) - } catch let e as CommandError { app.flash(e.appDisplayMessage) } + } catch let e as Core.Command.Error { app.flash(e.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } } @@ -138,7 +138,7 @@ struct FilesTab: View { destination: "\(snapshot.id):\(joined(src.lastPathComponent))") app.flash(AppText.copiedFileIntoContainer(src.lastPathComponent)) await load() - } catch let e as CommandError { app.flash(e.appDisplayMessage) } + } catch let e as Core.Command.Error { app.flash(e.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } } diff --git a/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift b/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift index 9b0655a6..9da15886 100644 --- a/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift @@ -6,7 +6,7 @@ import ContainedCore /// (`.task(id:)`), so leaving the tab cancels it and terminates the child process (SIGTERM). struct LogsTab: View { @Environment(AppModel.self) private var app - let snapshot: ContainerSnapshot + let snapshot: Core.Container.Snapshot @State private var lines: [String] = [] @State private var carry = "" @@ -101,7 +101,7 @@ struct LogsTab: View { } catch is CancellationError { // Expected on tab/container switch — the child process is terminated for us. } catch { - failed = (error as? CommandError)?.appDisplayMessage ?? error.appDisplayMessage + failed = (error as? Core.Command.Error)?.appDisplayMessage ?? error.appDisplayMessage } } diff --git a/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift b/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift index 0fdf5359..c5a2363b 100644 --- a/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift @@ -6,14 +6,14 @@ import ContainedCore /// into `ContainersStore` (so there's no second polling loop), and renders a tile per metric. struct StatsTab: View { @Environment(AppModel.self) private var app - let snapshot: ContainerSnapshot + let snapshot: Core.Container.Snapshot @State private var processes: String = "" private var metrics: ContainerMetricsState { app.containers.metricsState(for: snapshot.id) } - private var delta: StatsDelta? { metrics.stats } - private var history: [GraphMetric: UI.Chart.SampleBuffer] { metrics.historyByMetric } - private var normalization: StatsNormalizationContext { app.statsNormalizationContext } + private var delta: Core.Metrics.StatsDelta? { metrics.stats } + private var history: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer] { metrics.historyByMetric } + private var normalization: Core.Metrics.NormalizationContext { app.statsNormalizationContext } private var tint: Color { app.containerStyle(for: snapshot).color } @@ -77,7 +77,7 @@ struct StatsTab: View { await loadProcesses() } - private func tile(_ metric: GraphMetric, _ delta: StatsDelta, _ symbol: String) -> some View { + private func tile(_ metric: Core.Metrics.GraphMetric, _ delta: Core.Metrics.StatsDelta, _ symbol: String) -> some View { UI.Chart.MetricTile(label: metric.displayName, value: metric.caption(from: delta, snapshot: snapshot, normalization: normalization), systemImage: symbol, @@ -86,12 +86,12 @@ struct StatsTab: View { sparklineScale: sparklineScale(for: metric)) } - private func memoryTile(_ delta: StatsDelta) -> some View { - let memoryLimit = GraphMetric.memoryLimitBytes(for: delta, + private func memoryTile(_ delta: Core.Metrics.StatsDelta) -> some View { + let memoryLimit = Core.Metrics.GraphMetric.memoryLimitBytes(for: delta, snapshot: snapshot, normalization: normalization) return UI.Chart.MetricTile(label: AppText.string("stats.memory.detail", defaultValue: "Memory \(Format.bytes(delta.memoryUsageBytes)) / \(Format.bytes(memoryLimit))"), - value: GraphMetric.memory.caption(from: delta, + value: Core.Metrics.GraphMetric.memory.caption(from: delta, snapshot: snapshot, normalization: normalization), systemImage: "memorychip", @@ -100,7 +100,7 @@ struct StatsTab: View { sparklineScale: sparklineScale(for: .memory)) } - private func sparklineScale(for metric: GraphMetric) -> UI.Chart.Scale { + private func sparklineScale(for metric: Core.Metrics.GraphMetric) -> UI.Chart.Scale { switch metric { case .cpu, .memory: return .fraction case .netRx, .netTx, .diskRead, .diskWrite: return .normalized diff --git a/Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift b/Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift index 9387e656..72e17262 100644 --- a/Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift @@ -13,7 +13,7 @@ import Darwin /// in the plan. Only the terminal surface touches AppKit; everything around it stays SwiftUI. struct TerminalTab: View { @Environment(AppModel.self) private var app - let snapshot: ContainerSnapshot + let snapshot: Core.Container.Snapshot /// A finished session's exit code (boxed so `nil`-the-state differs from a `nil` exit code). private struct Ended: Equatable { let code: Int32? } diff --git a/Sources/ContainedApp/Features/Containers/Form/RunSpec.swift b/Sources/ContainedApp/Features/Containers/Form/RunSpec.swift index e7f42a04..5da4f3ef 100644 --- a/Sources/ContainedApp/Features/Containers/Form/RunSpec.swift +++ b/Sources/ContainedApp/Features/Containers/Form/RunSpec.swift @@ -1,9 +1,6 @@ import Foundation import ContainedCore -// `RestartPolicy` now lives in ContainedCore (Models/RestartPolicy.swift) so the watchdog's -// decision logic can be unit-tested without the app target. - /// An editable key/value row (env vars, labels). struct KeyValue: Identifiable, Hashable, Codable { let id = UUID() @@ -52,7 +49,7 @@ struct SocketMap: Identifiable, Hashable, Codable { } /// The complete app-owned state of the Create/Run form. Runtime adapters translate the derived -/// `ContainerCreateRequest` into backend-specific commands. +/// `Core.Container.CreateRequest` into backend-specific commands. struct RunSpec: Codable { var runtimeKind: Core.Runtime.Kind? = .appleContainer var image = "" @@ -78,7 +75,7 @@ struct RunSpec: Codable { var rosetta = false var ssh = false var virtualization = false - var restart: RestartPolicy = .no + var restart: Core.Container.RestartPolicy = .no // Advanced (all optional; empty entries are skipped when building argv). var workingDir = "" // -w @@ -108,7 +105,7 @@ struct RunSpec: Codable { // just the form's working copy, persisted by the sheet after a successful create/save. var personalization = Personalization() // App-managed healthcheck — also stored locally (HealthCheckStore), not as labels. - var healthCheck = HealthCheck() + var healthCheck = Core.Container.HealthCheck() var validationMessages: [String] { var messages: [String] = [] @@ -170,7 +167,7 @@ struct RunSpec: Codable { /// (container config is immutable, so "editing" means delete + re-run from this spec). /// Best-effort: reproduces the reversible run flags; the image's baked-in command is kept by /// re-passing the recorded arguments. - init(from config: ContainerConfiguration) { + init(from config: Core.Container.Configuration) { image = config.image.reference platform = config.platform.display name = config.id @@ -216,7 +213,7 @@ struct RunSpec: Codable { .filter { !$0.key.hasPrefix("contained.") } .sorted { $0.key < $1.key } .map { KeyValue(key: $0.key, value: $0.value) } - restart = RestartPolicy(label: config.labels["contained.restart"]) + restart = Core.Container.RestartPolicy(label: config.labels["contained.restart"]) // Personalization is resolved from the local store by the edit sheet, not from labels. } @@ -225,8 +222,8 @@ struct RunSpec: Codable { Core.Command.runPreview(for: createRequest) } - var createRequest: ContainerCreateRequest { - var request = ContainerCreateRequest() + var createRequest: Core.Container.CreateRequest { + var request = Core.Container.CreateRequest() request.runtimeKind = effectiveRuntimeKind request.image = image request.platform = platform @@ -239,13 +236,13 @@ struct RunSpec: Codable { request.tty = tty request.cpus = cpus request.memory = memory - request.env = env.map { ContainerCreateKeyValue(key: $0.key, value: $0.value) } + request.env = env.map { Core.Container.KeyValue(key: $0.key, value: $0.value) } request.envFiles = envFiles - request.ports = ports.map { ContainerCreatePort(hostPort: $0.hostPort, containerPort: $0.containerPort, proto: $0.proto) } - request.volumes = volumes.map { ContainerCreateVolume(source: $0.source, target: $0.target, readOnly: $0.readOnly) } + request.ports = ports.map { Core.Container.Port(hostPort: $0.hostPort, containerPort: $0.containerPort, proto: $0.proto) } + request.volumes = volumes.map { Core.Container.VolumeMount(source: $0.source, target: $0.target, readOnly: $0.readOnly) } request.mounts = mounts - request.sockets = sockets.map { ContainerCreateSocket(hostPath: $0.hostPath, containerPath: $0.containerPath) } - request.labels = labels.map { ContainerCreateKeyValue(key: $0.key, value: $0.value) } + request.sockets = sockets.map { Core.Container.Socket(hostPath: $0.hostPath, containerPath: $0.containerPath) } + request.labels = labels.map { Core.Container.KeyValue(key: $0.key, value: $0.value) } request.restart = restart request.readOnly = readOnly request.useInit = useInit @@ -277,7 +274,7 @@ struct RunSpec: Codable { return request } - init(request: ContainerCreateRequest, healthCheck: HealthCheck? = nil) { + init(request: Core.Container.CreateRequest, healthCheck: Core.Container.HealthCheck? = nil) { runtimeKind = request.runtimeKind image = request.image platform = request.platform diff --git a/Sources/ContainedApp/Features/Containers/Form/RunSpecForm.swift b/Sources/ContainedApp/Features/Containers/Form/RunSpecForm.swift index f35fa8c2..433da6bf 100644 --- a/Sources/ContainedApp/Features/Containers/Form/RunSpecForm.swift +++ b/Sources/ContainedApp/Features/Containers/Form/RunSpecForm.swift @@ -164,7 +164,7 @@ struct RunSpecForm: View { set: { if $0 != "custom" { spec.platform = $0 } }) } - private var imageDefaults: ContainerImageDefaults? { + private var imageDefaults: Core.Container.ImageDefaults? { app.imageDefaults(for: spec) } @@ -302,7 +302,7 @@ struct RunSpecForm: View { info: AppText.string("runSpec.gradient.info", defaultValue: "Blends the color across the card instead of using one flat wash."), isOn: $spec.personalization.gradient) if spec.personalization.gradient { - GradientAngleControl(angle: $spec.personalization.gradientAngle, title: AppText.direction) + UI.Control.GradientAngle(angle: $spec.personalization.gradientAngle, title: AppText.direction) } UI.Panel.Row(title: AppText.string("runSpec.blendMode", defaultValue: "Blend mode"), info: AppText.string("runSpec.blendMode.info", defaultValue: "Controls how the card color wash blends with the glass behind it.")) { @@ -322,7 +322,7 @@ struct RunSpecForm: View { UI.Panel.Row(title: AppText.string("runSpec.restartPolicy", defaultValue: "Restart policy"), info: AppText.string("runSpec.restartPolicy.info", defaultValue: "Contained restarts the container automatically based on this setting.")) { Picker("", selection: $spec.restart) { - ForEach(RestartPolicy.allCases) { Text($0.localizedDisplayName).tag($0) } + ForEach(Core.Container.RestartPolicy.allCases) { Text($0.localizedDisplayName).tag($0) } } .labelsHidden().fixedSize() } diff --git a/Sources/ContainedApp/Features/Containers/Form/RunSpecFormSupport.swift b/Sources/ContainedApp/Features/Containers/Form/RunSpecFormSupport.swift index f4fd70bb..ba1e7158 100644 --- a/Sources/ContainedApp/Features/Containers/Form/RunSpecFormSupport.swift +++ b/Sources/ContainedApp/Features/Containers/Form/RunSpecFormSupport.swift @@ -45,7 +45,7 @@ extension RunSpec { } @discardableResult - mutating func adoptImageDefaults(from defaults: ContainerImageDefaults) -> Int { + mutating func adoptImageDefaults(from defaults: Core.Container.ImageDefaults) -> Int { var applied = 0 if command.trimmingCharacters(in: .whitespaces).isEmpty, !defaults.command.isEmpty { let cmd = defaults.command diff --git a/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift b/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift index b14cc0e0..28625116 100644 --- a/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift +++ b/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift @@ -10,21 +10,21 @@ import ContainedCore @Observable final class ContainerMetricsState { let id: String - var stats: StatsDelta? - var historyByMetric: [GraphMetric: UI.Chart.SampleBuffer] + var stats: Core.Metrics.StatsDelta? + var historyByMetric: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer] private(set) var revision = 0 - init(id: String, stats: StatsDelta? = nil, historyByMetric: [GraphMetric: UI.Chart.SampleBuffer] = [:]) { + init(id: String, stats: Core.Metrics.StatsDelta? = nil, historyByMetric: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer] = [:]) { self.id = id self.stats = stats self.historyByMetric = historyByMetric } - func values(for metric: GraphMetric) -> [Double] { + func values(for metric: Core.Metrics.GraphMetric) -> [Double] { historyByMetric[metric]?.values ?? [] } - func update(stats: StatsDelta?, historyByMetric: [GraphMetric: UI.Chart.SampleBuffer]) { + func update(stats: Core.Metrics.StatsDelta?, historyByMetric: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer]) { var changed = false if self.stats != stats { self.stats = stats @@ -43,12 +43,12 @@ final class ContainerMetricsState { final class ContainersStore { private static let minimumStreamedStatsInterval: TimeInterval = 1 - var snapshots: [ContainerSnapshot] = [] + var snapshots: [Core.Container.Snapshot] = [] @ObservationIgnored - var statsByID: [String: StatsDelta] = [:] + var statsByID: [String: Core.Metrics.StatsDelta] = [:] /// Per-container, per-metric sparkline history. @ObservationIgnored - var historyByID: [String: [GraphMetric: UI.Chart.SampleBuffer]] = [:] + var historyByID: [String: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer]] = [:] @ObservationIgnored private(set) var statsRevision = 0 var errorMessage: String? @@ -56,11 +56,11 @@ final class ContainersStore { @ObservationIgnored var logger: AppLogger? @ObservationIgnored var now: () -> Date = Date.init @ObservationIgnored private var metricsStates: [String: ContainerMetricsState] = [:] - @ObservationIgnored private var statsNormalizationContext: StatsNormalizationContext = .containerSpecific + @ObservationIgnored private var statsNormalizationContext: Core.Metrics.NormalizationContext = .containerSpecific var client: Core.Orchestrator? - private var lastStreamedStats: [String: RuntimeStatsSnapshot] = [:] + private var lastStreamedStats: [String: Core.Metrics.RuntimeStatsSnapshot] = [:] private var lastStreamedStatsDate: Date? /// IDs the user (not a crash) just stopped/removed, so the RestartWatchdog won't fight them. private var intentionalStops: Set = [] @@ -72,7 +72,7 @@ final class ContainersStore { private var refreshRequested = false private let diagnosticLogger = Logger(subsystem: "app.contained.Contained", category: "diagnostic") - var running: [ContainerSnapshot] { snapshots.filter { $0.state == .running } } + var running: [Core.Container.Snapshot] { snapshots.filter { $0.state == .running } } func metricsState(for id: String) -> ContainerMetricsState { if let state = metricsStates[id] { return state } @@ -83,7 +83,7 @@ final class ContainersStore { return state } - func configureStatsNormalization(_ context: StatsNormalizationContext) { + func configureStatsNormalization(_ context: Core.Metrics.NormalizationContext) { guard statsNormalizationContext != context else { return } statsNormalizationContext = context rebuildDisplayHistories() @@ -150,7 +150,7 @@ final class ContainersStore { intentionalStops.formIntersection(Set(snapshots.map(\.id))) errorMessage = nil pruneStatsForCurrentRunningSet() - } catch let error as CommandError { + } catch let error as Core.Command.Error { errorMessage = error.appDisplayMessage } catch { errorMessage = error.appDisplayMessage @@ -180,7 +180,7 @@ final class ContainersStore { } } - func applyStreamedStats(_ samples: [RuntimeStatsSnapshot], observedAt: Date? = nil) { + func applyStreamedStats(_ samples: [Core.Metrics.RuntimeStatsSnapshot], observedAt: Date? = nil) { let runningSet = Set(running.map(\.id)) let samples = samples.filter { runningSet.contains($0.id) } guard !samples.isEmpty else { return } @@ -192,7 +192,7 @@ final class ContainersStore { var nextStats = statsByID var nextHistory = historyByID for sample in samples { - let delta = StatsDelta.from(snapshot: sample, + let delta = Core.Metrics.StatsDelta.from(snapshot: sample, previous: lastStreamedStats[sample.id], interval: interval) record(delta, snapshot: snapshotsByID[sample.id], stats: &nextStats, history: &nextHistory) @@ -206,13 +206,13 @@ final class ContainersStore { statsRevision &+= 1 } - private func record(_ delta: StatsDelta, - snapshot: ContainerSnapshot?, - stats: inout [String: StatsDelta], - history: inout [String: [GraphMetric: UI.Chart.SampleBuffer]]) { + private func record(_ delta: Core.Metrics.StatsDelta, + snapshot: Core.Container.Snapshot?, + stats: inout [String: Core.Metrics.StatsDelta], + history: inout [String: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer]]) { stats[delta.id] = delta var metrics = history[delta.id] ?? [:] - for metric in GraphMetric.allCases { + for metric in Core.Metrics.GraphMetric.allCases { var buffer = metrics[metric] ?? UI.Chart.SampleBuffer() buffer.append(metric.value(from: delta, snapshot: snapshot, normalization: statsNormalizationContext)) metrics[metric] = buffer @@ -223,10 +223,10 @@ final class ContainersStore { private func rebuildDisplayHistories() { let snapshotsByID = Dictionary(snapshots.map { ($0.id, $0) }, uniquingKeysWith: { current, _ in current }) let runningSet = Set(running.map(\.id)) - var rebuilt: [String: [GraphMetric: UI.Chart.SampleBuffer]] = [:] + var rebuilt: [String: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer]] = [:] for (id, delta) in statsByID where runningSet.contains(id) { - var metrics: [GraphMetric: UI.Chart.SampleBuffer] = [:] - for metric in GraphMetric.allCases { + var metrics: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer] = [:] + for metric in Core.Metrics.GraphMetric.allCases { var buffer = UI.Chart.SampleBuffer() buffer.append(metric.value(from: delta, snapshot: snapshotsByID[id], diff --git a/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift b/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift index ab2dffe1..02c37ad8 100644 --- a/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift +++ b/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift @@ -12,21 +12,21 @@ import ContainedCore @MainActor final class RestartWatchdog { /// Called when the watchdog issues a restart (snapshot, attempt number). - var onRestart: ((ContainerSnapshot, Int) -> Void)? + var onRestart: ((Core.Container.Snapshot, Int) -> Void)? /// Called when a container exits unexpectedly with no restart policy (for an informational note). - var onUnexpectedExit: ((ContainerSnapshot) -> Void)? + var onUnexpectedExit: ((Core.Container.Snapshot) -> Void)? private let maxRetries = 5 - private var lastState: [String: RuntimeStatus] = [:] + private var lastState: [String: Core.Runtime.Status] = [:] private var attempts: [String: Int] = [:] private var nextEligible: [String: Date] = [:] /// Evaluate the latest snapshots against the previous tick and act on crashes. - func evaluate(snapshots: [ContainerSnapshot], + func evaluate(snapshots: [Core.Container.Snapshot], store: ContainersStore, client: Core.Orchestrator, now: Date = Date()) async { - var restarts: [(ContainerSnapshot, Int)] = [] + var restarts: [(Core.Container.Snapshot, Int)] = [] for snapshot in snapshots { let id = snapshot.id @@ -41,9 +41,9 @@ final class RestartWatchdog { guard crashedNow else { continue } let userInitiated = store.consumeIntentionalStop(id) - let policy = RestartPolicy(label: snapshot.configuration.labels["contained.restart"]) + let policy = Core.Container.RestartPolicy(label: snapshot.configuration.labels["contained.restart"]) - guard RestartDecision.shouldRestart(policy: policy, userInitiated: userInitiated) else { + guard Core.Container.RestartDecision.shouldRestart(policy: policy, userInitiated: userInitiated) else { // An unexpected exit we won't act on — surface it once (informational). if !userInitiated && policy == .no { onUnexpectedExit?(snapshot) } continue @@ -54,7 +54,7 @@ final class RestartWatchdog { if let eligible = nextEligible[id], eligible > now { continue } // backing off attempts[id] = attempt + 1 - nextEligible[id] = now.addingTimeInterval(RestartDecision.backoff(attempt: attempt + 1)) + nextEligible[id] = now.addingTimeInterval(Core.Container.RestartDecision.backoff(attempt: attempt + 1)) restarts.append((snapshot, attempt + 1)) } diff --git a/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift b/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift index 67cdc7fa..3d681c6c 100644 --- a/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift +++ b/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift @@ -16,7 +16,7 @@ struct RegistryImageSearch: View { @State private var query = "" @State private var appliedInitialQuery = false - @State private var results: [HubSearchResult] = [] + @State private var results: [Core.Registry.HubSearchResult] = [] @State private var searching = false @State private var errorMessage: String? @State private var searchTask: Task? @@ -122,7 +122,7 @@ struct RegistryImageSearch: View { } } - private func resultRow(_ result: HubSearchResult) -> some View { + private func resultRow(_ result: Core.Registry.HubSearchResult) -> some View { choiceCard(symbol: "shippingbox", title: result.repoName, subtitle: result.shortDescription?.isEmpty == false ? result.shortDescription : nil, @@ -198,7 +198,7 @@ struct RegistryImageSearch: View { @MainActor private func runSearch(_ searchQuery: String) async { - guard HubSearch.url(query: searchQuery) != nil else { + guard Core.Registry.HubSearch.url(query: searchQuery) != nil else { results = [] searching = false errorMessage = nil @@ -208,7 +208,7 @@ struct RegistryImageSearch: View { errorMessage = nil defer { searching = false } do { - let searchResults = try await HubSearch.results(query: searchQuery) + let searchResults = try await Core.Registry.HubSearch.results(query: searchQuery) guard !Task.isCancelled, searchQuery == query else { return } results = searchResults } catch { diff --git a/Sources/ContainedApp/Features/Palette/CommandPalette.swift b/Sources/ContainedApp/Features/Palette/CommandPalette.swift index c3f4d667..f6714479 100644 --- a/Sources/ContainedApp/Features/Palette/CommandPalette.swift +++ b/Sources/ContainedApp/Features/Palette/CommandPalette.swift @@ -441,11 +441,11 @@ enum PaletteItemAccessory { enum PaletteItemVisual { case plain - case container(ContainerSnapshot) - case imageGroup(LocalImageTagGroup) + case container(Core.Container.Snapshot) + case imageGroup(Core.Image.LocalTagGroup) case imageTag(String, groupID: String) - case volume(VolumeResource) - case network(NetworkResource) + case volume(Core.Volume.Resource) + case network(Core.Network.Resource) case tint(UI.Theme.Tint) } diff --git a/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift index be8b77d9..c237d491 100644 --- a/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift +++ b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift @@ -72,7 +72,7 @@ struct RegistryLoginSheet: View { password: password) await app.refreshRegistries() dismiss() - } catch let e as CommandError { error = e.appDisplayMessage; busy = false } + } catch let e as Core.Command.Error { error = e.appDisplayMessage; busy = false } catch { self.error = error.appDisplayMessage; busy = false } } } diff --git a/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift b/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift index e878bcb4..5f308991 100644 --- a/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift +++ b/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift @@ -22,7 +22,7 @@ struct SettingsBackup: Codable, Equatable { var keepInMenuBar: Bool var cliPathOverride: String var refreshInterval: Double - var statsNormalizationMode: StatsNormalizationMode + var statsNormalizationMode: Core.Metrics.NormalizationMode var imageUpdateIntervalHours: Int var imageUpdateChecksEnabled: Bool var appUpdateChecksEnabled: Bool @@ -74,7 +74,7 @@ struct SettingsBackup: Codable, Equatable { keepInMenuBar: Bool, cliPathOverride: String, refreshInterval: Double, - statsNormalizationMode: StatsNormalizationMode = .container, + statsNormalizationMode: Core.Metrics.NormalizationMode = .container, imageUpdateIntervalHours: Int, imageUpdateChecksEnabled: Bool, appUpdateChecksEnabled: Bool, @@ -156,7 +156,7 @@ struct SettingsBackup: Codable, Equatable { keepInMenuBar = try container.decodeIfPresent(Bool.self, forKey: .keepInMenuBar) ?? true cliPathOverride = try container.decodeIfPresent(String.self, forKey: .cliPathOverride) ?? "" refreshInterval = try container.decodeIfPresent(Double.self, forKey: .refreshInterval) ?? 2 - statsNormalizationMode = try container.decodeIfPresent(StatsNormalizationMode.self, forKey: .statsNormalizationMode) ?? .container + statsNormalizationMode = try container.decodeIfPresent(Core.Metrics.NormalizationMode.self, forKey: .statsNormalizationMode) ?? .container imageUpdateIntervalHours = try container.decodeIfPresent(Int.self, forKey: .imageUpdateIntervalHours) ?? 6 imageUpdateChecksEnabled = try container.decodeIfPresent(Bool.self, forKey: .imageUpdateChecksEnabled) ?? true appUpdateChecksEnabled = try container.decodeIfPresent(Bool.self, forKey: .appUpdateChecksEnabled) ?? true diff --git a/Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift b/Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift index b21cdb04..4fceb594 100644 --- a/Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift +++ b/Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift @@ -32,7 +32,7 @@ final class SettingsStore { var keepInMenuBar: Bool { didSet { defaults.set(keepInMenuBar, forKey: Keys.keepInMenuBar) } } var cliPathOverride: String { didSet { defaults.set(cliPathOverride, forKey: Keys.cliPath) } } var refreshInterval: Double { didSet { defaults.set(refreshInterval, forKey: Keys.refresh) } } - var statsNormalizationMode: StatsNormalizationMode { + var statsNormalizationMode: Core.Metrics.NormalizationMode { didSet { defaults.set(statsNormalizationMode.rawValue, forKey: Keys.statsNormalizationMode) } } var imageUpdateIntervalHours: Int { didSet { defaults.set(imageUpdateIntervalHours, forKey: Keys.imageUpdateIntervalHours) } } @@ -120,7 +120,7 @@ final class SettingsStore { keepInMenuBar = defaults.object(forKey: Keys.keepInMenuBar) as? Bool ?? true cliPathOverride = defaults.string(forKey: Keys.cliPath) ?? "" refreshInterval = defaults.object(forKey: Keys.refresh) as? Double ?? 2.0 - statsNormalizationMode = StatsNormalizationMode(rawValue: defaults.string(forKey: Keys.statsNormalizationMode) ?? "") ?? .container + statsNormalizationMode = Core.Metrics.NormalizationMode(rawValue: defaults.string(forKey: Keys.statsNormalizationMode) ?? "") ?? .container imageUpdateIntervalHours = defaults.object(forKey: Keys.imageUpdateIntervalHours) as? Int ?? 6 imageUpdateChecksEnabled = defaults.object(forKey: Keys.imageUpdateChecksEnabled) as? Bool ?? true appUpdateChecksEnabled = defaults.object(forKey: Keys.appUpdateChecksEnabled) as? Bool ?? true diff --git a/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift index ee7b4346..9767d546 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift @@ -70,7 +70,7 @@ struct AppearanceTab: View { UI.Panel.ToggleRow(title: AppText.string("settings.appearance.gradient", defaultValue: "Gradient"), isOn: $settings.buttonTintGradient) if settings.buttonTintGradient { - GradientAngleControl(angle: $settings.buttonTintGradientAngle, title: AppText.direction) + UI.Control.GradientAngle(angle: $settings.buttonTintGradientAngle, title: AppText.direction) } UI.Panel.Row(title: AppText.string("settings.appearance.blendMode", defaultValue: "Blend mode")) { Picker("", selection: $settings.buttonTintBlendMode) { @@ -141,7 +141,7 @@ private struct ImageDefaultStyleSection: View { UI.Panel.ToggleRow(title: AppText.string("settings.appearance.gradient", defaultValue: "Gradient"), isOn: styleBinding(\.gradient)) if style.gradient { - GradientAngleControl(angle: styleBinding(\.gradientAngle), title: AppText.direction) + UI.Control.GradientAngle(angle: styleBinding(\.gradientAngle), title: AppText.direction) } UI.Panel.Row(title: AppText.string("settings.appearance.blendMode", defaultValue: "Blend mode")) { Picker("", selection: styleBinding(\.backgroundBlendMode)) { diff --git a/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift index 4eec4458..88dcb9c7 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift @@ -48,7 +48,7 @@ struct GeneralTab: View { } UI.Panel.Row(title: AppText.string("settings.general.normalizeStats", defaultValue: "Normalize stats")) { Picker("", selection: statsNormalizationBinding) { - ForEach(StatsNormalizationMode.allCases) { mode in + ForEach(Core.Metrics.NormalizationMode.allCases) { mode in Text(mode.displayName).tag(mode) } } @@ -111,7 +111,7 @@ struct GeneralTab: View { set: { app.applyHistoryRetention($0) }) } - private var statsNormalizationBinding: Binding { + private var statsNormalizationBinding: Binding { Binding(get: { settings.statsNormalizationMode }, set: { app.setStatsNormalizationMode($0) }) } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift index 02c7cfc7..604232cd 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift @@ -8,7 +8,7 @@ import ContainedCore struct RegistriesTab: View { @Environment(AppModel.self) private var app @State private var loggingIn = false - @State private var loggingOut: RegistryLogin? + @State private var loggingOut: Core.Registry.Login? var body: some View { LazyVStack(spacing: UI.Layout.Spacing.l) { @@ -51,10 +51,10 @@ struct RegistriesTab: View { Binding(get: { loggingOut != nil }, set: { if !$0 { loggingOut = nil } }) } - private func logout(_ login: RegistryLogin) async { + private func logout(_ login: Core.Registry.Login) async { guard let client = app.client else { return } do { _ = try await client.registryLogout(server: login.host); await app.refreshRegistries() } - catch let error as CommandError { app.flash(error.appDisplayMessage) } + catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } } diff --git a/Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift b/Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift index 58847355..e5275e48 100644 --- a/Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift +++ b/Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift @@ -21,13 +21,13 @@ enum SystemVolumeInventory { let kind: Kind let title: String let subtitle: String? - let containers: [ContainerSnapshot] - let resource: VolumeResource? + let containers: [Core.Container.Snapshot] + let resource: Core.Volume.Resource? let source: String? let destination: String? } - static func build(volumes: [VolumeResource], containers: [ContainerSnapshot]) -> [Entry] { + static func build(volumes: [Core.Volume.Resource], containers: [Core.Container.Snapshot]) -> [Entry] { let sortedVolumes = volumes.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } @@ -91,15 +91,15 @@ enum SystemVolumeInventory { destination: existing.destination ?? incoming.destination) } - private static func volumeSubtitle(_ volume: VolumeResource) -> String? { + private static func volumeSubtitle(_ volume: Core.Volume.Resource) -> String? { let config = volume.configuration let parts = [config.sizeInBytes.map { Format.bytes($0) }, config.format].compactMap { $0 } return parts.isEmpty ? nil : parts.joined(separator: " · ") } - private static func mountInventoryEntry(_ mount: Mount, - snapshot: ContainerSnapshot, - namedResources: [String: VolumeResource]) -> Entry? { + private static func mountInventoryEntry(_ mount: Core.Container.Mount, + snapshot: Core.Container.Snapshot, + namedResources: [String: Core.Volume.Resource]) -> Entry? { let source = mount.source?.trimmingCharacters(in: .whitespacesAndNewlines) let destination = mount.effectiveDestination let type = mount.type?.lowercased() @@ -153,13 +153,13 @@ enum SystemVolumeInventory { return type.uppercased() } - private static func containersMounting(source: String, in containers: [ContainerSnapshot]) -> [ContainerSnapshot] { + private static func containersMounting(source: String, in containers: [Core.Container.Snapshot]) -> [Core.Container.Snapshot] { sortedContainers(containers.filter { snapshot in snapshot.configuration.mounts.contains { $0.source == source } }) } - private static func sortedContainers(_ containers: [ContainerSnapshot]) -> [ContainerSnapshot] { + private static func sortedContainers(_ containers: [Core.Container.Snapshot]) -> [Core.Container.Snapshot] { containers.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } diff --git a/Sources/ContainedApp/Features/System/SystemView.swift b/Sources/ContainedApp/Features/System/SystemView.swift index e16c5e81..f740783b 100644 --- a/Sources/ContainedApp/Features/System/SystemView.swift +++ b/Sources/ContainedApp/Features/System/SystemView.swift @@ -18,7 +18,7 @@ struct SystemContent: View { @State private var working = false @State private var pruneTarget: PruneTarget? @State private var reclaimingAll = false - @State private var deletingVolume: VolumeResource? + @State private var deletingVolume: Core.Volume.Resource? @State private var page: SystemPage enum SystemPage: String, CaseIterable, Identifiable { @@ -288,7 +288,7 @@ struct SystemContent: View { Binding(get: { deletingVolume != nil }, set: { if !$0 { deletingVolume = nil } }) } - private func deleteVolume(_ volume: VolumeResource) async { + private func deleteVolume(_ volume: Core.Volume.Resource) async { guard let client = app.client else { return } if let error = await app.captured({ _ = try await client.deleteVolumes([volume.name]) }) { app.flash(error) } await app.refreshVolumes() @@ -403,7 +403,7 @@ struct SystemContent: View { } await app.refreshSystemResources() await app.refreshSystem() - } catch let error as CommandError { app.flash(error.appDisplayMessage) } + } catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } diff --git a/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift b/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift index 63fa30c4..c0b43cbe 100644 --- a/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift +++ b/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift @@ -10,7 +10,7 @@ struct MenuBarContent: View { @Environment(UIState.self) private var ui private var store: ContainersStore { app.containers } - private var stopped: [ContainerSnapshot] { store.snapshots.filter { $0.state != .running } } + private var stopped: [Core.Container.Snapshot] { store.snapshots.filter { $0.state != .running } } private var unreadActivityCount: Int { app.historyStore.unreadEventCount() } private var cliLabel: String { @@ -238,7 +238,7 @@ struct MenuBarContent: View { } } - private func containerName(for snapshot: ContainerSnapshot) -> String { + private func containerName(for snapshot: Core.Container.Snapshot) -> String { app.containerStyle(for: snapshot).displayName(fallback: snapshot.id) } diff --git a/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift b/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift index 4ad28ade..ac26df3b 100644 --- a/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift +++ b/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift @@ -165,7 +165,7 @@ private struct ClassicSectionPage: View { private struct BuildPage: View { var body: some View { - PageScaffold(symbol: "hammer", + UI.Panel.PageScaffold(symbol: "hammer", title: AppText.sectionBuild, subtitle: AppText.string("build.subtitle.context", defaultValue: "From a Dockerfile + build context")) { EmptyView() @@ -179,9 +179,9 @@ private struct BuildPage: View { private struct NetworksPage: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - @State private var deletingNetwork: NetworkResource? + @State private var deletingNetwork: Core.Network.Resource? - private var sortedNetworks: [NetworkResource] { + private var sortedNetworks: [Core.Network.Resource] { app.networks.filter(matchesFilter).sorted { lhs, rhs in switch ui.networkSort { case .name: @@ -203,7 +203,7 @@ private struct NetworksPage: View { } } - private var networkSections: [(title: String, networks: [NetworkResource])] { + private var networkSections: [(title: String, networks: [Core.Network.Resource])] { switch ui.networkGrouping { case .none: return [("", sortedNetworks)] @@ -219,7 +219,7 @@ private struct NetworksPage: View { } var body: some View { - PageScaffold(symbol: "network", + UI.Panel.PageScaffold(symbol: "network", title: AppText.sectionNetworks, subtitle: AppText.string("network.count", defaultValue: "\(sortedNetworks.count) network\(sortedNetworks.count == 1 ? "" : "s")")) { UI.Action.Group(UI.Action.Item(systemName: "plus", @@ -259,7 +259,7 @@ private struct NetworksPage: View { } } - private func networkRow(_ network: NetworkResource) -> some View { + private func networkRow(_ network: Core.Network.Resource) -> some View { UI.Card.Scaffold(size: .medium, elevated: false, title: network.name, @@ -294,7 +294,7 @@ private struct NetworksPage: View { } @ViewBuilder - private func networkMenu(_ network: NetworkResource) -> some View { + private func networkMenu(_ network: Core.Network.Resource) -> some View { Button { copyToPasteboard(network.name) } label: { Label("Copy Name", systemImage: "doc.on.doc") } @@ -311,7 +311,7 @@ private struct NetworksPage: View { } } - private func networkSubtitle(_ network: NetworkResource) -> String { + private func networkSubtitle(_ network: Core.Network.Resource) -> String { [ network.configuration.mode, network.configuration.plugin, @@ -323,7 +323,7 @@ private struct NetworksPage: View { .joined(separator: " · ") } - private func matchesFilter(_ network: NetworkResource) -> Bool { + private func matchesFilter(_ network: Core.Network.Resource) -> Bool { switch ui.networkFilter { case .all: return true case .custom: return !network.isBuiltin @@ -335,12 +335,12 @@ private struct NetworksPage: View { Binding(get: { deletingNetwork != nil }, set: { if !$0 { deletingNetwork = nil } }) } - private func deleteNetwork(_ network: NetworkResource) async { + private func deleteNetwork(_ network: Core.Network.Resource) async { guard let client = app.client else { return } do { _ = try await client.deleteNetworks([network.name]) await app.refreshNetworks() - } catch let error as CommandError { + } catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) @@ -351,7 +351,7 @@ private struct NetworksPage: View { private struct ImagesPage: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - @State private var detail: LocalImageTagGroup? + @State private var detail: Core.Image.LocalTagGroup? @State private var sourceFrame: CGRect? @State private var presented = false @State private var closeRequestToken = 0 @@ -426,7 +426,7 @@ private struct ImagesPage: View { CGRect(x: size.width / 2 - 1, y: size.height / 2 - 1, width: 2, height: 2) } - private func openImageDetail(_ group: LocalImageTagGroup, _ frame: CGRect) { + private func openImageDetail(_ group: Core.Image.LocalTagGroup, _ frame: CGRect) { detail = group sourceFrame = frame presented = true @@ -436,7 +436,7 @@ private struct ImagesPage: View { closeRequestToken &+= 1 } - private func currentGroup(_ group: LocalImageTagGroup) -> LocalImageTagGroup { + private func currentGroup(_ group: Core.Image.LocalTagGroup) -> Core.Image.LocalTagGroup { app.localImageGroups().first { $0.id == group.id } ?? group } } diff --git a/Sources/ContainedApp/Navigation/State/UIState.swift b/Sources/ContainedApp/Navigation/State/UIState.swift index fe25ebdd..32b0e881 100644 --- a/Sources/ContainedApp/Navigation/State/UIState.swift +++ b/Sources/ContainedApp/Navigation/State/UIState.swift @@ -11,7 +11,7 @@ final class UIState { struct CreationPresentation { var entry: CreationEntry = .menu var prefillSpec: RunSpec? - var editSnapshot: ContainerSnapshot? + var editSnapshot: Core.Container.Snapshot? var returnEntry: CreationEntry? var searchQuery = "" var requestToken = 0 @@ -69,7 +69,7 @@ final class UIState { /// A one-shot action requested by menus or the command palette. `RootView` consumes global /// actions, while toolbar panels and the Containers page handle their local operations directly. var pendingAction: PendingAction? - var editSheetSnapshot: ContainerSnapshot? + var editSheetSnapshot: Core.Container.Snapshot? // MARK: Actions @@ -218,7 +218,7 @@ final class UIState { openCreationPanel(entry: .configure, prefill: spec, searchQuery: searchQuery, returningTo: returnEntry) } - func openCreationPanel(editing snapshot: ContainerSnapshot) { + func openCreationPanel(editing snapshot: Core.Container.Snapshot) { guard panelNavigationEnabled else { editSheetSnapshot = snapshot return diff --git a/Sources/ContainedApp/Navigation/Toolbar/AppToolbar.swift b/Sources/ContainedApp/Navigation/Toolbar/AppToolbar.swift index 997fe627..24d79e8b 100644 --- a/Sources/ContainedApp/Navigation/Toolbar/AppToolbar.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/AppToolbar.swift @@ -21,7 +21,7 @@ struct AppToolbar: View { @State private var slots: [UIState.ToolbarMorph: CGRect] = [:] @State private var addSoftDismiss: (() -> Void)? - @State private var toolbarImageDetail: LocalImageTagGroup? + @State private var toolbarImageDetail: Core.Image.LocalTagGroup? @State private var toolbarImageSourceFrame: CGRect? @State private var toolbarImageDetailPresented = false @State private var toolbarImageCloseRequestToken = 0 @@ -455,11 +455,11 @@ struct AppToolbar: View { UI.Panel.Size.imageDetail } - private func currentToolbarImageGroup(_ group: LocalImageTagGroup) -> LocalImageTagGroup { + private func currentToolbarImageGroup(_ group: Core.Image.LocalTagGroup) -> Core.Image.LocalTagGroup { app.localImageGroups().first { $0.id == group.id } ?? group } - private func openToolbarImageDetail(_ group: LocalImageTagGroup, sourceFrame: CGRect) { + private func openToolbarImageDetail(_ group: Core.Image.LocalTagGroup, sourceFrame: CGRect) { toolbarImageDetail = group toolbarImageSourceFrame = sourceFrame toolbarImageDetailPresented = true diff --git a/Sources/ContainedApp/Navigation/Toolbar/Panels/PaletteResultCard.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/PaletteResultCard.swift index a57631d0..4120eedd 100644 --- a/Sources/ContainedApp/Navigation/Toolbar/Panels/PaletteResultCard.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/PaletteResultCard.swift @@ -73,7 +73,7 @@ struct PaletteResultCard: View { .accessibilityAddTraits(selected ? .isSelected : []) } - private func containerCard(_ snapshot: ContainerSnapshot) -> some View { + private func containerCard(_ snapshot: Core.Container.Snapshot) -> some View { let style = app.containerStyle(for: snapshot) let name = style.displayName(fallback: snapshot.id) let cardSize: UI.Card.Size = snapshot.state == .running ? .large : .medium @@ -117,7 +117,7 @@ struct PaletteResultCard: View { .accessibilityAddTraits(selected ? .isSelected : []) } - private func imageGroupCard(_ group: LocalImageTagGroup) -> some View { + private func imageGroupCard(_ group: Core.Image.LocalTagGroup) -> some View { ToolbarImageGroupCard(group: group, isExpanded: false, onTap: action, onClose: {}) .designCardSelectionOverlay(when: selected) .accessibilityAddTraits(selected ? .isSelected : []) @@ -232,7 +232,7 @@ struct PaletteResultCard: View { .accessibilityAddTraits(selected ? .isSelected : []) } - private func containerPaletteWidget(_ snapshot: ContainerSnapshot) -> some View { + private func containerPaletteWidget(_ snapshot: Core.Container.Snapshot) -> some View { UI.Card.WidgetGroup { UI.Card.FooterMini { UI.Symbol.Image(systemName: "clock", size: .caption2) @@ -280,11 +280,11 @@ struct PaletteResultCard: View { } private func repositoryTitle(_ reference: String) -> String { - let parsed = RegistryImageReference.parse(reference) + let parsed = Core.Registry.ImageReference.parse(reference) return parsed.repository.split(separator: "/").map(String.init).last ?? parsed.repository } - private func imageUpdateText(_ status: ImageUpdateStatus) -> String { + private func imageUpdateText(_ status: Core.Image.UpdateStatus) -> String { switch status.state { case .unknown: return "Not checked" case .checking: return "Checking for updates" @@ -294,7 +294,7 @@ struct PaletteResultCard: View { } } - private func containerStatus(_ snapshot: ContainerSnapshot) -> some View { + private func containerStatus(_ snapshot: Core.Container.Snapshot) -> some View { UI.Card.FooterMini { UI.Symbol.Image(systemName: snapshot.state == .running ? "circle.fill" : "circle", tone: snapshot.state == .running ? .success : .neutral, diff --git a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift index 7a85a434..e34311ea 100644 --- a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift @@ -34,7 +34,7 @@ struct ToolbarCommandPalette: View { @FocusState private var focused: Bool var onClose: () -> Void - @State private var hubResults: [HubSearchResult] = [] + @State private var hubResults: [Core.Registry.HubSearchResult] = [] @State private var hubSearching = false @State private var hubError: String? @@ -409,7 +409,7 @@ struct ToolbarCommandPalette: View { hubError = nil defer { hubSearching = false } do { - let results = try await HubSearch.results(query: trimmedQuery) + let results = try await Core.Registry.HubSearch.results(query: trimmedQuery) guard !Task.isCancelled else { return } hubResults = results } catch { diff --git a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift index 3641cfcc..9eb7ff35 100644 --- a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift @@ -8,7 +8,7 @@ import ContainedCore struct ToolbarImageGroupCard: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - let group: LocalImageTagGroup + let group: Core.Image.LocalTagGroup let isExpanded: Bool var onTap: () -> Void var onClose: () -> Void @@ -125,7 +125,7 @@ struct ToolbarImageGroupCard: View { // MARK: Detail sub-pages @ViewBuilder - private func imageBody(_ group: LocalImageTagGroup) -> some View { + private func imageBody(_ group: Core.Image.LocalTagGroup) -> some View { if !isExpanded { tagList(group) } else { @@ -294,7 +294,7 @@ struct ToolbarImageGroupCard: View { action: .none) } - let parsed = RegistryImageReference.parse(reference) + let parsed = Core.Registry.ImageReference.parse(reference) let registry = displayRegistry(parsed.registry) guard !parsed.isDigestReference else { @@ -345,7 +345,7 @@ struct ToolbarImageGroupCard: View { tagBusy = false tagTarget = "" withAnimation(spring) { page = .tags } - } catch let error as CommandError { + } catch let error as Core.Command.Error { app.flash(error.appDisplayMessage); tagBusy = false } catch { app.flash(error.appDisplayMessage); tagBusy = false @@ -397,7 +397,7 @@ struct ToolbarImageGroupCard: View { } } - private func imageFooterInfo(_ status: ImageUpdateStatus) -> some View { + private func imageFooterInfo(_ status: Core.Image.UpdateStatus) -> some View { UI.Card.FooterMini { UI.Symbol.Image(systemName: updateSymbol(status.state), tone: updateTone(status.state), @@ -407,7 +407,7 @@ struct ToolbarImageGroupCard: View { } } - private func imageFooterTagCount(_ group: LocalImageTagGroup) -> some View { + private func imageFooterTagCount(_ group: Core.Image.LocalTagGroup) -> some View { UI.Card.FooterMini { UI.Symbol.Image(systemName: "tag", size: .caption) } text: { @@ -416,7 +416,7 @@ struct ToolbarImageGroupCard: View { } @ViewBuilder - private func imageFooterActions(_ group: LocalImageTagGroup) -> some View { + private func imageFooterActions(_ group: Core.Image.LocalTagGroup) -> some View { footerAction("play", help: AppText.run) { ui.runImage(group.primaryReference) if isExpanded { onClose() } @@ -435,7 +435,7 @@ struct ToolbarImageGroupCard: View { footerAction("trash", help: AppText.prune, role: .destructive) { pruning = true } } - private func tagList(_ group: LocalImageTagGroup) -> some View { + private func tagList(_ group: Core.Image.LocalTagGroup) -> some View { LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { Text("Tags") .designHeadlineLabelStyle() @@ -453,7 +453,7 @@ struct ToolbarImageGroupCard: View { } } - private func tagRow(_ reference: String, in group: LocalImageTagGroup) -> some View { + private func tagRow(_ reference: String, in group: Core.Image.LocalTagGroup) -> some View { let style = app.imageStyle(for: reference) return UI.Card.Scaffold(size: .medium, fill: style.fillBackground ? style.color : nil, @@ -498,7 +498,7 @@ struct ToolbarImageGroupCard: View { /// Right-click actions for a single tag — mirrors the footer buttons so the row is consistent with /// the group card (which has its own context menu). @ViewBuilder - private func tagMenu(_ reference: String, in group: LocalImageTagGroup) -> some View { + private func tagMenu(_ reference: String, in group: Core.Image.LocalTagGroup) -> some View { Button { ui.runImage(reference); if isExpanded { onClose() } } label: { Label("Run…", systemImage: "play") } Button { copyToPasteboard(reference) } label: { Label("Copy reference", systemImage: "doc.on.doc") } Divider() @@ -516,7 +516,7 @@ struct ToolbarImageGroupCard: View { } @ViewBuilder - private func cardMenu(_ group: LocalImageTagGroup) -> some View { + private func cardMenu(_ group: Core.Image.LocalTagGroup) -> some View { Button { ui.runImage(group.primaryReference) } label: { Label("Run…", systemImage: "play") } if let image = primaryImage(group) { // History / Tag / Push grow the detail morph into a sub-page, so they're offered only @@ -549,7 +549,7 @@ struct ToolbarImageGroupCard: View { UI.Card.IconChip(symbol: style.symbol, tint: style.color) } - private func updateSymbol(_ state: ImageUpdateState) -> String { + private func updateSymbol(_ state: Core.Image.UpdateState) -> String { switch state { case .unknown: return "questionmark.circle" case .checking: return "arrow.triangle.2.circlepath" @@ -559,7 +559,7 @@ struct ToolbarImageGroupCard: View { } } - private func updateTone(_ state: ImageUpdateState) -> UI.State.Tone { + private func updateTone(_ state: Core.Image.UpdateState) -> UI.State.Tone { switch state { case .current: return .success case .updateAvailable, .error: return .warning @@ -569,7 +569,7 @@ struct ToolbarImageGroupCard: View { } private func repositoryName(_ reference: String) -> String { - let parsed = RegistryImageReference.parse(reference) + let parsed = Core.Registry.ImageReference.parse(reference) if parsed.registry == "registry-1.docker.io", parsed.repository.hasPrefix("library/") { return String(parsed.repository.dropFirst("library/".count)) } @@ -577,12 +577,12 @@ struct ToolbarImageGroupCard: View { } private func repositoryTitle(_ reference: String) -> String { - let parsed = RegistryImageReference.parse(reference) + let parsed = Core.Registry.ImageReference.parse(reference) return parsed.repository.split(separator: "/").map(String.init).last ?? parsed.repository } private func repositoryOwner(_ reference: String) -> String { - let parsed = RegistryImageReference.parse(reference) + let parsed = Core.Registry.ImageReference.parse(reference) let parts = parsed.repository.split(separator: "/").map(String.init) if parts.count > 1 { return parts.dropLast().joined(separator: "/") @@ -590,7 +590,7 @@ struct ToolbarImageGroupCard: View { return parsed.registry == "registry-1.docker.io" ? "docker.io" : parsed.registry } - private func updateFooterText(_ status: ImageUpdateStatus) -> String { + private func updateFooterText(_ status: Core.Image.UpdateStatus) -> String { switch status.state { case .unknown: return "Not checked" case .checking: return "Checking" @@ -600,7 +600,7 @@ struct ToolbarImageGroupCard: View { } } - private func primaryImage(_ group: LocalImageTagGroup) -> ContainedCore.ImageResource? { + private func primaryImage(_ group: Core.Image.LocalTagGroup) -> Core.Image.Resource? { group.images.first { $0.reference == group.primaryReference } ?? group.images.first } @@ -613,7 +613,7 @@ struct ToolbarImageGroupCard: View { set: { if !$0 { confirmingPushReference = nil } }) } - private func matchingRegistryLogin(for registry: String) -> RegistryLogin? { + private func matchingRegistryLogin(for registry: String) -> Core.Registry.Login? { let normalized = normalizedRegistryHost(registry) return app.registries.first { normalizedRegistryHost($0.host) == normalized } } @@ -646,18 +646,18 @@ struct ToolbarImageGroupCard: View { await app.refreshImagesIfNeeded(force: true) app.flash(AppText.deletedImage(Format.shortImage(reference))) deletingReference = nil - } catch let error as CommandError { app.flash(error.appDisplayMessage) } + } catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } private func prune(all: Bool) async { guard let client = app.client else { return } do { _ = try await client.pruneImages(all: all); await app.refreshImagesIfNeeded(force: true) } - catch let error as CommandError { app.flash(error.appDisplayMessage) } + catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } - private func save(_ image: ContainedCore.ImageResource) { + private func save(_ image: Core.Image.Resource) { guard let client = app.client else { return } let panel = NSSavePanel() panel.allowedContentTypes = [.init(filenameExtension: "tar") ?? .data] diff --git a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarUpdatesPanel.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarUpdatesPanel.swift index ff5915fe..305a0910 100644 --- a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarUpdatesPanel.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarUpdatesPanel.swift @@ -10,16 +10,16 @@ struct ToolbarUpdatesPanel: View { @Environment(UIState.self) private var ui var showClose = true var coordinateSpaceName = AppToolbar.space - var hiddenImageGroupID: LocalImageTagGroup.ID? - var onOpenImage: (LocalImageTagGroup, CGRect) -> Void + var hiddenImageGroupID: Core.Image.LocalTagGroup.ID? + var onOpenImage: (Core.Image.LocalTagGroup, CGRect) -> Void var onClose: () -> Void - @State private var imageFrames: [LocalImageTagGroup.ID: CGRect] = [:] + @State private var imageFrames: [Core.Image.LocalTagGroup.ID: CGRect] = [:] - private var imageGroups: [LocalImageTagGroup] { + private var imageGroups: [Core.Image.LocalTagGroup] { sortedImageGroups(app.localImageGroups().filter(matchesFilter)) } - private var imageSections: [(title: String, groups: [LocalImageTagGroup])] { + private var imageSections: [(title: String, groups: [Core.Image.LocalTagGroup])] { switch ui.imageGrouping { case .none: return [("", imageGroups)] @@ -124,7 +124,7 @@ struct ToolbarUpdatesPanel: View { } } - private func imageRow(_ group: LocalImageTagGroup) -> some View { + private func imageRow(_ group: Core.Image.LocalTagGroup) -> some View { ToolbarImageGroupCard(group: group, isExpanded: false, onTap: { @@ -145,12 +145,12 @@ struct ToolbarUpdatesPanel: View { } } - private func updateImageFrame(_ frame: CGRect, for id: LocalImageTagGroup.ID) { + private func updateImageFrame(_ frame: CGRect, for id: Core.Image.LocalTagGroup.ID) { guard imageFrames[id]?.isClose(to: frame) != true else { return } imageFrames[id] = frame } - private func imageRank(_ group: LocalImageTagGroup) -> Int { + private func imageRank(_ group: Core.Image.LocalTagGroup) -> Int { switch app.imageUpdateStatus(for: group.primaryReference).state { case .updateAvailable: return 0 case .error: return 1 @@ -160,7 +160,7 @@ struct ToolbarUpdatesPanel: View { } } - private func sortedImageGroups(_ groups: [LocalImageTagGroup]) -> [LocalImageTagGroup] { + private func sortedImageGroups(_ groups: [Core.Image.LocalTagGroup]) -> [Core.Image.LocalTagGroup] { groups.sorted { lhs, rhs in switch ui.imageSort { case .status: @@ -176,7 +176,7 @@ struct ToolbarUpdatesPanel: View { } } - private func matchesFilter(_ group: LocalImageTagGroup) -> Bool { + private func matchesFilter(_ group: Core.Image.LocalTagGroup) -> Bool { switch ui.imageFilter { case .all: return true @@ -187,12 +187,12 @@ struct ToolbarUpdatesPanel: View { } } - private func registryTitle(_ group: LocalImageTagGroup) -> String { - let parsed = RegistryImageReference.parse(group.primaryReference) + private func registryTitle(_ group: Core.Image.LocalTagGroup) -> String { + let parsed = Core.Registry.ImageReference.parse(group.primaryReference) return parsed.registry == "registry-1.docker.io" ? "docker.io" : parsed.registry } - private func statusTitle(_ group: LocalImageTagGroup) -> String { + private func statusTitle(_ group: Core.Image.LocalTagGroup) -> String { switch app.imageUpdateStatus(for: group.primaryReference).state { case .updateAvailable: return "Updates available" case .error: return "Errors" diff --git a/Sources/ContainedApp/Persistence/History/HistoryModels.swift b/Sources/ContainedApp/Persistence/History/HistoryModels.swift index 13bbb2ee..8e67e6ab 100644 --- a/Sources/ContainedApp/Persistence/History/HistoryModels.swift +++ b/Sources/ContainedApp/Persistence/History/HistoryModels.swift @@ -137,5 +137,5 @@ struct MetricSampleSnapshot: Codable, Equatable { } } -extension MetricSample: MetricHistorySample {} -extension MetricSampleSnapshot: MetricHistorySample {} +extension MetricSample: Core.Metrics.HistorySample {} +extension MetricSampleSnapshot: Core.Metrics.HistorySample {} diff --git a/Sources/ContainedApp/Persistence/History/HistoryStore.swift b/Sources/ContainedApp/Persistence/History/HistoryStore.swift index c8075fb6..ccc974ac 100644 --- a/Sources/ContainedApp/Persistence/History/HistoryStore.swift +++ b/Sources/ContainedApp/Persistence/History/HistoryStore.swift @@ -52,7 +52,7 @@ final class HistoryStore { } /// Persist a metric sample for each running container, throttled to `metricInterval`. - func recordMetrics(_ deltas: [String: StatsDelta], at date: Date = Date()) { + func recordMetrics(_ deltas: [String: Core.Metrics.StatsDelta], at date: Date = Date()) { if let last = lastMetricSample, date.timeIntervalSince(last) < metricInterval { return } lastMetricSample = date for (id, d) in deltas { diff --git a/Sources/ContainedApp/Personalization/Personalization.swift b/Sources/ContainedApp/Personalization/Personalization.swift index a283c0d7..81b03704 100644 --- a/Sources/ContainedApp/Personalization/Personalization.swift +++ b/Sources/ContainedApp/Personalization/Personalization.swift @@ -34,7 +34,7 @@ struct Personalization: Codable, Hashable, Sendable { var color: Color { tint.color } - var graphMetric: GraphMetric { + var graphMetric: Core.Metrics.GraphMetric { get { widgets.first(where: { $0.enabled })?.metric ?? widgets.first?.metric ?? .cpu } set { if widgets.indices.contains(0) { @@ -99,7 +99,7 @@ struct Personalization: Codable, Hashable, Sendable { !decodedWidgets.isEmpty { widgets = Self.normalizedWidgets(decodedWidgets) } else { - let metric = try container.decodeIfPresent(GraphMetric.self, forKey: .graphMetric) ?? .cpu + let metric = try container.decodeIfPresent(Core.Metrics.GraphMetric.self, forKey: .graphMetric) ?? .cpu let style = try container.decodeIfPresent(UI.Chart.GraphStyle.self, forKey: .graphStyle) ?? .area widgets = Self.normalizedWidgets([ WidgetConfiguration(enabled: true, metric: metric, style: style), diff --git a/Sources/ContainedApp/Personalization/WidgetConfiguration.swift b/Sources/ContainedApp/Personalization/WidgetConfiguration.swift index 04df4e0c..ddaa6b79 100644 --- a/Sources/ContainedApp/Personalization/WidgetConfiguration.swift +++ b/Sources/ContainedApp/Personalization/WidgetConfiguration.swift @@ -7,8 +7,8 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { var schemaVersion: Int = Self.schemaVersion var enabled: Bool = true - var metric: GraphMetric = .cpu - var secondaryMetric: GraphMetric? + var metric: Core.Metrics.GraphMetric = .cpu + var secondaryMetric: Core.Metrics.GraphMetric? var tint: UI.Theme.Tint? var icon: String = "" var style: UI.Chart.GraphStyle = .area @@ -28,8 +28,8 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { init() {} init(enabled: Bool = true, - metric: GraphMetric = .cpu, - secondaryMetric: GraphMetric? = nil, + metric: Core.Metrics.GraphMetric = .cpu, + secondaryMetric: Core.Metrics.GraphMetric? = nil, tint: UI.Theme.Tint? = nil, icon: String = "", style: UI.Chart.GraphStyle = .area, @@ -61,8 +61,8 @@ struct WidgetConfiguration: Codable, Hashable, Sendable { let decodedSchemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 0 schemaVersion = decodedSchemaVersion enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true - metric = try container.decodeIfPresent(GraphMetric.self, forKey: .metric) ?? .cpu - secondaryMetric = try container.decodeIfPresent(GraphMetric.self, forKey: .secondaryMetric) + metric = try container.decodeIfPresent(Core.Metrics.GraphMetric.self, forKey: .metric) ?? .cpu + secondaryMetric = try container.decodeIfPresent(Core.Metrics.GraphMetric.self, forKey: .secondaryMetric) tint = try container.decodeIfPresent(UI.Theme.Tint.self, forKey: .tint) icon = try container.decodeIfPresent(String.self, forKey: .icon) ?? "" style = try container.decodeIfPresent(UI.Chart.GraphStyle.self, forKey: .style) ?? .area diff --git a/Sources/ContainedApp/Presentation/Error/AppErrorPresentation.swift b/Sources/ContainedApp/Presentation/Error/AppErrorPresentation.swift index 13f6e115..f6a8f1eb 100644 --- a/Sources/ContainedApp/Presentation/Error/AppErrorPresentation.swift +++ b/Sources/ContainedApp/Presentation/Error/AppErrorPresentation.swift @@ -4,13 +4,13 @@ import ContainedCore enum AppErrorPresentation { static func message(for error: Error) -> String { switch error { - case let error as CommandError: + case let error as Core.Command.Error: return message(for: error) case let error as Core.Runtime.UnsupportedCapability: return message(for: error) - case let error as RegistryManifestError: + case let error as Core.Registry.ManifestError: return message(for: error) - case let error as ComposeError: + case let error as Core.Compose.Error: return message(for: error) case let error as LocalizedError: return error.errorDescription ?? (error as NSError).localizedDescription @@ -20,7 +20,7 @@ enum AppErrorPresentation { } static func packageSummary(for error: Error) -> String? { - guard let packageError = error as? ContainedPackageError else { return nil } + guard let packageError = error as? Core.Error.PackageError else { return nil } let context = packageError.packageErrorContext .sorted { $0.key < $1.key } .map { "\($0.key)=\(compactContextValue($0.value))" } @@ -46,7 +46,7 @@ enum AppErrorPresentation { return "\(collapsed.prefix(157))..." } - private static func message(for error: CommandError) -> String { + private static func message(for error: Core.Command.Error) -> String { switch error { case .cliNotFound(let searched): return AppText.string( @@ -76,7 +76,7 @@ enum AppErrorPresentation { ) } - private static func message(for error: RegistryManifestError) -> String { + private static func message(for error: Core.Registry.ManifestError) -> String { switch error { case .invalidResponse: return AppText.string( @@ -105,7 +105,7 @@ enum AppErrorPresentation { } } - private static func message(for error: ComposeError) -> String { + private static func message(for error: Core.Compose.Error) -> String { switch error { case .invalid(let reason): let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/ContainedApp/Presentation/GraphMetricPresentation.swift b/Sources/ContainedApp/Presentation/GraphMetricPresentation.swift index b01736fd..5475356f 100644 --- a/Sources/ContainedApp/Presentation/GraphMetricPresentation.swift +++ b/Sources/ContainedApp/Presentation/GraphMetricPresentation.swift @@ -1,6 +1,6 @@ import ContainedCore -extension GraphMetric { +extension Core.Metrics.GraphMetric { var displayName: String { switch self { case .cpu: return AppText.string("graphMetric.cpu", defaultValue: "CPU") @@ -23,9 +23,9 @@ extension GraphMetric { } } - func chipCaption(from delta: StatsDelta, - snapshot: ContainerSnapshot? = nil, - normalization: StatsNormalizationContext = .containerSpecific) -> String { + func chipCaption(from delta: Core.Metrics.StatsDelta, + snapshot: Core.Container.Snapshot? = nil, + normalization: Core.Metrics.NormalizationContext = .containerSpecific) -> String { switch self { case .cpu, .memory: return Format.compactPercent(value(from: delta, snapshot: snapshot, normalization: normalization)) @@ -36,9 +36,9 @@ extension GraphMetric { } } - func caption(from delta: StatsDelta, - snapshot: ContainerSnapshot? = nil, - normalization: StatsNormalizationContext = .containerSpecific) -> String { + func caption(from delta: Core.Metrics.StatsDelta, + snapshot: Core.Container.Snapshot? = nil, + normalization: Core.Metrics.NormalizationContext = .containerSpecific) -> String { switch self { case .cpu, .memory: return Format.compactPercent(value(from: delta, snapshot: snapshot, normalization: normalization)) @@ -50,7 +50,7 @@ extension GraphMetric { } } -extension StatsNormalizationMode { +extension Core.Metrics.NormalizationMode { var displayName: String { switch self { case .container: return AppText.string("statsNormalization.container", defaultValue: "Container") diff --git a/Sources/ContainedApp/Presentation/Localization/AppLocalization.swift b/Sources/ContainedApp/Presentation/Localization/AppLocalization.swift index eddfa3b4..01e01326 100644 --- a/Sources/ContainedApp/Presentation/Localization/AppLocalization.swift +++ b/Sources/ContainedApp/Presentation/Localization/AppLocalization.swift @@ -732,7 +732,7 @@ extension UI.Chart.Interpolation { } } -extension RestartPolicy { +extension Core.Container.RestartPolicy { var localizedDisplayName: String { switch self { case .no: return AppText.string("restartPolicy.no", defaultValue: "No") diff --git a/Sources/ContainedApp/Presentation/StatusPresentation.swift b/Sources/ContainedApp/Presentation/StatusPresentation.swift index 680f1e3c..4061eb6a 100644 --- a/Sources/ContainedApp/Presentation/StatusPresentation.swift +++ b/Sources/ContainedApp/Presentation/StatusPresentation.swift @@ -5,7 +5,7 @@ import ContainedCore enum StatusPresentation: Sendable, Equatable { case running, stopped, stopping, unknown, errored - init(_ status: RuntimeStatus, errored: Bool = false) { + init(_ status: Core.Runtime.Status, errored: Bool = false) { if errored { self = .errored; return } switch status { case .running: self = .running diff --git a/Sources/ContainedApp/Resources/CHANGELOG.md b/Sources/ContainedApp/Resources/CHANGELOG.md index 7645a55b..e50471c0 100644 --- a/Sources/ContainedApp/Resources/CHANGELOG.md +++ b/Sources/ContainedApp/Resources/CHANGELOG.md @@ -94,7 +94,7 @@ ### Technical - Swift Package layout with `ContainedCore` for backend orchestration, `ContainedUI` for visual primitives, `ContainedUX` for interaction infrastructure, `ContainedApp` for app-owned SwiftUI/persistence/localization policy, and a tiny `Contained` executable launcher. -- `ContainerCommands` is the single source of truth for `container` argv construction and is covered by golden tests. +- `Core.Command` owns command previews and runtime invocation construction, with adapter-specific argv covered by golden tests. - `ContainedCore` now owns the shared runtime contract, Core orchestrator, Apple `container` adapter internals, typed async runtime methods, and typed stats streams. - `RunSpec` is the single source of truth for Run/Edit form state, validation, CLI preview, and actual execution. - `AppModel` owns bootstrap, client wiring, stores, refresh coordination, image updates, service lifecycle, config transfer, and resource-style lookup through focused extensions. diff --git a/Sources/ContainedApp/Services/Health/HealthMonitor.swift b/Sources/ContainedApp/Services/Health/HealthMonitor.swift index 43fec011..50e759ba 100644 --- a/Sources/ContainedApp/Services/Health/HealthMonitor.swift +++ b/Sources/ContainedApp/Services/Health/HealthMonitor.swift @@ -6,23 +6,23 @@ import ContainedCore @MainActor @Observable final class HealthCheckStore { - private var checks: [String: HealthCheck] + private var checks: [String: Core.Container.HealthCheck] private let defaults: UserDefaults private let key = "healthChecks" init(defaults: UserDefaults = .standard) { self.defaults = defaults if let data = defaults.data(forKey: key), - let decoded = try? JSONDecoder().decode([String: HealthCheck].self, from: data) { + let decoded = try? JSONDecoder().decode([String: Core.Container.HealthCheck].self, from: data) { checks = decoded } else { checks = [:] } } - func check(for id: String) -> HealthCheck? { checks[id] } + func check(for id: String) -> Core.Container.HealthCheck? { checks[id] } - func setCheck(_ check: HealthCheck, for id: String) { + func setCheck(_ check: Core.Container.HealthCheck, for id: String) { if check.command.isEmpty { checks[id] = nil } else { checks[id] = check } persist() } @@ -32,9 +32,9 @@ final class HealthCheckStore { persist() } - func backupSnapshot() -> [String: HealthCheck] { checks } + func backupSnapshot() -> [String: Core.Container.HealthCheck] { checks } - func applyBackup(_ snapshot: [String: HealthCheck], replace: Bool) { + func applyBackup(_ snapshot: [String: Core.Container.HealthCheck], replace: Bool) { if replace { checks = snapshot } else { checks.merge(snapshot) { _, imported in imported } } persist() @@ -59,14 +59,14 @@ final class HealthCheckStore { @MainActor @Observable final class HealthMonitor { - private(set) var statusByID: [String: HealthStatus] = [:] + private(set) var statusByID: [String: Core.Container.HealthStatus] = [:] private var consecutiveFailures: [String: Int] = [:] private var lastProbe: [String: Date] = [:] /// Fired once when a container transitions into the unhealthy state. - var onUnhealthy: ((ContainerSnapshot) -> Void)? + var onUnhealthy: ((Core.Container.Snapshot) -> Void)? - func evaluate(snapshots: [ContainerSnapshot], + func evaluate(snapshots: [Core.Container.Snapshot], store: HealthCheckStore, client: Core.Orchestrator, now: Date = Date()) async { @@ -89,15 +89,15 @@ final class HealthMonitor { let failures = passed ? 0 : (consecutiveFailures[id] ?? 0) + 1 consecutiveFailures[id] = failures - let newStatus = passed ? HealthStatus.healthy - : HealthDecision.status(consecutiveFailures: failures, retries: check.retries) + let newStatus = passed ? Core.Container.HealthStatus.healthy + : Core.Container.HealthDecision.status(consecutiveFailures: failures, retries: check.retries) let previous = statusByID[id] statusByID[id] = newStatus if newStatus == .unhealthy && previous != .unhealthy { onUnhealthy?(snapshot) } } } - func status(for id: String) -> HealthStatus { statusByID[id] ?? .unknown } + func status(for id: String) -> Core.Container.HealthStatus { statusByID[id] ?? .unknown } func reset() { statusByID.removeAll(); consecutiveFailures.removeAll(); lastProbe.removeAll() } } diff --git a/Tests/ContainedAppTests/AppErrorPresentationTests.swift b/Tests/ContainedAppTests/AppErrorPresentationTests.swift index f9423a14..e4ec8f6d 100644 --- a/Tests/ContainedAppTests/AppErrorPresentationTests.swift +++ b/Tests/ContainedAppTests/AppErrorPresentationTests.swift @@ -5,9 +5,9 @@ import Testing @Suite("App error presentation") struct AppErrorPresentationTests { @Test func composeErrorsUseAppOwnedCopy() { - #expect(AppErrorPresentation.message(for: ComposeError.invalid("")) == "Invalid compose file.") + #expect(AppErrorPresentation.message(for: Core.Compose.Error.invalid("")) == "Invalid compose file.") #expect( - AppErrorPresentation.message(for: ComposeError.invalid("Top level is not a mapping.")) + AppErrorPresentation.message(for: Core.Compose.Error.invalid("Top level is not a mapping.")) == "Invalid compose file: Top level is not a mapping." ) } diff --git a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift index 46eb9e61..f694a414 100644 --- a/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift +++ b/Tests/ContainedAppTests/ContainersStoreRefreshTests.swift @@ -101,7 +101,7 @@ struct ContainersStoreRefreshTests { @Test func graphMetricCaptionsUseContainerResourceLimits() { let snapshot = Self.snapshot(id: "web", cpus: 4, memoryInBytes: 1_024) - let delta = StatsDelta(id: "web", + let delta = Core.Metrics.StatsDelta(id: "web", cpuCoreFraction: 1, memoryUsageBytes: 512, memoryLimitBytes: 2_048, @@ -111,17 +111,17 @@ struct ContainersStoreRefreshTests { blockWriteBytesPerSec: 40, numProcesses: 2) - #expect(GraphMetric.cpu.value(from: delta, snapshot: snapshot) == 0.25) - #expect(GraphMetric.memory.value(from: delta, snapshot: snapshot) == 0.5) - #expect(GraphMetric.netRx.value(from: delta, snapshot: snapshot) == 10) - #expect(GraphMetric.netTx.value(from: delta, snapshot: snapshot) == 20) - #expect(GraphMetric.diskRead.value(from: delta, snapshot: snapshot) == 30) - #expect(GraphMetric.diskWrite.value(from: delta, snapshot: snapshot) == 40) - #expect(GraphMetric.cpu.caption(from: delta, snapshot: snapshot) == "25%") - #expect(GraphMetric.memory.caption(from: delta, snapshot: snapshot) == "50%") - #expect(GraphMetric.cpu.chipCaption(from: delta, snapshot: snapshot) == "25%") - #expect(GraphMetric.memory.chipCaption(from: delta, snapshot: snapshot) == "50%") - #expect(GraphMetric.memoryLimitBytes(for: delta, snapshot: snapshot) == 1_024) + #expect(Core.Metrics.GraphMetric.cpu.value(from: delta, snapshot: snapshot) == 0.25) + #expect(Core.Metrics.GraphMetric.memory.value(from: delta, snapshot: snapshot) == 0.5) + #expect(Core.Metrics.GraphMetric.netRx.value(from: delta, snapshot: snapshot) == 10) + #expect(Core.Metrics.GraphMetric.netTx.value(from: delta, snapshot: snapshot) == 20) + #expect(Core.Metrics.GraphMetric.diskRead.value(from: delta, snapshot: snapshot) == 30) + #expect(Core.Metrics.GraphMetric.diskWrite.value(from: delta, snapshot: snapshot) == 40) + #expect(Core.Metrics.GraphMetric.cpu.caption(from: delta, snapshot: snapshot) == "25%") + #expect(Core.Metrics.GraphMetric.memory.caption(from: delta, snapshot: snapshot) == "50%") + #expect(Core.Metrics.GraphMetric.cpu.chipCaption(from: delta, snapshot: snapshot) == "25%") + #expect(Core.Metrics.GraphMetric.memory.chipCaption(from: delta, snapshot: snapshot) == "50%") + #expect(Core.Metrics.GraphMetric.memoryLimitBytes(for: delta, snapshot: snapshot) == 1_024) } @Test func percentFormattingUsesDecimalsOnlyWhenUseful() { @@ -136,7 +136,7 @@ struct ContainersStoreRefreshTests { @Test func graphMetricChipCaptionsExposeSmallPercentChanges() { let snapshot = Self.snapshot(id: "web", cpus: 8, memoryInBytes: 1_000_000) - let delta = StatsDelta(id: "web", + let delta = Core.Metrics.StatsDelta(id: "web", cpuCoreFraction: 0.032, memoryUsageBytes: 4_000, memoryLimitBytes: 1_000_000, @@ -146,24 +146,24 @@ struct ContainersStoreRefreshTests { blockWriteBytesPerSec: 40, numProcesses: 2) - #expect(GraphMetric.cpu.caption(from: delta, snapshot: snapshot) == "0.4%") - #expect(GraphMetric.cpu.chipCaption(from: delta, snapshot: snapshot) == "0.4%") - #expect(GraphMetric.memory.chipCaption(from: delta, snapshot: snapshot) == "0.4%") + #expect(Core.Metrics.GraphMetric.cpu.caption(from: delta, snapshot: snapshot) == "0.4%") + #expect(Core.Metrics.GraphMetric.cpu.chipCaption(from: delta, snapshot: snapshot) == "0.4%") + #expect(Core.Metrics.GraphMetric.memory.chipCaption(from: delta, snapshot: snapshot) == "0.4%") - let machine = StatsNormalizationContext(mode: .machine, + let machine = Core.Metrics.NormalizationContext(mode: .machine, machineCPUs: 16, machineMemoryBytes: 2_000_000) - #expect(GraphMetric.cpu.chipCaption(from: delta, + #expect(Core.Metrics.GraphMetric.cpu.chipCaption(from: delta, snapshot: snapshot, normalization: machine) == "0.2%") - #expect(GraphMetric.memory.chipCaption(from: delta, + #expect(Core.Metrics.GraphMetric.memory.chipCaption(from: delta, snapshot: snapshot, normalization: machine) == "0.2%") } @Test func graphMetricCaptionsKeepTinyContainerCPUVisible() { let snapshot = Self.snapshot(id: "web", cpus: 4, memoryInBytes: 1_000_000) - let delta = StatsDelta(id: "web", + let delta = Core.Metrics.StatsDelta(id: "web", cpuCoreFraction: 0.0012, memoryUsageBytes: 4_000, memoryLimitBytes: 1_000_000, @@ -173,17 +173,17 @@ struct ContainersStoreRefreshTests { blockWriteBytesPerSec: 40, numProcesses: 2) - #expect(GraphMetric.cpu.value(from: delta, snapshot: snapshot) == 0.0003) - #expect(GraphMetric.cpu.caption(from: delta, snapshot: snapshot) == "0.03%") - #expect(GraphMetric.cpu.chipCaption(from: delta, snapshot: snapshot) == "0.03%") + #expect(Core.Metrics.GraphMetric.cpu.value(from: delta, snapshot: snapshot) == 0.0003) + #expect(Core.Metrics.GraphMetric.cpu.caption(from: delta, snapshot: snapshot) == "0.03%") + #expect(Core.Metrics.GraphMetric.cpu.chipCaption(from: delta, snapshot: snapshot) == "0.03%") } @Test func graphMetricCaptionsCanUseMachineResourceLimits() { let snapshot = Self.snapshot(id: "web", cpus: 4, memoryInBytes: 1_024) - let normalization = StatsNormalizationContext(mode: .machine, + let normalization = Core.Metrics.NormalizationContext(mode: .machine, machineCPUs: 8, machineMemoryBytes: 4_096) - let delta = StatsDelta(id: "web", + let delta = Core.Metrics.StatsDelta(id: "web", cpuCoreFraction: 1, memoryUsageBytes: 512, memoryLimitBytes: 2_048, @@ -193,11 +193,11 @@ struct ContainersStoreRefreshTests { blockWriteBytesPerSec: 40, numProcesses: 2) - #expect(GraphMetric.cpu.value(from: delta, snapshot: snapshot, normalization: normalization) == 0.125) - #expect(GraphMetric.memory.value(from: delta, snapshot: snapshot, normalization: normalization) == 0.125) - #expect(GraphMetric.cpu.caption(from: delta, snapshot: snapshot, normalization: normalization) == "13%") - #expect(GraphMetric.memory.caption(from: delta, snapshot: snapshot, normalization: normalization) == "13%") - #expect(GraphMetric.memoryLimitBytes(for: delta, snapshot: snapshot, normalization: normalization) == 4_096) + #expect(Core.Metrics.GraphMetric.cpu.value(from: delta, snapshot: snapshot, normalization: normalization) == 0.125) + #expect(Core.Metrics.GraphMetric.memory.value(from: delta, snapshot: snapshot, normalization: normalization) == 0.125) + #expect(Core.Metrics.GraphMetric.cpu.caption(from: delta, snapshot: snapshot, normalization: normalization) == "13%") + #expect(Core.Metrics.GraphMetric.memory.caption(from: delta, snapshot: snapshot, normalization: normalization) == "13%") + #expect(Core.Metrics.GraphMetric.memoryLimitBytes(for: delta, snapshot: snapshot, normalization: normalization) == 4_096) } @Test func graphMetricHistoryValuesUseCurrentNormalization() { @@ -211,16 +211,16 @@ struct ContainersStoreRefreshTests { diskReadBytesPerSec: 30, diskWriteBytesPerSec: 40) - #expect(GraphMetric.cpu.value(from: sample, snapshot: snapshot) == 0.25) - #expect(GraphMetric.memory.value(from: sample, snapshot: snapshot, memoryFallbackBytes: 2_048) == 0.5) - #expect(GraphMetric.netRx.value(from: sample, snapshot: snapshot) == 10) - #expect(GraphMetric.diskWrite.value(from: sample, snapshot: snapshot) == 40) + #expect(Core.Metrics.GraphMetric.cpu.value(from: sample, snapshot: snapshot) == 0.25) + #expect(Core.Metrics.GraphMetric.memory.value(from: sample, snapshot: snapshot, memoryFallbackBytes: 2_048) == 0.5) + #expect(Core.Metrics.GraphMetric.netRx.value(from: sample, snapshot: snapshot) == 10) + #expect(Core.Metrics.GraphMetric.diskWrite.value(from: sample, snapshot: snapshot) == 40) - let machine = StatsNormalizationContext(mode: .machine, + let machine = Core.Metrics.NormalizationContext(mode: .machine, machineCPUs: 8, machineMemoryBytes: 4_096) - #expect(GraphMetric.cpu.value(from: sample, snapshot: snapshot, normalization: machine) == 0.125) - #expect(GraphMetric.memory.value(from: sample, + #expect(Core.Metrics.GraphMetric.cpu.value(from: sample, snapshot: snapshot, normalization: machine) == 0.125) + #expect(Core.Metrics.GraphMetric.memory.value(from: sample, snapshot: snapshot, normalization: machine, memoryFallbackBytes: 2_048) == 0.125) @@ -255,7 +255,7 @@ struct ContainersStoreRefreshTests { #expect(containerPoints.map { $0.netRxKBPerSec } == [1, 2]) #expect(containerPoints.map { $0.netTxKBPerSec } == [2, 4]) - let machine = StatsNormalizationContext(mode: .machine, + let machine = Core.Metrics.NormalizationContext(mode: .machine, machineCPUs: 4, machineMemoryBytes: 2_048) let machinePoints = HistoryChartPoint.points(from: samples, @@ -280,7 +280,7 @@ struct ContainersStoreRefreshTests { #expect(metrics.values(for: .cpu).last == 0.5) #expect(metrics.values(for: .memory).last == 0.5) - store.configureStatsNormalization(StatsNormalizationContext(mode: .machine, + store.configureStatsNormalization(Core.Metrics.NormalizationContext(mode: .machine, machineCPUs: 4, machineMemoryBytes: 2_000)) @@ -288,7 +288,7 @@ struct ContainersStoreRefreshTests { #expect(metrics.values(for: .memory) == [0.25]) } - private static func streamedStats(cpuCoreFraction: Double, networkRxBytes: UInt64) -> RuntimeStatsSnapshot { + private static func streamedStats(cpuCoreFraction: Double, networkRxBytes: UInt64) -> Core.Metrics.RuntimeStatsSnapshot { streamedStats(id: "fixture-web", cpuCoreFraction: cpuCoreFraction, memoryUsageBytes: 2_322_432, @@ -299,8 +299,8 @@ struct ContainersStoreRefreshTests { cpuCoreFraction: Double, memoryUsageBytes: UInt64, memoryLimitBytes: UInt64 = 1_073_741_824, - networkRxBytes: UInt64) -> RuntimeStatsSnapshot { - RuntimeStatsSnapshot(id: id, + networkRxBytes: UInt64) -> Core.Metrics.RuntimeStatsSnapshot { + Core.Metrics.RuntimeStatsSnapshot(id: id, cpuCoreFraction: cpuCoreFraction, memoryUsageBytes: memoryUsageBytes, memoryLimitBytes: memoryLimitBytes, @@ -311,7 +311,7 @@ struct ContainersStoreRefreshTests { numProcesses: 1) } - private static func snapshot(id: String, cpus: Int, memoryInBytes: UInt64) -> ContainerSnapshot { + private static func snapshot(id: String, cpus: Int, memoryInBytes: UInt64) -> Core.Container.Snapshot { let payload = """ { "configuration": { @@ -327,7 +327,7 @@ struct ContainersStoreRefreshTests { "status": { "state": "running" } } """ - return try! JSONDecoder().decode(ContainerSnapshot.self, from: Data(payload.utf8)) + return try! JSONDecoder().decode(Core.Container.Snapshot.self, from: Data(payload.utf8)) } } @@ -343,13 +343,13 @@ private final class TestClock { } } -private actor RecordingRunner: CommandRunning { +private actor RecordingRunner: Core.Command.Running { private var calls: [[String]] = [] private var statsRuns = 0 func run(_ arguments: [String], stdin: Data?, - priority: CommandExecutionPriority) async throws -> Data { + priority: Core.Command.ExecutionPriority) async throws -> Data { calls.append(arguments) switch arguments.first { case "list": @@ -362,7 +362,7 @@ private actor RecordingRunner: CommandRunning { } } - nonisolated func stream(_ arguments: [String], priority: CommandExecutionPriority) -> AsyncThrowingStream { + nonisolated func stream(_ arguments: [String], priority: Core.Command.ExecutionPriority) -> AsyncThrowingStream { AsyncThrowingStream { continuation in continuation.finish() } } diff --git a/Tests/ContainedAppTests/PreviewFixtureMappingTests.swift b/Tests/ContainedAppTests/PreviewFixtureMappingTests.swift index 9927b65e..b4ec0fde 100644 --- a/Tests/ContainedAppTests/PreviewFixtureMappingTests.swift +++ b/Tests/ContainedAppTests/PreviewFixtureMappingTests.swift @@ -17,7 +17,7 @@ struct PreviewFixtureMappingTests { } private extension Personalization { - static func fixture(for snapshot: ContainerSnapshot) -> Personalization { + static func fixture(for snapshot: Core.Container.Snapshot) -> Personalization { var style = Personalization() style.nickname = snapshot.displayName style.icon = "shippingbox.fill" diff --git a/Tests/ContainedAppTests/RunSpecTests.swift b/Tests/ContainedAppTests/RunSpecTests.swift index ac8fb024..7f69271a 100644 --- a/Tests/ContainedAppTests/RunSpecTests.swift +++ b/Tests/ContainedAppTests/RunSpecTests.swift @@ -296,7 +296,7 @@ struct RunSpecTests { @Test func adoptsPulledImageDefaultsIntoEmptyRunFields() throws { let data = try Data(contentsOf: fixturesURL.appending(path: "image-inspect.json")) - let images = try ContainerJSON.decode([ImageResource].self, from: data) + let images = try Core.Container.JSON.decode([Core.Image.Resource].self, from: data) var spec = RunSpec() spec.image = "alpine" @@ -315,7 +315,7 @@ struct RunSpecTests { @Test func adoptingImageDefaultsDoesNotOverwriteExistingEdits() throws { let data = try Data(contentsOf: fixturesURL.appending(path: "image-inspect.json")) - let images = try ContainerJSON.decode([ImageResource].self, from: data) + let images = try Core.Container.JSON.decode([Core.Image.Resource].self, from: data) var spec = RunSpec() spec.image = "alpine" spec.command = "custom" @@ -389,7 +389,7 @@ struct RunSpecTests { } } """ - let snapshot = try JSONDecoder().decode(ContainerSnapshot.self, from: Data(json.utf8)) + let snapshot = try JSONDecoder().decode(Core.Container.Snapshot.self, from: Data(json.utf8)) let spec = RunSpec(from: snapshot.configuration) #expect(spec.image == "example/app:1") @@ -442,15 +442,15 @@ struct RunSpecTests { } } -private struct RunSpecTestRunner: CommandRunning { +private struct RunSpecTestRunner: Core.Command.Running { func run(_ arguments: [String], stdin: Data?, - priority: CommandExecutionPriority) async throws -> Data { + priority: Core.Command.ExecutionPriority) async throws -> Data { Data() } func stream(_ arguments: [String], - priority: CommandExecutionPriority) -> AsyncThrowingStream { + priority: Core.Command.ExecutionPriority) -> AsyncThrowingStream { AsyncThrowingStream { $0.finish() } } } diff --git a/Tests/ContainedAppTests/SystemVolumeInventoryTests.swift b/Tests/ContainedAppTests/SystemVolumeInventoryTests.swift index 4b184f04..e8cbeb7c 100644 --- a/Tests/ContainedAppTests/SystemVolumeInventoryTests.swift +++ b/Tests/ContainedAppTests/SystemVolumeInventoryTests.swift @@ -6,10 +6,10 @@ import ContainedCore @Suite("System volume inventory") struct SystemVolumeInventoryTests { @Test func classifiesNamedBindAndAnonymousMounts() throws { - let volume = try decode(VolumeResource.self, from: """ + let volume = try decode(Core.Volume.Resource.self, from: """ {"configuration":{"name":"data","format":"apfs","sizeInBytes":1024}} """) - let snapshot = try decode(ContainerSnapshot.self, from: """ + let snapshot = try decode(Core.Container.Snapshot.self, from: """ { "id": "web", "status": {"state": "running"}, diff --git a/docs/README.md b/docs/README.md index 504b0cb2..2123862c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,7 @@ -# Contained Docs +# Contained Documentation -Contained documentation is organized by audience and ownership. +Contained docs are maintained beside the code so architecture, workflow, and +release changes can be reviewed with implementation changes. ## App @@ -33,9 +34,22 @@ Contained documentation is organized by audience and ownership. - [Contributing](development/Contributing.md) - [Issues and Discussions](development/Issues-and-Discussions.md) +- [Documentation Map](development/Documentation-Map.md) ## Release - [Release Runbook](release/Release.md) -Package-specific docs live beside each package under `Packages//`. +## Packages + +- [ContainedCore](../Packages/ContainedCore/README.md) +- [ContainedCore DocC](../Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md) +- [ContainedUI](../Packages/ContainedUI/README.md) +- [ContainedUI DocC](../Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md) +- [ContainedUX](../Packages/ContainedUX/README.md) +- [ContainedUX DocC](../Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md) + +## Wiki + +The checked-in [wiki map](wiki/README.md) describes how these maintained docs +map to the separate GitHub wiki repository. diff --git a/docs/app/Localization.md b/docs/app/Localization.md index c244698a..ee71208c 100644 --- a/docs/app/Localization.md +++ b/docs/app/Localization.md @@ -18,7 +18,7 @@ color/tint display names. Package failures follow the same ownership rule. Reusable targets should throw typed errors with stable codes/context, usually by conforming to -`ContainedPackageError`. The app maps those errors through +`Core.Error.PackageError`. The app maps those errors through `AppErrorPresentation` and `AppText`, then decides whether to show a toast, inline error, alert, or Activity entry. Do not attempt to localize arbitrary backend stderr; preserve it as runtime-provided detail unless an adapter can map diff --git a/docs/architecture/Architecture.md b/docs/architecture/Architecture.md index 9c46b4b1..8458238e 100644 --- a/docs/architecture/Architecture.md +++ b/docs/architecture/Architecture.md @@ -30,7 +30,7 @@ identifiers such as raw values, runtime descriptors, package error codes, or backend command output. Package errors follow the same ownership boundary. Core exposes -stable codes and compact context through `ContainedPackageError`; the app maps +stable codes and compact context through `Core.Error.PackageError`; the app maps those failures through `AppErrorPresentation` and `AppText` before showing toasts, inline errors, alerts, or Activity history. Arbitrary backend stderr is preserved as runtime-provided detail unless an adapter maps it to a known typed diff --git a/docs/development/Contributing.md b/docs/development/Contributing.md index a714e7e5..acf3a40a 100644 --- a/docs/development/Contributing.md +++ b/docs/development/Contributing.md @@ -4,8 +4,8 @@ Forks, issues, and pull requests are welcome. Contained is **source-available** ## Issues And Discussions -Start with the [wiki](https://github.com/tdeverx/contained-app/wiki), then choose -the place that fits: +Start with the [repo docs index](/docs/README.md), then choose the place that +fits: - Use **Discussions Q&A** for setup help, usage questions, and unclear behavior that is not yet an actionable bug. @@ -75,6 +75,7 @@ docs/features/ feature and workflow docs docs/architecture/ architecture and package-boundary docs docs/development/ contribution, issue, and repo workflow docs docs/release/ release and updater runbooks +docs/wiki/ file map and sidebar contract for the separate GitHub wiki repo appcast.xml Sparkle feed at the root of each release branch ``` @@ -90,30 +91,31 @@ appcast.xml Sparkle feed at the root of each release branch from `Sources/ContainedApp`; reusable enum labels and dynamic templates should flow through `AppText` with English fallbacks. - **The app owns package error presentation.** Reusable targets should throw - typed errors with stable codes/context, preferably `ContainedPackageError`. + typed errors with stable codes/context, preferably `Core.Error.PackageError`. Map those failures through `AppErrorPresentation`/`AppText` in `Sources/ContainedApp` before showing toasts, inline errors, alerts, or Activity entries. Preserve arbitrary backend stderr as runtime detail unless an adapter can classify it as a known typed case. - **Package docs live with the package.** Keep package-local import/setup/examples in each `Packages//README.md`, with DocC landing pages under each target's `.docc` catalog. Keep app-level architecture and workflow guidance under `docs/`. +- **The wiki map lives in the repo.** GitHub's wiki is a separate repository. Keep maintained docs in `docs/` and package directories, then update `docs/wiki/File-Map.md` and `docs/wiki/_Sidebar.md` when a doc should appear in the wiki. - **Xcode opens the workspace.** `Contained.xcworkspace` points at the native `Contained.xcodeproj` and local package manifests. The Xcode target links the root package's `ContainedApp` product and builds/runs a real `Contained.app`; SwiftPM remains the release, CI, bundle, signing, notarization, and appcast source of truth. - **Use Xcode for functional SwiftUI loops.** The shared `Contained` scheme builds/runs the app and runs `ContainedAppTests`; `ContainedAppTests` is the focused app-test scheme; package schemes come from the package manifests; `ContainedPreviews` is reserved for preview-oriented development. - **Navigation infrastructure belongs in `ContainedUX` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in `Sources/ContainedApp` until they can cross the boundary without app policy. - **Every backend action goes through `ContainedCore`.** Apple `container` argv builders and adapter clients are Core internals with golden tests. The UI never assembles argv inline; app stores call `Core.Orchestrator`. - **Runtime-facing code should use `Core.*` namespaces.** The Apple `container` implementation is the only enabled adapter today. Future Docker-compatible, Podman, Lima-backed, remote, or other runtimes should be sibling adapter folders inside Core and advertise capability differences through `Core.Runtime.Descriptor`. Create/import flows should translate through `Core.Container.CreateRequest` and carry `Core.Runtime.Kind` per container, not as a global app setting. -- **Pure decision logic is factored into `ContainedCore`** (`RestartDecision`, `HealthDecision`, compose ordering, runtime translation) and unit-tested without spawning processes. +- **Pure decision logic is factored into `ContainedCore`** (`Core.Container.RestartDecision`, `Core.Container.HealthDecision`, compose ordering, runtime translation) and unit-tested without spawning processes. - **No `contained.*` personalization labels.** Card styles and healthchecks live in local stores. Only `contained.restart` and `contained.stack` are written (they must round-trip through the container). - **Never put secrets or personal data in test fixtures.** Fixtures are captured CLI output — scrub tokens, domains, and paths before committing. (`.gitignore` blocks signing material; push protection is on.) - **Match the surrounding style** — comment density, naming, Liquid Glass idioms. Prefer app-facing design routes such as `UI.Panel.Header`, `UI.Panel.Section`, `UI.Panel.Scaffold`, `UI.Card.Scaffold`, `UI.Action.Group`, `UI.Action.TextButton`, `UI.Action.ToggleButton`, `UI.Command.PreviewBar`, and contextual element tokens. Do not add app-local spacing, padding, radius, shadow, material, opacity, material button styles, or badge/keycap/status-dot recipes; add them to `ContainedUI` first. - **Gate debug-only tools at compile time.** Use `#if CONTAINED_DEBUG_TOOLS` for debug menus, diagnostics, or local-only inspection surfaces. Fixture-backed samples use `CONTAINED_CORE_FIXTURES` in fixture/test/preview/sandbox-only targets, never plain `DEBUG`. SwiftPM defines `CONTAINED_DEBUG_TOOLS` only for debug builds, so release bundles exclude that code instead of merely hiding it at runtime. - **Keep the sidebar fallback working.** Toolbar-first UI and toolbar panel navigation are experimental gates, not replacements for the classic shell. -- **Sync docs with behavior.** If behavior, settings, routes, or user-facing wording changes, update the matching page under `docs/app`, `docs/features`, `docs/development`, `docs/architecture`, or `docs/release` and keep README links current. +- **Sync docs with behavior.** If behavior, settings, routes, or user-facing wording changes, update the matching page under `docs/app`, `docs/features`, `docs/development`, `docs/architecture`, or `docs/release`; keep README links and the wiki map current. - **Preserve update build numbers.** `scripts/version-info.sh` is the single build-number source of truth; beta/stable workflows must pass the retained `BUILD` into `scripts/bundle.sh` and merge promoted appcast items into the nightly feed. - **Keep code scanning intentional.** `.github/workflows/codeql.yml` is the repository-owned CodeQL setup. GitHub Actions workflow analysis runs on PRs and pushes that touch source, scripts, workflows, package files, or tests, plus a weekly scheduled baseline. Swift analysis is scheduled/manual because Swift CodeQL currently takes too long to be a healthy per-PR gate. Appcast-only, docs-only, changelog-resource-only, and change-fragment-only commits are ignored so generated release feed commits do not burn macOS scan minutes. - **Write release notes at the right level.** Keep `CHANGELOG.md` curated and version-level: use the base version section, such as `## [1.0.0]`, for durable user-facing release notes. Put PR/build deltas in `changes/unreleased/` fragments by default, not in `CHANGELOG.md` as a running implementation inventory. Use `changes/beta/` or `changes/nightly/` only for channel-specific notes. `scripts/collect-changes.sh` can compile those fragments for a directory or git range. When no explicit `CHANGES` or `CHANGES_DIR` source is provided, Beta/Nightly notes first try the previous matching appcast item plus the changelog/change-fragment git delta, then fall back to channel sections and `Unreleased` only when no channel item exists. Stable ships full notes only; Beta/Nightly ship channel changes plus full notes. - **Let CI check invariants, not fix them.** `scripts/ci-validate.sh` checks bundled changelog sync, shell syntax, workflow YAML syntax, Stable/Beta/Nightly release-note ordering, and PR release-note coverage when given a base ref. If `CHANGELOG.md` changes, run `./scripts/sync-changelog-resource.sh` locally and commit the bundled resource; CI uses `--check` so drift fails loudly. - **Use `no-release-note` narrowly.** PR CI accepts the label only through `NO_RELEASE_NOTE=1`; reserve it for docs/meta/dependency-only maintenance that does not change shipped behavior, scripts, workflows, tests, or source. Dependabot applies it automatically to grouped dependency update PRs. -- **Use `wiki-approved` for direct wiki-impacting changes only when a maintainer has reviewed the docs impact.** The wiki sync automation prototype is tracked separately in issue #26 and should not be assumed to exist until that issue is resolved. +- **Use `wiki-approved` for direct wiki-impacting changes only when a maintainer has reviewed the docs impact.** Repo docs stay authoritative; wiki work should follow `docs/wiki/File-Map.md`. ## Before a PR diff --git a/docs/development/Documentation-Map.md b/docs/development/Documentation-Map.md new file mode 100644 index 00000000..6683a1a5 --- /dev/null +++ b/docs/development/Documentation-Map.md @@ -0,0 +1,60 @@ +# Documentation Map + +This page explains where documentation belongs in the repo and how it maps to +the separate GitHub wiki repository. + +## Repo Docs + +Use `docs/` for maintained product, feature, architecture, development, and +release documentation. + +| Location | Belongs here | +| --- | --- | +| `docs/app/` | User-facing setup, updates, settings, troubleshooting, localization, and app behavior | +| `docs/features/` | Feature workflows such as containers, images, resources, creation, run/edit, Compose import, and command palette | +| `docs/architecture/` | Package boundaries, runtime architecture, UI/UX ownership, and cross-cutting technical decisions | +| `docs/development/` | Contribution workflow, issue routing, documentation ownership, and maintainer process | +| `docs/release/` | Release, packaging, appcast, signing, and channel runbooks | + +Keep `docs/README.md` as the entry point for the maintained docs tree. + +## Package Docs + +Reusable package docs live beside their package so API examples change with the +code they describe. + +| Package | README | DocC | +| --- | --- | --- | +| `ContainedCore` | `Packages/ContainedCore/README.md` | `Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md` | +| `ContainedUI` | `Packages/ContainedUI/README.md` | `Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md` | +| `ContainedUX` | `Packages/ContainedUX/README.md` | `Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md` | + +Package docs should use current nested APIs only: `Core.*`, `UI.*`, and +`UX.*`. Packages receive app-supplied copy and should not document localized +strings as package-owned resources. + +## Wiki Map + +GitHub wikis are stored in a separate repository. This repo keeps the wiki sync +contract under `docs/wiki/` instead of duplicating wiki content. + +| File | Purpose | +| --- | --- | +| `docs/wiki/README.md` | Explains the wiki sync model | +| `docs/wiki/File-Map.md` | Maps maintained source docs to intended wiki paths | +| `docs/wiki/_Sidebar.md` | Defines the intended wiki sidebar structure | + +When a maintained doc is added, renamed, or removed, update +`docs/wiki/File-Map.md` and `docs/wiki/_Sidebar.md` in the same change. + +## Naming Rules + +- Use PascalCase for Swift/domain folders and lowercase for repo infrastructure + such as `docs` and `scripts`. +- Use hyphenated names for multi-word markdown and shell script files. +- Name Swift files after the concrete type, namespace segment, or behavior they + define. +- Keep `+` Swift filenames only when SwiftPM target basename uniqueness requires + them. +- Remove wording that describes migration history, bridge shims, or previous + package names once the final API is in place. diff --git a/docs/features/Compose-Import.md b/docs/features/Compose-Import.md index 7e5ea4ce..df7a8ad4 100644 --- a/docs/features/Compose-Import.md +++ b/docs/features/Compose-Import.md @@ -42,6 +42,6 @@ CLI preview stays visible, and unsupported values are reported rather than silently guessed. Runtime-specific import rules belong in the adapter. Apple container currently -owns the Compose-to-`ContainerCreateRequest` translation; future Docker-compatible +owns the Compose-to-`Core.Container.CreateRequest` translation; future Docker-compatible or other adapters should return the same standardized fields with their own warnings and unsupported-operation plans. diff --git a/docs/features/Run-Edit-Form.md b/docs/features/Run-Edit-Form.md index e0d319f5..303dad2c 100644 --- a/docs/features/Run-Edit-Form.md +++ b/docs/features/Run-Edit-Form.md @@ -52,7 +52,7 @@ These controls deliberately do not mirror the CLI one-to-one: | UI control | CLI output | Notes | | --- | --- | --- | -| Core picker | runtime adapter selection | Apple container is currently the only available core, so the picker is disabled. Future adapters fill the same form fields through `ContainerCreateRequest` instead of adding backend-specific forms. | +| Core picker | runtime adapter selection | Apple container is currently the only available core, so the picker is disabled. Future adapters fill the same form fields through `Core.Container.CreateRequest` instead of adding backend-specific forms. | | Platform picker | `--platform ` | The UI offers common Apple-silicon presets plus Custom. Separate `--os` and `--arch` controls are omitted to avoid duplicate platform concepts because `--platform` takes precedence. | | Memory limit toggle + slider | `--memory ` | The user chooses a host-bounded amount; the app formats it as `M` or `G`. | | Shared memory toggle + slider | `--shm-size ` | Same UI pattern as memory, with a small default of `64M`. | diff --git a/docs/wiki/File-Map.md b/docs/wiki/File-Map.md new file mode 100644 index 00000000..08530f24 --- /dev/null +++ b/docs/wiki/File-Map.md @@ -0,0 +1,35 @@ +# Wiki File Map + +This map lists each maintained documentation source and its intended location in +the separate GitHub wiki repository. + +| Maintained source | Wiki path | Wiki title | +| --- | --- | --- | +| `docs/app/Home.md` | `App/Home.md` | Home | +| `docs/app/Installation.md` | `App/Installation.md` | Installation | +| `docs/app/Keyboard-Shortcuts.md` | `App/Keyboard-Shortcuts.md` | Keyboard Shortcuts | +| `docs/app/Localization.md` | `App/Localization.md` | Localization | +| `docs/app/System-Settings.md` | `App/System-Settings.md` | System Settings | +| `docs/app/Troubleshooting.md` | `App/Troubleshooting.md` | Troubleshooting | +| `docs/app/Updates.md` | `App/Updates.md` | Updates | +| `docs/features/Features.md` | `Features/Overview.md` | Feature Overview | +| `docs/features/Containers.md` | `Features/Containers.md` | Containers | +| `docs/features/Images.md` | `Features/Images.md` | Images | +| `docs/features/Resources.md` | `Features/Resources.md` | Resources | +| `docs/features/Creation-Workflow.md` | `Features/Creation-Workflow.md` | Creation Workflow | +| `docs/features/Run-Edit-Form.md` | `Features/Run-Edit-Form.md` | Run / Edit Form | +| `docs/features/Compose-Import.md` | `Features/Compose-Import.md` | Compose Import | +| `docs/features/Command-Palette.md` | `Features/Command-Palette.md` | Command Palette | +| `docs/architecture/Architecture.md` | `Architecture/Overview.md` | Architecture | +| `docs/architecture/Runtime-Adapters.md` | `Architecture/Runtime-Adapters.md` | Runtime Adapters | +| `docs/architecture/Design-System.md` | `Architecture/UI-UX.md` | UI And UX | +| `docs/development/Contributing.md` | `Development/Contributing.md` | Contributing | +| `docs/development/Issues-and-Discussions.md` | `Development/Issues-and-Discussions.md` | Issues And Discussions | +| `docs/development/Documentation-Map.md` | `Development/Documentation-Map.md` | Documentation Map | +| `docs/release/Release.md` | `Release/Release.md` | Release Runbook | +| `Packages/ContainedCore/README.md` | `Packages/ContainedCore.md` | ContainedCore | +| `Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md` | `Packages/ContainedCore-DocC.md` | ContainedCore DocC | +| `Packages/ContainedUI/README.md` | `Packages/ContainedUI.md` | ContainedUI | +| `Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md` | `Packages/ContainedUI-DocC.md` | ContainedUI DocC | +| `Packages/ContainedUX/README.md` | `Packages/ContainedUX.md` | ContainedUX | +| `Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md` | `Packages/ContainedUX-DocC.md` | ContainedUX DocC | diff --git a/docs/wiki/README.md b/docs/wiki/README.md index c69504b6..2d6cf819 100644 --- a/docs/wiki/README.md +++ b/docs/wiki/README.md @@ -1,12 +1,23 @@ -# Wiki Mirror Note +# Wiki Sync Contract -The wiki mirror is maintained through structured docs: +The GitHub wiki is a separate repository from the app source tree. Contained +keeps the maintained docs in this repository and stores only the wiki sync +contract here. -- [`docs/app`](../app) -- [`docs/features`](../features) -- [`docs/development`](../development) -- [`docs/architecture`](../architecture) -- [`docs/release`](../release) +## Source Of Truth -When syncing GitHub wiki content, use those structured files as the maintained -source and adapt page names only at the wiki boundary. +- `docs/` contains maintained app, feature, architecture, development, and + release docs. +- `Packages//README.md` contains package-level usage docs. +- `Packages//Sources//*.docc/*.md` contains package + DocC landing pages. + +## Wiki Files + +- [File Map](File-Map.md) maps each maintained source file to its intended wiki + path and page title. +- [_Sidebar](_Sidebar.md) describes the intended GitHub wiki sidebar. + +Wiki-local links, page titles, and navigation labels may be adjusted when +syncing into the separate wiki repo, but the mapped source file remains the +maintained content owner. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 80cfd384..75c5a27f 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -1,28 +1,36 @@ # Contained -This sidebar points to the maintained structured docs. - -- [Home](/docs/app/Home.md) -- [Features](/docs/features/Features.md) - - [Containers](/docs/features/Containers.md) - - [Images](/docs/features/Images.md) - - [Resources](/docs/features/Resources.md) - - [System & Settings](/docs/app/System-Settings.md) -- Workflows - - [Creation Workflow](/docs/features/Creation-Workflow.md) - - [Run / Edit Form](/docs/features/Run-Edit-Form.md) - - [Compose Import](/docs/features/Compose-Import.md) - - [Command Palette](/docs/features/Command-Palette.md) - - [Updates](/docs/app/Updates.md) -- Start - - [Installation](/docs/app/Installation.md) - - [Keyboard Shortcuts](/docs/app/Keyboard-Shortcuts.md) - - [Troubleshooting](/docs/app/Troubleshooting.md) -- Maintainers - - [Architecture](/docs/architecture/Architecture.md) - - [Runtime Adapters](/docs/architecture/Runtime-Adapters.md) - - [Design System](/docs/architecture/Design-System.md) - - [Localization](/docs/app/Localization.md) - - [Release](/docs/release/Release.md) -- [Contributing](/docs/development/Contributing.md) -- [Issues and Discussions](/docs/development/Issues-and-Discussions.md) +- [Home](App/Home.md) +- App + - [Installation](App/Installation.md) + - [Keyboard Shortcuts](App/Keyboard-Shortcuts.md) + - [Localization](App/Localization.md) + - [System Settings](App/System-Settings.md) + - [Troubleshooting](App/Troubleshooting.md) + - [Updates](App/Updates.md) +- Features + - [Overview](Features/Overview.md) + - [Containers](Features/Containers.md) + - [Images](Features/Images.md) + - [Resources](Features/Resources.md) + - [Creation Workflow](Features/Creation-Workflow.md) + - [Run / Edit Form](Features/Run-Edit-Form.md) + - [Compose Import](Features/Compose-Import.md) + - [Command Palette](Features/Command-Palette.md) +- Architecture + - [Overview](Architecture/Overview.md) + - [Runtime Adapters](Architecture/Runtime-Adapters.md) + - [UI And UX](Architecture/UI-UX.md) +- Development + - [Contributing](Development/Contributing.md) + - [Issues And Discussions](Development/Issues-and-Discussions.md) + - [Documentation Map](Development/Documentation-Map.md) +- Release + - [Release Runbook](Release/Release.md) +- Packages + - [ContainedCore](Packages/ContainedCore.md) + - [ContainedCore DocC](Packages/ContainedCore-DocC.md) + - [ContainedUI](Packages/ContainedUI.md) + - [ContainedUI DocC](Packages/ContainedUI-DocC.md) + - [ContainedUX](Packages/ContainedUX.md) + - [ContainedUX DocC](Packages/ContainedUX-DocC.md) diff --git a/scripts/build-and-run.sh b/scripts/build-and-run.sh index cd62fe9a..ee4792ff 100755 --- a/scripts/build-and-run.sh +++ b/scripts/build-and-run.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# Build a debug app bundle and optionally relaunch it. +# Usage: ./scripts/build-and-run.sh [run|build] set -euo pipefail MODE="${1:-run}" diff --git a/scripts/changes-since-release.sh b/scripts/changes-since-release.sh index e2bff381..076d4b41 100755 --- a/scripts/changes-since-release.sh +++ b/scripts/changes-since-release.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Emit channel/build changes since the previous appcast item for the same channel. +# Usage: CHANNEL=nightly APPCAST=appcast.xml HEAD_REF=HEAD ./scripts/changes-since-release.sh set -euo pipefail cd "$(dirname "$0")/.." diff --git a/scripts/ci-validate.sh b/scripts/ci-validate.sh index c52081a8..bd5131b9 100755 --- a/scripts/ci-validate.sh +++ b/scripts/ci-validate.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Fast repository checks shared by PR and release workflows. +# Usage: ./scripts/ci-validate.sh [--base-ref ] [--head-ref ] [--require-release-note] set -euo pipefail cd "$(dirname "$0")/.." @@ -41,15 +42,131 @@ first_line_matching() { awk -v pattern="$pattern" '$0 ~ pattern { print NR; exit }' } +check_no_matches() { + local label="$1" + local pattern="$2" + shift 2 + local output + output="$(rg -n --glob '!scripts/ci-validate.sh' "$pattern" "$@" 2>/dev/null || true)" + if [ -n "$output" ]; then + printf '✗ %s:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + echo "▸ Checking bundled changelog sync…" ./scripts/sync-changelog-resource.sh --check echo "▸ Checking shell script syntax…" bash -n scripts/*.sh +echo "▸ Checking shell script strict mode…" +for script in scripts/*.sh; do + [ "$(sed -n '1p' "$script")" = '#!/usr/bin/env bash' ] || fail "$script must start with #!/usr/bin/env bash" + rg -q '^set -euo pipefail$' "$script" || fail "$script must enable set -euo pipefail" +done + echo "▸ Checking workflow YAML syntax…" ruby -e 'require "yaml"; ARGV.each { |path| YAML.load_file(path) }' .github/workflows/*.yml +echo "▸ Checking local Markdown links…" +ruby <<'RUBY' +root = Dir.pwd +files = [ + "README.md", + "AGENTS.md", + ".github/CONTRIBUTING.md", + ".github/pull_request_template.md", + *Dir["docs/**/*.md"], + *Dir["Packages/*/README.md"], + *Dir["Packages/*/Sources/*/*.docc/*.md"] +].uniq + +failed = false +files.each do |file| + next if file == "docs/wiki/_Sidebar.md" + text = File.read(file) + text.scan(/!?\[[^\]]*\]\(([^)]+)\)/).flatten.each do |target| + next if target.start_with?("http://", "https://", "mailto:", "#") + clean = target.split("#", 2).first.split("?", 2).first + next if clean.empty? + path = if clean.start_with?("/") + File.join(root, clean.delete_prefix("/")) + else + File.expand_path(clean, File.dirname(File.join(root, file))) + end + next if File.exist?(path) + warn "✗ #{file} links to missing local path #{target}" + failed = true + end +end +exit(failed ? 1 : 0) +RUBY + +echo "▸ Checking wiki map coverage…" +wiki_map="docs/wiki/File-Map.md" +wiki_sidebar="docs/wiki/_Sidebar.md" +[ -f "$wiki_map" ] || fail "missing $wiki_map" +[ -f "$wiki_sidebar" ] || fail "missing $wiki_sidebar" +required_docs=( + docs/app/Home.md + docs/app/Installation.md + docs/app/Keyboard-Shortcuts.md + docs/app/Localization.md + docs/app/System-Settings.md + docs/app/Troubleshooting.md + docs/app/Updates.md + docs/features/Features.md + docs/features/Containers.md + docs/features/Images.md + docs/features/Resources.md + docs/features/Creation-Workflow.md + docs/features/Run-Edit-Form.md + docs/features/Compose-Import.md + docs/features/Command-Palette.md + docs/architecture/Architecture.md + docs/architecture/Runtime-Adapters.md + docs/architecture/Design-System.md + docs/development/Contributing.md + docs/development/Issues-and-Discussions.md + docs/development/Documentation-Map.md + docs/release/Release.md + Packages/ContainedCore/README.md + Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md + Packages/ContainedUI/README.md + Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md + Packages/ContainedUX/README.md + Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md +) +for doc in "${required_docs[@]}"; do + [ -f "$doc" ] || fail "mapped documentation source is missing: $doc" + rg -Fq "\`$doc\`" "$wiki_map" || fail "$wiki_map is missing $doc" +done +while IFS='|' read -r _ source target _; do + source="$(sed 's/^ *//; s/ *$//' <<< "$source")" + target="$(sed 's/^ *//; s/ *$//' <<< "$target")" + [[ "$source" == \`* ]] || continue + source="${source#\`}"; source="${source%\`}" + target="${target#\`}"; target="${target%\`}" + [ -f "$source" ] || fail "$wiki_map points to missing source $source" + rg -Fq "$target" "$wiki_sidebar" || fail "$wiki_sidebar is missing mapped target $target" +done < "$wiki_map" + +echo "▸ Checking package boundary and naming invariants…" +check_no_matches "stale package names" '\b(ContainedDesignSystem|ContainedNavigation|ContainedRuntime|AppleContainerRuntime|ContainedPreviewSupport)\b' README.md AGENTS.md docs Packages Sources Tests .github +check_no_matches "stale flat public names" '\b(DesignCard|DesignTokens|PanelHeader|SheetHeader|LiveSparkline|ErrorToast|ResourceGlassCard|ResourceCardHeader|RuntimeKind|RuntimeDescriptor|RuntimeCapability|RuntimeCommandPreview|RuntimeComposeImportPlan|RuntimeCoreSwitchPlan|ContainerCreateRequest|ContainerCreateResult|ContainerSnapshot)\b' README.md AGENTS.md docs Packages Sources Tests .github +check_no_matches "stale refactor wording" '\b(refactor history|compatibility alias|compatibility aliases|temporary migration|legacy compatibility|bridge wrappers|old package)\b' README.md AGENTS.md docs Packages Sources Tests scripts .github +check_no_matches "app adapter internals" '\b(ContainerCommands|AppleContainerClient|AppleContainerCLILocator|ContainerRuntimeClient)\b' Sources/ContainedApp Tests/ContainedAppTests docs .github +check_no_matches "Core imports UI/UX/app-only dependencies" '^import (ContainedUI|ContainedUX|SwiftUI|Sparkle|SwiftTerm)$' Packages/ContainedCore/Sources/ContainedCore +check_no_matches "UI imports Core/UX/App" '^import (ContainedCore|ContainedUX|ContainedApp|Sparkle|SwiftTerm)$' Packages/ContainedUI/Sources/ContainedUI +check_no_matches "UX imports Core/App" '^import (ContainedCore|ContainedApp|Sparkle|SwiftTerm)$' Packages/ContainedUX/Sources/ContainedUX +raw_token_output="$(rg -n --glob '*.swift' '\bUI\.Tokens\b' Sources/ContainedApp Packages/ContainedUX/Sources Tests 2>/dev/null || true)" +if [ -n "$raw_token_output" ]; then + printf '✗ raw UI.Tokens outside ContainedUI source:\n%s\n' "$raw_token_output" >&2 + exit 1 +fi +[ -z "$(find Packages Sources Tests docs -path '*/.build' -prune -o -path '*/.swiftpm' -prune -o -type d -empty -print)" ] || fail "empty source/docs folders found" + echo "▸ Checking release-note composition…" base="$(./scripts/version-info.sh base)" build="$(./scripts/version-info.sh build)" diff --git a/scripts/release-body.sh b/scripts/release-body.sh index e3e21eaf..d5dc8087 100755 --- a/scripts/release-body.sh +++ b/scripts/release-body.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Compose Markdown release notes for a channel build. +# Usage: CHANNEL=nightly VERSION_VALUE= ./scripts/release-body.sh set -euo pipefail cd "$(dirname "$0")/.." diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh index 07d6beb8..2f99da03 100755 --- a/scripts/release-notes.sh +++ b/scripts/release-notes.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Write composed Sparkle release-note HTML fragments next to each archive in an updates directory. +# Usage: CHANNEL=nightly VERSION_VALUE= ./scripts/release-notes.sh [updates-dir] set -euo pipefail cd "$(dirname "$0")/.." diff --git a/scripts/require-release-note.sh b/scripts/require-release-note.sh index f2667696..1f2100e8 100755 --- a/scripts/require-release-note.sh +++ b/scripts/require-release-note.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Fail material PRs that forget a committed release note or change fragment. +# Usage: BASE_REF=origin/nightly HEAD_REF=HEAD ./scripts/require-release-note.sh set -euo pipefail cd "$(dirname "$0")/.." diff --git a/scripts/validate-appcast.sh b/scripts/validate-appcast.sh index 964a3130..d60f104e 100755 --- a/scripts/validate-appcast.sh +++ b/scripts/validate-appcast.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Validate Sparkle appcast structure and channel/build invariants. +# Usage: CHANNEL=nightly ./scripts/validate-appcast.sh [appcast.xml] set -euo pipefail appcast="${1:-appcast.xml}" diff --git a/scripts/validate-bundle.sh b/scripts/validate-bundle.sh index 09b75dd3..c1186e35 100755 --- a/scripts/validate-bundle.sh +++ b/scripts/validate-bundle.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Smoke-check a built Contained.app before packaging or publishing. +# Usage: VERSION= BUILD= ./scripts/validate-bundle.sh [Contained.app] set -euo pipefail app="${1:-Contained.app}" diff --git a/scripts/version-info.sh b/scripts/version-info.sh index 2b8df26e..768d2fd5 100755 --- a/scripts/version-info.sh +++ b/scripts/version-info.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Print release version/build values shared by CI workflows and bundle.sh. +# Usage: CHANNEL=nightly BUILD= SHA= ./scripts/version-info.sh [base|build|sha|version|env] set -euo pipefail cd "$(dirname "$0")/.." From 62cf9d0eb54f983de3114160a3e68ed1a4964477 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Fri, 3 Jul 2026 02:36:20 +0100 Subject: [PATCH 42/53] chore: tighten repo consistency validation --- .github/workflows/beta.yml | 7 +- .github/workflows/codeql.yml | 10 +++ .github/workflows/nightly.yml | 7 +- .github/workflows/pr.yml | 2 +- .github/workflows/stable.yml | 7 +- .../Services/Health/HealthMonitor.swift | 1 - .../20260703-repo-consistency-sweep.md | 3 + docs/development/Contributing.md | 2 +- docs/development/Documentation-Map.md | 6 ++ docs/wiki/README.md | 4 + scripts/bundle.sh | 2 +- scripts/check-generated-clean.sh | 1 + scripts/ci-validate.sh | 84 ++++++++++++------- scripts/collect-changes.sh | 1 + scripts/make-dmg.sh | 1 + scripts/promote-appcast-to-nightly.sh | 1 + scripts/test-release-scripts.sh | 1 + 17 files changed, 102 insertions(+), 38 deletions(-) create mode 100644 changes/unreleased/20260703-repo-consistency-sweep.md diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index e38210b8..1b323875 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -20,10 +20,15 @@ on: - 'Packages/*/README.md' - 'Packages/*/Sources/*/*.docc/**' - 'CODE_OF_CONDUCT.md' - - '.github/CONTRIBUTING.md' + - '.github/*.md' + - '.github/ISSUE_TEMPLATE/**' - '.github/assets/**' + - '.github/dependabot.yml' + - 'CODEOWNERS' - 'LICENSE' - 'NOTICE' + - 'SECURITY.md' + - 'SUPPORT.md' - '.gitignore' - 'appcast.xml' workflow_dispatch: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 003828e5..3737995b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,11 +13,16 @@ on: - 'Packages/*/Sources/*/*.docc/**' - 'CHANGELOG.md' - 'CODE_OF_CONDUCT.md' + - '.github/*.md' - '.github/**/*.md' - '.github/ISSUE_TEMPLATE/**' - '.github/assets/**' + - '.github/dependabot.yml' + - 'CODEOWNERS' - 'LICENSE' - 'NOTICE' + - 'SECURITY.md' + - 'SUPPORT.md' - '.gitignore' - 'Sources/ContainedApp/Resources/CHANGELOG.md' - 'appcast.xml' @@ -31,11 +36,16 @@ on: - 'Packages/*/Sources/*/*.docc/**' - 'CHANGELOG.md' - 'CODE_OF_CONDUCT.md' + - '.github/*.md' - '.github/**/*.md' - '.github/ISSUE_TEMPLATE/**' - '.github/assets/**' + - '.github/dependabot.yml' + - 'CODEOWNERS' - 'LICENSE' - 'NOTICE' + - 'SECURITY.md' + - 'SUPPORT.md' - '.gitignore' - 'Sources/ContainedApp/Resources/CHANGELOG.md' - 'appcast.xml' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 91ebf012..da82c49f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -19,10 +19,15 @@ on: - 'Packages/*/README.md' - 'Packages/*/Sources/*/*.docc/**' - 'CODE_OF_CONDUCT.md' - - '.github/CONTRIBUTING.md' + - '.github/*.md' + - '.github/ISSUE_TEMPLATE/**' - '.github/assets/**' + - '.github/dependabot.yml' + - 'CODEOWNERS' - 'LICENSE' - 'NOTICE' + - 'SECURITY.md' + - 'SUPPORT.md' - '.gitignore' - 'appcast.xml' workflow_dispatch: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6107ed3c..dffd38bb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -33,7 +33,7 @@ jobs: while IFS= read -r file; do [ -n "$file" ] || continue case "$file" in - docs/**|README.md|Packages/*/README.md|Packages/*/Sources/*/*.docc/**|CODE_OF_CONDUCT.md|.github/CONTRIBUTING.md|.github/assets/**|.github/ISSUE_TEMPLATE/**|LICENSE|NOTICE|SECURITY.md|SUPPORT.md|CODEOWNERS|.github/dependabot.yml|.gitignore|appcast.xml) + docs/**|README.md|Packages/*/README.md|Packages/*/Sources/*/*.docc/**|CODE_OF_CONDUCT.md|.github/*.md|.github/assets/**|.github/ISSUE_TEMPLATE/**|LICENSE|NOTICE|SECURITY.md|SUPPORT.md|CODEOWNERS|.github/dependabot.yml|.gitignore|appcast.xml) ;; *) material=true diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml index 1c176405..03d6f4ef 100644 --- a/.github/workflows/stable.yml +++ b/.github/workflows/stable.yml @@ -21,10 +21,15 @@ on: - 'Packages/*/README.md' - 'Packages/*/Sources/*/*.docc/**' - 'CODE_OF_CONDUCT.md' - - '.github/CONTRIBUTING.md' + - '.github/*.md' + - '.github/ISSUE_TEMPLATE/**' - '.github/assets/**' + - '.github/dependabot.yml' + - 'CODEOWNERS' - 'LICENSE' - 'NOTICE' + - 'SECURITY.md' + - 'SUPPORT.md' - '.gitignore' - 'appcast.xml' workflow_dispatch: diff --git a/Sources/ContainedApp/Services/Health/HealthMonitor.swift b/Sources/ContainedApp/Services/Health/HealthMonitor.swift index 50e759ba..7321793e 100644 --- a/Sources/ContainedApp/Services/Health/HealthMonitor.swift +++ b/Sources/ContainedApp/Services/Health/HealthMonitor.swift @@ -2,7 +2,6 @@ import SwiftUI import ContainedCore /// Local store of per-container healthchecks (keyed by container id), persisted to UserDefaults. -/// Migrated to SwiftData in WS7, alongside personalization. @MainActor @Observable final class HealthCheckStore { diff --git a/changes/unreleased/20260703-repo-consistency-sweep.md b/changes/unreleased/20260703-repo-consistency-sweep.md new file mode 100644 index 00000000..7452e605 --- /dev/null +++ b/changes/unreleased/20260703-repo-consistency-sweep.md @@ -0,0 +1,3 @@ +### Repository + +- Tighten repository consistency validation for script headers, workflow path filters, docs indexes, and wiki mapping drift. diff --git a/docs/development/Contributing.md b/docs/development/Contributing.md index acf3a40a..a1c10791 100644 --- a/docs/development/Contributing.md +++ b/docs/development/Contributing.md @@ -113,7 +113,7 @@ appcast.xml Sparkle feed at the root of each release branch - **Preserve update build numbers.** `scripts/version-info.sh` is the single build-number source of truth; beta/stable workflows must pass the retained `BUILD` into `scripts/bundle.sh` and merge promoted appcast items into the nightly feed. - **Keep code scanning intentional.** `.github/workflows/codeql.yml` is the repository-owned CodeQL setup. GitHub Actions workflow analysis runs on PRs and pushes that touch source, scripts, workflows, package files, or tests, plus a weekly scheduled baseline. Swift analysis is scheduled/manual because Swift CodeQL currently takes too long to be a healthy per-PR gate. Appcast-only, docs-only, changelog-resource-only, and change-fragment-only commits are ignored so generated release feed commits do not burn macOS scan minutes. - **Write release notes at the right level.** Keep `CHANGELOG.md` curated and version-level: use the base version section, such as `## [1.0.0]`, for durable user-facing release notes. Put PR/build deltas in `changes/unreleased/` fragments by default, not in `CHANGELOG.md` as a running implementation inventory. Use `changes/beta/` or `changes/nightly/` only for channel-specific notes. `scripts/collect-changes.sh` can compile those fragments for a directory or git range. When no explicit `CHANGES` or `CHANGES_DIR` source is provided, Beta/Nightly notes first try the previous matching appcast item plus the changelog/change-fragment git delta, then fall back to channel sections and `Unreleased` only when no channel item exists. Stable ships full notes only; Beta/Nightly ship channel changes plus full notes. -- **Let CI check invariants, not fix them.** `scripts/ci-validate.sh` checks bundled changelog sync, shell syntax, workflow YAML syntax, Stable/Beta/Nightly release-note ordering, and PR release-note coverage when given a base ref. If `CHANGELOG.md` changes, run `./scripts/sync-changelog-resource.sh` locally and commit the bundled resource; CI uses `--check` so drift fails loudly. +- **Let CI check invariants, not fix them.** `scripts/ci-validate.sh` checks bundled changelog sync, shell syntax and headers, workflow YAML syntax and path filters, docs/wiki coverage, package-boundary naming, Stable/Beta/Nightly release-note ordering, and PR release-note coverage when given a base ref. If `CHANGELOG.md` changes, run `./scripts/sync-changelog-resource.sh` locally and commit the bundled resource; CI uses `--check` so drift fails loudly. - **Use `no-release-note` narrowly.** PR CI accepts the label only through `NO_RELEASE_NOTE=1`; reserve it for docs/meta/dependency-only maintenance that does not change shipped behavior, scripts, workflows, tests, or source. Dependabot applies it automatically to grouped dependency update PRs. - **Use `wiki-approved` for direct wiki-impacting changes only when a maintainer has reviewed the docs impact.** Repo docs stay authoritative; wiki work should follow `docs/wiki/File-Map.md`. diff --git a/docs/development/Documentation-Map.md b/docs/development/Documentation-Map.md index 6683a1a5..cf503402 100644 --- a/docs/development/Documentation-Map.md +++ b/docs/development/Documentation-Map.md @@ -17,6 +17,9 @@ release documentation. | `docs/release/` | Release, packaging, appcast, signing, and channel runbooks | Keep `docs/README.md` as the entry point for the maintained docs tree. +Root `README.md`, `docs/README.md`, and `docs/wiki/README.md` are repo-local +entry/index pages. They should point readers at maintained sources, but they are +not synced as wiki content. ## Package Docs @@ -46,6 +49,9 @@ contract under `docs/wiki/` instead of duplicating wiki content. When a maintained doc is added, renamed, or removed, update `docs/wiki/File-Map.md` and `docs/wiki/_Sidebar.md` in the same change. +`scripts/ci-validate.sh` checks this from the current tree, so new app, +feature, architecture, development, release, package README, and package DocC +landing pages must be indexed and mapped before CI passes. ## Naming Rules diff --git a/docs/wiki/README.md b/docs/wiki/README.md index 2d6cf819..c50db52e 100644 --- a/docs/wiki/README.md +++ b/docs/wiki/README.md @@ -21,3 +21,7 @@ contract here. Wiki-local links, page titles, and navigation labels may be adjusted when syncing into the separate wiki repo, but the mapped source file remains the maintained content owner. + +Root repo index pages such as `README.md`, `docs/README.md`, and this sync +contract are not mirrored into the wiki. They should link to maintained docs; +`File-Map.md` decides which maintained docs become wiki pages. diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 39ac8581..e5829de8 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -60,7 +60,7 @@ if [ -d "$FRAMEWORK_SRC" ]; then fi # Bundle app resources if SwiftPM produced them. The app implementation lives in the -# ContainedApp library target; keep the previous bundle name as a fallback for older build folders. +# ContainedApp library target; SwiftPM bundle names vary across target graph shapes. BUILD_PRODUCTS="$(swift build -c "$CONFIG" --show-bin-path)" for bundle_name in Contained_ContainedApp.bundle Contained_Contained.bundle; do BUNDLE_RES="$BUILD_PRODUCTS/$bundle_name" diff --git a/scripts/check-generated-clean.sh b/scripts/check-generated-clean.sh index a8c66ef7..ecc6c892 100755 --- a/scripts/check-generated-clean.sh +++ b/scripts/check-generated-clean.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Ensure build/generation steps did not leave tracked files dirty. +# Usage: ./scripts/check-generated-clean.sh set -euo pipefail cd "$(dirname "$0")/.." diff --git a/scripts/ci-validate.sh b/scripts/ci-validate.sh index bd5131b9..cec51510 100755 --- a/scripts/ci-validate.sh +++ b/scripts/ci-validate.sh @@ -64,11 +64,51 @@ echo "▸ Checking shell script strict mode…" for script in scripts/*.sh; do [ "$(sed -n '1p' "$script")" = '#!/usr/bin/env bash' ] || fail "$script must start with #!/usr/bin/env bash" rg -q '^set -euo pipefail$' "$script" || fail "$script must enable set -euo pipefail" + rg -q '^# .+' "$script" || fail "$script must document one purpose" + rg -q '^# Usage:' "$script" || fail "$script must document usage" done echo "▸ Checking workflow YAML syntax…" ruby -e 'require "yaml"; ARGV.each { |path| YAML.load_file(path) }' .github/workflows/*.yml +echo "▸ Checking workflow path filters…" +release_meta_paths=( + 'docs/**' + 'README.md' + 'Packages/*/README.md' + 'Packages/*/Sources/*/*.docc/**' + 'CODE_OF_CONDUCT.md' + '.github/*.md' + '.github/ISSUE_TEMPLATE/**' + '.github/assets/**' + '.github/dependabot.yml' + 'CODEOWNERS' + 'LICENSE' + 'NOTICE' + 'SECURITY.md' + 'SUPPORT.md' + '.gitignore' + 'appcast.xml' +) +for workflow in .github/workflows/nightly.yml .github/workflows/beta.yml .github/workflows/stable.yml; do + for pattern in "${release_meta_paths[@]}"; do + rg -Fq -- "- '$pattern'" "$workflow" || fail "$workflow paths-ignore is missing $pattern" + done +done +for pattern in "${release_meta_paths[@]}"; do + rg -Fq -- "$pattern" .github/workflows/pr.yml || fail ".github/workflows/pr.yml material classifier is missing $pattern" +done +codeql_meta_paths=( + "${release_meta_paths[@]}" + 'CHANGELOG.md' + 'Sources/ContainedApp/Resources/CHANGELOG.md' + 'changes/**' +) +for pattern in "${codeql_meta_paths[@]}"; do + matches="$(rg -F -- "- '$pattern'" .github/workflows/codeql.yml | wc -l | tr -d ' ')" + [ "$matches" -ge 2 ] || fail ".github/workflows/codeql.yml paths-ignore is missing $pattern in pull_request or push" +done + echo "▸ Checking local Markdown links…" ruby <<'RUBY' root = Dir.pwd @@ -108,38 +148,20 @@ wiki_map="docs/wiki/File-Map.md" wiki_sidebar="docs/wiki/_Sidebar.md" [ -f "$wiki_map" ] || fail "missing $wiki_map" [ -f "$wiki_sidebar" ] || fail "missing $wiki_sidebar" -required_docs=( - docs/app/Home.md - docs/app/Installation.md - docs/app/Keyboard-Shortcuts.md - docs/app/Localization.md - docs/app/System-Settings.md - docs/app/Troubleshooting.md - docs/app/Updates.md - docs/features/Features.md - docs/features/Containers.md - docs/features/Images.md - docs/features/Resources.md - docs/features/Creation-Workflow.md - docs/features/Run-Edit-Form.md - docs/features/Compose-Import.md - docs/features/Command-Palette.md - docs/architecture/Architecture.md - docs/architecture/Runtime-Adapters.md - docs/architecture/Design-System.md - docs/development/Contributing.md - docs/development/Issues-and-Discussions.md - docs/development/Documentation-Map.md - docs/release/Release.md - Packages/ContainedCore/README.md - Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md - Packages/ContainedUI/README.md - Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md - Packages/ContainedUX/README.md - Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md -) +required_docs=() +while IFS= read -r doc; do + required_docs+=("$doc") +done < <(find docs/app docs/features docs/architecture docs/development docs/release -type f -name '*.md' | sort) +while IFS= read -r doc; do + required_docs+=("$doc") +done < <(find Packages \( -path '*/.build' -o -path '*/.swiftpm' \) -prune -o \( -path 'Packages/*/README.md' -o -path 'Packages/*/Sources/*/*.docc/*.md' \) -type f -print | sort) for doc in "${required_docs[@]}"; do [ -f "$doc" ] || fail "mapped documentation source is missing: $doc" + case "$doc" in + docs/*) docs_index_target="${doc#docs/}" ;; + *) docs_index_target="../$doc" ;; + esac + rg -Fq "($docs_index_target)" docs/README.md || fail "docs/README.md is missing $doc" rg -Fq "\`$doc\`" "$wiki_map" || fail "$wiki_map is missing $doc" done while IFS='|' read -r _ source target _; do @@ -155,7 +177,7 @@ done < "$wiki_map" echo "▸ Checking package boundary and naming invariants…" check_no_matches "stale package names" '\b(ContainedDesignSystem|ContainedNavigation|ContainedRuntime|AppleContainerRuntime|ContainedPreviewSupport)\b' README.md AGENTS.md docs Packages Sources Tests .github check_no_matches "stale flat public names" '\b(DesignCard|DesignTokens|PanelHeader|SheetHeader|LiveSparkline|ErrorToast|ResourceGlassCard|ResourceCardHeader|RuntimeKind|RuntimeDescriptor|RuntimeCapability|RuntimeCommandPreview|RuntimeComposeImportPlan|RuntimeCoreSwitchPlan|ContainerCreateRequest|ContainerCreateResult|ContainerSnapshot)\b' README.md AGENTS.md docs Packages Sources Tests .github -check_no_matches "stale refactor wording" '\b(refactor history|compatibility alias|compatibility aliases|temporary migration|legacy compatibility|bridge wrappers|old package)\b' README.md AGENTS.md docs Packages Sources Tests scripts .github +check_no_matches "stale refactor wording" '\b(refactor history|compatibility alias|compatibility aliases|temporary migration|legacy compatibility|bridge wrappers|old package|Migrated to|WS[0-9]+|previous bundle name|older build folders)\b' README.md AGENTS.md docs Packages Sources Tests scripts .github check_no_matches "app adapter internals" '\b(ContainerCommands|AppleContainerClient|AppleContainerCLILocator|ContainerRuntimeClient)\b' Sources/ContainedApp Tests/ContainedAppTests docs .github check_no_matches "Core imports UI/UX/app-only dependencies" '^import (ContainedUI|ContainedUX|SwiftUI|Sparkle|SwiftTerm)$' Packages/ContainedCore/Sources/ContainedCore check_no_matches "UI imports Core/UX/App" '^import (ContainedCore|ContainedUX|ContainedApp|Sparkle|SwiftTerm)$' Packages/ContainedUI/Sources/ContainedUI diff --git a/scripts/collect-changes.sh b/scripts/collect-changes.sh index 220b8860..e0525db2 100755 --- a/scripts/collect-changes.sh +++ b/scripts/collect-changes.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Compile committed markdown change fragments from a directory or git range. +# Usage: ./scripts/collect-changes.sh [] [changes-dir] set -euo pipefail cd "$(dirname "$0")/.." diff --git a/scripts/make-dmg.sh b/scripts/make-dmg.sh index 3782802c..9171309c 100755 --- a/scripts/make-dmg.sh +++ b/scripts/make-dmg.sh @@ -11,6 +11,7 @@ # which includes the ~28pt title bar. The icon-view content height = frame - 28, # and Finder draws the background top-anchored at natural size, so the frame # must be 528 + 28 = 556 tall for the full image to show without clipping. +# Usage: ./scripts/make-dmg.sh set -euo pipefail CHANNEL="${1:?channel (stable|beta|nightly)}" diff --git a/scripts/promote-appcast-to-nightly.sh b/scripts/promote-appcast-to-nightly.sh index 302da8e8..63cacd41 100755 --- a/scripts/promote-appcast-to-nightly.sh +++ b/scripts/promote-appcast-to-nightly.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Insert promoted beta/stable appcast items into the nightly appcast. +# Usage: ./scripts/promote-appcast-to-nightly.sh [--non-nightly-only] set -euo pipefail FILTER_MODE="all" diff --git a/scripts/test-release-scripts.sh b/scripts/test-release-scripts.sh index f6d1678c..82354dc2 100755 --- a/scripts/test-release-scripts.sh +++ b/scripts/test-release-scripts.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Fixture coverage for release/version/change/appcast helper scripts. +# Usage: ./scripts/test-release-scripts.sh set -euo pipefail cd "$(dirname "$0")/.." From 6b02e14891a3d5c55b58cf6cd3992f7c95408b84 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Fri, 3 Jul 2026 02:40:51 +0100 Subject: [PATCH 43/53] docs: consolidate release notes --- CHANGELOG.md | 122 +++++++----------- Sources/ContainedApp/Resources/CHANGELOG.md | 122 +++++++----------- changes/unreleased/20260701-code-scanning.md | 4 - ...20260701-dependabot-release-note-policy.md | 2 - .../20260701-design-system-package.md | 1 - changes/unreleased/20260701-github-setup.md | 1 - .../unreleased/20260701-navigation-package.md | 1 - .../20260701-runtime-abstraction.md | 1 - .../unreleased/20260701-xcode-workspace.md | 3 - changes/unreleased/20260702-card-anatomy.md | 5 - .../20260702-localization-boundary.md | 4 - .../20260702-refresh-stats-performance.md | 25 ---- .../20260702-standalone-packages-xcode.md | 6 - .../20260703-foundation-consistency.md | 27 ++++ .../20260703-repo-consistency-sweep.md | 3 - 15 files changed, 123 insertions(+), 204 deletions(-) delete mode 100644 changes/unreleased/20260701-code-scanning.md delete mode 100644 changes/unreleased/20260701-dependabot-release-note-policy.md delete mode 100644 changes/unreleased/20260701-design-system-package.md delete mode 100644 changes/unreleased/20260701-github-setup.md delete mode 100644 changes/unreleased/20260701-navigation-package.md delete mode 100644 changes/unreleased/20260701-runtime-abstraction.md delete mode 100644 changes/unreleased/20260701-xcode-workspace.md delete mode 100644 changes/unreleased/20260702-card-anatomy.md delete mode 100644 changes/unreleased/20260702-localization-boundary.md delete mode 100644 changes/unreleased/20260702-refresh-stats-performance.md delete mode 100644 changes/unreleased/20260702-standalone-packages-xcode.md create mode 100644 changes/unreleased/20260703-foundation-consistency.md delete mode 100644 changes/unreleased/20260703-repo-consistency-sweep.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e50471c0..0dfe25cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,102 +6,76 @@ ### Highlights -- First complete Contained release: a native macOS 26 SwiftUI control surface for Apple's `container` CLI, built around local containers, images, volumes, networks, registries, system status, templates, activity history, and app-managed workflow state. -- The default experience is a classic macOS sidebar shell; the floating Liquid Glass toolbar, morph panels, command palette, Docker Hub search, Compose import, image build workspace, and keyboard shortcuts are available behind explicit experimental gates. -- Contained stays CLI-first: privileged runtime operations are handled by Apple's `container` command, generated `container ...` arguments can be revealed before run/edit operations, and local app metadata is kept out of container resources unless it is required for behavior. +- First complete Contained release: a native macOS 26 SwiftUI control surface for Apple's `container` CLI. +- Classic sidebar navigation is the default experience. The Liquid Glass toolbar, morph panels, command palette, Docker Hub search, Compose import, image build workspace, and keyboard shortcuts are available as explicit experimental features. +- Contained stays CLI-first: privileged runtime work goes through Apple's `container` command, run/edit operations can reveal the generated `container ...` command, and decorative app metadata stays local to Contained. +- Built-in Sparkle updates support Stable, Beta, and Nightly channels, with fresh pre-1.0 installs defaulting to Nightly so testers receive current builds. -### Added +### App & Navigation -#### App Shell & Navigation +- Full-page macOS app shell for Containers, Images, Build, Volumes, Networks, System, Templates, Activity, and Settings. +- Optional menu-bar app with service health, unread Activity count, update checks, quick actions, and navigation back into the main window. +- Shared presentation model for sidebar, menus, menu-bar actions, toolbar panels, sheets, and command-palette routes. +- Toolbar-aware safe areas and page filters so experimental toolbar chrome does not cover classic sidebar content or expanded cards. +- Keyboard shortcuts for common navigation, search, settings, creation, update, activity, and palette actions when the experimental gate is enabled. -- Classic sidebar navigation with full-page destinations for Containers, Images, Build, Volumes, Networks, System, Templates, Activity, and Settings. -- Optional menu-bar app with service health, unread Activity count, quick actions, update checks, and navigation into the main app. -- Menu and menu-bar navigation fallback that routes create, image, system, activity, registry, and settings actions through the same presentation model as the sidebar. -- Experimental toolbar-first UI with top/bottom Liquid Glass toolbar bands, measured detail-column safe areas, morph panels, contextual page actions, and bottom page filters. -- Experimental toolbar panel navigation setting so toolbar actions can open morph panels or fall back to classic pages and sheets. -- Experimental keyboard shortcuts for sidebar/search/settings/create/update/activity/palette actions. +### Containers & Creation -#### Containers - -- Container grid with personalized Liquid Glass cards, lifecycle actions, status, image, command, resource highlights, full-card hit targets, and context menus. -- Container grouping by Network, Volume, Image, or Flat list, with sort and running-only filtering in shared toolbar controls. -- Expanded container detail tabs for Overview, Logs, Terminal, Stats, History, and Files. -- Live logs, one-shot and follow modes, terminal access through SwiftTerm, file browsing/copy workflows, and per-container metrics history. -- Shared Run/Edit form for new containers and recreate/edit flows, with validation, image pre-pull, inline errors, and an exact CLI preview. +- Container grid with Liquid Glass cards, lifecycle actions, status, image, command, resource highlights, context menus, and full-card hit targets. +- Grouping, sorting, and running-only filters for containers by network, volume, image, or flat list. +- Expanded container tabs for Overview, Logs, Terminal, Stats, History, and Files, including live/follow logs, SwiftTerm terminal access, file browsing/copy workflows, and per-container metric history. +- Shared Run/Edit form for new containers and recreate/edit flows, with validation, image pre-pull, inline errors, app-managed options, and exact CLI preview. +- Compose import from paste, file picker, drag-and-drop, menu command, or command palette action, translating services into editable run forms instead of launching opaque stacks. +- Compose translation for image, platform, command, entrypoint, ports, volumes, env files, network mode, restart policy, health checks, working directory, user, capabilities, DNS, tmpfs, and ulimits, with warnings for unsupported shapes. +- Template storage for reusable run configurations using the same Run/Edit form as other creation paths. - App-managed restart policies and health checks for behavior Apple's `container` CLI does not provide natively. -- Container image update checks that can pull newer image tags and reopen the edit flow without automatically replacing the running container. -#### Images +### Images, Resources & Registries - Local image and tag browsing with grouped references, run actions, history pages, tagging, pushing, saving archives, loading OCI `.tar` archives, pruning, and update status. -- Registry image search flow for Docker Hub, gated behind Settings -> Experimental, with selected results handed into the run configuration flow. -- Image update detection based on local and remote digests, surfaced on image cards, container cards, toolbar panels, System, and command palette entries. +- Image update checks based on local and remote digests, surfaced on image cards, container cards, toolbar panels, System, and command palette entries. - Manual and scheduled image update checks, including "check all", "pull all available image updates", and container-image specific sweeps. +- Experimental Docker Hub search that can hand selected results into the run configuration flow. - Experimental image build workspace for Dockerfile/context builds with streamed BuildKit output. +- Volume and network browsing, creation, deletion, prune actions, local styling, and mount/network-aware container grouping. +- Registry credential management in Settings, including login/logout with passwords piped through `--password-stdin` rather than process arguments. -#### Creation, Compose & Templates - -- Shared creation flow for running containers, editing containers, choosing local images, searching registry images, importing Compose, loading image archives, creating networks, creating volumes, and building images. -- Run/Edit form organized as native macOS sections for Essentials, Resources, Networking, Storage, Environment, App Managed, Appearance, and Advanced Options. -- Structured controls for bounded CLI flags, repeatable rows for lists, and free-form fields where the CLI accepts arbitrary values. -- Compose import from paste, file picker, drag-and-drop, menu command, or command palette action, translating services into editable run forms instead of launching opaque stacks. -- Compose translations for image, platform, command, entrypoint, ports, volumes, env files, network mode, restart policy, health checks, working directory, user, capabilities, DNS, tmpfs, and ulimits, with warnings for unsupported shapes. -- Template storage for reusable run configurations, using the same Run/Edit form as other creation paths. - -#### Resources & Registries - -- Volume browsing, creation, deletion, prune actions, local card styling, and mount-aware container grouping. -- Network browsing, creation, deletion, prune actions, and network-aware container grouping. -- Registry credential management in Settings -> Registries, including login/logout with passwords piped through `--password-stdin` rather than process arguments. -- Activity history for lifecycle, image, compose, system, registry, pull, build, watchdog, healthcheck, alert, and UI events, including unread state, filtering, copy/delete actions, and clear controls. +### System, Settings & Activity -#### System & Settings - -- Onboarding/bootstrap states for missing CLI, unsupported CLI version, stopped service, and ready service. -- System page covering engine/service state, resource usage, background work, volumes, runtime defaults, system logs, and prune/service lifecycle actions. +- Bootstrap states for missing CLI, unsupported CLI version, stopped service, and ready service. +- System page for engine/service state, resource usage, background work, volume inventory, runtime defaults, system logs, and prune/service lifecycle actions. - Settings tabs for General, Appearance, Runtime, Registries, Experimental, Updates, and About. -- Configurable appearance, tint, materials, card density, card material, panel material, button tint, menu-bar behavior, CLI previews, info tips, logging, update cadence, image update cadence, and experimental feature gates. -- Versioned `.containedbackup` export/import for settings, personalization, health checks, templates, and activity history, with per-category selection and merge/replace import behavior. +- Configurable appearance, tint, materials, card density, menu-bar behavior, CLI previews, info tips, logging, update cadence, image update cadence, and experimental feature gates. +- Activity history for lifecycle, image, compose, system, registry, pull, build, watchdog, healthcheck, alert, and UI events, including unread state, filtering, copy/delete actions, and clear controls. +- Versioned `.containedbackup` export/import for settings, personalization, health checks, templates, and activity history, with per-category selection and merge/replace behavior. - Rollback guard for local data created by newer app schemas, including export-before-reset and best-effort keep-readable-data paths. -- Sparkle app updates with Stable, Beta, and Nightly channels plus in-app "What's New" views for current and available builds. -#### Personalization & Design System +### Personalization & Accessibility - Local-only personalization for containers, image groups, image tags, and volumes, including nickname, icon, tint, background, graph/widget choices, and inheritance from image or app defaults. -- Shared Liquid Glass design system primitives: `UI.Card.Scaffold`, `UI.Surface.Content`, `UI.Surface.Input`, `UI.Control.OptionTile`, `UI.Panel.Scaffold`, `UI.Panel.Header`, `UI.Panel.Section`, `UI.Panel.Row`, `UI.Panel.Field`, `UI.Command.PreviewBar`, `UI.Control.InfoButton`, `UI.Control.TintSelector`, `UI.Console.Stream`, `UI.State.ActivityStatusIndicator`, and toolbar controls. -- Accessibility-aware custom visual effects and motion handling, including Reduce Transparency and Reduce Motion support where the app supplies custom glass or animation. -- Shared page and panel scaffolds so sidebar pages, sheets, and toolbar morphs can reuse content without duplicate layouts. - -### Changed +- CPU, memory, network, and disk widgets with configurable graph style, interpolation, tint, and normalization choices. +- Accessibility-aware visual effects and motion handling, including Reduce Transparency and Reduce Motion support where Contained supplies custom glass or animation. -- Fresh installs default to the Nightly update channel and automatically check for app updates during pre-1.0 development. -- Registries live under Settings instead of a standalone app page, while registry actions remain discoverable through menus and the command palette. -- Local card personalization is stored in Contained's own state instead of being written back as decorative container labels. -- Container refresh, lifecycle actions, image list refreshes, disk usage refreshes, and image-update checks are throttled or serialized to avoid unnecessary CLI process churn and UI re-render storms. -- Background work uses a shared refresh coordinator, restart watchdog, health monitor, history store, and logger instead of page-local polling. -- Each release workflow publishes GitHub release notes and Sparkle appcast notes from the same release-note source. -- Pre-release build versions such as `1.0.0-nightly.N+sha` resolve full release notes from the matching base `1.0.0` section. +### Reliability & Performance -### Fixed - -- Sidebar layouts are no longer padded or covered by custom toolbar chrome because toolbar safe-area measurement is scoped to the detail column. -- Page and expanded-card layouts account for toolbar bands only where the experimental toolbar is visible. -- Container lifecycle actions and background polling no longer race the shared container list/stats dictionaries during start/stop/restart transitions. +- Shared refresh coordination for service/list refreshes, lifecycle actions, image list refreshes, disk usage refreshes, and image-update checks to avoid unnecessary CLI process churn. +- One app-wide low-priority stats stream for running containers, with narrower per-container metric invalidation and lazy long panels. +- Chronological sparkline windows, fixed CPU/memory percentage scales, raw network/disk throughput shapes, configurable CPU/memory normalization, and clearer sub-1% readouts. +- Deferred heavyweight expanded-card pages, cached style/tag lookups, and coalesced image refreshes so navigation and customization stay responsive. +- Hardened terminal teardown so rapid card or tab switching cleans up `container exec --tty` children. - Compose import preserves user control by opening editable run forms and reporting unsupported values rather than silently guessing. - App-managed restart and health flows suppress user-initiated stops where appropriate and avoid uncontrolled restart loops. -- Registry login avoids leaking passwords in argv. - -### Technical - -- Swift Package layout with `ContainedCore` for backend orchestration, `ContainedUI` for visual primitives, `ContainedUX` for interaction infrastructure, `ContainedApp` for app-owned SwiftUI/persistence/localization policy, and a tiny `Contained` executable launcher. -- `Core.Command` owns command previews and runtime invocation construction, with adapter-specific argv covered by golden tests. -- `ContainedCore` now owns the shared runtime contract, Core orchestrator, Apple `container` adapter internals, typed async runtime methods, and typed stats streams. -- `RunSpec` is the single source of truth for Run/Edit form state, validation, CLI preview, and actual execution. -- `AppModel` owns bootstrap, client wiring, stores, refresh coordination, image updates, service lifecycle, config transfer, and resource-style lookup through focused extensions. -- `UIState`, `AppSection`, toolbar option enums, and pending actions centralize navigation, filters, morph routing, and classic fallback routing. -- SwiftData-backed `HistoryStore` records events, metric samples, and templates with bounded retention and backup/import support. -- `AppStateEnvelope`, `JSONValue`, `MigrationStep`, and `StateMigrator` provide the baseline for forward migration and downgrade handling from schema version 1 onward. + +### Architecture & Release + +- Package-first SwiftPM layout with `ContainedCore` for backend orchestration, `ContainedUI` for visual primitives, `ContainedUX` for interaction infrastructure, `ContainedApp` for app-owned SwiftUI/persistence/localization policy, and a tiny `Contained` executable launcher. +- `Core.Orchestrator` is the app-facing backend boundary; Core owns runtime descriptors, command previews, Apple `container` adapter internals, typed async runtime methods, Compose/image-default translation, and typed stats streams. +- `RunSpec` owns Run/Edit form state, validation, CLI preview, and execution mapping. +- `AppModel`, focused stores, `UIState`, and toolbar option enums centralize bootstrap, navigation, refresh coordination, image updates, settings, resource styles, filters, and fallback routing. +- SwiftData-backed history records events, metric samples, and templates with bounded retention and backup/import support. +- Checked-in Xcode workspace and native macOS app target build and run `Contained.app` directly while SwiftPM remains the CI, release, packaging, signing, notarization, and appcast source of truth. - Release scripts centralize version/build derivation, retain promoted nightly build numbers for beta/stable, compose channel-specific and full-version release notes, generate Sparkle appcast HTML, and keep the bundled changelog resource in sync. +- Repository validation covers script strict mode and headers, workflow path filters, docs indexes, wiki mapping, package-boundary imports, app adapter internals, stale names, raw token use, empty source/docs folders, release-note composition, and bundled changelog sync. ### Migration Notes diff --git a/Sources/ContainedApp/Resources/CHANGELOG.md b/Sources/ContainedApp/Resources/CHANGELOG.md index e50471c0..0dfe25cd 100644 --- a/Sources/ContainedApp/Resources/CHANGELOG.md +++ b/Sources/ContainedApp/Resources/CHANGELOG.md @@ -6,102 +6,76 @@ ### Highlights -- First complete Contained release: a native macOS 26 SwiftUI control surface for Apple's `container` CLI, built around local containers, images, volumes, networks, registries, system status, templates, activity history, and app-managed workflow state. -- The default experience is a classic macOS sidebar shell; the floating Liquid Glass toolbar, morph panels, command palette, Docker Hub search, Compose import, image build workspace, and keyboard shortcuts are available behind explicit experimental gates. -- Contained stays CLI-first: privileged runtime operations are handled by Apple's `container` command, generated `container ...` arguments can be revealed before run/edit operations, and local app metadata is kept out of container resources unless it is required for behavior. +- First complete Contained release: a native macOS 26 SwiftUI control surface for Apple's `container` CLI. +- Classic sidebar navigation is the default experience. The Liquid Glass toolbar, morph panels, command palette, Docker Hub search, Compose import, image build workspace, and keyboard shortcuts are available as explicit experimental features. +- Contained stays CLI-first: privileged runtime work goes through Apple's `container` command, run/edit operations can reveal the generated `container ...` command, and decorative app metadata stays local to Contained. +- Built-in Sparkle updates support Stable, Beta, and Nightly channels, with fresh pre-1.0 installs defaulting to Nightly so testers receive current builds. -### Added +### App & Navigation -#### App Shell & Navigation +- Full-page macOS app shell for Containers, Images, Build, Volumes, Networks, System, Templates, Activity, and Settings. +- Optional menu-bar app with service health, unread Activity count, update checks, quick actions, and navigation back into the main window. +- Shared presentation model for sidebar, menus, menu-bar actions, toolbar panels, sheets, and command-palette routes. +- Toolbar-aware safe areas and page filters so experimental toolbar chrome does not cover classic sidebar content or expanded cards. +- Keyboard shortcuts for common navigation, search, settings, creation, update, activity, and palette actions when the experimental gate is enabled. -- Classic sidebar navigation with full-page destinations for Containers, Images, Build, Volumes, Networks, System, Templates, Activity, and Settings. -- Optional menu-bar app with service health, unread Activity count, quick actions, update checks, and navigation into the main app. -- Menu and menu-bar navigation fallback that routes create, image, system, activity, registry, and settings actions through the same presentation model as the sidebar. -- Experimental toolbar-first UI with top/bottom Liquid Glass toolbar bands, measured detail-column safe areas, morph panels, contextual page actions, and bottom page filters. -- Experimental toolbar panel navigation setting so toolbar actions can open morph panels or fall back to classic pages and sheets. -- Experimental keyboard shortcuts for sidebar/search/settings/create/update/activity/palette actions. +### Containers & Creation -#### Containers - -- Container grid with personalized Liquid Glass cards, lifecycle actions, status, image, command, resource highlights, full-card hit targets, and context menus. -- Container grouping by Network, Volume, Image, or Flat list, with sort and running-only filtering in shared toolbar controls. -- Expanded container detail tabs for Overview, Logs, Terminal, Stats, History, and Files. -- Live logs, one-shot and follow modes, terminal access through SwiftTerm, file browsing/copy workflows, and per-container metrics history. -- Shared Run/Edit form for new containers and recreate/edit flows, with validation, image pre-pull, inline errors, and an exact CLI preview. +- Container grid with Liquid Glass cards, lifecycle actions, status, image, command, resource highlights, context menus, and full-card hit targets. +- Grouping, sorting, and running-only filters for containers by network, volume, image, or flat list. +- Expanded container tabs for Overview, Logs, Terminal, Stats, History, and Files, including live/follow logs, SwiftTerm terminal access, file browsing/copy workflows, and per-container metric history. +- Shared Run/Edit form for new containers and recreate/edit flows, with validation, image pre-pull, inline errors, app-managed options, and exact CLI preview. +- Compose import from paste, file picker, drag-and-drop, menu command, or command palette action, translating services into editable run forms instead of launching opaque stacks. +- Compose translation for image, platform, command, entrypoint, ports, volumes, env files, network mode, restart policy, health checks, working directory, user, capabilities, DNS, tmpfs, and ulimits, with warnings for unsupported shapes. +- Template storage for reusable run configurations using the same Run/Edit form as other creation paths. - App-managed restart policies and health checks for behavior Apple's `container` CLI does not provide natively. -- Container image update checks that can pull newer image tags and reopen the edit flow without automatically replacing the running container. -#### Images +### Images, Resources & Registries - Local image and tag browsing with grouped references, run actions, history pages, tagging, pushing, saving archives, loading OCI `.tar` archives, pruning, and update status. -- Registry image search flow for Docker Hub, gated behind Settings -> Experimental, with selected results handed into the run configuration flow. -- Image update detection based on local and remote digests, surfaced on image cards, container cards, toolbar panels, System, and command palette entries. +- Image update checks based on local and remote digests, surfaced on image cards, container cards, toolbar panels, System, and command palette entries. - Manual and scheduled image update checks, including "check all", "pull all available image updates", and container-image specific sweeps. +- Experimental Docker Hub search that can hand selected results into the run configuration flow. - Experimental image build workspace for Dockerfile/context builds with streamed BuildKit output. +- Volume and network browsing, creation, deletion, prune actions, local styling, and mount/network-aware container grouping. +- Registry credential management in Settings, including login/logout with passwords piped through `--password-stdin` rather than process arguments. -#### Creation, Compose & Templates - -- Shared creation flow for running containers, editing containers, choosing local images, searching registry images, importing Compose, loading image archives, creating networks, creating volumes, and building images. -- Run/Edit form organized as native macOS sections for Essentials, Resources, Networking, Storage, Environment, App Managed, Appearance, and Advanced Options. -- Structured controls for bounded CLI flags, repeatable rows for lists, and free-form fields where the CLI accepts arbitrary values. -- Compose import from paste, file picker, drag-and-drop, menu command, or command palette action, translating services into editable run forms instead of launching opaque stacks. -- Compose translations for image, platform, command, entrypoint, ports, volumes, env files, network mode, restart policy, health checks, working directory, user, capabilities, DNS, tmpfs, and ulimits, with warnings for unsupported shapes. -- Template storage for reusable run configurations, using the same Run/Edit form as other creation paths. - -#### Resources & Registries - -- Volume browsing, creation, deletion, prune actions, local card styling, and mount-aware container grouping. -- Network browsing, creation, deletion, prune actions, and network-aware container grouping. -- Registry credential management in Settings -> Registries, including login/logout with passwords piped through `--password-stdin` rather than process arguments. -- Activity history for lifecycle, image, compose, system, registry, pull, build, watchdog, healthcheck, alert, and UI events, including unread state, filtering, copy/delete actions, and clear controls. +### System, Settings & Activity -#### System & Settings - -- Onboarding/bootstrap states for missing CLI, unsupported CLI version, stopped service, and ready service. -- System page covering engine/service state, resource usage, background work, volumes, runtime defaults, system logs, and prune/service lifecycle actions. +- Bootstrap states for missing CLI, unsupported CLI version, stopped service, and ready service. +- System page for engine/service state, resource usage, background work, volume inventory, runtime defaults, system logs, and prune/service lifecycle actions. - Settings tabs for General, Appearance, Runtime, Registries, Experimental, Updates, and About. -- Configurable appearance, tint, materials, card density, card material, panel material, button tint, menu-bar behavior, CLI previews, info tips, logging, update cadence, image update cadence, and experimental feature gates. -- Versioned `.containedbackup` export/import for settings, personalization, health checks, templates, and activity history, with per-category selection and merge/replace import behavior. +- Configurable appearance, tint, materials, card density, menu-bar behavior, CLI previews, info tips, logging, update cadence, image update cadence, and experimental feature gates. +- Activity history for lifecycle, image, compose, system, registry, pull, build, watchdog, healthcheck, alert, and UI events, including unread state, filtering, copy/delete actions, and clear controls. +- Versioned `.containedbackup` export/import for settings, personalization, health checks, templates, and activity history, with per-category selection and merge/replace behavior. - Rollback guard for local data created by newer app schemas, including export-before-reset and best-effort keep-readable-data paths. -- Sparkle app updates with Stable, Beta, and Nightly channels plus in-app "What's New" views for current and available builds. -#### Personalization & Design System +### Personalization & Accessibility - Local-only personalization for containers, image groups, image tags, and volumes, including nickname, icon, tint, background, graph/widget choices, and inheritance from image or app defaults. -- Shared Liquid Glass design system primitives: `UI.Card.Scaffold`, `UI.Surface.Content`, `UI.Surface.Input`, `UI.Control.OptionTile`, `UI.Panel.Scaffold`, `UI.Panel.Header`, `UI.Panel.Section`, `UI.Panel.Row`, `UI.Panel.Field`, `UI.Command.PreviewBar`, `UI.Control.InfoButton`, `UI.Control.TintSelector`, `UI.Console.Stream`, `UI.State.ActivityStatusIndicator`, and toolbar controls. -- Accessibility-aware custom visual effects and motion handling, including Reduce Transparency and Reduce Motion support where the app supplies custom glass or animation. -- Shared page and panel scaffolds so sidebar pages, sheets, and toolbar morphs can reuse content without duplicate layouts. - -### Changed +- CPU, memory, network, and disk widgets with configurable graph style, interpolation, tint, and normalization choices. +- Accessibility-aware visual effects and motion handling, including Reduce Transparency and Reduce Motion support where Contained supplies custom glass or animation. -- Fresh installs default to the Nightly update channel and automatically check for app updates during pre-1.0 development. -- Registries live under Settings instead of a standalone app page, while registry actions remain discoverable through menus and the command palette. -- Local card personalization is stored in Contained's own state instead of being written back as decorative container labels. -- Container refresh, lifecycle actions, image list refreshes, disk usage refreshes, and image-update checks are throttled or serialized to avoid unnecessary CLI process churn and UI re-render storms. -- Background work uses a shared refresh coordinator, restart watchdog, health monitor, history store, and logger instead of page-local polling. -- Each release workflow publishes GitHub release notes and Sparkle appcast notes from the same release-note source. -- Pre-release build versions such as `1.0.0-nightly.N+sha` resolve full release notes from the matching base `1.0.0` section. +### Reliability & Performance -### Fixed - -- Sidebar layouts are no longer padded or covered by custom toolbar chrome because toolbar safe-area measurement is scoped to the detail column. -- Page and expanded-card layouts account for toolbar bands only where the experimental toolbar is visible. -- Container lifecycle actions and background polling no longer race the shared container list/stats dictionaries during start/stop/restart transitions. +- Shared refresh coordination for service/list refreshes, lifecycle actions, image list refreshes, disk usage refreshes, and image-update checks to avoid unnecessary CLI process churn. +- One app-wide low-priority stats stream for running containers, with narrower per-container metric invalidation and lazy long panels. +- Chronological sparkline windows, fixed CPU/memory percentage scales, raw network/disk throughput shapes, configurable CPU/memory normalization, and clearer sub-1% readouts. +- Deferred heavyweight expanded-card pages, cached style/tag lookups, and coalesced image refreshes so navigation and customization stay responsive. +- Hardened terminal teardown so rapid card or tab switching cleans up `container exec --tty` children. - Compose import preserves user control by opening editable run forms and reporting unsupported values rather than silently guessing. - App-managed restart and health flows suppress user-initiated stops where appropriate and avoid uncontrolled restart loops. -- Registry login avoids leaking passwords in argv. - -### Technical - -- Swift Package layout with `ContainedCore` for backend orchestration, `ContainedUI` for visual primitives, `ContainedUX` for interaction infrastructure, `ContainedApp` for app-owned SwiftUI/persistence/localization policy, and a tiny `Contained` executable launcher. -- `Core.Command` owns command previews and runtime invocation construction, with adapter-specific argv covered by golden tests. -- `ContainedCore` now owns the shared runtime contract, Core orchestrator, Apple `container` adapter internals, typed async runtime methods, and typed stats streams. -- `RunSpec` is the single source of truth for Run/Edit form state, validation, CLI preview, and actual execution. -- `AppModel` owns bootstrap, client wiring, stores, refresh coordination, image updates, service lifecycle, config transfer, and resource-style lookup through focused extensions. -- `UIState`, `AppSection`, toolbar option enums, and pending actions centralize navigation, filters, morph routing, and classic fallback routing. -- SwiftData-backed `HistoryStore` records events, metric samples, and templates with bounded retention and backup/import support. -- `AppStateEnvelope`, `JSONValue`, `MigrationStep`, and `StateMigrator` provide the baseline for forward migration and downgrade handling from schema version 1 onward. + +### Architecture & Release + +- Package-first SwiftPM layout with `ContainedCore` for backend orchestration, `ContainedUI` for visual primitives, `ContainedUX` for interaction infrastructure, `ContainedApp` for app-owned SwiftUI/persistence/localization policy, and a tiny `Contained` executable launcher. +- `Core.Orchestrator` is the app-facing backend boundary; Core owns runtime descriptors, command previews, Apple `container` adapter internals, typed async runtime methods, Compose/image-default translation, and typed stats streams. +- `RunSpec` owns Run/Edit form state, validation, CLI preview, and execution mapping. +- `AppModel`, focused stores, `UIState`, and toolbar option enums centralize bootstrap, navigation, refresh coordination, image updates, settings, resource styles, filters, and fallback routing. +- SwiftData-backed history records events, metric samples, and templates with bounded retention and backup/import support. +- Checked-in Xcode workspace and native macOS app target build and run `Contained.app` directly while SwiftPM remains the CI, release, packaging, signing, notarization, and appcast source of truth. - Release scripts centralize version/build derivation, retain promoted nightly build numbers for beta/stable, compose channel-specific and full-version release notes, generate Sparkle appcast HTML, and keep the bundled changelog resource in sync. +- Repository validation covers script strict mode and headers, workflow path filters, docs indexes, wiki mapping, package-boundary imports, app adapter internals, stale names, raw token use, empty source/docs folders, release-note composition, and bundled changelog sync. ### Migration Notes diff --git a/changes/unreleased/20260701-code-scanning.md b/changes/unreleased/20260701-code-scanning.md deleted file mode 100644 index fb747eeb..00000000 --- a/changes/unreleased/20260701-code-scanning.md +++ /dev/null @@ -1,4 +0,0 @@ -- Improved GitHub CodeQL scanning so workflow checks run on relevant source, script, workflow, package, and test changes without rerunning for generated appcast-only update feed commits. -- Kept Swift CodeQL enabled as a scheduled/manual scan while avoiding a slow per-PR Swift gate. -- Kept release-note fixture and custom changelog generation isolated from the repository's default change-fragment directory unless `CHANGES_DIR` is explicitly supplied. -- Fixed empty channel-delta generation so "since last nightly/beta" notes fall back deliberately instead of treating a blank normalized diff as content. diff --git a/changes/unreleased/20260701-dependabot-release-note-policy.md b/changes/unreleased/20260701-dependabot-release-note-policy.md deleted file mode 100644 index 75b86077..00000000 --- a/changes/unreleased/20260701-dependabot-release-note-policy.md +++ /dev/null @@ -1,2 +0,0 @@ -- Allowed dependency-only maintenance PRs to use the `no-release-note` path so grouped Dependabot updates can pass repository validation without manual changelog fragments. -- Clarified that `CHANGELOG.md` is curated version-level release notes, while PR/build deltas should live in change fragments. diff --git a/changes/unreleased/20260701-design-system-package.md b/changes/unreleased/20260701-design-system-package.md deleted file mode 100644 index 55ee1817..00000000 --- a/changes/unreleased/20260701-design-system-package.md +++ /dev/null @@ -1 +0,0 @@ -- Extracted the shared Liquid Glass design system into a local `ContainedUI` Swift package, including tokens, panel/page/sheet scaffolds, resource-card chrome, toolbar controls, sparklines, JSON/stream surfaces, gradient controls, clipboard helper, reusable micro-primitives for badges, keycaps, status dots, metric tiles, terminal chrome, and selection overlays, plus package-local README and DocC entry-point documentation. diff --git a/changes/unreleased/20260701-github-setup.md b/changes/unreleased/20260701-github-setup.md deleted file mode 100644 index 48a8d1f0..00000000 --- a/changes/unreleased/20260701-github-setup.md +++ /dev/null @@ -1 +0,0 @@ -- Improved GitHub contributor setup with expanded issue templates, support/security files, discussion guidance, Dependabot configuration, and a PR workflow that still reports the required check for docs-only changes. diff --git a/changes/unreleased/20260701-navigation-package.md b/changes/unreleased/20260701-navigation-package.md deleted file mode 100644 index f2b737be..00000000 --- a/changes/unreleased/20260701-navigation-package.md +++ /dev/null @@ -1 +0,0 @@ -- Started the reusable navigation package by moving toolbar safe-area policy, morph target geometry, morph expander, and morph panel scaffolding into `ContainedUX`, with package-local README and DocC entry-point documentation. diff --git a/changes/unreleased/20260701-runtime-abstraction.md b/changes/unreleased/20260701-runtime-abstraction.md deleted file mode 100644 index 883963ab..00000000 --- a/changes/unreleased/20260701-runtime-abstraction.md +++ /dev/null @@ -1 +0,0 @@ -- Consolidate runtime contracts, the Core orchestrator, and the current Apple `container` adapter into `ContainedCore`, with open-ended runtime identifiers, typed stats streams, runtime-neutral create/import models, Core-owned Compose/image-default translation, and disabled per-container core selection so future Docker-compatible or other engine adapters can be added inside Core. diff --git a/changes/unreleased/20260701-xcode-workspace.md b/changes/unreleased/20260701-xcode-workspace.md deleted file mode 100644 index 23f91f36..00000000 --- a/changes/unreleased/20260701-xcode-workspace.md +++ /dev/null @@ -1,3 +0,0 @@ -## Architecture - -- Add a checked-in Xcode workspace and project entry point for local app development while preserving SwiftPM as the CI/release source of truth. diff --git a/changes/unreleased/20260702-card-anatomy.md b/changes/unreleased/20260702-card-anatomy.md deleted file mode 100644 index 76c56361..00000000 --- a/changes/unreleased/20260702-card-anatomy.md +++ /dev/null @@ -1,5 +0,0 @@ -- Centralized resource-card anatomy in `ContainedUI`: headers stay visible, expanded bodies own density-specific embedded content, large widgets remain sticky, medium widgets move into the body, and small footers move into the body. -- Routed remaining card widget/footer grouping through packaged card primitives, moved expanded page controls into the shared header trailing slot, kept that slot layout-stable during expansion, and updated design-system docs with the sticky/body slot contract. -- Added a packaged sticky header text lane so card titles and subtitles stay anchored with the leading icon chip while expanded controls and page selections change. -- Introduced `UI.Card.Scaffold` as the app-facing resource-card API, including typed page configuration and named icon/title/subtitle/header/body/widget/footer slots, and migrated app cards away from direct `card surface internals`/header composition. -- Added semantic design-system action, toggle, toolbar, selection bar, progress, and status banner routes, then migrated app command chrome away from direct glass button/surface styling. diff --git a/changes/unreleased/20260702-localization-boundary.md b/changes/unreleased/20260702-localization-boundary.md deleted file mode 100644 index 6664b69e..00000000 --- a/changes/unreleased/20260702-localization-boundary.md +++ /dev/null @@ -1,4 +0,0 @@ -## Changed - -- Routed design-system and navigation package copy through app-supplied strings so reusable packages stay language-light while the app is ready for English-only localization support. -- Added a display-neutral package error contract so Core/Runtime throw typed codes and context while the app owns localized error messages, alerts, and Activity entries. diff --git a/changes/unreleased/20260702-refresh-stats-performance.md b/changes/unreleased/20260702-refresh-stats-performance.md deleted file mode 100644 index 28fe232c..00000000 --- a/changes/unreleased/20260702-refresh-stats-performance.md +++ /dev/null @@ -1,25 +0,0 @@ -- Reduced idle UI churn by decoupling container stats from the main refresh tick: metrics now use one app-wide low-priority `container stats --format table` stream for all running containers. -- Lifecycle actions now relist containers without forcing vanity stats, so start/stop/run/recreate flows do not wait on Apple container's two-second stats sampling path. -- Narrowed stats-driven SwiftUI invalidation with per-container metric state, so one streamed sample no longer dirties the whole container grid through shared stats dictionaries. -- Retested always-live card sparklines on Swift Charts after reducing stats update fan-out and per-card history churn. -- Made live sparkline data read chronologically by plotting a full-width, zero-padded window of latest contiguous samples with linear defaults, while keeping user-selectable smoothing available and preventing one noisy sample from rescaling the whole graph. -- Anchored CPU and memory sparklines to the real 0...100% scale, while keeping network and disk widgets auto-scaled for readable throughput shapes. -- Anchored CPU and memory history charts to the real 0...100% scale too, so different percentage levels no longer look identical because of per-window chart auto-scaling. -- Stopped materializing every metric history array for every card frame; cards now pull only the active widget series from shared sample buffers. -- Tightened card metric binding so each widget renders only its own per-container series instead of falling back to another graph's samples when a buffer is missing. -- Normalized card and stats-page CPU/memory values against each container's configured CPU and memory limits while leaving network and disk throughput as raw bytes-per-second rates. -- Added a metric normalization setting, letting users switch CPU/memory percentages between per-container limits and Apple container's machine CPU/memory resources. -- Made scrollable and resource-sized app panels lazy by default, including container tabs, settings pages, image/tag panels, system/activity/history views, and package scaffolds that can host long content. -- Switched singular image detail expansion to the same single-surface card path as containers, leaving morph panels for true toolbar panels instead of wrapping expanded image cards in panel chrome. -- Kept image detail page switches inside one stable expanded card body, anchored the page controls inside the card header, and made Push require registry readiness plus explicit confirmation before starting the stream. -- Warmed container/image customizer sheets from the already-rendered style state and cached local image tag grouping so opening style popovers no longer rebuilds large resource views first. -- Deferred heavyweight expanded-card pages until their tab selection settles, avoiding transient Logs/Terminal/Stats work while users rapidly switch pages. -- Kept lightweight History/Files expanded-card pages immediate and shortened heavyweight page deferral so page switching feels snappier without eagerly loading Logs/Terminal/Stats. -- Removed user-facing JSON Inspect pages and row actions from containers, images, networks, and volumes; image details now use expanded-card pages for Tags, History, Add Tag, and Push. -- Made mini CPU/memory chip percentages preserve tiny sub-1% values so real streamed CPU changes do not disappear behind whole-percent rounding. -- Made CPU/memory percentage readouts consistent across cards, live stats, history charts, and mini chips: whole percentages stay clean, while only sub-1% values show the decimals needed. -- Hardened embedded terminal teardown so rapid card/tab switching cleans up `container exec --tty` children. -- Coalesced image-panel appearance refreshes so page/panel navigation does not force duplicate image-list reloads while users click through the UI. -- Reduced runtime layout churn by replacing the container grid's bound card-frame preference with coalesced frame tracking and clamping morph panel geometry before it reaches SwiftUI frames. -- Skipped empty changelog sections in the in-app What's New parser so nightly builds do not render a blank "Changes Since Last Nightly" block before full release notes. -- Added diagnostic timing around refresh, stats streaming/sampling, image list refreshes, and image update sweeps so future sluggishness has concrete evidence before tuning. diff --git a/changes/unreleased/20260702-standalone-packages-xcode.md b/changes/unreleased/20260702-standalone-packages-xcode.md deleted file mode 100644 index 3faba4a1..00000000 --- a/changes/unreleased/20260702-standalone-packages-xcode.md +++ /dev/null @@ -1,6 +0,0 @@ -- Consolidated backend orchestration into `ContainedCore`, moved visuals into `ContainedUI`, moved interaction infrastructure into `ContainedUX`, and kept preview fixture data as non-shipping Core fixture surfaces with package-local docs, DocC landing pages, and tests. -- Split the SwiftUI app implementation into `ContainedApp` with a tiny SwiftPM launcher, while keeping app-owned localization, stores, Sparkle, SwiftData, settings, and feature routing out of reusable packages. -- Added a checked-in native macOS app target that links `ContainedApp`, builds/runs `Contained.app` directly, and includes an app test bundle for SwiftUI-focused functional checks. -- Reorganized docs into app, feature, architecture, development, and release sections with current package boundary guidance. -- Tightened final boundary leaks by routing terminal `exec` through Core command surfaces, making service lifecycle actions typed runtime values, and keeping app-specific wording out of reusable package comments/previews. -- Updated ownership and release-note enforcement so `Packages/**` changes are covered by repository housekeeping after the package split. diff --git a/changes/unreleased/20260703-foundation-consistency.md b/changes/unreleased/20260703-foundation-consistency.md new file mode 100644 index 00000000..3244432c --- /dev/null +++ b/changes/unreleased/20260703-foundation-consistency.md @@ -0,0 +1,27 @@ +### Architecture + +- Split Contained into standalone local packages: `ContainedCore` for backend orchestration, `ContainedUI` for reusable visual primitives, `ContainedUX` for interaction infrastructure, and `ContainedApp` for app-owned SwiftUI, settings, persistence, localization, and feature policy. +- Added a checked-in Xcode workspace and native macOS app target that links the shared `ContainedApp` package product while keeping SwiftPM as the CI, release, signing, packaging, and appcast source of truth. +- Kept deterministic preview/test samples in the separate `ContainedCoreFixtures` product so normal app and distributable builds do not link fixture data. +- Reorganized docs into app, feature, architecture, development, release, package README, DocC, and wiki-map ownership areas. + +### Runtime & Creation + +- Consolidated runtime contracts, the Core orchestrator, the Apple `container` adapter, command previews, typed runtime methods, typed stats streams, Compose translation, image defaults, and runtime-neutral create/import models inside `ContainedCore`. +- Added open-ended runtime identifiers and disabled per-container runtime selection so future Docker-compatible, Podman, Lima-backed, remote, or other adapters can plug into Core without becoming app-side switches. +- Routed terminal exec, service lifecycle actions, package errors, and reusable-package copy through Core/app-owned presentation boundaries. + +### UI & Performance + +- Extracted Liquid Glass cards, panels, controls, toolbar chrome, command previews, charts, console surfaces, badges, keycaps, status indicators, tint controls, and contextual tokens into `ContainedUI`. +- Moved toolbar safe-area measurement, morph geometry, panel placement, and single-surface expansion infrastructure into `ContainedUX`. +- Centralized resource-card anatomy in `UI.Card.Scaffold`, with stable headers, sticky large widgets, body-hosted medium/small details, typed page controls, and packaged action/status routes. +- Reduced idle UI churn with one app-wide low-priority stats stream, narrower per-container metric invalidation, lazy long panels, deferred heavyweight expanded-card pages, cached style/tag lookups, and coalesced image refreshes. +- Improved metric rendering with chronological sparkline windows, fixed CPU/memory percentage scales, raw network/disk throughput shapes, configurable CPU/memory normalization, and clearer sub-1% CPU/memory readouts. + +### Release & Repository + +- Improved PR, release, CodeQL, Dependabot, support, security, issue-template, and release-note workflows so repository checks run for material source/script/workflow/package/test changes without rerunning for docs-only or appcast-only maintenance. +- Tightened release-note generation so stable releases ship full version notes, beta/nightly builds prepend channel changes, empty deltas are skipped deliberately, and release-script fixtures stay isolated unless a changes source is explicitly supplied. +- Added repository validation for script strict mode and headers, workflow path filters, docs indexes, wiki mapping drift, stale names, package-boundary imports, app adapter internals, raw token use, empty source/docs folders, and bundled changelog sync. +- Clarified that `CHANGELOG.md` remains curated version-level release notes while this consolidated change fragment carries PR/build-level deltas. diff --git a/changes/unreleased/20260703-repo-consistency-sweep.md b/changes/unreleased/20260703-repo-consistency-sweep.md deleted file mode 100644 index 7452e605..00000000 --- a/changes/unreleased/20260703-repo-consistency-sweep.md +++ /dev/null @@ -1,3 +0,0 @@ -### Repository - -- Tighten repository consistency validation for script headers, workflow path filters, docs indexes, and wiki mapping drift. From 09ff1d6fe5b0ddfc977e2d3e2b63bda1164dc364 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Fri, 3 Jul 2026 04:03:29 +0100 Subject: [PATCH 44/53] core run/edit/rebuild abstraction --- CHANGELOG.md | 2 +- Contained.xcodeproj/project.pbxproj | 8 +- Packages/ContainedCore/README.md | 20 +- .../ContainedCore/Compose/ImportExport.swift | 15 +- .../ContainedCore/Compose/YAML/Project.swift | 138 +++++- .../ContainedCore.docc/ContainedCore.md | 10 +- .../Container/CreateRequest.swift | 2 + .../ContainedCore/Container/Document.swift | 3 - .../ContainedCore/Field/Provenance.swift | 16 +- .../Orchestration/Orchestrator.swift | 170 +++++--- .../Command/ContainerCommands.swift | 4 + .../Translation/CreateTranslator.swift | 11 +- .../Schema/ContainerDocumentTranslation.swift | 264 +++++++++++ .../Schema/ContainerFieldCatalog.swift | 390 +++++++++++++++++ .../Schema/DocumentMigrator.swift | 96 ++++ .../ContainedCore/Schema/RunEditSchema.swift | 379 ++++++++++++++++ .../AppleContainer/AdapterTests.swift | 21 +- .../ContainedCoreTests/SchemaTests.swift | 285 ++++++++++++ Sources/ContainedApp/App/AppModel.swift | 16 +- .../Containers/Creation/ComposeImport.swift | 4 +- .../Containers/Creation/CreationFlow.swift | 8 +- .../Containers/Creation/CreationPages.swift | 4 +- .../Details/ContainerConfigureView.swift | 8 +- .../Details/ContainerEditSheet.swift | 2 +- .../Containers/Form/ContainerFormState.swift | 412 ++++++++++++++++++ .../ContainerFormStateMemoryFormatter.swift | 32 ++ ...ecForm.swift => ContainerSchemaForm.swift} | 247 ++++++++--- .../Features/Containers/Form/RunSpec.swift | 331 -------------- .../Containers/Form/RunSpecFormSupport.swift | 147 ------- .../Containers/Store/ContainersStore.swift | 8 +- .../Images/Registry/RegistryImageSearch.swift | 8 +- .../Features/Palette/CommandPalette.swift | 4 +- .../Navigation/State/UIState.swift | 24 +- .../Persistence/History/Template.swift | 233 +++++++++- .../Localization/AppLocalization.swift | 4 + Sources/ContainedApp/Resources/CHANGELOG.md | 2 +- .../Resources/Localizable.xcstrings | 60 +-- ...ts.swift => ContainerFormStateTests.swift} | 79 ++-- .../20260703-foundation-consistency.md | 3 +- docs/architecture/Architecture.md | 2 +- docs/architecture/Runtime-Adapters.md | 23 +- docs/development/Contributing.md | 4 +- docs/features/Compose-Import.md | 19 +- docs/features/Run-Edit-Form.md | 45 +- 44 files changed, 2736 insertions(+), 827 deletions(-) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerDocumentTranslation.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerFieldCatalog.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Schema/DocumentMigrator.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Schema/RunEditSchema.swift create mode 100644 Packages/ContainedCore/Tests/ContainedCoreTests/SchemaTests.swift create mode 100644 Sources/ContainedApp/Features/Containers/Form/ContainerFormState.swift create mode 100644 Sources/ContainedApp/Features/Containers/Form/ContainerFormStateMemoryFormatter.swift rename Sources/ContainedApp/Features/Containers/Form/{RunSpecForm.swift => ContainerSchemaForm.swift} (71%) delete mode 100644 Sources/ContainedApp/Features/Containers/Form/RunSpec.swift delete mode 100644 Sources/ContainedApp/Features/Containers/Form/RunSpecFormSupport.swift rename Tests/ContainedAppTests/{RunSpecTests.swift => ContainerFormStateTests.swift} (88%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfe25cd..8802b46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,7 @@ - Package-first SwiftPM layout with `ContainedCore` for backend orchestration, `ContainedUI` for visual primitives, `ContainedUX` for interaction infrastructure, `ContainedApp` for app-owned SwiftUI/persistence/localization policy, and a tiny `Contained` executable launcher. - `Core.Orchestrator` is the app-facing backend boundary; Core owns runtime descriptors, command previews, Apple `container` adapter internals, typed async runtime methods, Compose/image-default translation, and typed stats streams. -- `RunSpec` owns Run/Edit form state, validation, CLI preview, and execution mapping. +- Core-published run/edit schema documents own editable runtime fields, schema conformance, validation, source aliases, disabled runtime explanations, command previews, and execution mapping. - `AppModel`, focused stores, `UIState`, and toolbar option enums centralize bootstrap, navigation, refresh coordination, image updates, settings, resource styles, filters, and fallback routing. - SwiftData-backed history records events, metric samples, and templates with bounded retention and backup/import support. - Checked-in Xcode workspace and native macOS app target build and run `Contained.app` directly while SwiftPM remains the CI, release, packaging, signing, notarization, and appcast source of truth. diff --git a/Contained.xcodeproj/project.pbxproj b/Contained.xcodeproj/project.pbxproj index 12b9e9f4..feca8b27 100644 --- a/Contained.xcodeproj/project.pbxproj +++ b/Contained.xcodeproj/project.pbxproj @@ -13,7 +13,7 @@ 0E2100010000000000000002 /* ContainersStoreRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000002 /* ContainersStoreRefreshTests.swift */; }; 0E2100010000000000000003 /* MigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000003 /* MigrationTests.swift */; }; 0E2100010000000000000004 /* PaletteSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000004 /* PaletteSearchTests.swift */; }; - 0E2100010000000000000005 /* RunSpecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000005 /* RunSpecTests.swift */; }; + 0E2100010000000000000005 /* ContainerFormStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000005 /* ContainerFormStateTests.swift */; }; 0E2100010000000000000006 /* SystemVolumeInventoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000006 /* SystemVolumeInventoryTests.swift */; }; 0E2100010000000000000007 /* UpdaterControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000007 /* UpdaterControllerTests.swift */; }; 0E2100010000000000000008 /* ContainedApp in Frameworks */ = {isa = PBXBuildFile; productRef = 0E20000B0000000000000001 /* ContainedApp */; }; @@ -38,7 +38,7 @@ 0E2100020000000000000002 /* ContainersStoreRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainersStoreRefreshTests.swift; sourceTree = ""; }; 0E2100020000000000000003 /* MigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationTests.swift; sourceTree = ""; }; 0E2100020000000000000004 /* PaletteSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteSearchTests.swift; sourceTree = ""; }; - 0E2100020000000000000005 /* RunSpecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunSpecTests.swift; sourceTree = ""; }; + 0E2100020000000000000005 /* ContainerFormStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerFormStateTests.swift; sourceTree = ""; }; 0E2100020000000000000006 /* SystemVolumeInventoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemVolumeInventoryTests.swift; sourceTree = ""; }; 0E2100020000000000000007 /* UpdaterControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterControllerTests.swift; sourceTree = ""; }; 0E2100020000000000000008 /* ContainedAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ContainedAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -110,7 +110,7 @@ 0E2100020000000000000002 /* ContainersStoreRefreshTests.swift */, 0E2100020000000000000003 /* MigrationTests.swift */, 0E2100020000000000000004 /* PaletteSearchTests.swift */, - 0E2100020000000000000005 /* RunSpecTests.swift */, + 0E2100020000000000000005 /* ContainerFormStateTests.swift */, 0E2100020000000000000006 /* SystemVolumeInventoryTests.swift */, 0E2100020000000000000007 /* UpdaterControllerTests.swift */, ); @@ -235,7 +235,7 @@ 0E2100010000000000000002 /* ContainersStoreRefreshTests.swift in Sources */, 0E2100010000000000000003 /* MigrationTests.swift in Sources */, 0E2100010000000000000004 /* PaletteSearchTests.swift in Sources */, - 0E2100010000000000000005 /* RunSpecTests.swift in Sources */, + 0E2100010000000000000005 /* ContainerFormStateTests.swift in Sources */, 0E2100010000000000000006 /* SystemVolumeInventoryTests.swift in Sources */, 0E2100010000000000000007 /* UpdaterControllerTests.swift in Sources */, ); diff --git a/Packages/ContainedCore/README.md b/Packages/ContainedCore/README.md index a61bd812..e7de8d25 100644 --- a/Packages/ContainedCore/README.md +++ b/Packages/ContainedCore/README.md @@ -50,13 +50,12 @@ let descriptors = core.availableRuntimeDescriptors ## Create Preview Example ```swift -var request = Core.Container.CreateRequest() -request.runtimeKind = .appleContainer -request.name = "web" -request.image = "nginx:latest" -request.ports = [.init(hostPort: "8080", containerPort: "80")] +var document = Core.Schema.Document.containerCreate() +document.set(.containerName, .string("web")) +document.set(.imageReference, .string("nginx:latest")) +document.set(.networkPorts, .portList([.init(hostPort: "8080", containerPort: "80")])) -let preview = try core.previewCreateCommand(for: request) +let preview = try core.previewCreateCommand(for: document) let command = preview.command ``` @@ -65,12 +64,19 @@ let command = preview.command ```swift let project = try Core.Compose.parse(composeText, projectName: "stack") let plan = try core.translateCompose(project, baseDirectory: composeDirectory) -let requests = plan.items.map(\.request) +let documents = plan.items.map(\.document) ``` Compose is a Core-level interchange format. `Core.Compose.YAML` is the only place that imports Yams; no public Core API exposes Yams types. +## Schema Conformance + +Run/edit documents pass through `Core.Schema.DocumentMigrator` before validation +or execution. The migrator reads the selected runtime's schema descriptors, +maps descriptor-published legacy paths, coerces simple value-kind drift, and +leaves unresolved deviations as field-keyed validation issues. + ## Migration Planning Example ```swift diff --git a/Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift index e52ed509..9e6360ed 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift @@ -12,27 +12,18 @@ struct ImportPlan: Equatable, Sendable { public var items: [Core.Compose.ImportItem] public var warnings: [String] - public init(requests: [Core.Container.CreateRequest], warnings: [String] = []) { - self.items = requests.map { Core.Compose.ImportItem(request: $0) } - self.warnings = warnings - } - public init(items: [Core.Compose.ImportItem], warnings: [String] = []) { self.items = items self.warnings = warnings } - - public var requests: [Core.Container.CreateRequest] { - items.map(\.request) - } } struct ImportItem: Equatable, Sendable { - public var request: Core.Container.CreateRequest + public var document: Core.Schema.Document public var healthCheck: Core.Container.HealthCheck? - public init(request: Core.Container.CreateRequest, healthCheck: Core.Container.HealthCheck? = nil) { - self.request = request + public init(document: Core.Schema.Document, healthCheck: Core.Container.HealthCheck? = nil) { + self.document = document self.healthCheck = healthCheck } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift index 7f57f1d9..59525af8 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift @@ -44,6 +44,7 @@ struct Service: Sendable, Hashable, Identifiable { public let ulimits: [String] public let dependsOn: [Core.Compose.Dependency] public let healthcheck: Core.Compose.Healthcheck? + public let preservedFields: [Core.Field.Path: Core.Schema.Value] public var id: String { key } @@ -55,7 +56,8 @@ struct Service: Sendable, Hashable, Identifiable { interactive: Bool = false, tty: Bool = false, capAdd: [String] = [], capDrop: [String] = [], dns: [String] = [], dnsSearch: [String] = [], dnsOptions: [String] = [], tmpfs: [String] = [], ulimits: [String] = [], - dependsOn: [Core.Compose.Dependency], healthcheck: Core.Compose.Healthcheck?) { + dependsOn: [Core.Compose.Dependency], healthcheck: Core.Compose.Healthcheck?, + preservedFields: [Core.Field.Path: Core.Schema.Value] = [:]) { self.key = key; self.name = name; self.image = image; self.platform = platform; self.command = command self.entrypoint = entrypoint; self.workingDir = workingDir; self.user = user; self.cpus = cpus self.memory = memory; self.ports = ports; self.volumes = volumes; self.environment = environment @@ -65,6 +67,7 @@ struct Service: Sendable, Hashable, Identifiable { self.capAdd = capAdd; self.capDrop = capDrop; self.dns = dns; self.dnsSearch = dnsSearch self.dnsOptions = dnsOptions; self.tmpfs = tmpfs; self.ulimits = ulimits self.dependsOn = dependsOn; self.healthcheck = healthcheck + self.preservedFields = preservedFields } } @@ -166,7 +169,15 @@ enum Parser { "depends_on", "healthcheck", "platform", "entrypoint", "working_dir", "user", "cpus", "mem_limit", "env_file", "labels", "read_only", "init", "stdin_open", "tty", "cap_add", "cap_drop", "dns", "dns_search", "dns_opt", "tmpfs", "ulimits", - "network_mode", "networks"] + "network_mode", "networks", + "extra_hosts", "hostname", "domainname", "mac_address", "expose", "pull_policy", + "attach", "logging", "label_file", "stop_signal", "stop_grace_period", + "devices", "gpus", "group_add", "privileged", "security_opt", "sysctls", + "cgroup", "userns_mode", "pid", "ipc", "uts", "cpu_shares", "cpu_quota", + "cpu_period", "cpuset", "cpu_rt_runtime", "cpu_rt_period", "mem_reservation", + "memswap_limit", "mem_swappiness", "oom_kill_disable", "oom_score_adj", + "blkio_config", "storage_opt", "volumes_from", "secrets", "configs", + "profiles", "deploy", "scale", "links", "provider", "models", "use_api_socket"] private static func service(name: String, body: [String: Any], warnings: inout [String]) -> Core.Compose.Service { for key in body.keys where !supportedKeys.contains(key) { @@ -206,7 +217,8 @@ enum Parser { tmpfs: stringList(body["tmpfs"], service: name, key: "tmpfs", warnings: &warnings), ulimits: ulimits(body["ulimits"], service: name, warnings: &warnings), dependsOn: dependencies(body["depends_on"]), - healthcheck: healthcheck(body["healthcheck"]) + healthcheck: healthcheck(body["healthcheck"]), + preservedFields: preservedFields(body, service: name) ) } @@ -226,6 +238,60 @@ enum Parser { return [] } + private static func preservedFields(_ body: [String: Any], service: String) -> [Core.Field.Path: Core.Schema.Value] { + var fields: [Core.Field.Path: Core.Schema.Value] = [:] + putStringList(&fields, .networkExtraHosts, body["extra_hosts"]) + putString(&fields, .networkHostname, body["hostname"]) + putString(&fields, .networkDomainName, body["domainname"]) + putString(&fields, .networkMacAddress, body["mac_address"]) + putStringList(&fields, .networkExpose, body["expose"]) + putString(&fields, .imagePullPolicy, body["pull_policy"]) + putStringOrBoolList(&fields, .processAttachStreams, body["attach"]) + putLogging(&fields, body["logging"]) + putStringList(&fields, .metadataLabelFiles, body["label_file"]) + putString(&fields, .lifecycleStopSignal, body["stop_signal"]) + putString(&fields, .lifecycleStopGracePeriod, body["stop_grace_period"]) + putStringList(&fields, .devices, body["devices"]) + putString(&fields, .gpus, body["gpus"]) + putStringList(&fields, .processSupplementalGroups, body["group_add"]) + putBool(&fields, .securityPrivileged, body["privileged"]) + putStringList(&fields, .securityOptions, body["security_opt"]) + putKeyValues(&fields, .kernelSysctls, body["sysctls"]) + putString(&fields, .namespaceCgroup, body["cgroup"]) + putString(&fields, .namespaceUser, body["userns_mode"]) + putString(&fields, .namespacePID, body["pid"]) + putString(&fields, .namespaceIPC, body["ipc"]) + putString(&fields, .namespaceUTS, body["uts"]) + putString(&fields, .resourcesCPUShares, body["cpu_shares"]) + putString(&fields, .resourcesCPUQuota, body["cpu_quota"]) + putString(&fields, .resourcesCPUPeriod, body["cpu_period"]) + putString(&fields, .resourcesCPUSet, body["cpuset"]) + putString(&fields, .resourcesCPURealtimeRuntime, body["cpu_rt_runtime"]) + putString(&fields, .resourcesCPURealtimePeriod, body["cpu_rt_period"]) + putString(&fields, .resourcesMemoryReservation, body["mem_reservation"]) + putString(&fields, .resourcesMemorySwapLimit, body["memswap_limit"]) + putString(&fields, .resourcesMemorySwappiness, body["mem_swappiness"]) + putBool(&fields, .resourcesOOMKillDisable, body["oom_kill_disable"]) + putString(&fields, .resourcesOOMScoreAdjust, body["oom_score_adj"]) + putString(&fields, .resourcesBlockIO, body["blkio_config"].map(stringify)) + putKeyValues(&fields, .storageOptions, body["storage_opt"]) + putStringList(&fields, .storageVolumesFrom, body["volumes_from"]) + putStringList(&fields, .composeSecrets, body["secrets"]) + putStringList(&fields, .composeConfigs, body["configs"]) + putStringList(&fields, .composeProfiles, body["profiles"]) + putString(&fields, .composeDeploy, body["deploy"].map(stringify)) + putString(&fields, .composeScale, body["scale"]) + putStringList(&fields, .composeLinks, body["links"]) + let dependencies = dependencies(body["depends_on"]) + if !dependencies.isEmpty { + fields[.composeDependsOn] = .stringList(dependencies.map { "\($0.service)=\($0.condition.rawValue)" }) + } + putString(&fields, .composeProvider, body["provider"].map(stringify)) + putStringList(&fields, .composeModels, body["models"]) + putBool(&fields, .composeUseAPISocket, body["use_api_socket"]) + return fields + } + /// Parse a `healthcheck:` block. `test` accepts `["CMD-SHELL", ""]`, `["CMD", a, b]`, or a /// bare string; we normalize to an `exec` argv. private static func healthcheck(_ value: Any?) -> Core.Compose.Healthcheck? { @@ -407,12 +473,78 @@ enum Parser { return nil } + private static func putString(_ fields: inout [Core.Field.Path: Core.Schema.Value], + _ path: Core.Field.Path, + _ value: Any?) { + guard let string = stringValue(value), !string.isEmpty else { return } + fields[path] = .string(string) + } + + private static func putBool(_ fields: inout [Core.Field.Path: Core.Schema.Value], + _ path: Core.Field.Path, + _ value: Any?) { + guard let bool = value as? Bool else { return } + fields[path] = .bool(bool) + } + + private static func putStringList(_ fields: inout [Core.Field.Path: Core.Schema.Value], + _ path: Core.Field.Path, + _ value: Any?) { + let values: [String] + if let scalar = stringValue(value) { + values = [scalar] + } else if let list = value as? [Any] { + values = list.map(stringify).filter { !$0.isEmpty } + } else if let map = value as? [String: Any] { + values = map.keys.sorted().map { key in + let rendered = stringify(map[key]) + return rendered.isEmpty ? key : "\(key)=\(rendered)" + } + } else { + values = [] + } + guard !values.isEmpty else { return } + fields[path] = .stringList(values) + } + + private static func putStringOrBoolList(_ fields: inout [Core.Field.Path: Core.Schema.Value], + _ path: Core.Field.Path, + _ value: Any?) { + if let bool = value as? Bool { + fields[path] = .stringList([String(bool)]) + return + } + putStringList(&fields, path, value) + } + + private static func putKeyValues(_ fields: inout [Core.Field.Path: Core.Schema.Value], + _ path: Core.Field.Path, + _ value: Any?) { + let values = keyValues(value).compactMap { entry -> Core.Container.KeyValue? in + guard let eq = entry.firstIndex(of: "=") else { return nil } + return Core.Container.KeyValue(key: String(entry[.. String { switch value { case let s as String: return s case let b as Bool: return b ? "true" : "false" case let i as Int: return String(i) case let d as Double: return String(d) + case let list as [Any]: return list.map(stringify).joined(separator: ",") + case let map as [String: Any]: + return map.keys.sorted().map { "\($0)=\(stringify(map[$0]))" }.joined(separator: ",") default: return "" } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md b/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md index c8f40f4f..380704d1 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md +++ b/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md @@ -16,6 +16,7 @@ Use Core for: - canonical container create/edit/import/export models - command previews and host command invocations - Compose import/export plans +- run/edit schema conformance before validation and execution - image defaults and registry helpers - stats snapshots, metric normalization, and history inputs - typed display-neutral package errors @@ -39,13 +40,12 @@ and plans rather than Yams types. ```swift import ContainedCore -var request = Core.Container.CreateRequest() -request.runtimeKind = .appleContainer -request.name = "web" -request.image = "nginx:latest" +var document = Core.Schema.Document.containerCreate() +document.set(.containerName, .string("web")) +document.set(.imageReference, .string("nginx:latest")) let core = Core.Orchestrator.testing(runner: PreviewRunner()) -let preview = try core.previewCreateCommand(for: request) +let preview = try core.previewCreateCommand(for: document) ``` ## Fixtures diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift index e7ad8bbf..89b8fe10 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift @@ -135,6 +135,8 @@ struct CreateRequest: Codable, Equatable, Sendable { public var runtimeKind: Core.Runtime.Kind = .appleContainer public var image = "" public var platform = "" + public var os = "" + public var architecture = "" public var name = "" public var command: [String] = [] public var entrypoint = "" diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift index 4b676003..77beeeed 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift @@ -14,16 +14,13 @@ struct Spec: Codable, Equatable, Sendable { public extension Core.Runtime { struct Projection: Codable, Equatable, Sendable { public var kind: Core.Runtime.Kind - public var schemaVersion: Core.Schema.Version public var preservedFields: [String: String] public var unsupportedFields: [String] public init(kind: Core.Runtime.Kind, - schemaVersion: Core.Schema.Version = .current, preservedFields: [String: String] = [:], unsupportedFields: [String] = []) { self.kind = kind - self.schemaVersion = schemaVersion self.preservedFields = preservedFields self.unsupportedFields = unsupportedFields } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift b/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift index 612faea0..fbbd8753 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift @@ -11,9 +11,23 @@ struct Path: Codable, Equatable, Hashable, Sendable { struct ProvenanceMap: Codable, Equatable, Sendable { public var fields: [Core.Field.Path: Core.Runtime.Kind] + public var sources: [Core.Field.Path: Core.Schema.SourceKind] - public init(fields: [Core.Field.Path: Core.Runtime.Kind] = [:]) { + public init(fields: [Core.Field.Path: Core.Runtime.Kind] = [:], + sources: [Core.Field.Path: Core.Schema.SourceKind] = [:]) { self.fields = fields + self.sources = sources + } + + private enum CodingKeys: String, CodingKey { + case fields + case sources + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + fields = try container.decodeIfPresent([Core.Field.Path: Core.Runtime.Kind].self, forKey: .fields) ?? [:] + sources = try container.decodeIfPresent([Core.Field.Path: Core.Schema.SourceKind].self, forKey: .sources) ?? [:] } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift index 5b68d0d2..de64c70a 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift @@ -29,7 +29,7 @@ public extension Core { case ready(orchestrator: Core.Orchestrator, cliURL: URL, version: String?) } - private let client: AppleContainerClient + private let runtimes: [Core.Runtime.Kind: any ContainerRuntimeClient] public let cliURL: URL public let defaultRuntime: Core.Runtime.Kind @@ -43,7 +43,9 @@ public extension Core { } return Core.Orchestrator(cliURL: url, defaultRuntime: configuration.defaultRuntime, - client: AppleContainerClient(runner: Core.Command.Runner(executableURL: url))) + runtimes: [ + Core.Runtime.Kind.appleContainer: AppleContainerClient(runner: Core.Command.Runner(executableURL: url)), + ]) } public static func testing(runner: any Core.Command.Running, @@ -51,7 +53,9 @@ public extension Core { defaultRuntime: Core.Runtime.Kind = .appleContainer) -> Core.Orchestrator { Core.Orchestrator(cliURL: cliURL, defaultRuntime: defaultRuntime, - client: AppleContainerClient(runner: runner)) + runtimes: [ + Core.Runtime.Kind.appleContainer: AppleContainerClient(runner: runner), + ]) } public static func bootstrap(configuration: Core.Configuration = Core.Configuration()) async -> Bootstrap { @@ -70,16 +74,20 @@ public extension Core { version: version) } - init(cliURL: URL, defaultRuntime: Core.Runtime.Kind, client: AppleContainerClient) { + init(cliURL: URL, defaultRuntime: Core.Runtime.Kind, runtimes: [Core.Runtime.Kind: any ContainerRuntimeClient]) { self.cliURL = cliURL self.defaultRuntime = defaultRuntime - self.client = client + self.runtimes = runtimes } - public var descriptor: Core.Runtime.Descriptor { client.descriptor } + private var defaultClient: any ContainerRuntimeClient { + runtimes[defaultRuntime] ?? runtimes[.appleContainer]! + } + + public var descriptor: Core.Runtime.Descriptor { defaultClient.descriptor } public var availableRuntimeDescriptors: [Core.Runtime.Descriptor] { - [.appleContainer] + runtimes.values.map(\.descriptor).sorted { $0.displayName < $1.displayName } } public var runtimeCoreSelectorIsEnabled: Bool { @@ -95,56 +103,62 @@ public extension Core { } private func requireRuntime(_ kind: Core.Runtime.Kind, - capability: Core.Runtime.Capability) throws -> AppleContainerClient { - guard client.descriptor.kind == kind else { + capability: Core.Runtime.Capability) throws -> any ContainerRuntimeClient { + guard let runtime = runtimes[kind] else { throw Core.Runtime.UnsupportedCapability(kind: kind, capability: capability) } - try client.descriptor.require(capability) - return client + try runtime.descriptor.require(capability) + return runtime + } + + public func schemaDefinition(for operation: Core.Schema.Operation, + runtimeKind: Core.Runtime.Kind? = nil) -> Core.Schema.Definition { + Core.Schema.Definition.containerRunEdit(runtimeKind: runtimeKind ?? defaultRuntime, + operation: operation) } public func listContainers(all: Bool = true) async throws -> [Core.Container.Snapshot] { - try await client.listContainers(all: all) + try await defaultClient.listContainers(all: all) } public func stats(ids: [String] = []) async throws -> [Core.Metrics.ContainerStats] { - try await client.stats(ids: ids) + try await defaultClient.stats(ids: ids) } public func streamStats(ids: [String] = []) -> AsyncThrowingStream<[Core.Metrics.RuntimeStatsSnapshot], Swift.Error> { - client.streamStats(ids: ids) + defaultClient.streamStats(ids: ids) } public func diskUsage() async throws -> Core.System.DiskUsage { - try await client.diskUsage() + try await defaultClient.diskUsage() } public func systemProperties() async throws -> Core.System.Properties { - try await client.systemProperties() + try await defaultClient.systemProperties() } public func dnsDomains() async throws -> [String] { - try await client.dnsDomains() + try await defaultClient.dnsDomains() } @discardableResult public func createDNSDomain(_ domain: String) async throws -> Data { - try await client.createDNSDomain(domain) + try await defaultClient.createDNSDomain(domain) } @discardableResult public func deleteDNSDomain(_ domain: String) async throws -> Data { - try await client.deleteDNSDomain(domain) + try await defaultClient.deleteDNSDomain(domain) } @discardableResult public func setRecommendedKernel() async throws -> Data { - try await client.setRecommendedKernel() + try await defaultClient.setRecommendedKernel() } public func execCapture(_ id: String, _ command: [String]) async throws -> String { - try await client.execCapture(id, command) + try await defaultClient.execCapture(id, command) } @discardableResult public func copy(source: String, destination: String) async throws -> Data { - try await client.copy(source: source, destination: destination) + try await defaultClient.copy(source: source, destination: destination) } public func terminalInvocation(containerID: String, shell: String) throws -> Core.Command.Invocation { @@ -153,22 +167,34 @@ public extension Core { } public func streamSystemLogs(follow: Bool, last: Int? = 500) -> AsyncThrowingStream { - client.streamSystemLogs(follow: follow, last: last) + defaultClient.streamSystemLogs(follow: follow, last: last) } public func systemStatus() async throws -> Core.System.Status { - try await client.systemStatus() + try await defaultClient.systemStatus() } - public func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview { + private func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview { try requireRuntime(request.runtimeKind, capability: .containers).previewCreateCommand(for: request) } - @discardableResult public func createContainer(_ request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { + public func previewCreateCommand(for document: Core.Schema.Document) throws -> Core.Command.Preview { + let definition = schemaDefinition(for: document.operation, runtimeKind: document.runtimeKind) + let request = try document.validatedRequest(definition: definition) + return try previewCreateCommand(for: request) + } + + @discardableResult private func createContainer(_ request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { try await requireRuntime(request.runtimeKind, capability: .containers).createContainer(request) } - @discardableResult public func recreateContainer(originalID: String, + @discardableResult public func createContainer(_ document: Core.Schema.Document) async throws -> Core.Container.CreateResult { + let definition = schemaDefinition(for: document.operation, runtimeKind: document.runtimeKind) + let request = try document.validatedRequest(definition: definition) + return try await createContainer(request) + } + + @discardableResult private func recreateContainer(originalID: String, request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { let runtime = try requireRuntime(request.runtimeKind, capability: .containers) _ = try? await runtime.stop([originalID]) @@ -176,6 +202,13 @@ public extension Core { return try await runtime.createContainer(request) } + @discardableResult public func recreateContainer(originalID: String, + document: Core.Schema.Document) async throws -> Core.Container.CreateResult { + let definition = schemaDefinition(for: document.operation, runtimeKind: document.runtimeKind) + let request = try document.validatedRequest(definition: definition) + return try await recreateContainer(originalID: originalID, request: request) + } + public func translateCompose(_ project: Core.Compose.Project, baseDirectory: URL?, runtimeKind: Core.Runtime.Kind = .appleContainer) throws -> Core.Compose.ImportPlan { @@ -183,12 +216,19 @@ public extension Core { .translateCompose(project, baseDirectory: baseDirectory) } - public func imageDefaults(for request: Core.Container.CreateRequest, + private func imageDefaults(for request: Core.Container.CreateRequest, in images: [Core.Image.Resource]) throws -> Core.Container.ImageDefaults? { try requireRuntime(request.runtimeKind, capability: .containers) .imageDefaults(for: request, in: images) } + public func imageDefaults(for document: Core.Schema.Document, + in images: [Core.Image.Resource]) throws -> Core.Container.ImageDefaults? { + let definition = schemaDefinition(for: document.operation, runtimeKind: document.runtimeKind) + let request = try document.validatedRequest(definition: definition) + return try imageDefaults(for: request, in: images) + } + public func planMigration(_ document: Core.Container.Document, to target: Core.Runtime.Kind?) throws -> Core.Migration.Plan { let source = document.canonical.createRequest.runtimeKind @@ -203,31 +243,31 @@ public extension Core { } public func networks() async throws -> [Core.Network.Resource] { - try await client.networks() + try await defaultClient.networks() } public func volumes() async throws -> [Core.Volume.Resource] { - try await client.volumes() + try await defaultClient.volumes() } public func images() async throws -> [Core.Image.Resource] { - try await client.images() + try await defaultClient.images() } public func inspectImage(_ ref: String) async throws -> [Core.Image.Resource] { - try await client.inspectImage(ref) + try await defaultClient.inspectImage(ref) } public func streamLogs(id: String, follow: Bool = true, tail: Int? = 200, boot: Bool = false) -> AsyncThrowingStream { - client.streamLogs(id: id, follow: follow, tail: tail, boot: boot) + defaultClient.streamLogs(id: id, follow: follow, tail: tail, boot: boot) } public func streamPull(_ ref: String, platform: String? = nil) -> AsyncThrowingStream { - client.streamPull(ref, platform: platform) + defaultClient.streamPull(ref, platform: platform) } public func streamBuild(context: String, @@ -236,111 +276,111 @@ public extension Core { buildArgs: [String: String] = [:], noCache: Bool = false, platform: String? = nil) -> AsyncThrowingStream { - client.streamBuild(context: context, - tag: tag, - dockerfile: dockerfile, - buildArgs: buildArgs, - noCache: noCache, - platform: platform) + defaultClient.streamBuild(context: context, + tag: tag, + dockerfile: dockerfile, + buildArgs: buildArgs, + noCache: noCache, + platform: platform) } public func streamPush(_ ref: String, platform: String? = nil) -> AsyncThrowingStream { - client.streamPush(ref, platform: platform) + defaultClient.streamPush(ref, platform: platform) } @discardableResult public func runContainer(arguments: [String]) async throws -> Data { - try await client.runContainer(arguments: arguments) + try await defaultClient.runContainer(arguments: arguments) } @discardableResult public func performSystemAction(_ action: Core.Runtime.SystemAction) async throws -> Data { - try await client.performSystemAction(action) + try await defaultClient.performSystemAction(action) } public func registries() async throws -> [Core.Registry.Login] { - try await client.registries() + try await defaultClient.registries() } @discardableResult public func registryLogin(server: String, username: String, password: String) async throws -> Data { - try await client.registryLogin(server: server, username: username, password: password) + try await defaultClient.registryLogin(server: server, username: username, password: password) } @discardableResult public func registryLogout(server: String) async throws -> Data { - try await client.registryLogout(server: server) + try await defaultClient.registryLogout(server: server) } @discardableResult public func deleteImages(_ refs: [String]) async throws -> Data { - try await client.deleteImages(refs) + try await defaultClient.deleteImages(refs) } @discardableResult public func tagImage(source: String, target: String) async throws -> Data { - try await client.tagImage(source: source, target: target) + try await defaultClient.tagImage(source: source, target: target) } @discardableResult public func saveImages(_ refs: [String], to output: String) async throws -> Data { - try await client.saveImages(refs, to: output) + try await defaultClient.saveImages(refs, to: output) } @discardableResult public func loadImages(from input: String) async throws -> Data { - try await client.loadImages(from: input) + try await defaultClient.loadImages(from: input) } @discardableResult public func exportContainer(_ id: String, to output: String) async throws -> Data { - try await client.exportContainer(id, to: output) + try await defaultClient.exportContainer(id, to: output) } @discardableResult public func pruneImages(all: Bool = false) async throws -> Data { - try await client.pruneImages(all: all) + try await defaultClient.pruneImages(all: all) } @discardableResult public func start(_ ids: [String]) async throws -> Data { - try await client.start(ids) + try await defaultClient.start(ids) } @discardableResult public func stop(_ ids: [String]) async throws -> Data { - try await client.stop(ids) + try await defaultClient.stop(ids) } @discardableResult public func deleteContainers(_ ids: [String], force: Bool) async throws -> Data { - try await client.deleteContainers(ids, force: force) + try await defaultClient.deleteContainers(ids, force: force) } @discardableResult public func pruneContainers() async throws -> Data { - try await client.pruneContainers() + try await defaultClient.pruneContainers() } @discardableResult public func pruneVolumes() async throws -> Data { - try await client.pruneVolumes() + try await defaultClient.pruneVolumes() } @discardableResult public func pruneNetworks() async throws -> Data { - try await client.pruneNetworks() + try await defaultClient.pruneNetworks() } @discardableResult public func createVolume(name: String, size: String? = nil, labels: [String: String] = [:]) async throws -> Data { - try await client.createVolume(name: name, size: size, labels: labels) + try await defaultClient.createVolume(name: name, size: size, labels: labels) } @discardableResult public func deleteVolumes(_ names: [String]) async throws -> Data { - try await client.deleteVolumes(names) + try await defaultClient.deleteVolumes(names) } @discardableResult public func createNetwork(name: String, subnet: String? = nil, internalOnly: Bool = false, labels: [String: String] = [:]) async throws -> Data { - try await client.createNetwork(name: name, - subnet: subnet, - internalOnly: internalOnly, - labels: labels) + try await defaultClient.createNetwork(name: name, + subnet: subnet, + internalOnly: internalOnly, + labels: labels) } @discardableResult public func deleteNetworks(_ names: [String]) async throws -> Data { - try await client.deleteNetworks(names) + try await defaultClient.deleteNetworks(names) } } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift index 88475537..ec263174 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift @@ -82,6 +82,10 @@ enum ContainerCommands { if request.ssh { args.append("--ssh") } if request.virtualization { args.append("--virtualization") } if !request.platform.isEmpty { args += ["--platform", request.platform] } + if request.platform.isEmpty { + if !request.os.isEmpty { args += ["--os", request.os] } + if !request.architecture.isEmpty { args += ["--arch", request.architecture] } + } if !request.workingDir.isEmpty { args += ["--workdir", request.workingDir] } if !request.user.isEmpty { args += ["--user", request.user] } if !request.uid.isEmpty { args += ["--uid", request.uid] } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift index a73b33bb..7c13515f 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift @@ -18,8 +18,17 @@ enum AppleContainerCreateTranslator { baseDirectory: URL?) -> Core.Compose.ImportPlan { let items = project.services.compactMap { service -> Core.Compose.ImportItem? in guard service.image != nil else { return nil } + var document = Core.Schema.Document.containerCreate(from: createRequest(for: service, projectName: project.name, baseDirectory: baseDirectory)) + for path in document.values.keys + where !(document.values[path] ?? .string("")).isEmpty { + document.provenance.sources[path] = .compose + } + for (path, value) in service.preservedFields { + document.values[path] = value + document.provenance.sources[path] = .compose + } return Core.Compose.ImportItem( - request: createRequest(for: service, projectName: project.name, baseDirectory: baseDirectory), + document: document, healthCheck: healthCheck(for: service) ) } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerDocumentTranslation.swift b/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerDocumentTranslation.swift new file mode 100644 index 00000000..9075605d --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerDocumentTranslation.swift @@ -0,0 +1,264 @@ +import Foundation + +public extension Core.Schema.Definition { + static var appleContainerCreate: Core.Schema.Definition { + .containerRunEdit(runtimeKind: .appleContainer, operation: .containerCreate) + } +} + +public extension Core.Schema.Document { + static func containerCreate(runtimeKind: Core.Runtime.Kind = .appleContainer) -> Core.Schema.Document { + var document = Core.Schema.Document(operation: .containerCreate, runtimeKind: runtimeKind) + let definition = Core.Schema.Definition.containerRunEdit(runtimeKind: runtimeKind) + for field in definition.fields { + if !field.defaultValue.isEmpty || field.isRequired || field.path == .processDetach || field.path == .lifecycleRestartPolicy { + document.values[field.path] = field.defaultValue + } + } + document.set(.runtimeKind, .string(runtimeKind.rawValue)) + return document + } + + static func containerCreate(from request: Core.Container.CreateRequest) -> Core.Schema.Document { + var document = Core.Schema.Document.containerCreate(runtimeKind: request.runtimeKind) + document.set(.imageReference, .string(request.image)) + document.set(.imagePlatform, .string(request.platform)) + document.set(.imageOS, .string(request.os)) + document.set(.imageArchitecture, .string(request.architecture)) + document.set(.containerName, .string(request.name)) + document.set(.processCommand, .commandLine(request.command)) + document.set(.processEntrypoint, .string(request.entrypoint)) + document.set(.processDetach, .bool(request.detach)) + document.set(.processRemoveOnExit, .bool(request.removeOnExit)) + document.set(.processInteractive, .bool(request.interactive)) + document.set(.processTTY, .bool(request.tty)) + document.set(.resourcesCPULimit, .string(request.cpus)) + document.set(.resourcesMemoryLimit, .string(request.memory)) + document.set(.environmentVariables, .keyValueList(request.env)) + document.set(.environmentFiles, .stringList(request.envFiles)) + document.set(.networkPorts, .portList(request.ports)) + document.set(.storageVolumes, .volumeList(request.volumes)) + document.set(.storageMounts, .stringList(request.mounts)) + document.set(.networkSockets, .socketList(request.sockets)) + document.set(.metadataLabels, .keyValueList(request.labels)) + document.set(.lifecycleRestartPolicy, .enumeration(request.restart.rawValue)) + document.set(.securityReadOnlyRootFS, .bool(request.readOnly)) + document.set(.securityUseInit, .bool(request.useInit)) + document.set(.securityRosetta, .bool(request.rosetta)) + document.set(.securitySSHAgent, .bool(request.ssh)) + document.set(.securityVirtualization, .bool(request.virtualization)) + document.set(.processWorkingDirectory, .string(request.workingDir)) + document.set(.processUser, .string(request.user)) + document.set(.processUserID, .string(request.uid)) + document.set(.processGroupID, .string(request.gid)) + document.set(.resourcesSharedMemorySize, .string(request.shmSize)) + document.set(.securityCapabilitiesAdd, .stringList(request.capAdd)) + document.set(.securityCapabilitiesDrop, .stringList(request.capDrop)) + document.set(.outputContainerIDFile, .string(request.cidFile)) + document.set(.imageInitReference, .string(request.initImage)) + document.set(.kernelPath, .string(request.kernel)) + document.set(.networkName, .string(request.network)) + document.set(.networkDNSDisabled, .bool(request.noDNS)) + document.set(.networkDNSServers, .stringList(request.dns)) + document.set(.networkDNSDomain, .string(request.dnsDomain)) + document.set(.networkDNSSearchDomains, .stringList(request.dnsSearch)) + document.set(.networkDNSOptions, .stringList(request.dnsOption)) + document.set(.storageTmpfs, .stringList(request.tmpfs)) + document.set(.processUlimits, .stringList(request.ulimits)) + document.set(.runtimeHandler, .string(request.runtime)) + document.set(.registryScheme, .enumeration(request.scheme)) + document.set(.progressMode, .enumeration(request.progress)) + document.set(.imageMaxConcurrentDownloads, .string(request.maxConcurrentDownloads)) + return document + } + + static func containerEdit(from configuration: Core.Container.Configuration) -> Core.Schema.Document { + var request = Core.Container.CreateRequest() + request.runtimeKind = .appleContainer + request.image = configuration.image.reference + request.platform = configuration.platform.display + request.name = configuration.id + request.command = configuration.initProcess.arguments + request.tty = configuration.initProcess.terminal + request.cpus = String(configuration.resources.cpus) + request.memory = Self.memorySpec(configuration.resources.memoryInBytes) + request.readOnly = configuration.readOnly + request.useInit = configuration.useInit + request.rosetta = configuration.rosetta + request.ssh = configuration.ssh + request.virtualization = configuration.virtualization + request.workingDir = configuration.initProcess.workingDirectory ?? "" + request.shmSize = configuration.shmSize.map(Self.memorySpec) ?? "" + request.capAdd = configuration.capAdd + request.capDrop = configuration.capDrop + request.runtime = configuration.runtimeHandler ?? "" + request.network = configuration.networks.first?.network ?? "" + request.dns = configuration.dns?.nameservers ?? [] + request.dnsDomain = configuration.dns?.domain ?? "" + request.dnsSearch = configuration.dns?.searchDomains ?? [] + request.dnsOption = configuration.dns?.options ?? [] + request.ports = configuration.publishedPorts.map { + let hostPrefix = ($0.hostAddress ?? "").isEmpty || $0.hostAddress == "0.0.0.0" ? "" : "\($0.hostAddress!):" + return Core.Container.Port(hostPort: "\(hostPrefix)\($0.hostPort)", + containerPort: String($0.containerPort), + proto: $0.proto ?? "tcp") + } + request.sockets = configuration.publishedSockets.compactMap { socket in + guard let hostPath = socket.hostPath, let containerPath = socket.containerPath else { return nil } + return Core.Container.Socket(hostPath: hostPath, containerPath: containerPath) + } + request.volumes = configuration.mounts.compactMap { mount in + guard let source = mount.source, let target = mount.effectiveDestination else { return nil } + return Core.Container.VolumeMount(source: source, target: target, readOnly: mount.readonly ?? false) + } + request.env = configuration.initProcess.environment.compactMap { entry in + guard let eq = entry.firstIndex(of: "=") else { return nil } + return Core.Container.KeyValue(key: String(entry[.. Core.Container.CreateRequest { + let definition = definition ?? Core.Schema.Definition.containerRunEdit(runtimeKind: runtimeKind, operation: operation) + let document = migrated(to: definition) + let issues = document.rawValidationIssues(in: definition) + let errors = issues.filter { $0.severity == .error } + if !errors.isEmpty { throw Core.Schema.ValidationError.invalid(errors) } + + var request = Core.Container.CreateRequest() + request.runtimeKind = document.runtimeKind + request.image = document.string(.imageReference, in: definition) + request.platform = document.string(.imagePlatform, in: definition) + request.os = document.string(.imageOS, in: definition) + request.architecture = document.string(.imageArchitecture, in: definition) + request.name = document.string(.containerName, in: definition) + request.command = document.strings(.processCommand, in: definition).filter { !$0.isEmpty } + request.entrypoint = document.string(.processEntrypoint, in: definition) + request.detach = document.bool(.processDetach, in: definition) + request.removeOnExit = document.bool(.processRemoveOnExit, in: definition) + request.interactive = document.bool(.processInteractive, in: definition) + request.tty = document.bool(.processTTY, in: definition) + request.cpus = document.string(.resourcesCPULimit, in: definition) + request.memory = document.string(.resourcesMemoryLimit, in: definition) + request.env = document.keyValues(.environmentVariables, in: definition) + request.envFiles = document.strings(.environmentFiles, in: definition) + request.ports = document.ports(.networkPorts, in: definition) + request.volumes = document.volumes(.storageVolumes, in: definition) + request.mounts = document.strings(.storageMounts, in: definition) + request.sockets = document.sockets(.networkSockets, in: definition) + request.labels = document.keyValues(.metadataLabels, in: definition) + request.restart = Core.Container.RestartPolicy(rawValue: document.string(.lifecycleRestartPolicy, in: definition)) ?? .no + request.readOnly = document.bool(.securityReadOnlyRootFS, in: definition) + request.useInit = document.bool(.securityUseInit, in: definition) + request.rosetta = document.bool(.securityRosetta, in: definition) + request.ssh = document.bool(.securitySSHAgent, in: definition) + request.virtualization = document.bool(.securityVirtualization, in: definition) + request.workingDir = document.string(.processWorkingDirectory, in: definition) + request.user = document.string(.processUser, in: definition) + request.uid = document.string(.processUserID, in: definition) + request.gid = document.string(.processGroupID, in: definition) + request.shmSize = document.string(.resourcesSharedMemorySize, in: definition) + request.capAdd = document.strings(.securityCapabilitiesAdd, in: definition) + request.capDrop = document.strings(.securityCapabilitiesDrop, in: definition) + request.cidFile = document.string(.outputContainerIDFile, in: definition) + request.initImage = document.string(.imageInitReference, in: definition) + request.kernel = document.string(.kernelPath, in: definition) + request.network = document.string(.networkName, in: definition) + request.noDNS = document.bool(.networkDNSDisabled, in: definition) + request.dns = document.strings(.networkDNSServers, in: definition) + request.dnsDomain = document.string(.networkDNSDomain, in: definition) + request.dnsSearch = document.strings(.networkDNSSearchDomains, in: definition) + request.dnsOption = document.strings(.networkDNSOptions, in: definition) + request.tmpfs = document.strings(.storageTmpfs, in: definition) + request.ulimits = document.strings(.processUlimits, in: definition) + request.runtime = document.string(.runtimeHandler, in: definition) + request.scheme = document.string(.registryScheme, in: definition) + request.progress = document.string(.progressMode, in: definition) + request.maxConcurrentDownloads = document.string(.imageMaxConcurrentDownloads, in: definition) + return request + } + + func validationIssues(in definition: Core.Schema.Definition? = nil) -> [Core.Schema.ValidationIssue] { + let definition = definition ?? Core.Schema.Definition.containerRunEdit(runtimeKind: runtimeKind, operation: operation) + return migrated(to: definition).rawValidationIssues(in: definition) + } + + private func rawValidationIssues(in definition: Core.Schema.Definition) -> [Core.Schema.ValidationIssue] { + var issues: [Core.Schema.ValidationIssue] = [] + let descriptorsByPath = Dictionary(uniqueKeysWithValues: definition.fields.map { ($0.path, $0) }) + for (path, value) in values { + guard let descriptor = descriptorsByPath[path] else { + issues.append(.init(field: path, + severity: .error, + messageKey: "schema.validation.unknownField", + defaultMessage: "Unknown field: \(path.rawValue).")) + continue + } + guard descriptor.valueKind == value.valueKind else { + issues.append(.init(field: path, + severity: .error, + messageKey: "schema.validation.wrongType", + defaultMessage: "\(descriptor.defaultLabel) has the wrong value type.")) + continue + } + let support = descriptor.support(for: runtimeKind) + if support.state == .disabled, !value.isEmpty { + issues.append(.init(field: path, + severity: .warning, + messageKey: support.disabledReasonKey ?? "schema.validation.disabledField", + defaultMessage: support.defaultDisabledReason ?? "\(descriptor.defaultLabel) is not executable by this runtime.")) + } + } + for descriptor in definition.fields where descriptor.isRequired { + let value = values[descriptor.path] ?? descriptor.defaultValue + if value.isEmpty { + issues.append(.init(field: descriptor.path, + severity: .error, + messageKey: "schema.validation.required", + defaultMessage: "\(descriptor.defaultLabel) is required.")) + } + } + if !string(.resourcesMemoryLimit, in: definition).isEmpty, + Self.parseMemoryBytes(string(.resourcesMemoryLimit, in: definition)) == nil { + issues.append(.init(field: .resourcesMemoryLimit, + severity: .error, + messageKey: "schema.validation.memory", + defaultMessage: "Memory must be a positive number with optional K, M, G, or T suffix.")) + } + return issues + } + + private static func memorySpec(_ bytes: UInt64) -> String { + let gib = Double(bytes) / 1_073_741_824 + if gib >= 1, gib.rounded() == gib { return "\(Int(gib))G" } + let mib = Double(bytes) / 1_048_576 + if mib >= 1, mib.rounded() == mib { return "\(Int(mib))M" } + return String(bytes) + } + + private static func parseMemoryBytes(_ spec: String) -> UInt64? { + let trimmed = spec.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + let suffix = trimmed.last?.isLetter == true ? trimmed.last! : nil + let numberPart = suffix == nil ? trimmed : String(trimmed.dropLast()) + guard let value = Double(numberPart), value > 0 else { return nil } + let multiplier: Double + switch suffix?.uppercased() { + case nil: multiplier = 1 + case "K": multiplier = 1024 + case "M": multiplier = 1024 * 1024 + case "G": multiplier = 1024 * 1024 * 1024 + case "T": multiplier = 1024 * 1024 * 1024 * 1024 + default: return nil + } + return UInt64(value * multiplier) + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerFieldCatalog.swift b/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerFieldCatalog.swift new file mode 100644 index 00000000..1d6d6f36 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerFieldCatalog.swift @@ -0,0 +1,390 @@ +import Foundation + +public extension Core.Field.Path { + static let runtimeKind = Core.Field.Path("runtime.kind") + static let imageReference = Core.Field.Path("image.reference") + static let imagePlatform = Core.Field.Path("image.platform") + static let imageOS = Core.Field.Path("image.os") + static let imageArchitecture = Core.Field.Path("image.architecture") + static let containerName = Core.Field.Path("container.name") + static let processCommand = Core.Field.Path("process.command") + static let processEntrypoint = Core.Field.Path("process.entrypoint") + static let processDetach = Core.Field.Path("process.detach") + static let processRemoveOnExit = Core.Field.Path("process.removeOnExit") + static let processInteractive = Core.Field.Path("process.interactive") + static let processTTY = Core.Field.Path("process.tty") + static let processWorkingDirectory = Core.Field.Path("process.workingDirectory") + static let processUser = Core.Field.Path("process.user") + static let processUserID = Core.Field.Path("process.userID") + static let processGroupID = Core.Field.Path("process.groupID") + static let processUlimits = Core.Field.Path("process.ulimits") + static let resourcesCPULimit = Core.Field.Path("resources.cpu.limit") + static let resourcesMemoryLimit = Core.Field.Path("resources.memory.limit") + static let resourcesSharedMemorySize = Core.Field.Path("resources.sharedMemory.size") + static let environmentVariables = Core.Field.Path("environment.variables") + static let environmentFiles = Core.Field.Path("environment.files") + static let networkName = Core.Field.Path("network.name") + static let networkPorts = Core.Field.Path("network.ports") + static let networkSockets = Core.Field.Path("network.sockets") + static let networkDNSDisabled = Core.Field.Path("network.dns.disabled") + static let networkDNSServers = Core.Field.Path("network.dns.servers") + static let networkDNSDomain = Core.Field.Path("network.dns.domain") + static let networkDNSSearchDomains = Core.Field.Path("network.dns.searchDomains") + static let networkDNSOptions = Core.Field.Path("network.dns.options") + static let storageVolumes = Core.Field.Path("storage.volumes") + static let storageMounts = Core.Field.Path("storage.mounts") + static let storageTmpfs = Core.Field.Path("storage.tmpfs") + static let metadataLabels = Core.Field.Path("metadata.labels") + static let lifecycleRestartPolicy = Core.Field.Path("lifecycle.restartPolicy") + static let securityReadOnlyRootFS = Core.Field.Path("security.rootFilesystem.readOnly") + static let securityUseInit = Core.Field.Path("security.init.enabled") + static let securityRosetta = Core.Field.Path("security.rosetta.enabled") + static let securitySSHAgent = Core.Field.Path("security.sshAgent.forwarded") + static let securityVirtualization = Core.Field.Path("security.virtualization.exposed") + static let securityCapabilitiesAdd = Core.Field.Path("security.capabilities.add") + static let securityCapabilitiesDrop = Core.Field.Path("security.capabilities.drop") + static let outputContainerIDFile = Core.Field.Path("output.containerIDFile") + static let imageInitReference = Core.Field.Path("image.init.reference") + static let kernelPath = Core.Field.Path("kernel.path") + static let runtimeHandler = Core.Field.Path("runtime.handler") + static let registryScheme = Core.Field.Path("registry.scheme") + static let progressMode = Core.Field.Path("progress.mode") + static let imageMaxConcurrentDownloads = Core.Field.Path("image.fetch.maxConcurrentDownloads") + + static let networkExtraHosts = Core.Field.Path("network.extraHosts") + static let networkHostname = Core.Field.Path("network.hostname") + static let networkDomainName = Core.Field.Path("network.domainName") + static let networkMacAddress = Core.Field.Path("network.macAddress") + static let networkExpose = Core.Field.Path("network.expose") + static let networkPublishAll = Core.Field.Path("network.publishAll") + static let imagePullPolicy = Core.Field.Path("image.pullPolicy") + static let processAttachStreams = Core.Field.Path("process.attachStreams") + static let loggingDriver = Core.Field.Path("logging.driver") + static let loggingOptions = Core.Field.Path("logging.options") + static let metadataLabelFiles = Core.Field.Path("metadata.labelFiles") + static let lifecycleStopSignal = Core.Field.Path("lifecycle.stopSignal") + static let lifecycleStopGracePeriod = Core.Field.Path("lifecycle.stopGracePeriod") + static let devices = Core.Field.Path("hardware.devices") + static let gpus = Core.Field.Path("hardware.gpus") + static let processSupplementalGroups = Core.Field.Path("process.supplementalGroups") + static let securityPrivileged = Core.Field.Path("security.privileged") + static let securityOptions = Core.Field.Path("security.options") + static let kernelSysctls = Core.Field.Path("kernel.sysctls") + static let namespaceCgroup = Core.Field.Path("namespaces.cgroup") + static let namespaceUser = Core.Field.Path("namespaces.user") + static let namespacePID = Core.Field.Path("namespaces.pid") + static let namespaceIPC = Core.Field.Path("namespaces.ipc") + static let namespaceUTS = Core.Field.Path("namespaces.uts") + static let resourcesCPUShares = Core.Field.Path("resources.cpu.shares") + static let resourcesCPUQuota = Core.Field.Path("resources.cpu.quota") + static let resourcesCPUPeriod = Core.Field.Path("resources.cpu.period") + static let resourcesCPUSet = Core.Field.Path("resources.cpu.set") + static let resourcesCPURealtimeRuntime = Core.Field.Path("resources.cpu.realtimeRuntime") + static let resourcesCPURealtimePeriod = Core.Field.Path("resources.cpu.realtimePeriod") + static let resourcesMemoryReservation = Core.Field.Path("resources.memory.reservation") + static let resourcesMemorySwapLimit = Core.Field.Path("resources.memory.swapLimit") + static let resourcesMemorySwappiness = Core.Field.Path("resources.memory.swappiness") + static let resourcesOOMKillDisable = Core.Field.Path("resources.oom.killDisabled") + static let resourcesOOMScoreAdjust = Core.Field.Path("resources.oom.scoreAdjust") + static let resourcesBlockIO = Core.Field.Path("resources.blockIO") + static let storageOptions = Core.Field.Path("storage.options") + static let storageVolumesFrom = Core.Field.Path("storage.volumesFrom") + static let composeSecrets = Core.Field.Path("compose.secrets") + static let composeConfigs = Core.Field.Path("compose.configs") + static let composeProfiles = Core.Field.Path("compose.profiles") + static let composeDeploy = Core.Field.Path("compose.deploy") + static let composeScale = Core.Field.Path("compose.scale") + static let composeLinks = Core.Field.Path("compose.links") + static let composeDependsOn = Core.Field.Path("compose.dependsOn") + static let composeProvider = Core.Field.Path("compose.provider") + static let composeModels = Core.Field.Path("compose.models") + static let composeUseAPISocket = Core.Field.Path("compose.useAPISocket") +} + +public extension Core.Schema.Definition { + static func containerRunEdit(runtimeKind: Core.Runtime.Kind, + operation: Core.Schema.Operation = .containerCreate) -> Core.Schema.Definition { + Core.Schema.Definition(operation: operation, + runtimeKind: runtimeKind, + fields: Self.appleContainerRunFields) + } + + static var appleContainerRunFields: [Core.Schema.FieldDescriptor] { + let supported = Core.Schema.FieldSupport.supported + let unsupported = Core.Schema.FieldSupport( + state: .disabled, + disabledReasonKey: "schema.disabled.appleContainer.unsupportedDockerCompose", + defaultDisabledReason: "Known from Docker CLI or Compose, not executable by Apple container." + ) + + func appleTip(_ key: String, _ text: String) -> [Core.Runtime.Kind: Core.Schema.FieldTipRef] { + [.appleContainer: Core.Schema.FieldTipRef(key: key, defaultText: text)] + } + + func apple(_ path: Core.Field.Path, + _ kind: Core.Schema.ValueKind, + _ section: Core.Schema.FieldSection, + _ label: String, + defaultValue: Core.Schema.Value, + required: Bool = false, + options: [Core.Schema.ValueOption] = [], + flag: String? = nil, + example: String? = nil, + docker: (String, String)? = nil, + compose: (String, String)? = nil, + tip: String) -> Core.Schema.FieldDescriptor { + var aliases: [Core.Schema.SourceAlias] = [] + if let flag { + aliases.append(.init(source: .appleCLI, name: flag, example: example ?? flag)) + } + if let docker { + aliases.append(.init(source: .dockerCLI, name: docker.0, example: docker.1)) + } + if let compose { + aliases.append(.init(source: .compose, name: compose.0, example: compose.1)) + } + return Core.Schema.FieldDescriptor( + path: path, + valueKind: kind, + section: section, + labelKey: "schema.field.\(path.rawValue)", + defaultLabel: label, + defaultValue: defaultValue, + isRequired: required, + options: options, + support: [.appleContainer: supported], + tipRefs: appleTip("schema.tip.\(path.rawValue).apple-container", tip), + sourceAliases: aliases + ) + } + + func disabled(_ path: Core.Field.Path, + _ kind: Core.Schema.ValueKind, + _ label: String, + defaultValue: Core.Schema.Value, + docker: (String, String)? = nil, + compose: (String, String)? = nil, + tip: String) -> Core.Schema.FieldDescriptor { + var aliases: [Core.Schema.SourceAlias] = [] + if let docker { aliases.append(.init(source: .dockerCLI, name: docker.0, example: docker.1)) } + if let compose { aliases.append(.init(source: .compose, name: compose.0, example: compose.1)) } + return Core.Schema.FieldDescriptor( + path: path, + valueKind: kind, + section: .dockerCompose, + labelKey: "schema.field.\(path.rawValue)", + defaultLabel: label, + defaultValue: defaultValue, + support: [.appleContainer: unsupported], + tipRefs: appleTip("schema.tip.\(path.rawValue).apple-container", tip), + sourceAliases: aliases + ) + } + + let restartOptions = [ + Core.Schema.ValueOption(value: "no", labelKey: "schema.option.restart.no", defaultLabel: "No"), + Core.Schema.ValueOption(value: "always", labelKey: "schema.option.restart.always", defaultLabel: "Always"), + Core.Schema.ValueOption(value: "on-failure", labelKey: "schema.option.restart.onFailure", defaultLabel: "On failure"), + ] + let schemeOptions = [ + Core.Schema.ValueOption(value: "", labelKey: "schema.option.default", defaultLabel: "Default"), + Core.Schema.ValueOption(value: "auto", labelKey: "schema.option.scheme.auto", defaultLabel: "Auto"), + Core.Schema.ValueOption(value: "https", labelKey: "schema.option.scheme.https", defaultLabel: "HTTPS"), + Core.Schema.ValueOption(value: "http", labelKey: "schema.option.scheme.http", defaultLabel: "HTTP"), + ] + let progressOptions = [ + Core.Schema.ValueOption(value: "", labelKey: "schema.option.default", defaultLabel: "Default"), + Core.Schema.ValueOption(value: "auto", labelKey: "schema.option.progress.auto", defaultLabel: "Auto"), + Core.Schema.ValueOption(value: "none", labelKey: "schema.option.progress.none", defaultLabel: "None"), + Core.Schema.ValueOption(value: "ansi", labelKey: "schema.option.progress.ansi", defaultLabel: "ANSI"), + Core.Schema.ValueOption(value: "plain", labelKey: "schema.option.progress.plain", defaultLabel: "Plain"), + Core.Schema.ValueOption(value: "color", labelKey: "schema.option.progress.color", defaultLabel: "Color"), + ] + + return [ + apple(.runtimeKind, .string, .runtime, "Core", defaultValue: .string(Core.Runtime.Kind.appleContainer.rawValue), + tip: "Selects the container runtime Core should use for previewing and running this container."), + apple(.imageReference, .string, .essentials, "Image", defaultValue: .string(""), required: true, + flag: "", example: "nginx:latest", docker: ("IMAGE", "docker run nginx:latest"), compose: ("image", "image: nginx:latest"), + tip: "The container image to run. If it is not local, Contained pulls it before running."), + apple(.imagePlatform, .string, .essentials, "Platform", defaultValue: .string(""), + flag: "--platform", example: "--platform linux/arm64", docker: ("--platform", "--platform linux/arm64"), compose: ("platform", "platform: linux/arm64"), + tip: "Pins a multi-platform image to an OS and architecture. Apple container lets this override OS and architecture."), + apple(.imageOS, .string, .essentials, "Image OS", defaultValue: .string(""), + flag: "--os", example: "--os linux", docker: ("--platform", "--platform linux/arm64"), compose: ("platform", "platform: linux/arm64"), + tip: "Sets only the image operating system when no full platform is set."), + apple(.imageArchitecture, .string, .essentials, "Image architecture", defaultValue: .string(""), + flag: "--arch", example: "--arch arm64", docker: ("--platform", "--platform linux/arm64"), compose: ("platform", "platform: linux/arm64"), + tip: "Sets only the image architecture when no full platform is set."), + apple(.containerName, .string, .essentials, "Name", defaultValue: .string(""), + flag: "--name", example: "--name web", docker: ("--name", "--name web"), compose: ("container_name", "container_name: web"), + tip: "Optional container ID. Leave blank and the runtime will generate one."), + apple(.processCommand, .commandLine, .essentials, "Command", defaultValue: .commandLine([]), + flag: "", example: "sleep infinity", docker: ("COMMAND", "docker run alpine sleep infinity"), compose: ("command", "command: sleep infinity"), + tip: "Optional arguments to run instead of the image's default command."), + apple(.processEntrypoint, .string, .process, "Entrypoint", defaultValue: .string(""), + flag: "--entrypoint", example: "--entrypoint /bin/sh", docker: ("--entrypoint", "--entrypoint /bin/sh"), compose: ("entrypoint", "entrypoint: /bin/sh"), + tip: "Overrides the image entrypoint program."), + apple(.processDetach, .bool, .essentials, "Run in the background", defaultValue: .bool(true), + flag: "--detach", example: "--detach", docker: ("--detach", "--detach"), compose: ("detach-like", "Compose services run detached from the form"), + tip: "Runs without attaching to the process output."), + apple(.processRemoveOnExit, .bool, .essentials, "Remove when stopped", defaultValue: .bool(false), + flag: "--rm, --remove", example: "--rm", docker: ("--rm", "--rm"), compose: ("", ""), + tip: "Deletes the container record after it stops. Use volumes for data you need to keep."), + apple(.resourcesCPULimit, .string, .resources, "CPUs", defaultValue: .string(""), + flag: "--cpus", example: "--cpus 2", docker: ("--cpus", "--cpus 2"), compose: ("cpus", "cpus: 2"), + tip: "Limits how many CPUs the container can use. Leave blank to let the runtime decide."), + apple(.resourcesMemoryLimit, .string, .resources, "Memory", defaultValue: .string(""), + flag: "--memory", example: "--memory 1G", docker: ("--memory", "--memory 1G"), compose: ("mem_limit", "mem_limit: 512M"), + tip: "Sets a memory ceiling for the container."), + apple(.networkPorts, .portList, .networking, "Ports", defaultValue: .portList([]), + flag: "--publish", example: "--publish 127.0.0.1:8080:80/tcp", docker: ("--publish", "--publish 8080:80"), compose: ("ports", "ports: [\"8080:80\"]"), + tip: "Publishes container ports to the host."), + apple(.networkName, .string, .networking, "Network", defaultValue: .string(""), + flag: "--network", example: "--network media,mtu=1280", docker: ("--network", "--network media"), compose: ("network_mode/networks", "networks: [media]"), + tip: "Attaches the container to a runtime network."), + apple(.networkSockets, .socketList, .networking, "Sockets", defaultValue: .socketList([]), + flag: "--publish-socket", example: "--publish-socket /tmp/app.sock:/run/app.sock", + tip: "Publishes a host socket path into the container."), + apple(.storageVolumes, .volumeList, .storage, "Volumes", defaultValue: .volumeList([]), + flag: "--volume", example: "--volume data:/var/lib/app:ro", docker: ("--volume", "--volume data:/var/lib/app"), compose: ("volumes", "volumes: [\"data:/var/lib/app\"]"), + tip: "Bind mounts a host path or named volume into the container."), + apple(.storageMounts, .stringList, .storage, "Mounts", defaultValue: .stringList([]), + flag: "--mount", example: "--mount type=bind,source=/host,target=/container,readonly", docker: ("--mount", "--mount type=bind,src=/host,dst=/container"), compose: ("volumes long syntax", "volumes: [{type: bind, source: ./data, target: /data}]"), + tip: "Advanced mount syntax for bind, volume, or tmpfs style mounts."), + apple(.storageTmpfs, .stringList, .storage, "Tmpfs mounts", defaultValue: .stringList([]), + flag: "--tmpfs", example: "--tmpfs /tmp", docker: ("--tmpfs", "--tmpfs /tmp"), compose: ("tmpfs", "tmpfs: [/tmp]"), + tip: "Mounts an in-memory filesystem at the given container path."), + apple(.environmentVariables, .keyValueList, .environment, "Environment variables", defaultValue: .keyValueList([]), + flag: "--env", example: "--env KEY=value", docker: ("--env", "--env KEY=value"), compose: ("environment", "environment: { KEY: value }"), + tip: "Sets environment variables inside the container."), + apple(.environmentFiles, .stringList, .environment, "Environment files", defaultValue: .stringList([]), + flag: "--env-file", example: "--env-file ./app.env", docker: ("--env-file", "--env-file ./app.env"), compose: ("env_file", "env_file: ./app.env"), + tip: "Loads environment variables from a file."), + apple(.lifecycleRestartPolicy, .enumeration, .metadata, "Restart policy", defaultValue: .enumeration("no"), options: restartOptions, + docker: ("--restart", "--restart=always"), compose: ("restart", "restart: always"), + tip: "Contained stores this as local/runtime metadata and uses it for restart behavior."), + apple(.metadataLabels, .keyValueList, .metadata, "Labels", defaultValue: .keyValueList([]), + flag: "--label", example: "--label team=infra", docker: ("--label", "--label team=infra"), compose: ("labels", "labels: { team: infra }"), + tip: "Adds runtime metadata labels. Contained app personalization is stored separately."), + apple(.processInteractive, .bool, .process, "Keep stdin open", defaultValue: .bool(false), + flag: "--interactive", example: "--interactive", docker: ("--interactive", "--interactive"), compose: ("stdin_open", "stdin_open: true"), + tip: "Keeps standard input open for interactive processes."), + apple(.processTTY, .bool, .process, "Allocate TTY", defaultValue: .bool(false), + flag: "--tty", example: "--tty", docker: ("--tty", "--tty"), compose: ("tty", "tty: true"), + tip: "Allocates a terminal for the process."), + apple(.processWorkingDirectory, .string, .process, "Working directory", defaultValue: .string(""), + flag: "--workdir, --cwd", example: "--workdir /app", docker: ("--workdir", "--workdir /app"), compose: ("working_dir", "working_dir: /app"), + tip: "Sets the initial working directory inside the container."), + apple(.processUser, .string, .process, "User", defaultValue: .string(""), + flag: "--user", example: "--user 1000:1000", docker: ("--user", "--user 1000:1000"), compose: ("user", "user: \"1000:1000\""), + tip: "Runs the process as a user name or uid[:gid]."), + apple(.processUserID, .string, .process, "User ID", defaultValue: .string(""), + flag: "--uid", example: "--uid 1000", tip: "Sets the numeric user ID for the process."), + apple(.processGroupID, .string, .process, "Group ID", defaultValue: .string(""), + flag: "--gid", example: "--gid 1000", tip: "Sets the numeric group ID for the process."), + apple(.resourcesSharedMemorySize, .string, .process, "Shared memory", defaultValue: .string(""), + flag: "--shm-size", example: "--shm-size 64M", docker: ("--shm-size", "--shm-size 64M"), compose: ("shm_size", "shm_size: 64M"), + tip: "Sets the size of /dev/shm."), + apple(.processUlimits, .stringList, .process, "Resource limits", defaultValue: .stringList([]), + flag: "--ulimit", example: "--ulimit nofile=1024:2048", docker: ("--ulimit", "--ulimit nofile=1024:2048"), compose: ("ulimits", "ulimits: { nofile: { soft: 1024, hard: 2048 } }"), + tip: "Sets process resource limits."), + apple(.securityCapabilitiesAdd, .stringList, .security, "Add capabilities", defaultValue: .stringList([]), + flag: "--cap-add", example: "--cap-add CAP_NET_RAW", docker: ("--cap-add", "--cap-add CAP_NET_RAW"), compose: ("cap_add", "cap_add: [CAP_NET_RAW]"), + tip: "Adds Linux capabilities to the container."), + apple(.securityCapabilitiesDrop, .stringList, .security, "Drop capabilities", defaultValue: .stringList([]), + flag: "--cap-drop", example: "--cap-drop ALL", docker: ("--cap-drop", "--cap-drop ALL"), compose: ("cap_drop", "cap_drop: [ALL]"), + tip: "Drops Linux capabilities from the container."), + apple(.outputContainerIDFile, .string, .process, "Container ID file", defaultValue: .string(""), + flag: "--cidfile", example: "--cidfile /tmp/container.cid", docker: ("--cidfile", "--cidfile /tmp/container.cid"), + tip: "Writes the new container ID to a file."), + apple(.securityReadOnlyRootFS, .bool, .security, "Read-only filesystem", defaultValue: .bool(false), + flag: "--read-only", example: "--read-only", docker: ("--read-only", "--read-only"), compose: ("read_only", "read_only: true"), + tip: "Mounts the container root filesystem as read-only."), + apple(.securityUseInit, .bool, .security, "Use an init process", defaultValue: .bool(false), + flag: "--init", example: "--init", docker: ("--init", "--init"), compose: ("init", "init: true"), + tip: "Runs a small init process that forwards signals and reaps processes."), + apple(.securityRosetta, .bool, .security, "Rosetta", defaultValue: .bool(false), + flag: "--rosetta", example: "--rosetta", tip: "Allows x86-64 Linux binaries to run through Rosetta when supported."), + apple(.securitySSHAgent, .bool, .security, "Forward SSH agent", defaultValue: .bool(false), + flag: "--ssh", example: "--ssh", tip: "Forwards your host SSH agent socket into the container."), + apple(.securityVirtualization, .bool, .security, "Expose virtualization", defaultValue: .bool(false), + flag: "--virtualization", example: "--virtualization", tip: "Exposes virtualization capabilities to the container when host and guest support it."), + apple(.networkDNSDisabled, .bool, .networking, "Disable DNS", defaultValue: .bool(false), + flag: "--no-dns", example: "--no-dns", tip: "Prevents DNS from being configured in the container."), + apple(.networkDNSServers, .stringList, .networking, "DNS servers", defaultValue: .stringList([]), + flag: "--dns", example: "--dns 1.1.1.1", docker: ("--dns", "--dns 1.1.1.1"), compose: ("dns", "dns: [1.1.1.1]"), + tip: "Sets DNS nameserver IP addresses."), + apple(.networkDNSDomain, .string, .networking, "DNS domain", defaultValue: .string(""), + flag: "--dns-domain", example: "--dns-domain example.test", tip: "Sets the default DNS domain."), + apple(.networkDNSSearchDomains, .stringList, .networking, "DNS search domains", defaultValue: .stringList([]), + flag: "--dns-search", example: "--dns-search home.arpa", docker: ("--dns-search", "--dns-search home.arpa"), compose: ("dns_search", "dns_search: [home.arpa]"), + tip: "Adds DNS search domains."), + apple(.networkDNSOptions, .stringList, .networking, "DNS options", defaultValue: .stringList([]), + flag: "--dns-option", example: "--dns-option ndots:2", docker: ("--dns-option", "--dns-option ndots:2"), compose: ("dns_opt", "dns_opt: [ndots:2]"), + tip: "Adds resolver options."), + apple(.imageInitReference, .string, .imageFetch, "Init image", defaultValue: .string(""), + flag: "--init-image", example: "--init-image init:latest", tip: "Uses a custom init image instead of the runtime default."), + apple(.kernelPath, .string, .imageFetch, "Kernel", defaultValue: .string(""), + flag: "--kernel", example: "--kernel /path/to/vmlinux", tip: "Uses a custom kernel path."), + apple(.runtimeHandler, .string, .imageFetch, "Runtime handler", defaultValue: .string(""), + flag: "--runtime", example: "--runtime container-runtime-linux", compose: ("runtime", "runtime: runc"), + tip: "Sets the low-level runtime handler."), + apple(.registryScheme, .enumeration, .imageFetch, "Registry scheme", defaultValue: .enumeration(""), options: schemeOptions, + flag: "--scheme", example: "--scheme https", tip: "Controls the registry connection scheme for image fetches."), + apple(.progressMode, .enumeration, .imageFetch, "Progress", defaultValue: .enumeration(""), options: progressOptions, + flag: "--progress", example: "--progress plain", tip: "Controls progress output style for image fetches."), + apple(.imageMaxConcurrentDownloads, .string, .imageFetch, "Max parallel downloads", defaultValue: .string(""), + flag: "--max-concurrent-downloads", example: "--max-concurrent-downloads 2", tip: "Limits concurrent image downloads."), + + disabled(.networkExtraHosts, .stringList, "Extra hosts", defaultValue: .stringList([]), docker: ("--add-host", "--add-host host.docker.internal=host-gateway"), compose: ("extra_hosts", "extra_hosts: [\"host.docker.internal:host-gateway\"]"), tip: "Adds entries to /etc/hosts in Docker-compatible runtimes."), + disabled(.networkHostname, .string, "Hostname", defaultValue: .string(""), docker: ("--hostname", "--hostname app"), compose: ("hostname", "hostname: app"), tip: "Sets the container hostname in Docker-compatible runtimes."), + disabled(.networkDomainName, .string, "Domain name", defaultValue: .string(""), docker: ("--domainname", "--domainname example.test"), compose: ("domainname", "domainname: example.test"), tip: "Sets the container NIS/domain name."), + disabled(.networkMacAddress, .string, "MAC address", defaultValue: .string(""), docker: ("--mac-address", "--mac-address 02:42:ac:11:00:02"), compose: ("mac_address", "mac_address: 02:42:ac:11:00:02"), tip: "Requests a fixed MAC address."), + disabled(.networkExpose, .stringList, "Expose ports", defaultValue: .stringList([]), docker: ("--expose", "--expose 80"), compose: ("expose", "expose: [80]"), tip: "Exposes container ports without publishing them to the host."), + disabled(.networkPublishAll, .bool, "Publish all exposed ports", defaultValue: .bool(false), docker: ("--publish-all", "--publish-all"), tip: "Publishes every exposed port to random host ports."), + disabled(.imagePullPolicy, .string, "Pull policy", defaultValue: .string(""), docker: ("--pull", "--pull=always"), compose: ("pull_policy", "pull_policy: always"), tip: "Controls when Docker-compatible runtimes pull the image."), + disabled(.processAttachStreams, .stringList, "Attach streams", defaultValue: .stringList([]), docker: ("--attach", "--attach stdout"), compose: ("attach", "attach: false"), tip: "Controls attached STDIN/STDOUT/STDERR streams."), + disabled(.loggingDriver, .string, "Logging driver", defaultValue: .string(""), docker: ("--log-driver", "--log-driver syslog"), compose: ("logging.driver", "logging: { driver: syslog }"), tip: "Selects a Docker logging driver."), + disabled(.loggingOptions, .keyValueList, "Logging options", defaultValue: .keyValueList([]), docker: ("--log-opt", "--log-opt max-size=10m"), compose: ("logging.options", "logging: { options: { max-size: 10m } }"), tip: "Configures logging driver options."), + disabled(.metadataLabelFiles, .stringList, "Label files", defaultValue: .stringList([]), docker: ("--label-file", "--label-file ./labels"), tip: "Loads labels from a file."), + disabled(.lifecycleStopSignal, .string, "Stop signal", defaultValue: .string(""), docker: ("--stop-signal", "--stop-signal SIGTERM"), compose: ("stop_signal", "stop_signal: SIGTERM"), tip: "Signal used to stop the container."), + disabled(.lifecycleStopGracePeriod, .string, "Stop grace period", defaultValue: .string(""), compose: ("stop_grace_period", "stop_grace_period: 30s"), tip: "How long Compose waits before force-stopping."), + disabled(.devices, .stringList, "Devices", defaultValue: .stringList([]), docker: ("--device", "--device /dev/sda:/dev/xvdc"), compose: ("devices", "devices: [/dev/sda:/dev/xvdc]"), tip: "Passes host devices into the container."), + disabled(.gpus, .string, "GPUs", defaultValue: .string(""), docker: ("--gpus", "--gpus all"), compose: ("gpus", "gpus: all"), tip: "Requests GPU devices."), + disabled(.processSupplementalGroups, .stringList, "Supplemental groups", defaultValue: .stringList([]), docker: ("--group-add", "--group-add audio"), compose: ("group_add", "group_add: [audio]"), tip: "Adds supplemental groups."), + disabled(.securityPrivileged, .bool, "Privileged", defaultValue: .bool(false), docker: ("--privileged", "--privileged"), compose: ("privileged", "privileged: true"), tip: "Runs with extended host privileges."), + disabled(.securityOptions, .stringList, "Security options", defaultValue: .stringList([]), docker: ("--security-opt", "--security-opt no-new-privileges=true"), compose: ("security_opt", "security_opt: [no-new-privileges:true]"), tip: "Applies Docker security profiles or labels."), + disabled(.kernelSysctls, .keyValueList, "Sysctls", defaultValue: .keyValueList([]), docker: ("--sysctl", "--sysctl net.ipv4.ip_forward=1"), compose: ("sysctls", "sysctls: { net.ipv4.ip_forward: 1 }"), tip: "Sets kernel sysctls for the container."), + disabled(.namespaceCgroup, .string, "Cgroup namespace", defaultValue: .string(""), docker: ("--cgroupns", "--cgroupns private"), compose: ("cgroup", "cgroup: private"), tip: "Controls the cgroup namespace."), + disabled(.namespaceUser, .string, "User namespace", defaultValue: .string(""), docker: ("--userns", "--userns host"), compose: ("userns_mode", "userns_mode: host"), tip: "Controls the user namespace."), + disabled(.namespacePID, .string, "PID namespace", defaultValue: .string(""), docker: ("--pid", "--pid host"), compose: ("pid", "pid: host"), tip: "Controls the process namespace."), + disabled(.namespaceIPC, .string, "IPC namespace", defaultValue: .string(""), docker: ("--ipc", "--ipc host"), compose: ("ipc", "ipc: host"), tip: "Controls the IPC namespace."), + disabled(.namespaceUTS, .string, "UTS namespace", defaultValue: .string(""), compose: ("uts", "uts: host"), tip: "Controls the UTS namespace."), + disabled(.resourcesCPUShares, .string, "CPU shares", defaultValue: .string(""), docker: ("--cpu-shares", "--cpu-shares 512"), compose: ("cpu_shares", "cpu_shares: 512"), tip: "Sets relative CPU weight."), + disabled(.resourcesCPUQuota, .string, "CPU quota", defaultValue: .string(""), docker: ("--cpu-quota", "--cpu-quota 50000"), compose: ("cpu_quota", "cpu_quota: 50000"), tip: "Sets CPU CFS quota."), + disabled(.resourcesCPUPeriod, .string, "CPU period", defaultValue: .string(""), docker: ("--cpu-period", "--cpu-period 100000"), compose: ("cpu_period", "cpu_period: 100000"), tip: "Sets CPU CFS period."), + disabled(.resourcesCPUSet, .string, "CPU set", defaultValue: .string(""), docker: ("--cpuset-cpus", "--cpuset-cpus 0-3"), compose: ("cpuset", "cpuset: 0-3"), tip: "Pins execution to specific CPUs."), + disabled(.resourcesCPURealtimeRuntime, .string, "CPU realtime runtime", defaultValue: .string(""), docker: ("--cpu-rt-runtime", "--cpu-rt-runtime 95000"), compose: ("cpu_rt_runtime", "cpu_rt_runtime: 95000"), tip: "Sets real-time CPU runtime."), + disabled(.resourcesCPURealtimePeriod, .string, "CPU realtime period", defaultValue: .string(""), docker: ("--cpu-rt-period", "--cpu-rt-period 100000"), compose: ("cpu_rt_period", "cpu_rt_period: 100000"), tip: "Sets real-time CPU period."), + disabled(.resourcesMemoryReservation, .string, "Memory reservation", defaultValue: .string(""), compose: ("mem_reservation", "mem_reservation: 256M"), tip: "Sets a soft memory reservation."), + disabled(.resourcesMemorySwapLimit, .string, "Swap limit", defaultValue: .string(""), docker: ("--memory-swap", "--memory-swap 1G"), compose: ("memswap_limit", "memswap_limit: 1G"), tip: "Sets memory plus swap limit."), + disabled(.resourcesMemorySwappiness, .string, "Memory swappiness", defaultValue: .string(""), docker: ("--memory-swappiness", "--memory-swappiness 0"), compose: ("mem_swappiness", "mem_swappiness: 0"), tip: "Controls anonymous page swapping."), + disabled(.resourcesOOMKillDisable, .bool, "Disable OOM killer", defaultValue: .bool(false), docker: ("--oom-kill-disable", "--oom-kill-disable"), compose: ("oom_kill_disable", "oom_kill_disable: true"), tip: "Disables OOM killer behavior."), + disabled(.resourcesOOMScoreAdjust, .string, "OOM score adjustment", defaultValue: .string(""), docker: ("--oom-score-adj", "--oom-score-adj 500"), compose: ("oom_score_adj", "oom_score_adj: 500"), tip: "Adjusts host OOM scoring."), + disabled(.resourcesBlockIO, .string, "Block I/O", defaultValue: .string(""), docker: ("--blkio-weight", "--blkio-weight 300"), compose: ("blkio_config", "blkio_config: { weight: 300 }"), tip: "Controls block I/O weighting and throttling."), + disabled(.storageOptions, .keyValueList, "Storage options", defaultValue: .keyValueList([]), docker: ("--storage-opt", "--storage-opt size=120G"), compose: ("storage_opt", "storage_opt: { size: 120G }"), tip: "Passes storage driver options."), + disabled(.storageVolumesFrom, .stringList, "Volumes from", defaultValue: .stringList([]), docker: ("--volumes-from", "--volumes-from db"), compose: ("volumes_from", "volumes_from: [db]"), tip: "Mounts volumes from another container."), + disabled(.composeSecrets, .stringList, "Secrets", defaultValue: .stringList([]), compose: ("secrets", "secrets: [db_password]"), tip: "Compose secrets are preserved for future Docker-compatible runtimes."), + disabled(.composeConfigs, .stringList, "Configs", defaultValue: .stringList([]), compose: ("configs", "configs: [app_config]"), tip: "Compose configs are preserved for future Docker-compatible runtimes."), + disabled(.composeProfiles, .stringList, "Profiles", defaultValue: .stringList([]), compose: ("profiles", "profiles: [dev]"), tip: "Compose profiles select which services participate in a run."), + disabled(.composeDeploy, .string, "Deploy metadata", defaultValue: .string(""), compose: ("deploy", "deploy: { replicas: 2 }"), tip: "Compose deploy metadata is preserved but not applied by Apple container."), + disabled(.composeScale, .string, "Scale", defaultValue: .string(""), compose: ("scale", "scale: 2"), tip: "Compose service scale is preserved for future stack-oriented runtimes."), + disabled(.composeLinks, .stringList, "Links", defaultValue: .stringList([]), compose: ("links", "links: [db]"), tip: "Legacy Compose links are preserved for reference."), + disabled(.composeDependsOn, .stringList, "Dependencies", defaultValue: .stringList([]), compose: ("depends_on", "depends_on: { db: { condition: service_healthy } }"), tip: "Compose startup dependency metadata is preserved for reference."), + disabled(.composeProvider, .string, "Provider", defaultValue: .string(""), compose: ("provider", "provider: awesomecloud"), tip: "Compose provider metadata is preserved for future runtimes."), + disabled(.composeModels, .stringList, "Models", defaultValue: .stringList([]), compose: ("models", "models: [ai_model]"), tip: "Compose model metadata is preserved for future runtimes."), + disabled(.composeUseAPISocket, .bool, "Use API socket", defaultValue: .bool(false), compose: ("use_api_socket", "use_api_socket: true"), tip: "Compose API socket access is preserved for future runtimes."), + ] + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Schema/DocumentMigrator.swift b/Packages/ContainedCore/Sources/ContainedCore/Schema/DocumentMigrator.swift new file mode 100644 index 00000000..05c81fea --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/DocumentMigrator.swift @@ -0,0 +1,96 @@ +import Foundation + +public extension Core.Schema { +struct DocumentMigrator: Sendable { + public init() {} + + public func migrate(_ document: Core.Schema.Document, + to definition: Core.Schema.Definition) -> Core.Schema.Document { + var migrated = document + migrated.operation = definition.operation + migrated.runtimeKind = definition.runtimeKind + migrated.schemaVersion = definition.version + + let knownPaths = Set(definition.fields.map(\.path)) + var consumedLegacyPaths = Set() + + for field in definition.fields where migrated.values[field.path] == nil { + guard let legacyPath = field.legacyPaths.first(where: { migrated.values[$0] != nil }), + let legacyValue = migrated.values[legacyPath] else { continue } + migrated.values[field.path] = coerce(legacyValue, to: field.valueKind) ?? legacyValue + if let source = migrated.provenance.sources[legacyPath] { + migrated.provenance.sources[field.path] = source + } + consumedLegacyPaths.insert(legacyPath) + } + + for field in definition.fields { + guard let value = migrated.values[field.path], + value.valueKind != field.valueKind, + let coerced = coerce(value, to: field.valueKind) else { continue } + migrated.values[field.path] = coerced + } + + for legacyPath in consumedLegacyPaths where !knownPaths.contains(legacyPath) { + migrated.values.removeValue(forKey: legacyPath) + migrated.provenance.sources.removeValue(forKey: legacyPath) + } + + return migrated + } + + private func coerce(_ value: Core.Schema.Value, + to valueKind: Core.Schema.ValueKind) -> Core.Schema.Value? { + switch (value, valueKind) { + case (.enumeration(let value), .string): + return .string(value) + case (.string(let value), .enumeration): + return .enumeration(value) + case (.string(let value), .commandLine): + return .commandLine(splitCommand(value)) + case (.stringList(let value), .commandLine): + return .commandLine(value) + case (.string(let value), .stringList): + return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .stringList([]) : .stringList([value]) + case (.commandLine(let value), .stringList): + return .stringList(value) + case (.string(let value), .bool): + return bool(value).map(Core.Schema.Value.bool) + case (.enumeration(let value), .bool): + return bool(value).map(Core.Schema.Value.bool) + case (.stringList(let value), .keyValueList): + let pairs = value.compactMap(keyValue) + return pairs.count == value.count ? .keyValueList(pairs) : nil + case (.keyValueList(let value), .stringList): + return .stringList(value.filter(\.isValid).map { "\($0.key)=\($0.value)" }) + default: + return nil + } + } + + private func splitCommand(_ value: String) -> [String] { + value.split(separator: " ").map(String.init) + } + + private func bool(_ value: String) -> Bool? { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "true", "yes", "1", "on": return true + case "false", "no", "0", "off", "": return false + default: return nil + } + } + + private func keyValue(_ value: String) -> Core.Container.KeyValue? { + guard let eq = value.firstIndex(of: "=") else { return nil } + return Core.Container.KeyValue(key: String(value[.. Core.Schema.Document { + migrator.migrate(self, to: definition) + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Schema/RunEditSchema.swift b/Packages/ContainedCore/Sources/ContainedCore/Schema/RunEditSchema.swift new file mode 100644 index 00000000..aefea27d --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/RunEditSchema.swift @@ -0,0 +1,379 @@ +import Foundation + +public extension Core.Schema { +enum Operation: String, Codable, Equatable, Hashable, Sendable { + case containerCreate = "container.create" + case containerEdit = "container.edit" +} + +enum FieldSection: String, Codable, CaseIterable, Equatable, Hashable, Sendable { + case runtime + case essentials + case resources + case networking + case storage + case environment + case process + case security + case imageFetch + case metadata + case dockerCompose +} + +enum ValueKind: String, Codable, Equatable, Hashable, Sendable { + case string + case bool + case enumeration + case commandLine + case stringList + case keyValueList + case portList + case volumeList + case socketList +} + +enum Value: Codable, Equatable, Hashable, Sendable { + case string(String) + case bool(Bool) + case enumeration(String) + case commandLine([String]) + case stringList([String]) + case keyValueList([Core.Container.KeyValue]) + case portList([Core.Container.Port]) + case volumeList([Core.Container.VolumeMount]) + case socketList([Core.Container.Socket]) + + private enum CodingKeys: String, CodingKey { + case kind + case value + } + + public var valueKind: Core.Schema.ValueKind { + switch self { + case .string: return .string + case .bool: return .bool + case .enumeration: return .enumeration + case .commandLine: return .commandLine + case .stringList: return .stringList + case .keyValueList: return .keyValueList + case .portList: return .portList + case .volumeList: return .volumeList + case .socketList: return .socketList + } + } + + public var isEmpty: Bool { + switch self { + case .string(let value), .enumeration(let value): + return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .bool(let value): + return value == false + case .commandLine(let values), .stringList(let values): + return values.allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + case .keyValueList(let values): + return values.allSatisfy { !$0.isValid } + case .portList(let values): + return values.allSatisfy { !$0.isValid } + case .volumeList(let values): + return values.allSatisfy { !$0.isValid } + case .socketList(let values): + return values.allSatisfy { !$0.isValid } + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(Core.Schema.ValueKind.self, forKey: .kind) + switch kind { + case .string: + self = .string(try container.decode(String.self, forKey: .value)) + case .bool: + self = .bool(try container.decode(Bool.self, forKey: .value)) + case .enumeration: + self = .enumeration(try container.decode(String.self, forKey: .value)) + case .commandLine: + self = .commandLine(try container.decode([String].self, forKey: .value)) + case .stringList: + self = .stringList(try container.decode([String].self, forKey: .value)) + case .keyValueList: + self = .keyValueList(try container.decode([Core.Container.KeyValue].self, forKey: .value)) + case .portList: + self = .portList(try container.decode([Core.Container.Port].self, forKey: .value)) + case .volumeList: + self = .volumeList(try container.decode([Core.Container.VolumeMount].self, forKey: .value)) + case .socketList: + self = .socketList(try container.decode([Core.Container.Socket].self, forKey: .value)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(valueKind, forKey: .kind) + switch self { + case .string(let value): + try container.encode(value, forKey: .value) + case .bool(let value): + try container.encode(value, forKey: .value) + case .enumeration(let value): + try container.encode(value, forKey: .value) + case .commandLine(let value): + try container.encode(value, forKey: .value) + case .stringList(let value): + try container.encode(value, forKey: .value) + case .keyValueList(let value): + try container.encode(value, forKey: .value) + case .portList(let value): + try container.encode(value, forKey: .value) + case .volumeList(let value): + try container.encode(value, forKey: .value) + case .socketList(let value): + try container.encode(value, forKey: .value) + } + } +} + +enum SourceKind: String, Codable, Equatable, Hashable, Sendable { + case appleCLI + case dockerCLI + case compose +} + +struct SourceAlias: Codable, Equatable, Hashable, Sendable { + public var source: Core.Schema.SourceKind + public var name: String + public var example: String + + public init(source: Core.Schema.SourceKind, name: String, example: String) { + self.source = source + self.name = name + self.example = example + } +} + +struct FieldTipRef: Codable, Equatable, Hashable, Sendable { + public var key: String + public var defaultText: String + + public init(key: String, defaultText: String) { + self.key = key + self.defaultText = defaultText + } +} + +enum SupportState: String, Codable, Equatable, Hashable, Sendable { + case supported + case disabled +} + +struct FieldSupport: Codable, Equatable, Hashable, Sendable { + public var state: Core.Schema.SupportState + public var disabledReasonKey: String? + public var defaultDisabledReason: String? + + public init(state: Core.Schema.SupportState, + disabledReasonKey: String? = nil, + defaultDisabledReason: String? = nil) { + self.state = state + self.disabledReasonKey = disabledReasonKey + self.defaultDisabledReason = defaultDisabledReason + } + + public static let supported = Core.Schema.FieldSupport(state: .supported) +} + +struct ValueOption: Codable, Equatable, Hashable, Sendable { + public var value: String + public var labelKey: String + public var defaultLabel: String + + public init(value: String, labelKey: String, defaultLabel: String) { + self.value = value + self.labelKey = labelKey + self.defaultLabel = defaultLabel + } +} + +struct FieldDescriptor: Codable, Equatable, Hashable, Identifiable, Sendable { + public var path: Core.Field.Path + public var id: String { path.rawValue } + public var valueKind: Core.Schema.ValueKind + public var section: Core.Schema.FieldSection + public var labelKey: String + public var defaultLabel: String + public var defaultValue: Core.Schema.Value + public var isRequired: Bool + public var options: [Core.Schema.ValueOption] + public var support: [Core.Runtime.Kind: Core.Schema.FieldSupport] + public var tipRefs: [Core.Runtime.Kind: Core.Schema.FieldTipRef] + public var sourceAliases: [Core.Schema.SourceAlias] + public var legacyPaths: [Core.Field.Path] + + public init(path: Core.Field.Path, + valueKind: Core.Schema.ValueKind, + section: Core.Schema.FieldSection, + labelKey: String, + defaultLabel: String, + defaultValue: Core.Schema.Value, + isRequired: Bool = false, + options: [Core.Schema.ValueOption] = [], + support: [Core.Runtime.Kind: Core.Schema.FieldSupport] = [:], + tipRefs: [Core.Runtime.Kind: Core.Schema.FieldTipRef] = [:], + sourceAliases: [Core.Schema.SourceAlias] = [], + legacyPaths: [Core.Field.Path] = []) { + self.path = path + self.valueKind = valueKind + self.section = section + self.labelKey = labelKey + self.defaultLabel = defaultLabel + self.defaultValue = defaultValue + self.isRequired = isRequired + self.options = options + self.support = support + self.tipRefs = tipRefs + self.sourceAliases = sourceAliases + self.legacyPaths = legacyPaths + } + + public func support(for runtimeKind: Core.Runtime.Kind) -> Core.Schema.FieldSupport { + support[runtimeKind] ?? Core.Schema.FieldSupport( + state: .disabled, + disabledReasonKey: "schema.disabled.unsupported", + defaultDisabledReason: "This field is known to Core but is not available for the selected runtime." + ) + } + + public func tip(for runtimeKind: Core.Runtime.Kind) -> Core.Schema.FieldTipRef? { + tipRefs[runtimeKind] + } +} + +struct Definition: Codable, Equatable, Sendable { + public var operation: Core.Schema.Operation + public var version: Core.Schema.Version + public var runtimeKind: Core.Runtime.Kind + public var fields: [Core.Schema.FieldDescriptor] + + public init(operation: Core.Schema.Operation, + version: Core.Schema.Version = .current, + runtimeKind: Core.Runtime.Kind, + fields: [Core.Schema.FieldDescriptor]) { + self.operation = operation + self.version = version + self.runtimeKind = runtimeKind + self.fields = fields + } + + public func descriptor(for path: Core.Field.Path) -> Core.Schema.FieldDescriptor? { + fields.first { $0.path == path } + } +} + +enum ValidationSeverity: String, Codable, Equatable, Sendable { + case error + case warning +} + +struct ValidationIssue: Codable, Equatable, Sendable { + public var field: Core.Field.Path + public var severity: Core.Schema.ValidationSeverity + public var messageKey: String + public var defaultMessage: String + + public init(field: Core.Field.Path, + severity: Core.Schema.ValidationSeverity, + messageKey: String, + defaultMessage: String) { + self.field = field + self.severity = severity + self.messageKey = messageKey + self.defaultMessage = defaultMessage + } +} + +struct Document: Codable, Equatable, Sendable { + public var operation: Core.Schema.Operation + public var schemaVersion: Core.Schema.Version + public var runtimeKind: Core.Runtime.Kind + public var values: [Core.Field.Path: Core.Schema.Value] + public var provenance: Core.Field.ProvenanceMap + + public init(operation: Core.Schema.Operation = .containerCreate, + schemaVersion: Core.Schema.Version = .current, + runtimeKind: Core.Runtime.Kind = .appleContainer, + values: [Core.Field.Path: Core.Schema.Value] = [:], + provenance: Core.Field.ProvenanceMap = Core.Field.ProvenanceMap()) { + self.operation = operation + self.schemaVersion = schemaVersion + self.runtimeKind = runtimeKind + self.values = values + self.provenance = provenance + } + + public func value(_ path: Core.Field.Path, in definition: Core.Schema.Definition? = nil) -> Core.Schema.Value? { + values[path] ?? definition?.descriptor(for: path)?.defaultValue + } + + public mutating func set(_ path: Core.Field.Path, _ value: Core.Schema.Value?) { + values[path] = value + } + + public func string(_ path: Core.Field.Path, in definition: Core.Schema.Definition? = nil) -> String { + switch value(path, in: definition) { + case .string(let value), .enumeration(let value): return value + default: return "" + } + } + + public func bool(_ path: Core.Field.Path, in definition: Core.Schema.Definition? = nil) -> Bool { + if case .bool(let value) = value(path, in: definition) { return value } + return false + } + + public func strings(_ path: Core.Field.Path, in definition: Core.Schema.Definition? = nil) -> [String] { + switch value(path, in: definition) { + case .stringList(let value), .commandLine(let value): return value + default: return [] + } + } + + public func keyValues(_ path: Core.Field.Path, in definition: Core.Schema.Definition? = nil) -> [Core.Container.KeyValue] { + if case .keyValueList(let value) = value(path, in: definition) { return value } + return [] + } + + public func ports(_ path: Core.Field.Path, in definition: Core.Schema.Definition? = nil) -> [Core.Container.Port] { + if case .portList(let value) = value(path, in: definition) { return value } + return [] + } + + public func volumes(_ path: Core.Field.Path, in definition: Core.Schema.Definition? = nil) -> [Core.Container.VolumeMount] { + if case .volumeList(let value) = value(path, in: definition) { return value } + return [] + } + + public func sockets(_ path: Core.Field.Path, in definition: Core.Schema.Definition? = nil) -> [Core.Container.Socket] { + if case .socketList(let value) = value(path, in: definition) { return value } + return [] + } +} + +enum ValidationError: Error, Equatable, Sendable { + case invalid([Core.Schema.ValidationIssue]) +} +} + +extension Core.Schema.ValidationError: Core.Error.PackageError { + public var packageName: String { "ContainedCore" } + public var packageErrorCode: String { + switch self { + case .invalid: return "schemaInvalid" + } + } + + public var packageErrorContext: [String: String] { + switch self { + case .invalid(let issues): + return ["issues": issues.map(\.field.rawValue).joined(separator: ",")] + } + } +} diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift index 3578aa0a..7d4c0d96 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift @@ -99,16 +99,17 @@ struct AppleContainerAdapterTests { let base = URL(filePath: "/opt/stacks/demo", directoryHint: .isDirectory) let plan = AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: base) let item = try #require(plan.items.first) - - #expect(item.request.runtimeKind == .appleContainer) - #expect(item.request.name == "demo-app") - #expect(item.request.image == "example/app:1") - #expect(item.request.command == ["serve", "--port", "8080"]) - #expect(item.request.ports.map(\.spec) == ["18080:8080"]) - #expect(item.request.volumes.map(\.spec) == ["/opt/stacks/demo/config:/config:ro"]) - #expect(item.request.env.map { "\($0.key)=\($0.value)" } == ["TZ=Europe/London"]) - #expect(item.request.restart == .always) - #expect(item.request.labels.contains { $0.key == "contained.stack" && $0.value == "demo" }) + let request = try item.document.validatedRequest() + + #expect(request.runtimeKind == .appleContainer) + #expect(request.name == "demo-app") + #expect(request.image == "example/app:1") + #expect(request.command == ["serve", "--port", "8080"]) + #expect(request.ports.map(\.spec) == ["18080:8080"]) + #expect(request.volumes.map(\.spec) == ["/opt/stacks/demo/config:/config:ro"]) + #expect(request.env.map { "\($0.key)=\($0.value)" } == ["TZ=Europe/London"]) + #expect(request.restart == .always) + #expect(request.labels.contains { $0.key == "contained.stack" && $0.value == "demo" }) #expect(item.healthCheck?.command == ["curl", "-f", "http://localhost:8080"]) #expect(item.healthCheck?.retries == 2) } diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/SchemaTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/SchemaTests.swift new file mode 100644 index 00000000..41d965ab --- /dev/null +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/SchemaTests.swift @@ -0,0 +1,285 @@ +import Foundation +import Testing +@testable import ContainedCore + +@Suite("Container run/edit schema") +struct SchemaTests { + @Test func appleSchemaPublishesCurrentRunFieldAliases() throws { + let definition = Core.Schema.Definition.appleContainerCreate + let paths = Set(definition.fields.map(\.path)) + + let expectedPaths: [Core.Field.Path] = [ + .runtimeKind, + .imageReference, + .imagePlatform, + .imageOS, + .imageArchitecture, + .containerName, + .processCommand, + .processEntrypoint, + .processDetach, + .processRemoveOnExit, + .processInteractive, + .processTTY, + .processWorkingDirectory, + .processUser, + .processUserID, + .processGroupID, + .processUlimits, + .resourcesCPULimit, + .resourcesMemoryLimit, + .resourcesSharedMemorySize, + .environmentVariables, + .environmentFiles, + .networkName, + .networkPorts, + .networkSockets, + .networkDNSDisabled, + .networkDNSServers, + .networkDNSDomain, + .networkDNSSearchDomains, + .networkDNSOptions, + .storageVolumes, + .storageMounts, + .storageTmpfs, + .metadataLabels, + .securityReadOnlyRootFS, + .securityUseInit, + .securityRosetta, + .securitySSHAgent, + .securityVirtualization, + .securityCapabilitiesAdd, + .securityCapabilitiesDrop, + .outputContainerIDFile, + .imageInitReference, + .kernelPath, + .runtimeHandler, + .registryScheme, + .progressMode, + .imageMaxConcurrentDownloads, + ] + + for path in expectedPaths { + #expect(paths.contains(path), "Missing schema field \(path.rawValue)") + } + + let appleAliases = Set( + definition.fields + .flatMap(\.sourceAliases) + .filter { $0.source == .appleCLI } + .map(\.name) + ) + let helpAliases = [ + "", + "", + "--env", + "--env-file", + "--user", + "--uid", + "--gid", + "--workdir, --cwd", + "--interactive", + "--tty", + "--ulimit", + "--cpus", + "--memory", + "--cap-add", + "--cap-drop", + "--cidfile", + "--detach", + "--dns", + "--dns-domain", + "--dns-option", + "--dns-search", + "--entrypoint", + "--init", + "--init-image", + "--kernel", + "--label", + "--mount", + "--name", + "--network", + "--no-dns", + "--os", + "--arch", + "--platform", + "--publish", + "--publish-socket", + "--read-only", + "--rm, --remove", + "--rosetta", + "--runtime", + "--ssh", + "--shm-size", + "--tmpfs", + "--virtualization", + "--volume", + "--scheme", + "--progress", + "--max-concurrent-downloads", + ] + + for alias in helpAliases { + #expect(appleAliases.contains(alias), "Missing Apple source alias \(alias)") + } + + let publish = try #require(definition.descriptor(for: .networkPorts)) + #expect(publish.sourceAliases.contains { + $0.source == .appleCLI && + $0.name == "--publish" && + $0.example.contains("127.0.0.1:8080:80/tcp") + }) + #expect(publish.tip(for: .appleContainer)?.key == "schema.tip.network.ports.apple-container") + } + + @Test func appleOSAndArchitectureGenerateFlagsOnlyWithoutPlatform() throws { + var document = Core.Schema.Document.containerCreate() + document.set(.imageReference, .string("alpine")) + document.set(.imageOS, .string("linux")) + document.set(.imageArchitecture, .string("amd64")) + + let request = try document.validatedRequest() + let args = ContainerCommands.run(request) + #expect(subsequence(["--os", "linux"], in: args)) + #expect(subsequence(["--arch", "amd64"], in: args)) + + document.set(.imagePlatform, .string("linux/arm64")) + let platformRequest = try document.validatedRequest() + let platformArgs = ContainerCommands.run(platformRequest) + #expect(subsequence(["--platform", "linux/arm64"], in: platformArgs)) + #expect(!platformArgs.contains("--os")) + #expect(!platformArgs.contains("--arch")) + } + + @Test func documentRuntimeFieldTracksRuntimeKind() { + let document = Core.Schema.Document.containerCreate(runtimeKind: .dockerCompatible) + #expect(document.runtimeKind == .dockerCompatible) + #expect(document.string(.runtimeKind) == Core.Runtime.Kind.dockerCompatible.rawValue) + } + + @Test func schemaConformanceMigratorDoesNotNeedVersionGate() throws { + var document = Core.Schema.Document.containerCreate() + document.schemaVersion = Core.Schema.Version(999) + document.set(.imageReference, .string("alpine")) + document.set(.processCommand, .string("echo hello")) + + let definition = Core.Schema.Definition.appleContainerCreate + let migrated = document.migrated(to: definition) + #expect(migrated.schemaVersion == definition.version) + #expect(migrated.strings(.processCommand, in: definition) == ["echo", "hello"]) + + let request = try document.validatedRequest(definition: definition) + #expect(request.image == "alpine") + #expect(request.command == ["echo", "hello"]) + } + + @Test func schemaConformanceMigratorMapsLegacyPathsPublishedByCore() throws { + let oldImagePath = Core.Field.Path("legacy.image") + let definition = Core.Schema.Definition( + operation: .containerCreate, + runtimeKind: .appleContainer, + fields: [ + Core.Schema.FieldDescriptor( + path: .imageReference, + valueKind: .string, + section: .essentials, + labelKey: "schema.field.image.reference", + defaultLabel: "Image", + defaultValue: .string(""), + isRequired: true, + support: [.appleContainer: .supported], + legacyPaths: [oldImagePath] + ), + ] + ) + var document = Core.Schema.Document.containerCreate() + document.values = [oldImagePath: .string("alpine")] + + let migrated = document.migrated(to: definition) + #expect(migrated.string(.imageReference, in: definition) == "alpine") + #expect(migrated.value(oldImagePath) == nil) + #expect(migrated.validationIssues(in: definition).isEmpty) + } + + @Test func unresolvedSchemaDeviationsReturnFieldKeyedIssues() { + var document = Core.Schema.Document.containerCreate() + document.set(.imageReference, .bool(true)) + document.set(Core.Field.Path("unknown.future.field"), .string("value")) + + let issues = document.validationIssues() + #expect(issues.contains { + $0.field == .imageReference && + $0.severity == .error && + $0.messageKey == "schema.validation.wrongType" + }) + #expect(issues.contains { + $0.field.rawValue == "unknown.future.field" && + $0.severity == .error && + $0.messageKey == "schema.validation.unknownField" + }) + } + + @Test func composeOnlyFieldsImportAsDisabledSchemaValues() throws { + let yaml = """ + services: + app: + image: example/app:1 + pull_policy: always + extra_hosts: + - host.docker.internal:host-gateway + privileged: true + security_opt: + - no-new-privileges:true + volumes_from: + - db + secrets: + - app_secret + deploy: + replicas: 2 + use_api_socket: true + """ + + let project = try Core.Compose.Parser.parse(yaml, projectName: "demo") + let plan = AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: nil) + let item = try #require(plan.items.first) + let definition = Core.Schema.Definition.appleContainerCreate + + #expect(item.document.string(.imagePullPolicy, in: definition) == "always") + #expect(item.document.strings(.networkExtraHosts, in: definition) == ["host.docker.internal:host-gateway"]) + #expect(item.document.bool(.securityPrivileged, in: definition)) + #expect(item.document.strings(.securityOptions, in: definition) == ["no-new-privileges:true"]) + #expect(item.document.strings(.storageVolumesFrom, in: definition) == ["db"]) + #expect(item.document.strings(.composeSecrets, in: definition) == ["app_secret"]) + #expect(item.document.string(.composeDeploy, in: definition).contains("replicas")) + #expect(item.document.bool(.composeUseAPISocket, in: definition)) + #expect(item.document.provenance.sources[.imagePullPolicy] == .compose) + #expect(item.document.provenance.sources[.securityPrivileged] == .compose) + + let pullPolicyDescriptor = try #require(definition.descriptor(for: .imagePullPolicy)) + let support = pullPolicyDescriptor.support(for: .appleContainer) + #expect(support.state == .disabled) + #expect(support.defaultDisabledReason == "Known from Docker CLI or Compose, not executable by Apple container.") + #expect(pullPolicyDescriptor.sourceAliases.contains { + $0.source == .dockerCLI && $0.example == "--pull=always" + }) + #expect(pullPolicyDescriptor.sourceAliases.contains { + $0.source == .compose && $0.example == "pull_policy: always" + }) + + let warnings = item.document.validationIssues(in: definition).filter { $0.severity == .warning } + #expect(warnings.contains { $0.field == .imagePullPolicy }) + #expect(warnings.contains { $0.field == .securityPrivileged }) + + let request = try item.document.validatedRequest(definition: definition) + #expect(request.image == "example/app:1") + #expect(request.image != item.document.string(.imagePullPolicy, in: definition)) + } + + private func subsequence(_ needle: [String], in haystack: [String]) -> Bool { + guard needle.count <= haystack.count else { return false } + for start in 0...(haystack.count - needle.count) { + if Array(haystack[start.. 1 @@ -317,14 +317,14 @@ final class AppModel { await capturedError(work)?.appDisplayMessage } - func previewCreateCommand(for spec: RunSpec) -> [String] { - (try? core(for: spec.effectiveRuntimeKind)?.previewCreateCommand(for: spec.createRequest).command) - ?? spec.arguments() + func previewCreateCommand(for spec: ContainerFormState) -> [String] { + (try? core(for: spec.effectiveRuntimeKind)?.previewCreateCommand(for: spec.document).command) + ?? [] } - func imageDefaults(for spec: RunSpec) -> Core.Container.ImageDefaults? { + func imageDefaults(for spec: ContainerFormState) -> Core.Container.ImageDefaults? { guard let client = core(for: spec.effectiveRuntimeKind) else { return nil } - return try? client.imageDefaults(for: spec.createRequest, in: images) + return try? client.imageDefaults(for: spec.document, in: images) } /// Run a throwing action while preserving the original error for Activity/package metadata. @@ -469,7 +469,7 @@ final class AppModel { /// visible progress bar — so a fresh template or image "just works" instead of appearing to do /// nothing while the image silently downloads. Attaches local style + healthcheck on success. @discardableResult - func createContainer(_ spec: RunSpec) async -> String? { + func createContainer(_ spec: ContainerFormState) async -> String? { guard core(for: spec.effectiveRuntimeKind) != nil else { let error = Core.Runtime.UnsupportedCapability(kind: spec.effectiveRuntimeKind, capability: .containers) createError = error.appDisplayMessage @@ -501,7 +501,7 @@ final class AppModel { /// Recreate an existing container from an edited spec. Pulls the replacement image before /// deleting the current container so an unavailable image does not strand the edit flow. @discardableResult - func recreateContainer(originalID: String, spec: RunSpec) async -> String? { + func recreateContainer(originalID: String, spec: ContainerFormState) async -> String? { guard core(for: spec.effectiveRuntimeKind) != nil else { let error = Core.Runtime.UnsupportedCapability(kind: spec.effectiveRuntimeKind, capability: .containers) flash(error.appDisplayMessage) diff --git a/Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift b/Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift index 36048d65..644185f0 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift @@ -2,7 +2,7 @@ import AppKit import ContainedCore /// Import a `compose.yaml` without a dedicated page: pick the file, translate each service with an -/// image into a `RunSpec`, pull the images, then open a prefilled New-Container editor per service +/// image into a `ContainerFormState`, pull the images, then open a prefilled New-Container editor per service /// (the prefill queue steps through them). Triggered from File ▸ Import Compose…, drag-and-drop, /// and the palette. @MainActor @@ -43,7 +43,7 @@ enum ComposeImport { return } let plan = try client.translateCompose(parsed, baseDirectory: baseDirectory) - let specs = plan.items.map { RunSpec(request: $0.request, healthCheck: $0.healthCheck) } + let specs = plan.items.map { ContainerFormState(document: $0.document, healthCheck: $0.healthCheck) } guard !specs.isEmpty else { app.flash(AppText.composeNoServicesWithImages) app.logger.record("Compose import \(parsed.name) had no services with images", diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift index de2ef8e3..7272d1ee 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift @@ -58,7 +58,7 @@ struct CreationFlow: View { } } @State private var page: Page - @State private var spec = RunSpec() + @State private var spec = ContainerFormState() @State private var initialSearchQuery = "" @State private var localImageQuery = "" @State private var composeText = "" @@ -89,7 +89,7 @@ struct CreationFlow: View { } init(start: Start, onClose: @escaping () -> Void, - prefill: RunSpec? = nil, + prefill: ContainerFormState? = nil, editSnapshot: Core.Container.Snapshot? = nil, searchQuery: String = "", returnEntry: UIState.CreationEntry? = nil, @@ -191,7 +191,7 @@ struct CreationFlow: View { } box(symbol: "slider.horizontal.3", title: AppText.string("creation.option.scratch", defaultValue: "Start from scratch"), subtitle: AppText.string("creation.option.scratch.subtitle", defaultValue: "Configure manually"), - matchedID: "creation-option-2") { configure(with: RunSpec()) } + matchedID: "creation-option-2") { configure(with: ContainerFormState()) } } optionRow { box(symbol: "shippingbox.and.arrow.backward", title: AppText.string("creation.option.compose", defaultValue: "Compose"), @@ -392,7 +392,7 @@ struct CreationFlow: View { withAnimation(springAnim) { page = next } } - private func configure(with picked: RunSpec, returningTo returnPage: Page? = nil) { + private func configure(with picked: ContainerFormState, returningTo returnPage: Page? = nil) { let currentPage = page spec = picked configureReturnPage = returnPage ?? (currentPage == .configure ? nil : currentPage) diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift index 232002e4..a608c089 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift @@ -120,7 +120,7 @@ struct CreationVolumeFields: View { struct CreationLocalImagesContent: View { @Environment(AppModel.self) private var app @Binding var query: String - var onSelect: (RunSpec) -> Void + var onSelect: (ContainerFormState) -> Void var body: some View { LazyVStack(spacing: UI.Layout.Spacing.m) { @@ -189,7 +189,7 @@ struct CreationPastedComposeContent: View { struct CreationTemplatesContent: View { let templates: [Template] - var onSelect: (RunSpec) -> Void + var onSelect: (ContainerFormState) -> Void var body: some View { ScrollView { diff --git a/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift index bb89a615..dfadf7ff 100644 --- a/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift @@ -24,7 +24,7 @@ struct ContainerConfigureView: View { let leading: Leading var onFinished: () -> Void - @State private var spec: RunSpec + @State private var spec: ContainerFormState @State private var working = false @State private var confirming = false @State private var loaded = false @@ -36,8 +36,8 @@ struct ContainerConfigureView: View { self.leading = leading self.onFinished = onFinished switch mode { - case .new(let prefill): _spec = State(initialValue: prefill ?? RunSpec()) - case .edit(let snapshot, _): _spec = State(initialValue: RunSpec(from: snapshot.configuration)) + case .new(let prefill): _spec = State(initialValue: prefill ?? ContainerFormState()) + case .edit(let snapshot, _): _spec = State(initialValue: ContainerFormState(from: snapshot.configuration)) } } @@ -51,7 +51,7 @@ struct ContainerConfigureView: View { validationSummary } } content: { - RunSpecForm(spec: $spec) + ContainerSchemaForm(spec: $spec) .padding(UI.Layout.Spacing.s) } footer: { if app.settings.revealCLI { diff --git a/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift index 669d2f90..65295150 100644 --- a/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift @@ -8,7 +8,7 @@ import ContainedCore /// existing container and, on Save, tears it down and re-runs the edited spec in its place. struct ContainerEditSheet: View { enum Mode { - case new(prefill: RunSpec?) + case new(prefill: ContainerFormState?) case edit(Core.Container.Snapshot, onComplete: () -> Void) } diff --git a/Sources/ContainedApp/Features/Containers/Form/ContainerFormState.swift b/Sources/ContainedApp/Features/Containers/Form/ContainerFormState.swift new file mode 100644 index 00000000..0b313e18 --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Form/ContainerFormState.swift @@ -0,0 +1,412 @@ +import Foundation +import ContainedCore + +typealias KeyValue = Core.Container.KeyValue +typealias PortMap = Core.Container.Port +typealias VolumeMap = Core.Container.VolumeMount +typealias SocketMap = Core.Container.Socket + +/// App-owned create/edit working state. Runtime-editable fields live in the Core schema document; +/// local-only presentation/automation fields stay beside it. +struct ContainerFormState: Codable { + var document: Core.Schema.Document + var personalization = Personalization() + var healthCheck = Core.Container.HealthCheck() + + init(document: Core.Schema.Document = .containerCreate(), + healthCheck: Core.Container.HealthCheck? = nil) { + self.document = document + if let healthCheck { self.healthCheck = healthCheck } + } + + init(from config: Core.Container.Configuration) { + self.document = Core.Schema.Document.containerEdit(from: config) + } + + var definition: Core.Schema.Definition { + Core.Schema.Definition.containerRunEdit(runtimeKind: effectiveRuntimeKind, operation: document.operation) + } + + var runtimeKind: Core.Runtime.Kind? { + get { document.runtimeKind } + set { + document.runtimeKind = newValue ?? .appleContainer + document.set(.runtimeKind, .string(document.runtimeKind.rawValue)) + } + } + + var effectiveRuntimeKind: Core.Runtime.Kind { document.runtimeKind } + + var image: String { + get { string(.imageReference) } + set { setString(.imageReference, newValue) } + } + + var platform: String { + get { string(.imagePlatform) } + set { setString(.imagePlatform, newValue) } + } + + var imageOS: String { + get { string(.imageOS) } + set { setString(.imageOS, newValue) } + } + + var imageArchitecture: String { + get { string(.imageArchitecture) } + set { setString(.imageArchitecture, newValue) } + } + + var name: String { + get { string(.containerName) } + set { setString(.containerName, newValue) } + } + + var command: String { + get { strings(.processCommand).joined(separator: " ") } + set { set(.processCommand, .commandLine(newValue.split(separator: " ").map(String.init))) } + } + + var entrypoint: String { + get { string(.processEntrypoint) } + set { setString(.processEntrypoint, newValue) } + } + + var detach: Bool { + get { bool(.processDetach) } + set { set(.processDetach, .bool(newValue)) } + } + + var removeOnExit: Bool { + get { bool(.processRemoveOnExit) } + set { set(.processRemoveOnExit, .bool(newValue)) } + } + + var interactive: Bool { + get { bool(.processInteractive) } + set { set(.processInteractive, .bool(newValue)) } + } + + var tty: Bool { + get { bool(.processTTY) } + set { set(.processTTY, .bool(newValue)) } + } + + var cpus: String { + get { string(.resourcesCPULimit) } + set { setString(.resourcesCPULimit, newValue) } + } + + var memory: String { + get { string(.resourcesMemoryLimit) } + set { setString(.resourcesMemoryLimit, newValue) } + } + + var env: [KeyValue] { + get { document.keyValues(.environmentVariables, in: definition) } + set { set(.environmentVariables, .keyValueList(newValue)) } + } + + var envFiles: [String] { + get { strings(.environmentFiles) } + set { set(.environmentFiles, .stringList(newValue)) } + } + + var ports: [PortMap] { + get { document.ports(.networkPorts, in: definition) } + set { set(.networkPorts, .portList(newValue)) } + } + + var volumes: [VolumeMap] { + get { document.volumes(.storageVolumes, in: definition) } + set { set(.storageVolumes, .volumeList(newValue)) } + } + + var mounts: [String] { + get { strings(.storageMounts) } + set { set(.storageMounts, .stringList(newValue)) } + } + + var sockets: [SocketMap] { + get { document.sockets(.networkSockets, in: definition) } + set { set(.networkSockets, .socketList(newValue)) } + } + + var labels: [KeyValue] { + get { document.keyValues(.metadataLabels, in: definition) } + set { set(.metadataLabels, .keyValueList(newValue)) } + } + + var readOnly: Bool { + get { bool(.securityReadOnlyRootFS) } + set { set(.securityReadOnlyRootFS, .bool(newValue)) } + } + + var useInit: Bool { + get { bool(.securityUseInit) } + set { set(.securityUseInit, .bool(newValue)) } + } + + var rosetta: Bool { + get { bool(.securityRosetta) } + set { set(.securityRosetta, .bool(newValue)) } + } + + var ssh: Bool { + get { bool(.securitySSHAgent) } + set { set(.securitySSHAgent, .bool(newValue)) } + } + + var virtualization: Bool { + get { bool(.securityVirtualization) } + set { set(.securityVirtualization, .bool(newValue)) } + } + + var restart: Core.Container.RestartPolicy { + get { Core.Container.RestartPolicy(rawValue: string(.lifecycleRestartPolicy)) ?? .no } + set { set(.lifecycleRestartPolicy, .enumeration(newValue.rawValue)) } + } + + var workingDir: String { + get { string(.processWorkingDirectory) } + set { setString(.processWorkingDirectory, newValue) } + } + + var user: String { + get { string(.processUser) } + set { setString(.processUser, newValue) } + } + + var uid: String { + get { string(.processUserID) } + set { setString(.processUserID, newValue) } + } + + var gid: String { + get { string(.processGroupID) } + set { setString(.processGroupID, newValue) } + } + + var shmSize: String { + get { string(.resourcesSharedMemorySize) } + set { setString(.resourcesSharedMemorySize, newValue) } + } + + var capAdd: [String] { + get { strings(.securityCapabilitiesAdd) } + set { set(.securityCapabilitiesAdd, .stringList(newValue)) } + } + + var capDrop: [String] { + get { strings(.securityCapabilitiesDrop) } + set { set(.securityCapabilitiesDrop, .stringList(newValue)) } + } + + var cidFile: String { + get { string(.outputContainerIDFile) } + set { setString(.outputContainerIDFile, newValue) } + } + + var initImage: String { + get { string(.imageInitReference) } + set { setString(.imageInitReference, newValue) } + } + + var kernel: String { + get { string(.kernelPath) } + set { setString(.kernelPath, newValue) } + } + + var network: String { + get { string(.networkName) } + set { setString(.networkName, newValue) } + } + + var noDNS: Bool { + get { bool(.networkDNSDisabled) } + set { set(.networkDNSDisabled, .bool(newValue)) } + } + + var dns: [String] { + get { strings(.networkDNSServers) } + set { set(.networkDNSServers, .stringList(newValue)) } + } + + var dnsDomain: String { + get { string(.networkDNSDomain) } + set { setString(.networkDNSDomain, newValue) } + } + + var dnsSearch: [String] { + get { strings(.networkDNSSearchDomains) } + set { set(.networkDNSSearchDomains, .stringList(newValue)) } + } + + var dnsOption: [String] { + get { strings(.networkDNSOptions) } + set { set(.networkDNSOptions, .stringList(newValue)) } + } + + var tmpfs: [String] { + get { strings(.storageTmpfs) } + set { set(.storageTmpfs, .stringList(newValue)) } + } + + var ulimits: [String] { + get { strings(.processUlimits) } + set { set(.processUlimits, .stringList(newValue)) } + } + + var runtime: String { + get { string(.runtimeHandler) } + set { setString(.runtimeHandler, newValue) } + } + + var scheme: String { + get { string(.registryScheme) } + set { set(.registryScheme, .enumeration(newValue)) } + } + + var progress: String { + get { string(.progressMode) } + set { set(.progressMode, .enumeration(newValue)) } + } + + var maxConcurrentDownloads: String { + get { string(.imageMaxConcurrentDownloads) } + set { setString(.imageMaxConcurrentDownloads, newValue) } + } + + var validationIssues: [Core.Schema.ValidationIssue] { + document.validationIssues(in: definition) + } + + var validationMessages: [String] { + validationIssues + .filter { $0.severity == .error } + .map(\.defaultMessage) + } + + var warningMessages: [String] { + validationIssues + .filter { $0.severity == .warning } + .map(\.defaultMessage) + } + + var isRunnable: Bool { validationMessages.isEmpty } + + var normalizedImageReference: String { + Self.normalizedImageReference(image) + } + + static func normalizedImageReference(_ reference: String) -> String { + let short = Format.shortImage(reference.trimmingCharacters(in: .whitespaces)) + let nameStart = short.lastIndex(of: "/").map { short.index(after: $0) } ?? short.startIndex + let namePart = short[nameStart...] + if namePart.contains(":") || namePart.contains("@") { return short } + return short + ":latest" + } + + @discardableResult + mutating func adoptImageDefaults(from defaults: Core.Container.ImageDefaults) -> Int { + var applied = 0 + if command.trimmingCharacters(in: .whitespaces).isEmpty, !defaults.command.isEmpty { + command = defaults.command.joined(separator: " ") + applied += 1 + } + if entrypoint.trimmingCharacters(in: .whitespaces).isEmpty, !defaults.entrypoint.isEmpty { + entrypoint = defaults.entrypoint.joined(separator: " ") + applied += 1 + } + if workingDir.trimmingCharacters(in: .whitespaces).isEmpty, + let workingDirValue = defaults.workingDirectory, + !workingDirValue.isEmpty { + workingDir = workingDirValue + applied += 1 + } + if user.trimmingCharacters(in: .whitespaces).isEmpty, + let userValue = defaults.user, + !userValue.isEmpty { + user = userValue + applied += 1 + } + let existingEnvKeys = Set(env.map(\.key)) + var updatedEnv = env + for entry in defaults.environment { + guard entry.isValid, !existingEnvKeys.contains(entry.key) else { continue } + updatedEnv.append(entry) + applied += 1 + } + env = updatedEnv + return applied + } + + var hasGeneralOptions: Bool { + hasValues(in: [.runtime, .essentials]) + } + + var hasResourceOptions: Bool { + hasValues(in: [.resources]) + } + + var hasNetworkingOptions: Bool { + hasValues(in: [.networking]) + } + + var hasStorageOptions: Bool { + hasValues(in: [.storage]) + } + + var hasEnvironmentOptions: Bool { + hasValues(in: [.environment]) + } + + var hasPersonalizationOptions: Bool { + !personalization.isDefault + } + + var hasAppManagedOptions: Bool { + restart != .no || healthCheck.isActive + } + + var hasAdvancedOptions: Bool { + hasValues(in: [.process, .security, .imageFetch, .metadata, .dockerCompose]) + } + + var hasUnsupportedRuntimeValues: Bool { + definition.fields.contains { field in + field.support(for: effectiveRuntimeKind).state == .disabled && + !(document.value(field.path, in: definition) ?? field.defaultValue).isEmpty + } + } + + private func hasValues(in sections: Set) -> Bool { + definition.fields.contains { field in + sections.contains(field.section) && + field.path != .imageReference && + field.path != .processDetach && + field.path != .runtimeKind && + !(document.value(field.path, in: definition) ?? field.defaultValue).isEmpty + } + } + + private func string(_ path: Core.Field.Path) -> String { + document.string(path, in: definition) + } + + private func bool(_ path: Core.Field.Path) -> Bool { + document.bool(path, in: definition) + } + + private func strings(_ path: Core.Field.Path) -> [String] { + document.strings(path, in: definition) + } + + private mutating func setString(_ path: Core.Field.Path, _ value: String) { + set(path, .string(value)) + } + + private mutating func set(_ path: Core.Field.Path, _ value: Core.Schema.Value) { + document.set(path, value) + } +} diff --git a/Sources/ContainedApp/Features/Containers/Form/ContainerFormStateMemoryFormatter.swift b/Sources/ContainedApp/Features/Containers/Form/ContainerFormStateMemoryFormatter.swift new file mode 100644 index 00000000..b21322e2 --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Form/ContainerFormStateMemoryFormatter.swift @@ -0,0 +1,32 @@ +import Foundation +import ContainedCore + +enum ContainerFormStateMemoryFormatter { + /// Parse a `--memory` spec ("512M", "1G", "2g", bare bytes) into gigabytes. + static func parseGB(_ spec: String) -> Double? { + let trimmed = spec.trimmingCharacters(in: .whitespaces) + guard let last = trimmed.last else { return nil } + if last.isLetter { + guard let value = Double(trimmed.dropLast()) else { return nil } + switch last.uppercased() { + case "G": return value + case "M": return value / 1024 + case "K": return value / (1024 * 1024) + case "T": return value * 1024 + default: return nil + } + } + return Double(trimmed).map { $0 / 1_073_741_824 } + } + + /// Format gigabytes as a `--memory` spec, using `M` for fractional values. + static func spec(gb: Double) -> String { + gb.rounded() == gb ? "\(Int(gb))G" : "\(Int(gb * 1024))M" + } + + static func readout(_ spec: String, fallbackGB: Double) -> String { + let gb = parseGB(spec) ?? fallbackGB + if gb < 1 { return "\(Int(gb * 1024)) MB" } + return gb.rounded() == gb ? "\(Int(gb)) GB" : String(format: "%.1f GB", gb) + } +} diff --git a/Sources/ContainedApp/Features/Containers/Form/RunSpecForm.swift b/Sources/ContainedApp/Features/Containers/Form/ContainerSchemaForm.swift similarity index 71% rename from Sources/ContainedApp/Features/Containers/Form/RunSpecForm.swift rename to Sources/ContainedApp/Features/Containers/Form/ContainerSchemaForm.swift index 433da6bf..e3c66889 100644 --- a/Sources/ContainedApp/Features/Containers/Form/RunSpecForm.swift +++ b/Sources/ContainedApp/Features/Containers/Form/ContainerSchemaForm.swift @@ -9,16 +9,18 @@ import ContainedCore /// `UI.Panel.Section` glass-card primitives (not `Form`) so it lives inside the shared `UI.Panel.Scaffold` /// and measures/scrolls consistently. Field guidance is delivered through tappable `info.circle` /// popovers, not hover tooltips. -struct RunSpecForm: View { +struct ContainerSchemaForm: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - @Binding var spec: RunSpec + @Binding var spec: ContainerFormState @State private var advancedExpanded: Bool + @State private var dockerComposeExpanded: Bool - init(spec: Binding) { + init(spec: Binding) { self._spec = spec let initial = spec.wrappedValue self._advancedExpanded = State(initialValue: initial.hasAdvancedOptions) + self._dockerComposeExpanded = State(initialValue: initial.hasUnsupportedRuntimeValues) } var body: some View { @@ -41,8 +43,10 @@ struct RunSpecForm: View { } UI.Panel.Section(header: AppText.sectionSettingsAppearance, highlighted: spec.hasPersonalizationOptions) { personalizationSection } advancedOptionsSection + dockerComposeSection } .onChange(of: spec.hasAdvancedOptions) { _, hasValues in if hasValues { advancedExpanded = true } } + .onChange(of: spec.hasUnsupportedRuntimeValues) { _, hasValues in if hasValues { dockerComposeExpanded = true } } .task(id: spec.normalizedImageReference) { guard !spec.image.trimmingCharacters(in: .whitespaces).isEmpty else { return } await app.refreshImagesIfNeeded() @@ -63,14 +67,14 @@ struct RunSpecForm: View { .disabled(!app.runtimeCoreSelectorIsEnabled) } UI.Panel.Field(label: AppText.string("runSpec.image", defaultValue: "Image"), - info: AppText.string("runSpec.image.info", defaultValue: "The container image to start, such as `nginx:latest`. If it is not on this Mac yet, Contained pulls it before running."), + info: fieldInfo(.imageReference), error: spec.image.trimmingCharacters(in: .whitespaces).isEmpty ? AppText.string("runSpec.image.required", defaultValue: "An image reference is required.") : nil) { TextField("", text: $spec.image, prompt: Text("e.g. nginx:latest")).textFieldStyle(.roundedBorder) } if imageDefaults != nil { UI.Panel.Row(title: AppText.string("runSpec.imageDefaults", defaultValue: "Image defaults"), subtitle: AppText.string("runSpec.imageDefaults.subtitle", defaultValue: "Fill empty command, entrypoint, user, working directory, and environment fields from the pulled image config."), - info: AppText.string("runSpec.imageDefaults.info", defaultValue: "Images can define default startup settings. Adopt copies those defaults into this form so you can see and edit them before running.")) { + info: AppText.string("containerForm.imageDefaults.info", defaultValue: "Images can define default startup settings. Adopt copies those defaults into this form so you can see and edit them before running.")) { UI.Action.TextButton(title: AppText.string("runSpec.adopt", defaultValue: "Adopt"), systemName: "wand.and.stars") { adoptImageDefaults() @@ -78,7 +82,7 @@ struct RunSpecForm: View { } } UI.Panel.Row(title: AppText.string("runSpec.platform", defaultValue: "Platform"), - info: AppText.string("runSpec.platform.info", defaultValue: "Use this only when an image supports more than one CPU type. Leave Default unless you specifically need arm64 or amd64.")) { + info: fieldInfo(.imagePlatform)) { Picker("", selection: platformPresetBinding) { Text("Default").tag("") Text("Linux arm64").tag("linux/arm64") @@ -90,29 +94,37 @@ struct RunSpecForm: View { } if platformPresetBinding.wrappedValue == "custom" { UI.Panel.Field(label: AppText.string("runSpec.customPlatform", defaultValue: "Custom platform"), - info: AppText.string("runSpec.customPlatform.info", defaultValue: "Advanced platform value in `os/arch` form, for example `linux/arm64`.")) { + info: fieldInfo(.imagePlatform)) { TextField("", text: $spec.platform, prompt: Text("os/arch[/variant]")).textFieldStyle(.roundedBorder) } } + UI.Panel.Field(label: fieldLabel(.imageOS, fallback: "Image OS"), + info: fieldInfo(.imageOS)) { + TextField("", text: $spec.imageOS, prompt: Text("linux")).textFieldStyle(.roundedBorder) + } + UI.Panel.Field(label: fieldLabel(.imageArchitecture, fallback: "Image architecture"), + info: fieldInfo(.imageArchitecture)) { + TextField("", text: $spec.imageArchitecture, prompt: Text("arm64")).textFieldStyle(.roundedBorder) + } UI.Panel.Field(label: AppText.string("runSpec.name", defaultValue: "Name"), - info: AppText.string("runSpec.name.info", defaultValue: "Optional friendly runtime name. Leave it blank and the container runtime will generate one.")) { + info: fieldInfo(.containerName)) { TextField("", text: $spec.name, prompt: Text("optional")).textFieldStyle(.roundedBorder) } UI.Panel.Field(label: AppText.string("runSpec.command", defaultValue: "Command"), - info: AppText.string("runSpec.command.info", defaultValue: "Optional command to run instead of the image's normal startup command.")) { + info: fieldInfo(.processCommand)) { TextField("", text: $spec.command, prompt: Text("override the default command (optional)")).textFieldStyle(.roundedBorder) } UI.Panel.ToggleRow(title: AppText.string("runSpec.detach", defaultValue: "Run in the background"), - info: AppText.string("runSpec.detach.info", defaultValue: "Detached (-d): runs without attaching to its output."), isOn: $spec.detach) + info: fieldInfo(.processDetach), isOn: $spec.detach) UI.Panel.ToggleRow(title: AppText.string("runSpec.removeWhenStopped", defaultValue: "Remove when stopped"), - info: AppText.string("runSpec.removeWhenStopped.info", defaultValue: "Deletes the container record when it stops. Use volumes if you need data to survive."), isOn: $spec.removeOnExit) + info: fieldInfo(.processRemoveOnExit), isOn: $spec.removeOnExit) } } private var resourcesSection: some View { Group { UI.Panel.Row(title: AppText.string("runSpec.cpus", defaultValue: "CPUs"), - info: AppText.string("runSpec.cpus.info", defaultValue: "Limit how much CPU the container can use. Default lets the runtime decide. This Mac has \(hostCPUs) cores.")) { + info: fieldInfo(.resourcesCPULimit)) { Picker("", selection: cpuBinding) { Text("Default").tag(0) ForEach(1...max(1, hostCPUs), id: \.self) { Text("\($0)").tag($0) } @@ -120,7 +132,7 @@ struct RunSpecForm: View { .labelsHidden().fixedSize() } UI.Panel.ToggleRow(title: AppText.string("runSpec.limitMemory", defaultValue: "Limit memory"), - info: AppText.string("runSpec.limitMemory.info", defaultValue: "Set a memory ceiling for the container. If it goes past the limit, the runtime may stop it."), isOn: memoryLimitBinding) + info: fieldInfo(.resourcesMemoryLimit), isOn: memoryLimitBinding) if !spec.memory.isEmpty { UI.Panel.Field(label: AppText.string("runSpec.memory", defaultValue: "Memory")) { HStack(spacing: UI.Layout.Spacing.s) { @@ -155,7 +167,7 @@ struct RunSpecForm: View { memoryReadout(spec.memory, fallbackGB: 2) } private func memoryReadout(_ spec: String, fallbackGB: Double) -> String { - RunSpecMemoryFormatter.readout(spec, fallbackGB: fallbackGB) + ContainerFormStateMemoryFormatter.readout(spec, fallbackGB: fallbackGB) } private var platformPresetBinding: Binding { @@ -184,11 +196,11 @@ struct RunSpecForm: View { } static func parseMemoryGB(_ spec: String) -> Double? { - RunSpecMemoryFormatter.parseGB(spec) + ContainerFormStateMemoryFormatter.parseGB(spec) } static func memorySpec(gb: Double) -> String { - RunSpecMemoryFormatter.spec(gb: gb) + ContainerFormStateMemoryFormatter.spec(gb: gb) } private var portsSection: some View { @@ -204,7 +216,9 @@ struct RunSpecForm: View { removeButton { spec.ports.removeAll { $0.id == port.id } } } } - addButton(AppText.string("runSpec.addPort", defaultValue: "Add port")) { spec.ports.append(PortMap()) } + addButton(AppText.string("runSpec.addPort", defaultValue: "Add port"), info: fieldInfo(.networkPorts)) { + spec.ports.append(PortMap()) + } } } @@ -223,7 +237,9 @@ struct RunSpecForm: View { } } } - addButton(AppText.string("runSpec.addVolume", defaultValue: "Add volume")) { spec.volumes.append(VolumeMap()) } + addButton(AppText.string("runSpec.addVolume", defaultValue: "Add volume"), info: fieldInfo(.storageVolumes)) { + spec.volumes.append(VolumeMap()) + } } } @@ -237,9 +253,11 @@ struct RunSpecForm: View { removeButton { spec.env.removeAll { $0.id == variable.id } } } } - addButton(AppText.string("runSpec.addVariable", defaultValue: "Add variable")) { spec.env.append(KeyValue()) } + addButton(AppText.string("runSpec.addVariable", defaultValue: "Add variable"), info: fieldInfo(.environmentVariables)) { + spec.env.append(KeyValue()) + } stringList(AppText.string("runSpec.addEnvFile", defaultValue: "Add env file"), $spec.envFiles, prompt: "/path/to/.env", - info: AppText.string("runSpec.addEnvFile.info", defaultValue: "Read environment variables from a file (--env-file).")) + info: fieldInfo(.environmentFiles)) } } @@ -254,7 +272,9 @@ struct RunSpecForm: View { TextField("Container socket path", text: $socket.containerPath).textFieldStyle(.roundedBorder) } } - addButton(AppText.string("runSpec.addSocket", defaultValue: "Add socket")) { spec.sockets.append(SocketMap()) } + addButton(AppText.string("runSpec.addSocket", defaultValue: "Add socket"), info: fieldInfo(.networkSockets)) { + spec.sockets.append(SocketMap()) + } } } @@ -268,26 +288,28 @@ struct RunSpecForm: View { removeButton { spec.labels.removeAll { $0.id == label.id } } } } - addButton(AppText.string("runSpec.addLabel", defaultValue: "Add label")) { spec.labels.append(KeyValue()) } + addButton(AppText.string("runSpec.addLabel", defaultValue: "Add label"), info: fieldInfo(.metadataLabels)) { + spec.labels.append(KeyValue()) + } } } private var personalizationSection: some View { Group { UI.Panel.Field(label: AppText.string("runSpec.nickname", defaultValue: "Nickname"), - info: AppText.string("runSpec.nickname.info", defaultValue: "A display name for the card only. It does not rename the real container.")) { + info: AppText.string("containerForm.personalization.nickname.info", defaultValue: "A display name for the card only. It does not rename the real container.")) { TextField("", text: $spec.personalization.nickname, prompt: Text("display name (optional)")).textFieldStyle(.roundedBorder) } UI.Panel.Field(label: AppText.string("runSpec.icon", defaultValue: "Icon"), - info: AppText.string("runSpec.icon.info", defaultValue: "An SF Symbol name for the card icon, such as `shippingbox` or `bolt`.")) { + info: AppText.string("containerForm.personalization.icon.info", defaultValue: "An SF Symbol name for the card icon, such as `shippingbox` or `bolt`.")) { TextField("", text: $spec.personalization.icon, prompt: Text("SF Symbol, e.g. globe, bolt")).textFieldStyle(.roundedBorder) } UI.Panel.Row(title: AppText.string("runSpec.color", defaultValue: "Color"), - info: AppText.string("runSpec.color.info", defaultValue: "Sets the card icon color. If background color is enabled, it also tints the glass card.")) { + info: AppText.string("containerForm.personalization.color.info", defaultValue: "Sets the card icon color. If background color is enabled, it also tints the glass card.")) { UI.Control.TintSelector(selection: $spec.personalization.tint) { $0.localizedDisplayName } } UI.Panel.ToggleRow(title: AppText.string("runSpec.colorCardBackground", defaultValue: "Color the card background"), - info: AppText.string("runSpec.colorCardBackground.info", defaultValue: "Adds a soft color wash behind the glass. Turn it off for clear glass with only a colored icon."), + info: AppText.string("containerForm.personalization.colorCardBackground.info", defaultValue: "Adds a soft color wash behind the glass. Turn it off for clear glass with only a colored icon."), isOn: $spec.personalization.fillBackground) if spec.personalization.fillBackground { UI.Panel.Field(label: AppText.string("runSpec.opacity", defaultValue: "Opacity")) { @@ -299,13 +321,13 @@ struct RunSpecForm: View { } } UI.Panel.ToggleRow(title: AppText.string("runSpec.gradient", defaultValue: "Gradient"), - info: AppText.string("runSpec.gradient.info", defaultValue: "Blends the color across the card instead of using one flat wash."), + info: AppText.string("containerForm.personalization.gradient.info", defaultValue: "Blends the color across the card instead of using one flat wash."), isOn: $spec.personalization.gradient) if spec.personalization.gradient { UI.Control.GradientAngle(angle: $spec.personalization.gradientAngle, title: AppText.direction) } UI.Panel.Row(title: AppText.string("runSpec.blendMode", defaultValue: "Blend mode"), - info: AppText.string("runSpec.blendMode.info", defaultValue: "Controls how the card color wash blends with the glass behind it.")) { + info: AppText.string("containerForm.personalization.blendMode.info", defaultValue: "Controls how the card color wash blends with the glass behind it.")) { Picker("", selection: $spec.personalization.backgroundBlendMode) { ForEach(UI.Theme.ColorBlendMode.allCases) { mode in Text(mode.localizedDisplayName).tag(mode) @@ -320,7 +342,7 @@ struct RunSpecForm: View { private var restartSection: some View { UI.Panel.Row(title: AppText.string("runSpec.restartPolicy", defaultValue: "Restart policy"), - info: AppText.string("runSpec.restartPolicy.info", defaultValue: "Contained restarts the container automatically based on this setting.")) { + info: AppText.string("containerForm.restartPolicy.info", defaultValue: "Contained restarts the container automatically based on this setting.")) { Picker("", selection: $spec.restart) { ForEach(Core.Container.RestartPolicy.allCases) { Text($0.localizedDisplayName).tag($0) } } @@ -331,11 +353,11 @@ struct RunSpecForm: View { private var healthSection: some View { Group { UI.Panel.ToggleRow(title: AppText.string("runSpec.enableHealthcheck", defaultValue: "Enable healthcheck"), - info: AppText.string("runSpec.enableHealthcheck.info", defaultValue: "Contained probes the container on an interval (app-managed; the runtime has no native healthcheck)."), + info: AppText.string("containerForm.healthcheck.enabled.info", defaultValue: "Contained probes the container on an interval (app-managed; the runtime has no native healthcheck)."), isOn: $spec.healthCheck.enabled) if spec.healthCheck.enabled { UI.Panel.Field(label: AppText.string("runSpec.probeCommand", defaultValue: "Probe command"), - info: AppText.string("runSpec.probeCommand.info", defaultValue: "Run inside the container via `sh -c`; a zero exit = healthy. Needs a shell in the image.")) { + info: AppText.string("containerForm.healthcheck.probeCommand.info", defaultValue: "Run inside the container via `sh -c`; a zero exit = healthy. Needs a shell in the image.")) { TextField("", text: healthCommandBinding, prompt: Text("curl -f http://localhost/ || exit 1")).textFieldStyle(.roundedBorder) } Stepper("Interval: \(spec.healthCheck.intervalSeconds)s", @@ -362,23 +384,23 @@ struct RunSpecForm: View { private var runtimeSection: some View { Group { UI.Panel.Field(label: AppText.string("runSpec.entrypoint", defaultValue: "Entrypoint"), - info: AppText.string("runSpec.entrypoint.info", defaultValue: "Override the image's entrypoint program.")) { + info: fieldInfo(.processEntrypoint)) { TextField("", text: $spec.entrypoint, prompt: Text("optional")).textFieldStyle(.roundedBorder) } UI.Panel.ToggleRow(title: AppText.string("runSpec.keepStdinOpen", defaultValue: "Keep stdin open"), - info: AppText.string("runSpec.keepStdinOpen.info", defaultValue: "Keep standard input open even when detached (--interactive)."), isOn: $spec.interactive) + info: fieldInfo(.processInteractive), isOn: $spec.interactive) UI.Panel.ToggleRow(title: AppText.string("runSpec.allocateTTY", defaultValue: "Allocate TTY"), - info: AppText.string("runSpec.allocateTTY.info", defaultValue: "Allocate a terminal for the process (--tty)."), isOn: $spec.tty) + info: fieldInfo(.processTTY), isOn: $spec.tty) UI.Panel.Field(label: AppText.string("runSpec.workingDirectory", defaultValue: "Working directory"), - info: AppText.string("runSpec.workingDirectory.info", defaultValue: "Initial working directory inside the container (-w).")) { + info: fieldInfo(.processWorkingDirectory)) { TextField("", text: $spec.workingDir, prompt: Text("optional, e.g. /app")).textFieldStyle(.roundedBorder) } UI.Panel.Field(label: AppText.string("runSpec.user", defaultValue: "User"), - info: AppText.string("runSpec.user.info", defaultValue: "Run the process as this user (-u). Or set UID/GID below.")) { + info: fieldInfo(.processUser)) { TextField("", text: $spec.user, prompt: Text("name | uid[:gid]")).textFieldStyle(.roundedBorder) } UI.Panel.Field(label: AppText.string("runSpec.userID", defaultValue: "User ID"), - info: AppText.string("runSpec.userID.info", defaultValue: "Numeric user / group IDs (--uid / --gid).")) { + info: "\(fieldInfo(.processUserID))\n\n\(fieldInfo(.processGroupID))") { HStack { TextField("UID", text: $spec.uid).textFieldStyle(.roundedBorder).frame(width: UI.Form.Width.userID) TextField("GID", text: $spec.gid).textFieldStyle(.roundedBorder).frame(width: UI.Form.Width.userID) @@ -386,7 +408,7 @@ struct RunSpecForm: View { } } UI.Panel.ToggleRow(title: AppText.string("runSpec.setSharedMemorySize", defaultValue: "Set shared memory size"), - info: AppText.string("runSpec.setSharedMemorySize.info", defaultValue: "Size of /dev/shm (--shm-size)."), isOn: shmLimitBinding) + info: fieldInfo(.resourcesSharedMemorySize), isOn: shmLimitBinding) if !spec.shmSize.isEmpty { UI.Panel.Field(label: AppText.string("runSpec.sharedMemory", defaultValue: "Shared memory")) { HStack(spacing: UI.Layout.Spacing.s) { @@ -399,17 +421,17 @@ struct RunSpecForm: View { } stringList(AppText.string("runSpec.addCapability", defaultValue: "Add capability"), $spec.capAdd, prompt: "CAP_NET_RAW or ALL", - info: AppText.string("runSpec.addCapability.info", defaultValue: "Add a Linux capability (--cap-add).")) + info: fieldInfo(.securityCapabilitiesAdd)) stringList(AppText.string("runSpec.dropCapability", defaultValue: "Drop capability"), $spec.capDrop, prompt: "CAP_NET_RAW or ALL", - info: AppText.string("runSpec.dropCapability.info", defaultValue: "Drop a Linux capability (--cap-drop).")) + info: fieldInfo(.securityCapabilitiesDrop)) UI.Panel.Field(label: AppText.string("runSpec.containerIDFile", defaultValue: "Container ID file"), - info: AppText.string("runSpec.containerIDFile.info", defaultValue: "Write the new container ID to a file (--cidfile).")) { + info: fieldInfo(.outputContainerIDFile)) { TextField("", text: $spec.cidFile, prompt: Text("optional path")).textFieldStyle(.roundedBorder) } stringList(AppText.string("runSpec.addTmpfsMount", defaultValue: "Add tmpfs mount"), $spec.tmpfs, prompt: "/path", - info: AppText.string("runSpec.addTmpfsMount.info", defaultValue: "Mount a tmpfs at this path (--tmpfs).")) + info: fieldInfo(.storageTmpfs)) stringList(AppText.string("runSpec.addUlimit", defaultValue: "Add ulimit"), $spec.ulimits, prompt: "nofile=1024:2048", - info: AppText.string("runSpec.addUlimit.info", defaultValue: "Resource limit, type=soft[:hard] (--ulimit).")) + info: fieldInfo(.processUlimits)) } } @@ -417,22 +439,22 @@ struct RunSpecForm: View { private var securitySection: some View { Group { UI.Panel.ToggleRow(title: AppText.string("runSpec.readOnlyFilesystem", defaultValue: "Read-only filesystem"), - info: AppText.string("runSpec.readOnlyFilesystem.info", defaultValue: "Mounts the container's root filesystem as read-only."), isOn: $spec.readOnly) + info: fieldInfo(.securityReadOnlyRootFS), isOn: $spec.readOnly) UI.Panel.ToggleRow(title: AppText.string("runSpec.useInitProcess", defaultValue: "Use an init process"), - info: AppText.string("runSpec.useInitProcess.info", defaultValue: "Runs a tiny init that forwards signals and cleans up zombie processes."), isOn: $spec.useInit) + info: fieldInfo(.securityUseInit), isOn: $spec.useInit) UI.Panel.ToggleRow(title: AppText.string("runSpec.rosetta", defaultValue: "Rosetta (x86 apps)"), - info: AppText.string("runSpec.rosetta.info", defaultValue: "Lets the container run x86-64 binaries via Rosetta."), isOn: $spec.rosetta) + info: fieldInfo(.securityRosetta), isOn: $spec.rosetta) UI.Panel.ToggleRow(title: AppText.string("runSpec.forwardSSHAgent", defaultValue: "Forward SSH agent"), - info: AppText.string("runSpec.forwardSSHAgent.info", defaultValue: "Forwards your host SSH agent into the container."), isOn: $spec.ssh) + info: fieldInfo(.securitySSHAgent), isOn: $spec.ssh) UI.Panel.ToggleRow(title: AppText.string("runSpec.exposeVirtualization", defaultValue: "Expose virtualization"), - info: AppText.string("runSpec.exposeVirtualization.info", defaultValue: "Exposes nested virtualization (needs host + guest support)."), isOn: $spec.virtualization) + info: fieldInfo(.securityVirtualization), isOn: $spec.virtualization) } } @ViewBuilder private var networkSection: some View { UI.Panel.Row(title: AppText.string("runSpec.network", defaultValue: "Network"), - info: AppText.string("runSpec.network.info", defaultValue: "Attach the container to a network (--network).")) { + info: fieldInfo(.networkName)) { Menu(networkMenuTitle) { Button { spec.network = "" @@ -465,19 +487,19 @@ struct RunSpecForm: View { private var fetchSection: some View { Group { UI.Panel.Field(label: AppText.string("runSpec.runtime", defaultValue: "Runtime"), - info: AppText.string("runSpec.runtime.info", defaultValue: "Runtime handler (--runtime).")) { + info: fieldInfo(.runtimeHandler)) { TextField("", text: $spec.runtime, prompt: Text("optional")).textFieldStyle(.roundedBorder) } UI.Panel.Field(label: AppText.string("runSpec.initImage", defaultValue: "Init image"), - info: AppText.string("runSpec.initImage.info", defaultValue: "Use a custom init image (--init-image).")) { + info: fieldInfo(.imageInitReference)) { TextField("", text: $spec.initImage, prompt: Text("optional image")).textFieldStyle(.roundedBorder) } UI.Panel.Field(label: AppText.string("runSpec.kernel", defaultValue: "Kernel"), - info: AppText.string("runSpec.kernel.info", defaultValue: "Use a custom kernel path (--kernel).")) { + info: fieldInfo(.kernelPath)) { TextField("", text: $spec.kernel, prompt: Text("optional path")).textFieldStyle(.roundedBorder) } UI.Panel.Row(title: AppText.string("runSpec.registryScheme", defaultValue: "Registry scheme"), - info: AppText.string("runSpec.registryScheme.info", defaultValue: "Registry connection scheme for image fetches (--scheme).")) { + info: fieldInfo(.registryScheme)) { Picker("", selection: $spec.scheme) { Text("Default").tag("") Text("Auto").tag("auto") @@ -487,7 +509,7 @@ struct RunSpecForm: View { .labelsHidden().fixedSize() } UI.Panel.Row(title: AppText.string("runSpec.progress", defaultValue: "Progress"), - info: AppText.string("runSpec.progress.info", defaultValue: "Progress display mode for image fetches (--progress).")) { + info: fieldInfo(.progressMode)) { Picker("", selection: $spec.progress) { Text("Default").tag("") Text("Auto").tag("auto") @@ -499,7 +521,7 @@ struct RunSpecForm: View { .labelsHidden().fixedSize() } UI.Panel.ToggleRow(title: AppText.string("runSpec.limitParallelDownloads", defaultValue: "Limit parallel downloads"), - info: AppText.string("runSpec.limitParallelDownloads.info", defaultValue: "Maximum concurrent image downloads (--max-concurrent-downloads)."), isOn: maxDownloadsBinding) + info: fieldInfo(.imageMaxConcurrentDownloads), isOn: maxDownloadsBinding) if !spec.maxConcurrentDownloads.isEmpty { Stepper("Max downloads: \(maxConcurrentDownloadsBinding.wrappedValue)", value: maxConcurrentDownloadsBinding, in: 1...16) @@ -511,18 +533,18 @@ struct RunSpecForm: View { private var dnsSection: some View { Group { UI.Panel.ToggleRow(title: AppText.string("runSpec.disableDNS", defaultValue: "Disable DNS"), - info: AppText.string("runSpec.disableDNS.info", defaultValue: "Do not configure DNS inside the container (--no-dns)."), isOn: $spec.noDNS) + info: fieldInfo(.networkDNSDisabled), isOn: $spec.noDNS) if !spec.noDNS { stringList(AppText.string("runSpec.addNameserver", defaultValue: "Add nameserver"), $spec.dns, prompt: "1.1.1.1", - info: AppText.string("runSpec.addNameserver.info", defaultValue: "DNS nameserver IP (--dns).")) + info: fieldInfo(.networkDNSServers)) UI.Panel.Field(label: AppText.string("runSpec.searchDomain", defaultValue: "Search domain"), - info: AppText.string("runSpec.searchDomain.info", defaultValue: "Default DNS domain (--dns-domain).")) { + info: fieldInfo(.networkDNSDomain)) { TextField("", text: $spec.dnsDomain, prompt: Text("optional")).textFieldStyle(.roundedBorder) } stringList(AppText.string("runSpec.addSearchDomain", defaultValue: "Add search domain"), $spec.dnsSearch, prompt: "example.com", - info: AppText.string("runSpec.addSearchDomain.info", defaultValue: "DNS search domain (--dns-search).")) + info: fieldInfo(.networkDNSSearchDomains)) stringList(AppText.string("runSpec.addDNSOption", defaultValue: "Add DNS option"), $spec.dnsOption, prompt: "ndots:2", - info: AppText.string("runSpec.addDNSOption.info", defaultValue: "DNS resolver option (--dns-option).")) + info: fieldInfo(.networkDNSOptions)) } } } @@ -540,11 +562,40 @@ struct RunSpecForm: View { fetchSection dnsSection stringList(AppText.string("runSpec.addMount", defaultValue: "Add mount"), $spec.mounts, prompt: "type=bind,source=/host,target=/container", - info: AppText.string("runSpec.addMount.info", defaultValue: "Raw mount spec for advanced mount types (--mount).")) + info: fieldInfo(.storageMounts)) labelsSection } } + @ViewBuilder + private var dockerComposeSection: some View { + let fields = unsupportedFields + if !fields.isEmpty { + UI.Panel.Section(header: AppText.string("runSpec.section.dockerCompose", defaultValue: "Docker & Compose"), + footer: AppText.string("runSpec.section.dockerCompose.footer", defaultValue: "Known Docker CLI and Compose fields preserved for future cores. Apple container cannot execute these values."), + highlighted: spec.hasUnsupportedRuntimeValues, + enabled: $dockerComposeExpanded) { + ForEach(fields) { field in + UI.Panel.Field(label: fieldLabel(field), + info: fieldInfo(field.path), + error: field.support(for: spec.effectiveRuntimeKind).defaultDisabledReason) { + Text(valueDescription(for: field)) + .designSecondaryCallout() + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + } + + private var unsupportedFields: [Core.Schema.FieldDescriptor] { + spec.definition.fields.filter { field in + field.support(for: spec.effectiveRuntimeKind).state == .disabled && + !(spec.document.value(field.path, in: spec.definition) ?? field.defaultValue).isEmpty + } + } + private var shmLimitBinding: Binding { Binding(get: { !spec.shmSize.isEmpty }, set: { spec.shmSize = $0 ? "64M" : "" }) @@ -587,11 +638,21 @@ struct RunSpecForm: View { } } - private func addButton(_ title: String, action: @escaping () -> Void) -> some View { - UI.Action.Group(UI.Action.Item(systemName: "plus.circle", - title: title, - help: title, - action: action)) + @ViewBuilder + private func addButton(_ title: String, info: String? = nil, action: @escaping () -> Void) -> some View { + let button = UI.Action.Group(UI.Action.Item(systemName: "plus.circle", + title: title, + help: title, + action: action)) + if let info, !info.isEmpty { + HStack(spacing: UI.Layout.Spacing.s) { + button + UI.Control.InfoButton(info) + Spacer() + } + } else { + button + } } private func removeButton(action: @escaping () -> Void) -> some View { @@ -640,4 +701,58 @@ struct RunSpecForm: View { guard panel.runModal() == .OK, let url = panel.url else { return } source.wrappedValue = url.path } + + private func fieldLabel(_ field: Core.Schema.FieldDescriptor) -> String { + AppText.dynamicString(field.labelKey, defaultValue: field.defaultLabel) + } + + private func fieldLabel(_ path: Core.Field.Path, fallback: String) -> String { + guard let field = spec.definition.descriptor(for: path) else { return fallback } + return fieldLabel(field) + } + + private func fieldInfo(_ path: Core.Field.Path) -> String { + guard let field = spec.definition.descriptor(for: path) else { return "" } + let tip = field.tip(for: spec.effectiveRuntimeKind) + let body = tip.map { AppText.dynamicString($0.key, defaultValue: $0.defaultText) } ?? field.defaultLabel + let aliases = field.sourceAliases + .filter { !$0.name.isEmpty || !$0.example.isEmpty } + .map { alias -> String in + let source: String + switch alias.source { + case .appleCLI: source = "Apple container" + case .dockerCLI: source = "Docker CLI" + case .compose: source = "Compose" + } + if alias.example.isEmpty { return "\(source): \(alias.name)" } + return "\(source): \(alias.name) — \(alias.example)" + } + guard !aliases.isEmpty else { return body } + return ([body, "Source references:", aliases.joined(separator: "\n")]).joined(separator: "\n\n") + } + + private func valueDescription(for field: Core.Schema.FieldDescriptor) -> String { + let value = spec.document.value(field.path, in: spec.definition) ?? field.defaultValue + switch value { + case .string(let value), .enumeration(let value): + return value.isEmpty ? AppText.string("schema.value.notSet", defaultValue: "Not set") : value + case .bool(let value): + return value ? AppText.string("common.enabled", defaultValue: "Enabled") : AppText.string("common.disabled", defaultValue: "Disabled") + case .commandLine(let values), .stringList(let values): + let filtered = values.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + return filtered.isEmpty ? AppText.string("schema.value.notSet", defaultValue: "Not set") : filtered.joined(separator: ", ") + case .keyValueList(let values): + let rendered = values.filter(\.isValid).map { "\($0.key)=\($0.value)" } + return rendered.isEmpty ? AppText.string("schema.value.notSet", defaultValue: "Not set") : rendered.joined(separator: ", ") + case .portList(let values): + let rendered = values.filter(\.isValid).map(\.spec) + return rendered.isEmpty ? AppText.string("schema.value.notSet", defaultValue: "Not set") : rendered.joined(separator: ", ") + case .volumeList(let values): + let rendered = values.filter(\.isValid).map(\.spec) + return rendered.isEmpty ? AppText.string("schema.value.notSet", defaultValue: "Not set") : rendered.joined(separator: ", ") + case .socketList(let values): + let rendered = values.filter(\.isValid).map(\.spec) + return rendered.isEmpty ? AppText.string("schema.value.notSet", defaultValue: "Not set") : rendered.joined(separator: ", ") + } + } } diff --git a/Sources/ContainedApp/Features/Containers/Form/RunSpec.swift b/Sources/ContainedApp/Features/Containers/Form/RunSpec.swift deleted file mode 100644 index 5da4f3ef..00000000 --- a/Sources/ContainedApp/Features/Containers/Form/RunSpec.swift +++ /dev/null @@ -1,331 +0,0 @@ -import Foundation -import ContainedCore - -/// An editable key/value row (env vars, labels). -struct KeyValue: Identifiable, Hashable, Codable { - let id = UUID() - var key = "" - var value = "" - var isValid: Bool { !key.trimmingCharacters(in: .whitespaces).isEmpty } - private enum CodingKeys: String, CodingKey { case key, value } -} - -/// A port mapping row. -struct PortMap: Identifiable, Hashable, Codable { - let id = UUID() - var hostPort = "" - var containerPort = "" - var proto = "tcp" - var isValid: Bool { !hostPort.isEmpty && !containerPort.isEmpty } - var spec: String { - let base = "\(hostPort):\(containerPort)" - return proto == "tcp" ? base : "\(base)/\(proto)" - } - private enum CodingKeys: String, CodingKey { case hostPort, containerPort, proto } -} - -/// A volume / bind-mount row. -struct VolumeMap: Identifiable, Hashable, Codable { - let id = UUID() - var source = "" - var target = "" - var readOnly = false - var isValid: Bool { !source.isEmpty && !target.isEmpty } - var spec: String { - let base = "\(source):\(target)" - return readOnly ? "\(base):ro" : base - } - private enum CodingKeys: String, CodingKey { case source, target, readOnly } -} - -/// A host socket forwarded into the container. -struct SocketMap: Identifiable, Hashable, Codable { - let id = UUID() - var hostPath = "" - var containerPath = "" - var isValid: Bool { !hostPath.isEmpty && !containerPath.isEmpty } - var spec: String { "\(hostPath):\(containerPath)" } - private enum CodingKeys: String, CodingKey { case hostPath, containerPath } -} - -/// The complete app-owned state of the Create/Run form. Runtime adapters translate the derived -/// `Core.Container.CreateRequest` into backend-specific commands. -struct RunSpec: Codable { - var runtimeKind: Core.Runtime.Kind? = .appleContainer - var image = "" - var platform = "" - var name = "" - var command = "" // optional args after the image - var entrypoint = "" - var detach = true - var removeOnExit = false - var interactive = false - var tty = false - var cpus = "" - var memory = "" - var env: [KeyValue] = [] - var envFiles: [String] = [] - var ports: [PortMap] = [] - var volumes: [VolumeMap] = [] - var mounts: [String] = [] // --mount type=...,source=...,target=... - var sockets: [SocketMap] = [] - var labels: [KeyValue] = [] - var readOnly = false - var useInit = false - var rosetta = false - var ssh = false - var virtualization = false - var restart: Core.Container.RestartPolicy = .no - - // Advanced (all optional; empty entries are skipped when building argv). - var workingDir = "" // -w - var user = "" // -u name|uid[:gid] - var uid = "" // --uid - var gid = "" // --gid - var shmSize = "" // --shm-size e.g. 64M, 1G - var capAdd: [String] = [] // --cap-add - var capDrop: [String] = [] // --cap-drop - var cidFile = "" // --cidfile - var initImage = "" // --init-image - var kernel = "" // --kernel - var network = "" // --network - var noDNS = false // --no-dns - var dns: [String] = [] // --dns - var dnsDomain = "" // --dns-domain - var dnsSearch: [String] = [] // --dns-search - var dnsOption: [String] = [] // --dns-option - var tmpfs: [String] = [] // --tmpfs - var ulimits: [String] = [] // --ulimit type=soft[:hard] - var runtime = "" // --runtime - var scheme = "" // --scheme - var progress = "" // --progress - var maxConcurrentDownloads = "" // --max-concurrent-downloads - - // Personalization is stored locally (PersonalizationStore), never injected as labels — this is - // just the form's working copy, persisted by the sheet after a successful create/save. - var personalization = Personalization() - // App-managed healthcheck — also stored locally (HealthCheckStore), not as labels. - var healthCheck = Core.Container.HealthCheck() - - var validationMessages: [String] { - var messages: [String] = [] - if image.trimmingCharacters(in: .whitespaces).isEmpty { - messages.append(AppText.runSpecChooseImageToRun) - } - for port in ports { - let hasHost = !port.hostPort.trimmingCharacters(in: .whitespaces).isEmpty - let hasContainer = !port.containerPort.trimmingCharacters(in: .whitespaces).isEmpty - if hasHost != hasContainer { - messages.append(AppText.runSpecCompletePortMappings) - break - } - } - for volume in volumes { - let hasSource = !volume.source.trimmingCharacters(in: .whitespaces).isEmpty - let hasTarget = !volume.target.trimmingCharacters(in: .whitespaces).isEmpty - if hasSource != hasTarget { - messages.append(AppText.runSpecCompleteVolumeMounts) - break - } - } - if env.contains(where: { - $0.key.trimmingCharacters(in: .whitespaces).isEmpty && - !$0.value.trimmingCharacters(in: .whitespaces).isEmpty - }) { - messages.append(AppText.runSpecEnvironmentNeedsNames) - } - if !memory.trimmingCharacters(in: .whitespaces).isEmpty, - RunSpec.parseMemoryBytes(memory) == nil { - messages.append(AppText.runSpecMemoryFormat) - } - return messages - } - - var isRunnable: Bool { validationMessages.isEmpty } - - private static func parseMemoryBytes(_ spec: String) -> UInt64? { - let trimmed = spec.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return nil } - let suffix = trimmed.last?.isLetter == true ? trimmed.last! : nil - let numberPart = suffix == nil ? trimmed : String(trimmed.dropLast()) - guard let value = Double(numberPart), value > 0 else { return nil } - let multiplier: Double - switch suffix?.uppercased() { - case nil: multiplier = 1 - case "K": multiplier = 1024 - case "M": multiplier = 1024 * 1024 - case "G": multiplier = 1024 * 1024 * 1024 - case "T": multiplier = 1024 * 1024 * 1024 * 1024 - default: return nil - } - return UInt64(value * multiplier) - } - - init() {} - - /// Prefill from an existing container's configuration — the basis of the Recreate macro - /// (container config is immutable, so "editing" means delete + re-run from this spec). - /// Best-effort: reproduces the reversible run flags; the image's baked-in command is kept by - /// re-passing the recorded arguments. - init(from config: Core.Container.Configuration) { - image = config.image.reference - platform = config.platform.display - name = config.id - command = config.initProcess.arguments.joined(separator: " ") - tty = config.initProcess.terminal - cpus = String(config.resources.cpus) - memory = Format.memorySpec(config.resources.memoryInBytes) - readOnly = config.readOnly - useInit = config.useInit - rosetta = config.rosetta - ssh = config.ssh - virtualization = config.virtualization - workingDir = config.initProcess.workingDirectory ?? "" - shmSize = config.shmSize.map(Format.memorySpec) ?? "" - capAdd = config.capAdd - capDrop = config.capDrop - runtime = config.runtimeHandler ?? "" - network = config.networks.first?.network ?? "" - dns = config.dns?.nameservers ?? [] - dnsDomain = config.dns?.domain ?? "" - dnsSearch = config.dns?.searchDomains ?? [] - dnsOption = config.dns?.options ?? [] - - ports = config.publishedPorts.map { - let hostPrefix = ($0.hostAddress ?? "").isEmpty || $0.hostAddress == "0.0.0.0" ? "" : "\($0.hostAddress!):" - return PortMap(hostPort: "\(hostPrefix)\($0.hostPort)", containerPort: String($0.containerPort), - proto: $0.proto ?? "tcp") - } - sockets = config.publishedSockets.compactMap { socket in - guard let hostPath = socket.hostPath, let containerPath = socket.containerPath else { return nil } - return SocketMap(hostPath: hostPath, containerPath: containerPath) - } - volumes = config.mounts.compactMap { mount in - guard let source = mount.source, let target = mount.effectiveDestination else { return nil } - return VolumeMap(source: source, target: target, readOnly: mount.readonly ?? false) - } - env = config.initProcess.environment.compactMap { entry in - guard let eq = entry.firstIndex(of: "=") else { return nil } - return KeyValue(key: String(entry[.. [String] { - Core.Command.runPreview(for: createRequest) - } - - var createRequest: Core.Container.CreateRequest { - var request = Core.Container.CreateRequest() - request.runtimeKind = effectiveRuntimeKind - request.image = image - request.platform = platform - request.name = name - request.command = command.split(separator: " ").map(String.init) - request.entrypoint = entrypoint - request.detach = detach - request.removeOnExit = removeOnExit - request.interactive = interactive - request.tty = tty - request.cpus = cpus - request.memory = memory - request.env = env.map { Core.Container.KeyValue(key: $0.key, value: $0.value) } - request.envFiles = envFiles - request.ports = ports.map { Core.Container.Port(hostPort: $0.hostPort, containerPort: $0.containerPort, proto: $0.proto) } - request.volumes = volumes.map { Core.Container.VolumeMount(source: $0.source, target: $0.target, readOnly: $0.readOnly) } - request.mounts = mounts - request.sockets = sockets.map { Core.Container.Socket(hostPath: $0.hostPath, containerPath: $0.containerPath) } - request.labels = labels.map { Core.Container.KeyValue(key: $0.key, value: $0.value) } - request.restart = restart - request.readOnly = readOnly - request.useInit = useInit - request.rosetta = rosetta - request.ssh = ssh - request.virtualization = virtualization - request.workingDir = workingDir - request.user = user - request.uid = uid - request.gid = gid - request.shmSize = shmSize - request.capAdd = capAdd - request.capDrop = capDrop - request.cidFile = cidFile - request.initImage = initImage - request.kernel = kernel - request.network = network - request.noDNS = noDNS - request.dns = dns - request.dnsDomain = dnsDomain - request.dnsSearch = dnsSearch - request.dnsOption = dnsOption - request.tmpfs = tmpfs - request.ulimits = ulimits - request.runtime = runtime - request.scheme = scheme - request.progress = progress - request.maxConcurrentDownloads = maxConcurrentDownloads - return request - } - - init(request: Core.Container.CreateRequest, healthCheck: Core.Container.HealthCheck? = nil) { - runtimeKind = request.runtimeKind - image = request.image - platform = request.platform - name = request.name - command = request.command.joined(separator: " ") - entrypoint = request.entrypoint - detach = request.detach - removeOnExit = request.removeOnExit - interactive = request.interactive - tty = request.tty - cpus = request.cpus - memory = request.memory - env = request.env.map { KeyValue(key: $0.key, value: $0.value) } - envFiles = request.envFiles - ports = request.ports.map { PortMap(hostPort: $0.hostPort, containerPort: $0.containerPort, proto: $0.proto) } - volumes = request.volumes.map { VolumeMap(source: $0.source, target: $0.target, readOnly: $0.readOnly) } - mounts = request.mounts - sockets = request.sockets.map { SocketMap(hostPath: $0.hostPath, containerPath: $0.containerPath) } - labels = request.labels.map { KeyValue(key: $0.key, value: $0.value) } - restart = request.restart - readOnly = request.readOnly - useInit = request.useInit - rosetta = request.rosetta - ssh = request.ssh - virtualization = request.virtualization - workingDir = request.workingDir - user = request.user - uid = request.uid - gid = request.gid - shmSize = request.shmSize - capAdd = request.capAdd - capDrop = request.capDrop - cidFile = request.cidFile - initImage = request.initImage - kernel = request.kernel - network = request.network - noDNS = request.noDNS - dns = request.dns - dnsDomain = request.dnsDomain - dnsSearch = request.dnsSearch - dnsOption = request.dnsOption - tmpfs = request.tmpfs - ulimits = request.ulimits - runtime = request.runtime - scheme = request.scheme - progress = request.progress - maxConcurrentDownloads = request.maxConcurrentDownloads - if let healthCheck { self.healthCheck = healthCheck } - } - - var effectiveRuntimeKind: Core.Runtime.Kind { - runtimeKind ?? .appleContainer - } -} diff --git a/Sources/ContainedApp/Features/Containers/Form/RunSpecFormSupport.swift b/Sources/ContainedApp/Features/Containers/Form/RunSpecFormSupport.swift deleted file mode 100644 index ba1e7158..00000000 --- a/Sources/ContainedApp/Features/Containers/Form/RunSpecFormSupport.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation -import ContainedCore - -enum RunSpecMemoryFormatter { - /// Parse a `--memory` spec ("512M", "1G", "2g", bare bytes) into gigabytes. - static func parseGB(_ spec: String) -> Double? { - let trimmed = spec.trimmingCharacters(in: .whitespaces) - guard let last = trimmed.last else { return nil } - if last.isLetter { - guard let value = Double(trimmed.dropLast()) else { return nil } - switch last.uppercased() { - case "G": return value - case "M": return value / 1024 - case "K": return value / (1024 * 1024) - case "T": return value * 1024 - default: return nil - } - } - return Double(trimmed).map { $0 / 1_073_741_824 } - } - - /// Format gigabytes as a `--memory` spec, using `M` for fractional values. - static func spec(gb: Double) -> String { - gb.rounded() == gb ? "\(Int(gb))G" : "\(Int(gb * 1024))M" - } - - static func readout(_ spec: String, fallbackGB: Double) -> String { - let gb = parseGB(spec) ?? fallbackGB - if gb < 1 { return "\(Int(gb * 1024)) MB" } - return gb.rounded() == gb ? "\(Int(gb)) GB" : String(format: "%.1f GB", gb) - } -} - -extension RunSpec { - var normalizedImageReference: String { - Self.normalizedImageReference(image) - } - - static func normalizedImageReference(_ reference: String) -> String { - let short = Format.shortImage(reference.trimmingCharacters(in: .whitespaces)) - let nameStart = short.lastIndex(of: "/").map { short.index(after: $0) } ?? short.startIndex - let namePart = short[nameStart...] - if namePart.contains(":") || namePart.contains("@") { return short } - return short + ":latest" - } - - @discardableResult - mutating func adoptImageDefaults(from defaults: Core.Container.ImageDefaults) -> Int { - var applied = 0 - if command.trimmingCharacters(in: .whitespaces).isEmpty, !defaults.command.isEmpty { - let cmd = defaults.command - command = cmd.joined(separator: " ") - applied += 1 - } - if entrypoint.trimmingCharacters(in: .whitespaces).isEmpty, - !defaults.entrypoint.isEmpty { - let entrypointValue = defaults.entrypoint - entrypoint = entrypointValue.joined(separator: " ") - applied += 1 - } - if workingDir.trimmingCharacters(in: .whitespaces).isEmpty, - let workingDirValue = defaults.workingDirectory, - !workingDirValue.isEmpty { - workingDir = workingDirValue - applied += 1 - } - if user.trimmingCharacters(in: .whitespaces).isEmpty, - let userValue = defaults.user, - !userValue.isEmpty { - user = userValue - applied += 1 - } - let existingEnvKeys = Set(env.map(\.key)) - for entry in defaults.environment { - guard entry.isValid, !existingEnvKeys.contains(entry.key) else { continue } - env.append(KeyValue(key: entry.key, value: entry.value)) - applied += 1 - } - return applied - } - - var hasGeneralOptions: Bool { - !image.trimmingCharacters(in: .whitespaces).isEmpty || - !platform.isEmpty || - !name.isEmpty || - !command.isEmpty || - !entrypoint.isEmpty || - !detach || - removeOnExit - } - - var hasResourceOptions: Bool { - !cpus.isEmpty || !memory.isEmpty - } - - var hasNetworkingOptions: Bool { - !ports.isEmpty || !network.isEmpty || !sockets.isEmpty - } - - var hasStorageOptions: Bool { - !volumes.isEmpty || !mounts.isEmpty - } - - var hasEnvironmentOptions: Bool { - !env.isEmpty || !envFiles.isEmpty - } - - var hasPersonalizationOptions: Bool { - !personalization.isDefault - } - - var hasAppManagedOptions: Bool { - restart != .no || healthCheck.isActive - } - - var hasAdvancedOptions: Bool { - interactive || tty || - !workingDir.isEmpty || - !user.isEmpty || - !uid.isEmpty || - !gid.isEmpty || - !shmSize.isEmpty || - !capAdd.isEmpty || - !capDrop.isEmpty || - !cidFile.isEmpty || - !initImage.isEmpty || - !kernel.isEmpty || - noDNS || - !dns.isEmpty || - !dnsDomain.isEmpty || - !dnsSearch.isEmpty || - !dnsOption.isEmpty || - !tmpfs.isEmpty || - !ulimits.isEmpty || - !runtime.isEmpty || - !scheme.isEmpty || - !progress.isEmpty || - !maxConcurrentDownloads.isEmpty || - !mounts.isEmpty || - !labels.isEmpty || - readOnly || - useInit || - rosetta || - ssh || - virtualization - } -} diff --git a/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift b/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift index 28625116..ea59a1a0 100644 --- a/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift +++ b/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift @@ -264,7 +264,7 @@ final class ContainersStore { /// (the user-set name, or the id `container run` prints for a generated name), or nil on failure. /// The id lets the caller attach local personalization to exactly this container. @discardableResult - func run(_ spec: RunSpec) async -> String? { + func run(_ spec: ContainerFormState) async -> String? { guard let client else { return nil } let started = Date() logger?.record("Running container from creation flow", @@ -272,7 +272,7 @@ final class ContainersStore { severity: .info) diagnosticLogger.notice("Run started from creation flow") do { - let result = try await client.createContainer(spec.createRequest) + let result = try await client.createContainer(spec.document) performHaptic() await refresh() let elapsed = Date().timeIntervalSince(started) @@ -297,7 +297,7 @@ final class ContainersStore { /// Recreate macro: tear down `originalID` and run `spec` in its place. Container config is /// immutable, so edits become a replacement run. Errors are surfaced for the caller to show. @discardableResult - func recreate(originalID: String, spec: RunSpec) async -> Bool { + func recreate(originalID: String, spec: ContainerFormState) async -> Bool { guard let client else { return false } busyIDs.insert(originalID) intentionalStops.insert(originalID) // don't let the watchdog fight the teardown @@ -306,7 +306,7 @@ final class ContainersStore { logger?.record("Recreating \(originalID)", category: .lifecycle, containerID: originalID) diagnosticLogger.notice("Recreate started for \(originalID, privacy: .public)") do { - _ = try await client.recreateContainer(originalID: originalID, request: spec.createRequest) + _ = try await client.recreateContainer(originalID: originalID, document: spec.document) performHaptic() await refresh() let elapsed = Date().timeIntervalSince(started) diff --git a/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift b/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift index 3d681c6c..a39f4d79 100644 --- a/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift +++ b/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift @@ -4,7 +4,7 @@ import ContainedCore /// Reusable Docker Hub image search. Before the user types it offers ready-to-run **starters** and a /// curated list of **popular** images as quick-picks; while typing it debounces and queries Hub, with -/// explicit loading and empty states. Selecting anything yields a prefilled `RunSpec` (the starters +/// explicit loading and empty states. Selecting anything yields a prefilled `ContainerFormState` (the starters /// carry a full recipe; a Hub result or popular image carries just the image reference). /// /// Used by `CreationFlow` for the image-search entry point. Inline fuzzy matching is separate from @@ -12,7 +12,7 @@ import ContainedCore struct RegistryImageSearch: View { /// Called with a prefilled spec when the user picks a starter, a popular image, or a search result. var initialQuery = "" - var onSelect: (RunSpec) -> Void + var onSelect: (ContainerFormState) -> Void @State private var query = "" @State private var appliedInitialQuery = false @@ -239,8 +239,8 @@ struct RecommendedImage: Identifiable, Hashable { ] /// Build a minimal spec that just targets `reference` — the configure form fills in the rest. - static func spec(for reference: String) -> RunSpec { - var spec = RunSpec() + static func spec(for reference: String) -> ContainerFormState { + var spec = ContainerFormState() spec.image = reference return spec } diff --git a/Sources/ContainedApp/Features/Palette/CommandPalette.swift b/Sources/ContainedApp/Features/Palette/CommandPalette.swift index f6714479..0e21ed14 100644 --- a/Sources/ContainedApp/Features/Palette/CommandPalette.swift +++ b/Sources/ContainedApp/Features/Palette/CommandPalette.swift @@ -346,7 +346,7 @@ struct PaletteItem: Identifiable { visual: .volume(volume), icon: "externaldrive", tint: .secondary) { - var spec = RunSpec() + var spec = ContainerFormState() spec.volumes = [VolumeMap(source: volume.name, target: "/data")] ui.openCreationPanel(entry: .configure, prefill: spec) } @@ -364,7 +364,7 @@ struct PaletteItem: Identifiable { visual: .network(network), icon: "network", tint: .secondary) { - var spec = RunSpec() + var spec = ContainerFormState() spec.network = network.name ui.openCreationPanel(entry: .configure, prefill: spec) } diff --git a/Sources/ContainedApp/Navigation/State/UIState.swift b/Sources/ContainedApp/Navigation/State/UIState.swift index 32b0e881..7f1c9c2a 100644 --- a/Sources/ContainedApp/Navigation/State/UIState.swift +++ b/Sources/ContainedApp/Navigation/State/UIState.swift @@ -10,7 +10,7 @@ final class UIState { struct CreationPresentation { var entry: CreationEntry = .menu - var prefillSpec: RunSpec? + var prefillSpec: ContainerFormState? var editSnapshot: Core.Container.Snapshot? var returnEntry: CreationEntry? var searchQuery = "" @@ -35,8 +35,8 @@ final class UIState { struct PrefillPresentation { var showRunSheet = false - var currentSpec: RunSpec? - var queue: [RunSpec] = [] + var currentSpec: ContainerFormState? + var queue: [ContainerFormState] = [] } var creation = CreationPresentation() @@ -144,7 +144,7 @@ final class UIState { if !panelNavigationEnabled { switch action { case .runContainer: - presentCreate(RunSpec()) + presentCreate(ContainerFormState()) return case .pullImage, .createVolume, .createNetwork, .activityHistory: navigateForClassicFallback(action) @@ -181,13 +181,13 @@ final class UIState { /// Open the creation flow in the toolbar add morph at a specific page. func openCreationPanel(entry: CreationEntry = .menu, - prefill spec: RunSpec? = nil, + prefill spec: ContainerFormState? = nil, searchQuery: String = "", returningTo returnEntry: CreationEntry? = nil) { guard panelNavigationEnabled else { switch entry { case .menu, .chooser, .configure: - presentCreate(spec ?? RunSpec()) + presentCreate(spec ?? ContainerFormState()) case .network: navigate(to: .networks) case .volume: @@ -208,7 +208,7 @@ final class UIState { toolbar.activeMorph = .add } - func openCreationPanel(prefill spec: RunSpec, + func openCreationPanel(prefill spec: ContainerFormState, returningTo returnEntry: CreationEntry? = nil, searchQuery: String = "") { guard panelNavigationEnabled else { @@ -246,7 +246,7 @@ final class UIState { func runImage(_ reference: String, returningTo returnEntry: CreationEntry? = nil, searchQuery: String = "") { - var spec = RunSpec() + var spec = ContainerFormState() spec.image = reference guard panelNavigationEnabled else { presentCreate(spec) @@ -256,7 +256,7 @@ final class UIState { openCreationPanel(prefill: spec, returningTo: returnEntry, searchQuery: searchQuery) } - func useTemplate(_ spec: RunSpec) { + func useTemplate(_ spec: ContainerFormState) { guard panelNavigationEnabled else { presentCreate(spec) return @@ -266,7 +266,7 @@ final class UIState { } /// Open the New-Container window prefilled with `spec`. - func presentCreate(_ spec: RunSpec) { + func presentCreate(_ spec: ContainerFormState) { prefill.currentSpec = spec prefill.showRunSheet = true } @@ -274,7 +274,7 @@ final class UIState { /// Open the New-Container window for each queued spec in turn (compose import). Pulls each image /// first (with progress), then presents the first editor; the rest follow as editors close. The /// editor is the creation panel when panel navigation is enabled, otherwise the classic sheet. - func beginPrefillQueue(_ specs: [RunSpec], using app: AppModel) { + func beginPrefillQueue(_ specs: [ContainerFormState], using app: AppModel) { guard let first = specs.first else { return } prefill.queue = Array(specs.dropFirst()) Task { @@ -291,7 +291,7 @@ final class UIState { DispatchQueue.main.async { self.presentNextPrefill(next) } } - private func presentNextPrefill(_ spec: RunSpec) { + private func presentNextPrefill(_ spec: ContainerFormState) { if panelNavigationEnabled { openCreationPanel(prefill: spec) } else { diff --git a/Sources/ContainedApp/Persistence/History/Template.swift b/Sources/ContainedApp/Persistence/History/Template.swift index 24e022af..eb0072be 100644 --- a/Sources/ContainedApp/Persistence/History/Template.swift +++ b/Sources/ContainedApp/Persistence/History/Template.swift @@ -1,7 +1,8 @@ import Foundation +import ContainedCore import SwiftData -/// A saved container recipe — a named `RunSpec`, persisted (encoded) so it can prefill the edit form +/// A saved container recipe — a named `ContainerFormState`, persisted (encoded) so it can prefill the edit form /// later. Stored in the same SwiftData container as the history models. @Model final class Template { @@ -9,7 +10,7 @@ final class Template { var createdAt: Date var specData: Data - init(name: String, spec: RunSpec, createdAt: Date = Date()) { + init(name: String, spec: ContainerFormState, createdAt: Date = Date()) { self.name = name self.createdAt = createdAt self.specData = (try? JSONEncoder().encode(spec)) ?? Data() @@ -21,13 +22,231 @@ final class Template { self.specData = (try? JSONEncoder().encode(snapshot.spec)) ?? Data() } - var spec: RunSpec? { try? JSONDecoder().decode(RunSpec.self, from: specData) } + var spec: ContainerFormState? { + let decoder = JSONDecoder() + if let state = try? decoder.decode(ContainerFormState.self, from: specData) { + return state + } + return (try? decoder.decode(LegacyContainerTemplateSpec.self, from: specData))?.formState + } +} + +private struct LegacyContainerTemplateSpec: Decodable { + var runtimeKind: Core.Runtime.Kind? = .appleContainer + var image = "" + var platform = "" + var name = "" + var command = "" + var entrypoint = "" + var detach = true + var removeOnExit = false + var interactive = false + var tty = false + var cpus = "" + var memory = "" + var env: [KeyValue] = [] + var envFiles: [String] = [] + var ports: [PortMap] = [] + var volumes: [VolumeMap] = [] + var mounts: [String] = [] + var sockets: [SocketMap] = [] + var labels: [KeyValue] = [] + var readOnly = false + var useInit = false + var rosetta = false + var ssh = false + var virtualization = false + var restart: Core.Container.RestartPolicy = .no + var workingDir = "" + var user = "" + var uid = "" + var gid = "" + var shmSize = "" + var capAdd: [String] = [] + var capDrop: [String] = [] + var cidFile = "" + var initImage = "" + var kernel = "" + var network = "" + var noDNS = false + var dns: [String] = [] + var dnsDomain = "" + var dnsSearch: [String] = [] + var dnsOption: [String] = [] + var tmpfs: [String] = [] + var ulimits: [String] = [] + var runtime = "" + var scheme = "" + var progress = "" + var maxConcurrentDownloads = "" + var personalization = Personalization() + var healthCheck = Core.Container.HealthCheck() + + private enum CodingKeys: String, CodingKey { + case runtimeKind + case image + case platform + case name + case command + case entrypoint + case detach + case removeOnExit + case interactive + case tty + case cpus + case memory + case env + case envFiles + case ports + case volumes + case mounts + case sockets + case labels + case readOnly + case useInit + case rosetta + case ssh + case virtualization + case restart + case workingDir + case user + case uid + case gid + case shmSize + case capAdd + case capDrop + case cidFile + case initImage + case kernel + case network + case noDNS + case dns + case dnsDomain + case dnsSearch + case dnsOption + case tmpfs + case ulimits + case runtime + case scheme + case progress + case maxConcurrentDownloads + case personalization + case healthCheck + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + runtimeKind = try container.decodeIfPresent(Core.Runtime.Kind.self, forKey: .runtimeKind) ?? .appleContainer + image = try container.decodeIfPresent(String.self, forKey: .image) ?? "" + platform = try container.decodeIfPresent(String.self, forKey: .platform) ?? "" + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + command = try container.decodeIfPresent(String.self, forKey: .command) ?? "" + entrypoint = try container.decodeIfPresent(String.self, forKey: .entrypoint) ?? "" + detach = try container.decodeIfPresent(Bool.self, forKey: .detach) ?? true + removeOnExit = try container.decodeIfPresent(Bool.self, forKey: .removeOnExit) ?? false + interactive = try container.decodeIfPresent(Bool.self, forKey: .interactive) ?? false + tty = try container.decodeIfPresent(Bool.self, forKey: .tty) ?? false + cpus = try container.decodeIfPresent(String.self, forKey: .cpus) ?? "" + memory = try container.decodeIfPresent(String.self, forKey: .memory) ?? "" + env = try container.decodeIfPresent([KeyValue].self, forKey: .env) ?? [] + envFiles = try container.decodeIfPresent([String].self, forKey: .envFiles) ?? [] + ports = try container.decodeIfPresent([PortMap].self, forKey: .ports) ?? [] + volumes = try container.decodeIfPresent([VolumeMap].self, forKey: .volumes) ?? [] + mounts = try container.decodeIfPresent([String].self, forKey: .mounts) ?? [] + sockets = try container.decodeIfPresent([SocketMap].self, forKey: .sockets) ?? [] + labels = try container.decodeIfPresent([KeyValue].self, forKey: .labels) ?? [] + readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false + useInit = try container.decodeIfPresent(Bool.self, forKey: .useInit) ?? false + rosetta = try container.decodeIfPresent(Bool.self, forKey: .rosetta) ?? false + ssh = try container.decodeIfPresent(Bool.self, forKey: .ssh) ?? false + virtualization = try container.decodeIfPresent(Bool.self, forKey: .virtualization) ?? false + restart = try container.decodeIfPresent(Core.Container.RestartPolicy.self, forKey: .restart) ?? .no + workingDir = try container.decodeIfPresent(String.self, forKey: .workingDir) ?? "" + user = try container.decodeIfPresent(String.self, forKey: .user) ?? "" + uid = try container.decodeIfPresent(String.self, forKey: .uid) ?? "" + gid = try container.decodeIfPresent(String.self, forKey: .gid) ?? "" + shmSize = try container.decodeIfPresent(String.self, forKey: .shmSize) ?? "" + capAdd = try container.decodeIfPresent([String].self, forKey: .capAdd) ?? [] + capDrop = try container.decodeIfPresent([String].self, forKey: .capDrop) ?? [] + cidFile = try container.decodeIfPresent(String.self, forKey: .cidFile) ?? "" + initImage = try container.decodeIfPresent(String.self, forKey: .initImage) ?? "" + kernel = try container.decodeIfPresent(String.self, forKey: .kernel) ?? "" + network = try container.decodeIfPresent(String.self, forKey: .network) ?? "" + noDNS = try container.decodeIfPresent(Bool.self, forKey: .noDNS) ?? false + dns = try container.decodeIfPresent([String].self, forKey: .dns) ?? [] + dnsDomain = try container.decodeIfPresent(String.self, forKey: .dnsDomain) ?? "" + dnsSearch = try container.decodeIfPresent([String].self, forKey: .dnsSearch) ?? [] + dnsOption = try container.decodeIfPresent([String].self, forKey: .dnsOption) ?? [] + tmpfs = try container.decodeIfPresent([String].self, forKey: .tmpfs) ?? [] + ulimits = try container.decodeIfPresent([String].self, forKey: .ulimits) ?? [] + runtime = try container.decodeIfPresent(String.self, forKey: .runtime) ?? "" + scheme = try container.decodeIfPresent(String.self, forKey: .scheme) ?? "" + progress = try container.decodeIfPresent(String.self, forKey: .progress) ?? "" + maxConcurrentDownloads = try container.decodeIfPresent(String.self, forKey: .maxConcurrentDownloads) ?? "" + personalization = try container.decodeIfPresent(Personalization.self, forKey: .personalization) ?? Personalization() + healthCheck = try container.decodeIfPresent(Core.Container.HealthCheck.self, forKey: .healthCheck) ?? Core.Container.HealthCheck() + } + + var formState: ContainerFormState { + var request = Core.Container.CreateRequest() + request.runtimeKind = runtimeKind ?? .appleContainer + request.image = image + request.platform = platform + request.name = name + request.command = command.split(separator: " ").map(String.init) + request.entrypoint = entrypoint + request.detach = detach + request.removeOnExit = removeOnExit + request.interactive = interactive + request.tty = tty + request.cpus = cpus + request.memory = memory + request.env = env + request.envFiles = envFiles + request.ports = ports + request.volumes = volumes + request.mounts = mounts + request.sockets = sockets + request.labels = labels + request.readOnly = readOnly + request.useInit = useInit + request.rosetta = rosetta + request.ssh = ssh + request.virtualization = virtualization + request.restart = restart + request.workingDir = workingDir + request.user = user + request.uid = uid + request.gid = gid + request.shmSize = shmSize + request.capAdd = capAdd + request.capDrop = capDrop + request.cidFile = cidFile + request.initImage = initImage + request.kernel = kernel + request.network = network + request.noDNS = noDNS + request.dns = dns + request.dnsDomain = dnsDomain + request.dnsSearch = dnsSearch + request.dnsOption = dnsOption + request.tmpfs = tmpfs + request.ulimits = ulimits + request.runtime = runtime + request.scheme = scheme + request.progress = progress + request.maxConcurrentDownloads = maxConcurrentDownloads + + var state = ContainerFormState(document: .containerCreate(from: request), healthCheck: healthCheck) + state.personalization = personalization + return state + } } struct TemplateSnapshot: Codable { var name: String var createdAt: Date - var spec: RunSpec + var spec: ContainerFormState init?(_ template: Template) { guard let spec = template.spec else { return nil } @@ -39,7 +258,7 @@ struct TemplateSnapshot: Codable { /// A few ready-to-run starters offered alongside the user's saved templates. enum BuiltinTemplate { - static let all: [(name: String, symbol: String, spec: RunSpec)] = [ + static let all: [(name: String, symbol: String, spec: ContainerFormState)] = [ make("Postgres", symbol: "cylinder.split.1x2", image: "postgres:16", ports: [("5432", "5432")], env: [("POSTGRES_PASSWORD", "postgres")]), make("Redis", symbol: "bolt.horizontal", image: "redis:7", ports: [("6379", "6379")]), @@ -49,8 +268,8 @@ enum BuiltinTemplate { private static func make(_ name: String, symbol: String, image: String, command: String = "", ports: [(String, String)] = [], - env: [(String, String)] = []) -> (name: String, symbol: String, spec: RunSpec) { - var spec = RunSpec() + env: [(String, String)] = []) -> (name: String, symbol: String, spec: ContainerFormState) { + var spec = ContainerFormState() spec.image = image spec.command = command spec.ports = ports.map { PortMap(hostPort: $0.0, containerPort: $0.1, proto: "tcp") } diff --git a/Sources/ContainedApp/Presentation/Localization/AppLocalization.swift b/Sources/ContainedApp/Presentation/Localization/AppLocalization.swift index 01e01326..16a66947 100644 --- a/Sources/ContainedApp/Presentation/Localization/AppLocalization.swift +++ b/Sources/ContainedApp/Presentation/Localization/AppLocalization.swift @@ -9,6 +9,10 @@ enum AppText { String(localized: key, defaultValue: defaultValue, bundle: .main) } + static func dynamicString(_ key: String, defaultValue: String) -> String { + NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "") + } + static var back: String { string("common.back", defaultValue: "Back") } static var cancel: String { string("common.cancel", defaultValue: "Cancel") } static var clearSearch: String { string("common.clearSearch", defaultValue: "Clear search") } diff --git a/Sources/ContainedApp/Resources/CHANGELOG.md b/Sources/ContainedApp/Resources/CHANGELOG.md index 0dfe25cd..8802b46d 100644 --- a/Sources/ContainedApp/Resources/CHANGELOG.md +++ b/Sources/ContainedApp/Resources/CHANGELOG.md @@ -70,7 +70,7 @@ - Package-first SwiftPM layout with `ContainedCore` for backend orchestration, `ContainedUI` for visual primitives, `ContainedUX` for interaction infrastructure, `ContainedApp` for app-owned SwiftUI/persistence/localization policy, and a tiny `Contained` executable launcher. - `Core.Orchestrator` is the app-facing backend boundary; Core owns runtime descriptors, command previews, Apple `container` adapter internals, typed async runtime methods, Compose/image-default translation, and typed stats streams. -- `RunSpec` owns Run/Edit form state, validation, CLI preview, and execution mapping. +- Core-published run/edit schema documents own editable runtime fields, schema conformance, validation, source aliases, disabled runtime explanations, command previews, and execution mapping. - `AppModel`, focused stores, `UIState`, and toolbar option enums centralize bootstrap, navigation, refresh coordination, image updates, settings, resource styles, filters, and fallback routing. - SwiftData-backed history records events, metric samples, and templates with bounded retention and backup/import support. - Checked-in Xcode workspace and native macOS app target build and run `Contained.app` directly while SwiftPM remains the CI, release, packaging, signing, notarization, and appcast source of truth. diff --git a/Sources/ContainedApp/Resources/Localizable.xcstrings b/Sources/ContainedApp/Resources/Localizable.xcstrings index 37d88016..2ebcad23 100644 --- a/Sources/ContainedApp/Resources/Localizable.xcstrings +++ b/Sources/ContainedApp/Resources/Localizable.xcstrings @@ -129,6 +129,16 @@ "container.restartedAttempt": {}, "container.unhealthy": {}, "containerCard.accessibilityLabel": {}, + "containerForm.healthcheck.enabled.info": {}, + "containerForm.healthcheck.probeCommand.info": {}, + "containerForm.imageDefaults.info": {}, + "containerForm.personalization.blendMode.info": {}, + "containerForm.personalization.color.info": {}, + "containerForm.personalization.colorCardBackground.info": {}, + "containerForm.personalization.gradient.info": {}, + "containerForm.personalization.icon.info": {}, + "containerForm.personalization.nickname.info": {}, + "containerForm.restartPolicy.info": {}, "creation.badge.new": {}, "creation.compose.paste": {}, "creation.compose.paste.subtitle": {}, @@ -492,109 +502,64 @@ "restartPolicy.no": {}, "restartPolicy.onFailure": {}, "runSpec.addCapability": {}, - "runSpec.addCapability.info": {}, "runSpec.addDNSOption": {}, - "runSpec.addDNSOption.info": {}, "runSpec.addEnvFile": {}, - "runSpec.addEnvFile.info": {}, "runSpec.addLabel": {}, "runSpec.addMount": {}, - "runSpec.addMount.info": {}, "runSpec.addNameserver": {}, - "runSpec.addNameserver.info": {}, "runSpec.addPort": {}, "runSpec.addSearchDomain": {}, - "runSpec.addSearchDomain.info": {}, "runSpec.addSocket": {}, "runSpec.addTmpfsMount": {}, - "runSpec.addTmpfsMount.info": {}, "runSpec.addUlimit": {}, - "runSpec.addUlimit.info": {}, "runSpec.addVariable": {}, "runSpec.addVolume": {}, "runSpec.adopt": {}, "runSpec.allocateTTY": {}, - "runSpec.allocateTTY.info": {}, "runSpec.blendMode": {}, - "runSpec.blendMode.info": {}, "runSpec.chooseFileOrFolder": {}, "runSpec.color": {}, - "runSpec.color.info": {}, "runSpec.colorCardBackground": {}, - "runSpec.colorCardBackground.info": {}, "runSpec.command": {}, - "runSpec.command.info": {}, "runSpec.containerIDFile": {}, - "runSpec.containerIDFile.info": {}, "runSpec.cpus": {}, - "runSpec.cpus.info": {}, "runSpec.createNewVolume": {}, "runSpec.customPlatform": {}, - "runSpec.customPlatform.info": {}, "runSpec.default": {}, "runSpec.detach": {}, - "runSpec.detach.info": {}, "runSpec.disableDNS": {}, - "runSpec.disableDNS.info": {}, "runSpec.dropCapability": {}, - "runSpec.dropCapability.info": {}, "runSpec.enableHealthcheck": {}, - "runSpec.enableHealthcheck.info": {}, "runSpec.entrypoint": {}, - "runSpec.entrypoint.info": {}, "runSpec.exposeVirtualization": {}, - "runSpec.exposeVirtualization.info": {}, "runSpec.forwardSSHAgent": {}, - "runSpec.forwardSSHAgent.info": {}, "runSpec.gradient": {}, - "runSpec.gradient.info": {}, "runSpec.icon": {}, - "runSpec.icon.info": {}, "runSpec.image": {}, - "runSpec.image.info": {}, "runSpec.image.required": {}, "runSpec.imageDefaults": {}, - "runSpec.imageDefaults.info": {}, "runSpec.imageDefaults.subtitle": {}, "runSpec.importedValuesHint": {}, "runSpec.initImage": {}, - "runSpec.initImage.info": {}, "runSpec.keepStdinOpen": {}, - "runSpec.keepStdinOpen.info": {}, "runSpec.kernel": {}, - "runSpec.kernel.info": {}, "runSpec.limitMemory": {}, - "runSpec.limitMemory.info": {}, "runSpec.limitParallelDownloads": {}, - "runSpec.limitParallelDownloads.info": {}, "runSpec.memory": {}, "runSpec.name": {}, - "runSpec.name.info": {}, "runSpec.network": {}, - "runSpec.network.info": {}, "runSpec.nickname": {}, - "runSpec.nickname.info": {}, "runSpec.opacity": {}, "runSpec.platform": {}, - "runSpec.platform.info": {}, "runSpec.probeCommand": {}, - "runSpec.probeCommand.info": {}, "runSpec.progress": {}, - "runSpec.progress.info": {}, "runSpec.readOnlyFilesystem": {}, - "runSpec.readOnlyFilesystem.info": {}, "runSpec.registryScheme": {}, - "runSpec.registryScheme.info": {}, "runSpec.removeWhenStopped": {}, - "runSpec.removeWhenStopped.info": {}, "runSpec.restartPolicy": {}, - "runSpec.restartPolicy.info": {}, "runSpec.rosetta": {}, - "runSpec.rosetta.info": {}, "runSpec.runtime": {}, - "runSpec.runtime.info": {}, "runSpec.searchDomain": {}, - "runSpec.searchDomain.info": {}, "runSpec.section.advancedOptions": {}, "runSpec.section.advancedOptions.footer": {}, "runSpec.section.appManaged": {}, @@ -604,22 +569,17 @@ "runSpec.section.resources": {}, "runSpec.section.storage": {}, "runSpec.setSharedMemorySize": {}, - "runSpec.setSharedMemorySize.info": {}, "runSpec.sharedMemory": {}, "runSpec.sourcePicker.help": {}, "runSpec.useInitProcess": {}, - "runSpec.useInitProcess.info": {}, "runSpec.user": {}, - "runSpec.user.info": {}, "runSpec.userID": {}, - "runSpec.userID.info": {}, "runSpec.validation.chooseImage": {}, "runSpec.validation.completePortMappings": {}, "runSpec.validation.completeVolumeMounts": {}, "runSpec.validation.environmentNeedsNames": {}, "runSpec.validation.memoryFormat": {}, "runSpec.workingDirectory": {}, - "runSpec.workingDirectory.info": {}, "runtime.core": {}, "runtime.core.subtitle": {}, "runtime.coreSelector.disabledReason": {}, diff --git a/Tests/ContainedAppTests/RunSpecTests.swift b/Tests/ContainedAppTests/ContainerFormStateTests.swift similarity index 88% rename from Tests/ContainedAppTests/RunSpecTests.swift rename to Tests/ContainedAppTests/ContainerFormStateTests.swift index 7f69271a..b4038e79 100644 --- a/Tests/ContainedAppTests/RunSpecTests.swift +++ b/Tests/ContainedAppTests/ContainerFormStateTests.swift @@ -3,18 +3,18 @@ import Testing import ContainedCore @testable import ContainedApp -@Suite("RunSpec create requests + runtime mapping") +@Suite("ContainerFormState create requests + runtime mapping") @MainActor -struct RunSpecTests { +struct ContainerFormStateTests { - @Test func basicArgv() { - var spec = RunSpec() + @Test func basicArgv() throws { + var spec = ContainerFormState() spec.image = "nginx:latest" - #expect(spec.arguments() == ["run", "--detach", "nginx:latest"]) + #expect(try arguments(spec) == ["run", "--detach", "nginx:latest"]) } - @Test func coreFlagsArgv() { - var spec = RunSpec() + @Test func coreFlagsArgv() throws { + var spec = ContainerFormState() spec.image = "alpine" spec.name = "web" spec.detach = false @@ -25,7 +25,7 @@ struct RunSpecTests { spec.readOnly = true spec.useInit = true spec.command = "echo hi" - let args = spec.arguments() + let args = try arguments(spec) #expect(args.prefix(2) == ["run", "--rm"]) #expect(args.contains(["--name", "web"].joined()) == false) // sanity: not joined #expect(subsequence(["--name", "web"], in: args)) @@ -38,8 +38,8 @@ struct RunSpecTests { #expect(args.suffix(3) == ["alpine", "echo", "hi"]) } - @Test func advancedFlagsArgv() { - var spec = RunSpec() + @Test func advancedFlagsArgv() throws { + var spec = ContainerFormState() spec.image = "alpine" spec.interactive = true spec.tty = true @@ -67,7 +67,7 @@ struct RunSpecTests { spec.scheme = "https" spec.progress = "plain" spec.maxConcurrentDownloads = "2" - let args = spec.arguments() + let args = try arguments(spec) #expect(args.contains("--interactive")) #expect(args.contains("--tty")) #expect(subsequence(["--workdir", "/app"], in: args)) @@ -97,8 +97,8 @@ struct RunSpecTests { #expect(args.filter { $0 == "--cap-add" }.count == 1) // the empty cap was skipped } - @Test func portsVolumesEnvAndLabelsArgv() { - var spec = RunSpec() + @Test func portsVolumesEnvAndLabelsArgv() throws { + var spec = ContainerFormState() spec.image = "nginx" spec.ports = [PortMap(hostPort: "8080", containerPort: "80", proto: "tcp"), PortMap(hostPort: "53", containerPort: "53", proto: "udp")] @@ -107,7 +107,7 @@ struct RunSpecTests { spec.env = [KeyValue(key: "KEY", value: "val")] spec.labels = [KeyValue(key: "team", value: "infra")] spec.restart = .onFailure - let args = spec.arguments() + let args = try arguments(spec) #expect(subsequence(["--publish", "8080:80"], in: args)) #expect(subsequence(["--publish", "53:53/udp"], in: args)) #expect(subsequence(["--volume", "/data:/var/lib:ro"], in: args)) @@ -140,12 +140,12 @@ struct RunSpecTests { let project = try Core.Compose.parse(yaml, projectName: "demo") let plan = try core.translateCompose(project, baseDirectory: nil) let item = try #require(plan.items.first) - let spec = RunSpec(request: item.request, healthCheck: item.healthCheck) + let spec = ContainerFormState(document: item.document, healthCheck: item.healthCheck) #expect(spec.image == "postgres:16") #expect(spec.platform == "linux/arm64") #expect(spec.name == "db") #expect(spec.ports.first?.spec == "5432:5432") - #expect(subsequence(["--platform", "linux/arm64"], in: spec.arguments())) + #expect(subsequence(["--platform", "linux/arm64"], in: try arguments(spec))) #expect(spec.restart == .always) #expect(spec.labels.contains { $0.key == "contained.stack" && $0.value == "demo" }) #expect(spec.healthCheck.enabled) @@ -163,11 +163,12 @@ struct RunSpecTests { """ let base = URL(filePath: "/Volumes/Vault/.Docker/compose", directoryHint: .isDirectory) let project = try! Core.Compose.parse(yaml, projectName: "demo") + let definition = Core.Schema.Definition.appleContainerCreate let resolved = try core.translateCompose(project, baseDirectory: base) .items .first? - .request - .volumes + .document + .volumes(.storageVolumes, in: definition) .first #expect(resolved?.source == "/Volumes/Vault/.Docker/configs/bazarr") @@ -234,8 +235,8 @@ struct RunSpecTests { let plan = try core.translateCompose(project, baseDirectory: URL(filePath: "/opt/stacks/demo", directoryHint: .isDirectory)) let item = try #require(plan.items.first) - let spec = RunSpec(request: item.request, healthCheck: item.healthCheck) - let args = spec.arguments() + let spec = ContainerFormState(document: item.document, healthCheck: item.healthCheck) + let args = try arguments(spec) #expect(spec.image == "example/app:1") #expect(spec.platform == "linux/arm64") @@ -286,21 +287,21 @@ struct RunSpecTests { } @Test func memoryParsingRoundTrips() { - #expect(RunSpecForm.parseMemoryGB("1G") == 1) - #expect(RunSpecForm.parseMemoryGB("512M") == 0.5) - #expect(RunSpecForm.parseMemoryGB("2g") == 2) - #expect(RunSpecForm.parseMemoryGB("") == nil) - #expect(RunSpecForm.memorySpec(gb: 2) == "2G") - #expect(RunSpecForm.memorySpec(gb: 1.5) == "1536M") + #expect(ContainerFormStateMemoryFormatter.parseGB("1G") == 1) + #expect(ContainerFormStateMemoryFormatter.parseGB("512M") == 0.5) + #expect(ContainerFormStateMemoryFormatter.parseGB("2g") == 2) + #expect(ContainerFormStateMemoryFormatter.parseGB("") == nil) + #expect(ContainerFormStateMemoryFormatter.spec(gb: 2) == "2G") + #expect(ContainerFormStateMemoryFormatter.spec(gb: 1.5) == "1536M") } @Test func adoptsPulledImageDefaultsIntoEmptyRunFields() throws { let data = try Data(contentsOf: fixturesURL.appending(path: "image-inspect.json")) let images = try Core.Container.JSON.decode([Core.Image.Resource].self, from: data) - var spec = RunSpec() + var spec = ContainerFormState() spec.image = "alpine" - let maybeDefaults = try core.imageDefaults(for: spec.createRequest, in: images) + let maybeDefaults = try core.imageDefaults(for: spec.document, in: images) let defaults = try #require(maybeDefaults) let applied = spec.adoptImageDefaults(from: defaults) @@ -316,13 +317,13 @@ struct RunSpecTests { @Test func adoptingImageDefaultsDoesNotOverwriteExistingEdits() throws { let data = try Data(contentsOf: fixturesURL.appending(path: "image-inspect.json")) let images = try Core.Container.JSON.decode([Core.Image.Resource].self, from: data) - var spec = RunSpec() + var spec = ContainerFormState() spec.image = "alpine" spec.command = "custom" spec.workingDir = "/app" spec.env = [KeyValue(key: "PATH", value: "/custom")] - let maybeDefaults = try core.imageDefaults(for: spec.createRequest, in: images) + let maybeDefaults = try core.imageDefaults(for: spec.document, in: images) let defaults = try #require(maybeDefaults) _ = spec.adoptImageDefaults(from: defaults) @@ -331,14 +332,14 @@ struct RunSpecTests { #expect(spec.env.filter { $0.key == "PATH" }.map(\.value) == ["/custom"]) } - @Test func runSpecIsCodable() throws { - var spec = RunSpec() + @Test func containerFormStateIsCodable() throws { + var spec = ContainerFormState() spec.image = "redis:7" spec.ports = [PortMap(hostPort: "6379", containerPort: "6379", proto: "tcp")] spec.capAdd = ["CAP_NET_RAW"] let data = try JSONEncoder().encode(spec) - let decoded = try JSONDecoder().decode(RunSpec.self, from: data) - #expect(decoded.arguments() == spec.arguments()) + let decoded = try JSONDecoder().decode(ContainerFormState.self, from: data) + #expect(try arguments(decoded) == (try arguments(spec))) } @Test func editPrefillRestoresAdvancedContainerConfiguration() throws { @@ -390,7 +391,7 @@ struct RunSpecTests { } """ let snapshot = try JSONDecoder().decode(Core.Container.Snapshot.self, from: Data(json.utf8)) - let spec = RunSpec(from: snapshot.configuration) + let spec = ContainerFormState(from: snapshot.configuration) #expect(spec.image == "example/app:1") #expect(spec.platform == "linux/amd64/v2") @@ -438,11 +439,15 @@ struct RunSpecTests { } private var core: Core.Orchestrator { - Core.Orchestrator.testing(runner: RunSpecTestRunner()) + Core.Orchestrator.testing(runner: ContainerFormStateTestRunner()) + } + + private func arguments(_ spec: ContainerFormState) throws -> [String] { + try core.previewCreateCommand(for: spec.document).command } } -private struct RunSpecTestRunner: Core.Command.Running { +private struct ContainerFormStateTestRunner: Core.Command.Running { func run(_ arguments: [String], stdin: Data?, priority: Core.Command.ExecutionPriority) async throws -> Data { diff --git a/changes/unreleased/20260703-foundation-consistency.md b/changes/unreleased/20260703-foundation-consistency.md index 3244432c..b973548c 100644 --- a/changes/unreleased/20260703-foundation-consistency.md +++ b/changes/unreleased/20260703-foundation-consistency.md @@ -7,8 +7,9 @@ ### Runtime & Creation -- Consolidated runtime contracts, the Core orchestrator, the Apple `container` adapter, command previews, typed runtime methods, typed stats streams, Compose translation, image defaults, and runtime-neutral create/import models inside `ContainedCore`. +- Consolidated runtime contracts, the Core orchestrator, the Apple `container` adapter, command previews, typed runtime methods, typed stats streams, Compose translation, image defaults, and runtime-neutral schema/import models inside `ContainedCore`. - Added open-ended runtime identifiers and disabled per-container runtime selection so future Docker-compatible, Podman, Lima-backed, remote, or other adapters can plug into Core without becoming app-side switches. +- Replaced the app-owned Run/Edit request shape with Core-published schema documents that carry generic field paths, Apple/Docker/Compose source aliases, runtime-specific tips, disabled-field explanations, provenance, schema-conformance migration, and Apple `--os`/`--arch` support. - Routed terminal exec, service lifecycle actions, package errors, and reusable-package copy through Core/app-owned presentation boundaries. ### UI & Performance diff --git a/docs/architecture/Architecture.md b/docs/architecture/Architecture.md index 8458238e..3517b34d 100644 --- a/docs/architecture/Architecture.md +++ b/docs/architecture/Architecture.md @@ -55,7 +55,7 @@ bundles, signing, notarization, and appcast scripts. - **`Core.Orchestrator`** — the only backend object app stores own. It bootstraps the Apple CLI today, exposes available runtime descriptors, routes selected-runtime operations, and returns typed command invocations for host-owned UI integrations such as SwiftTerm. - **`Core.Runtime.Kind` / `Core.Runtime.Descriptor` / `Core.Runtime.Capability`** — open runtime identifiers and support metadata. Future engines register descriptors inside Core; the app reads capabilities instead of switching on backend names. -- **`Core.Container.CreateRequest`** — runtime-neutral create/recreate fields used by the app form and adapter import/default translation. It carries the intended runtime per container so the core choice is not app-global. +- **`Core.Schema.Document`** — runtime-neutral run/edit/recreate fields published by Core. Documents carry the intended runtime per container, generic field paths, source aliases, tips, support state, provenance, and validation; Core conforms documents to the selected schema before projecting executable values into `Core.Container.CreateRequest` internally. - **`Core.Compose`** — Core-level interchange semantics for Compose import/export. Yams is internal to `Core.Compose.YAML`; public APIs expose Core models and typed plans, never Yams types. - **`Runtimes/AppleContainer`** — Core-internal Apple adapter implementation. It owns CLI discovery, command execution, Apple create/import/default translation, command builders, and the Apple stats-table parser. - **`Core.Error.PackageError`** — display-neutral error metadata shared by reusable packages. It gives the app a package name, stable code, and context without forcing packages to own localized copy. diff --git a/docs/architecture/Runtime-Adapters.md b/docs/architecture/Runtime-Adapters.md index cc8e03e9..b3fa360a 100644 --- a/docs/architecture/Runtime-Adapters.md +++ b/docs/architecture/Runtime-Adapters.md @@ -32,19 +32,26 @@ before a UI route enables a command. ## Create, Import, Export, And Core Choice -The global Run/Edit form is app-owned form state, but it round-trips through -`Core.Container.CreateRequest`, a runtime-neutral model. Each request carries -its intended runtime, so the core choice is per-container or per-import item -rather than a global app setting. +The global Run/Edit form is app-owned form state, but editable runtime fields +round-trip through `Core.Schema.Document`, a runtime-neutral schema document. +Each document carries its intended runtime, so the core choice is per-container +or per-import item rather than a global app setting. Core translates into and out of the shared model: -- `previewCreateCommand(for:)` returns the command preview for the selected runtime. -- `createContainer(_:)` and `recreateContainer(originalID:request:)` create from shared fields. -- `translateCompose(_:baseDirectory:runtimeKind:)` turns parsed Compose projects into standardized create requests plus warnings. -- `imageDefaults(for:in:)` lets the selected runtime provide image-specific defaults for the same form fields. +- `schemaDefinition(for:runtimeKind:)` publishes the selected runtime's run/edit field metadata. +- `previewCreateCommand(for:)` validates a schema document and returns the command preview for the selected runtime. +- `createContainer(_:)` and `recreateContainer(originalID:document:)` create from schema documents. +- `translateCompose(_:baseDirectory:runtimeKind:)` turns parsed Compose projects into schema documents plus warnings and provenance. +- `imageDefaults(for:in:)` lets the selected runtime provide image-specific defaults for the same schema fields. - `planMigration(_:to:)` and `coreSwitchPlan(for:source:to:)` describe future export/import migration before the app enables a cross-core swap. +Before validation or execution, Core runs schema documents through +`Core.Schema.DocumentMigrator`. The migrator does not require a version ladder: +it compares values with the selected runtime's current schema, maps any +descriptor-published legacy paths, safely coerces simple value-kind drift, and +then lets validation report unresolved unknown or wrong-typed fields. + The UI currently shows Apple container as the only enabled core and disables the picker until another runtime descriptor is registered. The disabled control is intentional: it proves where future Docker-compatible or other adapters will diff --git a/docs/development/Contributing.md b/docs/development/Contributing.md index a1c10791..9d5ec026 100644 --- a/docs/development/Contributing.md +++ b/docs/development/Contributing.md @@ -68,7 +68,7 @@ Xcode/Contained/ tiny native Xcode app launcher and Info.plist Contained.xcworkspace/ Xcode entry point Contained.xcodeproj/ native macOS app target that links ContainedApp Packages/*/Tests/ package-local unit tests -Tests/ContainedAppTests/ RunSpec form state + runtime mapping +Tests/ContainedAppTests/ ContainerFormState form state + runtime mapping scripts/ bundle.sh, release.sh, appcast.sh docs/app/ user-facing app docs docs/features/ feature and workflow docs @@ -102,7 +102,7 @@ appcast.xml Sparkle feed at the root of each release branch - **Use Xcode for functional SwiftUI loops.** The shared `Contained` scheme builds/runs the app and runs `ContainedAppTests`; `ContainedAppTests` is the focused app-test scheme; package schemes come from the package manifests; `ContainedPreviews` is reserved for preview-oriented development. - **Navigation infrastructure belongs in `ContainedUX` only when it is generic.** App sections, pending actions, concrete toolbar panels, and `UIState` stay in `Sources/ContainedApp` until they can cross the boundary without app policy. - **Every backend action goes through `ContainedCore`.** Apple `container` argv builders and adapter clients are Core internals with golden tests. The UI never assembles argv inline; app stores call `Core.Orchestrator`. -- **Runtime-facing code should use `Core.*` namespaces.** The Apple `container` implementation is the only enabled adapter today. Future Docker-compatible, Podman, Lima-backed, remote, or other runtimes should be sibling adapter folders inside Core and advertise capability differences through `Core.Runtime.Descriptor`. Create/import flows should translate through `Core.Container.CreateRequest` and carry `Core.Runtime.Kind` per container, not as a global app setting. +- **Runtime-facing code should use `Core.*` namespaces.** The Apple `container` implementation is the only enabled adapter today. Future Docker-compatible, Podman, Lima-backed, remote, or other runtimes should be sibling adapter folders inside Core and advertise capability differences through `Core.Runtime.Descriptor`. Run/edit/import flows should translate through `Core.Schema.Document` and carry `Core.Runtime.Kind` per container, not as a global app setting. - **Pure decision logic is factored into `ContainedCore`** (`Core.Container.RestartDecision`, `Core.Container.HealthDecision`, compose ordering, runtime translation) and unit-tested without spawning processes. - **No `contained.*` personalization labels.** Card styles and healthchecks live in local stores. Only `contained.restart` and `contained.stack` are written (they must round-trip through the container). - **Never put secrets or personal data in test fixtures.** Fixtures are captured CLI output — scrub tokens, domains, and paths before committing. (`.gitignore` blocks signing material; push protection is on.) diff --git a/docs/features/Compose-Import.md b/docs/features/Compose-Import.md index df7a8ad4..1399a8e3 100644 --- a/docs/features/Compose-Import.md +++ b/docs/features/Compose-Import.md @@ -18,9 +18,11 @@ Compose import can start from: Compose import fills one or more editable [Run / Edit Form](/docs/features/Run-Edit-Form.md) entries instead of launching an opaque stack. The selected runtime adapter -translates parsed services into standardized create fields, then the app fills -the global Run/Edit form. Services with images become prefilled runs; -unsupported or ambiguous fields produce warnings. +translates parsed services into Core schema documents, then the app fills the +global Run/Edit form. Services with images become prefilled runs. Supported +fields become executable Apple container settings, while known Docker/Compose +fields Apple container cannot execute are preserved as disabled typed values +with source references and explanations. Important translations: @@ -32,6 +34,10 @@ Important translations: - `restart: unless-stopped` normalizes to Contained's app-managed Always policy - healthchecks become app-managed health checks +Known future-runtime fields such as `pull_policy`, `extra_hosts`, `privileged`, +`security_opt`, `volumes_from`, `secrets`, `deploy`, and `use_api_socket` stay +visible in the Docker & Compose section instead of disappearing. + Target-only ports such as `8080` are skipped with a warning because they do not provide a host port to publish for this runtime. @@ -42,6 +48,7 @@ CLI preview stays visible, and unsupported values are reported rather than silently guessed. Runtime-specific import rules belong in the adapter. Apple container currently -owns the Compose-to-`Core.Container.CreateRequest` translation; future Docker-compatible -or other adapters should return the same standardized fields with their own -warnings and unsupported-operation plans. +owns the Compose-to-schema translation and projects executable documents to +`Core.Container.CreateRequest` internally; future Docker-compatible or other +adapters should return the same generic schema fields with their own warnings +and unsupported-operation plans. diff --git a/docs/features/Run-Edit-Form.md b/docs/features/Run-Edit-Form.md index 303dad2c..bdc3eb94 100644 --- a/docs/features/Run-Edit-Form.md +++ b/docs/features/Run-Edit-Form.md @@ -1,8 +1,9 @@ # Run / Edit Form -The Run / Edit form is intentionally UI-first. It fills a runtime-neutral create -request with controls that feel native on macOS, while the selected runtime -adapter provides the live command preview for exactly what will execute. +The Run / Edit form is intentionally UI-first. It edits a Core-published +runtime-neutral schema document with controls that feel native on macOS, while +the selected runtime adapter validates the document and provides the live +command preview for exactly what will execute. ## Design rule @@ -27,7 +28,7 @@ strings, paths, names, or raw specs. The form uses native grouped macOS settings-style sections rather than mirroring CLI flag order: -- Essentials: core, image, platform, name, command, and basic run behavior +- Essentials: core, image, platform/OS/architecture, name, command, and basic run behavior - Resources: CPU and memory limits - Networking: published ports, network attachment, and socket forwarding - Storage: volume mounts @@ -36,10 +37,19 @@ CLI flag order: - Appearance: local-only card personalization - Advanced Options: one toggle for process internals, security/runtime switches, DNS, raw mounts, and labels +- Docker & Compose: read-only, disabled fields preserved from Docker CLI or + Compose imports for future runtimes The common sections stay visible. Compose import and Edit open Advanced Options when they contain advanced values, so imported settings are not lost just because -they live behind the toggle. +they live behind the toggle. If Compose import provides a known field Apple +container cannot execute, the form keeps it as a typed disabled value with a +runtime-specific explanation instead of discarding it. + +Each runtime-editable control gets its info text from the Core field descriptor +for the selected runtime. The app resolves the descriptor's localized text key, +then appends source aliases and examples such as the Apple `container` flag, +Docker CLI flag, or Compose service key when Core publishes one. The presentation path is shared. With toolbar panel navigation enabled, run and edit open in the creation morph from their measured toolbar origin. With it @@ -52,8 +62,9 @@ These controls deliberately do not mirror the CLI one-to-one: | UI control | CLI output | Notes | | --- | --- | --- | -| Core picker | runtime adapter selection | Apple container is currently the only available core, so the picker is disabled. Future adapters fill the same form fields through `Core.Container.CreateRequest` instead of adding backend-specific forms. | -| Platform picker | `--platform ` | The UI offers common Apple-silicon presets plus Custom. Separate `--os` and `--arch` controls are omitted to avoid duplicate platform concepts because `--platform` takes precedence. | +| Core picker | runtime adapter selection | Apple container is currently the only available core, so the picker is disabled. Future adapters publish the same generic schema paths instead of adding backend-specific forms. | +| Platform picker | `--platform ` | The UI offers common Apple-silicon presets plus Custom. | +| Image OS and architecture | `--os ` / `--arch ` | Separate generic fields for Apple container's split image selectors. `--platform` takes precedence when both are present. | | Memory limit toggle + slider | `--memory ` | The user chooses a host-bounded amount; the app formats it as `M` or `G`. | | Shared memory toggle + slider | `--shm-size ` | Same UI pattern as memory, with a small default of `64M`. | | Registry scheme picker | `--scheme auto\|https\|http` | Empty means runtime default. | @@ -83,10 +94,11 @@ the app should not pretend to know every valid shape: ## Compose import behavior Compose import follows the same UI-first rule: it asks the selected runtime -adapter to translate parsed Compose services into standardized create fields, -then fills editable Run forms rather than directly launching an opaque stack. -The importer maps every Compose service field that has a Run form equivalent and -warns for fields it cannot safely translate. +adapter to translate parsed Compose services into schema documents, then fills +editable Run forms rather than directly launching an opaque stack. The importer +maps every Compose service field that has an executable Run form equivalent, +preserves known Docker/Compose-only fields as disabled schema values, and warns +for fields it cannot safely translate. Important translations: @@ -99,7 +111,10 @@ Important translations: policy because user-initiated stops are already suppressed by the watchdog - healthchecks become app-managed health checks -Unsupported or ambiguous Compose shapes are skipped with warnings instead of -creating partial form rows that would block Run. For example, a target-only port -like `8080` has no host port to publish for this runtime, so it is reported and -not added as an incomplete port row. +Known unsupported fields such as pull policy, extra hosts, privileged/security +options, volumes-from, secrets, deploy metadata, and API-socket metadata render +read-only with their original Docker CLI or Compose source references. Ambiguous +Compose shapes are still skipped with warnings instead of creating partial form +rows that would block Run. For example, a target-only port like `8080` has no +host port to publish for this runtime, so it is reported and not added as an +incomplete port row. From 0ab9eb5d913bdb86777cd97f4a5fd6c7f6ca2ac9 Mon Sep 17 00:00:00 2001 From: tdeverx Date: Fri, 3 Jul 2026 10:33:50 +0100 Subject: [PATCH 45/53] Add runtime-first database and Docker runtime --- AGENTS.md | 17 +- CHANGELOG.md | 2 +- Packages/ContainedCore/Package.swift | 4 +- Packages/ContainedCore/README.md | 29 +- .../Command/PreviewBuilders.swift | 45 +- .../ContainedCore/Compose/YAML/Project.swift | 6 +- .../ContainedCore.docc/ContainedCore.md | 12 +- .../Container/CreateRequest.swift | 38 + .../ContainedCore/Container/Snapshot.swift | 112 + .../ContainedCore/Image/Image+Resource.swift | 20 + .../ContainedCore/Image/LocalTagGroup.swift | 91 +- .../Localization/CoreLocalization.swift | 75 + .../Sources/ContainedCore/Metrics/Stats.swift | 24 + .../Network/Network+Resource.swift | 38 + .../Orchestration/Orchestrator.swift | 522 +- .../Registry/ImageReference.swift | 2 +- .../ContainedCore/Registry/Login.swift | 24 +- .../Resources/en.lproj/Localizable.strings | 12 + .../Runtime/ContainerRuntimeMigration.swift | 151 + .../ContainedCore/Runtime/Descriptor.swift | 24 + .../Sources/ContainedCore/Runtime/Kind.swift | 39 +- .../Runtimes/Docker/Client/DockerClient.swift | 328 + .../Runtimes/Docker/Client/DockerModels.swift | 666 ++ .../Docker/Command/DockerCommands.swift | 221 + .../Docker/Support/DockerCLILocator.swift | 27 + .../Translation/DockerCreateTranslator.swift | 187 + .../Schema/ContainerDocumentTranslation.swift | 78 +- .../Schema/ContainerFieldCatalog.swift | 81 +- .../ContainedCore/System/Properties.swift | 10 + .../Volume/Volume+Resource.swift | 32 + .../ContainedCoreTests/DecodingTests.swift | 86 + .../ImageWorkflowTests.swift | 34 + .../LocalizationTests.swift | 33 + .../Runtime/RuntimeDescriptorTests.swift | 92 +- .../RuntimeWorkflowTests.swift | 82 + .../AppleContainer/AdapterTests.swift | 8 +- .../ContainedCoreTests/SchemaTests.swift | 37 +- Packages/ContainedUI/README.md | 11 +- .../Sources/ContainedUI/Action/Buttons.swift | 16 + .../Sources/ContainedUI/Action/Groups.swift | 28 + .../Sources/ContainedUI/Action/Item.swift | 10 + .../ContainedUI/Action/SelectionBar.swift | 10 + .../ContainedUI/Card/Card+Scaffold.swift | 61 + .../Sources/ContainedUI/Card/Chrome.swift | 101 +- .../Sources/ContainedUI/Card/Surface.swift | 71 +- .../ContainedUI/Card/ViewModifiers.swift | 18 + .../ContainedUI/Chart/MetricTile.swift | 9 + .../Sources/ContainedUI/Chart/Sparkline.swift | 19 + .../Chart/SparklineMetricTile.swift | 18 + .../ContainedUI/Command/PreviewBar.swift | 8 + .../Console/ConsoleViewModifiers.swift | 9 + .../ContainedUI/Console/StreamConsole.swift | 18 + .../ContainedUI.docc/ContainedUI.md | 8 + .../Sources/ContainedUI/Control/Chips.swift | 35 +- .../Control/ControlViewModifiers.swift | 14 + .../Control/GradientAngleControl.swift | 14 + .../ContainedUI/Control/InfoButton.swift | 8 + .../ContainedUI/Control/KeyboardHints.swift | 9 + .../ContainedUI/Control/ListControls.swift | 81 +- .../ContainedUI/Control/MaterialButton.swift | 21 + .../ContainedUI/Control/OptionTile.swift | 14 + .../Sources/ContainedUI/Control/RowMenu.swift | 8 + .../ContainedUI/Control/SearchField.swift | 17 + .../ContainedUI/Control/TintSelector.swift | 47 +- .../ContainedUI/Control/ToolbarControls.swift | 38 + .../ContainedUI/Panel/PageScaffold.swift | 13 + .../ContainedUI/Panel/Panel+Scaffold.swift | 23 + .../Sources/ContainedUI/Panel/Section.swift | 61 +- .../ContainedUI/Panel/SheetTitleBar.swift | 13 + .../Sources/ContainedUI/Panel/TitleBar.swift | 11 + .../ContainedUI/Previews/UIPreviews.swift | 89 - .../ContainedUI/Shared/CapsuleLabel.swift | 37 + .../ContainedUI/Shared/IconTextRow.swift | 77 + .../ContainedUI/Shared/PanelLabel.swift | 41 + .../ContainedUI/Shared/SurfaceRendering.swift | 22 + .../ContainedUI/Shared/TintSwatchMark.swift | 47 + .../State/ActivityStatusView.swift | 20 + .../Sources/ContainedUI/State/Badges.swift | 22 +- .../ContainedUI/State/ContentStates.swift | 36 + .../ContainedUI/State/ErrorBanner.swift | 6 + .../ContainedUI/State/StatusBanner.swift | 5 + .../ContainedUI/Surface/ContentSurface.swift | 10 + .../ContainedUI/Surface/ExteriorShadow.swift | 13 + .../ContainedUI/Surface/InputSurface.swift | 13 + .../ContainedUI/Surface/MaterialSurface.swift | 62 +- .../Surface/VisualEffectBackground.swift | 10 + .../Sources/ContainedUI/Theme/Theme.swift | 24 + Packages/ContainedUX/README.md | 7 +- .../ContainedUX.docc/ContainedUX.md | 4 + .../Measurement/SourceFrameReader.swift | 28 + .../Sources/ContainedUX/Morph/Expander.swift | 66 + .../ContainedUX/Morph/SingleSurface.swift | 81 + .../ContainedUX/Panel/BackdropStyle.swift | 15 + .../Sources/ContainedUX/Panel/Placement.swift | 16 + .../Previews/NavigationPreviews.swift | 57 - .../ContainedUX/SafeArea/Manager.swift | 29 + .../App/AppModel+Configuration.swift | 10 +- .../App/AppModel+ImageUpdates.swift | 45 +- .../App/AppModel+ResourceStyles.swift | 6 +- Sources/ContainedApp/App/AppModel.swift | 442 +- Sources/ContainedApp/App/ContainedApp.swift | 14 +- .../Features/Activity/ActivityView.swift | 20 +- .../Activity/ContainerHistoryTab.swift | 8 +- .../Containers/Card/ContainersGridView.swift | 152 +- .../Containers/Creation/ComposeImport.swift | 42 +- .../Containers/Creation/CreationFlow.swift | 111 +- .../Containers/Creation/CreationPages.swift | 47 +- .../Customization/CustomizeSheet.swift | 16 +- .../Details/ContainerConfigureView.swift | 24 +- .../Containers/Details/FilesTab.swift | 11 +- .../Features/Containers/Details/LogsTab.swift | 7 +- .../Containers/Details/StatsTab.swift | 6 +- .../Containers/Details/TerminalTab.swift | 6 +- .../Containers/Form/ContainerFormState.swift | 6 + .../Containers/Form/ContainerSchemaForm.swift | 12 +- .../Containers/Store/ContainersStore.swift | 87 +- .../Containers/Store/RestartWatchdog.swift | 4 +- .../Images/Build/BuildWorkspaceView.swift | 23 +- .../Images/Registry/RegistryImageSearch.swift | 5 +- .../Images/Style/ImageStyleButton.swift | 2 +- .../Features/Onboarding/BootstrapView.swift | 29 +- .../Features/Palette/CommandPalette.swift | 69 +- .../Registries/RegistryLoginSheet.swift | 24 +- .../Runtime/RuntimeSelectionSheet.swift | 139 + .../Settings/Store/SettingsBackup.swift | 8 +- .../Settings/Store/SettingsStore.swift | 192 +- .../Features/Settings/Tabs/AboutTab.swift | 6 +- .../Settings/Tabs/RegistriesTab.swift | 16 +- .../Features/Settings/Tabs/RuntimeTab.swift | 182 +- .../System/Components/SystemLogsSheet.swift | 4 +- .../Components/SystemVolumeInventory.swift | 4 +- .../Features/System/SystemView.swift | 85 +- .../Migration/StateMigrator.swift | 7 +- .../Navigation/MenuBar/MenuBarContent.swift | 20 +- .../Navigation/Shell/ClassicShell.swift | 2 +- .../Navigation/Shell/RootView.swift | 33 +- .../Navigation/State/UIState.swift | 24 +- .../Panels/ToolbarCommandPalette.swift | 29 +- .../Panels/ToolbarImageGroupCard.swift | 122 +- .../Panels/ToolbarResourcePanels.swift | 20 +- .../Toolbar/ToolbarViewOptions.swift | 10 +- .../AppDatabase+RuntimeInventory.swift | 315 + .../Persistence/AppDatabase/AppDatabase.swift | 145 + .../AppDatabase/AppDatabaseModels.swift | 283 + .../RecipeSupport.swift} | 46 +- .../Persistence/History/HistoryStore.swift | 60 +- .../PersonalizationStore.swift | 86 +- .../Localization/AppLocalization.swift | 12 +- Sources/ContainedApp/Resources/CHANGELOG.md | 2 +- .../Resources/Localizable.xcstrings | 6569 ++++++++++++++--- .../Services/Health/HealthMonitor.swift | 49 +- .../ContainedAppTests/AppDatabaseTests.swift | 120 + Tests/ContainedAppTests/AppLoggingTests.swift | 22 +- .../ContainerFormStateTests.swift | 7 +- .../ContainerRuntimeMigrationTests.swift | 219 + .../ContainersStoreRefreshTests.swift | 78 + Tests/ContainedAppTests/MigrationTests.swift | 5 +- .../20260703-docker-runtime-core.md | 11 + .../20260703-foundation-consistency.md | 4 +- docs/app/Localization.md | 26 +- docs/app/System-Settings.md | 25 +- docs/architecture/Architecture.md | 25 +- docs/architecture/Design-System.md | 26 +- docs/architecture/Runtime-Adapters.md | 42 +- docs/development/Contributing.md | 17 +- docs/features/Compose-Import.md | 24 +- docs/features/Containers.md | 16 +- docs/features/Features.md | 2 +- docs/features/Images.md | 17 +- docs/features/Run-Edit-Form.md | 4 +- docs/wiki/File-Map.md | 2 +- docs/wiki/_Sidebar.md | 2 +- 172 files changed, 13187 insertions(+), 2173 deletions(-) create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Localization/CoreLocalization.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Resources/en.lproj/Localizable.strings create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Runtime/ContainerRuntimeMigration.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Client/DockerClient.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Client/DockerModels.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Command/DockerCommands.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Support/DockerCLILocator.swift create mode 100644 Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Translation/DockerCreateTranslator.swift create mode 100644 Packages/ContainedCore/Tests/ContainedCoreTests/LocalizationTests.swift delete mode 100644 Packages/ContainedUI/Sources/ContainedUI/Previews/UIPreviews.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Shared/CapsuleLabel.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Shared/IconTextRow.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Shared/PanelLabel.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Shared/SurfaceRendering.swift create mode 100644 Packages/ContainedUI/Sources/ContainedUI/Shared/TintSwatchMark.swift delete mode 100644 Packages/ContainedUX/Sources/ContainedUX/Previews/NavigationPreviews.swift create mode 100644 Sources/ContainedApp/Features/Runtime/RuntimeSelectionSheet.swift create mode 100644 Sources/ContainedApp/Persistence/AppDatabase/AppDatabase+RuntimeInventory.swift create mode 100644 Sources/ContainedApp/Persistence/AppDatabase/AppDatabase.swift create mode 100644 Sources/ContainedApp/Persistence/AppDatabase/AppDatabaseModels.swift rename Sources/ContainedApp/Persistence/{History/Template.swift => AppDatabase/RecipeSupport.swift} (91%) create mode 100644 Tests/ContainedAppTests/AppDatabaseTests.swift create mode 100644 Tests/ContainedAppTests/ContainerRuntimeMigrationTests.swift create mode 100644 changes/unreleased/20260703-docker-runtime-core.md diff --git a/AGENTS.md b/AGENTS.md index c9e45795..b46138d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,8 @@ This file is the working contract for coding agents in this repository. Follow i - Local reusable packages live under `Packages/` and are consumed by the root SwiftPM package and the native Xcode app target. - `Contained.xcworkspace` is the Xcode entry point. `Contained.xcodeproj` contains a native macOS app target that links the `ContainedApp` package product and builds/runs `Contained.app` directly from Xcode. - SwiftPM remains the CI, release, packaging, signing, notarization, and appcast source of truth. Keep `Package.swift`, `swift build`, `swift test`, and `scripts/bundle.sh` working. -- `Packages/ContainedCore/Sources/ContainedCore` is the single backend/orchestration package. It owns pure models, runtime descriptors/capabilities, command execution, Compose import/export semantics, Apple `container` adapter internals, metrics, import/export planning, and typed display-neutral package errors. Keep SwiftUI, app state, Sparkle, SwiftTerm, localization resources, and persistence out of it. -- `ContainedCore` exposes app-facing backend APIs through `Core.*` namespaces. `Core.Orchestrator` is the only backend object the app should own. Runtime adapters live inside Core under adapter folders so future Docker-compatible, Podman, Lima-backed, remote, or other engines can plug in without becoming app switches. +- `Packages/ContainedCore/Sources/ContainedCore` is the single backend/orchestration package. It owns pure models, runtime descriptors/capabilities, command execution, Compose import/export semantics, Apple `container` adapter internals, metrics, import/export planning, display-neutral semantic localization, and typed display-neutral package errors. Keep SwiftUI, app state, Sparkle, SwiftTerm, product UI localization, and persistence out of it. +- `ContainedCore` exposes app-facing backend APIs through `Core.*` namespaces. `Core.Orchestrator` is the only backend object the app should own. Runtime adapters live inside Core under adapter folders so Docker, Podman, Lima-backed, remote, or other runtimes can plug in without becoming app switches. - `Sources/ContainedApp` is the app implementation: SwiftUI screens, app-specific presentation mappings, navigation, stores, history, settings, localization, and update support. - `Sources/Contained` is only the tiny SwiftPM executable launcher. - `Packages/ContainedUI` is the reusable SwiftUI/AppKit design-system package. Keep app state, stores, Sparkle, SwiftData, persistence, and feature routing out of it. @@ -65,11 +65,14 @@ This file is the working contract for coding agents in this repository. Follow i - Keep Apple `container` CLI actions behind `ContainedCore` adapter internals and Core command-preview routes; do not assemble argv inline in SwiftUI. App stores should call `Core.Orchestrator`, not adapter clients or runtime protocols. - Put pure decision logic and backend orchestration in `ContainedCore` with focused tests. -- Keep localization owned by `Sources/ContainedApp`. Packages should receive - app-supplied labels/help/accessibility strings and should not add English UI - defaults or localized resource bundles. Use `AppText` for reusable app copy - and dynamic templates; plain SwiftUI literals are acceptable when SwiftUI keeps - them localization-ready. +- Keep product-facing localization owned by `Sources/ContainedApp`. `ContainedUI` + and `ContainedUX` should receive app-supplied labels/help/accessibility strings + and should not add English UI defaults or localized resource bundles. Use + `AppText` for reusable app copy and dynamic templates; plain SwiftUI literals + are acceptable when SwiftUI keeps them localization-ready. `ContainedCore` may + own display-neutral semantic localization for schema labels/help, validation + messages, runtime capability reasons, Compose/projection warnings, and typed + package-error fallback descriptions. - Keep package errors display-neutral. Reusable targets should throw typed errors with stable codes/context, preferably `Core.Error.PackageError`, while `Sources/ContainedApp` maps them through `AppErrorPresentation`/`AppText` before diff --git a/CHANGELOG.md b/CHANGELOG.md index 8802b46d..5f517a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ ### System, Settings & Activity - Bootstrap states for missing CLI, unsupported CLI version, stopped service, and ready service. -- System page for engine/service state, resource usage, background work, volume inventory, runtime defaults, system logs, and prune/service lifecycle actions. +- System page for runtime/service state, resource usage, background work, volume inventory, system logs, and runtime-scoped prune/service lifecycle actions. - Settings tabs for General, Appearance, Runtime, Registries, Experimental, Updates, and About. - Configurable appearance, tint, materials, card density, menu-bar behavior, CLI previews, info tips, logging, update cadence, image update cadence, and experimental feature gates. - Activity history for lifecycle, image, compose, system, registry, pull, build, watchdog, healthcheck, alert, and UI events, including unread state, filtering, copy/delete actions, and clear controls. diff --git a/Packages/ContainedCore/Package.swift b/Packages/ContainedCore/Package.swift index 0a4e4b7c..878f8475 100644 --- a/Packages/ContainedCore/Package.swift +++ b/Packages/ContainedCore/Package.swift @@ -3,6 +3,7 @@ import PackageDescription let package = Package( name: "ContainedCore", + defaultLocalization: "en", platforms: [.macOS(.v26)], products: [ .library(name: "ContainedCore", targets: ["ContainedCore"]), @@ -14,7 +15,8 @@ let package = Package( targets: [ .target( name: "ContainedCore", - dependencies: [.product(name: "Yams", package: "Yams")] + dependencies: [.product(name: "Yams", package: "Yams")], + resources: [.process("Resources")] ), .target( name: "ContainedCoreFixtures", diff --git a/Packages/ContainedCore/README.md b/Packages/ContainedCore/README.md index e7de8d25..1e47dfcc 100644 --- a/Packages/ContainedCore/README.md +++ b/Packages/ContainedCore/README.md @@ -1,18 +1,18 @@ # ContainedCore `ContainedCore` is Contained's standalone backend/orchestration package. It -contains no SwiftUI, app state, Sparkle, SwiftTerm, localization resources, -persistence, or presentation policy. +contains no SwiftUI, app state, Sparkle, SwiftTerm, app persistence, or +product-specific presentation policy. ## Owns - `Core.Orchestrator`, the app-facing backend facade. -- `Core.Runtime` descriptors, capabilities, selected-runtime checks, and typed +- `Core.Runtime` descriptors, capabilities, runtime-scoped checks, and typed unsupported-operation errors. - `Core.Container` semantic create/edit/import/export models. - `Core.Compose` import/export plans and Compose YAML parsing/writing internals. - `Core.Command` command previews, command execution, and host invocations. -- Core-internal runtime adapters, beginning with Apple container. +- Core-internal runtime adapters for Apple container and Docker CLI runtimes. - Metrics, stats normalization, decoded resources, registry helpers, and display-neutral package errors. - A separate `ContainedCoreFixtures` product for deterministic semantic samples @@ -20,10 +20,16 @@ persistence, or presentation policy. ## Does Not Own -- Localized strings or user-facing copy. +- Product navigation, onboarding, toasts, alerts, and other app-owned copy. - SwiftUI views, app routing, settings, stores, or Activity presentation. - UI/UX packages, Sparkle, SwiftTerm, SwiftData, or app persistence. +`ContainedCore` may own display-neutral semantic localization for schema labels, +validation messages, runtime capability reasons, and typed package-error fallback +descriptions. Downstream consumers can override or wrap those strings; Contained +uses the Core-owned semantic strings directly because the app and package live in +the same repo. + ## Runtime Example ```swift @@ -31,20 +37,21 @@ import ContainedCore let result = await Core.Orchestrator.bootstrap( configuration: Core.Configuration( - appleContainer: .init(cliPathOverride: nil) + appleContainer: .init(cliPathOverride: nil), + docker: .init(cliPathOverride: nil) ) ) let core: Core.Orchestrator switch result { -case .ready(let orchestrator, _, _), - .unsupported(let orchestrator, _, _): +case .ready(let orchestrator, _): core = orchestrator case .cliMissing: throw Core.Error.Command.cliNotFound(searched: ["PATH"]) } let descriptors = core.availableRuntimeDescriptors +let containers = try await core.listRuntimeContainers(all: true) ``` ## Create Preview Example @@ -63,7 +70,9 @@ let command = preview.command ```swift let project = try Core.Compose.parse(composeText, projectName: "stack") -let plan = try core.translateCompose(project, baseDirectory: composeDirectory) +let plan = try core.translateCompose(project, + baseDirectory: composeDirectory, + runtimeKind: .docker) let documents = plan.items.map(\.document) ``` @@ -84,7 +93,7 @@ let document = Core.Container.Document( canonical: .init(createRequest: request) ) -let plan = try core.planMigration(document, to: .dockerCompatible) +let plan = try core.planMigration(document, to: .docker) if !plan.isAvailable { // The app maps the typed reason/context to localized Activity or alert copy. } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift index 930b2d98..82473142 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift @@ -10,25 +10,44 @@ public extension Core.Command { dockerfile: String? = nil, buildArgs: [String: String] = [:], noCache: Bool = false, - platform: String? = nil) -> [String] { - ContainerCommands.build(context: context, - tag: tag, - dockerfile: dockerfile, - buildArgs: buildArgs, - noCache: noCache, - platform: platform) + platform: String? = nil, + runtimeKind: Core.Runtime.Kind = .appleContainer) -> [String] { + if runtimeKind == .docker { + return DockerCommands.build(context: context, + tag: tag, + dockerfile: dockerfile, + buildArgs: buildArgs, + noCache: noCache, + platform: platform) + } + return ContainerCommands.build(context: context, + tag: tag, + dockerfile: dockerfile, + buildArgs: buildArgs, + noCache: noCache, + platform: platform) } static func networkCreatePreview(name: String, subnet: String? = nil, - internalOnly: Bool = false) -> [String] { - ContainerCommands.networkCreate(name: name, - subnet: subnet, - internalOnly: internalOnly) + internalOnly: Bool = false, + runtimeKind: Core.Runtime.Kind = .appleContainer) -> [String] { + if runtimeKind == .docker { + return DockerCommands.networkCreate(name: name, + subnet: subnet, + internalOnly: internalOnly) + } + return ContainerCommands.networkCreate(name: name, + subnet: subnet, + internalOnly: internalOnly) } static func volumeCreatePreview(name: String, - size: String? = nil) -> [String] { - ContainerCommands.volumeCreate(name: name, size: size) + size: String? = nil, + runtimeKind: Core.Runtime.Kind = .appleContainer) -> [String] { + if runtimeKind == .docker { + return DockerCommands.volumeCreate(name: name, size: size) + } + return ContainerCommands.volumeCreate(name: name, size: size) } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift index 59525af8..1b8fe8df 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/Project.swift @@ -31,6 +31,7 @@ struct Service: Sendable, Hashable, Identifiable { public let labels: [String] // "KEY=value" public let restart: String? public let network: String? + public let networkMode: String? public let readOnly: Bool public let initProcess: Bool public let interactive: Bool @@ -52,7 +53,7 @@ struct Service: Sendable, Hashable, Identifiable { entrypoint: String? = nil, workingDir: String? = nil, user: String? = nil, cpus: String? = nil, memory: String? = nil, ports: [String], volumes: [String], environment: [String], envFiles: [String] = [], labels: [String] = [], restart: String?, - network: String? = nil, readOnly: Bool = false, initProcess: Bool = false, + network: String? = nil, networkMode: String? = nil, readOnly: Bool = false, initProcess: Bool = false, interactive: Bool = false, tty: Bool = false, capAdd: [String] = [], capDrop: [String] = [], dns: [String] = [], dnsSearch: [String] = [], dnsOptions: [String] = [], tmpfs: [String] = [], ulimits: [String] = [], @@ -62,7 +63,7 @@ struct Service: Sendable, Hashable, Identifiable { self.entrypoint = entrypoint; self.workingDir = workingDir; self.user = user; self.cpus = cpus self.memory = memory; self.ports = ports; self.volumes = volumes; self.environment = environment self.envFiles = envFiles - self.labels = labels; self.restart = restart; self.network = network; self.readOnly = readOnly + self.labels = labels; self.restart = restart; self.network = network; self.networkMode = networkMode; self.readOnly = readOnly self.initProcess = initProcess; self.interactive = interactive; self.tty = tty self.capAdd = capAdd; self.capDrop = capDrop; self.dns = dns; self.dnsSearch = dnsSearch self.dnsOptions = dnsOptions; self.tmpfs = tmpfs; self.ulimits = ulimits @@ -205,6 +206,7 @@ enum Parser { labels: keyValues(body["labels"]), restart: restart(body["restart"]), network: network(mode: body["network_mode"], networks: body["networks"]), + networkMode: stringValue(body["network_mode"]), readOnly: body["read_only"] as? Bool ?? false, initProcess: body["init"] as? Bool ?? false, interactive: body["stdin_open"] as? Bool ?? false, diff --git a/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md b/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md index 380704d1..c33fccba 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md +++ b/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md @@ -7,13 +7,14 @@ interchange, metrics, and display-neutral errors for Contained. `ContainedCore` exposes a nested `Core.*` API, matching the `UI.*` and `UX.*` package style. App code talks to ``Core/Orchestrator``. Runtime adapters live -inside Core, beginning with the Apple container adapter, so the app does not -create adapter clients or assemble backend argv. +inside Core, including Apple container and Docker CLI adapters, so +the app does not create adapter clients or assemble backend argv. Use Core for: - runtime descriptors and capabilities - canonical container create/edit/import/export models +- multi-runtime container and image inventory - command previews and host command invocations - Compose import/export plans - run/edit schema conformance before validation and execution @@ -22,8 +23,11 @@ Use Core for: - typed display-neutral package errors `ContainedCore` does not import SwiftUI, Sparkle, SwiftTerm, ContainedUI, or -ContainedUX. It owns no localized resources; the app maps Core errors and -technical identifiers to user-facing copy. +ContainedUX. It may own display-neutral semantic localization for schema +labels/help, validation messages, runtime capability reasons, Compose or +projection warnings, and typed package-error fallback descriptions. The app +still maps Core errors and technical identifiers into product-specific copy +where presentation policy matters. Deterministic dev/test samples live in the separate `ContainedCoreFixtures` product. Import that product only from tests, previews, or sandbox-only targets; diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift index 89b8fe10..bdd4194b 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift @@ -181,6 +181,44 @@ struct CreateRequest: Codable, Equatable, Sendable { public var scheme = "" public var progress = "" public var maxConcurrentDownloads = "" + public var extraHosts: [String] = [] + public var hostname = "" + public var domainName = "" + public var macAddress = "" + public var expose: [String] = [] + public var publishAll = false + public var pullPolicy = "" + public var attachStreams: [String] = [] + public var loggingDriver = "" + public var loggingOptions: [Core.Container.KeyValue] = [] + public var labelFiles: [String] = [] + public var stopSignal = "" + public var stopGracePeriod = "" + public var devices: [String] = [] + public var gpus = "" + public var supplementalGroups: [String] = [] + public var privileged = false + public var securityOptions: [String] = [] + public var sysctls: [Core.Container.KeyValue] = [] + public var cgroupNamespace = "" + public var userNamespace = "" + public var pidNamespace = "" + public var ipcNamespace = "" + public var utsNamespace = "" + public var cpuShares = "" + public var cpuQuota = "" + public var cpuPeriod = "" + public var cpuSet = "" + public var cpuRealtimeRuntime = "" + public var cpuRealtimePeriod = "" + public var memoryReservation = "" + public var memorySwapLimit = "" + public var memorySwappiness = "" + public var oomKillDisable = false + public var oomScoreAdjust = "" + public var blockIOWeight = "" + public var storageOptions: [Core.Container.KeyValue] = [] + public var volumesFrom: [String] = [] public init() {} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift index 83c3c688..a6c1afe1 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift @@ -9,16 +9,43 @@ struct Snapshot: Codable, Sendable, Identifiable, Hashable { public let configuration: Core.Container.Configuration public let id: String public let status: Core.Container.RuntimeState + public let runtimeKind: Core.Runtime.Kind public var state: Core.Runtime.Status { status.state } public var image: String { configuration.image.reference } public var startedDate: Date? { status.startedDate } + public var scopedID: String { runtimeKind.scopedID(for: id) } /// Functional restart policy label consumed by the app-managed watchdog. public var restartLabel: String? { configuration.labels["contained.restart"] } public var displayName: String { id } + public init(configuration: Core.Container.Configuration, + id: String, + status: Core.Container.RuntimeState, + runtimeKind: Core.Runtime.Kind = .appleContainer) { + self.configuration = configuration + self.id = id + self.status = status + self.runtimeKind = runtimeKind + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + configuration = try c.decode(Core.Container.Configuration.self, forKey: .configuration) + id = try c.decode(String.self, forKey: .id) + status = try c.decode(Core.Container.RuntimeState.self, forKey: .status) + runtimeKind = try c.decodeIfPresent(Core.Runtime.Kind.self, forKey: .runtimeKind) ?? configuration.runtimeKind + } + + public func scoped(to runtimeKind: Core.Runtime.Kind) -> Core.Container.Snapshot { + Core.Container.Snapshot(configuration: configuration.scoped(to: runtimeKind), + id: id, + status: status, + runtimeKind: runtimeKind) + } + /// A synthetic snapshot for previews and image-level customization (styling an image's default /// before any container from it exists). Encodes a minimal payload first so unusual image or /// volume names are escaped safely before decoding through the same defaults as real snapshots. @@ -87,6 +114,7 @@ struct NetworkInterfaceStatus: Codable, Sendable, Hashable { /// The persistent `configuration` of a container. struct Configuration: Codable, Sendable, Hashable { + public let runtimeKind: Core.Runtime.Kind public let id: String public let image: ImageReference public let initProcess: ProcessConfiguration @@ -113,6 +141,7 @@ struct Configuration: Codable, Sendable, Hashable { public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) + runtimeKind = try c.decodeIfPresent(Core.Runtime.Kind.self, forKey: .runtimeKind) ?? .appleContainer id = try c.decode(String.self, forKey: .id) image = try c.decode(ImageReference.self, forKey: .image) initProcess = try c.decode(ProcessConfiguration.self, forKey: .initProcess) @@ -137,6 +166,83 @@ struct Configuration: Codable, Sendable, Hashable { stopSignal = try c.decodeIfPresent(String.self, forKey: .stopSignal) creationDate = try c.decodeIfPresent(Date.self, forKey: .creationDate) } + + public init(runtimeKind: Core.Runtime.Kind = .appleContainer, + id: String, + image: ImageReference, + initProcess: ProcessConfiguration, + resources: ResourceConfiguration = .default, + platform: Platform = .init(architecture: "arm64", os: "linux", variant: nil), + labels: [String: String] = [:], + mounts: [Mount] = [], + networks: [NetworkAttachment] = [], + publishedPorts: [PublishedPort] = [], + publishedSockets: [PublishedSocket] = [], + dns: DNSConfiguration? = nil, + sysctls: [String: String] = [:], + capAdd: [String] = [], + capDrop: [String] = [], + rosetta: Bool = false, + runtimeHandler: String? = nil, + ssh: Bool = false, + readOnly: Bool = false, + useInit: Bool = false, + virtualization: Bool = false, + shmSize: UInt64? = nil, + stopSignal: String? = nil, + creationDate: Date? = nil) { + self.runtimeKind = runtimeKind + self.id = id + self.image = image + self.initProcess = initProcess + self.resources = resources + self.platform = platform + self.labels = labels + self.mounts = mounts + self.networks = networks + self.publishedPorts = publishedPorts + self.publishedSockets = publishedSockets + self.dns = dns + self.sysctls = sysctls + self.capAdd = capAdd + self.capDrop = capDrop + self.rosetta = rosetta + self.runtimeHandler = runtimeHandler + self.ssh = ssh + self.readOnly = readOnly + self.useInit = useInit + self.virtualization = virtualization + self.shmSize = shmSize + self.stopSignal = stopSignal + self.creationDate = creationDate + } + + public func scoped(to runtimeKind: Core.Runtime.Kind) -> Core.Container.Configuration { + Core.Container.Configuration(runtimeKind: runtimeKind, + id: id, + image: image, + initProcess: initProcess, + resources: resources, + platform: platform, + labels: labels, + mounts: mounts, + networks: networks, + publishedPorts: publishedPorts, + publishedSockets: publishedSockets, + dns: dns, + sysctls: sysctls, + capAdd: capAdd, + capDrop: capDrop, + rosetta: rosetta, + runtimeHandler: runtimeHandler, + ssh: ssh, + readOnly: readOnly, + useInit: useInit, + virtualization: virtualization, + shmSize: shmSize, + stopSignal: stopSignal, + creationDate: creationDate) + } } struct ImageReference: Codable, Sendable, Hashable { @@ -181,6 +287,12 @@ struct Platform: Codable, Sendable, Hashable { public let os: String public let variant: String? + public init(architecture: String, os: String, variant: String? = nil) { + self.architecture = architecture + self.os = os + self.variant = variant + } + public var display: String { var s = "\(os)/\(architecture)" if let variant, !variant.isEmpty { s += "/\(variant)" } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Image/Image+Resource.swift b/Packages/ContainedCore/Sources/ContainedCore/Image/Image+Resource.swift index cb0cc9c0..6c5a8c52 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Image/Image+Resource.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Image/Image+Resource.swift @@ -7,15 +7,35 @@ struct Resource: Codable, Sendable, Identifiable, Hashable { public let configuration: Core.Image.Configuration public let id: String public let variants: [Core.Image.Variant] + public let runtimeKind: Core.Runtime.Kind public var reference: String { configuration.name } public var digest: String? { configuration.descriptor?.digest } + public var scopedID: String { runtimeKind.scopedID(for: id) } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) configuration = try c.decode(Core.Image.Configuration.self, forKey: .configuration) id = try c.decode(String.self, forKey: .id) variants = try c.decodeIfPresent([Core.Image.Variant].self, forKey: .variants) ?? [] + runtimeKind = try c.decodeIfPresent(Core.Runtime.Kind.self, forKey: .runtimeKind) ?? .appleContainer + } + + public init(configuration: Core.Image.Configuration, + id: String, + variants: [Core.Image.Variant] = [], + runtimeKind: Core.Runtime.Kind = .appleContainer) { + self.configuration = configuration + self.id = id + self.variants = variants + self.runtimeKind = runtimeKind + } + + public func scoped(to runtimeKind: Core.Runtime.Kind) -> Core.Image.Resource { + Core.Image.Resource(configuration: configuration, + id: id, + variants: variants, + runtimeKind: runtimeKind) } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Image/LocalTagGroup.swift b/Packages/ContainedCore/Sources/ContainedCore/Image/LocalTagGroup.swift index df6108b9..5cf0d01d 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Image/LocalTagGroup.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Image/LocalTagGroup.swift @@ -5,22 +5,70 @@ struct LocalTagGroup: Identifiable, Sendable, Hashable { public let id: String public let digest: String? public let references: [String] + public let tags: [Core.Image.LocalTag] public let images: [Core.Image.Resource] public var primaryReference: String { references.first ?? id } public static func groups(for images: [Core.Image.Resource]) -> [Core.Image.LocalTagGroup] { - let buckets = Dictionary(grouping: images) { image in - image.digest ?? image.id + var parent = Array(images.indices) + + func find(_ index: Int) -> Int { + var index = index + while parent[index] != index { index = parent[index] } + return index + } + + func union(_ lhs: Int, _ rhs: Int) { + let left = find(lhs) + let right = find(rhs) + if left != right { parent[right] = left } + } + + var indexByKey: [String: Int] = [:] + for (index, image) in images.enumerated() { + let keys = groupKeys(for: image) + for key in keys { + if let existing = indexByKey[key] { + union(existing, index) + } else { + indexByKey[key] = index + } + } } - return buckets.map { key, images in - let references = images.map(\.reference) + + let buckets = Dictionary(grouping: images.indices, by: find) + return buckets.values.map { indices in + let images = indices.map { images[$0] } + let references = Array(Set(images.map(\.reference))) .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + let sortedImages = images.sorted { lhs, rhs in + if lhs.reference == rhs.reference { + return lhs.runtimeKind.rawValue < rhs.runtimeKind.rawValue + } + return lhs.reference.localizedCaseInsensitiveCompare(rhs.reference) == .orderedAscending + } + let tags = Dictionary(grouping: sortedImages) { image in + "\(image.runtimeKind.rawValue)|\(Core.Registry.ImageReference.normalizedKey(image.reference))" + } + .map { _, images in + Core.Image.LocalTag(reference: images.first?.reference ?? "", + runtimeKind: images.first?.runtimeKind ?? .appleContainer, + images: images) + } + .sorted { lhs, rhs in + if lhs.reference == rhs.reference { + return lhs.runtimeKind.rawValue < rhs.runtimeKind.rawValue + } + return lhs.reference.localizedCaseInsensitiveCompare(rhs.reference) == .orderedAscending + } + let digest = sortedImages.compactMap(\.digest).first return Core.Image.LocalTagGroup( - id: key, - digest: images.compactMap(\.digest).first, + id: digest ?? references.first.map(Core.Registry.ImageReference.normalizedKey) ?? UUID().uuidString, + digest: digest, references: references, - images: images.sorted { $0.reference.localizedCaseInsensitiveCompare($1.reference) == .orderedAscending } + tags: tags, + images: sortedImages ) } .sorted { $0.primaryReference.localizedCaseInsensitiveCompare($1.primaryReference) == .orderedAscending } @@ -28,8 +76,33 @@ struct LocalTagGroup: Identifiable, Sendable, Hashable { public static func group(containing image: Core.Image.Resource, in images: [Core.Image.Resource]) -> Core.Image.LocalTagGroup { groups(for: images).first { $0.images.contains(image) } - ?? Core.Image.LocalTagGroup(id: image.digest ?? image.id, digest: image.digest, - references: [image.reference], images: [image]) + ?? Core.Image.LocalTagGroup(id: image.digest ?? Core.Registry.ImageReference.normalizedKey(image.reference), + digest: image.digest, + references: [image.reference], + tags: [Core.Image.LocalTag(reference: image.reference, + runtimeKind: image.runtimeKind, + images: [image])], + images: [image]) + } + + private static func groupKeys(for image: Core.Image.Resource) -> [String] { + var keys = ["ref:\(Core.Registry.ImageReference.normalizedKey(image.reference))"] + if let digest = image.digest, !digest.isEmpty { keys.append("digest:\(digest)") } + return keys + } +} + +struct LocalTag: Identifiable, Sendable, Hashable { + public let reference: String + public let runtimeKind: Core.Runtime.Kind + public let images: [Core.Image.Resource] + + public var id: String { + runtimeKind.scopedID(for: Core.Registry.ImageReference.normalizedKey(reference)) + } + + public var digest: String? { + images.compactMap(\.digest).first } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Localization/CoreLocalization.swift b/Packages/ContainedCore/Sources/ContainedCore/Localization/CoreLocalization.swift new file mode 100644 index 00000000..2b47e35f --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Localization/CoreLocalization.swift @@ -0,0 +1,75 @@ +import Foundation + +public extension Core { + enum Localization { + public struct Entry: Sendable, Hashable { + public var key: String + public var defaultValue: String + public var table: String + + public init(key: String, defaultValue: String, table: String = "Localizable") { + self.key = key + self.defaultValue = defaultValue + self.table = table + } + } + + public typealias Resolver = @Sendable (Entry) -> String? + + public static func string(_ key: String, + defaultValue: String, + table: String = "Localizable", + resolver: Resolver? = nil) -> String { + let entry = Entry(key: key, defaultValue: defaultValue, table: table) + if let resolved = resolver?(entry) { return resolved } + let localized = Bundle.module.localizedString(forKey: key, value: defaultValue, table: table) + return localized == key ? defaultValue : localized + } + } +} + +public extension Core.Schema.FieldDescriptor { + func localizedLabel(resolver: Core.Localization.Resolver? = nil) -> String { + Core.Localization.string(labelKey, defaultValue: defaultLabel, resolver: resolver) + } + + func localizedTip(for runtimeKind: Core.Runtime.Kind, + resolver: Core.Localization.Resolver? = nil) -> String? { + tip(for: runtimeKind)?.localizedText(resolver: resolver) + } +} + +public extension Core.Schema.ValueOption { + func localizedLabel(resolver: Core.Localization.Resolver? = nil) -> String { + Core.Localization.string(labelKey, defaultValue: defaultLabel, resolver: resolver) + } +} + +public extension Core.Schema.FieldTipRef { + func localizedText(resolver: Core.Localization.Resolver? = nil) -> String { + Core.Localization.string(key, defaultValue: defaultText, resolver: resolver) + } +} + +public extension Core.Schema.FieldSupport { + func localizedDisabledReason(resolver: Core.Localization.Resolver? = nil) -> String? { + guard let defaultDisabledReason else { return nil } + return Core.Localization.string(disabledReasonKey ?? "schema.disabled.unsupported", + defaultValue: defaultDisabledReason, + resolver: resolver) + } +} + +public extension Core.Schema.ValidationIssue { + func localizedMessage(resolver: Core.Localization.Resolver? = nil) -> String { + Core.Localization.string(messageKey, defaultValue: defaultMessage, resolver: resolver) + } +} + +public extension Core.Runtime.UnsupportedCapability { + func localizedFallbackDescription(resolver: Core.Localization.Resolver? = nil) -> String { + Core.Localization.string("runtime.capability.unsupported", + defaultValue: "This runtime does not support the requested capability.", + resolver: resolver) + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Metrics/Stats.swift b/Packages/ContainedCore/Sources/ContainedCore/Metrics/Stats.swift index 989ed27a..3ca73c01 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Metrics/Stats.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Metrics/Stats.swift @@ -20,6 +20,18 @@ struct ContainerStats: Codable, Sendable, Identifiable, Hashable { guard let used = memoryUsageBytes, let limit = memoryLimitBytes, limit > 0 else { return nil } return Double(used) / Double(limit) } + + public func scoped(to runtimeKind: Core.Runtime.Kind) -> Core.Metrics.ContainerStats { + Core.Metrics.ContainerStats(id: runtimeKind.scopedID(for: id), + cpuUsageUsec: cpuUsageUsec, + memoryUsageBytes: memoryUsageBytes, + memoryLimitBytes: memoryLimitBytes, + blockReadBytes: blockReadBytes, + blockWriteBytes: blockWriteBytes, + networkRxBytes: networkRxBytes, + networkTxBytes: networkTxBytes, + numProcesses: numProcesses) + } } /// Runtime-agnostic resource counters parsed from a streaming source. @@ -58,6 +70,18 @@ struct RuntimeStatsSnapshot: Sendable, Identifiable, Hashable { self.networkTxBytes = networkTxBytes self.numProcesses = numProcesses } + + public func scoped(to runtimeKind: Core.Runtime.Kind) -> Core.Metrics.RuntimeStatsSnapshot { + Core.Metrics.RuntimeStatsSnapshot(id: runtimeKind.scopedID(for: id), + cpuCoreFraction: cpuCoreFraction, + memoryUsageBytes: memoryUsageBytes, + memoryLimitBytes: memoryLimitBytes, + blockReadBytes: blockReadBytes, + blockWriteBytes: blockWriteBytes, + networkRxBytes: networkRxBytes, + networkTxBytes: networkTxBytes, + numProcesses: numProcesses) + } } /// A computed delta between two `Core.Metrics.ContainerStats` samples, the form the UI actually graphs. diff --git a/Packages/ContainedCore/Sources/ContainedCore/Network/Network+Resource.swift b/Packages/ContainedCore/Sources/ContainedCore/Network/Network+Resource.swift index 7e1a7c5c..964cbe7c 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Network/Network+Resource.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Network/Network+Resource.swift @@ -6,11 +6,35 @@ struct Resource: Codable, Sendable, Identifiable, Hashable { public let configuration: Core.Network.Configuration public let id: String public let status: Core.Network.Status? + public let runtimeKind: Core.Runtime.Kind public var name: String { configuration.name } public var labels: [String: String] { configuration.labels } + public var scopedID: String { runtimeKind.scopedID(for: id) } /// Networks Apple ships by default (e.g. `default`) carry a builtin resource-role label. public var isBuiltin: Bool { labels["com.apple.container.resource.role"] == "builtin" } + + public init(configuration: Core.Network.Configuration, + id: String, + status: Core.Network.Status?, + runtimeKind: Core.Runtime.Kind = .appleContainer) { + self.configuration = configuration + self.id = id + self.status = status + self.runtimeKind = runtimeKind + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + configuration = try c.decode(Core.Network.Configuration.self, forKey: .configuration) + id = try c.decode(String.self, forKey: .id) + status = try c.decodeIfPresent(Core.Network.Status.self, forKey: .status) + runtimeKind = try c.decodeIfPresent(Core.Runtime.Kind.self, forKey: .runtimeKind) ?? .appleContainer + } + + public func scoped(to runtimeKind: Core.Runtime.Kind) -> Core.Network.Resource { + Core.Network.Resource(configuration: configuration, id: id, status: status, runtimeKind: runtimeKind) + } } struct Configuration: Codable, Sendable, Hashable { @@ -25,6 +49,20 @@ struct Configuration: Codable, Sendable, Hashable { public let variant: String? } + public init(name: String, + mode: String? = nil, + plugin: String? = nil, + creationDate: Date? = nil, + labels: [String: String] = [:], + options: Options? = nil) { + self.name = name + self.mode = mode + self.plugin = plugin + self.creationDate = creationDate + self.labels = labels + self.options = options + } + public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) name = try c.decode(String.self, forKey: .name) diff --git a/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift index de64c70a..83b627b2 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift @@ -2,13 +2,13 @@ import Foundation public extension Core { struct Configuration: Sendable { - public var defaultRuntime: Core.Runtime.Kind public var appleContainer: AppleContainerConfiguration + public var docker: DockerConfiguration - public init(defaultRuntime: Core.Runtime.Kind = .appleContainer, - appleContainer: AppleContainerConfiguration = AppleContainerConfiguration()) { - self.defaultRuntime = defaultRuntime + public init(appleContainer: AppleContainerConfiguration = AppleContainerConfiguration(), + docker: DockerConfiguration = DockerConfiguration()) { self.appleContainer = appleContainer + self.docker = docker } } @@ -19,91 +19,153 @@ public extension Core { self.cliPathOverride = cliPathOverride } } + + struct DockerConfiguration: Sendable { + public var cliPathOverride: String? + + public init(cliPathOverride: String? = nil) { + self.cliPathOverride = cliPathOverride + } + } } public extension Core { + struct RuntimeReadiness: Sendable, Equatable, Identifiable { + public enum State: String, Sendable, Equatable { + case ready + case unsupported + case endpointUnavailable + } + + public var kind: Core.Runtime.Kind + public var cliURL: URL + public var version: String? + public var state: State + public var message: String? + + public var id: Core.Runtime.Kind { kind } + + public init(kind: Core.Runtime.Kind, + cliURL: URL, + version: String? = nil, + state: State = .ready, + message: String? = nil) { + self.kind = kind + self.cliURL = cliURL + self.version = version + self.state = state + self.message = message + } + } + struct Orchestrator: Sendable, Equatable { public enum Bootstrap: Sendable, Equatable { case cliMissing - case unsupported(orchestrator: Core.Orchestrator, cliURL: URL, version: String) - case ready(orchestrator: Core.Orchestrator, cliURL: URL, version: String?) + case ready(orchestrator: Core.Orchestrator, runtimes: [Core.RuntimeReadiness]) } private let runtimes: [Core.Runtime.Kind: any ContainerRuntimeClient] - public let cliURL: URL - public let defaultRuntime: Core.Runtime.Kind + private let runtimeCLIURLs: [Core.Runtime.Kind: URL] public static func == (lhs: Core.Orchestrator, rhs: Core.Orchestrator) -> Bool { - lhs.cliURL == rhs.cliURL && lhs.defaultRuntime == rhs.defaultRuntime + lhs.runtimeCLIURLs == rhs.runtimeCLIURLs } public static func live(configuration: Core.Configuration = Core.Configuration()) -> Core.Orchestrator? { - guard let url = AppleContainerCLILocator.locate(override: configuration.appleContainer.cliPathOverride) else { - return nil + var runtimes: [Core.Runtime.Kind: any ContainerRuntimeClient] = [:] + var cliURLs: [Core.Runtime.Kind: URL] = [:] + + if let url = AppleContainerCLILocator.locate(override: configuration.appleContainer.cliPathOverride) { + cliURLs[.appleContainer] = url + runtimes[.appleContainer] = AppleContainerClient(runner: Core.Command.Runner(executableURL: url)) + } + + if let url = DockerCLILocator.locate(override: configuration.docker.cliPathOverride) { + cliURLs[.docker] = url + runtimes[.docker] = DockerClient(runner: Core.Command.Runner(executableURL: url)) } - return Core.Orchestrator(cliURL: url, - defaultRuntime: configuration.defaultRuntime, - runtimes: [ - Core.Runtime.Kind.appleContainer: AppleContainerClient(runner: Core.Command.Runner(executableURL: url)), - ]) + + guard !runtimes.isEmpty else { return nil } + return Core.Orchestrator(cliURLs: cliURLs, runtimes: runtimes) } public static func testing(runner: any Core.Command.Running, cliURL: URL = URL(fileURLWithPath: "/usr/bin/container"), - defaultRuntime: Core.Runtime.Kind = .appleContainer) -> Core.Orchestrator { - Core.Orchestrator(cliURL: cliURL, - defaultRuntime: defaultRuntime, - runtimes: [ - Core.Runtime.Kind.appleContainer: AppleContainerClient(runner: runner), - ]) + runtimeKind: Core.Runtime.Kind = .appleContainer) -> Core.Orchestrator { + let client: any ContainerRuntimeClient = runtimeKind == .docker + ? DockerClient(runner: runner) + : AppleContainerClient(runner: runner) + return Core.Orchestrator(cliURLs: [runtimeKind: cliURL], + runtimes: [runtimeKind: client]) + } + + public static func testing(runners: [Core.Runtime.Kind: any Core.Command.Running], + cliURLs: [Core.Runtime.Kind: URL] = [:]) -> Core.Orchestrator { + var clients: [Core.Runtime.Kind: any ContainerRuntimeClient] = [:] + for (kind, runner) in runners { + clients[kind] = kind == .docker + ? DockerClient(runner: runner) + : AppleContainerClient(runner: runner) + } + let resolvedURLs = Dictionary(uniqueKeysWithValues: clients.keys.map { kind in + (kind, cliURLs[kind] ?? URL(fileURLWithPath: kind == .docker ? "/usr/local/bin/docker" : "/usr/bin/container")) + }) + return Core.Orchestrator(cliURLs: resolvedURLs, runtimes: clients) } public static func bootstrap(configuration: Core.Configuration = Core.Configuration()) async -> Bootstrap { guard let orchestrator = live(configuration: configuration) else { return .cliMissing } - let runner = Core.Command.Runner(executableURL: orchestrator.cliURL) - let versionData = try? await runner.run(ContainerCommands.version) - let version = versionData.map { String(decoding: $0, as: UTF8.self) } - .flatMap(AppleContainerCLILocator.parseVersion) - if let version, !AppleContainerCLILocator.isSupported(version) { - return .unsupported(orchestrator: orchestrator, - cliURL: orchestrator.cliURL, - version: version) + var readiness: [Core.RuntimeReadiness] = [] + for descriptor in orchestrator.availableRuntimeDescriptors { + guard let cliURL = orchestrator.cliURL(for: descriptor.kind) else { continue } + let version: String? + let state: Core.RuntimeReadiness.State + switch descriptor.kind { + case .appleContainer: + let runner = Core.Command.Runner(executableURL: cliURL) + let versionData = try? await runner.run(ContainerCommands.version) + version = versionData.map { String(decoding: $0, as: UTF8.self) } + .flatMap(AppleContainerCLILocator.parseVersion) + state = version.map(AppleContainerCLILocator.isSupported) == false ? .unsupported : .ready + case .docker: + let runner = Core.Command.Runner(executableURL: cliURL) + let versionData = try? await runner.run(DockerCommands.version) + version = versionData.map { String(decoding: $0, as: UTF8.self) } + .flatMap(DockerCLILocator.parseVersion) + state = .ready + default: + version = nil + state = .ready + } + readiness.append(Core.RuntimeReadiness(kind: descriptor.kind, + cliURL: cliURL, + version: version, + state: state)) } - return .ready(orchestrator: orchestrator, - cliURL: orchestrator.cliURL, - version: version) + return .ready(orchestrator: orchestrator, runtimes: readiness) } - init(cliURL: URL, defaultRuntime: Core.Runtime.Kind, runtimes: [Core.Runtime.Kind: any ContainerRuntimeClient]) { - self.cliURL = cliURL - self.defaultRuntime = defaultRuntime + init(cliURLs: [Core.Runtime.Kind: URL], + runtimes: [Core.Runtime.Kind: any ContainerRuntimeClient]) { + self.runtimeCLIURLs = cliURLs self.runtimes = runtimes } - private var defaultClient: any ContainerRuntimeClient { - runtimes[defaultRuntime] ?? runtimes[.appleContainer]! - } - - public var descriptor: Core.Runtime.Descriptor { defaultClient.descriptor } - public var availableRuntimeDescriptors: [Core.Runtime.Descriptor] { runtimes.values.map(\.descriptor).sorted { $0.displayName < $1.displayName } } - public var runtimeCoreSelectorIsEnabled: Bool { - availableRuntimeDescriptors.count > 1 - } - public func descriptor(for kind: Core.Runtime.Kind) -> Core.Runtime.Descriptor { - availableRuntimeDescriptors.first { $0.kind == kind } ?? .appleContainer + availableRuntimeDescriptors.first { $0.kind == kind } + ?? (kind == .docker ? .docker : .appleContainer) } public func supportsRuntime(_ kind: Core.Runtime.Kind, capability: Core.Runtime.Capability = .containers) -> Bool { availableRuntimeDescriptors.first { $0.kind == kind }?.supports(capability) == true } - private func requireRuntime(_ kind: Core.Runtime.Kind, - capability: Core.Runtime.Capability) throws -> any ContainerRuntimeClient { + internal func requireRuntime(_ kind: Core.Runtime.Kind, + capability: Core.Runtime.Capability) throws -> any ContainerRuntimeClient { guard let runtime = runtimes[kind] else { throw Core.Runtime.UnsupportedCapability(kind: kind, capability: capability) } @@ -111,67 +173,131 @@ public extension Core { return runtime } - public func schemaDefinition(for operation: Core.Schema.Operation, - runtimeKind: Core.Runtime.Kind? = nil) -> Core.Schema.Definition { - Core.Schema.Definition.containerRunEdit(runtimeKind: runtimeKind ?? defaultRuntime, - operation: operation) + public func cliURL(for kind: Core.Runtime.Kind) -> URL? { + runtimeCLIURLs[kind] } - public func listContainers(all: Bool = true) async throws -> [Core.Container.Snapshot] { - try await defaultClient.listContainers(all: all) + private func requireCLIURL(for kind: Core.Runtime.Kind) throws -> URL { + guard let url = runtimeCLIURLs[kind] else { + throw Core.Runtime.UnsupportedCapability(kind: kind, capability: .containers) + } + return url } - public func stats(ids: [String] = []) async throws -> [Core.Metrics.ContainerStats] { - try await defaultClient.stats(ids: ids) + public func schemaDefinition(for operation: Core.Schema.Operation, + runtimeKind: Core.Runtime.Kind) -> Core.Schema.Definition { + Core.Schema.Definition.containerRunEdit(runtimeKind: runtimeKind, + operation: operation) } - public func streamStats(ids: [String] = []) -> AsyncThrowingStream<[Core.Metrics.RuntimeStatsSnapshot], Swift.Error> { - defaultClient.streamStats(ids: ids) + public func listRuntimeContainers(all: Bool = true) async throws -> [Core.Container.Snapshot] { + var snapshots: [Core.Container.Snapshot] = [] + var successes = 0 + var firstError: Swift.Error? + for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + guard let runtime = runtimes[kind] else { continue } + do { + snapshots += try await runtime.listContainers(all: all).map { $0.scoped(to: kind) } + successes += 1 + } catch { + firstError = firstError ?? error + } + } + if successes == 0, let firstError { throw firstError } + return snapshots + } + + public func stats(ids: [String] = [], + runtimeKind: Core.Runtime.Kind) async throws -> [Core.Metrics.ContainerStats] { + let runtime = try requireRuntime(runtimeKind, capability: .containers) + return try await runtime.stats(ids: ids).map { $0.scoped(to: runtimeKind) } + } + + public func streamStats(ids: [String] = [], + runtimeKind: Core.Runtime.Kind) -> AsyncThrowingStream<[Core.Metrics.RuntimeStatsSnapshot], Swift.Error> { + do { + let runtime = try requireRuntime(runtimeKind, capability: .containers) + let source = runtime.streamStats(ids: ids) + return AsyncThrowingStream { continuation in + let task = Task(priority: .utility) { + do { + for try await samples in source { + continuation.yield(samples.map { $0.scoped(to: runtimeKind) }) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } catch { + return AsyncThrowingStream { continuation in continuation.finish(throwing: error) } + } } - public func diskUsage() async throws -> Core.System.DiskUsage { - try await defaultClient.diskUsage() + public func diskUsage(runtimeKind: Core.Runtime.Kind) async throws -> Core.System.DiskUsage { + try await requireRuntime(runtimeKind, capability: .systemStatus).diskUsage() } - public func systemProperties() async throws -> Core.System.Properties { - try await defaultClient.systemProperties() + public func systemProperties(runtimeKind: Core.Runtime.Kind) async throws -> Core.System.Properties { + try await requireRuntime(runtimeKind, capability: .systemProperties).systemProperties() } - public func dnsDomains() async throws -> [String] { - try await defaultClient.dnsDomains() + public func dnsDomains(runtimeKind: Core.Runtime.Kind) async throws -> [String] { + try await requireRuntime(runtimeKind, capability: .dnsManagement).dnsDomains() } - @discardableResult public func createDNSDomain(_ domain: String) async throws -> Data { - try await defaultClient.createDNSDomain(domain) + @discardableResult public func createDNSDomain(_ domain: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .dnsManagement).createDNSDomain(domain) } - @discardableResult public func deleteDNSDomain(_ domain: String) async throws -> Data { - try await defaultClient.deleteDNSDomain(domain) + @discardableResult public func deleteDNSDomain(_ domain: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .dnsManagement).deleteDNSDomain(domain) } - @discardableResult public func setRecommendedKernel() async throws -> Data { - try await defaultClient.setRecommendedKernel() + @discardableResult public func setRecommendedKernel(runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .kernelManagement).setRecommendedKernel() } - public func execCapture(_ id: String, _ command: [String]) async throws -> String { - try await defaultClient.execCapture(id, command) + public func execCapture(_ id: String, + _ command: [String], + runtimeKind: Core.Runtime.Kind) async throws -> String { + try await requireRuntime(runtimeKind, capability: .exec).execCapture(id, command) } - @discardableResult public func copy(source: String, destination: String) async throws -> Data { - try await defaultClient.copy(source: source, destination: destination) + @discardableResult public func copy(source: String, + destination: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .copy).copy(source: source, destination: destination) } - public func terminalInvocation(containerID: String, shell: String) throws -> Core.Command.Invocation { - Core.Command.Invocation(executableURL: cliURL, - arguments: ContainerCommands.execInteractive(containerID, shell: shell)) + public func terminalInvocation(containerID: String, + shell: String, + runtimeKind: Core.Runtime.Kind) throws -> Core.Command.Invocation { + _ = try requireRuntime(runtimeKind, capability: .exec) + let executable = try requireCLIURL(for: runtimeKind) + let arguments = runtimeKind == .docker + ? DockerCommands.execInteractive(containerID, shell: shell) + : ContainerCommands.execInteractive(containerID, shell: shell) + return Core.Command.Invocation(executableURL: executable, arguments: arguments) } - public func streamSystemLogs(follow: Bool, last: Int? = 500) -> AsyncThrowingStream { - defaultClient.streamSystemLogs(follow: follow, last: last) + public func streamSystemLogs(follow: Bool, + last: Int? = 500, + runtimeKind: Core.Runtime.Kind) -> AsyncThrowingStream { + do { + let runtime = try requireRuntime(runtimeKind, capability: .systemLogs) + return runtime.streamSystemLogs(follow: follow, last: last) + } catch { + return AsyncThrowingStream { continuation in continuation.finish(throwing: error) } + } } - public func systemStatus() async throws -> Core.System.Status { - try await defaultClient.systemStatus() + public func systemStatus(runtimeKind: Core.Runtime.Kind) async throws -> Core.System.Status { + try await requireRuntime(runtimeKind, capability: .systemStatus).systemStatus() } private func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview { @@ -211,7 +337,7 @@ public extension Core { public func translateCompose(_ project: Core.Compose.Project, baseDirectory: URL?, - runtimeKind: Core.Runtime.Kind = .appleContainer) throws -> Core.Compose.ImportPlan { + runtimeKind: Core.Runtime.Kind) throws -> Core.Compose.ImportPlan { try requireRuntime(runtimeKind, capability: .composeImport) .translateCompose(project, baseDirectory: baseDirectory) } @@ -237,37 +363,90 @@ public extension Core { } public func coreSwitchPlan(for containerID: String, - source: Core.Runtime.Kind = .appleContainer, + source: Core.Runtime.Kind, to target: Core.Runtime.Descriptor?) throws -> Core.Migration.Plan { try requireRuntime(source, capability: .coreMigration).coreSwitchPlan(for: containerID, to: target) } - public func networks() async throws -> [Core.Network.Resource] { - try await defaultClient.networks() - } - - public func volumes() async throws -> [Core.Volume.Resource] { - try await defaultClient.volumes() - } - - public func images() async throws -> [Core.Image.Resource] { - try await defaultClient.images() + public func runtimeNetworks() async throws -> [Core.Network.Resource] { + var networks: [Core.Network.Resource] = [] + var successes = 0 + var firstError: Swift.Error? + for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + guard let runtime = runtimes[kind] else { continue } + do { + networks += try await runtime.networks().map { $0.scoped(to: kind) } + successes += 1 + } catch { + firstError = firstError ?? error + } + } + if successes == 0, let firstError { throw firstError } + return networks + } + + public func runtimeVolumes() async throws -> [Core.Volume.Resource] { + var volumes: [Core.Volume.Resource] = [] + var successes = 0 + var firstError: Swift.Error? + for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + guard let runtime = runtimes[kind] else { continue } + do { + volumes += try await runtime.volumes().map { $0.scoped(to: kind) } + successes += 1 + } catch { + firstError = firstError ?? error + } + } + if successes == 0, let firstError { throw firstError } + return volumes + } + + public func runtimeImages() async throws -> [Core.Image.Resource] { + var images: [Core.Image.Resource] = [] + var successes = 0 + var firstError: Swift.Error? + for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + guard let runtime = runtimes[kind] else { continue } + do { + images += try await runtime.images().map { $0.scoped(to: kind) } + successes += 1 + } catch { + firstError = firstError ?? error + } + } + if successes == 0, let firstError { throw firstError } + return images } - public func inspectImage(_ ref: String) async throws -> [Core.Image.Resource] { - try await defaultClient.inspectImage(ref) + public func inspectImage(_ ref: String, + runtimeKind: Core.Runtime.Kind) async throws -> [Core.Image.Resource] { + let images = try await requireRuntime(runtimeKind, capability: .images).inspectImage(ref) + return images.map { $0.scoped(to: runtimeKind) } } public func streamLogs(id: String, + runtimeKind: Core.Runtime.Kind, follow: Bool = true, tail: Int? = 200, boot: Bool = false) -> AsyncThrowingStream { - defaultClient.streamLogs(id: id, follow: follow, tail: tail, boot: boot) + do { + let runtime = try requireRuntime(runtimeKind, capability: .containers) + return runtime.streamLogs(id: id, follow: follow, tail: tail, boot: boot) + } catch { + return AsyncThrowingStream { continuation in continuation.finish(throwing: error) } + } } public func streamPull(_ ref: String, - platform: String? = nil) -> AsyncThrowingStream { - defaultClient.streamPull(ref, platform: platform) + platform: String? = nil, + runtimeKind: Core.Runtime.Kind) -> AsyncThrowingStream { + do { + let runtime = try requireRuntime(runtimeKind, capability: .images) + return runtime.streamPull(ref, platform: platform) + } catch { + return AsyncThrowingStream { continuation in continuation.finish(throwing: error) } + } } public func streamBuild(context: String, @@ -275,112 +454,147 @@ public extension Core { dockerfile: String? = nil, buildArgs: [String: String] = [:], noCache: Bool = false, - platform: String? = nil) -> AsyncThrowingStream { - defaultClient.streamBuild(context: context, - tag: tag, - dockerfile: dockerfile, - buildArgs: buildArgs, - noCache: noCache, - platform: platform) + platform: String? = nil, + runtimeKind: Core.Runtime.Kind) -> AsyncThrowingStream { + do { + let runtime = try requireRuntime(runtimeKind, capability: .imageBuild) + return runtime.streamBuild(context: context, + tag: tag, + dockerfile: dockerfile, + buildArgs: buildArgs, + noCache: noCache, + platform: platform) + } catch { + return AsyncThrowingStream { continuation in continuation.finish(throwing: error) } + } } public func streamPush(_ ref: String, - platform: String? = nil) -> AsyncThrowingStream { - defaultClient.streamPush(ref, platform: platform) + platform: String? = nil, + runtimeKind: Core.Runtime.Kind) -> AsyncThrowingStream { + do { + let runtime = try requireRuntime(runtimeKind, capability: .imagePush) + return runtime.streamPush(ref, platform: platform) + } catch { + return AsyncThrowingStream { continuation in continuation.finish(throwing: error) } + } } - @discardableResult public func runContainer(arguments: [String]) async throws -> Data { - try await defaultClient.runContainer(arguments: arguments) + @discardableResult public func runContainer(arguments: [String], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .containers).runContainer(arguments: arguments) } @discardableResult public func performSystemAction(_ action: Core.Runtime.SystemAction) async throws -> Data { - try await defaultClient.performSystemAction(action) + try await requireRuntime(.appleContainer, capability: .systemStatus).performSystemAction(action) } - public func registries() async throws -> [Core.Registry.Login] { - try await defaultClient.registries() + public func registries(runtimeKind: Core.Runtime.Kind) async throws -> [Core.Registry.Login] { + try await requireRuntime(runtimeKind, capability: .registries).registries().map { $0.scoped(to: runtimeKind) } } @discardableResult public func registryLogin(server: String, username: String, - password: String) async throws -> Data { - try await defaultClient.registryLogin(server: server, username: username, password: password) + password: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .registries) + .registryLogin(server: server, username: username, password: password) } - @discardableResult public func registryLogout(server: String) async throws -> Data { - try await defaultClient.registryLogout(server: server) + @discardableResult public func registryLogout(server: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .registries).registryLogout(server: server) } - @discardableResult public func deleteImages(_ refs: [String]) async throws -> Data { - try await defaultClient.deleteImages(refs) + @discardableResult public func deleteImages(_ refs: [String], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .images).deleteImages(refs) } - @discardableResult public func tagImage(source: String, target: String) async throws -> Data { - try await defaultClient.tagImage(source: source, target: target) + @discardableResult public func tagImage(source: String, + target: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .images).tagImage(source: source, target: target) } - @discardableResult public func saveImages(_ refs: [String], to output: String) async throws -> Data { - try await defaultClient.saveImages(refs, to: output) + @discardableResult public func saveImages(_ refs: [String], + to output: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .imageArchive).saveImages(refs, to: output) } - @discardableResult public func loadImages(from input: String) async throws -> Data { - try await defaultClient.loadImages(from: input) + @discardableResult public func loadImages(from input: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .imageArchive).loadImages(from: input) } - @discardableResult public func exportContainer(_ id: String, to output: String) async throws -> Data { - try await defaultClient.exportContainer(id, to: output) + @discardableResult public func exportContainer(_ id: String, + to output: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .containerExport).exportContainer(id, to: output) } - @discardableResult public func pruneImages(all: Bool = false) async throws -> Data { - try await defaultClient.pruneImages(all: all) + @discardableResult public func pruneImages(all: Bool = false, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .images).pruneImages(all: all) } - @discardableResult public func start(_ ids: [String]) async throws -> Data { - try await defaultClient.start(ids) + @discardableResult public func start(_ ids: [String], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .containers).start(ids) } - @discardableResult public func stop(_ ids: [String]) async throws -> Data { - try await defaultClient.stop(ids) + @discardableResult public func stop(_ ids: [String], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .containers).stop(ids) } - @discardableResult public func deleteContainers(_ ids: [String], force: Bool) async throws -> Data { - try await defaultClient.deleteContainers(ids, force: force) + @discardableResult public func deleteContainers(_ ids: [String], + force: Bool, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .containers).deleteContainers(ids, force: force) } - @discardableResult public func pruneContainers() async throws -> Data { - try await defaultClient.pruneContainers() + @discardableResult public func pruneContainers(runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .containers).pruneContainers() } - @discardableResult public func pruneVolumes() async throws -> Data { - try await defaultClient.pruneVolumes() + @discardableResult public func pruneVolumes(runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .volumes).pruneVolumes() } - @discardableResult public func pruneNetworks() async throws -> Data { - try await defaultClient.pruneNetworks() + @discardableResult public func pruneNetworks(runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .networks).pruneNetworks() } @discardableResult public func createVolume(name: String, size: String? = nil, - labels: [String: String] = [:]) async throws -> Data { - try await defaultClient.createVolume(name: name, size: size, labels: labels) + labels: [String: String] = [:], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .volumes) + .createVolume(name: name, size: size, labels: labels) } - @discardableResult public func deleteVolumes(_ names: [String]) async throws -> Data { - try await defaultClient.deleteVolumes(names) + @discardableResult public func deleteVolumes(_ names: [String], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .volumes).deleteVolumes(names) } @discardableResult public func createNetwork(name: String, subnet: String? = nil, internalOnly: Bool = false, - labels: [String: String] = [:]) async throws -> Data { - try await defaultClient.createNetwork(name: name, - subnet: subnet, - internalOnly: internalOnly, - labels: labels) - } - - @discardableResult public func deleteNetworks(_ names: [String]) async throws -> Data { - try await defaultClient.deleteNetworks(names) + labels: [String: String] = [:], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .networks) + .createNetwork(name: name, + subnet: subnet, + internalOnly: internalOnly, + labels: labels) + } + + @discardableResult public func deleteNetworks(_ names: [String], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, capability: .networks).deleteNetworks(names) } } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Registry/ImageReference.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/ImageReference.swift index b9755178..83b4767c 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Registry/ImageReference.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Registry/ImageReference.swift @@ -28,7 +28,7 @@ struct ImageReference: Sendable, Hashable { let parts = namePart.split(separator: "/", omittingEmptySubsequences: false).map(String.init) let first = parts.first ?? "" - let hasRegistry = first.contains(".") || first.contains(":") || first == "localhost" + let hasRegistry = parts.count > 1 && (first.contains(".") || first.contains(":") || first == "localhost") let rawRegistry = hasRegistry ? first : "docker.io" var repositoryParts = hasRegistry ? Array(parts.dropFirst()) : parts if repositoryParts.count == 1, rawRegistry == "docker.io" || rawRegistry == "index.docker.io" { diff --git a/Packages/ContainedCore/Sources/ContainedCore/Registry/Login.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/Login.swift index 4d1da783..bea03b13 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Registry/Login.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Registry/Login.swift @@ -8,11 +8,20 @@ struct Login: Codable, Sendable, Identifiable, Hashable { public let username: String? public let created: Date? public let modified: Date? + public var runtimeKind: Core.Runtime.Kind - public var id: String { host } + public var id: String { runtimeKind.scopedID(for: host) } - public init(host: String, username: String? = nil, created: Date? = nil, modified: Date? = nil) { - self.host = host; self.username = username; self.created = created; self.modified = modified + public init(host: String, + username: String? = nil, + created: Date? = nil, + modified: Date? = nil, + runtimeKind: Core.Runtime.Kind = .appleContainer) { + self.host = host + self.username = username + self.created = created + self.modified = modified + self.runtimeKind = runtimeKind } public init(from decoder: Decoder) throws { @@ -35,6 +44,15 @@ struct Login: Codable, Sendable, Identifiable, Hashable { username = string(["username", "user"]) created = date(["created", "createdAt", "creationDate"]) modified = date(["modified", "modifiedAt", "updated"]) + runtimeKind = .appleContainer + } + + public func scoped(to runtimeKind: Core.Runtime.Kind) -> Core.Registry.Login { + Core.Registry.Login(host: host, + username: username, + created: created, + modified: modified, + runtimeKind: runtimeKind) } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Resources/en.lproj/Localizable.strings b/Packages/ContainedCore/Sources/ContainedCore/Resources/en.lproj/Localizable.strings new file mode 100644 index 00000000..543e3959 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Resources/en.lproj/Localizable.strings @@ -0,0 +1,12 @@ +"runtime.capability.unsupported" = "This runtime does not support the requested capability."; +"schema.disabled.unsupported" = "This field is known to Core but is not available for the selected runtime."; +"schema.disabled.appleContainer.unsupportedDockerCompose" = "Known from Docker CLI or Compose, not executable by Apple container."; +"schema.disabled.docker.unsupportedAppleContainer" = "Known from Apple container or Compose, not executable by Docker."; +"schema.disabled.docker.composeOnly" = "Compose stack metadata is preserved, but V1 Docker support runs single containers only."; +"schema.validation.disabledField" = "This field is not executable by the selected runtime."; +"schema.field.image.reference" = "Image"; +"schema.field.runtime.kind" = "Runtime"; +"schema.option.default" = "Default"; +"schema.option.restart.no" = "No"; +"schema.option.restart.always" = "Always"; +"schema.option.restart.onFailure" = "On failure"; diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/ContainerRuntimeMigration.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/ContainerRuntimeMigration.swift new file mode 100644 index 00000000..6607222d --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/ContainerRuntimeMigration.swift @@ -0,0 +1,151 @@ +import Foundation + +public extension Core.Runtime { +struct ContainerMigrationResult: Sendable, Equatable { + public var source: Core.Container.Snapshot + public var target: Core.Container.Snapshot + public var sourceDocument: Core.Schema.Document + public var targetDocument: Core.Schema.Document + + public init(source: Core.Container.Snapshot, + target: Core.Container.Snapshot, + sourceDocument: Core.Schema.Document, + targetDocument: Core.Schema.Document) { + self.source = source + self.target = target + self.sourceDocument = sourceDocument + self.targetDocument = targetDocument + } +} + +enum ContainerMigrationError: Error, Equatable, Sendable { + case imageUnavailable + case stabilizationTimedOut +} +} + +extension Core.Runtime.ContainerMigrationError: Core.Error.PackageError { + public var packageName: String { "ContainedCore" } + public var packageErrorCode: String { + switch self { + case .imageUnavailable: return "containerMigrationImageUnavailable" + case .stabilizationTimedOut: return "containerMigrationStabilizationTimedOut" + } + } + + public var packageErrorContext: [String: String] { [:] } +} + +extension Core.Runtime.ContainerMigrationError: LocalizedError { + public var errorDescription: String? { + switch self { + case .imageUnavailable: + Core.Localization.string("containerMigration.imageUnavailable", + defaultValue: "The image could not be made available on the target runtime.") + case .stabilizationTimedOut: + Core.Localization.string("containerMigration.stabilizationTimedOut", + defaultValue: "The target container did not become healthy in time.") + } + } +} + +public extension Core.Orchestrator { + func migrateContainer(_ source: Core.Container.Snapshot, + sourceDocument: Core.Schema.Document, + targetRuntimeKind: Core.Runtime.Kind, + healthCheck: Core.Container.HealthCheck?, + stabilizationTimeout: TimeInterval = 120, + pollInterval: TimeInterval = 2, + onPullProgress: (@Sendable (String) async -> Void)? = nil) async throws -> Core.Runtime.ContainerMigrationResult { + try requireMigrationRuntimePair(source: source.runtimeKind, target: targetRuntimeKind) + try await stop([source.id], runtimeKind: source.runtimeKind) + + guard try await ensureImage(source.image, + runtimeKind: targetRuntimeKind, + onPullProgress: onPullProgress) else { + throw Core.Runtime.ContainerMigrationError.imageUnavailable + } + + let targetDocument = migrationTargetDocument(sourceDocument, + targetRuntimeKind: targetRuntimeKind) + let result = try await createContainer(targetDocument) + let targetID = result.id ?? source.id + guard let target = try await waitForMigratedContainer(id: targetID, + runtimeKind: targetRuntimeKind, + healthCheck: healthCheck, + stabilizationTimeout: stabilizationTimeout, + pollInterval: pollInterval) else { + throw Core.Runtime.ContainerMigrationError.stabilizationTimedOut + } + + try await deleteContainers([source.id], force: true, runtimeKind: source.runtimeKind) + return Core.Runtime.ContainerMigrationResult(source: source, + target: target, + sourceDocument: sourceDocument, + targetDocument: targetDocument) + } + + private func requireMigrationRuntimePair(source: Core.Runtime.Kind, + target: Core.Runtime.Kind) throws { + _ = try requireRuntime(source, capability: .containers) + _ = try requireRuntime(target, capability: .containers) + } + + private func migrationTargetDocument(_ sourceDocument: Core.Schema.Document, + targetRuntimeKind: Core.Runtime.Kind) -> Core.Schema.Document { + var document = sourceDocument + document.operation = .containerCreate + document.runtimeKind = targetRuntimeKind + document.set(.runtimeKind, .string(targetRuntimeKind.rawValue)) + let definition = schemaDefinition(for: .containerCreate, runtimeKind: targetRuntimeKind) + return document.migrated(to: definition) + } + + private func ensureImage(_ reference: String, + runtimeKind: Core.Runtime.Kind, + onPullProgress: (@Sendable (String) async -> Void)?) async throws -> Bool { + let target = Core.Registry.ImageReference.normalizedKey(reference) + let images = try await requireRuntime(runtimeKind, capability: .images).images() + if images.contains(where: { Core.Registry.ImageReference.normalizedKey($0.reference) == target }) { + return true + } + do { + for try await line in streamPull(reference, platform: nil, runtimeKind: runtimeKind) { + await onPullProgress?(line) + } + return true + } catch { + return false + } + } + + private func waitForMigratedContainer(id: String, + runtimeKind: Core.Runtime.Kind, + healthCheck: Core.Container.HealthCheck?, + stabilizationTimeout: TimeInterval, + pollInterval: TimeInterval) async throws -> Core.Container.Snapshot? { + let deadline = Date().addingTimeInterval(stabilizationTimeout) + repeat { + let snapshots = try await requireRuntime(runtimeKind, capability: .containers) + .listContainers(all: true) + .map { $0.scoped(to: runtimeKind) } + if let target = snapshots.first(where: { $0.id == id }) { + if let healthCheck, healthCheck.isActive { + if (try? await execCapture(target.id, healthCheck.command, runtimeKind: runtimeKind)) != nil { + return target + } + } else if target.state == .running { + return target + } + } + if Date() >= deadline { break } + let seconds = max(0, pollInterval) + if seconds > 0 { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } else { + await Task.yield() + } + } while !Task.isCancelled + return nil + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/Descriptor.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Descriptor.swift index 26bbb60e..735b87e3 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Runtime/Descriptor.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Descriptor.swift @@ -46,6 +46,23 @@ struct Capability: OptionSet, Equatable, Sendable { .containerExport, .composeImport, ] + + public static let docker: Core.Runtime.Capability = [ + .containers, + .images, + .imageBuild, + .imagePush, + .imageArchive, + .registries, + .networks, + .volumes, + .systemStatus, + .systemProperties, + .exec, + .copy, + .containerExport, + .composeImport, + ] } struct Descriptor: Equatable, Sendable { @@ -80,6 +97,13 @@ struct Descriptor: Equatable, Sendable { executableName: "container", capabilities: .appleContainer ) + + public static let docker = Core.Runtime.Descriptor( + kind: .docker, + displayName: "Docker", + executableName: "docker", + capabilities: .docker + ) } struct UnsupportedCapability: Error, Equatable, Sendable { diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift index 6e6b2d09..3ce4f4a5 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift @@ -2,19 +2,38 @@ import Foundation /// Stable identifier for a runtime adapter. /// -/// This is intentionally open-ended rather than a closed enum. Apple `container` is the first -/// adapter, Docker-compatible engines are an obvious future adapter, and the app should also be able -/// to host runtimes that do not exist yet without editing stored app state. +/// This is intentionally open-ended rather than a closed enum. Apple `container` is one +/// adapter, Docker is another, and the app should also be able to host runtimes that do not +/// exist yet without editing stored app state. public extension Core.Runtime { -struct Kind: RawRepresentable, Codable, Equatable, Hashable, Sendable { - public var rawValue: String + struct Kind: RawRepresentable, Codable, Equatable, Hashable, Sendable { + public var rawValue: String - public init(rawValue: String) { - self.rawValue = rawValue - } + public init(rawValue: String) { + self.rawValue = rawValue + } + + public static let appleContainer = Kind(rawValue: "apple-container") + public static let docker = Kind(rawValue: "docker") + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } - public static let appleContainer = Kind(rawValue: "apple-container") - public static let dockerCompatible = Kind(rawValue: "docker-compatible") + public func scopedID(for id: String) -> String { + "\(rawValue)::\(id)" + } + } } +public extension Core.Runtime { + static func scopedID(kind: Core.Runtime.Kind, id: String) -> String { + kind.scopedID(for: id) + } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Client/DockerClient.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Client/DockerClient.swift new file mode 100644 index 00000000..71c2beb3 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Client/DockerClient.swift @@ -0,0 +1,328 @@ +import Foundation + +struct DockerClient: Sendable { + let runner: any Core.Command.Running + var descriptor: Core.Runtime.Descriptor { .docker } + + init(runner: any Core.Command.Running) { + self.runner = runner + } + + // MARK: Reads + + func listContainers(all: Bool = true) async throws -> [Core.Container.Snapshot] { + let data = try await runner.run(DockerCommands.containerIDs(all: all)) + let ids = String(decoding: data, as: UTF8.self) + .split(whereSeparator: \.isNewline) + .map(String.init) + guard !ids.isEmpty else { return [] } + let inspectData = try await runner.run(DockerCommands.inspectContainers(ids)) + return try DockerJSON.decode([DockerContainerInspect].self, from: inspectData) + .map { try $0.coreSnapshot() } + } + + func stats(ids: [String] = []) async throws -> [Core.Metrics.ContainerStats] { + let rows = try await decodeLines(DockerStatsRow.self, + DockerCommands.stats(ids: ids), + "docker stats", + priority: .utility) + return rows.map { row in + let sample = row.snapshot + return Core.Metrics.ContainerStats(id: sample.id, + cpuUsageUsec: nil, + memoryUsageBytes: sample.memoryUsageBytes, + memoryLimitBytes: sample.memoryLimitBytes, + blockReadBytes: sample.blockReadBytes, + blockWriteBytes: sample.blockWriteBytes, + networkRxBytes: sample.networkRxBytes, + networkTxBytes: sample.networkTxBytes, + numProcesses: sample.numProcesses) + } + } + + func streamStats(ids: [String] = []) -> AsyncThrowingStream<[Core.Metrics.RuntimeStatsSnapshot], Error> { + let source = runner.stream(DockerCommands.stats(ids: ids, noStream: false), priority: .utility) + return AsyncThrowingStream { continuation in + let task = Task(priority: .utility) { + var buffer = "" + do { + for try await chunk in source { + try Task.checkCancellation() + buffer += chunk + var lines = buffer.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + buffer = lines.popLast() ?? "" + let samples = lines.compactMap { line -> Core.Metrics.RuntimeStatsSnapshot? in + guard !line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let data = line.data(using: .utf8), + let row = try? DockerJSON.decode(DockerStatsRow.self, from: data) else { return nil } + return row.snapshot + } + if !samples.isEmpty { continuation.yield(samples) } + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + func diskUsage() async throws -> Core.System.DiskUsage { + let data = try await runner.run(DockerCommands.systemDF, stdin: nil, priority: .utility) + let rows = (try? DockerJSON.decodeJSONLines(DockerSystemDFRow.self, from: data)) ?? [] + func category(_ type: String) -> Core.System.DiskUsage.Category { + guard let row = rows.first(where: { $0.type.caseInsensitiveCompare(type) == .orderedSame }) else { + return Core.System.DiskUsage.Category(active: 0, total: 0, sizeInBytes: 0, reclaimable: 0) + } + return Core.System.DiskUsage.Category(active: row.activeCount, + total: row.totalCount, + sizeInBytes: row.sizeBytes, + reclaimable: row.reclaimableBytes) + } + return Core.System.DiskUsage(containers: category("Containers"), + images: category("Images"), + volumes: category("Local Volumes")) + } + + func systemProperties() async throws -> Core.System.Properties { + try await decode(DockerInfo.self, DockerCommands.systemStatus, "docker info").systemProperties + } + + func dnsDomains() async throws -> [String] { throw unsupported(.dnsManagement) } + @discardableResult func createDNSDomain(_ domain: String) async throws -> Data { throw unsupported(.dnsManagement) } + @discardableResult func deleteDNSDomain(_ domain: String) async throws -> Data { throw unsupported(.dnsManagement) } + @discardableResult func setRecommendedKernel() async throws -> Data { throw unsupported(.kernelManagement) } + + func execCapture(_ id: String, _ command: [String]) async throws -> String { + let data = try await runner.run(DockerCommands.exec(id, command)) + return String(decoding: data, as: UTF8.self) + } + + @discardableResult func copy(source: String, destination: String) async throws -> Data { + try await runner.run(DockerCommands.copy(source: source, destination: destination)) + } + + func streamSystemLogs(follow: Bool, last: Int?) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + continuation.finish(throwing: unsupported(.systemLogs)) + } + } + + func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview { + DockerCreateTranslator.preview(for: request) + } + + @discardableResult func createContainer(_ request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { + let data = try await runner.run(DockerCommands.run(request)) + return DockerCreateTranslator.result(from: data, request: request) + } + + func translateCompose(_ project: Core.Compose.Project, baseDirectory: URL?) throws -> Core.Compose.ImportPlan { + DockerCreateTranslator.composePlan(for: project, baseDirectory: baseDirectory) + } + + func imageDefaults(for request: Core.Container.CreateRequest, + in images: [Core.Image.Resource]) throws -> Core.Container.ImageDefaults? { + DockerCreateTranslator.imageDefaults(for: request, in: images.filter { $0.runtimeKind == .docker }) + } + + func coreSwitchPlan(for containerID: String, to target: Core.Runtime.Descriptor?) throws -> Core.Migration.Plan { + Core.Migration.Plan(isAvailable: false, + unavailableReason: .exportImportUnsupported, + context: ["source": descriptor.kind.rawValue, "target": target?.kind.rawValue ?? ""], + source: descriptor.kind, + target: target?.kind) + } + + func systemStatus() async throws -> Core.System.Status { + try await decode(DockerInfo.self, DockerCommands.systemStatus, "docker info").systemStatus + } + + func networks() async throws -> [Core.Network.Resource] { + try await decodeLines(DockerNetworkRow.self, DockerCommands.networkList(), "docker network ls") + .map { $0.coreNetwork() } + } + + func volumes() async throws -> [Core.Volume.Resource] { + try await decodeLines(DockerVolumeRow.self, DockerCommands.volumeList(), "docker volume ls") + .map { $0.coreVolume() } + } + + func images() async throws -> [Core.Image.Resource] { + try await decodeLines(DockerImageListRow.self, DockerCommands.imageList(), "docker image ls") + .compactMap { $0.coreImage() } + } + + func inspectImage(_ ref: String) async throws -> [Core.Image.Resource] { + try await decode([DockerImageInspect].self, DockerCommands.imageInspect([ref]), "docker image inspect") + .flatMap { $0.coreImages(fallbackReference: ref) } + } + + // MARK: Streaming + + func streamLogs(id: String, follow: Bool = true, tail: Int? = 200, boot: Bool = false) + -> AsyncThrowingStream { + runner.stream(DockerCommands.logs(id, follow: follow, tail: tail)) + } + + func streamPull(_ ref: String, platform: String? = nil) -> AsyncThrowingStream { + runner.stream(DockerCommands.imagePull(ref, platform: platform)) + } + + func streamBuild(context: String, tag: String? = nil, dockerfile: String? = nil, + buildArgs: [String: String] = [:], noCache: Bool = false, + platform: String? = nil) -> AsyncThrowingStream { + runner.stream(DockerCommands.build(context: context, + tag: tag, + dockerfile: dockerfile, + buildArgs: buildArgs, + noCache: noCache, + platform: platform)) + } + + func streamPush(_ ref: String, platform: String? = nil) -> AsyncThrowingStream { + runner.stream(DockerCommands.imagePush(ref, platform: platform)) + } + + @discardableResult func runContainer(arguments: [String]) async throws -> Data { + try await runner.run(arguments) + } + + @discardableResult func performSystemAction(_ action: Core.Runtime.SystemAction) async throws -> Data { + throw unsupported(.systemStatus) + } + + // MARK: Registries + + func registries() async throws -> [Core.Registry.Login] { [] } + + @discardableResult func registryLogin(server: String, username: String, password: String) async throws -> Data { + try await runner.run(DockerCommands.registryLogin(server: server, username: username), + stdin: Data(password.utf8)) + } + + @discardableResult func registryLogout(server: String) async throws -> Data { + try await runner.run(DockerCommands.registryLogout(server: server)) + } + + // MARK: Image writes + + @discardableResult func deleteImages(_ refs: [String]) async throws -> Data { + try await runner.run(DockerCommands.imageDelete(refs)) + } + @discardableResult func tagImage(source: String, target: String) async throws -> Data { + try await runner.run(DockerCommands.imageTag(source: source, target: target)) + } + @discardableResult func saveImages(_ refs: [String], to output: String) async throws -> Data { + try await runner.run(DockerCommands.imageSave(refs: refs, output: output)) + } + @discardableResult func loadImages(from input: String) async throws -> Data { + try await runner.run(DockerCommands.imageLoad(input: input)) + } + @discardableResult func exportContainer(_ id: String, to output: String) async throws -> Data { + try await runner.run(DockerCommands.containerExport(id, output: output)) + } + @discardableResult func pruneImages(all: Bool = false) async throws -> Data { + try await runner.run(DockerCommands.imagePrune(all: all)) + } + + // MARK: Lifecycle + + @discardableResult func start(_ ids: [String]) async throws -> Data { + try await runner.run(DockerCommands.start(ids)) + } + @discardableResult func stop(_ ids: [String]) async throws -> Data { + try await runner.run(DockerCommands.stop(ids)) + } + @discardableResult func deleteContainers(_ ids: [String], force: Bool) async throws -> Data { + try await runner.run(DockerCommands.deleteContainers(ids, force: force)) + } + @discardableResult func pruneContainers() async throws -> Data { + try await runner.run(DockerCommands.containerPrune()) + } + @discardableResult func pruneVolumes() async throws -> Data { + try await runner.run(DockerCommands.volumePrune()) + } + @discardableResult func pruneNetworks() async throws -> Data { + try await runner.run(DockerCommands.networkPrune()) + } + + // MARK: Infra writes + + @discardableResult func createVolume(name: String, size: String? = nil, + labels: [String: String] = [:]) async throws -> Data { + try await runner.run(DockerCommands.volumeCreate(name: name, size: size, labels: labels)) + } + @discardableResult func deleteVolumes(_ names: [String]) async throws -> Data { + try await runner.run(DockerCommands.volumeDelete(names)) + } + @discardableResult func createNetwork(name: String, subnet: String? = nil, internalOnly: Bool = false, + labels: [String: String] = [:]) async throws -> Data { + try await runner.run(DockerCommands.networkCreate(name: name, + subnet: subnet, + internalOnly: internalOnly, + labels: labels)) + } + @discardableResult func deleteNetworks(_ names: [String]) async throws -> Data { + try await runner.run(DockerCommands.networkDelete(names)) + } + + // MARK: Helpers + + private func decode(_ type: T.Type, + _ args: [String], + _ name: String, + priority: Core.Command.ExecutionPriority = .userInitiated) async throws -> T { + let data = try await runner.run(args, stdin: nil, priority: priority) + do { + return try DockerJSON.decode(type, from: data) + } catch { + throw Core.Command.Error.decodingFailed(underlying: String(describing: error), command: name) + } + } + + private func decodeLines(_ type: T.Type, + _ args: [String], + _ name: String, + priority: Core.Command.ExecutionPriority = .userInitiated) async throws -> [T] { + let data = try await runner.run(args, stdin: nil, priority: priority) + do { + return try DockerJSON.decodeJSONLines(type, from: data) + } catch { + throw Core.Command.Error.decodingFailed(underlying: String(describing: error), command: name) + } + } + + private func unsupported(_ capability: Core.Runtime.Capability) -> Core.Runtime.UnsupportedCapability { + Core.Runtime.UnsupportedCapability(kind: descriptor.kind, capability: capability) + } +} + +extension DockerClient: ContainerRuntimeClient {} + +private struct DockerSystemDFRow: Decodable { + var type: String + var totalCountString: String? + var activeString: String? + var sizeString: String? + var reclaimableString: String? + + enum CodingKeys: String, CodingKey { + case type = "Type" + case totalCountString = "TotalCount" + case activeString = "Active" + case sizeString = "Size" + case reclaimableString = "Reclaimable" + } + + var totalCount: Int { Int(totalCountString ?? "") ?? 0 } + var activeCount: Int { Int(activeString ?? "") ?? 0 } + var sizeBytes: UInt64 { parseByteSpec(sizeString ?? "") ?? 0 } + var reclaimableBytes: UInt64 { + let first = (reclaimableString ?? "").split(separator: " ").first.map(String.init) ?? "" + return parseByteSpec(first) ?? 0 + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Client/DockerModels.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Client/DockerModels.swift new file mode 100644 index 00000000..ea89991b --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Client/DockerModels.swift @@ -0,0 +1,666 @@ +import Foundation + +enum DockerJSON { + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + if let date = Core.Container.JSON.parseDate(raw) { return date } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unrecognized date: \(raw)") + } + return decoder + }() + + static func decode(_ type: T.Type, from data: Data) throws -> T { + try decoder.decode(type, from: data) + } + + static func decodeJSONLines(_ type: T.Type, from data: Data) throws -> [T] { + try String(decoding: data, as: UTF8.self) + .split(separator: "\n") + .map { Data($0.utf8) } + .map { try decoder.decode(type, from: $0) } + } +} + +struct DockerContainerInspect: Decodable { + var id: String + var name: String? + var config: Config + var state: State? + var hostConfig: HostConfig? + var networkSettings: NetworkSettings? + var mounts: [Mount]? + var platform: String? + var created: Date? + + enum CodingKeys: String, CodingKey { + case id = "Id" + case name = "Name" + case config = "Config" + case state = "State" + case hostConfig = "HostConfig" + case networkSettings = "NetworkSettings" + case mounts = "Mounts" + case platform = "Platform" + case created = "Created" + } + + struct Config: Decodable { + var image: String? + var cmd: [String]? + var entrypoint: DockerStringList? + var env: [String]? + var workingDir: String? + var user: String? + var labels: [String: String]? + var tty: Bool? + + enum CodingKeys: String, CodingKey { + case image = "Image" + case cmd = "Cmd" + case entrypoint = "Entrypoint" + case env = "Env" + case workingDir = "WorkingDir" + case user = "User" + case labels = "Labels" + case tty = "Tty" + } + } + + struct State: Decodable { + var status: String? + var running: Bool? + var startedAt: String? + + enum CodingKeys: String, CodingKey { + case status = "Status" + case running = "Running" + case startedAt = "StartedAt" + } + } + + struct HostConfig: Decodable { + var networkMode: String? + var portBindings: [String: [PortBinding]?]? + var readonlyRootfs: Bool? + var initProcess: Bool? + var shmSize: UInt64? + var capAdd: [String]? + var capDrop: [String]? + var runtime: String? + + enum CodingKeys: String, CodingKey { + case networkMode = "NetworkMode" + case portBindings = "PortBindings" + case readonlyRootfs = "ReadonlyRootfs" + case initProcess = "Init" + case shmSize = "ShmSize" + case capAdd = "CapAdd" + case capDrop = "CapDrop" + case runtime = "Runtime" + } + } + + struct PortBinding: Decodable { + var hostIP: String? + var hostPort: String? + + enum CodingKeys: String, CodingKey { + case hostIP = "HostIp" + case hostPort = "HostPort" + } + } + + struct NetworkSettings: Decodable { + var networks: [String: Network]? + + enum CodingKeys: String, CodingKey { case networks = "Networks" } + } + + struct Network: Decodable { + var ipAddress: String? + var gateway: String? + var globalIPv6Address: String? + var macAddress: String? + + enum CodingKeys: String, CodingKey { + case ipAddress = "IPAddress" + case gateway = "Gateway" + case globalIPv6Address = "GlobalIPv6Address" + case macAddress = "MacAddress" + } + } + + struct Mount: Decodable { + var type: String? + var source: String? + var destination: String? + var rw: Bool? + + enum CodingKeys: String, CodingKey { + case type = "Type" + case source = "Source" + case destination = "Destination" + case rw = "RW" + } + } + + func coreSnapshot() throws -> Core.Container.Snapshot { + let status = mappedStatus + let name = normalizedName + let reference = config.image ?? "" + let command = (config.entrypoint?.values ?? []) + (config.cmd ?? []) + let platformParts = parsedPlatform() + let payload = DockerContainerSnapshotPayload( + runtimeKind: Core.Runtime.Kind.docker, + id: name.isEmpty ? shortID : name, + status: .init(state: status.rawValue, + networks: networkStatuses(), + startedDate: parsedStartedDate()), + configuration: .init(runtimeKind: Core.Runtime.Kind.docker, + id: name.isEmpty ? shortID : name, + image: .init(reference: reference), + initProcess: .init(executable: command.first, + arguments: command, + environment: config.env ?? [], + workingDirectory: nilIfEmpty(config.workingDir), + terminal: config.tty ?? false), + resources: .init(cpus: 0, + memoryInBytes: 0, + cpuOverhead: nil, + storage: nil), + platform: .init(architecture: platformParts.architecture, + os: platformParts.os, + variant: platformParts.variant), + labels: config.labels ?? [:], + mounts: mountPayloads(), + networks: networkAttachments(), + publishedPorts: publishedPorts(), + publishedSockets: [], + dns: nil, + sysctls: [:], + capAdd: hostConfig?.capAdd ?? [], + capDrop: hostConfig?.capDrop ?? [], + rosetta: false, + runtimeHandler: nilIfEmpty(hostConfig?.runtime), + ssh: false, + readOnly: hostConfig?.readonlyRootfs ?? false, + useInit: hostConfig?.initProcess ?? false, + virtualization: false, + shmSize: hostConfig?.shmSize, + stopSignal: nil, + creationDate: created)) + let data = try Core.Container.JSON.encoder.encode(payload) + return try Core.Container.JSON.decode(Core.Container.Snapshot.self, from: data) + } + + private var normalizedName: String { + (name ?? "").trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + private var shortID: String { + String(id.prefix(12)) + } + + private var mappedStatus: Core.Runtime.Status { + if state?.running == true { return .running } + switch (state?.status ?? "").lowercased() { + case "created", "paused", "exited", "dead": return .stopped + case "running": return .running + case "restarting": return .running + case "removing": return .stopping + default: return .unknown + } + } + + private func parsedStartedDate() -> Date? { + guard let raw = state?.startedAt, !raw.isEmpty, !raw.hasPrefix("0001-") else { return nil } + return Core.Container.JSON.parseDate(raw) + } + + private func parsedPlatform() -> (os: String, architecture: String, variant: String?) { + let parts = (platform ?? "linux/unknown").split(separator: "/").map(String.init) + return (parts.first ?? "linux", parts.dropFirst().first ?? "unknown", parts.dropFirst(2).first) + } + + private func networkStatuses() -> [DockerContainerSnapshotPayload.Status.Network] { + (networkSettings?.networks ?? [:]).keys.sorted().map { key in + let value = networkSettings?.networks?[key] + return .init(network: key, + hostname: nil, + ipv4Address: nilIfEmpty(value?.ipAddress), + ipv4Gateway: nilIfEmpty(value?.gateway), + ipv6Address: nilIfEmpty(value?.globalIPv6Address), + macAddress: nilIfEmpty(value?.macAddress), + mtu: nil) + } + } + + private func networkAttachments() -> [DockerContainerSnapshotPayload.Configuration.NetworkAttachment] { + let networks = networkSettings?.networks?.keys.sorted() ?? [] + if !networks.isEmpty { + return networks.map { .init(network: $0) } + } + guard let mode = hostConfig?.networkMode, !mode.isEmpty else { return [] } + return [.init(network: mode)] + } + + private func mountPayloads() -> [DockerContainerSnapshotPayload.Configuration.Mount] { + (mounts ?? []).compactMap { mount in + guard let destination = mount.destination else { return nil } + return .init(type: mount.type, + source: mount.source, + destination: destination, + target: destination, + readonly: mount.rw.map { !$0 }) + } + } + + private func publishedPorts() -> [DockerContainerSnapshotPayload.Configuration.PublishedPort] { + (hostConfig?.portBindings ?? [:]).flatMap { key, bindings -> [DockerContainerSnapshotPayload.Configuration.PublishedPort] in + let parts = key.split(separator: "/").map(String.init) + guard let containerPort = Int(parts.first ?? "") else { return [] } + let proto = parts.dropFirst().first ?? "tcp" + return (bindings ?? []).compactMap { binding in + guard let host = binding.hostPort, let hostPort = Int(host) else { return nil } + return .init(containerPort: containerPort, + hostPort: hostPort, + hostAddress: nilIfEmpty(binding.hostIP), + proto: proto, + count: nil) + } + } + } +} + +struct DockerImageListRow: Decodable { + var repository: String + var tag: String + var digest: String + var id: String + + enum CodingKeys: String, CodingKey { + case repository = "Repository" + case tag = "Tag" + case digest = "Digest" + case id = "ID" + } + + var reference: String? { + guard repository != "", tag != "" else { return nil } + return "\(repository):\(tag)" + } + + func coreImage() -> Core.Image.Resource? { + guard let reference else { return nil } + let descriptor = digest == "" ? nil : Core.Container.Descriptor(digest: digest, mediaType: nil, size: nil) + let configuration = Core.Image.Configuration(name: reference, descriptor: descriptor, creationDate: nil) + return Core.Image.Resource(configuration: configuration, + id: id, + variants: [], + runtimeKind: .docker) + } +} + +struct DockerImageInspect: Decodable { + var id: String + var repoTags: [String]? + var repoDigests: [String]? + var created: Date? + var architecture: String? + var os: String? + var variant: String? + var config: Config? + + enum CodingKeys: String, CodingKey { + case id = "Id" + case repoTags = "RepoTags" + case repoDigests = "RepoDigests" + case created = "Created" + case architecture = "Architecture" + case os = "Os" + case variant = "Variant" + case config = "Config" + } + + struct Config: Decodable { + var cmd: [String]? + var entrypoint: DockerStringList? + var env: [String]? + var workingDir: String? + var user: String? + + enum CodingKeys: String, CodingKey { + case cmd = "Cmd" + case entrypoint = "Entrypoint" + case env = "Env" + case workingDir = "WorkingDir" + case user = "User" + } + } + + func coreImages(fallbackReference: String) -> [Core.Image.Resource] { + let refs = (repoTags ?? []).isEmpty ? [fallbackReference] : (repoTags ?? []) + let digest = repoDigests?.compactMap { $0.split(separator: "@").dropFirst().first.map(String.init) }.first + return refs.map { reference in + let descriptor = digest.map { Core.Container.Descriptor(digest: $0, mediaType: nil, size: nil) } + let platform = Core.Container.Platform(architecture: architecture ?? "unknown", + os: os ?? "linux", + variant: nilIfEmpty(variant)) + let oci = Core.Image.VariantConfig.OCIConfig(cmd: config?.cmd, + entrypoint: config?.entrypoint?.values, + env: config?.env, + workingDir: nilIfEmpty(config?.workingDir), + user: nilIfEmpty(config?.user)) + let variant = Core.Image.Variant(digest: digest ?? id, + size: nil, + platform: platform, + config: Core.Image.VariantConfig(architecture: architecture, + os: os, + created: created, + config: oci, + history: nil, + rootfs: nil)) + return Core.Image.Resource(configuration: Core.Image.Configuration(name: reference, + descriptor: descriptor, + creationDate: created), + id: id, + variants: [variant], + runtimeKind: .docker) + } + } +} + +struct DockerNetworkRow: Decodable { + var id: String + var name: String + var driver: String? + var labels: String? + + enum CodingKeys: String, CodingKey { + case id = "ID" + case name = "Name" + case driver = "Driver" + case labels = "Labels" + } + + func coreNetwork() -> Core.Network.Resource { + Core.Network.Resource(configuration: Core.Network.Configuration(name: name, + mode: nil, + plugin: driver, + creationDate: nil, + labels: parseLabels(labels), + options: nil), + id: id, + status: nil, + runtimeKind: .docker) + } +} + +struct DockerVolumeRow: Decodable { + var name: String + var driver: String? + var labels: String? + + enum CodingKeys: String, CodingKey { + case name = "Name" + case driver = "Driver" + case labels = "Labels" + } + + func coreVolume() -> Core.Volume.Resource { + Core.Volume.Resource(configuration: Core.Volume.Configuration(name: name, + source: nil, + format: driver, + sizeInBytes: nil, + creationDate: nil, + labels: parseLabels(labels)), + runtimeKind: .docker) + } +} + +struct DockerInfo: Decodable { + var serverVersion: String? + var operatingSystem: String? + var architecture: String? + var ncpu: Int? + var memTotal: UInt64? + + enum CodingKeys: String, CodingKey { + case serverVersion = "ServerVersion" + case operatingSystem = "OperatingSystem" + case architecture = "Architecture" + case ncpu = "NCPU" + case memTotal = "MemTotal" + } + + var systemStatus: Core.System.Status { + Core.System.Status(status: "running", + appRoot: nil, + installRoot: nil, + apiServerVersion: serverVersion, + apiServerCommit: nil, + apiServerBuild: nil, + apiServerAppName: "Docker") + } + + var systemProperties: Core.System.Properties { + Core.System.Properties(build: nil, + container: nil, + machine: .init(cpus: ncpu, memory: memTotal.map(String.init)), + kernel: nil) + } +} + +struct DockerStatsRow: Decodable { + var id: String? + var container: String? + var name: String? + var cpuPerc: String? + var memUsage: String? + var netIO: String? + var blockIO: String? + var pids: String? + + enum CodingKeys: String, CodingKey { + case id = "ID" + case container = "Container" + case name = "Name" + case cpuPerc = "CPUPerc" + case memUsage = "MemUsage" + case netIO = "NetIO" + case blockIO = "BlockIO" + case pids = "PIDs" + } + + var snapshot: Core.Metrics.RuntimeStatsSnapshot { + let memory = splitPair(memUsage).map { (parseByteSpec($0.0), parseByteSpec($0.1)) } + let net = splitPair(netIO).map { (parseByteSpec($0.0), parseByteSpec($0.1)) } + let block = splitPair(blockIO).map { (parseByteSpec($0.0), parseByteSpec($0.1)) } + return Core.Metrics.RuntimeStatsSnapshot( + id: name ?? container ?? id ?? "", + cpuCoreFraction: parsePercent(cpuPerc).map { $0 / 100 }, + memoryUsageBytes: memory?.0, + memoryLimitBytes: memory?.1, + blockReadBytes: block?.0, + blockWriteBytes: block?.1, + networkRxBytes: net?.0, + networkTxBytes: net?.1, + numProcesses: pids.flatMap { UInt64($0.trimmingCharacters(in: .whitespacesAndNewlines)) } + ) + } +} + +enum DockerStringList: Decodable, Hashable { + case string(String) + case list([String]) + + var values: [String] { + switch self { + case .string(let value): return value.isEmpty ? [] : [value] + case .list(let values): return values + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let list = try? container.decode([String].self) { + self = .list(list) + } else if let string = try? container.decode(String.self) { + self = .string(string) + } else { + self = .list([]) + } + } +} + +private struct DockerContainerSnapshotPayload: Encodable { + var runtimeKind: Core.Runtime.Kind + var id: String + var status: Status + var configuration: Configuration + + struct Status: Encodable { + var state: String + var networks: [Network] + var startedDate: Date? + + struct Network: Encodable { + var network: String + var hostname: String? + var ipv4Address: String? + var ipv4Gateway: String? + var ipv6Address: String? + var macAddress: String? + var mtu: Int? + } + } + + struct Configuration: Encodable { + var runtimeKind: Core.Runtime.Kind + var id: String + var image: Image + var initProcess: InitProcess + var resources: Resources + var platform: Platform + var labels: [String: String] + var mounts: [Mount] + var networks: [NetworkAttachment] + var publishedPorts: [PublishedPort] + var publishedSockets: [PublishedSocket] + var dns: DNS? + var sysctls: [String: String] + var capAdd: [String] + var capDrop: [String] + var rosetta: Bool + var runtimeHandler: String? + var ssh: Bool + var readOnly: Bool + var useInit: Bool + var virtualization: Bool + var shmSize: UInt64? + var stopSignal: String? + var creationDate: Date? + + struct Image: Encodable { var reference: String } + struct InitProcess: Encodable { + var executable: String? + var arguments: [String] + var environment: [String] + var workingDirectory: String? + var terminal: Bool + } + struct Resources: Encodable { + var cpus: Int + var memoryInBytes: UInt64 + var cpuOverhead: Int? + var storage: UInt64? + } + struct Platform: Encodable { + var architecture: String + var os: String + var variant: String? + } + struct Mount: Encodable { + var type: String? + var source: String? + var destination: String? + var target: String? + var readonly: Bool? + } + struct NetworkAttachment: Encodable { + var network: String + var options: Options? = nil + struct Options: Encodable { + var hostname: String? + var mtu: Int? + } + } + struct PublishedPort: Encodable { + var containerPort: Int + var hostPort: Int + var hostAddress: String? + var proto: String? + var count: Int? + } + struct PublishedSocket: Encodable { + var hostPath: String? + var containerPath: String? + } + struct DNS: Encodable { + var nameservers: [String] + var searchDomains: [String] + var options: [String] + var domain: String? + } + } +} + +private func nilIfEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value +} + +private func parseLabels(_ raw: String?) -> [String: String] { + guard let raw, !raw.isEmpty else { return [:] } + return raw.split(separator: ",").reduce(into: [:]) { result, part in + let pieces = part.split(separator: "=", maxSplits: 1).map(String.init) + guard let key = pieces.first, !key.isEmpty else { return } + result[key] = pieces.count > 1 ? pieces[1] : "" + } +} + +private func splitPair(_ raw: String?) -> (String, String)? { + guard let raw else { return nil } + let parts = raw.components(separatedBy: " / ") + guard parts.count == 2 else { return nil } + return (parts[0], parts[1]) +} + +private func parsePercent(_ raw: String?) -> Double? { + guard let raw else { return nil } + return Double(raw.replacingOccurrences(of: "%", with: "").trimmingCharacters(in: .whitespaces)) +} + +func parseByteSpec(_ raw: String) -> UInt64? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let range = trimmed.range(of: #"^[0-9]+(\.[0-9]+)?"#, options: .regularExpression), + let number = Double(trimmed[range]) else { return nil } + let suffix = trimmed[range.upperBound...].trimmingCharacters(in: .whitespaces).lowercased() + let multiplier: Double + switch suffix { + case "b", "": multiplier = 1 + case "kb": multiplier = 1_000 + case "mb": multiplier = 1_000_000 + case "gb": multiplier = 1_000_000_000 + case "tb": multiplier = 1_000_000_000_000 + case "kib": multiplier = 1_024 + case "mib": multiplier = 1_048_576 + case "gib": multiplier = 1_073_741_824 + case "tib": multiplier = 1_099_511_627_776 + default: multiplier = 1 + } + return UInt64(number * multiplier) +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Command/DockerCommands.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Command/DockerCommands.swift new file mode 100644 index 00000000..217190fb --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Command/DockerCommands.swift @@ -0,0 +1,221 @@ +import Foundation + +/// Pure builders for Docker `docker` argument vectors. +enum DockerCommands { + static let jsonTemplate = "{{json .}}" + + // MARK: Containers + + static func containerIDs(all: Bool) -> [String] { + var args = ["container", "ls", "--no-trunc", "--quiet"] + if all { args.insert("--all", at: 2) } + return args + } + + static func inspectContainers(_ ids: [String]) -> [String] { + ["container", "inspect"] + ids + } + + static func stats(ids: [String] = [], noStream: Bool = true) -> [String] { + var args = ["stats"] + if noStream { args.append("--no-stream") } + args += ["--format", jsonTemplate] + args += ids + return args + } + + static func start(_ ids: [String]) -> [String] { ["container", "start"] + ids } + static func stop(_ ids: [String]) -> [String] { ["container", "stop"] + ids } + static func deleteContainers(_ ids: [String], force: Bool) -> [String] { + var args = ["container", "rm"] + if force { args.append("--force") } + return args + ids + } + static func containerPrune() -> [String] { ["container", "prune", "--force"] } + static func exec(_ id: String, _ command: [String]) -> [String] { + ["container", "exec", id] + command + } + static func execInteractive(_ id: String, shell: String) -> [String] { + ["container", "exec", "--interactive", "--tty", id, shell] + } + static func containerExport(_ id: String, output: String) -> [String] { + ["container", "export", "--output", output, id] + } + static func copy(source: String, destination: String) -> [String] { + ["container", "cp", source, destination] + } + static func logs(_ id: String, follow: Bool = false, tail: Int? = nil) -> [String] { + var args = ["container", "logs"] + if follow { args.append("--follow") } + if let tail { args += ["--tail", String(tail)] } + args.append(id) + return args + } + + static func run(_ request: Core.Container.CreateRequest) -> [String] { + var args = ["container", "run"] + if request.detach { args.append("--detach") } + if request.removeOnExit { args.append("--rm") } + if request.interactive { args.append("--interactive") } + if request.tty { args.append("--tty") } + if !request.name.isEmpty { args += ["--name", request.name] } + if !request.entrypoint.isEmpty { args += ["--entrypoint", request.entrypoint] } + if !request.platform.isEmpty { args += ["--platform", request.platform] } + if !request.cpus.isEmpty { args += ["--cpus", request.cpus] } + if !request.memory.isEmpty { args += ["--memory", request.memory] } + if !request.memoryReservation.isEmpty { args += ["--memory-reservation", request.memoryReservation] } + if !request.memorySwapLimit.isEmpty { args += ["--memory-swap", request.memorySwapLimit] } + if !request.memorySwappiness.isEmpty { args += ["--memory-swappiness", request.memorySwappiness] } + if request.oomKillDisable { args.append("--oom-kill-disable") } + if !request.oomScoreAdjust.isEmpty { args += ["--oom-score-adj", request.oomScoreAdjust] } + if !request.cpuShares.isEmpty { args += ["--cpu-shares", request.cpuShares] } + if !request.cpuQuota.isEmpty { args += ["--cpu-quota", request.cpuQuota] } + if !request.cpuPeriod.isEmpty { args += ["--cpu-period", request.cpuPeriod] } + if !request.cpuSet.isEmpty { args += ["--cpuset-cpus", request.cpuSet] } + if !request.cpuRealtimeRuntime.isEmpty { args += ["--cpu-rt-runtime", request.cpuRealtimeRuntime] } + if !request.cpuRealtimePeriod.isEmpty { args += ["--cpu-rt-period", request.cpuRealtimePeriod] } + if !request.workingDir.isEmpty { args += ["--workdir", request.workingDir] } + if !request.user.isEmpty { args += ["--user", request.user] } + if !request.shmSize.isEmpty { args += ["--shm-size", request.shmSize] } + if request.readOnly { args.append("--read-only") } + if request.useInit { args.append("--init") } + if request.privileged { args.append("--privileged") } + if request.publishAll { args.append("--publish-all") } + if !request.cidFile.isEmpty { args += ["--cidfile", request.cidFile] } + if !request.pullPolicy.isEmpty { args += ["--pull", request.pullPolicy] } + if !request.hostname.isEmpty { args += ["--hostname", request.hostname] } + if !request.domainName.isEmpty { args += ["--domainname", request.domainName] } + if !request.macAddress.isEmpty { args += ["--mac-address", request.macAddress] } + if !request.network.isEmpty { args += ["--network", request.network] } + if !request.runtime.isEmpty { args += ["--runtime", request.runtime] } + if !request.stopSignal.isEmpty { args += ["--stop-signal", request.stopSignal] } + if !request.stopGracePeriod.isEmpty { args += ["--stop-timeout", request.stopGracePeriod] } + if !request.gpus.isEmpty { args += ["--gpus", request.gpus] } + if !request.cgroupNamespace.isEmpty { args += ["--cgroupns", request.cgroupNamespace] } + if !request.userNamespace.isEmpty { args += ["--userns", request.userNamespace] } + if !request.pidNamespace.isEmpty { args += ["--pid", request.pidNamespace] } + if !request.ipcNamespace.isEmpty { args += ["--ipc", request.ipcNamespace] } + if !request.utsNamespace.isEmpty { args += ["--uts", request.utsNamespace] } + if !request.blockIOWeight.isEmpty { args += ["--blkio-weight", request.blockIOWeight] } + + for stream in request.attachStreams where !stream.isEmpty { args += ["--attach", stream] } + for host in request.extraHosts where !host.isEmpty { args += ["--add-host", host] } + for port in request.expose where !port.isEmpty { args += ["--expose", port] } + for port in request.ports where port.isValid { args += ["--publish", port.spec] } + for volume in request.volumes where volume.isValid { args += ["--volume", volume.spec] } + for mount in request.mounts where !mount.isEmpty { args += ["--mount", mount] } + for volume in request.volumesFrom where !volume.isEmpty { args += ["--volumes-from", volume] } + for mount in request.tmpfs where !mount.isEmpty { args += ["--tmpfs", mount] } + for file in request.envFiles where !file.isEmpty { args += ["--env-file", file] } + for variable in request.env where variable.isValid { args += ["--env", "\(variable.key)=\(variable.value)"] } + for label in request.allLabelArguments() { args += ["--label", label] } + for file in request.labelFiles where !file.isEmpty { args += ["--label-file", file] } + for cap in request.capAdd where !cap.isEmpty { args += ["--cap-add", cap] } + for cap in request.capDrop where !cap.isEmpty { args += ["--cap-drop", cap] } + for group in request.supplementalGroups where !group.isEmpty { args += ["--group-add", group] } + for option in request.securityOptions where !option.isEmpty { args += ["--security-opt", option] } + for limit in request.ulimits where !limit.isEmpty { args += ["--ulimit", limit] } + for server in request.dns where !server.isEmpty { args += ["--dns", server] } + for domain in request.dnsSearch where !domain.isEmpty { args += ["--dns-search", domain] } + for option in request.dnsOption where !option.isEmpty { args += ["--dns-option", option] } + for entry in request.sysctls where entry.isValid { args += ["--sysctl", "\(entry.key)=\(entry.value)"] } + for entry in request.storageOptions where entry.isValid { args += ["--storage-opt", "\(entry.key)=\(entry.value)"] } + if !request.loggingDriver.isEmpty { args += ["--log-driver", request.loggingDriver] } + for entry in request.loggingOptions where entry.isValid { args += ["--log-opt", "\(entry.key)=\(entry.value)"] } + for device in request.devices where !device.isEmpty { args += ["--device", device] } + + args.append(request.image) + args += request.command + return args + } + + // MARK: Images + + static func imageList() -> [String] { + ["image", "ls", "--digests", "--no-trunc", "--format", jsonTemplate] + } + static func imageInspect(_ refs: [String]) -> [String] { ["image", "inspect"] + refs } + static func imageDelete(_ refs: [String]) -> [String] { ["image", "rm"] + refs } + static func imageTag(source: String, target: String) -> [String] { ["image", "tag", source, target] } + static func imagePrune(all: Bool = false) -> [String] { + var args = ["image", "prune", "--force"] + if all { args.append("--all") } + return args + } + static func imageSave(refs: [String], output: String) -> [String] { + ["image", "save"] + refs + ["--output", output] + } + static func imageLoad(input: String) -> [String] { ["image", "load", "--input", input] } + static func imagePull(_ ref: String, platform: String? = nil) -> [String] { + var args = ["image", "pull"] + if let platform, !platform.isEmpty { args += ["--platform", platform] } + args.append(ref) + return args + } + static func imagePush(_ ref: String, platform: String? = nil) -> [String] { + var args = ["image", "push"] + if let platform, !platform.isEmpty { args += ["--platform", platform] } + args.append(ref) + return args + } + + // MARK: Build + + static func build(context: String, tag: String? = nil, dockerfile: String? = nil, + buildArgs: [String: String] = [:], noCache: Bool = false, + platform: String? = nil) -> [String] { + var args = ["build", "--progress", "plain"] + if let tag, !tag.isEmpty { args += ["--tag", tag] } + if let dockerfile, !dockerfile.isEmpty { args += ["--file", dockerfile] } + for (key, value) in buildArgs.sorted(by: { $0.key < $1.key }) { + args += ["--build-arg", "\(key)=\(value)"] + } + if noCache { args.append("--no-cache") } + if let platform, !platform.isEmpty { args += ["--platform", platform] } + args.append(context) + return args + } + + // MARK: Infra + + static func networkList() -> [String] { + ["network", "ls", "--no-trunc", "--format", jsonTemplate] + } + static func networkInspect(_ names: [String]) -> [String] { ["network", "inspect"] + names } + static func networkCreate(name: String, subnet: String? = nil, internalOnly: Bool = false, + labels: [String: String] = [:]) -> [String] { + var args = ["network", "create"] + if internalOnly { args.append("--internal") } + for (key, value) in labels.sorted(by: { $0.key < $1.key }) { args += ["--label", "\(key)=\(value)"] } + if let subnet, !subnet.isEmpty { args += ["--subnet", subnet] } + args.append(name) + return args + } + static func networkDelete(_ names: [String]) -> [String] { ["network", "rm"] + names } + static func networkPrune() -> [String] { ["network", "prune", "--force"] } + + static func volumeList() -> [String] { + ["volume", "ls", "--format", jsonTemplate] + } + static func volumeInspect(_ names: [String]) -> [String] { ["volume", "inspect"] + names } + static func volumeCreate(name: String, size: String? = nil, labels: [String: String] = [:]) -> [String] { + var args = ["volume", "create"] + for (key, value) in labels.sorted(by: { $0.key < $1.key }) { args += ["--label", "\(key)=\(value)"] } + if let size, !size.isEmpty { args += ["--opt", "size=\(size)"] } + args.append(name) + return args + } + static func volumeDelete(_ names: [String]) -> [String] { ["volume", "rm"] + names } + static func volumePrune() -> [String] { ["volume", "prune", "--force"] } + + // MARK: Registries / System + + static func registryLogin(server: String, username: String) -> [String] { + ["login", "--username", username, "--password-stdin", server] + } + static func registryLogout(server: String) -> [String] { ["logout", server] } + + static let version = ["--version"] + static let systemStatus = ["info", "--format", jsonTemplate] + static let systemDF = ["system", "df", "--format", jsonTemplate] +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Support/DockerCLILocator.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Support/DockerCLILocator.swift new file mode 100644 index 00000000..cf6318d5 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Support/DockerCLILocator.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Finds a Docker CLI. V1 shells out to `docker` and does not manage Docker Desktop. +enum DockerCLILocator { + static let defaultCandidates = [ + "/usr/local/bin/docker", + "/opt/homebrew/bin/docker", + "/usr/bin/docker", + ] + + static func locate(override: String? = nil, + candidates: [String] = defaultCandidates, + fileManager: FileManager = .default) -> URL? { + if let override, !override.isEmpty, fileManager.isExecutableFile(atPath: override) { + return URL(fileURLWithPath: override) + } + for path in candidates where fileManager.isExecutableFile(atPath: path) { + return URL(fileURLWithPath: path) + } + return nil + } + + static func parseVersion(_ output: String) -> String? { + guard let range = output.range(of: #"\d+\.\d+\.\d+"#, options: .regularExpression) else { return nil } + return String(output[range]) + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Translation/DockerCreateTranslator.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Translation/DockerCreateTranslator.swift new file mode 100644 index 00000000..cc9afe50 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Translation/DockerCreateTranslator.swift @@ -0,0 +1,187 @@ +import Foundation + +enum DockerCreateTranslator { + static func preview(for request: Core.Container.CreateRequest) -> Core.Command.Preview { + Core.Command.Preview(command: DockerCommands.run(request)) + } + + static func result(from data: Data, request: Core.Container.CreateRequest) -> Core.Container.CreateResult { + let output = String(decoding: data, as: UTF8.self) + let printedID = output + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .last(where: { !$0.isEmpty }) + return Core.Container.CreateResult(id: request.effectiveName ?? printedID, output: output) + } + + static func composePlan(for project: Core.Compose.Project, + baseDirectory: URL?) -> Core.Compose.ImportPlan { + let items = project.services.compactMap { service -> Core.Compose.ImportItem? in + guard service.image != nil else { return nil } + var document = Core.Schema.Document.containerCreate(from: createRequest(for: service, + projectName: project.name, + baseDirectory: baseDirectory)) + for path in document.values.keys where !(document.values[path] ?? .string("")).isEmpty { + document.provenance.sources[path] = .compose + } + for (path, value) in service.preservedFields { + document.values[path] = value + document.provenance.sources[path] = .compose + } + return Core.Compose.ImportItem(document: document, + healthCheck: healthCheck(for: service)) + } + return Core.Compose.ImportPlan(items: items, warnings: project.warnings) + } + + static func imageDefaults(for request: Core.Container.CreateRequest, + in images: [Core.Image.Resource]) -> Core.Container.ImageDefaults? { + AppleContainerCreateTranslator.imageDefaults(for: request, in: images) + } + + private static func createRequest(for service: Core.Compose.Service, + projectName: String, + baseDirectory: URL?) -> Core.Container.CreateRequest { + var request = Core.Container.CreateRequest() + request.runtimeKind = .docker + request.image = service.image ?? "" + request.platform = service.platform ?? "" + request.name = service.name + request.command = splitCommand(service.command) + request.entrypoint = service.entrypoint ?? "" + request.detach = true + request.interactive = service.interactive + request.tty = service.tty + request.restart = Core.Container.RestartPolicy(label: service.restart) + request.cpus = service.cpus ?? "" + request.memory = service.memory ?? "" + request.readOnly = service.readOnly + request.useInit = service.initProcess + request.workingDir = service.workingDir ?? "" + request.user = service.user ?? "" + request.capAdd = service.capAdd + request.capDrop = service.capDrop + request.network = service.networkMode == "host" ? "host" : (service.network ?? "") + request.dns = service.dns + request.dnsSearch = service.dnsSearch + request.dnsOption = service.dnsOptions + request.tmpfs = service.tmpfs + request.ulimits = service.ulimits + request.ports = service.ports.compactMap(portMap) + request.volumes = service.volumes.compactMap { volumeMap($0, baseDirectory: baseDirectory) } + request.env = service.environment.compactMap(keyValue) + request.envFiles = service.envFiles + request.labels = service.labels.compactMap(keyValue) + request.labels.append(Core.Container.KeyValue(key: "contained.stack", value: projectName)) + request.extraHosts = strings(from: service.preservedFields[.networkExtraHosts]) + request.hostname = string(from: service.preservedFields[.networkHostname]) + request.domainName = string(from: service.preservedFields[.networkDomainName]) + request.macAddress = string(from: service.preservedFields[.networkMacAddress]) + request.expose = strings(from: service.preservedFields[.networkExpose]) + request.pullPolicy = string(from: service.preservedFields[.imagePullPolicy]) + request.attachStreams = strings(from: service.preservedFields[.processAttachStreams]) + request.loggingDriver = string(from: service.preservedFields[.loggingDriver]) + request.loggingOptions = keyValues(from: service.preservedFields[.loggingOptions]) + request.labelFiles = strings(from: service.preservedFields[.metadataLabelFiles]) + request.stopSignal = string(from: service.preservedFields[.lifecycleStopSignal]) + request.stopGracePeriod = string(from: service.preservedFields[.lifecycleStopGracePeriod]) + request.devices = strings(from: service.preservedFields[.devices]) + request.gpus = string(from: service.preservedFields[.gpus]) + request.supplementalGroups = strings(from: service.preservedFields[.processSupplementalGroups]) + request.privileged = bool(from: service.preservedFields[.securityPrivileged]) + request.securityOptions = strings(from: service.preservedFields[.securityOptions]) + request.sysctls = keyValues(from: service.preservedFields[.kernelSysctls]) + request.cgroupNamespace = string(from: service.preservedFields[.namespaceCgroup]) + request.userNamespace = string(from: service.preservedFields[.namespaceUser]) + request.pidNamespace = string(from: service.preservedFields[.namespacePID]) + request.ipcNamespace = string(from: service.preservedFields[.namespaceIPC]) + request.utsNamespace = string(from: service.preservedFields[.namespaceUTS]) + request.cpuShares = string(from: service.preservedFields[.resourcesCPUShares]) + request.cpuQuota = string(from: service.preservedFields[.resourcesCPUQuota]) + request.cpuPeriod = string(from: service.preservedFields[.resourcesCPUPeriod]) + request.cpuSet = string(from: service.preservedFields[.resourcesCPUSet]) + request.cpuRealtimeRuntime = string(from: service.preservedFields[.resourcesCPURealtimeRuntime]) + request.cpuRealtimePeriod = string(from: service.preservedFields[.resourcesCPURealtimePeriod]) + request.memoryReservation = string(from: service.preservedFields[.resourcesMemoryReservation]) + request.memorySwapLimit = string(from: service.preservedFields[.resourcesMemorySwapLimit]) + request.memorySwappiness = string(from: service.preservedFields[.resourcesMemorySwappiness]) + request.oomKillDisable = bool(from: service.preservedFields[.resourcesOOMKillDisable]) + request.oomScoreAdjust = string(from: service.preservedFields[.resourcesOOMScoreAdjust]) + request.blockIOWeight = string(from: service.preservedFields[.resourcesBlockIO]) + request.storageOptions = keyValues(from: service.preservedFields[.storageOptions]) + request.volumesFrom = strings(from: service.preservedFields[.storageVolumesFrom]) + return request + } + + private static func healthCheck(for service: Core.Compose.Service) -> Core.Container.HealthCheck? { + guard let healthcheck = service.healthcheck else { return nil } + return Core.Container.HealthCheck(command: healthcheck.test, + intervalSeconds: healthcheck.intervalSeconds, + retries: healthcheck.retries, + enabled: true) + } + + private static func splitCommand(_ command: String?) -> [String] { + guard let command else { return [] } + return command.split(separator: " ").map(String.init) + } + + private static func portMap(_ spec: String) -> Core.Container.Port? { + var raw = spec + let proto: String + if let slash = raw.lastIndex(of: "/") { + proto = String(raw[raw.index(after: slash)...]) + raw = String(raw[..= 2 else { return nil } + let host = parts.dropLast().joined(separator: ":") + let container = parts[parts.count - 1] + guard !host.isEmpty, !container.isEmpty else { return nil } + return Core.Container.Port(hostPort: host, containerPort: container, proto: proto) + } + + private static func volumeMap(_ spec: String, baseDirectory: URL?) -> Core.Container.VolumeMount? { + let parts = spec.split(separator: ":", maxSplits: 2).map(String.init) + guard parts.count > 1 else { return nil } + var source = parts.first ?? "" + if let baseDirectory, source.hasPrefix("./") || source.hasPrefix("../") { + source = baseDirectory.appending(path: source).standardizedFileURL.path + } + return Core.Container.VolumeMount(source: source, + target: parts.count > 1 ? parts[1] : "", + readOnly: parts.count > 2 && parts[2] == "ro") + } + + private static func keyValue(_ entry: String) -> Core.Container.KeyValue? { + guard let eq = entry.firstIndex(of: "=") else { return nil } + return Core.Container.KeyValue(key: String(entry[.. String { + switch value { + case .string(let value), .enumeration(let value): return value + default: return "" + } + } + + private static func strings(from value: Core.Schema.Value?) -> [String] { + switch value { + case .stringList(let values), .commandLine(let values): return values + default: return [] + } + } + + private static func keyValues(from value: Core.Schema.Value?) -> [Core.Container.KeyValue] { + if case .keyValueList(let values) = value { return values } + return [] + } + + private static func bool(from value: Core.Schema.Value?) -> Bool { + if case .bool(let value) = value { return value } + return false + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerDocumentTranslation.swift b/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerDocumentTranslation.swift index 9075605d..05ff0fe3 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerDocumentTranslation.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerDocumentTranslation.swift @@ -69,12 +69,50 @@ public extension Core.Schema.Document { document.set(.registryScheme, .enumeration(request.scheme)) document.set(.progressMode, .enumeration(request.progress)) document.set(.imageMaxConcurrentDownloads, .string(request.maxConcurrentDownloads)) + document.set(.networkExtraHosts, .stringList(request.extraHosts)) + document.set(.networkHostname, .string(request.hostname)) + document.set(.networkDomainName, .string(request.domainName)) + document.set(.networkMacAddress, .string(request.macAddress)) + document.set(.networkExpose, .stringList(request.expose)) + document.set(.networkPublishAll, .bool(request.publishAll)) + document.set(.imagePullPolicy, .string(request.pullPolicy)) + document.set(.processAttachStreams, .stringList(request.attachStreams)) + document.set(.loggingDriver, .string(request.loggingDriver)) + document.set(.loggingOptions, .keyValueList(request.loggingOptions)) + document.set(.metadataLabelFiles, .stringList(request.labelFiles)) + document.set(.lifecycleStopSignal, .string(request.stopSignal)) + document.set(.lifecycleStopGracePeriod, .string(request.stopGracePeriod)) + document.set(.devices, .stringList(request.devices)) + document.set(.gpus, .string(request.gpus)) + document.set(.processSupplementalGroups, .stringList(request.supplementalGroups)) + document.set(.securityPrivileged, .bool(request.privileged)) + document.set(.securityOptions, .stringList(request.securityOptions)) + document.set(.kernelSysctls, .keyValueList(request.sysctls)) + document.set(.namespaceCgroup, .string(request.cgroupNamespace)) + document.set(.namespaceUser, .string(request.userNamespace)) + document.set(.namespacePID, .string(request.pidNamespace)) + document.set(.namespaceIPC, .string(request.ipcNamespace)) + document.set(.namespaceUTS, .string(request.utsNamespace)) + document.set(.resourcesCPUShares, .string(request.cpuShares)) + document.set(.resourcesCPUQuota, .string(request.cpuQuota)) + document.set(.resourcesCPUPeriod, .string(request.cpuPeriod)) + document.set(.resourcesCPUSet, .string(request.cpuSet)) + document.set(.resourcesCPURealtimeRuntime, .string(request.cpuRealtimeRuntime)) + document.set(.resourcesCPURealtimePeriod, .string(request.cpuRealtimePeriod)) + document.set(.resourcesMemoryReservation, .string(request.memoryReservation)) + document.set(.resourcesMemorySwapLimit, .string(request.memorySwapLimit)) + document.set(.resourcesMemorySwappiness, .string(request.memorySwappiness)) + document.set(.resourcesOOMKillDisable, .bool(request.oomKillDisable)) + document.set(.resourcesOOMScoreAdjust, .string(request.oomScoreAdjust)) + document.set(.resourcesBlockIO, .string(request.blockIOWeight)) + document.set(.storageOptions, .keyValueList(request.storageOptions)) + document.set(.storageVolumesFrom, .stringList(request.volumesFrom)) return document } static func containerEdit(from configuration: Core.Container.Configuration) -> Core.Schema.Document { var request = Core.Container.CreateRequest() - request.runtimeKind = .appleContainer + request.runtimeKind = configuration.runtimeKind request.image = configuration.image.reference request.platform = configuration.platform.display request.name = configuration.id @@ -183,6 +221,44 @@ public extension Core.Schema.Document { request.scheme = document.string(.registryScheme, in: definition) request.progress = document.string(.progressMode, in: definition) request.maxConcurrentDownloads = document.string(.imageMaxConcurrentDownloads, in: definition) + request.extraHosts = document.strings(.networkExtraHosts, in: definition) + request.hostname = document.string(.networkHostname, in: definition) + request.domainName = document.string(.networkDomainName, in: definition) + request.macAddress = document.string(.networkMacAddress, in: definition) + request.expose = document.strings(.networkExpose, in: definition) + request.publishAll = document.bool(.networkPublishAll, in: definition) + request.pullPolicy = document.string(.imagePullPolicy, in: definition) + request.attachStreams = document.strings(.processAttachStreams, in: definition) + request.loggingDriver = document.string(.loggingDriver, in: definition) + request.loggingOptions = document.keyValues(.loggingOptions, in: definition) + request.labelFiles = document.strings(.metadataLabelFiles, in: definition) + request.stopSignal = document.string(.lifecycleStopSignal, in: definition) + request.stopGracePeriod = document.string(.lifecycleStopGracePeriod, in: definition) + request.devices = document.strings(.devices, in: definition) + request.gpus = document.string(.gpus, in: definition) + request.supplementalGroups = document.strings(.processSupplementalGroups, in: definition) + request.privileged = document.bool(.securityPrivileged, in: definition) + request.securityOptions = document.strings(.securityOptions, in: definition) + request.sysctls = document.keyValues(.kernelSysctls, in: definition) + request.cgroupNamespace = document.string(.namespaceCgroup, in: definition) + request.userNamespace = document.string(.namespaceUser, in: definition) + request.pidNamespace = document.string(.namespacePID, in: definition) + request.ipcNamespace = document.string(.namespaceIPC, in: definition) + request.utsNamespace = document.string(.namespaceUTS, in: definition) + request.cpuShares = document.string(.resourcesCPUShares, in: definition) + request.cpuQuota = document.string(.resourcesCPUQuota, in: definition) + request.cpuPeriod = document.string(.resourcesCPUPeriod, in: definition) + request.cpuSet = document.string(.resourcesCPUSet, in: definition) + request.cpuRealtimeRuntime = document.string(.resourcesCPURealtimeRuntime, in: definition) + request.cpuRealtimePeriod = document.string(.resourcesCPURealtimePeriod, in: definition) + request.memoryReservation = document.string(.resourcesMemoryReservation, in: definition) + request.memorySwapLimit = document.string(.resourcesMemorySwapLimit, in: definition) + request.memorySwappiness = document.string(.resourcesMemorySwappiness, in: definition) + request.oomKillDisable = document.bool(.resourcesOOMKillDisable, in: definition) + request.oomScoreAdjust = document.string(.resourcesOOMScoreAdjust, in: definition) + request.blockIOWeight = document.string(.resourcesBlockIO, in: definition) + request.storageOptions = document.keyValues(.storageOptions, in: definition) + request.volumesFrom = document.strings(.storageVolumesFrom, in: definition) return request } diff --git a/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerFieldCatalog.swift b/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerFieldCatalog.swift index 1d6d6f36..81746169 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerFieldCatalog.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerFieldCatalog.swift @@ -106,10 +106,32 @@ public extension Core.Schema.Definition { operation: Core.Schema.Operation = .containerCreate) -> Core.Schema.Definition { Core.Schema.Definition(operation: operation, runtimeKind: runtimeKind, - fields: Self.appleContainerRunFields) + fields: Self.runtimeAwareRunFields(initialRuntime: runtimeKind)) } static var appleContainerRunFields: [Core.Schema.FieldDescriptor] { + runtimeAwareRunFields(initialRuntime: .appleContainer) + } + + private static func runtimeAwareRunFields(initialRuntime: Core.Runtime.Kind) -> [Core.Schema.FieldDescriptor] { + appleContainerRunFieldsBase.map { descriptor in + var field = descriptor + if field.path == .runtimeKind { + field.defaultValue = .string(initialRuntime.rawValue) + field.support[.docker] = .supported + field.tipRefs[.docker] = field.tipRefs[.appleContainer] + return field + } + + field.support[.docker] = dockerSupport(for: field) + if field.support[.docker]?.state == .supported, field.tipRefs[.docker] == nil { + field.tipRefs[.docker] = field.tipRefs[.appleContainer] + } + return field + } + } + + private static var appleContainerRunFieldsBase: [Core.Schema.FieldDescriptor] { let supported = Core.Schema.FieldSupport.supported let unsupported = Core.Schema.FieldSupport( state: .disabled, @@ -202,8 +224,8 @@ public extension Core.Schema.Definition { ] return [ - apple(.runtimeKind, .string, .runtime, "Core", defaultValue: .string(Core.Runtime.Kind.appleContainer.rawValue), - tip: "Selects the container runtime Core should use for previewing and running this container."), + apple(.runtimeKind, .string, .runtime, "Runtime", defaultValue: .string(Core.Runtime.Kind.appleContainer.rawValue), + tip: "Selects the container runtime used for previewing and running this container."), apple(.imageReference, .string, .essentials, "Image", defaultValue: .string(""), required: true, flag: "", example: "nginx:latest", docker: ("IMAGE", "docker run nginx:latest"), compose: ("image", "image: nginx:latest"), tip: "The container image to run. If it is not local, Contained pulls it before running."), @@ -337,19 +359,19 @@ public extension Core.Schema.Definition { apple(.imageMaxConcurrentDownloads, .string, .imageFetch, "Max parallel downloads", defaultValue: .string(""), flag: "--max-concurrent-downloads", example: "--max-concurrent-downloads 2", tip: "Limits concurrent image downloads."), - disabled(.networkExtraHosts, .stringList, "Extra hosts", defaultValue: .stringList([]), docker: ("--add-host", "--add-host host.docker.internal=host-gateway"), compose: ("extra_hosts", "extra_hosts: [\"host.docker.internal:host-gateway\"]"), tip: "Adds entries to /etc/hosts in Docker-compatible runtimes."), - disabled(.networkHostname, .string, "Hostname", defaultValue: .string(""), docker: ("--hostname", "--hostname app"), compose: ("hostname", "hostname: app"), tip: "Sets the container hostname in Docker-compatible runtimes."), + disabled(.networkExtraHosts, .stringList, "Extra hosts", defaultValue: .stringList([]), docker: ("--add-host", "--add-host host.docker.internal=host-gateway"), compose: ("extra_hosts", "extra_hosts: [\"host.docker.internal:host-gateway\"]"), tip: "Adds entries to /etc/hosts in Docker runtimes."), + disabled(.networkHostname, .string, "Hostname", defaultValue: .string(""), docker: ("--hostname", "--hostname app"), compose: ("hostname", "hostname: app"), tip: "Sets the container hostname in Docker runtimes."), disabled(.networkDomainName, .string, "Domain name", defaultValue: .string(""), docker: ("--domainname", "--domainname example.test"), compose: ("domainname", "domainname: example.test"), tip: "Sets the container NIS/domain name."), disabled(.networkMacAddress, .string, "MAC address", defaultValue: .string(""), docker: ("--mac-address", "--mac-address 02:42:ac:11:00:02"), compose: ("mac_address", "mac_address: 02:42:ac:11:00:02"), tip: "Requests a fixed MAC address."), disabled(.networkExpose, .stringList, "Expose ports", defaultValue: .stringList([]), docker: ("--expose", "--expose 80"), compose: ("expose", "expose: [80]"), tip: "Exposes container ports without publishing them to the host."), disabled(.networkPublishAll, .bool, "Publish all exposed ports", defaultValue: .bool(false), docker: ("--publish-all", "--publish-all"), tip: "Publishes every exposed port to random host ports."), - disabled(.imagePullPolicy, .string, "Pull policy", defaultValue: .string(""), docker: ("--pull", "--pull=always"), compose: ("pull_policy", "pull_policy: always"), tip: "Controls when Docker-compatible runtimes pull the image."), + disabled(.imagePullPolicy, .string, "Pull policy", defaultValue: .string(""), docker: ("--pull", "--pull=always"), compose: ("pull_policy", "pull_policy: always"), tip: "Controls when Docker runtimes pull the image."), disabled(.processAttachStreams, .stringList, "Attach streams", defaultValue: .stringList([]), docker: ("--attach", "--attach stdout"), compose: ("attach", "attach: false"), tip: "Controls attached STDIN/STDOUT/STDERR streams."), disabled(.loggingDriver, .string, "Logging driver", defaultValue: .string(""), docker: ("--log-driver", "--log-driver syslog"), compose: ("logging.driver", "logging: { driver: syslog }"), tip: "Selects a Docker logging driver."), disabled(.loggingOptions, .keyValueList, "Logging options", defaultValue: .keyValueList([]), docker: ("--log-opt", "--log-opt max-size=10m"), compose: ("logging.options", "logging: { options: { max-size: 10m } }"), tip: "Configures logging driver options."), disabled(.metadataLabelFiles, .stringList, "Label files", defaultValue: .stringList([]), docker: ("--label-file", "--label-file ./labels"), tip: "Loads labels from a file."), disabled(.lifecycleStopSignal, .string, "Stop signal", defaultValue: .string(""), docker: ("--stop-signal", "--stop-signal SIGTERM"), compose: ("stop_signal", "stop_signal: SIGTERM"), tip: "Signal used to stop the container."), - disabled(.lifecycleStopGracePeriod, .string, "Stop grace period", defaultValue: .string(""), compose: ("stop_grace_period", "stop_grace_period: 30s"), tip: "How long Compose waits before force-stopping."), + disabled(.lifecycleStopGracePeriod, .string, "Stop grace period", defaultValue: .string(""), docker: ("--stop-timeout", "--stop-timeout 30"), compose: ("stop_grace_period", "stop_grace_period: 30s"), tip: "How long Compose waits before force-stopping."), disabled(.devices, .stringList, "Devices", defaultValue: .stringList([]), docker: ("--device", "--device /dev/sda:/dev/xvdc"), compose: ("devices", "devices: [/dev/sda:/dev/xvdc]"), tip: "Passes host devices into the container."), disabled(.gpus, .string, "GPUs", defaultValue: .string(""), docker: ("--gpus", "--gpus all"), compose: ("gpus", "gpus: all"), tip: "Requests GPU devices."), disabled(.processSupplementalGroups, .stringList, "Supplemental groups", defaultValue: .stringList([]), docker: ("--group-add", "--group-add audio"), compose: ("group_add", "group_add: [audio]"), tip: "Adds supplemental groups."), @@ -375,8 +397,8 @@ public extension Core.Schema.Definition { disabled(.resourcesBlockIO, .string, "Block I/O", defaultValue: .string(""), docker: ("--blkio-weight", "--blkio-weight 300"), compose: ("blkio_config", "blkio_config: { weight: 300 }"), tip: "Controls block I/O weighting and throttling."), disabled(.storageOptions, .keyValueList, "Storage options", defaultValue: .keyValueList([]), docker: ("--storage-opt", "--storage-opt size=120G"), compose: ("storage_opt", "storage_opt: { size: 120G }"), tip: "Passes storage driver options."), disabled(.storageVolumesFrom, .stringList, "Volumes from", defaultValue: .stringList([]), docker: ("--volumes-from", "--volumes-from db"), compose: ("volumes_from", "volumes_from: [db]"), tip: "Mounts volumes from another container."), - disabled(.composeSecrets, .stringList, "Secrets", defaultValue: .stringList([]), compose: ("secrets", "secrets: [db_password]"), tip: "Compose secrets are preserved for future Docker-compatible runtimes."), - disabled(.composeConfigs, .stringList, "Configs", defaultValue: .stringList([]), compose: ("configs", "configs: [app_config]"), tip: "Compose configs are preserved for future Docker-compatible runtimes."), + disabled(.composeSecrets, .stringList, "Secrets", defaultValue: .stringList([]), compose: ("secrets", "secrets: [db_password]"), tip: "Compose secrets are preserved for future Docker runtimes."), + disabled(.composeConfigs, .stringList, "Configs", defaultValue: .stringList([]), compose: ("configs", "configs: [app_config]"), tip: "Compose configs are preserved for future Docker runtimes."), disabled(.composeProfiles, .stringList, "Profiles", defaultValue: .stringList([]), compose: ("profiles", "profiles: [dev]"), tip: "Compose profiles select which services participate in a run."), disabled(.composeDeploy, .string, "Deploy metadata", defaultValue: .string(""), compose: ("deploy", "deploy: { replicas: 2 }"), tip: "Compose deploy metadata is preserved but not applied by Apple container."), disabled(.composeScale, .string, "Scale", defaultValue: .string(""), compose: ("scale", "scale: 2"), tip: "Compose service scale is preserved for future stack-oriented runtimes."), @@ -387,4 +409,45 @@ public extension Core.Schema.Definition { disabled(.composeUseAPISocket, .bool, "Use API socket", defaultValue: .bool(false), compose: ("use_api_socket", "use_api_socket: true"), tip: "Compose API socket access is preserved for future runtimes."), ] } + + private static func dockerSupport(for descriptor: Core.Schema.FieldDescriptor) -> Core.Schema.FieldSupport { + let unsupported = Core.Schema.FieldSupport( + state: .disabled, + disabledReasonKey: "schema.disabled.docker.unsupportedAppleContainer", + defaultDisabledReason: "Known from Apple container or Compose, not executable by Docker." + ) + let composeOnlyUnsupported = Core.Schema.FieldSupport( + state: .disabled, + disabledReasonKey: "schema.disabled.docker.composeOnly", + defaultDisabledReason: "Compose stack metadata is preserved, but V1 Docker support runs single containers only." + ) + + let sharedDockerPaths: Set = [ + .imageReference, .imagePlatform, .containerName, .processCommand, .processEntrypoint, + .processDetach, .processRemoveOnExit, .processInteractive, .processTTY, + .processWorkingDirectory, .processUser, .processUlimits, .resourcesCPULimit, + .resourcesMemoryLimit, .resourcesSharedMemorySize, .environmentVariables, + .environmentFiles, .networkName, .networkPorts, .storageVolumes, .storageMounts, + .storageTmpfs, .metadataLabels, .lifecycleRestartPolicy, .securityReadOnlyRootFS, + .securityUseInit, .securityCapabilitiesAdd, .securityCapabilitiesDrop, + .outputContainerIDFile, .runtimeHandler, .networkDNSServers, .networkDNSSearchDomains, + .networkDNSOptions, + ] + let appleOnlyPaths: Set = [ + .imageOS, .imageArchitecture, .networkSockets, .networkDNSDisabled, + .networkDNSDomain, .processUserID, .processGroupID, .securityRosetta, + .securitySSHAgent, .securityVirtualization, .imageInitReference, .kernelPath, + .registryScheme, .progressMode, .imageMaxConcurrentDownloads, + ] + let composeOnlyPaths: Set = [ + .composeSecrets, .composeConfigs, .composeProfiles, .composeDeploy, .composeScale, + .composeLinks, .composeDependsOn, .composeProvider, .composeModels, .composeUseAPISocket, + ] + + if sharedDockerPaths.contains(descriptor.path) { return .supported } + if appleOnlyPaths.contains(descriptor.path) { return unsupported } + if composeOnlyPaths.contains(descriptor.path) { return composeOnlyUnsupported } + if descriptor.sourceAliases.contains(where: { $0.source == .dockerCLI }) { return .supported } + return descriptor.support[.appleContainer] ?? unsupported + } } diff --git a/Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift b/Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift index e10922dc..d063ae85 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift @@ -24,6 +24,16 @@ struct Properties: Codable, Sendable, Hashable { public let url: String? } + public init(build: Build? = nil, + container: Defaults? = nil, + machine: Defaults? = nil, + kernel: Kernel? = nil) { + self.build = build + self.container = container + self.machine = machine + self.kernel = kernel + } + public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) build = try c.decodeIfPresent(Build.self, forKey: .build) diff --git a/Packages/ContainedCore/Sources/ContainedCore/Volume/Volume+Resource.swift b/Packages/ContainedCore/Sources/ContainedCore/Volume/Volume+Resource.swift index 12efa719..647fe147 100644 --- a/Packages/ContainedCore/Sources/ContainedCore/Volume/Volume+Resource.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Volume/Volume+Resource.swift @@ -8,9 +8,27 @@ import Foundation public extension Core.Volume { struct Resource: Codable, Sendable, Identifiable, Hashable { public let configuration: Core.Volume.Configuration + public let runtimeKind: Core.Runtime.Kind public var id: String { configuration.name } public var name: String { configuration.name } public var labels: [String: String] { configuration.labels } + public var scopedID: String { runtimeKind.scopedID(for: id) } + + public init(configuration: Core.Volume.Configuration, + runtimeKind: Core.Runtime.Kind = .appleContainer) { + self.configuration = configuration + self.runtimeKind = runtimeKind + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + configuration = try c.decode(Core.Volume.Configuration.self, forKey: .configuration) + runtimeKind = try c.decodeIfPresent(Core.Runtime.Kind.self, forKey: .runtimeKind) ?? .appleContainer + } + + public func scoped(to runtimeKind: Core.Runtime.Kind) -> Core.Volume.Resource { + Core.Volume.Resource(configuration: configuration, runtimeKind: runtimeKind) + } } struct Configuration: Codable, Sendable, Hashable { @@ -21,6 +39,20 @@ struct Configuration: Codable, Sendable, Hashable { public let creationDate: Date? public let labels: [String: String] + public init(name: String, + source: String? = nil, + format: String? = nil, + sizeInBytes: UInt64? = nil, + creationDate: Date? = nil, + labels: [String: String] = [:]) { + self.name = name + self.source = source + self.format = format + self.sizeInBytes = sizeInBytes + self.creationDate = creationDate + self.labels = labels + } + public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) name = try c.decode(String.self, forKey: .name) diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift index 135eaeda..7682b030 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift @@ -117,6 +117,92 @@ struct DecodingTests { #expect(arm64.config?.rootfs?.diffIDs?.isEmpty == false) } + @Test func decodesDockerContainerInspectToCoreSnapshot() throws { + let data = Data(""" + [ + { + "Id": "0123456789abcdef", + "Name": "/web", + "Created": "2026-07-03T09:30:00Z", + "Platform": "linux/arm64/v8", + "Config": { + "Image": "nginx:latest", + "Cmd": ["nginx", "-g", "daemon off;"], + "Entrypoint": null, + "Env": ["FOO=bar"], + "WorkingDir": "/app", + "User": "1000", + "Labels": {"contained.restart": "always"}, + "Tty": true + }, + "State": { + "Status": "running", + "Running": true, + "StartedAt": "2026-07-03T09:31:00Z" + }, + "HostConfig": { + "NetworkMode": "host", + "PortBindings": {"80/tcp": [{"HostIp": "127.0.0.1", "HostPort": "8080"}]}, + "ReadonlyRootfs": true, + "Init": true, + "ShmSize": 67108864, + "CapAdd": ["NET_ADMIN"], + "CapDrop": ["MKNOD"], + "Runtime": "runc" + }, + "NetworkSettings": { + "Networks": { + "bridge": { + "IPAddress": "172.17.0.2", + "Gateway": "172.17.0.1", + "GlobalIPv6Address": "", + "MacAddress": "02:42:ac:11:00:02" + } + } + }, + "Mounts": [ + {"Type": "bind", "Source": "/tmp/site", "Destination": "/usr/share/nginx/html", "RW": false} + ] + } + ] + """.utf8) + + let rows = try DockerJSON.decode([DockerContainerInspect].self, from: data) + let row = try #require(rows.first) + let snapshot = try row.coreSnapshot() + + #expect(snapshot.runtimeKind == .docker) + #expect(snapshot.id == "web") + #expect(snapshot.scopedID == "docker::web") + #expect(snapshot.state == .running) + #expect(snapshot.image == "nginx:latest") + #expect(snapshot.configuration.runtimeKind == .docker) + #expect(snapshot.configuration.initProcess.arguments == ["nginx", "-g", "daemon off;"]) + #expect(snapshot.configuration.initProcess.environment == ["FOO=bar"]) + #expect(snapshot.configuration.platform.architecture == "arm64") + #expect(snapshot.configuration.platform.variant == "v8") + #expect(snapshot.configuration.publishedPorts.first?.hostPort == 8080) + #expect(snapshot.configuration.mounts.first?.readonly == true) + #expect(snapshot.configuration.capAdd == ["NET_ADMIN"]) + #expect(snapshot.configuration.capDrop == ["MKNOD"]) + #expect(snapshot.status.networks.first?.ipv4Address == "172.17.0.2") + } + + @Test func decodesDockerImageListRows() throws { + let data = Data(""" + {"Repository":"nginx","Tag":"latest","Digest":"sha256:abc","ID":"sha256:image"} + {"Repository":"","Tag":"","Digest":"","ID":"sha256:dangling"} + """.utf8) + + let rows = try DockerJSON.decodeJSONLines(DockerImageListRow.self, from: data) + let images = rows.compactMap { $0.coreImage() } + + #expect(images.count == 1) + #expect(images.first?.reference == "nginx:latest") + #expect(images.first?.digest == "sha256:abc") + #expect(images.first?.runtimeKind == .docker) + } + @Test func handlesDatesWithAndWithoutFractionalSeconds() throws { #expect(Core.Container.JSON.parseDate("2026-06-24T10:16:58Z") != nil) #expect(Core.Container.JSON.parseDate("2026-06-16T00:01:29.967161902Z") != nil) diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/ImageWorkflowTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/ImageWorkflowTests.swift index 90da7438..bd93ffb8 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/ImageWorkflowTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/ImageWorkflowTests.swift @@ -61,6 +61,25 @@ struct ImageWorkflowTests { #expect(groups.first?.references == ["docker.io/library/alpine:latest", "localhost/alpine:test"]) } + @Test func localTagGroupingKeepsRuntimeSpecificAvailability() { + let apple = Self.image(reference: "docker.io/library/nginx:latest", + digest: "sha256:same", + runtimeKind: .appleContainer) + let docker = Self.image(reference: "nginx:latest", + digest: "sha256:same", + runtimeKind: .docker) + + let group = Core.Image.LocalTagGroup.groups(for: [apple, docker]).first + + #expect(group?.images.count == 2) + #expect(group?.references == ["docker.io/library/nginx:latest", "nginx:latest"]) + #expect(group?.tags.count == 2) + let runtimeKinds = Set(group?.tags.map(\.runtimeKind) ?? []) + let tagIDs = group?.tags.map(\.id) ?? [] + #expect(runtimeKinds == Set([Core.Runtime.Kind.appleContainer, .docker])) + #expect(tagIDs.allSatisfy { $0.contains("::") }) + } + @Test func hubSearchFetchesThroughSharedHelper() async throws { let session = Self.session { request in #expect(request.url?.path == "/v2/search/repositories") @@ -144,6 +163,21 @@ struct ImageWorkflowTests { let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: headers)! return (response, Data(body.utf8)) } + + private static func image(reference: String, + digest: String, + runtimeKind: Core.Runtime.Kind) -> Core.Image.Resource { + Core.Image.Resource(configuration: Core.Image.Configuration( + name: reference, + descriptor: Core.Container.Descriptor(digest: digest, + mediaType: "application/vnd.oci.image.index.v1+json", + size: 12), + creationDate: nil + ), + id: digest, + variants: [], + runtimeKind: runtimeKind) + } } private final class RegistryMockURLProtocol: URLProtocol { diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/LocalizationTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/LocalizationTests.swift new file mode 100644 index 00000000..a24c8795 --- /dev/null +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/LocalizationTests.swift @@ -0,0 +1,33 @@ +import Testing +@testable import ContainedCore + +@Suite("Core localization") +struct CoreLocalizationTests { + @Test func packageResourcesResolveSemanticDefaults() { + #expect(Core.Localization.string("schema.field.image.reference", defaultValue: "Fallback image") == "Image") + #expect(Core.Localization.string("runtime.capability.unsupported", + defaultValue: "Fallback unsupported") == "This runtime does not support the requested capability.") + } + + @Test func missingKeysUseProvidedFallback() { + #expect(Core.Localization.string("missing.core.key", defaultValue: "Fallback text") == "Fallback text") + } + + @Test func downstreamResolversCanOverrideOrWrapCoreStrings() throws { + let definition = Core.Schema.Definition.containerRunEdit(runtimeKind: .docker) + let image = try #require(definition.descriptor(for: .imageReference)) + let wrapped = image.localizedLabel { entry in + "[app] \(entry.defaultValue)" + } + + #expect(wrapped == "[app] Image") + } + + @Test func schemaSupportReasonsUseCoreLocalization() throws { + let definition = Core.Schema.Definition.containerRunEdit(runtimeKind: .docker) + let kernel = try #require(definition.descriptor(for: .kernelPath)) + let reason = kernel.support(for: .docker).localizedDisabledReason() + + #expect(reason == "Known from Apple container or Compose, not executable by Docker.") + } +} diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift index d239aaca..179fcfcb 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift @@ -20,13 +20,13 @@ struct RuntimeDescriptorTests { @Test func unsupportedCapabilityIsDisplayNeutralPackageError() { let error = Core.Runtime.UnsupportedCapability( - kind: .dockerCompatible, + kind: .docker, capability: .imageBuild ) #expect(error.packageName == "ContainedCore") #expect(error.packageErrorCode == "unsupportedRuntimeCapability") - #expect(error.packageErrorContext["kind"] == Core.Runtime.Kind.dockerCompatible.rawValue) + #expect(error.packageErrorContext["kind"] == Core.Runtime.Kind.docker.rawValue) #expect(error.packageErrorContext["capability"] == String(Core.Runtime.Capability.imageBuild.rawValue)) } @@ -47,12 +47,98 @@ struct RuntimeDescriptorTests { #expect(plan.context["source"] == "future-runtime") #expect(plan.context["target"] == Core.Runtime.Kind.appleContainer.rawValue) } + + @Test func orchestratorAggregatesAndScopesContainersAcrossRuntimes() async throws { + let apple = UnavailableRuntime( + descriptor: .appleContainer, + containers: [.placeholder(id: "web", image: "nginx:latest")] + ) + let docker = UnavailableRuntime( + descriptor: .docker, + containers: [.placeholder(id: "web", image: "nginx:latest")] + ) + let orchestrator = Core.Orchestrator( + cliURLs: [ + .appleContainer: URL(fileURLWithPath: "/usr/bin/container"), + .docker: URL(fileURLWithPath: "/usr/local/bin/docker"), + ], + runtimes: [ + .appleContainer: apple, + .docker: docker, + ] + ) + + let snapshots = try await orchestrator.listRuntimeContainers() + + #expect(snapshots.count == 2) + #expect(Set(snapshots.map(\.id)) == ["web"]) + #expect(Set(snapshots.map(\.scopedID)) == [ + "apple-container::web", + "docker::web", + ]) + #expect(Set(snapshots.map(\.runtimeKind)) == [.appleContainer, .docker]) + } + + @Test func orchestratorPreservesPartialInventoryWhenOneRuntimeFails() async throws { + let apple = UnavailableRuntime( + descriptor: .appleContainer, + containers: [.placeholder(id: "apple-web", image: "nginx:latest")] + ) + let docker = UnavailableRuntime( + descriptor: .docker, + listError: TestStubError.unused + ) + let orchestrator = Core.Orchestrator( + cliURLs: [ + .appleContainer: URL(fileURLWithPath: "/usr/bin/container"), + .docker: URL(fileURLWithPath: "/usr/local/bin/docker"), + ], + runtimes: [ + .appleContainer: apple, + .docker: docker, + ] + ) + + let snapshots = try await orchestrator.listRuntimeContainers() + + #expect(snapshots.map(\.scopedID) == ["apple-container::apple-web"]) + } + + @Test func orchestratorTerminalInvocationRoutesByRuntimeKind() throws { + let orchestrator = Core.Orchestrator( + cliURLs: [ + .appleContainer: URL(fileURLWithPath: "/usr/bin/container"), + .docker: URL(fileURLWithPath: "/usr/local/bin/docker"), + ], + runtimes: [ + .appleContainer: UnavailableRuntime(descriptor: .appleContainer), + .docker: UnavailableRuntime(descriptor: .docker), + ] + ) + + let docker = try orchestrator.terminalInvocation(containerID: "web", + shell: "/bin/sh", + runtimeKind: .docker) + let apple = try orchestrator.terminalInvocation(containerID: "web", + shell: "/bin/sh", + runtimeKind: .appleContainer) + + #expect(docker.executableURL.path == "/usr/local/bin/docker") + #expect(docker.arguments == DockerCommands.execInteractive("web", shell: "/bin/sh")) + #expect(apple.executableURL.path == "/usr/bin/container") + #expect(apple.arguments == ContainerCommands.execInteractive("web", shell: "/bin/sh")) + } } private struct UnavailableRuntime: ContainerRuntimeClient { let descriptor: Core.Runtime.Descriptor + var containers: [Core.Container.Snapshot] = [] + var listError: TestStubError? - func listContainers(all: Bool) async throws -> [Core.Container.Snapshot] { [] } + func listContainers(all: Bool) async throws -> [Core.Container.Snapshot] { + if let listError { throw listError } + return containers + } func stats(ids: [String]) async throws -> [Core.Metrics.ContainerStats] { [] } func streamStats(ids: [String]) -> AsyncThrowingStream<[Core.Metrics.RuntimeStatsSnapshot], Error> { AsyncThrowingStream { $0.finish() } diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift index 530c1c2b..1058cb08 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift @@ -16,6 +16,16 @@ struct RuntimeWorkflowTests { #expect(ContainerCommands.logs("web", follow: true, tail: 500) == ["logs", "--follow", "-n", "500", "web"]) } + @Test func dockerContainerImageAndStreamingArgv() { + #expect(DockerCommands.containerIDs(all: true) == ["container", "ls", "--all", "--no-trunc", "--quiet"]) + #expect(DockerCommands.inspectContainers(["web"]) == ["container", "inspect", "web"]) + #expect(DockerCommands.stats(ids: ["web"]) == ["stats", "--no-stream", "--format", "{{json .}}", "web"]) + #expect(DockerCommands.logs("web", follow: true, tail: 500) == ["container", "logs", "--follow", "--tail", "500", "web"]) + #expect(DockerCommands.execInteractive("web", shell: "/bin/sh") + == ["container", "exec", "--interactive", "--tty", "web", "/bin/sh"]) + #expect(DockerCommands.imageList() == ["image", "ls", "--digests", "--no-trunc", "--format", "{{json .}}"]) + } + @Test func volumeAndNetworkWriteArgv() { #expect(ContainerCommands.volumeCreate(name: "data") == ["volume", "create", "data"]) #expect(ContainerCommands.volumeCreate(name: "data", size: "10G", labels: ["a": "1"]) @@ -43,6 +53,45 @@ struct RuntimeWorkflowTests { "--build-arg", "A=1", "--no-cache", "ctx"]) } + @Test func dockerRunArgvIncludesDockerOnlyFields() { + var request = Core.Container.CreateRequest() + request.runtimeKind = .docker + request.image = "nginx:latest" + request.name = "web" + request.detach = true + request.network = "host" + request.publishAll = true + request.pullPolicy = "always" + request.extraHosts = ["host.docker.internal:host-gateway"] + request.loggingDriver = "json-file" + request.loggingOptions = [Core.Container.KeyValue(key: "max-size", value: "10m")] + request.gpus = "all" + request.privileged = true + request.securityOptions = ["no-new-privileges"] + request.stopGracePeriod = "30" + request.env = [Core.Container.KeyValue(key: "FOO", value: "bar")] + request.ports = [Core.Container.Port(hostPort: "8080", containerPort: "80", proto: "tcp")] + + #expect(DockerCommands.run(request) == [ + "container", "run", + "--detach", + "--name", "web", + "--privileged", + "--publish-all", + "--pull", "always", + "--network", "host", + "--stop-timeout", "30", + "--gpus", "all", + "--add-host", "host.docker.internal:host-gateway", + "--publish", "8080:80", + "--env", "FOO=bar", + "--security-opt", "no-new-privileges", + "--log-driver", "json-file", + "--log-opt", "max-size=10m", + "nginx:latest", + ]) + } + @Test func registryAndPushArgv() { #expect(ContainerCommands.registryList() == ["registry", "list", "--format", "json"]) #expect(ContainerCommands.registryLogin(server: "ghcr.io", username: "me") @@ -50,6 +99,11 @@ struct RuntimeWorkflowTests { #expect(ContainerCommands.registryLogout(server: "ghcr.io") == ["registry", "logout", "ghcr.io"]) #expect(ContainerCommands.imagePush("ghcr.io/me/app:1") == ["image", "push", "--progress", "plain", "ghcr.io/me/app:1"]) + #expect(DockerCommands.registryLogin(server: "ghcr.io", username: "me") + == ["login", "--username", "me", "--password-stdin", "ghcr.io"]) + #expect(DockerCommands.registryLogout(server: "ghcr.io") == ["logout", "ghcr.io"]) + #expect(DockerCommands.imagePush("ghcr.io/me/app:1", platform: "linux/arm64") + == ["image", "push", "--platform", "linux/arm64", "ghcr.io/me/app:1"]) } @Test func pruneSystemAndCopyArgv() { @@ -98,6 +152,34 @@ struct RuntimeWorkflowTests { #expect(project.warnings.contains { $0.contains("networks") }) } + @Test func composeHostNetworkIsRuntimeSpecific() throws { + let yaml = """ + services: + web: + image: nginx:latest + network_mode: host + """ + let project = try Core.Compose.Parser.parse(yaml, projectName: "demo") + let apple = Core.Orchestrator.testing(runner: MockCommandRunner(result: .success(Data()))) + let docker = Core.Orchestrator.testing(runner: MockCommandRunner(result: .success(Data())), + cliURL: URL(fileURLWithPath: "/usr/local/bin/docker"), + runtimeKind: .docker) + + let appleDocument = try #require(apple.translateCompose(project, + baseDirectory: nil, + runtimeKind: .appleContainer).items.first?.document) + let dockerDocument = try #require(docker.translateCompose(project, + baseDirectory: nil, + runtimeKind: .docker).items.first?.document) + + #expect(appleDocument.runtimeKind == .appleContainer) + #expect(appleDocument.string(.networkName) == "") + #expect(dockerDocument.runtimeKind == .docker) + #expect(dockerDocument.string(.networkName) == "host") + #expect(try docker.previewCreateCommand(for: dockerDocument).command.contains("--network")) + #expect(try docker.previewCreateCommand(for: dockerDocument).command.contains("host")) + } + // MARK: Restart watchdog decision logic @Test func restartPolicyParsing() { diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift index 7d4c0d96..0988da24 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift @@ -5,11 +5,11 @@ import Testing @Suite("Runtime adapter boundary") struct AppleContainerAdapterTests { @Test func runtimeKindAcceptsFutureAdapters() throws { - let descriptor = Core.Runtime.Descriptor(kind: Core.Runtime.Kind(rawValue: "future-engine"), - displayName: "Future Engine", + let descriptor = Core.Runtime.Descriptor(kind: Core.Runtime.Kind(rawValue: "future-runtime"), + displayName: "Future Runtime", capabilities: [.containers]) - #expect(descriptor.kind.rawValue == "future-engine") + #expect(descriptor.kind.rawValue == "future-runtime") #expect(descriptor.executableName == nil) #expect(descriptor.supports(.containers)) #expect(!descriptor.supports(.imageBuild)) @@ -21,7 +21,7 @@ struct AppleContainerAdapterTests { } catch let error as Core.Runtime.UnsupportedCapability { #expect(error.packageName == "ContainedCore") #expect(error.packageErrorCode == "unsupportedRuntimeCapability") - #expect(error.packageErrorContext["kind"] == "future-engine") + #expect(error.packageErrorContext["kind"] == "future-runtime") } } diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/SchemaTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/SchemaTests.swift index 41d965ab..b778fdc0 100644 --- a/Packages/ContainedCore/Tests/ContainedCoreTests/SchemaTests.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/SchemaTests.swift @@ -152,9 +152,9 @@ struct SchemaTests { } @Test func documentRuntimeFieldTracksRuntimeKind() { - let document = Core.Schema.Document.containerCreate(runtimeKind: .dockerCompatible) - #expect(document.runtimeKind == .dockerCompatible) - #expect(document.string(.runtimeKind) == Core.Runtime.Kind.dockerCompatible.rawValue) + let document = Core.Schema.Document.containerCreate(runtimeKind: .docker) + #expect(document.runtimeKind == .docker) + #expect(document.string(.runtimeKind) == Core.Runtime.Kind.docker.rawValue) } @Test func schemaConformanceMigratorDoesNotNeedVersionGate() throws { @@ -275,6 +275,37 @@ struct SchemaTests { #expect(request.image != item.document.string(.imagePullPolicy, in: definition)) } + @Test func dockerDefinitionSupportsDockerCLIFieldsButDisablesComposeStackFields() throws { + var document = Core.Schema.Document.containerCreate(runtimeKind: .docker) + document.set(.imageReference, .string("example/app:1")) + document.set(.imagePullPolicy, .enumeration("always")) + document.set(.networkExtraHosts, .stringList(["host.docker.internal:host-gateway"])) + document.set(.securityPrivileged, .bool(true)) + document.set(.securityOptions, .stringList(["no-new-privileges"])) + document.set(.composeSecrets, .stringList(["app_secret"])) + + let definition = Core.Schema.Definition.containerRunEdit(runtimeKind: .docker, + operation: .containerCreate) + let pullPolicy = try #require(definition.descriptor(for: .imagePullPolicy)) + let secrets = try #require(definition.descriptor(for: .composeSecrets)) + + #expect(pullPolicy.support(for: .docker).state == .supported) + #expect(secrets.support(for: .docker).state == .disabled) + + let warnings = document.validationIssues(in: definition).filter { $0.severity == .warning } + #expect(!warnings.contains { $0.field == .imagePullPolicy }) + #expect(warnings.contains { $0.field == .composeSecrets }) + + let request = try document.validatedRequest(definition: definition) + #expect(request.runtimeKind == .docker) + #expect(request.pullPolicy == "always") + #expect(request.extraHosts == ["host.docker.internal:host-gateway"]) + #expect(request.privileged) + #expect(request.securityOptions == ["no-new-privileges"]) + #expect(DockerCommands.run(request).contains("--privileged")) + #expect(subsequence(["--pull", "always"], in: DockerCommands.run(request))) + } + private func subsequence(_ needle: [String], in haystack: [String]) -> Bool { guard needle.count <= haystack.count else { return false } for start in 0...(haystack.count - needle.count) { diff --git a/Packages/ContainedUI/README.md b/Packages/ContainedUI/README.md index 91df9cbb..6ee85e97 100644 --- a/Packages/ContainedUI/README.md +++ b/Packages/ContainedUI/README.md @@ -149,9 +149,14 @@ UI.Panel.Scaffold(width: UI.Panel.Size.settings.width) { ## Previews -Package-local SwiftUI previews are guarded by `CONTAINED_UI_PREVIEWS`. Normal -app builds do not define that flag, so sample preview values are not linked into -debug, release, notarized, or non-notarized app bundles. +Package-local SwiftUI previews live beside the element declaration they +exercise. Opening a design-system source file in Xcode should show the matching +canvas preview without looking in a separate preview-only folder. Keep preview +sample state app-neutral and fixture-free. + +Internal helpers that only exist to remove repeated implementation structure +live under `Sources/ContainedUI/Shared`. They are not app-facing API; prefer the +public `UI.*` routes from app code. ## Verification diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift index 857e5f38..74ea7db3 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift @@ -100,3 +100,19 @@ struct ToggleButton: View { } } } + +#Preview("Action Buttons") { + VStack(alignment: .leading, spacing: UI.Tokens.Space.l) { + UI.Action.TextButton(title: "Save", + systemName: "checkmark", + prominence: .prominent) {} + UI.Action.TextButton(title: "Remove", + systemName: "trash", + role: .destructive) {} + UI.Action.ToggleButton(isOn: .constant(true), + title: "Pinned", + systemName: "pin.fill") + UI.Action.ProgressCapsule() + } + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift index 7fdd8f65..1bc88a8c 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift @@ -84,6 +84,34 @@ struct Cluster: View { } } +#Preview("Action Groups") { + VStack(alignment: .leading, spacing: UI.Tokens.Space.l) { + UI.Action.Group([ + UI.Action.Item(systemName: "play.fill", help: "Start") {}, + UI.Action.Item(systemName: "stop.fill", help: "Stop", role: .destructive) {}, + ]) + + UI.Action.Cluster { + UI.Action.MenuLabel(systemName: "ellipsis", + help: "More actions") + UI.Action.Items([ + UI.Action.Item(systemName: "doc.on.doc", help: "Duplicate") {}, + UI.Action.Item(systemName: "trash", help: "Delete", role: .destructive) {}, + ]) + } + + UI.Control.InputCluster { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + Text("Search field") + .foregroundStyle(.secondary) + } + .frame(width: 240) + } + .padding(UI.Tokens.Space.xl) + .environment(\.buttonMaterial, .glassClear) +} + /// Package-owned input cluster for search fields and compact inline controls. public extension UI.Control { struct InputCluster: View { diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift index bf20ec70..ca1a882d 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift @@ -31,3 +31,13 @@ struct Item { } } } + +#Preview("Action Item") { + UI.Action.Group([ + UI.Action.Item(systemName: "play.fill", help: "Start") {}, + UI.Action.Item(systemName: "pause.fill", help: "Pause", tint: .orange) {}, + UI.Action.Item(systemName: "trash", help: "Delete", role: .destructive) {}, + ]) + .padding(UI.Tokens.Space.xl) + .environment(\.buttonMaterial, .glassClear) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift index aa727e70..81e18b54 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift @@ -37,3 +37,13 @@ struct SelectionBar: View { } } } + +#Preview("Selection Bar") { + UI.Action.SelectionBar(count: 3, + countLabel: { "\($0) selected" }, + actions: [ + UI.Action.Item(systemName: "tag", title: "Tag") {}, + UI.Action.Item(systemName: "trash", title: "Delete", role: .destructive) {}, + ]) + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Card/Card+Scaffold.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/Card+Scaffold.swift index 845e48e4..468a18f1 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Card/Card+Scaffold.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/Card+Scaffold.swift @@ -283,3 +283,64 @@ public extension UI.Card.Scaffold where PageID == UI.Card.NoPage { widget: widget) } } + +#Preview("Card Scaffold") { + DesignCardScaffoldPreview() + .padding(UI.Tokens.Space.xl) + .frame(width: 420) + .environment(\.cardMaterial, .glassRegular) + .environment(\.buttonMaterial, .glassClear) +} + +private struct DesignCardScaffoldPreview: View { + @State private var page = "overview" + + private let pages = [ + UI.Card.Page(id: "overview", title: "Overview", systemImage: "rectangle.grid.1x2"), + UI.Card.Page(id: "stats", title: "Stats", systemImage: "chart.xyaxis.line"), + ] + + var body: some View { + UI.Card.Scaffold(size: .large, + isExpanded: true, + title: "preview-web", + subtitle: "docker.io/library/nginx:latest", + pages: UI.Card.Pages(items: pages, + selection: page, + tint: .accentColor, + closeLabel: "Close", + onSelect: { page = $0 }, + onClose: {})) { + UI.Card.IconChip(symbol: "shippingbox.fill", tint: .accentColor) + } titleAccessory: { + UI.Badge.Text(text: "Running") + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + UI.Card.InsetSection(title: "Live") { + UI.Chart.Sparkline(samples: [0.1, 0.2, 0.18, 0.4, 0.34, 0.55], + color: .accentColor, + scale: .fraction) + .frame(height: UI.Tokens.Card.sparklineHeight) + } + } footerLeading: { + UI.Card.FooterChip(isSelected: true, tint: .accentColor, help: "CPU", action: {}) { + Image(systemName: "cpu") + } text: { + UI.Card.MetricText(text: "42%") + } + } footerActions: { + UI.Card.FooterButton(systemName: "play.fill", help: "Start", tint: .accentColor) {} + } widget: { + UI.Card.WidgetGroup { + UI.Card.FooterMini { + Image(systemName: "memorychip") + } text: { + UI.Card.MetricText(text: "420 MB") + } + } + } + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Card/Chrome.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/Chrome.swift index 08fc9cce..76ebd2d0 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Card/Chrome.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/Chrome.swift @@ -486,12 +486,13 @@ struct Text: View { } public var body: some View { - SwiftUI.Text(text) - .font(font) - .foregroundStyle(foreground) - .padding(.horizontal, UI.Tokens.Space.s) - .padding(.vertical, UI.Tokens.Badge.verticalPadding) - .background(.quaternary, in: Capsule()) + SharedCapsuleLabel(horizontalPadding: UI.Tokens.Space.s, + verticalPadding: UI.Tokens.Badge.verticalPadding, + foreground: AnyShapeStyle(foreground), + fill: AnyShapeStyle(.quaternary)) { + SwiftUI.Text(text) + .font(font) + } } } } @@ -521,29 +522,20 @@ struct Row: View { } public var body: some View { - HStack(spacing: UI.Tokens.Space.s) { - Image(systemName: symbol) - .font(.callout) - .foregroundStyle(tint) - .frame(width: UI.Tokens.IconSize.rowMenu) - VStack(alignment: .leading, spacing: UI.Tokens.Card.compactTextSpacing) { - Text(title) - .font(.callout.weight(.medium)) - .lineLimit(1) - if let subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(monospacedSubtitle ? .system(.caption, design: .monospaced) : .caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - Spacer(minLength: UI.Tokens.Space.s) + SharedIconTextRow(systemImage: symbol, + tint: tint, + iconWidth: UI.Tokens.IconSize.rowMenu, + rowSpacing: UI.Tokens.Space.s, + subtitle: subtitle, + subtitleMonospaced: monospacedSubtitle, + horizontalPadding: UI.Tokens.Space.m, + fillsWidth: true) { + Text(title) + .font(.callout.weight(.medium)) + .lineLimit(1) + } accessory: { accessory() } - .padding(.horizontal, UI.Tokens.Space.m) - .padding(.vertical, UI.Tokens.Space.s) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) .materialSurface(.ultraThin, cornerRadius: UI.Tokens.Radius.control) } } @@ -615,3 +607,58 @@ public extension View { } } } + +#Preview("Card Chrome") { + VStack(alignment: .leading, spacing: UI.Tokens.Space.l) { + CardHeader { + UI.Card.IconChip(symbol: "shippingbox.fill", tint: .accentColor) + } content: { + CardHeaderTextBlock { + UI.Card.TitleText(text: "preview-web") + } subtitle: { + UI.Card.MonospacedSubtitleText(text: "sha256:preview") + } + } trailing: { + UI.Card.CardPageControls(items: [ + UI.Card.Page(id: "overview", title: "Overview", systemImage: "rectangle.grid.1x2"), + UI.Card.Page(id: "logs", title: "Logs", systemImage: "doc.text"), + ], + selection: "overview", + tint: .accentColor, + closeLabel: "Close", + onSelect: { _ in }, + onClose: {}) + } + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card) + + UI.Card.InsetSection(title: "Metadata") { + UI.List.Row(symbol: "network", + tint: .teal, + title: "bridge", + subtitle: "10.42.0.0/24") + } + + UI.Card.CardFooter { + UI.Card.FooterChip(isSelected: true, tint: .accentColor, help: "CPU", action: {}) { + Image(systemName: "cpu") + } text: { + UI.Card.MetricText(text: "42%") + } + } trailing: { + UI.Card.FooterButton(systemName: "stop.fill", help: "Stop", tint: .red) {} + } widget: { + UI.Card.WidgetGroup { + UI.Card.FooterMini { + Image(systemName: "memorychip") + } text: { + UI.Card.MetricText(text: "420 MB") + } + } + .padding(.horizontal, UI.Tokens.Card.padding) + } + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card) + } + .padding(UI.Tokens.Space.xl) + .frame(width: 520) + .environment(\.buttonMaterial, .glassClear) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Card/Surface.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/Surface.swift index b6b13973..88200567 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Card/Surface.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/Surface.swift @@ -291,26 +291,16 @@ private struct CardMaterialSurface: ViewModifier { @ViewBuilder private func fillLayer(_ shape: RoundedRectangle) -> some View { if let fill { - shape.fill(fillStyle(fill)) + shape.fill(SharedSurfaceRendering.fillStyle(color: fill, + opacity: fillOpacity, + gradient: gradient, + gradientAngle: gradientAngle)) .blendMode(blendMode.blendMode) .clipShape(shape) } } - private func fillStyle(_ color: Color) -> AnyShapeStyle { - if gradient { - let radians = gradientAngle * .pi / 180 - let dx = cos(radians) / 2 - let dy = sin(radians) / 2 - return AnyShapeStyle(LinearGradient( - colors: [color.opacity(fillOpacity * 1.35), color.opacity(fillOpacity * 0.4)], - startPoint: UnitPoint(x: 0.5 - dx, y: 0.5 - dy), - endPoint: UnitPoint(x: 0.5 + dx, y: 0.5 + dy))) - } - return AnyShapeStyle(color.opacity(fillOpacity)) - } - - private var shadowColor: Color { .black.opacity((colorScheme == .dark ? 0.55 : 0.18)) } + private var shadowColor: Color { SharedSurfaceRendering.shadowColor(for: colorScheme) } private var shadowRadius: CGFloat { 10 } private var shadowY: CGFloat { 4 } } @@ -432,3 +422,54 @@ extension CardSurface where Widget == EmptyView { widget: { EmptyView() }) } } + +#Preview("Card Surface") { + VStack(spacing: UI.Tokens.Space.l) { + CardSurface(size: .small, + isSelected: true, + fill: .accentColor, + fillOpacity: 0.12, + gradient: true) { + CardHeader { + UI.Card.IconChip(symbol: "shippingbox.fill", tint: .accentColor) + } content: { + CardHeaderTextBlock { + UI.Card.TitleText(text: "Small card") + } subtitle: { + UI.Card.SubtitleText(text: "Selected") + } + } trailing: { + EmptyView() + } + } + + CardSurface(size: .medium, + isExpanded: true, + controlsVisible: true, + fill: .teal, + fillOpacity: 0.10) { + CardHeader { + UI.Card.IconChip(symbol: "chart.xyaxis.line", tint: .teal) + } content: { + CardHeaderTextBlock { + UI.Card.TitleText(text: "Expanded surface") + } subtitle: { + UI.Card.SubtitleText(text: "Sticky footer") + } + } trailing: { + EmptyView() + } + } bodyContent: { + UI.Card.InsetSection { + UI.Chart.Sparkline(samples: [0.2, 0.35, 0.18, 0.6, 0.5]) + .frame(height: 52) + } + } footerLeading: { + UI.Card.MetricText(text: "62%") + } footerActions: { + UI.Card.FooterButton(systemName: "arrow.clockwise", help: "Refresh") {} + } + } + .padding(UI.Tokens.Space.xl) + .frame(width: 420) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Card/ViewModifiers.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/ViewModifiers.swift index 1f41a80f..9841b0b0 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Card/ViewModifiers.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/ViewModifiers.swift @@ -12,3 +12,21 @@ public extension View { } } } + +#Preview("Card Selection Overlay") { + VStack(spacing: UI.Tokens.Space.m) { + Text("Unselected") + .frame(maxWidth: .infinity) + .padding(UI.Tokens.Space.l) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card) + .designCardSelectionOverlay(when: false) + + Text("Selected") + .frame(maxWidth: .infinity) + .padding(UI.Tokens.Space.l) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card) + .designCardSelectionOverlay(when: true) + } + .padding(UI.Tokens.Space.xl) + .frame(width: 320) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift b/Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift index 949f9d11..be05a6dd 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Chart/MetricTile.swift @@ -36,3 +36,12 @@ struct MetricTile: View { } } } + +#Preview("Control Metric Tile") { + HStack(spacing: UI.Tokens.Space.m) { + UI.Control.MetricTile(label: "CPU", value: "42", caption: "%") + UI.Control.MetricTile(label: "Memory", value: "420", caption: "MB") + } + .padding(UI.Tokens.Space.xl) + .frame(width: 420) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift b/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift index 4407ea2d..31626e2a 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift @@ -363,3 +363,22 @@ struct SampleBuffer: Sendable, Equatable { } } } + +#Preview("Sparkline Styles") { + VStack(spacing: UI.Tokens.Space.l) { + UI.Chart.Sparkline(samples: [0.1, 0.22, 0.18, 0.44, 0.36, 0.72], + color: .accentColor, + style: .area, + scale: .fraction) + UI.Chart.Sparkline(samples: [4, 8, 6, 12, 10, 15], + color: .teal, + style: .line) + UI.Chart.Sparkline(samples: [0.2, 0.5, 0.35, 0.78, 0.58], + comparisonSamples: [0.12, 0.25, 0.42, 0.4, 0.62], + color: .orange, + style: .multiLine, + scale: .fraction) + } + .frame(width: 360, height: 180) + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift b/Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift index 8f681445..d1600789 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift @@ -51,3 +51,21 @@ struct MetricTile: View { } } } + +#Preview("Chart Metric Tile") { + HStack(spacing: UI.Tokens.Space.m) { + UI.Chart.MetricTile(label: "CPU", + value: "62%", + systemImage: "cpu", + tint: .accentColor, + samples: [0.2, 0.35, 0.32, 0.62, 0.58], + sparklineScale: .fraction) + UI.Chart.MetricTile(label: "Network", + value: "186 KB/s", + systemImage: "network", + tint: .teal, + samples: [40, 82, 70, 126, 110]) + } + .padding(UI.Tokens.Space.xl) + .frame(width: 520) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Command/PreviewBar.swift b/Packages/ContainedUI/Sources/ContainedUI/Command/PreviewBar.swift index ba49fc70..493b1193 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Command/PreviewBar.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Command/PreviewBar.swift @@ -48,3 +48,11 @@ public extension UI.Command { } } } + +#Preview("Command Preview Bar") { + UI.Command.PreviewBar(command: ["run", "--name", "preview-web", "nginx"], + copyHelp: "Copy command", + copiedAccessibilityLabel: "Copied") + .padding(UI.Tokens.Space.xl) + .frame(width: 520) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Console/ConsoleViewModifiers.swift b/Packages/ContainedUI/Sources/ContainedUI/Console/ConsoleViewModifiers.swift index 9f8ec857..d43802d6 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Console/ConsoleViewModifiers.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Console/ConsoleViewModifiers.swift @@ -9,3 +9,12 @@ public extension View { .padding(UI.Tokens.Space.s) } } + +#Preview("Terminal Surface Chrome") { + Text("preview-web$ nginx -g 'daemon off;'") + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .terminalSurfaceChrome() + .padding(UI.Tokens.Space.xl) + .frame(width: 420) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Console/StreamConsole.swift b/Packages/ContainedUI/Sources/ContainedUI/Console/StreamConsole.swift index c5e45cfc..283d8e9a 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Console/StreamConsole.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Console/StreamConsole.swift @@ -114,3 +114,21 @@ public extension UI.Console { } } } + +#Preview("Stream Console") { + UI.Console.Stream(stream: { + AsyncThrowingStream { continuation in + continuation.yield("Pulling docker.io/library/nginx:latest\n") + continuation.yield("Resolving manifest\n") + continuation.yield("Done\n") + continuation.finish() + } + }, + workingLabel: "Working", + completedLabel: "Completed", + lineCountLabel: { "\($0) lines" }, + copyLogHelp: "Copy log", + failureLabel: { error in error.localizedDescription }) + .frame(width: 520, height: 320) + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md b/Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md index 598dbc9f..ad6c51e5 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md +++ b/Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md @@ -25,6 +25,14 @@ such as `UI.Panel.Padding.top` or `UI.Toolbar.Size.controlHeight`. Contextual tokens mirror raw defaults unless their declaration explains an intentional divergence. +Implementation-only helpers that remove repeated structure across elements live +under `Sources/ContainedUI/Shared`. They are package-internal and are not app +API; app code should keep using the public `UI.*` routes. + +SwiftUI previews are colocated with the element declarations they exercise. The +package does not use a separate preview-only source tree, so opening an element +file in Xcode should show that element's canvas sample directly. + ## Topics ### Namespaces diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift index 962143cf..d3df2699 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/Chips.swift @@ -11,19 +11,19 @@ struct ScopeLabel: View { } public var body: some View { - HStack(spacing: UI.Tokens.Space.xs) { + SharedCapsuleLabel(horizontalPadding: UI.Tokens.Space.s, + verticalPadding: UI.Tokens.Badge.scopeVerticalPadding, + foreground: AnyShapeStyle(Color.accentColor), + fill: AnyShapeStyle(Color.accentColor.opacity(UI.Tokens.Badge.accentOpacity))) { + HStack(spacing: UI.Tokens.Space.xs) { Image(systemName: symbol) .font(.caption2) SwiftUI.Text(title) .font(.caption.weight(.semibold)) Image(systemName: "xmark") .font(.caption2.weight(.bold)) + } } - .padding(.horizontal, UI.Tokens.Space.s) - .padding(.vertical, UI.Tokens.Badge.scopeVerticalPadding) - .background(Color.accentColor.opacity(UI.Tokens.Badge.accentOpacity), - in: Capsule(style: .continuous)) - .foregroundStyle(Color.accentColor) } } } @@ -39,15 +39,20 @@ struct TintSwatch: View { } public var body: some View { - ZStack { - Circle().fill(color) - if followsAccent { - Image(systemName: "link") - .font(.caption2.weight(.bold)) - .foregroundStyle(.white) - } - } - .frame(width: UI.Tokens.IconSize.chip, height: UI.Tokens.IconSize.chip) + SharedTintSwatchMark(color: color, + markerSystemName: followsAccent ? "link" : nil, + size: UI.Tokens.IconSize.chip, + fillSize: UI.Tokens.IconSize.chip, + ringSize: UI.Tokens.IconSize.chip) } } } + +#Preview("Chips") { + HStack(spacing: UI.Tokens.Space.m) { + UI.Badge.ScopeLabel(symbol: "shippingbox", title: "Containers") + UI.Control.TintSwatch(color: .accentColor, followsAccent: true) + UI.Control.TintSwatch(color: .teal) + } + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/ControlViewModifiers.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/ControlViewModifiers.swift index 0521dee8..3541ad61 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/ControlViewModifiers.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/ControlViewModifiers.swift @@ -11,3 +11,17 @@ public extension View { contentShape(Capsule(style: .continuous)) } } + +#Preview("Control Modifiers") { + HStack(spacing: UI.Tokens.Space.m) { + Text("Subtle tile") + .padding(UI.Tokens.Space.m) + .subtleTileBackground() + + Image(systemName: "slider.horizontal.3") + .padding(UI.Tokens.Space.m) + .materialCapsuleSurface(shadow: false) + .toolbarControlContentShape() + } + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift index 534c0603..dabf2eb3 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift @@ -62,3 +62,17 @@ private struct AngleDial: View { } } } + +#Preview("Gradient Angle") { + GradientAnglePreview() + .padding(UI.Tokens.Space.xl) + .frame(width: 420) +} + +private struct GradientAnglePreview: View { + @State private var angle = 135.0 + + var body: some View { + UI.Control.GradientAngle(angle: $angle, title: "Gradient angle") + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/InfoButton.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/InfoButton.swift index 6522a3ad..227191ba 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/InfoButton.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/InfoButton.swift @@ -49,3 +49,11 @@ public extension UI.Control { } } } + +#Preview("Info Button") { + HStack(spacing: UI.Tokens.Space.s) { + Text("Runtime path") + UI.Control.InfoButton("The executable path used for this runtime.") + } + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift index 54d072a5..89e6bb6a 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift @@ -39,3 +39,12 @@ struct KeyboardHint: View { } } } + +#Preview("Keyboard Hints") { + HStack(spacing: UI.Tokens.Space.l) { + UI.Control.KeyCap("⌘K") + UI.Control.KeyboardHint("↩", "Open") + UI.Control.KeyboardHint("esc", "Close") + } + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift index 96ee1fee..f3ff27ad 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/ListControls.swift @@ -69,33 +69,43 @@ struct MetadataRow: View { } public var body: some View { - HStack(spacing: UI.Tokens.Space.m) { - Image(systemName: systemImage) - .foregroundStyle(tint) - .frame(width: UI.Tokens.IconSize.rowIconColumn) - VStack(alignment: .leading, spacing: UI.Tokens.Card.compactTextSpacing) { - Text(title) - .font(isMonospaced ? .system(.callout, design: .monospaced) : .callout) - .lineLimit(1) - if let subtitle { - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - Spacer(minLength: UI.Tokens.Space.s) + SharedIconTextRow(systemImage: systemImage, + tint: tint, + subtitle: subtitle, + action: action) { + Text(title) + .font(isMonospaced ? .system(.callout, design: .monospaced) : .callout) + .lineLimit(1) + } accessory: { accessory() } - .padding(.vertical, UI.Tokens.Space.s) - .contentShape(Rectangle()) - .onTapGesture { - action?() - } } } } +#Preview("List Controls") { + UI.List.Stack { + UI.List.Section("Runtime") { + UI.List.MetadataBadgeRow(systemImage: "shippingbox", + title: "preview-web", + badge: "running", + subtitle: "docker.io/library/nginx:latest", + tint: .accentColor) { + UI.List.RowChevron() + } + UI.List.MetadataRow(systemImage: "externaldrive", + title: "preview-data", + subtitle: "/Users/preview/.contained") { + UI.Badge.Text(text: "10 GB") + } + UI.List.KeyValueRow(label: "Runtime", value: "Apple container") + UI.List.CompactInfoRow("Image", value: "nginx:latest") + } + } + .padding(UI.Tokens.Space.xl) + .frame(width: 460) +} + public extension UI.List.MetadataRow where Accessory == EmptyView { init(systemImage: String, title: String, @@ -141,29 +151,20 @@ struct MetadataBadgeRow: View { } public var body: some View { - HStack(spacing: UI.Tokens.Space.m) { - Image(systemName: systemImage) - .foregroundStyle(tint) - .frame(width: UI.Tokens.IconSize.rowIconColumn) - VStack(alignment: .leading, spacing: UI.Tokens.Card.compactTextSpacing) { - HStack(spacing: UI.Tokens.Space.xs) { - Text(title) - .font(isMonospaced ? .system(.callout, design: .monospaced) : .callout) - .lineLimit(1) - if let badge { - UI.Badge.Text(text: badge) - } - } - if let subtitle { - Text(subtitle) - .designSecondaryCaption() - .lineLimit(1) + SharedIconTextRow(systemImage: systemImage, + tint: tint, + subtitle: subtitle) { + HStack(spacing: UI.Tokens.Space.xs) { + Text(title) + .font(isMonospaced ? .system(.callout, design: .monospaced) : .callout) + .lineLimit(1) + if let badge { + UI.Badge.Text(text: badge) } } - Spacer(minLength: UI.Tokens.Space.s) + } accessory: { accessory() } - .padding(.vertical, UI.Tokens.Space.s) } } diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift index 27e71d6c..9f3f8268 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/MaterialButton.swift @@ -234,3 +234,24 @@ struct MaterialButton: View { return AnyShapeStyle(color.opacity(tintStyle.opacity)) } } + +#Preview("Material Button") { + VStack(alignment: .leading, spacing: UI.Tokens.Space.l) { + MaterialButton { + MaterialButtonItem(systemName: "play.fill", tint: .green, help: "Start") {} + MaterialButtonItem(systemName: "stop.fill", role: .destructive, help: "Stop") {} + } + + MaterialButton(singleItem: true) { + MaterialButtonInputItem { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + Text("Search") + .foregroundStyle(.secondary) + } + } + .frame(width: 220) + } + .padding(UI.Tokens.Space.xl) + .environment(\.buttonMaterial, .glassClear) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift index c4a92dad..eab30232 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift @@ -117,3 +117,17 @@ private extension View { .glassEffect(interactive ? .regular.interactive() : .regular, in: shape) } } + +#Preview("Option Tiles") { + UI.Control.OptionStack { + UI.Control.OptionTile(symbol: "shippingbox", + title: "Container", + subtitle: "Create from an image") {} + UI.Control.OptionTile(symbol: "square.stack.3d.up", + title: "Image", + subtitle: "Pull or build", + enabled: false) {} + } + .padding(UI.Tokens.Space.xl) + .frame(width: 420) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift index 8475d496..68f88d29 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift @@ -35,3 +35,11 @@ struct RowMenu: View { } } } + +#Preview("Row Menu") { + UI.Control.RowMenu(accessibilityLabel: "More actions") { + Button("Inspect") {} + Button("Remove", role: .destructive) {} + } + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift index 9e6ab6a7..b3a7aa83 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift @@ -43,3 +43,20 @@ struct SearchField: View { } } } + +#Preview("Search Field") { + ControlSearchFieldPreview() + .padding(UI.Tokens.Space.xl) + .frame(width: 360) +} + +private struct ControlSearchFieldPreview: View { + @State private var text = "nginx" + + var body: some View { + UI.Control.SearchField(text: $text, + prompt: "Search images", + clearLabel: "Clear search", + isSearching: false) + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift index d3af4446..84c7e40d 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift @@ -47,34 +47,31 @@ public extension UI.Control { } private var automaticSwatch: some View { - ZStack { - Circle().fill(Color.secondary.opacity(0.18)).frame(width: 22, height: 22) - Image(systemName: "rectangle.on.rectangle") - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - Circle() - .strokeBorder(selection.wrappedValue == nil ? Color.primary : Color.secondary.opacity(0.35), - lineWidth: selection.wrappedValue == nil ? 2 : 1) - .frame(width: 24, height: 24) - } - .frame(width: 26, height: 26) + SharedTintSwatchMark(color: Color.secondary.opacity(0.18), + markerSystemName: "rectangle.on.rectangle", + markerForeground: .secondary, + selected: selection.wrappedValue == nil) } private func swatch(_ tint: UI.Theme.Tint) -> some View { - ZStack { - Circle().fill(tint.color).frame(width: 22, height: 22) - // Mark the "follow the host accent" option so it reads as automatic, not a fixed color. - if tint.followsAccent { - Image(systemName: "link") - .font(.caption2.weight(.bold)) - .foregroundStyle(.white) - } - Circle() - .strokeBorder(selection.wrappedValue == tint ? Color.primary : Color.secondary.opacity(0.35), - lineWidth: selection.wrappedValue == tint ? 2 : 1) - .frame(width: 24, height: 24) - } - .frame(width: 26, height: 26) + SharedTintSwatchMark(color: tint.color, + markerSystemName: tint.followsAccent ? "link" : nil, + selected: selection.wrappedValue == tint) } } } + +#Preview("Tint Selector") { + TintSelectorPreview() + .padding(UI.Tokens.Space.xl) +} + +private struct TintSelectorPreview: View { + @State private var tint: UI.Theme.Tint? = .azure + + var body: some View { + UI.Control.TintSelector(optionalSelection: $tint, + automaticLabel: "Automatic", + labelForTint: { $0.rawValue.capitalized }) + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/ToolbarControls.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/ToolbarControls.swift index 86da6e20..3eeab8e0 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Control/ToolbarControls.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/ToolbarControls.swift @@ -223,3 +223,41 @@ struct TitleSubtitle: View { } } } + +#Preview("Toolbar Controls") { + ToolbarControlsPreview() + .padding(UI.Tokens.Space.xl) + .frame(width: 620) + .environment(\.buttonMaterial, .glassClear) +} + +private struct ToolbarControlsPreview: View { + @State private var text = "preview" + @FocusState private var searchFocused: Bool + + var body: some View { + HStack(spacing: UI.Tokens.Space.m) { + UI.Toolbar.VanitySlot() + UI.Toolbar.SearchField(text: $text, + prompt: "Search", + clearSearchLabel: "Clear search", + focused: $searchFocused, + onClear: { text = "" }) { + Image(systemName: "command") + .foregroundStyle(.secondary) + } + UI.Toolbar.ActionCluster { + UI.Toolbar.MenuButton(systemName: "line.3.horizontal.decrease.circle", + help: "Filter") { + Button("Running") {} + Button("Stopped") {} + } + UI.Toolbar.StatusButton(help: "Runtime status", action: {}) { + UI.Toolbar.TitleSubtitle(symbol: "shippingbox", + title: "Runtime", + subtitle: "Ready") + } + } + } + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift index 041b5aef..eca0bde8 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift @@ -70,3 +70,16 @@ public extension UI.Panel.PageScaffold where Actions == EmptyView { actions: { EmptyView() }, content: content) } } + +#Preview("Page Scaffold") { + UI.Panel.PageScaffold(symbol: "shippingbox", + title: "Containers", + subtitle: "All reachable runtimes") { + UI.Panel.Section(header: "Runtime inventory") { + UI.List.MetadataRow(systemImage: "shippingbox", + title: "preview-web", + subtitle: "docker.io/library/nginx:latest") + } + } + .frame(width: 520, height: 420) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Panel/Panel+Scaffold.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/Panel+Scaffold.swift index a76aa3e8..190d5bbb 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Panel/Panel+Scaffold.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/Panel+Scaffold.swift @@ -70,3 +70,26 @@ public extension UI.Panel.Scaffold where Footer == EmptyView { chrome: chrome, content: content, footer: { EmptyView() }) } } + +#Preview("Panel Scaffold") { + UI.Panel.Scaffold(width: 460) { + UI.Panel.Header(symbol: "plus", + title: "New container", + subtitle: "Choose a source") { + UI.Action.Group(UI.Action.Item(systemName: "xmark", help: "Close") {}) + } + Divider() + } content: { + UI.Control.OptionStack { + UI.Control.OptionTile(symbol: "shippingbox", + title: "Container", + subtitle: "Run from an image") {} + UI.Control.OptionTile(symbol: "doc.text", + title: "Compose", + subtitle: "Import services") {} + } + .padding(UI.Tokens.Space.l) + } + .frame(width: 460, height: 360) + .environment(\.buttonMaterial, .glassClear) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Panel/Section.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/Section.swift index 8c5fbd15..03de60aa 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Panel/Section.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/Section.swift @@ -130,11 +130,6 @@ struct Row: View { @Environment(\.panelSectionHighlighted) private var sectionHighlighted @State private var labelHovering = false - private var labelColor: Color { - if error != nil { return .red } - return sectionHighlighted ? .accentColor : .primary - } - public init(title: String, subtitle: String? = nil, info: String? = nil, @@ -151,10 +146,11 @@ struct Row: View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: UI.Tokens.Space.m) { VStack(alignment: .leading, spacing: 1) { - HStack(spacing: UI.Tokens.Space.xs) { - Text(title).foregroundStyle(labelColor) - if let info { UI.Control.InfoButton(info, visible: labelHovering) } - } + SharedPanelLabel(title: title, + info: info, + error: error, + highlighted: sectionHighlighted, + hovering: labelHovering) if let subtitle { Text(subtitle).font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -174,6 +170,36 @@ struct Row: View { } } +#Preview("Panel Section") { + PanelSectionPreview() + .padding(UI.Tokens.Space.xl) + .frame(width: 520) +} + +private struct PanelSectionPreview: View { + @State private var enabled = true + @State private var path = "/usr/bin/container" + + var body: some View { + UI.Panel.Section(header: "Runtime", + footer: "Use explicit paths when a runtime is installed outside the default shell path.", + collapsible: true, + highlighted: true, + enabled: $enabled) { + UI.Panel.Row(title: "Status", subtitle: "Last checked just now") { + UI.Badge.Status(text: "Ready", tint: .green) + } + UI.Panel.ToggleRow(title: "Service controls", + subtitle: "Available for Apple container", + isOn: $enabled) + UI.Panel.Field(label: "CLI path") { + TextField("/usr/bin/container", text: $path) + .textFieldStyle(.roundedBorder) + } + } + } +} + public extension UI.Panel.Row where Trailing == EmptyView { init(title: String, subtitle: String? = nil, info: String? = nil, error: String? = nil) { self.init(title: title, subtitle: subtitle, info: info, error: error) { EmptyView() } @@ -222,11 +248,6 @@ struct Field: View { @Environment(\.panelSectionHighlighted) private var sectionHighlighted @State private var labelHovering = false - private var labelColor: Color { - if error != nil { return .red } - return sectionHighlighted ? .accentColor : .primary - } - public init(label: String, info: String? = nil, error: String? = nil, @@ -242,12 +263,12 @@ struct Field: View { public var body: some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: UI.Tokens.Space.m) { - HStack(spacing: UI.Tokens.Space.xs) { - Text(label) - .foregroundStyle(labelColor) - if let info { UI.Control.InfoButton(info, visible: labelHovering) } - } - .frame(width: labelWidth, alignment: .leading) + SharedPanelLabel(title: label, + info: info, + error: error, + highlighted: sectionHighlighted, + hovering: labelHovering, + width: labelWidth) control().frame(maxWidth: .infinity) } .contentShape(Rectangle()) diff --git a/Packages/ContainedUI/Sources/ContainedUI/Panel/SheetTitleBar.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/SheetTitleBar.swift index 315880cb..183df855 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Panel/SheetTitleBar.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/SheetTitleBar.swift @@ -54,3 +54,16 @@ public extension UI.Panel.SheetTitleBar where Trailing == EmptyView { onCancel: onCancel) { EmptyView() } } } + +#Preview("Sheet Title Bar") { + UI.Panel.SheetTitleBar(title: "Pull image", + subtitle: "docker.io/library/nginx:latest", + cancelHelp: "Cancel", + onCancel: {}) { + UI.Action.TextButton(title: "Pull", + systemName: "arrow.down.circle", + prominence: .prominent) {} + } + .frame(width: 520) + .environment(\.buttonMaterial, .glassClear) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Panel/TitleBar.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/TitleBar.swift index ce271260..dd6ecee6 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Panel/TitleBar.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/TitleBar.swift @@ -55,3 +55,14 @@ struct Header: View { } } } + +#Preview("Panel Header") { + UI.Panel.Header(symbol: "gearshape", + title: "Runtime settings", + subtitle: "Apple container") { + UI.Action.Group(UI.Action.Item(systemName: "arrow.clockwise", help: "Refresh") {}) + } + .padding(UI.Tokens.Space.xl) + .frame(width: 520) + .environment(\.buttonMaterial, .glassClear) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Previews/UIPreviews.swift b/Packages/ContainedUI/Sources/ContainedUI/Previews/UIPreviews.swift deleted file mode 100644 index 3cb26395..00000000 --- a/Packages/ContainedUI/Sources/ContainedUI/Previews/UIPreviews.swift +++ /dev/null @@ -1,89 +0,0 @@ -#if CONTAINED_UI_PREVIEWS -import SwiftUI - -#Preview("Card") { - CardPreview() - .padding(UI.Tokens.Space.xl) - .frame(width: 420) - .environment(\.cardMaterial, .glassRegular) - .environment(\.buttonMaterial, .glassClear) -} - -#Preview("Controls") { - VStack(alignment: .leading, spacing: UI.Tokens.Space.l) { - UI.Panel.Section(header: "Controls") { - UI.Panel.Row(title: "Tint") { - UI.Control.TintSelector(selection: .constant(.azure)) { tint in - tint.rawValue.capitalized - } - } - UI.Panel.Row(title: "Actions") { - UI.Action.Group([ - UI.Action.Item(systemName: "play.fill", help: "Start") {}, - UI.Action.Item(systemName: "stop.fill", help: "Stop", role: .destructive) {} - ]) - } - } - - UI.Command.PreviewBar(command: ["container", "run", "--name", "preview-web", "nginx"], - copyHelp: "Copy command", - copiedAccessibilityLabel: "Copied") - } - .padding(UI.Tokens.Space.xl) - .frame(width: 520) - .environment(\.buttonMaterial, .glassClear) -} - -private struct CardPreview: View { - @State private var page = "overview" - - private let pages = [ - UI.Card.Page(id: "overview", title: "Overview", systemImage: "rectangle.grid.1x2"), - UI.Card.Page(id: "stats", title: "Stats", systemImage: "chart.xyaxis.line"), - ] - - var body: some View { - UI.Card.Scaffold(size: .large, - isExpanded: true, - title: "preview-web", - subtitle: "docker.io/library/nginx:latest", - pages: UI.Card.Pages(items: pages, - selection: page, - tint: .accentColor, - closeLabel: "Close", - onSelect: { page = $0 }, - onClose: {})) { - UI.Card.IconChip(symbol: "shippingbox.fill", tint: .accentColor) - } titleAccessory: { - UI.Badge.Text(text: "Running") - } subtitleAccessory: { - EmptyView() - } headerAccessory: { - EmptyView() - } bodyContent: { - UI.Card.InsetSection(title: "Live") { - UI.Chart.Sparkline(samples: [0.1, 0.2, 0.18, 0.4, 0.34, 0.55], - color: .accentColor, - scale: .fraction) - .frame(height: UI.Tokens.Card.sparklineHeight) - } - } footerLeading: { - UI.Card.FooterChip(isSelected: true, tint: .accentColor, help: "CPU", action: {}) { - Image(systemName: "cpu") - } text: { - UI.Card.MetricText(text: "42%") - } - } footerActions: { - UI.Card.FooterButton(systemName: "play.fill", help: "Start", tint: .accentColor) {} - } widget: { - UI.Card.WidgetGroup { - UI.Card.FooterMini { - Image(systemName: "memorychip") - } text: { - UI.Card.MetricText(text: "420 MB") - } - } - } - } -} -#endif diff --git a/Packages/ContainedUI/Sources/ContainedUI/Shared/CapsuleLabel.swift b/Packages/ContainedUI/Sources/ContainedUI/Shared/CapsuleLabel.swift new file mode 100644 index 00000000..899bf6eb --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Shared/CapsuleLabel.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct SharedCapsuleLabel: View { + var horizontalPadding: CGFloat + var verticalPadding: CGFloat + var foreground: AnyShapeStyle? + var fill: AnyShapeStyle + @ViewBuilder var content: () -> Content + + init(horizontalPadding: CGFloat, + verticalPadding: CGFloat, + foreground: AnyShapeStyle? = nil, + fill: AnyShapeStyle, + @ViewBuilder content: @escaping () -> Content) { + self.horizontalPadding = horizontalPadding + self.verticalPadding = verticalPadding + self.foreground = foreground + self.fill = fill + self.content = content + } + + var body: some View { + styledContent + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .background(fill, in: Capsule(style: .continuous)) + } + + @ViewBuilder + private var styledContent: some View { + if let foreground { + content().foregroundStyle(foreground) + } else { + content() + } + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Shared/IconTextRow.swift b/Packages/ContainedUI/Sources/ContainedUI/Shared/IconTextRow.swift new file mode 100644 index 00000000..186a5432 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Shared/IconTextRow.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct SharedIconTextRow: View { + var systemImage: String + var tint: Color + var iconFont: Font + var iconWidth: CGFloat + var rowSpacing: CGFloat + var titleSubtitleSpacing: CGFloat + var subtitle: String? + var subtitleMonospaced: Bool + var horizontalPadding: CGFloat? + var verticalPadding: CGFloat + var fillsWidth: Bool + var action: (() -> Void)? + @ViewBuilder var title: () -> Title + @ViewBuilder var accessory: () -> Accessory + + init(systemImage: String, + tint: Color = .secondary, + iconFont: Font = .callout, + iconWidth: CGFloat = UI.Tokens.IconSize.rowIconColumn, + rowSpacing: CGFloat = UI.Tokens.Space.m, + titleSubtitleSpacing: CGFloat = UI.Tokens.Card.compactTextSpacing, + subtitle: String? = nil, + subtitleMonospaced: Bool = false, + horizontalPadding: CGFloat? = nil, + verticalPadding: CGFloat = UI.Tokens.Space.s, + fillsWidth: Bool = false, + action: (() -> Void)? = nil, + @ViewBuilder title: @escaping () -> Title, + @ViewBuilder accessory: @escaping () -> Accessory) { + self.systemImage = systemImage + self.tint = tint + self.iconFont = iconFont + self.iconWidth = iconWidth + self.rowSpacing = rowSpacing + self.titleSubtitleSpacing = titleSubtitleSpacing + self.subtitle = subtitle + self.subtitleMonospaced = subtitleMonospaced + self.horizontalPadding = horizontalPadding + self.verticalPadding = verticalPadding + self.fillsWidth = fillsWidth + self.action = action + self.title = title + self.accessory = accessory + } + + var body: some View { + row + .padding(.horizontal, horizontalPadding ?? 0) + .padding(.vertical, verticalPadding) + .frame(maxWidth: fillsWidth ? .infinity : nil, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { action?() } + } + + private var row: some View { + HStack(spacing: rowSpacing) { + Image(systemName: systemImage) + .font(iconFont) + .foregroundStyle(tint) + .frame(width: iconWidth) + VStack(alignment: .leading, spacing: titleSubtitleSpacing) { + title() + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(subtitleMonospaced ? .system(.caption, design: .monospaced) : .caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: UI.Tokens.Space.s) + accessory() + } + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Shared/PanelLabel.swift b/Packages/ContainedUI/Sources/ContainedUI/Shared/PanelLabel.swift new file mode 100644 index 00000000..874f168a --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Shared/PanelLabel.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct SharedPanelLabel: View { + var title: String + var info: String? + var error: String? + var highlighted: Bool + var hovering: Bool + var width: CGFloat? + + var body: some View { + label + .optionalFixedWidth(width) + } + + private var label: some View { + HStack(spacing: UI.Tokens.Space.xs) { + Text(title) + .foregroundStyle(labelColor) + if let info { + UI.Control.InfoButton(info, visible: hovering) + } + } + } + + private var labelColor: Color { + if error != nil { return .red } + return highlighted ? .accentColor : .primary + } +} + +private extension View { + @ViewBuilder + func optionalFixedWidth(_ width: CGFloat?) -> some View { + if let width { + frame(width: width, alignment: .leading) + } else { + self + } + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Shared/SurfaceRendering.swift b/Packages/ContainedUI/Sources/ContainedUI/Shared/SurfaceRendering.swift new file mode 100644 index 00000000..fbb92aab --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Shared/SurfaceRendering.swift @@ -0,0 +1,22 @@ +import SwiftUI + +enum SharedSurfaceRendering { + static func fillStyle(color: Color, + opacity: Double, + gradient: Bool, + gradientAngle: Double) -> AnyShapeStyle { + guard gradient else { return AnyShapeStyle(color.opacity(opacity)) } + let radians = gradientAngle * .pi / 180 + let dx = cos(radians) / 2 + let dy = sin(radians) / 2 + return AnyShapeStyle(LinearGradient( + colors: [color.opacity(opacity * 1.35), color.opacity(opacity * 0.4)], + startPoint: UnitPoint(x: 0.5 - dx, y: 0.5 - dy), + endPoint: UnitPoint(x: 0.5 + dx, y: 0.5 + dy))) + } + + static func shadowColor(for colorScheme: ColorScheme, scale: Double = 1) -> Color { + let base = colorScheme == .dark ? 0.55 : 0.18 + return .black.opacity(base * scale) + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Shared/TintSwatchMark.swift b/Packages/ContainedUI/Sources/ContainedUI/Shared/TintSwatchMark.swift new file mode 100644 index 00000000..30387bac --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Shared/TintSwatchMark.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct SharedTintSwatchMark: View { + var color: Color + var markerSystemName: String? + var markerForeground: Color + var selected: Bool? + var size: CGFloat + var fillSize: CGFloat + var ringSize: CGFloat + + init(color: Color, + markerSystemName: String? = nil, + markerForeground: Color = .white, + selected: Bool? = nil, + size: CGFloat = 26, + fillSize: CGFloat = 22, + ringSize: CGFloat = 24) { + self.color = color + self.markerSystemName = markerSystemName + self.markerForeground = markerForeground + self.selected = selected + self.size = size + self.fillSize = fillSize + self.ringSize = ringSize + } + + var body: some View { + ZStack { + Circle() + .fill(color) + .frame(width: fillSize, height: fillSize) + if let markerSystemName { + Image(systemName: markerSystemName) + .font(.caption2.weight(.bold)) + .foregroundStyle(markerForeground) + } + if let selected { + Circle() + .strokeBorder(selected ? Color.primary : Color.secondary.opacity(0.35), + lineWidth: selected ? 2 : 1) + .frame(width: ringSize, height: ringSize) + } + } + .frame(width: size, height: size) + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift b/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift index 6832899e..5be09bbc 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift @@ -89,3 +89,23 @@ struct ActivityStatusIndicator: View { } } } + +#Preview("Activity Status") { + VStack(alignment: .leading, spacing: UI.Tokens.Space.l) { + UI.State.ActivityStatusIndicator(activity: UI.State.ActivityStatus(title: "Pulling image", + detail: "sha256:preview", + fraction: 0.64), + style: .inline) + .padding(UI.Tokens.Space.s) + .materialCapsuleSurface(shadow: false) + + UI.State.ActivityStatusIndicator(activity: UI.State.ActivityStatus(title: "Building image", + detail: "RUN swift build", + fraction: 0.42), + style: .expanded) + .padding(UI.Tokens.Space.l) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card) + } + .padding(UI.Tokens.Space.xl) + .frame(width: 420) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift b/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift index 4874a66f..3fb427e2 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift @@ -31,12 +31,22 @@ struct Status: View { } public var body: some View { - SwiftUI.Text(text) - .font(font) - .foregroundStyle(tint) - .padding(.horizontal, UI.Tokens.Badge.horizontalPadding) - .padding(.vertical, UI.Tokens.Badge.verticalPadding) - .background(tint.opacity(UI.Tokens.Badge.statusOpacity), in: Capsule()) + SharedCapsuleLabel(horizontalPadding: UI.Tokens.Badge.horizontalPadding, + verticalPadding: UI.Tokens.Badge.verticalPadding, + foreground: AnyShapeStyle(tint), + fill: AnyShapeStyle(tint.opacity(UI.Tokens.Badge.statusOpacity))) { + SwiftUI.Text(text) + .font(font) + } } } } + +#Preview("Badges") { + HStack(spacing: UI.Tokens.Space.m) { + UI.Badge.Dot(color: .green) + UI.Badge.Status(text: "Running", tint: .green) + UI.Badge.Status(text: "Failed", tint: .red) + } + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift b/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift index 9e35624b..ab5fb76c 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift @@ -140,6 +140,12 @@ struct Empty: View { } } +// TODO(design-system): `UI.State.Empty`, `UI.State.Hero`, and `UI.State.Loading` all render centered +// state stacks, but their action slots and sizing contracts differ today. If this area gets another +// layout pass, consider a Shared centered-state scaffold and re-check current app call sites in: +// `Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift`, +// `Sources/ContainedApp/Features/Onboarding/BootstrapView.swift`, and +// `Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift`. struct Hero: View { public var systemImage: String public var title: String @@ -339,3 +345,33 @@ public extension View { .foregroundStyle(.secondary) } } + +#Preview("Content States") { + VStack(spacing: UI.Tokens.Space.l) { + HStack(spacing: UI.Tokens.Space.m) { + UI.Symbol.Image(systemName: "shippingbox", tone: .accent, size: .title3) + UI.State.StatusText("Ready", tone: .success) + UI.State.InlineStatus("Checking", isWorking: true) + UI.State.SectionLabel("Section") + } + + UI.State.Empty("No containers", + systemImage: "shippingbox", + description: "Create or import a container to begin.", + tone: .neutral, + minHeight: 120) + + UI.State.Hero(systemImage: "square.stack.3d.up", + title: "Images", + message: "Pull, build, and tag runtime images.") { + UI.Action.TextButton(title: "Pull", + systemName: "arrow.down.circle", + prominence: .prominent) {} + } + + UI.State.Loading("Loading runtimes", minHeight: 80) + UI.State.ProgressIndicator(frameSize: 24) + } + .padding(UI.Tokens.Space.xl) + .frame(width: 520) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift b/Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift index 9bff30b3..d6c087ca 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift @@ -23,3 +23,9 @@ struct ErrorBanner: View { } } } + +#Preview("Error Banner") { + UI.State.ErrorBanner(message: "Docker runtime is not reachable.") + .padding(UI.Tokens.Space.xl) + .frame(width: 460) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift b/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift index fd21cf76..a0f18bce 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift @@ -18,3 +18,8 @@ struct Banner: View { } } } + +#Preview("Status Banner") { + UI.State.Banner("Image pull completed") + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift index a945aa95..a0051b7b 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift @@ -29,3 +29,13 @@ struct Content: View { } } } + +#Preview("Content Surface") { + UI.Surface.Content(elevated: true, minHeight: 140) { + UI.State.Empty("No results", + systemImage: "magnifyingglass", + description: "Try another search term.") + } + .padding(UI.Tokens.Space.xl) + .frame(width: 420) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Surface/ExteriorShadow.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/ExteriorShadow.swift index a4fbf4ae..d98dba31 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Surface/ExteriorShadow.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/ExteriorShadow.swift @@ -28,3 +28,16 @@ struct ExteriorShadow: View { .allowsHitTesting(false) } } + +#Preview("Exterior Shadow") { + ZStack { + ExteriorShadow(cornerRadius: UI.Tokens.Radius.card, + color: .black.opacity(0.24), + radius: 16, + y: 8) + RoundedRectangle(cornerRadius: UI.Tokens.Radius.card, style: .continuous) + .fill(.thinMaterial) + } + .frame(width: 220, height: 120) + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift index fed607ea..e08eeb94 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift @@ -27,3 +27,16 @@ struct Input: View { } } } + +#Preview("Input Surface") { + UI.Surface.Input { + HStack { + Image(systemName: "terminal") + .foregroundStyle(.secondary) + Text("container run nginx") + .font(.system(.caption, design: .monospaced)) + } + } + .padding(UI.Tokens.Space.xl) + .frame(width: 420) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Surface/MaterialSurface.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/MaterialSurface.swift index f0db76fe..73a443a7 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Surface/MaterialSurface.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/MaterialSurface.swift @@ -55,33 +55,20 @@ struct MaterialSurface: ViewModifier { .glassEffect(glass, in: shape) .background { if let fill { - shape.fill(fillStyle(fill)) + shape.fill(SharedSurfaceRendering.fillStyle(color: fill, + opacity: fillOpacity, + gradient: gradient, + gradientAngle: gradientAngle)) } } } - private func fillStyle(_ color: Color) -> AnyShapeStyle { - if gradient { - let radians = gradientAngle * .pi / 180 - let dx = cos(radians) / 2 - let dy = sin(radians) / 2 - return AnyShapeStyle(LinearGradient( - colors: [color.opacity(fillOpacity * 1.35), color.opacity(fillOpacity * 0.4)], - startPoint: UnitPoint(x: 0.5 - dx, y: 0.5 - dy), - endPoint: UnitPoint(x: 0.5 + dx, y: 0.5 + dy))) - } - return AnyShapeStyle(color.opacity(fillOpacity)) + private var shadowColor: Color { + SharedSurfaceRendering.shadowColor(for: colorScheme, scale: shadowScale) } - private var shadowColor: Color { - let base = colorScheme == .dark ? 0.55 : 0.18 - let scale: Double - switch level { - case .regular: scale = 1.0 - case .thin: scale = 0.6 - case .ultraThin: scale = 0.4 - } - return .black.opacity(base * scale) + private var shadowScale: Double { + switch level { case .regular: return 1.0; case .thin: return 0.6; case .ultraThin: return 0.4 } } private var shadowRadius: CGFloat { @@ -129,14 +116,11 @@ struct MaterialCapsuleSurface: ViewModifier { } private var shadowColor: Color { - let base = colorScheme == .dark ? 0.55 : 0.18 - let scale: Double - switch level { - case .regular: scale = 1.0 - case .thin: scale = 0.6 - case .ultraThin: scale = 0.4 - } - return .black.opacity(base * scale) + SharedSurfaceRendering.shadowColor(for: colorScheme, scale: shadowScale) + } + + private var shadowScale: Double { + switch level { case .regular: return 1.0; case .thin: return 0.6; case .ultraThin: return 0.4 } } private var shadowRadius: CGFloat { @@ -174,3 +158,23 @@ extension View { fillOpacity: fillOpacity)) } } + +#Preview("Material Surfaces") { + VStack(spacing: UI.Tokens.Space.l) { + Text("Card surface") + .frame(maxWidth: .infinity) + .padding(UI.Tokens.Space.l) + .materialSurface(.regular, + cornerRadius: UI.Tokens.Radius.card, + fill: .accentColor, + fillOpacity: 0.10, + gradient: true) + + Text("Capsule surface") + .padding(.horizontal, UI.Tokens.Space.l) + .padding(.vertical, UI.Tokens.Space.s) + .materialCapsuleSurface(shadow: false, fill: .teal, fillOpacity: 0.12) + } + .padding(UI.Tokens.Space.xl) + .frame(width: 360) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift index def1d624..4f16f802 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift @@ -44,3 +44,13 @@ struct BackgroundLayer: View { } } } + +#Preview("Visual Effect Background") { + ZStack { + UI.Theme.BackgroundLayer(material: .fullScreenUI) + Text("Background layer") + .padding(UI.Tokens.Space.l) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card) + } + .frame(width: 360, height: 220) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift b/Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift index b426684f..14c7b546 100644 --- a/Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift @@ -261,3 +261,27 @@ public extension View { modifier(ToolbarControlMaterial(shape: shape)) } } + +#Preview("Theme Materials") { + VStack(spacing: UI.Tokens.Space.l) { + HStack(spacing: UI.Tokens.Space.s) { + ForEach(UI.Theme.Tint.allCases) { tint in + UI.Control.TintSwatch(color: tint.color, followsAccent: tint.followsAccent) + } + } + + Text("Floating panel material") + .frame(maxWidth: .infinity) + .padding(UI.Tokens.Space.l) + .floatingPanelMaterial() + + Text("Toolbar control material") + .padding(.horizontal, UI.Tokens.Space.l) + .padding(.vertical, UI.Tokens.Space.s) + .toolbarControlMaterial(in: Capsule()) + } + .padding(UI.Tokens.Space.xl) + .frame(width: 420) + .environment(\.modalMaterial, .glassRegular) + .environment(\.buttonMaterial, .glassClear) +} diff --git a/Packages/ContainedUX/README.md b/Packages/ContainedUX/README.md index 90f6559e..190de740 100644 --- a/Packages/ContainedUX/README.md +++ b/Packages/ContainedUX/README.md @@ -109,9 +109,10 @@ UX.Morph.SingleSurface(source: sourceFrame, ## Previews -Package-local SwiftUI previews are guarded by `CONTAINED_UX_PREVIEWS`. Normal -app builds do not define that flag, so package preview sample values are kept out -of debug, release, notarized, and non-notarized app bundles. +Package-local SwiftUI previews live beside the interaction primitive they +exercise. Opening a morph, measurement, placement, or safe-area source file in +Xcode should show the matching canvas preview without looking in a separate +preview-only folder. ## Verification diff --git a/Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md b/Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md index f579b119..bfaad888 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md +++ b/Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md @@ -12,6 +12,10 @@ surface opens and why. `UI.Panel.Size`, `UI.Panel.Radius`, `UI.Toolbar.Size`, and `UI.Layout.Spacing`. Raw `UI.Tokens` stay inside `ContainedUI`. +SwiftUI previews are colocated with the morph, placement, measurement, and +safe-area declarations they exercise. The package does not use a separate +preview-only source tree. + ```swift UX.Morph.Expander(isPresented: $isPresented, originFrame: originFrame, diff --git a/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrameReader.swift b/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrameReader.swift index 7c0b48b4..2d1c6f3a 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrameReader.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrameReader.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedUI public extension UX.Measurement { struct SourceFrameReader: View { @@ -58,3 +59,30 @@ public extension CGRect { abs(height - other.height) <= tolerance } } + +#Preview("Source Frame Reader") { + SourceFrameReaderPreview() + .frame(width: 360, height: 220) +} + +private struct SourceFrameReaderPreview: View { + @State private var frames: [String: CGRect] = [:] + + var body: some View { + VStack(alignment: .leading, spacing: UI.Layout.Spacing.l) { + UI.Action.Group(UI.Action.Item(systemName: "plus", help: "Measured") {}) + .background(UX.Measurement.SourceFrameReader("button", coordinateSpaceName: "preview-space")) + Text(frameSummary) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + .padding(UI.Layout.Spacing.xl) + .coordinateSpace(name: "preview-space") + .onPreferenceChange(UX.Measurement.SourceFramesKey.self) { frames = $0 } + } + + private var frameSummary: String { + guard let frame = frames["button"] else { return "measuring..." } + return "x:\(Int(frame.minX)) y:\(Int(frame.minY)) w:\(Int(frame.width)) h:\(Int(frame.height))" + } +} diff --git a/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift b/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift index 0bec79bb..f5e5de61 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift @@ -340,3 +340,69 @@ public extension View { preference(key: MorphPanelPlacementKey.self, value: placement) } } + +#Preview("Morph Expander") { + MorphExpanderPreview() + .frame(width: 720, height: 520) + .environment(\.morphSafeAreaManager, + UX.SafeArea.Manager(topToolbarHeight: UI.Toolbar.Size.band, + bottomToolbarHeight: UI.Toolbar.Size.band)) + .environment(\.buttonMaterial, .glassClear) +} + +#Preview("Global Backdrop") { + ZStack { + LinearGradient(colors: [.blue.opacity(0.45), .purple.opacity(0.35)], + startPoint: .topLeading, + endPoint: .bottomTrailing) + UI.Surface.Content(elevated: true) { + Text("Backdrop") + } + } + .globalBackdrop(style: .blurAndDim, progress: 1) + .frame(width: 360, height: 220) +} + +private struct MorphExpanderPreview: View { + @State private var isPresented = true + + private let origin = CGRect(x: 24, + y: 24, + width: UI.Toolbar.Size.buttonGroupHeight, + height: UI.Toolbar.Size.buttonGroupHeight) + + var body: some View { + ZStack(alignment: .topLeading) { + UI.Action.Group(UI.Action.Item(systemName: "plus", help: "Open") { + isPresented = true + }) + .padding(UI.Layout.Spacing.l) + + UX.Morph.Expander(isPresented: $isPresented, + originFrame: origin, + target: .centered(size: UI.Panel.Size.add)) { + UI.Panel.Scaffold(width: UI.Panel.Size.add.width) { + UI.Panel.Header(symbol: "plus", + title: "Preview panel", + subtitle: "Reusable morph layout") { + UI.Action.Group(UI.Action.Item(systemName: "xmark", + help: "Close", + isCancel: true) { + isPresented = false + }) + } + } content: { + VStack(spacing: UI.Layout.Spacing.s) { + UI.Control.OptionTile(symbol: "shippingbox", + title: "Primary item", + subtitle: "Start from a template") {} + UI.Control.OptionTile(symbol: "square.stack.3d.up", + title: "Image", + subtitle: "Use a local image") {} + } + .padding(UI.Layout.Spacing.s) + } + } + } + } +} diff --git a/Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift b/Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift index c14490f5..7c098c63 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedUI public extension UX.Morph { struct Frame: Equatable, Sendable { @@ -195,3 +196,83 @@ public extension CGRect { UX.Morph.Geometry.interpolatedRect(from: self, to: target, progress: progress) } } + +#Preview("Single Surface") { + SingleSurfacePreview() + .frame(width: 620, height: 360) + .environment(\.buttonMaterial, .glassClear) +} + +private struct SingleSurfacePreview: View { + @State private var isPresented = true + + private let source = CGRect(x: 24, y: 24, width: 180, height: 120) + private let target = CGRect(x: 180, y: 70, width: 360, height: 220) + + var body: some View { + ZStack(alignment: .topLeading) { + UI.Card.Scaffold(title: "preview-web", + subtitle: "Collapsed") { + UI.Card.IconChip(symbol: "shippingbox.fill", tint: .accentColor) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() + } + .frame(width: source.width, height: source.height) + .position(x: source.midX, y: source.midY) + + UX.Morph.SingleSurface(source: source, + target: target, + progress: 0.65) { + UI.Card.Scaffold(size: .large, + isExpanded: true, + title: "preview-web", + subtitle: "Expanded") { + UI.Card.IconChip(symbol: "shippingbox.fill", tint: .accentColor) + } titleAccessory: { + UI.Badge.Text(text: "Running") + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + UI.Card.InsetSection { + UI.Chart.Sparkline(samples: [0.1, 0.3, 0.2, 0.62], + scale: .fraction) + .frame(height: 64) + } + } footerLeading: { + UI.Card.MetricText(text: "62%") + } footerActions: { + UI.Card.FooterButton(systemName: "xmark", help: "Close") {} + } widget: { + EmptyView() + } + } + + if isPresented { + UX.Morph.SingleSurfaceExpander(isPresented: $isPresented, + originFrame: source, + target: .centered(size: CGSize(width: 320, height: 180)), + showsBackdrop: false) { + UI.Surface.Content(elevated: true) { + Text("Lifecycle expander") + } + } + .opacity(0.001) + .allowsHitTesting(false) + } + } + } +} diff --git a/Packages/ContainedUX/Sources/ContainedUX/Panel/BackdropStyle.swift b/Packages/ContainedUX/Sources/ContainedUX/Panel/BackdropStyle.swift index 99226199..14ae22b4 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/Panel/BackdropStyle.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Panel/BackdropStyle.swift @@ -1,3 +1,6 @@ +import SwiftUI +import ContainedUI + public extension UX.Panel { struct BackdropStyle: OptionSet, Equatable, Sendable { public let rawValue: Int @@ -11,3 +14,15 @@ struct BackdropStyle: OptionSet, Equatable, Sendable { public static let blurAndDim: BackdropStyle = [.blur, .dim] } } + +#Preview("Backdrop Styles") { + HStack(spacing: UI.Layout.Spacing.m) { + Text("Dim") + .frame(width: 140, height: 90) + .globalBackdrop(style: .dim, progress: 1) + Text("Blur + dim") + .frame(width: 140, height: 90) + .globalBackdrop(style: .blurAndDim, progress: 1) + } + .padding(UI.Layout.Spacing.xl) +} diff --git a/Packages/ContainedUX/Sources/ContainedUX/Panel/Placement.swift b/Packages/ContainedUX/Sources/ContainedUX/Panel/Placement.swift index 14081c4c..b226449d 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/Panel/Placement.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Panel/Placement.swift @@ -1,4 +1,6 @@ import CoreGraphics +import SwiftUI +import ContainedUI public extension UX.Panel { enum Placement: Equatable, Sendable { @@ -6,3 +8,17 @@ enum Placement: Equatable, Sendable { case centered } } + +#Preview("Panel Placement") { + VStack(alignment: .leading, spacing: UI.Layout.Spacing.m) { + Text("Anchored panels grow from a measured source.") + Text("Centered panels use the safe content bounds.") + .foregroundStyle(.secondary) + } + .padding(UI.Layout.Spacing.l) + .background(.regularMaterial, + in: RoundedRectangle(cornerRadius: UI.Panel.Radius.surface, + style: .continuous)) + .padding(UI.Layout.Spacing.xl) + .frame(width: 360) +} diff --git a/Packages/ContainedUX/Sources/ContainedUX/Previews/NavigationPreviews.swift b/Packages/ContainedUX/Sources/ContainedUX/Previews/NavigationPreviews.swift deleted file mode 100644 index 86d451d2..00000000 --- a/Packages/ContainedUX/Sources/ContainedUX/Previews/NavigationPreviews.swift +++ /dev/null @@ -1,57 +0,0 @@ -#if CONTAINED_UX_PREVIEWS -import SwiftUI -import ContainedUI - -#Preview("Morph Panel") { - NavigationPreview() - .frame(width: 720, height: 520) - .environment(\.morphSafeAreaManager, - UX.SafeArea.Manager(topToolbarHeight: UI.Toolbar.Size.band, - bottomToolbarHeight: UI.Toolbar.Size.band)) - .environment(\.buttonMaterial, .glassClear) -} - -private struct NavigationPreview: View { - @State private var isPresented = true - - private let origin = CGRect(x: 24, - y: 24, - width: UI.Toolbar.Size.buttonGroupHeight, - height: UI.Toolbar.Size.buttonGroupHeight) - - var body: some View { - ZStack(alignment: .topLeading) { - UI.Action.Group(UI.Action.Item(systemName: "plus", help: "Open") { - isPresented = true - }) - .padding(UI.Layout.Spacing.l) - - UX.Morph.Expander(isPresented: $isPresented, - originFrame: origin, - target: .centered(size: UI.Panel.Size.add)) { - UI.Panel.Scaffold(width: UI.Panel.Size.add.width) { - UI.Panel.Header(symbol: "plus", - title: "Preview panel", - subtitle: "Reusable morph layout") { - UI.Action.Group(UI.Action.Item(systemName: "xmark", - help: "Close", - isCancel: true) { - isPresented = false - }) - } - } content: { - VStack(spacing: UI.Layout.Spacing.s) { - UI.Control.OptionTile(symbol: "shippingbox", - title: "Primary item", - subtitle: "Start from a template") {} - UI.Control.OptionTile(symbol: "square.stack.3d.up", - title: "Image", - subtitle: "Use a local image") {} - } - .padding(UI.Layout.Spacing.s) - } - } - } - } -} -#endif diff --git a/Packages/ContainedUX/Sources/ContainedUX/SafeArea/Manager.swift b/Packages/ContainedUX/Sources/ContainedUX/SafeArea/Manager.swift index a434adab..3ad62544 100644 --- a/Packages/ContainedUX/Sources/ContainedUX/SafeArea/Manager.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/SafeArea/Manager.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedUI public extension UX.SafeArea { struct ToolbarExclusion: OptionSet, Equatable, Sendable { @@ -79,3 +80,31 @@ struct Manager: Equatable, Sendable { public extension EnvironmentValues { @Entry var morphSafeAreaManager = UX.SafeArea.Manager() } + +#Preview("Morph Safe Area") { + SafeAreaManagerPreview() + .frame(width: 420, height: 260) +} + +private struct SafeAreaManagerPreview: View { + private let manager = UX.SafeArea.Manager(topToolbarHeight: UI.Toolbar.Size.band, + bottomToolbarHeight: UI.Toolbar.Size.band) + + var body: some View { + GeometryReader { proxy in + let bounds = manager.bounds(in: proxy.size, policy: .content) + ZStack(alignment: .topLeading) { + Rectangle() + .fill(.quaternary) + RoundedRectangle(cornerRadius: UI.Card.Radius.container, style: .continuous) + .stroke(Color.accentColor, lineWidth: 2) + .frame(width: bounds.width, height: bounds.height) + .position(x: bounds.midX, y: bounds.midY) + Text("content bounds") + .font(.caption) + .padding(UI.Layout.Spacing.s) + } + } + .padding(UI.Layout.Spacing.xl) + } +} diff --git a/Sources/ContainedApp/App/AppModel+Configuration.swift b/Sources/ContainedApp/App/AppModel+Configuration.swift index ad75d0a1..5b1f8f49 100644 --- a/Sources/ContainedApp/App/AppModel+Configuration.swift +++ b/Sources/ContainedApp/App/AppModel+Configuration.swift @@ -30,11 +30,11 @@ extension AppModel { let imported = try JSONDecoder.containedBackup().decode(AppStateEnvelope.self, from: data) let envelope = try migrator.migrateToCurrent(imported) try apply(envelope: envelope, selected: selected, replace: replace) - UserDefaults.standard.set(StateMigrator.currentSchemaVersion, forKey: StateMigrator.schemaVersionKey) + database.setSetting(StateMigrator.currentSchemaVersion, for: StateMigrator.schemaVersionSettingKey) } func resolveDowngradeByKeepingReadableData() { - UserDefaults.standard.set(StateMigrator.currentSchemaVersion, forKey: StateMigrator.schemaVersionKey) + database.setSetting(StateMigrator.currentSchemaVersion, for: StateMigrator.schemaVersionSettingKey) downgradeSchemaVersion = nil flash(AppText.keptReadableLocalData) } @@ -57,11 +57,11 @@ extension AppModel { func resetIncompatibleLocalState() { historyStore.clearAll() - UserDefaults.standard.set(StateMigrator.currentSchemaVersion, forKey: StateMigrator.schemaVersionKey) + database.setSetting(StateMigrator.currentSchemaVersion, for: StateMigrator.schemaVersionSettingKey) } func purgeDeadRows() { - let liveContainerIDs = Set(containers.snapshots.map(\.id)) + let liveContainerIDs = Set(containers.snapshots.map(\.scopedID)) let liveImageRefs = Set(images.map(\.reference)) let personalizations = personalization.purgeOrphans(liveContainerIDs: liveContainerIDs, liveImageRefs: liveImageRefs) @@ -84,7 +84,7 @@ extension AppModel { healthChecks.applyBackup(try value.decode([String: Core.Container.HealthCheck].self), replace: replace) } if selected.contains(.templates), let value = envelope.sections[.templates] { - historyStore.applyTemplates(try value.decode([TemplateSnapshot].self), replace: replace) + historyStore.applyTemplates(try value.decode([RecipeSnapshot].self), replace: replace) } if selected.contains(.history), let value = envelope.sections[.history] { historyStore.applyHistory(try value.decode(HistoryBackup.self), replace: replace) diff --git a/Sources/ContainedApp/App/AppModel+ImageUpdates.swift b/Sources/ContainedApp/App/AppModel+ImageUpdates.swift index a4fd8a7d..018c828c 100644 --- a/Sources/ContainedApp/App/AppModel+ImageUpdates.swift +++ b/Sources/ContainedApp/App/AppModel+ImageUpdates.swift @@ -129,8 +129,9 @@ extension AppModel { /// Pull one image and re-check its status. Returns true on a successful pull. @discardableResult - func pullImageUpdate(_ reference: String) async -> Bool { - let ok = await pullImage(reference) + func pullImageUpdate(_ reference: String, + runtimeKind: Core.Runtime.Kind) async -> Bool { + let ok = await pullImage(reference, runtimeKind: runtimeKind) if ok { await checkImageUpdate(reference, notify: false) flash(AppText.updatedImage(Format.shortImage(reference))) @@ -146,7 +147,7 @@ extension AppModel { if let lastImageUpdateSweep, now.timeIntervalSince(lastImageUpdateSweep) < imageUpdateInterval { return } if images.isEmpty, let client { do { - images = try await client.images() + images = try await client.runtimeImages() imagesError = nil } catch let error as Core.Command.Error { imagesError = error.appDisplayMessage @@ -197,11 +198,29 @@ extension AppModel { return 0 } var updated = 0 - for reference in pending where await pullImageUpdate(reference) { updated += 1 } + for reference in pending { + for runtimeKind in localRuntimeKinds(for: reference, summary: summary) where await pullImageUpdate(reference, runtimeKind: runtimeKind) { + updated += 1 + } + } if manual { flash(AppText.updatedItems(updated, singular: summary.noun)) } return updated } + private func localRuntimeKinds(for reference: String, + summary: ImageUpdateSummaryScope) -> [Core.Runtime.Kind] { + let key = imageUpdateKey(reference) + let runtimes: Set + switch summary { + case .localImages: + runtimes = Set(images.filter { imageUpdateKey($0.reference) == key }.map(\.runtimeKind)) + case .containerImages: + runtimes = Set(containers.snapshots.filter { imageUpdateKey($0.image) == key }.map(\.runtimeKind)) + } + let ordered = runtimes.sorted { $0.rawValue < $1.rawValue } + return ordered + } + private func uniqueImageReferences() -> [String] { sortedUnique(images.map(\.reference)) } @@ -221,30 +240,16 @@ extension AppModel { // MARK: Persistence static func loadImageUpdates(defaults: UserDefaults = .standard) -> [String: Core.Image.UpdateStatus] { - guard let data = defaults.data(forKey: imageUpdatesKey), - let decoded = try? JSONDecoder().decode([String: Core.Image.UpdateStatus].self, from: data) else { - return [:] - } - // Never persist a transient "checking" state; restore it as unknown. - return decoded.mapValues { $0.state == .checking ? Core.Image.UpdateStatus() : $0 } + [:] } static func saveImageUpdates(_ updates: [String: Core.Image.UpdateStatus], defaults: UserDefaults = .standard) { - let stable = updates.mapValues { $0.state == .checking ? Core.Image.UpdateStatus() : $0 } - if let data = try? JSONEncoder().encode(stable) { - defaults.set(data, forKey: imageUpdatesKey) - } } static func loadLastImageUpdateSweep(defaults: UserDefaults = .standard) -> Date? { - defaults.object(forKey: imageUpdateLastSweepKey) as? Date + nil } static func saveLastImageUpdateSweep(_ date: Date?, defaults: UserDefaults = .standard) { - if let date { - defaults.set(date, forKey: imageUpdateLastSweepKey) - } else { - defaults.removeObject(forKey: imageUpdateLastSweepKey) - } } } diff --git a/Sources/ContainedApp/App/AppModel+ResourceStyles.swift b/Sources/ContainedApp/App/AppModel+ResourceStyles.swift index 6a39dfd7..e59a15d3 100644 --- a/Sources/ContainedApp/App/AppModel+ResourceStyles.swift +++ b/Sources/ContainedApp/App/AppModel+ResourceStyles.swift @@ -37,7 +37,7 @@ extension AppModel { func containerStyle(for snapshot: Core.Container.Snapshot) -> Personalization { let groupID = imageGroupID(containing: snapshot.image) - return personalization.resolved(id: snapshot.id, + return personalization.resolved(id: snapshot.scopedID, image: snapshot.image, groupID: groupID, fallback: defaultImageStyle) @@ -53,7 +53,7 @@ extension AppModel { /// Current block read/write rate for a volume, summed across every container mounting it. func volumeIORate(for name: String, metric: Core.Metrics.GraphMetric) -> Double { containersMounting(volume: name).reduce(0) { total, snapshot in - total + (containers.metricsState(for: snapshot.id).stats.map { + total + (containers.metricsState(for: snapshot.scopedID).stats.map { metric.value(from: $0, snapshot: snapshot, normalization: statsNormalizationContext) } ?? 0) } @@ -62,7 +62,7 @@ extension AppModel { /// Read/write sparkline series for a volume. Series are right-aligned so recent samples line up. func volumeIOHistory(for name: String, metric: Core.Metrics.GraphMetric) -> [Double] { let series = containersMounting(volume: name).compactMap { snapshot in - containers.metricsState(for: snapshot.id).historyByMetric[metric]?.values + containers.metricsState(for: snapshot.scopedID).historyByMetric[metric]?.values } return Self.sumRightAligned(series) } diff --git a/Sources/ContainedApp/App/AppModel.swift b/Sources/ContainedApp/App/AppModel.swift index a7fb2825..abcee8e9 100644 --- a/Sources/ContainedApp/App/AppModel.swift +++ b/Sources/ContainedApp/App/AppModel.swift @@ -15,15 +15,16 @@ final class AppModel { case ready } + let database: AppDatabase let settings: SettingsStore let containers = ContainersStore() - let personalization = PersonalizationStore() + let personalization: PersonalizationStore let coordinator = RefreshCoordinator() let watchdog = RestartWatchdog() let notifier = Notifier() - let healthChecks = HealthCheckStore() + let healthChecks: HealthCheckStore let health = HealthMonitor() - let historyStore = HistoryStore() + let historyStore: HistoryStore let updater = UpdaterController() let migrator = StateMigrator() let logger: AppLogger @@ -32,16 +33,16 @@ final class AppModel { private(set) var bootstrap: Bootstrap = .checking private(set) var client: Core.Orchestrator? - /// Resolved path to the `container` binary — needed to spawn the terminal's `exec` process. - private(set) var cliURL: URL? private(set) var systemStatus: Core.System.Status? private(set) var diskUsage: Core.System.DiskUsage? - private(set) var cliVersion: String? + private(set) var runtimeReadiness: [Core.Runtime.Kind: Core.RuntimeReadiness] = [:] @ObservationIgnored private var containerStatsVisible = true @ObservationIgnored private var containerStatsStreamTask: Task? @ObservationIgnored private var containerStatsStreamIDs: [String] = [] @ObservationIgnored private var containerStatsStreamGeneration = 0 @ObservationIgnored private var lastRecordedStatsRevision = 0 + @ObservationIgnored var migrationStabilizationTimeout: TimeInterval = 120 + @ObservationIgnored var migrationPollInterval: TimeInterval = 2 @ObservationIgnored let diagnosticLogger = Logger(subsystem: "app.contained.Contained", category: "diagnostic") // Resource caches shared by toolbar panels, creation pages, and the container grid. @@ -55,13 +56,14 @@ final class AppModel { didSet { imageGroupsCache = nil imageGroupIDByReferenceCache.removeAll(keepingCapacity: true) + database.upsertImages(images) } } @ObservationIgnored var imageGroupsCache: [Core.Image.LocalTagGroup]? @ObservationIgnored var imageGroupIDByReferenceCache: [String: String] = [:] var imagesError: String? var imageUpdates: [String: Core.Image.UpdateStatus] = [:] { - didSet { Self.saveImageUpdates(imageUpdates) } + didSet { database.updateImageStatuses(imageUpdates) } } /// Transient watchdog/crash banner text (auto-cleared). var banner: String? @@ -74,7 +76,13 @@ final class AppModel { var createError: String? // The image-update sweep state below is driven from `AppModel+ImageUpdates.swift`. var lastImageUpdateSweep: Date? { - didSet { Self.saveLastImageUpdateSweep(lastImageUpdateSweep) } + didSet { + if let lastImageUpdateSweep { + database.setSetting(lastImageUpdateSweep, for: Self.imageUpdateLastSweepKey) + } else { + database.deleteSetting(Self.imageUpdateLastSweepKey) + } + } } static let imageUpdatesKey = "imageUpdateStatuses" static let imageUpdateLastSweepKey = "imageUpdateLastSweep" @@ -95,17 +103,31 @@ final class AppModel { ) } var availableRuntimeDescriptors: [Core.Runtime.Descriptor] { - client?.availableRuntimeDescriptors ?? [.appleContainer] + client?.availableRuntimeDescriptors ?? [] + } + var supportedRuntimeDescriptors: [Core.Runtime.Descriptor] { + [.appleContainer, .docker] } - var runtimeCoreSelectorIsEnabled: Bool { + var runtimePickerIsEnabled: Bool { availableRuntimeDescriptors.count > 1 } - var runtimeCoreSelectorDisabledReason: String { + var runtimePickerDisabledReason: String { AppText.string( - "runtime.coreSelector.disabledReason", - defaultValue: "Apple container is the only available core in this build. Additional cores will enable this selector." + "runtime.selector.disabledReason", + defaultValue: "Only one container runtime is reachable. Additional runtimes will appear here automatically." ) } + var appleRuntimeAvailable: Bool { + client?.supportsRuntime(.appleContainer) == true + } + + func runtimeCLIURL(for kind: Core.Runtime.Kind) -> URL? { + runtimeReadiness[kind]?.cliURL ?? client?.cliURL(for: kind) + } + + func runtimeVersion(for kind: Core.Runtime.Kind) -> String? { + runtimeReadiness[kind]?.version + } /// One in-flight operation shown in the bottom progress bar. struct ActivityState: Equatable { @@ -114,18 +136,26 @@ final class AppModel { var fraction: Double? = nil // nil → indeterminate } - init(settings: SettingsStore = SettingsStore()) { - self.settings = settings + init(database: AppDatabase = AppDatabase()) { + self.database = database + self.settings = SettingsStore(database: database) + self.personalization = PersonalizationStore(database: database) + self.healthChecks = HealthCheckStore(database: database) + self.historyStore = HistoryStore(database: database) self.logger = AppLogger(settings: settings, history: historyStore) self.containers.logger = logger - imageUpdates = Self.loadImageUpdates() - lastImageUpdateSweep = Self.loadLastImageUpdateSweep() + self.containers.database = database + imageUpdates = database.imageStatusesSnapshot() + lastImageUpdateSweep = database.setting(Self.imageUpdateLastSweepKey, fallback: Optional.none) historyStore.retentionDays = settings.historyRetentionDays updater.channel = settings.updateChannel updater.automaticallyChecks = settings.appUpdateChecksEnabled applyStatsNormalizationContext() - if case .newerOnDisk(let version) = migrator.reconcile() { + let storedSchemaVersion: Int? = database.setting(StateMigrator.schemaVersionSettingKey, fallback: Optional.none) + if case .newerOnDisk(let version) = migrator.reconcile(storedVersion: storedSchemaVersion) { downgradeSchemaVersion = version + } else { + database.setSetting(StateMigrator.currentSchemaVersion, for: StateMigrator.schemaVersionSettingKey) } watchdog.onRestart = { [weak self] snapshot, attempt in guard let self else { return } @@ -135,7 +165,7 @@ final class AppModel { self.logger.record("Restarted \(name) (attempt \(attempt))", category: .health, severity: .warning, - containerID: snapshot.id) + containerID: snapshot.scopedID) self.notifier.containerRestarted(name: name, attempt: attempt, enabled: settings.notifyOnCrash) } watchdog.onUnexpectedExit = { [weak self] snapshot in @@ -145,7 +175,7 @@ final class AppModel { self.logger.record("\(name) exited unexpectedly", category: .health, severity: .warning, - containerID: snapshot.id) + containerID: snapshot.scopedID) self.notifier.containerExited(name: name, enabled: settings.notifyOnCrash) } health.onUnhealthy = { [weak self] snapshot in @@ -156,17 +186,18 @@ final class AppModel { self.logger.record("\(name) failed its healthcheck", category: .health, severity: .warning, - containerID: snapshot.id) + containerID: snapshot.scopedID) self.notifier.containerUnhealthy(name: name, enabled: settings.notifyOnCrash) // Hand off to the restart policy (once per unhealthy transition, so it can't spin). let policy = Core.Container.RestartPolicy(label: snapshot.configuration.labels["contained.restart"]) - if policy != .no { Task { await self.containers.restart(snapshot.id) } } + if policy != .no { Task { await self.containers.restart(snapshot.scopedID) } } } } func bootstrapIfNeeded() async { logger.record("Checking container CLI", category: .system, severity: .debug) - let configuration = Core.Configuration(appleContainer: .init(cliPathOverride: settings.cliPathOverride)) + let configuration = Core.Configuration(appleContainer: .init(cliPathOverride: settings.cliPathOverride), + docker: .init(cliPathOverride: settings.dockerCLIPathOverride)) let result = await Core.Orchestrator.bootstrap(configuration: configuration) if case .cliMissing = result { bootstrap = .cliMissing @@ -179,19 +210,17 @@ final class AppModel { bootstrap = .cliMissing logger.record("Container CLI missing", category: .system, severity: .error) return - case .unsupported(let orchestrator, let url, let version): - client = orchestrator - cliURL = url - cliVersion = version - containers.client = orchestrator - bootstrap = .unsupported(version: version) - logger.record("Unsupported container CLI version \(version)", category: .system, severity: .error) - return - case .ready(let orchestrator, let url, let version): + case .ready(let orchestrator, let readiness): client = orchestrator - cliURL = url - cliVersion = version + runtimeReadiness = Dictionary(uniqueKeysWithValues: readiness.map { ($0.kind, $0) }) containers.client = orchestrator + database.upsertRuntimeReadiness(readiness, + descriptors: orchestrator.availableRuntimeDescriptors) + if let apple = runtimeReadiness[.appleContainer], apple.state == .unsupported { + bootstrap = .unsupported(version: apple.version ?? "") + logger.record("Unsupported container CLI version \(apple.version ?? "unknown")", category: .system, severity: .error) + return + } } await refreshSystem() @@ -233,7 +262,9 @@ final class AppModel { } func runtimeDescriptor(for kind: Core.Runtime.Kind) -> Core.Runtime.Descriptor { - availableRuntimeDescriptors.first { $0.kind == kind } ?? .appleContainer + client?.descriptor(for: kind) + ?? availableRuntimeDescriptors.first { $0.kind == kind } + ?? (kind == .docker ? .docker : .appleContainer) } func core(for kind: Core.Runtime.Kind) -> Core.Orchestrator? { @@ -241,6 +272,19 @@ final class AppModel { return client } + func installRuntimeClientForTesting(_ orchestrator: Core.Orchestrator, + bootstrap: Bootstrap = .ready) { + client = orchestrator + containers.client = orchestrator + runtimeReadiness = Dictionary(uniqueKeysWithValues: orchestrator.availableRuntimeDescriptors.compactMap { descriptor in + guard let cliURL = orchestrator.cliURL(for: descriptor.kind) else { return nil } + return (descriptor.kind, Core.RuntimeReadiness(kind: descriptor.kind, cliURL: cliURL)) + }) + database.upsertRuntimeReadiness(Array(runtimeReadiness.values), + descriptors: orchestrator.availableRuntimeDescriptors) + self.bootstrap = bootstrap + } + func applyStatsNormalizationContext() { containers.configureStatsNormalization(statsNormalizationContext) } @@ -248,30 +292,41 @@ final class AppModel { func refreshSystem() async { guard let client else { return } let started = Date() - do { - let status = try await client.systemStatus() - systemStatus = status - if status.isRunning { - bootstrap = .ready - logger.record("Container service is running", category: .system, severity: .debug) - if settings.statsNormalizationMode == .machine { await loadPropertiesIfNeeded() } - applyStatsNormalizationContext() - await refreshDiskUsage() // throttled; the System panel can force a fresh read - await containers.refresh() - updateContainerStatsStream() - } else { - bootstrap = .serviceStopped - stopContainerStatsStream() - logger.record("Container service is stopped", category: .system, severity: .warning) + var anyRuntimeRunning = false + var firstStatus: Core.System.Status? + for descriptor in availableRuntimeDescriptors where descriptor.supports(.systemStatus) { + do { + let status = try await client.systemStatus(runtimeKind: descriptor.kind) + firstStatus = firstStatus ?? status + if descriptor.kind == .appleContainer { systemStatus = status } + if status.isRunning { + anyRuntimeRunning = true + logger.record("\(descriptor.displayName) is running", category: .system, severity: .debug) + markRuntimeReadiness(descriptor.kind, state: .ready) + } else { + logger.record("\(descriptor.displayName) is stopped", category: .system, severity: .warning) + markRuntimeReadiness(descriptor.kind, state: .endpointUnavailable) + } + } catch { + logger.recordFailure("Couldn't read \(descriptor.displayName) status", + error: error, + category: .system, + severity: .error) + markRuntimeReadiness(descriptor.kind, state: .endpointUnavailable, message: error.appDisplayMessage) } - } catch { - // `system status` exits non-zero when the service isn't running/registered. - bootstrap = .serviceStopped + } + if systemStatus == nil { systemStatus = firstStatus } + bootstrap = anyRuntimeRunning ? .ready : .serviceStopped + if anyRuntimeRunning { + if settings.statsNormalizationMode == .machine { await loadPropertiesIfNeeded() } + applyStatsNormalizationContext() + await refreshDiskUsage() + } + await containers.refresh() + if anyRuntimeRunning || !containers.running.isEmpty { + updateContainerStatsStream() + } else { stopContainerStatsStream() - logger.recordFailure("Couldn't read container service status", - error: error, - category: .system, - severity: .error) } let elapsed = Date().timeIntervalSince(started) if elapsed >= 0.75 { @@ -280,6 +335,17 @@ final class AppModel { } } + private func markRuntimeReadiness(_ kind: Core.Runtime.Kind, + state: Core.RuntimeReadiness.State, + message: String? = nil) { + guard var readiness = runtimeReadiness[kind] else { return } + readiness.state = state + readiness.message = message + runtimeReadiness[kind] = readiness + database.upsertRuntimeReadiness(Array(runtimeReadiness.values), + descriptors: availableRuntimeDescriptors) + } + /// `system df` is throttled during background refresh; the System panel can force a fresh read. private static let diskUsageThrottle: TimeInterval = 8 private static let bannerDuration: TimeInterval = 4 @@ -290,7 +356,10 @@ final class AppModel { private func refreshDiskUsage(force: Bool = false) async { guard let client else { return } if !force, let last = lastDiskUsageDate, Date().timeIntervalSince(last) < Self.diskUsageThrottle { return } - if let usage = try? await client.diskUsage() { diskUsage = usage; lastDiskUsageDate = Date() } + if appleRuntimeAvailable, let usage = try? await client.diskUsage(runtimeKind: .appleContainer) { + diskUsage = usage + lastDiskUsageDate = Date() + } } /// `images list` backs the toolbar Images panel, creation local-image choices, and update badges. @@ -298,10 +367,10 @@ final class AppModel { private static let imagesThrottle: TimeInterval = 15 private var lastImagesDate: Date? func refreshImagesIfNeeded(force: Bool = false) async { - guard let client, bootstrap == .ready else { return } + guard let client else { return } if !force, let last = lastImagesDate, Date().timeIntervalSince(last) < Self.imagesThrottle { return } let started = Date() - imagesError = await captured { self.images = try await client.images() } + imagesError = await captured { self.images = try await client.runtimeImages() } lastImagesDate = Date() let elapsed = Date().timeIntervalSince(started) if elapsed >= 0.75 || force { @@ -324,7 +393,8 @@ final class AppModel { func imageDefaults(for spec: ContainerFormState) -> Core.Container.ImageDefaults? { guard let client = core(for: spec.effectiveRuntimeKind) else { return nil } - return try? client.imageDefaults(for: spec.document, in: images) + return try? client.imageDefaults(for: spec.document, + in: images.filter { $0.runtimeKind == spec.effectiveRuntimeKind }) } /// Run a throwing action while preserving the original error for Activity/package metadata. @@ -342,7 +412,7 @@ final class AppModel { func tick() async { let started = Date() await refreshSystem() - guard bootstrap == .ready, let client else { return } + guard let client else { return } if settings.autoRestartEnabled { await watchdog.evaluate(snapshots: containers.snapshots, store: containers, client: client) } @@ -361,33 +431,42 @@ final class AppModel { } private func updateContainerStatsStream() { - guard bootstrap == .ready, let client else { + guard let client else { stopContainerStatsStream() return } - let ids = containers.running.map(\.id).sorted() - guard !ids.isEmpty else { + let scopedIDs = containers.running.map(\.scopedID).sorted() + guard !scopedIDs.isEmpty else { stopContainerStatsStream() return } - guard containerStatsStreamTask == nil || ids != containerStatsStreamIDs else { return } + guard containerStatsStreamTask == nil || scopedIDs != containerStatsStreamIDs else { return } stopContainerStatsStream() containerStatsStreamGeneration &+= 1 let generation = containerStatsStreamGeneration - containerStatsStreamIDs = ids - diagnosticLogger.info("Stats stream starting for \(ids.count, privacy: .public) container(s)") - containerStatsStreamTask = Task(priority: .utility) { [weak self, client, ids, generation] in + containerStatsStreamIDs = scopedIDs + let groups = Dictionary(grouping: containers.running, by: \.runtimeKind) + .mapValues { snapshots in snapshots.map(\.id).sorted() } + diagnosticLogger.info("Stats stream starting for \(scopedIDs.count, privacy: .public) container(s)") + containerStatsStreamTask = Task(priority: .utility) { [weak self, client, groups, generation] in do { - for try await samples in client.streamStats(ids: ids) { - guard !Task.isCancelled else { return } - guard !samples.isEmpty else { continue } - await MainActor.run { - guard let self, - self.containerStatsStreamGeneration == generation else { return } - self.containers.applyStreamedStats(samples) - self.recordFreshMetricsIfNeeded() + try await withThrowingTaskGroup(of: Void.self) { group in + for (runtimeKind, ids) in groups { + group.addTask { + for try await samples in client.streamStats(ids: ids, runtimeKind: runtimeKind) { + guard !Task.isCancelled else { return } + guard !samples.isEmpty else { continue } + await MainActor.run { + guard let self, + self.containerStatsStreamGeneration == generation else { return } + self.containers.applyStreamedStats(samples) + self.recordFreshMetricsIfNeeded() + } + } + } } + try await group.waitForAll() } } catch { await MainActor.run { @@ -431,7 +510,12 @@ final class AppModel { /// Refresh the registry-login list for Settings. func refreshRegistries() async { guard let client, bootstrap == .ready else { return } - if let r = try? await client.registries() { registries = r } + let runtimeKinds = availableRuntimeDescriptors.filter { $0.supports(.registries) }.map(\.kind) + var next: [Core.Registry.Login] = [] + for kind in runtimeKinds { + next += (try? await client.registries(runtimeKind: kind)) ?? [] + } + registries = next } /// Load the daemon's system properties once (the read-only Defaults shown in Settings). Cheap and @@ -444,7 +528,8 @@ final class AppModel { /// Force-reload the daemon's system properties (e.g. after a kernel change). func reloadProperties() async { guard let client, bootstrap == .ready else { return } - if let p = try? await client.systemProperties() { + guard appleRuntimeAvailable else { return } + if let p = try? await client.systemProperties(runtimeKind: .appleContainer) { properties = p applyStatsNormalizationContext() } @@ -454,13 +539,19 @@ final class AppModel { /// and called when that panel opens. func refreshVolumes() async { guard let client, bootstrap == .ready else { return } - if let v = try? await client.volumes() { volumes = v } + if let v = try? await client.runtimeVolumes() { + volumes = v + database.upsertVolumes(v) + } } /// Refresh the cached network list. Networks back the collapsible groups on the Containers page. func refreshNetworks() async { guard let client, bootstrap == .ready else { return } - if let n = try? await client.networks() { networks = n } + if let n = try? await client.runtimeNetworks() { + networks = n + database.upsertNetworks(n) + } } // MARK: Create (pull-aware) @@ -480,8 +571,8 @@ final class AppModel { return nil } createError = nil - if !(await imageIsLocal(spec.image)) { - guard await pullImage(spec.image) else { + if !(await imageIsLocal(spec.image, runtimeKind: spec.effectiveRuntimeKind)) { + guard await pullImage(spec.image, runtimeKind: spec.effectiveRuntimeKind) else { // pullImage already flashed; mirror it inline so the form can show it without dismissing. createError = banner ?? "Couldn't pull \(Format.shortImage(spec.image))." return nil @@ -490,9 +581,11 @@ final class AppModel { let newID = await containers.run(spec) if newID == nil { createError = containers.errorMessage ?? "Couldn't create the container." } if let newID { - if !spec.personalization.isDefault { personalization.setOverride(spec.personalization, for: newID) } - healthChecks.setCheck(spec.healthCheck, for: newID) - logger.record("Created \(newID)", category: .lifecycle, containerID: newID) + let scopedID = containers.snapshots.first { $0.id == newID && $0.runtimeKind == spec.effectiveRuntimeKind }?.scopedID + ?? spec.effectiveRuntimeKind.scopedID(for: newID) + if !spec.personalization.isDefault { personalization.setOverride(spec.personalization, for: scopedID) } + healthChecks.setCheck(spec.healthCheck, for: scopedID) + logger.record("Created \(newID)", category: .lifecycle, containerID: scopedID) flash(AppText.createdContainer(newID)) } return newID @@ -502,6 +595,9 @@ final class AppModel { /// deleting the current container so an unavailable image does not strand the edit flow. @discardableResult func recreateContainer(originalID: String, spec: ContainerFormState) async -> String? { + let originalSnapshot = containers.snapshots.first { $0.scopedID == originalID || $0.id == originalID } + let originalRuntimeID = originalSnapshot?.id ?? originalID + let originalScopedID = originalSnapshot?.scopedID ?? originalID guard core(for: spec.effectiveRuntimeKind) != nil else { let error = Core.Runtime.UnsupportedCapability(kind: spec.effectiveRuntimeKind, capability: .containers) flash(error.appDisplayMessage) @@ -509,52 +605,142 @@ final class AppModel { error: error, category: .lifecycle, severity: .warning, - containerID: originalID) + containerID: originalScopedID) return nil } - if !(await imageIsLocal(spec.image)) { - guard await pullImage(spec.image) else { return nil } + if !(await imageIsLocal(spec.image, runtimeKind: spec.effectiveRuntimeKind)) { + guard await pullImage(spec.image, runtimeKind: spec.effectiveRuntimeKind) else { return nil } } guard await containers.recreate(originalID: originalID, spec: spec) else { return nil } - let newID = spec.name.isEmpty ? originalID : spec.name - if newID != originalID { - personalization.clearOverride(id: originalID) - healthChecks.clear(id: originalID) + let newID = spec.name.isEmpty ? originalRuntimeID : spec.name + let newScopedID = containers.snapshots.first { $0.id == newID && $0.runtimeKind == spec.effectiveRuntimeKind }?.scopedID + ?? spec.effectiveRuntimeKind.scopedID(for: newID) + if newID != originalRuntimeID { + personalization.clearOverride(id: originalScopedID) + healthChecks.clear(id: originalScopedID) } if spec.personalization.isDefault { - personalization.clearOverride(id: newID) + personalization.clearOverride(id: newScopedID) } else { - personalization.setOverride(spec.personalization, for: newID) + personalization.setOverride(spec.personalization, for: newScopedID) } - healthChecks.setCheck(spec.healthCheck, for: newID) - logger.record("Recreated \(newID)", category: .lifecycle, containerID: newID) + healthChecks.setCheck(spec.healthCheck, for: newScopedID) + logger.record("Recreated \(newID)", category: .lifecycle, containerID: newScopedID) return newID } + /// Move a logical container record to another runtime. The source runtime instance is hidden and + /// stopped first, then deleted only after the target is created and stabilizes. + @discardableResult + func migrateContainer(scopedID: String, to targetRuntimeKind: Core.Runtime.Kind) async -> Bool { + guard let client else { return false } + guard let source = containers.snapshots.first(where: { $0.scopedID == scopedID || $0.id == scopedID }) else { + flash(ContainerRuntimeMigrationError.sourceNotFound.appDisplayMessage) + return false + } + guard source.runtimeKind != targetRuntimeKind else { return true } + guard client.supportsRuntime(targetRuntimeKind, capability: .containers) else { + let error = Core.Runtime.UnsupportedCapability(kind: targetRuntimeKind, capability: .containers) + flash(error.appDisplayMessage) + return false + } + + let sourceScopedID = source.scopedID + let sourceRuntimeKind = source.runtimeKind + let sourceDocument = Core.Schema.Document.containerEdit(from: source.configuration) + let sourceStyle = containerStyle(for: source) + let sourceHealthCheck = healthChecks.check(for: sourceScopedID) + database.markContainerMigrationStarted(source: source, + targetRuntimeKind: targetRuntimeKind, + sourceDocument: sourceDocument) + await containers.refresh() + logger.record("Migrating \(source.displayName) from \(sourceRuntimeKind.rawValue) to \(targetRuntimeKind.rawValue)", + category: .lifecycle, + containerID: sourceScopedID) + + do { + activity = ActivityState(title: AppText.string("container.migration.activity", + defaultValue: "Moving \(source.displayName)")) + defer { activity = nil } + let result = try await client.migrateContainer(source, + sourceDocument: sourceDocument, + targetRuntimeKind: targetRuntimeKind, + healthCheck: sourceHealthCheck, + stabilizationTimeout: migrationStabilizationTimeout, + pollInterval: migrationPollInterval, + onPullProgress: { line in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + await MainActor.run { self.activity?.detail = trimmed } + }) + let target = result.target + + database.completeContainerMigration(sourceScopedID: sourceScopedID, + target: target, + targetDocument: result.targetDocument, + sourceRuntimeKind: sourceRuntimeKind, + sourceDocument: sourceDocument) + if sourceStyle.isDefault { + personalization.clearOverride(id: target.scopedID) + } else { + personalization.setOverride(sourceStyle, for: target.scopedID) + } + personalization.clearOverride(id: sourceScopedID) + if let sourceHealthCheck { + healthChecks.setCheck(sourceHealthCheck, for: target.scopedID) + healthChecks.clear(id: sourceScopedID) + } + + await containers.refresh() + flash(AppText.string("container.migration.complete", defaultValue: "Container moved to \(runtimeDescriptor(for: targetRuntimeKind).displayName).")) + logger.record("Migrated \(source.displayName) to \(targetRuntimeKind.rawValue)", + category: .lifecycle, + containerID: target.scopedID) + return true + } catch { + database.markContainerMigrationFailed(scopedID: sourceScopedID, message: error.appDisplayMessage) + await containers.refresh() + flash(error.appDisplayMessage) + logger.recordFailure("Container migration failed", + error: error, + category: .lifecycle, + severity: .error, + containerID: sourceScopedID) + return false + } + } + /// Load images from an OCI `.tar` archive into the local store. Shared by app-wide drop, menu /// commands, and the add panel's image-archive path. - func loadImageTar(at url: URL) { + func loadImageTar(at url: URL, runtimeKind: Core.Runtime.Kind) { guard let client else { return } Task { - if let error = await capturedError({ _ = try await client.loadImages(from: url.path) }) { + if let error = await capturedError({ _ = try await client.loadImages(from: url.path, runtimeKind: runtimeKind) }) { flash(error.appDisplayMessage) logger.recordFailure("Failed loading image archive \(url.lastPathComponent)", error: error, category: .image, - severity: .error) + severity: .error, + containerID: runtimeKind.scopedID(for: url.lastPathComponent)) } else { await refreshImagesIfNeeded(force: true) flash(AppText.loadedFile(url.lastPathComponent)) - logger.record("Loaded image archive \(url.lastPathComponent)", category: .image) + logger.record("Loaded image archive \(url.lastPathComponent)", + category: .image, + containerID: runtimeKind.scopedID(for: url.lastPathComponent)) } } } @discardableResult - func createVolume(name: String, size: String?) async -> Bool { + func createVolume(name: String, + size: String?, + runtimeKind: Core.Runtime.Kind) async -> Bool { guard let client else { return false } let error = await capturedError { - _ = try await client.createVolume(name: name, size: size) + _ = try await client.createVolume(name: name, + size: size, + runtimeKind: runtimeKind) await refreshVolumes() } if let error { @@ -566,15 +752,21 @@ final class AppModel { return false } flash(AppText.createdVolume(name)) - logger.record("Created volume \(name)", category: .system) + logger.record("Created volume \(name)", category: .system, containerID: runtimeKind.scopedID(for: name)) return true } @discardableResult - func createNetwork(name: String, subnet: String?, internalOnly: Bool) async -> Bool { + func createNetwork(name: String, + subnet: String?, + internalOnly: Bool, + runtimeKind: Core.Runtime.Kind) async -> Bool { guard let client else { return false } let error = await capturedError { - _ = try await client.createNetwork(name: name, subnet: subnet, internalOnly: internalOnly) + _ = try await client.createNetwork(name: name, + subnet: subnet, + internalOnly: internalOnly, + runtimeKind: runtimeKind) await refreshNetworks() } if let error { @@ -586,28 +778,29 @@ final class AppModel { return false } flash(AppText.createdNetwork(name)) - logger.record("Created network \(name)", category: .system) + logger.record("Created network \(name)", category: .system, containerID: runtimeKind.scopedID(for: name)) return true } /// Ensure an image is present locally, pulling it (with the progress bar) only if missing. /// Returns true when the image is available. Used by compose import before prefilling a form. @discardableResult - func ensureImage(_ reference: String) async -> Bool { + func ensureImage(_ reference: String, + runtimeKind: Core.Runtime.Kind) async -> Bool { guard client != nil else { return false } - if await imageIsLocal(reference) { return true } - return await pullImage(reference) + if await imageIsLocal(reference, runtimeKind: runtimeKind) { return true } + return await pullImage(reference, runtimeKind: runtimeKind) } /// Pull an image, streaming `--progress` lines into the floating activity bar. Returns true on /// success. @discardableResult - func pullImage(_ reference: String) async -> Bool { + func pullImage(_ reference: String, runtimeKind: Core.Runtime.Kind) async -> Bool { guard let client else { return false } activity = ActivityState(title: AppText.activityPullingImage(Format.shortImage(reference))) defer { activity = nil } do { - for try await line in client.streamPull(reference, platform: nil) { + for try await line in client.streamPull(reference, platform: nil, runtimeKind: runtimeKind) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { activity?.detail = trimmed } } @@ -626,11 +819,11 @@ final class AppModel { /// Whether an image reference is already in the local store (tag-normalized compare so /// `nginx` matches `nginx:latest` and `docker.io/library/nginx:latest`). - private func imageIsLocal(_ reference: String) async -> Bool { + private func imageIsLocal(_ reference: String, runtimeKind: Core.Runtime.Kind) async -> Bool { guard let client else { return false } let target = normalizedRef(reference) - let list = (try? await client.images()) ?? images - return list.contains { normalizedRef($0.reference) == target } + let list = (try? await client.runtimeImages()) ?? images + return list.contains { $0.runtimeKind == runtimeKind && normalizedRef($0.reference) == target } } /// Strip the docker.io prefix and append `:latest` when no tag/digest is present. @@ -677,6 +870,11 @@ final class AppModel { /// then re-reads service status. Failures are intentionally ignored because `refreshSystem` /// reports the resulting state regardless. private func runServiceLifecycle(_ actions: [Core.Runtime.SystemAction], resetWatchdog: Bool) async { + guard appleRuntimeAvailable else { + flash(AppText.string("runtime.service.appleOnly", + defaultValue: "Service controls are only available for Apple container. Start Docker externally, then retry.")) + return + } guard let client else { return } bootstrap = .checking if resetWatchdog { watchdog.reset() } @@ -688,7 +886,7 @@ final class AppModel { var serviceLabel: String { switch bootstrap { case .ready: return "Running" - case .serviceStopped: return "Stopped" + case .serviceStopped: return appleRuntimeAvailable ? "Stopped" : "Endpoint unavailable" case .checking: return "Checking…" case .cliMissing: return "No CLI" case .unsupported(let v): return "v\(v)" @@ -697,3 +895,15 @@ final class AppModel { var serviceHealthy: Bool { bootstrap == .ready } } + +private enum ContainerRuntimeMigrationError: LocalizedError { + case sourceNotFound + + var errorDescription: String? { + switch self { + case .sourceNotFound: + return AppText.string("container.migration.error.sourceMissing", + defaultValue: "The source container is no longer available.") + } + } +} diff --git a/Sources/ContainedApp/App/ContainedApp.swift b/Sources/ContainedApp/App/ContainedApp.swift index 79e861b0..1c275d52 100644 --- a/Sources/ContainedApp/App/ContainedApp.swift +++ b/Sources/ContainedApp/App/ContainedApp.swift @@ -69,12 +69,16 @@ public struct ContainedApplication: App { Button(app.serviceLabel) { } .disabled(true) Divider() - if app.serviceHealthy { - Button("Stop Service") { Task { await app.stopService() } } + if app.appleRuntimeAvailable { + if app.serviceHealthy { + Button("Stop Service") { Task { await app.stopService() } } + } else { + Button("Start Service") { Task { await app.startService() } } + } + Button("Restart Service") { Task { await app.restartService() } } } else { - Button("Start Service") { Task { await app.startService() } } + Button("Retry Docker Connection") { Task { await app.retryBootstrap() } } } - Button("Restart Service") { Task { await app.restartService() } } } Divider() Button("Open Contained") { activateMainWindow() } @@ -235,7 +239,7 @@ public struct ContainedApplication: App { /// Reveal the resolved `container` binary in Finder (honoring the CLI-path override). private func revealCLIBinary() { - guard let url = app.client?.cliURL else { return } + guard let url = app.runtimeCLIURL(for: .appleContainer) else { return } NSWorkspace.shared.activateFileViewerSelecting([url]) } diff --git a/Sources/ContainedApp/Features/Activity/ActivityView.swift b/Sources/ContainedApp/Features/Activity/ActivityView.swift index 1a4a90f5..7574907c 100644 --- a/Sources/ContainedApp/Features/Activity/ActivityView.swift +++ b/Sources/ContainedApp/Features/Activity/ActivityView.swift @@ -147,7 +147,7 @@ struct ActivityContent: View { let unread = events.filter { !$0.isRead } guard !unread.isEmpty else { return } for event in unread { event.isRead = true } - try? modelContext.save() + saveActivityChanges() } /// Header action: marks only the currently-shown (filtered) events read. @@ -155,7 +155,7 @@ struct ActivityContent: View { let unread = filtered.filter { !$0.isRead } guard !unread.isEmpty else { return } for event in unread { event.isRead = true } - try? modelContext.save() + saveActivityChanges() } /// Header action: clears only the currently-shown events. With no filter that's everything; with a @@ -164,8 +164,20 @@ struct ActivityContent: View { if let filter = ui.activityFilter { for event in events where event.kind == filter { modelContext.delete(event) } } else { - try? modelContext.delete(model: EventRecord.self) + do { + try modelContext.delete(model: EventRecord.self) + } catch { + fatalError("Failed to delete activity records: \(error)") + } + } + saveActivityChanges() + } + + private func saveActivityChanges() { + do { + try modelContext.save() + } catch { + fatalError("Failed to save activity records: \(error)") } - try? modelContext.save() } } diff --git a/Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift b/Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift index 4417356b..38b64bad 100644 --- a/Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift +++ b/Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift @@ -269,7 +269,13 @@ struct EventRow: View { } } - private func save() { try? modelContext.save() } + private func save() { + do { + try modelContext.save() + } catch { + fatalError("Failed to save activity event: \(error)") + } + } } extension EventKind { diff --git a/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift b/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift index 0bd9f121..9ddafccc 100644 --- a/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift +++ b/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift @@ -197,11 +197,12 @@ struct ContainersGridView: View { let target = cardDetailTarget.rect(origin: .zero, in: viewport.size, safeAreaManager: cardDetailSafeAreaManager) - let source = cardFrames[detail.id].flatMap { $0.isUsableForMorph ? $0 : nil } ?? target - let rect = expanded ? target : source - expandedCard(detail) - .frame(width: rect.width, height: rect.height, alignment: .top) - .position(x: rect.midX, y: rect.midY) + let source = cardFrames[detail.scopedID].flatMap { $0.isUsableForMorph ? $0 : nil } ?? target + UX.Morph.SingleSurface(source: source, + target: target, + progress: expanded ? 1 : 0) { + expandedCard(detail) + } .zIndex(10) } } @@ -218,7 +219,7 @@ struct ContainersGridView: View { isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }) ) { Button("Delete", role: .destructive) { - if let id = deleting?.id { Task { await store.remove(id, force: true) } } + if let id = deleting?.scopedID { Task { await store.remove(id, force: true) } } deleting = nil } Button("Cancel", role: .cancel) { deleting = nil } @@ -235,7 +236,7 @@ struct ContainersGridView: View { // Report the in-page search count so the toolbar can escalate an empty search into the palette. .onAppear { ui.search.pageResultCount = filtered.count } .onChange(of: filtered.count) { _, count in ui.search.pageResultCount = count } - .onChange(of: store.snapshots.map(\.id)) { _, ids in + .onChange(of: store.snapshots.map(\.scopedID)) { _, ids in selectedWidgetIndices = selectedWidgetIndices.filter { ids.contains($0.key) } } } @@ -310,14 +311,17 @@ struct ContainersGridView: View { private func deleteNetwork(_ network: Core.Network.Resource) async { guard let client = app.client else { return } - do { _ = try await client.deleteNetworks([network.name]); await app.refreshNetworks() } + do { + _ = try await client.deleteNetworks([network.name], runtimeKind: network.runtimeKind) + await app.refreshNetworks() + } catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } @ViewBuilder private func gridCard(_ snapshot: Core.Container.Snapshot) -> some View { - let selected = detail?.id == snapshot.id + let selected = detail?.scopedID == snapshot.scopedID compactCard(snapshot) // Stays laid out (so the slot is reserved and its frame keeps publishing) but invisible // while the promoted overlay grows out of it — no second card to see double. @@ -327,10 +331,10 @@ struct ContainersGridView: View { GeometryReader { proxy in Color.clear .onAppear { - updateCardFrame(proxy.frame(in: .named("grid")), for: snapshot.id) + updateCardFrame(proxy.frame(in: .named("grid")), for: snapshot.scopedID) } .onChange(of: proxy.frame(in: .named("grid"))) { _, frame in - updateCardFrame(frame, for: snapshot.id) + updateCardFrame(frame, for: snapshot.scopedID) } } } @@ -344,7 +348,7 @@ struct ContainersGridView: View { private func compactCard(_ snapshot: Core.Container.Snapshot) -> some View { containerCard(snapshot, isExpanded: false) { - selecting ? toggle(snapshot.id) : openDetail(snapshot) + selecting ? toggle(snapshot.scopedID) : openDetail(snapshot) } } @@ -362,34 +366,35 @@ struct ContainersGridView: View { controlsVisible: Bool = true, onTap: @escaping () -> Void) -> some View { let style = app.containerStyle(for: snapshot) - let hasStyleOverride = app.personalization.hasOverride(id: snapshot.id) + let key = snapshot.scopedID + let hasStyleOverride = app.personalization.hasOverride(id: key) return ContainerCardMetricsRenderer( - metrics: store.metricsState(for: snapshot.id), + metrics: store.metricsState(for: key), snapshot: snapshot, style: style, hasStyleOverride: hasStyleOverride, density: app.settings.density, statsNormalization: app.statsNormalizationContext, - selectedWidgetIndex: selectedWidgetBinding(for: snapshot.id), - isBusy: store.busyIDs.contains(snapshot.id), + selectedWidgetIndex: selectedWidgetBinding(for: key), + isBusy: store.busyIDs.contains(key), hasImageUpdate: app.imageUpdateStatus(for: snapshot.image).state == .updateAvailable, isExpanded: isExpanded, cornerRadiusOverride: cornerRadiusOverride, controlsVisible: controlsVisible, onTap: onTap, - onStart: { Task { await store.start(snapshot.id) } }, - onStop: { Task { await store.stop(snapshot.id) } }, - onRestart: { Task { await store.restart(snapshot.id) } }, + onStart: { Task { await store.start(key) } }, + onStop: { Task { await store.stop(key) } }, + onRestart: { Task { await store.restart(key) } }, onEdit: { ui.openCreationPanel(editing: snapshot) }, onUpdate: { updateContainer(snapshot) }, onDelete: { deleting = snapshot }, onClose: closeDetail, - onSelectMultiple: { beginSelecting(snapshot.id) }, - onToggleSelected: { toggle(snapshot.id) }, + onSelectMultiple: { beginSelecting(key) }, + onToggleSelected: { toggle(key) }, onEndSelecting: { endSelecting() }, - health: app.health.status(for: snapshot.id), + health: app.health.status(for: key), selecting: selecting, - isSelected: selection.contains(snapshot.id) + isSelected: selection.contains(key) ) } @@ -488,7 +493,7 @@ struct ContainersGridView: View { private func updateContainer(_ snapshot: Core.Container.Snapshot) { Task { - if await app.pullImageUpdate(snapshot.image) { + if await app.pullImageUpdate(snapshot.image, runtimeKind: snapshot.runtimeKind) { ui.openCreationPanel(editing: snapshot) } } @@ -587,3 +592,102 @@ private extension CGRect { abs(height - other.height) <= tolerance } } + +#Preview("Containers Grid Fake Dataset") { + ContainersGridFakeDatasetPreview() +} + +@MainActor +private struct ContainersGridFakeDatasetPreview: View { + @State private var app: AppModel + @State private var ui: UIState + + init() { + let preview = ContainersGridPreviewDataset.make() + _app = State(initialValue: preview.app) + _ui = State(initialValue: preview.ui) + } + + var body: some View { + ContainersGridView() + .environment(app) + .environment(ui) + .environment(\.morphSafeAreaManager, + UX.SafeArea.Manager(topToolbarHeight: UI.Toolbar.Size.band, + bottomToolbarHeight: UI.Toolbar.Size.band)) + .frame(width: 900, height: 640) + } +} + +@MainActor +private enum ContainersGridPreviewDataset { + static func make() -> (app: AppModel, ui: UIState) { + let database = AppDatabase(isStoredInMemoryOnly: true) + let app = AppModel(database: database) + let ui = UIState() + ui.grouping = .flat + ui.sort = .name + ui.toolbarUIEnabled = true + + let appleWeb = Core.Container.Snapshot.placeholder( + id: "preview-web", + image: "docker.io/library/nginx:latest", + state: .running + ) + let dockerWeb = Core.Container.Snapshot.placeholder( + id: "preview-web", + image: "docker.io/library/nginx:latest", + state: .running + ) + .scoped(to: .docker) + let worker = Core.Container.Snapshot.placeholder( + id: "preview-worker", + image: "ghcr.io/example/worker:nightly", + state: .stopped + ) + .scoped(to: .docker) + let db = Core.Container.Snapshot.placeholder( + id: "preview-db", + image: "postgres:16", + state: .running + ) + + let snapshots = [appleWeb, dockerWeb, worker, db] + app.containers.snapshots = snapshots + for snapshot in snapshots { + app.containers.statsByID[snapshot.scopedID] = Core.Metrics.StatsDelta.sample(id: snapshot.scopedID) + app.containers.historyByID[snapshot.scopedID] = [ + .cpu: buffer([0.14, 0.22, 0.18, 0.42, 0.36, 0.62]), + .memory: buffer([0.28, 0.30, 0.34, 0.38, 0.42, 0.40]), + .netRx: buffer([0.12, 0.28, 0.20, 0.50, 0.44, 0.58]), + .netTx: buffer([0.08, 0.10, 0.16, 0.22, 0.18, 0.26]), + ] + } + + setStyle(tint: .azure, icon: "globe", nickname: "Apple web", for: appleWeb, app: app) + setStyle(tint: .teal, icon: "shippingbox.fill", nickname: "Docker web", for: dockerWeb, app: app) + setStyle(tint: .indigo, icon: "gearshape.2.fill", nickname: "Worker", for: worker, app: app) + setStyle(tint: .green, icon: "cylinder.split.1x2.fill", nickname: "Database", for: db, app: app) + + return (app, ui) + } + + private static func buffer(_ values: [Double]) -> UI.Chart.SampleBuffer { + var buffer = UI.Chart.SampleBuffer(capacity: 24) + values.forEach { buffer.append($0) } + return buffer + } + + private static func setStyle(tint: UI.Theme.Tint, + icon: String, + nickname: String, + for snapshot: Core.Container.Snapshot, + app: AppModel) { + var style = Personalization() + style.tint = tint + style.icon = icon + style.nickname = nickname + style.backgroundOpacity = 0.18 + app.personalization.setOverride(style, for: snapshot.scopedID) + } +} diff --git a/Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift b/Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift index 644185f0..c8ce551c 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift @@ -19,12 +19,18 @@ enum ComposeImport { } /// Parse a compose file and feed its services into the prefill queue (also used by drag-and-drop). - static func importFile(at url: URL, app: AppModel, ui: UIState) { + static func importFile(at url: URL, + runtimeKind: Core.Runtime.Kind? = nil, + app: AppModel, + ui: UIState) { do { let text = try String(contentsOf: url, encoding: .utf8) let projectName = url.deletingLastPathComponent().lastPathComponent importText(text, projectName: projectName.isEmpty ? "stack" : projectName, - baseDirectory: url.deletingLastPathComponent(), app: app, ui: ui) + baseDirectory: url.deletingLastPathComponent(), + runtimeKind: runtimeKind, + app: app, + ui: ui) } catch { app.flash(error.appDisplayMessage) } @@ -32,7 +38,10 @@ enum ComposeImport { /// Parse pasted compose text and feed its services into the prefill queue. static func importText(_ text: String, projectName: String = "pasted", - baseDirectory: URL? = nil, app: AppModel, ui: UIState) { + baseDirectory: URL? = nil, + runtimeKind: Core.Runtime.Kind? = nil, + app: AppModel, + ui: UIState) { do { let parsed = try Core.Compose.parse(text, projectName: projectName) guard let client = app.client else { @@ -42,7 +51,31 @@ enum ComposeImport { severity: .warning) return } - let plan = try client.translateCompose(parsed, baseDirectory: baseDirectory) + let selectedRuntime: Core.Runtime.Kind + if let runtimeKind { + selectedRuntime = runtimeKind + } else { + let runtimes = app.availableRuntimeDescriptors.filter { $0.supports(.composeImport) } + guard let first = runtimes.first else { + app.flash(AppText.containerRuntimeNotReady) + return + } + guard runtimes.count > 1 else { + return importText(text, + projectName: projectName, + baseDirectory: baseDirectory, + runtimeKind: first.kind, + app: app, + ui: ui) + } + ui.runtimeSelectionRequest = .composeText(text: text, + projectName: projectName, + baseDirectory: baseDirectory) + return + } + let plan = try client.translateCompose(parsed, + baseDirectory: baseDirectory, + runtimeKind: selectedRuntime) let specs = plan.items.map { ContainerFormState(document: $0.document, healthCheck: $0.healthCheck) } guard !specs.isEmpty else { app.flash(AppText.composeNoServicesWithImages) @@ -68,4 +101,5 @@ enum ComposeImport { severity: .error) } } + } diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift index 7272d1ee..c5cbe1f8 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift @@ -33,7 +33,7 @@ struct CreationFlow: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - @Query(sort: \Template.createdAt, order: .reverse) private var saved: [Template] + @Query(sort: \RecipeRecord.createdAt, order: .reverse) private var saved: [RecipeRecord] let start: Start let editSnapshot: Core.Container.Snapshot? @@ -67,6 +67,7 @@ struct CreationFlow: View { @State private var networkName = "" @State private var networkSubnet = "" @State private var networkInternalOnly = false + @State private var resourceRuntimeKind = Core.Runtime.Kind.appleContainer @State private var working = false @State private var configureToken = 0 @State private var configureReturnPage: Page? @@ -120,7 +121,10 @@ struct CreationFlow: View { .morphPanelSize(size(for: page)) .morphPanelPlacement(placement(for: page)) .animation(springAnim, value: page) - .onAppear { publishSoftDismiss() } + .onAppear { + applyInitialRuntimeSelectionIfNeeded() + publishSoftDismiss() + } .onDisappear { onSoftDismissChange?(nil) } .onChange(of: page) { _, _ in publishSoftDismiss() } } @@ -175,39 +179,44 @@ struct CreationFlow: View { private var chooserPage: some View { gridScaffold { - optionStack { - optionRow { - if app.settings.hubSearchEnabled { - box(symbol: "magnifyingglass", title: AppText.string("common.search", defaultValue: "Search"), - subtitle: AppText.string("creation.option.search.subtitle", defaultValue: "Find an image on Docker Hub"), - matchedID: "creation-option-0") { go(.search) } + VStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { + CreationRuntimePickerRow(runtimeKind: $resourceRuntimeKind, + runtimes: app.availableRuntimeDescriptors, + disabledReason: app.runtimePickerDisabledReason) + optionStack { + optionRow { + if app.settings.hubSearchEnabled { + box(symbol: "magnifyingglass", title: AppText.string("common.search", defaultValue: "Search"), + subtitle: AppText.string("creation.option.search.subtitle", defaultValue: "Find an image on Docker Hub"), + matchedID: "creation-option-0") { go(.search) } + } + box(symbol: "square.stack.3d.up", title: AppText.string("creation.option.localImage", defaultValue: "Local image"), + subtitle: app.images.isEmpty + ? AppText.string("creation.option.localImage.emptySubtitle", defaultValue: "Choose from pulled images") + : AppText.string("creation.option.localImage.subtitle", defaultValue: "Use an image already pulled"), + matchedID: "creation-option-1") { + go(.localImages) + } + box(symbol: "slider.horizontal.3", title: AppText.string("creation.option.scratch", defaultValue: "Start from scratch"), + subtitle: AppText.string("creation.option.scratch.subtitle", defaultValue: "Configure manually"), + matchedID: "creation-option-2") { configure(with: ContainerFormState(runtimeKind: resourceRuntimeKind)) } } - box(symbol: "square.stack.3d.up", title: AppText.string("creation.option.localImage", defaultValue: "Local image"), - subtitle: app.images.isEmpty - ? AppText.string("creation.option.localImage.emptySubtitle", defaultValue: "Choose from pulled images") - : AppText.string("creation.option.localImage.subtitle", defaultValue: "Use an image already pulled"), - matchedID: "creation-option-1") { - go(.localImages) - } - box(symbol: "slider.horizontal.3", title: AppText.string("creation.option.scratch", defaultValue: "Start from scratch"), - subtitle: AppText.string("creation.option.scratch.subtitle", defaultValue: "Configure manually"), - matchedID: "creation-option-2") { configure(with: ContainerFormState()) } - } - optionRow { - box(symbol: "shippingbox.and.arrow.backward", title: AppText.string("creation.option.compose", defaultValue: "Compose"), - subtitle: AppText.string("creation.option.compose.subtitle", defaultValue: "Paste YAML or choose a file"), - matchedID: "compose-option-0", - enabled: app.settings.composeImportEnabled) { - guard app.settings.composeImportEnabled else { return } - go(.compose) + optionRow { + box(symbol: "shippingbox.and.arrow.backward", title: AppText.string("creation.option.compose", defaultValue: "Compose"), + subtitle: AppText.string("creation.option.compose.subtitle", defaultValue: "Paste YAML or choose a file"), + matchedID: "compose-option-0", + enabled: app.settings.composeImportEnabled) { + guard app.settings.composeImportEnabled else { return } + go(.compose) + } + box(symbol: "archivebox", title: AppText.string("creation.option.imageArchive", defaultValue: "Image archive"), + subtitle: AppText.string("creation.option.imageArchive.subtitle", defaultValue: "Load an image .tar")) { selectImageArchive() } + box(symbol: "bookmark", title: AppText.sectionTemplates, + subtitle: saved.isEmpty + ? AppText.string("creation.option.templates.emptySubtitle", defaultValue: "None saved yet") + : AppText.string("creation.option.templates.subtitle", defaultValue: "Reuse a saved recipe"), + enabled: !saved.isEmpty) { go(.templates) } } - box(symbol: "archivebox", title: AppText.string("creation.option.imageArchive", defaultValue: "Image archive"), - subtitle: AppText.string("creation.option.imageArchive.subtitle", defaultValue: "Load an image .tar")) { selectImageArchive() } - box(symbol: "bookmark", title: AppText.sectionTemplates, - subtitle: saved.isEmpty - ? AppText.string("creation.option.templates.emptySubtitle", defaultValue: "None saved yet") - : AppText.string("creation.option.templates.subtitle", defaultValue: "Reuse a saved recipe"), - enabled: !saved.isEmpty) { go(.templates) } } } } @@ -219,6 +228,9 @@ struct CreationFlow: View { CreationNetworkFields(name: $networkName, subnet: $networkSubnet, internalOnly: $networkInternalOnly, + runtimeKind: $resourceRuntimeKind, + runtimes: app.availableRuntimeDescriptors, + runtimePickerDisabledReason: app.runtimePickerDisabledReason, working: working, onSubmit: createNetwork) } @@ -229,6 +241,9 @@ struct CreationFlow: View { leading: resourceLeading, contentAlignment: .top) { CreationVolumeFields(name: $volumeName, size: $volumeSize, + runtimeKind: $resourceRuntimeKind, + runtimes: app.availableRuntimeDescriptors, + runtimePickerDisabledReason: app.runtimePickerDisabledReason, working: working, onSubmit: createVolume) } @@ -246,7 +261,7 @@ struct CreationFlow: View { private var searchPage: some View { contentOnlyScaffold { RegistryImageSearch(initialQuery: initialSearchQuery) { picked in - configure(with: picked) + configure(with: pickedForSelectedRuntime(picked)) } } } @@ -400,6 +415,22 @@ struct CreationFlow: View { go(.configure) } + private func pickedForSelectedRuntime(_ picked: ContainerFormState) -> ContainerFormState { + var picked = picked + picked.runtimeKind = resourceRuntimeKind + return picked + } + + private func applyInitialRuntimeSelectionIfNeeded() { + if let firstRuntime = app.availableRuntimeDescriptors.first?.kind { + resourceRuntimeKind = firstRuntime + } + guard editSnapshot == nil, + spec.image.trimmingCharacters(in: .whitespaces).isEmpty, + spec.name.trimmingCharacters(in: .whitespaces).isEmpty else { return } + spec.runtimeKind = resourceRuntimeKind + } + private func backFromConfigure() { guard let target = configureBackTarget else { onClose() @@ -464,14 +495,14 @@ struct CreationFlow: View { panel.message = AppText.chooseComposeFile guard panel.runModal() == .OK, let url = panel.url else { return } onClose() - ComposeImport.importFile(at: url, app: app, ui: ui) + ComposeImport.importFile(at: url, runtimeKind: resourceRuntimeKind, app: app, ui: ui) } private func importPastedCompose() { let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } onClose() - ComposeImport.importText(text, app: app, ui: ui) + ComposeImport.importText(text, runtimeKind: resourceRuntimeKind, app: app, ui: ui) } /// Pick an image tar archive and load it into the local image store. @@ -483,7 +514,7 @@ struct CreationFlow: View { panel.message = AppText.chooseImageTarArchive guard panel.runModal() == .OK, let url = panel.url else { return } onClose() - app.loadImageTar(at: url) + app.loadImageTar(at: url, runtimeKind: resourceRuntimeKind) } private func createVolume() { @@ -493,7 +524,8 @@ struct CreationFlow: View { Task { let ok = await app.createVolume( name: name, - size: volumeSize.trimmingCharacters(in: .whitespaces).nilIfEmpty + size: volumeSize.trimmingCharacters(in: .whitespaces).nilIfEmpty, + runtimeKind: resourceRuntimeKind ) working = false if ok { onClose() } @@ -508,7 +540,8 @@ struct CreationFlow: View { let ok = await app.createNetwork( name: name, subnet: networkSubnet.trimmingCharacters(in: .whitespaces).nilIfEmpty, - internalOnly: networkInternalOnly + internalOnly: networkInternalOnly, + runtimeKind: resourceRuntimeKind ) working = false if ok { onClose() } diff --git a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift index a608c089..06cc294c 100644 --- a/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift @@ -6,6 +6,9 @@ struct CreationNetworkFields: View { @Binding var name: String @Binding var subnet: String @Binding var internalOnly: Bool + @Binding var runtimeKind: Core.Runtime.Kind + let runtimes: [Core.Runtime.Descriptor] + let runtimePickerDisabledReason: String let working: Bool var onSubmit: () -> Void @@ -15,6 +18,9 @@ struct CreationNetworkFields: View { subtitle: networkSubtitle, command: previewCommand) { UI.Panel.Section(header: AppText.string("creation.details", defaultValue: "Details"), highlighted: hasValues) { + CreationRuntimePickerRow(runtimeKind: $runtimeKind, + runtimes: runtimes, + disabledReason: runtimePickerDisabledReason) UI.Panel.Field(label: AppText.string("creation.name", defaultValue: "Name"), info: AppText.string("creation.network.name.info", defaultValue: "A readable name used by containers with `--network`."), error: nameError) { @@ -55,7 +61,8 @@ struct CreationNetworkFields: View { private var previewCommand: [String] { Core.Command.networkCreatePreview(name: trimmedName.isEmpty ? "" : trimmedName, subnet: trimmedSubnet.isEmpty ? nil : trimmedSubnet, - internalOnly: internalOnly) + internalOnly: internalOnly, + runtimeKind: runtimeKind) } private func submitIfReady() { @@ -67,6 +74,9 @@ struct CreationNetworkFields: View { struct CreationVolumeFields: View { @Binding var name: String @Binding var size: String + @Binding var runtimeKind: Core.Runtime.Kind + let runtimes: [Core.Runtime.Descriptor] + let runtimePickerDisabledReason: String let working: Bool var onSubmit: () -> Void @@ -76,6 +86,9 @@ struct CreationVolumeFields: View { subtitle: volumeSubtitle, command: previewCommand) { UI.Panel.Section(header: AppText.string("creation.details", defaultValue: "Details"), highlighted: hasValues) { + CreationRuntimePickerRow(runtimeKind: $runtimeKind, + runtimes: runtimes, + disabledReason: runtimePickerDisabledReason) UI.Panel.Field(label: AppText.string("creation.name", defaultValue: "Name"), info: AppText.string("creation.volume.name.info", defaultValue: "A persistent storage name you can mount into containers."), error: nameError) { @@ -108,7 +121,8 @@ struct CreationVolumeFields: View { private var volumeSubtitle: String { trimmedSize.isEmpty ? AppText.string("creation.volume.defaultSize", defaultValue: "default size") : trimmedSize } private var previewCommand: [String] { Core.Command.volumeCreatePreview(name: trimmedName.isEmpty ? "" : trimmedName, - size: trimmedSize.isEmpty ? nil : trimmedSize) + size: trimmedSize.isEmpty ? nil : trimmedSize, + runtimeKind: runtimeKind) } private func submitIfReady() { @@ -117,6 +131,30 @@ struct CreationVolumeFields: View { } } +struct CreationRuntimePickerRow: View { + @Binding var runtimeKind: Core.Runtime.Kind + let runtimes: [Core.Runtime.Descriptor] + let disabledReason: String + + var body: some View { + UI.Panel.Row(title: AppText.runtime, + subtitle: runtimes.count > 1 ? AppText.runtimeSubtitle : disabledReason) { + Picker("", selection: $runtimeKind) { + ForEach(runtimes, id: \.kind) { descriptor in + Text(descriptor.displayName).tag(descriptor.kind) + } + } + .labelsHidden() + .fixedSize() + .disabled(runtimes.count < 2) + } + .onAppear { + guard let first = runtimes.first, !runtimes.contains(where: { $0.kind == runtimeKind }) else { return } + runtimeKind = first.kind + } + } +} + struct CreationLocalImagesContent: View { @Environment(AppModel.self) private var app @Binding var query: String @@ -139,7 +177,8 @@ struct CreationLocalImagesContent: View { LazyVStack(spacing: UI.Layout.Spacing.xs) { ForEach(filteredLocalImages) { image in CreationLocalImageRow(image: image) { - onSelect(RecommendedImage.spec(for: image.reference)) + onSelect(RecommendedImage.spec(for: image.reference, + runtimeKind: image.runtimeKind)) } .accessibilityAddTraits(.isButton) } @@ -188,7 +227,7 @@ struct CreationPastedComposeContent: View { } struct CreationTemplatesContent: View { - let templates: [Template] + let templates: [RecipeRecord] var onSelect: (ContainerFormState) -> Void var body: some View { diff --git a/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift b/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift index 55bede31..8064631b 100644 --- a/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift +++ b/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift @@ -19,7 +19,7 @@ struct CustomizeSheet: View { var id: String { switch self { - case .container(let snapshot): return "container:\(snapshot.id)" + case .container(let snapshot): return "container:\(snapshot.scopedID)" case .image(let reference): return "image:\(reference)" case .imageGroup(let id, _): return "image-group:\(id)" case .imageTag(let reference, let groupID): return "image-tag:\(groupID ?? "none"):\(reference)" @@ -331,7 +331,7 @@ struct CustomizeSheet: View { case .imageGroup(let id, _): return app.personalization.imageGroupDefault(for: id) != nil case .container(let snapshot): - return app.personalization.hasOverride(id: snapshot.id) + return app.personalization.hasOverride(id: snapshot.scopedID) case .volume(let name): return app.personalization.volumeStyle(for: name) != nil } @@ -356,7 +356,7 @@ struct CustomizeSheet: View { switch target { case .container(let snapshot): style = app.containerStyle(for: snapshot) - overridesInheritedStyle = app.personalization.hasOverride(id: snapshot.id) + overridesInheritedStyle = app.personalization.hasOverride(id: snapshot.scopedID) case .image(let reference): let own = app.personalization.imageDefault(for: reference) overridesInheritedStyle = own != nil @@ -399,9 +399,9 @@ struct CustomizeSheet: View { } case .container(let snapshot): if overridesInheritedStyle { - app.personalization.setOverride(style, for: snapshot.id) + app.personalization.setOverride(style, for: snapshot.scopedID) } else { - app.personalization.clearOverride(id: snapshot.id) + app.personalization.clearOverride(id: snapshot.scopedID) } case .volume(let name): app.personalization.setVolumeStyle(style, for: name) @@ -416,7 +416,7 @@ struct CustomizeSheet: View { case .imageGroup(let id, _): app.personalization.clearImageGroupDefault(for: id) case .container(let snapshot): - app.personalization.clearOverride(id: snapshot.id) + app.personalization.clearOverride(id: snapshot.scopedID) case .volume(let name): app.personalization.clearVolumeStyle(for: name) } @@ -426,7 +426,7 @@ struct CustomizeSheet: View { private func applyToImage() { guard case .container(let snapshot) = target else { return } app.personalization.setImageDefault(style, for: snapshot.image) - app.personalization.clearOverride(id: snapshot.id) + app.personalization.clearOverride(id: snapshot.scopedID) overridesInheritedStyle = false dismiss() } @@ -447,7 +447,7 @@ struct CustomizeSheet: View { private func ownStyle() -> Personalization { switch target { case .container(let snapshot): - return app.personalization.hasOverride(id: snapshot.id) ? app.containerStyle(for: snapshot) : inheritedStyle() + return app.personalization.hasOverride(id: snapshot.scopedID) ? app.containerStyle(for: snapshot) : inheritedStyle() case .image(let reference), .imageTag(let reference, _): return app.personalization.imageDefault(for: reference) ?? inheritedStyle() case .imageGroup(let id, _): diff --git a/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift index dfadf7ff..2bae22ab 100644 --- a/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift @@ -154,7 +154,7 @@ struct ContainerConfigureView: View { var out: [String] = [] let name = spec.name.trimmingCharacters(in: .whitespaces) if !name.isEmpty, - app.containers.snapshots.contains(where: { $0.id == name || $0.displayName == name }) { + app.containers.snapshots.contains(where: { $0.runtimeKind == spec.effectiveRuntimeKind && ($0.id == name || $0.displayName == name) }) { out.append("A container named “\(name)” already exists — creating this will fail unless you rename it.") } // Two ports mapping the same host port within this spec. @@ -168,14 +168,18 @@ struct ContainerConfigureView: View { private func saveTemplate() { let name = templateName.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } - modelContext.insert(Template(name: name, spec: spec)) - try? modelContext.save() - app.flash(AppText.savedTemplate(name)) + modelContext.insert(RecipeRecord(name: name, spec: spec)) + do { + try modelContext.save() + app.flash(AppText.savedTemplate(name)) + } catch { + app.flash(error.appDisplayMessage) + } } /// The id of the container being edited (empty in `.new` mode). private var editID: String { - if case .edit(let snapshot, _) = mode { return snapshot.id } + if case .edit(let snapshot, _) = mode { return snapshot.scopedID } return "" } @@ -184,11 +188,15 @@ struct ContainerConfigureView: View { loaded = true switch mode { case .new: - break // spec was prefilled at init + if spec.image.trimmingCharacters(in: .whitespaces).isEmpty, + spec.name.trimmingCharacters(in: .whitespaces).isEmpty, + let firstRuntime = app.availableRuntimeDescriptors.first?.kind { + spec.runtimeKind = firstRuntime + } case .edit(let snapshot, _): // Pull the current style + healthcheck from the local stores so edits start from what's set. spec.personalization = app.containerStyle(for: snapshot) - spec.healthCheck = app.healthChecks.check(for: snapshot.id) ?? Core.Container.HealthCheck() + spec.healthCheck = app.healthChecks.check(for: snapshot.scopedID) ?? Core.Container.HealthCheck() } } @@ -212,7 +220,7 @@ struct ContainerConfigureView: View { guard case .edit(let snapshot, let onComplete) = mode else { return } working = true Task { - let newID = await app.recreateContainer(originalID: snapshot.id, spec: spec) + let newID = await app.recreateContainer(originalID: snapshot.scopedID, spec: spec) working = false if newID != nil { onComplete() diff --git a/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift b/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift index b8824d70..e1f77e99 100644 --- a/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift @@ -92,7 +92,9 @@ struct FilesTab: View { loading = true; error = nil defer { loading = false } do { - let out = try await client.execCapture(snapshot.id, ["ls", "-1ap", path]) + let out = try await client.execCapture(snapshot.id, + ["ls", "-1ap", path], + runtimeKind: snapshot.runtimeKind) entries = out.split(separator: "\n").map(String.init) .filter { $0 != "./" && $0 != "../" && !$0.isEmpty } .sorted { ($0.hasSuffix("/") ? 0 : 1, $0.lowercased()) < ($1.hasSuffix("/") ? 0 : 1, $1.lowercased()) } @@ -118,7 +120,9 @@ struct FilesTab: View { guard panel.runModal() == .OK, let dest = panel.url else { return } Task { do { - _ = try await app.client?.copy(source: "\(snapshot.id):\(joined(name))", destination: dest.path) + _ = try await app.client?.copy(source: "\(snapshot.id):\(joined(name))", + destination: dest.path, + runtimeKind: snapshot.runtimeKind) app.flash(AppText.copiedFileToHost(name)) } catch let e as Core.Command.Error { app.flash(e.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } @@ -135,7 +139,8 @@ struct FilesTab: View { Task { do { _ = try await app.client?.copy(source: src.path, - destination: "\(snapshot.id):\(joined(src.lastPathComponent))") + destination: "\(snapshot.id):\(joined(src.lastPathComponent))", + runtimeKind: snapshot.runtimeKind) app.flash(AppText.copiedFileIntoContainer(src.lastPathComponent)) await load() } catch let e as Core.Command.Error { app.flash(e.appDisplayMessage) } diff --git a/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift b/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift index 9da15886..85a2914d 100644 --- a/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift @@ -25,7 +25,7 @@ struct LogsTab: View { } // Stream is tied to the view's lifetime and the container id: switching tabs or containers // cancels it, terminating the child process (SIGTERM via the stream's onTermination). - .task(id: snapshot.id) { await stream() } + .task(id: snapshot.scopedID) { await stream() } } private var controls: some View { @@ -93,7 +93,10 @@ struct LogsTab: View { streaming = true defer { streaming = false } do { - for try await chunk in client.streamLogs(id: snapshot.id, follow: true, tail: 500) { + for try await chunk in client.streamLogs(id: snapshot.id, + runtimeKind: snapshot.runtimeKind, + follow: true, + tail: 500) { ingest(chunk) } // Stream ended (process exited): flush any trailing partial line. diff --git a/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift b/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift index c5a2363b..dbf32517 100644 --- a/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift @@ -10,7 +10,7 @@ struct StatsTab: View { @State private var processes: String = "" - private var metrics: ContainerMetricsState { app.containers.metricsState(for: snapshot.id) } + private var metrics: ContainerMetricsState { app.containers.metricsState(for: snapshot.scopedID) } private var delta: Core.Metrics.StatsDelta? { metrics.stats } private var history: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer] { metrics.historyByMetric } private var normalization: Core.Metrics.NormalizationContext { app.statsNormalizationContext } @@ -46,7 +46,7 @@ struct StatsTab: View { UI.State.Loading(AppText.string("stats.collecting", defaultValue: "Collecting stats...")) } } - .task(id: snapshot.id) { await refreshVisibleProcesses() } + .task(id: snapshot.scopedID) { await refreshVisibleProcesses() } } @ViewBuilder @@ -66,7 +66,7 @@ struct StatsTab: View { private func loadProcesses() async { guard snapshot.state == .running, let client = app.client else { processes = ""; return } // `ps` is present in most images (busybox/coreutils); ignore failures (e.g. distroless). - processes = (try? await client.execCapture(snapshot.id, ["ps"]))? + processes = (try? await client.execCapture(snapshot.id, ["ps"], runtimeKind: snapshot.runtimeKind))? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } diff --git a/Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift b/Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift index 72e17262..4d5a7f3d 100644 --- a/Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift @@ -30,7 +30,9 @@ struct TerminalTab: View { systemImage: "terminal", description: AppText.string("terminal.notRunning.description", defaultValue: "Start the container to open a shell.")) } else if let client = app.client, - let invocation = try? client.terminalInvocation(containerID: snapshot.id, shell: shell) { + let invocation = try? client.terminalInvocation(containerID: snapshot.id, + shell: shell, + runtimeKind: snapshot.runtimeKind) { ContainerToolTabScaffold { controls } content: { @@ -40,7 +42,7 @@ struct TerminalTab: View { } // Recreating the view tears down the exec. Include container/shell so rapid // card switches cannot reuse a terminal process for a different target. - .id("\(snapshot.id)-\(shell)-\(session)") + .id("\(snapshot.scopedID)-\(shell)-\(session)") .terminalSurfaceChrome() if let ended { endedOverlay(code: ended.code) diff --git a/Sources/ContainedApp/Features/Containers/Form/ContainerFormState.swift b/Sources/ContainedApp/Features/Containers/Form/ContainerFormState.swift index 0b313e18..3c56a8a5 100644 --- a/Sources/ContainedApp/Features/Containers/Form/ContainerFormState.swift +++ b/Sources/ContainedApp/Features/Containers/Form/ContainerFormState.swift @@ -19,6 +19,12 @@ struct ContainerFormState: Codable { if let healthCheck { self.healthCheck = healthCheck } } + init(runtimeKind: Core.Runtime.Kind, + healthCheck: Core.Container.HealthCheck? = nil) { + self.init(document: .containerCreate(runtimeKind: runtimeKind), + healthCheck: healthCheck) + } + init(from config: Core.Container.Configuration) { self.document = Core.Schema.Document.containerEdit(from: config) } diff --git a/Sources/ContainedApp/Features/Containers/Form/ContainerSchemaForm.swift b/Sources/ContainedApp/Features/Containers/Form/ContainerSchemaForm.swift index e3c66889..60bcd128 100644 --- a/Sources/ContainedApp/Features/Containers/Form/ContainerSchemaForm.swift +++ b/Sources/ContainedApp/Features/Containers/Form/ContainerSchemaForm.swift @@ -55,8 +55,8 @@ struct ContainerSchemaForm: View { private var generalSection: some View { Group { - UI.Panel.Row(title: AppText.runtimeCore, - subtitle: app.runtimeCoreSelectorIsEnabled ? AppText.runtimeCoreSubtitle : app.runtimeCoreSelectorDisabledReason) { + UI.Panel.Row(title: AppText.runtime, + subtitle: app.runtimePickerIsEnabled ? AppText.runtimeSubtitle : app.runtimePickerDisabledReason) { Picker("", selection: runtimeKindBinding) { ForEach(app.availableRuntimeDescriptors, id: \.kind) { descriptor in Text(descriptor.displayName).tag(descriptor.kind) @@ -64,7 +64,7 @@ struct ContainerSchemaForm: View { } .labelsHidden() .fixedSize() - .disabled(!app.runtimeCoreSelectorIsEnabled) + .disabled(!app.runtimePickerIsEnabled) } UI.Panel.Field(label: AppText.string("runSpec.image", defaultValue: "Image"), info: fieldInfo(.imageReference), @@ -578,7 +578,7 @@ struct ContainerSchemaForm: View { ForEach(fields) { field in UI.Panel.Field(label: fieldLabel(field), info: fieldInfo(field.path), - error: field.support(for: spec.effectiveRuntimeKind).defaultDisabledReason) { + error: field.support(for: spec.effectiveRuntimeKind).localizedDisabledReason()) { Text(valueDescription(for: field)) .designSecondaryCallout() .textSelection(.enabled) @@ -703,7 +703,7 @@ struct ContainerSchemaForm: View { } private func fieldLabel(_ field: Core.Schema.FieldDescriptor) -> String { - AppText.dynamicString(field.labelKey, defaultValue: field.defaultLabel) + field.localizedLabel() } private func fieldLabel(_ path: Core.Field.Path, fallback: String) -> String { @@ -714,7 +714,7 @@ struct ContainerSchemaForm: View { private func fieldInfo(_ path: Core.Field.Path) -> String { guard let field = spec.definition.descriptor(for: path) else { return "" } let tip = field.tip(for: spec.effectiveRuntimeKind) - let body = tip.map { AppText.dynamicString($0.key, defaultValue: $0.defaultText) } ?? field.defaultLabel + let body = tip?.localizedText() ?? field.localizedLabel() let aliases = field.sourceAliases .filter { !$0.name.isEmpty || !$0.example.isEmpty } .map { alias -> String in diff --git a/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift b/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift index ea59a1a0..6a739987 100644 --- a/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift +++ b/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift @@ -54,6 +54,7 @@ final class ContainersStore { var errorMessage: String? var busyIDs: Set = [] @ObservationIgnored var logger: AppLogger? + @ObservationIgnored weak var database: AppDatabase? @ObservationIgnored var now: () -> Date = Date.init @ObservationIgnored private var metricsStates: [String: ContainerMetricsState] = [:] @ObservationIgnored private var statsNormalizationContext: Core.Metrics.NormalizationContext = .containerSpecific @@ -140,14 +141,17 @@ final class ContainersStore { private func performRefresh() async { guard let client else { return } do { - let listed = try await client.listContainers(all: true) + let listedAll = try await client.listRuntimeContainers(all: true) .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + database?.upsertContainers(listedAll) + let migratingIDs = database?.hiddenContainerScopedIDs() ?? [] + let listed = listedAll.filter { !migratingIDs.contains($0.scopedID) } // Only publish when the list actually changed: reassigning an identical array would // needlessly invalidate the whole grid (and every card's sparkline) on each idle tick. if listed != snapshots { snapshots = listed } // Drop intentional-stop flags for containers that no longer exist, so the set can't grow // unbounded as containers are recreated/removed over a long session. - intentionalStops.formIntersection(Set(snapshots.map(\.id))) + intentionalStops.formIntersection(Set(snapshots.map(\.scopedID))) errorMessage = nil pruneStatsForCurrentRunningSet() } catch let error as Core.Command.Error { @@ -158,8 +162,8 @@ final class ContainersStore { } private func pruneStatsForCurrentRunningSet() { - let currentSet = Set(snapshots.map(\.id)) - let runningSet = Set(running.map(\.id)) + let currentSet = Set(snapshots.map(\.scopedID)) + let runningSet = Set(running.map(\.scopedID)) let prunedStats = statsByID.filter { runningSet.contains($0.key) } if prunedStats.count != statsByID.count { statsByID = prunedStats } let prunedHistory = historyByID.filter { runningSet.contains($0.key) } @@ -181,14 +185,14 @@ final class ContainersStore { } func applyStreamedStats(_ samples: [Core.Metrics.RuntimeStatsSnapshot], observedAt: Date? = nil) { - let runningSet = Set(running.map(\.id)) + let runningSet = runtimeAndScopedIDs(for: running) let samples = samples.filter { runningSet.contains($0.id) } guard !samples.isEmpty else { return } let observedAt = observedAt ?? now() let rawInterval = lastStreamedStatsDate.map { observedAt.timeIntervalSince($0) } let interval = max(rawInterval ?? Self.minimumStreamedStatsInterval, Self.minimumStreamedStatsInterval) - let snapshotsByID = Dictionary(snapshots.map { ($0.id, $0) }, uniquingKeysWith: { current, _ in current }) + let snapshotsByID = snapshotLookupByStatsID() var nextStats = statsByID var nextHistory = historyByID for sample in samples { @@ -221,8 +225,8 @@ final class ContainersStore { } private func rebuildDisplayHistories() { - let snapshotsByID = Dictionary(snapshots.map { ($0.id, $0) }, uniquingKeysWith: { current, _ in current }) - let runningSet = Set(running.map(\.id)) + let snapshotsByID = snapshotLookupByStatsID() + let runningSet = runtimeAndScopedIDs(for: running) var rebuilt: [String: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer]] = [:] for (id, delta) in statsByID where runningSet.contains(id) { var metrics: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer] = [:] @@ -245,19 +249,47 @@ final class ContainersStore { } } + private func snapshotLookupByStatsID() -> [String: Core.Container.Snapshot] { + var lookup = Dictionary(snapshots.map { ($0.scopedID, $0) }, uniquingKeysWith: { current, _ in current }) + let byRuntimeID = Dictionary(grouping: snapshots, by: \.id) + for (runtimeID, matches) in byRuntimeID where matches.count == 1 { + lookup[runtimeID] = matches[0] + } + return lookup + } + + private func runtimeAndScopedIDs(for snapshots: [Core.Container.Snapshot]) -> Set { + Set(snapshots.flatMap { [$0.id, $0.scopedID] }) + } + // MARK: Lifecycle - func start(_ id: String) async { await act(id, verb: "Start") { try await $0.start([id]) } } + func start(_ id: String) async { + guard let snapshot = snapshot(for: id) else { return } + await act(snapshot.scopedID, verb: "Start") { client in + try await client.start([snapshot.id], runtimeKind: snapshot.runtimeKind) + } + } func stop(_ id: String) async { - intentionalStops.insert(id) - await act(id, verb: "Stop") { try await $0.stop([id]) } + guard let snapshot = snapshot(for: id) else { return } + intentionalStops.insert(snapshot.scopedID) + await act(snapshot.scopedID, verb: "Stop") { client in + try await client.stop([snapshot.id], runtimeKind: snapshot.runtimeKind) + } } func restart(_ id: String) async { - await act(id, verb: "Restart") { _ = try await $0.stop([id]); _ = try await $0.start([id]) } + guard let snapshot = snapshot(for: id) else { return } + await act(snapshot.scopedID, verb: "Restart") { client in + _ = try await client.stop([snapshot.id], runtimeKind: snapshot.runtimeKind) + _ = try await client.start([snapshot.id], runtimeKind: snapshot.runtimeKind) + } } func remove(_ id: String, force: Bool) async { - intentionalStops.insert(id) - await act(id, verb: "Remove") { try await $0.deleteContainers([id], force: force) } + guard let snapshot = snapshot(for: id) else { return } + intentionalStops.insert(snapshot.scopedID) + await act(snapshot.scopedID, verb: "Remove") { client in + try await client.deleteContainers([snapshot.id], force: force, runtimeKind: snapshot.runtimeKind) + } } /// Create + run a container from the Create/Edit form. Returns the new container's id on success @@ -299,23 +331,26 @@ final class ContainersStore { @discardableResult func recreate(originalID: String, spec: ContainerFormState) async -> Bool { guard let client else { return false } - busyIDs.insert(originalID) - intentionalStops.insert(originalID) // don't let the watchdog fight the teardown - defer { busyIDs.remove(originalID) } + let original = snapshot(for: originalID) + let trackingID = original?.scopedID ?? originalID + let runtimeID = original?.id ?? originalID + busyIDs.insert(trackingID) + intentionalStops.insert(trackingID) // don't let the watchdog fight the teardown + defer { busyIDs.remove(trackingID) } let started = Date() - logger?.record("Recreating \(originalID)", category: .lifecycle, containerID: originalID) - diagnosticLogger.notice("Recreate started for \(originalID, privacy: .public)") + logger?.record("Recreating \(runtimeID)", category: .lifecycle, containerID: trackingID) + diagnosticLogger.notice("Recreate started for \(runtimeID, privacy: .public)") do { - _ = try await client.recreateContainer(originalID: originalID, document: spec.document) + _ = try await client.recreateContainer(originalID: runtimeID, document: spec.document) performHaptic() await refresh() let elapsed = Date().timeIntervalSince(started) - logger?.record("Recreated \(originalID) in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", + logger?.record("Recreated \(runtimeID) in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", category: .lifecycle, severity: elapsed >= 1.5 ? .warning : .info, - containerID: originalID) + containerID: trackingID) diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, - "Recreated \(originalID, privacy: .public) in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s") + "Recreated \(runtimeID, privacy: .public) in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s") return true } catch { errorMessage = error.appDisplayMessage @@ -324,13 +359,17 @@ final class ContainersStore { error: error, category: .lifecycle, severity: .warning, - containerID: originalID) + containerID: trackingID) diagnosticLogger.error("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.appDisplayMessage, privacy: .public)") await refresh() return false } } + private func snapshot(for id: String) -> Core.Container.Snapshot? { + snapshots.first { $0.scopedID == id || $0.id == id } + } + private func act(_ id: String, verb: String, _ body: @escaping (Core.Orchestrator) async throws -> Void) async { diff --git a/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift b/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift index 02c37ad8..e70795f5 100644 --- a/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift +++ b/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift @@ -29,7 +29,7 @@ final class RestartWatchdog { var restarts: [(Core.Container.Snapshot, Int)] = [] for snapshot in snapshots { - let id = snapshot.id + let id = snapshot.scopedID let current = snapshot.state defer { lastState[id] = current } @@ -60,7 +60,7 @@ final class RestartWatchdog { for (snapshot, attempt) in restarts { onRestart?(snapshot, attempt) - _ = try? await client.start([snapshot.id]) + _ = try? await client.start([snapshot.id], runtimeKind: snapshot.runtimeKind) } } diff --git a/Sources/ContainedApp/Features/Images/Build/BuildWorkspaceView.swift b/Sources/ContainedApp/Features/Images/Build/BuildWorkspaceView.swift index c46a0ede..d28fe697 100644 --- a/Sources/ContainedApp/Features/Images/Build/BuildWorkspaceView.swift +++ b/Sources/ContainedApp/Features/Images/Build/BuildWorkspaceView.swift @@ -17,6 +17,7 @@ struct BuildWorkspaceView: View { @State private var platform = "" @State private var noCache = false @State private var buildArgs: [KeyValue] = [] + @State private var runtimeKind = Core.Runtime.Kind.appleContainer @State private var building = false @State private var run = 0 // bump to restart the console private var canBuild: Bool { contextDir != nil && !tag.trimmingCharacters(in: .whitespaces).isEmpty } @@ -31,7 +32,8 @@ struct BuildWorkspaceView: View { tag: tag.trimmingCharacters(in: .whitespaces), dockerfile: dockerfile.isEmpty ? nil : dockerfile, buildArgs: argsDict, noCache: noCache, - platform: platform.isEmpty ? nil : platform) + platform: platform.isEmpty ? nil : platform, + runtimeKind: runtimeKind) }, workingLabel: AppText.working, completedLabel: AppText.completed, @@ -47,6 +49,7 @@ struct BuildWorkspaceView: View { description: AppText.string("build.empty.description", defaultValue: "Choose a context folder and a tag, then Build. Output streams here.")) } } + .onAppear(perform: normalizeRuntimeSelection) } private var form: some View { @@ -64,6 +67,9 @@ struct BuildWorkspaceView: View { private var sourceSection: some View { UI.Panel.Section(header: AppText.string("build.source", defaultValue: "Source")) { + CreationRuntimePickerRow(runtimeKind: $runtimeKind, + runtimes: buildRuntimes, + disabledReason: app.runtimePickerDisabledReason) UI.Panel.Field(label: AppText.string("build.context", defaultValue: "Context"), info: AppText.string("build.context.info", defaultValue: "The build context: the folder sent to the builder, usually your project root.")) { HStack { @@ -162,7 +168,8 @@ struct BuildWorkspaceView: View { tag: tag.isEmpty ? nil : tag, dockerfile: dockerfile.isEmpty ? nil : dockerfile, buildArgs: argsDict, noCache: noCache, - platform: platform.isEmpty ? nil : platform) + platform: platform.isEmpty ? nil : platform, + runtimeKind: runtimeKind) } private func startBuild() { @@ -179,4 +186,16 @@ struct BuildWorkspaceView: View { panel.message = AppText.chooseBuildContextFolder if panel.runModal() == .OK { contextDir = panel.url } } + + private var buildRuntimes: [Core.Runtime.Descriptor] { + let runtimes = app.availableRuntimeDescriptors.filter { $0.supports(.imageBuild) } + return runtimes.isEmpty ? app.availableRuntimeDescriptors : runtimes + } + + private func normalizeRuntimeSelection() { + guard let first = buildRuntimes.first else { return } + if !buildRuntimes.contains(where: { $0.kind == runtimeKind }) { + runtimeKind = first.kind + } + } } diff --git a/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift b/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift index a39f4d79..178af161 100644 --- a/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift +++ b/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift @@ -239,8 +239,9 @@ struct RecommendedImage: Identifiable, Hashable { ] /// Build a minimal spec that just targets `reference` — the configure form fills in the rest. - static func spec(for reference: String) -> ContainerFormState { - var spec = ContainerFormState() + static func spec(for reference: String, + runtimeKind: Core.Runtime.Kind = .appleContainer) -> ContainerFormState { + var spec = ContainerFormState(runtimeKind: runtimeKind) spec.image = reference return spec } diff --git a/Sources/ContainedApp/Features/Images/Style/ImageStyleButton.swift b/Sources/ContainedApp/Features/Images/Style/ImageStyleButton.swift index 2a011d89..356d13f1 100644 --- a/Sources/ContainedApp/Features/Images/Style/ImageStyleButton.swift +++ b/Sources/ContainedApp/Features/Images/Style/ImageStyleButton.swift @@ -67,7 +67,7 @@ private extension CustomizeSheet.Target { func hasOwnStyle(in app: AppModel) -> Bool { switch self { case .container(let snapshot): - return app.personalization.hasOverride(id: snapshot.id) + return app.personalization.hasOverride(id: snapshot.scopedID) case .image(let reference), .imageTag(let reference, _): return app.personalization.imageDefault(for: reference) != nil case .imageGroup(let id, _): diff --git a/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift b/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift index d56f48d5..8055f7ab 100644 --- a/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift +++ b/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift @@ -19,11 +19,21 @@ struct BootstrapView: View { private var actions: some View { switch app.bootstrap { case .serviceStopped: - UI.Action.TextButton(title: starting ? AppText.string("bootstrap.starting", defaultValue: "Starting...") : AppText.string("bootstrap.startService", defaultValue: "Start container service"), - systemName: "play.circle", - prominence: .prominent, - isEnabled: !starting) { - Task { starting = true; await app.startService(); starting = false } + if app.appleRuntimeAvailable { + UI.Action.TextButton(title: starting ? AppText.string("bootstrap.starting", defaultValue: "Starting...") : AppText.string("bootstrap.startService", defaultValue: "Start container service"), + systemName: "play.circle", + prominence: .prominent, + isEnabled: !starting) { + Task { starting = true; await app.startService(); starting = false } + } + } else { + HStack(spacing: UI.Layout.Spacing.m) { + UI.Action.TextButton(title: AppText.string("common.tryAgain", defaultValue: "Try again"), + systemName: "arrow.clockwise", + prominence: .prominent) { + Task { await app.retryBootstrap() } + } + } } case .cliMissing: HStack(spacing: UI.Layout.Spacing.m) { @@ -86,7 +96,10 @@ struct BootstrapView: View { switch app.bootstrap { case .cliMissing: return "Container CLI not found" case .unsupported(let v): return "Unsupported version (\(v))" - case .serviceStopped: return "Container service is stopped" + case .serviceStopped: + return app.appleRuntimeAvailable + ? "Container service is stopped" + : "Docker endpoint is unavailable" case .checking: return "Connecting…" case .ready: return "Ready" } @@ -98,7 +111,9 @@ struct BootstrapView: View { case .unsupported: return "Contained targets container 1.0.x. Some features may not work with this version." case .serviceStopped: - return "Start the service to manage containers, images, and more." + return app.appleRuntimeAvailable + ? "Start the service to manage containers, images, and more." + : "Start Docker externally, then retry the connection." case .checking: return "Talking to the container service." case .ready: diff --git a/Sources/ContainedApp/Features/Palette/CommandPalette.swift b/Sources/ContainedApp/Features/Palette/CommandPalette.swift index 0e21ed14..2c511618 100644 --- a/Sources/ContainedApp/Features/Palette/CommandPalette.swift +++ b/Sources/ContainedApp/Features/Palette/CommandPalette.swift @@ -181,7 +181,7 @@ struct PaletteItem: Identifiable { visual: .container(snapshot), icon: "arrow.down.circle", tint: .blue) { Task { - if await app.pullImageUpdate(snapshot.image) { + if await app.pullImageUpdate(snapshot.image, runtimeKind: snapshot.runtimeKind) { ui.openCreationPanel(editing: snapshot) } } @@ -190,18 +190,18 @@ struct PaletteItem: Identifiable { items.append(PaletteItem(title: AppText.paletteStopContainer(name), subtitle: AppText.paletteContainerSubtitle, kind: .container, visual: .container(snapshot), icon: "stop.fill", tint: .orange) { - Task { await app.containers.stop(snapshot.id) } + Task { await app.containers.stop(snapshot.scopedID) } }) items.append(PaletteItem(title: AppText.paletteRestartContainer(name), subtitle: AppText.paletteContainerSubtitle, kind: .container, visual: .container(snapshot), icon: "arrow.clockwise", tint: .blue) { - Task { await app.containers.restart(snapshot.id) } + Task { await app.containers.restart(snapshot.scopedID) } }) } else { items.append(PaletteItem(title: AppText.paletteStartContainer(name), subtitle: AppText.paletteContainerSubtitle, kind: .container, visual: .container(snapshot), icon: "play.fill", tint: .green) { - Task { await app.containers.start(snapshot.id) } + Task { await app.containers.start(snapshot.scopedID) } }) } } @@ -291,15 +291,19 @@ struct PaletteItem: Identifiable { var items: [PaletteItem] = [] let groups = app.localImageGroups() for group in groups { - items.append(PaletteItem(title: AppText.paletteRunImage(Format.shortImage(group.primaryReference)), - subtitle: AppText.paletteLocalImageSubtitle, - keywords: group.references, - kind: .image, - visual: .imageGroup(group), - icon: "play.fill", - tint: .green) { - ui.runImage(group.primaryReference) - }) + if let runtimeKind = group.images.first(where: { $0.reference == group.primaryReference })?.runtimeKind + ?? group.tags.first?.runtimeKind { + items.append(PaletteItem(title: AppText.paletteRunImage(Format.shortImage(group.primaryReference)), + subtitle: AppText.paletteLocalImageSubtitle, + keywords: group.references, + kind: .image, + visual: .imageGroup(group), + icon: "play.fill", + tint: .green) { + ui.runImage(group.primaryReference, + runtimeKind: runtimeKind) + }) + } items.append(PaletteItem(title: AppText.paletteCheckImageUpdate(Format.shortImage(group.primaryReference)), subtitle: AppText.paletteImageSubtitle, keywords: group.references, @@ -310,25 +314,32 @@ struct PaletteItem: Identifiable { Task { await app.checkImageUpdate(group.primaryReference) } }) if app.imageUpdateStatus(for: group.primaryReference).state == .updateAvailable { - items.append(PaletteItem(title: AppText.palettePullImageUpdate(Format.shortImage(group.primaryReference)), - subtitle: AppText.paletteImageSubtitle, - keywords: group.references, - kind: .image, - visual: .imageGroup(group), - icon: "arrow.down.circle", - tint: .orange) { - Task { await app.pullImageUpdate(group.primaryReference) } - }) + if let runtimeKind = group.images.first(where: { $0.reference == group.primaryReference })?.runtimeKind { + items.append(PaletteItem(title: AppText.palettePullImageUpdate(Format.shortImage(group.primaryReference)), + subtitle: AppText.paletteImageSubtitle, + keywords: group.references, + kind: .image, + visual: .imageGroup(group), + icon: "arrow.down.circle", + tint: .orange) { + Task { + await app.pullImageUpdate(group.primaryReference, + runtimeKind: runtimeKind) + } + }) + } } - for reference in group.references where reference != group.primaryReference { - items.append(PaletteItem(title: AppText.paletteRunImage(Format.shortImage(reference)), + let primaryRuntime = group.images.first { $0.reference == group.primaryReference }?.runtimeKind + ?? group.tags.first?.runtimeKind + for tag in group.tags where tag.reference != group.primaryReference || tag.runtimeKind != primaryRuntime { + items.append(PaletteItem(title: AppText.paletteRunImage(Format.shortImage(tag.reference)), subtitle: AppText.paletteImageTagSubtitle, - keywords: [group.primaryReference, reference], + keywords: [group.primaryReference, tag.reference], kind: .image, - visual: .imageTag(reference, groupID: group.id), + visual: .imageTag(tag.reference, groupID: group.id), icon: "tag", tint: .green) { - ui.runImage(reference) + ui.runImage(tag.reference, runtimeKind: tag.runtimeKind) }) } } @@ -346,7 +357,7 @@ struct PaletteItem: Identifiable { visual: .volume(volume), icon: "externaldrive", tint: .secondary) { - var spec = ContainerFormState() + var spec = ContainerFormState(runtimeKind: volume.runtimeKind) spec.volumes = [VolumeMap(source: volume.name, target: "/data")] ui.openCreationPanel(entry: .configure, prefill: spec) } @@ -364,7 +375,7 @@ struct PaletteItem: Identifiable { visual: .network(network), icon: "network", tint: .secondary) { - var spec = ContainerFormState() + var spec = ContainerFormState(runtimeKind: network.runtimeKind) spec.network = network.name ui.openCreationPanel(entry: .configure, prefill: spec) } diff --git a/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift index c237d491..0b0aceb1 100644 --- a/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift +++ b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift @@ -10,6 +10,7 @@ struct RegistryLoginSheet: View { @State private var server = "" @State private var username = "" @State private var password = "" + @State private var runtimeKind = Core.Runtime.Kind.appleContainer @State private var busy = false @State private var error: String? @@ -31,6 +32,15 @@ struct RegistryLoginSheet: View { } } VStack(spacing: UI.Layout.Spacing.l) { + UI.Panel.Section(header: AppText.runtime) { + Picker("", selection: $runtimeKind) { + ForEach(registryRuntimes, id: \.kind) { descriptor in + Text(descriptor.displayName).tag(descriptor.kind) + } + } + .pickerStyle(.segmented) + .disabled(registryRuntimes.count < 2) + } UI.Panel.Section(header: AppText.string("registry.credentials", defaultValue: "Credentials")) { UI.Panel.Field(label: AppText.string("registry.server", defaultValue: "Server")) { TextField("", text: $server, prompt: Text("e.g. ghcr.io, docker.io")) @@ -60,6 +70,17 @@ struct RegistryLoginSheet: View { } .frame(UI.Panel.SheetSize.small) .sheetMaterial() + .onAppear(perform: normalizeRuntimeSelection) + } + + private var registryRuntimes: [Core.Runtime.Descriptor] { + let runtimes = app.availableRuntimeDescriptors.filter { $0.supports(.registries) } + return runtimes.isEmpty ? app.availableRuntimeDescriptors : runtimes + } + + private func normalizeRuntimeSelection() { + guard let first = registryRuntimes.first, !registryRuntimes.contains(where: { $0.kind == runtimeKind }) else { return } + runtimeKind = first.kind } private func submit() { @@ -69,7 +90,8 @@ struct RegistryLoginSheet: View { do { _ = try await client.registryLogin(server: server.trimmingCharacters(in: .whitespaces), username: username.trimmingCharacters(in: .whitespaces), - password: password) + password: password, + runtimeKind: runtimeKind) await app.refreshRegistries() dismiss() } catch let e as Core.Command.Error { error = e.appDisplayMessage; busy = false } diff --git a/Sources/ContainedApp/Features/Runtime/RuntimeSelectionSheet.swift b/Sources/ContainedApp/Features/Runtime/RuntimeSelectionSheet.swift new file mode 100644 index 00000000..be4d1d24 --- /dev/null +++ b/Sources/ContainedApp/Features/Runtime/RuntimeSelectionSheet.swift @@ -0,0 +1,139 @@ +import SwiftUI +import ContainedCore +import ContainedUI + +struct RuntimeSelectionSheet: View { + @Environment(AppModel.self) private var app + @Environment(UIState.self) private var ui + let request: UIState.RuntimeSelectionRequest + @State private var runtimeKind = Core.Runtime.Kind.appleContainer + + var body: some View { + UI.Panel.Scaffold(width: 420, scrolls: false) { + UI.Panel.Header(symbol: request.symbol, + title: AppText.runtime, + subtitle: request.subtitle) { + UI.Action.Group(UI.Action.Item(systemName: "xmark", + help: AppText.cancel, + isCancel: true) { cancel() }) + } + Divider() + } content: { + VStack(alignment: .leading, spacing: UI.Layout.Spacing.m) { + if runtimes.isEmpty { + UI.State.Empty(AppText.containerRuntimeNotReady, + systemImage: "exclamationmark.triangle", + description: AppText.string("runtime.selector.none", + defaultValue: "No compatible runtime is available.")) + } else { + UI.Panel.Section { + UI.Panel.Row(title: AppText.runtime, + subtitle: request.message) { + Picker("", selection: $runtimeKind) { + ForEach(runtimes, id: \.kind) { descriptor in + Text(descriptor.displayName).tag(descriptor.kind) + } + } + .labelsHidden() + .fixedSize() + } + } + } + HStack { + Spacer() + UI.Action.TextButton(title: AppText.cancel, + systemName: "xmark", + role: .cancel) { cancel() } + UI.Action.TextButton(title: request.actionTitle, + systemName: "checkmark", + prominence: .prominent, + isEnabled: !runtimes.isEmpty) { run() } + } + } + .padding(UI.Layout.Spacing.s) + .frame(width: 420) + } + .frame(width: 420, height: 220) + .onAppear { selectAvailableRuntimeIfNeeded() } + } + + private var runtimes: [Core.Runtime.Descriptor] { + app.availableRuntimeDescriptors.filter { $0.supports(request.requiredCapability) } + } + + private func selectAvailableRuntimeIfNeeded() { + guard let first = runtimes.first, + !runtimes.contains(where: { $0.kind == runtimeKind }) else { return } + runtimeKind = first.kind + } + + private func cancel() { + ui.runtimeSelectionRequest = nil + } + + private func run() { + switch request { + case .composeFile(let url): + ComposeImport.importFile(at: url, runtimeKind: runtimeKind, app: app, ui: ui) + case .composeText(let text, let projectName, let baseDirectory): + ComposeImport.importText(text, + projectName: projectName, + baseDirectory: baseDirectory, + runtimeKind: runtimeKind, + app: app, + ui: ui) + case .imageArchive(let url): + app.loadImageTar(at: url, runtimeKind: runtimeKind) + } + ui.runtimeSelectionRequest = nil + } +} + +private extension UIState.RuntimeSelectionRequest { + var requiredCapability: Core.Runtime.Capability { + switch self { + case .composeFile, .composeText: + return .composeImport + case .imageArchive: + return .imageArchive + } + } + + var symbol: String { + switch self { + case .composeFile, .composeText: + return "shippingbox.and.arrow.backward" + case .imageArchive: + return "archivebox" + } + } + + var subtitle: String { + switch self { + case .composeFile, .composeText: + return AppText.string("runtime.selector.compose.subtitle", defaultValue: "Import Compose services") + case .imageArchive: + return AppText.string("runtime.selector.imageArchive.subtitle", defaultValue: "Load image archive") + } + } + + var message: String { + switch self { + case .composeFile, .composeText: + return AppText.string("runtime.selector.compose.message", + defaultValue: "Choose the runtime that should receive the imported Compose services.") + case .imageArchive: + return AppText.string("runtime.selector.imageArchive.message", + defaultValue: "Choose the runtime that should load this image archive.") + } + } + + var actionTitle: String { + switch self { + case .composeFile, .composeText: + return AppText.string("common.import", defaultValue: "Import") + case .imageArchive: + return AppText.string("common.load", defaultValue: "Load") + } + } +} diff --git a/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift b/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift index 5f308991..cdb4e452 100644 --- a/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift +++ b/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift @@ -21,6 +21,7 @@ struct SettingsBackup: Codable, Equatable { var imageDefaultStyleEnabled: Bool var keepInMenuBar: Bool var cliPathOverride: String + var dockerCLIPathOverride: String var refreshInterval: Double var statsNormalizationMode: Core.Metrics.NormalizationMode var imageUpdateIntervalHours: Int @@ -48,7 +49,8 @@ struct SettingsBackup: Codable, Equatable { case buttonTintEnabled, buttonTint, buttonTintOpacity, buttonTintGradient, buttonTintGradientAngle case buttonTintBlendMode case cardMaterial - case showInfoTips, imageDefaultStyleEnabled, keepInMenuBar, cliPathOverride, refreshInterval + case showInfoTips, imageDefaultStyleEnabled, keepInMenuBar, cliPathOverride + case dockerCLIPathOverride, refreshInterval case statsNormalizationMode, imageUpdateIntervalHours, imageUpdateChecksEnabled, appUpdateChecksEnabled, autoRestartEnabled case notifyOnCrash, revealCLI, historyRetentionDays, loggingLevel, enabledLogDestinations case enabledLogCategories, updateChannel, commandPaletteEnabled, hubSearchEnabled @@ -73,6 +75,7 @@ struct SettingsBackup: Codable, Equatable { imageDefaultStyleEnabled: Bool, keepInMenuBar: Bool, cliPathOverride: String, + dockerCLIPathOverride: String = "", refreshInterval: Double, statsNormalizationMode: Core.Metrics.NormalizationMode = .container, imageUpdateIntervalHours: Int, @@ -111,6 +114,7 @@ struct SettingsBackup: Codable, Equatable { self.imageDefaultStyleEnabled = imageDefaultStyleEnabled self.keepInMenuBar = keepInMenuBar self.cliPathOverride = cliPathOverride + self.dockerCLIPathOverride = dockerCLIPathOverride self.refreshInterval = refreshInterval self.statsNormalizationMode = statsNormalizationMode self.imageUpdateIntervalHours = imageUpdateIntervalHours @@ -155,6 +159,7 @@ struct SettingsBackup: Codable, Equatable { imageDefaultStyleEnabled = try container.decodeIfPresent(Bool.self, forKey: .imageDefaultStyleEnabled) ?? true keepInMenuBar = try container.decodeIfPresent(Bool.self, forKey: .keepInMenuBar) ?? true cliPathOverride = try container.decodeIfPresent(String.self, forKey: .cliPathOverride) ?? "" + dockerCLIPathOverride = try container.decodeIfPresent(String.self, forKey: .dockerCLIPathOverride) ?? "" refreshInterval = try container.decodeIfPresent(Double.self, forKey: .refreshInterval) ?? 2 statsNormalizationMode = try container.decodeIfPresent(Core.Metrics.NormalizationMode.self, forKey: .statsNormalizationMode) ?? .container imageUpdateIntervalHours = try container.decodeIfPresent(Int.self, forKey: .imageUpdateIntervalHours) ?? 6 @@ -200,6 +205,7 @@ struct SettingsBackup: Codable, Equatable { try container.encode(imageDefaultStyleEnabled, forKey: .imageDefaultStyleEnabled) try container.encode(keepInMenuBar, forKey: .keepInMenuBar) try container.encode(cliPathOverride, forKey: .cliPathOverride) + try container.encode(dockerCLIPathOverride, forKey: .dockerCLIPathOverride) try container.encode(refreshInterval, forKey: .refreshInterval) try container.encode(statsNormalizationMode, forKey: .statsNormalizationMode) try container.encode(imageUpdateIntervalHours, forKey: .imageUpdateIntervalHours) diff --git a/Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift b/Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift index 4fceb594..d9c35936 100644 --- a/Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift +++ b/Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift @@ -3,60 +3,61 @@ import ContainedUI import ServiceManagement import ContainedCore -/// User preferences, persisted to `UserDefaults`. `@Observable` so views update live. +/// User preferences, persisted through the app database. `@Observable` so views update live. @MainActor @Observable final class SettingsStore { - var accentTint: UI.Theme.Tint { didSet { defaults.set(accentTint.rawValue, forKey: Keys.tint) } } - var appearance: UI.Theme.Appearance { didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) } } - var density: UI.Card.Density { didSet { defaults.set(density.rawValue, forKey: Keys.density) } } + var accentTint: UI.Theme.Tint { didSet { persist(accentTint.rawValue, for: Keys.tint) } } + var appearance: UI.Theme.Appearance { didSet { persist(appearance.rawValue, for: Keys.appearance) } } + var density: UI.Card.Density { didSet { persist(density.rawValue, for: Keys.density) } } /// Behind-window vibrancy material for the main content area. - var windowMaterial: UI.Theme.WindowMaterial { didSet { defaults.set(windowMaterial.rawValue, forKey: Keys.windowMaterial) } } + var windowMaterial: UI.Theme.WindowMaterial { didSet { persist(windowMaterial.rawValue, for: Keys.windowMaterial) } } /// Material behind modal sheets. - var modalMaterial: UI.Theme.WindowMaterial { didSet { defaults.set(modalMaterial.rawValue, forKey: Keys.modalMaterial) } } + var modalMaterial: UI.Theme.WindowMaterial { didSet { persist(modalMaterial.rawValue, for: Keys.modalMaterial) } } /// Material for toolbar control surfaces (glass buttons / search field). - var buttonMaterial: UI.Theme.WindowMaterial { didSet { defaults.set(buttonMaterial.rawValue, forKey: Keys.buttonMaterial) } } + var buttonMaterial: UI.Theme.WindowMaterial { didSet { persist(buttonMaterial.rawValue, for: Keys.buttonMaterial) } } /// Optional color wash applied inside toolbar glass buttons. - var buttonTintEnabled: Bool { didSet { defaults.set(buttonTintEnabled, forKey: Keys.buttonTintEnabled) } } - var buttonTint: UI.Theme.Tint { didSet { defaults.set(buttonTint.rawValue, forKey: Keys.buttonTint) } } - var buttonTintOpacity: Double { didSet { defaults.set(buttonTintOpacity, forKey: Keys.buttonTintOpacity) } } - var buttonTintGradient: Bool { didSet { defaults.set(buttonTintGradient, forKey: Keys.buttonTintGradient) } } - var buttonTintGradientAngle: Double { didSet { defaults.set(buttonTintGradientAngle, forKey: Keys.buttonTintGradientAngle) } } - var buttonTintBlendMode: UI.Theme.ColorBlendMode { didSet { defaults.set(buttonTintBlendMode.rawValue, forKey: Keys.buttonTintBlendMode) } } + var buttonTintEnabled: Bool { didSet { persist(buttonTintEnabled, for: Keys.buttonTintEnabled) } } + var buttonTint: UI.Theme.Tint { didSet { persist(buttonTint.rawValue, for: Keys.buttonTint) } } + var buttonTintOpacity: Double { didSet { persist(buttonTintOpacity, for: Keys.buttonTintOpacity) } } + var buttonTintGradient: Bool { didSet { persist(buttonTintGradient, for: Keys.buttonTintGradient) } } + var buttonTintGradientAngle: Double { didSet { persist(buttonTintGradientAngle, for: Keys.buttonTintGradientAngle) } } + var buttonTintBlendMode: UI.Theme.ColorBlendMode { didSet { persist(buttonTintBlendMode.rawValue, for: Keys.buttonTintBlendMode) } } /// Material for cards, both compact and expanded. - var cardMaterial: UI.Theme.WindowMaterial { didSet { defaults.set(cardMaterial.rawValue, forKey: Keys.cardMaterial) } } + var cardMaterial: UI.Theme.WindowMaterial { didSet { persist(cardMaterial.rawValue, for: Keys.cardMaterial) } } /// Show the info.circle help popovers throughout the app. - var showInfoTips: Bool { didSet { defaults.set(showInfoTips, forKey: Keys.showInfoTips) } } + var showInfoTips: Bool { didSet { persist(showInfoTips, for: Keys.showInfoTips) } } /// Let images without their own style inherit the default card design edited in Settings. - var imageDefaultStyleEnabled: Bool { didSet { defaults.set(imageDefaultStyleEnabled, forKey: Keys.imageDefaultStyleEnabled) } } - var keepInMenuBar: Bool { didSet { defaults.set(keepInMenuBar, forKey: Keys.keepInMenuBar) } } - var cliPathOverride: String { didSet { defaults.set(cliPathOverride, forKey: Keys.cliPath) } } - var refreshInterval: Double { didSet { defaults.set(refreshInterval, forKey: Keys.refresh) } } + var imageDefaultStyleEnabled: Bool { didSet { persist(imageDefaultStyleEnabled, for: Keys.imageDefaultStyleEnabled) } } + var keepInMenuBar: Bool { didSet { persist(keepInMenuBar, for: Keys.keepInMenuBar) } } + var cliPathOverride: String { didSet { database.setRuntimePathOverride(cliPathOverride, for: .appleContainer) } } + var dockerCLIPathOverride: String { didSet { database.setRuntimePathOverride(dockerCLIPathOverride, for: .docker) } } + var refreshInterval: Double { didSet { persist(refreshInterval, for: Keys.refresh) } } var statsNormalizationMode: Core.Metrics.NormalizationMode { - didSet { defaults.set(statsNormalizationMode.rawValue, forKey: Keys.statsNormalizationMode) } + didSet { persist(statsNormalizationMode.rawValue, for: Keys.statsNormalizationMode) } } - var imageUpdateIntervalHours: Int { didSet { defaults.set(imageUpdateIntervalHours, forKey: Keys.imageUpdateIntervalHours) } } + var imageUpdateIntervalHours: Int { didSet { persist(imageUpdateIntervalHours, for: Keys.imageUpdateIntervalHours) } } /// Automation toggles (surfaced in System → Automation). Each gates a background task. - var imageUpdateChecksEnabled: Bool { didSet { defaults.set(imageUpdateChecksEnabled, forKey: Keys.imageUpdateChecksEnabled) } } - var appUpdateChecksEnabled: Bool { didSet { defaults.set(appUpdateChecksEnabled, forKey: Keys.appUpdateChecksEnabled) } } - var autoRestartEnabled: Bool { didSet { defaults.set(autoRestartEnabled, forKey: Keys.autoRestartEnabled) } } - var notifyOnCrash: Bool { didSet { defaults.set(notifyOnCrash, forKey: Keys.notifyOnCrash) } } + var imageUpdateChecksEnabled: Bool { didSet { persist(imageUpdateChecksEnabled, for: Keys.imageUpdateChecksEnabled) } } + var appUpdateChecksEnabled: Bool { didSet { persist(appUpdateChecksEnabled, for: Keys.appUpdateChecksEnabled) } } + var autoRestartEnabled: Bool { didSet { persist(autoRestartEnabled, for: Keys.autoRestartEnabled) } } + var notifyOnCrash: Bool { didSet { persist(notifyOnCrash, for: Keys.notifyOnCrash) } } /// Show "Reveal CLI" affordances on destructive/privileged actions (global gate). - var revealCLI: Bool { didSet { defaults.set(revealCLI, forKey: Keys.revealCLI) } } + var revealCLI: Bool { didSet { persist(revealCLI, for: Keys.revealCLI) } } /// How many days of metrics/events the on-disk history keeps before pruning. - var historyRetentionDays: Int { didSet { defaults.set(historyRetentionDays, forKey: Keys.historyRetention) } } + var historyRetentionDays: Int { didSet { persist(historyRetentionDays, for: Keys.historyRetention) } } /// App event logging verbosity. - var loggingLevel: AppLogLevel { didSet { defaults.set(loggingLevel.rawValue, forKey: Keys.loggingLevel) } } + var loggingLevel: AppLogLevel { didSet { persist(loggingLevel.rawValue, for: Keys.loggingLevel) } } /// Logging outputs. Activity history keeps events in-app; Console writes to macOS unified logging. var enabledLogDestinations: Set { - didSet { defaults.set(enabledLogDestinations.map(\.rawValue).sorted(), forKey: Keys.logDestinations) } + didSet { persist(enabledLogDestinations.map(\.rawValue).sorted(), for: Keys.logDestinations) } } /// Event categories the user wants recorded. var enabledLogCategories: Set { - didSet { defaults.set(enabledLogCategories.map(\.rawValue).sorted(), forKey: Keys.logCategories) } + didSet { persist(enabledLogCategories.map(\.rawValue).sorted(), for: Keys.logCategories) } } /// Which Sparkle update channel the user opts into (stable / beta / nightly). - var updateChannel: UpdateChannel { didSet { defaults.set(updateChannel.rawValue, forKey: Keys.updateChannel) } } + var updateChannel: UpdateChannel { didSet { persist(updateChannel.rawValue, for: Keys.updateChannel) } } // MARK: Experimental features // // Opt-in gates for surfaces that aren't fully baked yet. All default **off** so a fresh install @@ -65,24 +66,24 @@ final class SettingsStore { // regardless of any activation path. /// The `⌘K` command palette (toolbar search escalation + menu command + morph). - var commandPaletteEnabled: Bool { didSet { defaults.set(commandPaletteEnabled, forKey: Keys.commandPaletteEnabled) } } + var commandPaletteEnabled: Bool { didSet { persist(commandPaletteEnabled, for: Keys.commandPaletteEnabled) } } /// Inline Docker Hub / registry image search (the creation "Search" path + palette Hub scope). - var hubSearchEnabled: Bool { didSet { defaults.set(hubSearchEnabled, forKey: Keys.hubSearchEnabled) } } + var hubSearchEnabled: Bool { didSet { persist(hubSearchEnabled, for: Keys.hubSearchEnabled) } } /// Compose (YAML) import — paste, file pick, and drag-and-drop. - var composeImportEnabled: Bool { didSet { defaults.set(composeImportEnabled, forKey: Keys.composeImportEnabled) } } + var composeImportEnabled: Bool { didSet { persist(composeImportEnabled, for: Keys.composeImportEnabled) } } /// The Dockerfile image-build workspace. - var imageBuildEnabled: Bool { didSet { defaults.set(imageBuildEnabled, forKey: Keys.imageBuildEnabled) } } + var imageBuildEnabled: Bool { didSet { persist(imageBuildEnabled, for: Keys.imageBuildEnabled) } } /// Menu keyboard shortcuts and command shortcuts. Disabled by default. - var keyboardShortcutsEnabled: Bool { didSet { defaults.set(keyboardShortcutsEnabled, forKey: Keys.keyboardShortcutsEnabled) } } + var keyboardShortcutsEnabled: Bool { didSet { persist(keyboardShortcutsEnabled, for: Keys.keyboardShortcutsEnabled) } } /// Floating toolbar chrome. Off by default so the sidebar shell is the stable fresh-install path. - var experimentalToolbarUI: Bool { didSet { defaults.set(experimentalToolbarUI, forKey: Keys.experimentalToolbarUI) } } + var experimentalToolbarUI: Bool { didSet { persist(experimentalToolbarUI, for: Keys.experimentalToolbarUI) } } /// Route eligible actions through toolbar morph panels instead of classic pages/sheets. Depends on /// the floating toolbar so page routing never targets panels without visible toolbar origins. - var experimentalPanelNavigation: Bool { didSet { defaults.set(experimentalPanelNavigation, forKey: Keys.experimentalPanelNavigation) } } + var experimentalPanelNavigation: Bool { didSet { persist(experimentalPanelNavigation, for: Keys.experimentalPanelNavigation) } } var usesPanelNavigation: Bool { experimentalToolbarUI && experimentalPanelNavigation } /// Classic-shell sidebar visibility. Separate from the toolbar toggle so users can keep the /// stable content shell but reclaim width when they want a page-only layout. - var sidebarNavigationEnabled: Bool { didSet { defaults.set(sidebarNavigationEnabled, forKey: Keys.sidebarNavigationEnabled) } } + var sidebarNavigationEnabled: Bool { didSet { persist(sidebarNavigationEnabled, for: Keys.sidebarNavigationEnabled) } } /// Register/unregister the app as a login item via `SMAppService`. Backed by the live service /// status; failures (e.g. unsigned dev build) leave the stored value and the status governs. @@ -98,57 +99,60 @@ final class SettingsStore { } } - private let defaults: UserDefaults + private let database: AppDatabase - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - accentTint = UI.Theme.Tint(rawValue: defaults.string(forKey: Keys.tint) ?? "") ?? .multicolor - appearance = UI.Theme.Appearance(rawValue: defaults.string(forKey: Keys.appearance) ?? "") ?? .system - density = UI.Card.Density(stored: defaults.string(forKey: Keys.density)) - windowMaterial = UI.Theme.WindowMaterial(rawValue: defaults.string(forKey: Keys.windowMaterial) ?? "") ?? .fullScreenUI - modalMaterial = UI.Theme.WindowMaterial(rawValue: defaults.string(forKey: Keys.modalMaterial) ?? "") ?? .sheet - buttonMaterial = UI.Theme.WindowMaterial(rawValue: defaults.string(forKey: Keys.buttonMaterial) ?? "") ?? .glassClear - buttonTintEnabled = defaults.object(forKey: Keys.buttonTintEnabled) as? Bool ?? false - buttonTint = UI.Theme.Tint(rawValue: defaults.string(forKey: Keys.buttonTint) ?? "") ?? .multicolor - buttonTintOpacity = defaults.object(forKey: Keys.buttonTintOpacity) as? Double ?? 0.18 - buttonTintGradient = defaults.object(forKey: Keys.buttonTintGradient) as? Bool ?? true - buttonTintGradientAngle = defaults.object(forKey: Keys.buttonTintGradientAngle) as? Double ?? Personalization.defaultGradientAngle - buttonTintBlendMode = UI.Theme.ColorBlendMode(rawValue: defaults.string(forKey: Keys.buttonTintBlendMode) ?? "") ?? .softLight - cardMaterial = UI.Theme.WindowMaterial(rawValue: defaults.string(forKey: Keys.cardMaterial) ?? "") ?? .glassRegular - showInfoTips = defaults.object(forKey: Keys.showInfoTips) as? Bool ?? true - imageDefaultStyleEnabled = defaults.object(forKey: Keys.imageDefaultStyleEnabled) as? Bool ?? true - keepInMenuBar = defaults.object(forKey: Keys.keepInMenuBar) as? Bool ?? true - cliPathOverride = defaults.string(forKey: Keys.cliPath) ?? "" - refreshInterval = defaults.object(forKey: Keys.refresh) as? Double ?? 2.0 - statsNormalizationMode = Core.Metrics.NormalizationMode(rawValue: defaults.string(forKey: Keys.statsNormalizationMode) ?? "") ?? .container - imageUpdateIntervalHours = defaults.object(forKey: Keys.imageUpdateIntervalHours) as? Int ?? 6 - imageUpdateChecksEnabled = defaults.object(forKey: Keys.imageUpdateChecksEnabled) as? Bool ?? true - appUpdateChecksEnabled = defaults.object(forKey: Keys.appUpdateChecksEnabled) as? Bool ?? true - autoRestartEnabled = defaults.object(forKey: Keys.autoRestartEnabled) as? Bool ?? true - notifyOnCrash = defaults.object(forKey: Keys.notifyOnCrash) as? Bool ?? true - revealCLI = defaults.object(forKey: Keys.revealCLI) as? Bool ?? true - historyRetentionDays = defaults.object(forKey: Keys.historyRetention) as? Int ?? 7 - loggingLevel = AppLogLevel(rawValue: defaults.string(forKey: Keys.loggingLevel) ?? "") ?? .important - enabledLogDestinations = Self.loadSet(AppLogDestination.self, - key: Keys.logDestinations, - defaults: defaults, - fallback: [.activity]) - enabledLogCategories = Self.loadSet(AppLogCategory.self, - key: Keys.logCategories, - defaults: defaults, - fallback: Set(AppLogCategory.allCases)) + init(database: AppDatabase = AppDatabase()) { + self.database = database + _ = database.runtimeRecord(for: .appleContainer) + _ = database.runtimeRecord(for: .docker) + accentTint = UI.Theme.Tint(rawValue: database.setting(Keys.tint, fallback: "")) ?? .multicolor + appearance = UI.Theme.Appearance(rawValue: database.setting(Keys.appearance, fallback: "")) ?? .system + density = UI.Card.Density(stored: database.setting(Keys.density, fallback: "")) + windowMaterial = UI.Theme.WindowMaterial(rawValue: database.setting(Keys.windowMaterial, fallback: "")) ?? .fullScreenUI + modalMaterial = UI.Theme.WindowMaterial(rawValue: database.setting(Keys.modalMaterial, fallback: "")) ?? .sheet + buttonMaterial = UI.Theme.WindowMaterial(rawValue: database.setting(Keys.buttonMaterial, fallback: "")) ?? .glassClear + buttonTintEnabled = database.setting(Keys.buttonTintEnabled, fallback: false) + buttonTint = UI.Theme.Tint(rawValue: database.setting(Keys.buttonTint, fallback: "")) ?? .multicolor + buttonTintOpacity = database.setting(Keys.buttonTintOpacity, fallback: 0.18) + buttonTintGradient = database.setting(Keys.buttonTintGradient, fallback: true) + buttonTintGradientAngle = database.setting(Keys.buttonTintGradientAngle, fallback: Personalization.defaultGradientAngle) + buttonTintBlendMode = UI.Theme.ColorBlendMode(rawValue: database.setting(Keys.buttonTintBlendMode, fallback: "")) ?? .softLight + cardMaterial = UI.Theme.WindowMaterial(rawValue: database.setting(Keys.cardMaterial, fallback: "")) ?? .glassRegular + showInfoTips = database.setting(Keys.showInfoTips, fallback: true) + imageDefaultStyleEnabled = database.setting(Keys.imageDefaultStyleEnabled, fallback: true) + keepInMenuBar = database.setting(Keys.keepInMenuBar, fallback: true) + cliPathOverride = database.runtimePathOverride(for: .appleContainer) + dockerCLIPathOverride = database.runtimePathOverride(for: .docker) + refreshInterval = database.setting(Keys.refresh, fallback: 2.0) + statsNormalizationMode = Core.Metrics.NormalizationMode(rawValue: database.setting(Keys.statsNormalizationMode, fallback: "")) ?? .container + imageUpdateIntervalHours = database.setting(Keys.imageUpdateIntervalHours, fallback: 6) + imageUpdateChecksEnabled = database.setting(Keys.imageUpdateChecksEnabled, fallback: true) + appUpdateChecksEnabled = database.setting(Keys.appUpdateChecksEnabled, fallback: true) + autoRestartEnabled = database.setting(Keys.autoRestartEnabled, fallback: true) + notifyOnCrash = database.setting(Keys.notifyOnCrash, fallback: true) + revealCLI = database.setting(Keys.revealCLI, fallback: true) + historyRetentionDays = database.setting(Keys.historyRetention, fallback: 7) + loggingLevel = AppLogLevel(rawValue: database.setting(Keys.loggingLevel, fallback: "")) ?? .important + enabledLogDestinations = Self.decodeRawSet(AppLogDestination.self, + raw: database.setting(Keys.logDestinations, + fallback: [AppLogDestination.activity.rawValue]), + fallback: [.activity]) + enabledLogCategories = Self.decodeRawSet(AppLogCategory.self, + raw: database.setting(Keys.logCategories, + fallback: AppLogCategory.allCases.map(\.rawValue)), + fallback: Set(AppLogCategory.allCases)) // Default to Nightly while the app is pre-1.0 — that's where the only builds ship, so a fresh // install actually receives updates. Users can switch to Beta/Stable in Settings → Updates. - updateChannel = UpdateChannel(rawValue: defaults.string(forKey: Keys.updateChannel) ?? "") ?? .nightly + updateChannel = UpdateChannel(rawValue: database.setting(Keys.updateChannel, fallback: "")) ?? .nightly // Experimental features default off (opt-in). - commandPaletteEnabled = defaults.object(forKey: Keys.commandPaletteEnabled) as? Bool ?? false - hubSearchEnabled = defaults.object(forKey: Keys.hubSearchEnabled) as? Bool ?? false - composeImportEnabled = defaults.object(forKey: Keys.composeImportEnabled) as? Bool ?? false - imageBuildEnabled = defaults.object(forKey: Keys.imageBuildEnabled) as? Bool ?? false - keyboardShortcutsEnabled = defaults.object(forKey: Keys.keyboardShortcutsEnabled) as? Bool ?? false - experimentalToolbarUI = defaults.object(forKey: Keys.experimentalToolbarUI) as? Bool ?? false - experimentalPanelNavigation = defaults.object(forKey: Keys.experimentalPanelNavigation) as? Bool ?? false - sidebarNavigationEnabled = defaults.object(forKey: Keys.sidebarNavigationEnabled) as? Bool ?? true + commandPaletteEnabled = database.setting(Keys.commandPaletteEnabled, fallback: false) + hubSearchEnabled = database.setting(Keys.hubSearchEnabled, fallback: false) + composeImportEnabled = database.setting(Keys.composeImportEnabled, fallback: false) + imageBuildEnabled = database.setting(Keys.imageBuildEnabled, fallback: false) + keyboardShortcutsEnabled = database.setting(Keys.keyboardShortcutsEnabled, fallback: false) + experimentalToolbarUI = database.setting(Keys.experimentalToolbarUI, fallback: false) + experimentalPanelNavigation = database.setting(Keys.experimentalPanelNavigation, fallback: false) + sidebarNavigationEnabled = database.setting(Keys.sidebarNavigationEnabled, fallback: true) launchAtLogin = SMAppService.mainApp.status == .enabled } @@ -170,6 +174,7 @@ final class SettingsStore { imageDefaultStyleEnabled: imageDefaultStyleEnabled, keepInMenuBar: keepInMenuBar, cliPathOverride: cliPathOverride, + dockerCLIPathOverride: dockerCLIPathOverride, refreshInterval: refreshInterval, statsNormalizationMode: statsNormalizationMode, imageUpdateIntervalHours: imageUpdateIntervalHours, @@ -211,6 +216,7 @@ final class SettingsStore { imageDefaultStyleEnabled = snapshot.imageDefaultStyleEnabled keepInMenuBar = snapshot.keepInMenuBar cliPathOverride = snapshot.cliPathOverride + dockerCLIPathOverride = snapshot.dockerCLIPathOverride refreshInterval = snapshot.refreshInterval statsNormalizationMode = snapshot.statsNormalizationMode imageUpdateIntervalHours = snapshot.imageUpdateIntervalHours @@ -234,11 +240,14 @@ final class SettingsStore { sidebarNavigationEnabled = snapshot.sidebarNavigationEnabled } - private static func loadSet(_ type: T.Type, - key: String, - defaults: UserDefaults, - fallback: Set) -> Set where T.RawValue == String { - guard let raw = defaults.stringArray(forKey: key) else { return fallback } + private func persist(_ value: T, for key: String) { + database.setSetting(value, for: key) + } + + private static func decodeRawSet(_ type: T.Type, + raw: [String], + fallback: Set) -> Set where T.RawValue == String { + guard !raw.isEmpty else { return fallback } return Set(raw.compactMap { T(rawValue: $0) }) } @@ -259,7 +268,6 @@ final class SettingsStore { static let showInfoTips = "showInfoTips" static let imageDefaultStyleEnabled = "imageDefaultStyleEnabled" static let keepInMenuBar = "keepInMenuBar" - static let cliPath = "cliPathOverride" static let refresh = "refreshInterval" static let statsNormalizationMode = "statsNormalizationMode" static let imageUpdateIntervalHours = "imageUpdateIntervalHours" diff --git a/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift index e1682b8c..0bc1a0ce 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift @@ -26,7 +26,11 @@ struct AboutTab: View { } UI.Panel.Section(header: AppText.sectionSettingsRuntime) { - UI.Panel.Row(title: AppText.string("settings.about.containerCLI", defaultValue: "Container CLI")) { Text(app.cliVersion ?? "—").designSecondaryValueStyle() } + ForEach(app.availableRuntimeDescriptors, id: \.kind) { descriptor in + UI.Panel.Row(title: descriptor.displayName) { + Text(app.runtimeVersion(for: descriptor.kind) ?? "—").designSecondaryValueStyle() + } + } UI.Panel.Row(title: AppText.string("settings.about.apiServer", defaultValue: "API server")) { Text(app.systemStatus?.apiServerVersion ?? "—").designSecondaryValueStyle() } } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift index 604232cd..dbd8e84b 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift @@ -22,7 +22,7 @@ struct RegistriesTab: View { ForEach(app.registries) { login in UI.List.MetadataRow(systemImage: "key", title: login.host, - subtitle: login.username.map { AppText.string("settings.registries.username", defaultValue: "as \($0)") }) { + subtitle: registrySubtitle(login)) { Button("Log Out", role: .destructive) { loggingOut = login } } .contextMenu { @@ -53,8 +53,20 @@ struct RegistriesTab: View { private func logout(_ login: Core.Registry.Login) async { guard let client = app.client else { return } - do { _ = try await client.registryLogout(server: login.host); await app.refreshRegistries() } + do { + _ = try await client.registryLogout(server: login.host, runtimeKind: login.runtimeKind) + await app.refreshRegistries() + } catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } + + private func registrySubtitle(_ login: Core.Registry.Login) -> String { + let runtime = app.runtimeDescriptor(for: login.runtimeKind).displayName + if let username = login.username { + return AppText.string("settings.registries.usernameRuntime", + defaultValue: "\(runtime), as \(username)") + } + return runtime + } } diff --git a/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift index 8748e92c..cd751e47 100644 --- a/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift @@ -16,35 +16,32 @@ struct RuntimeTab: View { @State private var deletingDomain: String? var body: some View { + @Bindable var settings = app.settings LazyVStack(spacing: UI.Layout.Spacing.l) { - UI.Panel.Section(header: AppText.string("settings.runtime.kernel", defaultValue: "Kernel"), - footer: AppText.string("settings.runtime.kernel.footer", defaultValue: "Downloads and sets the recommended kernel as the default. May prompt for your administrator password - handled by the container CLI; Contained never sees it.")) { - UI.Panel.Row(title: AppText.string("settings.runtime.recommendedKernel", defaultValue: "Recommended kernel")) { - Button("Install…") { confirmingKernel = true } + UI.Panel.Section(header: AppText.string("settings.runtime.available", defaultValue: "Container runtimes"), + footer: AppText.runtimeSubtitle) { + ForEach(app.supportedRuntimeDescriptors, id: \.kind) { descriptor in + let reachable = app.availableRuntimeDescriptors.contains { $0.kind == descriptor.kind } + UI.Panel.Row(title: descriptor.displayName) { + Text(reachable ? AppText.string("settings.runtime.reachable", defaultValue: "Reachable") + : AppText.string("settings.runtime.notReachable", defaultValue: "Not reachable")) + .designSecondaryValueStyle() + } } - revealCLIHint("container system kernel set --recommended") } - UI.Panel.Section(header: AppText.string("settings.runtime.localDNSDomains", defaultValue: "Local DNS domains"), - footer: AppText.string("settings.runtime.localDNSDomains.footer", defaultValue: "Creating or deleting a domain may prompt for your administrator password - handled by the container CLI.")) { - if dnsDomains.isEmpty { - Text("No local DNS domains.") - .designSecondaryValueStyle() - .frame(maxWidth: .infinity, alignment: .leading) - } else { - ForEach(dnsDomains, id: \.self) { domain in - UI.List.MetadataRow(systemImage: "network", - title: domain, - isMonospaced: true) { - Button(role: .destructive) { deletingDomain = domain } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - } - } + UI.Panel.Section(header: AppText.string("settings.runtime.cliPaths", defaultValue: "Runtime paths"), + footer: AppText.string("settings.runtime.cliPaths.footer", defaultValue: "Path overrides are optional. Leave blank to use auto-detection; press Return after changing a path to reconnect.")) { + ForEach(app.supportedRuntimeDescriptors, id: \.kind) { descriptor in + runtimePathField(for: descriptor) } - Button("Add Domain…") { newDomain = ""; addingDNS = true } - .frame(maxWidth: .infinity, alignment: .leading) + } + + if app.appleRuntimeAvailable { + appleRuntimeControls + } + if app.supportedRuntimeDescriptors.contains(where: { $0.kind == .docker }) { + dockerRuntimeGuidance } if let props = app.properties { @@ -66,7 +63,7 @@ struct RuntimeTab: View { } } } - .task { await app.loadPropertiesIfNeeded(); await loadDNS() } + .task { await loadRuntimeDetails() } .confirmationDialog("Install the recommended kernel?", isPresented: $confirmingKernel) { Button("Download & install") { Task { await installKernel() } } } message: { @@ -85,6 +82,54 @@ struct RuntimeTab: View { } } + @ViewBuilder + private var appleRuntimeControls: some View { + UI.Panel.Section(header: AppText.string("settings.runtime.kernel", defaultValue: "Kernel"), + footer: AppText.string("settings.runtime.kernel.footer", defaultValue: "Downloads and sets the recommended kernel as the default. May prompt for your administrator password - handled by the container CLI; Contained never sees it.")) { + UI.Panel.Row(title: AppText.string("settings.runtime.recommendedKernel", defaultValue: "Recommended kernel")) { + Button("Install…") { confirmingKernel = true } + } + revealCLIHint("container system kernel set --recommended") + } + + UI.Panel.Section(header: AppText.string("settings.runtime.localDNSDomains", defaultValue: "Local DNS domains"), + footer: AppText.string("settings.runtime.localDNSDomains.footer", defaultValue: "Creating or deleting a domain may prompt for your administrator password - handled by the container CLI.")) { + if dnsDomains.isEmpty { + Text("No local DNS domains.") + .designSecondaryValueStyle() + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ForEach(dnsDomains, id: \.self) { domain in + UI.List.MetadataRow(systemImage: "network", + title: domain, + isMonospaced: true) { + Button(role: .destructive) { deletingDomain = domain } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + } + } + Button("Add Domain…") { newDomain = ""; addingDNS = true } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var dockerRuntimeGuidance: some View { + UI.Panel.Section(header: AppText.string("settings.runtime.dockerEndpoint", defaultValue: "Docker endpoint"), + footer: AppText.string("settings.runtime.dockerEndpoint.footer", defaultValue: "Contained talks to the Docker CLI and its configured endpoint. If Docker is not reachable, start Docker externally and retry.")) { + UI.Panel.Row(title: AppText.string("settings.runtime.endpointStatus", defaultValue: "Endpoint")) { + Text(app.availableRuntimeDescriptors.contains(where: { $0.kind == .docker }) ? AppText.string("settings.runtime.endpointReachable", defaultValue: "Reachable") + : AppText.string("settings.runtime.endpointUnavailable", defaultValue: "Unavailable")) + .designSecondaryValueStyle() + } + Button(AppText.string("common.retry", defaultValue: "Retry")) { + Task { await app.retryBootstrap() } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + /// A small copyable CLI hint, shown only when the Reveal-CLI setting is on. @ViewBuilder private func revealCLIHint(_ command: String) -> some View { @@ -105,28 +150,99 @@ struct RuntimeTab: View { Binding(get: { deletingDomain != nil }, set: { if !$0 { deletingDomain = nil } }) } + private func runtimePathField(for descriptor: Core.Runtime.Descriptor) -> some View { + UI.Panel.Field(label: runtimePathLabel(for: descriptor), + info: runtimePathInfo(for: descriptor)) { + TextField("", text: runtimePathBinding(for: descriptor.kind), + prompt: Text(defaultPathPrompt(for: descriptor))) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await app.retryBootstrap() } } + } + } + + private func runtimePathLabel(for descriptor: Core.Runtime.Descriptor) -> String { + switch descriptor.kind { + case .appleContainer: + return AppText.string("settings.runtime.path.appleContainer", defaultValue: "Apple container CLI path") + case .docker: + return AppText.string("settings.runtime.path.docker", defaultValue: "Docker CLI path") + default: + return "\(descriptor.displayName) CLI path" + } + } + + private func runtimePathInfo(for descriptor: Core.Runtime.Descriptor) -> String { + switch descriptor.kind { + case .appleContainer: + return AppText.string("settings.runtime.path.info.appleContainer", + defaultValue: "Override the auto-detected container binary location.") + case .docker: + return AppText.string("settings.runtime.path.info.docker", + defaultValue: "Override the auto-detected docker binary location.") + default: + let executable = descriptor.executableName ?? descriptor.displayName + return "Override the auto-detected \(executable) binary location." + } + } + + private func runtimePathBinding(for kind: Core.Runtime.Kind) -> Binding { + Binding { + switch kind { + case .appleContainer: app.settings.cliPathOverride + case .docker: app.settings.dockerCLIPathOverride + default: "" + } + } set: { value in + switch kind { + case .appleContainer: app.settings.cliPathOverride = value + case .docker: app.settings.dockerCLIPathOverride = value + default: break + } + } + } + + private func defaultPathPrompt(for descriptor: Core.Runtime.Descriptor) -> String { + switch descriptor.kind { + case .appleContainer: return "/usr/local/bin/container" + case .docker: return "/usr/local/bin/docker" + default: return descriptor.executableName.map { "/usr/local/bin/\($0)" } ?? "" + } + } + + private func loadRuntimeDetails(force: Bool = false) async { + if force { + dnsDomains = [] + await app.reloadProperties() + } else { + await app.loadPropertiesIfNeeded() + } + if app.appleRuntimeAvailable { + await loadDNS() + } + } + private func loadDNS() async { - guard let client = app.client else { return } - if let domains = try? await client.dnsDomains() { dnsDomains = domains } + guard app.appleRuntimeAvailable, let client = app.client else { return } + if let domains = try? await client.dnsDomains(runtimeKind: .appleContainer) { dnsDomains = domains } } private func installKernel() async { - guard let client = app.client else { return } - if let error = await app.captured({ _ = try await client.setRecommendedKernel() }) { app.flash(error) } + guard app.appleRuntimeAvailable, let client = app.client else { return } + if let error = await app.captured({ _ = try await client.setRecommendedKernel(runtimeKind: .appleContainer) }) { app.flash(error) } else { app.flash(AppText.recommendedKernelInstalled); await app.reloadProperties() } } private func addDNS() async { let domain = newDomain.trimmingCharacters(in: .whitespaces) newDomain = "" - guard !domain.isEmpty, let client = app.client else { return } - if let error = await app.captured({ _ = try await client.createDNSDomain(domain) }) { app.flash(error) } + guard app.appleRuntimeAvailable, !domain.isEmpty, let client = app.client else { return } + if let error = await app.captured({ _ = try await client.createDNSDomain(domain, runtimeKind: .appleContainer) }) { app.flash(error) } else { await loadDNS() } } private func deleteDNS(_ domain: String) async { - guard let client = app.client else { return } - if let error = await app.captured({ _ = try await client.deleteDNSDomain(domain) }) { app.flash(error) } + guard app.appleRuntimeAvailable, let client = app.client else { return } + if let error = await app.captured({ _ = try await client.deleteDNSDomain(domain, runtimeKind: .appleContainer) }) { app.flash(error) } else { await loadDNS() } } } diff --git a/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift b/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift index deca2606..3bd23680 100644 --- a/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift +++ b/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift @@ -22,8 +22,8 @@ struct SystemLogsSheet: View { }) } .padding(UI.Layout.Spacing.s) - if let client = app.client { - UI.Console.Stream(stream: { client.streamSystemLogs(follow: follow, last: 500) }, + if let client = app.client, app.appleRuntimeAvailable { + UI.Console.Stream(stream: { client.streamSystemLogs(follow: follow, last: 500, runtimeKind: .appleContainer) }, workingLabel: AppText.working, completedLabel: AppText.completed, lineCountLabel: AppText.lineCount, diff --git a/Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift b/Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift index e5275e48..06935a14 100644 --- a/Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift +++ b/Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift @@ -78,7 +78,7 @@ enum SystemVolumeInventory { private static func merging(_ existing: Entry, with incoming: Entry) -> Entry { var containers = existing.containers - for snapshot in incoming.containers where !containers.contains(where: { $0.id == snapshot.id }) { + for snapshot in incoming.containers where !containers.contains(where: { $0.scopedID == snapshot.scopedID }) { containers.append(snapshot) } return Entry(id: existing.id, @@ -129,7 +129,7 @@ enum SystemVolumeInventory { guard destination != nil || type == "tmpfs" else { return nil } let title = destination ?? "anonymous mount" - return Entry(id: "anon:\(snapshot.id):\(title)", + return Entry(id: "anon:\(snapshot.scopedID):\(title)", kind: .anonymous, title: title, subtitle: typeLabel(type), diff --git a/Sources/ContainedApp/Features/System/SystemView.swift b/Sources/ContainedApp/Features/System/SystemView.swift index f740783b..899d0e63 100644 --- a/Sources/ContainedApp/Features/System/SystemView.swift +++ b/Sources/ContainedApp/Features/System/SystemView.swift @@ -22,14 +22,14 @@ struct SystemContent: View { @State private var page: SystemPage enum SystemPage: String, CaseIterable, Identifiable { - case engine = "Engine" + case runtime = "Runtime" case automation = "Automation" case volumes = "Volumes" var id: String { rawValue } var systemImage: String { switch self { - case .engine: return "server.rack" + case .runtime: return "server.rack" case .automation: return "clock.arrow.circlepath" case .volumes: return "externaldrive" } @@ -37,7 +37,7 @@ struct SystemContent: View { var subtitle: String { switch self { - case .engine: return AppText.string("system.page.engine.subtitle", defaultValue: "Container engine") + case .runtime: return AppText.string("system.page.runtime.subtitle", defaultValue: "Container runtime") case .automation: return AppText.string("system.page.automation.subtitle", defaultValue: "Background work") case .volumes: return AppText.string("system.page.volumes.subtitle", defaultValue: "Named, temp, and path mounts") } @@ -45,7 +45,7 @@ struct SystemContent: View { var title: String { switch self { - case .engine: return AppText.string("system.page.engine", defaultValue: "Engine") + case .runtime: return AppText.string("system.page.runtime", defaultValue: "Runtime") case .automation: return AppText.string("system.page.automation", defaultValue: "Automation") case .volumes: return AppText.sectionVolumes } @@ -68,7 +68,7 @@ struct SystemContent: View { } } - init(initialPage: SystemPage = .engine, + init(initialPage: SystemPage = .runtime, showClose: Bool = true, elevated: Bool = true, usesToolbarSelection: Bool = true, @@ -106,7 +106,7 @@ struct SystemContent: View { } content: { LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.l) { switch activePage { - case .engine: engineStatusCard + case .runtime: runtimeStatusCard case .automation: automationCard case .volumes: volumesCard } @@ -140,7 +140,7 @@ struct SystemContent: View { title: AppText.sectionSystem, subtitle: activePage.subtitle) { HStack(spacing: UI.Toolbar.Spacing.groupSpacing) { - engineControls + runtimeControls UI.Action.Cluster { UI.Action.Items(pageActions) storageMenu @@ -165,15 +165,24 @@ struct SystemContent: View { } } - private var engineControls: some View { - UI.Action.Group([ - servicePowerAction, - UI.Action.Item(systemName: "arrow.clockwise", - help: AppText.restartService, - isEnabled: !working) { - run { await app.restartService() } - } - ]) + @ViewBuilder + private var runtimeControls: some View { + if app.appleRuntimeAvailable { + UI.Action.Group([ + servicePowerAction, + UI.Action.Item(systemName: "arrow.clockwise", + help: AppText.restartService, + isEnabled: !working) { + run { await app.restartService() } + } + ]) + } else { + UI.Action.Group(UI.Action.Item(systemName: "arrow.clockwise", + help: AppText.string("common.retry", defaultValue: "Retry"), + isEnabled: !working) { + run { await app.retryBootstrap() } + }) + } } private var servicePowerAction: UI.Action.Item { @@ -290,7 +299,9 @@ struct SystemContent: View { private func deleteVolume(_ volume: Core.Volume.Resource) async { guard let client = app.client else { return } - if let error = await app.captured({ _ = try await client.deleteVolumes([volume.name]) }) { app.flash(error) } + if let error = await app.captured({ + _ = try await client.deleteVolumes([volume.name], runtimeKind: volume.runtimeKind) + }) { app.flash(error) } await app.refreshVolumes() } @@ -396,10 +407,22 @@ struct SystemContent: View { guard let client = app.client else { return } do { switch target { - case .containers: _ = try await client.pruneContainers() - case .images: _ = try await client.pruneImages(all: false) - case .volumes: _ = try await client.pruneVolumes() - case .networks: _ = try await client.pruneNetworks() + case .containers: + for descriptor in app.availableRuntimeDescriptors where descriptor.supports(.containers) { + _ = try await client.pruneContainers(runtimeKind: descriptor.kind) + } + case .images: + for descriptor in app.availableRuntimeDescriptors where descriptor.supports(.images) { + _ = try await client.pruneImages(all: false, runtimeKind: descriptor.kind) + } + case .volumes: + for descriptor in app.availableRuntimeDescriptors where descriptor.supports(.volumes) { + _ = try await client.pruneVolumes(runtimeKind: descriptor.kind) + } + case .networks: + for descriptor in app.availableRuntimeDescriptors where descriptor.supports(.networks) { + _ = try await client.pruneNetworks(runtimeKind: descriptor.kind) + } } await app.refreshSystemResources() await app.refreshSystem() @@ -410,10 +433,18 @@ struct SystemContent: View { private func reclaimAll() async { guard let client = app.client else { return } if let error = await app.captured({ - _ = try await client.pruneContainers() - _ = try await client.pruneImages(all: false) - _ = try await client.pruneVolumes() - _ = try await client.pruneNetworks() + for descriptor in app.availableRuntimeDescriptors where descriptor.supports(.containers) { + _ = try await client.pruneContainers(runtimeKind: descriptor.kind) + } + for descriptor in app.availableRuntimeDescriptors where descriptor.supports(.images) { + _ = try await client.pruneImages(all: false, runtimeKind: descriptor.kind) + } + for descriptor in app.availableRuntimeDescriptors where descriptor.supports(.volumes) { + _ = try await client.pruneVolumes(runtimeKind: descriptor.kind) + } + for descriptor in app.availableRuntimeDescriptors where descriptor.supports(.networks) { + _ = try await client.pruneNetworks(runtimeKind: descriptor.kind) + } }) { app.flash(error) } await app.refreshSystemResources() await app.refreshSystem() @@ -421,12 +452,12 @@ struct SystemContent: View { // MARK: Runtime - private var engineStatusCard: some View { + private var runtimeStatusCard: some View { card { HStack(spacing: UI.Layout.Spacing.s) { UI.Badge.Dot(color: app.serviceHealthy ? .green : .orange, size: UI.Control.Size.serviceDot) - Text(AppText.string("system.containerEngine", defaultValue: "Container engine")).designHeadlineLabelStyle() + Text(AppText.string("system.containerRuntime", defaultValue: "Container runtime")).designHeadlineLabelStyle() UI.Badge.Status(text: app.serviceLabel, tint: app.serviceHealthy ? .green : .orange) Spacer(minLength: 0) diff --git a/Sources/ContainedApp/Migration/StateMigrator.swift b/Sources/ContainedApp/Migration/StateMigrator.swift index 694e45b3..a05f3824 100644 --- a/Sources/ContainedApp/Migration/StateMigrator.swift +++ b/Sources/ContainedApp/Migration/StateMigrator.swift @@ -2,7 +2,7 @@ import Foundation struct StateMigrator { static let currentSchemaVersion = 1 - static let schemaVersionKey = "contained.state.schemaVersion" + static let schemaVersionSettingKey = "app.schemaVersion" private var steps: [any MigrationStep] = [] @@ -15,12 +15,11 @@ struct StateMigrator { case newerOnDisk(Int) } - func reconcile(defaults: UserDefaults = .standard) -> ReconcileResult { - let stored = defaults.object(forKey: Self.schemaVersionKey) as? Int ?? Self.currentSchemaVersion + func reconcile(storedVersion: Int?) -> ReconcileResult { + let stored = storedVersion ?? Self.currentSchemaVersion if stored > Self.currentSchemaVersion { return .newerOnDisk(stored) } - defaults.set(Self.currentSchemaVersion, forKey: Self.schemaVersionKey) return .ready } diff --git a/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift b/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift index c0b43cbe..cfe727fe 100644 --- a/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift +++ b/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift @@ -16,7 +16,7 @@ struct MenuBarContent: View { private var cliLabel: String { switch app.bootstrap { case .ready: - return app.cliVersion.map { "CLI v\($0)" } ?? "CLI ready" + return app.runtimeVersion(for: .appleContainer).map { "CLI v\($0)" } ?? "CLI ready" case .checking: return "Checking CLI" case .cliMissing: @@ -45,12 +45,16 @@ struct MenuBarContent: View { Menu("Service") { statusItem Divider() - if app.serviceHealthy { - Button("Stop Service") { Task { await app.stopService() } } + if app.appleRuntimeAvailable { + if app.serviceHealthy { + Button("Stop Service") { Task { await app.stopService() } } + } else { + Button("Start Service") { Task { await app.startService() } } + } + Button("Restart Service") { Task { await app.restartService() } } } else { - Button("Start Service") { Task { await app.startService() } } + Button("Retry Docker Connection") { Task { await app.retryBootstrap() } } } - Button("Restart Service") { Task { await app.restartService() } } } Menu("Containers") { @@ -60,7 +64,7 @@ struct MenuBarContent: View { } else { ForEach(store.running) { snapshot in Button(containerName(for: snapshot)) { - Task { await store.stop(snapshot.id) } + Task { await store.stop(snapshot.scopedID) } } } } @@ -72,7 +76,7 @@ struct MenuBarContent: View { } else { ForEach(stopped) { snapshot in Button(containerName(for: snapshot)) { - Task { await store.start(snapshot.id) } + Task { await store.start(snapshot.scopedID) } } } } @@ -269,7 +273,7 @@ struct MenuBarContent: View { /// Reveal the resolved `container` binary in Finder (honoring the CLI-path override). private func revealCLIBinary() { - guard let url = app.client?.cliURL else { return } + guard let url = app.runtimeCLIURL(for: .appleContainer) else { return } NSWorkspace.shared.activateFileViewerSelecting([url]) } } diff --git a/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift b/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift index ac26df3b..74ca2f5a 100644 --- a/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift +++ b/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift @@ -338,7 +338,7 @@ private struct NetworksPage: View { private func deleteNetwork(_ network: Core.Network.Resource) async { guard let client = app.client else { return } do { - _ = try await client.deleteNetworks([network.name]) + _ = try await client.deleteNetworks([network.name], runtimeKind: network.runtimeKind) await app.refreshNetworks() } catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) diff --git a/Sources/ContainedApp/Navigation/Shell/RootView.swift b/Sources/ContainedApp/Navigation/Shell/RootView.swift index 648033b4..90bff3ef 100644 --- a/Sources/ContainedApp/Navigation/Shell/RootView.swift +++ b/Sources/ContainedApp/Navigation/Shell/RootView.swift @@ -41,6 +41,9 @@ struct RootView: View { html: app.updater.currentReleaseNotesHTML, onClose: { app.updater.markWhatsNewSeen() }) } + .sheet(item: runtimeSelectionBinding) { request in + RuntimeSelectionSheet(request: request) + } // Dispatch global actions from toolbar panels, pages, menus, and the command palette. Registry // credentials always live in Settings. .onChange(of: ui.pendingAction) { _, action in @@ -67,7 +70,9 @@ struct RootView: View { case "yaml", "yml": guard app.settings.composeImportEnabled else { continue } ComposeImport.importFile(at: url, app: app, ui: ui); return true - case "tar": app.loadImageTar(at: url); return true + case "tar": + loadImageTar(at: url) + return true default: continue } } @@ -177,6 +182,11 @@ struct RootView: View { set: { if !$0 { app.updater.markWhatsNewSeen() } }) } + private var runtimeSelectionBinding: Binding { + Binding(get: { ui.runtimeSelectionRequest }, + set: { ui.runtimeSelectionRequest = $0 }) + } + /// The page-overflow menu, shown by right-clicking the background. @ViewBuilder private func backgroundMenu() -> some View { @@ -234,12 +244,29 @@ struct RootView: View { panel.allowedContentTypes = [.init(filenameExtension: "tar") ?? .data] panel.message = AppText.chooseImageTarArchive guard panel.runModal() == .OK, let url = panel.url else { return } - app.loadImageTar(at: url) + loadImageTar(at: url) + } + + private func loadImageTar(at url: URL) { + let descriptors = app.availableRuntimeDescriptors.filter { $0.supports(.imageArchive) } + guard let first = descriptors.first else { + app.flash(AppText.containerRuntimeNotReady) + return + } + guard descriptors.count > 1 else { + app.loadImageTar(at: url, runtimeKind: first.kind) + return + } + ui.runtimeSelectionRequest = .imageArchive(url) } private func pruneImages(all: Bool) async { guard let client = app.client else { return } - if let error = await app.captured({ _ = try await client.pruneImages(all: all) }) { app.flash(error) } + if let error = await app.captured({ + for descriptor in app.availableRuntimeDescriptors where descriptor.supports(.images) { + _ = try await client.pruneImages(all: all, runtimeKind: descriptor.kind) + } + }) { app.flash(error) } await app.refreshImagesIfNeeded(force: true) } diff --git a/Sources/ContainedApp/Navigation/State/UIState.swift b/Sources/ContainedApp/Navigation/State/UIState.swift index 7f1c9c2a..4031aef7 100644 --- a/Sources/ContainedApp/Navigation/State/UIState.swift +++ b/Sources/ContainedApp/Navigation/State/UIState.swift @@ -39,10 +39,28 @@ final class UIState { var queue: [ContainerFormState] = [] } + enum RuntimeSelectionRequest: Identifiable, Hashable { + case composeFile(URL) + case composeText(text: String, projectName: String, baseDirectory: URL?) + case imageArchive(URL) + + var id: String { + switch self { + case .composeFile(let url): + return "compose-file-\(url.absoluteString)" + case .composeText(let text, let projectName, let baseDirectory): + return "compose-text-\(projectName)-\(baseDirectory?.absoluteString ?? "none")-\(text.hashValue)" + case .imageArchive(let url): + return "image-archive-\(url.absoluteString)" + } + } + } + var creation = CreationPresentation() var toolbar = ToolbarPresentation() var search = SearchPresentation() var prefill = PrefillPresentation() + var runtimeSelectionRequest: RuntimeSelectionRequest? var runningOnly = false var selectedSection: AppSection = .containers var sidebarVisible = true @@ -60,7 +78,7 @@ final class UIState { var networkSort: NetworkSort = .name var networkFilter: NetworkFilter = .all var activityFilter: EventKind? = nil - var systemPage: SystemContent.SystemPage = .engine + var systemPage: SystemContent.SystemPage = .runtime /// When set, `SettingsContent` will switch to this page as soon as it appears / becomes active. /// Cleared by `SettingsContent` after it consumes the value. @@ -244,10 +262,12 @@ final class UIState { } func runImage(_ reference: String, + runtimeKind: Core.Runtime.Kind? = nil, returningTo returnEntry: CreationEntry? = nil, searchQuery: String = "") { var spec = ContainerFormState() spec.image = reference + if let runtimeKind { spec.runtimeKind = runtimeKind } guard panelNavigationEnabled else { presentCreate(spec) return @@ -278,7 +298,7 @@ final class UIState { guard let first = specs.first else { return } prefill.queue = Array(specs.dropFirst()) Task { - for spec in specs { _ = await app.ensureImage(spec.image) } + for spec in specs { _ = await app.ensureImage(spec.image, runtimeKind: spec.effectiveRuntimeKind) } presentNextPrefill(first) } } diff --git a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift index e34311ea..192282f6 100644 --- a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift @@ -310,12 +310,14 @@ struct ToolbarCommandPalette: View { } private func hubItems() -> [PaletteItem] { + guard app.availableRuntimeDescriptors.count == 1, + let runtimeKind = app.availableRuntimeDescriptors.first?.kind else { return [] } if trimmedQuery.isEmpty { return RecommendedImage.all.map { rec in PaletteItem(title: AppText.paletteRunImage(rec.name), subtitle: rec.reference, keywords: [rec.reference], kind: .image, icon: rec.symbol, tint: .accentColor) { - ui.runImage(rec.reference, returningTo: .search) + ui.runImage(rec.reference, runtimeKind: runtimeKind, returningTo: .search) } } } @@ -330,7 +332,10 @@ struct ToolbarCommandPalette: View { kind: .image, icon: result.isOfficial ? "checkmark.seal.fill" : "shippingbox", tint: .accentColor) { - ui.runImage(result.pullReference, returningTo: .search, searchQuery: query) + ui.runImage(result.pullReference, + runtimeKind: runtimeKind, + returningTo: .search, + searchQuery: query) } } } @@ -340,14 +345,18 @@ struct ToolbarCommandPalette: View { let matched = trimmedQuery.isEmpty ? groups : groups.filter { PaletteSearch.score(query: trimmedQuery, in: $0.references + [Format.shortImage($0.primaryReference)]) != nil } - return matched.map { group in - PaletteItem(title: AppText.paletteRunImage(Format.shortImage(group.primaryReference)), - subtitle: AppText.paletteTagCountSubtitle(group.references.count), - keywords: group.references, - kind: .image, - visual: .imageGroup(group), - icon: "play.fill", tint: .green) { - ui.runImage(group.primaryReference, returningTo: .chooser) + return matched.compactMap { group in + guard let runtimeKind = group.images.first(where: { $0.reference == group.primaryReference })?.runtimeKind + ?? group.tags.first?.runtimeKind else { return nil } + return PaletteItem(title: AppText.paletteRunImage(Format.shortImage(group.primaryReference)), + subtitle: AppText.paletteTagCountSubtitle(group.tags.count), + keywords: group.references, + kind: .image, + visual: .imageGroup(group), + icon: "play.fill", tint: .green) { + ui.runImage(group.primaryReference, + runtimeKind: runtimeKind, + returningTo: .chooser) } } } diff --git a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift index 9eb7ff35..dd6a4e3a 100644 --- a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift @@ -13,7 +13,7 @@ struct ToolbarImageGroupCard: View { var onTap: () -> Void var onClose: () -> Void - @State private var deletingReference: String? + @State private var deletingTag: Core.Image.LocalTag? @State private var pruning = false /// Detailed image pages grow the image-detail morph in place, matching container cards: tags are /// the default body page, while history/tag/push reuse the same card shell. @@ -58,10 +58,10 @@ struct ToolbarImageGroupCard: View { rootCard } } - .confirmationDialog("Delete \(Format.shortImage(deletingReference ?? ""))?", + .confirmationDialog("Delete \(Format.shortImage(deletingTag?.reference ?? ""))?", isPresented: deletingBinding, - presenting: deletingReference) { reference in - Button("Delete", role: .destructive) { Task { await delete(reference) } } + presenting: deletingTag) { tag in + Button("Delete", role: .destructive) { Task { await delete(tag) } } } message: { _ in Text("This removes the selected local image reference.") } .confirmationDialog("Prune images?", isPresented: $pruning) { Button("Remove unused", role: .destructive) { Task { await prune(all: false) } } @@ -202,8 +202,11 @@ struct ToolbarImageGroupCard: View { private func pushPage(_ reference: String) -> some View { imagePageBody(title: AppText.string("image.pushImage", defaultValue: "Push image"), subtitle: Format.shortImage(reference)) { - if pushStartedReference == reference, let client = app.client { - UI.Console.Stream(stream: { client.streamPush(reference) }, + if pushStartedReference == reference, + let client = app.client, + let runtimeKind = pushRuntimeKind(for: reference) { + UI.Console.Stream(stream: { client.streamPush(reference, + runtimeKind: runtimeKind) }, workingLabel: AppText.working, completedLabel: AppText.completed, lineCountLabel: AppText.lineCount, @@ -334,13 +337,16 @@ struct ToolbarImageGroupCard: View { } private func submitTag(source: String) { - guard let client = app.client else { return } + guard let client = app.client, + let runtimeKind = pushRuntimeKind(for: source) else { return } let target = tagTarget.trimmingCharacters(in: .whitespaces) guard !target.isEmpty else { return } tagBusy = true Task { do { - _ = try await client.tagImage(source: source, target: target) + _ = try await client.tagImage(source: source, + target: target, + runtimeKind: runtimeKind) await app.refreshImagesIfNeeded(force: true) tagBusy = false tagTarget = "" @@ -411,22 +417,29 @@ struct ToolbarImageGroupCard: View { UI.Card.FooterMini { UI.Symbol.Image(systemName: "tag", size: .caption) } text: { - UI.Card.MetricText(text: "\(group.references.count)") + UI.Card.MetricText(text: "\(group.tags.count)") } } @ViewBuilder private func imageFooterActions(_ group: Core.Image.LocalTagGroup) -> some View { - footerAction("play", help: AppText.run) { - ui.runImage(group.primaryReference) - if isExpanded { onClose() } + if let runtimeKind = runRuntimeKind(for: group) { + footerAction("play", help: AppText.run) { + ui.runImage(group.primaryReference, + runtimeKind: runtimeKind) + if isExpanded { onClose() } + } } footerAction("arrow.triangle.2.circlepath", help: AppText.checkForUpdates) { Task { await app.checkImageUpdate(group.primaryReference) } } - if app.imageUpdateStatus(for: group.primaryReference).state == .updateAvailable { + if app.imageUpdateStatus(for: group.primaryReference).state == .updateAvailable, + let runtimeKind = primaryImage(group)?.runtimeKind { footerAction("arrow.down.circle", help: AppText.pullUpdate, tint: .orange) { - Task { await app.pullImageUpdate(group.primaryReference) } + Task { + await app.pullImageUpdate(group.primaryReference, + runtimeKind: runtimeKind) + } } } if let image = primaryImage(group) { @@ -442,8 +455,8 @@ struct ToolbarImageGroupCard: View { .padding(.leading, UI.Layout.Spacing.xs) ScrollView(.vertical) { LazyVStack(spacing: UI.Layout.Spacing.s) { - ForEach(group.references, id: \.self) { reference in - tagRow(reference, in: group) + ForEach(group.tags) { tag in + tagRow(tag, in: group) .frame(maxWidth: .infinity) } } @@ -453,7 +466,8 @@ struct ToolbarImageGroupCard: View { } } - private func tagRow(_ reference: String, in group: Core.Image.LocalTagGroup) -> some View { + private func tagRow(_ tag: Core.Image.LocalTag, in group: Core.Image.LocalTagGroup) -> some View { + let reference = tag.reference let style = app.imageStyle(for: reference) return UI.Card.Scaffold(size: .medium, fill: style.fillBackground ? style.color : nil, @@ -478,31 +492,32 @@ struct ToolbarImageGroupCard: View { EmptyView() } footerLeading: { UI.Card.FooterMini { - UI.Symbol.Image(systemName: "tag", size: .caption2) + UI.Symbol.Image(systemName: "cpu", size: .caption2) } text: { - UI.Card.MetricText(text: "Local tag") + UI.Card.MetricText(text: app.runtimeDescriptor(for: tag.runtimeKind).displayName) } } footerActions: { footerAction("play", help: AppText.run) { - ui.runImage(reference) + ui.runImage(reference, runtimeKind: tag.runtimeKind) if isExpanded { onClose() } } footerAction("doc.on.doc", help: AppText.copyReference) { copyToPasteboard(reference) } - footerAction("trash", help: AppText.deleteTag, role: .destructive) { deletingReference = reference } + footerAction("trash", help: AppText.deleteTag, role: .destructive) { deletingTag = tag } } widget: { EmptyView() } - .contextMenu { tagMenu(reference, in: group) } + .contextMenu { tagMenu(tag, in: group) } } /// Right-click actions for a single tag — mirrors the footer buttons so the row is consistent with /// the group card (which has its own context menu). @ViewBuilder - private func tagMenu(_ reference: String, in group: Core.Image.LocalTagGroup) -> some View { - Button { ui.runImage(reference); if isExpanded { onClose() } } label: { Label("Run…", systemImage: "play") } + private func tagMenu(_ tag: Core.Image.LocalTag, in group: Core.Image.LocalTagGroup) -> some View { + let reference = tag.reference + Button { ui.runImage(reference, runtimeKind: tag.runtimeKind); if isExpanded { onClose() } } label: { Label("Run…", systemImage: "play") } Button { copyToPasteboard(reference) } label: { Label("Copy reference", systemImage: "doc.on.doc") } Divider() - Button(role: .destructive) { deletingReference = reference } label: { Label("Delete tag", systemImage: "trash") } + Button(role: .destructive) { deletingTag = tag } label: { Label("Delete tag", systemImage: "trash") } } private func footerAction(_ systemName: String, help: String, tint: Color? = nil, @@ -517,7 +532,12 @@ struct ToolbarImageGroupCard: View { @ViewBuilder private func cardMenu(_ group: Core.Image.LocalTagGroup) -> some View { - Button { ui.runImage(group.primaryReference) } label: { Label("Run…", systemImage: "play") } + if let runtimeKind = runRuntimeKind(for: group) { + Button { + ui.runImage(group.primaryReference, + runtimeKind: runtimeKind) + } label: { Label("Run…", systemImage: "play") } + } if let image = primaryImage(group) { // History / Tag / Push grow the detail morph into a sub-page, so they're offered only // from the expanded detail (a collapsed card opens the detail first). @@ -534,13 +554,19 @@ struct ToolbarImageGroupCard: View { Button { Task { await app.checkImageUpdate(group.primaryReference) } } label: { Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath") } - if app.imageUpdateStatus(for: group.primaryReference).state == .updateAvailable { - Button { Task { await app.pullImageUpdate(group.primaryReference) } } label: { + if app.imageUpdateStatus(for: group.primaryReference).state == .updateAvailable, + let runtimeKind = primaryImage(group)?.runtimeKind { + Button { + Task { + await app.pullImageUpdate(group.primaryReference, + runtimeKind: runtimeKind) + } + } label: { Label("Pull Update", systemImage: "arrow.down.circle") } } Divider() - Button(role: .destructive) { deletingReference = group.primaryReference } label: { + Button(role: .destructive) { deletingTag = primaryTag(group) } label: { Label("Delete Primary Tag", systemImage: "trash") } } @@ -605,7 +631,7 @@ struct ToolbarImageGroupCard: View { } private var deletingBinding: Binding { - Binding(get: { deletingReference != nil }, set: { if !$0 { deletingReference = nil } }) + Binding(get: { deletingTag != nil }, set: { if !$0 { deletingTag = nil } }) } private var pushConfirmationBinding: Binding { @@ -639,20 +665,42 @@ struct ToolbarImageGroupCard: View { normalizedRegistryHost(registry) == "docker.io" ? "docker.io" : registry } - private func delete(_ reference: String) async { + private func primaryTag(_ group: Core.Image.LocalTagGroup) -> Core.Image.LocalTag? { + if let image = primaryImage(group) { + return group.tags.first { $0.reference == image.reference && $0.runtimeKind == image.runtimeKind } + } + return group.tags.first + } + + private func pushRuntimeKind(for reference: String) -> Core.Runtime.Kind? { + group.images.first { $0.reference == reference }?.runtimeKind + ?? primaryImage(group)?.runtimeKind + ?? group.tags.first?.runtimeKind + } + + private func runRuntimeKind(for group: Core.Image.LocalTagGroup) -> Core.Runtime.Kind? { + primaryImage(group)?.runtimeKind ?? primaryTag(group)?.runtimeKind + } + + private func delete(_ tag: Core.Image.LocalTag) async { guard let client = app.client else { return } do { - _ = try await client.deleteImages([reference]) + _ = try await client.deleteImages([tag.reference], runtimeKind: tag.runtimeKind) await app.refreshImagesIfNeeded(force: true) - app.flash(AppText.deletedImage(Format.shortImage(reference))) - deletingReference = nil + app.flash(AppText.deletedImage(Format.shortImage(tag.reference))) + deletingTag = nil } catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } private func prune(all: Bool) async { guard let client = app.client else { return } - do { _ = try await client.pruneImages(all: all); await app.refreshImagesIfNeeded(force: true) } + do { + for descriptor in app.availableRuntimeDescriptors where descriptor.supports(.images) { + _ = try await client.pruneImages(all: all, runtimeKind: descriptor.kind) + } + await app.refreshImagesIfNeeded(force: true) + } catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) } catch { app.flash(error.appDisplayMessage) } } @@ -665,7 +713,9 @@ struct ToolbarImageGroupCard: View { panel.message = AppText.saveImageTarArchive(Format.shortImage(image.reference)) guard panel.runModal() == .OK, let url = panel.url else { return } Task { - if let error = await app.captured({ _ = try await client.saveImages([image.reference], to: url.path) }) { + if let error = await app.captured({ _ = try await client.saveImages([image.reference], + to: url.path, + runtimeKind: image.runtimeKind) }) { app.flash(error) } else { app.flash(AppText.savedFile(url.lastPathComponent)) diff --git a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarResourcePanels.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarResourcePanels.swift index 1a757ca9..d32dbb61 100644 --- a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarResourcePanels.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarResourcePanels.swift @@ -43,7 +43,7 @@ struct ToolbarSettingsPanel: View { struct ToolbarTemplatesPanel: View { @Environment(UIState.self) private var ui @Environment(\.modelContext) private var modelContext - @Query(sort: \Template.createdAt, order: .reverse) private var saved: [Template] + @Query(sort: \RecipeRecord.createdAt, order: .reverse) private var saved: [RecipeRecord] var showClose = true var onClose: () -> Void @@ -51,7 +51,7 @@ struct ToolbarTemplatesPanel: View { showClose || !ui.toolbarUIEnabled } - private var sortedTemplates: [Template] { + private var sortedTemplates: [RecipeRecord] { saved.sorted { lhs, rhs in switch ui.templateSort { case .newest: @@ -71,7 +71,7 @@ struct ToolbarTemplatesPanel: View { } } - private var templateSections: [(title: String, templates: [Template])] { + private var templateSections: [(title: String, templates: [RecipeRecord])] { switch ui.templateGrouping { case .none: return [("", sortedTemplates)] @@ -146,7 +146,7 @@ struct ToolbarTemplatesPanel: View { } } - private func templateCard(_ template: Template) -> some View { + private func templateCard(_ template: RecipeRecord) -> some View { UI.Card.Scaffold(size: .medium, elevated: false, onTap: { use(template) }, @@ -197,18 +197,22 @@ struct ToolbarTemplatesPanel: View { } } - private func use(_ template: Template) { + private func use(_ template: RecipeRecord) { guard let spec = template.spec else { return } onClose() ui.useTemplate(spec) } - private func delete(_ template: Template) { + private func delete(_ template: RecipeRecord) { modelContext.delete(template) - try? modelContext.save() + do { + try modelContext.save() + } catch { + assertionFailure("Unable to delete recipe: \(error)") + } } - private func templateImageTitle(_ template: Template) -> String { + private func templateImageTitle(_ template: RecipeRecord) -> String { let image = template.spec?.image.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return image.isEmpty ? "No image" : Format.shortImage(image) } diff --git a/Sources/ContainedApp/Navigation/Toolbar/ToolbarViewOptions.swift b/Sources/ContainedApp/Navigation/Toolbar/ToolbarViewOptions.swift index 62be57f9..0bdd1db5 100644 --- a/Sources/ContainedApp/Navigation/Toolbar/ToolbarViewOptions.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/ToolbarViewOptions.swift @@ -9,7 +9,7 @@ struct ToolbarPageSwitcher: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui @Query private var events: [EventRecord] - @Query private var templates: [Template] + @Query private var templates: [RecipeRecord] var body: some View { UI.Control.MenuButton { @@ -197,6 +197,14 @@ struct ToolbarPageContextOptions: View { } private var serviceActions: [UI.Action.Item] { + guard app.appleRuntimeAvailable else { + return [ + UI.Action.Item(systemName: "arrow.clockwise", + help: AppText.string("common.retry", defaultValue: "Retry")) { + Task { await app.retryBootstrap() } + } + ] + } let power = app.serviceHealthy ? UI.Action.Item(systemName: "stop.fill", help: AppText.stopService, role: .destructive) { Task { await app.stopService() } diff --git a/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase+RuntimeInventory.swift b/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase+RuntimeInventory.swift new file mode 100644 index 00000000..f0f24580 --- /dev/null +++ b/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase+RuntimeInventory.swift @@ -0,0 +1,315 @@ +import Foundation +import ContainedCore + +extension AppDatabase { + func upsertContainers(_ snapshots: [Core.Container.Snapshot], observedAt: Date = Date()) { + let seen = Set(snapshots.map(\.scopedID)) + for snapshot in snapshots { + let document = Core.Schema.Document.containerEdit(from: snapshot.configuration) + let documentData = Self.encode(document) + let snapshotData = Self.encode(snapshot) + if let record = fetch(ContainerRecord.self).first(where: { $0.scopedID == snapshot.scopedID }) { + record.runtimeKindRaw = snapshot.runtimeKind.rawValue + record.runtimeID = snapshot.id + record.displayName = snapshot.displayName + record.imageReference = snapshot.image + record.statusRaw = snapshot.state.rawValue + record.documentData = documentData + record.snapshotData = snapshotData + record.isMissing = false + record.missingSince = nil + record.lastSeenAt = observedAt + record.updatedAt = observedAt + } else { + context.insert(ContainerRecord(scopedID: snapshot.scopedID, + runtimeKindRaw: snapshot.runtimeKind.rawValue, + runtimeID: snapshot.id, + displayName: snapshot.displayName, + imageReference: snapshot.image, + statusRaw: snapshot.state.rawValue, + documentData: documentData, + snapshotData: snapshotData, + lastSeenAt: observedAt, + updatedAt: observedAt)) + } + } + for record in fetch(ContainerRecord.self) where !seen.contains(record.scopedID) && !record.isMissing { + if shouldRetainMissingContainer(record) { + record.isMissing = true + record.missingSince = observedAt + record.updatedAt = observedAt + } else { + context.delete(record) + } + } + save() + } + + func upsertImages(_ images: [Core.Image.Resource], observedAt: Date = Date()) { + let groups = Core.Image.LocalTagGroup.groups(for: images) + var seenTags: Set = [] + for group in groups { + let identity = group.id + if let record = fetch(ImageRecord.self).first(where: { $0.identity == identity }) { + record.primaryReference = group.primaryReference + record.digest = group.digest + record.updatedAt = observedAt + } else { + context.insert(ImageRecord(identity: identity, + primaryReference: group.primaryReference, + digest: group.digest, + updatedAt: observedAt)) + } + } + for image in images { + let identity = image.digest ?? Core.Registry.ImageReference.normalizedKey(image.reference) + let tagID = image.runtimeKind.scopedID(for: Core.Registry.ImageReference.normalizedKey(image.reference)) + seenTags.insert(tagID) + if let tag = fetch(ImageTagRecord.self).first(where: { $0.scopedID == tagID }) { + tag.imageIdentity = identity + tag.reference = image.reference + tag.runtimeKindRaw = image.runtimeKind.rawValue + tag.runtimeImageID = image.id + tag.digest = image.digest + tag.resourceData = Self.encode(image) + tag.isLocal = true + tag.isMissing = false + tag.missingSince = nil + tag.lastSeenAt = observedAt + tag.updatedAt = observedAt + } else { + context.insert(ImageTagRecord(scopedID: tagID, + imageIdentity: identity, + reference: image.reference, + runtimeKindRaw: image.runtimeKind.rawValue, + runtimeImageID: image.id, + digest: image.digest, + resourceData: Self.encode(image), + lastSeenAt: observedAt, + updatedAt: observedAt)) + } + } + for tag in fetch(ImageTagRecord.self) where !seenTags.contains(tag.scopedID) && !tag.isMissing { + context.delete(tag) + } + save() + } + + func updateImageStatuses(_ statuses: [String: Core.Image.UpdateStatus], observedAt: Date = Date()) { + let seen = Set(statuses.keys) + for (key, status) in statuses { + let data = Self.encode(status) + if let image = fetch(ImageRecord.self).first(where: { $0.identity == key || Core.Registry.ImageReference.normalizedKey($0.primaryReference) == key }) { + image.updateStatusData = data + image.lastCheckedAt = observedAt + image.updatedAt = observedAt + } else { + context.insert(ImageRecord(identity: key, + primaryReference: key, + updateStatusData: data, + lastCheckedAt: observedAt, + updatedAt: observedAt)) + } + } + for image in fetch(ImageRecord.self) where !seen.contains(image.identity) && !seen.contains(Core.Registry.ImageReference.normalizedKey(image.primaryReference)) { + image.updateStatusData = nil + image.updatedAt = observedAt + } + save() + } + + func imageStatusesSnapshot() -> [String: Core.Image.UpdateStatus] { + var snapshot: [String: Core.Image.UpdateStatus] = [:] + for record in fetch(ImageRecord.self) { + guard let data = record.updateStatusData else { continue } + let status: Core.Image.UpdateStatus + do { + status = try JSONDecoder().decode(Core.Image.UpdateStatus.self, from: data) + } catch { + fatalError("Unable to decode image update status for \(record.identity): \(error)") + } + let value = status.state == .checking ? Core.Image.UpdateStatus() : status + snapshot[record.identity] = value + snapshot[Core.Registry.ImageReference.normalizedKey(record.primaryReference)] = value + } + return snapshot + } + + func hiddenContainerScopedIDs() -> Set { + Set(fetch(ContainerRecord.self).filter(\.isHiddenDuringMigration).map(\.scopedID)) + } + + func upsertVolumes(_ volumes: [Core.Volume.Resource], observedAt: Date = Date()) { + let seen = Set(volumes.map(\.scopedID)) + for volume in volumes { + if let record = fetch(VolumeRecord.self).first(where: { $0.scopedID == volume.scopedID }) { + record.runtimeKindRaw = volume.runtimeKind.rawValue + record.name = volume.name + record.resourceData = Self.encode(volume) + record.isMissing = false + record.missingSince = nil + record.lastSeenAt = observedAt + record.updatedAt = observedAt + } else { + context.insert(VolumeRecord(scopedID: volume.scopedID, + runtimeKindRaw: volume.runtimeKind.rawValue, + name: volume.name, + resourceData: Self.encode(volume), + lastSeenAt: observedAt, + updatedAt: observedAt)) + } + } + for record in fetch(VolumeRecord.self) where !seen.contains(record.scopedID) && !record.isMissing { + if record.personalizationData != nil { + record.isMissing = true + record.missingSince = observedAt + record.updatedAt = observedAt + } else { + context.delete(record) + } + } + save() + } + + func upsertNetworks(_ networks: [Core.Network.Resource], observedAt: Date = Date()) { + let seen = Set(networks.map(\.scopedID)) + for network in networks { + if let record = fetch(NetworkRecord.self).first(where: { $0.scopedID == network.scopedID }) { + record.runtimeKindRaw = network.runtimeKind.rawValue + record.name = network.name + record.resourceData = Self.encode(network) + record.isMissing = false + record.missingSince = nil + record.lastSeenAt = observedAt + record.updatedAt = observedAt + } else { + context.insert(NetworkRecord(scopedID: network.scopedID, + runtimeKindRaw: network.runtimeKind.rawValue, + name: network.name, + resourceData: Self.encode(network), + lastSeenAt: observedAt, + updatedAt: observedAt)) + } + } + for record in fetch(NetworkRecord.self) where !seen.contains(record.scopedID) && !record.isMissing { + context.delete(record) + } + save() + } + + func markContainerMigrationStarted(source: Core.Container.Snapshot, + targetRuntimeKind: Core.Runtime.Kind, + sourceDocument: Core.Schema.Document, + observedAt: Date = Date()) { + let record: ContainerRecord + if let existing = fetch(ContainerRecord.self).first(where: { $0.scopedID == source.scopedID }) { + record = existing + } else { + record = ContainerRecord(scopedID: source.scopedID, + runtimeKindRaw: source.runtimeKind.rawValue, + runtimeID: source.id, + displayName: source.displayName, + imageReference: source.image, + statusRaw: source.state.rawValue) + context.insert(record) + } + + var projections = runtimeProjections(from: record) + projections[source.runtimeKind.rawValue] = sourceDocument + record.runtimeKindRaw = source.runtimeKind.rawValue + record.runtimeID = source.id + record.displayName = source.displayName + record.imageReference = source.image + record.statusRaw = source.state.rawValue + record.documentData = Self.encode(sourceDocument) + record.snapshotData = Self.encode(source) + record.runtimeProjectionsData = Self.encode(projections) + record.isHiddenDuringMigration = true + record.migrationStateRaw = "migrating:\(source.runtimeKind.rawValue):\(targetRuntimeKind.rawValue)" + record.isMissing = false + record.lastSeenAt = observedAt + record.updatedAt = observedAt + save() + } + + func markContainerMigrationFailed(scopedID: String, + message: String? = nil, + observedAt: Date = Date()) { + guard let record = fetch(ContainerRecord.self).first(where: { $0.scopedID == scopedID }) else { return } + record.isHiddenDuringMigration = false + record.migrationStateRaw = message.map { "failed:\($0)" } ?? "failed" + record.updatedAt = observedAt + save() + } + + func completeContainerMigration(sourceScopedID: String, + target: Core.Container.Snapshot, + targetDocument: Core.Schema.Document, + sourceRuntimeKind: Core.Runtime.Kind, + sourceDocument: Core.Schema.Document, + observedAt: Date = Date()) { + let record: ContainerRecord + if let existing = fetch(ContainerRecord.self).first(where: { $0.scopedID == sourceScopedID }) { + record = existing + } else if let existing = fetch(ContainerRecord.self).first(where: { $0.scopedID == target.scopedID }) { + record = existing + } else { + record = ContainerRecord(scopedID: sourceScopedID, + runtimeKindRaw: sourceRuntimeKind.rawValue, + runtimeID: target.id, + displayName: target.displayName, + imageReference: target.image, + statusRaw: target.state.rawValue) + context.insert(record) + } + + for duplicate in fetch(ContainerRecord.self) where duplicate.scopedID == target.scopedID && duplicate !== record { + context.delete(duplicate) + } + + var projections = runtimeProjections(from: record) + projections[sourceRuntimeKind.rawValue] = sourceDocument + projections[target.runtimeKind.rawValue] = targetDocument + record.scopedID = target.scopedID + record.runtimeKindRaw = target.runtimeKind.rawValue + record.runtimeID = target.id + record.displayName = target.displayName + record.imageReference = target.image + record.statusRaw = target.state.rawValue + record.documentData = Self.encode(targetDocument) + record.snapshotData = Self.encode(target) + record.runtimeProjectionsData = Self.encode(projections) + record.isMissing = false + record.isHiddenDuringMigration = false + record.migrationStateRaw = "none" + record.missingSince = nil + record.lastSeenAt = observedAt + record.updatedAt = observedAt + save() + } + + private func runtimeProjections(from record: ContainerRecord) -> [String: Core.Schema.Document] { + guard let data = record.runtimeProjectionsData else { return [:] } + do { + return try JSONDecoder().decode([String: Core.Schema.Document].self, from: data) + } catch { + fatalError("Unable to decode runtime projections for \(record.scopedID): \(error)") + } + } + + private func shouldRetainMissingContainer(_ record: ContainerRecord) -> Bool { + record.isHiddenDuringMigration || + record.migrationStateRaw != "none" || + record.runtimeProjectionsData != nil || + fetch(PersonalizationRecord.self).contains { $0.key == record.scopedID } || + fetch(HealthCheckRecord.self).contains { $0.containerScopedID == record.scopedID } + } + + private static func encode(_ value: T) -> Data { + do { + return try JSONEncoder().encode(value) + } catch { + fatalError("Unable to encode app database value \(T.self): \(error)") + } + } +} diff --git a/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase.swift b/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase.swift new file mode 100644 index 00000000..65cb9088 --- /dev/null +++ b/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase.swift @@ -0,0 +1,145 @@ +import Foundation +import SwiftData +import ContainedCore + +@MainActor +final class AppDatabase { + let container: ModelContainer + var context: ModelContext { container.mainContext } + + init(isStoredInMemoryOnly: Bool = false) { + let schema = Schema(AppDatabaseSchemaV1.models) + do { + let config = ModelConfiguration("ContainedAppDatabase", + schema: schema, + isStoredInMemoryOnly: isStoredInMemoryOnly) + container = try ModelContainer(for: schema, configurations: config) + } catch { + fatalError("Unable to create app database: \(error)") + } + } + + func fetch(_ model: T.Type) -> [T] { + do { + return try context.fetch(FetchDescriptor()) + } catch { + fatalError("Unable to fetch \(T.self): \(error)") + } + } + + func save() { + do { + try context.save() + } catch { + fatalError("Unable to save app database: \(error)") + } + } + + func setting(_ key: String, fallback: T) -> T { + guard let record = fetch(AppSettingRecord.self).first(where: { $0.key == key }) else { + return fallback + } + do { + return try JSONDecoder().decode(T.self, from: record.valueData) + } catch { + fatalError("Unable to decode app setting \(key): \(error)") + } + } + + func setSetting(_ value: T, for key: String) { + let data: Data + do { + data = try JSONEncoder().encode(value) + } catch { + fatalError("Unable to encode app setting \(key): \(error)") + } + if let record = fetch(AppSettingRecord.self).first(where: { $0.key == key }) { + record.valueData = data + record.updatedAt = Date() + } else { + context.insert(AppSettingRecord(key: key, valueData: data)) + } + save() + } + + func deleteSetting(_ key: String) { + guard let record = fetch(AppSettingRecord.self).first(where: { $0.key == key }) else { return } + context.delete(record) + save() + } + + func runtimeRecord(for kind: Core.Runtime.Kind) -> RuntimeRecord { + let raw = kind.rawValue + if let record = fetch(RuntimeRecord.self).first(where: { $0.runtimeKindRaw == raw }) { + return record + } + let record = RuntimeRecord(runtimeKindRaw: raw) + context.insert(record) + return record + } + + func runtimePathOverride(for kind: Core.Runtime.Kind) -> String { + let raw = kind.rawValue + return fetch(RuntimeRecord.self).first(where: { $0.runtimeKindRaw == raw })?.cliPathOverride ?? "" + } + + func setRuntimePathOverride(_ path: String, for descriptor: Core.Runtime.Descriptor) { + let record = runtimeRecord(for: descriptor.kind) + record.cliPathOverride = path + record.updatedAt() + save() + } + + func upsertRuntimeReadiness(_ readiness: [Core.RuntimeReadiness], + descriptors: [Core.Runtime.Descriptor]) { + let now = Date() + let readyByKind = Dictionary(uniqueKeysWithValues: readiness.map { ($0.kind, $0) }) + for descriptor in descriptors { + let record = runtimeRecord(for: descriptor.kind) + let state = readyByKind[descriptor.kind] + record.isAvailable = state != nil + record.readinessRaw = state?.state.rawValue ?? "unavailable" + record.lastCheckedAt = now + record.lastError = state?.message + } + save() + } +} + +private extension RuntimeRecord { + func updatedAt() { + lastCheckedAt = Date() + } +} + +private extension AppModel.Bootstrap { + var rawDatabaseValue: String { + switch self { + case .checking: return "checking" + case .cliMissing: return "cliMissing" + case .unsupported: return "unsupported" + case .serviceStopped: return "endpointUnavailable" + case .ready: return "ready" + } + } +} + +enum AppDatabaseSchemaV1: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(1, 0, 0) } + static var models: [any PersistentModel.Type] { + [ + AppSettingRecord.self, + RuntimeRecord.self, + ContainerRecord.self, + RecipeRecord.self, + ImageRecord.self, + ImageTagRecord.self, + VolumeRecord.self, + NetworkRecord.self, + PersonalizationRecord.self, + HealthCheckRecord.self, + EventRecord.self, + MetricSample.self, + ] + } +} diff --git a/Sources/ContainedApp/Persistence/AppDatabase/AppDatabaseModels.swift b/Sources/ContainedApp/Persistence/AppDatabase/AppDatabaseModels.swift new file mode 100644 index 00000000..e43f6a78 --- /dev/null +++ b/Sources/ContainedApp/Persistence/AppDatabase/AppDatabaseModels.swift @@ -0,0 +1,283 @@ +import Foundation +import SwiftData + +@Model +final class AppSettingRecord { + var key: String + var valueData: Data + var updatedAt: Date + + init(key: String, valueData: Data, updatedAt: Date = Date()) { + self.key = key + self.valueData = valueData + self.updatedAt = updatedAt + } +} + +@Model +final class RuntimeRecord { + var runtimeKindRaw: String + var cliPathOverride: String + var isAvailable: Bool + var readinessRaw: String + var lastCheckedAt: Date? + var lastError: String? + + init(runtimeKindRaw: String, + cliPathOverride: String = "", + isAvailable: Bool = false, + readinessRaw: String = "unknown", + lastCheckedAt: Date? = nil, + lastError: String? = nil) { + self.runtimeKindRaw = runtimeKindRaw + self.cliPathOverride = cliPathOverride + self.isAvailable = isAvailable + self.readinessRaw = readinessRaw + self.lastCheckedAt = lastCheckedAt + self.lastError = lastError + } +} + +@Model +final class ContainerRecord { + var scopedID: String + var runtimeKindRaw: String + var runtimeID: String + var displayName: String + var imageReference: String + var statusRaw: String + var documentData: Data? + var snapshotData: Data? + var runtimeProjectionsData: Data? + var isMissing: Bool + var isHiddenDuringMigration: Bool + var migrationStateRaw: String + var lastSeenAt: Date? + var missingSince: Date? + var updatedAt: Date + + init(scopedID: String, + runtimeKindRaw: String, + runtimeID: String, + displayName: String, + imageReference: String, + statusRaw: String, + documentData: Data? = nil, + snapshotData: Data? = nil, + runtimeProjectionsData: Data? = nil, + isMissing: Bool = false, + isHiddenDuringMigration: Bool = false, + migrationStateRaw: String = "none", + lastSeenAt: Date? = nil, + missingSince: Date? = nil, + updatedAt: Date = Date()) { + self.scopedID = scopedID + self.runtimeKindRaw = runtimeKindRaw + self.runtimeID = runtimeID + self.displayName = displayName + self.imageReference = imageReference + self.statusRaw = statusRaw + self.documentData = documentData + self.snapshotData = snapshotData + self.runtimeProjectionsData = runtimeProjectionsData + self.isMissing = isMissing + self.isHiddenDuringMigration = isHiddenDuringMigration + self.migrationStateRaw = migrationStateRaw + self.lastSeenAt = lastSeenAt + self.missingSince = missingSince + self.updatedAt = updatedAt + } +} + +@Model +final class RecipeRecord { + var id: String + var name: String + var createdAt: Date + var updatedAt: Date + var documentData: Data + var personalizationData: Data? + var healthCheckData: Data? + var sourceRaw: String + + init(id: String = UUID().uuidString, + name: String, + createdAt: Date = Date(), + updatedAt: Date = Date(), + documentData: Data, + personalizationData: Data? = nil, + healthCheckData: Data? = nil, + sourceRaw: String = "template") { + self.id = id + self.name = name + self.createdAt = createdAt + self.updatedAt = updatedAt + self.documentData = documentData + self.personalizationData = personalizationData + self.healthCheckData = healthCheckData + self.sourceRaw = sourceRaw + } +} + +@Model +final class ImageRecord { + var identity: String + var primaryReference: String + var digest: String? + var updateStatusData: Data? + var registryMetadataData: Data? + var personalizationData: Data? + var lastCheckedAt: Date? + var updatedAt: Date + + init(identity: String, + primaryReference: String, + digest: String? = nil, + updateStatusData: Data? = nil, + registryMetadataData: Data? = nil, + personalizationData: Data? = nil, + lastCheckedAt: Date? = nil, + updatedAt: Date = Date()) { + self.identity = identity + self.primaryReference = primaryReference + self.digest = digest + self.updateStatusData = updateStatusData + self.registryMetadataData = registryMetadataData + self.personalizationData = personalizationData + self.lastCheckedAt = lastCheckedAt + self.updatedAt = updatedAt + } +} + +@Model +final class ImageTagRecord { + var scopedID: String + var imageIdentity: String + var reference: String + var runtimeKindRaw: String + var runtimeImageID: String + var digest: String? + var resourceData: Data? + var isLocal: Bool + var isMissing: Bool + var lastSeenAt: Date? + var missingSince: Date? + var updatedAt: Date + + init(scopedID: String, + imageIdentity: String, + reference: String, + runtimeKindRaw: String, + runtimeImageID: String, + digest: String? = nil, + resourceData: Data? = nil, + isLocal: Bool = true, + isMissing: Bool = false, + lastSeenAt: Date? = nil, + missingSince: Date? = nil, + updatedAt: Date = Date()) { + self.scopedID = scopedID + self.imageIdentity = imageIdentity + self.reference = reference + self.runtimeKindRaw = runtimeKindRaw + self.runtimeImageID = runtimeImageID + self.digest = digest + self.resourceData = resourceData + self.isLocal = isLocal + self.isMissing = isMissing + self.lastSeenAt = lastSeenAt + self.missingSince = missingSince + self.updatedAt = updatedAt + } +} + +@Model +final class VolumeRecord { + var scopedID: String + var runtimeKindRaw: String + var name: String + var resourceData: Data? + var personalizationData: Data? + var isMissing: Bool + var lastSeenAt: Date? + var missingSince: Date? + var updatedAt: Date + + init(scopedID: String, + runtimeKindRaw: String, + name: String, + resourceData: Data? = nil, + personalizationData: Data? = nil, + isMissing: Bool = false, + lastSeenAt: Date? = nil, + missingSince: Date? = nil, + updatedAt: Date = Date()) { + self.scopedID = scopedID + self.runtimeKindRaw = runtimeKindRaw + self.name = name + self.resourceData = resourceData + self.personalizationData = personalizationData + self.isMissing = isMissing + self.lastSeenAt = lastSeenAt + self.missingSince = missingSince + self.updatedAt = updatedAt + } +} + +@Model +final class NetworkRecord { + var scopedID: String + var runtimeKindRaw: String + var name: String + var resourceData: Data? + var isMissing: Bool + var lastSeenAt: Date? + var missingSince: Date? + var updatedAt: Date + + init(scopedID: String, + runtimeKindRaw: String, + name: String, + resourceData: Data? = nil, + isMissing: Bool = false, + lastSeenAt: Date? = nil, + missingSince: Date? = nil, + updatedAt: Date = Date()) { + self.scopedID = scopedID + self.runtimeKindRaw = runtimeKindRaw + self.name = name + self.resourceData = resourceData + self.isMissing = isMissing + self.lastSeenAt = lastSeenAt + self.missingSince = missingSince + self.updatedAt = updatedAt + } +} + +@Model +final class PersonalizationRecord { + var key: String + var scopeRaw: String + var valueData: Data + var updatedAt: Date + + init(key: String, scopeRaw: String, valueData: Data, updatedAt: Date = Date()) { + self.key = key + self.scopeRaw = scopeRaw + self.valueData = valueData + self.updatedAt = updatedAt + } +} + +@Model +final class HealthCheckRecord { + var containerScopedID: String + var valueData: Data + var updatedAt: Date + + init(containerScopedID: String, valueData: Data, updatedAt: Date = Date()) { + self.containerScopedID = containerScopedID + self.valueData = valueData + self.updatedAt = updatedAt + } +} diff --git a/Sources/ContainedApp/Persistence/History/Template.swift b/Sources/ContainedApp/Persistence/AppDatabase/RecipeSupport.swift similarity index 91% rename from Sources/ContainedApp/Persistence/History/Template.swift rename to Sources/ContainedApp/Persistence/AppDatabase/RecipeSupport.swift index eb0072be..55e58efa 100644 --- a/Sources/ContainedApp/Persistence/History/Template.swift +++ b/Sources/ContainedApp/Persistence/AppDatabase/RecipeSupport.swift @@ -1,33 +1,33 @@ import Foundation import ContainedCore -import SwiftData /// A saved container recipe — a named `ContainerFormState`, persisted (encoded) so it can prefill the edit form -/// later. Stored in the same SwiftData container as the history models. -@Model -final class Template { - var name: String - var createdAt: Date - var specData: Data - - init(name: String, spec: ContainerFormState, createdAt: Date = Date()) { - self.name = name - self.createdAt = createdAt - self.specData = (try? JSONEncoder().encode(spec)) ?? Data() +/// later. Stored as a `RecipeRecord` in the app database. +extension RecipeRecord { + convenience init(name: String, spec: ContainerFormState, createdAt: Date = Date()) { + let data: Data + do { + data = try JSONEncoder().encode(spec) + } catch { + fatalError("Unable to encode recipe \(name): \(error)") + } + self.init(name: name, + createdAt: createdAt, + updatedAt: createdAt, + documentData: data, + sourceRaw: "template") } - init(snapshot: TemplateSnapshot) { - self.name = snapshot.name - self.createdAt = snapshot.createdAt - self.specData = (try? JSONEncoder().encode(snapshot.spec)) ?? Data() + convenience init(snapshot: RecipeSnapshot) { + self.init(name: snapshot.name, spec: snapshot.spec, createdAt: snapshot.createdAt) } var spec: ContainerFormState? { let decoder = JSONDecoder() - if let state = try? decoder.decode(ContainerFormState.self, from: specData) { + if let state = try? decoder.decode(ContainerFormState.self, from: documentData) { return state } - return (try? decoder.decode(LegacyContainerTemplateSpec.self, from: specData))?.formState + return (try? decoder.decode(LegacyContainerTemplateSpec.self, from: documentData))?.formState } } @@ -243,15 +243,15 @@ private struct LegacyContainerTemplateSpec: Decodable { } } -struct TemplateSnapshot: Codable { +struct RecipeSnapshot: Codable { var name: String var createdAt: Date var spec: ContainerFormState - init?(_ template: Template) { - guard let spec = template.spec else { return nil } - self.name = template.name - self.createdAt = template.createdAt + init?(_ recipe: RecipeRecord) { + guard let spec = recipe.spec else { return nil } + self.name = recipe.name + self.createdAt = recipe.createdAt self.spec = spec } } diff --git a/Sources/ContainedApp/Persistence/History/HistoryStore.swift b/Sources/ContainedApp/Persistence/History/HistoryStore.swift index ccc974ac..ea2c1d9c 100644 --- a/Sources/ContainedApp/Persistence/History/HistoryStore.swift +++ b/Sources/ContainedApp/Persistence/History/HistoryStore.swift @@ -2,12 +2,13 @@ import Foundation import SwiftData import ContainedCore -/// Owns the SwiftData stack for the persistent history (events + metric samples) and writes to it. +/// Writes persistent history (events + metric samples) into the shared app database. /// Retention-bounded so the database can't grow without limit. The `container` is exposed so the /// SwiftUI views can read it via `@Query`. @MainActor final class HistoryStore { let container: ModelContainer + private let database: AppDatabase private var context: ModelContext { container.mainContext } /// Keep this many days of history; pruned on launch and periodically. Driven by the user @@ -21,22 +22,9 @@ final class HistoryStore { /// Suppress identical durable Activity rows generated by repeated background ticks. private let duplicateEventWindow: TimeInterval = 30 - init() { - let schema = Schema(HistorySchemaV1.models) - do { - container = try ModelContainer(for: schema, - migrationPlan: HistoryMigrationPlan.self) - } catch { - // Fall back to an in-memory store so the app still runs if the on-disk store can't open. - let config = ModelConfiguration(isStoredInMemoryOnly: true) - do { - container = try ModelContainer(for: schema, - migrationPlan: HistoryMigrationPlan.self, - configurations: config) - } catch { - fatalError("Unable to create history store, including in-memory fallback: \(error)") - } - } + init(database: AppDatabase = AppDatabase()) { + self.database = database + self.container = database.container pruneOld() } @@ -48,7 +36,7 @@ final class HistoryStore { recentEvents[key] = date recentEvents = recentEvents.filter { date.timeIntervalSince($0.value) < duplicateEventWindow } context.insert(EventRecord(timestamp: date, containerID: containerID, kind: kind, message: message)) - try? context.save() + save() } /// Persist a metric sample for each running container, throttled to `metricInterval`. @@ -61,7 +49,7 @@ final class HistoryStore { netRxBytesPerSec: d.netRxBytesPerSec, netTxBytesPerSec: d.netTxBytesPerSec, diskReadBytesPerSec: d.blockReadBytesPerSec, diskWriteBytesPerSec: d.blockWriteBytesPerSec)) } - try? context.save() + save() } /// Mark every recorded event as read (clears the toolbar unread badge). @@ -70,13 +58,13 @@ final class HistoryStore { predicate: #Predicate { !$0.isRead }))) ?? [] guard !unread.isEmpty else { return } for event in unread { event.isRead = true } - try? context.save() + save() } /// Delete every recorded event (keeps metric samples + templates). Backs the Activity "Clear" action. func clearEvents() { try? context.delete(model: EventRecord.self) - try? context.save() + save() } // MARK: Retention @@ -85,7 +73,7 @@ final class HistoryStore { guard let cutoff = Calendar.current.date(byAdding: .day, value: -retentionDays, to: now) else { return } try? context.delete(model: MetricSample.self, where: #Predicate { $0.timestamp < cutoff }) try? context.delete(model: EventRecord.self, where: #Predicate { $0.timestamp < cutoff }) - try? context.save() + save() } /// Wipe all recorded metrics and events (Templates are preserved). Used by the "Clear history" @@ -93,7 +81,7 @@ final class HistoryStore { func clearAll() { try? context.delete(model: MetricSample.self) try? context.delete(model: EventRecord.self) - try? context.save() + save() } func unreadEventCount() -> Int { @@ -101,8 +89,8 @@ final class HistoryStore { predicate: #Predicate { !$0.isRead }))) ?? 0 } - func templatesSnapshot() -> [TemplateSnapshot] { - ((try? context.fetch(FetchDescriptor