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/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..d896a3d6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -22,24 +22,25 @@ ## Validation - [ ] `git diff --check` -- [ ] `./scripts/ci-validate.sh` +- [ ] `./Scripts/check.sh repo` - [ ] `swift build` - [ ] `swift test` -- [ ] UI/app changes smoke-tested with `./scripts/bundle.sh debug` -- [ ] Release/script changes covered by `./scripts/test-release-scripts.sh` and relevant validators +- [ ] UI/app changes smoke-tested with `./Scripts/package.sh app debug` +- [ ] Release/script changes covered by `./Scripts/check.sh release` and relevant validators ## 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) +- [ ] Added or updated a release/change note, or applied the `no-release-note` label for documentation, metadata, or dependency-only maintenance +- [ ] Updated `docs` for user-facing behavior or workflow changes, or this PR does not need docs +- [ ] Updated `Documentation/Wiki/File-Map.md` and `Documentation/Wiki/_Sidebar.md` when documentation/package docs should appear in the wiki +- [ ] Synced `Sources/ContainedApp/Resources/CHANGELOG.md` when `CHANGELOG.md` changed (`./Scripts/package.sh app debug` then `./Scripts/check.sh repo` passes) ## Update Safety -- [ ] Build numbers still come only from `scripts/version-info.sh` +- [ ] Build numbers still come only from `Scripts/package.sh version` - [ ] Nightly can still receive promoted Beta/Stable appcast items - [ ] Generated appcast commits still use `[skip ci]` and do not trigger release loops -- [ ] Bundle/appcast changes were validated with `scripts/validate-bundle.sh` and `scripts/validate-appcast.sh` +- [ ] Bundle/appcast changes were validated with `Scripts/package.sh smoke` and `Scripts/appcast.sh validate` ## Notes For Reviewers diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 2feb7de8..fec070ba 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -15,13 +15,21 @@ on: # Docs/meta-only changes don't need a build. Changelog changes do, because the changelog is # bundled into the app and drives GitHub/Sparkle release notes. paths-ignore: - - 'docs/**' + - 'Documentation/**' - 'README.md' + - 'Packages/*/README.md' + - 'Packages/*/Sources/*/*.docc/**' + - 'Changes/**' - '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: @@ -36,6 +44,7 @@ permissions: jobs: beta: runs-on: macos-26 + timeout-minutes: 30 env: SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} GH_TOKEN: ${{ github.token }} @@ -58,34 +67,35 @@ jobs: if: steps.guard.outputs.build == 'true' run: | set -euo pipefail - ./scripts/ci-validate.sh - ./scripts/test-release-scripts.sh + brew list ripgrep >/dev/null 2>&1 || brew install ripgrep + ./Scripts/check.sh repo + ./Scripts/check.sh release swift build -c release swift test - ./scripts/check-generated-clean.sh + ./Scripts/check.sh generated - name: Bundle (ad-hoc signed) + DMG if: steps.guard.outputs.build == 'true' run: | set -euo pipefail git fetch origin nightly:refs/remotes/origin/nightly - SHA=$(CHANNEL=beta ./scripts/version-info.sh sha) - BUILD=$(CHANNEL=beta BUILD_SOURCE_REF=origin/nightly SHA="${SHA}" ./scripts/version-info.sh build) - VERSION=$(CHANNEL=beta BUILD="${BUILD}" SHA="${SHA}" ./scripts/version-info.sh version) + SHA=$(CHANNEL=beta ./Scripts/package.sh version sha) + BUILD=$(CHANNEL=beta BUILD_SOURCE_REF=origin/nightly SHA="${SHA}" ./Scripts/package.sh version build) + VERSION=$(CHANNEL=beta BUILD="${BUILD}" SHA="${SHA}" ./Scripts/package.sh version version) export BUILD SHA VERSION { - echo "BASE_VERSION=$(./scripts/version-info.sh base)" + echo "BASE_VERSION=$(./Scripts/package.sh version base)" echo "BUILD=${BUILD}" echo "SHA=${SHA}" echo "VERSION=${VERSION}" } >> "$GITHUB_ENV" - CHANNEL=beta BUILD="${BUILD}" VERSION="${VERSION}" ./scripts/bundle.sh release - VERSION="${VERSION}" BUILD="${BUILD}" ./scripts/validate-bundle.sh Contained.app - ./scripts/check-generated-clean.sh + CHANNEL=beta BUILD="${BUILD}" VERSION="${VERSION}" ./Scripts/package.sh app release + VERSION="${VERSION}" BUILD="${BUILD}" ./Scripts/package.sh smoke Contained.app + ./Scripts/check.sh generated mkdir -p updates brew install create-dmg - ./scripts/make-dmg.sh beta Contained.app "updates/Contained-${VERSION}.dmg" - CHANNEL=beta VERSION_VALUE="${VERSION}" ./scripts/release-body.sh > updates/release-notes.md + ./Scripts/package.sh dmg beta Contained.app "updates/Contained-${VERSION}.dmg" + CHANNEL=beta VERSION_VALUE="${VERSION}" ./Scripts/notes.sh body > updates/release-notes.md { echo "Automated beta build @ ${SHA}." echo @@ -119,8 +129,8 @@ jobs: [ -x "$BIN/generate_appcast" ] || BIN="$(dirname "$(find .build -type f -name generate_appcast | head -1)")" ED_KEY_FILE="$KEYFILE" \ DOWNLOAD_PREFIX="https://github.com/tdeverx/contained-app/releases/download/v${VERSION}/" \ - ./scripts/appcast.sh "$BIN" updates - CHANNEL=beta ./scripts/validate-appcast.sh appcast.xml + ./Scripts/appcast.sh generate "$BIN" updates + CHANNEL=beta ./Scripts/appcast.sh validate appcast.xml rm -f "$KEYFILE" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -138,10 +148,10 @@ jobs: NIGHTLY_WORKTREE="$(mktemp -d)" rm -rf "$NIGHTLY_WORKTREE" git worktree add "$NIGHTLY_WORKTREE" origin/nightly - ./scripts/promote-appcast-to-nightly.sh "$PROMOTED_APPCAST" "$NIGHTLY_WORKTREE/appcast.xml" + ./Scripts/appcast.sh promote "$PROMOTED_APPCAST" "$NIGHTLY_WORKTREE/appcast.xml" ( cd "$NIGHTLY_WORKTREE" - CHANNEL=nightly "$GITHUB_WORKSPACE/scripts/validate-appcast.sh" appcast.xml + CHANNEL=nightly "$GITHUB_WORKSPACE/Scripts/appcast.sh" validate appcast.xml git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add appcast.xml @@ -154,3 +164,18 @@ jobs: ) git worktree remove "$NIGHTLY_WORKTREE" --force rm -f "$PROMOTED_APPCAST" + + - name: Clear beta release-note accumulator + if: steps.guard.outputs.build == 'true' + run: | + set -euo pipefail + ./Scripts/notes.sh finalize beta + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Changes/Beta.md + if git diff --cached --quiet; then + echo "beta release notes already clear." + else + git commit -m "ci: clear beta notes (${VERSION}) [skip ci]" + git push origin HEAD:beta + fi diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fffb04c3..6ca7eea3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,35 +7,49 @@ on: pull_request: branches: [main, nightly, beta, stable] paths-ignore: - - 'docs/**' + - 'Documentation/**' - 'README.md' + - 'Packages/*/README.md' + - '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/Contained/Resources/CHANGELOG.md' + - 'Sources/ContainedApp/Resources/CHANGELOG.md' - 'appcast.xml' - - 'changes/**' + - 'Changes/**' push: branches: [nightly, beta, stable] paths-ignore: - - 'docs/**' + - 'Documentation/**' - 'README.md' + - 'Packages/*/README.md' + - '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/Contained/Resources/CHANGELOG.md' + - 'Sources/ContainedApp/Resources/CHANGELOG.md' - 'appcast.xml' - - 'changes/**' + - 'Changes/**' schedule: - cron: '37 4 * * 1' workflow_dispatch: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5c0e1490..ce4c64c1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -14,13 +14,21 @@ on: # Docs/meta-only changes don't need a build. Changelog changes do, because the changelog is # bundled into the app and drives GitHub/Sparkle release notes. paths-ignore: - - 'docs/**' + - 'Documentation/**' - 'README.md' + - 'Packages/*/README.md' + - 'Packages/*/Sources/*/*.docc/**' + - 'Changes/**' - '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: @@ -35,6 +43,7 @@ permissions: jobs: nightly: runs-on: macos-26 # GA, arm64 (Apple silicon) — ships Xcode 26 + timeout-minutes: 30 env: SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} GH_TOKEN: ${{ github.token }} @@ -49,32 +58,33 @@ jobs: - name: Build & test run: | set -euo pipefail - ./scripts/ci-validate.sh - ./scripts/test-release-scripts.sh + brew list ripgrep >/dev/null 2>&1 || brew install ripgrep + ./Scripts/check.sh repo + ./Scripts/check.sh release swift build -c release swift test - ./scripts/check-generated-clean.sh + ./Scripts/check.sh generated - name: Bundle (ad-hoc signed) + DMG run: | set -euo pipefail - BUILD=$(CHANNEL=nightly ./scripts/version-info.sh build) - SHA=$(CHANNEL=nightly ./scripts/version-info.sh sha) - VERSION=$(CHANNEL=nightly BUILD="${BUILD}" SHA="${SHA}" ./scripts/version-info.sh version) + BUILD=$(CHANNEL=nightly ./Scripts/package.sh version build) + SHA=$(CHANNEL=nightly ./Scripts/package.sh version sha) + VERSION=$(CHANNEL=nightly BUILD="${BUILD}" SHA="${SHA}" ./Scripts/package.sh version version) export BUILD SHA VERSION { - echo "BASE_VERSION=$(./scripts/version-info.sh base)" + echo "BASE_VERSION=$(./Scripts/package.sh version base)" echo "BUILD=${BUILD}" echo "SHA=${SHA}" echo "VERSION=${VERSION}" } >> "$GITHUB_ENV" - CHANNEL=nightly BUILD="${BUILD}" VERSION="${VERSION}" ./scripts/bundle.sh release - VERSION="${VERSION}" BUILD="${BUILD}" ./scripts/validate-bundle.sh Contained.app - ./scripts/check-generated-clean.sh + CHANNEL=nightly BUILD="${BUILD}" VERSION="${VERSION}" ./Scripts/package.sh app release + VERSION="${VERSION}" BUILD="${BUILD}" ./Scripts/package.sh smoke Contained.app + ./Scripts/check.sh generated mkdir -p updates brew install create-dmg - ./scripts/make-dmg.sh nightly Contained.app "updates/Contained-${VERSION}.dmg" - CHANNEL=nightly VERSION_VALUE="${VERSION}" ./scripts/release-body.sh > updates/release-notes.md + ./Scripts/package.sh dmg nightly Contained.app "updates/Contained-${VERSION}.dmg" + CHANNEL=nightly VERSION_VALUE="${VERSION}" ./Scripts/notes.sh body > updates/release-notes.md { echo "Automated build of \`nightly\` @ ${SHA}." echo @@ -101,9 +111,9 @@ jobs: [ -x "$BIN/generate_appcast" ] || BIN="$(dirname "$(find .build -type f -name generate_appcast | head -1)")" ED_KEY_FILE="$KEYFILE" \ DOWNLOAD_PREFIX="https://github.com/tdeverx/contained-app/releases/download/nightly-latest/" \ - ./scripts/appcast.sh "$BIN" updates - [ -s "$PREVIOUS_APPCAST" ] && ./scripts/promote-appcast-to-nightly.sh --non-nightly-only "$PREVIOUS_APPCAST" appcast.xml - CHANNEL=nightly ./scripts/validate-appcast.sh appcast.xml + ./Scripts/appcast.sh generate "$BIN" updates + [ -s "$PREVIOUS_APPCAST" ] && ./Scripts/appcast.sh promote --non-nightly-only "$PREVIOUS_APPCAST" appcast.xml + CHANNEL=nightly ./Scripts/appcast.sh validate appcast.xml rm -f "$KEYFILE" rm -f "$PREVIOUS_APPCAST" git config user.name "github-actions[bot]" @@ -115,3 +125,17 @@ jobs: git commit -m "ci: nightly appcast (${VERSION}) [skip ci]" git push origin HEAD:nightly fi + + - name: Roll current notes into beta accumulator + run: | + set -euo pipefail + ./Scripts/notes.sh finalize nightly + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Changes/Current.md Changes/Beta.md + if git diff --cached --quiet; then + echo "release notes unchanged — nothing to commit." + else + git commit -m "ci: roll nightly notes (${VERSION}) [skip ci]" + git push origin HEAD:nightly + fi diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b360ff24..17a6fd69 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,6 +16,7 @@ permissions: jobs: pr: runs-on: macos-26 + timeout-minutes: 30 steps: - uses: actions/checkout@v7 with: @@ -33,7 +34,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) + Documentation/**|README.md|Packages/*/README.md|Packages/*/Sources/*/*.docc/**|Changes/**|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 @@ -47,12 +48,13 @@ jobs: NO_RELEASE_NOTE: ${{ (contains(github.event.pull_request.labels.*.name, 'no-release-note') || github.actor == 'dependabot[bot]') && '1' || '' }} run: | set -euo pipefail + brew list ripgrep >/dev/null 2>&1 || brew install ripgrep git fetch origin "${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}" - ./scripts/ci-validate.sh --base-ref "origin/${{ github.base_ref }}" --require-release-note + ./Scripts/check.sh repo --base-ref "origin/${{ github.base_ref }}" --require-release-note - name: Release script fixtures if: steps.changes.outputs.material == 'true' - run: ./scripts/test-release-scripts.sh + run: ./Scripts/check.sh release - name: Build & test if: steps.changes.outputs.material == 'true' @@ -60,18 +62,22 @@ jobs: set -euo pipefail swift build -c release swift test - ./scripts/check-generated-clean.sh + ./Scripts/check.sh generated + + - name: Native Xcode tests + if: steps.changes.outputs.material == 'true' + run: xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug -destination 'platform=macOS' test - name: Bundle smoke validation if: steps.changes.outputs.material == 'true' run: | set -euo pipefail - BUILD=$(CHANNEL=nightly ./scripts/version-info.sh build) - SHA=$(CHANNEL=nightly ./scripts/version-info.sh sha) - VERSION=$(CHANNEL=nightly BUILD="${BUILD}" SHA="${SHA}" ./scripts/version-info.sh version) - CHANNEL=nightly BUILD="${BUILD}" VERSION="${VERSION}" ./scripts/bundle.sh release - VERSION="${VERSION}" BUILD="${BUILD}" ./scripts/validate-bundle.sh Contained.app - ./scripts/check-generated-clean.sh + BUILD=$(CHANNEL=nightly ./Scripts/package.sh version build) + SHA=$(CHANNEL=nightly ./Scripts/package.sh version sha) + VERSION=$(CHANNEL=nightly BUILD="${BUILD}" SHA="${SHA}" ./Scripts/package.sh version version) + CHANNEL=nightly BUILD="${BUILD}" VERSION="${VERSION}" ./Scripts/package.sh app release + VERSION="${VERSION}" BUILD="${BUILD}" ./Scripts/package.sh smoke Contained.app + ./Scripts/check.sh generated - name: Skip build notice if: steps.changes.outputs.material != 'true' diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml index f6f0552b..91e2d7ec 100644 --- a/.github/workflows/stable.yml +++ b/.github/workflows/stable.yml @@ -16,13 +16,21 @@ on: # Docs/meta-only changes don't need a build. Changelog changes do, because the changelog is # bundled into the app and drives GitHub/Sparkle release notes. paths-ignore: - - 'docs/**' + - 'Documentation/**' - 'README.md' + - 'Packages/*/README.md' + - 'Packages/*/Sources/*/*.docc/**' + - 'Changes/**' - '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: @@ -37,6 +45,7 @@ permissions: jobs: stable: runs-on: macos-26 + timeout-minutes: 30 env: SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} GH_TOKEN: ${{ github.token }} @@ -59,34 +68,35 @@ jobs: if: steps.guard.outputs.build == 'true' run: | set -euo pipefail - ./scripts/ci-validate.sh - ./scripts/test-release-scripts.sh + brew list ripgrep >/dev/null 2>&1 || brew install ripgrep + ./Scripts/check.sh repo + ./Scripts/check.sh release swift build -c release swift test - ./scripts/check-generated-clean.sh + ./Scripts/check.sh generated - name: Bundle (ad-hoc signed) + DMG if: steps.guard.outputs.build == 'true' run: | set -euo pipefail git fetch origin nightly:refs/remotes/origin/nightly - SHA=$(CHANNEL=stable ./scripts/version-info.sh sha) - BUILD=$(CHANNEL=stable BUILD_SOURCE_REF=origin/nightly SHA="${SHA}" ./scripts/version-info.sh build) - VERSION=$(CHANNEL=stable BUILD="${BUILD}" SHA="${SHA}" ./scripts/version-info.sh version) + SHA=$(CHANNEL=stable ./Scripts/package.sh version sha) + BUILD=$(CHANNEL=stable BUILD_SOURCE_REF=origin/nightly SHA="${SHA}" ./Scripts/package.sh version build) + VERSION=$(CHANNEL=stable BUILD="${BUILD}" SHA="${SHA}" ./Scripts/package.sh version version) export BUILD SHA VERSION { - echo "BASE_VERSION=$(./scripts/version-info.sh base)" + echo "BASE_VERSION=$(./Scripts/package.sh version base)" echo "BUILD=${BUILD}" echo "SHA=${SHA}" echo "VERSION=${VERSION}" } >> "$GITHUB_ENV" - CHANNEL=stable BUILD="${BUILD}" VERSION="${VERSION}" ./scripts/bundle.sh release - VERSION="${VERSION}" BUILD="${BUILD}" ./scripts/validate-bundle.sh Contained.app - ./scripts/check-generated-clean.sh + CHANNEL=stable BUILD="${BUILD}" VERSION="${VERSION}" ./Scripts/package.sh app release + VERSION="${VERSION}" BUILD="${BUILD}" ./Scripts/package.sh smoke Contained.app + ./Scripts/check.sh generated mkdir -p updates brew install create-dmg - ./scripts/make-dmg.sh stable Contained.app "updates/Contained-${VERSION}.dmg" - CHANNEL=stable VERSION_VALUE="${VERSION}" ./scripts/release-body.sh > updates/release-notes.md + ./Scripts/package.sh dmg stable Contained.app "updates/Contained-${VERSION}.dmg" + CHANNEL=stable VERSION_VALUE="${VERSION}" ./Scripts/notes.sh body > updates/release-notes.md { echo "Contained ${VERSION} @ ${SHA}." echo @@ -120,8 +130,8 @@ jobs: [ -x "$BIN/generate_appcast" ] || BIN="$(dirname "$(find .build -type f -name generate_appcast | head -1)")" ED_KEY_FILE="$KEYFILE" \ DOWNLOAD_PREFIX="https://github.com/tdeverx/contained-app/releases/download/v${VERSION}/" \ - ./scripts/appcast.sh "$BIN" updates - CHANNEL=stable ./scripts/validate-appcast.sh appcast.xml + ./Scripts/appcast.sh generate "$BIN" updates + CHANNEL=stable ./Scripts/appcast.sh validate appcast.xml rm -f "$KEYFILE" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -139,10 +149,10 @@ jobs: NIGHTLY_WORKTREE="$(mktemp -d)" rm -rf "$NIGHTLY_WORKTREE" git worktree add "$NIGHTLY_WORKTREE" origin/nightly - ./scripts/promote-appcast-to-nightly.sh "$PROMOTED_APPCAST" "$NIGHTLY_WORKTREE/appcast.xml" + ./Scripts/appcast.sh promote "$PROMOTED_APPCAST" "$NIGHTLY_WORKTREE/appcast.xml" ( cd "$NIGHTLY_WORKTREE" - CHANNEL=nightly "$GITHUB_WORKSPACE/scripts/validate-appcast.sh" appcast.xml + CHANNEL=nightly "$GITHUB_WORKSPACE/Scripts/appcast.sh" validate appcast.xml git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add appcast.xml @@ -155,3 +165,18 @@ jobs: ) git worktree remove "$NIGHTLY_WORKTREE" --force rm -f "$PROMOTED_APPCAST" + + - name: Clear stable hotfix release notes + if: steps.guard.outputs.build == 'true' + run: | + set -euo pipefail + ./Scripts/notes.sh finalize stable + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Changes/Current.md + if git diff --cached --quiet; then + echo "stable current notes already clear." + else + git commit -m "ci: clear stable current notes (${VERSION}) [skip ci]" + git push origin HEAD:stable + fi 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 1a1bcee8..1dd04706 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,27 +4,37 @@ 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. -- `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. -- `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`. +- 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/package.sh app` 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, 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. +- `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. +- `Documentation/` is structured by audience and ownership. User-facing behavior or workflow changes should update the matching page under `Documentation/App`, `Documentation/Features`, `Documentation/Development`, `Documentation/Architecture`, or `Documentation/Release`. +- `Documentation/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 maintained repo surfaces use PascalCase roots such as `Documentation`, `Scripts`, and `Changes`. ## Branches And Updates - Default target branch for ongoing work is `nightly` unless the maintainer says otherwise. -- Do not compute release build numbers directly in workflows or scripts. Use `scripts/version-info.sh`. +- Do not compute release build numbers directly in workflows or scripts. Use `Scripts/package.sh version`. - `CFBundleVersion` must be retained when a nightly build is promoted to beta or stable. - Stable and beta own their branch appcasts; nightly is a superset feed that also receives promoted beta/stable appcast items. - Appcast-only bot commits must not trigger release loops. Keep `appcast.xml` path-ignored in workflows and use `[skip ci]` for appcast bot commits. - CodeQL uses the checked-in advanced setup at `.github/workflows/codeql.yml`. Actions workflow analysis runs on PRs, pushes, and the weekly baseline; Swift analysis is scheduled/manual because Swift CodeQL currently takes too long to be a healthy per-PR gate. Keep appcast, docs, and release-note-only paths ignored there too. -- PR/release workflows run `scripts/ci-validate.sh`; keep that script fast and focused on repository invariants before expensive Swift builds. -- PR CI enforces release-note coverage for material changes. Add a changelog/change fragment, or use the `no-release-note` label only for docs/meta/dependency-only maintenance. +- PR/release workflows run `Scripts/check.sh repo`; keep that script fast and focused on repository invariants before expensive Swift builds. +- PR CI enforces release-note coverage for material changes. Add `Changes/Current.md` or curated changelog notes, or use the `no-release-note` label only for documentation, metadata, or dependency-only maintenance. - Release workflows validate built bundles and generated appcasts before publishing or committing feed changes. ## 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 `Documentation/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 @@ -34,29 +44,45 @@ This file is the working contract for coding agents in this repository. Follow i - Nightly ships `Changes Since Last Nightly` plus `Full Release Notes`. - Keep `CHANGELOG.md` ordered with `Unreleased` above released version sections; in-app What's New and generated Sparkle notes should both show channel/build changes before full version notes. -- Keep `CHANGELOG.md` curated and version-level. Put PR/build deltas in change fragments, not as +- Keep `CHANGELOG.md` curated and version-level. Put PR/build deltas in `Changes/Current.md`, not as a running implementation inventory. -- Prefer one committed change fragment per PR or user-facing change, not one file per commit. -- Use `changes/unreleased/YYYYMMDD-short-slug.md` for normal fragments. Use `changes/beta/` or `changes/nightly/` only for channel-specific notes. -- `scripts/collect-changes.sh` can compile fragments from a directory or git range. -- When no explicit `CHANGES`/`CHANGES_DIR` source is provided, Beta/Nightly release notes use `scripts/changes-since-release.sh` to derive the build-change section from the previous matching appcast item and the changelog/change-fragment git delta. +- Use one committed `Changes/Current.md` update per material PR or user-facing change. +- Nightly release generation consumes `Changes/Current.md`; after a successful nightly, CI prepends it to `Changes/Beta.md` and clears it. +- Beta release generation consumes `Changes/Beta.md`; after a successful beta, CI clears it. +- Stable release generation uses `CHANGELOG.md`; direct stable hotfix content in `Changes/Current.md` is included and then cleared after successful consumption. - Generated release-note scratch files belong under `updates/`, `.release/`, or `.release-notes/`; do not commit them. ## Design And UI Rules -- Reuse design-system primitives before adding local styling: `PanelHeader`, `PanelSection`, `PanelField`, `ResourceGlassCard`, `CommandPreviewBar`, `TintSelector`, `ToolbarIconButton`, and `Tokens`. +- 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 CLI actions behind `ContainerCommands` and `ContainerClient`; do not assemble argv inline in SwiftUI. -- 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 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 + 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 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 @@ -69,39 +95,41 @@ swift test git diff --check ``` -For release scripts/workflows: +For release Scripts/workflows: ```sh -./scripts/ci-validate.sh -./scripts/test-release-scripts.sh +./Scripts/check.sh repo +./Scripts/check.sh release swift test --filter UpdaterControllerTests ``` For CodeQL/workflow changes: ```sh -./scripts/ci-validate.sh +./Scripts/check.sh repo swift build --arch arm64 --product Contained ``` For generated release artifacts: ```sh -VERSION="$VERSION" BUILD="$BUILD" ./scripts/validate-bundle.sh Contained.app -CHANNEL=nightly ./scripts/validate-appcast.sh appcast.xml -./scripts/check-generated-clean.sh +VERSION="$VERSION" BUILD="$BUILD" ./Scripts/package.sh smoke Contained.app +CHANNEL=nightly ./Scripts/appcast.sh validate appcast.xml +./Scripts/check.sh generated ``` For app/UI changes: ```sh -./scripts/bundle.sh debug +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug build +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug test +./Scripts/package.sh app debug open Contained.app ``` -Sync the bundled changelog before verification when release notes changed: +The app bundle syncs `Sources/ContainedApp/Resources/CHANGELOG.md` from `CHANGELOG.md` during packaging; `Scripts/check.sh repo` verifies that it is already synced before CI builds: ```sh -./scripts/sync-changelog-resource.sh -./scripts/sync-changelog-resource.sh --check +./Scripts/package.sh app debug +./Scripts/check.sh repo ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index d5518cc3..5f517a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,105 +6,79 @@ ### 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, 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. -- 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, inspect/history views, 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. +- Local image and tag browsing with grouped references, run actions, history pages, tagging, pushing, saving archives, loading OCI `.tar` archives, pruning, and update status. +- 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, inspection, deletion, prune actions, local card styling, and mount-aware container grouping. -- Network browsing, creation, inspection, 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 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, 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: `ResourceGlassCard`, `GlassSurface`, `GlassButton`, `GlassOptionTile`, `MorphPanelScaffold`, `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. - -### 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 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. -- `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. +- 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. - 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 -- 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 fd054870..4cea66de 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -2,16 +2,19 @@ * @tdeverx # Core app areas. -/Sources/ContainedCore/ @tdeverx +/Packages/ContainedCore/ @tdeverx +/Packages/ContainedUI/ @tdeverx +/Packages/ContainedUX/ @tdeverx /Sources/Contained/ @tdeverx +/Sources/ContainedApp/ @tdeverx /Tests/ @tdeverx # Repository operations. /.github/ @tdeverx -/scripts/ @tdeverx -/docs/ @tdeverx +/Scripts/ @tdeverx +/Documentation/ @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/Changes/Beta.md b/Changes/Beta.md new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/Changes/Beta.md @@ -0,0 +1 @@ + diff --git a/Changes/Current.md b/Changes/Current.md new file mode 100644 index 00000000..6313a5aa --- /dev/null +++ b/Changes/Current.md @@ -0,0 +1,67 @@ +### Changed + +- Replaced avoidable AppKit panels, pasteboard writes, haptics, and WebView bridges with SwiftUI-native file, copy, feedback, and WebKit surfaces while keeping AppKit isolated to platform wrappers, SwiftTerm hosting, and material/vibrancy support. + +### Fixed + +- Bundle the generated current release notes artifact into local app builds so in-app What's New matches the CI/Appcast release-note source. +- Harden container recreate after stale runtime snapshots and reduce repeated optional-runtime endpoint errors in Activity. +- Validate replacement and rollback recipes before destructive container edits, automatically restore the original after replacement failure, and retain its recipe when restoration also fails. +- Link Core fixtures only into the native Xcode test target and run that suite in PR CI so the Xcode and SwiftPM package graphs cannot drift silently. +- Keep raw CLI commands, stderr, paths, environment values, and credentials out of persistent Activity and public Console diagnostics while retaining runtime detail in immediate errors. + +### Runtime & Images + +- Added dormant Docker CLI adapter groundwork in `ContainedCore`, with Docker command builders, decoders, create/Compose translation, runtime descriptors, CLI discovery, and direct adapter tests kept out of the default app registry. +- Kept Docker disabled in the default runtime registry so Contained discovers, probes, and offers Apple `container` only until a Docker provider model is chosen. +- Added runtime-scoped resource routing and image/tag models so future runtimes can aggregate containers and expose runtime-specific local image availability through each resource's owning runtime. +- Added runtime-scoped settings records; Apple service, kernel, and DNS controls remain Apple-only while dormant Docker path/endpoint UI stays hidden by default. +- Kept V1 image storage runtime-owned while centralizing registry search, remote digest/update metadata, and normalized tag grouping across runtimes. +- Scoped local image update comparisons to each runtime-owned tag so one runtime can be current while another has an update available for the same image reference. +- Removed implicit runtime fallbacks so create, pull, build, load, push, registry, network, volume, logs, stats, terminal, and migration actions route through an explicit runtime or an existing resource owner. +- Split registered runtime clients from ready runtime endpoints while keeping Apple-only service controls explicitly Apple-scoped. +- Hardened the Core runtime boundary with module-driven Apple container and Docker adapters, runtime-keyed CLI overrides/readiness, runtime-owned schema profiles, and static checks preventing concrete runtime behavior from leaking out of `Runtimes/**`. +- Moved the built-in module registry into shared `Runtime` infrastructure, kept `Runtimes/**` for concrete adapters only, and made canonical schema fields runtime-neutral while adapter profiles own support state and tips. +- Tightened the runtime cleanup pass by splitting Core runtime/Compose helpers, surfacing partial inventory failures, removing app-side Apple defaults from unowned flows, and recording typed app-database failures instead of crashing on corrupt records. +- Replaced alert-based runtime picking for no-context Compose/image archive imports with an in-app runtime selection sheet that preselects only when one compatible runtime is available. +- Moved migration visibility and runtime move progress into the app database/Core migration flow, retaining disappeared resources only when they carry Contained-owned value. +- Fixed the container-card morph regression by keying measured card frames and expanded overlays by runtime-scoped container IDs. + +### 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 schema/import models inside `ContainedCore`. +- Added open-ended runtime identifiers and runtime-scoped resource routing so future Docker, 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 + +- 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`. +- Added colocated SwiftUI previews for design-system and UX primitives, removed the separate preview-only source directories, and documented that design previews live beside each element declaration. +- Introduced package-internal `ContainedUI/Shared` helpers for repeated capsule, swatch, label, row, and surface-rendering anatomy while keeping app-facing calls on the public `UI.*` routes. +- 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. +- Reworked Run/Edit storage into Storage Groups, where each group can hold multiple host/internal paths and can optionally be backed by one runtime volume with Contained-managed symlinks. +- Reworked Run/Edit storage rows into one native form section per storage group, with labeled host folder, internal path, and explicit Read only/Read/Write access controls instead of a compact toggle. +- Moved Run/Edit and Settings back onto native grouped SwiftUI forms and section footers, added a Run/Edit header page switcher, and moved Run/Save to the command-preview footer while preserving row-level info popovers. +- Made Reveal CLI command previews render from each selected runtime descriptor instead of hardcoding the Apple `container` executable in the reusable UI package. +- Kept grouped form viewports transparent so only native section backgrounds carry the form surface. +- Added form label state colors so field-specific errors render red and changed-from-default rows render blue. +- Reworked the Volumes page into vertical runtime-volume and host-path mount card groups, with runtime-scoped inventory keys so same-named volumes from future runtimes remain distinct. +- 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. +- Capped PR, Nightly, Beta, and Stable macOS CI jobs with explicit 30-minute timeouts so wedged SwiftPM builds do not burn the six-hour GitHub Actions default. +- 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/Contained.xcodeproj/project.pbxproj b/Contained.xcodeproj/project.pbxproj new file mode 100644 index 00000000..f60bfeb4 --- /dev/null +++ b/Contained.xcodeproj/project.pbxproj @@ -0,0 +1,459 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + 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 /* 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 */; }; + 0E2100010000000000000009 /* AppErrorPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2100020000000000000009 /* AppErrorPresentationTests.swift */; }; + 0E210001000000000000000A /* AppDatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E210002000000000000000A /* AppDatabaseTests.swift */; }; + 0E210001000000000000000B /* AppKitBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E210002000000000000000B /* AppKitBoundaryTests.swift */; }; + 0E210001000000000000000C /* ContainerRuntimeMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E210002000000000000000C /* ContainerRuntimeMigrationTests.swift */; }; + 0E210001000000000000000D /* DesignSystemBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E210002000000000000000D /* DesignSystemBoundaryTests.swift */; }; + 0E210001000000000000000E /* PreviewFixtureMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E210002000000000000000E /* PreviewFixtureMappingTests.swift */; }; + 0E210001000000000000000F /* RuntimeTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E210002000000000000000F /* RuntimeTestSupport.swift */; }; + 0E2100010000000000000010 /* ContainedCore in Frameworks */ = {isa = PBXBuildFile; productRef = 0E21000B0000000000000001 /* ContainedCore */; }; + 0E2100010000000000000011 /* ContainedCoreFixtures in Frameworks */ = {isa = PBXBuildFile; productRef = 0E21000B0000000000000002 /* ContainedCoreFixtures */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 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 /* Documentation */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Documentation; 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 /* 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; }; + 0E2100020000000000000009 /* AppErrorPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppErrorPresentationTests.swift; sourceTree = ""; }; + 0E210002000000000000000A /* AppDatabaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDatabaseTests.swift; sourceTree = ""; }; + 0E210002000000000000000B /* AppKitBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKitBoundaryTests.swift; sourceTree = ""; }; + 0E210002000000000000000C /* ContainerRuntimeMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerRuntimeMigrationTests.swift; sourceTree = ""; }; + 0E210002000000000000000D /* DesignSystemBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystemBoundaryTests.swift; sourceTree = ""; }; + 0E210002000000000000000E /* PreviewFixtureMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewFixtureMappingTests.swift; sourceTree = ""; }; + 0E210002000000000000000F /* RuntimeTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeTestSupport.swift; sourceTree = ""; }; +/* 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 */, + 0E2100010000000000000010 /* ContainedCore in Frameworks */, + 0E2100010000000000000011 /* ContainedCoreFixtures in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0E2000040000000000000001 = { + isa = PBXGroup; + children = ( + 0E2000040000000000000002 /* Contained Xcode App */, + 0E2100040000000000000001 /* ContainedAppTests */, + 0E2000020000000000000003 /* Package.swift */, + 0E2000020000000000000004 /* Sources */, + 0E2000020000000000000005 /* Tests */, + 0E2000020000000000000006 /* Packages */, + 0E2000020000000000000007 /* Xcode */, + 0E2000020000000000000008 /* Resources */, + 0E2000020000000000000009 /* Documentation */, + 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 = ( + 0E2100020000000000000009 /* AppErrorPresentationTests.swift */, + 0E210002000000000000000A /* AppDatabaseTests.swift */, + 0E210002000000000000000B /* AppKitBoundaryTests.swift */, + 0E2100020000000000000001 /* AppLoggingTests.swift */, + 0E210002000000000000000C /* ContainerRuntimeMigrationTests.swift */, + 0E2100020000000000000002 /* ContainersStoreRefreshTests.swift */, + 0E210002000000000000000D /* DesignSystemBoundaryTests.swift */, + 0E2100020000000000000003 /* MigrationTests.swift */, + 0E2100020000000000000004 /* PaletteSearchTests.swift */, + 0E210002000000000000000E /* PreviewFixtureMappingTests.swift */, + 0E210002000000000000000F /* RuntimeTestSupport.swift */, + 0E2100020000000000000005 /* ContainerFormStateTests.swift */, + 0E2100020000000000000006 /* SystemVolumeInventoryTests.swift */, + 0E2100020000000000000007 /* UpdaterControllerTests.swift */, + ); + path = Tests/ContainedAppTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0E2000050000000000000001 /* Contained */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0E2000060000000000000002 /* Build configuration list for PBXNativeTarget "Contained" */; + buildPhases = ( + 0E2000070000000000000001 /* Sources */, + 0E2000030000000000000001 /* Frameworks */, + 0E2000080000000000000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Contained; + packageProductDependencies = ( + 0E20000B0000000000000001 /* ContainedApp */, + ); + productName = Contained; + productReference = 0E200002000000000000000D /* Contained.app */; + productType = "com.apple.product-type.application"; + }; + 0E2100050000000000000001 /* ContainedAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0E2100060000000000000001 /* Build configuration list for PBXNativeTarget "ContainedAppTests" */; + buildPhases = ( + 0E2100070000000000000001 /* Sources */, + 0E2100030000000000000001 /* Frameworks */, + 0E2100080000000000000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ContainedAppTests; + packageProductDependencies = ( + 0E20000B0000000000000001 /* ContainedApp */, + 0E21000B0000000000000001 /* ContainedCore */, + 0E21000B0000000000000002 /* ContainedCoreFixtures */, + ); + productName = ContainedAppTests; + productReference = 0E2100020000000000000008 /* ContainedAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0E2000090000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 0E2000050000000000000001 = { + CreatedOnToolsVersion = 26.0; + }; + 0E2100050000000000000001 = { + CreatedOnToolsVersion = 26.0; + }; + }; + }; + buildConfigurationList = 0E2000060000000000000001 /* Build configuration list for PBXProject "Contained" */; + compatibilityVersion = "Xcode 16.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 0E2000040000000000000001; + packageReferences = ( + 0E20000A0000000000000001 /* XCLocalSwiftPackageReference "." */, + 0E21000A0000000000000001 /* XCLocalSwiftPackageReference "Packages/ContainedCore" */, + ); + productRefGroup = 0E2000040000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 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 = ( + 0E2100010000000000000009 /* AppErrorPresentationTests.swift in Sources */, + 0E210001000000000000000A /* AppDatabaseTests.swift in Sources */, + 0E210001000000000000000B /* AppKitBoundaryTests.swift in Sources */, + 0E2100010000000000000001 /* AppLoggingTests.swift in Sources */, + 0E210001000000000000000C /* ContainerRuntimeMigrationTests.swift in Sources */, + 0E2100010000000000000002 /* ContainersStoreRefreshTests.swift in Sources */, + 0E210001000000000000000D /* DesignSystemBoundaryTests.swift in Sources */, + 0E2100010000000000000003 /* MigrationTests.swift in Sources */, + 0E2100010000000000000004 /* PaletteSearchTests.swift in Sources */, + 0E210001000000000000000E /* PreviewFixtureMappingTests.swift in Sources */, + 0E210001000000000000000F /* RuntimeTestSupport.swift in Sources */, + 0E2100010000000000000005 /* ContainerFormStateTests.swift in Sources */, + 0E2100010000000000000006 /* SystemVolumeInventoryTests.swift in Sources */, + 0E2100010000000000000007 /* UpdaterControllerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 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; + }; + 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; + }; + 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; + }; + 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; + }; + 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; + }; + 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 */ + 0E2000060000000000000001 /* Build configuration list for PBXProject "Contained" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0E20000C0000000000000001 /* Debug */, + 0E20000C0000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 0E2000060000000000000002 /* Build configuration list for PBXNativeTarget "Contained" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0E20000C0000000000000003 /* Debug */, + 0E20000C0000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 0E2100060000000000000001 /* Build configuration list for PBXNativeTarget "ContainedAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0E21000C0000000000000001 /* Debug */, + 0E21000C0000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 0E20000A0000000000000001 /* XCLocalSwiftPackageReference "." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = .; + }; + 0E21000A0000000000000001 /* XCLocalSwiftPackageReference "Packages/ContainedCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/ContainedCore; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 0E20000B0000000000000001 /* ContainedApp */ = { + isa = XCSwiftPackageProductDependency; + package = 0E20000A0000000000000001 /* XCLocalSwiftPackageReference "." */; + productName = ContainedApp; + }; + 0E21000B0000000000000001 /* ContainedCore */ = { + isa = XCSwiftPackageProductDependency; + package = 0E21000A0000000000000001 /* XCLocalSwiftPackageReference "Packages/ContainedCore" */; + productName = ContainedCore; + }; + 0E21000B0000000000000002 /* ContainedCoreFixtures */ = { + isa = XCSwiftPackageProductDependency; + package = 0E21000A0000000000000001 /* XCLocalSwiftPackageReference "Packages/ContainedCore" */; + productName = ContainedCoreFixtures; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 0E2000090000000000000001 /* Project object */; +} 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/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 new file mode 100644 index 00000000..9f74c253 --- /dev/null +++ b/Contained.xcodeproj/xcshareddata/xcschemes/Contained.xcscheme @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/ContainedPreviews.xcscheme b/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPreviews.xcscheme new file mode 100644 index 00000000..b9816fe8 --- /dev/null +++ b/Contained.xcodeproj/xcshareddata/xcschemes/ContainedPreviews.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Contained.xcworkspace/contents.xcworkspacedata b/Contained.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..0cc90ad5 --- /dev/null +++ b/Contained.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,16 @@ + + + + + + + + + + + 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/docs/wiki/Home.md b/Documentation/App/Home.md similarity index 50% rename from docs/wiki/Home.md rename to Documentation/App/Home.md index f9ae7265..7e742f99 100644 --- a/docs/wiki/Home.md +++ b/Documentation/App/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 @@ -8,52 +8,52 @@ A native, SwiftUI-first macOS app for Apple's [`container`](https://github.com/a git clone https://github.com/tdeverx/contained-app.git cd contained-app swift build && swift test -./scripts/bundle.sh debug +./Scripts/package.sh app debug 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](/Documentation/App/Installation.md). ## 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). - **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](/Documentation/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](/Documentation/Features/Features.md)** — overview and links into each feature area +- **[Installation](/Documentation/App/Installation.md)** — requirements, install, build from source, updates +- **[Keyboard Shortcuts](/Documentation/App/Keyboard-Shortcuts.md)** — shortcut gates and current bindings +- **[Troubleshooting](/Documentation/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](/Documentation/Features/Creation-Workflow.md)** — the shared create/edit/search/build front door +- **[Run / Edit Form](/Documentation/Features/Run-Edit-Form.md)** — UI-first mapping of `container run` flags +- **[Compose Import](/Documentation/Features/Compose-Import.md)** — compose-to-run-form behavior +- **[Command Palette](/Documentation/Features/Command-Palette.md)** — the app-wide command index and palette rules +- **[Updates](/Documentation/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](/Documentation/Features/Containers.md)** +- **[Images](/Documentation/Features/Images.md)** +- **[Resources](/Documentation/Features/Resources.md)** +- **[System & Settings](/Documentation/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](/Documentation/Architecture/Architecture.md)** — how the CLI wrapper, stores, and design system fit together +- **[Design System](/Documentation/Architecture/Design-System.md)** — shared Liquid Glass components and layout rules +- **[Contributing](/Documentation/Development/Contributing.md)** — layout, conventions, and the pre-PR checklist +- **[Release](/Documentation/Release/Release.md)** — the maintainer signing / notarization / appcast runbook ## Updates diff --git a/docs/wiki/Installation.md b/Documentation/App/Installation.md similarity index 63% rename from docs/wiki/Installation.md rename to Documentation/App/Installation.md index 38a2fd87..f572328c 100644 --- a/docs/wiki/Installation.md +++ b/Documentation/App/Installation.md @@ -12,28 +12,35 @@ Download the latest `Contained.dmg` from [Releases](https://github.com/tdeverx/c On first launch, the bootstrap screen checks for the `container` CLI and the runtime service, and helps you start it. -> If the CLI isn't found, set its path in **Settings → General → Container CLI path**, or install it from the [container releases](https://github.com/apple/container). +> If the CLI isn't found, set its path in **Settings → Runtime**, or install it from the [container releases](https://github.com/apple/container). ## Build from source -This is a Swift Package — there is no `.xcodeproj`. +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/package.sh app` remain the release packaging path. ```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: +From the command line: ```sh swift build # debug build swift test # run the unit tests -./scripts/bundle.sh debug # assemble a runnable development Contained.app +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug build +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug test +./Scripts/package.sh app debug # assemble a runnable development Contained.app open Contained.app ``` -`scripts/bundle.sh [debug|release]` compiles the executable and assembles the `.app` (Info.plist, icon, embedded Sparkle). The `.app` is a build artifact and is git-ignored. +`Scripts/package.sh app [debug|release]` compiles the executable and assembles the `.app` (Info.plist, icon, embedded Sparkle). The `.app` is a build artifact and is git-ignored. ## Updates diff --git a/docs/wiki/Keyboard-Shortcuts.md b/Documentation/App/Keyboard-Shortcuts.md similarity index 100% rename from docs/wiki/Keyboard-Shortcuts.md rename to Documentation/App/Keyboard-Shortcuts.md diff --git a/Documentation/App/Localization.md b/Documentation/App/Localization.md new file mode 100644 index 00000000..99727c2f --- /dev/null +++ b/Documentation/App/Localization.md @@ -0,0 +1,74 @@ +# Localization + +Contained is English-only for now, but app copy should already be routed through +localization-ready APIs. + +## Ownership + +- `Sources/ContainedApp` owns product-facing strings and the app localization catalog. +- `ContainedUI` and `ContainedUX` are building-block packages: + they own structure and visuals, not app copy. +- `ContainedCore` owns display-neutral semantic localization where the backend + needs to stand alone: schema labels/help, validation messages, runtime + capability reasons, Compose/projection warnings, and package-error fallback + descriptions. Its localization APIs support downstream override/wrap behavior. + +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. + +Package failures follow the same ownership rule. Reusable targets should throw +typed errors with stable codes/context, usually by conforming to +`Core.Error.PackageError`. Core may provide display-neutral fallback +descriptions for those codes. The app maps 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 +for immediate runtime-detail presentation unless an adapter can map it to a known +typed case. Do not persist that detail in Activity or emit it publicly to Console. + +## App Strings + +Use `AppText` for reusable app-owned labels and dynamic templates: + +```swift +UI.Toolbar.SearchField(text: $query, + prompt: "Search this page", + clearSearchLabel: AppText.clearSearch, + focused: $focused, + onClear: { query = "" }) { + EmptyView() +} + +UI.Control.TintSelector(selection: $settings.accentTint) { + $0.localizedDisplayName +} + +UI.Action.SelectionBar(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 +`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/ContainedApp/Resources/Localizable.xcstrings`. `ContainedCore` also +declares package resources for its semantic defaults. English currently comes +from localized resources plus `String(localized:defaultValue:bundle:)` +fallbacks. Future translations can fill the catalogs without changing package +APIs. diff --git a/Documentation/App/System-Settings.md b/Documentation/App/System-Settings.md new file mode 100644 index 00000000..62ca380e --- /dev/null +++ b/Documentation/App/System-Settings.md @@ -0,0 +1,77 @@ +# System & Settings + +System and Settings own runtime status, app preferences, update controls, +experimental gates, registries, and local data management. + +## System + +The System page surfaces runtime status, runtime details, resource usage, +image update status, and app/runtime actions. System content is shared between +the classic sidebar page and toolbar morph panel when the experimental toolbar +UI is enabled. + +Runtime controls are rendered from descriptor capabilities. Apple container +currently exposes service lifecycle, kernel, and DNS controls; future runtimes +can expose their own endpoint or management sections without becoming a global +default runtime. Docker adapter groundwork exists in Core but is dormant, so +Docker runtime controls are not shown until Contained has a provider model that +does not depend on Docker Desktop. Privileged kernel or DNS operations may +trigger prompts handled by the CLI or macOS. Contained does not ask for or store +administrator credentials. + +## Settings tabs + +Settings tabs use native grouped SwiftUI forms inside the shared Settings panel. +Editable rows use the same label-state rules as Run/Edit: red means that row has +a specific issue, and blue means the setting differs from the shipped default. + +- 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: runtime reachability, runtime path overrides, and capability-scoped service/kernel/DNS or endpoint controls. +- Registries: registry login/logout and credential management. +- Updates: app channel, Sparkle checks, release notes, and image update cadence. +- Experimental: opt-in feature gates. +- About: app and runtime information. + +## Experimental gates + +Experimental features default off: + +- Toolbar-first UI +- Toolbar panel navigation +- Sidebar navigation +- Command palette +- Docker Hub search +- Compose import +- Image build workspace +- Keyboard shortcuts + +Each gate hides or disables the matching menu commands, toolbar affordances, and +creation entry points where applicable. + +## Local data + +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 the available runtime machine CPU and + memory resources when available, 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/Documentation/App/Troubleshooting.md similarity index 76% rename from docs/wiki/Troubleshooting.md rename to Documentation/App/Troubleshooting.md index 33ae7373..f546dea1 100644 --- a/docs/wiki/Troubleshooting.md +++ b/Documentation/App/Troubleshooting.md @@ -5,7 +5,7 @@ Contained shells out to Apple's `container` binary. If onboarding can't find it: - Install it from the [container releases](https://github.com/apple/container) (it lands in `/usr/local/bin` or `/opt/homebrew/bin`). -- If it's somewhere else, set the full path in **Settings → General → Container CLI path**. +- If it's somewhere else, set the full path in **Settings → Runtime**. - Confirm in a terminal: `container --version` should print `1.0.0`. ## The runtime service won't start @@ -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](/Documentation/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 @@ -31,7 +31,9 @@ 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**. +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 diff --git a/docs/wiki/Updates.md b/Documentation/App/Updates.md similarity index 73% rename from docs/wiki/Updates.md rename to Documentation/App/Updates.md index 38e61324..20cf7581 100644 --- a/docs/wiki/Updates.md +++ b/Documentation/App/Updates.md @@ -40,10 +40,12 @@ Fresh installs default to Nightly during pre-1.0 development. ## Release notes Release notes are generated from full release notes plus channel/build changes. -By default both come from `CHANGELOG.md`; `RELEASE_NOTES.md`, `CHANGES.md`, and -`CHANGES_DIR` are supported when maintainers want split sources. The release -scripts embed the composed notes into Sparkle appcasts, and the bundled in-app -What's New view uses the same order: +Full version notes come from `CHANGELOG.md`; Nightly build changes come from +`Changes/Current.md`; Beta build changes come from `Changes/Beta.md`. Explicit +`RELEASE_NOTES`, `CHANGES`, and `CHANGES_DIR` overrides remain available for +local release maintenance and fixtures. The release scripts embed the composed +notes into Sparkle appcasts, and the bundled in-app What's New view uses the +same order: - channel/build changes first, when the channel has them - full version notes second @@ -54,15 +56,14 @@ Generated notes follow the channel: - Beta: `Changes Since Last Beta` plus `Full Release Notes`. - Nightly: `Changes Since Last Nightly` plus `Full Release Notes`. -For Beta and Nightly, the generated build-change section is a real channel -delta. When no explicit `CHANGES`/`CHANGES_DIR` source is provided, the release -scripts read the previous matching appcast item, extract its commit SHA, and use -the changelog/change-fragment git diff from that commit to the build being -published. The full version notes still come from the base version section. +For Nightly, CI consumes `Changes/Current.md`, then prepends it into +`Changes/Beta.md` and clears it. For Beta, CI consumes `Changes/Beta.md` and +clears it after the release has been published. The full version notes still +come from the base version section. -`scripts/ci-validate.sh` checks that ordering before CI builds, and -`scripts/sync-changelog-resource.sh --check` fails when the bundled in-app -changelog resource has drifted from the root `CHANGELOG.md`. +`Scripts/check.sh repo` checks that ordering before CI builds, and +`Scripts/check.sh repo` fails when the bundled in-app changelog resource has +drifted from the root `CHANGELOG.md`. PR CI also requires material source/script/workflow changes to include a release note or change fragment unless the PR is explicitly labeled `no-release-note`. diff --git a/Documentation/Architecture/Architecture.md b/Documentation/Architecture/Architecture.md new file mode 100644 index 00000000..48fb1d4a --- /dev/null +++ b/Documentation/Architecture/Architecture.md @@ -0,0 +1,89 @@ +# Architecture + +Contained is a SwiftUI-native macOS app that wraps CLI-backed container runtimes. It shells out to public CLI commands, usually with structured output, and decodes typed models. Apple `container` support is registered by default; Docker adapter groundwork lives behind the same Core runtime boundary but remains dormant until a provider model is chosen. There is no private API or daemon. + +```text + SwiftUI Views ──> @Observable Stores ──> Core.Orchestrator ──> Core runtime adapters + (Features/*) (AppModel, …) (ContainedCore) (AppleContainer, Docker) + ^ │ │ + └──── ContainedUI + ContainedUX ───────────────┘ +``` + +## Targets + +- **`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, dormant Docker adapter groundwork, 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 product copy such as navigation, settings layout, toasts, +alerts, onboarding, and Activity presentation. `ContainedCore` owns +display-neutral semantic localization where it helps the package stand alone: +schema labels/help, validation messages, runtime capability reasons, +projection/Compose warnings, and package-error fallback descriptions. Core +localization APIs support downstream override/wrap behavior; Contained uses the +Core-owned semantic strings directly because the app and package live in the +same repo. + +Package errors follow the same ownership boundary. Core exposes +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 for immediate runtime-detail presentation unless an adapter maps it to +a known typed case; persisted Activity and Console diagnostics retain only +allowlisted codes, phases, exit status, timings, and counts. + +Package-local docs: + +- [`Packages/ContainedCore/README.md`](../../Packages/ContainedCore/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 +`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. + +## Core Runtime Wrapper + +- **`Core.Orchestrator`** — the only backend object app stores own. It bootstraps available CLI-backed runtimes, exposes runtime descriptors, routes runtime-scoped calls, aggregates multi-runtime container and image inventory, 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 runtimes register descriptors inside Core's shared runtime registry; the app reads capabilities instead of switching on backend names. +- **`Core.Schema.Document`** — runtime-neutral run/edit/recreate fields published by Core. Documents carry the intended runtime per container, generic field paths, source aliases, default semantic tips, runtime-profile 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. +- **`Runtimes/Docker`** — Core-internal dormant Docker CLI-compatible adapter groundwork. It owns Docker CLI discovery, command builders, decoders, create/Compose translation, image actions, and Docker endpoint readiness mapping, but is not registered in the default runtime registry. +- **`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: 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. +- **`HistoryStore`** — SwiftData stack for the persistent event log + metric samples (the "rewind" timeline) with bounded retention. +- **`UpdaterController`** — wraps Sparkle; the user's selected update channel chooses a branch-hosted appcast feed. Stable and Beta feeds are branch-local, while Nightly is a superset that also carries promoted release items. +- **`SettingsStore`** — persists appearance, update cadence, logging, material choices, and experimental feature gates. `SettingsBackup` owns the portable export/import shape. +- **`UIState`** — owns navigation, sidebar visibility, toolbar morph state, palette routing, and creation/edit flow handoff. Toolbar grouping/sort/filter enums and one-shot actions live in adjacent navigation files so routing state stays readable. + +## Design system + +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 + +Card styles and healthchecks are stored locally (keyed by container id / image reference) — **never** injected as personalization labels, keeping the CLI and containers clean. `Personalization` owns the resolved style shape, `WidgetConfiguration` owns metric widget schema/options, and `PersonalizationStore` owns persistence and inheritance. Functional app-managed labels such as restart policy must round-trip through the container. + +## Testing + +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/Documentation/Architecture/Design-System.md b/Documentation/Architecture/Design-System.md new file mode 100644 index 00000000..3aa341ef --- /dev/null +++ b/Documentation/Architecture/Design-System.md @@ -0,0 +1,279 @@ +# Design System + +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 `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/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, +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, +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. + +Repeated package implementation structure belongs in +`Packages/ContainedUI/Sources/ContainedUI/Shared`. The `Shared` tree is +package-internal: it can factor common capsule, swatch, row, label, or surface +rendering anatomy, but app code should still consume the named public `UI.*` +components rather than reaching for shared helpers. + +Package SwiftUI previews live beside the declaration they exercise. Do not add +new preview-only folders for design-system elements; add a focused `#Preview` +to the element file so Xcode Canvas opens directly on that component. The main +app should keep a small fixture-free fake-data preview at the app surface that +needs it instead of linking fixture products into shipping targets. + +## 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/ContainedApp` +- package APIs that need words take app-supplied strings or semantic item + 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 +- 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 `ContainedApp` owns product-facing localization catalogs. `ContainedUI` +and `ContainedUX` should remain reusable without shipping language bundles +unless a future package genuinely owns standalone user-facing copy. +`ContainedCore` is the exception: it may ship display-neutral semantic strings +for schema labels/help, validation messages, capability reasons, projection +warnings, and typed package-error fallback descriptions. + +## Core principles + +- A morph panel is one stable shell. Content can swap inside it, but the shell + should own size, clipping, backdrop, and elevation. +- Search fields and toolbar controls should feel like compact macOS controls, + not large mobile bars. +- Cards own their content clipping. Parent scroll views own viewport clipping. +- Nested cards inside panels are flat by default; the enclosing shell owns the + elevation. +- Personalization is local app state. Tint, icon, nickname, and background + styling should not be written back to container labels. + +Style ownership: + +- `Personalization` is the resolved card style. +- `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 `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 `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 `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. + +Guidelines: + +- keep sections top-level; avoid nesting glass cards inside glass cards +- put explanatory help in the row `info` slot instead of appending ad-hoc + trailing info buttons +- split repeated editors into focused subviews when the parent sheet also owns + persistence or presentation state +- use `UI.Panel.SheetTitleBar` for modal sheets and `UI.Panel.Header` for in-window morph + panels or embedded panel pages + +## Toolbar shell + +The floating toolbar and toolbar-panel navigation are separate experimental +settings. `experimentalToolbarUI` turns on the custom top/bottom toolbar chrome; +`experimentalPanelNavigation` decides whether eligible routes open morph panels +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 `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 +search; page filters live in the bottom row next to System and hide on pages +without filters. + +Bottom page filters use the shared toolbar menu-button shape. Containers, +Images, Templates, and Networks all expose their page-specific grouping/sorting +state from this slot rather than inventing page-local controls. + +Contextual page controls act on the current page. They should switch page or +subpage state directly rather than opening morph panels. Global toolbar buttons +and menu commands own panel presentation. + +When the sidebar is visible, the top-row vanity traffic-light house and page +switcher are hidden because native sidebar navigation owns that role. Contextual +page controls remain in the leading toolbar cluster, immediately before search. + +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 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 + +Use package-owned semantic controls for command chrome: + +- `UI.Action.Group` and `UI.Action.Items` for icon action groups +- `UI.Action.Cluster` for mixed menu/action capsules +- `UI.Action.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.Action.MenuButton` for material-backed menu triggers outside toolbar slots +- `UI.Toolbar.SearchField`, + `UI.Toolbar.StatusButton`, `UI.Toolbar.ActionCluster`, and + `UI.Toolbar.VanitySlot` for toolbar-specific slots + +Feature views cannot call the package-internal `MaterialButton`, `MaterialButtonItem`, +`MaterialButtonInputItem`, `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 `UI.Card.Scaffold` for containers, images, tags, volumes, networks, and +palette result cards. + +Recommended inputs and package pieces: + +- `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 +- `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. + +`UI.Card.Scaffold` owns the card anatomy: + +- the header is always visible and stays outside the expanding body +- 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 +- 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` + +`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 `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 +`UI.Card.InsetSection`. + +## Palette visual results + +The palette should not degrade rich app objects into plain text. Use +`PaletteItemVisual` whenever a result has meaningful state: + +- `.container` for actual containers +- `.imageGroup` for local image groups +- `.imageTag` for local tags +- `.volume` and `.network` for resources +- `.tint` for appearance color choices + +Plain rows are reserved for generic actions such as refresh or opening a page. + +## 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.Action.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. + +When two design-system elements have the same internal anatomy, factor only the +shared implementation into the package-internal `Shared` tree. Keep distinct +public routes when callers need different semantics, names, or capability +boundaries. + +## Verification + +UI changes should run: + +```sh +swift test +git diff --check +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug build +./Scripts/package.sh app debug +open Contained.app +``` + +For Contained UI work, relaunch the built app after passing tests so the current +panel/card behavior is visible in the running app. If an existing `Contained.app` +is open, quit it before rebuilding and reopening the fresh bundle. diff --git a/Documentation/Architecture/Runtime-Adapters.md b/Documentation/Architecture/Runtime-Adapters.md new file mode 100644 index 00000000..0d2f9cd0 --- /dev/null +++ b/Documentation/Architecture/Runtime-Adapters.md @@ -0,0 +1,130 @@ +# Runtime Orchestration + +Contained's app-facing backend boundary is `ContainedCore`. + +- `Core.Orchestrator` is the only backend object app stores own. +- `Core.Runtime` owns runtime identity, descriptors, capabilities, runtime-scoped checks, module registration, 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 translate canonical models to backend-specific behavior through `Core.Runtime.Module`. `Runtimes/AppleContainer` is registered by default; `Runtimes/Docker` is dormant groundwork and must not be added to the default registry until a Docker provider model is chosen. + +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 folders inside `ContainedCore`, not standalone app +dependencies. Apple container lives under `Runtimes/AppleContainer`; dormant +Docker groundwork lives under `Runtimes/Docker`. Future runtimes such as Podman, +Lima-backed, remote, or other runtimes should be added as sibling adapter +folders under Core and registered in the built-in runtime module registry only +when they are ready to be app-discoverable. + +The shared registry lives in `Runtime/ModuleRegistry.swift`; `Runtimes/**` +contains concrete adapter behavior only. Shared `Runtime/**` files define the +module contract, descriptors, capabilities, readiness state, client protocols, +configuration, unsupported-capability errors, and migration contracts. + +Each adapter module owns its descriptor, capability preset, CLI lookup, client +creation, readiness probing, command previews, terminal invocation, schema +support profile, Compose projection, and runtime-specific Core strings. Shared +Core owns the contracts and normalized models; it does not construct concrete +clients, call concrete CLI locators, or branch on Docker/Apple command builders +outside the runtime tree. + +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. +Shipped kinds such as `apple-container` and `docker` are stable identity +constants only; unknown or unregistered kinds are unsupported instead of being +silently remapped. Use `Core.Runtime.Capability` and `Core.Runtime.Descriptor` +to advertise support before a UI route enables a command. + +Bootstrap returns readiness for every registered module. A runtime can be +`cliMissing`, `unsupported`, `endpointUnavailable`, or `ready`, so the app can +present missing CLI, unsupported CLI, and stopped daemon/service states without +guessing from a single global bootstrap value. + +Runtime inventory is aggregated per capability. If one runtime fails while +another succeeds, Core returns the successful resources with typed partial +failure details so the app can keep the grid usable and surface the degraded +runtime instead of hiding the problem. + +## Create, Import, Export, And Runtime Choice + +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 runtime choice is per-container +or per-import item rather than a global app setting. + +Core translates into and out of the shared model through the selected runtime +module: + +- `schemaDefinition(for:runtimeKind:)` publishes canonical field definitions with the selected module's schema profile applied. +- `previewCreateCommand(for:)` validates a schema document and returns the command preview for the selected runtime. +- `createContainer(_:)` and `recreateContainer(originalID:replacement:rollback:)` create from validated schema documents. Recreate validates both inputs before mutation and automatically restores the rollback document when replacement creation fails after deletion. +- `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 runtime move planning. `migrateContainer(_:sourceDocument:targetRuntimeKind:healthCheck:stabilizationTimeout:pollInterval:onPullProgress:)` owns the typed execution sequence: stop the source runtime instance, ensure the target image, create the target from normalized config plus preserved projections, wait for health/running stabilization, and only then remove the source. The app records progress, owns styling/health metadata, and presents recovery. + +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 does not store a global active/default runtime. Create, build, pull, +load, Compose, volume, and network flows either expose a runtime picker or route +from an existing resource's `runtimeKind`. Global prune/reclaim actions iterate +every reachable runtime with the required capability. Existing container actions +route through each resource's `runtimeKind`, so the container grid can aggregate +Apple and Docker containers without app-side runtime switching. + +Runtime settings are keyed by `Core.Runtime.Kind` and rendered from descriptors +and capabilities. Path overrides are runtime records. Service, kernel, DNS, and +endpoint controls appear only when a descriptor advertises the matching runtime +capability, so the app does not need a hardcoded active/default runtime. + +Images are unified at the group level by normalized reference or digest. +Registry search, remote digest checks, and shared image metadata are app-wide, +while local update status and runnable availability are runtime-scoped per tag: +Docker +`nginx:latest` and Apple container `nginx:latest` are different local tags and +delete/tag/save/push through their owning runtime. + +## Compose + +Compose is a Core-level interchange format, not a Docker-only package boundary. +V1 import remains UI-first: it translates services into editable Run forms and +does not manage Docker Compose stacks. Apple container keeps `network_mode: +host` as default/blank networking, while Docker import maps it to +`--network host`. Docker, Podman, and nerdctl-style runtimes may support native +Compose execution or export later. + +YAML parsing/writing belongs under `Core.Compose.YAML` and remains the only +place that imports Yams. Runtime-specific host networking, unsupported-field +preservation, and create/edit projection belong inside each runtime adapter +folder. 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. + +Immediate error presentation may include runtime-provided stderr so the user can +act on a failure. Durable Activity entries and macOS Console output use an +allowlisted summary instead: stable package/error codes, recreate phase and +recovery state, exit status, timings, and counts. Raw commands, stderr, +environment values, paths, labels, and credentials are not persisted or logged. + +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/Documentation/Development/Contributing.md b/Documentation/Development/Contributing.md new file mode 100644 index 00000000..28dc25a0 --- /dev/null +++ b/Documentation/Development/Contributing.md @@ -0,0 +1,146 @@ +# Contributing + +Forks, issues, and pull requests are welcome. Contained is **source-available** under the [PolyForm Noncommercial License 1.0.0](https://github.com/tdeverx/contained-app/blob/main/LICENSE) — contributions are accepted under those same non-commercial terms, and the "Contained" name and branding are reserved (see [NOTICE](https://github.com/tdeverx/contained-app/blob/main/NOTICE)). + +## Issues And Discussions + +Start with the [repo docs index](/Documentation/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. +- Use **Ideas** for early product thoughts before there is a concrete task. +- Use the **Development and architecture** starter thread in General for package + boundaries, design-system direction, navigation strategy, backend choices, + release process, and automation design until GitHub category setup is + customized further. +- Use **Issues** for bugs, crashes, accepted features, exploration tasks, + 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](/Documentation/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, +an implementation checklist, and acceptance criteria. Bug and crash forms stay +lighter so reports are not intimidating; maintainers can add implementation +checklists after triage. + +Labels are intentionally short and color-coded. Use one type label (`bug`, +`feature`, or `other`), add neutral area labels such as `app`, `core`, +`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 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. + +Use native GitHub relationships for hard sequencing: parent/sub-issues for work +breakdown, and blocked-by/blocking links when one issue genuinely cannot move +until another issue is resolved. Keep softer context as plain related links. +Milestones are target buckets: `beta` for work expected before the next beta, +`stable` for the first stable-release bar, and `future` for accepted but +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](/Documentation/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 +required-PR/check rule still needs a release-bot-safe bypass before it can be +enforced without breaking appcast publishing. + +## Layout + +``` +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/ 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 +Contained.xcodeproj/ native macOS app target that links ContainedApp +Packages/*/Tests/ package-local unit tests +Tests/ContainedAppTests/ ContainerFormState form state + runtime mapping +Scripts/ check.sh, build.sh, package.sh, notes.sh, appcast.sh +Documentation/App/ user-facing app docs +Documentation/Features/ feature and workflow docs +Documentation/Architecture/ architecture and package-boundary docs +Documentation/Development/ contribution, issue, and repo workflow docs +Documentation/Release/ release and updater runbooks +Documentation/Wiki/ file map and sidebar contract for the separate GitHub wiki repo +appcast.xml Sparkle feed at the root of each release branch +``` + +## Conventions + +- **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 maintained repo surfaces use PascalCase roots such as `Documentation`, `Scripts`, and `Changes`. 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 `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 product localization.** `ContainedUI` and `ContainedUX` should + not introduce user-facing English defaults or localized resource bundles. If a + visual component needs text, add an explicit parameter and pass app-owned + strings from `Sources/ContainedApp`; reusable enum labels and dynamic + templates should flow through `AppText` with English fallbacks. `ContainedCore` + may own display-neutral semantic localization for schema labels/help, + validation messages, runtime capability reasons, projection warnings, and + typed package-error fallback descriptions. +- **The app owns package error presentation.** Reusable targets should throw + 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 for immediate runtime-detail + presentation unless an adapter can classify it as a known typed case; durable + Activity and Console diagnostics must use allowlisted metadata instead. +- **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 `Documentation/`. +- **The wiki map lives in the repo.** GitHub's wiki is a separate repository. Keep maintained docs in `Documentation/` and package directories, then update `Documentation/Wiki/File-Map.md` and `Documentation/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. Package previews are colocated with the design-system element or UX primitive they exercise; do not add separate preview-only source folders. +- **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.** Apple `container` and Docker are sibling adapter folders inside Core; future Podman, Lima-backed, remote, or other runtimes should follow the same shape and advertise capability differences through `Core.Runtime.Descriptor`. Run/edit/import flows should translate through `Core.Schema.Document` and carry `Core.Runtime.Kind` per resource/action, 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.) +- **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 `Documentation/App`, `Documentation/Features`, `Documentation/Development`, `Documentation/Architecture`, or `Documentation/Release`; keep README links and the wiki map current. +- **Preserve update build numbers.** `Scripts/package.sh version` is the single build-number source of truth; beta/stable workflows must pass the retained `BUILD` into `Scripts/package.sh app` 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 release-note-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/Current.md`, not in `CHANGELOG.md` as a running implementation inventory. Nightly consumes `Changes/Current.md`, rolls it into `Changes/Beta.md`, and clears it; Beta consumes and clears `Changes/Beta.md`; Stable uses curated full notes and only includes `Changes/Current.md` for direct hotfix builds. +- **Let CI check invariants, not fix them.** `Scripts/check.sh repo` checks bundled changelog sync, shell syntax and headers, workflow YAML syntax and path filters, Documentation/Wiki coverage, package-boundary naming, stale path references, and PR release-note coverage when given a base ref. If `CHANGELOG.md` changes, run `./Scripts/package.sh app debug` to sync the bundled resource, then `./Scripts/check.sh repo` so drift fails loudly before CI. +- **Use `no-release-note` narrowly.** PR CI accepts the label only through `NO_RELEASE_NOTE=1`; reserve it for documentation, metadata, or 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 `Documentation/Wiki/File-Map.md`. + +## Before a PR + +Link a tracked issue when the PR changes user-facing behavior, architecture, +runtime/backend behavior, release/workflow policy, security/auth/networking, or +anything that needed design/research. Tiny docs fixes, dependency bumps, typo +fixes, and direct review follow-ups can skip the issue when the PR explains why. + +```sh +./Scripts/check.sh repo # release/workflow invariants +./Scripts/check.sh release # 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/package.sh app debug && open Contained.app # smoke-test the screens you touched +``` + +## Good first contributions + +- Work through a row of the **1.0 Polish Checklist** in the [README](https://github.com/tdeverx/contained-app/blob/main/README.md) for one screen (states, a11y, copy, layout). +- Add a golden-argv or decode test for an under-covered command. +- Improve an empty / loading / error state. + +See [Architecture](/Documentation/Architecture/Architecture.md) for how the pieces fit together. diff --git a/Documentation/Development/Documentation-Map.md b/Documentation/Development/Documentation-Map.md new file mode 100644 index 00000000..03eb6ad8 --- /dev/null +++ b/Documentation/Development/Documentation-Map.md @@ -0,0 +1,66 @@ +# Documentation Map + +This page explains where documentation belongs in the repo and how it maps to +the separate GitHub wiki repository. + +## Repo Docs + +Use `Documentation/` for maintained product, feature, architecture, development, and +release documentation. + +| Location | Belongs here | +| --- | --- | +| `Documentation/App/` | User-facing setup, updates, settings, troubleshooting, localization, and app behavior | +| `Documentation/Features/` | Feature workflows such as containers, images, resources, creation, run/edit, Compose import, and command palette | +| `Documentation/Architecture/` | Package boundaries, runtime architecture, UI/UX ownership, and cross-cutting technical decisions | +| `Documentation/Development/` | Contribution workflow, issue routing, documentation ownership, and maintainer process | +| `Documentation/Release/` | Release, packaging, appcast, signing, and channel runbooks | + +Keep `Documentation/README.md` as the entry point for the maintained docs tree. +Root `README.md`, `Documentation/README.md`, and `Documentation/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 + +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 `Documentation/Wiki/` instead of duplicating wiki content. + +| File | Purpose | +| --- | --- | +| `Documentation/Wiki/README.md` | Explains the wiki sync model | +| `Documentation/Wiki/File-Map.md` | Maps maintained source docs to intended wiki paths | +| `Documentation/Wiki/_Sidebar.md` | Defines the intended wiki sidebar structure | + +When a maintained doc is added, renamed, or removed, update +`Documentation/Wiki/File-Map.md` and `Documentation/Wiki/_Sidebar.md` in the same change. +`Scripts/check.sh repo` 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 + +- 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/wiki/Issues-and-Discussions.md b/Documentation/Development/Issues-and-Discussions.md similarity index 100% rename from docs/wiki/Issues-and-Discussions.md rename to Documentation/Development/Issues-and-Discussions.md diff --git a/docs/wiki/Command-Palette.md b/Documentation/Features/Command-Palette.md similarity index 84% rename from docs/wiki/Command-Palette.md rename to Documentation/Features/Command-Palette.md index a5b41383..3dbbbe00 100644 --- a/docs/wiki/Command-Palette.md +++ b/Documentation/Features/Command-Palette.md @@ -29,9 +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`, `ResourceCardIconChip`, `ResourceCardTitleText`, - `ResourceCardSubtitleText`, and `ResourceBadgeText` inside cards +- 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 - render containers, image groups, tags, volumes, networks, and tints as mini cards using their actual app styling @@ -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/Toolbar/Panels/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/Toolbar/Panels/ToolbarCommandPalette.swift` owns the visual panel and keyboard handling. -- `Sources/Contained/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/Documentation/Features/Compose-Import.md b/Documentation/Features/Compose-Import.md new file mode 100644 index 00000000..21c73808 --- /dev/null +++ b/Documentation/Features/Compose-Import.md @@ -0,0 +1,56 @@ +# Compose Import + +Compose import maps Docker Compose YAML into editable Run forms. It is +experimental and defaults off; enable **Settings → Experimental → Compose +import**. + +## Entry points + +Compose import can start from: + +- paste +- file picker +- drag and drop +- menu command +- command palette action + +## Behavior + +Compose import fills one or more editable [Run / Edit Form](/Documentation/Features/Run-Edit-Form.md) +entries instead of launching an opaque stack. The selected runtime adapter +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 settings for the selected runtime, while known fields a +runtime cannot execute are preserved as disabled typed values with source +references and explanations. + +Important translations: + +- relative bind mounts resolve relative to the compose file directory +- `platform` fills the platform picker/custom value +- `network_mode: host` stays blank/default for Apple container and becomes + `--network host` for Docker +- `stdin_open` and `tty` fill matching toggles +- `env_file` fills env file rows +- `restart: unless-stopped` normalizes to Contained's app-managed Always policy +- healthchecks become app-managed health checks + +Known Docker/Compose 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. Docker CLI +fields execute when Docker support exists; Compose stack fields stay +disabled in V1. + +Target-only ports such as `8080` are skipped with a warning because they do not +provide a host port to publish for this runtime. + +## Ownership + +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 and +Docker import both project executable documents to +`Core.Container.CreateRequest` internally, returning the same generic schema +fields with their own warnings and unsupported-operation plans. diff --git a/Documentation/Features/Containers.md b/Documentation/Features/Containers.md new file mode 100644 index 00000000..e74be60f --- /dev/null +++ b/Documentation/Features/Containers.md @@ -0,0 +1,65 @@ +# Containers + +The Containers page is the main workload surface. When more than one runtime is +available, the grid aggregates containers from every reachable runtime. + +## Grid and cards + +- Personalized Liquid Glass cards show status, image, command, resource + highlights, and local-only appearance choices. +- Card personalization can be set per container or inherited from image styling. +- Local tint, nickname, icon, and card background are not written back to + container labels. +- Cards expose full-card hit targets plus context actions for lifecycle and edit + operations. + +## Lifecycle + +Container actions route through the shared app model and each container's +owning runtime: + +- start +- stop +- restart +- delete +- refresh +- edit +- update image when an image update is available + +The app serializes refreshes around lifecycle actions so a user action and the +background polling tick do not fight over inventory and stats streams. Runtime +identity is part of each container's internal key, so Apple and Docker +containers with the same runtime ID do not collide. + +## Detail + +Expanded container detail surfaces include: + +- Overview +- Logs +- Terminal +- Stats +- History +- Files + +Expanded views use the same toolbar safe-area contract as morph panels, clearing +top and bottom toolbar bands when the experimental toolbar is visible. + +## Restart and health + +Apple `container` has no native restart policy or healthcheck. Contained stores +restart intent and health probes as app-managed state, runs probes through the +container's owning runtime, and records events in Activity/History. + +## Edit + +Container edit opens the same [Run / Edit Form](/Documentation/Features/Run-Edit-Form.md) used for new +containers. Toolbar panel navigation opens it in the [Creation Workflow](/Documentation/Features/Creation-Workflow.md) +morph; classic routing opens the same form state as a sheet. + +Runtime configuration is immutable, so saving an edit recreates the container. +Contained validates the replacement and a snapshot-derived rollback recipe before +deleting anything. If replacement creation then fails, Core automatically tries +to restore the original container. A failed restoration keeps the original recipe +in the app database for recovery; data that was not stored in volumes cannot be +reconstructed. diff --git a/docs/wiki/Creation-Workflow.md b/Documentation/Features/Creation-Workflow.md similarity index 94% rename from docs/wiki/Creation-Workflow.md rename to Documentation/Features/Creation-Workflow.md index 4d5145eb..ce302a1d 100644 --- a/docs/wiki/Creation-Workflow.md +++ b/Documentation/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](/Documentation/Features/Run-Edit-Form.md). ## Feature gates diff --git a/docs/wiki/Features.md b/Documentation/Features/Features.md similarity index 54% rename from docs/wiki/Features.md rename to Documentation/Features/Features.md index 93430910..f0803fbf 100644 --- a/docs/wiki/Features.md +++ b/Documentation/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 tabs, logs, - terminal, stats, files, inspect, app-managed restart/health, and local card +- **[Containers](/Documentation/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](/Documentation/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](/Documentation/Features/Resources.md)** — volumes, networks, registries, templates, and activity history. -- **[[System & Settings|System-Settings]]** — service status, runtime defaults, +- **[System & Settings](/Documentation/App/System-Settings.md)** — runtime status, runtime controls, app settings, experimental gates, updates, and local data. ## Shared workflows -- **[[Creation Workflow|Creation-Workflow]]** — the shared front door for run, +- **[Creation Workflow](/Documentation/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](/Documentation/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](/Documentation/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](/Documentation/Features/Command-Palette.md)** — fuzzy app-wide action index. +- **[Updates](/Documentation/App/Updates.md)** — Sparkle app updates, branch appcasts, image update checks, and release notes. ## Throughout diff --git a/Documentation/Features/Images.md b/Documentation/Features/Images.md new file mode 100644 index 00000000..52b29899 --- /dev/null +++ b/Documentation/Features/Images.md @@ -0,0 +1,54 @@ +# Images + +The Images area covers local image browsing, image actions, registry search, +updates, archives, and the experimental build workspace. + +## Local images + +Images are grouped by normalized reference and digest. When multiple runtimes are +available, the Images surface still shows one image group, while tag rows expose +where that tag exists locally. For example, Docker `nginx:latest` and Apple +container `nginx:latest` share registry/update metadata but remain separate +runtime-owned local tags. + +Common actions: + +- run image +- check for update +- pull available update +- tag +- push +- save archive +- load archive +- history +- prune + +Tag/delete/save/push actions use the tag's owning runtime. Pull, build, and load +flows ask for a target runtime at the action surface unless they are launched +from an existing runtime-scoped tag or context. + +## Docker Hub search + +Docker Hub search is experimental and defaults off. Enable **Settings → +Experimental → Docker Hub search** to reveal registry search entry points in the +creation flow and command palette. + +Search results can hand a selected image reference into the run configuration +flow. + +## Image updates + +Image update checks compare local and remote digests by normalized registry +reference or digest. Remote metadata is shared across runtimes; actual runnable +image availability remains owned by each runtime. + +Manual checks are available from Images, System, the toolbar, and the command +palette. Background cadence is configured in [Updates](/Documentation/App/Updates.md). + +## Build workspace + +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](/Documentation/Features/Creation-Workflow.md). diff --git a/docs/wiki/Features-Resources.md b/Documentation/Features/Resources.md similarity index 61% rename from docs/wiki/Features-Resources.md rename to Documentation/Features/Resources.md index 77c96586..2fca3d81 100644 --- a/docs/wiki/Features-Resources.md +++ b/Documentation/Features/Resources.md @@ -4,15 +4,17 @@ Resources cover volumes, networks, registries, templates, and activity history. ## Volumes -Volumes can be browsed, inspected, 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. +Volumes can be browsed, created, attached during container runs, and +deleted or pruned when appropriate. The page groups runtime-managed volumes and +host-path or temporary mounts vertically so the source type stays obvious. +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]]. +shared [Creation Workflow](/Documentation/Features/Creation-Workflow.md). ## Registries @@ -26,7 +28,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](/Documentation/Features/Run-Edit-Form.md) as other creation paths. ## Activity diff --git a/Documentation/Features/Run-Edit-Form.md b/Documentation/Features/Run-Edit-Form.md new file mode 100644 index 00000000..01c041ae --- /dev/null +++ b/Documentation/Features/Run-Edit-Form.md @@ -0,0 +1,157 @@ +# Run / Edit Form + +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 + +Use a structured control whenever the option has a bounded or recognizable value +space: + +- toggles for booleans, such as `--detach`, `--rm`, `--tty`, `--read-only`, + `--init`, `--ssh`, `--rosetta`, `--virtualization`, and `--no-dns` +- pickers for known option sets, such as platform presets, registry scheme, and + progress mode +- sliders for host-bounded resource values, such as memory and shared memory +- steppers for small numeric limits, such as concurrent image downloads +- repeatable rows for lists, such as environment variables, labels, ports, + volumes, sockets, capabilities, DNS servers, tmpfs mounts, ulimits, and env + files + +Keep free-form text fields only where the CLI genuinely accepts arbitrary +strings, paths, names, or raw specs. + +## Form structure + +The form uses native grouped macOS settings-style sections rather than mirroring +CLI flag order: + +- Essentials: runtime, 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 +- Environment: variables and env files +- App Managed: restart policy and health checks +- 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. 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 +disabled, the same form state opens as a classic sheet so card buttons, menus, +keyboard shortcuts, and palette actions keep one routing model. + +## UI abstractions over CLI flags + +These controls deliberately do not mirror the CLI one-to-one: + +The editor uses native grouped SwiftUI form sections split across Basics, +Network, Storage, Options, and Advanced pages. Persistent guidance and +validation belong in section footers; field-specific help stays on the row info +button so it is available without crowding the form. The Run/Save action sits +at the far right of the command preview so the submitted command is visually +tied to the action. The form viewport is transparent; the grouped section +backgrounds provide the visible structure. + +Row labels carry field state. A row turns red only when that specific field has +an error or local path issue. A row turns blue when its value differs from the +runtime/app default. Section headers use the same blue changed cue when any row +in that section has non-default content. + +| UI control | CLI output | Notes | +| --- | --- | --- | +| Runtime picker | runtime adapter selection | Available runtimes publish the same generic schema paths instead of adding backend-specific forms. The picker is enabled when more than one runtime descriptor is available. | +| 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. | +| Progress picker | `--progress auto\|none\|ansi\|plain\|color` | Empty means runtime default. | +| Limit parallel downloads toggle + stepper | `--max-concurrent-downloads ` | Empty means runtime default. | +| Storage group | `--volume :` | Each group can contain multiple host-folder paths. Turning on runtime-volume backing mounts one volume and links the group paths inside it. | +| Disable DNS toggle | `--no-dns` | When enabled, DNS-specific rows are hidden and the command omits `--dns*` flags so the UI cannot express contradictory settings. | +| Restart policy picker | `--label contained.restart=` | `container` has no native restart flag. Contained stores restart intent as a label and the app watchdog enforces it. | +| Health check section | local app state | `container` has no native healthcheck flag. Contained stores and runs probes itself. | +| Personalization section | local app state | Nickname, icon, tint, and card background are local-only and are not written as container labels. | + +## Storage groups + +Storage is edited as one native form section per group. By default, each path +row is a normal host-folder mount with its own internal path and access mode. A +group can also enable runtime-volume backing. In that mode, Contained mounts one +runtime volume at a base internal path, mounts each host folder at a stable +temporary path, then prepares symlinks inside the runtime volume before the main +container starts. + +This is intended for workflows where the app's config directory needs Linux +ownership semantics, but selected host folders should still appear under that +config tree. A single storage group can have multiple paths, and each path has +an explicit access mode: + +- **Read only** mounts the host folder with `:ro` and is the safer default for + media or library folders. +- **Read/Write** lets the container create, move, or delete files in the linked + host folder. + +If the requested link path already exists as a real file or directory, Contained +does not overwrite it silently. The run fails with a visible error so the user +can rename, remove, or choose another link path. + +## Free-form by design + +The following options stay textual because the CLI accepts arbitrary values and +the app should not pretend to know every valid shape: + +- image reference, container name, command, entrypoint +- working directory, user, UID/GID +- env file paths +- volume paths and raw `--mount` specs +- socket paths +- label and environment key/value rows +- capability names +- network attachment string, including options such as `mac=` and `mtu=` +- container ID file, runtime handler, init image, kernel path +- DNS search/options, tmpfs entries, and ulimits + +## Compose import behavior + +Compose import follows the same UI-first rule: it asks the selected runtime +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: + +- relative bind mounts are resolved relative to the compose file directory +- `platform` fills the platform picker/custom value +- `network_mode` fills Network +- `stdin_open` and `tty` fill the matching toggles +- `env_file` fills env file rows +- `restart: unless-stopped` is normalized to Contained's app-managed `Always` + policy because user-initiated stops are already suppressed by the watchdog +- healthchecks become app-managed health checks + +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. diff --git a/Documentation/README.md b/Documentation/README.md new file mode 100644 index 00000000..6594cc0d --- /dev/null +++ b/Documentation/README.md @@ -0,0 +1,55 @@ +# Contained Documentation + +Contained docs are maintained beside the code so architecture, workflow, and +release changes can be reviewed with implementation changes. + +## 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) +- [Documentation Map](Development/Documentation-Map.md) + +## Release + +- [Release Runbook](Release/Release.md) + +## 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/Documentation/Release/Release.md b/Documentation/Release/Release.md new file mode 100644 index 00000000..6eaa45f8 --- /dev/null +++ b/Documentation/Release/Release.md @@ -0,0 +1,109 @@ +# 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](/Documentation/App/Installation.md). + +## Versioning + +- **`CFBundleShortVersionString`** (marketing version) — semver, with a pre-release suffix per channel: + - Stable: `1.0.0` + - Beta: `1.0.0-beta.+` + - Nightly: `1.0.0-nightly.+` (set automatically by CI) +- **`CFBundleVersion`** (build number) — a monotonic integer. `Scripts/package.sh version build` is the single source of truth for scripts and CI; no workflow should calculate or validate a build number directly. The script rejects non-numeric build values. Beta/stable workflows first try to reuse the matching nightly appcast build for the promoted commit, then fall back to `git rev-list --count HEAD`. Sparkle orders updates by this, so it must be retained across nightly, beta, and stable for the same promoted build. + +Set the marketing version for a manual build with `VERSION=… ./Scripts/package.sh app`. `Scripts/package.sh notarized` defaults to the Stable channel; set `CHANNEL=beta` only when intentionally cutting a beta locally. + +## Channels + +Each channel has an `appcast.xml` at the root of its branch, served through `raw.githubusercontent.com`: + +- **stable** — `stable/appcast.xml` +- **beta** — `beta/appcast.xml` +- **nightly** — `nightly/appcast.xml`, a superset feed containing the newest nightly item plus promoted beta/stable items + +The app's Settings → Updates picker changes the feed URL. Appcast items do not need `` tags because the selected branch feed is the channel. + +## Release Notes + +Release notes are composed by `Scripts/notes.sh body` and embedded by `Scripts/notes.sh html`. +`Scripts/package.sh app` also writes the same generated Markdown to `CurrentReleaseNotes.md` inside the +app bundle so the in-app What's New view reads the current build artifact instead of reconstructing +notes from the source changelog. + +- Stable ships `Full Release Notes` for the base marketing version, such as `1.0.0`. +- Beta ships `Changes Since Last Beta` followed by `Full Release Notes`. +- Nightly ships `Changes Since Last Nightly` followed by `Full Release Notes`. + +Keep `CHANGELOG.md` curated and version-level. It should describe durable +user-facing release notes for a version, not every PR, internal refactor, or +build-specific detail. Use the rolling note files for channel/build deltas: + +- `Changes/Current.md` — the current PR/build note. Nightly consumes it, then CI prepends it into `Changes/Beta.md` and clears it. +- `Changes/Beta.md` — accumulated notes since the last beta. Beta consumes it, then CI clears it. +- `CHANGELOG.md` — full version notes used by Stable, Beta, and Nightly. + +The note composer still accepts explicit override sources for local release +maintenance and fixtures: + +- `RELEASE_NOTES=/path/to/file` — durable, version-wide notes. +- `CHANGES=/path/to/file` — channel/build-level changes. +- `CHANGES_DIR=/path/to/dir` — concatenates sorted `.md` files. + +`Scripts/notes.sh collect` can compile explicit files, directories, or a git range: + +```sh +./Scripts/notes.sh collect Changes/Current.md > updates/changes.md +./Scripts/notes.sh collect "v1.0.0-beta.78..HEAD" Changes/Current.md > updates/changes.md +CHANGES=updates/changes.md CHANNEL=beta VERSION_VALUE="$VERSION" ./Scripts/notes.sh body +``` + +Keep `Unreleased` above released version sections for release tooling, but +prefer `Changes/Current.md` for current PR/build notes. `Scripts/notes.sh delta` +remains available for diagnostics and fixtures, but the default CI release flow +uses the rolling files instead of deriving notes from appcast history. + +Generated release-note files should be written under `updates/`, `.release/`, or `.release-notes/`. Do not commit generated notes from release workflows. The workflows commit `appcast.xml` and rolling note-file consumption with `[skip ci]`, and those paths are ignored by release triggers so they do not start another release build. + +Run `./Scripts/check.sh repo` before opening release/workflow PRs. It checks bundled changelog sync, shell syntax, workflow YAML syntax, wiki coverage, stale path references, and PR release-note coverage when given a base ref. Material source/script/workflow changes must include `Changes/Current.md` or curated changelog updates unless the PR carries the `no-release-note` label for documentation, metadata, or dependency-only maintenance. + +Release helper behavior is covered by `./Scripts/check.sh release`. CI also runs: + +```sh +./Scripts/check.sh generated +VERSION="$VERSION" BUILD="$BUILD" ./Scripts/package.sh smoke Contained.app +CHANNEL="$CHANNEL" ./Scripts/appcast.sh validate appcast.xml +``` + +`Scripts/check.sh generated` catches tracked files rewritten by build/generation steps. `Scripts/package.sh smoke` checks the bundle executable, Info.plist version/build values, bundled changelog, Sparkle.framework, and code signature. `Scripts/appcast.sh validate` checks XML structure, numeric Sparkle build numbers, short versions, enclosure URLs, release notes, and channel shape; the nightly channel intentionally allows Stable/Beta/Nightly items because it is the superset feed. + +## One-time setup + +1. **Sparkle EdDSA keys** — run Sparkle's `generate_keys` once. It stores the private key in your login keychain and prints the public key. Put the public key in `SUPublicEDKey` (in `Scripts/package.sh app`'s Info.plist block). **Never commit the private key.** For CI, export it as the `SPARKLE_ED_PRIVATE_KEY` repo secret. +2. **Developer ID** — a "Developer ID Application" certificate in your keychain (local signing) and, for CI, its `.p12` base64-encoded as `DEVELOPER_ID_CERT_P12` + `CERT_PASSWORD`. +3. **Notarization** — an App Store Connect API key; locally store it with `xcrun notarytool store-credentials` and pass the profile name; for CI add `NOTARYTOOL_API_KEY` / `NOTARYTOOL_KEY_ID` / `NOTARYTOOL_ISSUER`. +4. **Branch feeds** — each release branch serves its root `appcast.xml` through `https://raw.githubusercontent.com/tdeverx/contained-app//appcast.xml`. + +## Cutting a stable or beta release (local) + +```sh +VERSION=1.0.0 ./Scripts/package.sh notarized # Stable build -> codesign -> DMG -> notarize -> staple +CHANNEL=beta VERSION=1.0.0-beta.79+abc123 ./Scripts/package.sh notarized +./Scripts/appcast.sh generate /path/to/Sparkle/bin updates # embed notes + generate root appcast.xml +``` + +Then: + +1. Create a GitHub release tagged `v1.0.0` (or `v1.0.0-beta.N`, with **Pre-release** checked for betas); upload the `.dmg` as a release asset. The appcast's enclosure URLs point at these assets via `--download-url-prefix`. +2. Commit the updated root `appcast.xml` back to the same branch that owns the channel. When promoting beta or stable, merge that appcast item into `nightly/appcast.xml` too so Nightly users get the promoted build. + +## Nightly (CI) + +`.github/workflows/nightly.yml` builds the latest green `nightly` on every push (newest commit wins via `concurrency: cancel-in-progress`), ad-hoc signs, refreshes the rolling **nightly** pre-release with the new DMG, regenerates the nightly appcast item, preserves promoted beta/stable items already in the feed, and commits root `appcast.xml` to the `nightly` branch. It skips appcast signing when `SPARKLE_ED_PRIVATE_KEY` is absent. + +`.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/package.sh version` 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 scratch nightly worktree before pushing the appcast-only `[skip ci]` commit. + +## Notes + +- Sparkle update *integrity* is the EdDSA signature on the appcast — that is the security boundary and works regardless of Apple notarization. Notarization is about Gatekeeper on first install; ship notarized builds so users aren't warned. +- The private Sparkle key and the Developer ID cert never live in the repo — only as keychain entries (local) or encrypted repo secrets (CI). diff --git a/Documentation/Wiki/File-Map.md b/Documentation/Wiki/File-Map.md new file mode 100644 index 00000000..95666781 --- /dev/null +++ b/Documentation/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 | +| --- | --- | --- | +| `Documentation/App/Home.md` | `App/Home.md` | Home | +| `Documentation/App/Installation.md` | `App/Installation.md` | Installation | +| `Documentation/App/Keyboard-Shortcuts.md` | `App/Keyboard-Shortcuts.md` | Keyboard Shortcuts | +| `Documentation/App/Localization.md` | `App/Localization.md` | Localization | +| `Documentation/App/System-Settings.md` | `App/System-Settings.md` | System Settings | +| `Documentation/App/Troubleshooting.md` | `App/Troubleshooting.md` | Troubleshooting | +| `Documentation/App/Updates.md` | `App/Updates.md` | Updates | +| `Documentation/Features/Features.md` | `Features/Overview.md` | Feature Overview | +| `Documentation/Features/Containers.md` | `Features/Containers.md` | Containers | +| `Documentation/Features/Images.md` | `Features/Images.md` | Images | +| `Documentation/Features/Resources.md` | `Features/Resources.md` | Resources | +| `Documentation/Features/Creation-Workflow.md` | `Features/Creation-Workflow.md` | Creation Workflow | +| `Documentation/Features/Run-Edit-Form.md` | `Features/Run-Edit-Form.md` | Run / Edit Form | +| `Documentation/Features/Compose-Import.md` | `Features/Compose-Import.md` | Compose Import | +| `Documentation/Features/Command-Palette.md` | `Features/Command-Palette.md` | Command Palette | +| `Documentation/Architecture/Architecture.md` | `Architecture/Overview.md` | Architecture | +| `Documentation/Architecture/Runtime-Adapters.md` | `Architecture/Runtime-Orchestration.md` | Runtime Orchestration | +| `Documentation/Architecture/Design-System.md` | `Architecture/UI-UX.md` | UI And UX | +| `Documentation/Development/Contributing.md` | `Development/Contributing.md` | Contributing | +| `Documentation/Development/Issues-and-Discussions.md` | `Development/Issues-and-Discussions.md` | Issues And Discussions | +| `Documentation/Development/Documentation-Map.md` | `Development/Documentation-Map.md` | Documentation Map | +| `Documentation/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/Documentation/Wiki/README.md b/Documentation/Wiki/README.md new file mode 100644 index 00000000..adba33ab --- /dev/null +++ b/Documentation/Wiki/README.md @@ -0,0 +1,27 @@ +# Wiki Sync Contract + +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. + +## Source Of Truth + +- `Documentation/` 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. + +Root repo index pages such as `README.md`, `Documentation/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/Documentation/Wiki/_Sidebar.md b/Documentation/Wiki/_Sidebar.md new file mode 100644 index 00000000..2bb7bc78 --- /dev/null +++ b/Documentation/Wiki/_Sidebar.md @@ -0,0 +1,36 @@ +# Contained + +- [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 Orchestration](Architecture/Runtime-Orchestration.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/Package.swift b/Package.swift index 617ccdb2..c61a7321 100644 --- a/Package.swift +++ b/Package.swift @@ -3,53 +3,55 @@ import PackageDescription let package = Package( name: "Contained", + defaultLocalization: "en", platforms: [.macOS(.v26)], products: [ .executable(name: "Contained", targets: ["Contained"]), - .library(name: "ContainedCore", targets: ["ContainedCore"]), + .library(name: "ContainedApp", targets: ["ContainedApp"]), ], dependencies: [ - // 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(path: "Packages/ContainedCore"), + .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"), - // 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). + // 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" - ), - // The SwiftUI app, including Sparkle wiring for signed release builds. - .executableTarget( - name: "Contained", + name: "ContainedApp", dependencies: [ - "ContainedCore", + .product(name: "ContainedCore", package: "ContainedCore"), + .product(name: "ContainedUI", package: "ContainedUI"), + .product(name: "ContainedUX", package: "ContainedUX"), .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")] + // SwiftPM executable launcher used by bundle/release scripts. + .executableTarget( + name: "Contained", + dependencies: ["ContainedApp"], + path: "Sources/Contained" ), - // Tests for app-target value types (RunSpec argv, compose→spec mapping). Imports the - // executable target 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: ["Contained", "ContainedCore"], + dependencies: [ + "ContainedApp", + .product(name: "ContainedCore", package: "ContainedCore"), + .product(name: "ContainedCoreFixtures", package: "ContainedCore"), + ], path: "Tests/ContainedAppTests" ), ] diff --git a/Packages/ContainedCore/Package.swift b/Packages/ContainedCore/Package.swift new file mode 100644 index 00000000..878f8475 --- /dev/null +++ b/Packages/ContainedCore/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "ContainedCore", + defaultLocalization: "en", + 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"), + ], + targets: [ + .target( + name: "ContainedCore", + dependencies: [.product(name: "Yams", package: "Yams")], + resources: [.process("Resources")] + ), + .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 new file mode 100644 index 00000000..eb640064 --- /dev/null +++ b/Packages/ContainedCore/README.md @@ -0,0 +1,156 @@ +# ContainedCore + +`ContainedCore` is Contained's standalone backend/orchestration package. It +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, runtime-scoped checks, and typed + unsupported-operation errors. +- `Core.Runtime.Module`, the adapter contract used by built-in runtime modules + to provide descriptors, CLI lookup, readiness, command previews, terminal + invocations, schema support profiles, and runtime clients. +- `Runtime/ModuleRegistry.swift`, the shared default registry. Concrete + adapter implementation files stay under `Runtimes//`. +- `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 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 + used by tests, previews, and sandbox-only targets. + +## Does Not Own + +- 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 +import ContainedCore + +let result = await Core.Orchestrator.bootstrap( + configuration: Core.Configuration( + runtimes: [ + .appleContainer: .init(cliPathOverride: nil), + ] + ) +) + +let core: Core.Orchestrator +switch result { +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 + +```swift +var document = Core.Schema.Document.containerCreate(runtimeKind: .appleContainer) +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: document) +let command = preview.command +``` + +Container edits supply both the requested replacement and a document derived +from the current runtime configuration: + +```swift +let result = try await core.recreateContainer( + originalID: snapshot.id, + replacement: editedDocument, + rollback: .containerEdit(from: snapshot.configuration) +) +``` + +Core validates both documents before deletion. If replacement creation fails +after the original was deleted, Core attempts the rollback and throws +`Core.Container.RecreateFailure` with its phase and recovery state. + +## Compose Example + +```swift +let project = try Core.Compose.parse(composeText, projectName: "stack") +let plan = try core.translateCompose(project, + baseDirectory: composeDirectory, + runtimeKind: .appleContainer) +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. +Docker projection support exists for direct adapter tests and future provider +work, but the Docker module is not in the default registry yet. + +## 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 +let document = Core.Container.Document( + canonical: .init(createRequest: request) +) + +let plan = try core.planMigration(document, to: .docker) +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 +``` + +## Related Documentation + +- [Runtime Adapters](../../Documentation/Architecture/Runtime-Adapters.md) +- [Architecture](../../Documentation/Architecture/Architecture.md) +- [Wiki sync map](../../Documentation/Wiki/File-Map.md) diff --git a/Packages/ContainedCore/Sources/ContainedCore/Command/Error.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/Error.swift new file mode 100644 index 00000000..244ddd5b --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/Error.swift @@ -0,0 +1,40 @@ +import Foundation + +/// A typed error for everything that can go wrong invoking the `container` CLI. +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. + case nonZeroExit(code: Int32, stderr: String, command: String) + /// The process produced output that failed to decode as the expected JSON. + case decodingFailed(underlying: String, command: String) + /// The process could not be launched at all. + case launchFailed(underlying: String) + + public var packageName: String { "ContainedCore" } + + 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 ["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 ["underlying": underlying] + } + } +} + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Command/Preview.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/Preview.swift new file mode 100644 index 00000000..6ab29d59 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/Preview.swift @@ -0,0 +1,24 @@ +import Foundation + +public extension Core.Command { +struct Preview: Equatable, Sendable { + public var command: [String] + public var warnings: [String] + + public init(command: [String], warnings: [String] = []) { + self.command = command + self.warnings = warnings + } +} + +struct Invocation: 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..74ced7c5 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/PreviewBuilders.swift @@ -0,0 +1,37 @@ +import Foundation + +public extension Core.Command { + static func runPreview(for request: Core.Container.CreateRequest) -> [String] { + Core.Runtime.module(for: request.runtimeKind)?.runPreview(for: request) ?? [] + } + + static func buildPreview(context: String, + tag: String? = nil, + dockerfile: String? = nil, + buildArgs: [String: String] = [:], + noCache: Bool = false, + platform: String? = nil, + runtimeKind: Core.Runtime.Kind) -> [String] { + Core.Runtime.module(for: runtimeKind)?.buildPreview(context: context, + tag: tag, + dockerfile: dockerfile, + buildArgs: buildArgs, + noCache: noCache, + platform: platform) ?? [] + } + + static func networkCreatePreview(name: String, + subnet: String? = nil, + internalOnly: Bool = false, + runtimeKind: Core.Runtime.Kind) -> [String] { + Core.Runtime.module(for: runtimeKind)?.networkCreatePreview(name: name, + subnet: subnet, + internalOnly: internalOnly) ?? [] + } + + static func volumeCreatePreview(name: String, + size: String? = nil, + runtimeKind: Core.Runtime.Kind) -> [String] { + Core.Runtime.module(for: runtimeKind)?.volumeCreatePreview(name: name, size: size) ?? [] + } +} diff --git a/Sources/ContainedCore/Services/CommandRunner.swift b/Packages/ContainedCore/Sources/ContainedCore/Command/Runner.swift similarity index 59% rename from Sources/ContainedCore/Services/CommandRunner.swift rename to Packages/ContainedCore/Sources/ContainedCore/Command/Runner.swift index dd714a2a..44e63ae8 100644 --- a/Sources/ContainedCore/Services/CommandRunner.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Command/Runner.swift @@ -1,29 +1,60 @@ import Foundation +public extension Core.Command { +enum ExecutionPriority: 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 +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]) 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: 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]) -> AsyncThrowingStream + func stream(_ arguments: [String], priority: Core.Command.ExecutionPriority) -> AsyncThrowingStream +} + } -public extension CommandRunning { - /// Default: ignore stdin (conformers that don't spawn a real process, like the test mock). +public extension Core.Command.Running { + 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) } } +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) { @@ -31,16 +62,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: Core.Command.ExecutionPriority) 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() @@ -52,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 } @@ -63,29 +101,29 @@ 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( + continuation.resume(throwing: Core.Command.Error.nonZeroExit( code: process.terminationStatus, stderr: stderr, command: arguments.joined(separator: " ") @@ -95,7 +133,7 @@ public final class CommandRunner: CommandRunning { } } - public func stream(_ arguments: [String]) -> 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 @@ -104,6 +142,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 +166,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: Core.Command.Error.launchFailed(underlying: error.localizedDescription)) + } } } } @@ -147,3 +188,18 @@ 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/Packages/ContainedCore/Sources/ContainedCore/Compose/API.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/API.swift new file mode 100644 index 00000000..d66a3b9e --- /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 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 new file mode 100644 index 00000000..9e6360ed --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/ImportExport.swift @@ -0,0 +1,45 @@ +import Foundation + +public extension Core.Compose { +enum Dialect: String, Codable, Equatable, Hashable, Sendable { + case generic + case dockerCompose + case podmanCompose + case nerdctlCompose +} + +struct ImportPlan: Equatable, Sendable { + public var items: [Core.Compose.ImportItem] + public var warnings: [String] + + public init(items: [Core.Compose.ImportItem], warnings: [String] = []) { + self.items = items + self.warnings = warnings + } +} + +struct ImportItem: Equatable, Sendable { + public var document: Core.Schema.Document + public var healthCheck: Core.Container.HealthCheck? + + public init(document: Core.Schema.Document, healthCheck: Core.Container.HealthCheck? = nil) { + self.document = document + self.healthCheck = healthCheck + } +} + +struct ExportPlan: Equatable, Sendable { + public var dialect: Core.Compose.Dialect + public var warnings: [String] + public var isAvailable: Bool + + public init(dialect: Core.Compose.Dialect = .generic, + warnings: [String] = [], + isAvailable: Bool = false) { + self.dialect = dialect + self.warnings = warnings + self.isAvailable = isAvailable + } +} + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/DependencyOrdering.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/DependencyOrdering.swift new file mode 100644 index 00000000..6b18b446 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/DependencyOrdering.swift @@ -0,0 +1,51 @@ +import Foundation + +public extension Core.Compose { +/// 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: [Core.Compose.Service]) -> (order: [String], cycle: Bool) { + let keys = services.map(\.key) + let known = Set(keys) + var edges: [String: [String]] = [:] + for service in services { + edges[service.key] = service.dependsOn.map(\.service).filter { known.contains($0) } + } + var state: [String: Int] = [:] // 0 = unseen, 1 = visiting, 2 = done + var result: [String] = [] + var cycle = false + + func visit(_ key: String) { + switch state[key] ?? 0 { + case 1: cycle = true; return + case 2: return + default: state[key] = 1 + } + for dep in edges[key] ?? [] { visit(dep) } + state[key] = 2 + result.append(key) + } + for key in keys { visit(key) } + return cycle ? (keys, true) : (result, false) + } +} +} + +extension Core.Compose.Parser { + /// Parse `depends_on` in both the short list form (`[a, b]` -> start order) and the long mapping + /// form (`{a: {condition: service_healthy}}`). + static func dependencies(_ value: Any?) -> [Core.Compose.Dependency] { + if let list = value as? [Any] { + 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 Core.Compose.Dependency(service: service, + condition: condition.flatMap(Core.Compose.Condition.init) ?? .started) + } + } + return [] + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/HealthcheckParsing.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/HealthcheckParsing.swift new file mode 100644 index 00000000..a35f36fa --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/HealthcheckParsing.swift @@ -0,0 +1,48 @@ +import Foundation + +extension Core.Compose.Parser { + /// Parse a `healthcheck:` block. `test` accepts `["CMD-SHELL", ""]`, `["CMD", a, b]`, or a + /// bare string; we normalize to an `exec` argv. + 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] + switch map["test"] { + case let s as String: test = ["sh", "-c", s] + case let list as [Any]: + let parts = list.compactMap { $0 as? String } + if parts.first == "CMD-SHELL" { test = ["sh", "-c", parts.dropFirst().joined(separator: " ")] } + else if parts.first == "CMD" { test = Array(parts.dropFirst()) } + else { test = parts } + default: return nil + } + guard !test.isEmpty else { return nil } + let interval = duration(map["interval"]) ?? 30 + let retries = (map["retries"] as? Int) ?? 3 + return Core.Compose.Healthcheck(test: test, intervalSeconds: interval, retries: retries) + } + + /// Parse a compose duration like "30s", "1m30s", or a bare number of seconds. + static func duration(_ value: Any?) -> Int? { + if let n = value as? Int { return n } + guard let s = value as? String else { return nil } + var total = 0 + var number = "" + for ch in s { + if ch.isNumber { + number.append(ch) + } else { + let n = Int(number) ?? 0 + number = "" + switch ch { + case "h": total += n * 3600 + case "m": total += n * 60 + case "s": total += n + default: break + } + } + } + if let trailing = Int(number) { total += trailing } + return total > 0 ? total : nil + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/PreservedFields.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/PreservedFields.swift new file mode 100644 index 00000000..e191b1b4 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/PreservedFields.swift @@ -0,0 +1,120 @@ +import Foundation + +extension Core.Compose.Parser { + 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 + } + + 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[.. Core.Compose.Project { + let loaded: Any? + 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. + for key in root.keys where !["services", "version", "name"].contains(key) { + warnings.append("Top-level `\(key)` isn't translated — set it up manually.") + } + + guard let servicesMap = root["services"] as? [String: Any] else { + throw Core.Compose.Error.invalid("No `services` section found.") + } + + 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 Core.Compose.Project(name: resolvedName, services: services, warnings: warnings) + } + + private static let supportedKeys: Set = + ["image", "command", "ports", "volumes", "environment", "restart", "container_name", + "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", + "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) { + warnings.append("`\(name).\(key)` isn't translated.") + } + let image = body["image"] as? String + if image == nil, body["build"] != nil { + warnings.append("`\(name)` uses `build:` — build the image first, then set its tag here.") + } + return Core.Compose.Service( + key: name, + name: (body["container_name"] as? String) ?? name, + image: image, + platform: body["platform"] as? String, + command: Self.scalarOrJoined(body["command"]), + entrypoint: Self.scalarOrJoined(body["entrypoint"]), + workingDir: body["working_dir"] as? String, + user: Self.stringValue(body["user"]), + cpus: Self.stringValue(body["cpus"]), + memory: Self.stringValue(body["mem_limit"]), + ports: Self.ports(body["ports"], service: name, warnings: &warnings), + volumes: Self.volumes(body["volumes"], service: name, warnings: &warnings), + environment: Self.environment(body["environment"]), + envFiles: Self.stringList(body["env_file"], service: name, key: "env_file", warnings: &warnings), + labels: Self.keyValues(body["labels"]), + restart: Self.restart(body["restart"]), + network: Self.network(mode: body["network_mode"], networks: body["networks"]), + networkMode: Self.stringValue(body["network_mode"]), + readOnly: body["read_only"] as? Bool ?? false, + initProcess: body["init"] as? Bool ?? false, + interactive: body["stdin_open"] as? Bool ?? false, + tty: body["tty"] as? Bool ?? false, + capAdd: Self.stringList(body["cap_add"], service: name, key: "cap_add", warnings: &warnings), + capDrop: Self.stringList(body["cap_drop"], service: name, key: "cap_drop", warnings: &warnings), + dns: Self.stringList(body["dns"], service: name, key: "dns", warnings: &warnings), + dnsSearch: Self.stringList(body["dns_search"], service: name, key: "dns_search", warnings: &warnings), + dnsOptions: Self.stringList(body["dns_opt"], service: name, key: "dns_opt", warnings: &warnings), + tmpfs: Self.stringList(body["tmpfs"], service: name, key: "tmpfs", warnings: &warnings), + ulimits: Self.ulimits(body["ulimits"], service: name, warnings: &warnings), + dependsOn: Self.dependencies(body["depends_on"]), + healthcheck: Self.healthcheck(body["healthcheck"]), + preservedFields: Self.preservedFields(body, service: name) + ) + } + +} + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/ScalarConversion.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/ScalarConversion.swift new file mode 100644 index 00000000..feade552 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/ScalarConversion.swift @@ -0,0 +1,64 @@ +import Foundation + +extension Core.Compose.Parser { + /// A scalar string, or a list joined with spaces (compose `command` accepts both). + static func scalarOrJoined(_ value: Any?) -> String? { + if let s = value as? String { return s } + if let list = value as? [Any] { return list.compactMap(stringValue).joined(separator: " ") } + return nil + } + + /// A scalar string/number/bool rendered as text. + static func stringValue(_ value: Any?) -> String? { + switch value { + case let s as String: return s + case let i as Int: return String(i) + case let d as Double: return String(d) + case let b as Bool: return b ? "true" : "false" + default: return nil + } + } + + /// A list of short-form strings; long-form (mapping) entries are reported, not translated. + static func stringList(_ value: Any?, service: String, key: String, warnings: inout [String]) -> [String] { + if let scalar = stringValue(value) { return [scalar] } + guard let list = value as? [Any] else { + if value != nil { warnings.append("`\(service).\(key)` uses an unsupported shape.") } + return [] + } + var out: [String] = [] + for entry in list { + if let s = stringValue(entry) { out.append(s) } + else { warnings.append("`\(service).\(key)` long syntax isn't translated.") } + } + return out + } + + /// Environment as a list ("KEY=val") or a mapping ({KEY: val}) -> normalized "KEY=value". + static func environment(_ value: Any?) -> [String] { + if let list = value as? [Any] { return list.compactMap(stringValue) } + return keyValues(value) + } + + /// Labels as a list ("KEY=val") or a mapping ({KEY: val}) -> normalized "KEY=value". + static func keyValues(_ value: Any?) -> [String] { + if let list = value as? [Any] { return list.compactMap(stringValue) } + if let map = value as? [String: Any] { + return map.keys.sorted().map { "\($0)=\(stringify(map[$0]))" } + } + return [] + } + + static func stringify(_ value: Any?) -> 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/Compose/YAML/ServiceExtraction.swift b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/ServiceExtraction.swift new file mode 100644 index 00000000..5905f8b2 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Compose/YAML/ServiceExtraction.swift @@ -0,0 +1,99 @@ +import Foundation + +extension Core.Compose.Parser { + /// Parse ports in short syntax or Compose long syntax into `container run --publish` specs. + static func ports(_ value: Any?, service: String, warnings: inout [String]) -> [String] { + guard let list = value as? [Any] else { + if value != nil { warnings.append("`\(service).ports` uses an unsupported shape.") } + return [] + } + var out: [String] = [] + for entry in list { + if let s = stringValue(entry) { + if s.contains(":") { + out.append(s) + } else { + warnings.append("`\(service).ports` entry `\(s)` has no host port to publish.") + } + } else if let map = entry as? [String: Any] { + guard let target = stringValue(map["target"]) else { + warnings.append("`\(service).ports` long syntax is missing `target`.") + continue + } + guard let published = stringValue(map["published"]) else { + warnings.append("`\(service).ports` entry for target `\(target)` has no published host port.") + continue + } + let hostIP = stringValue(map["host_ip"]) + let protocolName = (stringValue(map["protocol"]) ?? "tcp").lowercased() + var spec = [hostIP, published, target].compactMap { value in + let trimmed = value?.trimmingCharacters(in: .whitespaces) + return trimmed?.isEmpty == false ? trimmed : nil + }.joined(separator: ":") + if protocolName != "tcp" { spec += "/\(protocolName)" } + out.append(spec) + } else { + warnings.append("`\(service).ports` entry isn't translated.") + } + } + return out + } + + /// Parse bind/volume mounts in short syntax or Compose long syntax into `--volume` specs. + static func volumes(_ value: Any?, service: String, warnings: inout [String]) -> [String] { + guard let list = value as? [Any] else { + if value != nil { warnings.append("`\(service).volumes` uses an unsupported shape.") } + return [] + } + var out: [String] = [] + for entry in list { + if let s = stringValue(entry) { + out.append(s) + } else if let map = entry as? [String: Any], + let source = stringValue(map["source"]), + let target = stringValue(map["target"]) { + var spec = "\(source):\(target)" + if (map["read_only"] as? Bool) == true { spec += ":ro" } + out.append(spec) + } else { + warnings.append("`\(service).volumes` entry isn't translated.") + } + } + return out + } + + /// Parse Compose ulimits into `type=soft[:hard]` entries. + static func ulimits(_ value: Any?, service: String, warnings: inout [String]) -> [String] { + if let list = value as? [Any] { return list.compactMap(stringValue) } + guard let map = value as? [String: Any] else { + if value != nil { warnings.append("`\(service).ulimits` uses an unsupported shape.") } + return [] + } + return map.keys.sorted().compactMap { key in + if let scalar = stringValue(map[key]) { return "\(key)=\(scalar)" } + if let limits = map[key] as? [String: Any], + let soft = stringValue(limits["soft"]) { + let hard = stringValue(limits["hard"]) + return hard == nil ? "\(key)=\(soft)" : "\(key)=\(soft):\(hard!)" + } + warnings.append("`\(service).ulimits.\(key)` isn't translated.") + return nil + } + } + + /// Compose `unless-stopped` matches the app's existing `always` policy because user-initiated + /// stops are already suppressed by the watchdog. + static func restart(_ value: Any?) -> String? { + let raw = stringValue(value) + return raw == "unless-stopped" ? "always" : raw + } + + /// Prefer explicit `network_mode`; otherwise use the first named service network. Compose + /// `host` matches the default run form network for Apple's runtime, so leave it blank. + static func network(mode: Any?, networks: Any?) -> String? { + if let mode = stringValue(mode) { return mode == "host" ? nil : mode } + if let list = networks as? [Any] { return list.compactMap(stringValue).first } + if let map = networks as? [String: Any] { return map.keys.sorted().first } + return nil + } +} 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..a9495950 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/ContainedCore.docc/ContainedCore.md @@ -0,0 +1,94 @@ +# ``ContainedCore`` + +Backend orchestration, runtime translation, command execution, Compose +interchange, metrics, and display-neutral errors for Contained. + +## Overview + +`ContainedCore` exposes a nested `Core.*` API, matching the `UI.*` and `UX.*` +package style. App code talks to ``Core/Orchestrator``. Runtime adapters live +inside Core so the app does not create adapter clients or assemble backend argv. +Apple container is registered by default; Docker CLI adapter groundwork exists +under `Runtimes/Docker` but remains dormant until a provider model is chosen. +Shared Core consumes `Core.Runtime.Module` registrations; concrete descriptors, +CLI lookup, readiness probing, command previews, terminal invocations, schema +support profiles, Compose projection, and client creation live under runtime +adapter folders. The built-in registry lives in shared `Runtime` infrastructure, +not inside a concrete adapter folder. + +Use Core for: + +- runtime descriptors and capabilities +- runtime module registration and per-runtime readiness +- 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 +- image defaults and registry helpers +- stats snapshots, metric normalization, and history inputs +- typed display-neutral package errors + +`ContainedCore` does not import SwiftUI, Sparkle, SwiftTerm, ContainedUI, or +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; +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 + +var document = Core.Schema.Document.containerCreate(runtimeKind: .appleContainer) +document.set(.containerName, .string("web")) +document.set(.imageReference, .string("nginx:latest")) + +let core = Core.Orchestrator.testing(runner: PreviewRunner(), + runtimeKind: .appleContainer) +let preview = try core.previewCreateCommand(for: document) +``` + +For an edit, pass the requested replacement and a rollback document derived +from the live configuration. Both validate before mutation; a failed replacement +automatically attempts restoration and reports `Core.Container.RecreateFailure`. + +```swift +let result = try await core.recreateContainer( + originalID: snapshot.id, + replacement: editedDocument, + rollback: .containerEdit(from: snapshot.configuration) +) +``` + +## 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. + +## Related Documentation + +- +- [Runtime Adapters](../../../../../Documentation/Architecture/Runtime-Adapters.md) +- [Wiki sync map](../../../../../Documentation/Wiki/File-Map.md) diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift new file mode 100644 index 00000000..a48f3a62 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/CreateRequest.swift @@ -0,0 +1,252 @@ +import Foundation + +public extension Core.Container { +struct KeyValue: 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 + } +} + +struct Port: 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 + } +} + +struct VolumeMount: 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 id + case source + case target + case readOnly + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + source = try c.decode(String.self, forKey: .source) + target = try c.decode(String.self, forKey: .target) + readOnly = try c.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false + } +} + +struct Socket: 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 + } +} + +struct ImageDefaults: Codable, Equatable, Sendable { + public var command: [String] + public var entrypoint: [String] + public var workingDirectory: String? + public var user: String? + public var environment: [Core.Container.KeyValue] + + public init(command: [String] = [], + entrypoint: [String] = [], + workingDirectory: String? = nil, + user: String? = nil, + environment: [Core.Container.KeyValue] = []) { + self.command = command + self.entrypoint = entrypoint + self.workingDirectory = workingDirectory + self.user = user + self.environment = environment + } +} + +struct CreateResult: Codable, Equatable, Sendable { + public var id: String? + public var output: String + + public init(id: String? = nil, output: String = "") { + self.id = id + self.output = output + } +} + +struct CreateRequest: Codable, Equatable, Sendable { + public var runtimeKind: Core.Runtime.Kind + public var image = "" + public var platform = "" + public var os = "" + public var architecture = "" + 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: [Core.Container.KeyValue] = [] + public var envFiles: [String] = [] + public var ports: [Core.Container.Port] = [] + public var volumes: [Core.Container.VolumeMount] = [] + public var mounts: [String] = [] + 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 + 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 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(runtimeKind: Core.Runtime.Kind) { + self.runtimeKind = runtimeKind + } + + 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/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift new file mode 100644 index 00000000..ad926213 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/Document.swift @@ -0,0 +1,45 @@ +import Foundation + +public extension Core.Container { +struct Spec: Codable, Equatable, Sendable { + public var createRequest: Core.Container.CreateRequest + + public init(createRequest: Core.Container.CreateRequest) { + self.createRequest = createRequest + } +} + +} + +public extension Core.Runtime { +struct Projection: Codable, Equatable, Sendable { + public var kind: Core.Runtime.Kind + public var preservedFields: [String: String] + public var unsupportedFields: [String] + + public init(kind: Core.Runtime.Kind, + preservedFields: [String: String] = [:], + unsupportedFields: [String] = []) { + self.kind = kind + self.preservedFields = preservedFields + self.unsupportedFields = unsupportedFields + } +} +} + +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: 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/Sources/ContainedCore/Models/HealthCheck.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/HealthCheck.swift similarity index 85% rename from Sources/ContainedCore/Models/HealthCheck.swift rename to Packages/ContainedCore/Sources/ContainedCore/Container/HealthCheck.swift index 14312107..35d3c129 100644 --- a/Sources/ContainedCore/Models/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/Sources/ContainedCore/Models/ContainerJSON.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/JSONDecoding.swift similarity index 77% rename from Sources/ContainedCore/Models/ContainerJSON.swift rename to Packages/ContainedCore/Sources/ContainedCore/Container/JSONDecoding.swift index 33e21961..70fe4a25 100644 --- a/Sources/ContainedCore/Models/ContainerJSON.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/JSONDecoding.swift @@ -6,9 +6,14 @@ 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 = { + makeDecoder() + }() + + public static func makeDecoder(runtimeKind: Core.Runtime.Kind? = nil) -> JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() @@ -19,8 +24,11 @@ public enum ContainerJSON { debugDescription: "Unrecognized date format: \(raw)" ) } + if let runtimeKind { + decoder.userInfo[.coreRuntimeKind] = runtimeKind + } return decoder - }() + } public static let encoder: JSONEncoder = { let encoder = JSONEncoder() @@ -42,7 +50,11 @@ public enum ContainerJSON { } /// Decode a value of the given type from raw CLI stdout. - public static func decode(_ type: T.Type, from data: Data) throws -> T { - try decoder.decode(type, from: data) + public static func decode(_ type: T.Type, + from data: Data, + runtimeKind: Core.Runtime.Kind? = nil) throws -> T { + try makeDecoder(runtimeKind: runtimeKind).decode(type, from: data) } } + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Container/RecreateFailure.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/RecreateFailure.swift new file mode 100644 index 00000000..4365c390 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/RecreateFailure.swift @@ -0,0 +1,87 @@ +import Foundation + +public extension Core.Container { + /// Describes which destructive recreate phase failed and whether Core restored the original. + struct RecreateFailure: Error, Equatable, Sendable { + public enum Phase: String, Equatable, Sendable { + case deleteOriginal + case createReplacement + case restoreOriginal + } + + public enum Recovery: String, Equatable, Sendable { + case notNeeded + case originalRestored + case restoreFailed + } + + public struct Cause: Equatable, Sendable { + public let packageName: String + public let code: String + /// Runtime detail for immediate presentation. This must not be persisted or logged. + public let runtimeDetail: String + + init(_ error: Error) { + if let packageError = error as? Core.Error.PackageError { + packageName = packageError.packageName + code = packageError.packageErrorCode + } else { + packageName = "ContainedCore" + code = String(describing: type(of: error)) + } + runtimeDetail = Self.runtimeDetail(for: error) + } + + private static func runtimeDetail(for error: Error) -> String { + switch error { + case let error as Core.Command.Error: + switch error { + case .cliNotFound(let searched): + return searched.joined(separator: ", ") + case .nonZeroExit(_, let stderr, _): + return stderr + case .decodingFailed(let underlying, _), .launchFailed(let underlying): + return underlying + } + case let error as LocalizedError: + return error.errorDescription ?? String(describing: error) + default: + return String(describing: error) + } + } + } + + public let phase: Phase + public let recovery: Recovery + public let primaryFailure: Cause + public let recoveryFailure: Cause? + + init(phase: Phase, + recovery: Recovery, + primaryError: Error, + recoveryError: Error? = nil) { + self.phase = phase + self.recovery = recovery + primaryFailure = Cause(primaryError) + recoveryFailure = recoveryError.map(Cause.init) + } + } +} + +extension Core.Container.RecreateFailure: Core.Error.PackageError { + public var packageName: String { "ContainedCore" } + public var packageErrorCode: String { "containerRecreateFailed" } + + /// Deliberately allowlisted: runtime detail may contain commands, paths, or secrets. + public var packageErrorContext: [String: String] { + var context = [ + "phase": phase.rawValue, + "recovery": recovery.rawValue, + "primaryCode": primaryFailure.code, + ] + if let recoveryFailure { + context["recoveryCode"] = recoveryFailure.code + } + return context + } +} diff --git a/Sources/ContainedCore/Models/RestartPolicy.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/RestartPolicy.swift similarity index 79% rename from Sources/ContainedCore/Models/RestartPolicy.swift rename to Packages/ContainedCore/Sources/ContainedCore/Container/RestartPolicy.swift index e37ef618..6d4e6867 100644 --- a/Sources/ContainedCore/Models/RestartPolicy.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/RestartPolicy.swift @@ -2,27 +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 } - 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?) { - 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: @@ -32,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 @@ -51,3 +45,5 @@ public enum RestartDecision { return min(raw, cap) } } + +} diff --git a/Sources/ContainedCore/Models/Container.swift b/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift similarity index 54% rename from Sources/ContainedCore/Models/Container.swift rename to Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift index b0d3262c..d3932522 100644 --- a/Sources/ContainedCore/Models/Container.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Container/Snapshot.swift @@ -4,30 +4,55 @@ 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 let runtimeKind: Core.Runtime.Kind - public var state: RuntimeStatus { status.state } + public var state: Core.Runtime.Status { status.state } + public var rawState: String { status.rawState } public var image: String { configuration.image.reference } public var startedDate: Date? { status.startedDate } + public var scopedID: String { runtimeKind.scopedID(for: id) } - /// 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 } + + public init(configuration: Core.Container.Configuration, + id: String, + status: Core.Container.RuntimeState, + runtimeKind: Core.Runtime.Kind) { + 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. public static func placeholder(id: String, image: String, - state: RuntimeStatus = .running) -> ContainerSnapshot { + state: Core.Runtime.Status = .running, + runtimeKind: Core.Runtime.Kind) -> Core.Container.Snapshot { let payload = PlaceholderSnapshotPayload( id: id, status: .init(state: state.rawValue), @@ -35,7 +60,9 @@ public struct ContainerSnapshot: Codable, Sendable, Identifiable, Hashable { ) do { let data = try JSONEncoder().encode(payload) - return try JSONDecoder().decode(ContainerSnapshot.self, from: data) + return try Core.Container.JSON.decode(Core.Container.Snapshot.self, + from: data, + runtimeKind: runtimeKind) } catch { preconditionFailure("Invalid placeholder snapshot: \(error)") } @@ -65,21 +92,47 @@ 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 rawState: String public let networks: [NetworkInterfaceStatus] public let startedDate: Date? + enum CodingKeys: String, CodingKey { + case state + case networks + case startedDate + } + + public init(state: Core.Runtime.Status, + rawState: String? = nil, + networks: [NetworkInterfaceStatus] = [], + startedDate: Date? = nil) { + self.state = state + self.rawState = rawState ?? state.rawValue + self.networks = networks + self.startedDate = startedDate + } + public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - self.state = try c.decodeIfPresent(RuntimeStatus.self, forKey: .state) ?? .unknown + let rawState = try c.decodeIfPresent(String.self, forKey: .state) ?? Core.Runtime.Status.unknown.rawValue + self.rawState = rawState + self.state = Core.Runtime.Status(rawValue: rawState) ?? .unknown self.networks = try c.decodeIfPresent([NetworkInterfaceStatus].self, forKey: .networks) ?? [] self.startedDate = try c.decodeIfPresent(Date.self, forKey: .startedDate) } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(rawState, forKey: .state) + try c.encode(networks, forKey: .networks) + try c.encodeIfPresent(startedDate, 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? @@ -90,7 +143,8 @@ 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 runtimeKind: Core.Runtime.Kind public let id: String public let image: ImageReference public let initProcess: ProcessConfiguration @@ -117,6 +171,17 @@ public struct ContainerConfiguration: Codable, Sendable, Hashable { public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) + if let decodedRuntimeKind = try c.decodeIfPresent(Core.Runtime.Kind.self, forKey: .runtimeKind) { + runtimeKind = decodedRuntimeKind + } else if let contextRuntimeKind = decoder.coreRuntimeKindContext { + runtimeKind = contextRuntimeKind + } else { + throw DecodingError.keyNotFound( + CodingKeys.runtimeKind, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Missing runtimeKind and no runtime decoding context was provided.") + ) + } id = try c.decode(String.self, forKey: .id) image = try c.decode(ImageReference.self, forKey: .image) initProcess = try c.decode(ProcessConfiguration.self, forKey: .initProcess) @@ -141,20 +206,97 @@ public struct ContainerConfiguration: 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, + 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) + } } -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] @@ -171,7 +313,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? @@ -180,11 +322,17 @@ 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? + 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)" } @@ -192,7 +340,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? @@ -202,13 +350,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? @@ -218,7 +366,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] @@ -233,7 +381,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? @@ -263,6 +411,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 new file mode 100644 index 00000000..4eb65676 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Core.swift @@ -0,0 +1,22 @@ +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 {} +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Errors/PackageError.swift b/Packages/ContainedCore/Sources/ContainedCore/Errors/PackageError.swift new file mode 100644 index 00000000..fb74c60c --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Errors/PackageError.swift @@ -0,0 +1,18 @@ +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 extension Core.Error { +protocol PackageError: Error, Sendable { + var packageName: String { get } + var packageErrorCode: String { get } + var packageErrorContext: [String: String] { get } +} + +} + +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 new file mode 100644 index 00000000..fbbd8753 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Field/Provenance.swift @@ -0,0 +1,34 @@ +import Foundation + +public extension Core.Field { +struct Path: Codable, Equatable, Hashable, Sendable { + public var rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } +} + +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] = [:], + 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/Sources/ContainedCore/Models/ImageResource.swift b/Packages/ContainedCore/Sources/ContainedCore/Image/Image+Resource.swift similarity index 52% rename from Sources/ContainedCore/Models/ImageResource.swift rename to Packages/ContainedCore/Sources/ContainedCore/Image/Image+Resource.swift index 33ce2659..7cde8a09 100644 --- a/Sources/ContainedCore/Models/ImageResource.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Image/Image+Resource.swift @@ -1,42 +1,73 @@ 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 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(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) ?? [] + if let decodedRuntimeKind = try c.decodeIfPresent(Core.Runtime.Kind.self, forKey: .runtimeKind) { + runtimeKind = decodedRuntimeKind + } else if let contextRuntimeKind = decoder.coreRuntimeKindContext { + runtimeKind = contextRuntimeKind + } else { + throw DecodingError.keyNotFound( + CodingKeys.runtimeKind, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Missing runtimeKind and no runtime decoding context was provided.") + ) + } + } + + public init(configuration: Core.Image.Configuration, + id: String, + variants: [Core.Image.Variant] = [], + runtimeKind: Core.Runtime.Kind) { + 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) } } -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 +115,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 new file mode 100644 index 00000000..b63e8705 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Image/LocalTagGroup.swift @@ -0,0 +1,110 @@ +import Foundation + +public extension Core.Image { +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] { + 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 + } + } + } + + 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))" + } + .compactMap { _, images -> Core.Image.LocalTag? in + guard let first = images.first else { return nil } + return Core.Image.LocalTag(reference: first.reference, + runtimeKind: first.runtimeKind, + 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: digest ?? Core.Registry.ImageReference.normalizedKey(references[0]), + digest: digest, + references: references, + tags: tags, + images: sortedImages + ) + } + .sorted { $0.primaryReference.localizedCaseInsensitiveCompare($1.primaryReference) == .orderedAscending } + } + + 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 ?? 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/Sources/ContainedCore/Models/ImageUpdateStatus.swift b/Packages/ContainedCore/Sources/ContainedCore/Image/UpdateStatus.swift similarity index 55% rename from Sources/ContainedCore/Models/ImageUpdateStatus.swift rename to Packages/ContainedCore/Sources/ContainedCore/Image/UpdateStatus.swift index f7c95689..a2fba26d 100644 --- a/Sources/ContainedCore/Models/ImageUpdateStatus.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/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/GraphMetric.swift b/Packages/ContainedCore/Sources/ContainedCore/Metrics/GraphMetric.swift new file mode 100644 index 00000000..e060a36a --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Metrics/GraphMetric.swift @@ -0,0 +1,78 @@ +/// A runtime-neutral metric that can be graphed from live or persisted container stats. +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: 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) + 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 Core.Metrics.HistorySample, + snapshot: Core.Container.Snapshot? = nil, + normalization: Core.Metrics.NormalizationContext = .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: 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: 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: Core.Metrics.StatsDelta, + snapshot: Core.Container.Snapshot?, + normalization: Core.Metrics.NormalizationContext = .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 + } +} + +protocol HistorySample { + 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/Packages/ContainedCore/Sources/ContainedCore/Metrics/Normalization.swift b/Packages/ContainedCore/Sources/ContainedCore/Metrics/Normalization.swift new file mode 100644 index 00000000..842a8bcb --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Metrics/Normalization.swift @@ -0,0 +1,53 @@ +import Foundation + +public extension Core.Metrics { +enum NormalizationMode: String, CaseIterable, Identifiable, Codable, Hashable, Sendable { + case container + case machine = "global" + + public var id: String { rawValue } +} + +struct NormalizationContext: Equatable, Sendable { + public var mode: Core.Metrics.NormalizationMode + public var machineCPUs: Int? + public var machineMemoryBytes: UInt64? + + public static let containerSpecific = Core.Metrics.NormalizationContext(mode: .container) + + public init(mode: Core.Metrics.NormalizationMode = .container, + machineCPUs: Int? = nil, + machineMemoryBytes: UInt64? = nil) { + self.mode = mode + self.machineCPUs = machineCPUs + self.machineMemoryBytes = machineMemoryBytes + } + + public func cpuLimit(for snapshot: Core.Container.Snapshot?) -> 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: Core.Container.Snapshot?, 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: Core.Metrics.StatsDelta, snapshot: Core.Container.Snapshot?) -> UInt64 { + memoryLimitBytes(for: snapshot, fallback: delta.memoryLimitBytes) + } +} + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Metrics/Stats.swift b/Packages/ContainedCore/Sources/ContainedCore/Metrics/Stats.swift new file mode 100644 index 00000000..3ca73c01 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Metrics/Stats.swift @@ -0,0 +1,187 @@ +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 `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? + 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 var memoryFraction: Double? { + 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. +/// +/// 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 +/// `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. +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 + } + + 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. +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 + public let memoryUsageBytes: UInt64 + public let memoryLimitBytes: UInt64 + public let netRxBytesPerSec: Double + public let netTxBytesPerSec: Double + public let blockReadBytesPerSec: Double + public let blockWriteBytesPerSec: Double + public let numProcesses: UInt64 + + public var memoryFraction: Double { + memoryLimitBytes > 0 ? Double(memoryUsageBytes) / Double(memoryLimitBytes) : 0 + } + + public init(id: String, cpuCoreFraction: Double, memoryUsageBytes: UInt64, memoryLimitBytes: UInt64, + netRxBytesPerSec: Double, netTxBytesPerSec: Double, blockReadBytesPerSec: Double, + blockWriteBytesPerSec: Double, numProcesses: UInt64) { + self.id = id + self.cpuCoreFraction = cpuCoreFraction + self.memoryUsageBytes = memoryUsageBytes + self.memoryLimitBytes = memoryLimitBytes + self.netRxBytesPerSec = netRxBytesPerSec + self.netTxBytesPerSec = netTxBytesPerSec + self.blockReadBytesPerSec = blockReadBytesPerSec + self.blockWriteBytesPerSec = blockWriteBytesPerSec + self.numProcesses = numProcesses + } + + /// Deterministic sample values for previews and package examples. + 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) + } + + /// A smooth sample history for preview sparklines. + public static var sampleHistory: [Double] { + (0..<30).map { (i: Int) -> Double in + let x = Double(i) + let a: Double = sin(x / 3.0) * 0.22 + let b: Double = sin(x / 1.3) * 0.06 + return 0.42 + a + b + } + } + + /// Compute a delta from a previous sample taken `interval` seconds earlier. + 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 } + return Double(b - a) / dt + } + // cpuUsageUsec is microseconds of CPU time; fraction of a core = Δusec / (dt * 1e6). + let cpu: Double = { + guard let a = previous.cpuUsageUsec, let b = current.cpuUsageUsec, b >= a else { return 0 } + return Double(b - a) / (dt * 1_000_000) + }() + return Core.Metrics.StatsDelta( + id: current.id, + cpuCoreFraction: cpu, + memoryUsageBytes: current.memoryUsageBytes ?? 0, + memoryLimitBytes: current.memoryLimitBytes ?? 0, + netRxBytesPerSec: rate(previous.networkRxBytes, current.networkRxBytes), + netTxBytesPerSec: rate(previous.networkTxBytes, current.networkTxBytes), + blockReadBytesPerSec: rate(previous.blockReadBytes, current.blockReadBytes), + blockWriteBytesPerSec: rate(previous.blockWriteBytes, current.blockWriteBytes), + 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: 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 Core.Metrics.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/Packages/ContainedCore/Sources/ContainedCore/Migration/Plan.swift b/Packages/ContainedCore/Sources/ContainedCore/Migration/Plan.swift new file mode 100644 index 00000000..cceba49a --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Migration/Plan.swift @@ -0,0 +1,28 @@ +import Foundation + +public extension Core.Migration { +enum UnavailableReason: String, Equatable, Sendable { + case exportImportUnsupported +} + +struct Plan: Equatable, Sendable { + public var isAvailable: Bool + public var unavailableReason: Core.Migration.UnavailableReason? + public var context: [String: String] + public var source: Core.Runtime.Kind + public var target: Core.Runtime.Kind? + + public init(isAvailable: Bool, + unavailableReason: Core.Migration.UnavailableReason?, + context: [String: String] = [:], + source: Core.Runtime.Kind, + target: Core.Runtime.Kind?) { + self.isAvailable = isAvailable + self.unavailableReason = unavailableReason + self.context = context + self.source = source + self.target = target + } +} + +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Network/Network+Resource.swift b/Packages/ContainedCore/Sources/ContainedCore/Network/Network+Resource.swift new file mode 100644 index 00000000..af046606 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Network/Network+Resource.swift @@ -0,0 +1,93 @@ +import Foundation + +/// One element of `container network list --format json` (and `network inspect`). +public extension Core.Network { +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) { + 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) + if let decodedRuntimeKind = try c.decodeIfPresent(Core.Runtime.Kind.self, forKey: .runtimeKind) { + runtimeKind = decodedRuntimeKind + } else if let contextRuntimeKind = decoder.coreRuntimeKindContext { + runtimeKind = contextRuntimeKind + } else { + throw DecodingError.keyNotFound( + CodingKeys.runtimeKind, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Missing runtimeKind and no runtime decoding context was provided.") + ) + } + } + + 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 { + public let name: String + public let mode: String? + public let plugin: String? + public let creationDate: Date? + public let labels: [String: String] + public let options: Options? + + public struct Options: 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) + mode = try c.decodeIfPresent(String.self, forKey: .mode) + plugin = try c.decodeIfPresent(String.self, forKey: .plugin) + creationDate = try c.decodeIfPresent(Date.self, forKey: .creationDate) + labels = try c.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] + options = try c.decodeIfPresent(Options.self, forKey: .options) + } +} + +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/Orchestrator+Inventory.swift b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator+Inventory.swift new file mode 100644 index 00000000..683c976b --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator+Inventory.swift @@ -0,0 +1,107 @@ +import Foundation + +public extension Core.Orchestrator { + func containerInventory(all: Bool = true) async throws -> Core.Runtime.InventoryResult { + var snapshots: [Core.Container.Snapshot] = [] + var successes = 0 + var firstError: Swift.Error? + var failures: [Core.Runtime.InventoryFailure] = [] + for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + guard runtimes[kind]?.descriptor.supports(.containers) == true else { continue } + do { + let runtime = try requireRuntime(kind, + capability: .containers, + as: (any RuntimeContainerClient).self) + snapshots += try await runtime.listContainers(all: all).map { $0.scoped(to: kind) } + successes += 1 + } catch { + firstError = firstError ?? error + failures.append(.init(resource: "containers", kind: kind, message: String(describing: error))) + } + } + if successes == 0, let firstError { throw firstError } + return Core.Runtime.InventoryResult(items: snapshots, failures: failures) + } + + func listRuntimeContainers(all: Bool = true) async throws -> [Core.Container.Snapshot] { + try await containerInventory(all: all).items + } + + func networkInventory() async throws -> Core.Runtime.InventoryResult { + var networks: [Core.Network.Resource] = [] + var successes = 0 + var firstError: Swift.Error? + var failures: [Core.Runtime.InventoryFailure] = [] + for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + guard runtimes[kind]?.descriptor.supports(.networks) == true else { continue } + do { + let runtime = try requireRuntime(kind, + capability: .networks, + as: (any RuntimeNetworkClient).self) + networks += try await runtime.networks().map { $0.scoped(to: kind) } + successes += 1 + } catch { + firstError = firstError ?? error + failures.append(.init(resource: "networks", kind: kind, message: String(describing: error))) + } + } + if successes == 0, let firstError { throw firstError } + return Core.Runtime.InventoryResult(items: networks, failures: failures) + } + + func runtimeNetworks() async throws -> [Core.Network.Resource] { + try await networkInventory().items + } + + func volumeInventory() async throws -> Core.Runtime.InventoryResult { + var volumes: [Core.Volume.Resource] = [] + var successes = 0 + var firstError: Swift.Error? + var failures: [Core.Runtime.InventoryFailure] = [] + for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + guard runtimes[kind]?.descriptor.supports(.volumes) == true else { continue } + do { + let runtime = try requireRuntime(kind, + capability: .volumes, + as: (any RuntimeVolumeClient).self) + volumes += try await runtime.volumes().map { $0.scoped(to: kind) } + successes += 1 + } catch { + firstError = firstError ?? error + failures.append(.init(resource: "volumes", kind: kind, message: String(describing: error))) + } + } + if successes == 0, let firstError { throw firstError } + return Core.Runtime.InventoryResult(items: volumes, failures: failures) + } + + func runtimeVolumes() async throws -> [Core.Volume.Resource] { + try await volumeInventory().items + } + + func imageInventory() async throws -> Core.Runtime.InventoryResult { + var images: [Core.Image.Resource] = [] + var successes = 0 + var firstError: Swift.Error? + var failures: [Core.Runtime.InventoryFailure] = [] + for kind in runtimes.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + guard runtimes[kind]?.descriptor.supports(.images) == true else { continue } + do { + let runtime = try requireRuntime(kind, + capability: .images, + as: (any RuntimeImageClient).self) + images += try await runtime.images().map { $0.scoped(to: kind) } + successes += 1 + } catch { + firstError = firstError ?? error + failures.append(.init(resource: "images", kind: kind, message: String(describing: error))) + } + } + if successes == 0, let firstError { throw firstError } + return Core.Runtime.InventoryResult(items: images, failures: failures) + } + + func runtimeImages() async throws -> [Core.Image.Resource] { + try await imageInventory().items + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift new file mode 100644 index 00000000..d2057f9d --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Orchestration/Orchestrator.swift @@ -0,0 +1,660 @@ +import Foundation + +public extension Core { + struct Configuration: Sendable, Equatable { + public var runtimes: [Core.Runtime.Kind: Core.Runtime.Configuration] + + public init(runtimes: [Core.Runtime.Kind: Core.Runtime.Configuration] = [:]) { + self.runtimes = runtimes + } + + public func configuration(for kind: Core.Runtime.Kind) -> Core.Runtime.Configuration { + runtimes[kind] ?? Core.Runtime.Configuration() + } + } +} + +public extension Core { + struct RuntimeReadiness: Sendable, Equatable, Identifiable { + public enum State: String, Sendable, Equatable { + case cliMissing + 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(runtimes: [Core.RuntimeReadiness]) + case ready(orchestrator: Core.Orchestrator, runtimes: [Core.RuntimeReadiness]) + } + + let runtimes: [Core.Runtime.Kind: any RuntimeClient] + private let runtimeCLIURLs: [Core.Runtime.Kind: URL] + private let modules: [Core.Runtime.Kind: any Core.Runtime.Module] + + public static func == (lhs: Core.Orchestrator, rhs: Core.Orchestrator) -> Bool { + lhs.runtimeCLIURLs == rhs.runtimeCLIURLs + } + + public static func live(configuration: Core.Configuration = Core.Configuration()) -> Core.Orchestrator? { + var runtimes: [Core.Runtime.Kind: any RuntimeClient] = [:] + var cliURLs: [Core.Runtime.Kind: URL] = [:] + var modules: [Core.Runtime.Kind: any Core.Runtime.Module] = [:] + + for module in Core.Runtime.builtInModules { + let runtimeConfiguration = configuration.configuration(for: module.descriptor.kind) + guard let url = module.locateCLI(override: runtimeConfiguration.cliPathOverride) else { continue } + cliURLs[module.descriptor.kind] = url + modules[module.descriptor.kind] = module + runtimes[module.descriptor.kind] = module.makeClient( + runner: Core.Command.Runner(executableURL: url) + ) + } + + guard !runtimes.isEmpty else { return nil } + return Core.Orchestrator(cliURLs: cliURLs, runtimes: runtimes, modules: modules) + } + + public static func testing(runner: any Core.Command.Running, + cliURL: URL = URL(fileURLWithPath: "/usr/bin/container"), + runtimeKind: Core.Runtime.Kind) -> Core.Orchestrator { + guard let module = Core.Runtime.module(for: runtimeKind) else { + preconditionFailure("No runtime module registered for \(runtimeKind.rawValue)") + } + return Core.Orchestrator( + cliURLs: [runtimeKind: cliURL], + runtimes: [runtimeKind: module.makeClient(runner: runner)], + modules: [runtimeKind: module] + ) + } + + 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 RuntimeClient] = [:] + var modules: [Core.Runtime.Kind: any Core.Runtime.Module] = [:] + for (kind, runner) in runners { + guard let module = Core.Runtime.module(for: kind) else { + preconditionFailure("No runtime module registered for \(kind.rawValue)") + } + modules[kind] = module + clients[kind] = module.makeClient(runner: runner) + } + let resolvedURLs = Dictionary(uniqueKeysWithValues: clients.keys.map { kind in + let executableName = modules[kind]?.descriptor.executableName ?? kind.rawValue + return (kind, cliURLs[kind] ?? URL(fileURLWithPath: "/usr/bin/\(executableName)")) + }) + return Core.Orchestrator(cliURLs: resolvedURLs, runtimes: clients, modules: modules) + } + + public static func bootstrap(configuration: Core.Configuration = Core.Configuration()) async -> Bootstrap { + var readiness: [Core.RuntimeReadiness] = [] + var runtimes: [Core.Runtime.Kind: any RuntimeClient] = [:] + var cliURLs: [Core.Runtime.Kind: URL] = [:] + var modules: [Core.Runtime.Kind: any Core.Runtime.Module] = [:] + + for module in Core.Runtime.builtInModules { + let kind = module.descriptor.kind + let runtimeConfiguration = configuration.configuration(for: kind) + guard let cliURL = module.locateCLI(override: runtimeConfiguration.cliPathOverride) else { + readiness.append(Core.RuntimeReadiness(kind: kind, + cliURL: nil, + state: .cliMissing, + message: "\(module.descriptor.executableName ?? kind.rawValue) CLI was not found.")) + continue + } + + let runner = Core.Command.Runner(executableURL: cliURL) + cliURLs[kind] = cliURL + modules[kind] = module + runtimes[kind] = module.makeClient(runner: runner) + readiness.append(await module.readiness(cliURL: cliURL, runner: runner)) + } + + guard !runtimes.isEmpty else { return .cliMissing(runtimes: readiness) } + return .ready( + orchestrator: Core.Orchestrator(cliURLs: cliURLs, runtimes: runtimes, modules: modules), + runtimes: readiness + ) + } + + init(cliURLs: [Core.Runtime.Kind: URL], + runtimes: [Core.Runtime.Kind: any RuntimeClient], + modules: [Core.Runtime.Kind: any Core.Runtime.Module]? = nil) { + self.runtimeCLIURLs = cliURLs + self.runtimes = runtimes + self.modules = modules ?? Dictionary( + uniqueKeysWithValues: Core.Runtime.builtInModules + .filter { runtimes.keys.contains($0.descriptor.kind) } + .map { ($0.descriptor.kind, $0) } + ) + } + + public var availableRuntimeDescriptors: [Core.Runtime.Descriptor] { + runtimes.values.map(\.descriptor).sorted { $0.displayName < $1.displayName } + } + + public func descriptor(for kind: Core.Runtime.Kind) -> Core.Runtime.Descriptor? { + availableRuntimeDescriptors.first { $0.kind == kind } ?? modules[kind]?.descriptor + } + + public func supportsRuntime(_ kind: Core.Runtime.Kind, capability: Core.Runtime.Capability = .containers) -> Bool { + availableRuntimeDescriptors.first { $0.kind == kind }?.supports(capability) == true + } + + internal func requireRuntime(_ kind: Core.Runtime.Kind, + capability: Core.Runtime.Capability) throws -> any RuntimeClient { + guard let runtime = runtimes[kind] else { + throw Core.Runtime.UnsupportedCapability(kind: kind, capability: capability) + } + try runtime.descriptor.require(capability) + return runtime + } + + internal func requireRuntime(_ kind: Core.Runtime.Kind, + capability: Core.Runtime.Capability, + as capabilityType: Capability.Type) throws -> Capability { + let runtime = try requireRuntime(kind, capability: capability) + guard let typed = runtime as? Capability else { + throw Core.Runtime.UnsupportedCapability(kind: kind, capability: capability) + } + return typed + } + + private func requireModule(_ kind: Core.Runtime.Kind, + capability: Core.Runtime.Capability) throws -> any Core.Runtime.Module { + guard let module = modules[kind] ?? Core.Runtime.module(for: kind) else { + throw Core.Runtime.UnsupportedCapability(kind: kind, capability: capability) + } + try module.descriptor.require(capability) + return module + } + + public func cliURL(for kind: Core.Runtime.Kind) -> URL? { + runtimeCLIURLs[kind] + } + + 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 schemaDefinition(for operation: Core.Schema.Operation, + runtimeKind: Core.Runtime.Kind) -> Core.Schema.Definition { + Core.Schema.Definition.containerRunEdit(runtimeKind: runtimeKind, + operation: operation) + } + + public func stats(ids: [String] = [], + runtimeKind: Core.Runtime.Kind) async throws -> [Core.Metrics.ContainerStats] { + let runtime = try requireRuntime(runtimeKind, + capability: .containers, + as: (any RuntimeContainerClient).self) + 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, + as: (any RuntimeContainerClient).self) + 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(runtimeKind: Core.Runtime.Kind) async throws -> Core.System.DiskUsage { + try await requireRuntime(runtimeKind, + capability: .systemStatus, + as: (any RuntimeSystemStatusClient).self).diskUsage() + } + + public func systemProperties(runtimeKind: Core.Runtime.Kind) async throws -> Core.System.Properties { + try await requireRuntime(runtimeKind, + capability: .systemProperties, + as: (any RuntimeSystemStatusClient).self).systemProperties() + } + + public func dnsDomains(runtimeKind: Core.Runtime.Kind) async throws -> [String] { + try await requireRuntime(runtimeKind, + capability: .dnsManagement, + as: (any RuntimeDNSClient).self).dnsDomains() + } + + @discardableResult public func createDNSDomain(_ domain: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .dnsManagement, + as: (any RuntimeDNSClient).self).createDNSDomain(domain) + } + + @discardableResult public func deleteDNSDomain(_ domain: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .dnsManagement, + as: (any RuntimeDNSClient).self).deleteDNSDomain(domain) + } + + @discardableResult public func setRecommendedKernel(runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .kernelManagement, + as: (any RuntimeKernelClient).self).setRecommendedKernel() + } + + public func execCapture(_ id: String, + _ command: [String], + runtimeKind: Core.Runtime.Kind) async throws -> String { + try await requireRuntime(runtimeKind, + capability: .exec, + as: (any RuntimeExecClient).self).execCapture(id, command) + } + + @discardableResult public func copy(source: String, + destination: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .copy, + as: (any RuntimeExecClient).self).copy(source: source, destination: destination) + } + + public func terminalInvocation(containerID: String, + shell: String, + runtimeKind: Core.Runtime.Kind) throws -> Core.Command.Invocation { + _ = try requireRuntime(runtimeKind, capability: .exec, as: (any RuntimeExecClient).self) + let module = try requireModule(runtimeKind, capability: .exec) + return module.terminalInvocation(containerID: containerID, + shell: shell, + cliURL: try requireCLIURL(for: runtimeKind)) + } + + public func streamSystemLogs(follow: Bool, + last: Int? = 500, + runtimeKind: Core.Runtime.Kind) -> AsyncThrowingStream { + do { + let runtime = try requireRuntime(runtimeKind, + capability: .systemLogs, + as: (any RuntimeSystemLogsClient).self) + return runtime.streamSystemLogs(follow: follow, last: last) + } catch { + return AsyncThrowingStream { continuation in continuation.finish(throwing: error) } + } + } + + public func systemStatus(runtimeKind: Core.Runtime.Kind) async throws -> Core.System.Status { + try await requireRuntime(runtimeKind, + capability: .systemStatus, + as: (any RuntimeSystemStatusClient).self).systemStatus() + } + + private func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview { + try requireRuntime(request.runtimeKind, + capability: .containers, + as: (any RuntimeContainerClient).self).previewCreateCommand(for: request) + } + + 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, + as: (any RuntimeContainerClient).self).createContainer(request) + } + + @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, + replacement: Core.Container.CreateRequest, + rollback: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { + guard replacement.runtimeKind == rollback.runtimeKind else { + throw Core.Container.RecreateFailure(phase: .createReplacement, + recovery: .notNeeded, + primaryError: RecreatePreflightError.runtimeMismatch) + } + let runtime = try requireRuntime(replacement.runtimeKind, + capability: .containers, + as: (any RuntimeContainerClient).self) + return try await runtime.recreateContainer(originalID: originalID, + replacement: replacement, + rollback: rollback) + } + + @discardableResult public func recreateContainer(originalID: String, + replacement: Core.Schema.Document, + rollback: Core.Schema.Document) async throws -> Core.Container.CreateResult { + let replacementDefinition = schemaDefinition(for: replacement.operation, + runtimeKind: replacement.runtimeKind) + let rollbackDefinition = schemaDefinition(for: rollback.operation, + runtimeKind: rollback.runtimeKind) + // Validate both documents before the runtime receives any destructive request. + let replacementRequest = try replacement.validatedRequest(definition: replacementDefinition) + let rollbackRequest = try rollback.validatedRequest(definition: rollbackDefinition) + return try await recreateContainer(originalID: originalID, + replacement: replacementRequest, + rollback: rollbackRequest) + } + + public func translateCompose(_ project: Core.Compose.Project, + baseDirectory: URL?, + runtimeKind: Core.Runtime.Kind) throws -> Core.Compose.ImportPlan { + try requireRuntime(runtimeKind, + capability: .composeImport, + as: (any RuntimeComposeClient).self) + .translateCompose(project, baseDirectory: baseDirectory) + } + + private func imageDefaults(for request: Core.Container.CreateRequest, + in images: [Core.Image.Resource]) throws -> Core.Container.ImageDefaults? { + try requireRuntime(request.runtimeKind, + capability: .composeImport, + as: (any RuntimeComposeClient).self) + .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 + return try requireRuntime(source, + capability: .coreMigration, + as: (any RuntimeComposeClient).self) + .coreSwitchPlan(for: document.canonical.createRequest.effectiveName ?? "", + to: target.flatMap(descriptor(for:))) + } + + public func coreSwitchPlan(for containerID: String, + source: Core.Runtime.Kind, + to target: Core.Runtime.Descriptor?) throws -> Core.Migration.Plan { + try requireRuntime(source, + capability: .coreMigration, + as: (any RuntimeComposeClient).self) + .coreSwitchPlan(for: containerID, to: target) + } + + public func inspectImage(_ ref: String, + runtimeKind: Core.Runtime.Kind) async throws -> [Core.Image.Resource] { + let images = try await requireRuntime(runtimeKind, + capability: .images, + as: (any RuntimeImageClient).self).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 { + do { + let runtime = try requireRuntime(runtimeKind, + capability: .containers, + as: (any RuntimeContainerClient).self) + 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, + runtimeKind: Core.Runtime.Kind) -> AsyncThrowingStream { + do { + let runtime = try requireRuntime(runtimeKind, + capability: .images, + as: (any RuntimeImageClient).self) + return runtime.streamPull(ref, platform: platform) + } catch { + return AsyncThrowingStream { continuation in continuation.finish(throwing: error) } + } + } + + public func streamBuild(context: String, + tag: String? = nil, + dockerfile: String? = nil, + buildArgs: [String: String] = [:], + noCache: Bool = false, + platform: String? = nil, + runtimeKind: Core.Runtime.Kind) -> AsyncThrowingStream { + do { + let runtime = try requireRuntime(runtimeKind, + capability: .imageBuild, + as: (any RuntimeImageClient).self) + 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, + runtimeKind: Core.Runtime.Kind) -> AsyncThrowingStream { + do { + let runtime = try requireRuntime(runtimeKind, + capability: .imagePush, + as: (any RuntimeImageClient).self) + return runtime.streamPush(ref, platform: platform) + } catch { + return AsyncThrowingStream { continuation in continuation.finish(throwing: error) } + } + } + + @discardableResult public func runContainer(arguments: [String], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .containers, + as: (any RuntimeContainerClient).self).runContainer(arguments: arguments) + } + + @discardableResult public func performSystemAction(_ action: Core.Runtime.SystemAction, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .serviceControl, + as: (any RuntimeServiceControlClient).self).performSystemAction(action) + } + + public func registries(runtimeKind: Core.Runtime.Kind) async throws -> [Core.Registry.Login] { + try await requireRuntime(runtimeKind, + capability: .registries, + as: (any RuntimeRegistryClient).self).registries().map { $0.scoped(to: runtimeKind) } + } + + @discardableResult public func registryLogin(server: String, + username: String, + password: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .registries, + as: (any RuntimeRegistryClient).self) + .registryLogin(server: server, username: username, password: password) + } + + @discardableResult public func registryLogout(server: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .registries, + as: (any RuntimeRegistryClient).self).registryLogout(server: server) + } + + @discardableResult public func deleteImages(_ refs: [String], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .images, + as: (any RuntimeImageClient).self).deleteImages(refs) + } + + @discardableResult public func tagImage(source: String, + target: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .images, + as: (any RuntimeImageClient).self).tagImage(source: source, target: target) + } + + @discardableResult public func saveImages(_ refs: [String], + to output: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .imageArchive, + as: (any RuntimeImageClient).self).saveImages(refs, to: output) + } + + @discardableResult public func loadImages(from input: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .imageArchive, + as: (any RuntimeImageClient).self).loadImages(from: input) + } + + @discardableResult public func exportContainer(_ id: String, + to output: String, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .containerExport, + as: (any RuntimeImageClient).self).exportContainer(id, to: output) + } + + @discardableResult public func pruneImages(all: Bool = false, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .images, + as: (any RuntimeImageClient).self).pruneImages(all: all) + } + + @discardableResult public func start(_ ids: [String], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .containers, + as: (any RuntimeContainerClient).self).start(ids) + } + + @discardableResult public func stop(_ ids: [String], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .containers, + as: (any RuntimeContainerClient).self).stop(ids) + } + + @discardableResult public func deleteContainers(_ ids: [String], + force: Bool, + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .containers, + as: (any RuntimeContainerClient).self).deleteContainers(ids, force: force) + } + + @discardableResult public func pruneContainers(runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .containers, + as: (any RuntimeContainerClient).self).pruneContainers() + } + + @discardableResult public func pruneVolumes(runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .volumes, + as: (any RuntimeVolumeClient).self).pruneVolumes() + } + + @discardableResult public func pruneNetworks(runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .networks, + as: (any RuntimeNetworkClient).self).pruneNetworks() + } + + @discardableResult public func createVolume(name: String, + size: String? = nil, + labels: [String: String] = [:], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .volumes, + as: (any RuntimeVolumeClient).self) + .createVolume(name: name, size: size, labels: labels) + } + + @discardableResult public func deleteVolumes(_ names: [String], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .volumes, + as: (any RuntimeVolumeClient).self).deleteVolumes(names) + } + + @discardableResult public func createNetwork(name: String, + subnet: String? = nil, + internalOnly: Bool = false, + labels: [String: String] = [:], + runtimeKind: Core.Runtime.Kind) async throws -> Data { + try await requireRuntime(runtimeKind, + capability: .networks, + as: (any RuntimeNetworkClient).self) + .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, + as: (any RuntimeNetworkClient).self).deleteNetworks(names) + } + } +} + +private enum RecreatePreflightError: Error { + case runtimeMismatch +} diff --git a/Sources/ContainedCore/Models/HubSearch.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/HubSearch.swift similarity index 84% rename from Sources/ContainedCore/Models/HubSearch.swift rename to Packages/ContainedCore/Sources/ContainedCore/Registry/HubSearch.swift index c91bfc75..53d44943 100644 --- a/Sources/ContainedCore/Models/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/Sources/ContainedCore/Models/RegistryImageReference.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/ImageReference.swift similarity index 89% rename from Sources/ContainedCore/Models/RegistryImageReference.swift rename to Packages/ContainedCore/Sources/ContainedCore/Registry/ImageReference.swift index 24189a14..83b4767c 100644 --- a/Sources/ContainedCore/Models/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 ?? "") @@ -27,7 +28,7 @@ public struct RegistryImageReference: 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" { @@ -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 new file mode 100644 index 00000000..8ca8c4d2 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Registry/Login.swift @@ -0,0 +1,68 @@ +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 extension Core.Registry { +struct Login: Codable, Sendable, Identifiable, Hashable { + public let host: String + public let username: String? + public let created: Date? + public let modified: Date? + public var runtimeKind: Core.Runtime.Kind + + public var id: String { runtimeKind.scopedID(for: host) } + + public init(host: String, + username: String? = nil, + created: Date? = nil, + modified: Date? = nil, + runtimeKind: Core.Runtime.Kind) { + self.host = host + self.username = username + self.created = created + self.modified = modified + self.runtimeKind = runtimeKind + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: DynamicCodingKey.self) + func string(_ keys: [String]) -> String? { + for k in keys { + guard let key = DynamicCodingKey(stringValue: k) else { continue } + if let v = try? c.decode(String.self, forKey: key) { return v } + } + return nil + } + func date(_ keys: [String]) -> Date? { + for k in keys { + guard let key = DynamicCodingKey(stringValue: k) else { continue } + if let v = try? c.decode(Date.self, forKey: key) { return v } + } + return nil + } + host = string(["host", "hostname", "server", "registry"]) ?? "unknown" + username = string(["username", "user"]) + created = date(["created", "createdAt", "creationDate"]) + modified = date(["modified", "modifiedAt", "updated"]) + if let runtimeKind = string(["runtimeKind"]).map(Core.Runtime.Kind.init(rawValue:)) { + self.runtimeKind = runtimeKind + } else if let contextRuntimeKind = decoder.coreRuntimeKindContext { + runtimeKind = contextRuntimeKind + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Missing runtimeKind and no runtime decoding context was provided.") + ) + } + } + + 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/Sources/ContainedCore/Services/RegistryManifestClient.swift b/Packages/ContainedCore/Sources/ContainedCore/Registry/ManifestClient.swift similarity index 75% rename from Sources/ContainedCore/Services/RegistryManifestClient.swift rename to Packages/ContainedCore/Sources/ContainedCore/Registry/ManifestClient.swift index 777f8764..df53a385 100644 --- a/Sources/ContainedCore/Services/RegistryManifestClient.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/Registry/ManifestClient.swift @@ -1,6 +1,7 @@ import Foundation -public enum RegistryManifestError: Error, LocalizedError, Equatable { +public extension Core.Registry { +enum ManifestError: Core.Error.PackageError, Equatable { case invalidResponse case unauthorized case notFound @@ -8,19 +9,28 @@ 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 "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 .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 .httpStatus(let code): return ["status": String(code)] + case .invalidResponse, .unauthorized, .notFound, .missingDigest, .tokenUnavailable: return [:] } } } -public struct RegistryManifestClient: Sendable { +struct ManifestClient: Sendable { private let session: URLSession public init(session: URLSession = .shared) { @@ -28,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) @@ -41,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") @@ -49,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"), @@ -60,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) } } @@ -79,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 } @@ -167,3 +177,5 @@ private struct BearerChallenge { return result } } + +} 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/Capability.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Capability.swift new file mode 100644 index 00000000..c51de85a --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Capability.swift @@ -0,0 +1,31 @@ +import Foundation + +public extension Core.Runtime { +struct Capability: OptionSet, Equatable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + 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 serviceControl = Core.Runtime.Capability(rawValue: 1 << 18) +} +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/ClientDefaults.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/ClientDefaults.swift new file mode 100644 index 00000000..d23565e4 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/ClientDefaults.swift @@ -0,0 +1,142 @@ +import Foundation + +extension RuntimeContainerClient { + func listContainers() async throws -> [Core.Container.Snapshot] { + try await listContainers(all: true) + } + + func stats() async throws -> [Core.Metrics.ContainerStats] { + try await stats(ids: []) + } + + func streamStats() -> AsyncThrowingStream<[Core.Metrics.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 previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview { + throw Core.Runtime.UnsupportedCapability(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, + replacement: Core.Container.CreateRequest, + rollback: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { + _ = try? await stop([originalID]) + let deletedOriginal: Bool + do { + deletedOriginal = try await deleteContainerIfPresent(originalID, force: true) + } catch { + throw Core.Container.RecreateFailure(phase: .deleteOriginal, + recovery: .notNeeded, + primaryError: error) + } + + do { + return try await createContainer(replacement) + } catch { + let replacementError = error + guard deletedOriginal else { + throw Core.Container.RecreateFailure(phase: .createReplacement, + recovery: .notNeeded, + primaryError: replacementError) + } + do { + _ = try await createContainer(rollback) + } catch { + throw Core.Container.RecreateFailure(phase: .restoreOriginal, + recovery: .restoreFailed, + primaryError: replacementError, + recoveryError: error) + } + throw Core.Container.RecreateFailure(phase: .createReplacement, + recovery: .originalRestored, + primaryError: replacementError) + } + } + + private func deleteContainerIfPresent(_ id: String, force: Bool) async throws -> Bool { + if let current = try? await listContainers(all: true), + !current.contains(where: { $0.id == id || $0.scopedID == id || $0.scopedID == descriptor.kind.scopedID(for: id) }) { + return false + } + do { + _ = try await deleteContainers([id], force: force) + return true + } catch let error as Core.Command.Error where error.isContainerNotFound { + return false + } + } +} + +private extension Core.Command.Error { + var isContainerNotFound: Bool { + guard case .nonZeroExit(_, let stderr, _) = self else { return false } + let lowercased = stderr.lowercased() + guard lowercased.contains("container") else { return false } + return lowercased.contains("not found") || lowercased.contains("no such container") + } +} + +extension RuntimeImageClient { + func streamPull(_ ref: String) -> AsyncThrowingStream { + streamPull(ref, platform: nil) + } + + func streamPush(_ ref: String) -> AsyncThrowingStream { + streamPush(ref, platform: nil) + } +} + +extension RuntimeComposeClient { + 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: Core.Container.CreateRequest, in images: [Core.Image.Resource]) throws -> Core.Container.ImageDefaults? { + nil + } + + 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 + ) + } +} + +extension RuntimeVolumeClient { + @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: [:]) + } +} + +extension RuntimeNetworkClient { + @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/Packages/ContainedCore/Sources/ContainedCore/Runtime/ClientProtocols.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/ClientProtocols.swift new file mode 100644 index 00000000..009dd4aa --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/ClientProtocols.swift @@ -0,0 +1,93 @@ +import Foundation + +protocol RuntimeDescribing: Sendable { + var descriptor: Core.Runtime.Descriptor { get } +} + +protocol RuntimeContainerClient: RuntimeDescribing { + 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 streamLogs(id: String, follow: Bool, tail: Int?, boot: Bool) -> AsyncThrowingStream + func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview + @discardableResult func createContainer(_ request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult + @discardableResult func runContainer(arguments: [String]) 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 +} + +protocol RuntimeSystemStatusClient: RuntimeDescribing { + func diskUsage() async throws -> Core.System.DiskUsage + func systemProperties() async throws -> Core.System.Properties + func systemStatus() async throws -> Core.System.Status +} + +protocol RuntimeDNSClient: RuntimeDescribing { + func dnsDomains() async throws -> [String] + @discardableResult func createDNSDomain(_ domain: String) async throws -> Data + @discardableResult func deleteDNSDomain(_ domain: String) async throws -> Data +} + +protocol RuntimeKernelClient: RuntimeDescribing { + @discardableResult func setRecommendedKernel() async throws -> Data +} + +protocol RuntimeExecClient: RuntimeDescribing { + func execCapture(_ id: String, _ command: [String]) async throws -> String + @discardableResult func copy(source: String, destination: String) async throws -> Data +} + +protocol RuntimeSystemLogsClient: RuntimeDescribing { + func streamSystemLogs(follow: Bool, last: Int?) -> AsyncThrowingStream +} + +protocol RuntimeComposeClient: RuntimeDescribing { + 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 +} + +protocol RuntimeNetworkClient: RuntimeDescribing { + func networks() async throws -> [Core.Network.Resource] + @discardableResult func pruneNetworks() async throws -> Data + @discardableResult func createNetwork(name: String, subnet: String?, internalOnly: Bool, + labels: [String: String]) async throws -> Data + @discardableResult func deleteNetworks(_ names: [String]) async throws -> Data +} + +protocol RuntimeVolumeClient: RuntimeDescribing { + func volumes() async throws -> [Core.Volume.Resource] + @discardableResult func pruneVolumes() async throws -> Data + @discardableResult func createVolume(name: String, size: String?, labels: [String: String]) async throws -> Data + @discardableResult func deleteVolumes(_ names: [String]) async throws -> Data +} + +protocol RuntimeImageClient: RuntimeDescribing { + func images() async throws -> [Core.Image.Resource] + func inspectImage(_ ref: String) async throws -> [Core.Image.Resource] + 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 + @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 +} + +protocol RuntimeRegistryClient: RuntimeDescribing { + 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 +} + +protocol RuntimeServiceControlClient: RuntimeDescribing { + @discardableResult func performSystemAction(_ action: Core.Runtime.SystemAction) async throws -> Data +} + +protocol RuntimeClient: RuntimeDescribing {} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/Configuration.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Configuration.swift new file mode 100644 index 00000000..6faa33e1 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Configuration.swift @@ -0,0 +1,11 @@ +import Foundation + +public extension Core.Runtime { +struct Configuration: Sendable, Equatable { + public var cliPathOverride: String? + + public init(cliPathOverride: String? = nil) { + self.cliPathOverride = cliPathOverride + } +} +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/ContainerRuntimeMigration.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/ContainerRuntimeMigration.swift new file mode 100644 index 00000000..f81a7892 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/ContainerRuntimeMigration.swift @@ -0,0 +1,157 @@ +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 runtime = try requireRuntime(runtimeKind, + capability: .images, + as: (any RuntimeImageClient).self) + let images = try await runtime.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 runtime = try requireRuntime(runtimeKind, + capability: .containers, + as: (any RuntimeContainerClient).self) + let snapshots = try await runtime + .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 new file mode 100644 index 00000000..5a64f745 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Descriptor.swift @@ -0,0 +1,30 @@ +import Foundation + +public extension Core.Runtime { +struct Descriptor: Equatable, Sendable { + public var kind: Core.Runtime.Kind + public var displayName: String + public var executableName: String? + public var capabilities: Core.Runtime.Capability + + public init(kind: Core.Runtime.Kind, + displayName: String, + executableName: String? = nil, + capabilities: Core.Runtime.Capability) { + self.kind = kind + self.displayName = displayName + self.executableName = executableName + self.capabilities = capabilities + } + + public func supports(_ capability: Core.Runtime.Capability) -> Bool { + capabilities.isSuperset(of: capability) + } + + public func require(_ capability: Core.Runtime.Capability) throws { + guard supports(capability) else { + throw Core.Runtime.UnsupportedCapability(kind: kind, capability: capability) + } + } +} +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift new file mode 100644 index 00000000..471d30f0 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Kind.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Stable identifier for a runtime adapter. +/// +/// 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 + + 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 func scopedID(for id: String) -> String { + "\(rawValue)::\(id)" + } + + public static func parseScopedID(_ scopedID: String) -> (kind: Core.Runtime.Kind, id: String)? { + guard let separator = scopedID.range(of: "::") else { return nil } + let rawKind = String(scopedID[.. String { + kind.scopedID(for: id) + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/Module.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Module.swift new file mode 100644 index 00000000..4c4dc19c --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Module.swift @@ -0,0 +1,24 @@ +import Foundation + +public extension Core.Runtime { +enum SystemAction: String, CaseIterable, Sendable { + case start + case stop +} + +internal protocol Module: Sendable { + var descriptor: Core.Runtime.Descriptor { get } + + func locateCLI(override: String?) -> URL? + func makeClient(runner: any Core.Command.Running) -> any RuntimeClient + func readiness(cliURL: URL, runner: any Core.Command.Running) async -> Core.RuntimeReadiness + func terminalInvocation(containerID: String, shell: String, cliURL: URL) -> Core.Command.Invocation + func runPreview(for request: Core.Container.CreateRequest) -> [String] + func buildPreview(context: String, tag: String?, dockerfile: String?, + buildArgs: [String: String], noCache: Bool, + platform: String?) -> [String] + func networkCreatePreview(name: String, subnet: String?, internalOnly: Bool) -> [String] + func volumeCreatePreview(name: String, size: String?) -> [String] + func schemaProfile() -> Core.Schema.RuntimeProfile +} +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/ModuleRegistry.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/ModuleRegistry.swift new file mode 100644 index 00000000..307c9c12 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/ModuleRegistry.swift @@ -0,0 +1,23 @@ +import Foundation + +public extension Core.Runtime { + static var supportedDescriptors: [Core.Runtime.Descriptor] { + builtInModules.map(\.descriptor).sorted { $0.displayName < $1.displayName } + } + + static func descriptor(for kind: Core.Runtime.Kind) -> Core.Runtime.Descriptor? { + module(for: kind)?.descriptor + } +} + +extension Core.Runtime { + static var builtInModules: [any Core.Runtime.Module] { + [ + AppleContainerRuntimeModule(), + ] + } + + static func module(for kind: Core.Runtime.Kind) -> (any Core.Runtime.Module)? { + builtInModules.first { $0.descriptor.kind == kind } + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeDecodingContext.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeDecodingContext.swift new file mode 100644 index 00000000..c06fb2c1 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/RuntimeDecodingContext.swift @@ -0,0 +1,11 @@ +import Foundation + +extension CodingUserInfoKey { + static let coreRuntimeKind = CodingUserInfoKey(rawValue: "com.contained.core.runtimeKind")! +} + +extension Decoder { + var coreRuntimeKindContext: Core.Runtime.Kind? { + userInfo[.coreRuntimeKind] as? Core.Runtime.Kind + } +} diff --git a/Sources/ContainedCore/Models/RuntimeStatus.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/Status.swift similarity index 79% rename from Sources/ContainedCore/Models/RuntimeStatus.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtime/Status.swift index ed180709..b7f91941 100644 --- a/Sources/ContainedCore/Models/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/Runtime/UnsupportedCapability.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtime/UnsupportedCapability.swift new file mode 100644 index 00000000..5317d85e --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtime/UnsupportedCapability.swift @@ -0,0 +1,58 @@ +import Foundation + +public extension Core.Runtime { +struct UnsupportedCapability: Error, Equatable, Sendable { + public var kind: Core.Runtime.Kind + public var capability: Core.Runtime.Capability + + public init(kind: Core.Runtime.Kind, capability: Core.Runtime.Capability) { + self.kind = kind + self.capability = capability + } +} + +struct InventoryFailure: Error, Equatable, Sendable { + public var resource: String + public var kind: Core.Runtime.Kind + public var message: String + + public init(resource: String, kind: Core.Runtime.Kind, message: String) { + self.resource = resource + self.kind = kind + self.message = message + } +} + +struct InventoryResult: Sendable { + public var items: [Item] + public var failures: [Core.Runtime.InventoryFailure] + + public init(items: [Item], failures: [Core.Runtime.InventoryFailure] = []) { + self.items = items + self.failures = failures + } +} +} + +extension Core.Runtime.UnsupportedCapability: Core.Error.PackageError { + public var packageName: String { "ContainedCore" } + public var packageErrorCode: String { "unsupportedRuntimeCapability" } + public var packageErrorContext: [String: String] { + [ + "kind": kind.rawValue, + "capability": String(capability.rawValue), + ] + } +} + +extension Core.Runtime.InventoryFailure: Core.Error.PackageError { + public var packageName: String { "ContainedCore" } + public var packageErrorCode: String { "partialRuntimeInventoryFailure" } + public var packageErrorContext: [String: String] { + [ + "resource": resource, + "kind": kind.rawValue, + "message": message, + ] + } +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Client/Client.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Client/Client.swift new file mode 100644 index 00000000..19627608 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Client/Client.swift @@ -0,0 +1,253 @@ +import Foundation + +/// 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 Core.Command.Running + var descriptor: Core.Runtime.Descriptor { .appleContainer } + + init(runner: any Core.Command.Running) { + self.runner = runner + } + + // MARK: Reads + + 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 -> [Core.Metrics.ContainerStats] { + try await decode([Core.Metrics.ContainerStats].self, + ContainerCommands.stats(ids: ids), + "stats", + priority: .utility) + } + + private func statsTableStream(ids: [String] = []) -> AsyncThrowingStream { + runner.stream(ContainerCommands.statsTableStream(ids: ids), priority: .utility) + } + + func streamStats(ids: [String] = []) -> AsyncThrowingStream<[Core.Metrics.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() } + } + } + + func diskUsage() async throws -> Core.System.DiskUsage { + try await decode(Core.System.DiskUsage.self, ContainerCommands.systemDF, "system df") + } + + 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. + func dnsDomains() async throws -> [String] { + try await decode([String].self, ContainerCommands.systemDNSList, "system dns list") + } + @discardableResult func createDNSDomain(_ domain: String) async throws -> Data { + try await runner.run(ContainerCommands.systemDNSCreate(domain)) + } + @discardableResult func deleteDNSDomain(_ domain: String) async throws -> Data { + try await runner.run(ContainerCommands.systemDNSDelete(domain)) + } + /// Install the recommended kernel (`system kernel set --recommended`). + @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`. + 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 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. + func streamSystemLogs(follow: Bool, last: Int? = 500) -> AsyncThrowingStream { + runner.stream(ContainerCommands.systemLogs(follow: follow, last: last)) + } + + func systemStatus() async throws -> Core.System.Status { + try await decode(Core.System.Status.self, ContainerCommands.systemStatus, "system status") + } + + func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview { + AppleContainerCreateTranslator.preview(for: request) + } + + @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: Core.Compose.Project, baseDirectory: URL?) throws -> Core.Compose.ImportPlan { + AppleContainerCreateTranslator.composePlan(for: project, baseDirectory: baseDirectory) + } + + 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 -> [Core.Network.Resource] { + try await decode([Core.Network.Resource].self, ContainerCommands.networkList(), "network list") + } + + func volumes() async throws -> [Core.Volume.Resource] { + try await decode([Core.Volume.Resource].self, ContainerCommands.volumeList(), "volume 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 -> [Core.Image.Resource] { + try await decode([Core.Image.Resource].self, ContainerCommands.imageInspect([ref]), "image inspect") + } + + // MARK: Streaming + + /// 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`). + 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. + func streamPull(_ ref: String, platform: String? = nil) -> AsyncThrowingStream { + runner.stream(ContainerCommands.imagePull(ref, platform: platform)) + } + + /// Stream `container build --progress plain` (BuildKit log). + 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, + buildArgs: buildArgs, noCache: noCache, platform: platform)) + } + + /// Stream `image push --progress plain` to a logged-in registry. + func streamPush(_ ref: String, platform: String? = nil) -> AsyncThrowingStream { + runner.stream(ContainerCommands.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 { + try await runner.run(["system", action.rawValue]) + } + + // MARK: Registries + + 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). + @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 func registryLogout(server: String) async throws -> Data { + try await runner.run(ContainerCommands.registryLogout(server: server)) + } + + // MARK: Image writes + + @discardableResult func deleteImages(_ refs: [String]) async throws -> Data { + try await runner.run(ContainerCommands.imageDelete(refs)) + } + @discardableResult func tagImage(source: String, target: String) async throws -> Data { + try await runner.run(ContainerCommands.imageTag(source: source, target: target)) + } + @discardableResult func saveImages(_ refs: [String], to output: String) async throws -> Data { + try await runner.run(ContainerCommands.imageSave(refs: refs, output: output)) + } + @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 func exportContainer(_ id: String, to output: String) async throws -> Data { + try await runner.run(ContainerCommands.containerExport(id, output: output)) + } + @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 func start(_ ids: [String]) async throws -> Data { + try await runner.run(ContainerCommands.start(ids)) + } + @discardableResult func stop(_ ids: [String]) async throws -> Data { + try await runner.run(ContainerCommands.stop(ids)) + } + @discardableResult func deleteContainers(_ ids: [String], force: Bool) async throws -> Data { + try await runner.run(ContainerCommands.deleteContainers(ids, force: force)) + } + @discardableResult func pruneContainers() async throws -> Data { + try await runner.run(ContainerCommands.containerPrune()) + } + @discardableResult func pruneVolumes() async throws -> Data { + try await runner.run(ContainerCommands.volumePrune()) + } + @discardableResult func pruneNetworks() async throws -> Data { + try await runner.run(ContainerCommands.networkPrune()) + } + + // MARK: Infra writes + + @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 func deleteVolumes(_ names: [String]) async throws -> Data { + try await runner.run(ContainerCommands.volumeDelete(names)) + } + @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 func deleteNetworks(_ names: [String]) async throws -> Data { + try await runner.run(ContainerCommands.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 Core.Container.JSON.decode(type, from: data, runtimeKind: descriptor.kind) + } catch { + 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 new file mode 100644 index 00000000..ec263174 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Command/ContainerCommands.swift @@ -0,0 +1,243 @@ +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). +enum ContainerCommands { + static let jsonFormat = ["--format", "json"] + + enum StatsFormat: String, Sendable { + case json + case table + case toml + case yaml + } + + // MARK: Containers + + static func list(all: Bool) -> [String] { + var args = ["list"] + if all { args.append("--all") } + return args + jsonFormat + } + + 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 + } + + static func statsTableStream(ids: [String] = []) -> [String] { + stats(ids: ids, noStream: false, format: .table) + } + + 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 + } + 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. + static func containerPrune() -> [String] { ["prune"] } + /// `container exec ` (no TTY) — for one-shot captures like `ps`, `ls`. + static func exec(_ id: String, _ command: [String]) -> [String] { ["exec", id] + command } + /// `container exec --interactive --tty ` — for hosted terminal sessions. + 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`). + static func containerExport(_ id: String, output: String) -> [String] { + ["export", "--output", output, id] + } + /// `container copy ` — paths are `container-id:path` or local. + 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") } + if let tail { args += ["-n", String(tail)] } + args.append(id) + return args + } + + static func run(_ request: Core.Container.CreateRequest) -> [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.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] } + 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 + + 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. + static func imageSave(refs: [String], output: String) -> [String] { + ["image", "save"] + refs + ["--output", output] + } + /// `image load -i ` — import images from an archive. + static func imageLoad(input: String) -> [String] { + ["image", "load", "--input", input] + } + + /// `image pull [--platform os/arch] --progress plain ` — plain progress is line-streamable. + 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) + return args + } + + // MARK: Build + + /// `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. + 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 (k, v) in buildArgs.sorted(by: { $0.key < $1.key }) { args += ["--build-arg", "\(k)=\(v)"] } + 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", "list"] + jsonFormat } + static func volumeList() -> [String] { ["volume", "list"] + jsonFormat } + + /// `volume create [--label k=v ...] [-s size] ` + 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] } + args.append(name) + return args + } + /// `volume delete ` + static func volumeDelete(_ names: [String]) -> [String] { ["volume", "delete"] + names } + static func volumePrune() -> [String] { ["volume", "prune"] } + + /// `network create [--internal] [--label k=v ...] [--subnet ] ` + 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 (k, v) in labels.sorted(by: { $0.key < $1.key }) { args += ["--label", "\(k)=\(v)"] } + if let subnet, !subnet.isEmpty { args += ["--subnet", subnet] } + args.append(name) + return args + } + /// `network delete ` + 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. + 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) + return args + } + + // MARK: Registries + + static func registryList() -> [String] { ["registry", "list"] + jsonFormat } + /// `registry login --username --password-stdin ` — password is piped via stdin. + static func registryLogin(server: String, username: String) -> [String] { + ["registry", "login", "--username", username, "--password-stdin", server] + } + static func registryLogout(server: String) -> [String] { ["registry", "logout", server] } + + // MARK: System + + 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). + 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)] } + return args + } + + // MARK: System — kernel & DNS (privileged; may trigger a system sudo prompt handled by the CLI) + + static let systemDNSList = ["system", "dns", "list"] + jsonFormat + /// `system dns create ` — must run as administrator (the CLI prompts). + static func systemDNSCreate(_ domain: String) -> [String] { ["system", "dns", "create", domain] } + /// `system dns delete ` — must run as administrator (the CLI prompts). + static func systemDNSDelete(_ domain: String) -> [String] { ["system", "dns", "delete", domain] } + /// `system kernel set --recommended` — download + install the recommended kernel. + static let systemKernelSetRecommended = ["system", "kernel", "set", "--recommended"] +} diff --git a/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Metrics/StatsTableParser.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Metrics/StatsTableParser.swift new file mode 100644 index 00000000..8ba03eaa --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Metrics/StatsTableParser.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. +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? + + init() {} + + 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 [] } + lastEmittedFrame = frame + trimBuffer() + return snapshots + } + + static func parseLatestFrame(in output: String) -> [Core.Metrics.RuntimeStatsSnapshot] { + guard let frame = latestParseableFrame(in: output), + let snapshots = parseFrame(frame) else { + return [] + } + return snapshots + } + + static func parseFrame(_ frame: String) -> [Core.Metrics.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: [Core.Metrics.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]) -> 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) + } + 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 Core.Metrics.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.. URL? { + AppleContainerCLILocator.locate(override: override) + } + + func makeClient(runner: any Core.Command.Running) -> any RuntimeClient { + AppleContainerClient(runner: runner) + } + + func readiness(cliURL: URL, runner: any Core.Command.Running) async -> Core.RuntimeReadiness { + let versionOutput = try? await runner.run(ContainerCommands.version) + let version = versionOutput.map { String(decoding: $0, as: UTF8.self) } + .flatMap(AppleContainerCLILocator.parseVersion) + + guard AppleContainerCLILocator.isSupported(version) else { + return Core.RuntimeReadiness(kind: descriptor.kind, + cliURL: cliURL, + version: version, + state: .unsupported) + } + + do { + let data = try await runner.run(ContainerCommands.systemStatus) + let status = try Core.Container.JSON.decode(Core.System.Status.self, from: data) + return Core.RuntimeReadiness(kind: descriptor.kind, + cliURL: cliURL, + version: version, + state: status.isRunning ? .ready : .endpointUnavailable, + message: status.isRunning ? nil : "Apple container service is stopped.") + } catch { + return Core.RuntimeReadiness(kind: descriptor.kind, + cliURL: cliURL, + version: version, + state: .endpointUnavailable, + message: String(describing: error)) + } + } + + func terminalInvocation(containerID: String, shell: String, cliURL: URL) -> Core.Command.Invocation { + Core.Command.Invocation(executableURL: cliURL, + arguments: ContainerCommands.execInteractive(containerID, shell: shell)) + } + + func runPreview(for request: Core.Container.CreateRequest) -> [String] { + ContainerCommands.run(request) + } + + func buildPreview(context: String, + tag: String?, + dockerfile: String?, + buildArgs: [String: String], + noCache: Bool, + platform: String?) -> [String] { + ContainerCommands.build(context: context, + tag: tag, + dockerfile: dockerfile, + buildArgs: buildArgs, + noCache: noCache, + platform: platform) + } + + func networkCreatePreview(name: String, subnet: String?, internalOnly: Bool) -> [String] { + ContainerCommands.networkCreate(name: name, + subnet: subnet, + internalOnly: internalOnly) + } + + func volumeCreatePreview(name: String, size: String?) -> [String] { + ContainerCommands.volumeCreate(name: name, size: size) + } + + func schemaProfile() -> Core.Schema.RuntimeProfile { + let fields = Core.Schema.Definition.canonicalRunFields + let unsupported = Core.Schema.FieldSupport( + state: .disabled, + disabledReasonKey: "schema.disabled.appleContainer.unsupportedDockerCompose", + defaultDisabledReason: "Known from Docker CLI or Compose, not executable by Apple container." + ) + let supported = Set(fields.compactMap { field -> Core.Field.Path? in + field.section == .dockerCompose ? nil : field.path + }) + let disabled = Dictionary(uniqueKeysWithValues: fields.compactMap { field -> (Core.Field.Path, Core.Schema.FieldSupport)? in + guard field.section == .dockerCompose else { return nil } + return (field.path, unsupported) + }) + return Core.Schema.RuntimeProfile(kind: descriptor.kind, + supportedPaths: supported, + disabledSupport: disabled) + } +} + +extension AppleContainerClient: RuntimeClient, + RuntimeContainerClient, + RuntimeSystemStatusClient, + RuntimeDNSClient, + RuntimeKernelClient, + RuntimeExecClient, + RuntimeSystemLogsClient, + RuntimeComposeClient, + RuntimeNetworkClient, + RuntimeVolumeClient, + RuntimeImageClient, + RuntimeRegistryClient, + RuntimeServiceControlClient {} diff --git a/Sources/ContainedCore/Services/CLILocator.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Support/CLILocator.swift similarity index 84% rename from Sources/ContainedCore/Services/CLILocator.swift rename to Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Support/CLILocator.swift index b3fc92e1..739a330f 100644 --- a/Sources/ContainedCore/Services/CLILocator.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 CLILocator { - 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 CLILocator { } /// 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 CLILocator { } /// 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/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift new file mode 100644 index 00000000..69a7e92b --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/AppleContainer/Translation/CreateTranslator.swift @@ -0,0 +1,163 @@ +import Foundation + +enum AppleContainerCreateTranslator { + 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: 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? { + 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 Core.Container.ImageDefaults( + command: config.cmd ?? [], + entrypoint: config.entrypoint ?? [], + workingDirectory: config.workingDir, + user: config.user, + environment: (config.env ?? []).compactMap(keyValue) + ) + } + + private static func createRequest(for service: Core.Compose.Service, + projectName: String, + baseDirectory: URL?) -> Core.Container.CreateRequest { + var request = Core.Container.CreateRequest(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 = 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.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)) + 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[.. Core.Image.Resource? { + 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/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..e83cbef6 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Client/DockerClient.swift @@ -0,0 +1,326 @@ +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) + } +} + +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/Support/DockerRuntimeModule.swift b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Support/DockerRuntimeModule.swift new file mode 100644 index 00000000..d9a7d3a0 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Support/DockerRuntimeModule.swift @@ -0,0 +1,187 @@ +import Foundation + +public extension Core.Runtime.Capability { + static let docker: Core.Runtime.Capability = [ + .containers, + .images, + .imageBuild, + .imagePush, + .imageArchive, + .registries, + .networks, + .volumes, + .systemStatus, + .systemProperties, + .exec, + .copy, + .containerExport, + .composeImport, + ] +} + +public extension Core.Runtime.Descriptor { + static let docker = Core.Runtime.Descriptor( + kind: .docker, + displayName: "Docker", + executableName: "docker", + capabilities: .docker + ) +} + +struct DockerRuntimeModule: Core.Runtime.Module { + let descriptor = Core.Runtime.Descriptor.docker + + func locateCLI(override: String?) -> URL? { + DockerCLILocator.locate(override: override) + } + + func makeClient(runner: any Core.Command.Running) -> any RuntimeClient { + DockerClient(runner: runner) + } + + func readiness(cliURL: URL, runner: any Core.Command.Running) async -> Core.RuntimeReadiness { + let versionOutput = try? await runner.run(DockerCommands.version) + let version = versionOutput.map { String(decoding: $0, as: UTF8.self) } + .flatMap(DockerCLILocator.parseVersion) + + do { + _ = try await runner.run(DockerCommands.systemStatus) + return Core.RuntimeReadiness(kind: descriptor.kind, + cliURL: cliURL, + version: version, + state: .ready) + } catch { + return Core.RuntimeReadiness(kind: descriptor.kind, + cliURL: cliURL, + version: version, + state: .endpointUnavailable, + message: String(describing: error)) + } + } + + func terminalInvocation(containerID: String, shell: String, cliURL: URL) -> Core.Command.Invocation { + Core.Command.Invocation(executableURL: cliURL, + arguments: DockerCommands.execInteractive(containerID, shell: shell)) + } + + func runPreview(for request: Core.Container.CreateRequest) -> [String] { + DockerCommands.run(request) + } + + func buildPreview(context: String, + tag: String?, + dockerfile: String?, + buildArgs: [String: String], + noCache: Bool, + platform: String?) -> [String] { + DockerCommands.build(context: context, + tag: tag, + dockerfile: dockerfile, + buildArgs: buildArgs, + noCache: noCache, + platform: platform) + } + + func networkCreatePreview(name: String, subnet: String?, internalOnly: Bool) -> [String] { + DockerCommands.networkCreate(name: name, + subnet: subnet, + internalOnly: internalOnly) + } + + func volumeCreatePreview(name: String, size: String?) -> [String] { + DockerCommands.volumeCreate(name: name, size: size) + } + + func schemaProfile() -> Core.Schema.RuntimeProfile { + let fields = Core.Schema.Definition.canonicalRunFields + let supportByPath = Dictionary(uniqueKeysWithValues: fields.map { ($0.path, dockerSupport(for: $0)) }) + let supported = Set(supportByPath.compactMap { path, support in + support.state == .supported ? path : nil + }) + let disabled = supportByPath.filter { _, support in support.state == .disabled } + let tips = Dictionary(uniqueKeysWithValues: fields.compactMap { field -> (Core.Field.Path, Core.Schema.FieldTipRef)? in + guard supportByPath[field.path]?.state == .supported, + let tip = dockerTip(for: field) else { return nil } + return (field.path, tip) + }) + return Core.Schema.RuntimeProfile(kind: descriptor.kind, + supportedPaths: supported, + disabledSupport: disabled, + tips: tips) + } + + private func dockerTip(for descriptor: Core.Schema.FieldDescriptor) -> Core.Schema.FieldTipRef? { + let key = "schema.tip.\(descriptor.path.rawValue).docker" + if descriptor.path == .runtimeKind { + return Core.Schema.FieldTipRef(key: key, + defaultText: "Selects Docker as the runtime used for previewing and running this container.") + } + if descriptor.path == .imageReference { + return Core.Schema.FieldTipRef(key: key, + defaultText: "The image Docker runs. If it is not local, Contained pulls it before running.") + } + if descriptor.path == .networkName { + return Core.Schema.FieldTipRef(key: key, + defaultText: "Maps to Docker networking. Host networking is projected as Docker's host network mode.") + } + if let alias = descriptor.sourceAliases.first(where: { $0.source == .dockerCLI }) { + return Core.Schema.FieldTipRef(key: key, + defaultText: "Maps to Docker CLI \(alias.name).") + } + return Core.Schema.FieldTipRef(key: key, + defaultText: "Supported by the Docker runtime.") + } + + private 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 = [ + .runtimeKind, .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 unsupported + } +} + +extension DockerClient: RuntimeClient, + RuntimeContainerClient, + RuntimeSystemStatusClient, + RuntimeExecClient, + RuntimeComposeClient, + RuntimeNetworkClient, + RuntimeVolumeClient, + RuntimeImageClient, + RuntimeRegistryClient {} 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..faaf44c0 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Runtimes/Docker/Translation/DockerCreateTranslator.swift @@ -0,0 +1,186 @@ +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(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 new file mode 100644 index 00000000..7ef0ce90 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerDocumentTranslation.swift @@ -0,0 +1,332 @@ +import Foundation + +public extension Core.Schema.Document { + static func containerCreate(runtimeKind: Core.Runtime.Kind) -> 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)) + 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(runtimeKind: configuration.runtimeKind) + 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(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) + 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 + } + + 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..d2ec11a9 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/ContainerFieldCatalog.swift @@ -0,0 +1,401 @@ +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 { + let profile = Core.Runtime.module(for: runtimeKind)?.schemaProfile() + ?? Core.Schema.RuntimeProfile(kind: runtimeKind, supportedPaths: []) + return containerRunEdit(runtimeProfile: profile, operation: operation) + } + + internal static func containerRunEdit(runtimeProfile profile: Core.Schema.RuntimeProfile, + operation: Core.Schema.Operation = .containerCreate) -> Core.Schema.Definition { + return Core.Schema.Definition(operation: operation, + runtimeKind: profile.kind, + fields: Self.runtimeAwareRunFields(initialRuntime: profile.kind, + profile: profile)) + } + + static var canonicalRunFields: [Core.Schema.FieldDescriptor] { + canonicalRunFieldDescriptors + } + + private static func runtimeAwareRunFields(initialRuntime: Core.Runtime.Kind, + profile: Core.Schema.RuntimeProfile) -> [Core.Schema.FieldDescriptor] { + let fields = canonicalRunFieldDescriptors.map { descriptor in + var field = descriptor + if field.path == .runtimeKind { + field.defaultValue = .string(initialRuntime.rawValue) + } + return field + } + return profile.apply(to: fields) + } + + private static var canonicalRunFieldDescriptors: [Core.Schema.FieldDescriptor] { + func field(_ 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, + defaultTip: tip, + sourceAliases: aliases + ) + } + + func preserved(_ 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, + defaultTip: 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 [ + field(.runtimeKind, .string, .runtime, "Runtime", defaultValue: .string(""), + tip: "Selects the container runtime used for previewing and running this container."), + field(.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."), + field(.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 when the selected runtime supports it."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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]."), + field(.processUserID, .string, .process, "User ID", defaultValue: .string(""), + flag: "--uid", example: "--uid 1000", tip: "Sets the numeric user ID for the process."), + field(.processGroupID, .string, .process, "Group ID", defaultValue: .string(""), + flag: "--gid", example: "--gid 1000", tip: "Sets the numeric group ID for the process."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.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."), + field(.securityRosetta, .bool, .security, "Rosetta", defaultValue: .bool(false), + flag: "--rosetta", example: "--rosetta", tip: "Allows x86-64 Linux binaries to run through Rosetta when supported."), + field(.securitySSHAgent, .bool, .security, "Forward SSH agent", defaultValue: .bool(false), + flag: "--ssh", example: "--ssh", tip: "Forwards your host SSH agent socket into the container."), + field(.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."), + field(.networkDNSDisabled, .bool, .networking, "Disable DNS", defaultValue: .bool(false), + flag: "--no-dns", example: "--no-dns", tip: "Prevents DNS from being configured in the container."), + field(.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."), + field(.networkDNSDomain, .string, .networking, "DNS domain", defaultValue: .string(""), + flag: "--dns-domain", example: "--dns-domain example.test", tip: "Sets the default DNS domain."), + field(.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."), + field(.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."), + field(.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."), + field(.kernelPath, .string, .imageFetch, "Kernel", defaultValue: .string(""), + flag: "--kernel", example: "--kernel /path/to/vmlinux", tip: "Uses a custom kernel path."), + field(.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."), + field(.registryScheme, .enumeration, .imageFetch, "Registry scheme", defaultValue: .enumeration(""), options: schemeOptions, + flag: "--scheme", example: "--scheme https", tip: "Controls the registry connection scheme for image fetches."), + field(.progressMode, .enumeration, .imageFetch, "Progress", defaultValue: .enumeration(""), options: progressOptions, + flag: "--progress", example: "--progress plain", tip: "Controls progress output style for image fetches."), + field(.imageMaxConcurrentDownloads, .string, .imageFetch, "Max parallel downloads", defaultValue: .string(""), + flag: "--max-concurrent-downloads", example: "--max-concurrent-downloads 2", tip: "Limits concurrent image downloads."), + + preserved(.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 when the selected runtime supports it."), + preserved(.networkHostname, .string, "Hostname", defaultValue: .string(""), docker: ("--hostname", "--hostname app"), compose: ("hostname", "hostname: app"), tip: "Sets the container hostname when the selected runtime supports it."), + preserved(.networkDomainName, .string, "Domain name", defaultValue: .string(""), docker: ("--domainname", "--domainname example.test"), compose: ("domainname", "domainname: example.test"), tip: "Sets the container NIS/domain name."), + preserved(.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."), + preserved(.networkExpose, .stringList, "Expose ports", defaultValue: .stringList([]), docker: ("--expose", "--expose 80"), compose: ("expose", "expose: [80]"), tip: "Exposes container ports without publishing them to the host."), + preserved(.networkPublishAll, .bool, "Publish all exposed ports", defaultValue: .bool(false), docker: ("--publish-all", "--publish-all"), tip: "Publishes every exposed port to random host ports."), + preserved(.imagePullPolicy, .string, "Pull policy", defaultValue: .string(""), docker: ("--pull", "--pull=always"), compose: ("pull_policy", "pull_policy: always"), tip: "Controls when the selected runtime pulls the image."), + preserved(.processAttachStreams, .stringList, "Attach streams", defaultValue: .stringList([]), docker: ("--attach", "--attach stdout"), compose: ("attach", "attach: false"), tip: "Controls attached STDIN/STDOUT/STDERR streams."), + preserved(.loggingDriver, .string, "Logging driver", defaultValue: .string(""), docker: ("--log-driver", "--log-driver syslog"), compose: ("logging.driver", "logging: { driver: syslog }"), tip: "Selects a Docker logging driver."), + preserved(.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."), + preserved(.metadataLabelFiles, .stringList, "Label files", defaultValue: .stringList([]), docker: ("--label-file", "--label-file ./labels"), tip: "Loads labels from a file."), + preserved(.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."), + preserved(.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."), + preserved(.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."), + preserved(.gpus, .string, "GPUs", defaultValue: .string(""), docker: ("--gpus", "--gpus all"), compose: ("gpus", "gpus: all"), tip: "Requests GPU devices."), + preserved(.processSupplementalGroups, .stringList, "Supplemental groups", defaultValue: .stringList([]), docker: ("--group-add", "--group-add audio"), compose: ("group_add", "group_add: [audio]"), tip: "Adds supplemental groups."), + preserved(.securityPrivileged, .bool, "Privileged", defaultValue: .bool(false), docker: ("--privileged", "--privileged"), compose: ("privileged", "privileged: true"), tip: "Runs with extended host privileges."), + preserved(.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."), + preserved(.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."), + preserved(.namespaceCgroup, .string, "Cgroup namespace", defaultValue: .string(""), docker: ("--cgroupns", "--cgroupns private"), compose: ("cgroup", "cgroup: private"), tip: "Controls the cgroup namespace."), + preserved(.namespaceUser, .string, "User namespace", defaultValue: .string(""), docker: ("--userns", "--userns host"), compose: ("userns_mode", "userns_mode: host"), tip: "Controls the user namespace."), + preserved(.namespacePID, .string, "PID namespace", defaultValue: .string(""), docker: ("--pid", "--pid host"), compose: ("pid", "pid: host"), tip: "Controls the process namespace."), + preserved(.namespaceIPC, .string, "IPC namespace", defaultValue: .string(""), docker: ("--ipc", "--ipc host"), compose: ("ipc", "ipc: host"), tip: "Controls the IPC namespace."), + preserved(.namespaceUTS, .string, "UTS namespace", defaultValue: .string(""), compose: ("uts", "uts: host"), tip: "Controls the UTS namespace."), + preserved(.resourcesCPUShares, .string, "CPU shares", defaultValue: .string(""), docker: ("--cpu-shares", "--cpu-shares 512"), compose: ("cpu_shares", "cpu_shares: 512"), tip: "Sets relative CPU weight."), + preserved(.resourcesCPUQuota, .string, "CPU quota", defaultValue: .string(""), docker: ("--cpu-quota", "--cpu-quota 50000"), compose: ("cpu_quota", "cpu_quota: 50000"), tip: "Sets CPU CFS quota."), + preserved(.resourcesCPUPeriod, .string, "CPU period", defaultValue: .string(""), docker: ("--cpu-period", "--cpu-period 100000"), compose: ("cpu_period", "cpu_period: 100000"), tip: "Sets CPU CFS period."), + preserved(.resourcesCPUSet, .string, "CPU set", defaultValue: .string(""), docker: ("--cpuset-cpus", "--cpuset-cpus 0-3"), compose: ("cpuset", "cpuset: 0-3"), tip: "Pins execution to specific CPUs."), + preserved(.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."), + preserved(.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."), + preserved(.resourcesMemoryReservation, .string, "Memory reservation", defaultValue: .string(""), compose: ("mem_reservation", "mem_reservation: 256M"), tip: "Sets a soft memory reservation."), + preserved(.resourcesMemorySwapLimit, .string, "Swap limit", defaultValue: .string(""), docker: ("--memory-swap", "--memory-swap 1G"), compose: ("memswap_limit", "memswap_limit: 1G"), tip: "Sets memory plus swap limit."), + preserved(.resourcesMemorySwappiness, .string, "Memory swappiness", defaultValue: .string(""), docker: ("--memory-swappiness", "--memory-swappiness 0"), compose: ("mem_swappiness", "mem_swappiness: 0"), tip: "Controls anonymous page swapping."), + preserved(.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."), + preserved(.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."), + preserved(.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."), + preserved(.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."), + preserved(.storageVolumesFrom, .stringList, "Volumes from", defaultValue: .stringList([]), docker: ("--volumes-from", "--volumes-from db"), compose: ("volumes_from", "volumes_from: [db]"), tip: "Mounts volumes from another container."), + preserved(.composeSecrets, .stringList, "Secrets", defaultValue: .stringList([]), compose: ("secrets", "secrets: [db_password]"), tip: "Compose secrets are preserved for runtimes that can apply them later."), + preserved(.composeConfigs, .stringList, "Configs", defaultValue: .stringList([]), compose: ("configs", "configs: [app_config]"), tip: "Compose configs are preserved for runtimes that can apply them later."), + preserved(.composeProfiles, .stringList, "Profiles", defaultValue: .stringList([]), compose: ("profiles", "profiles: [dev]"), tip: "Compose profiles select which services participate in a run."), + preserved(.composeDeploy, .string, "Deploy metadata", defaultValue: .string(""), compose: ("deploy", "deploy: { replicas: 2 }"), tip: "Compose deploy metadata is preserved when the selected runtime cannot apply it."), + preserved(.composeScale, .string, "Scale", defaultValue: .string(""), compose: ("scale", "scale: 2"), tip: "Compose service scale is preserved for future stack-oriented runtimes."), + preserved(.composeLinks, .stringList, "Links", defaultValue: .stringList([]), compose: ("links", "links: [db]"), tip: "Legacy Compose links are preserved for reference."), + preserved(.composeDependsOn, .stringList, "Dependencies", defaultValue: .stringList([]), compose: ("depends_on", "depends_on: { db: { condition: service_healthy } }"), tip: "Compose startup dependency metadata is preserved for reference."), + preserved(.composeProvider, .string, "Provider", defaultValue: .string(""), compose: ("provider", "provider: awesomecloud"), tip: "Compose provider metadata is preserved for future runtimes."), + preserved(.composeModels, .stringList, "Models", defaultValue: .stringList([]), compose: ("models", "models: [ai_model]"), tip: "Compose model metadata is preserved for future runtimes."), + preserved(.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..fe8602ef --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/RunEditSchema.swift @@ -0,0 +1,427 @@ +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 defaultTip: String? + 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] = [], + defaultTip: String? = nil, + 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.defaultTip = defaultTip + 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 RuntimeProfile: Equatable, Sendable { + public var kind: Core.Runtime.Kind + public var supportedPaths: Set + public var disabledSupport: [Core.Field.Path: Core.Schema.FieldSupport] + public var tips: [Core.Field.Path: Core.Schema.FieldTipRef] + public var defaultDisabledSupport: Core.Schema.FieldSupport + + public init(kind: Core.Runtime.Kind, + supportedPaths: Set, + disabledSupport: [Core.Field.Path: Core.Schema.FieldSupport] = [:], + tips: [Core.Field.Path: Core.Schema.FieldTipRef] = [:], + defaultDisabledSupport: Core.Schema.FieldSupport = Core.Schema.FieldSupport( + state: .disabled, + disabledReasonKey: "schema.disabled.unsupported", + defaultDisabledReason: "This field is known to Core but is not available for the selected runtime." + )) { + self.kind = kind + self.supportedPaths = supportedPaths + self.disabledSupport = disabledSupport + self.tips = tips + self.defaultDisabledSupport = defaultDisabledSupport + } + + public func apply(to descriptors: [Core.Schema.FieldDescriptor]) -> [Core.Schema.FieldDescriptor] { + descriptors.map { descriptor in + var field = descriptor + field.support[kind] = support(for: descriptor) + if let tip = tips[descriptor.path] { + field.tipRefs[kind] = tip + } else if let defaultTip = descriptor.defaultTip { + field.tipRefs[kind] = Core.Schema.FieldTipRef( + key: "schema.tip.\(descriptor.path.rawValue).\(kind.rawValue)", + defaultText: defaultTip + ) + } + return field + } + } + + public func support(for descriptor: Core.Schema.FieldDescriptor) -> Core.Schema.FieldSupport { + if supportedPaths.contains(descriptor.path) { return .supported } + return disabledSupport[descriptor.path] ?? defaultDisabledSupport + } +} + +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, + 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/Sources/ContainedCore/Schema/Version.swift b/Packages/ContainedCore/Sources/ContainedCore/Schema/Version.swift new file mode 100644 index 00000000..d25dddf8 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Schema/Version.swift @@ -0,0 +1,14 @@ +import Foundation + +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 = Core.Schema.Version() +} + +} diff --git a/Sources/ContainedCore/Models/SystemProperties.swift b/Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift similarity index 69% rename from Sources/ContainedCore/Models/SystemProperties.swift rename to Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift index cf740a93..d063ae85 100644 --- a/Sources/ContainedCore/Models/SystemProperties.swift +++ b/Packages/ContainedCore/Sources/ContainedCore/System/Properties.swift @@ -2,9 +2,11 @@ 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? public let kernel: Kernel? public struct Build: Codable, Sendable, Hashable { @@ -22,10 +24,23 @@ public struct SystemProperties: 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) 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/Sources/ContainedCore/Models/SystemInfo.swift b/Packages/ContainedCore/Sources/ContainedCore/System/SystemStatus.swift similarity index 88% rename from Sources/ContainedCore/Models/SystemInfo.swift rename to Packages/ContainedCore/Sources/ContainedCore/System/SystemStatus.swift index 22eb0c82..382f75d8 100644 --- a/Sources/ContainedCore/Models/SystemInfo.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/Volume+Resource.swift b/Packages/ContainedCore/Sources/ContainedCore/Volume/Volume+Resource.swift new file mode 100644 index 00000000..c5e0c8a4 --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCore/Volume/Volume+Resource.swift @@ -0,0 +1,77 @@ +import Foundation + +/// One element of `container volume list --format json`. +/// +/// 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 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) { + 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) + if let decodedRuntimeKind = try c.decodeIfPresent(Core.Runtime.Kind.self, forKey: .runtimeKind) { + runtimeKind = decodedRuntimeKind + } else if let contextRuntimeKind = decoder.coreRuntimeKindContext { + runtimeKind = contextRuntimeKind + } else { + throw DecodingError.keyNotFound( + CodingKeys.runtimeKind, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Missing runtimeKind and no runtime decoding context was provided.") + ) + } + } + + public func scoped(to runtimeKind: Core.Runtime.Kind) -> Core.Volume.Resource { + Core.Volume.Resource(configuration: configuration, runtimeKind: runtimeKind) + } +} + +struct Configuration: Codable, Sendable, Hashable { + public let name: String + public let source: String? + public let format: String? + public let sizeInBytes: UInt64? + 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) + source = try c.decodeIfPresent(String.self, forKey: .source) + format = try c.decodeIfPresent(String.self, forKey: .format) + sizeInBytes = try c.decodeIfPresent(UInt64.self, forKey: .sizeInBytes) + creationDate = try c.decodeIfPresent(Date.self, forKey: .creationDate) + 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 new file mode 100644 index 00000000..e63adbee --- /dev/null +++ b/Packages/ContainedCore/Sources/ContainedCoreFixtures/Core/Fixtures/AppleContainer/AppleContainerFixtures.swift @@ -0,0 +1,136 @@ +import Foundation +import ContainedCore + +public extension Core.Fixtures.AppleContainer { + static let runtimeDescriptor = Core.Runtime.Descriptor.appleContainer + static let runtimes = [runtimeDescriptor] + + static let webContainer = Core.Container.Snapshot.placeholder( + id: "preview-web", + image: "docker.io/library/nginx:latest", + state: .running, + runtimeKind: .appleContainer + ) + + static let workerContainer = Core.Container.Snapshot.placeholder( + id: "preview-worker", + image: "ghcr.io/example/worker:nightly", + state: .stopped, + runtimeKind: .appleContainer + ) + + static let stats = Core.Metrics.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(Core.Image.Resource.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 = Core.Image.LocalTagGroup.group(containing: image, in: [image]) + + static let volume = decode(Core.Volume.Resource.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(Core.Network.Resource.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 = Core.Command.Error.nonZeroExit( + code: 42, + stderr: "preview failure", + command: "container preview" + ) + + static let createRequest: Core.Container.CreateRequest = { + var request = Core.Container.CreateRequest(runtimeKind: .appleContainer) + request.image = image.reference + request.platform = "linux/arm64" + request.name = "preview-web" + request.command = ["nginx", "-g", "daemon off;"] + 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 = "/" + request.useInit = true + return request + }() +} + +private func decode(_ type: T.Type, from json: String) -> T { + do { + return try Core.Container.JSON.decode(type, from: Data(json.utf8), runtimeKind: .appleContainer) + } 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..5c272efd --- /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, Core.Metrics.HistorySample { + 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/Tests/ContainedCoreTests/CommandTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift similarity index 50% rename from Tests/ContainedCoreTests/CommandTests.swift rename to Packages/ContainedCore/Tests/ContainedCoreTests/CommandTests.swift index 8e0fef70..aa512bd3 100644 --- a/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() { @@ -57,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") @@ -66,87 +68,73 @@ 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) #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 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 registryPasswordsAreSentOnlyThroughStdin() async throws { + let runner = CapturingCommandRunner() + let password = "password-SENTINEL" - @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() - } - } + _ = try await AppleContainerClient(runner: runner) + .registryLogin(server: "ghcr.io", username: "octocat", password: password) + _ = try await DockerClient(runner: runner) + .registryLogin(server: "registry.example", username: "builder", password: password) - @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() + let invocations = await runner.invocations + #expect(invocations.count == 2) + for invocation in invocations { + #expect(!invocation.arguments.contains { $0.contains(password) }) + #expect(invocation.arguments.contains("--password-stdin")) + #expect(invocation.stdin == Data(password.utf8)) } } @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) @@ -154,4 +142,47 @@ struct CommandTests { #expect(delta.netRxBytesPerSec == 2048) #expect(delta.numProcesses == 2) } + + @Test func statsDeltaConvertsRuntimeSnapshotRates() { + 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 = 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 = Core.Metrics.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) + } +} + +private actor CapturingCommandRunner: Core.Command.Running { + struct Invocation: Sendable { + var arguments: [String] + var stdin: Data? + } + + private(set) var invocations: [Invocation] = [] + + func run(_ arguments: [String], + stdin: Data?, + priority: Core.Command.ExecutionPriority) async throws -> Data { + invocations.append(Invocation(arguments: arguments, stdin: stdin)) + return Data() + } + + nonisolated func stream(_ arguments: [String], + priority: Core.Command.ExecutionPriority) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } } diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift new file mode 100644 index 00000000..3f68645c --- /dev/null +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/DecodingTests.swift @@ -0,0 +1,237 @@ +import Foundation +import Testing +@testable import ContainedCore + +@Suite("Decoding real CLI fixtures") +struct DecodingTests { + + @Test func placeholderSnapshotDecodes() { + let s = Core.Container.Snapshot.placeholder(id: "nginx", image: "nginx:latest", runtimeKind: .appleContainer) + #expect(s.id == "nginx") + #expect(s.image == "nginx:latest") + #expect(s.state == .running) + let stopped = Core.Container.Snapshot.placeholder(id: "x", image: "redis:7", state: .stopped, runtimeKind: .appleContainer) + #expect(stopped.state == .stopped) + let quoted = Core.Container.Snapshot.placeholder(id: #"weird "id""#, image: #"repo/"quoted":tag"#, runtimeKind: .appleContainer) + #expect(quoted.id == #"weird "id""#) + #expect(quoted.image == #"repo/"quoted":tag"#) + } + + @Test func decodesContainerList() throws { + let snapshots = try Core.Container.JSON.decode([Core.Container.Snapshot].self, + from: try Fixture.data("list"), + runtimeKind: .appleContainer) + try #require(snapshots.count == 1) + let c = snapshots[0] + #expect(c.id == "fixture-web") + #expect(c.state == .running) + #expect(c.image == "docker.io/library/alpine:latest") + #expect(c.configuration.resources.cpus == 4) + #expect(c.configuration.resources.memoryInBytes == 1_073_741_824) + #expect(c.configuration.initProcess.environment.contains("FOO=bar")) + #expect(c.configuration.publishedPorts.first?.hostPort == 18080) + #expect(c.configuration.publishedPorts.first?.containerPort == 80) + #expect(c.configuration.labels.isEmpty) + #expect(c.startedDate != nil) + #expect(c.status.networks.first?.ipv4Address == "192.168.64.3/24") + } + + @Test func resourceDecodingRequiresRuntimeIdentityOrContext() throws { + #expect(throws: DecodingError.self) { + _ = try Core.Container.JSON.decode([Core.Container.Snapshot].self, + from: try Fixture.data("list")) + } + } + + @Test func decodesMultiContainerListWithVirtiofsMounts() throws { + // Live output can represent mount `type` as an enum-like object such as {"virtiofs":{}}. + let snapshots = try Core.Container.JSON.decode([Core.Container.Snapshot].self, + from: try Fixture.data("list-current"), + runtimeKind: .appleContainer) + #expect(snapshots.count == 4) + let npm = try #require(snapshots.first { $0.id == "nginx-proxy-manager-latest" }) + #expect(npm.configuration.mounts.count == 2) + #expect(npm.configuration.mounts.first?.type == "virtiofs") + #expect(npm.configuration.mounts.first?.effectiveDestination == "/data") + } + + @Test func decodesInspectMatchesList() throws { + let inspected = try Core.Container.JSON.decode([Core.Container.Snapshot].self, + from: try Fixture.data("inspect"), + runtimeKind: .appleContainer) + #expect(inspected.first?.id == "fixture-web") + #expect(inspected.first?.configuration.platform.architecture == "arm64") + } + + @Test func decodesStats() throws { + 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) + #expect(s.numProcesses == 1) + #expect(s.cpuUsageUsec == 1827) + } + + @Test func decodesDiskUsage() throws { + 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) + #expect(df.totalSizeInBytes > 0) + } + + @Test func decodesSystemStatus() throws { + 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) + } + + @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 Core.Container.JSON.decode(Core.System.Properties.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 Core.Container.JSON.decode([Core.Network.Resource].self, + from: try Fixture.data("networks"), + runtimeKind: .appleContainer) + let def = try #require(nets.first) + #expect(def.name == "default") + #expect(def.isBuiltin) + #expect(def.status?.ipv4Subnet == "192.168.64.0/24") + } + + @Test func decodesEmptyVolumes() throws { + let vols = try Core.Container.JSON.decode([Core.Volume.Resource].self, + from: try Fixture.data("volumes"), + runtimeKind: .appleContainer) + #expect(vols.isEmpty) + } + + @Test func decodesMultiArchImage() throws { + let images = try Core.Container.JSON.decode([Core.Image.Resource].self, + from: try Fixture.data("image-inspect"), + runtimeKind: .appleContainer) + let img = try #require(images.first) + #expect(img.reference == "docker.io/library/alpine:latest") + #expect(img.variants.count > 1) + // Real OS/arch variants are runnable; "unknown/unknown" attestation blobs are filtered out. + let runnable = img.variants.filter(\.isRunnable) + #expect(runnable.contains { $0.platform.architecture == "arm64" }) + #expect(runnable.allSatisfy { $0.platform.os == "linux" }) + // Snake_case / capitalized OCI keys decode. + let arm64 = try #require(runnable.first { $0.platform.architecture == "arm64" }) + #expect(arm64.config?.config?.cmd == ["/bin/sh"]) + #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) + #expect(Core.Container.JSON.parseDate("not-a-date") == nil) + } + + @Test func unknownRuntimeStatusFallsBack() throws { + let data = Data(#"{"state":"frobnicating","networks":[]}"#.utf8) + let s = try Core.Container.JSON.decode(Core.Container.RuntimeState.self, from: data) + #expect(s.state == .unknown) + #expect(s.rawState == "frobnicating") + } +} diff --git a/Tests/ContainedCoreTests/Fixtures.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift similarity index 64% rename from Tests/ContainedCoreTests/Fixtures.swift rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift index e47c51c9..5e5adab2 100644 --- a/Tests/ContainedCoreTests/Fixtures.swift +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures.swift @@ -18,13 +18,19 @@ 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). +/// 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]) async throws -> Data { try result.get() } - func stream(_ arguments: [String]) -> AsyncThrowingStream { + + func run(_ arguments: [String], + stdin: Data?, + priority: Core.Command.ExecutionPriority) async throws -> Data { + try result.get() + } + + func stream(_ arguments: [String], + priority: Core.Command.ExecutionPriority) -> AsyncThrowingStream { let chunks = streamChunks return AsyncThrowingStream { continuation in for chunk in chunks { continuation.yield(chunk) } diff --git a/Tests/ContainedCoreTests/Fixtures/df.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/df.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/df.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/df.json diff --git a/Tests/ContainedCoreTests/Fixtures/image-inspect.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/image-inspect.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/image-inspect.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/image-inspect.json diff --git a/Tests/ContainedCoreTests/Fixtures/images-error.txt b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/images-error.txt similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/images-error.txt rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/images-error.txt diff --git a/Tests/ContainedCoreTests/Fixtures/inspect.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/inspect.json similarity index 97% rename from Tests/ContainedCoreTests/Fixtures/inspect.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/inspect.json index f73f540e..09ff4aac 100644 --- a/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/Tests/ContainedCoreTests/Fixtures/list-current.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list-current.json similarity index 88% rename from Tests/ContainedCoreTests/Fixtures/list-current.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/list-current.json index bf64c211..8c4ebf36 100644 --- a/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 new file mode 100644 index 00000000..6d069fad --- /dev/null +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/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":{},"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/ContainedCoreTests/Fixtures/networks.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/networks.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/networks.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/networks.json diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/stats-table.txt b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/stats-table.txt new file mode 100644 index 00000000..85cb21e6 --- /dev/null +++ b/Packages/ContainedCore/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/Fixtures/stats.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/stats.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/stats.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/stats.json diff --git a/Tests/ContainedCoreTests/Fixtures/status.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/status.json similarity index 100% rename from Tests/ContainedCoreTests/Fixtures/status.json rename to Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/status.json diff --git a/Tests/ContainedCoreTests/Fixtures/volumes.json b/Packages/ContainedCore/Tests/ContainedCoreTests/Fixtures/volumes.json similarity index 100% rename from Tests/ContainedCoreTests/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 64% rename from Tests/ContainedCoreTests/ImageWorkflowTests.swift rename to Packages/ContainedCore/Tests/ContainedCoreTests/ImageWorkflowTests.swift index 2c6e4779..8d3fe02b 100644 --- a/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,12 +55,33 @@ struct ImageWorkflowTests { } ] """ - let images = try JSONDecoder().decode([ImageResource].self, from: Data(json.utf8)) - let groups = LocalImageTagGroup.groups(for: images) + let images = try Core.Container.JSON.decode([Core.Image.Resource].self, + from: Data(json.utf8), + runtimeKind: .appleContainer) + let groups = Core.Image.LocalTagGroup.groups(for: images) #expect(groups.count == 1) #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") @@ -70,7 +91,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 +103,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 +127,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,16 +136,21 @@ 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 = Core.Registry.ManifestError.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 { @@ -139,6 +165,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..cc350b4b --- /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(runtimeProfile: DockerRuntimeModule().schemaProfile()) + 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(runtimeProfile: DockerRuntimeModule().schemaProfile()) + 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/RuntimeBoundaryConventionTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeBoundaryConventionTests.swift new file mode 100644 index 00000000..ad1b58a2 --- /dev/null +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeBoundaryConventionTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing + +@Suite("Runtime boundary conventions") +struct RuntimeBoundaryConventionTests { + @Test func concreteRuntimeSymbolsStayInsideRuntimeFolders() throws { + let packageRoot = try packageRootURL() + let sourceRoot = packageRoot.appending(path: "Sources/ContainedCore") + let forbiddenSymbols = [ + "AppleContainerClient", + "DockerClient", + "ContainerCommands", + "DockerCommands", + "AppleContainerCLILocator", + "DockerCLILocator", + "AppleContainerCreateTranslator", + "DockerCreateTranslator", + "ContainerStatsTableParser", + "DockerJSON", + ] + + let violations = try swiftFiles(under: sourceRoot).flatMap { url -> [String] in + let path = url.path(percentEncoded: false) + guard !path.contains("/Runtimes/") else { return [] } + let contents = (try? String(contentsOf: url, encoding: .utf8)) ?? "" + return forbiddenSymbols.compactMap { symbol in + contents.contains(symbol) ? "\(path.replacingOccurrences(of: packageRoot.path(percentEncoded: false) + "/", with: "")) uses \(symbol)" : nil + } + } + + #expect(violations.isEmpty, Comment(rawValue: violations.joined(separator: "\n"))) + } + + @Test func removedWideRuntimeProtocolNameDoesNotReturn() throws { + let packageRoot = try packageRootURL() + let sourceAndTests = [ + packageRoot.appending(path: "Sources"), + packageRoot.appending(path: "Tests"), + ] + let violations = try sourceAndTests.flatMap { root in + try swiftFiles(under: root).compactMap { url -> String? in + let contents = (try? String(contentsOf: url, encoding: .utf8)) ?? "" + guard contents.contains("ContainerRuntime" + "Client") else { return nil } + return url.path(percentEncoded: false) + .replacingOccurrences(of: packageRoot.path(percentEncoded: false) + "/", with: "") + } + } + + #expect(violations.isEmpty, Comment(rawValue: violations.joined(separator: "\n"))) + } + + @Test func runtimeRegistryLivesInSharedRuntimeInfrastructure() throws { + let packageRoot = try packageRootURL() + let runtimeRoot = packageRoot.appending(path: "Sources/ContainedCore/Runtime") + let runtimesRoot = packageRoot.appending(path: "Sources/ContainedCore/Runtimes") + + #expect(FileManager.default.fileExists(atPath: runtimeRoot.appending(path: "ModuleRegistry.swift").path(percentEncoded: false))) + + let misplacedFiles = try swiftFiles(under: runtimesRoot).filter { url in + url.deletingLastPathComponent() == runtimesRoot + } + #expect(misplacedFiles.isEmpty, Comment(rawValue: misplacedFiles.map(\.path).joined(separator: "\n"))) + } + + @Test func canonicalSchemaCatalogIsNotApplePrimary() throws { + let packageRoot = try packageRootURL() + let url = packageRoot.appending(path: "Sources/ContainedCore/Schema/ContainerFieldCatalog.swift") + let contents = try String(contentsOf: url, encoding: .utf8) + let forbidden = [ + "appleContainerRunFields", + "appleTip", + "func apple(", + "support: [.appleContainer", + "defaultValue: .string(Core.Runtime.Kind.appleContainer.rawValue)", + ] + let violations = forbidden.filter { contents.contains($0) } + + #expect(violations.isEmpty, Comment(rawValue: violations.joined(separator: "\n"))) + } + + private func packageRootURL() throws -> URL { + var url = URL(filePath: #filePath) + for _ in 0..<4 { url.deleteLastPathComponent() } + return url + } + + private func swiftFiles(under root: URL) throws -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + return try enumerator.compactMap { item -> URL? in + guard let url = item as? URL, url.pathExtension == "swift" else { return nil } + let values = try url.resourceValues(forKeys: [.isRegularFileKey]) + return values.isRegularFile == true ? url : nil + } + } +} diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift new file mode 100644 index 00000000..e3538651 --- /dev/null +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtime/RuntimeDescriptorTests.swift @@ -0,0 +1,462 @@ +import Foundation +import Testing +@testable import ContainedCore + +@Suite("Runtime descriptor contracts") +struct RuntimeDescriptorTests { + @Test func defaultRegistryKeepsDockerDormant() { + #expect(Core.Runtime.builtInModules.map(\.descriptor.kind) == [.appleContainer]) + #expect(Core.Runtime.supportedDescriptors.map(\.kind) == [.appleContainer]) + #expect(Core.Runtime.descriptor(for: .appleContainer) == .appleContainer) + #expect(Core.Runtime.descriptor(for: .docker) == nil) + } + + @Test func openRuntimeKindsCanAdvertiseCapabilities() throws { + let descriptor = Core.Runtime.Descriptor( + kind: Core.Runtime.Kind(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 = Core.Runtime.UnsupportedCapability( + kind: .docker, + capability: .imageBuild + ) + + #expect(error.packageName == "ContainedCore") + #expect(error.packageErrorCode == "unsupportedRuntimeCapability") + #expect(error.packageErrorContext["kind"] == Core.Runtime.Kind.docker.rawValue) + #expect(error.packageErrorContext["capability"] == String(Core.Runtime.Capability.imageBuild.rawValue)) + } + + @Test func defaultCoreSwitchPlanIsDisplayNeutral() throws { + let runtime = UnavailableRuntime( + descriptor: Core.Runtime.Descriptor( + kind: Core.Runtime.Kind(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"] == Core.Runtime.Kind.appleContainer.rawValue) + } + + @Test func orchestratorAggregatesAndScopesContainersAcrossRuntimes() async throws { + let apple = UnavailableRuntime( + descriptor: .appleContainer, + containers: [.placeholder(id: "web", image: "nginx:latest", runtimeKind: .appleContainer)] + ) + let docker = UnavailableRuntime( + descriptor: .docker, + containers: [.placeholder(id: "web", image: "nginx:latest", runtimeKind: .docker)] + ) + let orchestrator = Core.Orchestrator( + cliURLs: [ + .appleContainer: URL(fileURLWithPath: "/usr/bin/container"), + .docker: URL(fileURLWithPath: "/usr/local/bin/docker"), + ], + runtimes: [ + .appleContainer: apple, + .docker: docker, + ] as [Core.Runtime.Kind: any RuntimeClient] + ) + + let snapshots = try await orchestrator.listRuntimeContainers() + + #expect(snapshots.count == 2) + #expect(Set(snapshots.map { $0.id }) == ["web"]) + #expect(Set(snapshots.map { $0.scopedID }) == [ + "apple-container::web", + "docker::web", + ]) + #expect(Set(snapshots.map { $0.runtimeKind }) == [ + Core.Runtime.Kind.appleContainer, + Core.Runtime.Kind.docker, + ]) + } + + @Test func orchestratorPreservesPartialInventoryWhenOneRuntimeFails() async throws { + let apple = UnavailableRuntime( + descriptor: .appleContainer, + containers: [.placeholder(id: "apple-web", image: "nginx:latest", runtimeKind: .appleContainer)] + ) + 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, + ] as [Core.Runtime.Kind: any RuntimeClient] + ) + + let inventory = try await orchestrator.containerInventory() + let snapshots = inventory.items + + #expect(snapshots.map { $0.scopedID } == ["apple-container::apple-web"]) + #expect(inventory.failures.count == 1) + #expect(inventory.failures.first?.kind == .docker) + } + + @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), + ] as [Core.Runtime.Kind: any RuntimeClient], + modules: [ + .appleContainer: AppleContainerRuntimeModule(), + .docker: DockerRuntimeModule(), + ] + ) + + 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")) + } + + @Test func orchestratorDoesNotFabricateUnregisteredDescriptors() { + let orchestrator = Core.Orchestrator( + cliURLs: [.appleContainer: URL(fileURLWithPath: "/usr/bin/container")], + runtimes: [.appleContainer: UnavailableRuntime(descriptor: .appleContainer)] as [Core.Runtime.Kind: any RuntimeClient] + ) + + #expect(orchestrator.descriptor(for: .appleContainer) == .appleContainer) + #expect(orchestrator.descriptor(for: .docker) == nil) + #expect(orchestrator.descriptor(for: Core.Runtime.Kind(rawValue: "future-runtime")) == nil) + } + + @Test func serviceControlRequiresExplicitCapability() async { + let orchestrator = Core.Orchestrator( + cliURLs: [.docker: URL(fileURLWithPath: "/usr/local/bin/docker")], + runtimes: [.docker: UnavailableRuntime(descriptor: .docker)] as [Core.Runtime.Kind: any RuntimeClient] + ) + + await #expect(throws: Core.Runtime.UnsupportedCapability.self) { + _ = try await orchestrator.performSystemAction(.start, runtimeKind: .docker) + } + } + + @Test func recreateSkipsDeleteWhenOriginalIsAlreadyMissing() async throws { + let runtime = RecordingContainerRuntime(containers: []) + let orchestrator = Core.Orchestrator( + cliURLs: [.appleContainer: URL(fileURLWithPath: "/usr/bin/container")], + runtimes: [.appleContainer: runtime] as [Core.Runtime.Kind: any RuntimeClient] + ) + + let result = try await orchestrator.recreateContainer(originalID: "missing", + replacement: recreateDocument(), + rollback: recreateDocument(name: "missing")) + + #expect(result.id == "created") + #expect(await runtime.deletedIDs.isEmpty) + #expect(await runtime.createdRequests.count == 1) + } + + @Test func recreateTreatsRacingDeleteNotFoundAsAlreadyGone() async throws { + let runtime = RecordingContainerRuntime( + containers: [.placeholder(id: "gone", image: "nginx:latest", runtimeKind: .appleContainer)], + deleteError: .nonZeroExit(code: 1, + stderr: "Error: container with ID gone not found", + command: "delete --force gone") + ) + let orchestrator = Core.Orchestrator( + cliURLs: [.appleContainer: URL(fileURLWithPath: "/usr/bin/container")], + runtimes: [.appleContainer: runtime] as [Core.Runtime.Kind: any RuntimeClient] + ) + + let result = try await orchestrator.recreateContainer(originalID: "gone", + replacement: recreateDocument(), + rollback: recreateDocument(name: "gone")) + + #expect(result.id == "created") + #expect(await runtime.deletedIDs == ["gone"]) + #expect(await runtime.createdRequests.count == 1) + } + + @Test func recreateStillSurfacesRealDeleteFailures() async throws { + let runtime = RecordingContainerRuntime( + containers: [.placeholder(id: "blocked", image: "nginx:latest", runtimeKind: .appleContainer)], + deleteError: .nonZeroExit(code: 1, + stderr: "permission denied", + command: "delete --force blocked") + ) + let orchestrator = Core.Orchestrator( + cliURLs: [.appleContainer: URL(fileURLWithPath: "/usr/bin/container")], + runtimes: [.appleContainer: runtime] as [Core.Runtime.Kind: any RuntimeClient] + ) + + do { + _ = try await orchestrator.recreateContainer(originalID: "blocked", + replacement: recreateDocument(), + rollback: recreateDocument(name: "blocked")) + Issue.record("Expected recreate to fail") + } catch let error as Core.Container.RecreateFailure { + #expect(error.phase == .deleteOriginal) + #expect(error.recovery == .notNeeded) + } + #expect(await runtime.createdRequests.isEmpty) + } + + @Test func recreateValidatesRollbackBeforeDeletingOriginal() async throws { + let runtime = RecordingContainerRuntime( + containers: [.placeholder(id: "web", image: "nginx:latest", runtimeKind: .appleContainer)] + ) + let orchestrator = Core.Orchestrator( + cliURLs: [.appleContainer: URL(fileURLWithPath: "/usr/bin/container")], + runtimes: [.appleContainer: runtime] as [Core.Runtime.Kind: any RuntimeClient] + ) + var invalidRollback = recreateDocument(name: "web") + invalidRollback.set(.imageReference, .string("")) + + await #expect(throws: Core.Schema.ValidationError.self) { + _ = try await orchestrator.recreateContainer(originalID: "web", + replacement: recreateDocument(), + rollback: invalidRollback) + } + #expect(await runtime.stoppedIDs.isEmpty) + #expect(await runtime.deletedIDs.isEmpty) + #expect(await runtime.createdRequests.isEmpty) + } + + @Test func recreateValidatesReplacementBeforeDeletingOriginal() async throws { + let runtime = RecordingContainerRuntime( + containers: [.placeholder(id: "web", image: "nginx:latest", runtimeKind: .appleContainer)] + ) + let orchestrator = Core.Orchestrator( + cliURLs: [.appleContainer: URL(fileURLWithPath: "/usr/bin/container")], + runtimes: [.appleContainer: runtime] as [Core.Runtime.Kind: any RuntimeClient] + ) + var invalidReplacement = recreateDocument() + invalidReplacement.set(.imageReference, .string("")) + + await #expect(throws: Core.Schema.ValidationError.self) { + _ = try await orchestrator.recreateContainer(originalID: "web", + replacement: invalidReplacement, + rollback: recreateDocument(name: "web")) + } + #expect(await runtime.stoppedIDs.isEmpty) + #expect(await runtime.deletedIDs.isEmpty) + #expect(await runtime.createdRequests.isEmpty) + } + + @Test func recreateRestoresOriginalWhenReplacementCreationFails() async throws { + let runtime = RecordingContainerRuntime( + containers: [.placeholder(id: "web", image: "nginx:stable", runtimeKind: .appleContainer)], + createErrors: [.nonZeroExit(code: 1, stderr: "replacement failed", command: "run replacement")] + ) + let orchestrator = Core.Orchestrator( + cliURLs: [.appleContainer: URL(fileURLWithPath: "/usr/bin/container")], + runtimes: [.appleContainer: runtime] as [Core.Runtime.Kind: any RuntimeClient] + ) + + do { + _ = try await orchestrator.recreateContainer(originalID: "web", + replacement: recreateDocument(), + rollback: recreateDocument(name: "web", image: "nginx:stable")) + Issue.record("Expected recreate to report the replacement failure") + } catch let error as Core.Container.RecreateFailure { + #expect(error.phase == .createReplacement) + #expect(error.recovery == .originalRestored) + #expect(error.primaryFailure.runtimeDetail == "replacement failed") + } + let requests = await runtime.createdRequests + #expect(requests.map(\.name) == ["created", "web"]) + } + + @Test func recreateReportsWhenReplacementAndRestorationFail() async throws { + let runtime = RecordingContainerRuntime( + containers: [.placeholder(id: "web", image: "nginx:stable", runtimeKind: .appleContainer)], + createErrors: [ + .nonZeroExit(code: 1, stderr: "replacement failed", command: "run replacement"), + .nonZeroExit(code: 2, stderr: "restore failed", command: "run original"), + ] + ) + let orchestrator = Core.Orchestrator( + cliURLs: [.appleContainer: URL(fileURLWithPath: "/usr/bin/container")], + runtimes: [.appleContainer: runtime] as [Core.Runtime.Kind: any RuntimeClient] + ) + + do { + _ = try await orchestrator.recreateContainer(originalID: "web", + replacement: recreateDocument(), + rollback: recreateDocument(name: "web", image: "nginx:stable")) + Issue.record("Expected recreate and restoration to fail") + } catch let error as Core.Container.RecreateFailure { + #expect(error.phase == .restoreOriginal) + #expect(error.recovery == .restoreFailed) + #expect(error.primaryFailure.runtimeDetail == "replacement failed") + #expect(error.recoveryFailure?.runtimeDetail == "restore failed") + #expect(error.packageErrorContext["phase"] == "restoreOriginal") + #expect(!error.packageErrorContext.values.contains { $0.contains("failed") }) + } + } + + private func recreateDocument(name: String = "created", image: String = "nginx:latest") -> Core.Schema.Document { + var request = Core.Container.CreateRequest(runtimeKind: .appleContainer) + request.image = image + request.name = name + return Core.Schema.Document.containerCreate(from: request) + } +} + +private struct UnavailableRuntime: RuntimeClient, + RuntimeContainerClient, + RuntimeSystemStatusClient, + RuntimeDNSClient, + RuntimeKernelClient, + RuntimeExecClient, + RuntimeSystemLogsClient, + RuntimeComposeClient, + RuntimeNetworkClient, + RuntimeVolumeClient, + RuntimeImageClient, + RuntimeRegistryClient, + RuntimeServiceControlClient { + let descriptor: Core.Runtime.Descriptor + var containers: [Core.Container.Snapshot] = [] + var listError: TestStubError? + + 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() } + } + 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 } + 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 -> 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() } + } + 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: 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 } + 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 +} + +private actor RecordingContainerRuntime: RuntimeClient, RuntimeContainerClient { + nonisolated let descriptor = Core.Runtime.Descriptor.appleContainer + var containers: [Core.Container.Snapshot] + var deleteError: Core.Command.Error? + var createErrors: [Core.Command.Error] + var deletedIDs: [String] = [] + var stoppedIDs: [String] = [] + var createdRequests: [Core.Container.CreateRequest] = [] + + init(containers: [Core.Container.Snapshot], + deleteError: Core.Command.Error? = nil, + createErrors: [Core.Command.Error] = []) { + self.containers = containers + self.deleteError = deleteError + self.createErrors = createErrors + } + + func listContainers(all: Bool) async throws -> [Core.Container.Snapshot] { containers } + func stats(ids: [String]) async throws -> [Core.Metrics.ContainerStats] { [] } + nonisolated func streamStats(ids: [String]) -> AsyncThrowingStream<[Core.Metrics.RuntimeStatsSnapshot], Error> { + AsyncThrowingStream { $0.finish() } + } + nonisolated func streamLogs(id: String, follow: Bool, tail: Int?, boot: Bool) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } + nonisolated func previewCreateCommand(for request: Core.Container.CreateRequest) throws -> Core.Command.Preview { + Core.Command.Preview(command: ["run", request.image]) + } + func createContainer(_ request: Core.Container.CreateRequest) async throws -> Core.Container.CreateResult { + createdRequests.append(request) + if !createErrors.isEmpty { throw createErrors.removeFirst() } + return Core.Container.CreateResult(id: "created") + } + func runContainer(arguments: [String]) async throws -> Data { Data() } + func start(_ ids: [String]) async throws -> Data { Data() } + func stop(_ ids: [String]) async throws -> Data { + stoppedIDs.append(contentsOf: ids) + return Data() + } + func deleteContainers(_ ids: [String], force: Bool) async throws -> Data { + deletedIDs.append(contentsOf: ids) + if let deleteError { throw deleteError } + return Data() + } + func pruneContainers() async throws -> Data { Data() } +} diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift new file mode 100644 index 00000000..9b433903 --- /dev/null +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/RuntimeWorkflowTests.swift @@ -0,0 +1,215 @@ +import Foundation +import Testing +@testable import ContainedCore + +@Suite("Runtime command workflows") +struct RuntimeWorkflowTests { + + // MARK: Command builders + + @Test func imageAndStreamingArgv() { + #expect(ContainerCommands.imageList() == ["image", "list", "--format", "json"]) + // 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"]) + } + + @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"]) + == ["volume", "create", "--label", "a=1", "-s", "10G", "data"]) + #expect(ContainerCommands.volumeDelete(["a", "b"]) == ["volume", "delete", "a", "b"]) + + #expect(ContainerCommands.networkCreate(name: "net") == ["network", "create", "net"]) + #expect(ContainerCommands.networkCreate(name: "net", subnet: "10.0.0.0/24", internalOnly: true) + == ["network", "create", "--internal", "--subnet", "10.0.0.0/24", "net"]) + #expect(ContainerCommands.networkDelete(["net"]) == ["network", "delete", "net"]) + } + + @Test func imageWriteAndBuildArgv() { + #expect(ContainerCommands.imageDelete(["a", "b"]) == ["image", "delete", "a", "b"]) + #expect(ContainerCommands.imageTag(source: "a:1", target: "a:2") == ["image", "tag", "a:1", "a:2"]) + #expect(ContainerCommands.imagePrune() == ["image", "prune"]) + #expect(ContainerCommands.imagePrune(all: true) == ["image", "prune", "--all"]) + #expect(ContainerCommands.imagePull("alpine") == ["image", "pull", "--progress", "plain", "alpine"]) + #expect(ContainerCommands.imagePull("alpine", platform: "linux/arm64") + == ["image", "pull", "--progress", "plain", "--platform", "linux/arm64", "alpine"]) + #expect(ContainerCommands.build(context: ".") == ["build", "--progress", "plain", "."]) + #expect(ContainerCommands.build(context: "ctx", tag: "img:1", dockerfile: "Dockerfile", + buildArgs: ["A": "1"], noCache: true) + == ["build", "--progress", "plain", "--tag", "img:1", "--file", "Dockerfile", + "--build-arg", "A=1", "--no-cache", "ctx"]) + } + + @Test func dockerRunArgvIncludesDockerOnlyFields() { + var request = Core.Container.CreateRequest(runtimeKind: .appleContainer) + 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") + == ["registry", "login", "--username", "me", "--password-stdin", "ghcr.io"]) + #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() { + #expect(ContainerCommands.containerPrune() == ["prune"]) + #expect(ContainerCommands.volumePrune() == ["volume", "prune"]) + #expect(ContainerCommands.networkPrune() == ["network", "prune"]) + #expect(ContainerCommands.systemPropertyList == ["system", "property", "list", "--format", "json"]) + #expect(ContainerCommands.systemLogs(follow: true, last: 200) == ["system", "logs", "--follow", "--last", "200"]) + #expect(ContainerCommands.exec("web", ["ps"]) == ["exec", "web", "ps"]) + #expect(ContainerCommands.copy(source: "web:/etc/hosts", destination: "/tmp/hosts") + == ["copy", "web:/etc/hosts", "/tmp/hosts"]) + } + + @Test func composeParsing() throws { + let yaml = """ + services: + web: + image: nginx:latest + platform: linux/arm64 + ports: + - "8080:80" + environment: + - FOO=bar + restart: always + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: secret + volumes: + - "pgdata:/var/lib/postgresql/data" + networks: + default: {} + """ + 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") + #expect(web?.platform == "linux/arm64") + #expect(web?.ports == ["8080:80"]) + #expect(web?.environment == ["FOO=bar"]) + #expect(web?.restart == "always") + let db = project.services.first { $0.name == "db" } + #expect(db?.environment == ["POSTGRES_PASSWORD=secret"]) + #expect(db?.volumes == ["pgdata:/var/lib/postgresql/data"]) + // The top-level `networks` key is reported as not translated. + #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())), + runtimeKind: .appleContainer) + let dockerModule = DockerRuntimeModule() + let docker = Core.Orchestrator(cliURLs: [.docker: URL(fileURLWithPath: "/usr/local/bin/docker")], + runtimes: [.docker: dockerModule.makeClient(runner: MockCommandRunner(result: .success(Data())))] as [Core.Runtime.Kind: any RuntimeClient], + modules: [.docker: dockerModule]) + + 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() { + #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(!Core.Container.RestartDecision.shouldRestart(policy: .always, userInitiated: true)) + // .no never restarts. + #expect(!Core.Container.RestartDecision.shouldRestart(policy: .no, userInitiated: false)) + // .always restarts any crash. + #expect(Core.Container.RestartDecision.shouldRestart(policy: .always, userInitiated: false)) + // .onFailure: unknown exit treated as failure; known 0 suppressed; nonzero restarts. + #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(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 new file mode 100644 index 00000000..8c608c95 --- /dev/null +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/AppleContainer/AdapterTests.swift @@ -0,0 +1,255 @@ +import Foundation +import Testing +@testable import ContainedCore + +@Suite("Runtime adapter boundary") +struct AppleContainerAdapterTests { + @Test func runtimeKindAcceptsFutureAdapters() throws { + let descriptor = Core.Runtime.Descriptor(kind: Core.Runtime.Kind(rawValue: "future-runtime"), + displayName: "Future Runtime", + capabilities: [.containers]) + + #expect(descriptor.kind.rawValue == "future-runtime") + #expect(descriptor.executableName == nil) + #expect(descriptor.supports(.containers)) + #expect(!descriptor.supports(.imageBuild)) + #expect(throws: Core.Runtime.UnsupportedCapability.self) { + try descriptor.require(.imageBuild) + } + do { + try descriptor.require(.imageBuild) + } catch let error as Core.Runtime.UnsupportedCapability { + #expect(error.packageName == "ContainedCore") + #expect(error.packageErrorCode == "unsupportedRuntimeCapability") + #expect(error.packageErrorContext["kind"] == "future-runtime") + } + } + + @Test func commandErrorsExposePackageCodesAndContext() { + let error = Core.Command.Error.nonZeroExit(code: 42, stderr: "boom", command: "container list") + + #expect(error.packageName == "ContainedCore") + #expect(error.packageErrorCode == "nonZeroExit") + #expect(error.packageErrorContext["code"] == "42") + #expect(error.packageErrorContext["stderr"] == "boom") + #expect(error.packageErrorContext["command"] == "container list") + } + + @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 = Core.Runtime.Descriptor.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])) + #expect(descriptor.supports(.composeImport)) + #expect(!descriptor.supports(.coreMigration)) + try descriptor.require([.imageBuild, .imagePush, .registries]) + } + + @Test func appleModuleReadinessDistinguishesUnsupportedAndStopped() async { + let module = AppleContainerRuntimeModule() + let ready = await module.readiness( + cliURL: URL(fileURLWithPath: "/usr/local/bin/container"), + runner: CommandMapRunner(outputs: [ + ContainerCommands.version: .success(Data("container CLI version 1.0.0\n".utf8)), + ContainerCommands.systemStatus: .success(Data(#"{"status":"running"}"#.utf8)), + ]) + ) + + #expect(ready.kind == .appleContainer) + #expect(ready.version == "1.0.0") + #expect(ready.state == .ready) + + let unsupported = await module.readiness( + cliURL: URL(fileURLWithPath: "/usr/local/bin/container"), + runner: CommandMapRunner(outputs: [ + ContainerCommands.version: .success(Data("container CLI version 0.9.0\n".utf8)), + ]) + ) + + #expect(unsupported.version == "0.9.0") + #expect(unsupported.state == .unsupported) + + let stopped = await module.readiness( + cliURL: URL(fileURLWithPath: "/usr/local/bin/container"), + runner: CommandMapRunner(outputs: [ + ContainerCommands.version: .success(Data("container CLI version 1.0.0\n".utf8)), + ContainerCommands.systemStatus: .success(Data(#"{"status":"stopped"}"#.utf8)), + ]) + ) + + #expect(stopped.state == .endpointUnavailable) + } + + @Test func appleCreateTranslatorBuildsPreviewAndResult() { + var request = Core.Container.CreateRequest(runtimeKind: .appleContainer) + 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 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) + 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) + } + + @Test func appleClientConformsToRuntimeContainerClient() async throws { + let runner = MockCommandRunner(result: .success(try Fixture.data("list"))) + let runtime: any RuntimeContainerClient = 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: Core.Command.Error.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: Core.Command.Error.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 RuntimeContainerClient = AppleContainerClient(runner: runner) + var received: [[Core.Metrics.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"]) + } +} + +private struct CommandMapRunner: Core.Command.Running { + var outputs: [[String]: Result] + + func run(_ arguments: [String], + stdin: Data?, + priority: Core.Command.ExecutionPriority) async throws -> Data { + try (outputs[arguments] ?? .failure(.nonZeroExit( + code: 127, + stderr: "unexpected command: \(arguments.joined(separator: " "))", + command: arguments.joined(separator: " ") + ))).get() + } + + func stream(_ arguments: [String], + priority: Core.Command.ExecutionPriority) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } +} diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/Docker/DockerAdapterTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/Docker/DockerAdapterTests.swift new file mode 100644 index 00000000..02f669e1 --- /dev/null +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/Runtimes/Docker/DockerAdapterTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing +@testable import ContainedCore + +@Suite("Docker runtime adapter") +struct DockerAdapterTests { + @Test func dockerRuntimeDescriptorAdvertisesCurrentCapabilities() throws { + let descriptor = Core.Runtime.Descriptor.docker + + #expect(descriptor.kind == .docker) + #expect(descriptor.displayName == "Docker") + #expect(descriptor.executableName == "docker") + #expect(descriptor.supports([.containers, .images, .volumes, .networks])) + #expect(descriptor.supports([.systemStatus, .exec, .copy])) + #expect(!descriptor.supports(.systemLogs)) + #expect(!descriptor.supports(.dnsManagement)) + try descriptor.require([.imageBuild, .imagePush, .registries]) + } + + @Test func dockerCLIVersionParsing() { + #expect(DockerCLILocator.parseVersion("Docker version 27.3.1, build ce12230") == "27.3.1") + #expect(DockerCLILocator.parseVersion("no version here") == nil) + } + + @Test func dockerModuleReadinessDistinguishesEndpointFailure() async { + let module = DockerRuntimeModule() + let ready = await module.readiness( + cliURL: URL(fileURLWithPath: "/usr/local/bin/docker"), + runner: CommandMapRunner(outputs: [ + DockerCommands.version: .success(Data("Docker version 27.3.1, build ce12230\n".utf8)), + DockerCommands.systemStatus: .success(Data(#"{"ServerVersion":"27.3.1","NCPU":8}"#.utf8)), + ]) + ) + + #expect(ready.kind == .docker) + #expect(ready.version == "27.3.1") + #expect(ready.state == .ready) + + let unavailable = await module.readiness( + cliURL: URL(fileURLWithPath: "/usr/local/bin/docker"), + runner: CommandMapRunner(outputs: [ + DockerCommands.version: .success(Data("Docker version 27.3.1, build ce12230\n".utf8)), + DockerCommands.systemStatus: .failure(.nonZeroExit( + code: 1, + stderr: "Cannot connect to the Docker daemon", + command: "info" + )), + ]) + ) + + #expect(unavailable.version == "27.3.1") + #expect(unavailable.state == .endpointUnavailable) + } + + @Test func dockerModuleOwnsTerminalAndPreviewArgv() { + let module = DockerRuntimeModule() + var request = Core.Container.CreateRequest(runtimeKind: .docker) + request.image = "nginx:latest" + request.network = "host" + + #expect(module.terminalInvocation(containerID: "web", + shell: "/bin/sh", + cliURL: URL(fileURLWithPath: "/usr/local/bin/docker")).arguments + == ["container", "exec", "--interactive", "--tty", "web", "/bin/sh"]) + #expect(module.runPreview(for: request).contains("--network")) + #expect(module.buildPreview(context: ".", tag: "web:dev", dockerfile: nil, + buildArgs: [:], noCache: false, platform: nil) + == ["build", "--progress", "plain", "--tag", "web:dev", "."]) + } + + @Test func dockerSchemaProfileLivesWithRuntimeAdapter() throws { + let profile = DockerRuntimeModule().schemaProfile() + let definition = Core.Schema.Definition.containerRunEdit(runtimeProfile: profile) + let pullPolicy = try #require(definition.descriptor(for: .imagePullPolicy)) + let rosetta = try #require(definition.descriptor(for: .securityRosetta)) + + #expect(profile.kind == .docker) + #expect(pullPolicy.support(for: .docker).state == .supported) + #expect(rosetta.support(for: .docker).state == .disabled) + } +} + +private struct CommandMapRunner: Core.Command.Running { + var outputs: [[String]: Result] + + func run(_ arguments: [String], + stdin: Data?, + priority: Core.Command.ExecutionPriority) async throws -> Data { + try (outputs[arguments] ?? .failure(.nonZeroExit( + code: 127, + stderr: "unexpected command: \(arguments.joined(separator: " "))", + command: arguments.joined(separator: " ") + ))).get() + } + + func stream(_ arguments: [String], + priority: Core.Command.ExecutionPriority) -> AsyncThrowingStream { + AsyncThrowingStream { $0.finish() } + } +} diff --git a/Packages/ContainedCore/Tests/ContainedCoreTests/SchemaTests.swift b/Packages/ContainedCore/Tests/ContainedCoreTests/SchemaTests.swift new file mode 100644 index 00000000..63f7dc60 --- /dev/null +++ b/Packages/ContainedCore/Tests/ContainedCoreTests/SchemaTests.swift @@ -0,0 +1,316 @@ +import Foundation +import Testing +@testable import ContainedCore + +@Suite("Container run/edit schema") +struct SchemaTests { + @Test func appleSchemaPublishesCurrentRunFieldAliases() throws { + let definition = Core.Schema.Definition.containerRunEdit(runtimeKind: .appleContainer) + 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(runtimeKind: .appleContainer) + 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: .docker) + #expect(document.runtimeKind == .docker) + #expect(document.string(.runtimeKind) == Core.Runtime.Kind.docker.rawValue) + } + + @Test func schemaConformanceMigratorDoesNotNeedVersionGate() throws { + var document = Core.Schema.Document.containerCreate(runtimeKind: .appleContainer) + document.schemaVersion = Core.Schema.Version(999) + document.set(.imageReference, .string("alpine")) + document.set(.processCommand, .string("echo hello")) + + let definition = Core.Schema.Definition.containerRunEdit(runtimeKind: .appleContainer) + 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(runtimeKind: .appleContainer) + 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(runtimeKind: .appleContainer) + 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.containerRunEdit(runtimeKind: .appleContainer) + + #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)) + } + + @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(runtimeProfile: DockerRuntimeModule().schemaProfile(), + 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) { + if Array(haystack[start.. 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) +} +``` + +## Form Example + +```swift +UI.Form.Grouped { + Section("Runtime") { + UI.Form.Field(label: "Image", info: "The image reference to run.") { + TextField("nginx:latest", text: $image) + } + UI.Form.ToggleRow(title: "Run in background", + info: "Runs the container detached.", + isChanged: true, + isOn: $detached) + } +} +``` + +## Previews + +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 + +```sh +swift build --package-path Packages/ContainedUI +swift test --package-path Packages/ContainedUI +``` + +## Related Documentation + +- [Design System](../../Documentation/Architecture/Design-System.md) +- [Documentation Map](../../Documentation/Development/Documentation-Map.md) +- [Wiki sync map](../../Documentation/Wiki/File-Map.md) diff --git a/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift new file mode 100644 index 00000000..3ae96c43 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Buttons.swift @@ -0,0 +1,164 @@ +import SwiftUI + +/// Package-owned progress capsule for action slots that are temporarily busy. +public extension UI.Action { +struct ProgressCapsule: View { + public var controlSize: ControlSize + public var material: UI.Theme.WindowMaterial? + public var tintStyle: UI.Theme.ButtonTintStyle? + + public init(controlSize: ControlSize = .small, + material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil) { + self.controlSize = controlSize + self.material = material + self.tintStyle = tintStyle + } + + public var body: some View { + MaterialButton(singleItem: true, + material: material, + tintStyle: tintStyle) { + ProgressView() + .controlSize(controlSize) + .frame(width: UI.Tokens.Toolbar.buttonItemHeight, + height: UI.Tokens.Toolbar.buttonItemHeight) + } + } +} + +/// Prominence levels for package-owned text action buttons. +enum TextProminence { + case standard + case prominent +} + +/// Package-owned text action button for command rows and form footers. +struct TextButton: View { + public var title: String + public var systemName: String + public var help: String + public var role: ButtonRole? + public var prominence: UI.Action.TextProminence + public var controlSize: ControlSize + public var isEnabled: Bool + public var material: UI.Theme.WindowMaterial? + public var tintStyle: UI.Theme.ButtonTintStyle? + public var action: () -> Void + + public init(title: String, + systemName: String, + help: String? = nil, + role: ButtonRole? = nil, + prominence: UI.Action.TextProminence = .standard, + controlSize: ControlSize = .regular, + isEnabled: Bool = true, + material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil, + 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.material = material + self.tintStyle = tintStyle + self.action = action + } + + public var body: some View { + if material != nil || tintStyle != nil { + MaterialButton(singleItem: true, + material: material, + tintStyle: tintStyle) { + MaterialButtonItem(role: role, + help: help, + action: action) { + Label(title, systemImage: systemName) + } + } + .controlSize(controlSize) + .disabled(!isEnabled) + } else { + 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. +struct ToggleButton: View { + @Binding public var isOn: Bool + public var title: String + public var systemName: String + public var material: UI.Theme.WindowMaterial? + public var tintStyle: UI.Theme.ButtonTintStyle? + + public init(isOn: Binding, + title: String, + systemName: String, + material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil) { + self._isOn = isOn + self.title = title + self.systemName = systemName + self.material = material + self.tintStyle = tintStyle + } + + public var body: some View { + if material != nil || tintStyle != nil { + MaterialButton(singleItem: true, + material: material, + tintStyle: tintStyle) { + MaterialButtonItem(tint: isOn ? .accentColor : nil, + help: title, + action: { isOn.toggle() }) { + Label(title, systemImage: systemName) + } + } + } else { + Toggle(isOn: $isOn) { + Label(title, systemImage: systemName) + } + .toggleStyle(.button) + .buttonStyle(.glass) + .buttonBorderShape(.capsule) + } + } +} +} + +#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 new file mode 100644 index 00000000..a6cb28c4 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Groups.swift @@ -0,0 +1,214 @@ +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 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 var material: UI.Theme.WindowMaterial? + public var tintStyle: UI.Theme.ButtonTintStyle? + + public init(_ actions: [UI.Action.Item], + spacing: CGFloat = 0, + height: CGFloat = UI.Tokens.Toolbar.buttonGroupHeight, + minWidth: CGFloat? = nil, + singleItem: Bool? = nil, + interactive: Bool = true, + material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil) { + self.actions = actions + self.spacing = spacing + self.height = height + self.minWidth = minWidth + self.singleItem = singleItem + self.interactive = interactive + self.material = material + self.tintStyle = tintStyle + } + + public init(_ action: UI.Action.Item, + height: CGFloat = UI.Tokens.Toolbar.buttonGroupHeight, + minWidth: CGFloat? = nil, + interactive: Bool = true, + material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil) { + self.init([action], + height: height, + minWidth: minWidth, + singleItem: true, + interactive: interactive, + material: material, + tintStyle: tintStyle) + } + + public var body: some View { + MaterialButton(spacing: spacing, + height: height, + minWidth: minWidth, + singleItem: singleItem ?? (actions.count == 1), + interactive: interactive, + material: material, + tintStyle: tintStyle) { + UI.Action.Items(actions) + } + } +} + +/// Package-owned glass cluster for mixed content, such as a menu plus action items. +struct Cluster: View { + public var spacing: CGFloat + public var height: CGFloat + public var minWidth: CGFloat? + public var singleItem: Bool? + public var interactive: Bool + public var material: UI.Theme.WindowMaterial? + public var tintStyle: UI.Theme.ButtonTintStyle? + @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, + material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.height = height + self.minWidth = minWidth + self.singleItem = singleItem + self.interactive = interactive + self.material = material + self.tintStyle = tintStyle + self.content = content + } + + public var body: some View { + MaterialButton(spacing: spacing, + height: height, + minWidth: minWidth, + singleItem: singleItem ?? false, + interactive: interactive, + material: material, + tintStyle: tintStyle) { + content() + } + } +} +} + +#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.Action.InputCluster { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + Text("Search field") + .foregroundStyle(.secondary) + } + .frame(width: 240) + } + .padding(UI.Tokens.Space.xl) + .environment(\.buttonMaterial, .glassClear) +} + +/// Package-owned material input cluster for search fields and compact inline controls. +public extension UI.Action { +struct InputCluster: View { + public var material: UI.Theme.WindowMaterial? + public var tintStyle: UI.Theme.ButtonTintStyle? + @ViewBuilder public var content: () -> Content + + public init(material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil, + @ViewBuilder content: @escaping () -> Content) { + self.material = material + self.tintStyle = tintStyle + self.content = content + } + + public var body: some View { + MaterialButton(singleItem: true, + material: material, + tintStyle: tintStyle) { + MaterialButtonInputItem { + content() + } + } + } +} +} + +/// Package-owned action item renderer for mixed groups that also contain menus or status labels. +public extension UI.Action { +struct Items: View { + public var actions: [UI.Action.Item] + + public init(_ actions: [UI.Action.Item]) { + 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. +struct MenuLabel: 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..ca1a882d --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/Item.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// Semantic action description for package-owned glass action chrome. +public extension UI.Action { +struct Item { + 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 + } +} +} + +#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/MaterialButton.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/MaterialButton.swift new file mode 100644 index 00000000..02b370d4 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/MaterialButton.swift @@ -0,0 +1,265 @@ +import SwiftUI + +private struct MaterialButtonItemHoverEnabledKey: EnvironmentKey { + static let defaultValue = true +} + +private extension EnvironmentValues { + var materialButtonItemHoverEnabled: Bool { + get { self[MaterialButtonItemHoverEnabledKey.self] } + set { self[MaterialButtonItemHoverEnabledKey.self] = newValue } + } +} + +private struct OptionalAccessibilityLabel: ViewModifier { + var label: String + + func body(content: Content) -> some View { + if label.isEmpty { + content + } else { + content.accessibilityLabel(label) + } + } +} + +public extension UI.Theme { +struct ButtonTintStyle: Equatable, Sendable { + public var enabled = false + public var tint: UI.Theme.Tint = .multicolor + public var opacity = 0.18 + public var gradient = true + public var gradientAngle = 135.0 + public var blendMode: UI.Theme.ColorBlendMode = .softLight + + public init(enabled: Bool = false, + tint: UI.Theme.Tint = .multicolor, + opacity: Double = 0.18, + gradient: Bool = true, + gradientAngle: Double = 135.0, + blendMode: UI.Theme.ColorBlendMode = .softLight) { + self.enabled = enabled + self.tint = tint + self.opacity = opacity + self.gradient = gradient + self.gradientAngle = gradientAngle + self.blendMode = blendMode + } + + public static let disabled = UI.Theme.ButtonTintStyle() +} +} + +/// 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 = "" + var isCancel: Bool = false + var isLabel: Bool = false + var isIcon = false + var action: (() -> Void)? = nil + @ViewBuilder var label: () -> Label + + @State private var hovering = false + @Environment(\.materialButtonItemHoverEnabled) private var hoverEnabled + @Environment(\.colorScheme) private var colorScheme + + private var itemForegroundStyle: AnyShapeStyle { + if role == .destructive { return AnyShapeStyle(Color.red) } + if let tint { return AnyShapeStyle(tint) } + return AnyShapeStyle(.primary) + } + + 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 + self.tint = tint + self.help = help + self.isCancel = isCancel + self.isLabel = isLabel + self.isIcon = isIcon + self.action = action + self.label = label + } + + private var content: some View { + label() + .font(.body.weight(.medium)) + .foregroundStyle(itemForegroundStyle) + .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 + ? UI.Theme.Material.toolbarInteractiveHoverFill(for: colorScheme) + : .clear + ) + } + .onHover { hovering = isLabel ? false : $0 } + .animation(.easeOut(duration: 0.15), value: hovering) + } + + var body: some View { + Group { + if let action, !isLabel { + Button(role: role, action: action) { + content + } + .buttonStyle(.plain) + .buttonBorderShape(.capsule) + .tint(role == .destructive ? .red : tint) + } else { + content + } + } + .help(help) + .modifier(OptionalAccessibilityLabel(label: help)) + .keyboardShortcut(isCancel && action != nil ? .cancelAction : nil) + } +} + +/// 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 = UI.Tokens.Toolbar.searchIconGap, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.content = content + } + + var body: some View { + HStack(spacing: spacing) { content() } + .font(.body.weight(.medium)) + .padding(UI.Tokens.Toolbar.iconInnerPadding) + .frame(height: UI.Tokens.Toolbar.buttonItemHeight) + .contentShape(Rectangle()) + } +} + +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 + self.tint = tint + self.help = help + self.isCancel = isCancel + self.isLabel = isLabel + self.isIcon = true + self.action = action + self.label = { Image(systemName: systemName) } + } +} + +/// 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 MaterialButton: View { + var spacing: CGFloat = 0 + 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. + var interactive: Bool = true + var material: UI.Theme.WindowMaterial? + var tintStyle: UI.Theme.ButtonTintStyle? + @ViewBuilder var content: () -> Content + + init(spacing: CGFloat = 0, + height: CGFloat = UI.Tokens.Toolbar.buttonGroupHeight, + minWidth: CGFloat? = nil, + singleItem: Bool = false, + interactive: Bool = true, + material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.height = height + self.minWidth = minWidth + self.singleItem = singleItem + self.interactive = interactive + self.material = material + self.tintStyle = tintStyle + self.content = content + } + + @State private var hovering = false + @Environment(\.colorScheme) private var colorScheme + @Environment(\.buttonTintStyle) private var environmentTintStyle + + var body: some View { + let shape = Capsule(style: .continuous) + HStack(spacing: spacing) { content() } + .padding(.horizontal, UI.Tokens.Toolbar.iconInnerPadding) + .frame(height: height) + .frame(minWidth: minWidth) + .background { + if singleItem && interactive { + Capsule(style: .continuous) + .fill( + hovering + ? UI.Theme.Material.toolbarInteractiveHoverFill(for: colorScheme) + : .clear + ) + } + } + .environment(\.materialButtonItemHoverEnabled, !singleItem && interactive) + .onHover { if interactive { hovering = $0 } } + .background { tintLayer(in: shape) } + .toolbarControlMaterial(in: shape, material: material) + .animation(.spring(response: 0.18, dampingFraction: 0.82), value: hovering) + } + + @ViewBuilder + private func tintLayer(in shape: Capsule) -> some View { + let resolvedTintStyle = tintStyle ?? environmentTintStyle + if resolvedTintStyle.enabled { + shape + .fill(tintFillStyle(resolvedTintStyle.tint.color, + tintStyle: resolvedTintStyle)) + .blendMode(resolvedTintStyle.blendMode.blendMode) + .clipShape(shape) + } + } + + private func tintFillStyle(_ color: Color, tintStyle: UI.Theme.ButtonTintStyle) -> AnyShapeStyle { + if tintStyle.gradient { + let radians = tintStyle.gradientAngle * .pi / 180 + let dx = cos(radians) / 2 + let dy = sin(radians) / 2 + return AnyShapeStyle(LinearGradient( + colors: [color.opacity(tintStyle.opacity * 1.35), color.opacity(tintStyle.opacity * 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(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/Action/SelectionBar.swift b/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift new file mode 100644 index 00000000..81e18b54 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Action/SelectionBar.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// Package-owned floating selection action bar. +public extension UI.Action { +struct SelectionBar: View { + public var count: Int + public var countLabel: (Int) -> String + public var actions: [UI.Action.Item] + + public init(count: Int, + countLabel: @escaping (Int) -> String, + actions: [UI.Action.Item]) { + 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 + 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) + .padding(.vertical, UI.Tokens.Space.s) + .materialCapsuleSurface(shadow: false) + } +} +} + +#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/Badge/Chips.swift b/Packages/ContainedUI/Sources/ContainedUI/Badge/Chips.swift new file mode 100644 index 00000000..d3df2699 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Badge/Chips.swift @@ -0,0 +1,58 @@ +import SwiftUI + +public extension UI.Badge { +struct ScopeLabel: 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 { + 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)) + } + } + } +} +} + +public extension UI.Control { +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 { + 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/Card/Card+Scaffold.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/Card+Scaffold.swift new file mode 100644 index 00000000..468a18f1 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/Card+Scaffold.swift @@ -0,0 +1,346 @@ +import SwiftUI + +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 `UI.Card.Scaffold` when a card has no page controls. +enum NoPage: Hashable { + case none +} + +/// 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 + public var closeLabel: String + public var onSelect: (ID) -> Void + public var onClose: () -> Void + + public init(items: [UI.Card.Page], + 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 + } +} + +/// 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. +struct Scaffold: View { + public var size: UI.Card.Size + 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: UI.Theme.ColorBlendMode + public var elevated: Bool + public var onTap: () -> Void + public var title: String + public var subtitle: String? + 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 + @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: UI.Card.Size = .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: UI.Theme.ColorBlendMode = .softLight, + elevated: Bool = true, + onTap: @escaping () -> Void = {}, + title: String, + subtitle: String? = nil, + 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, + @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 { + CardSurface(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 { + CardHeader { + icon() + } content: { + CardHeaderTextBlock { + HStack(spacing: UI.Tokens.Space.s) { + titleText + titleAccessory() + } + } subtitle: { + if hasSubtitleRow { + HStack(spacing: UI.Tokens.Space.xs) { + subtitleAccessory() + if let subtitle, !subtitle.isEmpty { + subtitleText(subtitle) + } + } + } + } + } trailing: { + HStack(spacing: UI.Tokens.Space.s) { + if let pages { + CardPageControls(items: pages.items, + selection: pages.selection, + tint: pages.tint, + controlsReveal: pages.controlsReveal, + closeLabel: pages.closeLabel, + onSelect: pages.onSelect, + onClose: pages.onClose) + } + headerAccessory() + } + } + } + + @ViewBuilder + private var titleText: some View { + switch titleStyle { + case .standard: + UI.Card.TitleText(text: title) + case .monospaced: + UI.Card.MonospacedTitleText(text: title) + } + } + + @ViewBuilder + private func subtitleText(_ text: String) -> some View { + switch subtitleStyle { + case .standard: + UI.Card.SubtitleText(text: text) + case .monospaced: + UI.Card.MonospacedSubtitleText(text: text) + } + } + + private var hasSubtitleRow: Bool { + (subtitle?.isEmpty == false) || SubtitleAccessory.self != EmptyView.self + } +} +} + +public extension UI.Card.Scaffold where PageID == UI.Card.NoPage { + init(size: UI.Card.Size = .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: UI.Theme.ColorBlendMode = .softLight, + elevated: Bool = true, + onTap: @escaping () -> Void = {}, + title: String, + subtitle: String? = nil, + titleStyle: UI.Card.TextStyle = .standard, + subtitleStyle: UI.Card.TextStyle = .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) + } +} + +#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 new file mode 100644 index 00000000..76ebd2d0 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/Chrome.swift @@ -0,0 +1,664 @@ +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 CardHeader: View { + var spacing: CGFloat + var padding: CGFloat + @ViewBuilder var leading: () -> Leading + @ViewBuilder var content: () -> Content + @ViewBuilder var trailing: () -> Trailing + + 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) { + 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() + .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 `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 CardHeaderTextBlock: View { + var spacing: CGFloat + @ViewBuilder var title: () -> Title + @ViewBuilder var subtitle: () -> Subtitle + + init(spacing: CGFloat = UI.Tokens.Card.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 + } + } +} + +extension CardHeaderTextBlock where Subtitle == EmptyView { + init(spacing: CGFloat = UI.Tokens.Card.compactTextSpacing, + @ViewBuilder title: @escaping () -> Title) { + self.init(spacing: spacing, title: title) { + EmptyView() + } + } +} + +public extension UI.Card { +/// A small reusable footer mini: optional icon + optional text, aligned on one baseline. +struct FooterMini: View { + public var spacing: CGFloat + @ViewBuilder public var icon: () -> Icon + @ViewBuilder public var text: () -> TextContent + + public init(spacing: CGFloat = UI.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 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. +struct InsetSection: 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 = UI.Tokens.Space.s, + padding: CGFloat = UI.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: UI.Tokens.Space.s) { + if let title { + Text(title) + .font(.headline) + .padding(.leading, UI.Tokens.Space.xs) + } + LazyVStack(alignment: alignment, spacing: spacing) { + content() + } + .padding(padding) + .frame(maxWidth: .infinity, alignment: Alignment(horizontal: alignment, vertical: .center)) + .background(UI.Theme.Material.toolbarHoverFill, + in: RoundedRectangle(cornerRadius: UI.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. +struct FooterChip: 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) { + FooterMini { + icon() + } text: { + text() + } + .foregroundStyle(isSelected ? AnyShapeStyle(tint) : AnyShapeStyle(.secondary)) + } + .buttonStyle(.plain) + .help(help) + .accessibilityLabel(help) + } +} + +/// Shared icon-only action used by design-card footers. +struct FooterButton: 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) { + FooterMini { + 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) + } +} + +struct Page: 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. +struct CardPageControls: View { + var items: [Page] + var selection: ID + var tint: Color + var controlsReveal: Double + var closeLabel: String + var onSelect: (ID) -> Void + var onClose: () -> Void + + init(items: [Page], + 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 + } + + public var body: some View { + MaterialButton(singleItem: false) { + ForEach(items) { item in + MaterialButtonItem(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) + } + } + MaterialButtonItem(systemName: "xmark", help: closeLabel, action: onClose) + } + .opacity(controlsReveal) + .allowsHitTesting(controlsReveal > 0.01) + .animation(.easeOut(duration: 0.18), value: controlsReveal) + } +} + +/// A reusable footer item band that hugs its content and anchors either left or right. +struct FooterGroup: 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 = UI.Tokens.Card.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 horizontal group for content in a card widget band. +struct WidgetGroup: View { + public var spacing: CGFloat + @ViewBuilder public var content: () -> Content + + public init(spacing: CGFloat = UI.Tokens.Card.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. +struct CardFooter: 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 = UI.Tokens.Card.padding, + horizontalPadding: CGFloat = UI.Tokens.Card.padding, + topPadding: CGFloat = 0, + bottomPadding: CGFloat = UI.Tokens.Card.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) { + FooterGroup(alignment: .leading, spacing: spacing) { + leading() + } + FooterGroup(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) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct TitleText: 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) + } +} + +struct SubtitleText: 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) + } +} + +struct MonospacedSubtitleText: 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) + } +} + +struct MonospacedTitleText: 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) + } +} + +struct IconChip: 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 = UI.Tokens.Card.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: UI.Tokens.IconSize.chip, height: UI.Tokens.IconSize.chip) + .background(tint.opacity(backgroundOpacity), + in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.iconChip, style: .continuous)) + } +} +} + +/// Small capsule count/state badge used in section headers and compact metadata rows. +public extension UI.Badge { +struct Text: 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 { + SharedCapsuleLabel(horizontalPadding: UI.Tokens.Space.s, + verticalPadding: UI.Tokens.Badge.verticalPadding, + foreground: AnyShapeStyle(foreground), + fill: AnyShapeStyle(.quaternary)) { + SwiftUI.Text(text) + .font(font) + } + } +} +} + +/// Flat selectable row for lists inside panels and sheets. +public extension UI.List { +struct Row: 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 { + 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() + } + .materialSurface(.ultraThin, cornerRadius: UI.Tokens.Radius.control) + } +} + +struct RowChevron: View { + public init() {} + + public var body: some View { + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } +} +} + +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, + tint: tint, + title: title, + subtitle: subtitle, + monospacedSubtitle: monospacedSubtitle) { + UI.List.RowChevron() + } + } +} + +public extension UI.Card { +struct MetricText: View { + public let text: String + + public init(text: String) { + self.text = text + } + + public var body: some View { + Text(text) + .font(.caption.weight(.medium)) + .monospacedDigit() + .contentTransition(.numericText()) + } +} +} + +public extension View { + @ViewBuilder + func designCardFloatingControls( + when isVisible: Bool, + @ViewBuilder controls: @escaping () -> Controls + ) -> some View { + overlay(alignment: .topTrailing) { + if isVisible { + controls() + .fixedSize(horizontal: true, vertical: false) + .padding(UI.Tokens.Space.s) + .zIndex(1) + } + } + } + + @ViewBuilder + func designCardProgressOverlay(when isBusy: Bool) -> some View { + overlay { + if isBusy { + ProgressView() + .controlSize(.small) + } + } + } +} + +#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/Sources/Contained/DesignSystem/ResourceGlassCard.swift b/Packages/ContainedUI/Sources/ContainedUI/Card/Surface.swift similarity index 54% rename from Sources/Contained/DesignSystem/ResourceGlassCard.swift rename to Packages/ContainedUI/Sources/ContainedUI/Card/Surface.swift index e858a1b4..0eba9f81 100644 --- a/Sources/Contained/DesignSystem/ResourceGlassCard.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/Card/Surface.swift @@ -1,47 +1,69 @@ import SwiftUI -enum ResourceCardSize { +public extension UI.Card { +enum Size { case small, medium, large - var showsFooter: Bool { self != .small } - 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 } } -enum ResourceCardExpandedMetrics { - static let maxWidth: CGFloat = 760 +enum ExpandedMetrics { + public static let maxWidth: CGFloat = 760 } -struct CardSizePicker: View { - @Binding var selection: CardDensity +struct SizePicker: View { + @Binding var selection: UI.Card.Density + public var title: String + public var labelForDensity: (UI.Card.Density) -> String - var body: some View { - Picker("Card size", selection: $selection) { - ForEach(CardDensity.allCases) { density in - Text(density.displayName).tag(density) + public init(selection: Binding, + title: String, + labelForDensity: @escaping (UI.Card.Density) -> String) { + self._selection = selection + self.title = title + self.labelForDensity = labelForDensity + } + + public var body: some View { + Picker(title, selection: $selection) { + ForEach(UI.Card.Density.allCases) { density in + Text(labelForDensity(density)).tag(density) } } .pickerStyle(.segmented) .frame(width: 230) } } +} -struct ResourceGlassCard: View { - var size: ResourceCardSize + var size: UI.Card.Size var isExpanded = false var cornerRadiusOverride: CGFloat? var controlsVisible = true var isSelected = false + var showsFooter = true + var showsWidget = true /// When set, the selected state reads as a soft `white.opacity` wash (matching a hovered glass - /// button) instead of the 2.5pt accent stroke — used by the Activity and command-palette rows. + /// button) instead of the 2.5pt accent stroke, useful for dense action rows. var usesSelectionFill = false var fill: Color? var fillOpacity: Double = 0.18 var gradient: Bool = false var gradientAngle: Double = 135 - var blendMode: ColorLayerBlendMode = .softLight - /// Lift the card with a shadow. Pass `false` for flat tiles inside an already-elevated panel - /// (e.g. the toolbar Images/Activity morph panels), matching the creation-menu tile style. + var blendMode: UI.Theme.ColorBlendMode = .softLight + /// Lift the card with a shadow. Pass `false` for flat tiles inside an already-elevated panel. var elevated: Bool = true var onTap: () -> Void = {} @ViewBuilder var header: () -> Header @@ -60,16 +82,18 @@ struct ResourceGlassCard Void = {}, @ViewBuilder header: @escaping () -> Header, @@ -82,6 +106,8 @@ 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 size == .small { - footer(showWidget: false, showActions: controlsVisible) + if shouldEmbedWidgetInBody { + embeddedWidget + } + if shouldEmbedFooterInBody { + stickyFooter(showActions: controlsVisible) + .layoutPriority(1) } } } - 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 { + UI.Card.CardFooter(actionsVisible: showActions) { footerLeading() } trailing: { footerActions() } widget: { - widget() - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 10) + EmptyView() } } + + private var widgetBand: some View { + widget() + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, UI.Tokens.Card.padding) + .padding(.bottom, UI.Tokens.Card.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 { - var material: WindowMaterial +private struct CardMaterialSurface: ViewModifier { + var material: UI.Theme.WindowMaterial var cornerRadius: CGFloat var shadow: Bool var fill: Color? var fillOpacity: Double var gradient: Bool var gradientAngle: Double - var blendMode: ColorLayerBlendMode + var blendMode: UI.Theme.ColorBlendMode @Environment(\.colorScheme) private var colorScheme func body(content: Content) -> some View { @@ -218,7 +278,7 @@ private struct ResourceCardMaterialSurface: ViewModifier { if let glass = material.glass { Color.clear.glassEffect(glass, in: shape) } else { - VisualEffectBackground(material: material.nsMaterial, blendingMode: .withinWindow) + VisualEffectBackground(material: material, blendingMode: .withinWindow) } fillLayer(shape) @@ -231,40 +291,43 @@ private struct ResourceCardMaterialSurface: 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 } } +public extension View { + @ViewBuilder + func designCardSelectionOverlay(when isSelected: Bool) -> some View { + overlay { + if isSelected { + RoundedRectangle(cornerRadius: UI.Tokens.Radius.card, style: .continuous) + .fill(UI.Theme.Material.toolbarHoverFill) + .allowsHitTesting(false) + } + } + } +} + private extension View { - func resourceCardMaterial(_ material: WindowMaterial, + func designCardMaterial(_ material: UI.Theme.WindowMaterial, cornerRadius: CGFloat, shadow: Bool, fill: Color?, fillOpacity: Double, gradient: Bool, gradientAngle: Double, - blendMode: ColorLayerBlendMode) -> some View { - modifier(ResourceCardMaterialSurface(material: material, + blendMode: UI.Theme.ColorBlendMode) -> some View { + modifier(CardMaterialSurface(material: material, cornerRadius: cornerRadius, shadow: shadow, fill: fill, @@ -275,15 +338,15 @@ private extension View { } } -extension ResourceGlassCard where BodyContent == EmptyView, FooterLeading == EmptyView, +extension CardSurface where BodyContent == EmptyView, FooterLeading == EmptyView, FooterActions == EmptyView, Widget == EmptyView { - init(size: ResourceCardSize = .small, + init(size: UI.Card.Size = .small, isSelected: Bool = false, fill: Color? = nil, fillOpacity: Double = 0.18, gradient: Bool = false, gradientAngle: Double = 135, - blendMode: ColorLayerBlendMode = .softLight, + blendMode: UI.Theme.ColorBlendMode = .softLight, elevated: Bool = true, onTap: @escaping () -> Void = {}, @ViewBuilder header: @escaping () -> Header) { @@ -304,8 +367,8 @@ extension ResourceGlassCard where BodyContent == EmptyView, FooterLeading == Emp } } -extension ResourceGlassCard where BodyContent == EmptyView, Widget == EmptyView { - init(size: ResourceCardSize, +extension CardSurface where BodyContent == EmptyView, Widget == EmptyView { + init(size: UI.Card.Size, isExpanded: Bool = false, controlsVisible: Bool = true, isSelected: Bool = false, @@ -313,7 +376,7 @@ extension ResourceGlassCard where BodyContent == EmptyView, Widget == EmptyView fillOpacity: Double = 0.18, gradient: Bool = false, gradientAngle: Double = 135, - blendMode: ColorLayerBlendMode = .softLight, + blendMode: UI.Theme.ColorBlendMode = .softLight, elevated: Bool = true, onTap: @escaping () -> Void = {}, @ViewBuilder header: @escaping () -> Header, @@ -338,8 +401,8 @@ extension ResourceGlassCard where BodyContent == EmptyView, Widget == EmptyView } } -extension ResourceGlassCard where Widget == EmptyView { - init(size: ResourceCardSize, +extension CardSurface where Widget == EmptyView { + init(size: UI.Card.Size, isExpanded: Bool = false, controlsVisible: Bool = true, isSelected: Bool = false, @@ -347,7 +410,7 @@ extension ResourceGlassCard where Widget == EmptyView { fillOpacity: Double = 0.18, gradient: Bool = false, gradientAngle: Double = 135, - blendMode: ColorLayerBlendMode = .softLight, + blendMode: UI.Theme.ColorBlendMode = .softLight, elevated: Bool = true, onTap: @escaping () -> Void = {}, @ViewBuilder header: @escaping () -> Header, @@ -372,3 +435,72 @@ extension ResourceGlassCard 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) +} + +#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/Sparkline.swift b/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift new file mode 100644 index 00000000..31626e2a --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Chart/Sparkline.swift @@ -0,0 +1,384 @@ +import SwiftUI +import Charts + +public extension UI.Chart { +enum Style { + 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) + } +} + +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 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 + } + } +} + +enum Interpolation: String, CaseIterable, Identifiable, Codable, Sendable { + case linear, catmullRom, cardinal, monotone, stepStart, stepCenter, stepEnd + + public var id: String { rawValue } + +} + +enum Scale: 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. +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: UI.Chart.GraphStyle = .area + public var areaUsesGradient = true + public var interpolation: UI.Chart.Interpolation = .linear + public var pointSize: CGFloat = 18 + public var barWidth: CGFloat = 4 + 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: UI.Chart.GraphStyle = .area, + areaUsesGradient: Bool = true, + interpolation: UI.Chart.Interpolation = .linear, + pointSize: CGFloat = 18, + barWidth: CGFloat = 4, + scale: UI.Chart.Scale = .normalized, + comparisonScale: UI.Chart.Scale? = nil) { + 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 + self.scale = scale + self.comparisonScale = comparisonScale ?? scale + } + + public var body: some View { + let plotted = plottedSamples(samples) + Group { + if plotted.count > 1 { + chart + } else { + baseline + } + } + .accessibilityHidden(true) + } + + 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) + } + } + } + .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 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 var areaFillStyle: AnyShapeStyle { + if areaUsesGradient { + return AnyShapeStyle( + LinearGradient(colors: [color.opacity(0.25), color.opacity(0.02)], + startPoint: .top, + endPoint: .bottom) + ) + } + return AnyShapeStyle(color.opacity(0.22)) + } + + private var primaryPoints: [ChartPoint] { + chartPoints(for: samples, scale: scale) + } + + private var secondaryPoints: [ChartPoint] { + chartPoints(for: comparisonSamples, scale: comparisonScale) + } + + 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 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) + return scaled.enumerated().map { offset, value in + ChartPoint(index: startIndex + offset, value: value) + } + } + + private func plottedSamples(_ values: [Double]) -> [Double] { + SparklineSeriesScaling.paddedWindow(values, capacity: Self.maximumPlottedSamples) + } +} +} + +enum SparklineSeriesScaling { + private static let minimumCeiling = 0.0001 + + 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 + } + + static func normalized(_ values: [Double]) -> [Double] { + let ceiling = displayCeiling(for: values) + return values.map { min(max(sanitizedSample($0) / ceiling, 0), 1) } + } + + static func fractions(_ values: [Double]) -> [Double] { + values.map { min(max(sanitizedSample($0), 0), 1) } + } + + static func scaled(_ values: [Double], mode: UI.Chart.Scale) -> [Double] { + switch mode { + case .normalized: return normalized(values) + case .fraction: return fractions(values) + } + } + + 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 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 struct ChartRangePoint: Identifiable { + let index: Int + let low: Double + let high: Double + var id: Int { index } +} + +private extension UI.Chart.Interpolation { + 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 + } + } +} + +/// A fixed-size ring buffer for sparkline history. +public extension UI.Chart { +struct SampleBuffer: Sendable, Equatable { + public private(set) var values: [Double] = [] + public let capacity: Int + + public init(capacity: Int = 40) { self.capacity = capacity } + + public mutating func append(_ value: Double) { + values.append(value) + if values.count > capacity { values.removeFirst(values.count - capacity) } + } +} +} + +#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 new file mode 100644 index 00000000..d1600789 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Chart/SparklineMetricTile.swift @@ -0,0 +1,71 @@ +import SwiftUI + +/// A dashboard summary tile: muted label, large value, optional symbol and sparkline. +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: UI.Chart.Scale = .normalized + + public init(label: String, + value: String, + systemImage: String? = nil, + tint: Color = .accentColor, + samples: [Double]? = nil, + sparklineScale: UI.Chart.Scale = .normalized) { + self.label = label + self.value = value + self.systemImage = systemImage + self.tint = tint + self.samples = samples + self.sparklineScale = sparklineScale + } + + public var body: some View { + VStack(alignment: .leading, spacing: UI.Tokens.Space.s) { + HStack(spacing: UI.Tokens.Space.s) { + if let systemImage { + Image(systemName: systemImage) + .font(.body.weight(.medium)) + .foregroundStyle(tint) + } + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + Text(value) + .font(.title.weight(.semibold)) + .contentTransition(.numericText()) + if let samples { + UI.Chart.Sparkline(samples: samples, color: tint, scale: sparklineScale) + .frame(height: 22) + } + } + .padding(UI.Tokens.Space.l) + .frame(maxWidth: .infinity, alignment: .leading) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card, fill: tint, fillOpacity: 0.10) + } +} +} + +#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 new file mode 100644 index 00000000..e80e09bd --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Command/PreviewBar.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// The signature "Reveal CLI" strip: shows the exact runtime command an action will run, +/// copyable to the clipboard. Drives user trust and learning. +public extension UI.Command { + struct PreviewBar: View { + public let commandText: String + public var copyHelp: String + public var copiedAccessibilityLabel: String + @ViewBuilder public var actions: () -> Actions + + public init(commandText: String, + copyHelp: String, + copiedAccessibilityLabel: String, + @ViewBuilder actions: @escaping () -> Actions) { + self.commandText = commandText + self.copyHelp = copyHelp + self.copiedAccessibilityLabel = copiedAccessibilityLabel + self.actions = actions + } + + public var body: some View { + HStack(spacing: UI.Tokens.Space.s) { + Image(systemName: "terminal") + .foregroundStyle(.primary) + ScrollView(.horizontal, showsIndicators: false) { + Text(commandText) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .copyable([commandText]) + .lineLimit(1) + } + Spacer(minLength: UI.Tokens.Space.s) + UI.Copy.Icon(value: commandText, help: copyHelp) + actions() + } + .padding(.horizontal, UI.Tokens.Space.s) + .padding(.vertical, UI.Tokens.Space.s) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card, shadow: false) + } + } +} + +public extension UI.Command.PreviewBar where Actions == EmptyView { + init(commandText: String, + copyHelp: String, + copiedAccessibilityLabel: String) { + self.init(commandText: commandText, + copyHelp: copyHelp, + copiedAccessibilityLabel: copiedAccessibilityLabel) { + EmptyView() + } + } +} + +#Preview("Command Preview Bar") { + UI.Command.PreviewBar(commandText: "container run --name preview-web nginx", + copyHelp: "Copy command", + copiedAccessibilityLabel: "Copied") { + UI.Action.TextButton(title: "Run", systemName: "play.fill", prominence: .prominent) {} + } + .padding(UI.Tokens.Space.xl) + .frame(width: 520) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Console/StreamConsole.swift b/Packages/ContainedUI/Sources/ContainedUI/Console/StreamConsole.swift new file mode 100644 index 00000000..7ee50d95 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Console/StreamConsole.swift @@ -0,0 +1,149 @@ +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() + UI.Copy.Icon(value: lines.joined(separator: "\n"), help: copyLogHelp) + } + .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) } + } + } +} + +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) + } +} + +#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) +} + +#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/ContainedUI.docc/ContainedUI.md b/Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md new file mode 100644 index 00000000..cb981a50 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/ContainedUI.docc/ContainedUI.md @@ -0,0 +1,97 @@ +# ``ContainedUI`` + +Reusable visual building blocks for Contained and related macOS SwiftUI apps. + +## Overview + +`ContainedUI` exposes a nested `UI.*` API for app-neutral cards, panels, +native form rows, 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. + +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 + +- `UI.Card` +- `UI.Panel` +- `UI.Form.Grouped` plus rows, fields, toggles, info buttons, and error/changed label states +- `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.Form.Grouped { + Section("Runtime") { + UI.Form.Field(label: "Image", info: "The image reference to run.") { + TextField("nginx:latest", text: $image) + } + UI.Form.ToggleRow(title: "Run in background", + isChanged: true, + isOn: $detached) + } +} +``` + +```swift +UI.Chart.Sparkline(samples: samples, + color: .accentColor, + scale: .fraction) + .frame(height: UI.Card.Metric.sparklineHeight) +``` + +## Related Documentation + +- [Design System](../../../../../Documentation/Architecture/Design-System.md) +- [Wiki sync map](../../../../../Documentation/Wiki/File-Map.md) diff --git a/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift new file mode 100644 index 00000000..dabf2eb3 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/GradientAngleControl.swift @@ -0,0 +1,78 @@ +import SwiftUI + +/// A 360° gradient-direction control: a draggable dial plus a degree readout. +public extension UI.Control { +struct GradientAngle: View { + @Binding var angle: Double + public var title: String + + public init(angle: Binding, title: String) { + self._angle = angle + self.title = title + } + + public var body: some View { + LabeledContent(title) { + HStack(spacing: UI.Tokens.Space.m) { + AngleDial(angle: $angle) + .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: UI.Tokens.InlineControl.gradientReadout) + } + } + } +} +} + +/// A small dial knob whose pointer reflects the gradient angle; drag to set. +private struct AngleDial: View { + @Binding var angle: Double + + var body: some View { + GeometryReader { geo in + 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 - UI.Tokens.InlineControl.gradientKnobInset), + y: center.y + sin(radians) * (radius - UI.Tokens.InlineControl.gradientKnobInset)) + ZStack { + Circle() + .strokeBorder(.secondary.opacity(UI.Tokens.InlineControl.gradientStrokeOpacity), + lineWidth: UI.Tokens.Space.hairline) + Circle() + .fill(.tint) + .frame(width: UI.Tokens.InlineControl.gradientKnob, + height: UI.Tokens.InlineControl.gradientKnob) + .position(knob) + } + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let dx = value.location.x - center.x + let dy = value.location.y - center.y + var deg = atan2(dy, dx) * 180 / .pi + if deg < 0 { deg += 360 } + angle = deg + } + ) + } + } +} + +#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 new file mode 100644 index 00000000..3bd31a56 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/InfoButton.swift @@ -0,0 +1,59 @@ +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, blendingMode: .withinWindow) + } + .presentationBackground(.clear) + } + } + } + } +} + +#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 new file mode 100644 index 00000000..89e6bb6a --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/KeyboardHints.swift @@ -0,0 +1,50 @@ +import SwiftUI + +public extension UI.Control { +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)) + } +} + +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) { + UI.Control.KeyCap(key) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} +} + +#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/MetricTile.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/MetricTile.swift new file mode 100644 index 00000000..be05a6dd --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/MetricTile.swift @@ -0,0 +1,47 @@ +import SwiftUI + +public extension UI.Control { +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(UI.Theme.Material.toolbarHoverFill, + in: RoundedRectangle(cornerRadius: UI.Tokens.Radius.control, + style: .continuous)) + } +} +} + +#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/Control/OptionTile.swift b/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift new file mode 100644 index 00000000..eab30232 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/OptionTile.swift @@ -0,0 +1,133 @@ +import SwiftUI + +public extension UI.Control { +struct OptionStack: View { + public var spacing: CGFloat + @ViewBuilder public var content: () -> Content + + public init(spacing: CGFloat = UI.Tokens.Space.s, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.content = content + } + + public var body: some View { + GlassEffectContainer(spacing: spacing) { + LazyVStack(spacing: spacing) { + content() + } + } + } +} + +struct OptionTile: View { + public static let defaultHeight: CGFloat = 100 + + 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 + + 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() + } label: { + 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: UI.Tokens.Space.s) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.callout.weight(.medium)) + .foregroundStyle(enabled ? .primary : .secondary) + .lineLimit(1) + + Text(subtitle ?? " ") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .opacity(subtitle == nil ? 0 : 1) + .accessibilityHidden(subtitle == nil) + } + } + .padding(UI.Tokens.Space.m) + .frame(maxWidth: .infinity, minHeight: height, maxHeight: height, alignment: .leading) + .designOptionTileSurface(cornerRadius: UI.Tokens.Radius.card, interactive: enabled) + .contentShape(RoundedRectangle(cornerRadius: UI.Tokens.Radius.card, style: .continuous)) + .optionalMatchedGeometry(id: matchedID, namespace: matchedNamespace) + } + .buttonStyle(.plain) + .disabled(!enabled) + .opacity(enabled ? 1 : 0.5) + .accessibilityLabel(title) + .accessibilityHint(subtitle ?? "") + } +} +} + +private struct OptionalMatchedGeometry: ViewModifier { + var id: String? + var namespace: Namespace.ID? + + @ViewBuilder + func body(content: Content) -> some View { + if let id, let namespace { + content.matchedGeometryEffect(id: id, in: namespace, properties: .frame) + } else { + content + } + } +} + +private extension View { + func optionalMatchedGeometry(id: String?, namespace: Namespace.ID?) -> some View { + modifier(OptionalMatchedGeometry(id: id, namespace: namespace)) + } + + func designOptionTileSurface(cornerRadius: CGFloat, interactive: Bool) -> some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + return self + .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 new file mode 100644 index 00000000..68f88d29 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/RowMenu.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// 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 extension UI.Control { +struct RowMenu: View { + public var systemImage: String + public var accessibilityLabel: String + @ViewBuilder public var content: () -> Content + + public init(systemImage: String = "ellipsis", + accessibilityLabel: String, + @ViewBuilder content: @escaping () -> Content) { + self.systemImage = systemImage + self.accessibilityLabel = accessibilityLabel + self.content = content + } + + public var body: some View { + Menu { + content() + } label: { + Image(systemName: systemImage) + .font(.body.weight(.medium)) + .frame(width: UI.Tokens.IconSize.rowMenu, height: UI.Tokens.IconSize.rowMenu) + } + .menuStyle(.button) + .buttonStyle(.glass) + .buttonBorderShape(.circle) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityLabel(accessibilityLabel) + } +} +} + +#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 new file mode 100644 index 00000000..c26b1d04 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/SearchField.swift @@ -0,0 +1,62 @@ +import SwiftUI + +public extension UI.Control { +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 { + UI.Action.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) + } +} +} + +#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 new file mode 100644 index 00000000..84c7e40d --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Control/TintSelector.swift @@ -0,0 +1,77 @@ +import SwiftUI + +/// 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 automaticLabel: String? + private let labelForTint: (UI.Theme.Tint) -> String + + public init(selection: Binding, + labelForTint: @escaping (UI.Theme.Tint) -> 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 (UI.Theme.Tint) -> 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(UI.Theme.Tint.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 { + 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 { + 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/Form/GroupedForm.swift b/Packages/ContainedUI/Sources/ContainedUI/Form/GroupedForm.swift new file mode 100644 index 00000000..003e05a2 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Form/GroupedForm.swift @@ -0,0 +1,21 @@ +import SwiftUI + +public extension UI.Form { +struct Grouped: View { + @ViewBuilder public var content: () -> Content + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + Form { + content() + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .background(Color.clear) + } +} +} + diff --git a/Packages/ContainedUI/Sources/ContainedUI/Form/Rows.swift b/Packages/ContainedUI/Sources/ContainedUI/Form/Rows.swift new file mode 100644 index 00000000..017fa704 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Form/Rows.swift @@ -0,0 +1,211 @@ +import SwiftUI + +public extension UI.Form { +enum LabelState: Equatable { + case normal + case changed + case invalid + + var foregroundStyle: Color { + switch self { + case .normal: .primary + case .changed: .blue + case .invalid: .red + } + } +} + +struct Row: View { + public var title: String + public var subtitle: String? + public var info: String? + public var error: String? + public var isChanged: Bool + @ViewBuilder public var trailing: () -> Trailing + + @State private var labelHovering = false + + public init(title: String, + subtitle: String? = nil, + info: String? = nil, + error: String? = nil, + isChanged: Bool = false, + @ViewBuilder trailing: @escaping () -> Trailing) { + self.title = title + self.subtitle = subtitle + self.info = info + self.error = error + self.isChanged = isChanged + self.trailing = trailing + } + + public var body: some View { + VStack(alignment: .leading, spacing: UI.Tokens.Space.xxs) { + HStack(spacing: UI.Tokens.Space.m) { + labelStack + Spacer(minLength: UI.Tokens.Space.m) + trailing() + } + .contentShape(Rectangle()) + .onHover { labelHovering = $0 } + + if let error { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + .frame(maxWidth: .infinity) + } + + private var labelStack: some View { + VStack(alignment: .leading, spacing: UI.Tokens.Space.xxs) { + label + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var label: some View { + HStack(spacing: UI.Tokens.Space.xs) { + Text(title) + .foregroundStyle(labelState.foregroundStyle) + if let info { + UI.Control.InfoButton(info, visible: labelHovering) + } + } + } + + private var labelState: UI.Form.LabelState { + if error != nil { return .invalid } + if isChanged { return .changed } + return .normal + } +} +} + +public extension UI.Form.Row where Trailing == EmptyView { + init(title: String, subtitle: String? = nil, info: String? = nil, error: String? = nil, isChanged: Bool = false) { + self.init(title: title, subtitle: subtitle, info: info, error: error, isChanged: isChanged) { EmptyView() } + } +} + +public extension UI.Form { +struct ToggleRow: View { + public var title: String + public var subtitle: String? + public var info: String? + public var error: String? + public var isChanged: Bool + @Binding public var isOn: Bool + + public init(title: String, + subtitle: String? = nil, + info: String? = nil, + error: String? = nil, + isChanged: Bool = false, + isOn: Binding) { + self.title = title + self.subtitle = subtitle + self.info = info + self.error = error + self.isChanged = isChanged + self._isOn = isOn + } + + public var body: some View { + UI.Form.Row(title: title, subtitle: subtitle, info: info, error: error, isChanged: isChanged) { + Toggle("", isOn: $isOn) + .labelsHidden() + .toggleStyle(.switch) + } + } +} + +struct Field: View { + public var label: String + public var info: String? + public var error: String? + public var isChanged: Bool + public var labelWidth: CGFloat + @ViewBuilder public var control: () -> Control + + @State private var labelHovering = false + + public init(label: String, + info: String? = nil, + error: String? = nil, + isChanged: Bool = false, + labelWidth: CGFloat = 124, + @ViewBuilder control: @escaping () -> Control) { + self.label = label + self.info = info + self.error = error + self.isChanged = isChanged + self.labelWidth = labelWidth + self.control = control + } + + public var body: some View { + VStack(alignment: .leading, spacing: UI.Tokens.Space.xxs) { + HStack(spacing: UI.Tokens.Space.m) { + fieldLabel + .frame(width: labelWidth, alignment: .leading) + control() + .frame(maxWidth: .infinity) + } + .contentShape(Rectangle()) + .onHover { labelHovering = $0 } + + if let error { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .padding(.leading, labelWidth + UI.Tokens.Space.m) + } + } + } + + private var fieldLabel: some View { + HStack(spacing: UI.Tokens.Space.xs) { + Text(label) + .foregroundStyle(labelState.foregroundStyle) + if let info { + UI.Control.InfoButton(info, visible: labelHovering) + } + } + } + + private var labelState: UI.Form.LabelState { + if error != nil { return .invalid } + if isChanged { return .changed } + return .normal + } +} +} + +#Preview("Form Rows") { + @Previewable @State var enabled = true + @Previewable @State var name = "nginx" + + UI.Form.Grouped { + Section("Essentials") { + UI.Form.Field(label: "Image", info: "Container image reference.") { + TextField("nginx:latest", text: $name) + } + UI.Form.ToggleRow(title: "Run in background", + info: "Maps to detached runtime execution.", + isChanged: true, + isOn: $enabled) + UI.Form.Row(title: "Status", subtitle: "Ready to run") { + Text("Ready") + .foregroundStyle(.secondary) + } + } + } + .frame(width: 420, height: 260) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/List/ListRows.swift b/Packages/ContainedUI/Sources/ContainedUI/List/ListRows.swift new file mode 100644 index 00000000..f3ff27ad --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/List/ListRows.swift @@ -0,0 +1,240 @@ +import SwiftUI + +public extension UI.List { +struct Stack: 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) + } +} + +struct Section: 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) { + UI.State.SectionLabel(title) + content() + } + } +} + +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 { + SharedIconTextRow(systemImage: systemImage, + tint: tint, + subtitle: subtitle, + action: action) { + Text(title) + .font(isMonospaced ? .system(.callout, design: .monospaced) : .callout) + .lineLimit(1) + } accessory: { + accessory() + } + } +} +} + +#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, + 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 extension UI.List { +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 { + 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) + } + } + } accessory: { + accessory() + } + } +} + +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 + } + } +} + +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/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift new file mode 100644 index 00000000..eca0bde8 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/PageScaffold.swift @@ -0,0 +1,85 @@ +import SwiftUI + +public extension EnvironmentValues { + @Entry var pageScaffoldUsesToolbarChrome = false + @Entry var pageScaffoldBottomClearance: CGFloat = 0 +} + +public extension UI.Panel { +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 { + UI.Panel.Header(symbol: symbol, title: title, subtitle: subtitle) { + actions() + } + Divider() + } + if scrolls { + ScrollView { + LazyVStack(spacing: 0) { + content() + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(usesToolbarChrome ? UI.Tokens.Space.s : UI.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 UI.Panel.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) + } +} + +#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 new file mode 100644 index 00000000..190d5bbb --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/Panel+Scaffold.swift @@ -0,0 +1,95 @@ +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, +/// `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 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 + 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` + /// is false, in which case the content is placed directly and owns its own scrolling). + @ViewBuilder var content: () -> Content + /// Optional fixed chrome pinned below the scroll area (submit bars, command preview). + @ViewBuilder var footer: () -> Footer + + public init(width: CGFloat, + scrollEdgeStyle: ScrollEdgeEffectStyle = .soft, + scrolls: Bool = true, + @ViewBuilder chrome: @escaping () -> Chrome, + @ViewBuilder content: @escaping () -> Content, + @ViewBuilder footer: @escaping () -> Footer) { + self.width = width + 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 { + ScrollView { + LazyVStack(spacing: 0) { + content() + .frame(maxWidth: .infinity) + } + } + .scrollEdgeEffectStyle(scrollEdgeStyle, for: .all) + } else { + content() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + footer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} +} + +public extension UI.Panel.Scaffold where Footer == EmptyView { + init(width: CGFloat, + scrollEdgeStyle: ScrollEdgeEffectStyle = .soft, + scrolls: Bool = true, + @ViewBuilder chrome: @escaping () -> Chrome, + @ViewBuilder content: @escaping () -> Content) { + self.init(width: width, scrollEdgeStyle: scrollEdgeStyle, scrolls: scrolls, + 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 new file mode 100644 index 00000000..03de60aa --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/Section.swift @@ -0,0 +1,283 @@ +import SwiftUI + +private struct PanelSectionHighlightedKey: EnvironmentKey { + static let defaultValue = false +} + +private extension EnvironmentValues { + var panelSectionHighlighted: Bool { + get { self[PanelSectionHighlightedKey.self] } + set { self[PanelSectionHighlightedKey.self] = newValue } + } +} + +/// 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 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 +/// per-card customization blocks). +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 + public var collapsible: Bool = false + /// Subtle blue treatment for sections containing explicit non-default values. + public var highlighted: Bool = false + /// When provided, the header shows a switch; turning it off hides the body (and footer). + public var enabled: Binding? = nil + @ViewBuilder var content: () -> Content + + @State private var collapsed = false + + private var bodyHidden: Bool { + if let enabled, !enabled.wrappedValue { return true } + return collapsible && collapsed + } + + private var hasHeader: Bool { header != nil || collapsible || enabled != nil } + + public init(header: String? = nil, + footer: String? = nil, + rowSpacing: CGFloat = UI.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: UI.Tokens.Space.s) { + if hasHeader { headerRow } + if !bodyHidden { + LazyVStack(alignment: .leading, spacing: rowSpacing) { + content() + } + .environment(\.panelSectionHighlighted, highlighted) + .padding(UI.Tokens.Space.m) + .frame(maxWidth: .infinity, alignment: .leading) + .materialSurface(.regular, cornerRadius: UI.Tokens.Radius.card, shadow: false) + .overlay { + if highlighted { + 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** and `code` without custom rows. + Text(.init(footer)).font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, UI.Tokens.Space.xs) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var headerRow: some View { + HStack(spacing: UI.Tokens.Space.s) { + if collapsible { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(collapsed ? 0 : 90)) + } + if let header { + HStack(spacing: UI.Tokens.Space.xs) { + if highlighted { + Circle() + .fill(Color.accentColor) + .frame(width: 6, height: 6) + } + Text(header) + .font(.headline) + .foregroundStyle(highlighted ? Color.accentColor : Color.primary) + } + } + Spacer(minLength: UI.Tokens.Space.s) + if let enabled { + Toggle("", isOn: enabled).labelsHidden().toggleStyle(.switch) + } + } + .padding(.leading, UI.Tokens.Space.xs) + .contentShape(Rectangle()) + .onTapGesture { + guard collapsible else { return } + withAnimation(.easeOut(duration: 0.18)) { collapsed.toggle() } + } + } +} + +/// 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 Row: 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 + @State private var labelHovering = false + + 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: UI.Tokens.Space.m) { + VStack(alignment: .leading, spacing: 1) { + 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) + } + } + Spacer(minLength: UI.Tokens.Space.m) + trailing() + } + .contentShape(Rectangle()) + .onHover { labelHovering = $0 } + if let error { + Text(error).font(.caption).foregroundStyle(.red) + } + } + .frame(maxWidth: .infinity) + } +} +} + +#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() } + } +} + +/// A switch row — the common Toggle case, rendered label-left / switch-right like a grouped Form. +public extension UI.Panel { +struct ToggleRow: View { + public var title: String + public var subtitle: String? = nil + public var info: String? = nil + public var error: String? = nil + @Binding var isOn: Bool + + 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 { + 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 `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. +struct Field: 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 + @State private var labelHovering = false + + 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: UI.Tokens.Space.m) { + SharedPanelLabel(title: label, + info: info, + error: error, + highlighted: sectionHighlighted, + hovering: labelHovering, + width: labelWidth) + control().frame(maxWidth: .infinity) + } + .contentShape(Rectangle()) + .onHover { labelHovering = $0 } + if let error { + Text(error).font(.caption).foregroundStyle(.red) + .padding(.leading, labelWidth + UI.Tokens.Space.m) + } + } + } +} +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Panel/SheetTitleBar.swift b/Packages/ContainedUI/Sources/ContainedUI/Panel/SheetTitleBar.swift new file mode 100644 index 00000000..183df855 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/SheetTitleBar.swift @@ -0,0 +1,69 @@ +import SwiftUI + +/// Standard sheet header: a title (with optional subtitle), a flexible spacer, a cancel/close glass +/// 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 extension UI.Panel { +struct SheetTitleBar: View { + public let title: String + public var subtitle: String? = nil + public var cancelIcon: String = "xmark" + public var cancelHelp: String + public let onCancel: () -> Void + @ViewBuilder var trailing: () -> Trailing + + public init(title: String, + subtitle: String? = nil, + cancelIcon: String = "xmark", + cancelHelp: String, + 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) + if let subtitle { + Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + Spacer() + MaterialButton(singleItem: true) { + MaterialButtonItem(systemName: cancelIcon, help: cancelHelp, isCancel: true, action: onCancel) + } + trailing() + } + .padding(UI.Tokens.Space.l) + } +} +} + +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) { + self.init(title: title, subtitle: subtitle, cancelIcon: cancelIcon, cancelHelp: cancelHelp, + 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 new file mode 100644 index 00000000..dd6ecee6 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Panel/TitleBar.swift @@ -0,0 +1,68 @@ +import SwiftUI + +/// Standard in-window panel header for toolbar morphs and embedded panels. +public extension UI.Panel { +struct Header: View { + public let symbol: String + public let title: String + public var subtitle: String? + 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 = UI.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: UI.Tokens.Space.s) { + if leadingReserve > 0 { + Color.clear + .frame(width: leadingReserve, height: UI.Tokens.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) + .font(.headline) + .lineLimit(1) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, + minHeight: UI.Tokens.Toolbar.buttonGroupHeight, + alignment: .leading) + trailing() + } + .frame(minHeight: UI.Tokens.Toolbar.buttonGroupHeight) + .padding(padding) + } +} +} + +#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/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/Sources/Contained/DesignSystem/ActivityStatusView.swift b/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift similarity index 51% rename from Sources/Contained/DesignSystem/ActivityStatusView.swift rename to Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift index 9bb090b6..5be09bbc 100644 --- a/Sources/Contained/DesignSystem/ActivityStatusView.swift +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ActivityStatusView.swift @@ -1,19 +1,37 @@ import SwiftUI -/// The app's single "something is happening" asset, driven by `AppModel.ActivityState`. One source of +public extension UI.State { +struct ActivityStatus: 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 } +struct ActivityStatusIndicator: View { + public enum Style { case inline, expanded } + + public let activity: UI.State.ActivityStatus + public var style: Style = .inline - let activity: AppModel.ActivityState - var style: Style = .inline + public init(activity: UI.State.ActivityStatus, 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 @@ -21,11 +39,11 @@ struct ActivityStatusView: View { } private var inlineBody: some View { - HStack(spacing: Tokens.Toolbar.searchIconGap) { + HStack(spacing: UI.Tokens.Toolbar.searchIconGap) { ProgressView() .controlSize(.small) .scaleEffect(0.8) - .frame(width: Tokens.Toolbar.buttonItemHeight - Tokens.Toolbar.iconInnerPadding * 2) + .frame(width: UI.Tokens.Toolbar.buttonItemHeight - UI.Tokens.Toolbar.iconInnerPadding * 2) Text(activity.title) .lineLimit(1) .truncationMode(.tail) @@ -37,12 +55,12 @@ struct ActivityStatusView: View { .monospacedDigit() } } - .padding(.trailing, Tokens.Toolbar.iconInnerPadding * 2) + .padding(.trailing, UI.Tokens.Toolbar.iconInnerPadding * 2) } private var expandedBody: some View { VStack(alignment: .leading, spacing: 6) { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: UI.Tokens.Space.s) { ProgressView().controlSize(.small) Text(activity.title).font(.callout.weight(.medium)) Spacer(minLength: 0) @@ -70,3 +88,24 @@ struct ActivityStatusView: View { return "\(Int((fraction * 100).rounded()))%" } } +} + +#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 new file mode 100644 index 00000000..3fb427e2 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/State/Badges.swift @@ -0,0 +1,52 @@ +import SwiftUI + +public extension UI.Badge { +struct Dot: 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) + } +} + +struct Status: 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 { + 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 new file mode 100644 index 00000000..7fbddfb5 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ContentStates.swift @@ -0,0 +1,414 @@ +import SwiftUI + +public extension UI.State { +enum Tone { + 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 extension UI.Symbol { +enum Size { + 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 + } + } +} + +struct Image: View { + public var systemName: String + public var tone: UI.State.Tone + public var tint: Color? + public var size: UI.Symbol.Size + public var frameWidth: CGFloat? + + public init(systemName: String, + tone: UI.State.Tone = .neutral, + tint: Color? = nil, + size: UI.Symbol.Size = .callout, + frameWidth: CGFloat? = nil) { + self.systemName = systemName + self.tone = tone + self.tint = tint + self.size = size + self.frameWidth = frameWidth + } + + public var body: some View { + SwiftUI.Image(systemName: systemName) + .font(size.font) + .foregroundStyle(tint ?? tone.color) + .frame(width: frameWidth) + } +} +} + +public extension UI.State { +struct StatusText: View { + public var text: String + public var tone: UI.State.Tone + public var style: Font + + public init(_ text: String, + tone: UI.State.Tone = .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) + } +} + +struct Empty: View { + public var title: String + public var systemImage: String + public var description: String? + public var tone: UI.State.Tone + public var minHeight: CGFloat? + public var padding: CGFloat + + public init(_ title: String, + systemImage: String, + description: String? = nil, + tone: UI.State.Tone = .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 { + CenteredStateLayout(spacing: UI.Tokens.Space.s, + padding: padding, + minHeight: minHeight, + fillsHeight: false) { + SwiftUI.Image(systemName: systemImage) + .font(.title2) + .foregroundStyle(tone.color) + } content: { + Text(title) + .font(.callout.weight(.medium)) + if let description { + Text(description) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + +struct Hero: 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 { + CenteredStateLayout(spacing: UI.Tokens.Space.l, + padding: UI.Tokens.Space.xxl, + minHeight: nil, + fillsHeight: true) { + SwiftUI.Image(systemName: systemImage) + .font(.system(size: UI.Tokens.IconSize.appIcon - UI.Tokens.Space.xs)) + .foregroundStyle(.tint) + } content: { + Text(title).font(.title2.weight(.semibold)) + Text(message) + .designSecondaryCallout() + .multilineTextAlignment(.center) + .frame(maxWidth: 420) + } actions: { + actions() + } + } +} + +struct Loading: 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 { + CenteredStateLayout(spacing: UI.Tokens.Space.s, + padding: padding, + minHeight: minHeight, + fillsHeight: true) { + ProgressView() + } content: { + Text(title) + .font(.callout) + .foregroundStyle(.secondary) + } + } +} + +private struct CenteredStateLayout: View { + var spacing: CGFloat + var padding: CGFloat + var minHeight: CGFloat? + var fillsHeight: Bool + @ViewBuilder var graphic: () -> Graphic + @ViewBuilder var content: () -> Content + @ViewBuilder var actions: () -> Actions + + init(spacing: CGFloat, + padding: CGFloat, + minHeight: CGFloat?, + fillsHeight: Bool, + @ViewBuilder graphic: @escaping () -> Graphic, + @ViewBuilder content: @escaping () -> Content, + @ViewBuilder actions: @escaping () -> Actions = { EmptyView() }) { + self.spacing = spacing + self.padding = padding + self.minHeight = minHeight + self.fillsHeight = fillsHeight + self.graphic = graphic + self.content = content + self.actions = actions + } + + var body: some View { + VStack(spacing: spacing) { + graphic() + content() + actions() + } + .frame(maxWidth: .infinity, maxHeight: fillsHeight ? .infinity : nil) + .frame(minHeight: minHeight) + .padding(padding) + } +} + +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) + } +} + +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) + } +} + +struct InlineStatus: View { + public var title: String + public var systemImage: String? + public var isWorking: Bool + public var tone: UI.State.Tone + + public init(_ title: String, + systemImage: String? = nil, + isWorking: Bool = false, + tone: UI.State.Tone = .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 { + SwiftUI.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: UI.State.Tone) -> some View { + foregroundStyle(tone.color) + } + + func designStateIconStyle() -> some View { + font(.largeTitle) + .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 new file mode 100644 index 00000000..d6c087ca --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/State/ErrorBanner.swift @@ -0,0 +1,31 @@ +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 extension UI.State { +struct ErrorBanner: View { + public let message: String + + public init(message: String) { + self.message = message + } + + public var body: some View { + HStack(spacing: UI.Tokens.Space.s) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red) + Text(message).font(.callout).lineLimit(2) + } + .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)) + } +} +} + +#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 new file mode 100644 index 00000000..a0f18bce --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/State/StatusBanner.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// Package-owned transient banner chrome. +public extension UI.State { +struct Banner: 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) + } +} +} + +#Preview("Status Banner") { + UI.State.Banner("Image pull completed") + .padding(UI.Tokens.Space.xl) +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Support/Copyable.swift b/Packages/ContainedUI/Sources/ContainedUI/Support/Copyable.swift new file mode 100644 index 00000000..22c57fab --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Support/Copyable.swift @@ -0,0 +1,49 @@ +import SwiftUI + +public extension UI.Copy { + /// SwiftUI-native copy affordance for values that used to need imperative pasteboard writes. + struct ValueLabel: View { + public var title: String + public var value: String + public var systemName: String + public var help: String + + public init(_ title: String, + value: String, + systemName: String = "doc.on.doc", + help: String? = nil) { + self.title = title + self.value = value + self.systemName = systemName + self.help = help ?? title + } + + public var body: some View { + Label(title, systemImage: systemName) + .copyable([value]) + .help(help) + } + } + + struct Icon: View { + public var value: String + public var systemName: String + public var help: String + + public init(value: String, + systemName: String = "doc.on.doc", + help: String) { + self.value = value + self.systemName = systemName + self.help = help + } + + public var body: some View { + Image(systemName: systemName) + .foregroundStyle(.secondary) + .copyable([value]) + .help(help) + .accessibilityLabel(help) + } + } +} diff --git a/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift b/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift new file mode 100644 index 00000000..a0051b7b --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/ContentSurface.swift @@ -0,0 +1,41 @@ +import SwiftUI + +/// A package-owned content surface for empty states and grouped panel content. +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: () -> ContentView + + public init(elevated: Bool = false, + minHeight: CGFloat? = nil, + alignment: Alignment = .center, + padding: CGFloat = UI.Tokens.Space.s, + @ViewBuilder content: @escaping () -> ContentView) { + 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) + } +} +} + +#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 new file mode 100644 index 00000000..d98dba31 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/ExteriorShadow.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct ExteriorShadow: View { + var cornerRadius: CGFloat + var color: Color + var radius: CGFloat + var y: CGFloat + + init(cornerRadius: CGFloat, color: Color, radius: CGFloat, y: CGFloat) { + self.cornerRadius = cornerRadius + self.color = color + self.radius = radius + self.y = y + } + + var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + ZStack { + shape + .fill(color) + .blur(radius: radius) + .offset(y: y) + shape + .fill(.black) + .blendMode(.destinationOut) + } + .compositingGroup() + .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 new file mode 100644 index 00000000..e08eeb94 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/InputSurface.swift @@ -0,0 +1,42 @@ +import SwiftUI + +/// A package-owned surface for compact inline controls such as search fields and text editors. +public extension UI.Surface { +struct Input: 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) + } +} +} + +#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 new file mode 100644 index 00000000..73a443a7 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/MaterialSurface.swift @@ -0,0 +1,180 @@ +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 MaterialSurface: 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). + var shadow: Bool + var fill: Color? + var fillOpacity: Double + var gradient: Bool + var gradientAngle: Double + + @Environment(\.colorScheme) private var colorScheme + + init(level: 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) { + self.level = level + self.cornerRadius = cornerRadius + self.glass = glass + self.shadow = shadow + self.fill = fill + self.fillOpacity = fillOpacity + self.gradient = gradient + self.gradientAngle = gradientAngle + } + + 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(SharedSurfaceRendering.fillStyle(color: fill, + opacity: fillOpacity, + gradient: gradient, + gradientAngle: gradientAngle)) + } + } + } + + private var shadowColor: Color { + 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 { + 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. +struct MaterialCapsuleSurface: ViewModifier { + var level: MaterialSurface.Level + var glass: Glass + var shadow: Bool + var fill: Color? + var fillOpacity: Double + + @Environment(\.colorScheme) private var colorScheme + + init(level: MaterialSurface.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 + } + + 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 { + 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 { + 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 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(MaterialSurface(level: level, cornerRadius: cornerRadius, glass: glass, + shadow: shadow, fill: fill, fillOpacity: fillOpacity, gradient: gradient, + gradientAngle: gradientAngle)) + } + + func materialCapsuleSurface(_ level: MaterialSurface.Level = .regular, + glass: Glass = .regular, + shadow: Bool = true, + fill: Color? = nil, + fillOpacity: Double = 0.18) -> some View { + modifier(MaterialCapsuleSurface(level: level, + glass: glass, + shadow: shadow, + fill: fill, + 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 new file mode 100644 index 00000000..37b56d39 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Surface/VisualEffectBackground.swift @@ -0,0 +1,80 @@ +import SwiftUI +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: UI.Theme.WindowMaterial + var blendingMode: NSVisualEffectView.BlendingMode + + init(material: UI.Theme.WindowMaterial = .fullScreenUI, + blendingMode: NSVisualEffectView.BlendingMode = .behindWindow) { + self.material = material + self.blendingMode = blendingMode + } + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.blendingMode = blendingMode + view.state = .active + view.material = material.visualEffectMaterial + return view + } + + func updateNSView(_ view: NSVisualEffectView, context: Context) { + view.blendingMode = blendingMode + view.material = material.visualEffectMaterial + } +} + +private extension UI.Theme.WindowMaterial { + /// Glass cases fall back to a sensible vibrancy material for places that need a behind-window + /// layer, such as root content backing. + var visualEffectMaterial: NSVisualEffectView.Material { + switch self { + case .glassClear, .glassRegular: return .fullScreenUI + case .fullScreenUI: return .fullScreenUI + case .underWindowBackground: return .underWindowBackground + case .underPageBackground: return .underPageBackground + case .windowBackground: return .windowBackground + case .contentBackground: return .contentBackground + case .sidebar: return .sidebar + case .headerView: return .headerView + case .titlebar: return .titlebar + case .sheet: return .sheet + case .popover: return .popover + case .menu: return .menu + case .selection: return .selection + case .hudWindow: return .hudWindow + case .toolTip: return .toolTip + } + } +} + +/// 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 extension UI.Theme { +struct BackgroundLayer: View { + public var material: UI.Theme.WindowMaterial + + public init(material: UI.Theme.WindowMaterial = .fullScreenUI) { + self.material = material + } + + public var body: some View { + VisualEffectBackground(material: material) + .ignoresSafeArea() + } +} +} + +#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 new file mode 100644 index 00000000..07bcc706 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Theme/Theme.swift @@ -0,0 +1,258 @@ +import SwiftUI + +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. +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) + } + 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 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. +enum Tint: 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 host accent" option (rendered with a marker in the swatch row). + public var followsAccent: Bool { self == .multicolor } + + public var color: Color { + switch self { + case .multicolor: return .accentColor + case .graphite: return Color(red: 0.45, green: 0.46, blue: 0.50) + case .azure: return Color(red: 0.14, green: 0.52, blue: 0.92) + case .teal: return Color(red: 0.11, green: 0.62, blue: 0.50) + case .coral: return Color(red: 0.85, green: 0.35, blue: 0.19) + case .indigo: return Color(red: 0.33, green: 0.29, blue: 0.72) + case .green: return Color(red: 0.39, green: 0.60, blue: 0.13) + case .amber: return Color(red: 0.73, green: 0.46, blue: 0.09) + case .pink: return Color(red: 0.83, green: 0.21, blue: 0.34) + } + } + +} + +enum ColorBlendMode: String, CaseIterable, Identifiable, Codable, Sendable { + case normal, softLight, overlay, multiply, screen + + public var id: String { rawValue } + + public var blendMode: BlendMode { + switch self { + case .normal: return .normal + case .softLight: return .softLight + case .overlay: return .overlay + case .multiply: return .multiply + case .screen: return .screen + } + } +} + +enum Appearance: String, CaseIterable, Identifiable, Codable, Sendable { + case system, light, dark + public var id: String { rawValue } + public var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } + +} + +} + +public extension UI.Card { +enum Density: String, CaseIterable, Identifiable, Codable, Sendable { + case small, medium, large + public var id: String { rawValue } + public var resourceSize: UI.Card.Size { + switch self { + case .small: return .small + case .medium: return .medium + case .large: return .large + } + } + + public init(stored raw: String?) { + if raw == "compact" { + self = .medium + } else { + 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. +enum WindowMaterial: String, CaseIterable, Identifiable, Codable, Sendable { + // Liquid Glass options (rendered with `.glassEffect`, not an `NSVisualEffectView`). + case glassClear, glassRegular + // System vibrancy materials. + case fullScreenUI, underWindowBackground, underPageBackground, + windowBackground, contentBackground, sidebar, headerView, titlebar, + sheet, popover, menu, selection, hudWindow, toolTip + + public var id: String { rawValue } + + /// True for the Liquid Glass options, which render via `.glassEffect` rather than vibrancy. + public var isGlass: Bool { self == .glassClear || self == .glassRegular } + + /// The Liquid Glass variant for the glass cases (nil for vibrancy materials). + public var glass: Glass? { + switch self { + case .glassClear: return .clear + case .glassRegular: return .regular + default: return nil + } + } + +} +} + +public extension EnvironmentValues { + /// The user-chosen modal material, seeded at the app root and inherited by presented sheets. + @Entry var modalMaterial: UI.Theme.WindowMaterial = .sheet + /// The user-chosen toolbar-control (button) material, seeded at the app root. + @Entry var buttonMaterial: UI.Theme.WindowMaterial = .glassClear + /// The user-chosen design-card material, seeded at the app root. + @Entry var cardMaterial: UI.Theme.WindowMaterial = .glassRegular + /// Optional color/gradient wash layered into toolbar button groups. + @Entry var buttonTintStyle: UI.Theme.ButtonTintStyle = .disabled +} + +private struct SheetMaterial: ViewModifier { + @Environment(\.modalMaterial) private var material + func body(content: Content) -> some View { + content + .background { + if let glass = material.glass { + Color.clear.glassEffect(glass, in: Rectangle()).ignoresSafeArea() + } else { + VisualEffectBackground(material: material, blendingMode: .withinWindow) + .ignoresSafeArea() + } + } + .presentationBackground(.clear) + } +} + +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()) } + + /// Apply the sheet material only when `active`. Popovers bring their own native vibrant + /// background, and layering an `NSVisualEffectView` + `presentationBackground(.clear)` inside one + /// leaves controls unpainted until the first mouse event — so popover presentations pass `false`. + @ViewBuilder + func sheetMaterial(_ active: Bool) -> some View { + if active { modifier(SheetMaterial()) } else { self } + } +} + +private struct FloatingPanelMaterial: AnimatableModifier { + @Environment(\.modalMaterial) private var material + var cornerRadius = UI.Tokens.Radius.sheet + var showsShadow = true + + nonisolated var animatableData: CGFloat { + get { cornerRadius } + set { cornerRadius = newValue } + } + + func body(content: Content) -> some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + content + .background { + if showsShadow { + ExteriorShadow(cornerRadius: cornerRadius, + color: UI.Theme.Material.floatingPanelShadow, + radius: UI.Theme.Material.floatingPanelShadowRadius, + y: UI.Theme.Material.floatingPanelShadowY) + } + } + .background { + if let glass = material.glass { + Color.clear.glassEffect(glass, in: shape) + } else { + VisualEffectBackground(material: material, blendingMode: .withinWindow) + .clipShape(shape) + } + } + .clipShape(shape) + .overlay { + shape.strokeBorder(UI.Theme.Material.floatingPanelStroke, lineWidth: 1) + } + } +} + +private struct ToolbarControlMaterial: ViewModifier { + let shape: S + var material: UI.Theme.WindowMaterial? + @Environment(\.buttonMaterial) private var buttonMaterial + + func body(content: Content) -> some View { + let resolvedMaterial = material ?? buttonMaterial + if let glass = resolvedMaterial.glass { + content.glassEffect(glass.interactive(), in: shape) + } else { + // A vibrancy material chosen for buttons — back the capsule with it and clip. + content.background { + VisualEffectBackground(material: resolvedMaterial, blendingMode: .withinWindow) + .clipShape(shape) + } + } + } +} + +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 = UI.Tokens.Radius.sheet, + showsShadow: Bool = true) -> some View { + modifier(FloatingPanelMaterial(cornerRadius: cornerRadius, showsShadow: showsShadow)) + } + + /// Standard interactive glass used by toolbar buttons and collapsed toolbar search. + func toolbarControlMaterial(in shape: S, + material: UI.Theme.WindowMaterial? = nil) -> some View { + modifier(ToolbarControlMaterial(shape: shape, material: material)) + } +} + +#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/ContainedUI/Sources/ContainedUI/Tokens/UI.swift b/Packages/ContainedUI/Sources/ContainedUI/Tokens/UI.swift new file mode 100644 index 00000000..33297f0c --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Tokens/UI.swift @@ -0,0 +1,474 @@ +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 Copy {} + 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/Sources/ContainedUI/Toolbar/Controls.swift b/Packages/ContainedUI/Sources/ContainedUI/Toolbar/Controls.swift new file mode 100644 index 00000000..2adf7fa5 --- /dev/null +++ b/Packages/ContainedUI/Sources/ContainedUI/Toolbar/Controls.swift @@ -0,0 +1,320 @@ +import SwiftUI + +/// 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 +/// (`OptionTile`), and future band controls visually consistent. + +public extension UI.Action { +struct MenuButton: View { + public var material: UI.Theme.WindowMaterial? + public var tintStyle: UI.Theme.ButtonTintStyle? + @ViewBuilder public var menuContent: () -> MenuContent + @ViewBuilder public var labelContent: () -> LabelContent + + public init(material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil, + @ViewBuilder menuContent: @escaping () -> MenuContent, + @ViewBuilder labelContent: @escaping () -> LabelContent) { + self.material = material + self.tintStyle = tintStyle + self.menuContent = menuContent + self.labelContent = labelContent + } + + public var body: some View { + Menu { + menuContent() + } label: { + MaterialButton(singleItem: true, + material: material, + tintStyle: tintStyle) { + labelContent() + } + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize(horizontal: true, vertical: false) + } +} +} + +public extension UI.Toolbar { +struct SearchField: View { + @Binding public var text: String + public var prompt: String + public var clearSearchLabel: String + public var material: UI.Theme.WindowMaterial? + public var tintStyle: UI.Theme.ButtonTintStyle? + public var focused: FocusState.Binding + public var onSubmit: () -> Void + public var onClear: () -> Void + @ViewBuilder public var trailing: () -> Trailing + + public init(text: Binding, + prompt: String, + clearSearchLabel: String, + material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil, + focused: FocusState.Binding, + onSubmit: @escaping () -> Void = {}, + onClear: @escaping () -> Void, + @ViewBuilder trailing: @escaping () -> Trailing) { + self._text = text + self.prompt = prompt + self.clearSearchLabel = clearSearchLabel + self.material = material + self.tintStyle = tintStyle + self.focused = focused + self.onSubmit = onSubmit + self.onClear = onClear + self.trailing = trailing + } + + public var body: some View { + MaterialButton(singleItem: true, + material: material, + tintStyle: tintStyle) { + MaterialButtonInputItem { + 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(clearSearchLabel) + .accessibilityLabel(clearSearchLabel) + } else { + trailing() + } + } + } + .toolbarControlContentShape() + .simultaneousGesture(TapGesture().onEnded { focused.wrappedValue = true }) + } +} + +/// Package-owned empty toolbar slot for stable morph origins and vanity chrome. +struct VanitySlot: View { + public var minWidth: CGFloat + public var interactive: Bool + public var material: UI.Theme.WindowMaterial? + public var tintStyle: UI.Theme.ButtonTintStyle? + @ViewBuilder public var content: () -> Content + + public init(minWidth: CGFloat = UI.Tokens.Toolbar.trafficLightsWidth, + interactive: Bool = false, + material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil, + @ViewBuilder content: @escaping () -> Content = { Color.clear }) { + self.minWidth = minWidth + self.interactive = interactive + self.material = material + self.tintStyle = tintStyle + self.content = content + } + + public var body: some View { + MaterialButton(minWidth: minWidth, + singleItem: true, + interactive: interactive, + material: material, + tintStyle: tintStyle) { + content() + } + .fixedSize(horizontal: true, vertical: false) + } +} + +/// Package-owned toolbar button for custom status content. +struct StatusButton: View { + public var help: String + public var material: UI.Theme.WindowMaterial? + public var tintStyle: UI.Theme.ButtonTintStyle? + public var action: () -> Void + @ViewBuilder public var content: () -> Content + + public init(help: String, + material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil, + action: @escaping () -> Void, + @ViewBuilder content: @escaping () -> Content) { + self.help = help + self.material = material + self.tintStyle = tintStyle + self.action = action + self.content = content + } + + public var body: some View { + MaterialButton(singleItem: true, + material: material, + tintStyle: tintStyle) { + MaterialButtonItem(help: help, action: action) { + content() + } + } + } +} + +/// Package-owned glass shell for toolbar clusters that mix action items and status/menu items. +struct ActionCluster: View { + public var spacing: CGFloat + public var material: UI.Theme.WindowMaterial? + public var tintStyle: UI.Theme.ButtonTintStyle? + @ViewBuilder public var content: () -> Content + + public init(spacing: CGFloat = 0, + material: UI.Theme.WindowMaterial? = nil, + tintStyle: UI.Theme.ButtonTintStyle? = nil, + @ViewBuilder content: @escaping () -> Content) { + self.spacing = spacing + self.material = material + self.tintStyle = tintStyle + self.content = content + } + + public var body: some View { + MaterialButton(spacing: spacing, + material: material, + tintStyle: tintStyle) { + content() + } + } +} + +/// A toolbar-styled menu trigger that uses the shared toolbar icon lane while keeping native menu +/// behavior. +struct MenuButton: View { + public let systemName: String + public var help: String + @ViewBuilder public var content: () -> Content + + 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: { + Image(systemName: systemName) + .font(.body.weight(.medium)) + .padding(UI.Tokens.Toolbar.iconInnerPadding) + .frame(height: UI.Tokens.Toolbar.buttonItemHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .menuStyle(.button) + .menuIndicator(.hidden) + .help(help) + .accessibilityLabel(help) + } +} + +/// 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 TitleSubtitle: 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 + } + + public var body: some View { + HStack(spacing: UI.Tokens.Toolbar.searchIconGap) { + Image(systemName: symbol) + .font(.body) + .foregroundStyle(.secondary) + .frame(width: UI.Tokens.Toolbar.buttonItemHeight - UI.Tokens.Toolbar.iconInnerPadding * 2) + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + if showsChevron { + Image(systemName: "chevron.down") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .lineLimit(1) + .padding(.trailing, UI.Tokens.Toolbar.iconInnerPadding * 2) + .frame(height: UI.Tokens.Toolbar.buttonGroupHeight) + .contentShape(Rectangle()) + } +} +} + +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)) + } +} + +#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") + } + } + Text("Tile") + .padding(UI.Tokens.Space.m) + .subtleTileBackground() + } + } +} 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/ContainedUI/Tests/ContainedUITests/SparklineScalingTests.swift b/Packages/ContainedUI/Tests/ContainedUITests/SparklineScalingTests.swift new file mode 100644 index 00000000..689cdf07 --- /dev/null +++ b/Packages/ContainedUI/Tests/ContainedUITests/SparklineScalingTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import ContainedUI + +@Suite("Live sparkline scaling") +struct SparklineScalingTests { + @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/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..a006ac02 --- /dev/null +++ b/Packages/ContainedUX/README.md @@ -0,0 +1,128 @@ +# 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 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 + +```sh +swift build --package-path Packages/ContainedUX +swift test --package-path Packages/ContainedUX +``` + +## Related Documentation + +- [Design System](../../Documentation/Architecture/Design-System.md) +- [Architecture](../../Documentation/Architecture/Architecture.md) +- [Wiki sync map](../../Documentation/Wiki/File-Map.md) 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..9ccbe1e6 --- /dev/null +++ b/Packages/ContainedUX/Sources/ContainedUX/ContainedUX.docc/ContainedUX.md @@ -0,0 +1,53 @@ +# ``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`. + +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, + 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` + +## Related Documentation + +- [Design System](../../../../../Documentation/Architecture/Design-System.md) +- [Wiki sync map](../../../../../Documentation/Wiki/File-Map.md) diff --git a/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrameReader.swift b/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrameReader.swift new file mode 100644 index 00000000..2d1c6f3a --- /dev/null +++ b/Packages/ContainedUX/Sources/ContainedUX/Measurement/SourceFrameReader.swift @@ -0,0 +1,88 @@ +import SwiftUI +import ContainedUI + +public extension UX.Measurement { +struct SourceFrameReader: 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: UX.Measurement.SourceFramesKey.self, + value: Dictionary(uniqueKeysWithValues: ids.map { ($0, frame) }) + ) + } + } +} + +struct SourceFramesKey: 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 { + UX.Morph.Geometry.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 + } +} + +#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/Sources/Contained/DesignSystem/MorphingExpander.swift b/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift similarity index 55% rename from Sources/Contained/DesignSystem/MorphingExpander.swift rename to Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift index cf2137a4..f5e5de61 100644 --- a/Sources/Contained/DesignSystem/MorphingExpander.swift +++ b/Packages/ContainedUX/Sources/ContainedUX/Morph/Expander.swift @@ -1,50 +1,57 @@ import SwiftUI +import ContainedUI + +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: UX.Panel.Placement, + safeArea: UX.SafeArea.Policy, + margin: CGFloat, + proposedSize: @escaping (CGRect) -> CGSize) { + self.placement = placement + self.safeArea = safeArea + self.margin = margin + self.proposedSize = proposedSize + } -enum MorphPanelPlacement: Equatable { - case anchored - case centered -} - -struct AppMorphTarget { - var placement: MorphPanelPlacement - var safeArea: AppSafeAreaPolicy - var margin: CGFloat - var proposedSize: (CGRect) -> CGSize - - static func anchored(size: CGSize, - safeArea: AppSafeAreaPolicy = .toolbarChrome, - margin: CGFloat = MorphGeometry.defaultMargin) -> AppMorphTarget { - AppMorphTarget(placement: .anchored, + public static func anchored(size: CGSize, + 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 }) } - static func centered(size: CGSize, - safeArea: AppSafeAreaPolicy = .content, - margin: CGFloat = MorphGeometry.defaultMargin) -> AppMorphTarget { - AppMorphTarget(placement: .centered, + public static func centered(size: CGSize, + 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 }) } - static func centered(safeArea: AppSafeAreaPolicy = .content, - margin: CGFloat = MorphGeometry.defaultMargin, - proposedSize: @escaping (CGRect) -> CGSize) -> AppMorphTarget { - AppMorphTarget(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) } - 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, + public func rect(origin: CGRect, + in container: CGSize, + safeAreaManager: UX.SafeArea.Manager, + proposedSize overrideSize: CGSize? = nil, + placement overridePlacement: UX.Panel.Placement? = nil) -> CGRect { + let bounds = safeAreaManager.bounds(in: container, policy: safeArea) + return UX.Morph.Geometry.targetRect(origin: origin, proposedSize: overrideSize ?? proposedSize(bounds), bounds: bounds, placement: overridePlacement ?? placement, @@ -52,34 +59,26 @@ struct AppMorphTarget { } } -struct GlobalBackdropStyle: OptionSet { - let rawValue: Int - - static let dim = GlobalBackdropStyle(rawValue: 1 << 0) - static let blur = GlobalBackdropStyle(rawValue: 1 << 1) - static let blurAndDim: GlobalBackdropStyle = [.blur, .dim] -} - -enum MorphGeometry { - static let defaultMargin: CGFloat = Tokens.Space.l - static let centeredTopMargin: CGFloat = Tokens.Space.xxl * 2 +enum Geometry { + public static let defaultMargin: CGFloat = UI.Layout.Spacing.l + public static let centeredTopMargin: CGFloat = UI.Layout.Spacing.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)) + return CGSize(width: min(max(UI.Panel.Size.minWidth, proposed.width), maxWidth), + height: min(max(UI.Panel.Size.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: UX.Panel.Placement, + margin: CGFloat = defaultMargin) -> CGRect { targetRect(origin: origin, proposedSize: proposedSize, bounds: CGRect(origin: .zero, size: container), @@ -87,9 +86,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: UX.Panel.Placement, + margin: CGFloat = defaultMargin) -> CGRect { let size = fittedSize(proposedSize, in: bounds, margin: margin) switch placement { case .centered: @@ -99,19 +98,19 @@ 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) } } - 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 @@ -123,32 +122,34 @@ enum MorphGeometry { return CGRect(x: x, y: y, width: width, height: height) } } +} -private extension CGRect { - var isUsable: Bool { - width.isFinite && height.isFinite && minX.isFinite && minY.isFinite && width > 1 && height > 1 +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 +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. /// /// 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 { +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: AppMorphTarget - var backdropStyle: GlobalBackdropStyle = .dim + var target: UX.Morph.Target + var backdropStyle: UX.Panel.BackdropStyle = .dim var showsBackdrop = true var showsPanelShadow = true var closeRequestToken = 0 - var sourceCornerRadius = Tokens.Toolbar.groupRadius - var targetCornerRadius = Tokens.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 @@ -158,21 +159,21 @@ 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: UX.Panel.Placement? @Namespace private var shellNamespace @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.appSafeAreas) private var safeAreas + @Environment(\.morphSafeAreaManager) private var safeAreaManager 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, + 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, - sourceCornerRadius: CGFloat = Tokens.Toolbar.groupRadius, - targetCornerRadius: CGFloat = Tokens.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) { @@ -190,10 +191,13 @@ 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.isUsableForMorph ? originFrame : target + let rect = UX.Morph.Frame(source: source, + target: target, + progress: expanded ? 1 : 0).rect let cornerRadius = expanded ? targetCornerRadius : sourceCornerRadius ZStack { if showsBackdrop { @@ -216,27 +220,27 @@ 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() // 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. .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 @@ -262,7 +266,7 @@ struct MorphingExpander: View { private func targetRect(in size: CGSize) -> CGRect { target.rect(origin: originFrame, in: size, - safeAreas: safeAreas, + safeAreaManager: safeAreaManager, proposedSize: liveSize, placement: livePlacement) } @@ -273,9 +277,10 @@ struct MorphingExpander: View { withAnimation(spring) { expanded = false } completion: { isPresented = false } } } +} -extension View { - func globalBackdrop(style: GlobalBackdropStyle, +public extension View { + func globalBackdrop(style: UX.Panel.BackdropStyle, progress: Double, dimOpacity: Double = 0.28) -> some View { self @@ -298,7 +303,7 @@ extension View { } private struct MorphPanelShell: View { - var cornerRadius = Tokens.Radius.sheet + var cornerRadius = UI.Panel.Radius.surface var showsShadow = true var body: some View { @@ -307,31 +312,97 @@ 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 `UX.Morph.Expander`, 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 { - static let defaultValue: MorphPanelPlacement? = nil - static func reduce(value: inout MorphPanelPlacement?, - nextValue: () -> MorphPanelPlacement?) { +struct MorphPanelPlacementKey: PreferenceKey { + 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 } } } -extension View { - /// Declare the desired size of the panel hosting this content (read by `MorphingExpander`). +public extension View { + /// 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: MorphPanelPlacement) -> some View { + func morphPanelPlacement(_ placement: UX.Panel.Placement) -> some 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 new file mode 100644 index 00000000..7c098c63 --- /dev/null +++ b/Packages/ContainedUX/Sources/ContainedUX/Morph/SingleSurface.swift @@ -0,0 +1,278 @@ +import SwiftUI +import ContainedUI + +public extension UX.Morph { +struct Frame: 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 = UX.Morph.Geometry.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 `UX.Morph.Expander`: callers keep the +/// source view laid out in place, hide it while selected, and render one overlay through this helper. +struct SingleSurface: 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 = 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 `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 `UX.Morph.Expander`. +struct SingleSurfaceExpander: View { + @Binding var isPresented: Bool + public var originFrame: CGRect + public var target: UX.Morph.Target + public var backdropStyle: UX.Panel.BackdropStyle + 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: 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: UX.Morph.Target, + backdropStyle: UX.Panel.BackdropStyle = .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, + safeAreaManager: safeAreaManager, + proposedSize: liveSize, + placement: livePlacement) + } + + private func close() { + onExpansionChange?(false) + guard !reduceMotion else { + isPresented = false + return + } + withAnimation(spring) { expanded = false } completion: { + isPresented = false + } + } +} +} + +public extension UX.Morph.Geometry { + 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 { + func morphInterpolated(to target: CGRect, progress: CGFloat) -> 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 new file mode 100644 index 00000000..14ae22b4 --- /dev/null +++ b/Packages/ContainedUX/Sources/ContainedUX/Panel/BackdropStyle.swift @@ -0,0 +1,28 @@ +import SwiftUI +import ContainedUI + +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] +} +} + +#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 new file mode 100644 index 00000000..b226449d --- /dev/null +++ b/Packages/ContainedUX/Sources/ContainedUX/Panel/Placement.swift @@ -0,0 +1,24 @@ +import CoreGraphics +import SwiftUI +import ContainedUI + +public extension UX.Panel { +enum Placement: Equatable, Sendable { + case anchored + 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/SafeArea/Manager.swift b/Packages/ContainedUX/Sources/ContainedUX/SafeArea/Manager.swift new file mode 100644 index 00000000..3ad62544 --- /dev/null +++ b/Packages/ContainedUX/Sources/ContainedUX/SafeArea/Manager.swift @@ -0,0 +1,110 @@ +import SwiftUI +import ContainedUI + +public extension UX.SafeArea { +struct ToolbarExclusion: OptionSet, Equatable, Sendable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + 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] +} + +enum Padding: CGFloat, Equatable, Sendable { + case none = 0 + case small = 8 + case medium = 16 + case large = 24 +} + +struct Policy: Equatable, Sendable { + public var excluding: UX.SafeArea.ToolbarExclusion + public var padding: UX.SafeArea.Padding + public var includesSystemInsets: Bool + + 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 = 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) +} + +struct Manager: 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: 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 + // 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: UX.SafeArea.Policy = .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 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/Packages/ContainedUX/Sources/ContainedUX/UX.swift b/Packages/ContainedUX/Sources/ContainedUX/UX.swift new file mode 100644 index 00000000..0a16ce3a --- /dev/null +++ b/Packages/ContainedUX/Sources/ContainedUX/UX.swift @@ -0,0 +1,12 @@ +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 {} +} diff --git a/Packages/ContainedUX/Tests/ContainedUXTests/SingleSurfaceTests.swift b/Packages/ContainedUX/Tests/ContainedUXTests/SingleSurfaceTests.swift new file mode 100644 index 00000000..245fcf99 --- /dev/null +++ b/Packages/ContainedUX/Tests/ContainedUXTests/SingleSurfaceTests.swift @@ -0,0 +1,33 @@ +import CoreGraphics +import Testing +@testable import ContainedUX + +@Suite("Single surface morph geometry") +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) + + 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/README.md b/README.md index 0b820aaf..ffd69e6b 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,51 +46,66 @@ Requirements: ## Build -Contained is a Swift Package. +Contained has two supported development entry points over the same package graph. + +| 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 Package.swift +open Contained.xcworkspace swift build swift test -./scripts/bundle.sh debug +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug build +xcodebuild -workspace Contained.xcworkspace -scheme Contained -configuration Debug test +./Scripts/package.sh app debug open Contained.app ``` -Maintainers use `scripts/release.sh` and `scripts/appcast.sh` for signing, notarization, DMG creation, GitHub release notes, and Sparkle appcasts. +Maintainers use `Scripts/package.sh notarized` and `Scripts/appcast.sh generate` for signing, notarization, DMG creation, GitHub release notes, and Sparkle appcasts. ## 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: +Start with the [documentation index](Documentation/README.md). The most-used pages are: -- 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) +- App: [Home](Documentation/App/Home.md), [Installation](Documentation/App/Installation.md), [Keyboard Shortcuts](Documentation/App/Keyboard-Shortcuts.md), [Troubleshooting](Documentation/App/Troubleshooting.md), [Updates](Documentation/App/Updates.md), [System Settings](Documentation/App/System-Settings.md) +- Features: [Feature Overview](Documentation/Features/Features.md), [Containers](Documentation/Features/Containers.md), [Images](Documentation/Features/Images.md), [Resources](Documentation/Features/Resources.md), [Creation Workflow](Documentation/Features/Creation-Workflow.md), [Run / Edit Form](Documentation/Features/Run-Edit-Form.md), [Compose Import](Documentation/Features/Compose-Import.md), [Command Palette](Documentation/Features/Command-Palette.md) +- Architecture: [Architecture](Documentation/Architecture/Architecture.md), [Runtime Adapters](Documentation/Architecture/Runtime-Adapters.md), [Design System](Documentation/Architecture/Design-System.md) +- Development: [Contributing](Documentation/Development/Contributing.md), [Issues and Discussions](Documentation/Development/Issues-and-Discussions.md), [Documentation Map](Documentation/Development/Documentation-Map.md), [Localization](Documentation/App/Localization.md) +- Release: [Release Runbook](Documentation/Release/Release.md) -## Contributing And Support +Package docs live beside each local package: -Start with the [wiki](https://github.com/tdeverx/contained-app/wiki) and -[Troubleshooting](https://github.com/tdeverx/contained-app/wiki/Troubleshooting). -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](https://github.com/tdeverx/contained-app/wiki/Contributing) -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](Documentation/Wiki/README.md) explains how maintained docs map to the separate GitHub wiki repository. ## Architecture -The package has two main targets: +The root package contains a tiny SwiftPM launcher and the shared app implementation, then consumes standalone local packages. + +| 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 -- `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. +Read the [contributing guide](Documentation/Development/Contributing.md) before opening a larger PR. -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. +- 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/scripts/Contained.entitlements b/Scripts/Contained.entitlements similarity index 100% rename from scripts/Contained.entitlements rename to Scripts/Contained.entitlements diff --git a/Scripts/appcast.sh b/Scripts/appcast.sh new file mode 100755 index 00000000..0d1a2758 --- /dev/null +++ b/Scripts/appcast.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# Generate, promote, and validate Sparkle appcasts. +# Usage: ./Scripts/appcast.sh [...] +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +fail() { + echo "✗ $*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'USAGE' +Usage: + ./Scripts/appcast.sh generate [updates-dir] + ./Scripts/appcast.sh promote [--non-nightly-only] + ./Scripts/appcast.sh validate [appcast.xml] +USAGE +} + +generate_appcast() { + local sparkle_bin="${1:?Pass the path to the Sparkle bin directory containing generate_appcast}" + local updates_dir="${2:-updates}" + local download_prefix="${DOWNLOAD_PREFIX:-https://github.com/tdeverx/contained-app/releases/download/}" + + [ -d "$updates_dir" ] || fail "Updates dir '$updates_dir' not found" + + local key_args=() + [ -n "${ED_KEY_FILE:-}" ] && key_args=(--ed-key-file "$ED_KEY_FILE") + + echo "▸ Generating appcast for $updates_dir (download prefix: $download_prefix)..." + ./Scripts/notes.sh html "$updates_dir" + if [ "${#key_args[@]}" -gt 0 ]; then + "$sparkle_bin/generate_appcast" "${key_args[@]}" --embed-release-notes --download-url-prefix "$download_prefix" "$updates_dir" + else + "$sparkle_bin/generate_appcast" --embed-release-notes --download-url-prefix "$download_prefix" "$updates_dir" + fi + + cp "$updates_dir/appcast.xml" appcast.xml + echo "✓ Wrote appcast.xml at the repo root for this branch." +} + +promote_appcast() { + local filter_mode="all" + if [ "${1:-}" = "--non-nightly-only" ]; then + filter_mode="non-nightly" + shift + fi + + local promoted="${1:?path to promoted appcast.xml}" + local nightly="${2:?path to nightly appcast.xml}" + + [ -f "$promoted" ] || fail "Promoted appcast '$promoted' not found" + + if [ ! -f "$nightly" ]; then + cat > "$nightly" <<'XML' + + + + Contained + + +XML + fi + + PROMOTED_APPCAST="$promoted" FILTER_MODE="$filter_mode" perl -0pi -e ' + my $source_path = $ENV{"PROMOTED_APPCAST"}; + my $filter_mode = $ENV{"FILTER_MODE"} // "all"; + open my $source_fh, "<", $source_path or die "Unable to read $source_path: $!"; + local $/; + my $source = <$source_fh>; + close $source_fh; + + my @promoted_items; + while ($source =~ m{(.*?)}gs) { + my $item = $1; + $item =~ m{([^<]+)} + or die "No sparkle:version in promoted item\n"; + my $version = $1; + my ($short_version) = $item =~ m{([^<]+)}; + next if $filter_mode eq "non-nightly" && defined $short_version && $short_version =~ /-nightly\./; + push @promoted_items, [$version, $item]; + } + + if (!@promoted_items) { + next; + } + + my $insert = ""; + for my $entry (@promoted_items) { + my ($version, $item) = @$entry; + my $quoted_version = quotemeta($version); + s{\s*.*?$quoted_version.*?}{}gs; + $insert .= "\n $item\n"; + } + + s{(\s*)}{$1$insert}s + or die "No channel title found in nightly appcast\n"; + ' "$nightly" + + echo "✓ Merged promoted appcast item(s) into $nightly" +} + +validate_appcast() { + local appcast="${1:-appcast.xml}" + local channel="${CHANNEL:-nightly}" + + [ -f "$appcast" ] || fail "Appcast '$appcast' was not found" + + APPCAST="$appcast" CHANNEL_VALUE="$channel" ruby <<'RUBY' +require "rexml/document" +require "rexml/xpath" + +path = ENV.fetch("APPCAST") +channel = ENV.fetch("CHANNEL_VALUE") +allow_missing_notes = ENV["ALLOW_MISSING_RELEASE_NOTES"] == "1" +namespaces = { "sparkle" => "http://www.andymatuschak.org/xml-namespaces/sparkle" } + +def fail!(message) + warn "✗ #{message}" + exit 1 +end + +unless %w[stable beta nightly].include?(channel) + fail!("Unknown CHANNEL '#{channel}' (expected stable, beta, or nightly)") +end + +doc = REXML::Document.new(File.read(path)) +items = REXML::XPath.match(doc, "//item") +fail!("Appcast has no entries") if items.empty? + +items.each_with_index do |item, index| + label = "item #{index + 1}" + version = REXML::XPath.first(item, "sparkle:version", namespaces)&.text.to_s.strip + short_version = REXML::XPath.first(item, "sparkle:shortVersionString", namespaces)&.text.to_s.strip + enclosure = REXML::XPath.first(item, "enclosure") + enclosure_url = enclosure&.attributes&.[]("url").to_s.strip + description = REXML::XPath.first(item, "description")&.text.to_s.strip + release_notes_link = REXML::XPath.first(item, "sparkle:releaseNotesLink", namespaces)&.text.to_s.strip + + fail!("#{label} is missing sparkle:version") if version.empty? + fail!("#{label} sparkle:version must be numeric, got '#{version}'") unless version.match?(/\A[1-9][0-9]*\z/) + fail!("#{label} is missing sparkle:shortVersionString") if short_version.empty? + fail!("#{label} is missing enclosure URL") if enclosure_url.empty? + + unless allow_missing_notes || !description.empty? || !release_notes_link.empty? + fail!("#{label} is missing embedded or linked release notes") + end + + case channel + when "stable" + fail!("#{label} is not a stable short version: #{short_version}") if short_version.match?(/-(beta|nightly)\./) + when "beta" + fail!("#{label} is not a beta short version: #{short_version}") unless short_version.include?("-beta.") + when "nightly" + # Nightly intentionally accepts stable, beta, and nightly items. + end +end + +puts "✓ Appcast validation passed for #{path} (#{channel}, #{items.length} item#{items.length == 1 ? "" : "s"})." +RUBY +} + +command="${1:-}" +[ -n "$command" ] || { usage; exit 2; } +shift + +case "$command" in + generate) + generate_appcast "$@" + ;; + promote) + promote_appcast "$@" + ;; + validate) + validate_appcast "$@" + ;; + *) + usage + exit 2 + ;; +esac diff --git a/Scripts/build.sh b/Scripts/build.sh new file mode 100755 index 00000000..9fe78506 --- /dev/null +++ b/Scripts/build.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Build, open, and run the local Contained.app development bundle. +# Usage: ./Scripts/build.sh [...] +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +APP_NAME="Contained" +APP_BUNDLE="$ROOT/Contained.app" +BUNDLE_BINARY="$APP_BUNDLE/Contents/MacOS/$APP_NAME" +BUNDLE_ID="com.contained.app" + +usage() { + cat >&2 <<'USAGE' +Usage: + ./Scripts/build.sh app [debug|release] + ./Scripts/build.sh open + ./Scripts/build.sh run [--debug|--logs|--telemetry|--verify] +USAGE +} + +kill_app() { + pkill -x "$APP_NAME" >/dev/null 2>&1 || true +} + +build_bundle() { + ./Scripts/package.sh app "${1:-debug}" +} + +open_bundle() { + /usr/bin/open "$APP_BUNDLE" +} + +command="${1:-}" +[ -n "$command" ] || { usage; exit 2; } +shift + +case "$command" in + app) + build_bundle "${1:-debug}" + ;; + open) + open_bundle + ;; + run) + mode="${1:-run}" + case "$mode" in + run) + kill_app + build_bundle debug + open_bundle + ;; + --debug|debug) + kill_app + build_bundle debug + lldb -- "$BUNDLE_BINARY" + ;; + --logs|logs) + kill_app + build_bundle debug + open_bundle + /usr/bin/log stream --info --style compact --predicate "process == \"$APP_NAME\"" + ;; + --telemetry|telemetry) + kill_app + build_bundle debug + open_bundle + /usr/bin/log stream --info --style compact --predicate "subsystem == \"$BUNDLE_ID\"" + ;; + --verify|verify) + kill_app + build_bundle debug + open_bundle + sleep 1 + pgrep -x "$APP_NAME" >/dev/null + ;; + *) + usage + exit 2 + ;; + esac + ;; + *) + usage + exit 2 + ;; +esac diff --git a/Scripts/check.sh b/Scripts/check.sh new file mode 100755 index 00000000..6413f58f --- /dev/null +++ b/Scripts/check.sh @@ -0,0 +1,535 @@ +#!/usr/bin/env bash +# Run repository, release, Swift, and generated-file checks. +# Usage: ./Scripts/check.sh [...] +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +base_ref="" +head_ref="HEAD" +require_release_note=false + +fail() { + echo "✗ $*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'USAGE' +Usage: + ./Scripts/check.sh doctor + ./Scripts/check.sh repo [--base-ref ] [--head-ref ] [--require-release-note] + ./Scripts/check.sh swift + ./Scripts/check.sh release + ./Scripts/check.sh generated + ./Scripts/check.sh all [--base-ref ] [--head-ref ] [--require-release-note] +USAGE +} + +parse_repo_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --base-ref) + base_ref="${2:-}" + [ -n "$base_ref" ] || fail "--base-ref requires a value" + shift 2 + ;; + --head-ref) + head_ref="${2:-}" + [ -n "$head_ref" ] || fail "--head-ref requires a value" + shift 2 + ;; + --require-release-note) + require_release_note=true + shift + ;; + *) + usage + exit 2 + ;; + esac + done +} + +first_line_matching() { + local pattern="$1" + 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 "$pattern" "$@" 2>/dev/null || true)" + if [ -n "$output" ]; then + printf '✗ %s:\n%s\n' "$label" "$output" >&2 + exit 1 + fi +} + +doctor_checks() { + swift --version + xcodebuild -version + git --version + ruby --version + rg --version | sed -n '1p' +} + +generated_checks() { + git update-index -q --refresh + + if ! git diff --quiet --exit-code || ! git diff --cached --quiet --exit-code; then + echo "✗ Tracked files changed unexpectedly:" >&2 + git status --short >&2 + exit 1 + fi + + echo "✓ No tracked generated-file drift." +} + +swift_checks() { + swift build + swift test +} + +require_release_note_check() { + if [ "${NO_RELEASE_NOTE:-}" = "1" ]; then + echo "✓ Release-note requirement skipped by NO_RELEASE_NOTE=1." + return 0 + fi + + [ -n "$base_ref" ] || fail "BASE_REF is required for release-note enforcement" + git rev-parse --verify "$base_ref" >/dev/null 2>&1 || fail "Base ref '$base_ref' was not found" + git rev-parse --verify "$head_ref" >/dev/null 2>&1 || fail "Head ref '$head_ref' was not found" + + local changed_files + changed_files="$(git diff --name-only --diff-filter=ACMR "$base_ref...$head_ref")" + if [ -z "$changed_files" ]; then + echo "✓ No changed files to inspect for release notes." + return 0 + fi + + local has_note=false + local has_material_change=false + + while IFS= read -r file; do + [ -n "$file" ] || continue + + case "$file" in + CHANGELOG.md|Sources/ContainedApp/Resources/CHANGELOG.md|Changes/Current.md|Changes/Beta.md|Changes/*.md) + has_note=true + ;; + esac + + case "$file" in + Package.swift|Package.resolved|VERSION|Sources/**|Tests/**|Packages/**|Resources/**|Scripts/**|.github/workflows/**) + has_material_change=true + ;; + esac + done <<< "$changed_files" + + if $has_material_change && ! $has_note; then + echo "✗ Material changes need a release note." >&2 + echo >&2 + echo "Changed files:" >&2 + while IFS= read -r file; do + [ -n "$file" ] && printf ' %s\n' "$file" >&2 + done <<< "$changed_files" + echo >&2 + echo "Add one of:" >&2 + echo " - Changes/Current.md for PR/build-level notes" >&2 + echo " - CHANGELOG.md plus synced Sources/ContainedApp/Resources/CHANGELOG.md for curated version notes" >&2 + echo >&2 + echo "For documentation, metadata, or dependency-only maintenance, apply the 'no-release-note' PR label so CI sets NO_RELEASE_NOTE=1." >&2 + exit 1 + fi + + echo "✓ Release-note requirement passed." +} + +repo_checks() { + echo "▸ Checking bundled changelog sync..." + cmp -s CHANGELOG.md Sources/ContainedApp/Resources/CHANGELOG.md \ + || fail "Bundled changelog is out of sync. Run ./Scripts/package.sh app debug and commit the synced resource." + + 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" + 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=( + 'Documentation/**' + 'README.md' + 'Packages/*/README.md' + 'Packages/*/Sources/*/*.docc/**' + 'Changes/**' + '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' + ) + 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 + for workflow in .github/workflows/pr.yml .github/workflows/nightly.yml .github/workflows/beta.yml .github/workflows/stable.yml; do + rg -Fq 'timeout-minutes: 30' "$workflow" || fail "$workflow must cap macOS job runtime with timeout-minutes: 30" + done + + echo "▸ Checking local Markdown links..." + ruby <<'RUBY' +root = Dir.pwd +files = [ + "README.md", + "AGENTS.md", + ".github/CONTRIBUTING.md", + ".github/pull_request_template.md", + *Dir["Documentation/**/*.md"], + *Dir["Packages/*/README.md"], + *Dir["Packages/*/Sources/*/*.docc/*.md"] +].uniq + +failed = false +files.each do |file| + next if file == "Documentation/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..." + local wiki_map="Documentation/Wiki/File-Map.md" + local wiki_sidebar="Documentation/Wiki/_Sidebar.md" + [ -f "$wiki_map" ] || fail "missing $wiki_map" + [ -f "$wiki_sidebar" ] || fail "missing $wiki_sidebar" + + required_docs=() + while IFS= read -r doc; do + required_docs+=("$doc") + done < <(find Documentation/App Documentation/Features Documentation/Architecture Documentation/Development Documentation/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 + Documentation/*) docs_index_target="${doc#Documentation/}" ;; + *) docs_index_target="../$doc" ;; + esac + rg -Fq "($docs_index_target)" Documentation/README.md || fail "Documentation/README.md 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 stale path references..." + check_no_matches "lowercase scripts path references" '(^|[^A-Za-z])(\./)?scripts/' README.md AGENTS.md Documentation Packages Sources Tests .github CODEOWNERS Package.swift Contained.xcodeproj + check_no_matches "lowercase docs path references" '(^|[^A-Za-z])(/)?docs/' README.md AGENTS.md Documentation Packages Sources Tests .github CODEOWNERS Package.swift + check_no_matches "old wiki path references" 'docs/wiki' README.md AGENTS.md Documentation Packages Sources Tests .github CODEOWNERS Package.swift + check_no_matches "old change-fragment path references" 'changes/(unreleased|beta|nightly)|changes/\*\*|Changes/unrelease[d]' README.md AGENTS.md Documentation Packages Sources Tests .github CODEOWNERS Package.swift Scripts + + echo "▸ Checking package boundary and naming invariants..." + check_no_matches "stale package names" '\b(ContainedDesignSystem|ContainedNavigation|ContainedRuntime|AppleContainerRuntime|ContainedPreviewSupport)\b' README.md AGENTS.md Documentation 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 Documentation 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|Migrated to|WS[0-9]+|previous bundle name|older build folders)\b' README.md AGENTS.md Documentation Packages Sources Tests .github + check_no_matches "app adapter internals" '\b(ContainerCommands|DockerCommands|AppleContainerClient|DockerClient|AppleContainerCLILocator|DockerCLILocator|AppleContainerCreateTranslator|DockerCreateTranslator)\b' Sources/ContainedApp Tests/ContainedAppTests Documentation .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 + empty_dirs="$(find Packages Sources Tests Documentation -path '*/.build' -prune -o -path '*/.swiftpm' -prune -o -type d -empty -print)" + [ -z "$empty_dirs" ] || fail "empty source/docs folders found: $empty_dirs" + + if $require_release_note; then + echo "▸ Checking PR release-note coverage..." + require_release_note_check + fi + + echo "✓ Repository validation passed." +} + +release_checks() { + assert_contains() { + local haystack="$1" + local needle="$2" + local label="$3" + grep -Fq -- "$needle" <<< "$haystack" || fail "$label did not contain '$needle'" + } + + assert_not_contains() { + local haystack="$1" + local needle="$2" + local label="$3" + if grep -Fq -- "$needle" <<< "$haystack"; then + fail "$label unexpectedly contained '$needle'" + fi + } + + mkdir -p .release + local tmp + tmp="$(mktemp -d .release/test-release-scripts.XXXXXX)" + trap 'rm -rf "$tmp"' RETURN + + echo "▸ Checking package version validation..." + if BUILD=abc ./Scripts/package.sh version build >/dev/null 2>&1; then + fail "package version accepted a non-numeric BUILD" + fi + + env_output="$(CHANNEL=beta BUILD=123 SHA=abcdef0 BASE_VERSION=9.8.7 ./Scripts/package.sh version env)" + assert_contains "$env_output" "BASE_VERSION=9.8.7" "package version env" + assert_contains "$env_output" "BUILD=123" "package version env" + assert_contains "$env_output" "SHA=abcdef0" "package version env" + assert_contains "$env_output" "VERSION=9.8.7-beta.123+abcdef0" "package version env" + + echo "▸ Checking release-note composition..." + local fixture_changelog="$tmp/CHANGELOG.md" + local current_changes="$tmp/Current.md" + local beta_changes="$tmp/Beta.md" + local empty_changes="$tmp/Empty.md" + cat > "$fixture_changelog" <<'MARKDOWN' +# Changelog + +## [Unreleased] - Current Build + +### Fixed + +- Build-specific fallback fix. + +## [9.8.7] - Version Notes + +### Added + +- Version-wide feature. +MARKDOWN + printf '%s\n' '### Current' '' '- Current nightly fix.' > "$current_changes" + printf '%s\n' '### Beta' '' '- Accumulated beta fix.' > "$beta_changes" + : > "$empty_changes" + + stable_notes="$(CHANGELOG="$fixture_changelog" RELEASE_NOTES="$fixture_changelog" CHANGES="$empty_changes" VERSION_VALUE=9.8.7 CHANNEL=stable ./Scripts/notes.sh body)" + assert_contains "$stable_notes" "## Full Release Notes" "stable notes" + assert_contains "$stable_notes" "Version-wide feature." "stable notes" + assert_not_contains "$stable_notes" "Changes Since Last" "stable notes" + + stable_hotfix_notes="$(CHANGELOG="$fixture_changelog" RELEASE_NOTES="$fixture_changelog" CURRENT_CHANGES_FILE="$current_changes" VERSION_VALUE=9.8.7 CHANNEL=stable ./Scripts/notes.sh body)" + assert_contains "$stable_hotfix_notes" "## Changes In This Build" "stable hotfix notes" + assert_contains "$stable_hotfix_notes" "Current nightly fix." "stable hotfix notes" + + beta_notes="$(CHANGELOG="$fixture_changelog" RELEASE_NOTES="$fixture_changelog" CHANGES="$beta_changes" VERSION_VALUE=9.8.7-beta.123+abcdef0 CHANNEL=beta ./Scripts/notes.sh body)" + assert_contains "$beta_notes" "## Changes Since Last Beta" "beta notes" + assert_contains "$beta_notes" "Accumulated beta fix." "beta notes" + assert_contains "$beta_notes" "## Full Release Notes" "beta notes" + assert_contains "$beta_notes" "Version-wide feature." "beta notes" + + nightly_notes="$(CHANGELOG="$fixture_changelog" RELEASE_NOTES="$fixture_changelog" CHANGES="$current_changes" VERSION_VALUE=9.8.7-nightly.123+abcdef0 CHANNEL=nightly ./Scripts/notes.sh body)" + assert_contains "$nightly_notes" "## Changes Since Last Nightly" "nightly notes" + assert_contains "$nightly_notes" "Current nightly fix." "nightly notes" + assert_contains "$nightly_notes" "## Full Release Notes" "nightly notes" + + echo "▸ Checking rolling-note finalization..." + local finalize_current="$tmp/finalize-current.md" + local finalize_beta="$tmp/finalize-beta.md" + printf '%s\n' '### First' '' '- Current content.' > "$finalize_current" + printf '%s\n' '### Existing' '' '- Existing beta content.' > "$finalize_beta" + CURRENT_CHANGES_FILE="$finalize_current" BETA_CHANGES_FILE="$finalize_beta" ./Scripts/notes.sh finalize nightly >/dev/null + [ ! -s "$finalize_current" ] || fail "nightly finalize did not clear current notes" + assert_contains "$(cat "$finalize_beta")" "Current content." "nightly finalized beta" + assert_contains "$(cat "$finalize_beta")" "Existing beta content." "nightly finalized beta" + CURRENT_CHANGES_FILE="$finalize_current" BETA_CHANGES_FILE="$finalize_beta" ./Scripts/notes.sh finalize beta >/dev/null + [ ! -s "$finalize_beta" ] || fail "beta finalize did not clear beta notes" + printf '%s\n' '- Hotfix.' > "$finalize_current" + CURRENT_CHANGES_FILE="$finalize_current" ./Scripts/notes.sh finalize stable >/dev/null + [ ! -s "$finalize_current" ] || fail "stable finalize did not clear current notes" + + echo "▸ Checking note collection and HTML rendering..." + mkdir -p "$tmp/changes" "$tmp/updates" + printf '%s\n' '- Second fragment.' > "$tmp/changes/20260701-b.md" + printf '%s\n' '- First fragment.' > "$tmp/changes/20260701-a.md" + fragment_output="$(./Scripts/notes.sh collect "$tmp/changes")" + assert_contains "$fragment_output" "- First fragment." "note collection" + assert_contains "$fragment_output" "- Second fragment." "note collection" + : > "$tmp/updates/Contained-test.dmg" + CHANNEL=nightly VERSION_VALUE=9.8.7-nightly.123+abcdef0 CHANGELOG="$fixture_changelog" RELEASE_NOTES="$fixture_changelog" CHANGES="$current_changes" ./Scripts/notes.sh html "$tmp/updates" >/dev/null + [ -f "$tmp/updates/Contained-test.html" ] || fail "notes html did not write archive sibling" + + echo "▸ Checking automatic channel deltas..." + local no_delta_appcast="$tmp/no-delta-nightly.xml" + local head_sha + head_sha="$(git rev-parse --short HEAD)" + cat > "$no_delta_appcast" < + + + Contained + + 1.0.0-nightly.999+${head_sha} + 999 + 1.0.0-nightly.999+${head_sha} + Current nightly notes. + + + + +XML + no_delta_notes="$(APPCAST="$no_delta_appcast" CHANNEL=nightly ./Scripts/notes.sh delta)" + assert_contains "$no_delta_notes" "No channel-specific changes were recorded for this build." "empty automatic nightly notes" + + echo "▸ Checking appcast promotion and validation..." + local promoted="$tmp/promoted.xml" + local beta_only="$tmp/beta.xml" + local stable_only="$tmp/stable.xml" + local nightly="$tmp/nightly.xml" + cat > "$promoted" <<'XML' + + + + Contained + + 9.8.7-beta.123+abcdef0 + 123 + 9.8.7-beta.123+abcdef0 + Beta notes. + + + + 9.8.7-nightly.124+abcdef1 + 124 + 9.8.7-nightly.124+abcdef1 + Nightly notes. + + + + +XML + cat > "$beta_only" <<'XML' + + + + Contained + + 9.8.7-beta.123+abcdef0 + 123 + 9.8.7-beta.123+abcdef0 + Beta notes. + + + + +XML + cat > "$stable_only" <<'XML' + + + + Contained + + 9.8.7 + 123 + 9.8.7 + Stable notes. + + + + +XML + + CHANNEL=beta ./Scripts/appcast.sh validate "$beta_only" >/dev/null + CHANNEL=stable ./Scripts/appcast.sh validate "$stable_only" >/dev/null + ./Scripts/appcast.sh promote --non-nightly-only "$promoted" "$nightly" >/dev/null + promoted_output="$(cat "$nightly")" + assert_contains "$promoted_output" "9.8.7-beta.123+abcdef0" "promoted appcast" + assert_not_contains "$promoted_output" "9.8.7-nightly.124+abcdef1" "promoted appcast" + CHANNEL=nightly ./Scripts/appcast.sh validate "$nightly" >/dev/null + + echo "✓ Release script fixture tests passed." +} + +command="${1:-}" +[ -n "$command" ] || { usage; exit 2; } +shift + +case "$command" in + doctor) + doctor_checks + ;; + repo) + parse_repo_args "$@" + repo_checks + ;; + swift) + swift_checks + ;; + release) + release_checks + ;; + generated) + generated_checks + ;; + all) + parse_repo_args "$@" + doctor_checks + repo_checks + release_checks + swift_checks + generated_checks + ;; + *) + usage + exit 2 + ;; +esac diff --git a/Scripts/notes.sh b/Scripts/notes.sh new file mode 100755 index 00000000..6e2b836e --- /dev/null +++ b/Scripts/notes.sh @@ -0,0 +1,443 @@ +#!/usr/bin/env bash +# Compose, render, collect, and finalize rolling release notes. +# Usage: ./Scripts/notes.sh [...] +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +fail() { + echo "✗ $*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'USAGE' +Usage: + ./Scripts/notes.sh body + ./Scripts/notes.sh html [updates-dir] + ./Scripts/notes.sh delta [stable|beta|nightly] [head-ref] + ./Scripts/notes.sh collect [] [...] + ./Scripts/notes.sh finalize +USAGE +} + +trim_markdown() { + ruby -e ' +text = STDIN.read.lines.map(&:rstrip).join("\n") +text = text.gsub(/\A\s+/, "").gsub(/\s+\z/, "") +puts text unless text.empty? +' +} + +file_has_content() { + local file="$1" + [ -f "$file" ] || return 1 + ruby -e 'exit(File.read(ARGV.fetch(0)).strip.empty? ? 1 : 0)' "$file" +} + +read_markdown_file() { + local file="$1" + [ -f "$file" ] || return 0 + trim_markdown < "$file" +} + +extract_section() { + local file="$1" + local version="$2" + awk -v version="$version" ' + BEGIN { in_section=0 } + /^## / { + if (in_section) exit + if (index($0, version) > 0 || index($0, "[" version "]") > 0) { in_section=1; next } + } + in_section { print } + ' "$file" | trim_markdown +} + +channel_title() { + case "$1" in + beta) printf 'Beta' ;; + nightly) printf 'Nightly' ;; + *) printf 'Release' ;; + esac +} + +default_changes_file() { + case "$1" in + nightly) printf '%s\n' "${CURRENT_CHANGES_FILE:-Changes/Current.md}" ;; + beta) printf '%s\n' "${BETA_CHANGES_FILE:-Changes/Beta.md}" ;; + stable) printf '%s\n' "${CURRENT_CHANGES_FILE:-Changes/Current.md}" ;; + *) return 1 ;; + esac +} + +collect_files() { + local range="" + local paths=() + + if [ "$#" -gt 0 ] && [[ "$1" == *..* ]]; then + range="$1" + shift + fi + paths=("$@") + [ "${#paths[@]}" -gt 0 ] || paths=("Changes/Current.md") + + emit_file() { + local file="$1" + [ -f "$file" ] || return 0 + read_markdown_file "$file" + printf '\n' + } + + for path in "${paths[@]}"; do + case "$path" in + ""|*..*) + fail "notes paths must not contain '..': $path" + ;; + esac + + if [ -n "$range" ]; then + git diff --name-only --diff-filter=AM "$range" -- "$path" \ + | LC_ALL=C sort -u \ + | while IFS= read -r file; do + case "$file" in + *.md) emit_file "$file" ;; + esac + done + elif [ -d "$path" ]; then + find "$path" -type f -name '*.md' \ + | LC_ALL=C sort \ + | while IFS= read -r file; do emit_file "$file"; done + else + emit_file "$path" + fi + done | trim_markdown +} + +compose_body() { + local changelog="${CHANGELOG:-CHANGELOG.md}" + local release_notes_file="${RELEASE_NOTES:-$changelog}" + local changes_file="${CHANGES:-}" + local changes_dir="${CHANGES_DIR:-}" + local changes_was_set="${CHANGES+x}" + local changes_dir_was_set="${CHANGES_DIR+x}" + local version_value="${VERSION_VALUE:-${VERSION:-$(cat VERSION 2>/dev/null || true)}}" + local channel_value="${CHANNEL:-}" + + [ -f "$changelog" ] || fail "$changelog not found" + [ -f "$release_notes_file" ] || fail "Release notes file '$release_notes_file' not found" + [ -n "$version_value" ] || fail "VERSION is empty" + + local base="$version_value" + base="${base%%+*}" + base="${base%%-*}" + + if [ -z "$channel_value" ]; then + case "$version_value" in + *-nightly.*) channel_value="nightly" ;; + *-beta.*) channel_value="beta" ;; + *) channel_value="stable" ;; + esac + fi + + local full_fragment="" + if [ "$base" != "$version_value" ]; then + full_fragment="$(extract_section "$release_notes_file" "$base")" + else + full_fragment="$(extract_section "$release_notes_file" "$version_value")" + fi + if [ -z "$full_fragment" ] && [ "$base" != "$version_value" ]; then + full_fragment="$(extract_section "$release_notes_file" "$version_value")" + fi + if [ -z "$full_fragment" ]; then + full_fragment="$(extract_section "$release_notes_file" "Unreleased")" + fi + + local changes_fragment="" + if [ -n "$changes_dir" ]; then + changes_fragment="$(collect_files "$changes_dir")" + elif [ -n "$changes_file" ]; then + changes_fragment="$(extract_section "$changes_file" "$version_value")" + if [ -z "$changes_fragment" ]; then + changes_fragment="$(extract_section "$changes_file" "$channel_value")" + fi + if [ -z "$changes_fragment" ]; then + changes_fragment="$(extract_section "$changes_file" "Unreleased")" + fi + if [ -z "$changes_fragment" ]; then + changes_fragment="$(read_markdown_file "$changes_file")" + fi + else + case "$channel_value" in + nightly|beta) + changes_file="$(default_changes_file "$channel_value")" + changes_fragment="$(read_markdown_file "$changes_file")" + ;; + stable) + changes_file="$(default_changes_file stable)" + changes_fragment="$(read_markdown_file "$changes_file")" + ;; + esac + fi + + if [ -n "$changes_fragment" ]; then + if [ "$channel_value" = "stable" ]; then + if [ -n "$changes_was_set" ] || [ -n "$changes_dir_was_set" ]; then + changes_fragment="" + else + printf '## Changes In This Build\n\n' + fi + else + printf '## Changes Since Last %s\n\n' "$(channel_title "$channel_value")" + fi + if [ -n "$changes_fragment" ]; then + printf '%s\n\n' "$changes_fragment" + fi + fi + + printf '## Full Release Notes\n\n' + if [ -n "$full_fragment" ]; then + printf '%s\n' "$full_fragment" + else + printf 'No full release notes were found for %s.\n' "$version_value" + fi +} + +write_html() { + local updates_dir="${1:-updates}" + local changelog="${CHANGELOG:-CHANGELOG.md}" + local version_value="${VERSION_VALUE:-${VERSION:-$(cat VERSION 2>/dev/null || true)}}" + + [ -d "$updates_dir" ] || fail "Updates dir '$updates_dir' not found" + [ -f "$changelog" ] || fail "$changelog not found" + [ -n "$version_value" ] || fail "VERSION is empty" + + local fragment + fragment="$(VERSION_VALUE="$version_value" CHANGELOG="$changelog" compose_body)" + + local html + html="$(printf '%s\n' "$fragment" | awk ' + BEGIN { in_list=0 } + /^## / { + if (in_list) { print ""; in_list=0 } + sub(/^## /, "") + print "

" $0 "

" + next + } + /^### / { + if (in_list) { print ""; in_list=0 } + sub(/^### /, "") + print "

" $0 "

" + next + } + /^#### / { + if (in_list) { print ""; in_list=0 } + sub(/^#### /, "") + print "

" $0 "

" + next + } + /^[[:space:]]*- / { + if (!in_list) { print "
    "; in_list=1 } + sub(/^[[:space:]]*- /, "") + print "
  • " $0 "
  • " + next + } + /^[[:space:]]*$/ { + if (in_list) { print "
"; in_list=0 } + next + } + { + if (in_list) { print ""; in_list=0 } + print "

" $0 "

" + } + END { if (in_list) print "" } + ')" + + local found=0 + for archive in "$updates_dir"/*.{dmg,zip,tar.gz,tgz}; do + [ -e "$archive" ] || continue + found=1 + local base="$archive" + case "$base" in + *.tar.gz) base="${base%.tar.gz}" ;; + *) base="${base%.*}" ;; + esac + printf '%s\n' "$html" > "$base.html" + echo "✓ Wrote ${base}.html" + done + + [ "$found" -eq 1 ] || fail "No archives found in $updates_dir" +} + +emit_delta() { + local channel="${1:-${CHANNEL:-nightly}}" + local head_ref="${2:-${HEAD_REF:-HEAD}}" + local appcast="${APPCAST:-appcast.xml}" + local changelog="${CHANGELOG:-CHANGELOG.md}" + + [ -f "$appcast" ] || exit 0 + + local previous_ref + previous_ref="$( + APPCAST="$appcast" CHANNEL_VALUE="$channel" ruby <<'RUBY' +require "rexml/document" +require "rexml/xpath" + +path = ENV.fetch("APPCAST") +channel = ENV.fetch("CHANNEL_VALUE") +namespaces = { "sparkle" => "http://www.andymatuschak.org/xml-namespaces/sparkle" } +doc = REXML::Document.new(File.read(path)) + +items = REXML::XPath.match(doc, "//item").map do |item| + build = REXML::XPath.first(item, "sparkle:version", namespaces)&.text.to_s.strip + short = REXML::XPath.first(item, "sparkle:shortVersionString", namespaces)&.text.to_s.strip + next if build.empty? || short.empty? + + channel_match = case channel + when "nightly" then short.include?("-nightly.") + when "beta" then short.include?("-beta.") + when "stable" then !short.match?(/-(beta|nightly)\./) + else false + end + next unless channel_match + + { build: build.to_i, short: short } +end.compact + +selected = items.max_by { |item| item[:build] } +sha = selected&.dig(:short).to_s.split("+", 2)[1].to_s +puts sha if sha.match?(/\A[0-9a-f]{7,40}\z/) +RUBY + )" + + [ -n "$previous_ref" ] || exit 0 + git rev-parse --verify "$previous_ref^{commit}" >/dev/null 2>&1 || exit 0 + git rev-parse --verify "$head_ref^{commit}" >/dev/null 2>&1 || exit 0 + + local range="$previous_ref..$head_ref" + mkdir -p .release + local tmp + tmp="$(mktemp -d .release/changes-since-release.XXXXXX)" + trap 'rm -rf "$tmp"' RETURN + + for candidate in "Changes/Current.md" "Changes/Beta.md"; do + if [ -e "$candidate" ]; then + collect_files "$range" "$candidate" >> "$tmp/fragments.md" + fi + done + + if [ -s "$tmp/fragments.md" ]; then + trim_markdown < "$tmp/fragments.md" + exit 0 + fi + + case "$changelog" in + ""|/*|*..*) + echo "- No channel-specific changes were recorded for this build." + exit 0 + ;; + esac + + extract_unreleased() { + awk ' + BEGIN { in_section=0 } + /^## / { + if (in_section) exit + if (index($0, "Unreleased") > 0 || index($0, "[Unreleased]") > 0) { in_section=1; next } + } + in_section { print } + ' + } + + git show "$previous_ref:$changelog" 2>/dev/null | extract_unreleased > "$tmp/old.md" || : + if [ "$head_ref" = "HEAD" ] && [ -f "$changelog" ]; then + extract_unreleased < "$changelog" > "$tmp/new.md" + elif ! git show "$head_ref:$changelog" 2>/dev/null | extract_unreleased > "$tmp/new.md"; then + extract_unreleased < "$changelog" > "$tmp/new.md" + fi + + diff -U0 "$tmp/old.md" "$tmp/new.md" \ + | awk '/^\+/ && !/^\+\+\+/ { sub(/^\+/, ""); print }' \ + | trim_markdown > "$tmp/delta.md" || : + + if [ -s "$tmp/delta.md" ]; then + cat "$tmp/delta.md" + else + echo "- No channel-specific changes were recorded for this build." + fi +} + +prepend_current_to_beta() { + local current="${CURRENT_CHANGES_FILE:-Changes/Current.md}" + local beta="${BETA_CHANGES_FILE:-Changes/Beta.md}" + file_has_content "$current" || { + echo "✓ No current release note content to roll into beta." + return 0 + } + + local current_text + local beta_text + current_text="$(read_markdown_file "$current")" + beta_text="$(read_markdown_file "$beta")" + + if [ -n "$beta_text" ]; then + printf '%s\n\n%s\n' "$current_text" "$beta_text" > "$beta" + else + printf '%s\n' "$current_text" > "$beta" + fi + : > "$current" + echo "✓ Rolled $current into $beta." +} + +clear_file() { + local file="$1" + mkdir -p "$(dirname "$file")" + : > "$file" +} + +finalize_notes() { + local channel="${1:?channel (nightly|beta|stable)}" + case "$channel" in + nightly) + prepend_current_to_beta + ;; + beta) + clear_file "${BETA_CHANGES_FILE:-Changes/Beta.md}" + echo "✓ Cleared ${BETA_CHANGES_FILE:-Changes/Beta.md} after beta consumption." + ;; + stable) + clear_file "${CURRENT_CHANGES_FILE:-Changes/Current.md}" + echo "✓ Cleared ${CURRENT_CHANGES_FILE:-Changes/Current.md} after stable consumption." + ;; + *) + fail "Unknown finalize channel '$channel' (expected nightly, beta, or stable)" + ;; + esac +} + +command="${1:-}" +[ -n "$command" ] || { usage; exit 2; } +shift + +case "$command" in + body) + compose_body "$@" + ;; + html) + write_html "$@" + ;; + delta) + emit_delta "$@" + ;; + collect) + collect_files "$@" + ;; + finalize) + finalize_notes "$@" + ;; + *) + usage + exit 2 + ;; +esac diff --git a/Scripts/package.sh b/Scripts/package.sh new file mode 100755 index 00000000..30b19ddb --- /dev/null +++ b/Scripts/package.sh @@ -0,0 +1,383 @@ +#!/usr/bin/env bash +# Build, package, sign, notarize, and print release values for Contained. +# Usage: ./Scripts/package.sh [...] +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +fail() { + echo "✗ $*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'USAGE' +Usage: + ./Scripts/package.sh version [base|build|sha|version|env] [stable|beta|nightly] + ./Scripts/package.sh app [debug|release] + ./Scripts/package.sh smoke [Contained.app] + ./Scripts/package.sh dmg [volume-name] + ./Scripts/package.sh signed + ./Scripts/package.sh notarized +USAGE +} + +version_info() { + local command="${1:-version}" + local channel="${CHANNEL:-${2:-nightly}}" + local base="${BASE_VERSION:-$(cat VERSION 2>/dev/null || echo 1.0.0)}" + local build="${BUILD:-}" + local sha="${SHA:-}" + local build_source_ref="${BUILD_SOURCE_REF:-}" + + if [ -z "$sha" ]; then + sha="$(git rev-parse --short HEAD 2>/dev/null || echo local)" + fi + + build_from_source_ref() { + [ -n "$build_source_ref" ] || return 1 + local appcast + appcast="$(git show "$build_source_ref:appcast.xml" 2>/dev/null || true)" + [ -n "$appcast" ] || return 1 + + SHORT_SHA="$sha" perl -0ne ' + my $sha = $ENV{"SHORT_SHA"}; + while (m{.*?}gs) { + my $item = $&; + next unless index($item, $sha) >= 0; + if ($item =~ m{([^<]+)}) { + print "$1\n"; + exit 0; + } + } + exit 1; + ' <<<"$appcast" + } + + if [ -z "$build" ]; then + build="$(build_from_source_ref || git rev-list --count HEAD 2>/dev/null || echo 1)" + fi + + case "$build" in + ''|*[!0-9]*) + fail "Build number must be a positive integer, got '$build'" + ;; + esac + + case "$command" in + base) + printf '%s\n' "$base" + ;; + build) + printf '%s\n' "$build" + ;; + sha) + printf '%s\n' "$sha" + ;; + version) + case "$channel" in + stable) + printf '%s\n' "$base" + ;; + beta) + printf '%s-beta.%s+%s\n' "$base" "$build" "$sha" + ;; + nightly) + printf '%s-nightly.%s+%s\n' "$base" "$build" "$sha" + ;; + *) + fail "Unknown channel '$channel' (expected stable, beta, or nightly)" + ;; + esac + ;; + env) + case "$channel" in + stable|beta|nightly) ;; + *) fail "Unknown channel '$channel' (expected stable, beta, or nightly)" ;; + esac + printf 'BASE_VERSION=%s\n' "$base" + printf 'BUILD=%s\n' "$build" + printf 'SHA=%s\n' "$sha" + printf 'VERSION=' + CHANNEL="$channel" BUILD="$build" SHA="$sha" BASE_VERSION="$base" version_info version + ;; + *) + usage + exit 2 + ;; + esac +} + +sync_changelog_resource() { + local source="CHANGELOG.md" + local target="Sources/ContainedApp/Resources/CHANGELOG.md" + [ -f "$source" ] || fail "$source not found" + + if cmp -s "$source" "$target"; then + echo "✓ Bundled changelog is already in sync." + else + mkdir -p "$(dirname "$target")" + cp "$source" "$target" + echo "✓ Synced $source -> $target" + fi +} + +build_app() { + local config="${1:-release}" + local app="Contained.app" + local current_release_notes="CurrentReleaseNotes.md" + local channel="${CHANNEL:-nightly}" + local version="${VERSION:-$(CHANNEL="$channel" version_info version)}" + local build="${BUILD:-$(version_info build)}" + + sync_changelog_resource + + echo "▸ Building ($config)..." + swift build -c "$config" + + local bin_path + bin_path="$(swift build -c "$config" --show-bin-path)/Contained" + [ -x "$bin_path" ] || fail "Built binary not found at $bin_path" + + echo "▸ Assembling ${app}..." + rm -rf "$app" + mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" + cp "$bin_path" "$app/Contents/MacOS/Contained" + + local icon_src="Resources/${channel}.icon" + if [ -d "$icon_src" ]; then + echo "▸ Compiling icon ($channel)..." + local tmpicon + tmpicon="$(mktemp -d)" + cp -R "$icon_src" "$tmpicon/Contained.icon" + xcrun actool "$tmpicon/Contained.icon" \ + --compile "$app/Contents/Resources" \ + --app-icon Contained \ + --output-partial-info-plist "$tmpicon/icon.plist" \ + --platform macosx --minimum-deployment-target 26 \ + --errors --warnings >/dev/null + rm -rf "$tmpicon" + else + echo "⚠ No icon source at $icon_src - bundling without an app icon." + fi + + local build_products + build_products="$(swift build -c "$config" --show-bin-path)" + local framework_src="$build_products/Sparkle.framework" + if [ -d "$framework_src" ]; then + mkdir -p "$app/Contents/Frameworks" + cp -R "$framework_src" "$app/Contents/Frameworks/" + install_name_tool -add_rpath "@executable_path/../Frameworks" "$app/Contents/MacOS/Contained" 2>/dev/null || true + fi + + for bundle_name in Contained_ContainedApp.bundle Contained_Contained.bundle; do + local bundle_res="$build_products/$bundle_name" + if [ -d "$bundle_res" ]; then + cp -R "$bundle_res" "$app/Contents/Resources/" || true + break + fi + done + + echo "▸ Generating bundled release notes..." + CHANNEL="$channel" VERSION_VALUE="$version" ./Scripts/notes.sh body > "$app/Contents/Resources/$current_release_notes" + + cat > "$app/Contents/Info.plist" < + + + + CFBundleNameContained + CFBundleDisplayNameContained + CFBundleIdentifiercom.contained.app + CFBundleExecutableContained + CFBundlePackageTypeAPPL + CFBundleShortVersionString$version + CFBundleVersion$build + LSMinimumSystemVersion26.0 + NSPrincipalClassNSApplication + NSHighResolutionCapable + CFBundleIconFileContained + CFBundleIconNameContained + LSApplicationCategoryTypepublic.app-category.developer-tools + NSHumanReadableCopyright© 2026 Contained. All rights reserved. + SUFeedURLhttps://raw.githubusercontent.com/tdeverx/contained-app/nightly/appcast.xml + SUPublicEDKeyM/wt6mIO/OCxhM5wK8Le0jCtaCBIhlRh2aBWv0jkq8o= + SUEnableInstallerLauncherService + + +PLIST + + codesign --force --deep --sign - "$app" >/dev/null 2>&1 || true + + echo "✓ Built $app ($version build $build)" + echo " Run with: open $app" +} + +smoke_bundle() { + local app="${1:-Contained.app}" + local plist="$app/Contents/Info.plist" + local binary="$app/Contents/MacOS/Contained" + local sparkle_framework="$app/Contents/Frameworks/Sparkle.framework" + local current_release_notes="$app/Contents/Resources/CurrentReleaseNotes.md" + + [ -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" + + local resource_changelog="" + for bundle_name in Contained_ContainedApp.bundle Contained_Contained.bundle; do + local 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" + [ -s "$current_release_notes" ] || fail "CurrentReleaseNotes.md resource is missing or empty" + [ -d "$sparkle_framework" ] || fail "Sparkle.framework is missing from the bundle" + + plist_value() { + /usr/libexec/PlistBuddy -c "Print :$1" "$plist" + } + + local short_version + local build_number + short_version="$(plist_value CFBundleShortVersionString)" + build_number="$(plist_value CFBundleVersion)" + + if [ -n "${VERSION:-}" ] && [ "$short_version" != "$VERSION" ]; then + fail "CFBundleShortVersionString '$short_version' does not match VERSION '$VERSION'" + fi + + case "$build_number" in + ''|*[!0-9]*) + fail "CFBundleVersion must be a numeric build number, got '$build_number'" + ;; + esac + + if [ -n "${BUILD:-}" ] && [ "$build_number" != "$BUILD" ]; then + fail "CFBundleVersion '$build_number' does not match BUILD '$BUILD'" + fi + + codesign --verify --deep --strict "$app" >/dev/null 2>&1 || fail "codesign verification failed for '$app'" + + echo "✓ Bundle validation passed for $app ($short_version build $build_number)." +} + +make_dmg() { + local channel="${1:?channel (stable|beta|nightly)}" + local app="${2:?path to .app}" + local out="${3:?output dmg path}" + local volname="${4:-Contained}" + local dmgdir="$ROOT/Resources/dmg" + + local titlebar=28 + local bg_w=400 + local bg_h=528 + local win_h=$((bg_h + titlebar)) + + local bg1="$dmgdir/background-${channel}.png" + local bg2="$dmgdir/background-${channel}@2x.png" + if [ ! -f "$bg1" ]; then + bg1="$dmgdir/background-stable.png" + bg2="$dmgdir/background-stable@2x.png" + fi + + local work + work="$(mktemp -d)" + trap 'rm -rf "$work"' RETURN + local bg="$work/background.tiff" + if [ -f "$bg2" ]; then + tiffutil -cathidpicheck "$bg1" "$bg2" -out "$bg" + else + cp "$bg1" "$bg" + fi + + command -v create-dmg >/dev/null 2>&1 || fail "create-dmg not found (brew install create-dmg)" + + local app_name + app_name="$(basename "$app")" + rm -f "$out" + create-dmg \ + --volname "$volname" \ + --background "$bg" \ + --window-pos 200 120 \ + --window-size "$bg_w" "$win_h" \ + --icon-size 104 \ + --icon "$app_name" 200 152 \ + --app-drop-link 200 376 \ + --hide-extension "$app_name" \ + --no-internet-enable \ + "$out" \ + "$app" +} + +distribution_package() { + local notarize="$1" + : "${DEV_ID:?Set DEV_ID to your Developer ID Application identity}" + if [ "$notarize" = "true" ]; then + : "${KEYCHAIN_PROFILE:?Set KEYCHAIN_PROFILE to your notarytool keychain profile}" + fi + + local app="Contained.app" + local dmg="Contained.dmg" + local entitlements="Scripts/Contained.entitlements" + local channel="${CHANNEL:-stable}" + + echo "▸ Building release bundle..." + CHANNEL="$channel" build_app release + + echo "▸ Code-signing (hardened runtime)..." + codesign --force --deep --options runtime --timestamp \ + --entitlements "$entitlements" --sign "$DEV_ID" "$app" + codesign --verify --strict --verbose=2 "$app" + + echo "▸ Building DMG..." + rm -f "$dmg" + hdiutil create -volname "Contained" -srcfolder "$app" -ov -format UDZO "$dmg" + + echo "▸ Signing DMG..." + codesign --force --timestamp --sign "$DEV_ID" "$dmg" + + if [ "$notarize" = "true" ]; then + echo "▸ Notarizing (this can take a few minutes)..." + xcrun notarytool submit "$dmg" --keychain-profile "$KEYCHAIN_PROFILE" --wait + + echo "▸ Stapling..." + xcrun stapler staple "$dmg" + xcrun stapler staple "$app" + echo "✓ $dmg is signed, notarized, and stapled." + else + echo "✓ $dmg is signed." + fi +} + +command="${1:-}" +[ -n "$command" ] || { usage; exit 2; } +shift + +case "$command" in + version) + version_info "$@" + ;; + app) + build_app "${1:-release}" + ;; + smoke) + smoke_bundle "$@" + ;; + dmg) + make_dmg "$@" + ;; + signed) + distribution_package false + ;; + notarized) + distribution_package true + ;; + *) + usage + exit 2 + ;; +esac 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/CommandPreviewBar.swift b/Sources/Contained/DesignSystem/CommandPreviewBar.swift deleted file mode 100644 index d00083ab..00000000 --- a/Sources/Contained/DesignSystem/CommandPreviewBar.swift +++ /dev/null @@ -1,38 +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. -struct CommandPreviewBar: View { - let command: [String] - @State private var copied = false - - private var rendered: String { (["container"] + command).joined(separator: " ") } - - var body: some View { - HStack(spacing: 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: 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("Copy command") - .accessibilityLabel(copied ? "Copied" : "Copy command") - } - .padding(.horizontal, Tokens.Space.s) - .padding(.vertical, Tokens.Space.s) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) - } -} diff --git a/Sources/Contained/DesignSystem/ErrorToast.swift b/Sources/Contained/DesignSystem/ErrorToast.swift deleted file mode 100644 index 2b1a0203..00000000 --- a/Sources/Contained/DesignSystem/ErrorToast.swift +++ /dev/null @@ -1,19 +0,0 @@ -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 - - var body: some View { - HStack(spacing: Tokens.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) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } -} diff --git a/Sources/Contained/DesignSystem/ExteriorShadow.swift b/Sources/Contained/DesignSystem/ExteriorShadow.swift deleted file mode 100644 index 3fe53f7f..00000000 --- a/Sources/Contained/DesignSystem/ExteriorShadow.swift +++ /dev/null @@ -1,23 +0,0 @@ -import SwiftUI - -struct ExteriorShadow: View { - var cornerRadius: CGFloat - var color: Color - var radius: CGFloat - var y: CGFloat - - var body: some View { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - ZStack { - shape - .fill(color) - .blur(radius: radius) - .offset(y: y) - shape - .fill(.black) - .blendMode(.destinationOut) - } - .compositingGroup() - .allowsHitTesting(false) - } -} diff --git a/Sources/Contained/DesignSystem/GlassButton.swift b/Sources/Contained/DesignSystem/GlassButton.swift deleted file mode 100644 index 1d110b28..00000000 --- a/Sources/Contained/DesignSystem/GlassButton.swift +++ /dev/null @@ -1,188 +0,0 @@ -import SwiftUI - -private struct GlassButtonItemHoverEnabledKey: EnvironmentKey { - static let defaultValue = true -} - -private extension EnvironmentValues { - var glassButtonItemHoverEnabled: Bool { - get { self[GlassButtonItemHoverEnabledKey.self] } - set { self[GlassButtonItemHoverEnabledKey.self] = newValue } - } -} - -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() -} - -/// 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 { - var role: ButtonRole? = nil - var tint: Color? = nil - var help: String = "" - var isCancel: Bool = false - var isLabel: Bool = false - var isIcon = false - var action: (() -> Void)? = nil - @ViewBuilder var label: () -> Label - - @State private var hovering = false - @Environment(\.glassButtonItemHoverEnabled) private var hoverEnabled - @Environment(\.colorScheme) private var colorScheme - - private var itemForegroundStyle: AnyShapeStyle { - if role == .destructive { return AnyShapeStyle(Color.red) } - if let tint { return AnyShapeStyle(tint) } - return AnyShapeStyle(.primary) - } - - 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 - self.tint = tint - self.help = help - self.isCancel = isCancel - self.isLabel = isLabel - self.isIcon = isIcon - self.action = action - self.label = label - } - - private var content: some View { - label() - .font(.body.weight(.medium)) - .foregroundStyle(itemForegroundStyle) - .padding(Tokens.Toolbar.iconInnerPadding) - .frame(width: isIcon ? Tokens.Toolbar.buttonItemHeight : nil, - height: Tokens.Toolbar.buttonItemHeight) - .contentShape(Rectangle()) - .background { - Capsule(style: .continuous) - .fill( - hoverEnabled && hovering && !isLabel - ? AppMaterial.toolbarInteractiveHoverFill(for: colorScheme) - : .clear - ) - } - .onHover { hovering = isLabel ? false : $0 } - .animation(.easeOut(duration: 0.15), value: hovering) - } - - var body: some View { - Group { - if let action, !isLabel { - Button(role: role, action: action) { - content - } - .buttonStyle(.plain) - .buttonBorderShape(.capsule) - .tint(role == .destructive ? .red : tint) - } else { - content - } - } - .help(help) - .accessibilityLabel(help.isEmpty ? "Button" : help) - .keyboardShortcut(isCancel && action != nil ? .cancelAction : nil) - } -} - -/// 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 - @ViewBuilder var content: () -> Content - - var body: some View { - HStack(spacing: spacing) { content() } - .font(.body.weight(.medium)) - .padding(Tokens.Toolbar.iconInnerPadding) - .frame(height: Tokens.Toolbar.buttonItemHeight) - .contentShape(Rectangle()) - } -} - -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 - self.tint = tint - self.help = help - self.isCancel = isCancel - self.isLabel = isLabel - self.isIcon = true - self.action = action - self.label = { Image(systemName: systemName) } - } -} - -/// 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 - /// Set `false` for a static glass container (no hover treatment) — e.g. vanity toolbar chrome. - var interactive: Bool = true - @ViewBuilder var content: () -> Content - - @State private var hovering = false - @Environment(\.colorScheme) private var colorScheme - @Environment(\.buttonTintStyle) private var tintStyle - - var body: some View { - let shape = Capsule(style: .continuous) - HStack(spacing: spacing) { content() } - .padding(.horizontal, Tokens.Toolbar.iconInnerPadding) - .frame(height: height) - .frame(minWidth: minWidth) - .background { - if singleItem && interactive { - Capsule(style: .continuous) - .fill( - hovering - ? AppMaterial.toolbarInteractiveHoverFill(for: colorScheme) - : .clear - ) - } - } - .environment(\.glassButtonItemHoverEnabled, !singleItem && interactive) - .onHover { if interactive { hovering = $0 } } - .background { tintLayer(in: shape) } - .toolbarControlMaterial(in: shape) - .animation(.spring(response: 0.18, dampingFraction: 0.82), value: hovering) - } - - @ViewBuilder - private func tintLayer(in shape: Capsule) -> some View { - if tintStyle.enabled { - shape - .fill(tintFillStyle(tintStyle.tint.color)) - .blendMode(tintStyle.blendMode.blendMode) - .clipShape(shape) - } - } - - private func tintFillStyle(_ color: Color) -> AnyShapeStyle { - if tintStyle.gradient { - let radians = tintStyle.gradientAngle * .pi / 180 - let dx = cos(radians) / 2 - let dy = sin(radians) / 2 - return AnyShapeStyle(LinearGradient( - colors: [color.opacity(tintStyle.opacity * 1.35), color.opacity(tintStyle.opacity * 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(tintStyle.opacity)) - } -} diff --git a/Sources/Contained/DesignSystem/GlassOptionTile.swift b/Sources/Contained/DesignSystem/GlassOptionTile.swift deleted file mode 100644 index 3d819313..00000000 --- a/Sources/Contained/DesignSystem/GlassOptionTile.swift +++ /dev/null @@ -1,80 +0,0 @@ -import SwiftUI - -struct GlassOptionTile: View { - 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 - - var body: some View { - Button { - guard enabled else { return } - action() - } label: { - VStack(alignment: .leading, spacing: Tokens.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) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.callout.weight(.medium)) - .foregroundStyle(enabled ? .primary : .secondary) - .lineLimit(1) - - Text(subtitle ?? " ") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .opacity(subtitle == nil ? 0 : 1) - .accessibilityHidden(subtitle == nil) - } - } - .padding(Tokens.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)) - .optionalMatchedGeometry(id: matchedID, namespace: matchedNamespace) - } - .buttonStyle(.plain) - .disabled(!enabled) - .opacity(enabled ? 1 : 0.5) - .accessibilityLabel(title) - .accessibilityHint(subtitle ?? "") - } -} - -private struct OptionalMatchedGeometry: ViewModifier { - var id: String? - var namespace: Namespace.ID? - - @ViewBuilder - func body(content: Content) -> some View { - if let id, let namespace { - content.matchedGeometryEffect(id: id, in: namespace, properties: .frame) - } else { - content - } - } -} - -private extension View { - func optionalMatchedGeometry(id: String?, namespace: Namespace.ID?) -> some View { - modifier(OptionalMatchedGeometry(id: id, namespace: namespace)) - } - - func glassOptionTileSurface(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/Sources/Contained/DesignSystem/GlassRowMenu.swift b/Sources/Contained/DesignSystem/GlassRowMenu.swift deleted file mode 100644 index 560752db..00000000 --- a/Sources/Contained/DesignSystem/GlassRowMenu.swift +++ /dev/null @@ -1,26 +0,0 @@ -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 - - var body: some View { - Menu { - content() - } label: { - Image(systemName: systemImage) - .font(.body.weight(.medium)) - .frame(width: Tokens.IconSize.rowMenu, height: Tokens.IconSize.rowMenu) - } - .menuStyle(.button) - .buttonStyle(.glass) - .buttonBorderShape(.circle) - .menuIndicator(.hidden) - .fixedSize() - .accessibilityLabel(accessibilityLabel) - } -} 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/GraphMetric.swift b/Sources/Contained/DesignSystem/GraphMetric.swift deleted file mode 100644 index 8db72695..00000000 --- a/Sources/Contained/DesignSystem/GraphMetric.swift +++ /dev/null @@ -1,68 +0,0 @@ -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). -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 "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" - } - } - - 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 raw value plotted for this metric from a stats delta. - func value(from delta: StatsDelta) -> Double { - switch self { - case .cpu: return delta.cpuCoreFraction - case .memory: return delta.memoryFraction - case .netRx: return delta.netRxBytesPerSec - case .netTx: return delta.netTxBytesPerSec - case .diskRead: return delta.blockReadBytesPerSec - case .diskWrite: return delta.blockWriteBytesPerSec - } - } - - /// 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 { - switch self { - case .cpu: return Format.percent(delta.cpuCoreFraction) - case .memory: return Format.percent(delta.memoryFraction) - 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) -> String { - switch self { - case .cpu: return Format.percent(delta.cpuCoreFraction) - case .memory: return Format.percent(delta.memoryFraction) - 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) - } - } -} diff --git a/Sources/Contained/DesignSystem/InfoButton.swift b/Sources/Contained/DesignSystem/InfoButton.swift deleted file mode 100644 index c1844d79..00000000 --- a/Sources/Contained/DesignSystem/InfoButton.swift +++ /dev/null @@ -1,45 +0,0 @@ -import SwiftUI - -/// 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 - @Environment(\.modalMaterial) private var modalMaterial - @State private var showing = false - - init(_ text: String, visible: Bool = true) { - self.text = text - self.visible = visible - } - - var body: some View { - if app.settings.showInfoTips { - 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("More info") - .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(Tokens.Space.m) - .background { - VisualEffectBackground(material: modalMaterial.nsMaterial, blendingMode: .withinWindow) - } - .presentationBackground(.clear) - } - } - } -} diff --git a/Sources/Contained/DesignSystem/JSONInspectorSheet.swift b/Sources/Contained/DesignSystem/JSONInspectorSheet.swift deleted file mode 100644 index bbe6a858..00000000 --- a/Sources/Contained/DesignSystem/JSONInspectorSheet.swift +++ /dev/null @@ -1,58 +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). -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. -struct InlineJSONView: View { - let json: String - - 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. -struct JSONInspectorSheet: View { - @Environment(\.dismiss) private var dismiss - let title: String - let value: Value - - var body: some View { - VStack(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/Sources/Contained/DesignSystem/LiveSparkline.swift b/Sources/Contained/DesignSystem/LiveSparkline.swift deleted file mode 100644 index d5e14ce2..00000000 --- a/Sources/Contained/DesignSystem/LiveSparkline.swift +++ /dev/null @@ -1,196 +0,0 @@ -import SwiftUI -import Charts - -/// 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 { - Group { - if samples.count > 1 { - chart - } else { - baseline - } - } - .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) - } - } - } - .chartXAxis(.hidden) - .chartYAxis(.hidden) - .chartLegend(.hidden) - .chartYScale(domain: 0...1) - .chartPlotStyle { plot in - plot.background(.clear) - } - } - - 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 var areaFillStyle: AnyShapeStyle { - if areaUsesGradient { - return AnyShapeStyle( - LinearGradient(colors: [color.opacity(0.25), color.opacity(0.02)], - startPoint: .top, - endPoint: .bottom) - ) - } - return AnyShapeStyle(color.opacity(0.22)) - } - - private var primaryPoints: [ChartPoint] { - normalized(samples).enumerated().map { ChartPoint(index: $0.offset, value: $0.element) } - } - - private var secondaryPoints: [ChartPoint] { - normalized(comparisonSamples).enumerated().map { ChartPoint(index: $0.offset, value: $0.element) } - } - - private var rangePoints: [ChartRangePoint] { - let count = min(primaryPoints.count, secondaryPoints.count) - guard count > 0 else { return [] } - return (0.. 0 else { return [] } - return (0.. [Double] { - let maxValue = max(values.max() ?? 1, 0.0001) - return values.map { min(max($0 / maxValue, 0), 1) } - } -} - -private struct ChartPoint: Identifiable { - let index: Int - let value: Double - var id: Int { index } -} - -private struct ChartRangePoint: Identifiable { - let index: Int - let low: Double - let high: Double - var id: Int { index } -} - -private struct ChartScatterPoint: Identifiable { - let index: Int - let x: Double - let y: Double - var id: Int { index } -} - -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 - } - } -} - -/// A fixed-size ring buffer for sparkline history. -struct SampleBuffer: Sendable, Equatable { - private(set) var values: [Double] = [] - let capacity: Int - - init(capacity: Int = 40) { self.capacity = capacity } - - 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/Sources/Contained/DesignSystem/MetricTile.swift deleted file mode 100644 index 9f0acdc3..00000000 --- a/Sources/Contained/DesignSystem/MetricTile.swift +++ /dev/null @@ -1,36 +0,0 @@ -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 - - var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.s) { - HStack(spacing: Tokens.Space.s) { - if let systemImage { - Image(systemName: systemImage) - .font(.body.weight(.medium)) - .foregroundStyle(tint) - } - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - Spacer(minLength: 0) - } - Text(value) - .font(.title.weight(.semibold)) - .contentTransition(.numericText()) - if let samples { - LiveSparkline(samples: samples, color: tint) - .frame(height: 22) - } - } - .padding(Tokens.Space.l) - .frame(maxWidth: .infinity, alignment: .leading) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, fill: tint, fillOpacity: 0.10) - } -} diff --git a/Sources/Contained/DesignSystem/MorphPanelScaffold.swift b/Sources/Contained/DesignSystem/MorphPanelScaffold.swift deleted file mode 100644 index a08c6b8d..00000000 --- a/Sources/Contained/DesignSystem/MorphPanelScaffold.swift +++ /dev/null @@ -1,59 +0,0 @@ -import SwiftUI - -/// 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 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). -/// -/// 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`. -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 - /// 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` - /// is false, in which case the content is placed directly and owns its own scrolling). - @ViewBuilder var content: () -> Content - /// Optional fixed chrome pinned below the scroll area (submit bars, command preview). - @ViewBuilder var footer: () -> Footer - - var body: some View { - VStack(spacing: 0) { - chrome() - if scrolls { - ScrollView { - content() - .frame(maxWidth: .infinity) - } - .scrollEdgeEffectStyle(scrollEdgeStyle, for: .all) - } else { - content() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - footer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } -} - -extension MorphPanelScaffold 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, - chrome: chrome, content: content, footer: { EmptyView() }) - } -} 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/PanelHeader.swift b/Sources/Contained/DesignSystem/PanelHeader.swift deleted file mode 100644 index 4c83b576..00000000 --- a/Sources/Contained/DesignSystem/PanelHeader.swift +++ /dev/null @@ -1,41 +0,0 @@ -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 - @ViewBuilder var trailing: () -> Trailing - - var body: some View { - HStack(alignment: .center, spacing: Tokens.Space.s) { - if leadingReserve > 0 { - Color.clear - .frame(width: leadingReserve, height: Tokens.Toolbar.buttonGroupHeight) - } - GlassButtonItem(systemName: symbol, help: title, isLabel: true) - .frame(width: Tokens.Toolbar.buttonGroupHeight, - height: Tokens.Toolbar.buttonGroupHeight, - alignment: .center) - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(.headline) - .lineLimit(1) - if let subtitle { - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .frame(maxWidth: .infinity, - minHeight: Tokens.Toolbar.buttonGroupHeight, - alignment: .leading) - trailing() - } - .frame(minHeight: Tokens.Toolbar.buttonGroupHeight) - .padding(padding) - } -} diff --git a/Sources/Contained/DesignSystem/PanelSection.swift b/Sources/Contained/DesignSystem/PanelSection.swift deleted file mode 100644 index f0c81c80..00000000 --- a/Sources/Contained/DesignSystem/PanelSection.swift +++ /dev/null @@ -1,206 +0,0 @@ -import SwiftUI - -private struct PanelSectionHighlightedKey: EnvironmentKey { - static let defaultValue = false -} - -private extension EnvironmentValues { - var panelSectionHighlighted: Bool { - get { self[PanelSectionHighlightedKey.self] } - set { self[PanelSectionHighlightedKey.self] = newValue } - } -} - -/// 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. -/// -/// 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 - /// Subtle blue treatment for sections containing explicit non-default values. - var highlighted: Bool = false - /// When provided, the header shows a switch; turning it off hides the body (and footer). - var enabled: Binding? = nil - @ViewBuilder var content: () -> Content - - @State private var collapsed = false - - private var bodyHidden: Bool { - if let enabled, !enabled.wrappedValue { return true } - return collapsible && collapsed - } - - private var hasHeader: Bool { header != nil || collapsible || enabled != nil } - - var body: some View { - VStack(alignment: .leading, spacing: Tokens.Space.s) { - if hasHeader { headerRow } - if !bodyHidden { - VStack(alignment: .leading, spacing: rowSpacing) { - content() - } - .environment(\.panelSectionHighlighted, highlighted) - .padding(Tokens.Space.m) - .frame(maxWidth: .infinity, alignment: .leading) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) - .overlay { - if highlighted { - RoundedRectangle(cornerRadius: 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. - Text(.init(footer)).font(.caption).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .padding(.leading, Tokens.Space.xs) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private var headerRow: some View { - HStack(spacing: Tokens.Space.s) { - if collapsible { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .rotationEffect(.degrees(collapsed ? 0 : 90)) - } - if let header { - HStack(spacing: Tokens.Space.xs) { - if highlighted { - Circle() - .fill(Color.accentColor) - .frame(width: 6, height: 6) - } - Text(header) - .font(.headline) - .foregroundStyle(highlighted ? Color.accentColor : Color.primary) - } - } - Spacer(minLength: Tokens.Space.s) - if let enabled { - Toggle("", isOn: enabled).labelsHidden().toggleStyle(.switch) - } - } - .padding(.leading, Tokens.Space.xs) - .contentShape(Rectangle()) - .onTapGesture { - guard collapsible else { return } - withAnimation(.easeOut(duration: 0.18)) { collapsed.toggle() } - } - } -} - -/// 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 - @ViewBuilder var trailing: () -> Trailing - - @Environment(\.panelSectionHighlighted) private var sectionHighlighted - @State private var labelHovering = false - - private var labelColor: Color { - if error != nil { return .red } - return sectionHighlighted ? .accentColor : .primary - } - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: Tokens.Space.m) { - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: Tokens.Space.xs) { - Text(title).foregroundStyle(labelColor) - if let info { InfoButton(info, visible: labelHovering) } - } - if let subtitle { - Text(subtitle).font(.caption).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - Spacer(minLength: Tokens.Space.m) - trailing() - } - .contentShape(Rectangle()) - .onHover { labelHovering = $0 } - if let error { - Text(error).font(.caption).foregroundStyle(.red) - } - } - .frame(maxWidth: .infinity) - } -} - -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 - @Binding var isOn: Bool - - var body: some View { - PanelRow(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 -/// 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 - @ViewBuilder var control: () -> Control - - @Environment(\.panelSectionHighlighted) private var sectionHighlighted - @State private var labelHovering = false - - private var labelColor: Color { - if error != nil { return .red } - return sectionHighlighted ? .accentColor : .primary - } - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: Tokens.Space.m) { - HStack(spacing: Tokens.Space.xs) { - Text(label) - .foregroundStyle(labelColor) - if let info { InfoButton(info, visible: labelHovering) } - } - .frame(width: labelWidth, alignment: .leading) - control().frame(maxWidth: .infinity) - } - .contentShape(Rectangle()) - .onHover { labelHovering = $0 } - if let error { - Text(error).font(.caption).foregroundStyle(.red) - .padding(.leading, labelWidth + Tokens.Space.m) - } - } - } -} 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/DesignSystem/SheetHeader.swift b/Sources/Contained/DesignSystem/SheetHeader.swift deleted file mode 100644 index 0bbfdd5f..00000000 --- a/Sources/Contained/DesignSystem/SheetHeader.swift +++ /dev/null @@ -1,40 +0,0 @@ -import SwiftUI - -/// Standard sheet header: a title (with optional subtitle), a flexible spacer, a cancel/close glass -/// 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. -struct SheetHeader: View { - let title: String - var subtitle: String? = nil - var cancelIcon: String = "xmark" - var cancelHelp: String = "Cancel" - let onCancel: () -> Void - @ViewBuilder var trailing: () -> Trailing - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 1) { - Text(title).font(.headline).lineLimit(1) - if let subtitle { - Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) - } - } - Spacer() - GlassButton(singleItem: true) { - GlassButtonItem(systemName: cancelIcon, help: cancelHelp, isCancel: true, action: onCancel) - } - trailing() - } - .padding(Tokens.Space.l) - } -} - -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) { - self.init(title: title, subtitle: subtitle, cancelIcon: cancelIcon, cancelHelp: cancelHelp, - onCancel: onCancel) { EmptyView() } - } -} diff --git a/Sources/Contained/DesignSystem/StreamConsole.swift b/Sources/Contained/DesignSystem/StreamConsole.swift deleted file mode 100644 index 2dcd3d36..00000000 --- a/Sources/Contained/DesignSystem/StreamConsole.swift +++ /dev/null @@ -1,94 +0,0 @@ -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 { - /// Factory so the stream starts with the view's `.task` (and cancels on disappear). - let stream: () -> AsyncThrowingStream - 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" - - 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(Tokens.Space.s) - } - .scrollEdgeEffectStyle(.soft, for: .all) - .onChange(of: lines.count) { _, _ in proxy.scrollTo(bottomID, anchor: .bottom) } - } - .padding(Tokens.Space.s) - .background(.black.opacity(0.22), - in: RoundedRectangle(cornerRadius: Tokens.Radius.card, style: .continuous)) - .padding(Tokens.Space.s) - } - .task { await consume() } - } - - private var statusBar: some View { - HStack(spacing: Tokens.Space.s) { - switch state { - case .running: - ProgressView().controlSize(.small) - Text("Working…").foregroundStyle(.secondary) - case .done: - Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - Text("Completed").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() - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "doc.on.doc", help: "Copy log") { - copyToPasteboard(lines.joined(separator: "\n")) - } - } - } - .font(.callout) - .padding(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((error as? CommandError)?.userMessage ?? error.localizedDescription) - 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/Sources/Contained/DesignSystem/Theme.swift b/Sources/Contained/DesignSystem/Theme.swift deleted file mode 100644 index 12bc0ba1..00000000 --- a/Sources/Contained/DesignSystem/Theme.swift +++ /dev/null @@ -1,432 +0,0 @@ -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 { - 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 -} - -/// 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 { - case multicolor, graphite, azure, teal, coral, indigo, green, amber, pink - - var id: String { rawValue } - - 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 } - - var color: Color { - switch self { - case .multicolor: return .accentColor - case .graphite: return Color(red: 0.45, green: 0.46, blue: 0.50) - case .azure: return Color(red: 0.14, green: 0.52, blue: 0.92) - case .teal: return Color(red: 0.11, green: 0.62, blue: 0.50) - case .coral: return Color(red: 0.85, green: 0.35, blue: 0.19) - case .indigo: return Color(red: 0.33, green: 0.29, blue: 0.72) - case .green: return Color(red: 0.39, green: 0.60, blue: 0.13) - case .amber: return Color(red: 0.73, green: 0.46, blue: 0.09) - case .pink: return Color(red: 0.83, green: 0.21, blue: 0.34) - } - } - - /// 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] { - 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. - 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 { - case normal, softLight, overlay, multiply, screen - - var id: String { rawValue } - - 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" - } - } - - var blendMode: BlendMode { - switch self { - case .normal: return .normal - case .softLight: return .softLight - case .overlay: return .overlay - case .multiply: return .multiply - case .screen: return .screen - } - } -} - -enum AppearanceMode: String, CaseIterable, Identifiable, Codable, Sendable { - case system, light, dark - var id: String { rawValue } - var displayName: String { rawValue.capitalized } - var colorScheme: ColorScheme? { - switch self { - case .system: return nil - case .light: return .light - case .dark: return .dark - } - } - - /// 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? { - switch self { - case .system: return nil - case .light: return NSAppearance(named: .aqua) - case .dark: return NSAppearance(named: .darkAqua) - } - } -} - -enum CardDensity: String, CaseIterable, Identifiable, Codable, Sendable { - case small, medium, large - var id: String { rawValue } - var displayName: String { rawValue.capitalized } - var resourceSize: ResourceCardSize { - switch self { - case .small: return .small - case .medium: return .medium - case .large: return .large - } - } - - init(stored raw: String?) { - if raw == "compact" { - self = .medium - } else { - self = CardDensity(rawValue: raw ?? "") ?? .medium - } - } -} - -/// 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 { - // Liquid Glass options (rendered with `.glassEffect`, not an `NSVisualEffectView`). - case glassClear, glassRegular - // System vibrancy materials. - case fullScreenUI, underWindowBackground, underPageBackground, - windowBackground, contentBackground, sidebar, headerView, titlebar, - sheet, popover, menu, selection, hudWindow, toolTip - - var id: String { rawValue } - - 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. - var isGlass: Bool { self == .glassClear || self == .glassRegular } - - /// The Liquid Glass variant for the glass cases (nil for vibrancy materials). - var glass: Glass? { - switch self { - case .glassClear: return .clear - case .glassRegular: return .regular - default: return nil - } - } - - /// 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 { - switch self { - case .glassClear, .glassRegular: return .fullScreenUI - case .fullScreenUI: return .fullScreenUI - case .underWindowBackground: return .underWindowBackground - case .underPageBackground: return .underPageBackground - case .windowBackground: return .windowBackground - case .contentBackground: return .contentBackground - case .sidebar: return .sidebar - case .headerView: return .headerView - case .titlebar: return .titlebar - case .sheet: return .sheet - case .popover: return .popover - case .menu: return .menu - case .selection: return .selection - case .hudWindow: return .hudWindow - case .toolTip: return .toolTip - } - } -} - -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. - @Entry var buttonMaterial: WindowMaterial = .glassClear - /// The user-chosen resource-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 -} - -private struct SheetMaterial: ViewModifier { - @Environment(\.modalMaterial) private var material - func body(content: Content) -> some View { - content - .background { - if let glass = material.glass { - Color.clear.glassEffect(glass, in: Rectangle()).ignoresSafeArea() - } else { - VisualEffectBackground(material: material.nsMaterial, blendingMode: .withinWindow) - .ignoresSafeArea() - } - } - .presentationBackground(.clear) - } -} - -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()) } - - /// Apply the sheet material only when `active`. Popovers bring their own native vibrant - /// background, and layering an `NSVisualEffectView` + `presentationBackground(.clear)` inside one - /// leaves controls unpainted until the first mouse event — so popover presentations pass `false`. - @ViewBuilder - func sheetMaterial(_ active: Bool) -> some View { - if active { modifier(SheetMaterial()) } else { self } - } -} - -private struct FloatingPanelMaterial: AnimatableModifier { - @Environment(\.modalMaterial) private var material - var cornerRadius = Tokens.Radius.sheet - var showsShadow = true - - nonisolated var animatableData: CGFloat { - get { cornerRadius } - set { cornerRadius = newValue } - } - - func body(content: Content) -> some View { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - content - .background { - if showsShadow { - ExteriorShadow(cornerRadius: cornerRadius, - color: AppMaterial.floatingPanelShadow, - radius: AppMaterial.floatingPanelShadowRadius, - y: AppMaterial.floatingPanelShadowY) - } - } - .background { - if let glass = material.glass { - Color.clear.glassEffect(glass, in: shape) - } else { - VisualEffectBackground(material: material.nsMaterial, blendingMode: .withinWindow) - .clipShape(shape) - } - } - .clipShape(shape) - .overlay { - shape.strokeBorder(AppMaterial.floatingPanelStroke, lineWidth: 1) - } - } -} - -private struct ToolbarControlMaterial: ViewModifier { - let shape: S - @Environment(\.buttonMaterial) private var buttonMaterial - - func body(content: Content) -> some View { - if let glass = buttonMaterial.glass { - content.glassEffect(glass.interactive(), in: shape) - } else { - // A vibrancy material chosen for buttons — back the capsule with it and clip. - content.background { - VisualEffectBackground(material: buttonMaterial.nsMaterial, blendingMode: .withinWindow) - .clipShape(shape) - } - } - } -} - -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, - showsShadow: Bool = true) -> some View { - modifier(FloatingPanelMaterial(cornerRadius: cornerRadius, showsShadow: showsShadow)) - } - - /// Standard interactive glass used by toolbar buttons and collapsed toolbar search. - func toolbarControlMaterial(in shape: S) -> some View { - modifier(ToolbarControlMaterial(shape: shape)) - } -} diff --git a/Sources/Contained/DesignSystem/TintSelector.swift b/Sources/Contained/DesignSystem/TintSelector.swift deleted file mode 100644 index 7d6e2a69..00000000 --- a/Sources/Contained/DesignSystem/TintSelector.swift +++ /dev/null @@ -1,71 +0,0 @@ -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 { - private let selection: Binding - private let automaticLabel: String? - - init(selection: Binding) { - self.selection = Binding( - get: { selection.wrappedValue }, - set: { if let newValue = $0 { selection.wrappedValue = newValue } } - ) - self.automaticLabel = nil - } - - init(optionalSelection: Binding, automaticLabel: String) { - self.selection = optionalSelection - self.automaticLabel = automaticLabel - } - - var body: some View { - HStack(spacing: Tokens.Space.s) { - if let automaticLabel { - Button { selection.wrappedValue = nil } label: { automaticSwatch } - .buttonStyle(.plain) - .help(automaticLabel) - .accessibilityLabel(automaticLabel) - .accessibilityAddTraits(selection.wrappedValue == nil ? .isSelected : []) - } - ForEach(AppTint.allCases) { tint in - Button { selection.wrappedValue = tint } label: { swatch(tint) } - .buttonStyle(.plain) - .help(tint.displayName) - .accessibilityLabel(tint.displayName) - .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: AppTint) -> 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 { - 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/Sources/Contained/DesignSystem/ToolbarControls.swift b/Sources/Contained/DesignSystem/ToolbarControls.swift deleted file mode 100644 index 0011cc23..00000000 --- a/Sources/Contained/DesignSystem/ToolbarControls.swift +++ /dev/null @@ -1,65 +0,0 @@ -import SwiftUI - -/// Design-system controls for the app toolbar band, sized from `Tokens.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. - -/// 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 - - var body: some View { - Menu { - content() - } label: { - Image(systemName: systemName) - .font(.body.weight(.medium)) - .padding(Tokens.Toolbar.iconInnerPadding) - .frame(height: Tokens.Toolbar.buttonItemHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .menuStyle(.button) - .menuIndicator(.hidden) - .help(help) - .accessibilityLabel(help) - } -} - -/// 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 - - var body: some View { - HStack(spacing: Tokens.Toolbar.searchIconGap) { - Image(systemName: symbol) - .font(.body) - .foregroundStyle(.secondary) - .frame(width: Tokens.Toolbar.buttonItemHeight - Tokens.Toolbar.iconInnerPadding * 2) - VStack(alignment: .leading, spacing: 0) { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(.primary) - Text(subtitle) - .font(.caption2) - .foregroundStyle(.secondary) - } - if showsChevron { - Image(systemName: "chevron.down") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.tertiary) - } - } - .lineLimit(1) - .padding(.trailing, Tokens.Toolbar.iconInnerPadding * 2) - .frame(height: Tokens.Toolbar.buttonGroupHeight) - .contentShape(Rectangle()) - } -} diff --git a/Sources/Contained/DesignSystem/VisualEffectBackground.swift b/Sources/Contained/DesignSystem/VisualEffectBackground.swift deleted file mode 100644 index 62628f5f..00000000 --- a/Sources/Contained/DesignSystem/VisualEffectBackground.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftUI -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 - - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.blendingMode = blendingMode - view.state = .active - view.material = material - return view - } - - func updateNSView(_ view: NSVisualEffectView, context: Context) { - view.blendingMode = blendingMode - view.material = material - } -} - -/// 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 - - var body: some View { - VisualEffectBackground(material: material) - .ignoresSafeArea() - } -} diff --git a/Sources/Contained/Features/Containers/ComposeImport.swift b/Sources/Contained/Features/Containers/ComposeImport.swift deleted file mode 100644 index f4344039..00000000 --- a/Sources/Contained/Features/Containers/ComposeImport.swift +++ /dev/null @@ -1,84 +0,0 @@ -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 -/// (the prefill queue steps through them). Triggered from File ▸ Import Compose…, drag-and-drop, -/// and the palette. -@MainActor -enum ComposeImport { - /// Show an open panel, then import the chosen file. - static func pickAndImport(app: AppModel, ui: UIState) { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.allowsMultipleSelection = false - panel.allowedContentTypes = [.yaml] - panel.message = "Choose a compose.yaml" - guard panel.runModal() == .OK, let url = panel.url else { return } - importFile(at: url, app: app, ui: ui) - } - - /// 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) { - 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) - } catch let error as ComposeError { - app.flash({ if case .invalid(let message) = error { return message }; return "Invalid compose file." }()) - } catch { - app.flash(error.localizedDescription) - } - } - - /// 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) { - 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 !specs.isEmpty else { - app.flash("No services with an image to import.") - 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")", - category: .compose, - severity: .warning) - } - 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) - } - } - - /// 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/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 deleted file mode 100644 index fe765779..00000000 --- a/Sources/Contained/Features/Containers/ContainersGridView.swift +++ /dev/null @@ -1,510 +0,0 @@ -import SwiftUI -import AppKit -import ContainedCore - -/// 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 -/// into a centered detail panel. -struct ContainersGridView: View { - @Environment(AppModel.self) private var app - @Environment(UIState.self) private var ui - @Environment(\.appSafeAreas) private var safeAreas - - @State private var detail: ContainerSnapshot? - @State private var deleting: ContainerSnapshot? - @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 - /// panel. A single spring on this flag owns the whole motion (no matchedGeometry to fight). - @State private var expanded = false - /// 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] = [:] - - // 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) - - private var store: ContainersStore { app.containers } - - /// A bucket of containers under one heading. `resource` is set only for network grouping (so the - /// section keeps its network context menu); `symbol` drives the section header glyph. - private struct ContainerGroup: Identifiable { - let name: String - let symbol: String - let resource: NetworkResource? - let containers: [ContainerSnapshot] - 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] { - let names = snapshot.configuration.networks.map(\.network) + snapshot.status.networks.map(\.network) - return Array(Set(names)).sorted() - } - - /// Containers bucketed according to the toolbar grouping choice, each bucket sorted by the chosen - /// sort. Network grouping keeps every known network as a section (empty ones included). - private var groups: [ContainerGroup] { - switch ui.grouping { - case .network: return networkGroups - case .volume: return volumeGroups - case .image: return imageGroups - case .flat: return [ContainerGroup(name: "All containers", symbol: "square.grid.2x2", - resource: nil, containers: sorted(filtered), isBuiltin: false)] - } - } - - private var networkGroups: [ContainerGroup] { - 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]] = [:] - for network in app.networks { buckets[network.name] = [] } - buckets[defaultName, default: []] = buckets[defaultName] ?? [] - - for snapshot in filtered { - let names = networkNames(snapshot) - if names.isEmpty { - buckets[defaultName, default: []].append(snapshot) - } else { - for name in names { buckets[name, default: []].append(snapshot) } - } - } - - return buckets.keys.sorted { lhs, rhs in - if lhs == defaultName { return true } - if rhs == defaultName { return false } - return lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending - }.map { name in - ContainerGroup(name: name, symbol: "network", resource: byNetworkName[name], - containers: sorted(buckets[name] ?? []), - isBuiltin: byNetworkName[name]?.isBuiltin ?? true) - } - } - - private var volumeGroups: [ContainerGroup] { - let noVolume = "No volume" - var buckets: [String: [ContainerSnapshot]] = [:] - for snapshot in filtered { - let volumes = Set(snapshot.configuration.mounts.compactMap { mount -> String? in - guard let source = mount.source, !source.isEmpty else { return nil } - return source - }) - if volumes.isEmpty { - buckets[noVolume, default: []].append(snapshot) - } else { - for volume in volumes { buckets[volume, default: []].append(snapshot) } - } - } - return buckets.keys.sorted { lhs, rhs in - if lhs == noVolume { return false } - if rhs == noVolume { return true } - return lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending - }.map { name in - ContainerGroup(name: name, symbol: "externaldrive", resource: nil, - containers: sorted(buckets[name] ?? []), isBuiltin: false) - } - } - - private var imageGroups: [ContainerGroup] { - var buckets: [String: [ContainerSnapshot]] = [:] - for snapshot in filtered { - buckets[Format.shortImage(snapshot.image), default: []].append(snapshot) - } - return buckets.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - .map { name in - ContainerGroup(name: name, symbol: "shippingbox", resource: nil, - containers: sorted(buckets[name] ?? []), isBuiltin: false) - } - } - - /// Order a bucket of containers by the chosen sort. - private func sorted(_ containers: [ContainerSnapshot]) -> [ContainerSnapshot] { - switch ui.sort { - case .name: - return containers.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - case .status: - return containers.sorted { lhs, rhs in - let lhsRunning = lhs.state == .running, rhsRunning = rhs.state == .running - if lhsRunning != rhsRunning { return lhsRunning } - return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending - } - case .image: - return containers.sorted { lhs, rhs in - let cmp = lhs.image.localizedCaseInsensitiveCompare(rhs.image) - if cmp != .orderedSame { return cmp == .orderedAscending } - return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending - } - } - } - - private var columns: [GridItem] { - return [GridItem(.adaptive(minimum: Tokens.CardSize.largeMin, maximum: Tokens.CardSize.largeMax), - spacing: Tokens.Space.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)) - } - } - - var body: some View { - @Bindable var ui = ui - return GeometryReader { viewport in - let scrollBounds = safeAreas.bounds(in: viewport.size, policy: .content) - ZStack { - ScrollView { - ZStack(alignment: .top) { - // Background sibling (behind the cards): double-click empty space to zoom the - // window. As a sibling — not an ancestor — of the cards, it never delays or - // intercepts their taps; only clicks that fall through the gaps reach it. - Color.clear - .frame(maxWidth: .infinity, minHeight: scrollBounds.height) - .contentShape(Rectangle()) - .onTapGesture(count: 2) { zoomFrontWindow() } - LazyVStack(alignment: .leading, spacing: Tokens.Space.l) { - ForEach(groups) { group in - groupSection(group) - } - Color.clear - .frame(height: Tokens.Toolbar.band) - } - .padding(.horizontal, Tokens.Space.l) - } - } - .contentMargins(.top, ui.toolbarUIEnabled ? 0 : Tokens.Toolbar.band, for: .scrollContent) - - if detail != nil { - Color.clear - .globalBackdrop(style: .blur, progress: expanded ? 1 : 0) - .contentShape(Rectangle()) - .onTapGesture { closeDetail() } - .zIndex(5) - } - - if let detail { - let target = cardDetailTarget.rect(origin: .zero, - in: viewport.size, - safeAreas: cardDetailSafeAreas) - 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) - .zIndex(10) - } - } - .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) } - } - .overlay { - if store.snapshots.isEmpty && app.networks.isEmpty { emptyState } - } - .confirmationDialog( - "Delete \(customizeName(deleting))?", - 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) } } - deleting = nil - } - Button("Cancel", role: .cancel) { deleting = nil } - } message: { - Text("This removes the container. This can't be undone.") - } - // 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) } } - } 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 } - } - - // MARK: - Network sections - - @ViewBuilder - private func groupSection(_ group: ContainerGroup) -> some View { - let collapsed = collapsedNetworks.contains(group.name) - VStack(alignment: .leading, spacing: Tokens.Space.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, Tokens.Space.s) - } else { - LazyVGrid(columns: columns, spacing: Tokens.Space.m) { - ForEach(group.containers) { snapshot in - gridCard(snapshot) - } - } - } - } - } - } - - private func sectionHeader(_ group: ContainerGroup, collapsed: Bool) -> some View { - HStack(spacing: Tokens.Space.s) { - Button { - toggleCollapsed(group.name) - } label: { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .rotationEffect(.degrees(collapsed ? 0 : 90)) - } - .buttonStyle(.plain) - Image(systemName: group.symbol).font(.callout).foregroundStyle(.secondary) - Text(group.name).font(.headline) - ResourceBadgeText(text: "\(group.containers.count)") - if group.isBuiltin { - ResourceBadgeText(text: "builtin", font: .caption2.weight(.medium)) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Tokens.Space.xs) - .padding(.vertical, Tokens.Space.xs) - .contextMenu { if let resource = group.resource { networkMenu(resource) } } - } - - @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() - Button(role: .destructive) { deletingNetwork = resource } label: { Label("Delete Network", systemImage: "trash") } - } - } - - private func toggleCollapsed(_ name: String) { - if collapsedNetworks.contains(name) { collapsedNetworks.remove(name) } else { collapsedNetworks.insert(name) } - } - - /// Zoom (fill/restore) the window — the title-bar gesture, relocated to the empty background. - private func zoomFrontWindow() { - (NSApp.keyWindow ?? NSApp.mainWindow)?.zoom(nil) - } - - private var deleteNetworkBinding: Binding { - Binding(get: { deletingNetwork != nil }, set: { if !$0 { deletingNetwork = nil } }) - } - - 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) } - } - - @ViewBuilder - private func gridCard(_ snapshot: ContainerSnapshot) -> 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 - // while the promoted overlay grows out of it — no second card to see double. - .opacity(selected ? 0 : 1) - .allowsHitTesting(detail == nil) - .background( - GeometryReader { proxy in - Color.clear.preference( - key: CardFrameKey.self, - value: [snapshot.id: proxy.frame(in: .named("grid"))] - ) - } - ) - } - - private func compactCard(_ snapshot: ContainerSnapshot) -> some View { - containerCard(snapshot, isExpanded: false) { - selecting ? toggle(snapshot.id) : openDetail(snapshot) - } - } - - private func expandedCard(_ snapshot: ContainerSnapshot) -> 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, - isExpanded: true, - cornerRadiusOverride: expanded ? Tokens.Radius.sheet : Tokens.Radius.card, - controlsVisible: expanded) {} - } - - private func containerCard(_ snapshot: ContainerSnapshot, isExpanded: Bool, - cornerRadiusOverride: CGFloat? = nil, - controlsVisible: Bool = true, onTap: @escaping () -> Void) -> some View { - let style = app.containerStyle(for: snapshot) - return ContainerCard( - 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, - 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) } }, - onEdit: { ui.openCreationPanel(editing: snapshot) }, - onUpdate: { updateContainer(snapshot) }, - onDelete: { deleting = snapshot }, - onClose: closeDetail, - 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) - ) - } - - private var cardDetailTarget: AppMorphTarget { - .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 cardDetailSafeAreas: AppSafeAreaManager { - guard ui.toolbarUIEnabled else { return safeAreas } - return AppSafeAreaManager(system: safeAreas.system, - topToolbarHeight: AppToolbar.bandHeight, - bottomToolbarHeight: AppToolbar.bandHeight) - } - - private func panelSize(in available: CGSize) -> CGSize { - let fitted = MorphGeometry.fittedSize( - CGSize(width: max(available.width * 0.62, 680), height: 620), - in: available, - margin: 0 - ) - let width = max(min(fitted.width, available.width), min(360, fitted.width)) - let height = fitted.height - return CGSize(width: width, height: height) - } - - private func openDetail(_ snapshot: ContainerSnapshot) { - // 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 - expanded = false - DispatchQueue.main.async { - withAnimation(detailSpring) { expanded = true } - } - } - - private func closeDetail() { - withAnimation(detailSpring) { expanded = false } completion: { - detail = nil - } - } - - 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") - } - } - .buttonStyle(.glass) - .padding(.horizontal, Tokens.Space.l) - .padding(.vertical, Tokens.Space.s) - .glassEffect(.regular, in: Capsule()) - .padding(.bottom, Tokens.Space.l) - } - - private func toggle(_ id: String) { - if selection.contains(id) { selection.remove(id) } else { selection.insert(id) } - } - - private func beginSelecting(_ id: String) { - selecting = true - selection = [id] - } - - private func endSelecting() { - selection.removeAll() - selecting = false - } - - /// Run an action over every selected container, then exit selection mode. - private func batch(_ action: @escaping (String) async -> Void) { - let ids = selection - Task { - for id in ids { await action(id) } - endSelecting() - } - } - - private func updateContainer(_ snapshot: ContainerSnapshot) { - Task { - if await app.pullImageUpdate(snapshot.image) { - ui.openCreationPanel(editing: snapshot) - } - } - } - - private func customizeName(_ snapshot: ContainerSnapshot?) -> String { - guard let snapshot else { return "" } - return app.containerStyle(for: snapshot) - .displayName(fallback: snapshot.id) - } - - @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) } - } - } -} - -/// 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 - } -} diff --git a/Sources/Contained/Features/Containers/Creation/CreationPages.swift b/Sources/Contained/Features/Containers/Creation/CreationPages.swift deleted file mode 100644 index 78ec031a..00000000 --- a/Sources/Contained/Features/Containers/Creation/CreationPages.swift +++ /dev/null @@ -1,313 +0,0 @@ -import SwiftUI -import ContainedCore - -struct CreationNetworkFields: View { - @Binding var name: String - @Binding var subnet: String - @Binding var internalOnly: Bool - let working: Bool - var onSubmit: () -> Void - - var body: some View { - CreationResourceForm(symbol: "network", - title: networkName, - subtitle: networkSubtitle, - command: previewCommand) { - PanelSection(header: "Details", highlighted: hasValues) { - PanelField(label: "Name", - info: "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`.") { - 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.", - isOn: $internalOnly) - } - } footer: { - CreationSubmitBar(title: "Create network", - systemImage: "network.badge.plus", - canSubmit: canSubmit, - working: working, - action: onSubmit) - } - } - - private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } - 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 networkSubtitle: String { - var parts = [internalOnly ? "internal" : "bridge"] - if !trimmedSubnet.isEmpty { parts.append(trimmedSubnet) } - return parts.joined(separator: " · ") - } - private var previewCommand: [String] { - ContainerCommands.networkCreate(name: trimmedName.isEmpty ? "" : trimmedName, - subnet: trimmedSubnet.isEmpty ? nil : trimmedSubnet, - internalOnly: internalOnly) - } - - private func submitIfReady() { - guard canSubmit else { return } - onSubmit() - } -} - -struct CreationVolumeFields: View { - @Binding var name: String - @Binding var size: String - let working: Bool - var onSubmit: () -> Void - - var body: some View { - CreationResourceForm(symbol: "externaldrive", - title: volumeName, - subtitle: volumeSubtitle, - command: previewCommand) { - PanelSection(header: "Details", highlighted: hasValues) { - PanelField(label: "Name", - info: "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.") { - TextField("", text: $size, prompt: Text("optional, e.g. 10G")) - .textFieldStyle(.roundedBorder) - .onSubmit(submitIfReady) - } - } - } footer: { - CreationSubmitBar(title: "Create volume", - systemImage: "externaldrive.badge.plus", - canSubmit: canSubmit, - working: working, - action: onSubmit) - } - } - - private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } - 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 previewCommand: [String] { - ContainerCommands.volumeCreate(name: trimmedName.isEmpty ? "" : trimmedName, - size: trimmedSize.isEmpty ? nil : trimmedSize) - } - - private func submitIfReady() { - guard canSubmit else { return } - onSubmit() - } -} - -struct CreationLocalImagesContent: View { - @Environment(AppModel.self) private var app - @Binding var query: String - 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) - } - } - .padding(.horizontal, Tokens.Space.m) - .padding(.vertical, Tokens.Space.s) - .glassSurface(.thin, cornerRadius: Tokens.Radius.control) - - if filteredLocalImages.isEmpty { - ContentUnavailableView { - Label("No matching images", systemImage: "square.stack.3d.up") - } description: { - Text(query.isEmpty ? "Pull or build an image first." : "Try a different filter.") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - LazyVStack(spacing: Tokens.Space.xs) { - ForEach(filteredLocalImages) { image in - CreationLocalImageRow(image: image) { - onSelect(RecommendedImage.spec(for: image.reference)) - } - .accessibilityAddTraits(.isButton) - } - } - } - } - } - .task { await app.refreshImagesIfStale(force: true) } - } - - private var filteredLocalImages: [ContainedCore.ImageResource] { - let images = app.images - .filter { $0.variants.contains(where: \.isRunnable) || $0.variants.isEmpty } - .sorted { $0.reference.localizedCaseInsensitiveCompare($1.reference) == .orderedAscending } - let trimmed = query.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return images } - return images.filter { $0.reference.localizedCaseInsensitiveContains(trimmed) } - } -} - -struct CreationPastedComposeContent: View { - @Binding var text: String - 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) - - HStack { - Spacer() - Button(action: onImport) { - Label("Import", systemImage: "arrow.down.doc") - } - .buttonStyle(.glassProminent) - .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } -} - -struct CreationTemplatesContent: View { - let templates: [Template] - var onSelect: (RunSpec) -> Void - - var body: some View { - ScrollView { - LazyVStack(spacing: Tokens.Space.s) { - ForEach(templates) { template in - CreationChoiceCard(symbol: "bookmark", - title: template.name, - subtitle: Format.shortImage(template.spec?.image ?? "—")) { - if let spec = template.spec { onSelect(spec) } - } - .accessibilityAddTraits(.isButton) - } - } - } - } -} - -private struct CreationSubmitBar: View { - let title: String - let systemImage: String - let canSubmit: Bool - let working: Bool - var action: () -> Void - - var body: some View { - HStack(spacing: Tokens.Space.s) { - Spacer() - if working { ProgressView().controlSize(.small) } - Button(action: action) { - Label(title, systemImage: systemImage) - } - .buttonStyle(.glassProminent) - .disabled(!canSubmit || working) - } - .padding(Tokens.Space.s) - .background(.clear) - } -} - -private struct CreationResourceForm: View { - let symbol: String - let title: String - let subtitle: String - let command: [String] - @ViewBuilder var fields: () -> Fields - @ViewBuilder var footer: () -> Footer - - var body: some View { - VStack(spacing: Tokens.Space.m) { - ResourceGlassCard(size: .small, elevated: false) { - ResourceCardHeader { - ResourceCardIconChip(symbol: symbol, tint: .accentColor) - } content: { - VStack(alignment: .leading, spacing: 1) { - ResourceCardTitleText(text: title) - ResourceCardSubtitleText(text: subtitle) - } - } trailing: { - ResourceBadgeText(text: "new", font: .caption2.weight(.semibold)) - } - } - - fields() - - CommandPreviewBar(command: command) - .frame(maxWidth: .infinity) - - footer() - } - } -} - -private struct CreationLocalImageRow: View { - let image: ContainedCore.ImageResource - var onSelect: () -> Void - - var body: some View { - let runnable = image.variants.filter(\.isRunnable) - let size = runnable.compactMap(\.size).max() ?? image.variants.compactMap(\.size).max() - let arches = runnable.map(\.platform.architecture).joined(separator: ", ") - let subtitle = [size.map { Format.bytes(UInt64($0)) }, arches.isEmpty ? nil : arches] - .compactMap { $0 }.joined(separator: " · ") - - CreationChoiceCard(symbol: "square.stack.3d.up", - title: Format.shortImage(image.reference), - subtitle: subtitle, - action: onSelect) - } -} - -private struct CreationChoiceCard: View { - let symbol: String - let title: String - let subtitle: String? - var action: () -> Void - - var body: some View { - ResourceGlassCard(size: .small, elevated: false, onTap: action) { - ResourceCardHeader { - ResourceCardIconChip(symbol: symbol, tint: .accentColor) - } content: { - VStack(alignment: .leading, spacing: 1) { - ResourceCardTitleText(text: title) - if let subtitle, !subtitle.isEmpty { - ResourceCardMonospacedSubtitleText(text: subtitle) - } - } - } trailing: { - GlassListRowChevron() - } - .contentShape(Rectangle()) - } -} -} diff --git a/Sources/Contained/Features/Containers/FilesTab.swift b/Sources/Contained/Features/Containers/FilesTab.swift deleted file mode 100644 index defb8fd1..00000000 --- a/Sources/Contained/Features/Containers/FilesTab.swift +++ /dev/null @@ -1,147 +0,0 @@ -import SwiftUI -import AppKit -import ContainedCore - -/// 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. -struct FilesTab: View { - @Environment(AppModel.self) private var app - let snapshot: ContainerSnapshot - - @State private var path = "/" - @State private var entries: [String] = [] - @State private var loading = false - @State private var error: String? - - var body: some View { - if snapshot.state != .running { - ContentUnavailableView { - Label("Not running", systemImage: "folder") - } description: { Text("Start the container to browse its files.") } - } else { - ContainerToolTabScaffold { - pathBar - } content: { - listing - } - .task(id: path) { await load() } - } - } - - private var pathBar: some View { - HStack(spacing: Tokens.Space.s) { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "chevron.up", help: "Parent") { goUp() } - .disabled(path == "/") - } - 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") { - copyIn() - } - } - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "arrow.clockwise", help: "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) } - } else { - ScrollView { - LazyVStack(spacing: 1) { - ForEach(entries, id: \.self) { entry in row(entry) } - } - .padding(Tokens.Space.s) - } - .scrollEdgeEffectStyle(.soft, for: .all) - } - } - - private func row(_ entry: String) -> some View { - let isDir = entry.hasSuffix("/") - let name = isDir ? String(entry.dropLast()) : entry - return HStack(spacing: Tokens.Space.s) { - Image(systemName: isDir ? "folder.fill" : "doc") - .foregroundStyle(isDir ? Color.accentColor : Color.secondary) - .frame(width: Tokens.IconSize.rowMenu) - Text(name).font(.system(.callout, design: .monospaced)) - Spacer() - if isDir { - Image(systemName: "chevron.right").font(.caption).foregroundStyle(.secondary) - } else { - Button { copyOut(name) } label: { Image(systemName: "square.and.arrow.up") } - .buttonStyle(.plain).foregroundStyle(.secondary).help("Copy to host") - } - } - .padding(.vertical, Tokens.Space.xs) - .padding(.horizontal, Tokens.Space.s) - .contentShape(Rectangle()) - .onTapGesture { if isDir { path = joined(name) + "/" } } - } - - // MARK: Actions - - private func load() async { - guard let client = app.client else { return } - loading = true; error = nil - defer { loading = false } - do { - let out = try await client.execCapture(snapshot.id, ["ls", "-1ap", path]) - 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 } - } - - private func goUp() { - let trimmed = path.hasSuffix("/") ? String(path.dropLast()) : path - guard let slash = trimmed.lastIndex(of: "/") else { path = "/"; return } - let parent = String(trimmed[.. String { - path.hasSuffix("/") ? path + name : path + "/" + name - } - - private func copyOut(_ name: String) { - let panel = NSSavePanel() - panel.nameFieldStringValue = name - panel.message = "Copy \(name) from the container" - 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") - } catch let e as CommandError { app.flash(e.userMessage) } - catch { app.flash(error.localizedDescription) } - } - } - - private func copyIn() { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.message = "Copy a file into \(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") - await load() - } catch let e as CommandError { app.flash(e.userMessage) } - catch { app.flash(error.localizedDescription) } - } - } -} diff --git a/Sources/Contained/Features/Containers/GradientAngleControl.swift b/Sources/Contained/Features/Containers/GradientAngleControl.swift deleted file mode 100644 index 20d36d10..00000000 --- a/Sources/Contained/Features/Containers/GradientAngleControl.swift +++ /dev/null @@ -1,46 +0,0 @@ -import SwiftUI - -/// A 360° gradient-direction control: a draggable dial plus a degree readout. -struct GradientAngleControl: View { - @Binding var angle: Double - - var body: some View { - LabeledContent("Direction") { - HStack(spacing: Tokens.Space.m) { - AngleDial(angle: $angle).frame(width: 36, height: 36) - Slider(value: $angle, in: 0...360, step: 1) - Text("\(Int(angle))°").monospacedDigit().frame(width: 40) - } - } - } -} - -/// A small dial knob whose pointer reflects the gradient angle; drag to set. -struct AngleDial: View { - @Binding var angle: Double - - var body: some View { - GeometryReader { geo in - 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)) - ZStack { - Circle().strokeBorder(.secondary.opacity(0.4), lineWidth: 1) - Circle().fill(.tint).frame(width: 7, height: 7).position(knob) - } - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let dx = value.location.x - center.x - let dy = value.location.y - center.y - var deg = atan2(dy, dx) * 180 / .pi - if deg < 0 { deg += 360 } - angle = deg - } - ) - } - } -} diff --git a/Sources/Contained/Features/Containers/RunSpec.swift b/Sources/Contained/Features/Containers/RunSpec.swift deleted file mode 100644 index f07f8c8c..00000000 --- a/Sources/Contained/Features/Containers/RunSpec.swift +++ /dev/null @@ -1,356 +0,0 @@ -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() - 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 state of the Create/Run form. Knows how to render itself as a `container run` argv. -struct RunSpec: Codable { - 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: 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 = HealthCheck() - - var validationMessages: [String] { - var messages: [String] = [] - if image.trimmingCharacters(in: .whitespaces).isEmpty { - messages.append("Choose an image to run.") - } - 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.") - 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("Complete or remove partial volume mounts.") - break - } - } - if env.contains(where: { - $0.key.trimmingCharacters(in: .whitespaces).isEmpty && - !$0.value.trimmingCharacters(in: .whitespaces).isEmpty - }) { - messages.append("Environment variables with values need names.") - } - if !memory.trimmingCharacters(in: .whitespaces).isEmpty, - RunSpec.parseMemoryBytes(memory) == nil { - messages.append("Memory must use a value like 512M or 2G.") - } - 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: ContainerConfiguration) { - 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] { - 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 - } - - /// 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) - } - - 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() - } -} diff --git a/Sources/Contained/Features/Containers/RunSpecForm.swift b/Sources/Contained/Features/Containers/RunSpecForm.swift deleted file mode 100644 index fc49651c..00000000 --- a/Sources/Contained/Features/Containers/RunSpecForm.swift +++ /dev/null @@ -1,607 +0,0 @@ -import SwiftUI -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 `MorphPanelScaffold` -/// and measures/scrolls consistently. Field guidance is delivered through tappable `info.circle` -/// popovers, not hover tooltips. -struct RunSpecForm: View { - @Environment(AppModel.self) private var app - @Environment(UIState.self) private var ui - @Binding var spec: RunSpec - @State private var advancedExpanded: Bool - - init(spec: Binding) { - self._spec = spec - let initial = spec.wrappedValue - self._advancedExpanded = State(initialValue: initial.hasAdvancedOptions) - } - - var body: some View { - VStack(spacing: Tokens.Space.l) { - Text("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) { - portsSection - networkSection - socketsSection - } - PanelSection(header: "Storage", highlighted: spec.hasStorageOptions) { volumesSection } - PanelSection(header: "Environment", highlighted: spec.hasEnvironmentOptions) { environmentSection } - PanelSection(header: "App Managed", highlighted: spec.hasAppManagedOptions) { - restartSection - healthSection - } - PanelSection(header: "Appearance", 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() - } - } - - private var generalSection: some View { - Group { - 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 { - 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 { - 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.") { - Picker("", selection: platformPresetBinding) { - Text("Default").tag("") - Text("Linux arm64").tag("linux/arm64") - Text("Linux amd64").tag("linux/amd64") - Text("Linux amd64/v2").tag("linux/amd64/v2") - Text("Custom").tag("custom") - } - .labelsHidden().fixedSize() - } - if platformPresetBinding.wrappedValue == "custom" { - PanelField(label: "Custom platform", info: "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.") { - 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.") { - 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) - } - } - - 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.") { - 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) - if !spec.memory.isEmpty { - PanelField(label: "Memory") { - HStack(spacing: Tokens.Space.s) { - Slider(value: memoryGBBinding, in: 0.5...max(0.5, maxMemoryGB), step: 0.5) - Text(memoryReadout).monospacedDigit().frame(width: Tokens.FormWidth.memoryReadout) - } - } - } - } - } - - // MARK: Host-bounded resource controls - - private var hostCPUs: Int { ProcessInfo.processInfo.activeProcessorCount } - /// Whole gigabytes of physical RAM, rounded down — the slider's upper bound. - private var maxMemoryGB: Double { (Double(ProcessInfo.processInfo.physicalMemory) / 1_073_741_824).rounded(.down) } - - /// CPU picker selection; `0` means "Default" (no `--cpus`, runtime decides). - private var cpuBinding: Binding { - Binding(get: { Int(spec.cpus) ?? 0 }, set: { spec.cpus = $0 == 0 ? "" : String($0) }) - } - /// Memory-limit toggle: on writes a sensible default spec, off clears it. - private var memoryLimitBinding: Binding { - Binding(get: { !spec.memory.isEmpty }, - set: { spec.memory = $0 ? Self.memorySpec(gb: min(2, max(0.5, maxMemoryGB))) : "" }) - } - /// Memory slider value in GB, parsed from / written back to the `--memory` spec string. - private var memoryGBBinding: Binding { - Binding(get: { Self.parseMemoryGB(spec.memory) ?? 2 }, set: { spec.memory = Self.memorySpec(gb: $0) }) - } - private var memoryReadout: String { - memoryReadout(spec.memory, fallbackGB: 2) - } - private func memoryReadout(_ spec: String, fallbackGB: Double) -> String { - RunSpecMemoryFormatter.readout(spec, fallbackGB: fallbackGB) - } - - private var platformPresetBinding: Binding { - let presets = Set(["", "linux/arm64", "linux/amd64", "linux/amd64/v2"]) - return Binding(get: { presets.contains(spec.platform) ? spec.platform : "custom" }, - set: { if $0 != "custom" { spec.platform = $0 } }) - } - - private var imageDefaultConfig: VariantConfig.OCIConfig? { - spec.imageDefaults(in: app.images) - } - - private func adoptImageDefaults() { - guard let imageDefaultConfig else { return } - let applied = spec.adoptImageDefaults(from: imageDefaultConfig) - if applied > 0 { - app.flash("Adopted \(applied) image default\(applied == 1 ? "" : "s")") - } else { - app.flash("Image defaults are already represented") - } - } - - static func parseMemoryGB(_ spec: String) -> Double? { - RunSpecMemoryFormatter.parseGB(spec) - } - - static func memorySpec(gb: Double) -> String { - RunSpecMemoryFormatter.spec(gb: gb) - } - - private var portsSection: some View { - Group { - ForEach($spec.ports) { $port in - HStack { - TextField("Host", text: $port.hostPort).textFieldStyle(.roundedBorder).frame(width: Tokens.FormWidth.port) - Image(systemName: "arrow.right").foregroundStyle(.secondary) - TextField("Container", text: $port.containerPort).textFieldStyle(.roundedBorder).frame(width: Tokens.FormWidth.containerPort) - Picker("", selection: $port.proto) { Text("tcp").tag("tcp"); Text("udp").tag("udp") } - .labelsHidden().frame(width: Tokens.FormWidth.port) - Spacer() - removeButton { spec.ports.removeAll { $0.id == port.id } } - } - } - addButton("Add port") { spec.ports.append(PortMap()) } - } - } - - private var volumesSection: some View { - Group { - ForEach($spec.volumes) { $vol in - VStack(spacing: Tokens.Space.xs) { - HStack { - sourcePicker(source: $vol.source) - TextField("Source (host path or volume)", text: $vol.source).textFieldStyle(.roundedBorder) - removeButton { spec.volumes.removeAll { $0.id == vol.id } } - } - HStack { - TextField("Target (container path)", text: $vol.target).textFieldStyle(.roundedBorder) - Toggle("RO", isOn: $vol.readOnly).labelsHidden().toggleStyle(.switch).controlSize(.mini) - } - } - } - addButton("Add volume") { spec.volumes.append(VolumeMap()) } - } - } - - private var environmentSection: some View { - Group { - ForEach($spec.env) { $variable in - HStack { - TextField("KEY", text: $variable.key).textFieldStyle(.roundedBorder) - Text("=").foregroundStyle(.secondary) - TextField("value", text: $variable.value).textFieldStyle(.roundedBorder) - 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).") - } - } - - private var socketsSection: some View { - Group { - ForEach($spec.sockets) { $socket in - VStack(spacing: Tokens.Space.xs) { - HStack { - TextField("Host socket path", text: $socket.hostPath).textFieldStyle(.roundedBorder) - removeButton { spec.sockets.removeAll { $0.id == socket.id } } - } - TextField("Container socket path", text: $socket.containerPath).textFieldStyle(.roundedBorder) - } - } - addButton("Add socket") { spec.sockets.append(SocketMap()) } - } - } - - private var labelsSection: some View { - Group { - ForEach($spec.labels) { $label in - HStack { - TextField("KEY", text: $label.key).textFieldStyle(.roundedBorder) - Text("=").foregroundStyle(.secondary) - TextField("value", text: $label.value).textFieldStyle(.roundedBorder) - removeButton { spec.labels.removeAll { $0.id == label.id } } - } - } - addButton("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.") { - 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`.") { - 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) - } - 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.", - isOn: $spec.personalization.fillBackground) - if spec.personalization.fillBackground { - PanelField(label: "Opacity") { - HStack(spacing: Tokens.Space.s) { - Slider(value: $spec.personalization.backgroundOpacity, in: 0.05...0.6) - Text(Format.percent(spec.personalization.backgroundOpacity)) - .monospacedDigit() - .frame(width: Tokens.FormWidth.shortReadout) - } - } - PanelToggleRow(title: "Gradient", - 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) - } - 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) - } - } - .labelsHidden() - .fixedSize() - } - } - } - } - - 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) } - } - .labelsHidden().fixedSize() - } - } - - 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).", - 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.") { - TextField("", text: healthCommandBinding, prompt: Text("curl -f http://localhost/ || exit 1")).textFieldStyle(.roundedBorder) - } - Stepper("Interval: \(spec.healthCheck.intervalSeconds)s", - value: $spec.healthCheck.intervalSeconds, in: 5...600, step: 5) - Stepper("Unhealthy after \(spec.healthCheck.retries) failures", - value: $spec.healthCheck.retries, in: 1...10) - } - } - } - - /// Bridges the probe string to/from a `sh -c ` argv so shell expressions work. - private var healthCommandBinding: Binding { - Binding(get: { - let cmd = spec.healthCheck.command - if cmd.count >= 3, cmd[0] == "sh", cmd[1] == "-c" { return cmd[2] } - return cmd.joined(separator: " ") - }, set: { newValue in - let trimmed = newValue.trimmingCharacters(in: .whitespaces) - spec.healthCheck.command = trimmed.isEmpty ? [] : ["sh", "-c", trimmed] - }) - } - - @ViewBuilder - private var runtimeSection: some View { - Group { - PanelField(label: "Entrypoint", info: "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).") { - 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.") { - TextField("", text: $spec.user, prompt: Text("name | uid[:gid]")).textFieldStyle(.roundedBorder) - } - 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) - Spacer() - } - } - PanelToggleRow(title: "Set shared memory size", - info: "Size of /dev/shm (--shm-size).", isOn: shmLimitBinding) - if !spec.shmSize.isEmpty { - PanelField(label: "Shared memory") { - HStack(spacing: Tokens.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) - } - } - } - - 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).") { - 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).") - } - } - - @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) - } - } - - @ViewBuilder - private var networkSection: some View { - PanelRow(title: "Network", info: "Attach the container to a network (--network).") { - Menu(networkMenuTitle) { - Button { - spec.network = "" - } label: { - Label("Default", systemImage: spec.network.isEmpty ? "checkmark" : "network") - } - if !app.networks.isEmpty { Divider() } - ForEach(app.networks) { network in - Button { - spec.network = network.name - } label: { - Label(network.name, systemImage: spec.network == network.name ? "checkmark" : "network") - } - } - Divider() - Button { - ui.dispatch(.createNetwork) - } label: { - Label("Create New Network…", systemImage: "plus") - } - } - .fixedSize() - TextField("", text: $spec.network, prompt: Text("custom network")).textFieldStyle(.roundedBorder) - .frame(width: 180) - } - .task { await app.refreshNetworks() } - } - - @ViewBuilder - private var fetchSection: some View { - Group { - PanelField(label: "Runtime", info: "Runtime handler (--runtime).") { - TextField("", text: $spec.runtime, prompt: Text("optional")).textFieldStyle(.roundedBorder) - } - PanelField(label: "Init image", info: "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).") { - TextField("", text: $spec.kernel, prompt: Text("optional path")).textFieldStyle(.roundedBorder) - } - PanelRow(title: "Registry scheme", info: "Registry connection scheme for image fetches (--scheme).") { - Picker("", selection: $spec.scheme) { - Text("Default").tag("") - Text("Auto").tag("auto") - Text("HTTPS").tag("https") - Text("HTTP").tag("http") - } - .labelsHidden().fixedSize() - } - PanelRow(title: "Progress", info: "Progress display mode for image fetches (--progress).") { - Picker("", selection: $spec.progress) { - Text("Default").tag("") - Text("Auto").tag("auto") - Text("None").tag("none") - Text("ANSI").tag("ansi") - Text("Plain").tag("plain") - Text("Color").tag("color") - } - .labelsHidden().fixedSize() - } - PanelToggleRow(title: "Limit parallel downloads", - info: "Maximum concurrent image downloads (--max-concurrent-downloads).", isOn: maxDownloadsBinding) - if !spec.maxConcurrentDownloads.isEmpty { - Stepper("Max downloads: \(maxConcurrentDownloadsBinding.wrappedValue)", - value: maxConcurrentDownloadsBinding, in: 1...16) - } - } - } - - @ViewBuilder - private var dnsSection: some View { - Group { - PanelToggleRow(title: "Disable DNS", - info: "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).") { - 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).") - } - } - } - - @ViewBuilder - 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.", - 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).") - labelsSection - } - } - - private var shmLimitBinding: Binding { - Binding(get: { !spec.shmSize.isEmpty }, - set: { spec.shmSize = $0 ? "64M" : "" }) - } - - private var shmGBBinding: Binding { - Binding(get: { Self.parseMemoryGB(spec.shmSize) ?? 0.0625 }, - set: { spec.shmSize = Self.memorySpec(gb: $0) }) - } - - private var maxDownloadsBinding: Binding { - Binding(get: { !spec.maxConcurrentDownloads.isEmpty }, - set: { spec.maxConcurrentDownloads = $0 ? "3" : "" }) - } - - private var maxConcurrentDownloadsBinding: Binding { - Binding(get: { max(1, Int(spec.maxConcurrentDownloads) ?? 3) }, - set: { spec.maxConcurrentDownloads = String($0) }) - } - - private var networkMenuTitle: String { - spec.network.trimmingCharacters(in: .whitespaces).isEmpty ? "Default" : spec.network - } - - /// A repeatable single-string list editor (capabilities, DNS servers, tmpfs, ulimits…). - @ViewBuilder - private func stringList(_ addTitle: String, _ list: Binding<[String]>, prompt: String, info: String) -> some View { - ForEach(list.wrappedValue.indices, id: \.self) { idx in - HStack { - TextField(prompt, text: Binding(get: { list.wrappedValue[idx] }, - set: { list.wrappedValue[idx] = $0 })) - .textFieldStyle(.roundedBorder) - removeButton { list.wrappedValue.remove(at: idx) } - } - } - HStack(spacing: Tokens.Space.s) { - addButton(addTitle) { list.wrappedValue.append("") } - InfoButton(info) - Spacer() - } - } - - private func addButton(_ title: String, action: @escaping () -> Void) -> some View { - GlassButton(singleItem: true) { - GlassButtonItem(help: title, action: action) { - Label(title, systemImage: "plus.circle") - } - } - } - - private func removeButton(action: @escaping () -> Void) -> some View { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "minus.circle.fill", help: "Remove", action: action) - } - } - - private func sourcePicker(source: Binding) -> some View { - Menu { - Button { - pickHostSource(into: source) - } label: { - Label("Choose File or Folder…", systemImage: "folder") - } - if !app.volumes.isEmpty { - Divider() - ForEach(app.volumes) { volume in - Button { - source.wrappedValue = volume.name - } label: { - Label(volume.name, systemImage: source.wrappedValue == volume.name ? "checkmark" : "externaldrive") - } - } - } - Divider() - Button { - ui.dispatch(.createVolume) - } label: { - Label("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") - .task { await app.refreshVolumes() } - } - - private func pickHostSource(into source: Binding) { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.message = "Choose a host file or folder" - guard panel.runModal() == .OK, let url = panel.url else { return } - source.wrappedValue = url.path - } -} diff --git a/Sources/Contained/Features/Containers/RunSpecFormSupport.swift b/Sources/Contained/Features/Containers/RunSpecFormSupport.swift deleted file mode 100644 index b0aa64b7..00000000 --- a/Sources/Contained/Features/Containers/RunSpecFormSupport.swift +++ /dev/null @@ -1,171 +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" - } - - 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 { - var applied = 0 - if command.trimmingCharacters(in: .whitespaces).isEmpty, let cmd = config.cmd, !cmd.isEmpty { - command = cmd.joined(separator: " ") - applied += 1 - } - if entrypoint.trimmingCharacters(in: .whitespaces).isEmpty, - let entrypointValue = config.entrypoint, - !entrypointValue.isEmpty { - entrypoint = entrypointValue.joined(separator: " ") - applied += 1 - } - if workingDir.trimmingCharacters(in: .whitespaces).isEmpty, - let workingDirValue = config.workingDir, - !workingDirValue.isEmpty { - workingDir = workingDirValue - applied += 1 - } - if user.trimmingCharacters(in: .whitespaces).isEmpty, - let userValue = config.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[.. some View { - MetricTile(label: metric.displayName, value: metric.caption(from: delta), - systemImage: symbol, tint: tint, samples: history[metric]?.values) - } - - 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) - } -} diff --git a/Sources/Contained/Features/Containers/TerminalTab.swift b/Sources/Contained/Features/Containers/TerminalTab.swift deleted file mode 100644 index 8d3303a3..00000000 --- a/Sources/Contained/Features/Containers/TerminalTab.swift +++ /dev/null @@ -1,139 +0,0 @@ -import SwiftUI -import AppKit -import SwiftTerm -import ContainedCore - -/// An interactive shell inside a running container, via `container exec -it `. -/// -/// AppKit bridge (flagged per the build rule): SwiftTerm is a mature, AppKit-backed VT100/xterm -/// emulator wrapped through `NSViewRepresentable`. Re-implementing a correct terminal from scratch -/// in SwiftUI would be the single riskiest component, so we use SwiftTerm — the same choice locked -/// 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 - - /// A finished session's exit code (boxed so `nil`-the-state differs from a `nil` exit code). - private struct Ended: Equatable { let code: Int32? } - - @State private var shell = "/bin/sh" - @State private var session = 0 // bump to force a fresh terminal (reconnect / shell change) - @State private var ended: Ended? // nil = live; non-nil = process ended - - private let shells = ["/bin/sh", "/bin/bash", "/bin/ash", "/bin/zsh"] - - 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 { - ContainerToolTabScaffold { - controls - } content: { - ZStack { - 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 - .padding(Tokens.Space.s) - .background(.black.opacity(0.22), - in: RoundedRectangle(cornerRadius: Tokens.Radius.card, style: .continuous)) - .padding(Tokens.Space.s) - if let ended { - endedOverlay(code: ended.code) - } - } - } - } else { - ContentUnavailableView("Terminal unavailable", systemImage: "terminal", - description: Text("The container CLI path couldn't be resolved.")) - } - } - - private var controls: some View { - HStack(spacing: Tokens.Space.m) { - Picker("Shell", selection: $shell) { - ForEach(shells, id: \.self) { Text($0).tag($0) } - } - .labelsHidden() - .frame(width: 140) - .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() } - } - } - } - - private func endedOverlay(code: Int32?) -> some View { - VStack(spacing: Tokens.Space.m) { - 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") - } - } - } - .padding(Tokens.Space.xl) - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: Tokens.Radius.card, style: .continuous)) - } - - private func reconnect() { - ended = nil - session += 1 - } -} - -/// `NSViewRepresentable` wrapper around SwiftTerm's `LocalProcessTerminalView`, which owns the PTY -/// and child process. Tearing down the view (`dismantleNSView`) terminates the `exec` child. -struct TerminalSurface: NSViewRepresentable { - let executableURL: URL - let containerID: String - let shell: String - var onExit: (Int32?) -> Void = { _ in } - - func makeCoordinator() -> Coordinator { Coordinator(onExit: onExit) } - - 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) - - // `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 - // (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 - env["TERM"] = "xterm-256color" - env["LANG"] = env["LANG"] ?? "en_US.UTF-8" - env["COLORTERM"] = "truecolor" - view.startProcess(executable: executableURL.path, - args: ["exec", "--interactive", "--tty", containerID, shell], - environment: env.map { "\($0.key)=\($0.value)" }, execName: nil) - return view - } - - func updateNSView(_ nsView: LocalProcessTerminalView, context: Context) {} - - static func dismantleNSView(_ nsView: LocalProcessTerminalView, coordinator: Coordinator) { - nsView.terminate() - } - - final class Coordinator: NSObject, LocalProcessTerminalViewDelegate { - let onExit: (Int32?) -> Void - init(onExit: @escaping (Int32?) -> Void) { self.onExit = onExit } - - func processTerminated(source: TerminalView, exitCode: Int32?) { onExit(exitCode) } - func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} - func setTerminalTitle(source: LocalProcessTerminalView, title: String) {} - func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} - } -} diff --git a/Sources/Contained/Features/Images/BuildWorkspaceView.swift b/Sources/Contained/Features/Images/BuildWorkspaceView.swift deleted file mode 100644 index bdd3efa0..00000000 --- a/Sources/Contained/Features/Images/BuildWorkspaceView.swift +++ /dev/null @@ -1,176 +0,0 @@ -import SwiftUI -import AppKit -import ContainedCore - -/// Build an image from a Dockerfile + context, streaming the BuildKit log via -/// `container build --progress plain`. -/// -/// AppKit bridge (flagged per the build rule): the folder picker uses `NSOpenPanel` — SwiftUI has -/// no native directory chooser on macOS. Only the picker touches AppKit. -struct BuildWorkspaceView: View { - @Environment(AppModel.self) private var app - - @State private var contextDir: URL? - @State private var dockerfile = "" - @State private var tag = "" - @State private var platform = "" - @State private var noCache = false - @State private var buildArgs: [KeyValue] = [] - @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 } - - var body: some View { - VStack(spacing: 0) { - form - Divider() - if building, let context = contextDir, let client = app.client { - StreamConsole(stream: { - client.streamBuild(context: context.path, - tag: tag.trimmingCharacters(in: .whitespaces), - 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) } } }) - .id(run) - .padding(Tokens.Space.s) - } else { - ContentUnavailableView { - Label("Build an image", systemImage: "hammer") - } description: { - Text("Choose a context folder and a tag, then Build. Output streams here.") - } - } - } - } - - private var form: some View { - ScrollView { - VStack(spacing: Tokens.Space.l) { - sourceSection - optionsSection - commandSection - } - .padding(Tokens.Space.l) - } - .scrollEdgeEffectStyle(.soft, for: .all) - .frame(maxHeight: 420) - } - - 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.") { - HStack { - Text(contextDir?.path ?? "Choose a folder...") - .foregroundStyle(contextDir == nil ? .secondary : .primary) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - GlassButton(singleItem: true) { - GlassButtonItem(help: "Choose context folder", action: chooseFolder) { - Label("Choose", systemImage: "folder") - } - } - } - } - PanelField(label: "Dockerfile", - info: "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).") { - TextField("", text: $tag, prompt: Text("name for the built image, e.g. myapp:latest")) - .textFieldStyle(.roundedBorder) - } - } - } - - private var optionsSection: some View { - PanelSection(header: "Options") { - PanelField(label: "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).", - isOn: $noCache) - ForEach($buildArgs) { $arg in - PanelField(label: "Build arg") { - HStack { - TextField("KEY", text: $arg.key) - .textFieldStyle(.roundedBorder) - Text("=").foregroundStyle(.secondary) - TextField("value", text: $arg.value) - .textFieldStyle(.roundedBorder) - GlassButton(singleItem: true) { - GlassButtonItem(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") - } - } - } - } - } - - private var commandSection: some View { - PanelSection { - HStack(spacing: Tokens.Space.s) { - CommandPreviewBar(command: previewCommand) - .frame(maxWidth: .infinity) - if building { - GlassButton(singleItem: true) { - GlassButtonItem(role: .destructive, help: "Cancel build", action: { building = false }) { - Label("Cancel", systemImage: "xmark") - } - } - } else { - GlassButton(singleItem: true) { - GlassButtonItem(help: "Build image", action: startBuild) { - Label("Build", systemImage: "hammer.fill") - } - } - .disabled(!canBuild) - } - } - } - } - - private var argsDict: [String: String] { - Dictionary(buildArgs.filter(\.isValid).map { ($0.key, $0.value) }, uniquingKeysWith: { _, b in b }) - } - - private var previewCommand: [String] { - ContainerCommands.build(context: contextDir?.path ?? "", - tag: tag.isEmpty ? nil : tag, - dockerfile: dockerfile.isEmpty ? nil : dockerfile, - buildArgs: argsDict, noCache: noCache, - platform: platform.isEmpty ? nil : platform) - } - - private func startBuild() { - run += 1 - building = true - } - - private func chooseFolder() { - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - panel.prompt = "Choose" - panel.message = "Choose the build context folder" - if panel.runModal() == .OK { contextDir = panel.url } - } -} diff --git a/Sources/Contained/Features/Images/ImageStyleButton.swift b/Sources/Contained/Features/Images/ImageStyleButton.swift deleted file mode 100644 index 6ebd5277..00000000 --- a/Sources/Contained/Features/Images/ImageStyleButton.swift +++ /dev/null @@ -1,52 +0,0 @@ -import SwiftUI -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 { - let reference: String - let style: Personalization - let target: CustomizeSheet.Target - - @State private var hovering = false - @State private var showingCustomize = false - - var body: some View { - Button { showingCustomize = true } label: { - ResourceCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, - tint: style.color) - } - .buttonStyle(.plain) - .onHover { hovering = $0 } - .help("Customize image style") - .accessibilityLabel("Customize \(Format.shortImage(reference)) image style") - .popover(isPresented: $showingCustomize, arrowEdge: .trailing) { - CustomizeSheet(target: target, presentation: .popover) - } - } -} - -/// 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 { - let style: Personalization - let target: CustomizeSheet.Target - var help = "Customize" - - @State private var hovering = false - @State private var showingCustomize = false - - var body: some View { - Button { showingCustomize = true } label: { - ResourceCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, - tint: style.color) - } - .buttonStyle(.plain) - .onHover { hovering = $0 } - .help(help) - .accessibilityLabel(help) - .popover(isPresented: $showingCustomize, arrowEdge: .trailing) { - CustomizeSheet(target: target, presentation: .popover) - } - } -} diff --git a/Sources/Contained/Features/Onboarding/BootstrapView.swift b/Sources/Contained/Features/Onboarding/BootstrapView.swift deleted file mode 100644 index bb4c76f3..00000000 --- a/Sources/Contained/Features/Onboarding/BootstrapView.swift +++ /dev/null @@ -1,107 +0,0 @@ -import SwiftUI -import AppKit -import ContainedCore - -/// First-run / degraded states: CLI missing, unsupported version, or service stopped — each with -/// the action that resolves it (start service, locate the CLI, continue anyway, try again). -struct BootstrapView: View { - @Environment(AppModel.self) private var app - @State private var starting = false - - var body: some View { - VStack(spacing: Tokens.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) - - actions - } - .padding(Tokens.Space.xxl) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - @ViewBuilder - private var actions: some View { - switch app.bootstrap { - case .serviceStopped: - Button { - Task { starting = true; await app.startService(); starting = false } - } label: { - Label(starting ? "Starting…" : "Start container service", systemImage: "play.circle") - .padding(.horizontal, Tokens.Space.s) - } - .buttonStyle(.borderedProminent) - .disabled(starting) - case .cliMissing: - HStack(spacing: Tokens.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) { - Button("Continue anyway") { Task { await app.continueUnsupported() } } - .buttonStyle(.borderedProminent) - Button("Try again") { Task { await app.retryBootstrap() } } - } - case .checking: - ProgressView() - case .ready: - EmptyView() - } - } - - private func openReleases() { - if let url = URL(string: "https://github.com/apple/container/releases") { NSWorkspace.shared.open(url) } - } - - private func locateCLI() { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.allowsMultipleSelection = false - panel.message = "Select the `container` binary" - panel.directoryURL = URL(fileURLWithPath: "/usr/local/bin") - if panel.runModal() == .OK, let url = panel.url { - Task { await app.useCLIPath(url.path) } - } - } - - private var icon: String { - switch app.bootstrap { - case .cliMissing: return "exclamationmark.triangle" - case .unsupported: return "exclamationmark.circle" - case .serviceStopped: return "powersleep" - default: return "cube" - } - } - private var title: String { - 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 .checking: return "Connecting…" - case .ready: return "Ready" - } - } - private var message: String { - switch app.bootstrap { - case .cliMissing: - return "Install Apple's container tool, or set its path in Settings. Looked in /usr/local/bin and /opt/homebrew/bin." - 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." - case .checking: - return "Talking to the container service." - case .ready: - return "" - } - } -} diff --git a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift b/Sources/Contained/Features/Registries/RegistryLoginSheet.swift deleted file mode 100644 index d08439bb..00000000 --- a/Sources/Contained/Features/Registries/RegistryLoginSheet.swift +++ /dev/null @@ -1,75 +0,0 @@ -import SwiftUI -import ContainedCore - -/// 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. -struct RegistryLoginSheet: View { - @Environment(AppModel.self) private var app - @Environment(\.dismiss) private var dismiss - @State private var server = "" - @State private var username = "" - @State private var password = "" - @State private var busy = false - @State private var error: String? - - var body: some View { - VStack(spacing: 0) { - SheetHeader(title: "Registry login", onCancel: { dismiss() }) { - 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) - } - } - } - VStack(spacing: Tokens.Space.l) { - PanelSection(header: "Credentials") { - PanelField(label: "Server") { - TextField("", text: $server, prompt: Text("e.g. ghcr.io, docker.io")) - .textContentType(.URL) - .textFieldStyle(.roundedBorder) - } - PanelField(label: "Username") { - TextField("", text: $username, prompt: Text("registry username")) - .textContentType(.username) - .textFieldStyle(.roundedBorder) - } - PanelField(label: "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) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .padding(Tokens.Space.l) - } - .frame(Tokens.SheetSize.small) - .sheetMaterial() - } - - private func submit() { - guard let client = app.client else { return } - busy = true; error = nil - Task { - do { - _ = try await client.registryLogin(server: server.trimmingCharacters(in: .whitespaces), - username: username.trimmingCharacters(in: .whitespaces), - password: password) - await app.refreshRegistries() - dismiss() - } catch let e as CommandError { error = e.userMessage; busy = false } - catch { self.error = error.localizedDescription; busy = false } - } - } -} diff --git a/Sources/Contained/Features/Settings/ConfigTransfer.swift b/Sources/Contained/Features/Settings/ConfigTransfer.swift deleted file mode 100644 index 18df2b55..00000000 --- a/Sources/Contained/Features/Settings/ConfigTransfer.swift +++ /dev/null @@ -1,81 +0,0 @@ -import SwiftUI -import AppKit -import UniformTypeIdentifiers - -enum ConfigImportMode: String, CaseIterable, Identifiable { - case merge = "Merge" - case replace = "Replace" - - var id: String { rawValue } - var replacesExistingData: Bool { self == .replace } -} - -extension UTType { - static let containedBackup = UTType(exportedAs: "app.contained.backup") -} - -struct ConfigTransferControls: View { - @Environment(AppModel.self) private var app - @State private var sections = Set(AppStateSection.allCases) - @State private var importMode: ConfigImportMode = .merge - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(AppStateSection.allCases) { section in - Toggle(section.displayName, isOn: binding(for: section)) - .toggleStyle(.checkbox) - } - Picker("Import mode", selection: $importMode) { - ForEach(ConfigImportMode.allCases) { mode in - Text(mode.rawValue).tag(mode) - } - } - .pickerStyle(.segmented) - - HStack { - Button("Export Backup…") { exportBackup() } - Button("Import Backup…") { importBackup() } - Button("Clean Up Orphans") { app.purgeDeadRows() } - } - } - } - - private func binding(for section: AppStateSection) -> Binding { - Binding { - sections.contains(section) - } set: { isOn in - if isOn { sections.insert(section) } - else { sections.remove(section) } - } - } - - private func exportBackup() { - let panel = NSSavePanel() - panel.allowedContentTypes = [.containedBackup, .json] - panel.nameFieldStringValue = "Contained Backup.containedbackup" - panel.canCreateDirectories = true - guard panel.runModal() == .OK, let url = panel.url else { return } - do { - try app.exportConfiguration(to: url, sections: sections) - app.flash("Exported backup") - } catch { - app.flash(error.localizedDescription) - } - } - - private func importBackup() { - let panel = NSOpenPanel() - panel.allowedContentTypes = [.containedBackup, .json] - panel.canChooseFiles = true - panel.allowsMultipleSelection = false - guard panel.runModal() == .OK, let url = panel.url else { return } - do { - try app.importConfiguration(from: url, - sections: sections, - replace: importMode.replacesExistingData) - app.flash("Imported backup") - } catch { - app.flash(error.localizedDescription) - } - } -} diff --git a/Sources/Contained/Features/Settings/ReleaseNotesView.swift b/Sources/Contained/Features/Settings/ReleaseNotesView.swift deleted file mode 100644 index 7fb27eb6..00000000 --- a/Sources/Contained/Features/Settings/ReleaseNotesView.swift +++ /dev/null @@ -1,52 +0,0 @@ -import SwiftUI -import WebKit - -struct ReleaseNotesView: View { - var title: String - var html: String - var onClose: (() -> Void)? - - var body: some View { - VStack(spacing: 0) { - HStack { - Text(title).font(.headline) - Spacer() - if let onClose { - Button("Done") { onClose() } - } - } - .padding() - Divider() - HTMLView(html: html) - } - .frame(width: 620, height: 520) - } -} - -private struct HTMLView: NSViewRepresentable { - var html: String - - func makeNSView(context: Context) -> WKWebView { - WKWebView() - } - - func updateNSView(_ webView: WKWebView, context: Context) { - let document = """ - - - - - - - \(html) - - """ - webView.loadHTMLString(document, baseURL: nil) - } -} diff --git a/Sources/Contained/Features/Settings/Tabs/AboutTab.swift b/Sources/Contained/Features/Settings/Tabs/AboutTab.swift deleted file mode 100644 index 04ef4e59..00000000 --- a/Sources/Contained/Features/Settings/Tabs/AboutTab.swift +++ /dev/null @@ -1,43 +0,0 @@ -import SwiftUI -import AppKit -import ContainedCore - -// MARK: - About - -struct AboutTab: View { - @Environment(AppModel.self) private var app - - var body: some View { - VStack(spacing: Tokens.Space.l) { - PanelSection { - HStack(spacing: Tokens.Space.m) { - Image(nsImage: NSApp.applicationIconImage) - .resizable().frame(width: 56, height: 56) - VStack(alignment: .leading, spacing: 2) { - Text("Contained").font(.title3.weight(.semibold)) - Text("Version \(appVersion)").font(.callout).foregroundStyle(.secondary) - Text("A native macOS UI for Apple’s container runtime.") - .font(.caption).foregroundStyle(.secondary) - } - Spacer() - } - } - - PanelSection(header: "Runtime") { - PanelRow(title: "Container CLI") { Text(app.cliVersion ?? "—").foregroundStyle(.secondary) } - PanelRow(title: "API server") { Text(app.systemStatus?.apiServerVersion ?? "—").foregroundStyle(.secondary) } - } - - PanelSection { - PanelRow(title: "Copyright") { Text("© 2026 Contained").foregroundStyle(.secondary) } - } - } - } - - private var appVersion: String { - let info = Bundle.main.infoDictionary - let short = info?["CFBundleShortVersionString"] as? String ?? "1.0" - let build = info?["CFBundleVersion"] as? String ?? "1" - return "\(short) (\(build))" - } -} diff --git a/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift b/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift deleted file mode 100644 index 7ff2fbba..00000000 --- a/Sources/Contained/Features/Settings/Tabs/AppearanceTab.swift +++ /dev/null @@ -1,161 +0,0 @@ -import SwiftUI -import ContainedCore - -// MARK: - Appearance - -struct AppearanceTab: View { - @Bindable var settings: SettingsStore - - var body: some View { - VStack(spacing: Tokens.Space.l) { - PanelSection(header: "Theme") { - PanelRow(title: "Appearance") { - Picker("", selection: $settings.appearance) { - ForEach(AppearanceMode.allCases) { Text($0.displayName).tag($0) } - } - .pickerStyle(.segmented).labelsHidden().fixedSize() - } - PanelRow(title: "Accent tint") { - TintSelector(selection: $settings.accentTint) - } - } - - PanelSection(header: "Layout") { - PanelRow(title: "Card size") { - Picker("", selection: $settings.density) { - ForEach(CardDensity.allCases) { Text($0.displayName).tag($0) } - } - .pickerStyle(.segmented).labelsHidden().fixedSize() - } - PanelToggleRow(title: "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.") { - materialMenu($settings.windowMaterial) - } - PanelRow(title: "Panel & sheet material", - info: "Changes floating panels, popovers, and sheets such as Settings and create/edit flows.") { - materialMenu($settings.modalMaterial) - } - PanelRow(title: "Card material", - info: "Changes all resource cards, including compact cards and expanded detail cards.") { - materialMenu($settings.cardMaterial) - } - PanelRow(title: "Button material", - info: "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.", - enabled: $settings.buttonTintEnabled) { - PanelRow(title: "Tint") { - TintSelector(selection: $settings.buttonTint) - } - PanelRow(title: "Opacity") { - HStack(spacing: Tokens.Space.s) { - Slider(value: $settings.buttonTintOpacity, in: 0.05...0.6).frame(width: 140) - Text(Format.percent(settings.buttonTintOpacity)) - .monospacedDigit() - .frame(width: Tokens.FormWidth.shortReadout) - } - } - PanelToggleRow(title: "Gradient", isOn: $settings.buttonTintGradient) - if settings.buttonTintGradient { - GradientAngleControl(angle: $settings.buttonTintGradientAngle) - } - PanelRow(title: "Blend mode") { - Picker("", selection: $settings.buttonTintBlendMode) { - ForEach(ColorLayerBlendMode.allCases) { mode in - Text(mode.displayName).tag(mode) - } - } - .labelsHidden() - .fixedSize() - } - } - - ImageDefaultStyleSection(settings: settings) - } - } - - private func materialMenu(_ binding: Binding) -> some View { - Picker("", selection: binding) { - ForEach(WindowMaterial.allCases) { Text($0.displayName).tag($0) } - } - .labelsHidden().fixedSize() - } -} - -private struct ImageDefaultStyleSection: View { - @Environment(AppModel.self) private var app - @Bindable var settings: SettingsStore - - 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.", - enabled: $settings.imageDefaultStyleEnabled) { - HStack(spacing: Tokens.Space.m) { - ResourceCardIconChip(symbol: style.symbol, tint: style.color) - VStack(alignment: .leading, spacing: 1) { - Text(style.displayName(fallback: "Image cards")) - Text("Inherited unless an image, group, tag, or container overrides it") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - } - PanelRow(title: "Color") { - TintSelector(selection: styleBinding(\.tint)) - } - PanelToggleRow(title: "Custom icon", isOn: styleBinding(\.iconEnabled)) - if style.iconEnabled { - PanelRow(title: "Icon") { - TextField("", text: styleBinding(\.icon), prompt: Text("SF Symbol, e.g. shippingbox.fill")) - .textFieldStyle(.roundedBorder) - .frame(width: 220) - } - } - 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) - Text(Format.percent(style.backgroundOpacity)) - .monospacedDigit() - .frame(width: Tokens.FormWidth.shortReadout) - } - } - PanelToggleRow(title: "Gradient", isOn: styleBinding(\.gradient)) - if style.gradient { - GradientAngleControl(angle: styleBinding(\.gradientAngle)) - } - PanelRow(title: "Blend mode") { - Picker("", selection: styleBinding(\.backgroundBlendMode)) { - ForEach(ColorLayerBlendMode.allCases) { mode in - Text(mode.displayName).tag(mode) - } - } - .labelsHidden() - .fixedSize() - } - } - } - } - - private func styleBinding(_ keyPath: WritableKeyPath) -> Binding { - Binding { - app.personalization.defaultImageStyle[keyPath: keyPath] - } set: { newValue in - var updated = app.personalization.defaultImageStyle - updated[keyPath: keyPath] = newValue - app.personalization.setDefaultImageStyle(updated) - } - } -} diff --git a/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift b/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift deleted file mode 100644 index e96bdff3..00000000 --- a/Sources/Contained/Features/Settings/Tabs/ExperimentalTab.swift +++ /dev/null @@ -1,44 +0,0 @@ -import SwiftUI -import ContainedCore - -// MARK: - Experimental - -/// Opt-in gates for features that aren't fully baked yet. Everything here defaults **off** so a fresh -/// install ships the stable core; flipping a switch reveals the corresponding surface app-wide (menu -/// commands, toolbar affordances, creation options). See `SettingsStore`'s "Experimental features". -struct ExperimentalTab: View { - @Bindable var settings: SettingsStore - - var body: some View { - VStack(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", - info: "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.", - 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.", - 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.", - 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.", - 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.", - isOn: $settings.composeImportEnabled) - PanelToggleRow(title: "Image build workspace", - info: "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.", - isOn: $settings.keyboardShortcutsEnabled) - } - } - } -} diff --git a/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift b/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift deleted file mode 100644 index 8d9c39f2..00000000 --- a/Sources/Contained/Features/Settings/Tabs/GeneralTab.swift +++ /dev/null @@ -1,109 +0,0 @@ -import SwiftUI -import ContainedCore - -// MARK: - General - -struct GeneralTab: View { - @Environment(AppModel.self) private var app - @Bindable var settings: SettingsStore - @State private var confirmingClear = false - - var body: some View { - VStack(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) - } - - 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.", - isOn: $settings.revealCLI) - } - - PanelSection(header: "Data", - footer: "How often running containers are polled, and how long persistent metrics & events are kept before pruning.") { - 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) - } - } - PanelRow(title: "Keep history for") { - Picker("", selection: retentionBinding) { - Text("1 day").tag(1) - Text("7 days").tag(7) - Text("14 days").tag(14) - Text("30 days").tag(30) - } - .labelsHidden().fixedSize() - } - Button("Clear History…", role: .destructive) { confirmingClear = true } - .frame(maxWidth: .infinity, alignment: .leading) - ConfigTransferControls() - } - - PanelSection(header: "Logging", - footer: settings.loggingLevel.footnote) { - PanelRow(title: "Level") { - Picker("", selection: $settings.loggingLevel) { - ForEach(AppLogLevel.allCases) { level in - Text(level.displayName).tag(level) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - } - VStack(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) { - Text("Categories").font(.caption).foregroundStyle(.secondary) - LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), alignment: .leading)], - alignment: .leading, - spacing: Tokens.Space.s) { - ForEach(AppLogCategory.allCases) { category in - Toggle(category.displayName, isOn: setBinding(category, in: \.enabledLogCategories)) - .toggleStyle(.checkbox) - } - } - } - } - - PanelSection(header: "Advanced") { - PanelField(label: "Container CLI path", - info: "Override the auto-detected `container` binary location.") { - TextField("", text: $settings.cliPathOverride, prompt: Text("/usr/local/bin/container")) - .textFieldStyle(.roundedBorder) - } - } - } - .confirmationDialog("Clear all history?", isPresented: $confirmingClear) { - Button("Clear History", role: .destructive) { app.clearHistory() } - } message: { - Text("This permanently removes all recorded metrics and events. Saved templates are kept.") - } - } - - private var retentionBinding: Binding { - Binding(get: { settings.historyRetentionDays }, - set: { app.applyHistoryRetention($0) }) - } - - private func setBinding(_ value: T, in keyPath: ReferenceWritableKeyPath>) -> Binding where T: Hashable { - Binding { - settings[keyPath: keyPath].contains(value) - } set: { isEnabled in - var values = settings[keyPath: keyPath] - if isEnabled { values.insert(value) } - else { values.remove(value) } - settings[keyPath: keyPath] = values - } - } -} diff --git a/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift b/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift deleted file mode 100644 index 1ece95f2..00000000 --- a/Sources/Contained/Features/Settings/Tabs/RuntimeTab.swift +++ /dev/null @@ -1,127 +0,0 @@ -import SwiftUI -import ContainedCore - -// MARK: - Runtime - -/// Daemon runtime configuration: the editable bits (recommended kernel, local DNS domains) plus a -/// read-only view of the daemon defaults. Defaults are read-only because the `container` CLI exposes -/// no setter for them — `system property` only lists; only the kernel and DNS are settable. -struct RuntimeTab: View { - @Environment(AppModel.self) private var app - @State private var dnsDomains: [String] = [] - @State private var confirmingKernel = false - @State private var addingDNS = false - @State private var newDomain = "" - @State private var deletingDomain: String? - - var body: some View { - VStack(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") { - 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.") { - if dnsDomains.isEmpty { - Text("No local DNS domains.") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } else { - ForEach(dnsDomains, id: \.self) { domain in - HStack { - Text(domain).font(.system(.callout, design: .monospaced)) - Spacer() - Button(role: .destructive) { deletingDomain = domain } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - } - } - } - Button("Add Domain…") { newDomain = ""; addingDNS = true } - .frame(maxWidth: .infinity, alignment: .leading) - } - - 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.") { - 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 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 k = props.kernel, let path = k.binaryPath { PanelRow(title: "Kernel") { Text(path).foregroundStyle(.secondary) } } - } - } - } - .task { await app.loadPropertiesIfNeeded(); await loadDNS() } - .confirmationDialog("Install the recommended kernel?", isPresented: $confirmingKernel) { - Button("Download & install") { Task { await installKernel() } } - } message: { - Text("Downloads and sets the recommended kernel as the default. This may take a moment.") - } - .confirmationDialog("Delete DNS domain \(deletingDomain ?? "")?", - isPresented: deletingDomainBinding, presenting: deletingDomain) { domain in - Button("Delete", role: .destructive) { Task { await deleteDNS(domain) } } - } message: { _ in Text("This may prompt for your administrator password (handled by the container CLI).") } - .alert("New local DNS domain", isPresented: $addingDNS) { - TextField("example.test", text: $newDomain) - Button("Cancel", role: .cancel) { newDomain = "" } - Button("Create") { Task { await addDNS() } } - } message: { - Text("Creating a domain may prompt for your administrator password (handled by the container CLI).") - } - } - - /// A small copyable CLI hint, shown only when the Reveal-CLI setting is on. - @ViewBuilder - private func revealCLIHint(_ command: String) -> some View { - if app.settings.revealCLI { - HStack(spacing: Tokens.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") - } - } - } - - private var deletingDomainBinding: Binding { - Binding(get: { deletingDomain != nil }, set: { if !$0 { deletingDomain = nil } }) - } - - private func loadDNS() async { - guard let client = app.client else { return } - if let domains = try? await client.dnsDomains() { 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) } - else { app.flash("Recommended kernel installed"); 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) } - 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) } - else { await loadDNS() } - } -} - diff --git a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift b/Sources/Contained/Features/System/Components/SystemLogsSheet.swift deleted file mode 100644 index 5ebe25c3..00000000 --- a/Sources/Contained/Features/System/Components/SystemLogsSheet.swift +++ /dev/null @@ -1,35 +0,0 @@ -import SwiftUI -import ContainedCore - -struct SystemLogsSheet: View { - @Environment(AppModel.self) private var app - @Environment(\.dismiss) private var dismiss - @State private var follow = false - @State private var session = 0 - - var body: some 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) - .onChange(of: follow) { _, _ in session += 1 } - Spacer() - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true) { - dismiss() - } - } - } - .padding(Tokens.Space.s) - if let client = app.client { - StreamConsole(stream: { client.streamSystemLogs(follow: follow, last: 500) }) - .id(session) - .padding(.horizontal, Tokens.Space.s) - .padding(.bottom, Tokens.Space.s) - } - } - .frame(Tokens.SheetSize.wide) - .sheetMaterial() - } -} diff --git a/Sources/Contained/Features/System/SystemView.swift b/Sources/Contained/Features/System/SystemView.swift deleted file mode 100644 index 8446f627..00000000 --- a/Sources/Contained/Features/System/SystemView.swift +++ /dev/null @@ -1,487 +0,0 @@ -import SwiftUI -import ContainedCore - -/// 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 -/// defaults, kernel, and DNS configuration live in Settings → Runtime. -struct SystemContent: View { - @Environment(AppModel.self) private var app - @Environment(UIState.self) private var ui - /// Flat cards (no shadow) when hosted in the toolbar morph panel; elevated if shown standalone. - var showClose: Bool - var elevated = true - var usesToolbarSelection = true - var onClose: () -> Void = {} - - @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 - - enum SystemPage: String, CaseIterable, Identifiable { - case engine = "Engine" - case automation = "Automation" - case volumes = "Volumes" - - var id: String { rawValue } - var systemImage: String { - switch self { - case .engine: return "server.rack" - case .automation: return "clock.arrow.circlepath" - case .volumes: return "externaldrive" - } - } - - var subtitle: String { - switch self { - case .engine: return "Container engine" - case .automation: return "Background work" - case .volumes: return "Named, temp, and path mounts" - } - } - } - - private var showsHeader: Bool { - showClose || !ui.toolbarUIEnabled - } - - private var activePage: SystemPage { - ui.toolbarUIEnabled && !showClose && usesToolbarSelection ? ui.systemPage : page - } - - private func setPage(_ item: SystemPage) { - if ui.toolbarUIEnabled && !showClose && usesToolbarSelection { - ui.systemPage = item - } else { - page = item - } - } - - init(initialPage: SystemPage = .engine, - showClose: Bool = true, - elevated: Bool = true, - usesToolbarSelection: Bool = true, - onClose: @escaping () -> Void = {}) { - self.showClose = showClose - self.elevated = elevated - self.usesToolbarSelection = usesToolbarSelection - self.onClose = onClose - _page = State(initialValue: initialPage) - } - - private typealias VolumeInventoryEntry = SystemVolumeInventory.Entry - - enum PruneTarget: String, Identifiable { - case containers, images, volumes, networks - 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?" - } - } - } - - var body: some View { - MorphPanelScaffold(width: Tokens.PanelSize.system.width) { - if showsHeader { - VStack(spacing: 0) { - header - Divider() - } - } - } content: { - VStack(alignment: .leading, spacing: Tokens.Space.l) { - switch activePage { - case .engine: engineStatusCard - case .automation: automationCard - case .volumes: volumesCard - } - } - .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) } } - } message: { _ in Text("This permanently removes the volume and its data.") } - .confirmationDialog(pruneTarget?.title ?? "", isPresented: pruneBinding, presenting: pruneTarget) { target in - Button("Remove", role: .destructive) { Task { await prune(target) } } - } message: { _ in Text("This permanently removes unused resources to reclaim disk space.") } - .confirmationDialog("Reclaim all unused space?", isPresented: $reclaimingAll) { - Button("Reclaim all", role: .destructive) { Task { await reclaimAll() } } - } message: { - Text("Removes stopped containers, unused images, unused volumes, and unused networks.") - } - } - - /// 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) - } - - private var header: some View { - PanelHeader(symbol: "gearshape.2", - title: "System", - subtitle: activePage.subtitle) { - HStack(spacing: Tokens.Toolbar.groupSpacing) { - engineControls - GlassButton { - pageButtons - storageMenu - if showClose { - GlassButtonItem(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 engineControls: some View { - GlassButton { - servicePowerButton - GlassButtonItem(systemName: "arrow.clockwise", help: "Restart service") { - run { await app.restartService() } - } - .disabled(working) - } - } - - @ViewBuilder - private var servicePowerButton: some View { - if app.serviceHealthy { - GlassButtonItem(systemName: "stop.fill", role: .destructive, help: "Stop service") { - run { await app.stopService() } - } - .disabled(working) - } else { - GlassButtonItem(systemName: "play.fill", help: "Start service") { - run { await app.startService() } - } - .disabled(working) - } - } - - private var storageMenu: some View { - Menu { - Button { reclaimingAll = true } label: { - Label("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") } - } label: { - GlassButtonItem(systemName: "trash", role: .destructive, - help: "Storage cleanup") - } - .buttonStyle(.plain) - } - - // MARK: Volumes - - private var volumeInventory: [VolumeInventoryEntry] { - SystemVolumeInventory.build(volumes: app.volumes, - containers: app.containers.snapshots) - } - - // MARK: Volumes (condensed rows — the rich per-volume I/O card moves to a detail view) - - private var volumesCard: some 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()) - Spacer() - GlassButton(singleItem: true) { - GlassButtonItem(help: "New volume", action: { - onClose() - ui.dispatch(.createVolume) - }) { - Label("New", systemImage: "plus") - } - } - } - if volumeInventory.isEmpty { - Text("No named volumes or container mounts found.") - .font(.callout).foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, Tokens.Space.xs) - } else { - VStack(spacing: 0) { - ForEach(Array(volumeInventory.enumerated()), id: \.element.id) { index, entry in - if index > 0 { Divider() } - volumeRow(entry) - } - } - } - } - } - - 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) { - HStack(spacing: Tokens.Space.xs) { - Text(entry.title).font(.system(.callout, design: .monospaced)).lineLimit(1) - ResourceBadgeText(text: entry.kind.rawValue) - } - if let subtitle = SystemVolumeInventory.rowSubtitle(entry) { - Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) - } - } - Spacer(minLength: Tokens.Space.s) - if !entry.containers.isEmpty { - Text("\(entry.containers.count)") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - GlassRowMenu { volumeMenu(entry) } - } - .padding(.vertical, Tokens.Space.s) - .contextMenu { volumeMenu(entry) } - } - - @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") } - } else { - Button("Copy destination", systemImage: "arrow.down.doc") {} - .disabled(true) - } - Divider() - if let volume = entry.resource { - Button(role: .destructive) { deletingVolume = volume } label: { Label("Delete", systemImage: "trash") } - } else { - Button("Delete", systemImage: "trash", role: .destructive) {} - .disabled(true) - } - } - - private var deletingVolumeBinding: Binding { - Binding(get: { deletingVolume != nil }, set: { if !$0 { deletingVolume = nil } }) - } - - private func deleteVolume(_ volume: VolumeResource) 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() - } - - private var automationCard: some View { - card { - Text("Automation").font(.headline) - TimelineView(.periodic(from: .now, by: 1)) { context in - automationRow(icon: "arrow.triangle.2.circlepath", - title: "Image update check", - detail: app.settings.imageUpdateChecksEnabled - ? "\(backgroundTaskDetail(now: context.date)) · \(app.imageUpdateIntervalDescription)" - : "Paused", - isOn: settingBinding(\.imageUpdateChecksEnabled)) { - 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: { - Task { await app.runImageUpdateSweepNow() } - }) { - Label("Run now", systemImage: "arrow.triangle.2.circlepath") - } - } - } - } - } - Divider() - automationRow(icon: "arrow.down.app", - title: "App update check", - detail: app.updater.canCheckForUpdates - ? "Sparkle · \(app.settings.updateChannel.rawValue.capitalized) channel" - : "Unavailable in this build", - isOn: appUpdateBinding) { - GlassButton(singleItem: true) { - GlassButtonItem(help: "Check for app updates now", action: { - app.updater.checkForUpdates() - }) { - Label("Check now", systemImage: "arrow.down.app") - } - } - .disabled(!app.updater.canCheckForUpdates || !app.settings.appUpdateChecksEnabled) - } - Divider() - automationRow(icon: "arrow.clockwise.circle", - title: "Auto-restart crashed containers", - detail: app.settings.autoRestartEnabled - ? "Restarts containers that exit unexpectedly" - : "Off", - isOn: settingBinding(\.autoRestartEnabled)) { EmptyView() } - Divider() - HStack(spacing: Tokens.Space.s) { - Image(systemName: "dot.radiowaves.left.and.right").foregroundStyle(.secondary).frame(width: 20) - Text("Refresh loop").font(.callout) - Spacer() - Text(app.coordinator.isActive ? "Active" : "Paused") - .font(.callout) - .foregroundStyle(app.coordinator.isActive ? .green : .secondary) - } - } - } - - private func automationRow(icon: String, title: String, detail: String, - isOn: Binding, - @ViewBuilder trailing: () -> Trailing) -> some 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) { - Text(title).font(.callout) - Text(detail).font(.caption).foregroundStyle(.secondary) - } - Spacer(minLength: Tokens.Space.s) - trailing() - Toggle("", isOn: isOn).labelsHidden().controlSize(.mini) - } - } - - /// A binding to a `SettingsStore` boolean (the store is a class, so its key paths are writable). - private func settingBinding(_ keyPath: ReferenceWritableKeyPath) -> Binding { - Binding(get: { app.settings[keyPath: keyPath] }, set: { app.settings[keyPath: keyPath] = $0 }) - } - - /// The Sparkle toggle writes through to both the persisted setting and the live updater. - private var appUpdateBinding: Binding { - Binding(get: { app.settings.appUpdateChecksEnabled }, - set: { app.settings.appUpdateChecksEnabled = $0; app.updater.automaticallyChecks = $0 }) - } - - private func backgroundTaskDetail(now: Date) -> String { - if let last = app.imageUpdateLastRunDate { - return "Last ran \(last.formatted(date: .omitted, time: .shortened))" - } - return app.imageUpdateNextRunDate <= now ? "Ready to run" : "Not run yet" - } - - private func countdown(to date: Date, now: Date) -> String { - let seconds = max(0, Int(date.timeIntervalSince(now))) - if seconds == 0 { return "due now" } - let hours = seconds / 3600 - let minutes = (seconds % 3600) / 60 - let secs = seconds % 60 - if hours > 0 { return String(format: "%dh %02dm", hours, minutes) } - if minutes > 0 { return String(format: "%dm %02ds", minutes, secs) } - return "\(secs)s" - } - - private var pruneBinding: Binding { - Binding(get: { pruneTarget != nil }, set: { if !$0 { pruneTarget = nil } }) - } - - private func prune(_ target: PruneTarget) async { - 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() - } - await app.refreshSystemResources() - await app.refreshSystem() - } catch let error as CommandError { app.flash(error.userMessage) } - catch { app.flash(error.localizedDescription) } - } - - 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() - }) { app.flash(error) } - await app.refreshSystemResources() - await app.refreshSystem() - } - - // MARK: Runtime - - private var engineStatusCard: some View { - card { - HStack(spacing: Tokens.Space.s) { - Circle().fill(app.serviceHealthy ? Color.green : Color.orange).frame(width: 9, height: 9) - 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()) - Spacer(minLength: 0) - if let version = app.systemStatus?.apiServerVersion { - Text(version).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary) - .textSelection(.enabled) - } - } - 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) - } - 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/ContainerHistoryTab.swift b/Sources/Contained/History/ContainerHistoryTab.swift deleted file mode 100644 index 1c3e0110..00000000 --- a/Sources/Contained/History/ContainerHistoryTab.swift +++ /dev/null @@ -1,199 +0,0 @@ -import SwiftUI -import SwiftData -import Charts -import ContainedCore - -/// Selectable history windows for the timeline charts. -enum HistoryRange: String, CaseIterable, Identifiable { - case hour = "1h" - case day = "24h" - case week = "7d" - var id: String { rawValue } - var seconds: TimeInterval { - switch self { - case .hour: return 3600 - case .day: return 86_400 - case .week: return 604_800 - } - } -} - -/// 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 { - let snapshot: ContainerSnapshot - @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. - @State private var cutoff = Date().addingTimeInterval(-HistoryRange.day.seconds) - - var body: some View { - ContainerTabScaffold { - VStack(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) - } - } - .onChange(of: range) { _, newRange in cutoff = Date().addingTimeInterval(-newRange.seconds) } - } -} - -/// The charts + event list for one container, scoped to a time window. The window is pushed straight -/// 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 { - @Query private var samples: [MetricSample] - @Query private var events: [EventRecord] - - init(containerID: String, cutoff: Date) { - _samples = Query(filter: #Predicate { $0.containerID == containerID && $0.timestamp >= cutoff }, - sort: \MetricSample.timestamp) - _events = Query(filter: #Predicate { $0.containerID == containerID && $0.timestamp >= cutoff }, - sort: \EventRecord.timestamp, order: .reverse) - } - - var body: some View { - VStack(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") { - Chart(samples) { sample in - LineMark(x: .value("Time", sample.timestamp), - y: .value("CPU", sample.cpuFraction * 100)) - .foregroundStyle(Color.accentColor) - .interpolationMethod(.monotone) - } - } - chartCard("Memory", unit: "MB") { - Chart(samples) { sample in - AreaMark(x: .value("Time", sample.timestamp), - y: .value("Memory", sample.memoryBytes / 1_048_576)) - .foregroundStyle(Color.accentColor.opacity(0.3)) - } - } - 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 { - VStack(alignment: .leading, spacing: Tokens.Space.s) { - Text("Events").font(.headline) - ForEach(events.prefix(50)) { event in - EventRow(event: event) - } - } - } - } - } - - private func chartCard(_ title: String, unit: String, @ViewBuilder chart: @escaping () -> C) -> some View { - ContainerTabSection { - HStack { - Text(title).font(.subheadline.weight(.semibold)) - Spacer() - Text(unit).font(.caption).foregroundStyle(.secondary) - } - chart() - .frame(height: 140) - .chartXAxis { AxisMarks(values: .automatic(desiredCount: 4)) } - } - } -} - -/// One row in an event log (used by the history tab and the system Activity view). -struct EventRow: View { - let event: EventRecord - var elevated = true - /// When true, the row is highlighted (accent wash + dot) to mark an event the user hasn't seen yet. - /// The Activity panel passes this; the per-container history tab leaves it false. - var isUnread = false - @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: 0.22) - } content: { - VStack(alignment: .leading, spacing: 1) { - ResourceCardTitleText(text: event.message) - HStack(spacing: Tokens.Space.xs) { - ResourceBadgeText(text: event.kind.rawValue.capitalized) - ResourceCardSubtitleText(text: subtitle) - } - } - } trailing: { - if isUnread { - Circle().fill(Color.accentColor).frame(width: 8, height: 8) - .accessibilityLabel("Unread") - } else { - EmptyView() - } - } - } - .selectionFill() - .contextMenu { rowMenu } - } - - /// Relative time, plus the container's short id when the event is container-scoped. - private var subtitle: String { - var parts = [event.timestamp.formatted(.relative(presentation: .numeric))] - if let id = event.containerID, !id.isEmpty { parts.append(String(id.prefix(12))) } - return parts.joined(separator: " · ") - } - - @ViewBuilder - private var rowMenu: some View { - if event.isRead { - Button { event.isRead = false; save() } label: { Label("Mark as Unread", systemImage: "circle") } - } else { - Button { event.isRead = true; save() } label: { Label("Mark as Read", systemImage: "checkmark.circle") } - } - Button { copyToPasteboard(event.message) } label: { Label("Copy Message", systemImage: "doc.on.doc") } - Divider() - Button(role: .destructive) { modelContext.delete(event); save() } label: { - Label("Delete Event", systemImage: "trash") - } - } - - private func save() { try? modelContext.save() } -} - -extension EventKind { - /// A per-kind accent used for the event row's icon chip — gives the log visual texture and lets - /// alerts/health transitions read at a glance. - var tint: Color { - switch self { - case .lifecycle: return .green - case .image, .pull: return .blue - case .compose: return .purple - case .build: return .orange - case .registry: return .teal - case .watchdog: return .orange - case .healthcheck: return .pink - case .alert: return .red - case .system, .ui: return .secondary - } - } -} diff --git a/Sources/Contained/History/Template.swift b/Sources/Contained/History/Template.swift deleted file mode 100644 index 24e022af..00000000 --- a/Sources/Contained/History/Template.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation -import SwiftData - -/// A saved container recipe — a named `RunSpec`, 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: RunSpec, createdAt: Date = Date()) { - self.name = name - self.createdAt = createdAt - self.specData = (try? JSONEncoder().encode(spec)) ?? Data() - } - - init(snapshot: TemplateSnapshot) { - self.name = snapshot.name - self.createdAt = snapshot.createdAt - self.specData = (try? JSONEncoder().encode(snapshot.spec)) ?? Data() - } - - var spec: RunSpec? { try? JSONDecoder().decode(RunSpec.self, from: specData) } -} - -struct TemplateSnapshot: Codable { - var name: String - var createdAt: Date - var spec: RunSpec - - init?(_ template: Template) { - guard let spec = template.spec else { return nil } - self.name = template.name - self.createdAt = template.createdAt - self.spec = spec - } -} - -/// A few ready-to-run starters offered alongside the user's saved templates. -enum BuiltinTemplate { - static let all: [(name: String, symbol: String, spec: RunSpec)] = [ - 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")]), - make("nginx", symbol: "globe", image: "nginx:latest", ports: [("8080", "80")]), - make("Alpine (shell)", symbol: "terminal", image: "alpine:latest", command: "sleep infinity"), - ] - - 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() - spec.image = image - spec.command = command - spec.ports = ports.map { PortMap(hostPort: $0.0, containerPort: $0.1, proto: "tcp") } - spec.env = env.map { KeyValue(key: $0.0, value: $0.1) } - return (name, symbol, spec) - } -} diff --git a/Sources/Contained/Migration/DowngradeDecisionView.swift b/Sources/Contained/Migration/DowngradeDecisionView.swift deleted file mode 100644 index 6d2aa2fb..00000000 --- a/Sources/Contained/Migration/DowngradeDecisionView.swift +++ /dev/null @@ -1,31 +0,0 @@ -import SwiftUI - -struct DowngradeDecisionView: View { - let schemaVersion: Int - var onExportAndReset: () -> Void - var onKeep: () -> Void - var onQuit: () -> Void - - var body: some View { - VStack(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", - 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.") - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - VStack(alignment: .leading, spacing: Tokens.Space.s) { - Button("Export Backup, Then Reset") { onExportAndReset() } - .buttonStyle(.borderedProminent) - Button("Try to Keep Readable Data") { onKeep() } - Button("Quit Contained", role: .cancel) { onQuit() } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(Tokens.Space.l) - .frame(width: 460) - } -} diff --git a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift b/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift deleted file mode 100644 index ab8f6c5c..00000000 --- a/Sources/Contained/Navigation/ToolbarPanels/PaletteResultCard.swift +++ /dev/null @@ -1,306 +0,0 @@ -import SwiftUI -import AppKit -import ContainedCore - -/// One result row in the command palette. Renders a different card layout per `PaletteItem.visual` -/// (plain action, container, image group/tag, volume, network), with selection highlighting. -struct PaletteResultCard: View { - @Environment(AppModel.self) private var app - let item: PaletteItem - let selected: Bool - var action: () -> Void - - var body: some View { - switch item.visual { - case .plain: - plainCard - case .container(let snapshot): - containerCard(snapshot) - case .imageGroup(let group): - imageGroupCard(group) - case .imageTag(let reference, let groupID): - imageTagCard(reference, groupID: groupID) - case .volume(let volume): - resourceCard(symbol: "externaldrive", - title: volume.name, - subtitle: "Volume", - footer: "Use in a new run") - case .network(let network): - resourceCard(symbol: "network", - title: network.name, - subtitle: network.isBuiltin ? "Built-in network" : "Network", - footer: "Run a container on this network") - case .tint(let tint): - tintCard(tint) - } - } - - private var plainCard: some View { - ResourceGlassCard(size: .small, - isSelected: selected, - fill: nil, - fillOpacity: selected ? 0.10 : 0.18, - elevated: false, - onTap: action) { - ResourceCardHeader { - ResourceCardIconChip(symbol: item.icon, tint: item.tint, backgroundOpacity: selected ? 0.24 : 0.16) - } content: { - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: Tokens.Space.s) { - ResourceCardTitleText(text: item.title) - ResourceBadgeText(text: item.kind.rawValue, - font: .caption2.weight(.semibold), - foreground: selected ? .accentColor : .secondary) - } - if let subtitle = item.subtitle, !subtitle.isEmpty { - ResourceCardSubtitleText(text: subtitle) - } - } - } trailing: { - accessory - } - } footerLeading: { - EmptyView() - } footerActions: { - EmptyView() - } - .selectionFill() - .accessibilityAddTraits(selected ? .isSelected : []) - } - - 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 ResourceGlassCard(size: cardSize, - isSelected: selected, - fill: style.fillBackground ? style.color : nil, - fillOpacity: selected ? 0.14 : 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) - } content: { - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: Tokens.Space.s) { - ResourceCardTitleText(text: name) - ResourceBadgeText(text: snapshot.state.rawValue.capitalized, - font: .caption2.weight(.semibold), - foreground: snapshot.state == .running ? .green : .secondary) - } - ResourceCardMonospacedSubtitleText(text: Format.shortImage(snapshot.image)) - } - } trailing: { - EmptyView() - } - } bodyContent: { - EmptyView() - } footerLeading: { - containerStatus(snapshot) - } footerActions: { - accessory - } widget: { - if snapshot.state == .running { - containerPaletteWidget(snapshot) - .padding(.horizontal, 10) - .padding(.bottom, 10) - } - } - .selectionFill() - .accessibilityAddTraits(selected ? .isSelected : []) - } - - 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) - } - } - .accessibilityAddTraits(selected ? .isSelected : []) - } - - 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 ? 0.14 : 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) - } content: { - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: Tokens.Space.s) { - ResourceCardMonospacedTitleText(text: Format.shortImage(reference)) - ResourceBadgeText(text: "Tag", font: .caption2.weight(.semibold)) - } - ResourceCardSubtitleText(text: repositoryTitle(reference)) - } - } trailing: { - EmptyView() - } - } footerLeading: { - ResourceCardFooterMini { - Image(systemName: style.symbol) - .font(.caption2) - .foregroundStyle(style.color) - } text: { - ResourceCardMetricText(text: "Image") - .foregroundStyle(.secondary) - } - } footerActions: { - accessory - } - .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 ? 0.12 : 0.18, - elevated: false, - onTap: action) { - ResourceCardHeader { - ResourceCardIconChip(symbol: symbol, tint: item.tint, backgroundOpacity: selected ? 0.24 : 0.16) - } content: { - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: Tokens.Space.s) { - ResourceCardTitleText(text: title) - ResourceBadgeText(text: subtitle, font: .caption2.weight(.semibold)) - } - ResourceCardSubtitleText(text: footer) - } - } trailing: { - accessory - } - } - .selectionFill() - .accessibilityAddTraits(selected ? .isSelected : []) - } - - private func tintCard(_ tint: AppTint) -> some View { - ResourceGlassCard(size: .small, - isSelected: selected, - fill: tint.color, - fillOpacity: selected ? 0.18 : 0.10, - 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) - } content: { - VStack(alignment: .leading, spacing: 1) { - 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) - } - ResourceCardSubtitleText(text: item.title) - } - } trailing: { - accessory - } - } - .selectionFill() - .accessibilityAddTraits(selected ? .isSelected : []) - } - - private func containerPaletteWidget(_ snapshot: ContainerSnapshot) -> some View { - HStack(spacing: 10) { - ResourceCardFooterMini { - Image(systemName: "clock").font(.caption2) - } text: { - ResourceCardMetricText(text: Format.uptime(since: snapshot.startedDate)) - } - ResourceCardFooterMini { - Image(systemName: "network").font(.caption2) - } text: { - ResourceCardMetricText(text: "\(snapshot.status.networks.count)") - } - ResourceCardFooterMini { - Image(systemName: "shippingbox").font(.caption2) - } text: { - ResourceCardMetricText(text: Format.shortImage(snapshot.image)) - } - } - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private var accessory: some View { - switch item.accessory { - case .run: - if selected { - Image(systemName: "return") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - .frame(width: Tokens.IconSize.chip, height: Tokens.IconSize.chip) - } else { - GlassListRowChevron() - .frame(width: Tokens.IconSize.chip, height: Tokens.IconSize.chip) - } - case .toggle(let isOn, let set): - Toggle("", isOn: Binding { - isOn() - } set: { newValue in - set(newValue) - }) - .labelsHidden() - .toggleStyle(.switch) - case .disabled(let reason): - Text(reason) - .font(.caption) - .foregroundStyle(.tertiary) - } - } - - private func repositoryTitle(_ reference: String) -> String { - let parsed = RegistryImageReference.parse(reference) - return parsed.repository.split(separator: "/").map(String.init).last ?? parsed.repository - } - - private func imageUpdateText(_ status: ImageUpdateStatus) -> String { - switch status.state { - case .unknown: return "Not checked" - case .checking: return "Checking for updates" - case .current: return "Up to date" - case .updateAvailable: return "Update available" - case .error: return "Update check failed" - } - } - - private func containerStatus(_ snapshot: ContainerSnapshot) -> some View { - ResourceCardFooterMini { - Image(systemName: snapshot.state == .running ? "circle.fill" : "circle") - .font(.caption2) - .foregroundStyle(snapshot.state == .running ? .green : .secondary) - } text: { - ResourceCardMetricText(text: snapshot.state.rawValue.capitalized) - .foregroundStyle(.secondary) - } - } -} - -/// Collects toolbar button slot frames (in the toolbar coordinate space) so a morph can grow from the -/// exact button that opened it. diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift deleted file mode 100644 index d5bafb63..00000000 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarImageGroupCard.swift +++ /dev/null @@ -1,513 +0,0 @@ -import SwiftUI -import SwiftData -import AppKit -import ContainedCore - -struct ToolbarImageGroupCard: View { - @Environment(AppModel.self) private var app - @Environment(UIState.self) private var ui - let group: LocalImageTagGroup - let isExpanded: Bool - var onTap: () -> Void - var onClose: () -> Void - - @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 - @State private var tagTarget = "" - @State private var tagBusy = false - - enum ImageDetailPage: Equatable { - case root - case inspect(String) - case history(String) - case tag(String) - case push(String) - } - - private var spring: Animation { .spring(response: 0.42, dampingFraction: 0.86) } - - var body: some View { - Group { - if isExpanded { - expandedContent - .morphPanelSize(size(for: page)) - .morphPanelPlacement(.anchored) - .animation(spring, value: page) - } else { - rootCard - } - } - .confirmationDialog("Delete \(Format.shortImage(deletingReference ?? ""))?", - isPresented: deletingBinding, - presenting: deletingReference) { reference in - Button("Delete", role: .destructive) { Task { await delete(reference) } } - } 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) } } - Button("Remove all unreferenced", role: .destructive) { Task { await prune(all: true) } } - } message: { - Text("Unused images aren't referenced by any container. “All” also removes dangling layers.") - } - } - - @ViewBuilder - private var expandedContent: some View { - switch page { - case .root: rootCard - case .inspect(let ref): inspectPage(ref) - case .history(let ref): historyPage(ref) - case .tag(let source): tagPage(source) - case .push(let ref): pushPage(ref) - } - } - - private var rootCard: some 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) - } bodyContent: { - tagList(group) - } footerLeading: { - HStack(spacing: 10) { - imageFooterTagCount(group) - imageFooterInfo(status) - } - } footerActions: { - imageFooterActions(group) - } - .contextMenu { cardMenu(group) } - } - - // MARK: Detail sub-pages - - private func size(for page: ImageDetailPage) -> CGSize { - switch page { - case .root: return Tokens.PanelSize.imageDetail - case .inspect, .history: return Tokens.SheetSize.inspector - case .tag: return CGSize(width: 560, height: 360) - case .push: return Tokens.SheetSize.console - } - } - - 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 } - } - } - } - Divider() - } - } content: { - content() - .padding(Tokens.Space.s) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - } - - 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 - let history = variant?.config?.history ?? [] - return subPageScaffold(symbol: "clock.arrow.circlepath", title: "History", - subtitle: Format.shortImage(reference)) { - if history.isEmpty { - ContentUnavailableView("No history", systemImage: "clock", - description: Text("This image records no layer history.")) - } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { - ForEach(Array(history.enumerated()), id: \.offset) { _, entry in - VStack(alignment: .leading, spacing: 2) { - Text(entry.createdBy ?? entry.comment ?? "—") - .font(.system(.caption, design: .monospaced)) - .frame(maxWidth: .infinity, alignment: .leading) - if let created = entry.created { - Text(created.formatted(date: .abbreviated, time: .shortened)) - .font(.caption2).foregroundStyle(.secondary) - } - } - .padding(Tokens.Space.s) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary.opacity(0.25), - in: RoundedRectangle(cornerRadius: Tokens.Radius.control, - style: .continuous)) - } - } - .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)) { - VStack(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) } - } - } - 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 { - StreamConsole(stream: { client.streamPush(reference) }) - } else { - ContentUnavailableView("Runtime unavailable", systemImage: "exclamationmark.triangle") - } - } - } - - private func submitTag(source: String) { - guard let client = app.client 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) - await app.refreshImagesIfStale(force: true) - tagBusy = false - tagTarget = "" - withAnimation(spring) { page = .root } - } catch let error as CommandError { - app.flash(error.userMessage); tagBusy = false - } catch { - app.flash(error.localizedDescription); tagBusy = false - } - } - } - - 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: { - VStack(alignment: .leading, spacing: 1) { - ResourceCardTitleText(text: repositoryTitle(group.primaryReference)) - ResourceCardSubtitleText(text: repositoryOwner(group.primaryReference)) - } - } trailing: { - if isExpanded { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true, action: onClose) - } - } else { - EmptyView() - } - } - } - - private func imageFooterInfo(_ status: ImageUpdateStatus) -> some View { - ResourceCardFooterMini { - Image(systemName: updateSymbol(status.state)) - .font(.caption) - .foregroundStyle(updateTint(status.state)) - } text: { - ResourceCardSubtitleText(text: updateFooterText(status)) - } - } - - private func imageFooterTagCount(_ group: LocalImageTagGroup) -> some View { - ResourceCardFooterMini { - Image(systemName: "tag") - .font(.caption) - .foregroundStyle(.secondary) - } text: { - ResourceCardMetricText(text: "\(group.references.count)") - } - } - - @ViewBuilder - private func imageFooterActions(_ group: LocalImageTagGroup) -> some View { - footerAction("play", help: "Run") { - ui.runImage(group.primaryReference) - if isExpanded { onClose() } - } - footerAction("arrow.triangle.2.circlepath", help: "Check for Updates") { - Task { await app.checkImageUpdate(group.primaryReference) } - } - if app.imageUpdateStatus(for: group.primaryReference).state == .updateAvailable { - footerAction("arrow.down.circle", help: "Pull Update", tint: .orange) { - Task { await app.pullImageUpdate(group.primaryReference) } - } - } - 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", tint: .red) { pruning = true } - } - - private func tagList(_ group: LocalImageTagGroup) -> some View { - VStack(alignment: .leading, spacing: Tokens.Space.s) { - Text("Tags") - .font(.headline) - .padding(.leading, Tokens.Space.xs) - ScrollView(.vertical) { - LazyVStack(spacing: Tokens.Space.s) { - ForEach(group.references, id: \.self) { reference in - tagRow(reference, in: group) - .frame(maxWidth: .infinity) - } - } - .padding(Tokens.Space.s) - } - .scrollEdgeEffectStyle(.soft, for: .all) - } - } - - 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: { - VStack(alignment: .leading, spacing: 1) { - ResourceCardMonospacedTitleText(text: Format.shortImage(reference)) - ResourceCardSubtitleText(text: repositoryName(reference)) - } - } trailing: { - EmptyView() - } - } footerLeading: { - ResourceCardFooterMini { - Image(systemName: "tag").font(.caption2) - } text: { - ResourceCardMetricText(text: "Local tag") - } - } footerActions: { - footerAction("play", help: "Run") { - ui.runImage(reference) - 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", tint: .red) { deletingReference = reference } - } - .contextMenu { tagMenu(reference, 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: 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") } - } - - private func footerAction(_ systemName: String, help: String, tint: Color? = 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) - } - - @ViewBuilder - 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). - 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") } - } - Button { save(image) } label: { Label("Save to tar…", systemImage: "arrow.up.doc") } - } - Divider() - 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: { - Label("Pull Update", systemImage: "arrow.down.circle") - } - } - Divider() - Button(role: .destructive) { deletingReference = group.primaryReference } label: { - Label("Delete Primary Tag", systemImage: "trash") - } - } - - private func imageChip(_ style: Personalization) -> some View { - ResourceCardIconChip(symbol: style.symbol, tint: style.color) - } - - private func updateSymbol(_ state: ImageUpdateState) -> String { - switch state { - case .unknown: return "questionmark.circle" - case .checking: return "arrow.triangle.2.circlepath" - case .current: return "checkmark.circle.fill" - case .updateAvailable: return "arrow.down.circle.fill" - case .error: return "exclamationmark.triangle.fill" - } - } - - private func updateTint(_ state: ImageUpdateState) -> Color { - switch state { - case .current: return .green - case .updateAvailable, .error: return .orange - case .checking: return .blue - case .unknown: return .secondary - } - } - - private func repositoryName(_ reference: String) -> String { - let parsed = RegistryImageReference.parse(reference) - if parsed.registry == "registry-1.docker.io", parsed.repository.hasPrefix("library/") { - return String(parsed.repository.dropFirst("library/".count)) - } - return parsed.repository - } - - private func repositoryTitle(_ reference: String) -> String { - let parsed = RegistryImageReference.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 parts = parsed.repository.split(separator: "/").map(String.init) - if parts.count > 1 { - return parts.dropLast().joined(separator: "/") - } - return parsed.registry == "registry-1.docker.io" ? "docker.io" : parsed.registry - } - - private func updateFooterText(_ status: ImageUpdateStatus) -> String { - switch status.state { - case .unknown: return "Not checked" - case .checking: return "Checking" - case .current: return "Up to date" - case .updateAvailable: return "Updates available" - case .error: return "Check failed" - } - } - - private func primaryImage(_ group: LocalImageTagGroup) -> ContainedCore.ImageResource? { - group.images.first { $0.reference == group.primaryReference } ?? group.images.first - } - - private var deletingBinding: Binding { - 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 { - _ = try await client.deleteImages([reference]) - 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) } - } - - 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) } - } - - private func save(_ image: ContainedCore.ImageResource) { - guard let client = app.client else { return } - 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" - 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)") - } - } - } - -} diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift b/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift deleted file mode 100644 index 7ec51744..00000000 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarSearchSource.swift +++ /dev/null @@ -1,61 +0,0 @@ -import SwiftUI - -struct ToolbarSearchSource: View { - @Environment(AppModel.self) private var app - @Environment(UIState.self) private var ui - @FocusState private var focused: Bool - - /// The toolbar search field escalates into the command palette only when that experimental - /// feature is enabled; otherwise it stays a plain page filter. - private var paletteEnabled: Bool { app.settings.commandPaletteEnabled && ui.panelNavigationEnabled } - - 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) - } - } - } - .contentShape(Capsule(style: .continuous)) - .simultaneousGesture(TapGesture().onEnded { focused = true }) - .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 - if morph != nil { focused = false } - } - .onExitCommand { focused = false } - .onKeyPress(.escape) { - focused = false - return .handled - } - } - - 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 - } - } -} diff --git a/Sources/Contained/Resources/CHANGELOG.md b/Sources/Contained/Resources/CHANGELOG.md deleted file mode 100644 index d5518cc3..00000000 --- a/Sources/Contained/Resources/CHANGELOG.md +++ /dev/null @@ -1,110 +0,0 @@ -# Changelog - -## [Unreleased] - -## [1.0.0] - Initial macOS Container Control Surface - -### 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. - -### Added - -#### App Shell & Navigation - -- 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 - -- 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. -- 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. -- 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. -- Experimental image build workspace for Dockerfile/context builds with streamed BuildKit output. - -#### 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, inspection, deletion, prune actions, local card styling, and mount-aware container grouping. -- Network browsing, creation, inspection, 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 - -- 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. -- 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. -- 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 - -- 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. -- 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 - -- 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. - -### 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. -- 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 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. -- `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. -- 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. - -### Migration Notes - -- Saved local container, image, and volume styles are preserved and migrated away from legacy decorative `contained.*` labels where possible. -- 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/Contained/Resources/Localizable.xcstrings b/Sources/Contained/Resources/Localizable.xcstrings deleted file mode 100644 index d1fe3470..00000000 --- a/Sources/Contained/Resources/Localizable.xcstrings +++ /dev/null @@ -1,7 +0,0 @@ -{ - "sourceLanguage" : "en", - "strings" : { - - }, - "version" : "1.0" -} diff --git a/Sources/Contained/Stores/AppModel+ImageUpdates.swift b/Sources/Contained/Stores/AppModel+ImageUpdates.swift deleted file mode 100644 index a6f8c3aa..00000000 --- a/Sources/Contained/Stores/AppModel+ImageUpdates.swift +++ /dev/null @@ -1,221 +0,0 @@ -import SwiftUI -import ContainedCore - -/// 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`, -/// declared on the main type since stored properties can't live in an extension). -/// -/// "Image updates" cover every local image; "container image updates" narrow the same machinery to -/// just the images that running/stopped containers were created from. -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() - } - - /// 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) - } - - // MARK: Sweeps - - /// Check every local image against its registry. `manual` adds a summary banner (the silent - /// background sweep stays quiet). - func checkAllImageUpdates(manual: Bool = false) async { - await runUpdateCheck(over: uniqueImageReferences(), - emptyMessage: "No local images to check", - summaryNoun: "image", - manual: manual) - lastImageUpdateSweep = Date() - } - - /// Re-run the full sweep immediately with banners (the Settings "Check now" action). - func runImageUpdateSweepNow() async { - await checkAllImageUpdates(manual: true) - } - - /// 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", - manual: manual) - } - - /// Pull every local image that has an update available. Returns the number pulled. - @discardableResult - func pullAvailableImageUpdates(manual: Bool = false) async -> Int { - await pullUpdates(over: uniqueImageReferences(), - emptyMessage: "No image updates available", - summaryNoun: "image", - manual: manual) - } - - /// Pull updates only for images backing existing containers. Returns the number pulled. - @discardableResult - func pullAvailableContainerImageUpdates(manual: Bool = true) async -> Int { - await pullUpdates(over: uniqueContainerImageReferences(), - emptyMessage: "No container image updates available", - summaryNoun: "container image", - manual: manual) - } - - // MARK: Single image - - /// Compare one image's local digest against the registry. `notify` controls per-image banners - /// (off during bulk sweeps, which summarize once at the end). - func checkImageUpdate(_ reference: String, notify: Bool = true) async { - let key = imageUpdateKey(reference) - let localDigest = localDigest(for: key) - imageUpdates[key] = .checking(localDigest: localDigest) - do { - 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") } - return - } - let status = ImageUpdateStatus.resolved(localDigest: localDigest, remoteDigest: remoteDigest) - imageUpdates[key] = status - if notify { - switch status.state { - case .updateAvailable: - flash("Update available for \(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") - default: - break - } - } - } catch { - let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - 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) - } - } - - /// 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) - if ok { - await checkImageUpdate(reference, notify: false) - flash("Updated \(Format.shortImage(reference))") - logger.record("Updated \(Format.shortImage(reference))", category: .image) - } - return ok - } - - /// Background entry point (from `tick()`): run a silent sweep only when the throttle window has - /// elapsed, loading the image list first if it hasn't been fetched yet. - func checkImageUpdatesIfNeeded(now: Date = Date()) async { - guard settings.imageUpdateChecksEnabled else { return } - if let lastImageUpdateSweep, now.timeIntervalSince(lastImageUpdateSweep) < imageUpdateInterval { return } - if images.isEmpty, let client { - do { - images = try await client.images() - imagesError = nil - } catch let error as CommandError { - imagesError = error.userMessage - return - } catch { - imagesError = error.localizedDescription - return - } - } - guard !images.isEmpty else { return } - await checkAllImageUpdates(manual: false) - } - - // MARK: Bulk helpers - - /// 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 { - guard !references.isEmpty else { - if manual { flash(emptyMessage) } - return - } - 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") - } - - /// 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 { - let pending = references.filter { imageUpdateStatus(for: $0).state == .updateAvailable } - guard !pending.isEmpty else { - if manual { flash(emptyMessage) } - return 0 - } - var updated = 0 - for reference in pending where await pullImageUpdate(reference) { updated += 1 } - if manual { flash("Updated \(updated) \(summaryNoun)\(updated == 1 ? "" : "s")") } - return updated - } - - private func uniqueImageReferences() -> [String] { - sortedUnique(images.map(\.reference)) - } - - private func uniqueContainerImageReferences() -> [String] { - sortedUnique(containers.snapshots.map(\.image)) - } - - private func sortedUnique(_ references: [String]) -> [String] { - Array(Set(references)).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - } - - private func localDigest(for key: String) -> String? { - images.first { imageUpdateKey($0.reference) == key }?.digest - } - - // MARK: Persistence - - static func loadImageUpdates(defaults: UserDefaults = .standard) -> [String: ImageUpdateStatus] { - guard let data = defaults.data(forKey: imageUpdatesKey), - let decoded = try? JSONDecoder().decode([String: ImageUpdateStatus].self, from: data) else { - return [:] - } - // Never persist a transient "checking" state; restore it as unknown. - return decoded.mapValues { $0.state == .checking ? ImageUpdateStatus() : $0 } - } - - static func saveImageUpdates(_ updates: [String: ImageUpdateStatus], defaults: UserDefaults = .standard) { - let stable = updates.mapValues { $0.state == .checking ? ImageUpdateStatus() : $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 - } - - 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/Contained/Stores/AppModel.swift b/Sources/Contained/Stores/AppModel.swift deleted file mode 100644 index 1c21e1a8..00000000 --- a/Sources/Contained/Stores/AppModel.swift +++ /dev/null @@ -1,506 +0,0 @@ -import SwiftUI -import ContainedCore - -/// 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. -@MainActor -@Observable -final class AppModel { - enum Bootstrap: Equatable { - case checking - case cliMissing - case unsupported(version: String) - case serviceStopped - case ready - } - - let settings: SettingsStore - let containers = ContainersStore() - let personalization = PersonalizationStore() - let coordinator = RefreshCoordinator() - let watchdog = RestartWatchdog() - let notifier = Notifier() - let healthChecks = HealthCheckStore() - let health = HealthMonitor() - let historyStore = HistoryStore() - let updater = UpdaterController() - let migrator = StateMigrator() - let logger: AppLogger - /// Shared with `AppModel+ImageUpdates.swift` (Swift extensions in other files need ≥ internal). - let manifestClient = RegistryManifestClient() - - private(set) var bootstrap: Bootstrap = .checking - private(set) var client: ContainerClient? - /// 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 cliVersion: String? - - // 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? - // `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 imagesError: String? - var imageUpdates: [String: ImageUpdateStatus] = [:] { - didSet { Self.saveImageUpdates(imageUpdates) } - } - /// Transient watchdog/crash banner text (auto-cleared). - var banner: String? - /// A long-running operation surfaced as a floating progress bar (e.g. pulling an image before a - /// run). `nil` when idle. - var activity: ActivityState? - var downgradeSchemaVersion: Int? - /// The most recent create/pull failure, surfaced inline by the create form so the user can fix the - /// problem without losing their spec. Cleared at the start of each attempt. - var createError: String? - // The image-update sweep state below is driven from `AppModel+ImageUpdates.swift`. - var lastImageUpdateSweep: Date? { - didSet { Self.saveLastImageUpdateSweep(lastImageUpdateSweep) } - } - static let imageUpdatesKey = "imageUpdateStatuses" - static let imageUpdateLastSweepKey = "imageUpdateLastSweep" - var imageUpdateInterval: TimeInterval { TimeInterval(settings.imageUpdateIntervalHours) * 60 * 60 } - var imageUpdateLastRunDate: Date? { lastImageUpdateSweep } - var imageUpdateNextRunDate: Date { - lastImageUpdateSweep?.addingTimeInterval(imageUpdateInterval) ?? Date() - } - var imageUpdateIntervalDescription: String { - "Every \(settings.imageUpdateIntervalHours) hour\(settings.imageUpdateIntervalHours == 1 ? "" : "s")" - } - - /// One in-flight operation shown in the bottom progress bar. - struct ActivityState: Equatable { - var title: String - var detail: String = "" - var fraction: Double? = nil // nil → indeterminate - } - - init(settings: SettingsStore = SettingsStore()) { - self.settings = settings - self.logger = AppLogger(settings: settings, history: historyStore) - self.containers.logger = logger - imageUpdates = Self.loadImageUpdates() - lastImageUpdateSweep = Self.loadLastImageUpdateSweep() - historyStore.retentionDays = settings.historyRetentionDays - updater.channel = settings.updateChannel - updater.automaticallyChecks = settings.appUpdateChecksEnabled - if case .newerOnDisk(let version) = migrator.reconcile() { - downgradeSchemaVersion = version - } - watchdog.onRestart = { [weak self] snapshot, attempt in - guard let self else { return } - let name = self.containerStyle(for: snapshot) - .displayName(fallback: snapshot.id) - self.flash("Restarted \(name) (attempt \(attempt))") - self.logger.record("Restarted \(name) (attempt \(attempt))", - category: .health, - severity: .warning, - containerID: snapshot.id) - self.notifier.containerRestarted(name: name, attempt: attempt, enabled: settings.notifyOnCrash) - } - watchdog.onUnexpectedExit = { [weak self] snapshot in - guard let self else { return } - let name = self.containerStyle(for: snapshot) - .displayName(fallback: snapshot.id) - self.logger.record("\(name) exited unexpectedly", - category: .health, - severity: .warning, - containerID: snapshot.id) - self.notifier.containerExited(name: name, enabled: settings.notifyOnCrash) - } - health.onUnhealthy = { [weak self] snapshot in - guard let self else { return } - let name = self.containerStyle(for: snapshot) - .displayName(fallback: snapshot.id) - self.flash("\(name) is unhealthy") - self.logger.record("\(name) failed its healthcheck", - category: .health, - severity: .warning, - 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"]) - if policy != .no { Task { await self.containers.restart(snapshot.id) } } - } - } - - func bootstrapIfNeeded() async { - logger.record("Checking container CLI", category: .system, severity: .debug) - guard let url = CLILocator.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) - 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 = CLILocator.parseVersion(raw) - if let v = cliVersion, !CLILocator.isSupported(v) { - bootstrap = .unsupported(version: v) - logger.record("Unsupported container CLI version \(v)", category: .system, severity: .error) - return - } - } - - await refreshSystem() - } - - /// Re-run CLI/service detection (onboarding "Try again"). - func retryBootstrap() async { - bootstrap = .checking - await bootstrapIfNeeded() - } - - /// Point at a specific `container` binary (onboarding "Locate…") and re-detect. - func useCLIPath(_ path: String) async { - settings.cliPathOverride = path - await retryBootstrap() - } - - /// Proceed despite an unsupported CLI version (onboarding "Continue anyway"). - func continueUnsupported() async { - bootstrap = .checking - await refreshSystem() - } - - func refreshSystem() async { - guard let client else { return } - do { - let status = try await client.systemStatus() - systemStatus = status - if status.isRunning { - 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() - // 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 - 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 - logger.record("Couldn't read container service status: \(error.localizedDescription)", - category: .system, - severity: .error) - } - } - - /// `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 - - private var lastDiskUsageDate: Date? - /// Fetch `system df`. Throttle background ticks to avoid spawning a process every poll; `force` - /// bypasses the throttle for explicit System-panel refreshes. - 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() } - } - - /// `images list` backs the toolbar Images panel, creation local-image choices, and update badges. - /// 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 { - guard let client, bootstrap == .ready else { return } - if !force, let last = lastImagesDate, Date().timeIntervalSince(last) < Self.imagesThrottle { return } - imagesError = await captured { self.images = try await client.images() } - lastImagesDate = Date() - } - - /// 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 } - } - - /// One polling tick: refresh system + containers, run the restart watchdog, and keep the cached - /// resources warm. Called by `RefreshCoordinator`. - func tick() async { - 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) - 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() - } - - /// Refresh the data behind the System toolbar panel (volumes + a forced `system df`). Called from - /// the panel's `.task` since System is no longer a standing page refreshed by the tick. - func refreshSystemResources() async { - guard client != nil, bootstrap == .ready else { return } - await refreshDiskUsage(force: true) - await refreshVolumes() - } - - /// 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 } - } - - /// Load the daemon's system properties once (the read-only Defaults shown in Settings). Cheap and - /// idempotent — skips the call when already loaded. - func loadPropertiesIfNeeded() async { - guard properties == nil else { return } - await reloadProperties() - } - - /// 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 } - } - - /// Refresh the cached volume list. Volumes live in the System panel, so this is exposed directly - /// 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 } - } - - /// 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 } - } - - // MARK: Create (pull-aware) - - /// Create a container from the form. If its image isn't present locally, pull it first with a - /// 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? { - guard client != nil else { return nil } - createError = nil - if !(await imageIsLocal(spec.image)) { - guard await pullImage(spec.image) 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 - } - } - 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) - flash("Created \(newID)") - } - return newID - } - - /// 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? { - guard client != nil else { return nil } - if !(await imageIsLocal(spec.image)) { - guard await pullImage(spec.image) 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) - } - if spec.personalization.isDefault { - personalization.clearOverride(id: newID) - } else { - personalization.setOverride(spec.personalization, for: newID) - } - healthChecks.setCheck(spec.healthCheck, for: newID) - logger.record("Recreated \(newID)", category: .lifecycle, containerID: newID) - return newID - } - - /// 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) { - 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) - } else { - await refreshImagesIfStale(force: true) - flash("Loaded \(url.lastPathComponent)") - logger.record("Loaded image archive \(url.lastPathComponent)", category: .image) - } - } - } - - @discardableResult - func createVolume(name: String, size: String?) async -> Bool { - guard let client else { return false } - let error = await captured { - _ = 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) - return false - } - flash("Created volume \(name)") - logger.record("Created volume \(name)", category: .system) - return true - } - - @discardableResult - func createNetwork(name: String, subnet: String?, internalOnly: Bool) async -> Bool { - guard let client else { return false } - let error = await captured { - _ = 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) - return false - } - flash("Created network \(name)") - logger.record("Created network \(name)", category: .system) - 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 { - guard client != nil else { return false } - if await imageIsLocal(reference) { return true } - return await pullImage(reference) - } - - /// Pull an image, streaming `--progress` lines into the floating activity bar. Returns true on - /// success. - @discardableResult - func pullImage(_ reference: String) async -> Bool { - guard let client else { return false } - activity = ActivityState(title: "Pulling \(Format.shortImage(reference))…") - defer { activity = nil } - do { - for try await line in client.streamPull(reference, platform: nil) { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { activity?.detail = trimmed } - } - 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) - return false - } - } - - /// 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 { - guard let client else { return false } - let target = normalizedRef(reference) - let list = (try? await client.images()) ?? images - return list.contains { normalizedRef($0.reference) == target } - } - - /// Strip the docker.io prefix and append `:latest` when no tag/digest is present. - private func normalizedRef(_ reference: String) -> String { - let short = Format.shortImage(reference) - 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 var bannerClear: Task? - /// Show a transient banner for ~4s. - func flash(_ message: String) { - banner = message - logger.record(message, category: .ui, severity: .warning) - bannerClear?.cancel() - bannerClear = Task { [weak self] in - try? await Task.sleep(for: .seconds(Self.bannerDuration)) - if !Task.isCancelled { self?.banner = nil } - } - } - - /// Start the container system service, then re-bootstrap. - func startService() async { - 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) - 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) - 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 { - guard let client else { return } - bootstrap = .checking - if resetWatchdog { watchdog.reset() } - for action in actions { _ = try? await client.runner.run(["system", action]) } - await refreshSystem() - } - - /// Short health label for the toolbar indicator. - var serviceLabel: String { - switch bootstrap { - case .ready: return "Running" - case .serviceStopped: return "Stopped" - case .checking: return "Checking…" - case .cliMissing: return "No CLI" - case .unsupported(let v): return "v\(v)" - } - } - - var serviceHealthy: Bool { bootstrap == .ready } -} diff --git a/Sources/Contained/Stores/ContainersStore.swift b/Sources/Contained/Stores/ContainersStore.swift deleted file mode 100644 index ffb9375f..00000000 --- a/Sources/Contained/Stores/ContainersStore.swift +++ /dev/null @@ -1,279 +0,0 @@ -import SwiftUI -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. -@MainActor -@Observable -final class ContainersStore { - var snapshots: [ContainerSnapshot] = [] - var statsByID: [String: StatsDelta] = [:] - /// Per-container, per-metric sparkline history. - var historyByID: [String: [GraphMetric: SampleBuffer]] = [:] - var errorMessage: String? - var busyIDs: Set = [] - @ObservationIgnored var logger: AppLogger? - - var client: ContainerClient? - - 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. - 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. - private var refreshTask: Task? - private var refreshRequested = false - private let diagnosticLogger = Logger(subsystem: "app.contained.Contained", category: "diagnostic") - - var running: [ContainerSnapshot] { snapshots.filter { $0.state == .running } } - - /// True (consuming the flag) if the given container's last stop was user-initiated. - func consumeIntentionalStop(_ id: String) -> Bool { - 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 { - refreshRequested = true - if refreshTask != nil { - logger?.record("Refresh already in flight; coalescing another pass", - category: .system, - severity: .debug) - diagnosticLogger.debug("Refresh already in flight; coalescing another pass") - } - await refreshTaskOrStart().value - } - - private func refreshTaskOrStart() -> Task { - if let refreshTask { return refreshTask } - let task = Task { @MainActor [weak self] in - _ = await self?.drainRefreshRequests() - } - refreshTask = task - return task - } - - private func drainRefreshRequests() async { - let started = Date() - var passes = 0 - repeat { - passes += 1 - refreshRequested = false - await performRefresh() - } while refreshRequested - refreshTask = nil - let elapsed = Date().timeIntervalSince(started) - if elapsed >= 0.75 || passes > 1 { - let suffix = passes == 1 ? "" : "es" - logger?.record("Refresh finished in \(elapsed.formatted(.number.precision(.fractionLength(2))))s across \(passes) pass\(passes == 1 ? "" : "es")", - category: .system, - severity: elapsed >= 1.5 ? .warning : .info) - diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, - "Refresh finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s across \(passes, privacy: .public) pass\(suffix, privacy: .public)") - } - } - - private func performRefresh() async { - guard let client else { return } - do { - let listed = try await client.listContainers(all: true) - .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - // 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))) - errorMessage = nil - await refreshStats() - } catch let error as CommandError { - errorMessage = error.userMessage - } catch { - errorMessage = error.localizedDescription - } - } - - private func refreshStats() async { - guard let client else { return } - let runningIDs = running.map(\.id) - guard !runningIDs.isEmpty else { - statsByID.removeAll(); lastRawStats.removeAll(); return - } - do { - let samples = try await client.stats(ids: runningIDs) - let now = Date() - let interval = lastStatsDate.map { now.timeIntervalSince($0) } ?? 1 - 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] ?? [:] - for metric in GraphMetric.allCases { - var buffer = metrics[metric] ?? SampleBuffer() - buffer.append(metric.value(from: delta)) - metrics[metric] = buffer - } - historyByID[sample.id] = metrics - } - lastRawStats[sample.id] = sample - } - lastStatsDate = now - } catch { - // Stats are best-effort; a failure here shouldn't blank the list. - } - } - - // MARK: Lifecycle - - func start(_ id: String) async { await act(id, verb: "Start") { try await $0.start([id]) } } - func stop(_ id: String) async { - intentionalStops.insert(id) - await act(id, verb: "Stop") { try await $0.stop([id]) } - } - func restart(_ id: String) async { - await act(id, verb: "Restart") { _ = try await $0.stop([id]); _ = try await $0.start([id]) } - } - func remove(_ id: String, force: Bool) async { - intentionalStops.insert(id) - await act(id, verb: "Remove") { try await $0.deleteContainers([id], force: force) } - } - - /// Create + run a container from the Create/Edit form. Returns the new container's id on success - /// (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? { - guard let client else { return nil } - let started = Date() - logger?.record("Running container from creation flow", - category: .lifecycle, - severity: .info) - diagnosticLogger.notice("Run started from creation flow") - do { - let output = try await client.runner.run(spec.arguments()) - performHaptic() - await refresh() - let elapsed = Date().timeIntervalSince(started) - logger?.record("Run finished in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", - category: .lifecycle, - 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 - } 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 - 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)") - return nil - } - } - - /// 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. - @discardableResult - func recreate(originalID: String, spec: RunSpec) 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 started = Date() - 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.runner.run(spec.arguments()) - performHaptic() - await refresh() - let elapsed = Date().timeIntervalSince(started) - logger?.record("Recreated \(originalID) in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", - category: .lifecycle, - severity: elapsed >= 1.5 ? .warning : .info, - containerID: originalID) - 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 - 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)") - await refresh() - return false - } - } - - private func act(_ id: String, verb: String, _ body: @escaping (ContainerClient) async throws -> Void) async { - guard let client else { return } - busyIDs.insert(id) - defer { busyIDs.remove(id) } - let started = Date() - logger?.record("\(verb) \(id)", category: .lifecycle, containerID: id) - diagnosticLogger.notice("\(verb) started for \(id, privacy: .public)") - do { - try await body(client) - performHaptic() - await refresh() - let elapsed = Date().timeIntervalSince(started) - logger?.record("\(verb) finished in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", - category: .lifecycle, - severity: elapsed >= 1.5 ? .warning : .info, - 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 - 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)") - } - } -} diff --git a/Sources/Contained/Support/HealthMonitor.swift b/Sources/Contained/Support/HealthMonitor.swift deleted file mode 100644 index a7a6b75a..00000000 --- a/Sources/Contained/Support/HealthMonitor.swift +++ /dev/null @@ -1,101 +0,0 @@ -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 { - private var checks: [String: 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) { - checks = decoded - } else { - checks = [:] - } - } - - func check(for id: String) -> HealthCheck? { checks[id] } - - func setCheck(_ check: HealthCheck, for id: String) { - if check.command.isEmpty { checks[id] = nil } else { checks[id] = check } - persist() - } - - func clear(id: String) { - checks[id] = nil - persist() - } - - func backupSnapshot() -> [String: HealthCheck] { checks } - - func applyBackup(_ snapshot: [String: HealthCheck], replace: Bool) { - if replace { checks = snapshot } - else { checks.merge(snapshot) { _, imported in imported } } - persist() - } - - func purgeOrphans(liveContainerIDs: Set) -> Int { - let before = checks.count - checks = checks.filter { liveContainerIDs.contains($0.key) } - persist() - return before - checks.count - } - - private func persist() { - if let data = try? JSONEncoder().encode(checks) { defaults.set(data, forKey: key) } - } -} - -/// App-managed healthcheck runner. On each poll tick it probes running containers whose check is due -/// (`exec` the probe; zero exit = pass), tracks consecutive failures, and flips status to unhealthy -/// once the retry budget is reached — surfacing a badge + a one-time callback. Runs only while the -/// app is open; it is not a daemon. -@MainActor -@Observable -final class HealthMonitor { - private(set) var statusByID: [String: 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)? - - func evaluate(snapshots: [ContainerSnapshot], store: HealthCheckStore, - client: ContainerClient, now: Date = Date()) async { - let running = Dictionary(snapshots.filter { $0.state == .running }.map { ($0.id, $0) }, - uniquingKeysWith: { a, _ in a }) - - // Drop tracking for containers that stopped or whose check was removed/disabled. - for id in Array(statusByID.keys) where running[id] == nil || store.check(for: id)?.isActive != true { - statusByID[id] = nil; consecutiveFailures[id] = nil; lastProbe[id] = nil - } - - for (id, snapshot) in running { - guard let check = store.check(for: id), check.isActive else { continue } - if let last = lastProbe[id], now.timeIntervalSince(last) < Double(check.intervalSeconds) { continue } - lastProbe[id] = now - - let passed: Bool - do { _ = try await client.execCapture(id, check.command); passed = true } - catch { passed = false } - - let failures = passed ? 0 : (consecutiveFailures[id] ?? 0) + 1 - consecutiveFailures[id] = failures - let newStatus = passed ? HealthStatus.healthy - : 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 reset() { statusByID.removeAll(); consecutiveFailures.removeAll(); lastProbe.removeAll() } -} diff --git a/Sources/Contained/Support/Platform.swift b/Sources/Contained/Support/Platform.swift deleted file mode 100644 index 6ad0a2a5..00000000 --- a/Sources/Contained/Support/Platform.swift +++ /dev/null @@ -1,14 +0,0 @@ -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) -} - -@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 deleted file mode 100644 index c48d45ba..00000000 --- a/Sources/Contained/Support/WidgetConfiguration.swift +++ /dev/null @@ -1,173 +0,0 @@ -import AppKit - -struct WidgetConfiguration: Codable, Hashable, Sendable { - static let schemaVersion = 4 - - var schemaVersion: Int = Self.schemaVersion - var enabled: Bool = true - var metric: GraphMetric = .cpu - var secondaryMetric: GraphMetric? - var tint: AppTint? - var icon: String = "" - var style: GraphStyle = .area - var areaUsesGradient = true - var interpolation: WidgetInterpolation = .catmullRom - var lineWidth: Double = 1.5 - var pointSize: Double = 18 - var barWidth: Double = 4 - var showIcon: Bool = true - var showText: Bool = true - - enum CodingKeys: String, CodingKey { - case schemaVersion, enabled, metric, secondaryMetric, tint, icon, style, areaUsesGradient - case interpolation, lineWidth, pointSize, barWidth, showIcon, showText - } - - init() {} - - init(enabled: Bool = true, - metric: GraphMetric = .cpu, - secondaryMetric: GraphMetric? = nil, - tint: AppTint? = nil, - icon: String = "", - style: GraphStyle = .area, - areaUsesGradient: Bool = true, - interpolation: WidgetInterpolation = .catmullRom, - lineWidth: Double = 1.5, - pointSize: Double = 18, - barWidth: Double = 4, - showIcon: Bool = true, - showText: Bool = true) { - self.schemaVersion = Self.schemaVersion - self.enabled = enabled - self.metric = metric - self.secondaryMetric = secondaryMetric - self.tint = tint - self.icon = icon - self.style = style - self.areaUsesGradient = areaUsesGradient - self.interpolation = interpolation - self.lineWidth = lineWidth - self.pointSize = pointSize - self.barWidth = barWidth - self.showIcon = showIcon - self.showText = showText - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 0 - 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) - 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 - 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 - showIcon = try container.decodeIfPresent(Bool.self, forKey: .showIcon) ?? true - showText = try container.decodeIfPresent(Bool.self, forKey: .showText) ?? true - schemaVersion = Self.schemaVersion - } - - static func defaultWidgets() -> [WidgetConfiguration] { - [ - WidgetConfiguration(enabled: true, metric: .cpu, style: .area), - WidgetConfiguration(enabled: true, metric: .memory, style: .area), - WidgetConfiguration(enabled: true, metric: .netRx, style: .area), - WidgetConfiguration(enabled: true, metric: .netTx, style: .area), - WidgetConfiguration(enabled: false, metric: .diskRead, style: .area) - ] - } - - var resolvedSystemImage: String { - guard !icon.isEmpty, - NSImage(systemSymbolName: icon, accessibilityDescription: nil) != nil else { - return metric.systemImage - } - 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/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/Stores/AppModel+Configuration.swift b/Sources/ContainedApp/App/AppModel+Configuration.swift similarity index 65% rename from Sources/Contained/Stores/AppModel+Configuration.swift rename to Sources/ContainedApp/App/AppModel+Configuration.swift index b27f388f..43317f4f 100644 --- a/Sources/Contained/Stores/AppModel+Configuration.swift +++ b/Sources/ContainedApp/App/AppModel+Configuration.swift @@ -1,4 +1,3 @@ -import AppKit import Foundation import ContainedCore @@ -13,14 +12,17 @@ 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) } func exportConfiguration(to url: URL, sections: Set = Set(AppStateSection.allCases)) throws { + try configurationData(sections: sections).write(to: url, options: .atomic) + } + + func configurationData(sections: Set = Set(AppStateSection.allCases)) throws -> Data { let envelope = try AppStateEnvelope.make(from: self, sections: sections) - let data = try JSONEncoder.containedBackup().encode(envelope) - try data.write(to: url, options: .atomic) + return try JSONEncoder.containedBackup().encode(envelope) } func importConfiguration(from url: URL, @@ -30,44 +32,28 @@ 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("Kept readable local data") - } - - func exportForDowngradeAndReset() { - let panel = NSSavePanel() - panel.allowedContentTypes = [.containedBackup, .json] - panel.nameFieldStringValue = "Contained Downgrade Backup.containedbackup" - panel.canCreateDirectories = true - guard panel.runModal() == .OK, let url = panel.url else { return } - do { - try exportConfiguration(to: url) - resetIncompatibleLocalState() - downgradeSchemaVersion = nil - flash("Exported backup and reset local state") - } catch { - flash(error.localizedDescription) - } + flash(AppText.keptReadableLocalData) } 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) 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.cleanedOrphanedRows(personalizations + checks + history.events + history.metrics)) } private func apply(envelope: AppStateEnvelope, selected: Set, replace: Bool) throws { @@ -75,15 +61,16 @@ 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) } 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) + 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 new file mode 100644 index 00000000..5fe57189 --- /dev/null +++ b/Sources/ContainedApp/App/AppModel+ImageUpdates.swift @@ -0,0 +1,315 @@ +import SwiftUI +import ContainedCore + +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`, +/// declared on the main type since stored properties can't live in an extension). +/// +/// "Image updates" cover every local image; "container image updates" narrow the same machinery to +/// just the images that running/stopped containers were created from. +extension AppModel { + + // MARK: Status lookup + + /// The tracked update status for an image reference (defaults to an empty/unknown status). + func imageUpdateStatus(for reference: String) -> Core.Image.UpdateStatus { + let runtimeStatuses = localRuntimeTargets(for: reference).compactMap { + imageUpdates[imageUpdateKey(reference, runtimeKind: $0)] + } + guard !runtimeStatuses.isEmpty else { + return imageUpdates[imageUpdateKey(reference)] ?? Core.Image.UpdateStatus() + } + return aggregateUpdateStatus(runtimeStatuses) + } + + /// The tracked update status for a local tag in one runtime. + func imageUpdateStatus(for reference: String, + runtimeKind: Core.Runtime.Kind) -> Core.Image.UpdateStatus { + imageUpdates[imageUpdateKey(reference, runtimeKind: runtimeKind)] + ?? 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 { + Core.Registry.ImageReference.normalizedKey(reference) + } + + func imageUpdateKey(_ reference: String, + runtimeKind: Core.Runtime.Kind) -> String { + runtimeKind.scopedID(for: imageUpdateKey(reference)) + } + + // MARK: Sweeps + + /// Check every local image against its registry. `manual` adds a summary banner (the silent + /// background sweep stays quiet). + func checkAllImageUpdates(manual: Bool = false) async { + await runUpdateCheck(over: uniqueImageReferences(), + emptyMessage: AppText.noLocalImagesToCheck, + summary: .localImages, + manual: manual) + lastImageUpdateSweep = Date() + } + + /// Re-run the full sweep immediately with banners (the Settings "Check now" action). + func runImageUpdateSweepNow() async { + await checkAllImageUpdates(manual: true) + } + + /// Check only the images that existing containers were created from. + func checkContainerImageUpdates(manual: Bool = true) async { + await runUpdateCheck(over: uniqueContainerImageReferences(), + emptyMessage: AppText.noContainerImagesToCheck, + summary: .containerImages, + manual: manual) + } + + /// Pull every local image that has an update available. Returns the number pulled. + @discardableResult + func pullAvailableImageUpdates(manual: Bool = false) async -> Int { + await pullUpdates(over: uniqueImageReferences(), + emptyMessage: AppText.noImageUpdatesAvailable, + summary: .localImages, + manual: manual) + } + + /// Pull updates only for images backing existing containers. Returns the number pulled. + @discardableResult + func pullAvailableContainerImageUpdates(manual: Bool = true) async -> Int { + await pullUpdates(over: uniqueContainerImageReferences(), + emptyMessage: AppText.noContainerImageUpdatesAvailable, + summary: .containerImages, + manual: manual) + } + + // MARK: Single image + + /// Compare one image's local digest against the registry. `notify` controls per-image banners + /// (off during bulk sweeps, which summarize once at the end). + func checkImageUpdate(_ reference: String, notify: Bool = true) async { + let runtimeKinds = localRuntimeTargets(for: reference) + guard !runtimeKinds.isEmpty else { + let key = imageUpdateKey(reference) + imageUpdates[key] = .failed(localDigest: nil, + message: AppText.string("updates.localDigestUnavailable.short", defaultValue: "Local digest unavailable")) + if notify { flash(AppText.imageLocalDigestUnavailable(Format.shortImage(reference))) } + return + } + await checkImageUpdate(reference, runtimeKinds: runtimeKinds, notify: notify) + } + + /// Compare one runtime-owned local tag against the registry. + func checkImageUpdate(_ reference: String, + runtimeKind: Core.Runtime.Kind, + notify: Bool = true) async { + await checkImageUpdate(reference, runtimeKinds: [runtimeKind], notify: notify) + } + + private func checkImageUpdate(_ reference: String, + runtimeKinds: [Core.Runtime.Kind], + notify: Bool) async { + let runtimeKinds = Array(Set(runtimeKinds)).sorted { $0.rawValue < $1.rawValue } + for runtimeKind in runtimeKinds { + let key = imageUpdateKey(reference, runtimeKind: runtimeKind) + imageUpdates[key] = .checking(localDigest: localDigest(for: reference, runtimeKind: runtimeKind)) + } + do { + let remoteDigest = try await manifestClient.remoteDigest(for: reference) + var statuses: [Core.Image.UpdateStatus] = [] + for runtimeKind in runtimeKinds { + let key = imageUpdateKey(reference, runtimeKind: runtimeKind) + guard let localDigest = localDigest(for: reference, runtimeKind: runtimeKind), + !localDigest.isEmpty else { + let status = Core.Image.UpdateStatus.failed( + localDigest: nil, + message: AppText.string("updates.localDigestUnavailable.short", defaultValue: "Local digest unavailable") + ) + imageUpdates[key] = status + statuses.append(status) + continue + } + let status = Core.Image.UpdateStatus.resolved(localDigest: localDigest, remoteDigest: remoteDigest) + imageUpdates[key] = status + statuses.append(status) + } + if notify { + switch aggregateUpdateStatus(statuses).state { + case .updateAvailable: + flash(AppText.imageUpdateAvailable(Format.shortImage(reference))) + logger.record("Image update available", + category: .image, + severity: .warning) + case .current: + flash(AppText.imageUpToDate(Format.shortImage(reference))) + default: + break + } + } + } catch { + let message = error.appDisplayMessage + for runtimeKind in runtimeKinds { + let key = imageUpdateKey(reference, runtimeKind: runtimeKind) + imageUpdates[key] = .failed(localDigest: localDigest(for: reference, runtimeKind: runtimeKind), message: message) + } + if notify { flash(message) } + logger.recordFailure("Failed checking image update", + error: error, + category: .image, + severity: .error) + } + } + + /// Pull one image and re-check its status. Returns true on a successful pull. + @discardableResult + func pullImageUpdate(_ reference: String, + runtimeKind: Core.Runtime.Kind) async -> Bool { + let ok = await pullImage(reference, runtimeKind: runtimeKind) + if ok { + await checkImageUpdate(reference, runtimeKind: runtimeKind, notify: false) + flash(AppText.updatedImage(Format.shortImage(reference))) + logger.record("Updated image", category: .image) + } + return ok + } + + /// Background entry point (from `tick()`): run a silent sweep only when the throttle window has + /// elapsed, loading the image list first if it hasn't been fetched yet. + func checkImageUpdatesIfNeeded(now: Date = Date()) async { + guard settings.imageUpdateChecksEnabled else { return } + if let lastImageUpdateSweep, now.timeIntervalSince(lastImageUpdateSweep) < imageUpdateInterval { return } + if images.isEmpty, let client { + do { + images = try await client.runtimeImages() + imagesError = nil + } catch let error as Core.Command.Error { + imagesError = error.appDisplayMessage + return + } catch { + imagesError = error.appDisplayMessage + return + } + } + guard !images.isEmpty else { return } + await checkAllImageUpdates(manual: false) + } + + // MARK: Bulk helpers + + /// 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, + 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) \(summary.noun, privacy: .public)(s)") + } + } + guard !references.isEmpty else { + if manual { flash(emptyMessage) } + return + } + for reference in references { await checkImageUpdate(reference, notify: false) } + guard manual else { return } + let available = references.filter { imageUpdateStatus(for: $0).state == .updateAvailable }.count + 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, + summary: ImageUpdateSummaryScope, manual: Bool) async -> Int { + let pending: [(reference: String, runtimeKind: Core.Runtime.Kind)] = references.flatMap { reference in + localRuntimeKinds(for: reference, summary: summary).compactMap { runtimeKind in + imageUpdateStatus(for: reference, runtimeKind: runtimeKind).state == .updateAvailable + ? (reference, runtimeKind) + : nil + } + } + guard !pending.isEmpty else { + if manual { flash(emptyMessage) } + return 0 + } + var updated = 0 + for item in pending { + if await pullImageUpdate(item.reference, runtimeKind: item.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)) + } + + private func uniqueContainerImageReferences() -> [String] { + sortedUnique(containers.snapshots.map(\.image)) + } + + private func sortedUnique(_ references: [String]) -> [String] { + Array(Set(references)).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + private func localRuntimeTargets(for reference: String) -> [Core.Runtime.Kind] { + Array(Set(localRuntimeKinds(for: reference, summary: .localImages) + + localRuntimeKinds(for: reference, summary: .containerImages))) + .sorted { $0.rawValue < $1.rawValue } + } + + private func localDigest(for reference: String, + runtimeKind: Core.Runtime.Kind) -> String? { + let key = imageUpdateKey(reference) + return images.first { $0.runtimeKind == runtimeKind && imageUpdateKey($0.reference) == key }?.digest + } + + private func aggregateUpdateStatus(_ statuses: [Core.Image.UpdateStatus]) -> Core.Image.UpdateStatus { + for state in [Core.Image.UpdateState.updateAvailable, .checking, .error, .current] { + if let status = statuses.first(where: { $0.state == state }) { return status } + } + return Core.Image.UpdateStatus() + } + +} diff --git a/Sources/Contained/Stores/AppModel+ResourceStyles.swift b/Sources/ContainedApp/App/AppModel+ResourceStyles.swift similarity index 58% rename from Sources/Contained/Stores/AppModel+ResourceStyles.swift rename to Sources/ContainedApp/App/AppModel+ResourceStyles.swift index a7cf289b..e59a15d3 100644 --- a/Sources/Contained/Stores/AppModel+ResourceStyles.swift +++ b/Sources/ContainedApp/App/AppModel+ResourceStyles.swift @@ -2,18 +2,25 @@ import Foundation import ContainedCore extension AppModel { + func localImageGroups() -> [Core.Image.LocalTagGroup] { + if let imageGroupsCache { + return imageGroupsCache + } + let groups = Core.Image.LocalTagGroup.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 } - func imageGroupStyle(for group: LocalImageTagGroup) -> Personalization { + func imageGroupStyle(for group: Core.Image.LocalTagGroup) -> Personalization { personalization.imageGroupDefault(for: group.id) ?? defaultImageStyle } @@ -28,34 +35,34 @@ extension AppModel { return style } - func containerStyle(for snapshot: ContainerSnapshot) -> Personalization { - let groupID = LocalImageTagGroup.groups(for: images).first { group in - group.references.contains(snapshot.image) - }?.id - return personalization.resolved(id: snapshot.id, + func containerStyle(for snapshot: Core.Container.Snapshot) -> Personalization { + let groupID = imageGroupID(containing: snapshot.image) + return personalization.resolved(id: snapshot.scopedID, image: snapshot.image, groupID: groupID, fallback: defaultImageStyle) } /// 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.statsByID[snapshot.id].map { metric.value(from: $0) } ?? 0) + total + (containers.metricsState(for: snapshot.scopedID).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 + func volumeIOHistory(for name: String, metric: Core.Metrics.GraphMetric) -> [Double] { + let series = containersMounting(volume: name).compactMap { snapshot in + containers.metricsState(for: snapshot.scopedID).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/ContainedApp/App/AppModel.swift b/Sources/ContainedApp/App/AppModel.swift new file mode 100644 index 00000000..e7f3e5e5 --- /dev/null +++ b/Sources/ContainedApp/App/AppModel.swift @@ -0,0 +1,1086 @@ +import SwiftUI +import ContainedCore +import OSLog + +/// 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 +final class AppModel { + enum Bootstrap: Equatable { + case checking + case cliMissing + case unsupported(version: String) + case serviceStopped + case ready + } + + let database: AppDatabase + let settings: SettingsStore + let containers = ContainersStore() + let personalization: PersonalizationStore + let coordinator = RefreshCoordinator() + let watchdog = RestartWatchdog() + let notifier = Notifier() + let healthChecks: HealthCheckStore + let health = HealthMonitor() + let historyStore: HistoryStore + let updater = UpdaterController() + let migrator = StateMigrator() + let logger: AppLogger + /// Shared with `AppModel+ImageUpdates.swift` (Swift extensions in other files need ≥ internal). + let manifestClient = Core.Registry.ManifestClient() + + private(set) var bootstrap: Bootstrap = .checking + private(set) var client: Core.Orchestrator? + private(set) var systemStatus: Core.System.Status? + private(set) var diskUsage: Core.System.DiskUsage? + 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. + 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? + private(set) var resourceInventoryErrors: [String: String] = [:] + // `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: [Core.Image.Resource] = [] { + 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 { database.updateImageStatuses(imageUpdates) } + } + /// Transient watchdog/crash banner text (auto-cleared). + var banner: String? + /// A long-running operation surfaced as a floating progress bar (e.g. pulling an image before a + /// run). `nil` when idle. + var activity: ActivityState? + var downgradeSchemaVersion: Int? + /// The most recent create/pull failure, surfaced inline by the create form so the user can fix the + /// problem without losing their spec. Cleared at the start of each attempt. + var createError: String? + // The image-update sweep state below is driven from `AppModel+ImageUpdates.swift`. + var lastImageUpdateSweep: Date? { + didSet { + if let lastImageUpdateSweep { + database.setSetting(lastImageUpdateSweep, for: Self.imageUpdateLastSweepKey) + } else { + database.deleteSetting(Self.imageUpdateLastSweepKey) + } + } + } + static let imageUpdatesKey = "imageUpdateStatuses" + static let imageUpdateLastSweepKey = "imageUpdateLastSweep" + var databaseFailureMessage: String? { + database.lastFailure?.localizedDescription + } + var imageUpdateInterval: TimeInterval { TimeInterval(settings.imageUpdateIntervalHours) * 60 * 60 } + var imageUpdateLastRunDate: Date? { lastImageUpdateSweep } + var imageUpdateNextRunDate: Date { + lastImageUpdateSweep?.addingTimeInterval(imageUpdateInterval) ?? Date() + } + var imageUpdateIntervalDescription: String { + "Every \(settings.imageUpdateIntervalHours) hour\(settings.imageUpdateIntervalHours == 1 ? "" : "s")" + } + 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) + ?? ProcessInfo.processInfo.physicalMemory + ) + } + var registeredRuntimeDescriptors: [Core.Runtime.Descriptor] { + client?.availableRuntimeDescriptors ?? [] + } + var availableRuntimeDescriptors: [Core.Runtime.Descriptor] { + registeredRuntimeDescriptors.filter { runtimeIsReady($0.kind) } + } + var supportedRuntimeDescriptors: [Core.Runtime.Descriptor] { + Core.Runtime.supportedDescriptors + } + var runtimePickerIsEnabled: Bool { + availableRuntimeDescriptors.count > 1 + } + var runtimePickerDisabledReason: String { + AppText.string( + "runtime.selector.disabledReason", + defaultValue: "Only one container runtime is reachable. Additional runtimes will appear here automatically." + ) + } + var serviceControlRuntimeKind: Core.Runtime.Kind? { + registeredRuntimeDescriptors.first { $0.supports(.serviceControl) }?.kind + } + + var serviceControlRuntimeAvailable: Bool { + guard let kind = serviceControlRuntimeKind else { return false } + return client?.supportsRuntime(kind, capability: .serviceControl) == true + } + + var serviceControlRuntimeReady: Bool { + guard let kind = serviceControlRuntimeKind else { return false } + return runtimeIsReady(kind) + } + + func runtimeIsReady(_ kind: Core.Runtime.Kind) -> Bool { + runtimeReadiness[kind]?.state == .ready + } + + 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 + } + + func firstRuntimeKind(supporting capability: Core.Runtime.Capability, + readyOnly: Bool = true) -> Core.Runtime.Kind? { + registeredRuntimeDescriptors.first { descriptor in + descriptor.supports(capability) && (!readyOnly || runtimeIsReady(descriptor.kind)) + }?.kind + } + + /// One in-flight operation shown in the bottom progress bar. + struct ActivityState: Equatable { + var title: String + var detail: String = "" + var fraction: Double? = nil // nil → indeterminate + } + + 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 + 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() + 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 } + let name = self.containerStyle(for: snapshot) + .displayName(fallback: snapshot.id) + self.flash(AppText.restartedContainer(name, attempt: attempt)) + self.logger.record("Restarted container (attempt \(attempt))", + category: .health, + severity: .warning, + containerID: snapshot.scopedID) + self.notifier.containerRestarted(name: name, attempt: attempt, enabled: settings.notifyOnCrash) + } + watchdog.onUnexpectedExit = { [weak self] snapshot in + guard let self else { return } + let name = self.containerStyle(for: snapshot) + .displayName(fallback: snapshot.id) + self.logger.record("Container exited unexpectedly", + category: .health, + severity: .warning, + containerID: snapshot.scopedID) + self.notifier.containerExited(name: name, enabled: settings.notifyOnCrash) + } + health.onUnhealthy = { [weak self] snapshot in + guard let self else { return } + let name = self.containerStyle(for: snapshot) + .displayName(fallback: snapshot.id) + self.flash(AppText.containerUnhealthy(name)) + self.logger.record("Container failed its healthcheck", + category: .health, + severity: .warning, + 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.scopedID) } } + } + } + + func bootstrapIfNeeded() async { + logger.record("Checking container runtime CLI", category: .system, severity: .debug) + let configuration = Core.Configuration(runtimes: Dictionary(uniqueKeysWithValues: supportedRuntimeDescriptors.map { descriptor in + (descriptor.kind, Core.Runtime.Configuration(cliPathOverride: settings.runtimePathOverride(for: descriptor.kind))) + })) + let result = await Core.Orchestrator.bootstrap(configuration: configuration) + switch result { + case .cliMissing(let readiness): + runtimeReadiness = Dictionary(uniqueKeysWithValues: readiness.map { ($0.kind, $0) }) + database.upsertRuntimeReadiness(readiness, + descriptors: supportedRuntimeDescriptors) + bootstrap = .cliMissing + logger.record("Container CLI missing", category: .system, severity: .error) + return + case .ready(let orchestrator, let readiness): + client = orchestrator + runtimeReadiness = Dictionary(uniqueKeysWithValues: readiness.map { ($0.kind, $0) }) + containers.client = orchestrator + database.upsertRuntimeReadiness(readiness, + descriptors: supportedRuntimeDescriptors) + let anyReady = runtimeReadiness.values.contains { $0.state == .ready } + if !anyReady, let unsupported = runtimeReadiness.values.first(where: { $0.state == .unsupported }) { + bootstrap = .unsupported(version: unsupported.version ?? "") + logger.record("Unsupported container runtime CLI version", category: .system, severity: .error) + return + } + } + + await refreshSystem() + } + + /// Re-run CLI/service detection (onboarding "Try again"). + func retryBootstrap() async { + bootstrap = .checking + await bootstrapIfNeeded() + } + + /// Point at a specific `container` binary (onboarding "Locate…") and re-detect. + func useCLIPath(_ path: String) async { + guard let kind = supportedRuntimeDescriptors.first?.kind else { return } + settings.setRuntimePathOverride(path, for: kind) + await retryBootstrap() + } + + /// Proceed despite an unsupported CLI version (onboarding "Continue anyway"). + func continueUnsupported() async { + bootstrap = .checking + await refreshSystem() + } + + func setContainerStatsVisible(_ visible: Bool) { + guard containerStatsVisible != visible else { return } + containerStatsVisible = visible + if visible { coordinator.wake() } + } + + func setStatsNormalizationMode(_ mode: Core.Metrics.NormalizationMode) { + guard settings.statsNormalizationMode != mode else { return } + settings.statsNormalizationMode = mode + applyStatsNormalizationContext() + guard mode == .machine else { return } + Task { + await self.loadPropertiesIfNeeded() + self.applyStatsNormalizationContext() + } + } + + func runtimeDescriptor(for kind: Core.Runtime.Kind) -> Core.Runtime.Descriptor? { + client?.descriptor(for: kind) + ?? supportedRuntimeDescriptors.first { $0.kind == kind } + } + + func core(for kind: Core.Runtime.Kind) -> Core.Orchestrator? { + guard let client, runtimeIsReady(kind), client.supportsRuntime(kind) else { return nil } + return client + } + + func installRuntimeClientForTesting(_ orchestrator: Core.Orchestrator, + readiness: [Core.RuntimeReadiness]? = nil, + bootstrap: Bootstrap = .ready) { + client = orchestrator + containers.client = orchestrator + if let readiness { + runtimeReadiness = Dictionary(uniqueKeysWithValues: readiness.map { ($0.kind, $0) }) + } else { + 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: supportedRuntimeDescriptors) + self.bootstrap = bootstrap + } + + func applyStatsNormalizationContext() { + containers.configureStatsNormalization(statsNormalizationContext) + } + + func refreshSystem() async { + guard let client else { return } + let started = Date() + var firstStatus: Core.System.Status? + for descriptor in registeredRuntimeDescriptors where descriptor.supports(.systemStatus) { + guard runtimeReadiness[descriptor.kind]?.state != .unsupported else { continue } + do { + let status = try await client.systemStatus(runtimeKind: descriptor.kind) + firstStatus = firstStatus ?? status + if status.isRunning { + if markRuntimeReadiness(descriptor.kind, state: .ready) { + logger.record("\(descriptor.displayName) is running", category: .system, severity: .debug) + } + } else { + if markRuntimeReadiness(descriptor.kind, state: .endpointUnavailable) { + logger.record("\(descriptor.displayName) is stopped", category: .system, severity: .warning) + } + } + } catch { + if markRuntimeReadiness(descriptor.kind, state: .endpointUnavailable, message: error.appDisplayMessage) { + logger.recordFailure("Couldn't read \(descriptor.displayName) status", + error: error, + category: .system, + severity: .warning) + } + } + } + systemStatus = firstStatus + let hasReadyRuntime = runtimeReadiness.values.contains { $0.state == .ready } + bootstrap = hasReadyRuntime ? .ready : .serviceStopped + if hasReadyRuntime { + if settings.statsNormalizationMode == .machine { await loadPropertiesIfNeeded() } + applyStatsNormalizationContext() + await refreshDiskUsage() + } + await containers.refresh() + if hasReadyRuntime || !containers.running.isEmpty { + updateContainerStatsStream() + } else { + stopContainerStatsStream() + } + 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") + } + } + + @discardableResult + private func markRuntimeReadiness(_ kind: Core.Runtime.Kind, + state: Core.RuntimeReadiness.State, + message: String? = nil) -> Bool { + guard var readiness = runtimeReadiness[kind] else { return false } + let changed = readiness.state != state || readiness.message != message + guard changed else { return false } + readiness.state = state + readiness.message = message + runtimeReadiness[kind] = readiness + database.upsertRuntimeReadiness(Array(runtimeReadiness.values), + descriptors: supportedRuntimeDescriptors) + return true + } + + /// `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 + + private var lastDiskUsageDate: Date? + /// Fetch `system df`. Throttle background ticks to avoid spawning a process every poll; `force` + /// bypasses the throttle for explicit System-panel refreshes. + 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 kind = firstRuntimeKind(supporting: .systemStatus), + let usage = try? await client.diskUsage(runtimeKind: kind) { + diskUsage = usage + lastDiskUsageDate = Date() + } + } + + /// `images list` backs the toolbar Images panel, creation local-image choices, and update badges. + /// 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 refreshImagesIfNeeded(force: Bool = false) async { + guard let client else { return } + if !force, let last = lastImagesDate, Date().timeIntervalSince(last) < Self.imagesThrottle { return } + let started = Date() + do { + let inventory = try await client.imageInventory() + images = inventory.items + imagesError = partialInventoryMessage(inventory.failures) + } catch { + imagesError = error.appDisplayMessage + } + lastImagesDate = Date() + let elapsed = Date().timeIntervalSince(started) + if elapsed >= 0.75 || force { + 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)") + } + } + + /// Run a throwing CLI action, returning a user-facing error string on failure (nil on success). + /// 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 + } + + func previewCreateCommand(for spec: ContainerFormState) -> [String] { + (try? core(for: spec.effectiveRuntimeKind)?.previewCreateCommand(for: spec.materializedDocumentForRun()).command) + ?? [] + } + + func commandPreviewText(arguments: [String], runtimeKind: Core.Runtime.Kind) -> String { + let executable = runtimeDescriptor(for: runtimeKind)?.executableName ?? runtimeKind.rawValue + return ([executable] + arguments).joined(separator: " ") + } + + func previewCreateCommandText(for spec: ContainerFormState) -> String { + commandPreviewText(arguments: previewCreateCommand(for: spec), runtimeKind: spec.effectiveRuntimeKind) + } + + 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.filter { $0.runtimeKind == spec.effectiveRuntimeKind }) + } + + /// 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 + /// resources warm. Called by `RefreshCoordinator`. + func tick() async { + let started = Date() + await refreshSystem() + guard 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) + 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 refreshImagesIfNeeded() + 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 func updateContainerStatsStream() { + guard let client else { + stopContainerStatsStream() + return + } + let scopedIDs = containers.running.map(\.scopedID).sorted() + guard !scopedIDs.isEmpty else { + stopContainerStatsStream() + return + } + guard containerStatsStreamTask == nil || scopedIDs != containerStatsStreamIDs else { return } + + stopContainerStatsStream() + containerStatsStreamGeneration &+= 1 + let generation = containerStatsStreamGeneration + 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 { + 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 { + guard let self, + self.containerStatsStreamGeneration == generation else { return } + self.diagnosticLogger.error("Stats stream failed: \(error.appDisplayMessage, privacy: .private(mask: .hash))") + } + } + + 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 + historyStore.recordMetrics(containers.statsByID) + } + + /// Refresh the data behind the System toolbar panel (volumes + a forced `system df`). Called from + /// the panel's `.task` since System is no longer a standing page refreshed by the tick. + func refreshSystemResources() async { + guard client != nil, bootstrap == .ready else { return } + await refreshDiskUsage(force: true) + await refreshVolumes() + } + + /// Refresh the registry-login list for Settings. + func refreshRegistries() async { + guard let client, bootstrap == .ready else { return } + 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 + /// idempotent — skips the call when already loaded. + func loadPropertiesIfNeeded() async { + guard properties == nil else { return } + await reloadProperties() + } + + /// Force-reload the daemon's system properties (e.g. after a kernel change). + func reloadProperties() async { + guard let client, bootstrap == .ready else { return } + guard let kind = firstRuntimeKind(supporting: .systemProperties) else { return } + if let p = try? await client.systemProperties(runtimeKind: kind) { + properties = p + applyStatsNormalizationContext() + } + } + + /// Refresh the cached volume list. Volumes live in the System panel, so this is exposed directly + /// and called when that panel opens. + func refreshVolumes() async { + guard let client, bootstrap == .ready else { return } + do { + let inventory = try await client.volumeInventory() + volumes = inventory.items + database.upsertVolumes(inventory.items) + setResourceInventoryError(partialInventoryMessage(inventory.failures), for: "volumes") + } catch { + resourceInventoryErrors["volumes"] = error.appDisplayMessage + } + } + + /// Refresh the cached network list. Networks back the collapsible groups on the Containers page. + func refreshNetworks() async { + guard let client, bootstrap == .ready else { return } + do { + let inventory = try await client.networkInventory() + networks = inventory.items + database.upsertNetworks(inventory.items) + setResourceInventoryError(partialInventoryMessage(inventory.failures), for: "networks") + } catch { + resourceInventoryErrors["networks"] = error.appDisplayMessage + } + } + + private func setResourceInventoryError(_ message: String?, for key: String) { + if let message { + resourceInventoryErrors[key] = message + } else { + resourceInventoryErrors.removeValue(forKey: key) + } + } + + private func partialInventoryMessage(_ failures: [Core.Runtime.InventoryFailure]) -> String? { + guard !failures.isEmpty else { return nil } + let runtimes = failures.map(\.kind.rawValue).sorted().joined(separator: ", ") + return AppText.string("runtime.inventory.partialFailure", + defaultValue: "Some runtimes could not refresh: \(runtimes).") + } + + // MARK: Create (pull-aware) + + /// Create a container from the form. If its image isn't present locally, pull it first with a + /// 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: ContainerFormState) async -> String? { + 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, + category: .lifecycle, + severity: .warning) + return nil + } + createError = nil + 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 + } + } + do { + try await prepareLinkedVolumePaths(for: spec) + } catch { + createError = error.appDisplayMessage + logger.recordFailure("Failed preparing linked volume paths", + error: error, + category: .lifecycle, + severity: .warning) + return nil + } + let newID = await containers.run(spec) + if newID == nil { createError = containers.errorMessage ?? "Couldn't create the container." } + if let 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) + database.setLinkedVolumePaths(spec.linkedVolumePathsForPersistence, for: scopedID) + logger.record("Created container", category: .lifecycle, containerID: scopedID) + flash(AppText.createdContainer(newID)) + } + return newID + } + + /// 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: ContainerFormState) async -> String? { + guard let originalSnapshot = containers.snapshots.first(where: { $0.scopedID == originalID || $0.id == originalID }) else { + flash(AppText.recreateOriginalUnavailable) + return nil + } + let originalRuntimeID = originalSnapshot.id + let originalScopedID = originalSnapshot.scopedID + 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, + category: .lifecycle, + severity: .warning, + containerID: originalScopedID) + return nil + } + if !(await imageIsLocal(spec.image, runtimeKind: spec.effectiveRuntimeKind)) { + guard await pullImage(spec.image, runtimeKind: spec.effectiveRuntimeKind) else { return nil } + } + do { + try await prepareLinkedVolumePaths(for: spec) + } catch { + flash(error.appDisplayMessage) + logger.recordFailure("Failed preparing linked volume paths", + error: error, + category: .lifecycle, + severity: .warning, + containerID: originalScopedID) + return nil + } + let replacementDocument = spec.materializedDocumentForRun() + let rollbackDocument = Core.Schema.Document.containerEdit(from: originalSnapshot.configuration) + database.markContainerRecreateStarted(source: originalSnapshot, sourceDocument: rollbackDocument) + guard await containers.recreate(originalID: originalID, + replacement: replacementDocument, + rollback: rollbackDocument) else { + if let failure = containers.recreateFailure { + switch failure.recovery { + case .originalRestored: + database.completeContainerRecreate(sourceScopedID: originalScopedID, + replacementScopedID: originalScopedID) + case .restoreFailed: + database.markContainerRecreateFailed(scopedID: originalScopedID) + case .notNeeded: + if failure.phase == .deleteOriginal { + database.completeContainerRecreate(sourceScopedID: originalScopedID, + replacementScopedID: originalScopedID) + } else { + database.markContainerRecreateFailed(scopedID: originalScopedID) + } + } + flash(failure.appDisplayMessage) + } else { + database.markContainerRecreateFailed(scopedID: originalScopedID) + } + return nil + } + 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) + database.completeContainerRecreate(sourceScopedID: originalScopedID, + replacementScopedID: newScopedID) + if newID != originalRuntimeID { + personalization.clearOverride(id: originalScopedID) + healthChecks.clear(id: originalScopedID) + database.setLinkedVolumePaths([], for: originalScopedID) + } + if spec.personalization.isDefault { + personalization.clearOverride(id: newScopedID) + } else { + personalization.setOverride(spec.personalization, for: newScopedID) + } + healthChecks.setCheck(spec.healthCheck, for: newScopedID) + database.setLinkedVolumePaths(spec.linkedVolumePathsForPersistence, for: newScopedID) + logger.record("Recreated container", category: .lifecycle, containerID: newScopedID) + return newID + } + + private func prepareLinkedVolumePaths(for spec: ContainerFormState) async throws { + guard let plan = spec.volumeLinkPlan() else { return } + guard let client = core(for: plan.runtimeKind) else { + throw Core.Runtime.UnsupportedCapability(kind: plan.runtimeKind, capability: .containers) + } + var request = Core.Container.CreateRequest(runtimeKind: plan.runtimeKind) + request.image = plan.image + request.platform = plan.platform + request.os = plan.os + request.architecture = plan.architecture + request.detach = false + request.removeOnExit = true + request.entrypoint = "/bin/sh" + request.volumes = plan.volumeMounts + request.command = ["-c", linkedVolumePathScript(for: plan)] + _ = try await client.createContainer(Core.Schema.Document.containerCreate(from: request)) + } + + private func linkedVolumePathScript(for plan: VolumeLinkPlan) -> String { + var lines = ["set -eu"] + for link in plan.links { + let linkPath = shellQuote(link.linkPath) + let targetPath = shellQuote(link.targetPath) + lines.append("mkdir -p \"$(dirname \(linkPath))\"") + lines.append(""" + if [ -e \(linkPath) ] || [ -L \(linkPath) ]; then + if [ -L \(linkPath) ]; then + current="$(readlink \(linkPath))" + if [ "$current" != \(targetPath) ]; then ln -sfn \(targetPath) \(linkPath); fi + else + echo "Cannot create linked path: path exists and is not a symlink." >&2 + exit 64 + fi + else + ln -s \(targetPath) \(linkPath) + fi + """) + } + return lines.joined(separator: "\n") + } + + private func shellQuote(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } + + /// 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 container 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() + let runtimeName = runtimeDescriptor(for: targetRuntimeKind)?.displayName ?? targetRuntimeKind.rawValue + flash(AppText.string("container.migration.complete", defaultValue: "Container moved to \(runtimeName).")) + logger.record("Migrated container 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, runtimeKind: Core.Runtime.Kind) { + guard let client else { return } + Task { + if let error = await capturedError({ _ = try await client.loadImages(from: url.path, runtimeKind: runtimeKind) }) { + flash(error.appDisplayMessage) + logger.recordFailure("Failed loading image archive", + error: error, + category: .image, + severity: .error, + containerID: runtimeKind.scopedID(for: url.lastPathComponent)) + } else { + await refreshImagesIfNeeded(force: true) + flash(AppText.loadedFile(url.lastPathComponent)) + logger.record("Loaded image archive", + category: .image, + containerID: runtimeKind.scopedID(for: url.lastPathComponent)) + } + } + } + + @discardableResult + 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, + runtimeKind: runtimeKind) + await refreshVolumes() + } + if let error { + flash(error.appDisplayMessage) + logger.recordFailure("Failed creating volume", + error: error, + category: .system, + severity: .error) + return false + } + flash(AppText.createdVolume(name)) + logger.record("Created volume", category: .system, containerID: runtimeKind.scopedID(for: name)) + return true + } + + @discardableResult + 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, + runtimeKind: runtimeKind) + await refreshNetworks() + } + if let error { + flash(error.appDisplayMessage) + logger.recordFailure("Failed creating network", + error: error, + category: .system, + severity: .error) + return false + } + flash(AppText.createdNetwork(name)) + logger.record("Created network", 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, + runtimeKind: Core.Runtime.Kind) async -> Bool { + guard client != nil else { return false } + 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, 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, runtimeKind: runtimeKind) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { activity?.detail = trimmed } + } + await refreshImagesIfNeeded(force: true) + logger.record("Pulled image", category: .image) + return true + } catch { + flash(error.appDisplayMessage) + logger.recordFailure("Failed pulling image", + error: error, + category: .image, + severity: .error) + return false + } + } + + /// 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, runtimeKind: Core.Runtime.Kind) async -> Bool { + guard let client else { return false } + let target = normalizedRef(reference) + 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. + private func normalizedRef(_ reference: String) -> String { + let short = Format.shortImage(reference) + 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 var bannerClear: Task? + /// Show a transient banner for ~4s. + func flash(_ message: String) { + banner = message + bannerClear?.cancel() + bannerClear = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.bannerDuration)) + if !Task.isCancelled { self?.banner = nil } + } + } + + /// Start the container system service, then re-bootstrap. + func startService() async { + 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) + 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) + logger.record("Restarted container service", category: .system, severity: .warning) + } + + /// 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: [Core.Runtime.SystemAction], resetWatchdog: Bool) async { + guard let kind = serviceControlRuntimeKind, serviceControlRuntimeAvailable else { + flash(AppText.string("runtime.service.unavailable", + defaultValue: "Service controls are not available for the current runtime. Start the runtime provider externally, then retry.")) + return + } + guard let client else { return } + bootstrap = .checking + if resetWatchdog { watchdog.reset() } + for action in actions { _ = try? await client.performSystemAction(action, runtimeKind: kind) } + await refreshSystem() + } + + /// Short health label for the toolbar indicator. + var serviceLabel: String { + switch bootstrap { + case .ready: return "Running" + case .serviceStopped: return serviceControlRuntimeAvailable ? "Stopped" : "Endpoint unavailable" + case .checking: return "Checking…" + case .cliMissing: return "No CLI" + case .unsupported(let v): return "v\(v)" + } + } + + 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/AppRuntimeIntent.swift b/Sources/ContainedApp/App/AppRuntimeIntent.swift new file mode 100644 index 00000000..1a5a49be --- /dev/null +++ b/Sources/ContainedApp/App/AppRuntimeIntent.swift @@ -0,0 +1,47 @@ +import ContainedCore + +/// App-level runtime choice policy for actions that are not already owned by a resource. +enum AppRuntimeIntent { + static let placeholderKind = Core.Runtime.Kind(rawValue: "contained-runtime-unselected") + + enum Resolution: Equatable { + case resolved(Core.Runtime.Kind) + case needsSelection([Core.Runtime.Descriptor]) + case unavailable(Core.Runtime.Capability) + } +} + +@MainActor +extension AppModel { + func runtimeDescriptors(supporting capability: Core.Runtime.Capability) -> [Core.Runtime.Descriptor] { + availableRuntimeDescriptors.filter { $0.supports(capability) } + } + + func preselectedRuntimeKind(current: Core.Runtime.Kind, + capability: Core.Runtime.Capability) -> Core.Runtime.Kind { + let candidates = runtimeDescriptors(supporting: capability) + if candidates.contains(where: { $0.kind == current }) { + return current + } + return candidates.first?.kind ?? AppRuntimeIntent.placeholderKind + } + + func resolveRuntimeIntent(owner: Core.Runtime.Kind? = nil, + selected: Core.Runtime.Kind? = nil, + capability: Core.Runtime.Capability) -> AppRuntimeIntent.Resolution { + let candidates = runtimeDescriptors(supporting: capability) + if let owner, candidates.contains(where: { $0.kind == owner }) { + return .resolved(owner) + } + if let selected, candidates.contains(where: { $0.kind == selected }) { + return .resolved(selected) + } + if candidates.count == 1, let only = candidates.first { + return .resolved(only.kind) + } + if candidates.isEmpty { + return .unavailable(capability) + } + return .needsSelection(candidates) + } +} diff --git a/Sources/Contained/ContainedApp.swift b/Sources/ContainedApp/App/ContainedApp.swift similarity index 83% rename from Sources/Contained/ContainedApp.swift rename to Sources/ContainedApp/App/ContainedApp.swift index 6f153fe5..f2cba37e 100644 --- a/Sources/Contained/ContainedApp.swift +++ b/Sources/ContainedApp/App/ContainedApp.swift @@ -1,17 +1,17 @@ import SwiftUI -import AppKit +import ContainedUI import ContainedCore -@main -struct ContainedApp: App { +public struct ContainedApplication: App { + @Environment(\.openURL) private var openURL @State private var app = AppModel() @State private var ui = UIState() - init() { - NSWindow.allowsAutomaticWindowTabbing = false + public init() { + Platform.disableAutomaticWindowTabbing() } - var body: some Scene { + public var body: some Scene { WindowGroup { RootView() .environment(app) @@ -39,8 +39,8 @@ struct ContainedApp: 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, @@ -60,7 +60,7 @@ struct ContainedApp: App { Divider() Button("New Volume…") { route(.createVolume) } Button("New Network…") { route(.createNetwork) } - Button("Import Compose…") { ComposeImport.pickAndImport(app: app, ui: ui) } + Button("Import Compose…") { route(.importCompose) } .disabled(!app.settings.composeImportEnabled) } } @@ -69,12 +69,16 @@ struct ContainedApp: App { Button(app.serviceLabel) { } .disabled(true) Divider() - if app.serviceHealthy { - Button("Stop Service") { Task { await app.stopService() } } + if app.serviceControlRuntimeAvailable { + 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 Runtime Connection") { Task { await app.retryBootstrap() } } } - Button("Restart Service") { Task { await app.restartService() } } } Divider() Button("Open Contained") { activateMainWindow() } @@ -115,7 +119,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(UI.Card.Density.allCases) { Text($0.localizedDisplayName).tag($0) } } Divider() Button("Reload") { app.coordinator.wake() } @@ -150,18 +154,18 @@ struct ContainedApp: App { } } CommandGroup(replacing: .help) { - Button("Contained Help") { NSWorkspace.shared.open(Links.helpURL) } - Button("Features Guide") { NSWorkspace.shared.open(Links.featuresURL) } - Button("Installation & Updates") { NSWorkspace.shared.open(Links.installURL) } - Button("Keyboard Shortcuts") { NSWorkspace.shared.open(Links.shortcutsURL) } - Button("Troubleshooting") { NSWorkspace.shared.open(Links.troubleshootingURL) } + Button("Contained Help") { openURL(Links.helpURL) } + Button("Features Guide") { openURL(Links.featuresURL) } + Button("Installation & Updates") { openURL(Links.installURL) } + Button("Keyboard Shortcuts") { openURL(Links.shortcutsURL) } + Button("Troubleshooting") { openURL(Links.troubleshootingURL) } Divider() Button("Release Notes") { showReleaseNotes() } - Button("Architecture") { NSWorkspace.shared.open(Links.architectureURL) } - Button("Contributing") { NSWorkspace.shared.open(Links.contributingURL) } + Button("Architecture") { openURL(Links.architectureURL) } + Button("Contributing") { openURL(Links.contributingURL) } Divider() - Button("Report an Issue…") { NSWorkspace.shared.open(Links.issuesURL) } - Button("View Source on GitHub") { NSWorkspace.shared.open(Links.repoURL) } + Button("Report an Issue…") { openURL(Links.issuesURL) } + Button("View Source on GitHub") { openURL(Links.repoURL) } Divider() Button("Reveal CLI Binary in Finder") { revealCLIBinary() } } @@ -186,7 +190,7 @@ struct ContainedApp: 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 }) } @@ -235,17 +239,13 @@ 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 } - NSWorkspace.shared.activateFileViewerSelecting([url]) + guard let url = app.runtimeCLIURL(for: .appleContainer) else { return } + Platform.revealInFinder(url) } /// Bring the main window to the front so panel morphs open in the right window. private func activateMainWindow() { - NSApplication.shared.activate(ignoringOtherApps: true) - for window in NSApplication.shared.windows where window.canBecomeMain { - window.makeKeyAndOrderFront(nil) - break - } + Platform.activateMainWindow() } } diff --git a/Sources/Contained/History/ActivityView.swift b/Sources/ContainedApp/Features/Activity/ActivityView.swift similarity index 56% rename from Sources/Contained/History/ActivityView.swift rename to Sources/ContainedApp/Features/Activity/ActivityView.swift index a63c92c7..7574907c 100644 --- a/Sources/Contained/History/ActivityView.swift +++ b/Sources/ContainedApp/Features/Activity/ActivityView.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedUX +import ContainedUI import SwiftData /// System-wide activity log: every recorded event across all containers, newest first, filterable @@ -8,7 +10,7 @@ struct ActivityView: View { var body: some View { ActivityContent(showClose: true) { dismiss() } - .frame(Tokens.SheetSize.wide) + .frame(UI.Panel.SheetSize.wide) .sheetMaterial() } } @@ -39,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 @@ -58,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)) @@ -67,9 +71,11 @@ 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)") + 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") + : AppText.string("activity.filter.current", defaultValue: "Filter: \(ui.activityFilter!.rawValue.capitalized)")) } .buttonStyle(.plain) .disabled(presentKinds.isEmpty) @@ -80,28 +86,15 @@ struct ActivityContent: View { } var body: some View { - MorphPanelScaffold(width: Tokens.PanelSize.activity.width) { + UI.Panel.Scaffold(width: UI.Panel.Size.activity.width) { if showsHeader { VStack(spacing: 0) { - PanelHeader(symbol: "bell", - title: "Activity", + UI.Panel.Header(symbol: "bell", + title: AppText.sectionActivity, subtitle: subtitle) { - GlassButton { + UI.Action.Cluster { 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) - } + UI.Action.Items(activityHeaderActions) } } Divider() @@ -109,28 +102,52 @@ struct ActivityContent: View { } } content: { 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) + 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: Tokens.Space.s) { + UI.List.Stack { ForEach(filtered) { event in EventRow(event: event, elevated: elevated, isUnread: !event.isRead) } } - .padding(Tokens.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: [UI.Action.Item] { + var actions = [ + 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), + UI.Action.Item(systemName: "trash", + 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) + ] + if showClose { + actions.append(UI.Action.Item(systemName: "xmark", + help: AppText.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 } 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. @@ -138,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 @@ -147,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 new file mode 100644 index 00000000..09587662 --- /dev/null +++ b/Sources/ContainedApp/Features/Activity/ContainerHistoryTab.swift @@ -0,0 +1,297 @@ +import SwiftUI +import ContainedUI +import SwiftData +import Charts +import ContainedCore + +/// Selectable history windows for the timeline charts. +enum HistoryRange: String, CaseIterable, Identifiable { + case hour = "1h" + case day = "24h" + case week = "7d" + var id: String { rawValue } + var seconds: TimeInterval { + switch self { + case .hour: return 3600 + case .day: return 86_400 + case .week: return 604_800 + } + } +} + +/// 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: 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. + @State private var cutoff = Date().addingTimeInterval(-HistoryRange.day.seconds) + + var body: some View { + ContainerTabScaffold { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { + Picker("Range", selection: $range) { + ForEach(HistoryRange.allCases) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .frame(maxWidth: 240) + + ContainerHistoryWindow(snapshot: snapshot, + cutoff: cutoff, + normalization: app.statsNormalizationContext) + } + } + .onChange(of: range) { _, newRange in cutoff = Date().addingTimeInterval(-newRange.seconds) } + } +} + +/// The charts + event list for one container, scoped to a time window. The window is pushed straight +/// 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: Core.Container.Snapshot + private let normalization: Core.Metrics.NormalizationContext + @Query private var samples: [MetricSample] + @Query private var events: [EventRecord] + + init(snapshot: Core.Container.Snapshot, cutoff: Date, normalization: Core.Metrics.NormalizationContext) { + 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 }, + sort: \EventRecord.timestamp, order: .reverse) + } + + var body: some View { + let chartPoints = HistoryChartPoint.points(from: samples.map(MetricSampleSnapshot.init), + snapshot: snapshot, + normalization: normalization) + + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.l) { + if chartPoints.isEmpty { + 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 + UI.Chart.Style.primaryLine( + LineMark(x: .value("Time", point.timestamp), + y: .value("CPU", point.cpuPercent)) + ) + } + .percentHistoryScale() + } + chartCard("Memory", unit: percentUnit) { + Chart(chartPoints) { point in + 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 + 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 { + UI.List.Stack(padding: 0) { + Text(AppText.string("history.events", defaultValue: "Events")).designHeadlineLabelStyle() + ForEach(events.prefix(50)) { event in + EventRow(event: event) + } + } + } + } + } + + private func chartCard(_ title: String, unit: String, @ViewBuilder chart: @escaping () -> C) -> some View { + ContainerTabSection { + HStack { + Text(title).designSubheadlineLabelStyle() + Spacer() + Text(unit).designSecondaryCaption() + } + chart() + .frame(height: UI.Chart.Size.height) + .chartXAxis { AxisMarks(values: .automatic(desiredCount: UI.Chart.Rendering.axisDesiredCount)) } + .transaction { transaction in transaction.animation = nil } + } + } + + private var percentUnit: String { + switch normalization.mode { + case .container: return "% of container" + 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: Core.Container.Snapshot, + normalization: Core.Metrics.NormalizationContext) -> [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 static func percent(_ fraction: Double) -> Double { + min(max(sanitized(fraction), 0), 1) * 100 + } + + private static func sanitized(_ value: Double) -> Double { + guard value.isFinite, value > 0 else { return 0 } + return value + } + + private static func bytes(from value: Double) -> UInt64 { + UInt64(min(sanitized(value), Double(UInt64.max))) + } +} + +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 + var elevated = true + /// When true, the row is highlighted (accent wash + dot) to mark an event the user hasn't seen yet. + /// The Activity panel passes this; the per-container history tab leaves it false. + var isUnread = false + @Environment(\.modelContext) private var modelContext + + var body: some View { + UI.Card.Scaffold(size: .small, + isSelected: isUnread, + elevated: elevated, + title: event.message, + subtitle: subtitle) { + UI.Card.IconChip(symbol: event.kind.symbol, + tint: event.kind.tint, + backgroundOpacity: UI.Card.Metric.iconEmphasisBackgroundOpacity) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + UI.Badge.Text(text: event.kind.rawValue.capitalized) + } headerAccessory: { + if isUnread { + UI.Badge.Dot(color: .accentColor) + .accessibilityLabel(AppText.unread) + } else { + EmptyView() + } + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() + } + .selectionFill() + .contextMenu { rowMenu } + } + + /// Relative time, plus the container's short id when the event is container-scoped. + private var subtitle: String { + var parts = [event.timestamp.formatted(.relative(presentation: .numeric))] + if let id = event.containerID, !id.isEmpty { parts.append(String(id.prefix(12))) } + return parts.joined(separator: " · ") + } + + @ViewBuilder + private var rowMenu: some View { + if event.isRead { + Button { event.isRead = false; save() } label: { Label("Mark as Unread", systemImage: "circle") } + } else { + Button { event.isRead = true; save() } label: { Label("Mark as Read", systemImage: "checkmark.circle") } + } + UI.Copy.ValueLabel("Copy Message", value: event.message) + Divider() + Button(role: .destructive) { modelContext.delete(event); save() } label: { + Label("Delete Event", systemImage: "trash") + } + } + + private func save() { + do { + try modelContext.save() + } catch { + fatalError("Failed to save activity event: \(error)") + } + } +} + +extension EventKind { + /// A per-kind accent used for the event row's icon chip — gives the log visual texture and lets + /// alerts/health transitions read at a glance. + var tint: Color { + switch self { + case .lifecycle: return .green + case .image, .pull: return .blue + case .compose: return .purple + case .build: return .orange + case .registry: return .teal + case .watchdog: return .orange + case .healthcheck: return .pink + case .alert: return .red + case .system, .ui: return .secondary + } + } +} diff --git a/Sources/Contained/Features/Containers/ContainerCard.swift b/Sources/ContainedApp/Features/Containers/Card/ContainerCard.swift similarity index 57% rename from Sources/Contained/Features/Containers/ContainerCard.swift rename to Sources/ContainedApp/Features/Containers/Card/ContainerCard.swift index 4ec5820d..5a6ebc30 100644 --- a/Sources/Contained/Features/Containers/ContainerCard.swift +++ b/Sources/ContainedApp/Features/Containers/Card/ContainerCard.swift @@ -1,17 +1,19 @@ import SwiftUI +import ContainedUI 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 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 histories: [GraphMetric: [Double]] = [:] + var hasStyleOverride: Bool = true + var density: UI.Card.Density + 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: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer] = [:] var isBusy: Bool var hasImageUpdate: Bool = false var isExpanded: Bool = false @@ -30,13 +32,12 @@ 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 + 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 + var selectedWidgetIndex: Binding? @State private var tab: Tab = .overview @State private var confirmingDelete = false @@ -44,7 +45,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" @@ -53,7 +54,6 @@ struct ContainerCard: View { case stats = "Stats" case history = "History" case files = "Files" - case inspect = "Inspect" var id: String { rawValue } @@ -65,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" } } } @@ -77,16 +76,17 @@ 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 } - 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: ResourceCardSize { density.resourceSize } + private var cardSize: UI.Card.Size { density.resourceSize } var body: some View { Group { @@ -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 @@ -109,9 +109,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) { @@ -124,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) { - headerRow(controlsReveal: controlsVisible ? 1 : 0) + UI.Card.Scaffold(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: { @@ -148,66 +162,31 @@ struct ContainerCard: View { } footerActions: { footerActions } widget: { - LiveSparkline(samples: histories[activeWidget.metric] ?? history, - comparisonSamples: activeWidgetComparisonMetric.flatMap { histories[$0] } ?? [], + UI.Chart.Sparkline(samples: histories[activeWidget.metric]?.values ?? [], + comparisonSamples: activeWidgetComparisonMetric.flatMap { histories[$0]?.values } ?? [], color: activeWidgetColor, lineWidth: activeWidget.lineWidth, style: activeWidget.style, 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: 58) + .frame(height: UI.Card.Metric.sparklineHeight) } - .overlay { if isBusy { ProgressView().controlSize(.small) } } + .designCardProgressOverlay(when: isBusy) } - @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 - } - } - - private var compactHeaderRow: some View { - ResourceCardHeader { - iconChip - } content: { - headerTitleBlock - } trailing: { - EmptyView() - } - } - - 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) - } - - private var headerTitleBlock: some View { - VStack(alignment: .leading, spacing: 1) { - HStack(alignment: .top, spacing: 4) { - ResourceCardTitleText(text: name) - } - HStack(alignment: .top, spacing: 4) { - ResourceCardMonospacedSubtitleText(text: Format.shortImage(snapshot.image)) - } - } + private var cardPages: UI.Card.Pages { + UI.Card.Pages(items: pageControlItems, + selection: tab, + tint: tint, + controlsReveal: isExpanded && controlsVisible ? 1 : 0, + closeLabel: AppText.close, + onSelect: selectTab, + onClose: onClose) } private var iconChip: some View { @@ -218,16 +197,16 @@ struct ContainerCard: View { } private var statusChip: some View { - ResourceCardFooterMini { + 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 { - ResourceCardMetricText(text: statusLabel) - .foregroundStyle(.secondary) + UI.Card.MetricText(text: statusLabel) + .designSecondaryValueStyle() } } .help(statusHelp) @@ -268,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 } } @@ -311,26 +290,16 @@ struct ContainerCard: View { if hasImageUpdate { 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") - } - } + UI.Copy.ValueLabel("Copy ID", value: snapshot.id) Divider() 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: [UI.Card.Page] { + Tab.allCases.map { item in + UI.Card.Page(id: item, + title: item.rawValue, + systemImage: item.systemImage) } } @@ -345,17 +314,30 @@ 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) + } + } + + private func selectTab(_ item: Tab) { + guard tab != item else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + tab = item } } @@ -363,22 +345,36 @@ 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 UI.Card.FooterChip(isSelected: active, + tint: widget.tint?.color ?? tint, + help: widget.metric.displayName, + action: { + if let selectedWidgetIndex { + selectedWidgetIndex.wrappedValue = index + } else { + localSelectedWidgetIndex = index + } + }) { + if widget.showIcon { + UI.Symbol.Image(systemName: widget.resolvedSystemImage, + size: .caption2) + } + } text: { + if widget.showText { + UI.Card.MetricText(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: Core.Metrics.GraphMetric) -> UI.Chart.Scale { + 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 @@ -386,27 +382,51 @@ 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", tint: .red) { 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, + role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { - Button(action: action) { - ResourceCardFooterMini { - Image(systemName: systemName).font(.body) - } text: { - EmptyView() + UI.Card.FooterButton(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/ContainedApp/Features/Containers/Card/ContainersGridView.swift b/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift new file mode 100644 index 00000000..66df8b7e --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Card/ContainersGridView.swift @@ -0,0 +1,705 @@ +import SwiftUI +import ContainedUX +import ContainedUI +import ContainedCore + +/// 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 +/// into a centered detail panel. +struct ContainersGridView: View { + @Environment(AppModel.self) private var app + @Environment(UIState.self) private var ui + @Environment(\.morphSafeAreaManager) private var safeAreaManager + + @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 + /// panel. A single spring on this flag owns the whole motion (no matchedGeometry to fight). + @State private var expanded = false + @State private var lifecycleFeedback = 0 + /// 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 = [] + @State private var deletingNetwork: Core.Network.Resource? + + private let detailSpring = Animation.spring(response: 0.42, dampingFraction: 0.86) + + private var store: ContainersStore { app.containers } + + /// A bucket of containers under one heading. `resource` is set only for network grouping (so the + /// section keeps its network context menu); `symbol` drives the section header glyph. + private struct ContainerGroup: Identifiable { + let name: String + let symbol: String + 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: Core.Container.Snapshot) -> [String] { + let names = snapshot.configuration.networks.map(\.network) + snapshot.status.networks.map(\.network) + return Array(Set(names)).sorted() + } + + /// Containers bucketed according to the toolbar grouping choice, each bucket sorted by the chosen + /// sort. Network grouping keeps every known network as a section (empty ones included). + private var groups: [ContainerGroup] { + switch ui.grouping { + case .network: return networkGroups + case .volume: return volumeGroups + case .image: return imageGroups + case .flat: return [ContainerGroup(name: "All containers", symbol: "square.grid.2x2", + resource: nil, containers: sorted(filtered), isBuiltin: false)] + } + } + + private var networkGroups: [ContainerGroup] { + 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: [Core.Container.Snapshot]] = [:] + for network in app.networks { buckets[network.name] = [] } + buckets[defaultName, default: []] = buckets[defaultName] ?? [] + + for snapshot in filtered { + let names = networkNames(snapshot) + if names.isEmpty { + buckets[defaultName, default: []].append(snapshot) + } else { + for name in names { buckets[name, default: []].append(snapshot) } + } + } + + return buckets.keys.sorted { lhs, rhs in + if lhs == defaultName { return true } + if rhs == defaultName { return false } + return lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending + }.map { name in + ContainerGroup(name: name, symbol: "network", resource: byNetworkName[name], + containers: sorted(buckets[name] ?? []), + isBuiltin: byNetworkName[name]?.isBuiltin ?? true) + } + } + + private var volumeGroups: [ContainerGroup] { + let noVolume = "No volume" + 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 } + return source + }) + if volumes.isEmpty { + buckets[noVolume, default: []].append(snapshot) + } else { + for volume in volumes { buckets[volume, default: []].append(snapshot) } + } + } + return buckets.keys.sorted { lhs, rhs in + if lhs == noVolume { return false } + if rhs == noVolume { return true } + return lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending + }.map { name in + ContainerGroup(name: name, symbol: "externaldrive", resource: nil, + containers: sorted(buckets[name] ?? []), isBuiltin: false) + } + } + + private var imageGroups: [ContainerGroup] { + var buckets: [String: [Core.Container.Snapshot]] = [:] + for snapshot in filtered { + buckets[Format.shortImage(snapshot.image), default: []].append(snapshot) + } + return buckets.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + .map { name in + ContainerGroup(name: name, symbol: "shippingbox", resource: nil, + containers: sorted(buckets[name] ?? []), isBuiltin: false) + } + } + + /// Order a bucket of containers by the chosen sort. + private func sorted(_ containers: [Core.Container.Snapshot]) -> [Core.Container.Snapshot] { + switch ui.sort { + case .name: + return containers.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + case .status: + return containers.sorted { lhs, rhs in + let lhsRunning = lhs.state == .running, rhsRunning = rhs.state == .running + if lhsRunning != rhsRunning { return lhsRunning } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + case .image: + return containers.sorted { lhs, rhs in + let cmp = lhs.image.localizedCaseInsensitiveCompare(rhs.image) + if cmp != .orderedSame { return cmp == .orderedAscending } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + } + } + + private var columns: [GridItem] { + return [GridItem(.adaptive(minimum: UI.Card.Grid.largeMin, maximum: UI.Card.Grid.largeMax), + spacing: UI.Layout.Spacing.m)] + } + + private var filtered: [Core.Container.Snapshot] { + store.snapshots.filter { snapshot in + (!ui.runningOnly || snapshot.state == .running) && + (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 = safeAreaManager.bounds(in: viewport.size, policy: .content) + ZStack { + ScrollView { + ZStack(alignment: .top) { + // Background sibling (behind the cards): double-click empty space to zoom the + // window. As a sibling — not an ancestor — of the cards, it never delays or + // intercepts their taps; only clicks that fall through the gaps reach it. + Color.clear + .frame(maxWidth: .infinity, minHeight: scrollBounds.height) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { zoomFrontWindow() } + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.l) { + ForEach(groups) { group in + groupSection(group) + } + Color.clear + .frame(height: UI.Toolbar.Size.band) + } + .padding(.horizontal, UI.Layout.Spacing.l) + } + } + .contentMargins(.top, ui.toolbarUIEnabled ? 0 : UI.Toolbar.Size.band, for: .scrollContent) + + if detail != nil { + Color.clear + .globalBackdrop(style: .blur, progress: expanded ? 1 : 0) + .contentShape(Rectangle()) + .onTapGesture { closeDetail() } + .zIndex(5) + } + + if let detail { + let target = cardDetailTarget.rect(origin: .zero, + in: viewport.size, + safeAreaManager: cardDetailSafeAreaManager) + 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) + } + } + .coordinateSpace(.named("grid")) + } + .overlay(alignment: .bottom) { + 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 } + } + .confirmationDialog( + "Delete \(customizeName(deleting))?", + isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }) + ) { + Button("Delete", role: .destructive) { + if let id = deleting?.scopedID { Task { await store.remove(id, force: true) } } + deleting = nil + } + Button("Cancel", role: .cancel) { deleting = nil } + } message: { + Text("This removes the container. This can't be undone.") + } + // Network-level actions. + .task { await app.refreshNetworks() } + .confirmationDialog("Delete network \(deletingNetwork?.name ?? "")?", + isPresented: deleteNetworkBinding, presenting: deletingNetwork) { network in + Button("Delete", role: .destructive) { Task { await deleteNetwork(network) } } + } message: { _ in Text("This removes the network. Containers must be detached first.") } + .refreshable { await store.refresh() } + .sensoryFeedback(.success, trigger: lifecycleFeedback) + // 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(\.scopedID)) { _, ids in + selectedWidgetIndices = selectedWidgetIndices.filter { ids.contains($0.key) } + } + } + + // MARK: - Network sections + + @ViewBuilder + private func groupSection(_ group: ContainerGroup) -> some View { + let collapsed = collapsedNetworks.contains(group.name) + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { + sectionHeader(group, collapsed: collapsed) + if !collapsed { + if group.containers.isEmpty { + 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: UI.Layout.Spacing.m) { + ForEach(group.containers) { snapshot in + gridCard(snapshot) + } + } + } + } + } + } + + private func sectionHeader(_ group: ContainerGroup, collapsed: Bool) -> some View { + HStack(spacing: UI.Layout.Spacing.s) { + Button { + toggleCollapsed(group.name) + } label: { + UI.Symbol.Image(systemName: "chevron.right", size: .caption) + .rotationEffect(.degrees(collapsed ? 0 : 90)) + } + .buttonStyle(.plain) + UI.Symbol.Image(systemName: group.symbol) + Text(group.name).designHeadlineLabelStyle() + UI.Badge.Text(text: "\(group.containers.count)") + if group.isBuiltin { + UI.Badge.Text(text: "builtin", font: .caption2.weight(.medium)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, UI.Layout.Spacing.xs) + .padding(.vertical, UI.Layout.Spacing.xs) + .contextMenu { if let resource = group.resource { networkMenu(resource) } } + } + + @ViewBuilder + private func networkMenu(_ resource: Core.Network.Resource) -> some View { + UI.Copy.ValueLabel("Copy Name", value: resource.name) + if !resource.isBuiltin { + Divider() + Button(role: .destructive) { deletingNetwork = resource } label: { Label("Delete Network", systemImage: "trash") } + } + } + + private func toggleCollapsed(_ name: String) { + if collapsedNetworks.contains(name) { collapsedNetworks.remove(name) } else { collapsedNetworks.insert(name) } + } + + /// Zoom (fill/restore) the window — the title-bar gesture, relocated to the empty background. + private func zoomFrontWindow() { + Platform.zoomFrontWindow() + } + + private var deleteNetworkBinding: Binding { + Binding(get: { deletingNetwork != nil }, set: { if !$0 { deletingNetwork = nil } }) + } + + private func deleteNetwork(_ network: Core.Network.Resource) async { + guard let client = app.client else { return } + 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?.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. + .opacity(selected ? 0 : 1) + .allowsHitTesting(detail == nil) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { + updateCardFrame(proxy.frame(in: .named("grid")), for: snapshot.scopedID) + } + .onChange(of: proxy.frame(in: .named("grid"))) { _, frame in + updateCardFrame(frame, for: snapshot.scopedID) + } + } + } + } + + 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: Core.Container.Snapshot) -> some View { + containerCard(snapshot, isExpanded: false) { + selecting ? toggle(snapshot.scopedID) : openDetail(snapshot) + } + } + + 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, + isExpanded: true, + cornerRadiusOverride: expanded ? UI.Card.Radius.expanded : UI.Card.Radius.container, + controlsVisible: expanded) {} + } + + private func containerCard(_ snapshot: Core.Container.Snapshot, isExpanded: Bool, + cornerRadiusOverride: CGFloat? = nil, + controlsVisible: Bool = true, + onTap: @escaping () -> Void) -> some View { + let style = app.containerStyle(for: snapshot) + let key = snapshot.scopedID + let hasStyleOverride = app.personalization.hasOverride(id: key) + return ContainerCardMetricsRenderer( + metrics: store.metricsState(for: key), + snapshot: snapshot, + style: style, + hasStyleOverride: hasStyleOverride, + density: app.settings.density, + statsNormalization: app.statsNormalizationContext, + selectedWidgetIndex: selectedWidgetBinding(for: key), + isBusy: store.busyIDs.contains(key), + hasImageUpdate: app.imageUpdateStatus(for: snapshot.image, + runtimeKind: snapshot.runtimeKind).state == .updateAvailable, + isExpanded: isExpanded, + cornerRadiusOverride: cornerRadiusOverride, + controlsVisible: controlsVisible, + onTap: onTap, + onStart: { lifecycleAction { await store.start(key) } }, + onStop: { lifecycleAction { await store.stop(key) } }, + onRestart: { lifecycleAction { await store.restart(key) } }, + onEdit: { ui.openCreationPanel(editing: snapshot) }, + onUpdate: { updateContainer(snapshot) }, + onDelete: { deleting = snapshot }, + onClose: closeDetail, + onSelectMultiple: { beginSelecting(key) }, + onToggleSelected: { toggle(key) }, + onEndSelecting: { endSelecting() }, + health: app.health.status(for: key), + selecting: selecting, + isSelected: selection.contains(key) + ) + } + + private func selectedWidgetBinding(for id: String) -> Binding { + Binding { + selectedWidgetIndices[id] ?? 0 + } set: { index in + selectedWidgetIndices[id] = index + } + } + + private var cardDetailTarget: UX.Morph.Target { + .centered(safeArea: cardDetailSafeAreaPolicy, margin: 0) { bounds in + panelSize(in: bounds.size) + } + } + + 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 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 = UX.Morph.Geometry.fittedSize( + CGSize(width: max(available.width * 0.62, 680), height: 620), + in: available, + margin: 0 + ) + let width = max(min(fitted.width, available.width), min(360, fitted.width)) + let height = fitted.height + return CGSize(width: width, height: height) + } + + 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 + expanded = false + DispatchQueue.main.async { + withAnimation(detailSpring) { expanded = true } + } + } + + private func closeDetail() { + withAnimation(detailSpring) { expanded = false } completion: { + detail = nil + } + } + + private var batchBar: some View { + UI.Action.SelectionBar(count: selection.count, + countLabel: AppText.selectedCount, + actions: [ + UI.Action.Item(systemName: "play.fill", title: AppText.start) { + batch { await store.start($0) } + }, + UI.Action.Item(systemName: "stop.fill", title: AppText.stop) { + batch { await store.stop($0) } + }, + UI.Action.Item(systemName: "trash", title: AppText.delete, role: .destructive) { + batch { await store.remove($0, force: true) } + } + ]) + .padding(.bottom, UI.Layout.Spacing.l) + } + + private func toggle(_ id: String) { + if selection.contains(id) { selection.remove(id) } else { selection.insert(id) } + } + + private func beginSelecting(_ id: String) { + selecting = true + selection = [id] + } + + private func endSelecting() { + selection.removeAll() + selecting = false + } + + /// Run an action over every selected container, then exit selection mode. + private func batch(_ action: @escaping (String) async -> Void) { + let ids = selection + Task { + for id in ids { await action(id) } + endSelecting() + lifecycleFeedback &+= 1 + } + } + + private func lifecycleAction(_ action: @escaping () async -> Void) { + Task { + await action() + lifecycleFeedback &+= 1 + } + } + + private func updateContainer(_ snapshot: Core.Container.Snapshot) { + Task { + if await app.pullImageUpdate(snapshot.image, runtimeKind: snapshot.runtimeKind) { + ui.openCreationPanel(editing: snapshot) + } + } + } + + private func customizeName(_ snapshot: Core.Container.Snapshot?) -> String { + guard let snapshot else { return "" } + return app.containerStyle(for: snapshot) + .displayName(fallback: snapshot.id) + } + + @ViewBuilder + private var emptyState: some View { + 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) + } + } + } +} + +private struct ContainerCardMetricsRenderer: View { + let metrics: ContainerMetricsState + let snapshot: Core.Container.Snapshot + let style: Personalization + let hasStyleOverride: Bool + let density: UI.Card.Density + let statsNormalization: Core.Metrics.NormalizationContext + let selectedWidgetIndex: Binding + 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 health: Core.Container.HealthStatus + let selecting: Bool + let isSelected: Bool + + var body: some View { + ContainerCard( + snapshot: snapshot, + style: style, + hasStyleOverride: hasStyleOverride, + density: density, + stats: metrics.stats, + statsNormalization: statsNormalization, + histories: metrics.historyByMetric, + 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, + health: health, + selecting: selecting, + isSelected: isSelected, + selectedWidgetIndex: selectedWidgetIndex + ) + } +} + +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 + } +} + +#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, + runtimeKind: .appleContainer + ) + let dockerWeb = Core.Container.Snapshot.placeholder( + id: "preview-web", + image: "docker.io/library/nginx:latest", + state: .running, + runtimeKind: .docker + ) + let worker = Core.Container.Snapshot.placeholder( + id: "preview-worker", + image: "ghcr.io/example/worker:nightly", + state: .stopped, + runtimeKind: .docker + ) + let db = Core.Container.Snapshot.placeholder( + id: "preview-db", + image: "postgres:16", + state: .running, + runtimeKind: .appleContainer + ) + + 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 new file mode 100644 index 00000000..b5fc97e9 --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Creation/ComposeImport.swift @@ -0,0 +1,94 @@ +import Foundation +import ContainedCore + +/// Import a `compose.yaml` without a dedicated page: pick the file, translate each service with an +/// 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 +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, + 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(), + runtimeKind: runtimeKind, + app: app, + ui: ui) + } catch { + app.flash(error.appDisplayMessage) + } + } + + /// Parse pasted compose text and feed its services into the prefill queue. + static func importText(_ text: String, projectName: String = "pasted", + 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 { + app.flash(AppText.containerRuntimeNotReady) + app.logger.record("Compose import could not start because no runtime is available", + category: .compose, + severity: .warning) + return + } + 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) + app.logger.record("Compose import had no services with images", + category: .compose, + severity: .warning) + return + } + if !plan.warnings.isEmpty { + app.flash(AppText.composeWarnings) + app.logger.record("Compose import produced \(plan.warnings.count) warning\(plan.warnings.count == 1 ? "" : "s")", + category: .compose, + severity: .warning) + } + app.logger.record("Imported compose project with \(specs.count) service\(specs.count == 1 ? "" : "s")", + category: .compose) + ui.beginPrefillQueue(specs, using: app) + } catch { + app.flash(error.appDisplayMessage) + app.logger.recordFailure("Compose import failed", + error: error, + category: .compose, + severity: .error) + } + } + +} diff --git a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift similarity index 57% rename from Sources/Contained/Features/Containers/Creation/CreationFlow.swift rename to Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift index 5febfeb1..f78df2d6 100644 --- a/Sources/Contained/Features/Containers/Creation/CreationFlow.swift +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationFlow.swift @@ -1,6 +1,8 @@ import SwiftUI +import ContainedUX +import ContainedUI import SwiftData -import AppKit +import UniformTypeIdentifiers import ContainedCore /// The unified, **paged** creation flow hosted by the toolbar's `+` morph panel, where each page @@ -31,10 +33,10 @@ 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: ContainerSnapshot? + let editSnapshot: Core.Container.Snapshot? /// Close the host (dismiss the sheet / collapse the morph panel). var onClose: () -> Void var onSoftDismissChange: (((() -> Void)?) -> Void)? @@ -56,7 +58,7 @@ struct CreationFlow: View { } } @State private var page: Page - @State private var spec = RunSpec() + @State private var spec = ContainerFormState(runtimeKind: AppRuntimeIntent.placeholderKind) @State private var initialSearchQuery = "" @State private var localImageQuery = "" @State private var composeText = "" @@ -65,14 +67,17 @@ struct CreationFlow: View { @State private var networkName = "" @State private var networkSubnet = "" @State private var networkInternalOnly = false + @State private var resourceRuntimeKind = AppRuntimeIntent.placeholderKind @State private var working = false @State private var configureToken = 0 @State private var configureReturnPage: Page? + @State private var selectingComposeFile = false + @State private var selectingImageArchive = false @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 { 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) } @@ -87,8 +92,8 @@ struct CreationFlow: View { } init(start: Start, onClose: @escaping () -> Void, - prefill: RunSpec? = nil, - editSnapshot: ContainerSnapshot? = nil, + prefill: ContainerFormState? = nil, + editSnapshot: Core.Container.Snapshot? = nil, searchQuery: String = "", returnEntry: UIState.CreationEntry? = nil, onSoftDismissChange: (((() -> Void)?) -> Void)? = nil) { @@ -118,9 +123,20 @@ 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() } + .fileImporter(isPresented: $selectingComposeFile, + allowedContentTypes: UTType.composeDocuments) { result in + handleSelectedComposeFile(result) + } + .fileImporter(isPresented: $selectingImageArchive, + allowedContentTypes: UTType.imageArchives) { result in + handleSelectedImageArchive(result) + } } @ViewBuilder @@ -150,21 +166,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) } } } @@ -173,63 +189,80 @@ struct CreationFlow: View { private var chooserPage: some View { gridScaffold { - optionStack { - optionRow { - if app.settings.hubSearchEnabled { - box(symbol: "magnifyingglass", title: "Search", - subtitle: "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", - matchedID: "creation-option-1") { - go(.localImages) + 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: "slider.horizontal.3", title: "Start from scratch", - subtitle: "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", - 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: "Image archive", - subtitle: "Load an image .tar") { selectImageArchive() } - box(symbol: "bookmark", title: "Templates", - subtitle: saved.isEmpty ? "None saved yet" : "Reuse a saved recipe", - enabled: !saved.isEmpty) { go(.templates) } } } } } 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, internalOnly: $networkInternalOnly, + runtimeKind: $resourceRuntimeKind, + runtimes: app.availableRuntimeDescriptors, + runtimePickerDisabledReason: app.runtimePickerDisabledReason, working: working, onSubmit: createNetwork) } } 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, + runtimeKind: $resourceRuntimeKind, + runtimes: app.availableRuntimeDescriptors, + runtimePickerDisabledReason: app.runtimePickerDisabledReason, working: working, onSubmit: createVolume) } } 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() } @@ -237,14 +270,17 @@ struct CreationFlow: View { private var searchPage: some View { contentOnlyScaffold { - RegistryImageSearch(initialQuery: initialSearchQuery) { picked in - configure(with: picked) + RegistryImageSearch(initialQuery: initialSearchQuery, + runtimeKind: resourceRuntimeKind) { picked in + configure(with: pickedForSelectedRuntime(picked)) } } } 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) @@ -256,11 +292,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() } } } @@ -268,7 +304,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) } @@ -318,18 +356,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:))`. - MorphPanelScaffold(width: 0, scrolls: false) { + UI.Panel.Scaffold(width: 0, scrolls: false) { VStack(spacing: 0) { - PanelHeader(symbol: symbol, title: title, subtitle: subtitle) { - GlassButton(singleItem: true) { - leadingControl(leading) - } + UI.Panel.Header(symbol: symbol, title: title, subtitle: subtitle) { + UI.Action.Group(leadingAction(leading)) } Divider() } } content: { content() - .padding(Tokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: contentAlignment) } } @@ -337,40 +373,39 @@ struct CreationFlow: View { private func contentOnlyScaffold(contentAlignment: Alignment = .topLeading, @ViewBuilder content: @escaping () -> C) -> some View { content() - .padding(Tokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: contentAlignment) } - @ViewBuilder - private func leadingControl(_ leading: Leading) -> some View { + private func leadingAction(_ leading: Leading) -> UI.Action.Item { switch leading { case .close: - GlassButtonItem(systemName: "xmark", help: "Cancel", isCancel: true) { onClose() } + return UI.Action.Item(systemName: "xmark", help: AppText.cancel, isCancel: true) { onClose() } case .back(let action): - GlassButtonItem(systemName: "chevron.left", help: "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(Tokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private func optionStack(@ViewBuilder content: () -> C) -> some View { - GlassEffectContainer(spacing: Tokens.Space.s) { - VStack(spacing: Tokens.Space.s) { content() } + private func optionStack(@ViewBuilder content: @escaping () -> C) -> some View { + UI.Control.OptionStack(spacing: UI.Layout.Spacing.s) { + content() } } private func optionRow(@ViewBuilder content: () -> C) -> some View { - HStack(spacing: Tokens.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 { - GlassOptionTile(symbol: symbol, title: title, subtitle: subtitle, + UI.Control.OptionTile(symbol: symbol, title: title, subtitle: subtitle, enabled: enabled, matchedID: matchedID, matchedNamespace: matchedID == nil ? nil : tileNamespace, @@ -383,14 +418,33 @@ 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 + var picked = picked + if picked.image.trimmingCharacters(in: .whitespaces).isEmpty || + app.availableRuntimeDescriptors.contains(where: { $0.kind == picked.effectiveRuntimeKind }) == false { + picked.runtimeKind = resourceRuntimeKind + } spec = picked configureReturnPage = returnPage ?? (currentPage == .configure ? nil : currentPage) configureToken &+= 1 go(.configure) } + private func pickedForSelectedRuntime(_ picked: ContainerFormState) -> ContainerFormState { + var picked = picked + picked.runtimeKind = resourceRuntimeKind + return picked + } + + private func applyInitialRuntimeSelectionIfNeeded() { + resourceRuntimeKind = app.preselectedRuntimeKind(current: resourceRuntimeKind, capability: .containers) + 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() @@ -438,43 +492,49 @@ 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 UI.Panel.SheetSize.form } } - private func placement(for page: Page) -> MorphPanelPlacement { + private func placement(for page: Page) -> UX.Panel.Placement { .anchored } /// Pick a compose file, then use the existing prefill queue (one form per service). private func selectComposeFile() { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.allowsMultipleSelection = false - panel.allowedContentTypes = [.yaml] - panel.message = "Choose a compose.yaml" - guard panel.runModal() == .OK, let url = panel.url else { return } - onClose() - ComposeImport.importFile(at: url, app: app, ui: ui) + selectingComposeFile = true + } + + private func handleSelectedComposeFile(_ result: Result) { + switch result { + case .success(let url): + onClose() + ComposeImport.importFile(at: url, runtimeKind: resourceRuntimeKind, app: app, ui: ui) + case .failure(let error): + app.flash(error.appDisplayMessage) + } } 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. private func selectImageArchive() { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.allowsMultipleSelection = false - panel.allowedContentTypes = [.init(filenameExtension: "tar") ?? .data] - panel.message = "Choose an image .tar archive" - guard panel.runModal() == .OK, let url = panel.url else { return } - onClose() - app.loadImageTar(at: url) + selectingImageArchive = true + } + + private func handleSelectedImageArchive(_ result: Result) { + switch result { + case .success(let url): + onClose() + app.loadImageTar(at: url, runtimeKind: resourceRuntimeKind) + case .failure(let error): + app.flash(error.appDisplayMessage) + } } private func createVolume() { @@ -484,7 +544,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() } @@ -499,7 +560,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 new file mode 100644 index 00000000..034697e6 --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Creation/CreationPages.swift @@ -0,0 +1,375 @@ +import SwiftUI +import ContainedUI +import ContainedCore + +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 + + var body: some View { + CreationResourceForm(symbol: "network", + title: networkName, + subtitle: networkSubtitle, + command: previewCommand, + runtimeKind: runtimeKind, + runtimes: runtimes) { + 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) { + TextField("", text: $name, prompt: Text("my-network")) + .textFieldStyle(.roundedBorder) + .onSubmit(submitIfReady) + } + 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) + } + 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) + } + } footer: { + CreationSubmitBar(title: AppText.string("creation.network.create", defaultValue: "Create network"), + systemImage: "network.badge.plus", + canSubmit: canSubmit, + working: working, + action: onSubmit) + } + } + + private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } + 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 ? 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 ? 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: " · ") + } + private var previewCommand: [String] { + Core.Command.networkCreatePreview(name: trimmedName.isEmpty ? "" : trimmedName, + subnet: trimmedSubnet.isEmpty ? nil : trimmedSubnet, + internalOnly: internalOnly, + runtimeKind: runtimeKind) + } + + private func submitIfReady() { + guard canSubmit else { return } + onSubmit() + } +} + +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 + + var body: some View { + CreationResourceForm(symbol: "externaldrive", + title: volumeName, + subtitle: volumeSubtitle, + command: previewCommand, + runtimeKind: runtimeKind, + runtimes: runtimes) { + 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) { + TextField("", text: $name, prompt: Text("my-volume")) + .textFieldStyle(.roundedBorder) + .onSubmit(submitIfReady) + } + 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) + .onSubmit(submitIfReady) + } + } + } footer: { + CreationSubmitBar(title: AppText.string("creation.volume.create", defaultValue: "Create volume"), + systemImage: "externaldrive.badge.plus", + canSubmit: canSubmit, + working: working, + action: onSubmit) + } + } + + private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } + 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 ? 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] { + Core.Command.volumeCreatePreview(name: trimmedName.isEmpty ? "" : trimmedName, + size: trimmedSize.isEmpty ? nil : trimmedSize, + runtimeKind: runtimeKind) + } + + private func submitIfReady() { + guard canSubmit else { return } + onSubmit() + } +} + +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 + var onSelect: (ContainerFormState) -> Void + + var body: some View { + 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 { + 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: UI.Layout.Spacing.xs) { + ForEach(filteredLocalImages) { image in + CreationLocalImageRow(image: image) { + onSelect(RecommendedImage.spec(for: image.reference, + runtimeKind: image.runtimeKind)) + } + .accessibilityAddTraits(.isButton) + } + } + } + } + } + .task { await app.refreshImagesIfNeeded() } + } + + 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 } + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return images } + return images.filter { $0.reference.localizedCaseInsensitiveContains(trimmed) } + } +} + +struct CreationPastedComposeContent: View { + @Binding var text: String + var onImport: () -> Void + + var body: some View { + 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) + .designMonospacedCallout() + .scrollContentBackground(.hidden) + } + + HStack { + Spacer() + UI.Action.TextButton(title: AppText.string("common.import", defaultValue: "Import"), + systemName: "arrow.down.doc", + prominence: .prominent, + isEnabled: !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) { + onImport() + } + } + } + } +} + +struct CreationTemplatesContent: View { + let templates: [RecipeRecord] + var onSelect: (ContainerFormState) -> Void + + var body: some View { + ScrollView { + LazyVStack(spacing: UI.Layout.Spacing.s) { + ForEach(templates) { template in + CreationChoiceCard(symbol: "bookmark", + title: template.name, + subtitle: Format.shortImage(template.spec?.image ?? "—")) { + if let spec = template.spec { onSelect(spec) } + } + .accessibilityAddTraits(.isButton) + } + } + } + } +} + +private struct CreationSubmitBar: View { + let title: String + let systemImage: String + let canSubmit: Bool + let working: Bool + var action: () -> Void + + var body: some View { + HStack(spacing: UI.Layout.Spacing.s) { + Spacer() + if working { UI.State.ProgressIndicator() } + UI.Action.TextButton(title: title, + systemName: systemImage, + prominence: .prominent, + isEnabled: canSubmit && !working) { + action() + } + } + .padding(UI.Layout.Spacing.s) + .background(.clear) + } +} + +private struct CreationResourceForm: View { + let symbol: String + let title: String + let subtitle: String + let command: [String] + let runtimeKind: Core.Runtime.Kind + let runtimes: [Core.Runtime.Descriptor] + @ViewBuilder var fields: () -> Fields + @ViewBuilder var footer: () -> Footer + + private var commandText: String { + let executable = runtimes.first { $0.kind == runtimeKind }?.executableName ?? runtimeKind.rawValue + return ([executable] + command).joined(separator: " ") + } + + var body: some View { + LazyVStack(spacing: UI.Layout.Spacing.m) { + UI.Card.Scaffold(size: .small, + elevated: false, + title: title, + subtitle: subtitle) { + UI.Card.IconChip(symbol: symbol, tint: .accentColor) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + UI.Badge.Text(text: AppText.string("creation.badge.new", defaultValue: "new"), font: .caption2.weight(.semibold)) + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() + } + + fields() + + UI.Command.PreviewBar(commandText: commandText, + copyHelp: AppText.copyCommand, + copiedAccessibilityLabel: AppText.copied) + .frame(maxWidth: .infinity) + + footer() + } + } +} + +private struct CreationLocalImageRow: View { + let image: Core.Image.Resource + var onSelect: () -> Void + + var body: some View { + let runnable = image.variants.filter(\.isRunnable) + let size = runnable.compactMap(\.size).max() ?? image.variants.compactMap(\.size).max() + let arches = runnable.map(\.platform.architecture).joined(separator: ", ") + let subtitle = [size.map { Format.bytes(UInt64($0)) }, arches.isEmpty ? nil : arches] + .compactMap { $0 }.joined(separator: " · ") + + CreationChoiceCard(symbol: "square.stack.3d.up", + title: Format.shortImage(image.reference), + subtitle: subtitle, + action: onSelect) + } +} + +private struct CreationChoiceCard: View { + let symbol: String + let title: String + let subtitle: String? + var action: () -> Void + + var body: some View { + UI.Card.Scaffold(size: .small, + elevated: false, + onTap: action, + title: title, + subtitle: subtitle, + subtitleStyle: .monospaced) { + UI.Card.IconChip(symbol: symbol, tint: .accentColor) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + UI.List.RowChevron() + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() + } + .contentShape(Rectangle()) + } +} diff --git a/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift b/Sources/ContainedApp/Features/Containers/Customization/ContainerCustomizeButton.swift similarity index 72% rename from Sources/Contained/Features/Containers/ContainerCustomizeButton.swift rename to Sources/ContainedApp/Features/Containers/Customization/ContainerCustomizeButton.swift index 3c18149a..5e5ba53b 100644 --- a/Sources/Contained/Features/Containers/ContainerCustomizeButton.swift +++ b/Sources/ContainedApp/Features/Containers/Customization/ContainerCustomizeButton.swift @@ -1,11 +1,12 @@ import SwiftUI +import ContainedUI import ContainedCore /// Container identity chip that turns into the customization affordance on hover. The customize /// 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 @@ -13,12 +14,12 @@ struct ContainerCustomizeButton: View { var body: some View { Button(action: onTap) { - ResourceCardIconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, + UI.Card.IconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, tint: style.color) } .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/ContainerViewOptions.swift b/Sources/ContainedApp/Features/Containers/Customization/ContainerViewOptions.swift similarity index 65% rename from Sources/Contained/Features/Containers/ContainerViewOptions.swift rename to Sources/ContainedApp/Features/Containers/Customization/ContainerViewOptions.swift index 5df8e312..19d9e218 100644 --- a/Sources/Contained/Features/Containers/ContainerViewOptions.swift +++ b/Sources/ContainedApp/Features/Containers/Customization/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/Contained/Features/Containers/CustomizeSheet.swift b/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift similarity index 57% rename from Sources/Contained/Features/Containers/CustomizeSheet.swift rename to Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift index b5c4ad94..da78e5e3 100644 --- a/Sources/Contained/Features/Containers/CustomizeSheet.swift +++ b/Sources/ContainedApp/Features/Containers/Customization/CustomizeSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedUI import ContainedCore /// Edits local card personalization for containers, images, image groups, tags, and volumes. @@ -10,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?) @@ -18,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)" @@ -50,13 +51,17 @@ 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, _): - return .placeholder(id: Format.shortImage(reference), image: reference) + return .placeholder(id: Format.shortImage(reference), + image: reference, + runtimeKind: AppRuntimeIntent.placeholderKind) case .volume(let name): - return .placeholder(id: name, image: "") + return .placeholder(id: name, + image: "", + runtimeKind: AppRuntimeIntent.placeholderKind) } } } @@ -68,26 +73,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 + init(snapshot: Core.Container.Snapshot, presentation: Presentation = .popover) { + self.init(snapshot: snapshot, + presentation: presentation, + initialStyle: nil, + initiallyOverridesInheritedStyle: nil, + onDraftChange: nil) } - init(snapshot: ContainerSnapshot, + init(snapshot: Core.Container.Snapshot, 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 } @@ -100,7 +120,7 @@ struct CustomizeSheet: View { header Divider() ScrollView { - VStack(spacing: Tokens.Space.l) { + LazyVStack(spacing: UI.Layout.Spacing.l) { if target.supportsInheritance { inheritanceSection } editableSection { styleSection } if case .container = target { @@ -114,8 +134,8 @@ struct CustomizeSheet: View { editableSection { backgroundSection } actionsSection } - .padding(.horizontal, Tokens.Space.l) - .padding(.vertical, Tokens.Space.m) + .padding(.horizontal, UI.Layout.Spacing.l) + .padding(.vertical, UI.Layout.Spacing.m) } .scrollEdgeEffectStyle(.soft, for: .all) } @@ -126,76 +146,77 @@ struct CustomizeSheet: View { } private var header: some View { - PanelHeader(symbol: "paintbrush.pointed", + UI.Panel.Header(symbol: "paintbrush.pointed", title: headerTitle, subtitle: imageSubtitle) { - GlassButton { - GlassButtonItem(systemName: "checkmark", help: "Save") { save() } - GlassButtonItem(systemName: "xmark", help: "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: "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: "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: "Custom icon", isOn: $style.iconEnabled) + UI.Panel.ToggleRow(title: AppText.string("customize.customIcon", defaultValue: "Custom icon"), isOn: $style.iconEnabled) if style.iconEnabled { - PanelField(label: "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: "Icon", subtitle: "Using the default icon") + UI.Panel.Row(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.") { - TintSelector(selection: $style.tint) + 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.")) { + UI.Control.TintSelector(selection: $style.tint) { $0.localizedDisplayName } } } } private var statusSection: some View { - PanelSection(header: "Status") { - PanelToggleRow(title: "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: "Show icon", isOn: $style.showStatusIcon) - PanelToggleRow(title: "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: "Background") { - PanelToggleRow(title: "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: "Opacity") { - HStack(spacing: Tokens.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: Tokens.FormWidth.shortReadout) + .frame(width: UI.Form.Width.shortReadout) } } - PanelToggleRow(title: "Gradient", isOn: $style.gradient) + UI.Panel.ToggleRow(title: AppText.string("customize.gradient", defaultValue: "Gradient"), isOn: $style.gradient) if style.gradient { - GradientAngleControl(angle: $style.gradientAngle) + UI.Control.GradientAngle(angle: $style.gradientAngle, title: AppText.direction) } - PanelRow(title: "Blend mode") { + UI.Panel.Row(title: AppText.string("customize.blendMode", defaultValue: "Blend mode")) { Picker("", selection: $style.backgroundBlendMode) { - ForEach(ColorLayerBlendMode.allCases) { mode in - Text(mode.displayName).tag(mode) + ForEach(UI.Theme.ColorBlendMode.allCases) { mode in + Text(mode.localizedDisplayName).tag(mode) } } .labelsHidden() @@ -206,19 +227,23 @@ 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.") { + 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 + ? 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.") { + 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("Apply", systemImage: "square.stack.3d.up") + Label(AppText.string("customize.apply", defaultValue: "Apply"), systemImage: "square.stack.3d.up") } .disabled(settingsDisabled) } @@ -226,47 +251,47 @@ 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 { 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 { @@ -280,13 +305,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 } @@ -310,7 +335,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 } @@ -335,7 +360,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 @@ -378,9 +403,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) @@ -395,7 +420,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) } @@ -405,7 +430,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() } @@ -426,7 +451,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/Contained/Features/Containers/CustomizeWidgetsPanel.swift b/Sources/ContainedApp/Features/Containers/Customization/CustomizeWidgetsPanel.swift similarity index 62% rename from Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift rename to Sources/ContainedApp/Features/Containers/Customization/CustomizeWidgetsPanel.swift index 94816b63..cb22f3cd 100644 --- a/Sources/Contained/Features/Containers/CustomizeWidgetsPanel.swift +++ b/Sources/ContainedApp/Features/Containers/Customization/CustomizeWidgetsPanel.swift @@ -1,10 +1,12 @@ import SwiftUI +import ContainedUI +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. struct CustomizeWidgetsPanel: View { @Binding var style: Personalization - let graphOptions: [GraphMetric] + let graphOptions: [Core.Metrics.GraphMetric] let settingsDisabled: Bool private var activeWidgetIndices: [Int] { @@ -16,14 +18,17 @@ struct CustomizeWidgetsPanel: View { } var body: some View { - ForEach(activeWidgetIndices, id: \.self) { index in - widgetSection(index) + LazyVStack(spacing: UI.Layout.Spacing.l) { + ForEach(activeWidgetIndices, id: \.self) { index in + widgetSection(index) + } + addWidgetSection } - addWidgetSection + .frame(maxWidth: .infinity, alignment: .leading) } private func widgetSection(_ index: Int) -> some View { - PanelSection(header: widgetTitle(for: index)) { + UI.Panel.Section(header: widgetTitle(for: index)) { widgetOrderControls(index) Divider() widgetDisplayOptions(index) @@ -37,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.") { + UI.Panel.Section(header: AppText.string("customize.widgets", defaultValue: "Widgets"), + footer: AppText.string("customize.widgets.footer", defaultValue: "\(activeWidgetIndices.count) of \(Personalization.widgetSlotCount) widgets")) { + 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.")) { Button { addWidget() } label: { - Label("Add", systemImage: "plus") + Label(AppText.add, systemImage: "plus") } .disabled(!canAddWidget) } @@ -53,52 +60,54 @@ 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) { + 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: UI.Layout.Spacing.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") + UI.Panel.ToggleRow(title: AppText.string("customize.widget.showIcon", defaultValue: "Show icon"), isOn: widgetBinding(index, \.showIcon)) if style.widget(at: index).showIcon { - PanelField(label: "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: "Show text", isOn: widgetBinding(index, \.showText)) - PanelRow(title: "Color") { - TintSelector(optionalSelection: widgetBinding(index, \.tint), automaticLabel: "Card Color") + 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 } } } @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") + 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) @@ -109,7 +118,7 @@ struct CustomizeWidgetsPanel: View { } if widgetStyle(index).requiresSecondaryMetric { if let fallback = secondaryMetricFallback(for: index) { - PanelRow(title: "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) @@ -119,7 +128,8 @@ struct CustomizeWidgetsPanel: View { .fixedSize() } } else { - PanelRow(title: "Compare", subtitle: "This graph needs a second metric.") + 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.")) } } } @@ -127,40 +137,40 @@ 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") + UI.Panel.Row(title: AppText.string("customize.widget.type", defaultValue: "Type")) { Picker("", selection: widgetStyleBinding(index)) { - ForEach(GraphStyle.allCases) { Text($0.displayName).tag($0) } + ForEach(UI.Chart.GraphStyle.allCases) { Text($0.localizedDisplayName).tag($0) } } .labelsHidden() .fixedSize() } if chartStyle == .area { - PanelToggleRow(title: "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: "Interpolation") { + UI.Panel.Row(title: AppText.string("customize.widget.interpolation", defaultValue: "Interpolation")) { Picker("", selection: widgetBinding(index, \.interpolation)) { - ForEach(WidgetInterpolation.allCases) { Text($0.displayName).tag($0) } + ForEach(UI.Chart.Interpolation.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, @@ -173,34 +183,32 @@ struct CustomizeWidgetsPanel: View { range: ClosedRange, step: Double, readout: String) -> some View { - PanelRow(title: title) { - HStack(spacing: Tokens.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: Tokens.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 { 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 { + private func graphLabel(_ metric: Core.Metrics.GraphMetric) -> String { guard graphOptions.allSatisfy({ $0 == .diskRead || $0 == .diskWrite }) else { 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 } } @@ -231,16 +239,16 @@ 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 } - private func widgetStyle(_ index: Int) -> GraphStyle { + private func widgetStyle(_ index: Int) -> UI.Chart.GraphStyle { 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, @@ -258,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 @@ -273,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 @@ -286,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/Contained/Features/Containers/ContainerConfigureView.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift similarity index 56% rename from Sources/Contained/Features/Containers/ContainerConfigureView.swift rename to Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift index 786b8686..59e9bd8c 100644 --- a/Sources/Contained/Features/Containers/ContainerConfigureView.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerConfigureView.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedUX +import ContainedUI import SwiftData import ContainedCore @@ -22,41 +24,37 @@ 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 @State private var savingTemplate = false @State private var templateName = "" + @State private var page: ContainerFormPage = .basics init(mode: ContainerEditSheet.Mode, leading: Leading, onFinished: @escaping () -> Void) { self.mode = mode 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(runtimeKind: AppRuntimeIntent.placeholderKind)) + case .edit(let snapshot, _): _spec = State(initialValue: ContainerFormState(from: snapshot.configuration)) } } private var isEdit: Bool { if case .edit = mode { return true }; return false } var body: some View { - MorphPanelScaffold(width: Tokens.SheetSize.form.width) { + UI.Panel.Scaffold(width: UI.Panel.SheetSize.form.width, scrolls: false) { VStack(spacing: 0) { header Divider() validationSummary } } content: { - RunSpecForm(spec: $spec) - .padding(Tokens.Space.s) + ContainerSchemaForm(spec: $spec, page: page) } footer: { - if app.settings.revealCLI { - CommandPreviewBar(command: spec.arguments()) - .padding(Tokens.Space.s) - .frame(maxWidth: .infinity) - } + commandFooter } .onAppear(perform: load) .confirmationDialog("Replace \(spec.name.isEmpty ? editID : spec.name)?", @@ -75,42 +73,80 @@ 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: 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 } - 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 } - if isEdit { confirming = true } else { create() } - } - } - } - .opacity(!working && !spec.isRunnable ? 0.55 : 1) + subtitle: page.subtitle) { + HStack(spacing: UI.Layout.Spacing.s) { + UI.Action.Group(pageActions) + UI.Action.Group(utilityActions) } } } + private var pageActions: [UI.Action.Item] { + ContainerFormPage.allCases.map { item in + UI.Action.Item(systemName: item.systemImage, + help: item.title, + tint: page == item ? .accentColor : nil) { + page = item + } + } + } + + private var utilityActions: [UI.Action.Item] { + [ + UI.Action.Item(systemName: "bookmark", + help: AppText.saveAsTemplate, + isEnabled: spec.isRunnable && !working) { + templateName = spec.name.isEmpty ? Format.shortImage(spec.image) : spec.name + savingTemplate = true + }, + leadingAction, + ] + } + @ViewBuilder - private var leadingButton: some View { + private var commandFooter: some View { + if app.settings.revealCLI { + UI.Command.PreviewBar(commandText: app.previewCreateCommandText(for: spec), + copyHelp: AppText.copyCommand, + copiedAccessibilityLabel: AppText.copied) { + primaryCommandAction + } + .padding(UI.Layout.Spacing.s) + .frame(maxWidth: .infinity) + } else { + HStack { + Spacer() + primaryCommandAction + } + .padding(UI.Layout.Spacing.s) + .frame(maxWidth: .infinity) + } + } + + @ViewBuilder + private var primaryCommandAction: some View { + if working { + UI.Action.ProgressCapsule() + } else { + UI.Action.TextButton(title: isEdit ? AppText.string("common.save", defaultValue: "Save") : AppText.string("runSpec.run", defaultValue: "Run"), + systemName: isEdit ? "checkmark" : "play.fill", + help: isEdit ? AppText.string("runSpec.saveAndReplace", defaultValue: "Save and replace the container") : AppText.string("runSpec.runCommand", defaultValue: "Run this command"), + prominence: .prominent, + isEnabled: spec.isRunnable) { + if isEdit { confirming = true } else { create() } + } + .opacity(spec.isRunnable ? 1 : 0.55) + } + } + + private var leadingAction: UI.Action.Item { switch leading { case .cancel(let action): - GlassButtonItem(systemName: "xmark", help: "Cancel", isCancel: true, action: action) + return UI.Action.Item(systemName: "xmark", help: AppText.cancel, isCancel: true, action: action) case .back(let action): - GlassButtonItem(systemName: "chevron.left", help: "Back", action: action) + return UI.Action.Item(systemName: "chevron.left", help: AppText.back, action: action) } } @@ -119,25 +155,25 @@ struct ContainerConfigureView: View { let messages = spec.validationMessages let warnings = preflightWarnings if !messages.isEmpty || !warnings.isEmpty || runError != nil { - VStack(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. + 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, Tokens.Space.l) - .padding(.bottom, Tokens.Space.s) + .padding(.horizontal, UI.Layout.Spacing.l) + .padding(.bottom, UI.Layout.Spacing.s) } } @@ -153,7 +189,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. @@ -167,14 +203,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("Saved template “\(name)”") + do { + modelContext.insert(try RecipeRecord.make(name: name, spec: spec)) + 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 "" } @@ -183,11 +223,14 @@ struct ContainerConfigureView: View { loaded = true switch mode { case .new: - break // spec was prefilled at init + if spec.effectiveRuntimeKind == AppRuntimeIntent.placeholderKind { + spec.runtimeKind = app.preselectedRuntimeKind(current: spec.effectiveRuntimeKind, capability: .containers) + } 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.scopedID) ?? Core.Container.HealthCheck() + spec.applyLinkedVolumePaths(app.database.linkedVolumePaths(for: snapshot.scopedID)) } } @@ -211,7 +254,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/Contained/Features/Containers/ContainerEditSheet.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift similarity index 80% rename from Sources/Contained/Features/Containers/ContainerEditSheet.swift rename to Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift index a70ffc38..65295150 100644 --- a/Sources/Contained/Features/Containers/ContainerEditSheet.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerEditSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedUI import ContainedCore /// The container Create/Edit form presented as a modal sheet. The form body lives in the shared @@ -7,8 +8,8 @@ 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 edit(ContainerSnapshot, onComplete: () -> Void) + case new(prefill: ContainerFormState?) + case edit(Core.Container.Snapshot, onComplete: () -> Void) } @Environment(\.dismiss) private var dismiss @@ -16,7 +17,7 @@ struct ContainerEditSheet: View { var body: some View { ContainerConfigureView(mode: mode, leading: .cancel { dismiss() }, onFinished: { dismiss() }) - .frame(Tokens.SheetSize.form) + .frame(UI.Panel.SheetSize.form) .sheetMaterial() } } diff --git a/Sources/Contained/Features/Containers/ContainerOverviewTab.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerOverviewTab.swift similarity index 82% rename from Sources/Contained/Features/Containers/ContainerOverviewTab.swift rename to Sources/ContainedApp/Features/Containers/Details/ContainerOverviewTab.swift index b02771b3..cae5e2f5 100644 --- a/Sources/Contained/Features/Containers/ContainerOverviewTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerOverviewTab.swift @@ -1,15 +1,16 @@ import SwiftUI +import ContainedUI 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 { - VStack(alignment: .leading, spacing: Tokens.Space.m) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.m) { section("General") { row("Image", snapshot.image) row("Platform", config.platform.display) @@ -39,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) } } @@ -62,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/Contained/Features/Containers/ContainerTabScaffold.swift b/Sources/ContainedApp/Features/Containers/Details/ContainerTabScaffold.swift similarity index 64% rename from Sources/Contained/Features/Containers/ContainerTabScaffold.swift rename to Sources/ContainedApp/Features/Containers/Details/ContainerTabScaffold.swift index a7842083..4dd6a2ad 100644 --- a/Sources/Contained/Features/Containers/ContainerTabScaffold.swift +++ b/Sources/ContainedApp/Features/Containers/Details/ContainerTabScaffold.swift @@ -1,4 +1,5 @@ import SwiftUI +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. @@ -8,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(UI.Layout.Spacing.s) + .frame(maxWidth: .infinity, alignment: .leading) + } } .scrollEdgeEffectStyle(.soft, for: .all) } @@ -24,7 +27,7 @@ struct ContainerToolTabScaffold: View { var body: some View { VStack(spacing: 0) { chrome() - .padding(Tokens.Space.s) + .padding(UI.Layout.Spacing.s) .frame(maxWidth: .infinity, alignment: .leading) Divider() content() @@ -39,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) + UI.Card.InsetSection(title: title) { + content() } - .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift b/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift new file mode 100644 index 00000000..3ebfb60a --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Details/FilesTab.swift @@ -0,0 +1,178 @@ +import SwiftUI +import ContainedUI +import ContainedCore +import UniformTypeIdentifiers + +/// Browse a running container's filesystem (`exec ls -1ap`) and copy files in/out with the native +/// `container cp`. +struct FilesTab: View { + @Environment(AppModel.self) private var app + let snapshot: Core.Container.Snapshot + + @State private var path = "/" + @State private var entries: [String] = [] + @State private var loading = false + @State private var error: String? + @State private var copyingIn = false + @State private var movingCopiedFile = false + @State private var copiedFileURL: URL? + @State private var copiedFileName = "" + + var body: some View { + if snapshot.state != .running { + 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 + } content: { + listing + } + .task(id: path) { await load() } + .fileImporter(isPresented: $copyingIn, + allowedContentTypes: [.item, .folder]) { result in + handleCopyInSelection(result) + } + .fileMover(isPresented: $movingCopiedFile, + file: copiedFileURL) { result in + handleCopyOutMove(result) + } + } + } + + private var pathBar: some View { + HStack(spacing: UI.Layout.Spacing.s) { + UI.Action.Group(UI.Action.Item(systemName: "chevron.up", + help: AppText.parent, + isEnabled: path != "/") { goUp() }) + Text(path).designMonospacedCallout().lineLimit(1).truncationMode(.middle) + Spacer() + 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")) { + copyingIn = true + }) + UI.Action.Group(UI.Action.Item(systemName: "arrow.clockwise", help: AppText.refresh) { Task { await load() } }) + } + } + + @ViewBuilder + private var listing: some View { + if let 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: UI.Layout.Spacing.hairline) { + ForEach(entries, id: \.self) { entry in row(entry) } + } + .padding(UI.Layout.Spacing.s) + } + .scrollEdgeEffectStyle(.soft, for: .all) + } + } + + private func row(_ entry: String) -> some View { + let isDir = entry.hasSuffix("/") + let name = isDir ? String(entry.dropLast()) : entry + 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 { + UI.List.RowChevron() + } else { + Button { copyOut(name) } label: { + UI.Symbol.Image(systemName: "square.and.arrow.up") + } + .buttonStyle(.plain) + .help(AppText.string("files.copyToHost", defaultValue: "Copy to host")) + } + } + } + + // MARK: Actions + + 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 { + 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()) } + } catch let e as Core.Command.Error { error = e.appDisplayMessage } + catch { self.error = error.appDisplayMessage } + } + + private func goUp() { + let trimmed = path.hasSuffix("/") ? String(path.dropLast()) : path + guard let slash = trimmed.lastIndex(of: "/") else { path = "/"; return } + let parent = String(trimmed[.. String { + path.hasSuffix("/") ? path + name : path + "/" + name + } + + private func copyOut(_ name: String) { + Task { + do { + let stagedURL = try StagedFile.url(named: name) + _ = try await app.client?.copy(source: "\(snapshot.id):\(joined(name))", + destination: stagedURL.path, + runtimeKind: snapshot.runtimeKind) + copiedFileURL = stagedURL + copiedFileName = name + movingCopiedFile = true + } catch let e as Core.Command.Error { app.flash(e.appDisplayMessage) } + catch { app.flash(error.appDisplayMessage) } + } + } + + private func handleCopyInSelection(_ result: Result) { + switch result { + case .success(let src): + copyIn(src) + case .failure(let error): + app.flash(error.appDisplayMessage) + } + } + + private func copyIn(_ src: URL) { + Task { + do { + _ = try await app.client?.copy(source: src.path, + 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) } + catch { app.flash(error.appDisplayMessage) } + } + } + + private func handleCopyOutMove(_ result: Result) { + defer { + StagedFile.cleanup(copiedFileURL) + copiedFileURL = nil + copiedFileName = "" + } + switch result { + case .success: + app.flash(AppText.copiedFileToHost(copiedFileName)) + case .failure(let error): + app.flash(error.appDisplayMessage) + } + } +} diff --git a/Sources/Contained/Features/Containers/LogsTab.swift b/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift similarity index 54% rename from Sources/Contained/Features/Containers/LogsTab.swift rename to Sources/ContainedApp/Features/Containers/Details/LogsTab.swift index 04a04e78..da21671c 100644 --- a/Sources/Contained/Features/Containers/LogsTab.swift +++ b/Sources/ContainedApp/Features/Containers/Details/LogsTab.swift @@ -1,11 +1,12 @@ import SwiftUI +import ContainedUI import ContainedCore /// 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). 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 = "" @@ -24,65 +25,55 @@ 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 { - HStack(spacing: Tokens.Space.m) { - Toggle(isOn: $following) { Label("Follow", systemImage: "arrow.down.to.line") } - .toggleStyle(.button) - .buttonStyle(.glass) - .buttonBorderShape(.capsule) + HStack(spacing: UI.Layout.Spacing.m) { + UI.Action.ToggleButton(isOn: $following, title: AppText.follow, systemName: "arrow.down.to.line") if streaming { - HStack(spacing: 5) { - 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() - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "doc.on.doc", help: "Copy all") { - copyToPasteboard(lines.joined(separator: "\n")) - } - } - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "trash", role: .destructive, help: "Clear") { + Text(AppText.lineCount(lines.count)).designSecondaryCaption().monospacedDigit() + UI.Copy.Icon(value: lines.joined(separator: "\n"), help: AppText.copyAll) + UI.Action.Group(UI.Action.Item(systemName: "trash", + help: AppText.clear, + role: .destructive, + isEnabled: !lines.isEmpty) { lines.removeAll(); carry = "" - } - .disabled(lines.isEmpty) - } + }) } } @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: 1) { + 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: 1).id(bottomID) + Color.clear.frame(height: UI.Layout.Spacing.hairline).id(bottomID) } - .padding(Tokens.Space.s) + .padding(UI.Layout.Spacing.s) } .scrollEdgeEffectStyle(.soft, for: .all) .onChange(of: lines.count) { _, _ in @@ -94,11 +85,16 @@ 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 } 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. @@ -106,7 +102,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? Core.Command.Error)?.appDisplayMessage ?? error.appDisplayMessage } } diff --git a/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift b/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift new file mode 100644 index 00000000..dbf32517 --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Details/StatsTab.swift @@ -0,0 +1,109 @@ +import SwiftUI +import ContainedUI +import ContainedCore + +/// Live resource stats for one container. Reads the deltas the `RefreshCoordinator` already polls +/// 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: Core.Container.Snapshot + + @State private var processes: String = "" + + 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 } + private var tint: Color { + app.containerStyle(for: snapshot).color + } + + private let columns = [GridItem(.adaptive(minimum: 200), spacing: UI.Layout.Spacing.m)] + + var body: some View { + Group { + if snapshot.state != .running { + 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: 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") + UI.Chart.MetricTile(label: AppText.string("stats.processes", defaultValue: "Processes"), + value: "\(delta.numProcesses)", + systemImage: "gearshape.2", + tint: tint) + } + processList + } + } else { + UI.State.Loading(AppText.string("stats.collecting", defaultValue: "Collecting stats...")) + } + } + .task(id: snapshot.scopedID) { await refreshVisibleProcesses() } + } + + @ViewBuilder + private var processList: some View { + if !processes.isEmpty { + UI.Card.InsetSection { + Label(AppText.string("stats.processes", defaultValue: "Processes"), systemImage: "list.bullet.rectangle") + .designSectionLabelStyle() + Text(processes) + .designMonospacedCaption() + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + 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"], runtimeKind: snapshot.runtimeKind))? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + + private func refreshVisibleProcesses() async { + guard snapshot.state == .running else { processes = ""; return } + try? await Task.sleep(for: .milliseconds(140)) + guard !Task.isCancelled else { return } + await loadProcesses() + } + + 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, + tint: tint, + samples: history[metric]?.values, + sparklineScale: sparklineScale(for: metric)) + } + + 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: Core.Metrics.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: 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 new file mode 100644 index 00000000..a619777b --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Details/TerminalTab.swift @@ -0,0 +1,88 @@ +import SwiftUI +import ContainedUI +import ContainedCore + +/// An interactive shell inside a running container, via `container exec -it `. +/// +/// SwiftTerm hosting lives in `Services/Platform/TerminalSurface.swift`; this tab stays SwiftUI and +/// runtime state only. +struct TerminalTab: View { + @Environment(AppModel.self) private var app + 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? } + + @State private var shell = "/bin/sh" + @State private var session = 0 // bump to force a fresh terminal (reconnect / shell change) + @State private var ended: Ended? // nil = live; non-nil = process ended + + private let shells = ["/bin/sh", "/bin/bash", "/bin/ash", "/bin/zsh"] + + var body: some View { + if snapshot.state != .running { + 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, + runtimeKind: snapshot.runtimeKind) { + ContainerToolTabScaffold { + controls + } content: { + ZStack { + TerminalSurface(invocation: invocation) { code in + ended = Ended(code: code) + } + // 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.scopedID)-\(shell)-\(session)") + .terminalSurfaceChrome() + if let ended { + endedOverlay(code: ended.code) + } + } + } + } else { + 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: UI.Layout.Spacing.m) { + Picker("Shell", selection: $shell) { + ForEach(shells, id: \.self) { Text($0).tag($0) } + } + .labelsHidden() + .frame(width: UI.Form.Width.shellPicker) + .onChange(of: shell) { _, _ in reconnect() } + Text(AppText.string("terminal.execInto", defaultValue: "exec into \(snapshot.id)")) + .designSecondaryCaption() + .lineLimit(1) + Spacer() + UI.Action.Group(UI.Action.Item(systemName: "arrow.clockwise", help: AppText.reconnect) { reconnect() }) + } + } + + private func endedOverlay(code: Int32?) -> some View { + 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!))")) + .designHeadlineLabelStyle() + UI.Action.Group(UI.Action.Item(systemName: "arrow.clockwise", + title: AppText.reconnect, + help: AppText.reconnectTerminal, + action: reconnect)) + } + } + + private func reconnect() { + ended = nil + session += 1 + } +} diff --git a/Sources/ContainedApp/Features/Containers/Form/ContainerFormPage.swift b/Sources/ContainedApp/Features/Containers/Form/ContainerFormPage.swift new file mode 100644 index 00000000..a4daf2e8 --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Form/ContainerFormPage.swift @@ -0,0 +1,51 @@ +import Foundation + +enum ContainerFormPage: CaseIterable, Identifiable { + case basics + case network + case storage + case options + case advanced + + var id: Self { self } + + var title: String { + switch self { + case .basics: + AppText.string("runSpec.page.basics", defaultValue: "Basics") + case .network: + AppText.string("runSpec.page.network", defaultValue: "Network") + case .storage: + AppText.string("runSpec.page.storage", defaultValue: "Storage") + case .options: + AppText.string("runSpec.page.options", defaultValue: "Options") + case .advanced: + AppText.string("runSpec.page.advanced", defaultValue: "Advanced") + } + } + + var subtitle: String { + switch self { + case .basics: + AppText.string("runSpec.page.basics.subtitle", defaultValue: "Image, resources, and process basics") + case .network: + AppText.string("runSpec.page.network.subtitle", defaultValue: "Ports, networks, and sockets") + case .storage: + AppText.string("runSpec.page.storage.subtitle", defaultValue: "Host paths and runtime volumes") + case .options: + AppText.string("runSpec.page.options.subtitle", defaultValue: "Environment, health, and appearance") + case .advanced: + AppText.string("runSpec.page.advanced.subtitle", defaultValue: "Less-common runtime fields") + } + } + + var systemImage: String { + switch self { + case .basics: return "shippingbox" + case .network: return "network" + case .storage: return "externaldrive" + case .options: return "switch.2" + case .advanced: return "slider.horizontal.3" + } + } +} diff --git a/Sources/ContainedApp/Features/Containers/Form/ContainerFormState.swift b/Sources/ContainedApp/Features/Containers/Form/ContainerFormState.swift new file mode 100644 index 00000000..3b3da4c8 --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Form/ContainerFormState.swift @@ -0,0 +1,530 @@ +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() + var linkedVolumePaths: [VolumeLinkedPath] = [] + var storageGroups: [StorageGroup] = [] + + init(document: Core.Schema.Document, + healthCheck: Core.Container.HealthCheck? = nil) { + self.document = document + 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) + } + + private enum CodingKeys: String, CodingKey { + case document + case personalization + case healthCheck + case linkedVolumePaths + case storageGroups + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + document = try c.decode(Core.Schema.Document.self, forKey: .document) + personalization = try c.decodeIfPresent(Personalization.self, forKey: .personalization) ?? Personalization() + healthCheck = try c.decodeIfPresent(Core.Container.HealthCheck.self, forKey: .healthCheck) ?? Core.Container.HealthCheck() + linkedVolumePaths = try c.decodeIfPresent([VolumeLinkedPath].self, forKey: .linkedVolumePaths) ?? [] + storageGroups = try c.decodeIfPresent([StorageGroup].self, forKey: .storageGroups) ?? [] + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(document, forKey: .document) + try c.encode(personalization, forKey: .personalization) + try c.encode(healthCheck, forKey: .healthCheck) + try c.encode(linkedVolumePaths, forKey: .linkedVolumePaths) + try c.encode(storageGroups, forKey: .storageGroups) + } + + 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 ?? AppRuntimeIntent.placeholderKind + 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 storageGroupsForEditing: [StorageGroup] { + get { + storageGroups.isEmpty + ? StorageGroup.groups(from: volumes, linkedVolumePaths: linkedVolumePaths) + : storageGroups + } + set { + applyStorageGroups(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]) || !linkedVolumePaths.isEmpty || !storageGroups.isEmpty + } + + 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) + } +} + +extension ContainerFormState { + mutating func applyStorageGroups(_ groups: [StorageGroup]) { + storageGroups = groups + volumes = StorageGroup.volumeMounts(from: groups) + linkedVolumePaths = StorageGroup.linkedPaths(from: groups) + } + + mutating func applyLinkedVolumePaths(_ links: [VolumeLinkedPath]) { + linkedVolumePaths = links.map { link in + guard let volume = parentVolume(for: link) else { return link } + return link.attached(to: volume) + } + storageGroups = StorageGroup.groups(from: volumes, linkedVolumePaths: linkedVolumePaths) + } + + var linkedVolumePathsForPersistence: [VolumeLinkedPath] { + linkedVolumePaths.compactMap { link in + guard let volume = parentVolume(for: link) else { return nil } + return link.attached(to: volume) + } + } + + func materializedDocumentForRun() -> Core.Schema.Document { + var materialized = document + var materializedVolumes = volumes + for link in linkedVolumePaths where link.isValid { + guard let parent = parentVolume(for: link), + !link.resolvedLinkPath(in: parent).isEmpty else { continue } + appendUnique(link.hostMount(), to: &materializedVolumes) + } + materialized.set(.storageVolumes, .volumeList(materializedVolumes)) + return materialized + } + + func volumeLinkPlan() -> VolumeLinkPlan? { + var mountedVolumes: [Core.Container.VolumeMount] = [] + var links: [(linkPath: String, targetPath: String)] = [] + for link in linkedVolumePaths where link.isValid { + guard let parent = parentVolume(for: link) else { continue } + let resolvedLinkPath = link.resolvedLinkPath(in: parent) + guard !resolvedLinkPath.isEmpty else { continue } + var setupVolume = parent + setupVolume.readOnly = false + appendUnique(setupVolume, to: &mountedVolumes) + appendUnique(link.hostMount(), to: &mountedVolumes) + links.append((linkPath: resolvedLinkPath, targetPath: link.mountTarget)) + } + guard !links.isEmpty else { return nil } + return VolumeLinkPlan(runtimeKind: effectiveRuntimeKind, + image: image, + platform: platform, + os: imageOS, + architecture: imageArchitecture, + volumeMounts: mountedVolumes, + links: links) + } + + private func parentVolume(for link: VolumeLinkedPath) -> Core.Container.VolumeMount? { + volumes.first { $0.isValid && link.isAttached(to: $0) } + } + + private func appendUnique(_ volume: Core.Container.VolumeMount, + to volumes: inout [Core.Container.VolumeMount]) { + guard volume.isValid else { return } + guard !volumes.contains(where: { existing in + existing.source == volume.source && + existing.target == volume.target && + existing.readOnly == volume.readOnly + }) else { return } + volumes.append(volume) + } +} 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/ContainerSchemaForm.swift b/Sources/ContainedApp/Features/Containers/Form/ContainerSchemaForm.swift new file mode 100644 index 00000000..3831d4cd --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Form/ContainerSchemaForm.swift @@ -0,0 +1,1247 @@ +import SwiftUI +import ContainedUX +import ContainedUI +import UniformTypeIdentifiers +import ContainedCore + +/// The shared container Create/Edit form body: native grouped Form sections mapping the `run` +/// flags. Reused by `ContainerEditSheet` for both new and edit modes. Field guidance stays available +/// through tappable `info.circle` popovers that appear on row hover/focus. +struct ContainerSchemaForm: View { + @Environment(AppModel.self) private var app + @Environment(UIState.self) private var ui + @Binding var spec: ContainerFormState + let page: ContainerFormPage + + init(spec: Binding, page: ContainerFormPage) { + self._spec = spec + self.page = page + } + + var body: some View { + UI.Form.Grouped { + pageSections + } + .task(id: spec.normalizedImageReference) { + guard !spec.image.trimmingCharacters(in: .whitespaces).isEmpty else { return } + await app.refreshImagesIfNeeded() + } + } + + @ViewBuilder + private var pageSections: some View { + switch page { + case .basics: + Section { + generalSection + } header: { + formSectionHeader(AppText.string("runSpec.section.essentials", defaultValue: "Essentials"), highlighted: spec.hasGeneralOptions) + } + Section { + resourcesSection + } header: { + formSectionHeader(AppText.string("runSpec.section.resources", defaultValue: "Resources"), highlighted: spec.hasResourceOptions) + } + case .network: + Section { + portsSection + networkSection + socketsSection + } header: { + formSectionHeader(AppText.string("runSpec.section.networking", defaultValue: "Networking"), highlighted: spec.hasNetworkingOptions) + } + case .storage: + storageSections + case .options: + Section { + environmentSection + } header: { + formSectionHeader(AppText.string("runSpec.section.environment", defaultValue: "Environment"), highlighted: spec.hasEnvironmentOptions) + } + Section { + restartSection + healthSection + } header: { + formSectionHeader(AppText.string("runSpec.section.appManaged", defaultValue: "App Managed"), highlighted: spec.hasAppManagedOptions) + } + Section { + personalizationSection + } header: { + formSectionHeader(AppText.sectionSettingsAppearance, highlighted: spec.hasPersonalizationOptions) + } + case .advanced: + advancedOptionsSection + dockerComposeSection + } + } + + private func formSectionHeader(_ title: String, highlighted: Bool) -> some View { + HStack(spacing: UI.Layout.Spacing.xs) { + if highlighted { + Circle() + .fill(Color.blue) + .frame(width: 6, height: 6) + } + Text(title) + .foregroundStyle(highlighted ? Color.blue : Color.secondary) + } + } + + private func storageGroupHeader(index: Int, group: StorageGroup, onRemove: @escaping () -> Void) -> some View { + HStack(spacing: UI.Layout.Spacing.s) { + formSectionHeader(storageGroupTitle(index: index, group: group), highlighted: storageGroupHasValues(group)) + Spacer() + Button(role: .destructive, action: onRemove) { + Image(systemName: "minus.circle") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(AppText.string("runSpec.removeStorageGroup", defaultValue: "Remove storage group")) + } + } + + private func storageGroupTitle(index: Int, group: StorageGroup) -> String { + if group.usesRuntimeVolume { + let name = group.volumeName.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { return name } + } + return "\(AppText.string("runSpec.storageGroup", defaultValue: "Storage Group")) \(index + 1)" + } + + private func storageGroupFooter(_ group: StorageGroup) -> some View { + Text(group.usesRuntimeVolume + ? AppText.string("runSpec.storageGroup.runtimeVolume.footer", defaultValue: "Contained mounts this runtime volume, then links each host folder inside it before the container starts.") + : AppText.string("runSpec.storageGroup.bindMount.footer", defaultValue: "Each path is mounted directly from the host into the container.")) + .foregroundStyle(.secondary) + } + + private func storageGroupHasValues(_ group: StorageGroup) -> Bool { + group.usesRuntimeVolume || + !group.volumeName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + !group.volumeTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + group.paths.contains { path in + !path.hostPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + !path.internalPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + private func formRow(title: String, + path: Core.Field.Path? = nil, + subtitle: String? = nil, + info: String? = nil, + error: String? = nil, + isChanged: Bool? = nil, + @ViewBuilder trailing: @escaping () -> Trailing) -> some View { + UI.Form.Row(title: title, + subtitle: subtitle, + info: info, + error: error ?? path.flatMap(fieldError), + isChanged: isChanged ?? path.map(fieldChanged) ?? false, + trailing: trailing) + } + + private func formField(label: String, + path: Core.Field.Path? = nil, + info: String? = nil, + error: String? = nil, + isChanged: Bool? = nil, + @ViewBuilder control: @escaping () -> Control) -> some View { + UI.Form.Field(label: label, + info: info, + error: error ?? path.flatMap(fieldError), + isChanged: isChanged ?? path.map(fieldChanged) ?? false, + control: control) + } + + private func formToggleRow(title: String, + path: Core.Field.Path? = nil, + subtitle: String? = nil, + info: String? = nil, + error: String? = nil, + isChanged: Bool? = nil, + isOn: Binding) -> some View { + UI.Form.ToggleRow(title: title, + subtitle: subtitle, + info: info, + error: error ?? path.flatMap(fieldError), + isChanged: isChanged ?? path.map(fieldChanged) ?? false, + isOn: isOn) + } + + private func fieldChanged(_ path: Core.Field.Path) -> Bool { + guard let field = spec.definition.descriptor(for: path) else { return false } + let value = spec.document.value(path, in: spec.definition) ?? field.defaultValue + return value != field.defaultValue + } + + private func fieldError(_ path: Core.Field.Path) -> String? { + spec.validationIssues + .first { $0.field == path && $0.severity == .error }? + .localizedMessage() + } + + private var generalSection: some View { + Group { + formRow(title: AppText.runtime, + path: .runtimeKind, + subtitle: app.runtimePickerIsEnabled ? AppText.runtimeSubtitle : app.runtimePickerDisabledReason) { + Picker("", selection: runtimeKindBinding) { + ForEach(app.availableRuntimeDescriptors, id: \.kind) { descriptor in + Text(descriptor.displayName).tag(descriptor.kind) + } + } + .labelsHidden() + .fixedSize() + .disabled(!app.runtimePickerIsEnabled) + } + formField(label: AppText.string("runSpec.image", defaultValue: "Image"), + path: .imageReference, + info: fieldInfo(.imageReference)) { + TextField("", text: $spec.image, prompt: Text("e.g. nginx:latest")) + } + if imageDefaults != nil { + formRow(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("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() + } + } + } + formRow(title: AppText.string("runSpec.platform", defaultValue: "Platform"), + path: .imagePlatform, + info: fieldInfo(.imagePlatform)) { + Picker("", selection: platformPresetBinding) { + Text("Default").tag("") + Text("Linux arm64").tag("linux/arm64") + Text("Linux amd64").tag("linux/amd64") + Text("Linux amd64/v2").tag("linux/amd64/v2") + Text("Custom").tag("custom") + } + .labelsHidden().fixedSize() + } + if platformPresetBinding.wrappedValue == "custom" { + formField(label: AppText.string("runSpec.customPlatform", defaultValue: "Custom platform"), + path: .imagePlatform, + info: fieldInfo(.imagePlatform)) { + TextField("", text: $spec.platform, prompt: Text("os/arch[/variant]")) + } + } + formField(label: fieldLabel(.imageOS, fallback: "Image OS"), + path: .imageOS, + info: fieldInfo(.imageOS)) { + TextField("", text: $spec.imageOS, prompt: Text("linux")) + } + formField(label: fieldLabel(.imageArchitecture, fallback: "Image architecture"), + path: .imageArchitecture, + info: fieldInfo(.imageArchitecture)) { + TextField("", text: $spec.imageArchitecture, prompt: Text("arm64")) + } + formField(label: AppText.string("runSpec.name", defaultValue: "Name"), + path: .containerName, + info: fieldInfo(.containerName)) { + TextField("", text: $spec.name, prompt: Text("optional")) + } + formField(label: AppText.string("runSpec.command", defaultValue: "Command"), + path: .processCommand, + info: fieldInfo(.processCommand)) { + TextField("", text: $spec.command, prompt: Text("override the default command (optional)")) + } + formToggleRow(title: AppText.string("runSpec.detach", defaultValue: "Run in the background"), + path: .processDetach, + info: fieldInfo(.processDetach), + isOn: $spec.detach) + formToggleRow(title: AppText.string("runSpec.removeWhenStopped", defaultValue: "Remove when stopped"), + path: .processRemoveOnExit, + info: fieldInfo(.processRemoveOnExit), + isOn: $spec.removeOnExit) + } + } + + private var resourcesSection: some View { + Group { + formRow(title: AppText.string("runSpec.cpus", defaultValue: "CPUs"), + path: .resourcesCPULimit, + info: fieldInfo(.resourcesCPULimit)) { + Picker("", selection: cpuBinding) { + Text("Default").tag(0) + ForEach(1...max(1, hostCPUs), id: \.self) { Text("\($0)").tag($0) } + } + .labelsHidden().fixedSize() + } + formToggleRow(title: AppText.string("runSpec.limitMemory", defaultValue: "Limit memory"), + path: .resourcesMemoryLimit, + info: fieldInfo(.resourcesMemoryLimit), + isOn: memoryLimitBinding) + if !spec.memory.isEmpty { + formField(label: AppText.string("runSpec.memory", defaultValue: "Memory"), + path: .resourcesMemoryLimit) { + HStack(spacing: UI.Layout.Spacing.s) { + Slider(value: memoryGBBinding, in: 0.5...max(0.5, maxMemoryGB), step: 0.5) + Text(memoryReadout).monospacedDigit().frame(width: UI.Form.Width.memoryReadout) + } + } + } + } + } + + // MARK: Host-bounded resource controls + + private var hostCPUs: Int { ProcessInfo.processInfo.activeProcessorCount } + /// Whole gigabytes of physical RAM, rounded down — the slider's upper bound. + private var maxMemoryGB: Double { (Double(ProcessInfo.processInfo.physicalMemory) / 1_073_741_824).rounded(.down) } + + /// CPU picker selection; `0` means "Default" (no `--cpus`, runtime decides). + private var cpuBinding: Binding { + Binding(get: { Int(spec.cpus) ?? 0 }, set: { spec.cpus = $0 == 0 ? "" : String($0) }) + } + /// Memory-limit toggle: on writes a sensible default spec, off clears it. + private var memoryLimitBinding: Binding { + Binding(get: { !spec.memory.isEmpty }, + set: { spec.memory = $0 ? Self.memorySpec(gb: min(2, max(0.5, maxMemoryGB))) : "" }) + } + /// Memory slider value in GB, parsed from / written back to the `--memory` spec string. + private var memoryGBBinding: Binding { + Binding(get: { Self.parseMemoryGB(spec.memory) ?? 2 }, set: { spec.memory = Self.memorySpec(gb: $0) }) + } + private var memoryReadout: String { + memoryReadout(spec.memory, fallbackGB: 2) + } + private func memoryReadout(_ spec: String, fallbackGB: Double) -> String { + ContainerFormStateMemoryFormatter.readout(spec, fallbackGB: fallbackGB) + } + + private var platformPresetBinding: Binding { + let presets = Set(["", "linux/arm64", "linux/amd64", "linux/amd64/v2"]) + return Binding(get: { presets.contains(spec.platform) ? spec.platform : "custom" }, + set: { if $0 != "custom" { spec.platform = $0 } }) + } + + private var imageDefaults: Core.Container.ImageDefaults? { + app.imageDefaults(for: spec) + } + + private var runtimeKindBinding: Binding { + Binding(get: { spec.effectiveRuntimeKind }, + set: { spec.runtimeKind = $0 }) + } + + private func adoptImageDefaults() { + guard let imageDefaults else { return } + let applied = spec.adoptImageDefaults(from: imageDefaults) + if applied > 0 { + app.flash(AppText.adoptedImageDefaults(applied)) + } else { + app.flash(AppText.imageDefaultsAlreadyRepresented) + } + } + + static func parseMemoryGB(_ spec: String) -> Double? { + ContainerFormStateMemoryFormatter.parseGB(spec) + } + + static func memorySpec(gb: Double) -> String { + ContainerFormStateMemoryFormatter.spec(gb: gb) + } + + private var portsSection: some View { + Group { + ForEach(spec.ports) { port in + HStack { + TextField("Host", text: elementBinding($spec.ports, id: port.id, \.hostPort, fallback: "")) + + .frame(width: UI.Form.Width.port) + UI.Symbol.Image(systemName: "arrow.right") + TextField("Container", text: elementBinding($spec.ports, id: port.id, \.containerPort, fallback: "")) + + .frame(width: UI.Form.Width.containerPort) + Picker("", selection: elementBinding($spec.ports, id: port.id, \.proto, fallback: "tcp")) { Text("tcp").tag("tcp"); Text("udp").tag("udp") } + .labelsHidden().frame(width: UI.Form.Width.port) + Spacer() + removeButton { spec.ports.removeAll { $0.id == port.id } } + } + } + addButton(AppText.string("runSpec.addPort", defaultValue: "Add port"), info: fieldInfo(.networkPorts)) { + spec.ports.append(PortMap()) + } + } + } + + @ViewBuilder + private var storageSections: some View { + ForEach(Array(storageGroupsBinding.wrappedValue.enumerated()), id: \.element.id) { index, group in + Section { + StorageGroupEditor(runtimeKind: spec.effectiveRuntimeKind, + group: storageGroupBinding(id: group.id)) + } header: { + storageGroupHeader(index: index, group: group) { + var groups = storageGroupsBinding.wrappedValue + groups.removeAll { $0.id == group.id } + storageGroupsBinding.wrappedValue = groups + } + } footer: { + storageGroupFooter(group) + } + } + + Section { + addButton(AppText.string("runSpec.addStorageGroup", defaultValue: "Add storage group"), info: fieldInfo(.storageVolumes)) { + var groups = storageGroupsBinding.wrappedValue + groups.append(StorageGroup()) + storageGroupsBinding.wrappedValue = groups + } + } + } + + private var environmentSection: some View { + Group { + ForEach(spec.env) { variable in + HStack { + TextField("KEY", text: elementBinding($spec.env, id: variable.id, \.key, fallback: "")) + + UI.State.StatusText("=") + TextField("value", text: elementBinding($spec.env, id: variable.id, \.value, fallback: "")) + + removeButton { spec.env.removeAll { $0.id == variable.id } } + } + } + 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: fieldInfo(.environmentFiles)) + } + } + + private var socketsSection: some View { + Group { + ForEach(spec.sockets) { socket in + LazyVStack(spacing: UI.Layout.Spacing.xs) { + HStack { + TextField("Host socket path", text: elementBinding($spec.sockets, id: socket.id, \.hostPath, fallback: "")) + + removeButton { spec.sockets.removeAll { $0.id == socket.id } } + } + TextField("Container socket path", text: elementBinding($spec.sockets, id: socket.id, \.containerPath, fallback: "")) + + } + } + addButton(AppText.string("runSpec.addSocket", defaultValue: "Add socket"), info: fieldInfo(.networkSockets)) { + spec.sockets.append(SocketMap()) + } + } + } + + private var labelsSection: some View { + Group { + ForEach(spec.labels) { label in + HStack { + TextField("KEY", text: elementBinding($spec.labels, id: label.id, \.key, fallback: "")) + + UI.State.StatusText("=") + TextField("value", text: elementBinding($spec.labels, id: label.id, \.value, fallback: "")) + + removeButton { spec.labels.removeAll { $0.id == label.id } } + } + } + addButton(AppText.string("runSpec.addLabel", defaultValue: "Add label"), info: fieldInfo(.metadataLabels)) { + spec.labels.append(KeyValue()) + } + } + } + + private var personalizationSection: some View { + Group { + formField(label: AppText.string("runSpec.nickname", defaultValue: "Nickname"), + info: AppText.string("containerForm.personalization.nickname.info", defaultValue: "A display name for the card only. It does not rename the real container."), + isChanged: spec.personalization.nickname != Personalization().nickname) { + TextField("", text: $spec.personalization.nickname, prompt: Text("display name (optional)")) + } + formField(label: AppText.string("runSpec.icon", defaultValue: "Icon"), + info: AppText.string("containerForm.personalization.icon.info", defaultValue: "An SF Symbol name for the card icon, such as `shippingbox` or `bolt`."), + isChanged: spec.personalization.icon != Personalization().icon) { + TextField("", text: $spec.personalization.icon, prompt: Text("SF Symbol, e.g. globe, bolt")) + } + formRow(title: AppText.string("runSpec.color", defaultValue: "Color"), + info: AppText.string("containerForm.personalization.color.info", defaultValue: "Sets the card icon color. If background color is enabled, it also tints the glass card."), + isChanged: spec.personalization.tint != Personalization().tint) { + UI.Control.TintSelector(selection: $spec.personalization.tint) { $0.localizedDisplayName } + } + formToggleRow(title: AppText.string("runSpec.colorCardBackground", defaultValue: "Color the card background"), + 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."), + isChanged: spec.personalization.fillBackground != Personalization().fillBackground, + isOn: $spec.personalization.fillBackground) + if spec.personalization.fillBackground { + formField(label: AppText.string("runSpec.opacity", defaultValue: "Opacity"), + isChanged: spec.personalization.backgroundOpacity != Personalization.defaultBackgroundOpacity) { + 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: UI.Form.Width.shortReadout) + } + } + formToggleRow(title: AppText.string("runSpec.gradient", defaultValue: "Gradient"), + info: AppText.string("containerForm.personalization.gradient.info", defaultValue: "Blends the color across the card instead of using one flat wash."), + isChanged: spec.personalization.gradient != Personalization().gradient, + isOn: $spec.personalization.gradient) + if spec.personalization.gradient { + UI.Control.GradientAngle(angle: $spec.personalization.gradientAngle, title: AppText.direction) + } + formRow(title: AppText.string("runSpec.blendMode", defaultValue: "Blend mode"), + info: AppText.string("containerForm.personalization.blendMode.info", defaultValue: "Controls how the card color wash blends with the glass behind it."), + isChanged: spec.personalization.backgroundBlendMode != Personalization().backgroundBlendMode) { + Picker("", selection: $spec.personalization.backgroundBlendMode) { + ForEach(UI.Theme.ColorBlendMode.allCases) { mode in + Text(mode.localizedDisplayName).tag(mode) + } + } + .labelsHidden() + .fixedSize() + } + } + } + } + + private var restartSection: some View { + formRow(title: AppText.string("runSpec.restartPolicy", defaultValue: "Restart policy"), + path: .lifecycleRestartPolicy, + 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) } + } + .labelsHidden().fixedSize() + } + } + + private var healthSection: some View { + Group { + formToggleRow(title: AppText.string("runSpec.enableHealthcheck", defaultValue: "Enable healthcheck"), + info: AppText.string("containerForm.healthcheck.enabled.info", defaultValue: "Contained probes the container on an interval (app-managed; the runtime has no native healthcheck)."), + isChanged: spec.healthCheck.enabled != Core.Container.HealthCheck().enabled, + isOn: $spec.healthCheck.enabled) + if spec.healthCheck.enabled { + formField(label: AppText.string("runSpec.probeCommand", defaultValue: "Probe command"), + 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."), + isChanged: spec.healthCheck.command != Core.Container.HealthCheck().command) { + TextField("", text: healthCommandBinding, prompt: Text("curl -f http://localhost/ || exit 1")) + } + Stepper("Interval: \(spec.healthCheck.intervalSeconds)s", + value: $spec.healthCheck.intervalSeconds, in: 5...600, step: 5) + Stepper("Unhealthy after \(spec.healthCheck.retries) failures", + value: $spec.healthCheck.retries, in: 1...10) + } + } + } + + /// Bridges the probe string to/from a `sh -c ` argv so shell expressions work. + private var healthCommandBinding: Binding { + Binding(get: { + let cmd = spec.healthCheck.command + if cmd.count >= 3, cmd[0] == "sh", cmd[1] == "-c" { return cmd[2] } + return cmd.joined(separator: " ") + }, set: { newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespaces) + spec.healthCheck.command = trimmed.isEmpty ? [] : ["sh", "-c", trimmed] + }) + } + + @ViewBuilder + private var runtimeSection: some View { + Group { + formField(label: AppText.string("runSpec.entrypoint", defaultValue: "Entrypoint"), + path: .processEntrypoint, + info: fieldInfo(.processEntrypoint)) { + TextField("", text: $spec.entrypoint, prompt: Text("optional")) + } + formToggleRow(title: AppText.string("runSpec.keepStdinOpen", defaultValue: "Keep stdin open"), + path: .processInteractive, + info: fieldInfo(.processInteractive), isOn: $spec.interactive) + formToggleRow(title: AppText.string("runSpec.allocateTTY", defaultValue: "Allocate TTY"), + path: .processTTY, + info: fieldInfo(.processTTY), isOn: $spec.tty) + formField(label: AppText.string("runSpec.workingDirectory", defaultValue: "Working directory"), + path: .processWorkingDirectory, + info: fieldInfo(.processWorkingDirectory)) { + TextField("", text: $spec.workingDir, prompt: Text("optional, e.g. /app")) + } + formField(label: AppText.string("runSpec.user", defaultValue: "User"), + path: .processUser, + info: fieldInfo(.processUser)) { + TextField("", text: $spec.user, prompt: Text("name | uid[:gid]")) + } + formField(label: AppText.string("runSpec.userID", defaultValue: "User ID"), + info: "\(fieldInfo(.processUserID))\n\n\(fieldInfo(.processGroupID))", + error: fieldError(.processUserID) ?? fieldError(.processGroupID), + isChanged: fieldChanged(.processUserID) || fieldChanged(.processGroupID)) { + HStack { + TextField("UID", text: $spec.uid).frame(width: UI.Form.Width.userID) + TextField("GID", text: $spec.gid).frame(width: UI.Form.Width.userID) + Spacer() + } + } + formToggleRow(title: AppText.string("runSpec.setSharedMemorySize", defaultValue: "Set shared memory size"), + path: .resourcesSharedMemorySize, + info: fieldInfo(.resourcesSharedMemorySize), isOn: shmLimitBinding) + if !spec.shmSize.isEmpty { + formField(label: AppText.string("runSpec.sharedMemory", defaultValue: "Shared memory"), + path: .resourcesSharedMemorySize) { + 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: UI.Form.Width.memoryReadout) + } + } + } + + stringList(AppText.string("runSpec.addCapability", defaultValue: "Add capability"), $spec.capAdd, prompt: "CAP_NET_RAW or ALL", + info: fieldInfo(.securityCapabilitiesAdd)) + stringList(AppText.string("runSpec.dropCapability", defaultValue: "Drop capability"), $spec.capDrop, prompt: "CAP_NET_RAW or ALL", + info: fieldInfo(.securityCapabilitiesDrop)) + formField(label: AppText.string("runSpec.containerIDFile", defaultValue: "Container ID file"), + path: .outputContainerIDFile, + info: fieldInfo(.outputContainerIDFile)) { + TextField("", text: $spec.cidFile, prompt: Text("optional path")) + } + stringList(AppText.string("runSpec.addTmpfsMount", defaultValue: "Add tmpfs mount"), $spec.tmpfs, prompt: "/path", + info: fieldInfo(.storageTmpfs)) + stringList(AppText.string("runSpec.addUlimit", defaultValue: "Add ulimit"), $spec.ulimits, prompt: "nofile=1024:2048", + info: fieldInfo(.processUlimits)) + } + } + + @ViewBuilder + private var securitySection: some View { + Group { + formToggleRow(title: AppText.string("runSpec.readOnlyFilesystem", defaultValue: "Read-only filesystem"), + path: .securityReadOnlyRootFS, + info: fieldInfo(.securityReadOnlyRootFS), isOn: $spec.readOnly) + formToggleRow(title: AppText.string("runSpec.useInitProcess", defaultValue: "Use an init process"), + path: .securityUseInit, + info: fieldInfo(.securityUseInit), isOn: $spec.useInit) + formToggleRow(title: AppText.string("runSpec.rosetta", defaultValue: "Rosetta (x86 apps)"), + path: .securityRosetta, + info: fieldInfo(.securityRosetta), isOn: $spec.rosetta) + formToggleRow(title: AppText.string("runSpec.forwardSSHAgent", defaultValue: "Forward SSH agent"), + path: .securitySSHAgent, + info: fieldInfo(.securitySSHAgent), isOn: $spec.ssh) + formToggleRow(title: AppText.string("runSpec.exposeVirtualization", defaultValue: "Expose virtualization"), + path: .securityVirtualization, + info: fieldInfo(.securityVirtualization), isOn: $spec.virtualization) + } + } + + @ViewBuilder + private var networkSection: some View { + formRow(title: AppText.string("runSpec.network", defaultValue: "Network"), + path: .networkName, + info: fieldInfo(.networkName)) { + Menu(networkMenuTitle) { + Button { + spec.network = "" + } label: { + Label("Default", systemImage: spec.network.isEmpty ? "checkmark" : "network") + } + if !app.networks.isEmpty { Divider() } + ForEach(app.networks) { network in + Button { + spec.network = network.name + } label: { + Label(network.name, systemImage: spec.network == network.name ? "checkmark" : "network") + } + } + Divider() + Button { + ui.dispatch(.createNetwork) + } label: { + Label("Create New Network…", systemImage: "plus") + } + } + .fixedSize() + TextField("", text: $spec.network, prompt: Text("custom network")) + .frame(width: UI.Form.Width.networkName) + } + .task { await app.refreshNetworks() } + } + + @ViewBuilder + private var fetchSection: some View { + Group { + formField(label: AppText.string("runSpec.runtime", defaultValue: "Runtime"), + path: .runtimeHandler, + info: fieldInfo(.runtimeHandler)) { + TextField("", text: $spec.runtime, prompt: Text("optional")) + } + formField(label: AppText.string("runSpec.initImage", defaultValue: "Init image"), + path: .imageInitReference, + info: fieldInfo(.imageInitReference)) { + TextField("", text: $spec.initImage, prompt: Text("optional image")) + } + formField(label: AppText.string("runSpec.kernel", defaultValue: "Kernel"), + path: .kernelPath, + info: fieldInfo(.kernelPath)) { + TextField("", text: $spec.kernel, prompt: Text("optional path")) + } + formRow(title: AppText.string("runSpec.registryScheme", defaultValue: "Registry scheme"), + path: .registryScheme, + info: fieldInfo(.registryScheme)) { + Picker("", selection: $spec.scheme) { + Text("Default").tag("") + Text("Auto").tag("auto") + Text("HTTPS").tag("https") + Text("HTTP").tag("http") + } + .labelsHidden().fixedSize() + } + formRow(title: AppText.string("runSpec.progress", defaultValue: "Progress"), + path: .progressMode, + info: fieldInfo(.progressMode)) { + Picker("", selection: $spec.progress) { + Text("Default").tag("") + Text("Auto").tag("auto") + Text("None").tag("none") + Text("ANSI").tag("ansi") + Text("Plain").tag("plain") + Text("Color").tag("color") + } + .labelsHidden().fixedSize() + } + formToggleRow(title: AppText.string("runSpec.limitParallelDownloads", defaultValue: "Limit parallel downloads"), + path: .imageMaxConcurrentDownloads, + info: fieldInfo(.imageMaxConcurrentDownloads), isOn: maxDownloadsBinding) + if !spec.maxConcurrentDownloads.isEmpty { + Stepper("Max downloads: \(maxConcurrentDownloadsBinding.wrappedValue)", + value: maxConcurrentDownloadsBinding, in: 1...16) + } + } + } + + @ViewBuilder + private var dnsSection: some View { + Group { + formToggleRow(title: AppText.string("runSpec.disableDNS", defaultValue: "Disable DNS"), + path: .networkDNSDisabled, + 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: fieldInfo(.networkDNSServers)) + formField(label: AppText.string("runSpec.searchDomain", defaultValue: "Search domain"), + path: .networkDNSDomain, + info: fieldInfo(.networkDNSDomain)) { + TextField("", text: $spec.dnsDomain, prompt: Text("optional")) + } + stringList(AppText.string("runSpec.addSearchDomain", defaultValue: "Add search domain"), $spec.dnsSearch, prompt: "example.com", + info: fieldInfo(.networkDNSSearchDomains)) + stringList(AppText.string("runSpec.addDNSOption", defaultValue: "Add DNS option"), $spec.dnsOption, prompt: "ndots:2", + info: fieldInfo(.networkDNSOptions)) + } + } + } + + @ViewBuilder + private var advancedOptionsSection: some View { + Section { + runtimeSection + securitySection + fetchSection + dnsSection + stringList(AppText.string("runSpec.addMount", defaultValue: "Add mount"), $spec.mounts, prompt: "type=bind,source=/host,target=/container", + info: fieldInfo(.storageMounts)) + labelsSection + } header: { + formSectionHeader(AppText.string("runSpec.section.advancedOptions", defaultValue: "Advanced Options"), highlighted: spec.hasAdvancedOptions) + } footer: { + Text(.init(AppText.string("runSpec.section.advancedOptions.footer", defaultValue: "Less-common run settings. Compose import and Edit reveal these automatically when advanced values are present."))) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var dockerComposeSection: some View { + let fields = unsupportedFields + if !fields.isEmpty { + Section { + ForEach(fields) { field in + formField(label: fieldLabel(field), + path: field.path, + info: fieldInfo(field.path), + error: field.support(for: spec.effectiveRuntimeKind).localizedDisabledReason()) { + Text(valueDescription(for: field)) + .designSecondaryCallout() + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } header: { + formSectionHeader(AppText.string("runSpec.section.dockerCompose", defaultValue: "Docker & Compose"), highlighted: spec.hasUnsupportedRuntimeValues) + } footer: { + Text(.init(AppText.string("runSpec.section.dockerCompose.footer", defaultValue: "Known Docker CLI and Compose fields preserved for future runtimes. Apple container cannot execute these values."))) + .foregroundStyle(.secondary) + } + } + } + + 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" : "" }) + } + + private var shmGBBinding: Binding { + Binding(get: { Self.parseMemoryGB(spec.shmSize) ?? 0.0625 }, + set: { spec.shmSize = Self.memorySpec(gb: $0) }) + } + + private var maxDownloadsBinding: Binding { + Binding(get: { !spec.maxConcurrentDownloads.isEmpty }, + set: { spec.maxConcurrentDownloads = $0 ? "3" : "" }) + } + + private var maxConcurrentDownloadsBinding: Binding { + Binding(get: { max(1, Int(spec.maxConcurrentDownloads) ?? 3) }, + set: { spec.maxConcurrentDownloads = String($0) }) + } + + private var networkMenuTitle: String { + 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…). + @ViewBuilder + private func stringList(_ addTitle: String, _ list: Binding<[String]>, prompt: String, info: String) -> some View { + ForEach(list.wrappedValue.indices, id: \.self) { idx in + HStack { + TextField(prompt, text: stringListBinding(list, index: idx)) + + removeButton { list.wrappedValue.remove(at: idx) } + } + } + HStack(spacing: UI.Layout.Spacing.s) { + addButton(addTitle) { list.wrappedValue.append("") } + UI.Control.InfoButton(info) + Spacer() + } + } + + @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 elementBinding(_ list: Binding<[Element]>, + id: Element.ID, + _ keyPath: WritableKeyPath, + fallback: Value) -> Binding where Element.ID: Equatable { + Binding { + list.wrappedValue.first { $0.id == id }?[keyPath: keyPath] ?? fallback + } set: { newValue in + guard let index = list.wrappedValue.firstIndex(where: { $0.id == id }) else { return } + list.wrappedValue[index][keyPath: keyPath] = newValue + } + } + + private func stringListBinding(_ list: Binding<[String]>, index: Int) -> Binding { + Binding { + guard list.wrappedValue.indices.contains(index) else { return "" } + return list.wrappedValue[index] + } set: { newValue in + guard list.wrappedValue.indices.contains(index) else { return } + list.wrappedValue[index] = newValue + } + } + + private func removeButton(action: @escaping () -> Void) -> some View { + Button(role: .destructive, action: action) { + Image(systemName: "minus.circle") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(AppText.string("common.remove", defaultValue: "Remove")) + } + + private var storageGroupsBinding: Binding<[StorageGroup]> { + Binding { + spec.storageGroupsForEditing + } set: { groups in + spec.storageGroupsForEditing = groups + } + } + + private func storageGroupBinding(id: UUID) -> Binding { + Binding { + storageGroupsBinding.wrappedValue.first { $0.id == id } ?? StorageGroup(id: id) + } set: { updated in + var groups = storageGroupsBinding.wrappedValue + guard let index = groups.firstIndex(where: { $0.id == id }) else { return } + groups[index] = updated + storageGroupsBinding.wrappedValue = groups + } + } + + private func fieldLabel(_ field: Core.Schema.FieldDescriptor) -> String { + field.localizedLabel() + } + + 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?.localizedText() ?? field.localizedLabel() + 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: ", ") + } + } +} + +private enum StoragePathMode { + case bindMount + case volumeLink +} + +private struct StorageGroupEditor: View { + @Environment(AppModel.self) private var app + @Environment(UIState.self) private var ui + let runtimeKind: Core.Runtime.Kind + @Binding var group: StorageGroup + + var body: some View { + Group { + UI.Form.ToggleRow(title: AppText.string("runSpec.storageGroup.useRuntimeVolume", defaultValue: "Use runtime volume"), + info: AppText.string("containerForm.storageGroup.useRuntimeVolume.info", + defaultValue: "Mounts one runtime-owned volume, then lets Contained place host-folder links inside it before the container starts."), + isChanged: group.usesRuntimeVolume, + isOn: usesRuntimeVolumeBinding) + + if group.usesRuntimeVolume { + runtimeVolumeFields + } + + pathRows + } + .task(id: runtimeKind) { await app.refreshVolumes() } + } + + private var runtimeVolumeFields: some View { + Group { + UI.Form.Field(label: AppText.string("runSpec.storageGroup.runtimeVolume", defaultValue: "Runtime volume"), + error: group.volumeName.trimmedForVolumeLink.isEmpty ? AppText.string("runSpec.storageGroup.runtimeVolume.required", defaultValue: "Select or name a runtime volume.") : nil, + isChanged: !group.volumeName.trimmedForVolumeLink.isEmpty) { + HStack(spacing: UI.Layout.Spacing.s) { + Menu(runtimeVolumeMenuTitle) { + if runtimeVolumes.isEmpty { + Text(AppText.string("runSpec.noRuntimeVolumes", defaultValue: "No runtime volumes found")) + } else { + ForEach(runtimeVolumes) { volume in + Button { + group.volumeName = volume.name + } label: { + Label(volume.name, systemImage: group.volumeName == volume.name ? "checkmark" : "externaldrive") + } + } + } + Divider() + Button { + ui.dispatch(.createVolume) + } label: { + Label(AppText.string("runSpec.createNewVolume", defaultValue: "Create New Volume..."), + systemImage: "plus") + } + } + .fixedSize() + TextField("Volume name", text: $group.volumeName) + } + } + + UI.Form.Field(label: AppText.string("runSpec.storageGroup.volumeMountPath", defaultValue: "Mounted at"), + error: group.volumeTarget.trimmedForVolumeLink.isEmpty ? AppText.string("runSpec.storageGroup.volumeMountPath.required", defaultValue: "Choose where the runtime volume is mounted in the container.") : nil, + isChanged: !group.volumeTarget.trimmedForVolumeLink.isEmpty) { + TextField("Volume path", text: $group.volumeTarget, prompt: Text("/config")) + } + } + } + + private var pathRows: some View { + Group { + if group.usesRuntimeVolume { + Text(AppText.string("runSpec.storageGroup.volumePathsHelp", + defaultValue: "Each path mounts a host folder temporarily and links it inside the runtime volume.")) + .designSecondaryCaption() + .fixedSize(horizontal: false, vertical: true) + } + + ForEach(Array(group.paths.enumerated()), id: \.element.id) { index, path in + StoragePathRow(title: "\(AppText.string("runSpec.storagePath", defaultValue: "Path")) \(index + 1)", + mode: group.usesRuntimeVolume ? .volumeLink : .bindMount, + volumeTarget: group.volumeTarget, + path: storagePathBinding(id: path.id), + onRemove: { group.paths.removeAll { $0.id == path.id } }) + } + + UI.Action.TextButton(title: AppText.string("runSpec.addStoragePath", defaultValue: "Add Path"), + systemName: "plus.circle") { + group.paths.append(StoragePath()) + } + } + } + + private var usesRuntimeVolumeBinding: Binding { + Binding { + group.usesRuntimeVolume + } set: { enabled in + group.usesRuntimeVolume = enabled + if enabled { + if group.volumeName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + runtimeVolumes.count == 1, + let onlyVolume = runtimeVolumes.first { + group.volumeName = onlyVolume.name + } + if group.volumeTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + group.volumeTarget = "/config" + } + } + if group.paths.isEmpty { + group.paths.append(StoragePath()) + } + } + } + + private func storagePathBinding(id: UUID) -> Binding { + Binding { + group.paths.first { $0.id == id } ?? StoragePath(id: id) + } set: { updated in + guard let index = group.paths.firstIndex(where: { $0.id == id }) else { return } + group.paths[index] = updated + } + } + + private var runtimeVolumes: [Core.Volume.Resource] { + app.volumes + .filter { $0.runtimeKind == runtimeKind } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private var runtimeVolumeMenuTitle: String { + let trimmed = group.volumeName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty + ? AppText.string("runSpec.selectVolume", defaultValue: "Select Volume") + : trimmed + } + +} + +private struct StoragePathRow: View { + @Environment(AppModel.self) private var app + let title: String + let mode: StoragePathMode + let volumeTarget: String + @Binding var path: StoragePath + var onRemove: () -> Void + @State private var choosingHostPath = false + + var body: some View { + Group { + UI.Form.Row(title: title) { + removeButton + } + + UI.Form.Field(label: AppText.string("runSpec.storagePath.hostFolder", defaultValue: "Host folder"), + error: hostPathError, + isChanged: !path.hostPath.trimmedForVolumeLink.isEmpty) { + HStack(spacing: UI.Layout.Spacing.s) { + UI.Action.Group(UI.Action.Item(systemName: "folder", + title: AppText.string("runSpec.chooseFolder", defaultValue: "Choose Folder..."), + help: AppText.string("runSpec.chooseFolder", defaultValue: "Choose Folder...")) { + choosingHostPath = true + }) + TextField("Host folder", text: $path.hostPath) + } + } + + UI.Form.Field(label: internalPathLabel, + error: internalPathError, + isChanged: !path.internalPath.trimmedForVolumeLink.isEmpty) { + TextField(internalPathPrompt, text: $path.internalPath) + } + + UI.Form.Row(title: AppText.string("runSpec.mountAccess", defaultValue: "Access"), + subtitle: AppText.string("runSpec.storagePath.access.subtitle", defaultValue: "Controls whether the container can write to this host folder."), + isChanged: path.readOnly != true) { + Picker("", selection: accessBinding) { + Text(AppText.string("runSpec.access.readOnly", defaultValue: "Read only")).tag(true) + Text(AppText.string("runSpec.access.readWrite", defaultValue: "Read/Write")).tag(false) + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + } + + if mode == .volumeLink && pathIsValid && !resolvedVolumeLinkPath.isEmpty { + Text("\(resolvedVolumeLinkPath) → \(temporaryMountTarget)") + .designSecondaryMonospacedCaption() + .lineLimit(1) + .truncationMode(.middle) + } else if mode == .volumeLink && pathIsValid { + UI.State.InlineStatus(AppText.string("runSpec.linkedPaths.outsideVolume", + defaultValue: "Link paths must stay inside the selected runtime volume."), + systemImage: "exclamationmark.triangle", + tone: .warning) + } + } + .fileImporter(isPresented: $choosingHostPath, + allowedContentTypes: [.folder]) { result in + switch result { + case .success(let url): + path.hostPath = url.path + case .failure(let error): + app.flash(error.appDisplayMessage) + } + } + } + + private var accessBinding: Binding { + Binding { + path.readOnly + } set: { newValue in + path.readOnly = newValue + } + } + + private var hostPathError: String? { + if path.hostPath.trimmedForVolumeLink.isEmpty, + !path.internalPath.trimmedForVolumeLink.isEmpty { + return AppText.string("runSpec.storagePath.hostFolder.required", defaultValue: "Choose the host folder for this path.") + } + return nil + } + + private var internalPathError: String? { + if path.internalPath.trimmedForVolumeLink.isEmpty, + !path.hostPath.trimmedForVolumeLink.isEmpty { + return AppText.string("runSpec.storagePath.internalPath.required", defaultValue: "Choose where this path appears in the container.") + } + if mode == .volumeLink, + !path.internalPath.trimmedForVolumeLink.isEmpty, + resolvedVolumeLinkPath.isEmpty { + return AppText.string("runSpec.linkedPaths.outsideVolume", defaultValue: "Link paths must stay inside the selected runtime volume.") + } + return nil + } + + private var internalPathLabel: String { + switch mode { + case .bindMount: + AppText.string("runSpec.storagePath.internalPath", defaultValue: "Internal path") + case .volumeLink: + AppText.string("runSpec.storagePath.insideVolume", defaultValue: "Inside volume") + } + } + + private var internalPathPrompt: String { + switch mode { + case .bindMount: + "/media" + case .volumeLink: + volumeTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "media" : "\(volumeTarget)/media" + } + } + + private var pathIsValid: Bool { + !path.hostPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !path.internalPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var temporaryMountTarget: String { + "/run/contained-links/\(path.id.uuidString.lowercased())" + } + + private var resolvedVolumeLinkPath: String { + let trimmed = path.internalPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + let target = volumeTarget.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("/") { + return trimmed.isInsideStorageVolumeTarget(target) ? trimmed : "" + } + guard !target.isEmpty else { return "" } + return target.hasSuffix("/") ? target + trimmed : target + "/" + trimmed + } + + private var removeButton: some View { + UI.Action.Group(UI.Action.Item(systemName: "minus.circle.fill", + help: AppText.string("common.remove", defaultValue: "Remove"), + action: onRemove)) + } +} + +private extension String { + var trimmedForVolumeLink: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } + + func isInsideStorageVolumeTarget(_ target: String) -> Bool { + let trimmedTarget = target.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTarget.isEmpty else { return false } + if trimmedTarget == "/" { return hasPrefix("/") } + let prefix = trimmedTarget.hasSuffix("/") ? trimmedTarget : trimmedTarget + "/" + return self == trimmedTarget || hasPrefix(prefix) + } +} diff --git a/Sources/ContainedApp/Features/Containers/Form/VolumeLinkedPath.swift b/Sources/ContainedApp/Features/Containers/Form/VolumeLinkedPath.swift new file mode 100644 index 00000000..3add5e0d --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Form/VolumeLinkedPath.swift @@ -0,0 +1,291 @@ +import Foundation +import ContainedCore + +struct VolumeLinkedPath: Codable, Identifiable, Hashable { + var id = UUID() + var volumeID: UUID + var volumeSource: String + var volumeTarget: String + var hostPath: String + var linkPath: String + var readOnly: Bool + + private enum CodingKeys: String, CodingKey { + case id + case volumeID + case volumeSource + case volumeTarget + case hostPath + case linkPath + case readOnly + } + + init(id: UUID = UUID(), + volumeID: UUID, + volumeSource: String = "", + volumeTarget: String = "", + hostPath: String = "", + linkPath: String = "", + readOnly: Bool = true) { + self.id = id + self.volumeID = volumeID + self.volumeSource = volumeSource + self.volumeTarget = volumeTarget + self.hostPath = hostPath + self.linkPath = linkPath + self.readOnly = readOnly + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + volumeID = try c.decode(UUID.self, forKey: .volumeID) + volumeSource = try c.decodeIfPresent(String.self, forKey: .volumeSource) ?? "" + volumeTarget = try c.decodeIfPresent(String.self, forKey: .volumeTarget) ?? "" + hostPath = try c.decodeIfPresent(String.self, forKey: .hostPath) ?? "" + linkPath = try c.decodeIfPresent(String.self, forKey: .linkPath) ?? "" + readOnly = try c.decodeIfPresent(Bool.self, forKey: .readOnly) ?? true + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(volumeID, forKey: .volumeID) + try c.encode(volumeSource, forKey: .volumeSource) + try c.encode(volumeTarget, forKey: .volumeTarget) + try c.encode(hostPath, forKey: .hostPath) + try c.encode(linkPath, forKey: .linkPath) + try c.encode(readOnly, forKey: .readOnly) + } + + init(id: UUID = UUID(), + volume: Core.Container.VolumeMount, + hostPath: String = "", + linkPath: String = "", + readOnly: Bool = true) { + self.init(id: id, + volumeID: volume.id, + volumeSource: volume.source, + volumeTarget: volume.target, + hostPath: hostPath, + linkPath: linkPath, + readOnly: readOnly) + } + + var isValid: Bool { + !hostPath.trimmedForVolumeLink.isEmpty && !linkPath.trimmedForVolumeLink.isEmpty + } + + var mountTarget: String { + "/run/contained-links/\(id.uuidString.lowercased())" + } + + func resolvedLinkPath(in volume: Core.Container.VolumeMount) -> String { + let trimmed = linkPath.trimmedForVolumeLink + guard !trimmed.isEmpty else { return "" } + let volumeTarget = volume.target.trimmedForVolumeLink + if trimmed.hasPrefix("/") { + return trimmed.isInsideVolumeTarget(volumeTarget) ? trimmed : "" + } + return volumeTarget.appendingPathComponent(trimmed) + } + + func isAttached(to volume: Core.Container.VolumeMount) -> Bool { + if volume.id == volumeID { return true } + return !volumeSource.trimmedForVolumeLink.isEmpty && + volume.source.trimmedForVolumeLink == volumeSource.trimmedForVolumeLink && + volume.target.trimmedForVolumeLink == volumeTarget.trimmedForVolumeLink + } + + mutating func attach(to volume: Core.Container.VolumeMount) { + volumeID = volume.id + volumeSource = volume.source + volumeTarget = volume.target + } + + func attached(to volume: Core.Container.VolumeMount) -> VolumeLinkedPath { + var copy = self + copy.attach(to: volume) + return copy + } + + func hostMount() -> Core.Container.VolumeMount { + Core.Container.VolumeMount(source: hostPath.trimmedForVolumeLink, + target: mountTarget, + readOnly: readOnly) + } +} + +struct VolumeLinkPlan { + var runtimeKind: Core.Runtime.Kind + var image: String + var platform: String + var os: String + var architecture: String + var volumeMounts: [Core.Container.VolumeMount] + var links: [(linkPath: String, targetPath: String)] +} + +struct StorageGroup: Codable, Identifiable, Hashable { + var id = UUID() + var volumeID = UUID() + var usesRuntimeVolume: Bool + var volumeName: String + var volumeTarget: String + var paths: [StoragePath] + + init(id: UUID = UUID(), + volumeID: UUID = UUID(), + usesRuntimeVolume: Bool = false, + volumeName: String = "", + volumeTarget: String = "", + paths: [StoragePath] = [StoragePath()]) { + self.id = id + self.volumeID = volumeID + self.usesRuntimeVolume = usesRuntimeVolume + self.volumeName = volumeName + self.volumeTarget = volumeTarget + self.paths = paths + } + + init(volume: Core.Container.VolumeMount, links: [VolumeLinkedPath]) { + self.init(id: UUID(), + volumeID: volume.id, + usesRuntimeVolume: true, + volumeName: volume.source, + volumeTarget: volume.target, + paths: links.map(StoragePath.init(link:))) + } +} + +struct StoragePath: Codable, Identifiable, Hashable { + var id = UUID() + var hostPath: String + var internalPath: String + var readOnly: Bool + + init(id: UUID = UUID(), + hostPath: String = "", + internalPath: String = "", + readOnly: Bool = true) { + self.id = id + self.hostPath = hostPath + self.internalPath = internalPath + self.readOnly = readOnly + } + + init(volume: Core.Container.VolumeMount) { + self.init(id: volume.id, + hostPath: volume.source, + internalPath: volume.target, + readOnly: volume.readOnly) + } + + init(link: VolumeLinkedPath) { + self.init(id: link.id, + hostPath: link.hostPath, + internalPath: link.linkPath, + readOnly: link.readOnly) + } +} + +extension StorageGroup { + static func groups(from volumes: [Core.Container.VolumeMount], + linkedVolumePaths: [VolumeLinkedPath]) -> [StorageGroup] { + var groups: [StorageGroup] = [] + var plainPaths: [StoragePath] = [] + + for volume in volumes { + if volume.source.trimmedForVolumeLink.isEmpty || volume.source.isHostPathStorageSource { + plainPaths.append(StoragePath(volume: volume)) + } else { + let links = linkedVolumePaths.filter { $0.isAttached(to: volume) } + groups.append(StorageGroup(volume: volume, links: links)) + } + } + + if !plainPaths.isEmpty { + groups.insert(StorageGroup(usesRuntimeVolume: false, paths: plainPaths), at: 0) + } + return groups + } + + static func volumeMounts(from groups: [StorageGroup]) -> [Core.Container.VolumeMount] { + var result: [Core.Container.VolumeMount] = [] + for group in groups { + if group.usesRuntimeVolume { + var volume = Core.Container.VolumeMount(source: group.volumeName.trimmedForVolumeLink, + target: group.volumeTarget.trimmedForVolumeLink, + readOnly: false) + volume.id = group.volumeID + appendUnique(volume, to: &result) + } else { + for path in group.paths { + var volume = Core.Container.VolumeMount(source: path.hostPath.trimmedForVolumeLink, + target: path.internalPath.trimmedForVolumeLink, + readOnly: path.readOnly) + volume.id = path.id + appendUnique(volume, to: &result) + } + } + } + return result + } + + static func linkedPaths(from groups: [StorageGroup]) -> [VolumeLinkedPath] { + groups.flatMap { group -> [VolumeLinkedPath] in + guard group.usesRuntimeVolume else { return [] } + let volume = Core.Container.VolumeMount(source: group.volumeName.trimmedForVolumeLink, + target: group.volumeTarget.trimmedForVolumeLink, + readOnly: false) + return group.paths.map { path in + VolumeLinkedPath(id: path.id, + volumeID: group.volumeID, + volumeSource: volume.source, + volumeTarget: volume.target, + hostPath: path.hostPath, + linkPath: path.internalPath, + readOnly: path.readOnly) + } + } + } + + private static func appendUnique(_ volume: Core.Container.VolumeMount, + to volumes: inout [Core.Container.VolumeMount]) { + guard volume.isValid else { return } + guard !volumes.contains(where: { existing in + existing.source == volume.source && + existing.target == volume.target && + existing.readOnly == volume.readOnly + }) else { return } + volumes.append(volume) + } +} + +private extension String { + var trimmedForVolumeLink: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } + + var isHostPathStorageSource: Bool { + let trimmed = trimmedForVolumeLink + return trimmed.hasPrefix("/") || trimmed.hasPrefix("~") + } + + func appendingPathComponent(_ component: String) -> String { + let trimmedBase = trimmedForVolumeLink + let trimmedComponent = component.trimmedForVolumeLink + guard !trimmedBase.isEmpty else { return trimmedComponent } + guard !trimmedComponent.isEmpty else { return trimmedBase } + if trimmedBase.hasSuffix("/") { return trimmedBase + trimmedComponent } + return trimmedBase + "/" + trimmedComponent + } + + func isInsideVolumeTarget(_ target: String) -> Bool { + let trimmedTarget = target.trimmedForVolumeLink + guard !trimmedTarget.isEmpty else { return false } + if trimmedTarget == "/" { return hasPrefix("/") } + let prefix = trimmedTarget.hasSuffix("/") ? trimmedTarget : trimmedTarget + "/" + return self == trimmedTarget || hasPrefix(prefix) + } +} diff --git a/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift b/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift new file mode 100644 index 00000000..2ebe4bef --- /dev/null +++ b/Sources/ContainedApp/Features/Containers/Store/ContainersStore.swift @@ -0,0 +1,416 @@ +import SwiftUI +import ContainedUI +import OSLog +import ContainedCore + +/// 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 +@Observable +final class ContainerMetricsState { + let id: String + var stats: Core.Metrics.StatsDelta? + var historyByMetric: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer] + private(set) var revision = 0 + + 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: Core.Metrics.GraphMetric) -> [Double] { + historyByMetric[metric]?.values ?? [] + } + + func update(stats: Core.Metrics.StatsDelta?, historyByMetric: [Core.Metrics.GraphMetric: UI.Chart.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: [Core.Container.Snapshot] = [] + @ObservationIgnored + var statsByID: [String: Core.Metrics.StatsDelta] = [:] + /// Per-container, per-metric sparkline history. + @ObservationIgnored + var historyByID: [String: [Core.Metrics.GraphMetric: UI.Chart.SampleBuffer]] = [:] + @ObservationIgnored + private(set) var statsRevision = 0 + var errorMessage: String? + @ObservationIgnored private(set) var recreateFailure: Core.Container.RecreateFailure? + 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 + + var client: Core.Orchestrator? + + 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 = [] + + /// 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` runs when a container is busy starting up. + private var refreshTask: Task? + private var refreshRequested = false + private let diagnosticLogger = Logger(subsystem: "app.contained.Contained", category: "diagnostic") + + var running: [Core.Container.Snapshot] { 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: Core.Metrics.NormalizationContext) { + 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 + } + + /// 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() async { + refreshRequested = true + if refreshTask != nil { + logger?.record("Refresh already in flight; coalescing another pass", + category: .system, + severity: .debug) + diagnosticLogger.debug("Refresh already in flight; coalescing another pass") + } + await refreshTaskOrStart().value + } + + private func refreshTaskOrStart() -> Task { + if let refreshTask { return refreshTask } + let task = Task { @MainActor [weak self] in + _ = await self?.drainRefreshRequests() + } + refreshTask = task + return task + } + + private func drainRefreshRequests() async { + let started = Date() + var passes = 0 + repeat { + passes += 1 + refreshRequested = false + await performRefresh() + } while refreshRequested + refreshTask = nil + let elapsed = Date().timeIntervalSince(started) + if elapsed >= 0.75 || passes > 1 { + let suffix = passes == 1 ? "" : "es" + logger?.record("Refresh finished in \(elapsed.formatted(.number.precision(.fractionLength(2))))s across \(passes) pass\(passes == 1 ? "" : "es")", + category: .system, + severity: elapsed >= 1.5 ? .warning : .info) + diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, + "Refresh finished in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s across \(passes, privacy: .public) pass\(suffix, privacy: .public)") + } + } + + private func performRefresh() async { + guard let client else { return } + do { + let inventory = try await client.containerInventory(all: true) + let listedAll = inventory.items + .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(\.scopedID))) + errorMessage = partialInventoryMessage(inventory.failures) + pruneStatsForCurrentRunningSet() + } catch let error as Core.Command.Error { + errorMessage = error.appDisplayMessage + } catch { + errorMessage = error.appDisplayMessage + } + } + + private func pruneStatsForCurrentRunningSet() { + 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) } + 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 + return + } + } + + func applyStreamedStats(_ samples: [Core.Metrics.RuntimeStatsSnapshot], observedAt: Date? = nil) { + 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 = snapshotLookupByStatsID() + var nextStats = statsByID + var nextHistory = historyByID + for sample in samples { + let delta = Core.Metrics.StatsDelta.from(snapshot: sample, + previous: lastStreamedStats[sample.id], + interval: interval) + record(delta, snapshot: snapshotsByID[sample.id], stats: &nextStats, history: &nextHistory) + metricsStates[sample.id]?.update(stats: delta, historyByMetric: nextHistory[sample.id] ?? [:]) + lastStreamedStats[sample.id] = sample + } + + if nextStats != statsByID { statsByID = nextStats } + if nextHistory != historyByID { historyByID = nextHistory } + lastStreamedStatsDate = observedAt + statsRevision &+= 1 + } + + 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 Core.Metrics.GraphMetric.allCases { + var buffer = metrics[metric] ?? UI.Chart.SampleBuffer() + buffer.append(metric.value(from: delta, snapshot: snapshot, normalization: statsNormalizationContext)) + metrics[metric] = buffer + } + history[delta.id] = metrics + } + + private func rebuildDisplayHistories() { + 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] = [:] + for metric in Core.Metrics.GraphMetric.allCases { + var buffer = UI.Chart.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: [:]) + } + } + } + + private func partialInventoryMessage(_ failures: [Core.Runtime.InventoryFailure]) -> String? { + guard !failures.isEmpty else { return nil } + let runtimes = failures.map(\.kind.rawValue).sorted().joined(separator: ", ") + return AppText.string("runtime.inventory.partialFailure", + defaultValue: "Some runtimes could not refresh: \(runtimes).") + } + + 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 { + 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 { + 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 { + 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 { + 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 + /// (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: ContainerFormState) async -> String? { + guard let client else { return nil } + let started = Date() + logger?.record("Running container from creation flow", + category: .lifecycle, + severity: .info) + diagnosticLogger.notice("Run started from creation flow") + do { + let result = try await client.createContainer(spec.materializedDocumentForRun()) + await refresh() + let elapsed = Date().timeIntervalSince(started) + logger?.record("Run finished in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", + category: .lifecycle, + 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") + return result.id + } catch { + errorMessage = error.appDisplayMessage + let elapsed = Date().timeIntervalSince(started) + 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: .private(mask: .hash))") + return nil + } + } + + /// 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, + replacement: Core.Schema.Document, + rollback: Core.Schema.Document) async -> Bool { + guard let client else { return false } + recreateFailure = nil + 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 container", category: .lifecycle, containerID: trackingID) + diagnosticLogger.notice("Recreate started for \(runtimeID, privacy: .private(mask: .hash))") + do { + _ = try await client.recreateContainer(originalID: runtimeID, + replacement: replacement, + rollback: rollback) + await refresh() + let elapsed = Date().timeIntervalSince(started) + logger?.record("Recreated container in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", + category: .lifecycle, + severity: elapsed >= 1.5 ? .warning : .info, + containerID: trackingID) + diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, + "Recreated \(runtimeID, privacy: .private(mask: .hash)) in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s") + return true + } catch { + recreateFailure = error as? Core.Container.RecreateFailure + errorMessage = error.appDisplayMessage + let elapsed = Date().timeIntervalSince(started) + logger?.recordFailure("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s", + error: error, + category: .lifecycle, + severity: .warning, + containerID: trackingID) + diagnosticLogger.error("Recreate failed after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.appDisplayMessage, privacy: .private(mask: .hash))") + 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 { + guard let client else { return } + busyIDs.insert(id) + defer { busyIDs.remove(id) } + let started = Date() + logger?.record("\(verb) container", category: .lifecycle, containerID: id) + diagnosticLogger.notice("\(verb) started for \(id, privacy: .private(mask: .hash))") + do { + try await body(client) + await refresh() + let elapsed = Date().timeIntervalSince(started) + logger?.record("\(verb) finished in \(elapsed.formatted(.number.precision(.fractionLength(2))))s", + category: .lifecycle, + severity: elapsed >= 1.5 ? .warning : .info, + containerID: id) + diagnosticLogger.log(level: elapsed >= 1.5 ? .default : .info, + "\(verb) finished for \(id, privacy: .private(mask: .hash)) in \(elapsed.formatted(.number.precision(.fractionLength(2))), privacy: .public)s") + } catch { + errorMessage = error.appDisplayMessage + let elapsed = Date().timeIntervalSince(started) + 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: .private(mask: .hash)) after \(elapsed.formatted(.number.precision(.fractionLength(2))))s: \(error.appDisplayMessage, privacy: .private(mask: .hash))") + } + } +} diff --git a/Sources/Contained/Stores/RestartWatchdog.swift b/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift similarity index 74% rename from Sources/Contained/Stores/RestartWatchdog.swift rename to Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift index 6ca85ca7..e70795f5 100644 --- a/Sources/Contained/Stores/RestartWatchdog.swift +++ b/Sources/ContainedApp/Features/Containers/Store/RestartWatchdog.swift @@ -12,22 +12,24 @@ 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], store: ContainersStore, client: ContainerClient, + 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 + let id = snapshot.scopedID let current = snapshot.state defer { lastState[id] = current } @@ -39,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 @@ -52,13 +54,13 @@ 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)) } 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 new file mode 100644 index 00000000..bb669ee1 --- /dev/null +++ b/Sources/ContainedApp/Features/Images/Build/BuildWorkspaceView.swift @@ -0,0 +1,213 @@ +import SwiftUI +import ContainedUI +import UniformTypeIdentifiers +import ContainedCore + +/// Build an image from a Dockerfile + context, streaming the BuildKit log via +/// `container build --progress plain`. +struct BuildWorkspaceView: View { + @Environment(AppModel.self) private var app + + @State private var contextDir: URL? + @State private var dockerfile = "" + @State private var tag = "" + @State private var platform = "" + @State private var noCache = false + @State private var buildArgs: [KeyValue] = [] + @State private var runtimeKind = AppRuntimeIntent.placeholderKind + @State private var building = false + @State private var choosingContext = false + @State private var run = 0 // bump to restart the console + private var canBuild: Bool { contextDir != nil && !tag.trimmingCharacters(in: .whitespaces).isEmpty } + + var body: some View { + LazyVStack(spacing: 0) { + form + Divider() + if building, let context = contextDir, let client = app.client { + UI.Console.Stream(stream: { + client.streamBuild(context: context.path, + tag: tag.trimmingCharacters(in: .whitespaces), + dockerfile: dockerfile.isEmpty ? nil : dockerfile, + buildArgs: argsDict, noCache: noCache, + platform: platform.isEmpty ? nil : platform, + runtimeKind: runtimeKind) + }, + workingLabel: AppText.working, + completedLabel: AppText.completed, + lineCountLabel: AppText.lineCount, + copyLogHelp: AppText.copyLog, + failureLabel: AppErrorPresentation.message, + onComplete: { ok in if ok { Task { await app.refreshImagesIfNeeded(force: true) } } }) + .id(run) + .padding(UI.Layout.Spacing.s) + } else { + 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.")) + } + } + .onAppear(perform: normalizeRuntimeSelection) + .fileImporter(isPresented: $choosingContext, + allowedContentTypes: [.folder]) { result in + switch result { + case .success(let url): + contextDir = url + case .failure(let error): + app.flash(error.appDisplayMessage) + } + } + } + + private var form: some View { + ScrollView { + LazyVStack(spacing: UI.Layout.Spacing.l) { + sourceSection + optionsSection + commandSection + } + .padding(UI.Layout.Spacing.l) + } + .scrollEdgeEffectStyle(.soft, for: .all) + .frame(maxHeight: 420) + } + + 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 { + UI.State.StatusText(contextDir?.path ?? AppText.string("build.chooseFolderPlaceholder", defaultValue: "Choose a folder..."), + tone: contextDir == nil ? .neutral : .primary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + UI.Action.Group(UI.Action.Item(systemName: "folder", + title: AppText.choose, + help: AppText.chooseContextFolder, + action: chooseFolder)) + } + } + 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) + } + 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) + } + } + } + + private var optionsSection: some View { + 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) + } + 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 + UI.Panel.Field(label: AppText.string("build.arg", defaultValue: "Build arg")) { + HStack { + TextField("KEY", text: buildArgBinding(id: arg.id, \.key, fallback: "")) + .textFieldStyle(.roundedBorder) + UI.State.StatusText("=") + TextField("value", text: buildArgBinding(id: arg.id, \.value, fallback: "")) + .textFieldStyle(.roundedBorder) + UI.Action.Group(UI.Action.Item(systemName: "minus.circle.fill", + help: AppText.removeBuildArgument) { + buildArgs.removeAll { $0.id == arg.id } + }) + } + } + } + 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)")) { + 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()) + }) + } + } + } + + private var commandSection: some View { + UI.Panel.Section { + HStack(spacing: UI.Layout.Spacing.s) { + UI.Command.PreviewBar(commandText: previewCommandText, + copyHelp: AppText.copyCommand, + copiedAccessibilityLabel: AppText.copied) + .frame(maxWidth: .infinity) + if building { + UI.Action.Group(UI.Action.Item(systemName: "xmark", + title: AppText.cancel, + help: AppText.cancelBuild, + role: .destructive) { + building = false + }) + } else { + UI.Action.Group(UI.Action.Item(systemName: "hammer.fill", + title: AppText.string("build.build", defaultValue: "Build"), + help: AppText.buildImage, + isEnabled: canBuild, + action: startBuild)) + } + } + } + } + + private var argsDict: [String: String] { + Dictionary(buildArgs.filter(\.isValid).map { ($0.key, $0.value) }, uniquingKeysWith: { _, b in b }) + } + + private var previewCommand: [String] { + Core.Command.buildPreview(context: contextDir?.path ?? "", + tag: tag.isEmpty ? nil : tag, + dockerfile: dockerfile.isEmpty ? nil : dockerfile, + buildArgs: argsDict, noCache: noCache, + platform: platform.isEmpty ? nil : platform, + runtimeKind: runtimeKind) + } + + private var previewCommandText: String { + app.commandPreviewText(arguments: previewCommand, runtimeKind: runtimeKind) + } + + private func startBuild() { + run += 1 + building = true + } + + private func chooseFolder() { + choosingContext = true + } + + private var buildRuntimes: [Core.Runtime.Descriptor] { + app.runtimeDescriptors(supporting: .imageBuild) + } + + private func normalizeRuntimeSelection() { + runtimeKind = app.preselectedRuntimeKind(current: runtimeKind, capability: .imageBuild) + } + + private func buildArgBinding(id: KeyValue.ID, + _ keyPath: WritableKeyPath, + fallback: Value) -> Binding { + Binding { + buildArgs.first { $0.id == id }?[keyPath: keyPath] ?? fallback + } set: { newValue in + guard let index = buildArgs.firstIndex(where: { $0.id == id }) else { return } + buildArgs[index][keyPath: keyPath] = newValue + } + } +} diff --git a/Sources/Contained/Features/Images/RegistryImageSearch.swift b/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift similarity index 62% rename from Sources/Contained/Features/Images/RegistryImageSearch.swift rename to Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift index 05fb9d3b..9fe6ddf6 100644 --- a/Sources/Contained/Features/Images/RegistryImageSearch.swift +++ b/Sources/ContainedApp/Features/Images/Registry/RegistryImageSearch.swift @@ -1,9 +1,10 @@ import SwiftUI +import ContainedUI 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 @@ -11,11 +12,12 @@ 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 runtimeKind: Core.Runtime.Kind + var onSelect: (ContainerFormState) -> Void @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? @@ -23,7 +25,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: UI.Layout.Spacing.m) { searchField if trimmedQuery.isEmpty { idleSuggestions @@ -38,59 +40,50 @@ 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) - } - } - } - .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 { - VStack(alignment: .leading, spacing: Tokens.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, subtitle: Format.shortImage(item.spec.image)) { - onSelect(item.spec) + onSelect(spec(item.spec, runtimeKind: runtimeKind)) } } } suggestionSection("Popular") { ForEach(RecommendedImage.all) { image in quickPick(symbol: image.symbol, title: image.name, subtitle: image.reference) { - onSelect(RecommendedImage.spec(for: image.reference)) + onSelect(RecommendedImage.spec(for: image.reference, + runtimeKind: runtimeKind)) } } } } - .padding(.bottom, Tokens.Space.s) + .padding(.bottom, UI.Layout.Spacing.s) } } - private func suggestionSection(_ title: String, @ViewBuilder content: () -> C) -> some View { - VStack(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() } + 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) { - GlassListRowChevron() + UI.List.RowChevron() } .accessibilityAddTraits(.isButton) } @@ -100,26 +93,28 @@ struct RegistryImageSearch: View { @ViewBuilder private var resultsList: some View { if results.isEmpty { - VStack(spacing: Tokens.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) - Button { searchNow() } label: { Label("Retry", systemImage: "arrow.clockwise") } - .buttonStyle(.glass) - } else { - Image(systemName: "magnifyingglass").font(.title2).foregroundStyle(.tertiary) - Text("No images found for “\(trimmedQuery)”").font(.callout).foregroundStyle(.secondary) + 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() + } } + .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(Tokens.Space.xl) } else { ScrollView { - LazyVStack(spacing: Tokens.Space.xs) { + LazyVStack(spacing: UI.Layout.Spacing.xs) { ForEach(results) { result in resultRow(result) .accessibilityAddTraits(.isButton) @@ -129,19 +124,19 @@ 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, - action: { onSelect(RecommendedImage.spec(for: result.pullReference)) }) { - HStack(spacing: Tokens.Space.s) { + action: { onSelect(RecommendedImage.spec(for: result.pullReference, + runtimeKind: runtimeKind)) }) { + 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") } } } @@ -151,22 +146,29 @@ 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: { - VStack(alignment: .leading, spacing: 1) { - ResourceCardTitleText(text: title) - if let subtitle, !subtitle.isEmpty { - ResourceCardSubtitleText(text: subtitle) - } - } - } trailing: { + UI.Card.Scaffold(size: .small, + elevated: false, + onTap: action, + title: title, + subtitle: subtitle) { + UI.Card.IconChip(symbol: symbol, tint: .accentColor) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { accessory() + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } .contentShape(Rectangle()) } - } // MARK: Search plumbing @@ -197,9 +199,16 @@ struct RegistryImageSearch: View { query = trimmed } + private func spec(_ spec: ContainerFormState, + runtimeKind: Core.Runtime.Kind) -> ContainerFormState { + var spec = spec + spec.runtimeKind = runtimeKind + return spec + } + @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 @@ -209,13 +218,13 @@ 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 { guard !Task.isCancelled, searchQuery == query else { return } results = [] - errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + errorMessage = error.appDisplayMessage } } } @@ -240,8 +249,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) -> RunSpec { - var spec = RunSpec() + static func spec(for reference: String, + runtimeKind: Core.Runtime.Kind) -> ContainerFormState { + var spec = ContainerFormState(runtimeKind: runtimeKind) spec.image = reference return spec } diff --git a/Sources/ContainedApp/Features/Images/Style/CardStyleButton.swift b/Sources/ContainedApp/Features/Images/Style/CardStyleButton.swift new file mode 100644 index 00000000..b4e91d63 --- /dev/null +++ b/Sources/ContainedApp/Features/Images/Style/CardStyleButton.swift @@ -0,0 +1,48 @@ +import SwiftUI +import ContainedUI + +/// Identity chip that opens the customize popover for any `CustomizeSheet.Target`. +struct CardStyleButton: View { + @Environment(AppModel.self) private var app + + let style: Personalization + let target: CustomizeSheet.Target + var help = "Customize" + var accessibilityLabel: String? + + @State private var hovering = false + @State private var showingCustomize = false + + var body: some View { + Button { showingCustomize = true } label: { + UI.Card.IconChip(symbol: hovering ? "paintbrush.pointed.fill" : style.symbol, + tint: style.color) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help(help) + .accessibilityLabel(accessibilityLabel ?? help) + .popover(isPresented: $showingCustomize, arrowEdge: .trailing) { + 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.scopedID) + 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/ContainedApp/Features/Onboarding/BootstrapView.swift b/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift new file mode 100644 index 00000000..1d095362 --- /dev/null +++ b/Sources/ContainedApp/Features/Onboarding/BootstrapView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import ContainedUI +import UniformTypeIdentifiers +import ContainedCore + +/// First-run / degraded states: CLI missing, unsupported version, or service stopped — each with +/// the action that resolves it (start service, locate the CLI, continue anyway, try again). +struct BootstrapView: View { + @Environment(\.openURL) private var openURL + @Environment(AppModel.self) private var app + @State private var starting = false + @State private var locatingCLI = false + + var body: some View { + UI.State.Hero(systemImage: icon, title: title, message: message) { + actions + } + .fileImporter(isPresented: $locatingCLI, + allowedContentTypes: [.unixExecutable, .item]) { result in + switch result { + case .success(let url): + Task { await app.useCLIPath(url.path) } + case .failure(let error): + app.flash(error.appDisplayMessage) + } + } + .fileDialogDefaultDirectory(URL(fileURLWithPath: "/usr/local/bin")) + } + + @ViewBuilder + private var actions: some View { + switch app.bootstrap { + case .serviceStopped: + if app.serviceControlRuntimeAvailable { + 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) { + 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() } + } + case .unsupported: + 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: + UI.State.ProgressIndicator() + case .ready: + EmptyView() + } + } + + private func openReleases() { + if let url = URL(string: "https://github.com/apple/container/releases") { openURL(url) } + } + + private func locateCLI() { + locatingCLI = true + } + + private var icon: String { + switch app.bootstrap { + case .cliMissing: return "exclamationmark.triangle" + case .unsupported: return "exclamationmark.circle" + case .serviceStopped: return "powersleep" + default: return "cube" + } + } + private var title: String { + switch app.bootstrap { + case .cliMissing: return "Container CLI not found" + case .unsupported(let v): return "Unsupported version (\(v))" + case .serviceStopped: + return app.serviceControlRuntimeAvailable + ? "Container service is stopped" + : "Runtime endpoint is unavailable" + case .checking: return "Connecting…" + case .ready: return "Ready" + } + } + private var message: String { + switch app.bootstrap { + case .cliMissing: + return "Install Apple's container tool, or set its path in Settings. Looked in /usr/local/bin and /opt/homebrew/bin." + case .unsupported: + return "Contained targets container 1.0.x. Some features may not work with this version." + case .serviceStopped: + return app.serviceControlRuntimeAvailable + ? "Start the service to manage containers, images, and more." + : "Start the runtime provider externally, then retry the connection." + case .checking: + return "Talking to the container service." + case .ready: + return "" + } + } +} diff --git a/Sources/Contained/Features/Palette/CommandPalette.swift b/Sources/ContainedApp/Features/Palette/CommandPalette.swift similarity index 59% rename from Sources/Contained/Features/Palette/CommandPalette.swift rename to Sources/ContainedApp/Features/Palette/CommandPalette.swift index 4bceb457..8b925ca6 100644 --- a/Sources/Contained/Features/Palette/CommandPalette.swift +++ b/Sources/ContainedApp/Features/Palette/CommandPalette.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedUI import ContainedCore /// A palette search scope. Pins a chip to the search field and searches in-place instead of leaving @@ -9,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 } } @@ -23,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 } } } @@ -54,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) { @@ -64,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) { - ComposeImport.pickAndImport(app: app, ui: ui) + items.append(PaletteItem(title: AppText.paletteImportCompose, + subtitle: AppText.paletteCreateSubtitle, + kind: .create, + icon: "square.on.square", + tint: .accentColor) { + ui.dispatch(.importCompose) }) } // 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) @@ -87,65 +92,71 @@ 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 + ui.search.scope = .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 + ui.search.scope = .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() } 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, + 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) @@ -157,32 +168,40 @@ 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) { Task { - if await app.pullImageUpdate(snapshot.image) { + if await app.pullImageUpdate(snapshot.image, runtimeKind: snapshot.runtimeKind) { ui.openCreationPanel(editing: snapshot) } } }) if snapshot.state == .running { - items.append(PaletteItem(title: "Stop \(name)", subtitle: "container", kind: .container, visual: .container(snapshot), icon: "stop.fill", tint: .orange) { - Task { await app.containers.stop(snapshot.id) } + 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.scopedID) } }) - items.append(PaletteItem(title: "Restart \(name)", subtitle: "container", kind: .container, visual: .container(snapshot), icon: "arrow.clockwise", tint: .blue) { - Task { await app.containers.restart(snapshot.id) } + 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.scopedID) } }) } else { - items.append(PaletteItem(title: "Start \(name)", subtitle: "container", kind: .container, visual: .container(snapshot), icon: "play.fill", tint: .green) { - Task { await app.containers.start(snapshot.id) } + 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.scopedID) } }) } } @@ -207,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 }, @@ -233,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 }, @@ -243,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 }, @@ -253,10 +272,10 @@ struct PaletteItem: Identifiable { tint: .secondary) { app.settings.showInfoTips.toggle() }) - for tint in AppTint.allCases { - items.append(PaletteItem(title: "Set app tint to \(tint.displayName)", - subtitle: "appearance", - keywords: ["accent", "color", "theme", "tint", tint.rawValue] + tint.searchAliases, + 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, visual: .tint(tint), icon: "paintpalette", @@ -270,20 +289,23 @@ 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", - keywords: group.references, - kind: .image, - visual: .imageGroup(group), - icon: "play.fill", - tint: .green) { - ui.runImage(group.primaryReference) - }) - items.append(PaletteItem(title: "Check update for \(Format.shortImage(group.primaryReference))", - subtitle: "image", + 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, kind: .image, visual: .imageGroup(group), @@ -292,25 +314,32 @@ 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", - 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: "Run \(Format.shortImage(reference))", - subtitle: "image tag", - keywords: [group.primaryReference, 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, 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) }) } } @@ -321,14 +350,14 @@ 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), icon: "externaldrive", tint: .secondary) { - var spec = RunSpec() + var spec = ContainerFormState(runtimeKind: volume.runtimeKind) spec.volumes = [VolumeMap(source: volume.name, target: "/data")] ui.openCreationPanel(entry: .configure, prefill: spec) } @@ -339,14 +368,14 @@ 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), icon: "network", tint: .secondary) { - var spec = RunSpec() + var spec = ContainerFormState(runtimeKind: network.runtimeKind) spec.network = network.name ui.openCreationPanel(entry: .configure, prefill: spec) } @@ -390,13 +419,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 } } } @@ -409,16 +452,16 @@ 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 tint(AppTint) + case volume(Core.Volume.Resource) + case network(Core.Network.Resource) + case tint(UI.Theme.Tint) } 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/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/ContainedApp/Features/Registries/RegistryLoginSheet.swift b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift new file mode 100644 index 00000000..bcfb0382 --- /dev/null +++ b/Sources/ContainedApp/Features/Registries/RegistryLoginSheet.swift @@ -0,0 +1,100 @@ +import SwiftUI +import ContainedUI +import ContainedCore + +/// 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. +struct RegistryLoginSheet: View { + @Environment(AppModel.self) private var app + @Environment(\.dismiss) private var dismiss + @State private var server = "" + @State private var username = "" + @State private var password = "" + @State private var runtimeKind = AppRuntimeIntent.placeholderKind + @State private var busy = false + @State private var error: String? + + var body: some View { + VStack(spacing: 0) { + UI.Panel.SheetTitleBar(title: AppText.string("registry.login.title", defaultValue: "Registry login"), + cancelHelp: AppText.close, + onCancel: { dismiss() }) { + if busy { + UI.State.ProgressIndicator(frameSize: UI.Control.Size.control) + } else { + UI.Action.Group(UI.Action.Item(systemName: "checkmark", + help: AppText.logIn, + isEnabled: !server.trimmingCharacters(in: .whitespaces).isEmpty + && !username.trimmingCharacters(in: .whitespaces).isEmpty + && !password.isEmpty + && registryRuntimes.contains(where: { $0.kind == runtimeKind })) { + submit() + }) + } + } + 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")) + .textContentType(.URL) + .textFieldStyle(.roundedBorder) + } + UI.Panel.Field(label: AppText.string("registry.username", defaultValue: "Username")) { + TextField("", text: $username, prompt: Text("registry username")) + .textContentType(.username) + .textFieldStyle(.roundedBorder) + } + UI.Panel.Field(label: AppText.string("registry.password", defaultValue: "Password")) { + SecureField("", text: $password, prompt: Text("password or access token")) + .textFieldStyle(.roundedBorder) + } + } + if let error { + UI.Panel.Section { + UI.State.InlineStatus(error, + systemImage: "exclamationmark.triangle", + tone: .error) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(UI.Layout.Spacing.l) + } + .frame(UI.Panel.SheetSize.small) + .sheetMaterial() + .onAppear(perform: normalizeRuntimeSelection) + } + + private var registryRuntimes: [Core.Runtime.Descriptor] { + app.runtimeDescriptors(supporting: .registries) + } + + private func normalizeRuntimeSelection() { + runtimeKind = app.preselectedRuntimeKind(current: runtimeKind, capability: .registries) + } + + private func submit() { + guard let client = app.client else { return } + busy = true; error = nil + Task { + do { + _ = try await client.registryLogin(server: server.trimmingCharacters(in: .whitespaces), + username: username.trimmingCharacters(in: .whitespaces), + password: password, + runtimeKind: runtimeKind) + await app.refreshRegistries() + dismiss() + } 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/Runtime/RuntimeSelectionSheet.swift b/Sources/ContainedApp/Features/Runtime/RuntimeSelectionSheet.swift new file mode 100644 index 00000000..868a35ab --- /dev/null +++ b/Sources/ContainedApp/Features/Runtime/RuntimeSelectionSheet.swift @@ -0,0 +1,137 @@ +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 = AppRuntimeIntent.placeholderKind + + 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() { + runtimeKind = app.preselectedRuntimeKind(current: runtimeKind, capability: request.requiredCapability) + } + + 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/ReleaseNotesView.swift b/Sources/ContainedApp/Features/Settings/ReleaseNotesView.swift new file mode 100644 index 00000000..e8e6d431 --- /dev/null +++ b/Sources/ContainedApp/Features/Settings/ReleaseNotesView.swift @@ -0,0 +1,59 @@ +import SwiftUI +import WebKit +import ContainedUI + +struct ReleaseNotesView: View { + var title: String + var html: String + var onClose: (() -> Void)? + + var body: some View { + VStack(spacing: 0) { + UI.Panel.SheetTitleBar(title: title, cancelHelp: AppText.done) { + onClose?() + } + Divider() + HTMLView(html: html) + } + .frame(UI.Panel.SheetSize.releaseNotes) + } +} + +private struct HTMLView: View { + var html: String + @State private var page = WebPage() + + var body: some View { + WebView(page) + .task(id: html) { + page.load(html: document) + } + } + + private var document: String { + """ + + + + + + + \(html) + + """ + } +} diff --git a/Sources/ContainedApp/Features/Settings/SettingsForm.swift b/Sources/ContainedApp/Features/Settings/SettingsForm.swift new file mode 100644 index 00000000..437fc842 --- /dev/null +++ b/Sources/ContainedApp/Features/Settings/SettingsForm.swift @@ -0,0 +1,12 @@ +import SwiftUI +import ContainedUI + +struct SettingsForm: View { + @ViewBuilder var content: () -> Content + + var body: some View { + UI.Form.Grouped { + content() + } + } +} diff --git a/Sources/Contained/Features/Settings/SettingsView.swift b/Sources/ContainedApp/Features/Settings/SettingsView.swift similarity index 59% rename from Sources/Contained/Features/Settings/SettingsView.swift rename to Sources/ContainedApp/Features/Settings/SettingsView.swift index 525cc3ba..ddb34a0f 100644 --- a/Sources/Contained/Features/Settings/SettingsView.swift +++ b/Sources/ContainedApp/Features/Settings/SettingsView.swift @@ -1,13 +1,13 @@ import SwiftUI -import AppKit +import ContainedUX +import ContainedUI import ContainedCore -/// App preferences. Six sections, each built from the same `PanelSection` glass-card model so spacing, -/// headers, and explanatory footers stay consistent: Appearance (theme + glass), General (behavior, -/// data, CLI), Runtime, Registries, Updates, and About. +/// App preferences. Sections use native grouped forms inside the shared settings panel shell: +/// Appearance, General, Runtime, Registries, Updates, Experimental, and About. /// -/// Hosted in the toolbar Settings morph panel via the shared `MorphPanelScaffold`, 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 `UI.Panel.Scaffold`. +/// 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 @@ -25,6 +25,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" @@ -49,7 +61,7 @@ struct SettingsContent: View { var body: some View { @Bindable var settings = app.settings - MorphPanelScaffold(width: Tokens.PanelSize.settings.width, placement: .centered) { + UI.Panel.Scaffold(width: UI.Panel.Size.settings.width, scrolls: false) { if showsHeader { VStack(spacing: 0) { header @@ -58,8 +70,8 @@ struct SettingsContent: View { } } content: { sectionBody(settings: settings) - .padding(Tokens.Space.s) } + .morphPanelPlacement(.centered) .onAppear { consumeRequestedPage() } .onChange(of: ui.settingsPage) { _, requested in guard let requested else { return } @@ -74,24 +86,25 @@ struct SettingsContent: View { } private var header: some 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) - } + UI.Panel.Header(symbol: page.systemImage, + title: AppText.sectionSettings, + subtitle: page.title) { + UI.Action.Group(headerActions) + } + } + + private var headerActions: [UI.Action.Item] { + var actions = SettingsPage.allCases.map { item in + UI.Action.Item(systemName: item.systemImage, + help: item.title, + tint: page == item ? .accentColor : nil) { + page = item } } + if let onClose { + actions.append(UI.Action.Item(systemName: "xmark", help: AppText.close, isCancel: true, action: onClose)) + } + return actions } @ViewBuilder diff --git a/Sources/Contained/Stores/SettingsBackup.swift b/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift similarity index 77% rename from Sources/Contained/Stores/SettingsBackup.swift rename to Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift index 9e4f0293..169106cf 100644 --- a/Sources/Contained/Stores/SettingsBackup.swift +++ b/Sources/ContainedApp/Features/Settings/Store/SettingsBackup.swift @@ -1,25 +1,28 @@ import Foundation +import ContainedUI +import ContainedCore /// Portable snapshot of user preferences for config export/import. struct SettingsBackup: Codable, Equatable { - var accentTint: AppTint - 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: AppTint + 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 - var cliPathOverride: String + var runtimePathOverrides: [String: String] var refreshInterval: Double + var statsNormalizationMode: Core.Metrics.NormalizationMode var imageUpdateIntervalHours: Int var imageUpdateChecksEnabled: Bool var appUpdateChecksEnabled: Bool @@ -45,32 +48,34 @@ struct SettingsBackup: Codable, Equatable { case buttonTintEnabled, buttonTint, buttonTintOpacity, buttonTintGradient, buttonTintGradientAngle case buttonTintBlendMode case cardMaterial - case showInfoTips, imageDefaultStyleEnabled, keepInMenuBar, cliPathOverride, refreshInterval - case imageUpdateIntervalHours, imageUpdateChecksEnabled, appUpdateChecksEnabled, autoRestartEnabled + case showInfoTips, imageDefaultStyleEnabled, keepInMenuBar, runtimePathOverrides + case cliPathOverride, dockerCLIPathOverride, refreshInterval + case statsNormalizationMode, imageUpdateIntervalHours, imageUpdateChecksEnabled, appUpdateChecksEnabled, autoRestartEnabled case notifyOnCrash, revealCLI, historyRetentionDays, loggingLevel, enabledLogDestinations case enabledLogCategories, updateChannel, commandPaletteEnabled, hubSearchEnabled case composeImportEnabled, imageBuildEnabled, keyboardShortcutsEnabled, experimentalToolbarUI case experimentalPanelNavigation, sidebarNavigationEnabled } - init(accentTint: AppTint, - 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: AppTint = .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, - cliPathOverride: String, + runtimePathOverrides: [String: String] = [:], refreshInterval: Double, + statsNormalizationMode: Core.Metrics.NormalizationMode = .container, imageUpdateIntervalHours: Int, imageUpdateChecksEnabled: Bool, appUpdateChecksEnabled: Bool, @@ -106,8 +111,9 @@ struct SettingsBackup: Codable, Equatable { self.showInfoTips = showInfoTips self.imageDefaultStyleEnabled = imageDefaultStyleEnabled self.keepInMenuBar = keepInMenuBar - self.cliPathOverride = cliPathOverride + self.runtimePathOverrides = runtimePathOverrides self.refreshInterval = refreshInterval + self.statsNormalizationMode = statsNormalizationMode self.imageUpdateIntervalHours = imageUpdateIntervalHours self.imageUpdateChecksEnabled = imageUpdateChecksEnabled self.appUpdateChecksEnabled = appUpdateChecksEnabled @@ -131,26 +137,33 @@ 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) - 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(AppTint.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 - cliPathOverride = try container.decodeIfPresent(String.self, forKey: .cliPathOverride) ?? "" + runtimePathOverrides = try container.decodeIfPresent([String: String].self, forKey: .runtimePathOverrides) ?? [:] + if let applePath = try container.decodeIfPresent(String.self, forKey: .cliPathOverride), !applePath.isEmpty { + runtimePathOverrides[Core.Runtime.Kind.appleContainer.rawValue] = applePath + } + if let dockerPath = try container.decodeIfPresent(String.self, forKey: .dockerCLIPathOverride), !dockerPath.isEmpty { + runtimePathOverrides[Core.Runtime.Kind.docker.rawValue] = dockerPath + } 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 imageUpdateChecksEnabled = try container.decodeIfPresent(Bool.self, forKey: .imageUpdateChecksEnabled) ?? true appUpdateChecksEnabled = try container.decodeIfPresent(Bool.self, forKey: .appUpdateChecksEnabled) ?? true @@ -193,8 +206,9 @@ struct SettingsBackup: Codable, Equatable { try container.encode(showInfoTips, forKey: .showInfoTips) try container.encode(imageDefaultStyleEnabled, forKey: .imageDefaultStyleEnabled) try container.encode(keepInMenuBar, forKey: .keepInMenuBar) - try container.encode(cliPathOverride, forKey: .cliPathOverride) + try container.encode(runtimePathOverrides, forKey: .runtimePathOverrides) 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/ContainedApp/Features/Settings/Store/SettingsStore.swift similarity index 50% rename from Sources/Contained/Stores/SettingsStore.swift rename to Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift index 29ac8a79..2365f2c0 100644 --- a/Sources/Contained/Stores/SettingsStore.swift +++ b/Sources/ContainedApp/Features/Settings/Store/SettingsStore.swift @@ -1,58 +1,62 @@ import SwiftUI +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: AppTint { 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 { 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: 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: 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: 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: AppTint { 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. - var cardMaterial: WindowMaterial { didSet { defaults.set(cardMaterial.rawValue, forKey: Keys.cardMaterial) } } + 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 { 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 imageUpdateIntervalHours: Int { didSet { defaults.set(imageUpdateIntervalHours, forKey: Keys.imageUpdateIntervalHours) } } + var imageDefaultStyleEnabled: Bool { didSet { persist(imageDefaultStyleEnabled, for: Keys.imageDefaultStyleEnabled) } } + var keepInMenuBar: Bool { didSet { persist(keepInMenuBar, for: Keys.keepInMenuBar) } } + private var runtimePathOverrides: [Core.Runtime.Kind: String] + var refreshInterval: Double { didSet { persist(refreshInterval, for: Keys.refresh) } } + var statsNormalizationMode: Core.Metrics.NormalizationMode { + didSet { persist(statsNormalizationMode.rawValue, for: Keys.statsNormalizationMode) } + } + 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 @@ -61,24 +65,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. @@ -94,56 +98,62 @@ final class SettingsStore { } } - private let defaults: UserDefaults + private let database: AppDatabase - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - accentTint = AppTint(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 - 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 - 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 - 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 + for descriptor in Core.Runtime.supportedDescriptors { + _ = database.runtimeRecord(for: descriptor.kind) + } + 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) + runtimePathOverrides = Dictionary(uniqueKeysWithValues: Core.Runtime.supportedDescriptors.map { descriptor in + (descriptor.kind, database.runtimePathOverride(for: descriptor.kind)) + }) + 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 } @@ -164,8 +174,9 @@ final class SettingsStore { showInfoTips: showInfoTips, imageDefaultStyleEnabled: imageDefaultStyleEnabled, keepInMenuBar: keepInMenuBar, - cliPathOverride: cliPathOverride, + runtimePathOverrides: backupRuntimePathOverrides, refreshInterval: refreshInterval, + statsNormalizationMode: statsNormalizationMode, imageUpdateIntervalHours: imageUpdateIntervalHours, imageUpdateChecksEnabled: imageUpdateChecksEnabled, appUpdateChecksEnabled: appUpdateChecksEnabled, @@ -204,8 +215,11 @@ final class SettingsStore { showInfoTips = snapshot.showInfoTips imageDefaultStyleEnabled = snapshot.imageDefaultStyleEnabled keepInMenuBar = snapshot.keepInMenuBar - cliPathOverride = snapshot.cliPathOverride + for (rawKind, path) in snapshot.runtimePathOverrides { + setRuntimePathOverride(path, for: Core.Runtime.Kind(rawValue: rawKind)) + } refreshInterval = snapshot.refreshInterval + statsNormalizationMode = snapshot.statsNormalizationMode imageUpdateIntervalHours = snapshot.imageUpdateIntervalHours imageUpdateChecksEnabled = snapshot.imageUpdateChecksEnabled appUpdateChecksEnabled = snapshot.appUpdateChecksEnabled @@ -227,11 +241,28 @@ 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) + } + + func runtimePathOverride(for kind: Core.Runtime.Kind) -> String { + runtimePathOverrides[kind] ?? "" + } + + func setRuntimePathOverride(_ path: String, for kind: Core.Runtime.Kind) { + guard runtimePathOverrides[kind] != path else { return } + runtimePathOverrides[kind] = path + database.setRuntimePathOverride(path, for: kind) + } + + private var backupRuntimePathOverrides: [String: String] { + Dictionary(uniqueKeysWithValues: runtimePathOverrides.map { ($0.key.rawValue, $0.value) }) + } + + 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) }) } @@ -252,8 +283,8 @@ 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" static let imageUpdateChecksEnabled = "imageUpdateChecksEnabled" static let appUpdateChecksEnabled = "appUpdateChecksEnabled" diff --git a/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift new file mode 100644 index 00000000..e0c82feb --- /dev/null +++ b/Sources/ContainedApp/Features/Settings/Tabs/AboutTab.swift @@ -0,0 +1,49 @@ +import SwiftUI +import ContainedUI +import ContainedCore + +// MARK: - About + +struct AboutTab: View { + @Environment(AppModel.self) private var app + + var body: some View { + SettingsForm { + Section { + HStack(spacing: UI.Layout.Spacing.m) { + Image(systemName: "shippingbox.fill") + .resizable() + .symbolRenderingMode(.hierarchical) + .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.") + .designSecondaryCaption() + } + Spacer() + } + } + + Section(AppText.sectionSettingsRuntime) { + ForEach(app.availableRuntimeDescriptors, id: \.kind) { descriptor in + UI.Form.Row(title: descriptor.displayName) { + Text(app.runtimeVersion(for: descriptor.kind) ?? "—").designSecondaryValueStyle() + } + } + UI.Form.Row(title: AppText.string("settings.about.apiServer", defaultValue: "API server")) { Text(app.systemStatus?.apiServerVersion ?? "—").designSecondaryValueStyle() } + } + + Section { + UI.Form.Row(title: AppText.string("settings.about.copyright", defaultValue: "Copyright")) { Text("© 2026 Contained").designSecondaryValueStyle() } + } + } + } + + private var appVersion: String { + let info = Bundle.main.infoDictionary + let short = info?["CFBundleShortVersionString"] as? String ?? "1.0" + let build = info?["CFBundleVersion"] as? String ?? "1" + return "\(short) (\(build))" + } +} diff --git a/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift new file mode 100644 index 00000000..c0017a59 --- /dev/null +++ b/Sources/ContainedApp/Features/Settings/Tabs/AppearanceTab.swift @@ -0,0 +1,199 @@ +import SwiftUI +import ContainedUI +import ContainedCore + +// MARK: - Appearance + +struct AppearanceTab: View { + @Bindable var settings: SettingsStore + + var body: some View { + SettingsForm { + Section(AppText.string("settings.appearance.theme", defaultValue: "Theme")) { + UI.Form.Row(title: AppText.string("settings.appearance.appearance", defaultValue: "Appearance"), + isChanged: settings.appearance != .system) { + Picker("", selection: $settings.appearance) { + ForEach(UI.Theme.Appearance.allCases) { Text($0.localizedDisplayName).tag($0) } + } + .pickerStyle(.segmented).labelsHidden().fixedSize() + } + UI.Form.Row(title: AppText.string("settings.appearance.accentTint", defaultValue: "Accent tint"), + isChanged: settings.accentTint != .multicolor) { + UI.Control.TintSelector(selection: $settings.accentTint) { $0.localizedDisplayName } + } + } + + Section(AppText.string("settings.appearance.layout", defaultValue: "Layout")) { + UI.Form.Row(title: AppText.string("settings.appearance.cardSize", defaultValue: "Card size"), + isChanged: settings.density != UI.Card.Density(stored: "")) { + Picker("", selection: $settings.density) { + ForEach(UI.Card.Density.allCases) { Text($0.localizedDisplayName).tag($0) } + } + .pickerStyle(.segmented).labelsHidden().fixedSize() + } + UI.Form.ToggleRow(title: AppText.string("settings.appearance.showInfoTips", defaultValue: "Show info tips"), + isChanged: settings.showInfoTips != true, + isOn: $settings.showInfoTips) + } + + Section { + UI.Form.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."), + isChanged: settings.windowMaterial != .fullScreenUI) { + materialMenu($settings.windowMaterial) + } + UI.Form.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."), + isChanged: settings.modalMaterial != .sheet) { + materialMenu($settings.modalMaterial) + } + UI.Form.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."), + isChanged: settings.cardMaterial != .glassRegular) { + materialMenu($settings.cardMaterial) + } + UI.Form.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."), + isChanged: settings.buttonMaterial != .glassClear) { + materialMenu($settings.buttonMaterial) + } + } header: { + Text(AppText.string("settings.appearance.materials", defaultValue: "Materials")) + } footer: { + Text(AppText.string("settings.appearance.materials.footer", defaultValue: "Glass options use Liquid Glass. Other options use macOS vibrancy and follow the window background.")) + } + + Section { + UI.Form.ToggleRow(title: AppText.string("common.enabled", defaultValue: "Enabled"), + isChanged: settings.buttonTintEnabled != false, + isOn: $settings.buttonTintEnabled) + UI.Form.Row(title: AppText.tint, + isChanged: settings.buttonTint != .multicolor) { + UI.Control.TintSelector(selection: $settings.buttonTint) { $0.localizedDisplayName } + } + UI.Form.Row(title: AppText.string("settings.appearance.opacity", defaultValue: "Opacity"), + isChanged: settings.buttonTintOpacity != 0.18) { + HStack(spacing: UI.Layout.Spacing.s) { + Slider(value: $settings.buttonTintOpacity, in: 0.05...0.6) + .frame(width: UI.Form.Width.compactSlider) + Text(Format.percent(settings.buttonTintOpacity)) + .monospacedDigit() + .frame(width: UI.Form.Width.shortReadout) + } + } + UI.Form.ToggleRow(title: AppText.string("settings.appearance.gradient", defaultValue: "Gradient"), + isChanged: settings.buttonTintGradient != true, + isOn: $settings.buttonTintGradient) + if settings.buttonTintGradient { + UI.Control.GradientAngle(angle: $settings.buttonTintGradientAngle, title: AppText.direction) + } + UI.Form.Row(title: AppText.string("settings.appearance.blendMode", defaultValue: "Blend mode"), + isChanged: settings.buttonTintBlendMode != .softLight) { + Picker("", selection: $settings.buttonTintBlendMode) { + ForEach(UI.Theme.ColorBlendMode.allCases) { mode in + Text(mode.localizedDisplayName).tag(mode) + } + } + .labelsHidden() + .fixedSize() + } + } header: { + Text(AppText.string("settings.appearance.buttonTint", defaultValue: "Button tint")) + } footer: { + Text(AppText.string("settings.appearance.buttonTint.footer", defaultValue: "Button tint uses the same color layer model as card backgrounds, applied inside toolbar glass controls.")) + } + + ImageDefaultStyleSection(settings: settings) + } + } + + private func materialMenu(_ binding: Binding) -> some View { + Picker("", selection: binding) { + ForEach(UI.Theme.WindowMaterial.allCases) { Text($0.localizedDisplayName).tag($0) } + } + .labelsHidden().fixedSize() + } +} + +private struct ImageDefaultStyleSection: View { + @Environment(AppModel.self) private var app + @Bindable var settings: SettingsStore + + private var style: Personalization { app.personalization.defaultImageStyle } + + var body: some View { + Section { + UI.Form.ToggleRow(title: AppText.string("common.enabled", defaultValue: "Enabled"), + isChanged: settings.imageDefaultStyleEnabled != true, + isOn: $settings.imageDefaultStyleEnabled) + 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")) + .designSecondaryCaption() + } + Spacer() + } + UI.Form.Row(title: AppText.string("settings.appearance.color", defaultValue: "Color"), + isChanged: style.tint != Personalization().tint) { + UI.Control.TintSelector(selection: styleBinding(\.tint)) { $0.localizedDisplayName } + } + UI.Form.ToggleRow(title: AppText.string("settings.appearance.customIcon", defaultValue: "Custom icon"), + isChanged: style.iconEnabled != Personalization().iconEnabled, + isOn: styleBinding(\.iconEnabled)) + if style.iconEnabled { + UI.Form.Field(label: AppText.string("settings.appearance.icon", defaultValue: "Icon"), + isChanged: style.icon != Personalization().icon) { + TextField("", text: styleBinding(\.icon), prompt: Text("SF Symbol, e.g. shippingbox.fill")) + .frame(width: UI.Form.Width.tintColorHex) + } + } + UI.Form.ToggleRow(title: AppText.string("settings.appearance.colorCardBackground", defaultValue: "Color the card background"), + isChanged: style.fillBackground != Personalization().fillBackground, + isOn: styleBinding(\.fillBackground)) + if style.fillBackground { + UI.Form.Row(title: AppText.string("settings.appearance.opacity", defaultValue: "Opacity"), + isChanged: style.backgroundOpacity != Personalization.defaultBackgroundOpacity) { + HStack(spacing: UI.Layout.Spacing.s) { + Slider(value: styleBinding(\.backgroundOpacity), in: 0.05...0.6) + .frame(width: UI.Form.Width.compactSlider) + Text(Format.percent(style.backgroundOpacity)) + .monospacedDigit() + .frame(width: UI.Form.Width.shortReadout) + } + } + UI.Form.ToggleRow(title: AppText.string("settings.appearance.gradient", defaultValue: "Gradient"), + isChanged: style.gradient != Personalization().gradient, + isOn: styleBinding(\.gradient)) + if style.gradient { + UI.Control.GradientAngle(angle: styleBinding(\.gradientAngle), title: AppText.direction) + } + UI.Form.Row(title: AppText.string("settings.appearance.blendMode", defaultValue: "Blend mode"), + isChanged: style.backgroundBlendMode != Personalization().backgroundBlendMode) { + Picker("", selection: styleBinding(\.backgroundBlendMode)) { + ForEach(UI.Theme.ColorBlendMode.allCases) { mode in + Text(mode.localizedDisplayName).tag(mode) + } + } + .labelsHidden() + .fixedSize() + } + } + } header: { + Text(AppText.string("settings.appearance.defaultImageCardStyle", defaultValue: "Default image card style")) + } footer: { + Text(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.")) + } + } + + private func styleBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding { + app.personalization.defaultImageStyle[keyPath: keyPath] + } set: { newValue in + var updated = app.personalization.defaultImageStyle + updated[keyPath: keyPath] = newValue + app.personalization.setDefaultImageStyle(updated) + } + } +} diff --git a/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift new file mode 100644 index 00000000..28d07839 --- /dev/null +++ b/Sources/ContainedApp/Features/Settings/Tabs/ExperimentalTab.swift @@ -0,0 +1,56 @@ +import SwiftUI +import ContainedUI +import ContainedCore + +// MARK: - Experimental + +/// Opt-in gates for features that aren't fully baked yet. Everything here defaults **off** so a fresh +/// install ships the stable core; flipping a switch reveals the corresponding surface app-wide (menu +/// commands, toolbar affordances, creation options). See `SettingsStore`'s "Experimental features". +struct ExperimentalTab: View { + @Bindable var settings: SettingsStore + + var body: some View { + SettingsForm { + Section { + UI.Form.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."), + isChanged: settings.experimentalToolbarUI != false, + isOn: $settings.experimentalToolbarUI) + UI.Form.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."), + isChanged: settings.experimentalPanelNavigation != false, + isOn: $settings.experimentalPanelNavigation) + .disabled(!settings.experimentalToolbarUI) + UI.Form.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."), + isChanged: settings.sidebarNavigationEnabled != true, + isOn: $settings.sidebarNavigationEnabled) + UI.Form.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."), + isChanged: settings.commandPaletteEnabled != false, + isOn: $settings.commandPaletteEnabled) + UI.Form.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."), + isChanged: settings.hubSearchEnabled != false, + isOn: $settings.hubSearchEnabled) + UI.Form.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."), + isChanged: settings.composeImportEnabled != false, + isOn: $settings.composeImportEnabled) + UI.Form.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."), + isChanged: settings.imageBuildEnabled != false, + isOn: $settings.imageBuildEnabled) + UI.Form.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."), + isChanged: settings.keyboardShortcutsEnabled != false, + isOn: $settings.keyboardShortcutsEnabled) + } header: { + Text(AppText.sectionSettingsExperimental) + } footer: { + Text(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.")) + } + } + } +} diff --git a/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift new file mode 100644 index 00000000..cc672ae8 --- /dev/null +++ b/Sources/ContainedApp/Features/Settings/Tabs/GeneralTab.swift @@ -0,0 +1,138 @@ +import SwiftUI +import ContainedUI +import ContainedCore + +// MARK: - General + +struct GeneralTab: View { + @Environment(AppModel.self) private var app + @Bindable var settings: SettingsStore + @State private var confirmingClear = false + + var body: some View { + SettingsForm { + Section(AppText.string("settings.general.startup", defaultValue: "Startup")) { + UI.Form.ToggleRow(title: AppText.string("settings.general.launchAtLogin", defaultValue: "Launch at login"), + isOn: $settings.launchAtLogin) + UI.Form.ToggleRow(title: AppText.string("settings.general.keepInMenuBar", defaultValue: "Keep running in the menu bar"), + isChanged: settings.keepInMenuBar != true, + isOn: $settings.keepInMenuBar) + } + + Section(AppText.string("settings.general.activityAlerts", defaultValue: "Activity & alerts")) { + UI.Form.ToggleRow(title: AppText.string("settings.general.notifyOnCrash", defaultValue: "System alert on container crash / restart"), + isChanged: settings.notifyOnCrash != true, + isOn: $settings.notifyOnCrash) + UI.Form.ToggleRow(title: AppText.string("settings.general.showRevealCLI", defaultValue: "Show Reveal CLI on actions"), + info: AppText.string("settings.general.showRevealCLI.info", defaultValue: "Shows the exact runtime CLI command for important actions. Useful when you are learning the CLI or want to verify what will run."), + isChanged: settings.revealCLI != true, + isOn: $settings.revealCLI) + } + + Section { + UI.Form.Row(title: AppText.string("settings.general.listRefreshInterval", defaultValue: "List refresh interval"), + isChanged: settings.refreshInterval != 2.0) { + HStack(spacing: UI.Layout.Spacing.s) { + Slider(value: $settings.refreshInterval, in: 1...10, step: 1) + .frame(width: UI.Form.Width.compactSlider) + Text("\(Int(settings.refreshInterval))s") + .monospacedDigit() + .frame(width: UI.Form.Width.refreshReadout, alignment: .trailing) + } + } + UI.Form.Row(title: AppText.string("settings.general.keepHistoryFor", defaultValue: "Keep history for"), + isChanged: settings.historyRetentionDays != 7) { + Picker("", selection: retentionBinding) { + Text("1 day").tag(1) + Text("7 days").tag(7) + Text("14 days").tag(14) + Text("30 days").tag(30) + } + .labelsHidden().fixedSize() + } + UI.Form.Row(title: AppText.string("settings.general.normalizeStats", defaultValue: "Normalize stats"), + isChanged: settings.statsNormalizationMode != .container) { + Picker("", selection: statsNormalizationBinding) { + ForEach(Core.Metrics.NormalizationMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } + Button("Clear History…", role: .destructive) { confirmingClear = true } + .frame(maxWidth: .infinity, alignment: .leading) + ConfigTransferControls() + } header: { + Text(AppText.string("settings.general.data", defaultValue: "Data")) + } footer: { + Text(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)")) + } + + Section { + UI.Form.Row(title: AppText.string("settings.general.loggingLevel", defaultValue: "Level"), + isChanged: settings.loggingLevel != .important) { + Picker("", selection: $settings.loggingLevel) { + ForEach(AppLogLevel.allCases) { level in + Text(level.displayName).tag(level) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } + LabeledContent(AppText.string("settings.logging.writeTo", defaultValue: "Write to")) { + ForEach(AppLogDestination.allCases) { destination in + Toggle(destination.displayName, isOn: setBinding(destination, in: \.enabledLogDestinations)) + .toggleStyle(.checkbox) + } + } + VStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { + Text(AppText.string("settings.logging.categories", defaultValue: "Categories")) + .font(.caption) + .foregroundStyle(settings.enabledLogCategories.count == AppLogCategory.allCases.count ? Color.secondary : Color.blue) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), alignment: .leading)], + alignment: .leading, + spacing: UI.Layout.Spacing.s) { + ForEach(AppLogCategory.allCases) { category in + Toggle(category.displayName, isOn: setBinding(category, in: \.enabledLogCategories)) + .toggleStyle(.checkbox) + } + } + } + } header: { + Text(AppText.string("settings.general.logging", defaultValue: "Logging")) + } footer: { + Text(settings.loggingLevel.footnote) + } + + } + .confirmationDialog("Clear all history?", isPresented: $confirmingClear) { + Button("Clear History", role: .destructive) { app.clearHistory() } + } message: { + Text("This permanently removes all recorded metrics and events. Saved templates are kept.") + } + } + + private var retentionBinding: Binding { + Binding(get: { settings.historyRetentionDays }, + 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) + } set: { isEnabled in + var values = settings[keyPath: keyPath] + if isEnabled { values.insert(value) } + else { values.remove(value) } + settings[keyPath: keyPath] = values + } + } +} diff --git a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift similarity index 51% rename from Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift rename to Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift index a383afc7..9e35b3ed 100644 --- a/Sources/Contained/Features/Settings/Tabs/RegistriesTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/RegistriesTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedUI import ContainedCore // MARK: - Registries @@ -7,38 +8,36 @@ 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 { - VStack(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.") { + SettingsForm { + Section { 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: 2) { - 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: registrySubtitle(login)) { Button("Log Out", role: .destructive) { loggingOut = login } } .contextMenu { - Button { copyToPasteboard(login.host) } label: { Label("Copy Server", systemImage: "doc.on.doc") } + UI.Copy.ValueLabel("Copy Server", value: login.host) Divider() Button(role: .destructive) { loggingOut = login } label: { Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right") } } } } + } header: { + Text(AppText.string("settings.registries.signedIn", defaultValue: "Signed-in registries")) + } footer: { + Text(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.")) } - PanelSection { + Section { Button("Log In to Registry…") { loggingIn = true } .frame(maxWidth: .infinity, alignment: .leading) } @@ -55,11 +54,22 @@ 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.userMessage) } - catch { app.flash(error.localizedDescription) } + 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 ?? login.runtimeKind.rawValue + 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 new file mode 100644 index 00000000..15511b72 --- /dev/null +++ b/Sources/ContainedApp/Features/Settings/Tabs/RuntimeTab.swift @@ -0,0 +1,257 @@ +import SwiftUI +import ContainedUI +import ContainedCore + +// MARK: - Runtime + +/// Daemon runtime configuration: the editable bits (recommended kernel, local DNS domains) plus a +/// read-only view of the daemon defaults. Defaults are read-only because the `container` CLI exposes +/// no setter for them — `system property` only lists; only the kernel and DNS are settable. +struct RuntimeTab: View { + @Environment(AppModel.self) private var app + @State private var dnsDomains: [String] = [] + @State private var confirmingKernel = false + @State private var addingDNS = false + @State private var newDomain = "" + @State private var deletingDomain: String? + + var body: some View { + SettingsForm { + Section { + ForEach(app.supportedRuntimeDescriptors, id: \.kind) { descriptor in + let reachable = app.availableRuntimeDescriptors.contains { $0.kind == descriptor.kind } + UI.Form.Row(title: descriptor.displayName) { + Text(reachable ? AppText.string("settings.runtime.reachable", defaultValue: "Reachable") + : AppText.string("settings.runtime.notReachable", defaultValue: "Not reachable")) + .designSecondaryValueStyle() + } + } + } header: { + Text(AppText.string("settings.runtime.available", defaultValue: "Container runtimes")) + } footer: { + Text(AppText.runtimeSubtitle) + } + + Section { + ForEach(app.supportedRuntimeDescriptors, id: \.kind) { descriptor in + runtimePathField(for: descriptor) + } + } header: { + Text(AppText.string("settings.runtime.cliPaths", defaultValue: "Runtime paths")) + } footer: { + Text(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.")) + } + + if let descriptor = managementRuntimeDescriptor { + managementRuntimeControls(for: descriptor) + } + ForEach(endpointGuidanceDescriptors, id: \.kind) { descriptor in + endpointGuidance(for: descriptor) + } + + if let props = app.properties { + Section { + if let d = props.container { + if let c = d.cpus { UI.Form.Row(title: AppText.string("settings.runtime.defaultCPUs", defaultValue: "Default CPUs")) { Text("\(c)").designSecondaryValueStyle() } } + if let m = d.memory { UI.Form.Row(title: AppText.string("settings.runtime.defaultMemory", defaultValue: "Default memory")) { Text(m).designSecondaryValueStyle() } } + } + if let machine = props.machine { + if let c = machine.cpus { UI.Form.Row(title: AppText.string("settings.runtime.machineCPUs", defaultValue: "Machine CPUs")) { Text("\(c)").designSecondaryValueStyle() } } + if let m = machine.memory { UI.Form.Row(title: AppText.string("settings.runtime.machineMemory", defaultValue: "Machine memory")) { Text(m).designSecondaryValueStyle() } } + } + if let b = props.build { + if let img = b.image { UI.Form.Row(title: AppText.string("settings.runtime.builderImage", defaultValue: "Builder image")) { Text(img).designSecondaryValueStyle() } } + if let r = b.rosetta { UI.Form.Row(title: AppText.string("settings.runtime.builderRosetta", defaultValue: "Builder Rosetta")) { Text(r ? "On" : "Off").designSecondaryValueStyle() } } + } + if let k = props.kernel, let path = k.binaryPath { UI.Form.Row(title: AppText.string("settings.runtime.kernel", defaultValue: "Kernel")) { Text(path).designSecondaryValueStyle() } } + } header: { + Text(AppText.string("settings.runtime.resources", defaultValue: "Runtime resources")) + } footer: { + Text(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.")) + } + } + } + .task { await loadRuntimeDetails() } + .confirmationDialog("Install the recommended kernel?", isPresented: $confirmingKernel) { + Button("Download & install") { Task { await installKernel() } } + } message: { + Text("Downloads and sets the recommended kernel as the default. This may take a moment.") + } + .confirmationDialog("Delete DNS domain \(deletingDomain ?? "")?", + isPresented: deletingDomainBinding, presenting: deletingDomain) { domain in + Button("Delete", role: .destructive) { Task { await deleteDNS(domain) } } + } message: { _ in Text("This may prompt for your administrator password (handled by the runtime CLI).") } + .alert("New local DNS domain", isPresented: $addingDNS) { + TextField("example.test", text: $newDomain) + Button("Cancel", role: .cancel) { newDomain = "" } + Button("Create") { Task { await addDNS() } } + } message: { + Text("Creating a domain may prompt for your administrator password (handled by the runtime CLI).") + } + } + + private var managementRuntimeDescriptor: Core.Runtime.Descriptor? { + app.supportedRuntimeDescriptors.first(where: { descriptor in + app.runtimeIsReady(descriptor.kind) && + (descriptor.supports(.kernelManagement) || descriptor.supports(.dnsManagement)) + }) + } + + private var endpointGuidanceDescriptors: [Core.Runtime.Descriptor] { + app.supportedRuntimeDescriptors.filter { descriptor in + descriptor.supports(.systemStatus) && !descriptor.supports(.serviceControl) + } + } + + @ViewBuilder + private func managementRuntimeControls(for descriptor: Core.Runtime.Descriptor) -> some View { + if descriptor.supports(.kernelManagement) { + Section { + UI.Form.Row(title: AppText.string("settings.runtime.recommendedKernel", defaultValue: "Recommended kernel")) { + Button("Install…") { confirmingKernel = true } + } + revealCLIHint("\(descriptor.executableName ?? descriptor.kind.rawValue) system kernel set --recommended") + } header: { + Text(AppText.string("settings.runtime.kernel", defaultValue: "Kernel")) + } footer: { + Text(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 runtime CLI; Contained never sees it.")) + } + } + + if descriptor.supports(.dnsManagement) { + Section { + 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) + } header: { + Text(AppText.string("settings.runtime.localDNSDomains", defaultValue: "Local DNS domains")) + } footer: { + Text(AppText.string("settings.runtime.localDNSDomains.footer", defaultValue: "Creating or deleting a domain may prompt for your administrator password - handled by the runtime CLI.")) + } + } + } + + private func endpointGuidance(for descriptor: Core.Runtime.Descriptor) -> some View { + Section { + UI.Form.Row(title: AppText.string("settings.runtime.endpointStatus", defaultValue: "Endpoint")) { + Text(app.availableRuntimeDescriptors.contains(where: { $0.kind == descriptor.kind }) ? 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) + } header: { + Text("\(descriptor.displayName) endpoint") + } footer: { + Text(AppText.string("settings.runtime.endpoint.footer", defaultValue: "Contained talks to this runtime through its CLI and configured endpoint. If the endpoint is unavailable, make sure the provider is running and retry.")) + } + } + + /// A small copyable CLI hint, shown only when the Reveal-CLI setting is on. + @ViewBuilder + private func revealCLIHint(_ command: String) -> some View { + if app.settings.revealCLI { + UI.List.MetadataRow(systemImage: "terminal", + title: command, + isMonospaced: true) { + UI.Copy.Icon(value: command, help: AppText.copyCommand) + } + } + } + + private var deletingDomainBinding: Binding { + Binding(get: { deletingDomain != nil }, set: { if !$0 { deletingDomain = nil } }) + } + + private func runtimePathField(for descriptor: Core.Runtime.Descriptor) -> some View { + UI.Form.Field(label: runtimePathLabel(for: descriptor), + info: runtimePathInfo(for: descriptor), + isChanged: !app.settings.runtimePathOverride(for: descriptor.kind).isEmpty) { + TextField("", text: runtimePathBinding(for: descriptor.kind), + prompt: Text(defaultPathPrompt(for: descriptor))) + .onSubmit { Task { await app.retryBootstrap() } } + } + } + + private func runtimePathLabel(for descriptor: Core.Runtime.Descriptor) -> String { + "\(descriptor.displayName) CLI path" + } + + private func runtimePathInfo(for descriptor: Core.Runtime.Descriptor) -> String { + let executable = descriptor.executableName ?? descriptor.displayName + return "Override the auto-detected \(executable) binary location." + } + + private func runtimePathBinding(for kind: Core.Runtime.Kind) -> Binding { + Binding { + app.settings.runtimePathOverride(for: kind) + } set: { value in + app.settings.setRuntimePathOverride(value, for: kind) + } + } + + private func defaultPathPrompt(for descriptor: Core.Runtime.Descriptor) -> String { + 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 let descriptor = managementRuntimeDescriptor, descriptor.supports(.dnsManagement) { + await loadDNS(runtimeKind: descriptor.kind) + } + } + + private func loadDNS(runtimeKind: Core.Runtime.Kind) async { + guard app.runtimeIsReady(runtimeKind), let client = app.client else { return } + if let domains = try? await client.dnsDomains(runtimeKind: runtimeKind) { dnsDomains = domains } + } + + private func installKernel() async { + guard let descriptor = managementRuntimeDescriptor, + descriptor.supports(.kernelManagement), + let client = app.client else { return } + if let error = await app.captured({ _ = try await client.setRecommendedKernel(runtimeKind: descriptor.kind) }) { app.flash(error) } + else { app.flash(AppText.recommendedKernelInstalled); await app.reloadProperties() } + } + + private func addDNS() async { + let domain = newDomain.trimmingCharacters(in: .whitespaces) + newDomain = "" + guard let descriptor = managementRuntimeDescriptor, + descriptor.supports(.dnsManagement), + !domain.isEmpty, + let client = app.client else { return } + if let error = await app.captured({ _ = try await client.createDNSDomain(domain, runtimeKind: descriptor.kind) }) { app.flash(error) } + else { await loadDNS(runtimeKind: descriptor.kind) } + } + + private func deleteDNS(_ domain: String) async { + guard let descriptor = managementRuntimeDescriptor, + descriptor.supports(.dnsManagement), + let client = app.client else { return } + if let error = await app.captured({ _ = try await client.deleteDNSDomain(domain, runtimeKind: descriptor.kind) }) { app.flash(error) } + else { await loadDNS(runtimeKind: descriptor.kind) } + } +} diff --git a/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift b/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift similarity index 57% rename from Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift rename to Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift index a6da60e5..604d5cb4 100644 --- a/Sources/Contained/Features/Settings/Tabs/UpdatesTab.swift +++ b/Sources/ContainedApp/Features/Settings/Tabs/UpdatesTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import ContainedUI import ContainedCore // MARK: - Updates @@ -10,10 +11,10 @@ struct UpdatesTab: View { var body: some View { @Bindable var settings = app.settings - VStack(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") { + SettingsForm { + Section { + UI.Form.Row(title: AppText.string("settings.updates.channel", defaultValue: "Update channel"), + isChanged: settings.updateChannel != .nightly) { Menu(app.settings.updateChannel.displayName) { ForEach(UpdateChannel.allCases) { channel in Button { @@ -30,12 +31,13 @@ struct UpdatesTab: View { } .fixedSize() } - PanelToggleRow(title: "Automatically check for updates", - isOn: Binding(get: { settings.appUpdateChecksEnabled }, - set: { - settings.appUpdateChecksEnabled = $0 - app.updater.automaticallyChecks = $0 - })) + UI.Form.ToggleRow(title: AppText.string("settings.updates.automaticallyCheck", defaultValue: "Automatically check for updates"), + isChanged: settings.appUpdateChecksEnabled != true, + isOn: Binding(get: { settings.appUpdateChecksEnabled }, + set: { + settings.appUpdateChecksEnabled = $0 + app.updater.automaticallyChecks = $0 + })) Button("Check for Updates…") { app.updater.checkForUpdates() } .disabled(!app.updater.canCheckForUpdates) .frame(maxWidth: .infinity, alignment: .leading) @@ -44,11 +46,15 @@ struct UpdatesTab: View { Button(availableUpdateNotesLabel) { showingAvailableNotes = true } .disabled(app.updater.availableReleaseNotesHTML == nil) .frame(maxWidth: .infinity, alignment: .leading) + } header: { + Text(AppText.sectionSettingsUpdates) + } footer: { + Text(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.")) } - 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") { + Section { + UI.Form.Row(title: AppText.string("settings.updates.checkImages", defaultValue: "Check images"), + isChanged: settings.imageUpdateIntervalHours != 6) { Picker("", selection: $settings.imageUpdateIntervalHours) { Text("Every hour").tag(1) Text("Every 3 hours").tag(3) @@ -58,11 +64,15 @@ struct UpdatesTab: View { } .labelsHidden().fixedSize() } + } header: { + Text(AppText.string("settings.updates.imageUpdates", defaultValue: "Image updates")) + } footer: { + Text(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.")) } } .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 }) } @@ -75,16 +85,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/Features/Settings/Transfer/ConfigTransfer.swift b/Sources/ContainedApp/Features/Settings/Transfer/ConfigTransfer.swift new file mode 100644 index 00000000..342e8f51 --- /dev/null +++ b/Sources/ContainedApp/Features/Settings/Transfer/ConfigTransfer.swift @@ -0,0 +1,100 @@ +import SwiftUI +import UniformTypeIdentifiers +import ContainedUI + +enum ConfigImportMode: String, CaseIterable, Identifiable { + case merge = "Merge" + 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 } +} + +struct ConfigTransferControls: View { + @Environment(AppModel.self) private var app + @State private var sections = Set(AppStateSection.allCases) + @State private var importMode: ConfigImportMode = .merge + @State private var exportingBackup = false + @State private var importingBackup = false + @State private var backupDocument: DataFileDocument? + + var body: some View { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { + ForEach(AppStateSection.allCases) { section in + Toggle(section.displayName, isOn: binding(for: section)) + .toggleStyle(.checkbox) + } + Picker("Import mode", selection: $importMode) { + ForEach(ConfigImportMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + + HStack { + Button("Export Backup…") { exportBackup() } + Button("Import Backup…") { importBackup() } + Button("Clean Up Orphans") { app.purgeDeadRows() } + } + } + .fileExporter(isPresented: $exportingBackup, + document: backupDocument, + contentTypes: UTType.containedBackupDocuments, + defaultFilename: "Contained Backup.containedbackup") { result in + backupDocument = nil + switch result { + case .success: + app.flash(AppText.exportedBackup) + case .failure(let error): + app.flash(error.appDisplayMessage) + } + } + .fileImporter(isPresented: $importingBackup, + allowedContentTypes: UTType.containedBackupDocuments) { result in + handleBackupImport(result) + } + } + + private func binding(for section: AppStateSection) -> Binding { + Binding { + sections.contains(section) + } set: { isOn in + if isOn { sections.insert(section) } + else { sections.remove(section) } + } + } + + private func exportBackup() { + do { + backupDocument = DataFileDocument(data: try app.configurationData(sections: sections)) + exportingBackup = true + } catch { + app.flash(error.appDisplayMessage) + } + } + + private func importBackup() { + importingBackup = true + } + + private func handleBackupImport(_ result: Result) { + guard case .success(let url) = result else { + if case .failure(let error) = result { app.flash(error.appDisplayMessage) } + return + } + do { + try app.importConfiguration(from: url, + sections: sections, + replace: importMode.replacesExistingData) + app.flash(AppText.importedBackup) + } catch { + app.flash(error.appDisplayMessage) + } + } +} diff --git a/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift b/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift new file mode 100644 index 00000000..f68646df --- /dev/null +++ b/Sources/ContainedApp/Features/System/Components/SystemLogsSheet.swift @@ -0,0 +1,41 @@ +import SwiftUI +import ContainedUI +import ContainedCore + +struct SystemLogsSheet: View { + @Environment(AppModel.self) private var app + @Environment(\.dismiss) private var dismiss + @State private var follow = false + @State private var session = 0 + + var body: some View { + VStack(spacing: 0) { + 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() + UI.Action.Group(UI.Action.Item(systemName: "xmark", + help: AppText.close, + isCancel: true) { + dismiss() + }) + } + .padding(UI.Layout.Spacing.s) + if let client = app.client, + let runtimeKind = app.firstRuntimeKind(supporting: .systemLogs) { + UI.Console.Stream(stream: { client.streamSystemLogs(follow: follow, last: 500, runtimeKind: runtimeKind) }, + workingLabel: AppText.working, + completedLabel: AppText.completed, + lineCountLabel: AppText.lineCount, + copyLogHelp: AppText.copyLog, + failureLabel: AppErrorPresentation.message) + .id(session) + .padding(.horizontal, UI.Layout.Spacing.s) + .padding(.bottom, UI.Layout.Spacing.s) + } + } + .frame(UI.Panel.SheetSize.wide) + .sheetMaterial() + } +} diff --git a/Sources/Contained/Features/System/Components/SystemVolumeInventory.swift b/Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift similarity index 71% rename from Sources/Contained/Features/System/Components/SystemVolumeInventory.swift rename to Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift index 58847355..70694855 100644 --- a/Sources/Contained/Features/System/Components/SystemVolumeInventory.swift +++ b/Sources/ContainedApp/Features/System/Components/SystemVolumeInventory.swift @@ -19,28 +19,35 @@ enum SystemVolumeInventory { struct Entry: Identifiable { let id: String let kind: Kind + let runtimeKind: Core.Runtime.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 + if $0.runtimeKind != $1.runtimeKind { + return $0.runtimeKind.rawValue.localizedCaseInsensitiveCompare($1.runtimeKind.rawValue) == .orderedAscending + } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - let namedResources = Dictionary(uniqueKeysWithValues: sortedVolumes.map { ($0.name, $0) }) + let namedResources = Dictionary(uniqueKeysWithValues: sortedVolumes.map { ($0.scopedID, $0) }) var byID: [String: Entry] = [:] for volume in sortedVolumes { - byID["named:\(volume.name)"] = Entry( - id: "named:\(volume.name)", + byID["named:\(volume.scopedID)"] = Entry( + id: "named:\(volume.scopedID)", kind: .named, + runtimeKind: volume.runtimeKind, title: volume.name, subtitle: volumeSubtitle(volume), - containers: containersMounting(source: volume.name, in: containers), + containers: containersMounting(source: volume.name, + runtimeKind: volume.runtimeKind, + in: containers), resource: volume, source: volume.name, destination: nil @@ -78,11 +85,12 @@ 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, kind: existing.kind, + runtimeKind: existing.runtimeKind, title: existing.title, subtitle: existing.subtitle, containers: sortedContainers(containers), @@ -91,22 +99,23 @@ 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() if let source, !source.isEmpty, isLocalPath(source, type: type) { - return Entry(id: "path:\(source):\(destination ?? "")", + return Entry(id: "path:\(snapshot.runtimeKind.rawValue):\(source):\(destination ?? "")", kind: .localPath, + runtimeKind: snapshot.runtimeKind, title: source, subtitle: typeLabel(type), containers: [snapshot], @@ -116,9 +125,11 @@ enum SystemVolumeInventory { } if let source, !source.isEmpty { - let resource = namedResources[source] - return Entry(id: "named:\(source)", + let scopedSource = snapshot.runtimeKind.scopedID(for: source) + let resource = namedResources[scopedSource] + return Entry(id: "named:\(scopedSource)", kind: .named, + runtimeKind: snapshot.runtimeKind, title: source, subtitle: resource.map(volumeSubtitle(_:)) ?? typeLabel(type), containers: [snapshot], @@ -129,8 +140,9 @@ 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, + runtimeKind: snapshot.runtimeKind, title: title, subtitle: typeLabel(type), containers: [snapshot], @@ -153,13 +165,16 @@ enum SystemVolumeInventory { return type.uppercased() } - private static func containersMounting(source: String, in containers: [ContainerSnapshot]) -> [ContainerSnapshot] { + private static func containersMounting(source: String, + runtimeKind: Core.Runtime.Kind, + in containers: [Core.Container.Snapshot]) -> [Core.Container.Snapshot] { sortedContainers(containers.filter { snapshot in + snapshot.runtimeKind == runtimeKind && 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/Contained/Stores/RefreshCoordinator.swift b/Sources/ContainedApp/Features/System/Store/RefreshCoordinator.swift similarity index 86% rename from Sources/Contained/Stores/RefreshCoordinator.swift rename to Sources/ContainedApp/Features/System/Store/RefreshCoordinator.swift index e6366708..09ee50c3 100644 --- a/Sources/Contained/Stores/RefreshCoordinator.swift +++ b/Sources/ContainedApp/Features/System/Store/RefreshCoordinator.swift @@ -2,9 +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 -/// transitioning, slows when idle, and pauses when the window is in the background. +/// adaptive polling loop fetches system status, the container list, and active resource caches, then +/// 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/ContainedApp/Features/System/SystemView.swift b/Sources/ContainedApp/Features/System/SystemView.swift new file mode 100644 index 00000000..00ca6289 --- /dev/null +++ b/Sources/ContainedApp/Features/System/SystemView.swift @@ -0,0 +1,587 @@ +import SwiftUI +import ContainedUX +import ContainedUI +import ContainedCore + +/// 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 +/// defaults, kernel, and DNS configuration live in Settings → Runtime. +struct SystemContent: View { + @Environment(AppModel.self) private var app + @Environment(UIState.self) private var ui + /// Flat cards (no shadow) when hosted in the toolbar morph panel; elevated if shown standalone. + var showClose: Bool + var elevated = true + var usesToolbarSelection = true + var onClose: () -> Void = {} + + @State private var working = false + @State private var pruneTarget: PruneTarget? + @State private var reclaimingAll = false + @State private var deletingVolume: Core.Volume.Resource? + @State private var page: SystemPage + + enum SystemPage: String, CaseIterable, Identifiable { + case runtime = "Runtime" + case automation = "Automation" + case volumes = "Volumes" + + var id: String { rawValue } + var systemImage: String { + switch self { + case .runtime: return "server.rack" + case .automation: return "clock.arrow.circlepath" + case .volumes: return "externaldrive" + } + } + + var subtitle: String { + switch self { + 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") + } + } + + var title: String { + switch self { + 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 + } + } + } + + private var showsHeader: Bool { + showClose || !ui.toolbarUIEnabled + } + + private var activePage: SystemPage { + ui.toolbarUIEnabled && !showClose && usesToolbarSelection ? ui.systemPage : page + } + + private func setPage(_ item: SystemPage) { + if ui.toolbarUIEnabled && !showClose && usesToolbarSelection { + ui.systemPage = item + } else { + page = item + } + } + + init(initialPage: SystemPage = .runtime, + showClose: Bool = true, + elevated: Bool = true, + usesToolbarSelection: Bool = true, + onClose: @escaping () -> Void = {}) { + self.showClose = showClose + self.elevated = elevated + self.usesToolbarSelection = usesToolbarSelection + self.onClose = onClose + _page = State(initialValue: initialPage) + } + + private typealias VolumeInventoryEntry = SystemVolumeInventory.Entry + + enum PruneTarget: String, Identifiable { + case containers, images, volumes, networks + var id: String { rawValue } + var title: String { + switch self { + 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?") + } + } + } + + var body: some View { + UI.Panel.Scaffold(width: UI.Panel.Size.system.width) { + if showsHeader { + VStack(spacing: 0) { + header + Divider() + } + } + } content: { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.l) { + switch activePage { + case .runtime: runtimeStatusCard + case .automation: automationCard + case .volumes: volumesCard + } + } + .padding(UI.Layout.Spacing.s) + } + .task { await app.refreshSystemResources() } + .confirmationDialog("Delete volume \(deletingVolume?.name ?? "")?", + isPresented: deletingVolumeBinding, presenting: deletingVolume) { volume in + Button("Delete", role: .destructive) { Task { await deleteVolume(volume) } } + } message: { _ in Text("This permanently removes the volume and its data.") } + .confirmationDialog(pruneTarget?.title ?? "", isPresented: pruneBinding, presenting: pruneTarget) { target in + Button("Remove", role: .destructive) { Task { await prune(target) } } + } message: { _ in Text("This permanently removes unused resources to reclaim disk space.") } + .confirmationDialog("Reclaim all unused space?", isPresented: $reclaimingAll) { + Button("Reclaim all", role: .destructive) { Task { await reclaimAll() } } + } message: { + Text("Removes stopped containers, unused images, unused volumes, and unused networks.") + } + } + + /// A consistent design-system section card. + private func card(@ViewBuilder _ content: @escaping () -> Content) -> some View { + UI.Surface.Content(elevated: elevated, alignment: .leading) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.m) { content() } + } + } + + private var header: some View { + UI.Panel.Header(symbol: "gearshape.2", + title: AppText.sectionSystem, + subtitle: activePage.subtitle) { + HStack(spacing: UI.Toolbar.Spacing.groupSpacing) { + runtimeControls + UI.Action.Cluster { + UI.Action.Items(pageActions) + storageMenu + if showClose { + UI.Action.Items([UI.Action.Item(systemName: "xmark", + help: AppText.close, + isCancel: true, + action: onClose)]) + } + } + } + } + } + + private var pageActions: [UI.Action.Item] { + SystemPage.allCases.map { item in + UI.Action.Item(systemName: item.systemImage, + help: item.title, + tint: activePage == item ? .accentColor : nil) { + setPage(item) + } + } + } + + @ViewBuilder + private var runtimeControls: some View { + if app.serviceControlRuntimeAvailable { + 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 { + if app.serviceHealthy { + return UI.Action.Item(systemName: "stop.fill", + help: AppText.stopService, + role: .destructive, + isEnabled: !working) { + run { await app.stopService() } + } + } else { + return UI.Action.Item(systemName: "play.fill", + help: AppText.startService, + isEnabled: !working) { + run { await app.startService() } + } + } + } + + private var storageMenu: some View { + Menu { + Button { reclaimingAll = true } label: { + Label(AppText.string("cleanup.reclaimAll", defaultValue: "Reclaim all"), systemImage: "trash") + } + .disabled((app.diskUsage?.totalReclaimableBytes ?? 0) == 0) + Divider() + 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: { + UI.Action.MenuLabel(systemName: "trash", + help: AppText.storageCleanup, + role: .destructive) + } + .buttonStyle(.plain) + } + + // MARK: Volumes + + private var volumeInventory: [VolumeInventoryEntry] { + SystemVolumeInventory.build(volumes: app.volumes, + containers: app.containers.snapshots) + } + + private var volumesCard: some View { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.m) { + HStack { + Text(AppText.sectionVolumes).designHeadlineLabelStyle() + UI.Badge.Text(text: "\(volumeInventory.count)") + Spacer() + UI.Action.Group(UI.Action.Item(systemName: "plus", + title: AppText.string("common.new", defaultValue: "New"), + help: AppText.newVolume) { + onClose() + ui.dispatch(.createVolume) + }) + } + if let message = app.resourceInventoryErrors["volumes"] { + UI.State.InlineStatus(message, isWorking: false) + } + if volumeInventory.isEmpty { + card { + 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(alignment: .leading, spacing: UI.Layout.Spacing.l) { + volumeSection(title: AppText.string("volume.section.runtimeVolumes", defaultValue: "Runtime volumes"), + subtitle: AppText.string("volume.section.runtimeVolumes.subtitle", defaultValue: "Managed storage owned by the runtime."), + entries: runtimeVolumeEntries, + emptyTitle: AppText.string("volume.runtime.empty", defaultValue: "No runtime volumes"), + emptySymbol: "externaldrive") + volumeSection(title: AppText.string("volume.section.pathMounts", defaultValue: "Host path mounts"), + subtitle: AppText.string("volume.section.pathMounts.subtitle", defaultValue: "Bind paths and temporary mounts discovered from containers."), + entries: hostPathVolumeEntries, + emptyTitle: AppText.string("volume.pathMounts.empty", defaultValue: "No host path mounts"), + emptySymbol: "folder") + } + } + } + } + + private var runtimeVolumeEntries: [VolumeInventoryEntry] { + volumeInventory.filter { $0.kind == .named } + } + + private var hostPathVolumeEntries: [VolumeInventoryEntry] { + volumeInventory.filter { $0.kind != .named } + } + + private func volumeSection(title: String, + subtitle: String, + entries: [VolumeInventoryEntry], + emptyTitle: String, + emptySymbol: String) -> some View { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { + HStack(spacing: UI.Layout.Spacing.s) { + Text(title).designSectionLabelStyle() + UI.Badge.Text(text: "\(entries.count)") + } + Text(subtitle) + .designSecondaryCaption() + .fixedSize(horizontal: false, vertical: true) + if entries.isEmpty { + UI.Surface.Content(elevated: elevated, minHeight: 132, alignment: .center) { + UI.State.Empty(emptyTitle, + systemImage: emptySymbol, + padding: UI.Layout.Spacing.xs) + } + } else { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { + ForEach(entries) { entry in + volumeEntryCard(entry) + } + } + } + } + } + + private func volumeEntryCard(_ entry: VolumeInventoryEntry) -> some View { + UI.Card.Scaffold(size: .medium, + elevated: elevated, + title: entry.title, + subtitle: volumeCardSubtitle(entry), + titleStyle: entry.kind == .localPath ? .monospaced : .standard, + subtitleStyle: .monospaced) { + UI.Card.IconChip(symbol: entry.kind.symbol, + tint: volumeTint(entry), + backgroundOpacity: UI.Card.Metric.iconBackgroundOpacity) + } titleAccessory: { + UI.Badge.Text(text: entry.kind.rawValue, font: .caption2.weight(.semibold)) + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + UI.Control.RowMenu(accessibilityLabel: AppText.string("menu.volumeActions", defaultValue: "Volume actions")) { + volumeMenu(entry) + } + } bodyContent: { + EmptyView() + } footerLeading: { + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "shippingbox", size: .caption2) + } text: { + UI.Card.MetricText(text: volumeContainerCount(entry)) + .designSecondaryValueStyle() + } + } footerActions: { + if entry.resource != nil { + Button(role: .destructive) { deletingVolume = entry.resource } label: { + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "trash", tone: .error, size: .body) + } text: { + EmptyView() + } + } + .buttonStyle(.plain) + .help(AppText.delete) + .accessibilityLabel(AppText.delete) + } + } widget: { + EmptyView() + } + .contextMenu { volumeMenu(entry) } + } + + private func volumeCardSubtitle(_ entry: VolumeInventoryEntry) -> String? { + var parts: [String] = [] + parts.append(app.runtimeDescriptor(for: entry.runtimeKind)?.displayName ?? entry.runtimeKind.rawValue) + if let destination = entry.destination { parts.append(destination) } + if let subtitle = entry.subtitle { parts.append(subtitle) } + return parts.joined(separator: " · ") + } + + private func volumeContainerCount(_ entry: VolumeInventoryEntry) -> String { + switch entry.containers.count { + case 0: return AppText.string("volume.containers.none", defaultValue: "No containers") + case 1: return AppText.string("volume.containers.one", defaultValue: "1 container") + default: return AppText.string("volume.containers.count", defaultValue: "\(entry.containers.count) containers") + } + } + + private func volumeTint(_ entry: VolumeInventoryEntry) -> Color { + switch entry.kind { + case .named: return .accentColor + case .localPath: return .orange + case .anonymous: return .secondary + } + } + + @ViewBuilder + private func volumeMenu(_ entry: VolumeInventoryEntry) -> some View { + UI.Copy.ValueLabel(AppText.string("volume.copySource", defaultValue: "Copy source"), + value: entry.source ?? entry.title) + if let destination = entry.destination { + UI.Copy.ValueLabel(AppText.string("volume.copyDestination", defaultValue: "Copy destination"), + value: destination, + systemName: "arrow.down.doc") + } else { + 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(AppText.delete, systemImage: "trash") } + } else { + Button(AppText.delete, systemImage: "trash", role: .destructive) {} + .disabled(true) + } + } + + private var deletingVolumeBinding: Binding { + Binding(get: { deletingVolume != nil }, set: { if !$0 { deletingVolume = nil } }) + } + + 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], runtimeKind: volume.runtimeKind) + }) { app.flash(error) } + await app.refreshVolumes() + } + + private var automationCard: some View { + card { + 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"), + detail: app.settings.imageUpdateChecksEnabled + ? "\(backgroundTaskDetail(now: context.date)) · \(app.imageUpdateIntervalDescription)" + : AppText.string("status.paused", defaultValue: "Paused"), + isOn: settingBinding(\.imageUpdateChecksEnabled)) { + if app.settings.imageUpdateChecksEnabled { + Text(countdown(to: app.imageUpdateNextRunDate, now: context.date)) + .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() } + }) + } + } + } + Divider() + automationRow(icon: "arrow.down.app", + title: AppText.string("automation.appUpdateCheck", defaultValue: "App update check"), + detail: app.updater.canCheckForUpdates + ? AppText.string("automation.appUpdateCheck.detail", defaultValue: "Sparkle · \(app.settings.updateChannel.rawValue.capitalized) channel") + : AppText.string("status.unavailableInBuild", defaultValue: "Unavailable in this build"), + isOn: appUpdateBinding) { + 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 + && app.settings.appUpdateChecksEnabled) { + app.updater.checkForUpdates() + }) + } + Divider() + automationRow(icon: "arrow.clockwise.circle", + title: AppText.string("automation.autoRestart", defaultValue: "Auto-restart crashed containers"), + detail: app.settings.autoRestartEnabled + ? AppText.string("automation.autoRestart.detail", defaultValue: "Restarts containers that exit unexpectedly") + : AppText.string("status.off", defaultValue: "Off"), + isOn: settingBinding(\.autoRestartEnabled)) { EmptyView() } + Divider() + 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: @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) + } + } + + /// A binding to a `SettingsStore` boolean (the store is a class, so its key paths are writable). + private func settingBinding(_ keyPath: ReferenceWritableKeyPath) -> Binding { + Binding(get: { app.settings[keyPath: keyPath] }, set: { app.settings[keyPath: keyPath] = $0 }) + } + + /// The Sparkle toggle writes through to both the persisted setting and the live updater. + private var appUpdateBinding: Binding { + Binding(get: { app.settings.appUpdateChecksEnabled }, + set: { app.settings.appUpdateChecksEnabled = $0; app.updater.automaticallyChecks = $0 }) + } + + private func backgroundTaskDetail(now: Date) -> String { + if let last = app.imageUpdateLastRunDate { + return "Last ran \(last.formatted(date: .omitted, time: .shortened))" + } + return app.imageUpdateNextRunDate <= now ? "Ready to run" : "Not run yet" + } + + private func countdown(to date: Date, now: Date) -> String { + let seconds = max(0, Int(date.timeIntervalSince(now))) + if seconds == 0 { return "due now" } + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + let secs = seconds % 60 + if hours > 0 { return String(format: "%dh %02dm", hours, minutes) } + if minutes > 0 { return String(format: "%dm %02ds", minutes, secs) } + return "\(secs)s" + } + + private var pruneBinding: Binding { + Binding(get: { pruneTarget != nil }, set: { if !$0 { pruneTarget = nil } }) + } + + private func prune(_ target: PruneTarget) async { + guard let client = app.client else { return } + do { + switch target { + 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() + } catch let error as Core.Command.Error { app.flash(error.appDisplayMessage) } + catch { app.flash(error.appDisplayMessage) } + } + + private func reclaimAll() async { + guard let client = app.client else { return } + if let error = await app.captured({ + 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() + } + + // MARK: Runtime + + 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.containerRuntime", defaultValue: "Container runtime")).designHeadlineLabelStyle() + UI.Badge.Status(text: app.serviceLabel, + tint: app.serviceHealthy ? .green : .orange) + Spacer(minLength: 0) + if let version = app.systemStatus?.apiServerVersion { + Text(version).designSecondaryMonospacedCaption() + .textSelection(.enabled) + } + } + 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")) + 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 { UI.State.ProgressIndicator() } + } + } + + private func run(_ action: @escaping () async -> Void) { + working = true + Task { await action(); working = false } + } +} 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 78% rename from Sources/Contained/Migration/AppStateEnvelope.swift rename to Sources/ContainedApp/Migration/AppStateEnvelope.swift index 898838a3..c8663f5c 100644 --- a/Sources/Contained/Migration/AppStateEnvelope.swift +++ b/Sources/ContainedApp/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/ContainedApp/Migration/DowngradeDecisionView.swift b/Sources/ContainedApp/Migration/DowngradeDecisionView.swift new file mode 100644 index 00000000..f006bb7b --- /dev/null +++ b/Sources/ContainedApp/Migration/DowngradeDecisionView.swift @@ -0,0 +1,32 @@ +import SwiftUI +import ContainedUI + +struct DowngradeDecisionView: View { + let schemaVersion: Int + var onExportAndReset: () -> Void + var onKeep: () -> Void + var onQuit: () -> Void + + var body: some View { + 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.")) + .designSecondaryValueStyle() + .fixedSize(horizontal: false, vertical: true) + + 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() } + Button(AppText.string("downgrade.quitContained", defaultValue: "Quit Contained"), role: .cancel) { onQuit() } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(UI.Layout.Spacing.l) + .frame(width: UI.Panel.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 87% rename from Sources/Contained/Migration/StateMigrator.swift rename to Sources/ContainedApp/Migration/StateMigrator.swift index 694e45b3..a05f3824 100644 --- a/Sources/Contained/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/Contained/Features/MenuBar/MenuBarContent.swift b/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift similarity index 70% rename from Sources/Contained/Features/MenuBar/MenuBarContent.swift rename to Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift index 2f67f788..565e6f80 100644 --- a/Sources/Contained/Features/MenuBar/MenuBarContent.swift +++ b/Sources/ContainedApp/Navigation/MenuBar/MenuBarContent.swift @@ -1,21 +1,25 @@ import SwiftUI -import AppKit import ContainedCore +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. struct MenuBarContent: View { + @Environment(\.openURL) private var openURL @Environment(AppModel.self) private var app @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 statusRuntimeKind: Core.Runtime.Kind? { + app.serviceControlRuntimeKind ?? app.firstRuntimeKind(supporting: .systemStatus, readyOnly: false) + } private var cliLabel: String { switch app.bootstrap { case .ready: - return app.cliVersion.map { "CLI v\($0)" } ?? "CLI ready" + return statusRuntimeKind.flatMap { app.runtimeVersion(for: $0) }.map { "CLI v\($0)" } ?? "CLI ready" case .checking: return "Checking CLI" case .cliMissing: @@ -28,7 +32,7 @@ struct MenuBarContent: View { } var body: some View { - VStack(alignment: .leading, spacing: 12) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.m) { header Divider() @@ -44,12 +48,16 @@ struct MenuBarContent: View { Menu("Service") { statusItem Divider() - if app.serviceHealthy { - Button("Stop Service") { Task { await app.stopService() } } + if app.serviceControlRuntimeAvailable { + 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 Runtime Connection") { Task { await app.retryBootstrap() } } } - Button("Restart Service") { Task { await app.restartService() } } } Menu("Containers") { @@ -59,7 +67,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) } } } } @@ -71,7 +79,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) } } } } @@ -87,7 +95,7 @@ struct MenuBarContent: View { Divider() Button("New Volume…") { activate(); route(.createVolume) } Button("New Network…") { activate(); route(.createNetwork) } - Button("Import Compose…") { activate(); ComposeImport.pickAndImport(app: app, ui: ui) } + Button("Import Compose…") { activate(); route(.importCompose) } .disabled(!app.settings.composeImportEnabled) } @@ -135,59 +143,57 @@ struct MenuBarContent: View { Button("About Contained") { activate(); openSettings(to: .about) } Button("Reveal CLI Binary in Finder") { activate(); revealCLIBinary() } Divider() - Button("Release Notes") { activate(); NSWorkspace.shared.open(Links.releasesURL) } - Button("Troubleshooting") { activate(); NSWorkspace.shared.open(Links.troubleshootingURL) } - Button("Keyboard Shortcuts") { activate(); NSWorkspace.shared.open(Links.shortcutsURL) } + Button("Release Notes") { activate(); openURL(Links.releasesURL) } + Button("Troubleshooting") { activate(); openURL(Links.troubleshootingURL) } + Button("Keyboard Shortcuts") { activate(); openURL(Links.shortcutsURL) } } Divider() footerRow } - .padding(14) - .frame(width: 340) + .padding(UI.MenuBar.Padding.all) + .frame(width: UI.MenuBar.Size.width) } @ViewBuilder private var header: some View { - VStack(alignment: .leading, spacing: 6) { + 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: 10) { - 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 { - VStack(alignment: .leading, spacing: 8) { - 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: 8) { + 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) } @@ -196,10 +202,10 @@ struct MenuBarContent: View { } private var footerRow: some View { - HStack(spacing: 8) { + HStack(spacing: UI.Layout.Spacing.s) { Button("Open Contained") { activate() } Spacer(minLength: 0) - Button("Quit") { NSApplication.shared.terminate(nil) } + Button("Quit") { Platform.quit() } } .buttonStyle(.borderless) } @@ -217,18 +223,6 @@ struct MenuBarContent: View { .buttonStyle(.borderless) } - @ViewBuilder - private func infoRow(_ title: String, value: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(title) - .foregroundStyle(.secondary) - .frame(width: 78, alignment: .leading) - Text(value) - .foregroundStyle(.primary) - Spacer(minLength: 0) - } - } - @ViewBuilder private func disabledPlaceholder(_ text: String) -> some View { Button(text) { } @@ -237,21 +231,17 @@ 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. private func activate() { - NSApplication.shared.activate(ignoringOtherApps: true) - for window in NSApplication.shared.windows where window.canBecomeMain { - window.makeKeyAndOrderFront(nil) - break - } + Platform.activateMainWindow() } - private func containerName(for snapshot: ContainerSnapshot) -> String { + private func containerName(for snapshot: Core.Container.Snapshot) -> String { app.containerStyle(for: snapshot).displayName(fallback: snapshot.id) } @@ -282,7 +272,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 } - NSWorkspace.shared.activateFileViewerSelecting([url]) + guard let url = app.runtimeCLIURL(for: .appleContainer) else { return } + Platform.revealInFinder(url) } } diff --git a/Sources/Contained/Navigation/ClassicShell.swift b/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift similarity index 65% rename from Sources/Contained/Navigation/ClassicShell.swift rename to Sources/ContainedApp/Navigation/Shell/ClassicShell.swift index 8a7d3b0a..1c2e1c1c 100644 --- a/Sources/Contained/Navigation/ClassicShell.swift +++ b/Sources/ContainedApp/Navigation/Shell/ClassicShell.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedUX +import ContainedUI import SwiftData import ContainedCore @@ -39,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(\.appSafeAreas, toolbarSafeAreas) + .environment(\.morphSafeAreaManager, toolbarSafeAreaManager) .ignoresSafeArea(.container, edges: .vertical) } } - .environment(\.appSafeAreas, AppSafeAreaManager(system: EdgeInsets())) + .environment(\.morphSafeAreaManager, UX.SafeArea.Manager(system: EdgeInsets())) } private var detailPage: some View { - let insets = toolbarSafeAreas.insets(AppSafeAreaPolicy(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. @@ -55,8 +57,8 @@ struct ClassicShell: View { .ignoresSafeArea(.container, edges: .vertical) } - private var toolbarSafeAreas: AppSafeAreaManager { - AppSafeAreaManager(system: EdgeInsets(), + private var toolbarSafeAreaManager: UX.SafeArea.Manager { + UX.SafeArea.Manager(system: EdgeInsets(), topToolbarHeight: AppToolbar.bandHeight, bottomToolbarHeight: AppToolbar.bandHeight) } @@ -83,7 +85,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 { @@ -91,8 +93,7 @@ private struct AppSidebar: View { Spacer() if let badge = badge(for: section) { Text(badge) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) + .designSecondaryMonospacedDigitCaption() } } } icon: { @@ -164,9 +165,9 @@ private struct ClassicSectionPage: View { private struct BuildPage: View { var body: some View { - PageScaffold(symbol: "hammer", - title: "Build", - subtitle: "From a Dockerfile + build context") { + UI.Panel.PageScaffold(symbol: "hammer", + title: AppText.sectionBuild, + subtitle: AppText.string("build.subtitle.context", defaultValue: "From a Dockerfile + build context")) { EmptyView() } content: { BuildWorkspaceView() @@ -178,10 +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 inspectingNetwork: NetworkResource? - @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,27 +219,30 @@ private struct NetworksPage: View { } var body: some 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") - } - } + 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", + title: AppText.string("common.new", defaultValue: "New"), + help: AppText.string("network.newNetwork.lowercase", defaultValue: "New network")) { + ui.dispatch(.createNetwork) + }) } content: { + if let message = app.resourceInventoryErrors["networks"] { + UI.State.InlineStatus(message, isWorking: false) + } if sortedNetworks.isEmpty { - ContentUnavailableView("No networks", - systemImage: "network", - description: Text("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: Tokens.Space.s) { + LazyVStack(spacing: UI.Layout.Spacing.s) { ForEach(Array(networkSections.enumerated()), id: \.offset) { _, section in if ui.networkGrouping != .none { - ResourceBadgeText(text: section.title, font: .caption.weight(.semibold)) + UI.Badge.Text(text: section.title, font: .caption.weight(.semibold)) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Tokens.Space.xs) + .padding(.horizontal, UI.Layout.Spacing.xs) } ForEach(section.networks) { network in networkRow(network) @@ -250,55 +253,54 @@ private struct NetworksPage: View { } } .task { await app.refreshNetworks() } - .sheet(item: $inspectingNetwork) { JSONInspectorSheet(title: $0.name, value: $0) } - .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.")) } } - 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: { - VStack(alignment: .leading, spacing: 1) { - ResourceCardTitleText(text: network.name) - ResourceCardSubtitleText(text: networkSubtitle(network)) - } - } trailing: { - GlassRowMenu { networkMenu(network) } + private func networkRow(_ network: Core.Network.Resource) -> some View { + UI.Card.Scaffold(size: .medium, + elevated: false, + title: network.name, + subtitle: networkSubtitle(network)) { + UI.Card.IconChip(symbol: network.isBuiltin ? "network.badge.shield.half.filled" : "network", + tint: network.isBuiltin ? .secondary : .accentColor) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + UI.Control.RowMenu(accessibilityLabel: AppText.string("menu.networkActions", defaultValue: "Network actions")) { + networkMenu(network) } + } bodyContent: { + EmptyView() } footerLeading: { if network.isBuiltin { - ResourceCardFooterMini { - Image(systemName: "network.badge.shield.half.filled").font(.caption2) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "network.badge.shield.half.filled", + size: .caption2) } text: { - ResourceCardMetricText(text: "Built-in") + UI.Card.MetricText(text: "Built-in") } } } footerActions: { EmptyView() + } widget: { + EmptyView() } .contextMenu { networkMenu(network) } } @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") - } + private func networkMenu(_ network: Core.Network.Resource) -> some View { + UI.Copy.ValueLabel("Copy Name", value: network.name) if let subnet = network.status?.ipv4Subnet { - Button { copyToPasteboard(subnet) } label: { - Label("Copy IPv4 Subnet", systemImage: "network") - } + UI.Copy.ValueLabel("Copy IPv4 Subnet", value: subnet, systemName: "network") } if !network.isBuiltin { Divider() @@ -308,7 +310,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, @@ -320,7 +322,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 @@ -332,15 +334,15 @@ 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]) + _ = try await client.deleteNetworks([network.name], runtimeKind: network.runtimeKind) await app.refreshNetworks() - } catch let error as CommandError { - app.flash(error.userMessage) + } catch let error as Core.Command.Error { + app.flash(error.appDisplayMessage) } catch { - app.flash(error.localizedDescription) + app.flash(error.appDisplayMessage) } } } @@ -348,7 +350,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 @@ -358,26 +360,26 @@ private struct ImagesPage: View { ZStack { ToolbarUpdatesPanel(showClose: false, coordinateSpaceName: pageImageSpace, + hiddenImageGroupID: presented ? detail?.id : nil, onOpenImage: openImageDetail, onClose: {}) - .task { await app.refreshImagesIfStale(force: true) } 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) { + UX.Morph.SingleSurfaceExpander(isPresented: detailBinding, + originFrame: usableSourceFrame ?? fallbackSourceFrame(in: viewport.size), + target: .anchored(size: UI.Panel.Size.imageDetail, + safeArea: imageDetailSafeAreaPolicy, + margin: 16), + backdropStyle: .dim, + showsBackdrop: true, + closeRequestToken: closeRequestToken, + onBackdropTap: closeDetail) { ToolbarImageGroupCard(group: currentGroup(detail), isExpanded: true, onTap: {}, onClose: closeDetail) } - .environment(\.appSafeAreas, imageDetailSafeAreas) + .environment(\.morphSafeAreaManager, imageDetailSafeAreaManager) .zIndex(10) } } @@ -387,13 +389,13 @@ private struct ImagesPage: View { private let pageImageSpace = "imagesPage" - private var imageDetailSafeAreaPolicy: AppSafeAreaPolicy { - ui.toolbarUIEnabled ? AppSafeAreaPolicy(excluding: .both, padding: .small) : .content + private var imageDetailSafeAreaPolicy: UX.SafeArea.Policy { + ui.toolbarUIEnabled ? UX.SafeArea.Policy(excluding: .both, padding: .small) : .content } - private var imageDetailSafeAreas: AppSafeAreaManager { - guard ui.toolbarUIEnabled else { return AppSafeAreaManager(system: EdgeInsets()) } - return AppSafeAreaManager(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) } @@ -423,7 +425,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 @@ -433,7 +435,7 @@ private struct ImagesPage: View { closeRequestToken &+= 1 } - private func currentGroup(_ group: LocalImageTagGroup) -> LocalImageTagGroup { - LocalImageTagGroup.groups(for: app.images).first { $0.id == group.id } ?? group + private func currentGroup(_ group: Core.Image.LocalTagGroup) -> Core.Image.LocalTagGroup { + app.localImageGroups().first { $0.id == group.id } ?? group } } diff --git a/Sources/Contained/Navigation/RootView.swift b/Sources/ContainedApp/Navigation/Shell/RootView.swift similarity index 59% rename from Sources/Contained/Navigation/RootView.swift rename to Sources/ContainedApp/Navigation/Shell/RootView.swift index ce08c9df..cee53d79 100644 --- a/Sources/Contained/Navigation/RootView.swift +++ b/Sources/ContainedApp/Navigation/Shell/RootView.swift @@ -1,7 +1,8 @@ import SwiftUI -import AppKit +import ContainedUX import UniformTypeIdentifiers import ContainedCore +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. @@ -13,6 +14,10 @@ struct RootView: View { /// Image load/prune are global actions because they can be invoked from pages, toolbar panels, /// menus, and the command palette. @State private var pruningImages = false + @State private var importingImageArchive = false + @State private var importingComposeFile = false + @State private var exportingDowngradeBackup = false + @State private var downgradeBackupDocument: DataFileDocument? /// System logs are reachable from menus and the command palette while system resources can render /// as either a sidebar page or toolbar panel. @State private var showSystemLogs = false @@ -21,28 +26,33 @@ 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: {})) } .sheet(isPresented: downgradeBinding) { DowngradeDecisionView(schemaVersion: app.downgradeSchemaVersion ?? StateMigrator.currentSchemaVersion, - onExportAndReset: { app.exportForDowngradeAndReset() }, + onExportAndReset: prepareDowngradeBackupExport, onKeep: { app.resolveDowngradeByKeepingReadableData() }, - onQuit: { NSApplication.shared.terminate(nil) }) + onQuit: { Platform.quit() }) } .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() }) } + .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 switch action { case .loadImage: ui.pendingAction = nil; loadImageTar() + case .importCompose: ui.pendingAction = nil; importingComposeFile = true case .pruneImages: ui.pendingAction = nil; pruningImages = true case .registryLogin: ui.pendingAction = nil; ui.openSettings(to: .registries) case .systemLogs: ui.pendingAction = nil; showSystemLogs = true @@ -64,49 +74,64 @@ 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 } } return false } + .modifier(RootFileDialogs(importingImageArchive: $importingImageArchive, + importingComposeFile: $importingComposeFile, + exportingDowngradeBackup: $exportingDowngradeBackup, + downgradeBackupDocument: $downgradeBackupDocument, + onImageArchive: handleImportedImageArchive, + onComposeFile: handleImportedComposeFile, + onDowngradeBackupExport: handleDowngradeBackupExport)) // 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, Tokens.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, GlassButtonTintStyle(enabled: settings.buttonTintEnabled, + .environment(\.buttonTintStyle, UI.Theme.ButtonTintStyle(enabled: settings.buttonTintEnabled, tint: settings.buttonTint, opacity: settings.buttonTintOpacity, gradient: settings.buttonTintGradient, 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 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.toolbar.activeMorph) { _, _ in updateContainerStatsVisibility() } .onChange(of: settings.imageBuildEnabled) { _, enabled in if !enabled, ui.selectedSection == .build { ui.navigate(to: .images) @@ -133,7 +158,7 @@ struct RootView: View { private func toolbarShell(settings: SettingsStore) -> some View { GeometryReader { _ in ZStack { - ContentBackgroundLayer(material: settings.windowMaterial.nsMaterial) + UI.Theme.BackgroundLayer(material: settings.windowMaterial) toolbarContent } } @@ -148,11 +173,11 @@ struct RootView: View { private func classicShell(settings: SettingsStore) -> some View { ZStack { - ContentBackgroundLayer(material: settings.windowMaterial.nsMaterial) + UI.Theme.BackgroundLayer(material: settings.windowMaterial) content .ignoresSafeArea(.container, edges: .vertical) } - .environment(\.appSafeAreas, AppSafeAreaManager(system: EdgeInsets())) + .environment(\.morphSafeAreaManager, UX.SafeArea.Manager(system: EdgeInsets())) .contextMenu { backgroundMenu() } } @@ -166,6 +191,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 { @@ -175,7 +205,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(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") } @@ -196,6 +226,10 @@ struct RootView: View { } } + private func updateContainerStatsVisibility() { + app.setContainerStatsVisible(ui.selectedSection == .containers && ui.toolbar.activeMorph == nil) + } + private func openSectionOrMorph(_ section: AppSection, morph: UIState.ToolbarMorph) { if app.settings.usesPanelNavigation { ui.toggleMorph(morph) @@ -204,44 +238,85 @@ 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) { - NSApplication.shared.appearance = mode.nsAppearance - } - /// Pick an image `.tar` and load it into the local store. private func loadImageTar() { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.allowsMultipleSelection = false - panel.allowedContentTypes = [.init(filenameExtension: "tar") ?? .data] - panel.message = "Choose an image tar archive" - guard panel.runModal() == .OK, let url = panel.url else { return } - app.loadImageTar(at: url) + importingImageArchive = true + } + + private func handleImportedImageArchive(_ result: Result) { + guard let url = importedURL(from: result) else { return } + loadImageTar(at: url) + } + + private func handleImportedComposeFile(_ result: Result) { + guard let url = importedURL(from: result) else { return } + ComposeImport.importFile(at: url, app: app, ui: ui) + } + + 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) } - await app.refreshImagesIfStale(force: true) + 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) } /// Toggle the front window between its zoomed (filled) and restored size — emulates the /// title-bar double-click now that there's no title bar to double-click. private func zoomFrontWindow() { - (NSApp.keyWindow ?? NSApp.mainWindow)?.zoom(nil) + Platform.zoomFrontWindow() + } + + private func importedURL(from result: Result) -> URL? { + switch result { + case .success(let url): + return url + case .failure(let error): + app.flash(error.appDisplayMessage) + return nil + } + } + + private func prepareDowngradeBackupExport() { + do { + downgradeBackupDocument = DataFileDocument(data: try app.configurationData()) + exportingDowngradeBackup = true + } catch { + app.flash(error.appDisplayMessage) + } + } + + private func handleDowngradeBackupExport(_ result: Result) { + defer { downgradeBackupDocument = nil } + switch result { + case .success: + app.resetIncompatibleLocalState() + app.downgradeSchemaVersion = nil + app.flash(AppText.exportedBackupAndReset) + case .failure(let error): + app.flash(error.appDisplayMessage) + } } @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) - .glassEffect(.regular, in: Capsule()) + UI.State.Banner(banner) .transition(.move(edge: .bottom).combined(with: .opacity)) } } @@ -265,3 +340,28 @@ struct RootView: View { } } + +private struct RootFileDialogs: ViewModifier { + @Binding var importingImageArchive: Bool + @Binding var importingComposeFile: Bool + @Binding var exportingDowngradeBackup: Bool + @Binding var downgradeBackupDocument: DataFileDocument? + var onImageArchive: (Result) -> Void + var onComposeFile: (Result) -> Void + var onDowngradeBackupExport: (Result) -> Void + + func body(content: Content) -> some View { + content + .fileImporter(isPresented: $importingImageArchive, + allowedContentTypes: UTType.imageArchives, + onCompletion: onImageArchive) + .fileImporter(isPresented: $importingComposeFile, + allowedContentTypes: UTType.composeDocuments, + onCompletion: onComposeFile) + .fileExporter(isPresented: $exportingDowngradeBackup, + document: downgradeBackupDocument, + contentTypes: UTType.containedBackupDocuments, + defaultFilename: "Contained Downgrade Backup.containedbackup", + onCompletion: onDowngradeBackupExport) + } +} diff --git a/Sources/Contained/Navigation/AppSection.swift b/Sources/ContainedApp/Navigation/State/AppSection.swift similarity index 72% rename from Sources/Contained/Navigation/AppSection.swift rename to Sources/ContainedApp/Navigation/State/AppSection.swift index 9e70726d..c2b6caf0 100644 --- a/Sources/Contained/Navigation/AppSection.swift +++ b/Sources/ContainedApp/Navigation/State/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/Contained/Navigation/PendingAction.swift b/Sources/ContainedApp/Navigation/State/PendingAction.swift similarity index 93% rename from Sources/Contained/Navigation/PendingAction.swift rename to Sources/ContainedApp/Navigation/State/PendingAction.swift index 3b9bd0db..7ca3cf9b 100644 --- a/Sources/Contained/Navigation/PendingAction.swift +++ b/Sources/ContainedApp/Navigation/State/PendingAction.swift @@ -2,6 +2,7 @@ enum PendingAction: Equatable { case runContainer case pullImage, loadImage, pruneImages + case importCompose case createVolume case createNetwork case registryLogin diff --git a/Sources/Contained/Navigation/UIState.swift b/Sources/ContainedApp/Navigation/State/UIState.swift similarity index 65% rename from Sources/Contained/Navigation/UIState.swift rename to Sources/ContainedApp/Navigation/State/UIState.swift index d852e45a..5bb4b393 100644 --- a/Sources/Contained/Navigation/UIState.swift +++ b/Sources/ContainedApp/Navigation/State/UIState.swift @@ -10,8 +10,8 @@ final class UIState { struct CreationPresentation { var entry: CreationEntry = .menu - var prefillSpec: RunSpec? - var editSnapshot: ContainerSnapshot? + var prefillSpec: ContainerFormState? + var editSnapshot: Core.Container.Snapshot? var returnEntry: CreationEntry? var searchQuery = "" var requestToken = 0 @@ -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 @@ -36,14 +35,32 @@ final class UIState { struct PrefillPresentation { var showRunSheet = false - var currentSpec: RunSpec? - var queue: [RunSpec] = [] + var currentSpec: ContainerFormState? + 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 @@ -61,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. @@ -70,89 +87,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? - - // 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 } - } + var editSheetSnapshot: Core.Container.Snapshot? // MARK: Actions @@ -163,12 +98,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() { @@ -185,7 +120,7 @@ final class UIState { func navigateForClassicFallback(_ action: PendingAction) { switch action { - case .runContainer: + case .runContainer, .importCompose: navigate(to: .containers) case .pullImage, .loadImage, .pruneImages: navigate(to: .images) @@ -206,16 +141,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. @@ -227,7 +162,7 @@ final class UIState { if !panelNavigationEnabled { switch action { case .runContainer: - presentCreate(RunSpec()) + presentCreate(ContainerFormState(runtimeKind: AppRuntimeIntent.placeholderKind)) return case .pullImage, .createVolume, .createNetwork, .activityHistory: navigateForClassicFallback(action) @@ -235,7 +170,7 @@ final class UIState { case .build: navigate(to: .build) return - case .loadImage, .pruneImages, .systemLogs: + case .loadImage, .pruneImages, .importCompose, .systemLogs: pendingAction = action return case .registryLogin: @@ -254,8 +189,8 @@ final class UIState { case .build: openCreationPanel(entry: .build) case .activityHistory: - activeMorph = .activity - case .loadImage, .pruneImages, .systemLogs: + toolbar.activeMorph = .activity + case .loadImage, .pruneImages, .importCompose, .systemLogs: pendingAction = action case .registryLogin: break @@ -264,13 +199,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(runtimeKind: AppRuntimeIntent.placeholderKind)) case .network: navigate(to: .networks) case .volume: @@ -282,16 +217,16 @@ 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, + func openCreationPanel(prefill spec: ContainerFormState, returningTo returnEntry: CreationEntry? = nil, searchQuery: String = "") { guard panelNavigationEnabled else { @@ -301,80 +236,81 @@ 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 } - 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 } } func runImage(_ reference: String, + runtimeKind: Core.Runtime.Kind, returningTo returnEntry: CreationEntry? = nil, searchQuery: String = "") { - var spec = RunSpec() + var spec = ContainerFormState(runtimeKind: runtimeKind) spec.image = reference guard panelNavigationEnabled else { presentCreate(spec) return } - prefillQueue = [] + prefill.queue = [] openCreationPanel(prefill: spec, returningTo: returnEntry, searchQuery: searchQuery) } - func useTemplate(_ spec: RunSpec) { + func useTemplate(_ spec: ContainerFormState) { guard panelNavigationEnabled else { 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 + func presentCreate(_ spec: ContainerFormState) { + prefill.currentSpec = spec + prefill.showRunSheet = true } /// 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 } - prefillQueue = Array(specs.dropFirst()) + 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) } } /// 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) } } - private func presentNextPrefill(_ spec: RunSpec) { + private func presentNextPrefill(_ spec: ContainerFormState) { if panelNavigationEnabled { openCreationPanel(prefill: spec) } else { diff --git a/Sources/Contained/Navigation/AppToolbar.swift b/Sources/ContainedApp/Navigation/Toolbar/AppToolbar.swift similarity index 57% rename from Sources/Contained/Navigation/AppToolbar.swift rename to Sources/ContainedApp/Navigation/Toolbar/AppToolbar.swift index d5941bb5..24d79e8b 100644 --- a/Sources/Contained/Navigation/AppToolbar.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/AppToolbar.swift @@ -1,4 +1,6 @@ import SwiftUI +import ContainedUX +import ContainedUI import SwiftData import ContainedCore @@ -10,16 +12,16 @@ 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 `Tokens.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(\.appSafeAreas) private var safeAreas + @Environment(\.morphSafeAreaManager) private var safeAreaManager @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 @@ -27,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 `UI.Toolbar` so the band, the safe-area /// manager, and the controls all agree. - static let bandHeight: CGFloat = Tokens.Toolbar.band + static let bandHeight: CGFloat = UI.Toolbar.Size.band var body: some View { ZStack(alignment: .top) { @@ -37,35 +39,35 @@ struct AppToolbar: View { .zIndex(40) VStack(spacing: 0) { topToolbarRow - .frame(height: Tokens.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: Tokens.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(ToolbarSlotKey.self) { slots = $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) } } } @@ -74,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) } @@ -82,17 +84,17 @@ struct AppToolbar: View { // MARK: Top Row private var topToolbarRow: some View { - HStack(spacing: Tokens.Toolbar.groupSpacing) { + HStack(spacing: UI.Toolbar.Spacing.groupSpacing) { if !isSidebarOpen { settingsZone ToolbarPageSwitcher() } ToolbarPageContextOptions() - Spacer(minLength: Tokens.Space.m) + Spacer(minLength: UI.Layout.Spacing.m) searchZone } - .padding(.leading, Tokens.Toolbar.outerPadding) - .padding(.trailing, Tokens.Toolbar.outerPadding) + .padding(.leading, UI.Toolbar.Spacing.outerPadding) + .padding(.trailing, UI.Toolbar.Spacing.outerPadding) .frame(maxWidth: .infinity) } @@ -100,13 +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 { - // 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) - .opacity(ui.activeMorph == .settings ? 0 : 1) + UI.Toolbar.VanitySlot() + .opacity(ui.toolbar.activeMorph == .settings ? 0 : 1) .background(singleSlotReader(.settings)) } @@ -116,26 +113,21 @@ struct AppToolbar: View { private var searchZone: some 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))]) - } - ) + .frame(width: UI.Toolbar.Size.searchMaxWidth, height: UI.Toolbar.Size.controlHeight) + .opacity(ui.toolbar.activeMorph == .palette ? 0 : 1) + .background(singleSlotReader(.palette)) } @ViewBuilder 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: Tokens.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) } } @@ -145,46 +137,51 @@ struct AppToolbar: View { // MARK: Bottom Row private var bottomToolbarRow: some View { - HStack(spacing: Tokens.Toolbar.groupSpacing) { + HStack(spacing: UI.Toolbar.Spacing.groupSpacing) { systemStatusButton ToolbarPageFilterOptions() - Spacer(minLength: Tokens.Space.m) + Spacer(minLength: UI.Layout.Spacing.m) bottomActionGroup } - .padding(.horizontal, Tokens.Toolbar.outerPadding) + .padding(.horizontal, UI.Toolbar.Spacing.outerPadding) .frame(maxWidth: .infinity) } 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: activity, style: .inline) - } else { - HStack(spacing: Tokens.Toolbar.searchIconGap) { - Image(systemName: systemStatusIcon) - .foregroundStyle(systemStatusColor) - .frame(width: Tokens.Toolbar.buttonItemHeight - Tokens.Toolbar.iconInnerPadding * 2) - Text(app.serviceLabel) - .foregroundStyle(.secondary) - .padding(.trailing, Tokens.Toolbar.iconInnerPadding * 2) - } + UI.Toolbar.StatusButton(help: app.activity?.title ?? "System \(app.serviceLabel)", + action: { openGlobalSectionOrPanel(.system, morph: .system) }) { + if let activity = app.activity { + UI.State.ActivityStatusIndicator(activity: UI.State.ActivityStatus(title: activity.title, + detail: activity.detail, + fraction: activity.fraction), + style: .inline) + } else { + 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: 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) } + 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) + }, + UI.Action.Item(systemName: "bookmark", help: AppText.string("section.templates", defaultValue: "Templates")) { + openGlobalSectionOrPanel(.templates, morph: .templates) + } + ]) ActivityToolbarButton() } .opacity(isBottomGroupMorphActive ? 0 : 1) @@ -196,44 +193,45 @@ 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: Tokens.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: Tokens.PanelSize.images), + target: toolbarMorphTarget(for: .updates, size: UI.Panel.Size.images), showsBackdrop: false, - closeRequestToken: ui.morphCloseRequestToken, + closeRequestToken: ui.toolbar.closeRequestToken, onExpansionChange: setMorphBackdropExpanded) { - ToolbarUpdatesPanel(onOpenImage: openToolbarImageDetail) { + ToolbarUpdatesPanel(hiddenImageGroupID: toolbarImageDetailPresented ? toolbarImageDetail?.id : nil, + onOpenImage: openToolbarImageDetail) { ui.requestMorphClose(.updates) } } @@ -243,15 +241,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) { + UX.Morph.SingleSurfaceExpander(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: {}, @@ -262,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: Tokens.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) @@ -278,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: Tokens.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) @@ -294,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: Tokens.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) } } @@ -308,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: Tokens.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) } } @@ -321,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() @@ -354,6 +352,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 { @@ -368,34 +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 { - GeometryReader { proxy in - let frame = proxy.frame(in: .named(Self.space)) - Color.clear.preference(key: ToolbarSlotKey.self, - value: Dictionary(uniqueKeysWithValues: morphs.map { ($0, frame) })) - } + UX.Measurement.SourceFrameReader(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))]) - } + 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) -> AppSafeAreaPolicy { + private func toolbarMorphSafeArea(for morph: UIState.ToolbarMorph) -> UX.SafeArea.Policy { switch morph { - case .settings: AppSafeAreaPolicy(excluding: .both, padding: .small) - case .palette: AppSafeAreaPolicy(excluding: .bottom, padding: .small) - default: AppSafeAreaPolicy(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) -> AppMorphTarget { + placement: UX.Panel.Placement = .anchored) -> UX.Morph.Target { let safeArea = toolbarMorphSafeArea(for: morph) switch placement { case .anchored: @@ -405,22 +401,22 @@ struct AppToolbar: View { } } - private var rowTopInset: CGFloat { Tokens.Toolbar.topPadding } + private var rowTopInset: CGFloat { UI.Toolbar.Spacing.topPadding } private var bottomRowInset: CGFloat { - max(Tokens.Toolbar.outerPadding, safeAreas.system.bottom + Tokens.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 } } @@ -438,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: @@ -456,14 +452,14 @@ struct AppToolbar: View { } private var toolbarImageDetailSize: CGSize { - Tokens.PanelSize.imageDetail + UI.Panel.Size.imageDetail } - private func currentToolbarImageGroup(_ group: LocalImageTagGroup) -> LocalImageTagGroup { - LocalImageTagGroup.groups(for: app.images).first { $0.id == group.id } ?? group + 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 @@ -492,27 +488,16 @@ 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 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 { ui.toggleMorph(.activity) } else { ui.navigate(to: .activity) } - }) { - Image(systemName: hasUnread ? "bell.fill" : "bell") - } - } -} - -/// 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 } + } + ]) } } diff --git a/Sources/ContainedApp/Navigation/Toolbar/Panels/PaletteResultCard.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/PaletteResultCard.swift new file mode 100644 index 00000000..6f8c0829 --- /dev/null +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/PaletteResultCard.swift @@ -0,0 +1,309 @@ +import SwiftUI +import ContainedUI +import ContainedCore + +/// One result row in the command palette. Renders a different card layout per `PaletteItem.visual` +/// (plain action, container, image group/tag, volume, network), with selection highlighting. +struct PaletteResultCard: View { + @Environment(AppModel.self) private var app + let item: PaletteItem + let selected: Bool + var action: () -> Void + + var body: some View { + switch item.visual { + case .plain: + plainCard + case .container(let snapshot): + containerCard(snapshot) + case .imageGroup(let group): + imageGroupCard(group) + case .imageTag(let reference, let groupID): + imageTagCard(reference, groupID: groupID) + case .volume(let volume): + designCard(symbol: "externaldrive", + title: volume.name, + 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 + ? 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) + } + } + + private var plainCard: some View { + UI.Card.Scaffold(size: .small, + isSelected: selected, + fill: nil, + fillOpacity: selected ? UI.Card.Metric.selectedSubtleFillOpacity : UI.Card.Metric.plainFillOpacity, + elevated: false, + onTap: action, + title: item.title, + subtitle: item.subtitle) { + UI.Card.IconChip(symbol: item.icon, + tint: item.tint, + backgroundOpacity: selected + ? UI.Card.Metric.iconSelectedBackgroundOpacity + : UI.Card.Metric.iconBackgroundOpacity) + } titleAccessory: { + UI.Badge.Text(text: item.kind.localizedTitle, + font: .caption2.weight(.semibold), + foreground: selected ? .accentColor : .secondary) + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + accessory + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() + } + .selectionFill() + .accessibilityAddTraits(selected ? .isSelected : []) + } + + 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 + return UI.Card.Scaffold(size: cardSize, + isSelected: selected, + fill: style.fillBackground ? style.color : nil, + fillOpacity: selected ? UI.Card.Metric.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) { + UI.Card.IconChip(symbol: style.symbol, + tint: style.color, + backgroundOpacity: selected + ? UI.Card.Metric.iconSelectedBackgroundOpacity + : UI.Card.Metric.iconBackgroundOpacity) + } titleAccessory: { + UI.Badge.Text(text: snapshot.state.rawValue.capitalized, + font: .caption2.weight(.semibold), + foreground: snapshot.state == .running ? .green : .secondary) + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + EmptyView() + } footerLeading: { + containerStatus(snapshot) + } footerActions: { + accessory + } widget: { + if snapshot.state == .running { + containerPaletteWidget(snapshot) + } + } + .selectionFill() + .accessibilityAddTraits(selected ? .isSelected : []) + } + + private func imageGroupCard(_ group: Core.Image.LocalTagGroup) -> some View { + ToolbarImageGroupCard(group: group, isExpanded: false, onTap: action, onClose: {}) + .designCardSelectionOverlay(when: selected) + .accessibilityAddTraits(selected ? .isSelected : []) + } + + private func imageTagCard(_ reference: String, groupID: String) -> some View { + let style = app.imageGroupStyle(forID: groupID) + return UI.Card.Scaffold(size: .medium, + isSelected: selected, + fill: style.fillBackground ? style.color : nil, + fillOpacity: selected ? UI.Card.Metric.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) { + UI.Card.IconChip(symbol: "tag", + tint: style.color, + backgroundOpacity: selected + ? UI.Card.Metric.iconSelectedBackgroundOpacity + : UI.Card.Metric.iconBackgroundOpacity) + } titleAccessory: { + UI.Badge.Text(text: "Tag", font: .caption2.weight(.semibold)) + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + EmptyView() + } footerLeading: { + UI.Card.FooterMini { + UI.Symbol.Image(systemName: style.symbol, + tint: style.color, + size: .caption2) + } text: { + UI.Card.MetricText(text: "Image") + .designSecondaryValueStyle() + } + } footerActions: { + accessory + } widget: { + EmptyView() + } + .selectionFill() + .accessibilityAddTraits(selected ? .isSelected : []) + } + + private func designCard(symbol: String, title: String, subtitle: String, footer: String) -> some View { + UI.Card.Scaffold(size: .small, + isSelected: selected, + fill: nil, + fillOpacity: selected ? UI.Card.Metric.selectedResourceFillOpacity : UI.Card.Metric.plainFillOpacity, + elevated: false, + onTap: action, + title: title, + subtitle: footer) { + UI.Card.IconChip(symbol: symbol, + tint: item.tint, + backgroundOpacity: selected + ? UI.Card.Metric.iconSelectedBackgroundOpacity + : UI.Card.Metric.iconBackgroundOpacity) + } titleAccessory: { + UI.Badge.Text(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: UI.Theme.Tint) -> some View { + UI.Card.Scaffold(size: .small, + isSelected: selected, + fill: tint.color, + fillOpacity: selected ? UI.Card.Metric.selectedTintFillOpacity : UI.Card.Metric.selectedSubtleFillOpacity, + elevated: false, + onTap: action, + title: tint.localizedDisplayName, + subtitle: item.title) { + UI.Control.TintSwatch(color: tint.color, followsAccent: tint.followsAccent) + } titleAccessory: { + UI.Badge.Text(text: app.settings.accentTint == tint ? AppText.current : AppText.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 : []) + } + + private func containerPaletteWidget(_ snapshot: Core.Container.Snapshot) -> some View { + UI.Card.WidgetGroup { + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "clock", size: .caption2) + } text: { + UI.Card.MetricText(text: Format.uptime(since: snapshot.startedDate)) + } + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "network", size: .caption2) + } text: { + UI.Card.MetricText(text: "\(snapshot.status.networks.count)") + } + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "shippingbox", size: .caption2) + } text: { + UI.Card.MetricText(text: Format.shortImage(snapshot.image)) + } + } + } + + @ViewBuilder + private var accessory: some View { + switch item.accessory { + case .run: + if selected { + UI.Symbol.Image(systemName: "return", + tone: .tertiary, + size: .caption) + .frame(width: UI.Control.Size.chip, height: UI.Control.Size.chip) + } else { + UI.List.RowChevron() + .frame(width: UI.Control.Size.chip, height: UI.Control.Size.chip) + } + case .toggle(let isOn, let set): + Toggle("", isOn: Binding { + isOn() + } set: { newValue in + set(newValue) + }) + .labelsHidden() + .toggleStyle(.switch) + case .disabled(let reason): + Text(reason) + .designTertiaryCaption() + } + } + + private func repositoryTitle(_ reference: String) -> String { + let parsed = Core.Registry.ImageReference.parse(reference) + return parsed.repository.split(separator: "/").map(String.init).last ?? parsed.repository + } + + private func imageUpdateText(_ status: Core.Image.UpdateStatus) -> String { + switch status.state { + case .unknown: return "Not checked" + case .checking: return "Checking for updates" + case .current: return "Up to date" + case .updateAvailable: return "Update available" + case .error: return "Update check failed" + } + } + + 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, + size: .caption2) + } text: { + UI.Card.MetricText(text: snapshot.state.rawValue.capitalized) + .designSecondaryValueStyle() + } + } +} + +/// Collects toolbar button slot frames (in the toolbar coordinate space) so a morph can grow from the +/// exact button that opened it. diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift similarity index 55% rename from Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift rename to Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift index 4ffca27d..fa8384df 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarCommandPalette.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarCommandPalette.swift @@ -1,6 +1,7 @@ import SwiftUI +import ContainedUX +import ContainedUI import SwiftData -import AppKit import ContainedCore /// A labelled group of palette rows (a heading + its items). Title is nil for a query's flat ranked @@ -25,28 +26,28 @@ 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 @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? - 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] { 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() @@ -72,45 +73,45 @@ struct ToolbarCommandPalette: View { private var hubSearchKey: String { "\(scope == .dockerHub)|\(trimmedQuery)" } var body: some View { - MorphPanelScaffold(width: Tokens.PanelSize.palette.width, scrolls: false) { + UI.Panel.Scaffold(width: UI.Panel.Size.palette.width, scrolls: false) { VStack(spacing: 0) { - VStack(spacing: Tokens.Space.xs) { + VStack(spacing: UI.Layout.Spacing.xs) { fieldRow - .frame(height: Tokens.Toolbar.searchOpenHeaderHeight) + .frame(height: UI.Toolbar.Size.searchOpenHeaderHeight) inlineSearchRow } - .padding(.bottom, Tokens.Space.s) - Divider().opacity(0.5) + .padding(.bottom, UI.Layout.Spacing.s) + Divider() } } content: { resultsList } footer: { footerBar } - .morphPanelSize(Tokens.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: Tokens.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 ?? "Search or run a command…", 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 } @@ -118,92 +119,82 @@ 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) - .help("Clear search").accessibilityLabel("Clear search") + 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, Tokens.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: { - 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) + Button { ui.search.scope = nil } label: { + UI.Badge.ScopeLabel(symbol: scope.symbol, title: scope.title) } .buttonStyle(.plain) - .help("Remove \(scope.title) scope") - .accessibilityLabel("Remove \(scope.title) scope") + .help(AppText.removeScopeAccessibility(scope.title)) + .accessibilityLabel(AppText.removeScopeAccessibility(scope.title)) .fixedSize() } private var inlineSearchRow: some View { - HStack(spacing: Tokens.Space.s) { + HStack(spacing: UI.Layout.Spacing.s) { if let scope { - ResourceBadgeText(text: scopeCountText(scope)) + UI.Badge.Text(text: scopeCountText(scope)) Spacer() - Button { ui.paletteScope = nil } label: { - Label("Commands", 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("Back to commands") } else { - ResourceBadgeText(text: "\(flatItems.count) match\(flatItems.count == 1 ? "" : "es")") - ResourceBadgeText(text: "\(localImageMatches) local image\(localImageMatches == 1 ? "" : "s")") + 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("Search Docker Hub", 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("Search Docker Hub for \(trimmedQuery)") } } } - .padding(.horizontal, Tokens.Space.l) + .padding(.horizontal, UI.Layout.Spacing.l) } 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) } } private var resultsList: some View { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: Tokens.Space.s) { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { let indexed = indexedSections // one evaluation → stable positional indices if indexed.isEmpty { emptyState @@ -214,26 +205,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(Tokens.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, Tokens.Space.xs) - .padding(.top, Tokens.Space.xs) + UI.State.SectionLabel(title.uppercased()) + .padding(.horizontal, UI.Layout.Spacing.xs) + .padding(.top, UI.Layout.Spacing.xs) .frame(maxWidth: .infinity, alignment: .leading) } @@ -242,57 +231,55 @@ struct ToolbarCommandPalette: View { if scope == .dockerHub { dockerHubPlaceholder } else { - ContentUnavailableView { - Label("No matches", systemImage: "magnifyingglass") - } description: { - Text("Try a setting, image, container, network, or action.") + UI.Surface.Content(minHeight: 260) { + UI.State.Empty(AppText.paletteNoMatches, + systemImage: "magnifyingglass", + description: AppText.paletteNoMatchesDescription, + padding: 0) } - .frame(maxWidth: .infinity, minHeight: 260) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) } } private var dockerHubPlaceholder: some View { - VStack(spacing: Tokens.Space.s) { + UI.Surface.Content(minHeight: 260) { if hubSearching { - ProgressView() - Text("Searching Docker Hub…").font(.callout).foregroundStyle(.secondary) + UI.State.Loading(AppText.paletteSearchingDockerHub, padding: 0) } 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) + UI.State.Empty(AppText.paletteCouldNotSearchDockerHub, + systemImage: "wifi.exclamationmark", + description: hubError, + tone: .warning, + padding: 0) } 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) + UI.State.Empty(trimmedQuery.isEmpty ? AppText.paletteTypeToSearchDockerHub : AppText.paletteNoImagesFound(trimmedQuery), + systemImage: "magnifyingglass", + padding: 0) } } - .frame(maxWidth: .infinity, minHeight: 260) - .glassSurface(.regular, cornerRadius: Tokens.Radius.card, shadow: false) } private var footerBar: some View { - HStack(spacing: Tokens.Space.m) { - keyboardHint("↑↓", "Select") - keyboardHint("return", "Run") - keyboardHint("esc", scope == nil ? "Close" : "Clear scope") + 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 { - ResourceBadgeText(text: selected.kind.rawValue) + UI.Badge.Text(text: selected.kind.localizedTitle) } } - .padding(.horizontal, Tokens.Space.l) - .padding(.vertical, Tokens.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 @@ -305,16 +292,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) - } + UI.Control.KeyboardHint(key, label) } // MARK: Sections @@ -331,12 +309,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: "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) + ui.runImage(rec.reference, runtimeKind: runtimeKind, returningTo: .search) } } } @@ -344,32 +324,38 @@ 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], 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) } } } 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 } - return matched.map { group in - PaletteItem(title: "Run \(Format.shortImage(group.primaryReference))", - subtitle: "\(group.references.count) tag\(group.references.count == 1 ? "" : "s")", - 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) } } } @@ -382,20 +368,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) { @@ -406,14 +392,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() } @@ -431,13 +417,13 @@ 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 { guard !Task.isCancelled else { return } hubResults = [] - hubError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + hubError = error.appDisplayMessage } } diff --git a/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift new file mode 100644 index 00000000..19b034ac --- /dev/null +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarImageGroupCard.swift @@ -0,0 +1,754 @@ +import SwiftUI +import ContainedUX +import ContainedUI +import SwiftData +import ContainedCore + +struct ToolbarImageGroupCard: View { + @Environment(AppModel.self) private var app + @Environment(UIState.self) private var ui + let group: Core.Image.LocalTagGroup + let isExpanded: Bool + var onTap: () -> Void + var onClose: () -> Void + + @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. + @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? + @State private var savedImageArchiveURL: URL? + @State private var savedImageArchiveName = "" + @State private var movingSavedImageArchive = false + + enum ImageDetailPage: Hashable { + case tags + case history(String) + case tag(String) + 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 tone: UI.State.Tone + var action: PushAction + } + + private var spring: Animation { .spring(response: 0.42, dampingFraction: 0.86) } + + var body: some View { + Group { + if isExpanded { + rootCard + .morphPanelPlacement(.anchored) + .animation(spring, value: page) + } else { + rootCard + } + } + .confirmationDialog("Delete \(Format.shortImage(deletingTag?.reference ?? ""))?", + isPresented: deletingBinding, + 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) } } + Button("Remove all unreferenced", role: .destructive) { Task { await prune(all: true) } } + } message: { + Text("Unused images aren't referenced by any container. “All” also removes dangling layers.") + } + .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.") + } + .fileMover(isPresented: $movingSavedImageArchive, + file: savedImageArchiveURL) { result in + handleSavedImageArchiveMove(result) + } + } + + private var rootCard: some View { + let image = primaryImage(group) + let status = app.imageUpdateStatus(for: group.primaryReference) + let resolved = app.imageGroupStyle(for: group) + return UI.Card.Scaffold(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 { + CardStyleButton(style: resolved, + target: .imageGroup(id: group.id, reference: group.primaryReference), + help: "Customize image style", + accessibilityLabel: AppText.customizeImageStyleAccessibility(Format.shortImage(image.reference))) + } else { + imageChip(resolved) + } + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + imageBody(group) + } footerLeading: { + imageFooterTagCount(group) + imageFooterInfo(status) + } footerActions: { + imageFooterActions(group) + } widget: { + EmptyView() + } + .contextMenu { cardMenu(group) } + } + + // MARK: Detail sub-pages + + @ViewBuilder + private func imageBody(_ group: Core.Image.LocalTagGroup) -> 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) + } + } + } + + 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 + let history = variant?.config?.history ?? [] + return imagePageBody(title: AppText.string("image.history", defaultValue: "History"), subtitle: Format.shortImage(reference)) { + if history.isEmpty { + 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 { + UI.Card.InsetSection { + UI.List.Stack { + ForEach(Array(history.enumerated()), id: \.offset) { _, entry in + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.xxs) { + Text(entry.createdBy ?? entry.comment ?? "—") + .designMonospacedCaption() + .frame(maxWidth: .infinity, alignment: .leading) + if let created = entry.created { + Text(created.formatted(date: .abbreviated, time: .shortened)) + .designSecondaryCaption() + } + } + .padding(UI.Layout.Spacing.s) + .frame(maxWidth: .infinity, alignment: .leading) + .subtleTileBackground() + } + } + } + } + } + } + + private func tagPage(_ source: String) -> some View { + imagePageBody(title: AppText.addTag, subtitle: Format.shortImage(source)) { + UI.Card.InsetSection { + UI.Panel.Field(label: AppText.string("image.tag.source", defaultValue: "Source")) { + Text(Format.shortImage(source)).designSecondaryValueStyle() + } + 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) } + } + } + HStack { + Spacer() + if tagBusy { UI.State.ProgressIndicator() } + UI.Action.TextButton(title: AppText.addTag, + systemName: "checkmark", + prominence: .prominent, + isEnabled: !tagTarget.trimmingCharacters(in: .whitespaces).isEmpty && !tagBusy) { + submitTag(source: source) + } + } + } + } + + 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, + let runtimeKind = pushRuntimeKind(for: reference) { + UI.Console.Stream(stream: { client.streamPush(reference, + runtimeKind: runtimeKind) }, + workingLabel: AppText.working, + completedLabel: AppText.completed, + lineCountLabel: AppText.lineCount, + copyLogHelp: AppText.copyLog, + failureLabel: AppErrorPresentation.message) + .frame(minHeight: 260) + } else { + pushReadiness(reference) + } + } + .task { await app.refreshRegistries() } + } + + private func imagePageBody(title: String, subtitle: String?, + @ViewBuilder content: @escaping () -> C) -> some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { + VStack(alignment: .leading, spacing: UI.Card.Spacing.compactText) { + UI.Card.TitleText(text: title) + if let subtitle { + UI.Card.MonospacedSubtitleText(text: subtitle) + } + } + .padding(.horizontal, UI.Layout.Spacing.s) + content() + } + .padding(UI.Layout.Spacing.s) + .frame(maxWidth: .infinity, alignment: .leading) + } + .scrollEdgeEffectStyle(.soft, for: .all) + } + + private func pushReadiness(_ reference: String) -> some View { + let readiness = pushState(for: reference) + 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) + .designHeadlineLabelStyle() + Text(readiness.message) + .designSecondaryCallout() + if let detail = readiness.detail { + Text(detail) + .designSecondaryCaption() + } + } + Spacer(minLength: UI.Layout.Spacing.s) + } + HStack { + Spacer() + switch readiness.action { + case .push: + UI.Action.TextButton(title: AppText.push, + systemName: "arrow.up.circle", + prominence: .prominent, + isEnabled: app.client != nil) { + confirmingPushReference = reference + } + case .openRegistries: + UI.Action.TextButton(title: AppText.string("registry.openRegistries", defaultValue: "Open Registries"), + systemName: "key") { + onClose() + ui.openSettings(to: .registries) + } + case .tag: + UI.Action.TextButton(title: AppText.addTag, + systemName: "tag") { + withAnimation(spring) { page = .tag(reference) } + } + case .none: + EmptyView() + } + } + } + } + + private func pushState(for reference: String) -> PushState { + guard app.client != nil else { + 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", + tone: .warning, + action: .none) + } + + let parsed = Core.Registry.ImageReference.parse(reference) + let registry = displayRegistry(parsed.registry) + + guard !parsed.isDigestReference else { + 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", + tone: .warning, + action: .tag) + } + + if normalizedRegistryHost(parsed.registry) == "docker.io", + parsed.repository.hasPrefix("library/") { + 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", + tone: .warning, + action: .tag) + } + + guard let login = matchingRegistryLogin(for: parsed.registry) else { + 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", + tone: .warning, + action: .openRegistries) + } + + 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", + tone: .success, + action: .push) + } + + private func submitTag(source: String) { + 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, + runtimeKind: runtimeKind) + await app.refreshImagesIfNeeded(force: true) + tagBusy = false + tagTarget = "" + withAnimation(spring) { page = .tags } + } catch let error as Core.Command.Error { + app.flash(error.appDisplayMessage); tagBusy = false + } catch { + app.flash(error.appDisplayMessage); tagBusy = false + } + } + } + + private var imagePageControlItems: [UI.Card.Page] { + let reference = primaryImage(group)?.reference ?? group.primaryReference + return [ + UI.Card.Page(id: .tags, + title: AppText.string("image.tags", defaultValue: "Tags"), + systemImage: "tag"), + UI.Card.Page(id: .history(reference), + title: AppText.string("image.history", defaultValue: "History"), + systemImage: "clock.arrow.circlepath"), + UI.Card.Page(id: .tag(reference), + title: AppText.addTag, + systemImage: "plus.circle"), + UI.Card.Page(id: .push(reference), + title: AppText.push, + systemImage: "arrow.up.circle") + ] + } + + private var imagePages: UI.Card.Pages { + UI.Card.Pages(items: imagePageControlItems, + selection: page, + tint: resolvedImageTint, + controlsReveal: isExpanded ? 1 : 0, + closeLabel: AppText.close, + onSelect: selectPage, + onClose: onClose) + } + + private var resolvedImageTint: Color { + app.imageGroupStyle(for: group).color + } + + private func selectPage(_ item: ImageDetailPage) { + guard page != item else { return } + if case .push = item {} else { + pushStartedReference = nil + } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + page = item + } + } + + private func imageFooterInfo(_ status: Core.Image.UpdateStatus) -> some View { + UI.Card.FooterMini { + UI.Symbol.Image(systemName: updateSymbol(status.state), + tone: updateTone(status.state), + size: .caption) + } text: { + UI.Card.SubtitleText(text: updateFooterText(status)) + } + } + + private func imageFooterTagCount(_ group: Core.Image.LocalTagGroup) -> some View { + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "tag", size: .caption) + } text: { + UI.Card.MetricText(text: "\(group.tags.count)") + } + } + + @ViewBuilder + private func imageFooterActions(_ group: Core.Image.LocalTagGroup) -> some View { + 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, + let runtimeKind = primaryImage(group)?.runtimeKind { + footerAction("arrow.down.circle", help: AppText.pullUpdate, tint: .orange) { + Task { + await app.pullImageUpdate(group.primaryReference, + runtimeKind: runtimeKind) + } + } + } + if let image = primaryImage(group) { + footerAction("arrow.up.doc", help: AppText.save) { save(image) } + } + footerAction("trash", help: AppText.prune, role: .destructive) { pruning = true } + } + + private func tagList(_ group: Core.Image.LocalTagGroup) -> some View { + LazyVStack(alignment: .leading, spacing: UI.Layout.Spacing.s) { + Text("Tags") + .designHeadlineLabelStyle() + .padding(.leading, UI.Layout.Spacing.xs) + ScrollView(.vertical) { + LazyVStack(spacing: UI.Layout.Spacing.s) { + ForEach(group.tags) { tag in + tagRow(tag, in: group) + .frame(maxWidth: .infinity) + } + } + .padding(UI.Layout.Spacing.s) + } + .scrollEdgeEffectStyle(.soft, for: .all) + } + } + + 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, + fillOpacity: style.backgroundOpacity, + gradient: style.gradient, + gradientAngle: style.gradientAngle, + blendMode: style.backgroundBlendMode, + elevated: false, + title: Format.shortImage(reference), + subtitle: repositoryName(reference), + titleStyle: .monospaced) { + CardStyleButton(style: style, + target: .imageTag(reference: reference, groupID: group.id), + help: "Customize image style", + accessibilityLabel: AppText.customizeImageStyleAccessibility(Format.shortImage(reference))) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + EmptyView() + } footerLeading: { + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "cpu", size: .caption2) + } text: { + UI.Card.MetricText(text: app.runtimeDescriptor(for: tag.runtimeKind)?.displayName ?? tag.runtimeKind.rawValue) + } + } footerActions: { + footerAction("play", help: AppText.run) { + ui.runImage(reference, runtimeKind: tag.runtimeKind) + if isExpanded { onClose() } + } + UI.Copy.Icon(value: reference, help: AppText.copyReference) + footerAction("trash", help: AppText.deleteTag, role: .destructive) { deletingTag = tag } + } widget: { + EmptyView() + } + .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(_ 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") } + UI.Copy.ValueLabel("Copy reference", value: reference) + Divider() + Button(role: .destructive) { deletingTag = tag } label: { Label("Delete tag", systemImage: "trash") } + } + + private func footerAction(_ systemName: String, help: String, tint: Color? = nil, + role: ButtonRole? = nil, + action: @escaping () -> Void) -> some View { + UI.Card.FooterButton(systemName: systemName, + help: help, + tint: tint, + role: role, + action: action) + } + + @ViewBuilder + private func cardMenu(_ group: Core.Image.LocalTagGroup) -> some View { + 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). + 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 = .history(image.reference) } } label: { Label("History", systemImage: "clock.arrow.circlepath") } + } else { + Button(action: onTap) { Label("Show Details…", systemImage: "rectangle.expand.vertical") } + } + Button { save(image) } label: { Label("Save to tar…", systemImage: "arrow.up.doc") } + } + Divider() + 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, + 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) { deletingTag = primaryTag(group) } label: { + Label("Delete Primary Tag", systemImage: "trash") + } + } + + private func imageChip(_ style: Personalization) -> some View { + UI.Card.IconChip(symbol: style.symbol, tint: style.color) + } + + private func updateSymbol(_ state: Core.Image.UpdateState) -> String { + switch state { + case .unknown: return "questionmark.circle" + case .checking: return "arrow.triangle.2.circlepath" + case .current: return "checkmark.circle.fill" + case .updateAvailable: return "arrow.down.circle.fill" + case .error: return "exclamationmark.triangle.fill" + } + } + + private func updateTone(_ state: Core.Image.UpdateState) -> UI.State.Tone { + switch state { + case .current: return .success + case .updateAvailable, .error: return .warning + case .checking: return .info + case .unknown: return .neutral + } + } + + private func repositoryName(_ reference: String) -> String { + 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)) + } + return parsed.repository + } + + private func repositoryTitle(_ reference: String) -> String { + 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 = Core.Registry.ImageReference.parse(reference) + let parts = parsed.repository.split(separator: "/").map(String.init) + if parts.count > 1 { + return parts.dropLast().joined(separator: "/") + } + return parsed.registry == "registry-1.docker.io" ? "docker.io" : parsed.registry + } + + private func updateFooterText(_ status: Core.Image.UpdateStatus) -> String { + switch status.state { + case .unknown: return "Not checked" + case .checking: return "Checking" + case .current: return "Up to date" + case .updateAvailable: return "Updates available" + case .error: return "Check failed" + } + } + + private func primaryImage(_ group: Core.Image.LocalTagGroup) -> Core.Image.Resource? { + group.images.first { $0.reference == group.primaryReference } ?? group.images.first + } + + private var deletingBinding: Binding { + Binding(get: { deletingTag != nil }, set: { if !$0 { deletingTag = nil } }) + } + + private var pushConfirmationBinding: Binding { + Binding(get: { confirmingPushReference != nil }, + set: { if !$0 { confirmingPushReference = nil } }) + } + + private func matchingRegistryLogin(for registry: String) -> Core.Registry.Login? { + 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 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([tag.reference], runtimeKind: tag.runtimeKind) + await app.refreshImagesIfNeeded(force: true) + 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 { + 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) } + } + + private func save(_ image: Core.Image.Resource) { + guard let client = app.client else { return } + Task { + do { + let name = Format.shortImage(image.reference).replacingOccurrences(of: ":", with: "_") + ".tar" + let stagedURL = try StagedFile.url(named: name) + if let error = await app.captured({ + _ = try await client.saveImages([image.reference], + to: stagedURL.path, + runtimeKind: image.runtimeKind) + }) { + StagedFile.cleanup(stagedURL) + app.flash(error) + } else { + savedImageArchiveURL = stagedURL + savedImageArchiveName = name + movingSavedImageArchive = true + } + } catch { + app.flash(error.appDisplayMessage) + } + } + } + + private func handleSavedImageArchiveMove(_ result: Result) { + defer { + StagedFile.cleanup(savedImageArchiveURL) + savedImageArchiveURL = nil + savedImageArchiveName = "" + } + switch result { + case .success(let url): + app.flash(AppText.savedFile(url.lastPathComponent.isEmpty ? savedImageArchiveName : url.lastPathComponent)) + case .failure(let error): + app.flash(error.appDisplayMessage) + } + } + +} diff --git a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarResourcePanels.swift similarity index 52% rename from Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift rename to Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarResourcePanels.swift index a4e76fec..6744eef0 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarResourcePanels.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarResourcePanels.swift @@ -1,14 +1,14 @@ import SwiftUI +import ContainedUX +import ContainedUI import SwiftData -import AppKit import ContainedCore struct ToolbarActivityPanel: View { var onClose: () -> Void var body: some View { - // Size + placement are reported by the MorphPanelScaffold 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) } } @@ -20,8 +20,7 @@ struct ToolbarSystemPanel: View { var onClose: () -> Void var body: some View { - // Size + placement are reported by the MorphPanelScaffold 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) } } @@ -33,8 +32,7 @@ struct ToolbarSettingsPanel: View { var onClose: () -> Void var body: some View { - // Size + placement are reported by the MorphPanelScaffold 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) } } @@ -44,7 +42,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 @@ -52,7 +50,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: @@ -72,7 +70,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)] @@ -84,7 +82,7 @@ struct ToolbarTemplatesPanel: View { } var body: some View { - MorphPanelScaffold(width: Tokens.PanelSize.templates.width) { + UI.Panel.Scaffold(width: UI.Panel.Size.templates.width) { if showsHeader { VStack(alignment: .leading, spacing: 0) { header @@ -92,83 +90,104 @@ struct ToolbarTemplatesPanel: View { } } } content: { - LazyVStack(alignment: .leading, spacing: Tokens.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 { - ResourceBadgeText(text: section.title, font: .caption.weight(.semibold)) - .padding(.horizontal, Tokens.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(Tokens.Space.s) + .padding(UI.Layout.Spacing.s) } } private var header: some View { - PanelHeader(symbol: "bookmark", - title: "Templates", - subtitle: "\(saved.count) saved") { + UI.Panel.Header(symbol: "bookmark", + title: AppText.sectionTemplates, + subtitle: AppText.string("template.savedCount", defaultValue: "\(saved.count) saved")) { if showClose { - GlassButton(singleItem: true) { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true, action: onClose) - } + UI.Action.Group(UI.Action.Item(systemName: "xmark", + help: AppText.close, + isCancel: true, + action: onClose)) } } } private var emptyCard: some View { - ResourceGlassCard(size: .small, elevated: false) { - ResourceCardHeader { - ResourceCardIconChip(symbol: "bookmark", tint: .secondary, backgroundOpacity: 0.22) - } content: { - VStack(alignment: .leading, spacing: 1) { - ResourceCardTitleText(text: "No templates") - ResourceCardSubtitleText(text: "Save a container's settings as a template from the create form.") - } - } trailing: { - EmptyView() - } + 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.")) { + UI.Card.IconChip(symbol: "bookmark", + tint: .secondary, + backgroundOpacity: UI.Card.Metric.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: { - VStack(alignment: .leading, spacing: 1) { - ResourceCardTitleText(text: template.name) - 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() - } + private func templateCard(_ template: RecipeRecord) -> some View { + UI.Card.Scaffold(size: .medium, + elevated: false, + onTap: { use(template) }, + title: template.name, + subtitle: Format.shortImage(template.spec?.image ?? "—"), + subtitleStyle: .monospaced) { + UI.Card.IconChip(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). + UI.List.RowChevron() + } bodyContent: { + EmptyView() } footerLeading: { - ResourceCardFooterMini { - Image(systemName: "bookmark").font(.caption2) + UI.Card.FooterMini { + UI.Symbol.Image(systemName: "bookmark", size: .caption2) } text: { - ResourceCardMetricText(text: "Saved run configuration") + UI.Card.MetricText(text: "Saved run configuration") } } footerActions: { Button(role: .destructive) { delete(template) } label: { - ResourceCardFooterMini { - 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("Delete") - Button("Use") { use(template) }.buttonStyle(.glassProminent).controlSize(.small) + .accessibilityLabel(AppText.delete) + UI.Action.TextButton(title: AppText.string("common.use", defaultValue: "Use"), + systemName: "plus.circle", + prominence: .prominent, + controlSize: .small) { + use(template) + } + } widget: { + EmptyView() } .contextMenu { Button { use(template) } label: { Label("Use", systemImage: "plus.circle") } @@ -177,22 +196,26 @@ 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) } } /// 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/Toolbar/Panels/ToolbarSearchSource.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarSearchSource.swift new file mode 100644 index 00000000..21fffae9 --- /dev/null +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarSearchSource.swift @@ -0,0 +1,47 @@ +import SwiftUI +import ContainedUI + +struct ToolbarSearchSource: View { + @Environment(AppModel.self) private var app + @Environment(UIState.self) private var ui + @FocusState private var focused: Bool + + /// The toolbar search field escalates into the command palette only when that experimental + /// feature is enabled; otherwise it stays a plain page filter. + private var paletteEnabled: Bool { app.settings.commandPaletteEnabled && ui.panelNavigationEnabled } + + var body: some View { + @Bindable var ui = ui + 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.toolbar.activeMorph = .palette } }, + onClear: { ui.search.text = "" }) { + if paletteEnabled { + UI.Control.KeyCap("⌘K") + } + } + .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 } + .onKeyPress(.escape) { + focused = false + return .handled + } + } + + private func escalateIfEmpty() { + 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/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarUpdatesPanel.swift similarity index 54% rename from Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift rename to Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarUpdatesPanel.swift index d843e745..e9dce724 100644 --- a/Sources/Contained/Navigation/ToolbarPanels/ToolbarUpdatesPanel.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/Panels/ToolbarUpdatesPanel.swift @@ -1,6 +1,7 @@ import SwiftUI +import ContainedUX +import ContainedUI import SwiftData -import AppKit import ContainedCore struct ToolbarUpdatesPanel: View { @@ -8,15 +9,16 @@ struct ToolbarUpdatesPanel: View { @Environment(UIState.self) private var ui var showClose = true var coordinateSpaceName = AppToolbar.space - 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] { - sortedImageGroups(LocalImageTagGroup.groups(for: app.images).filter(matchesFilter)) + 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)] @@ -32,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 } @@ -42,7 +44,7 @@ struct ToolbarUpdatesPanel: View { } var body: some View { - MorphPanelScaffold(width: Tokens.PanelSize.images.width) { + UI.Panel.Scaffold(width: UI.Panel.Size.images.width) { if showsHeader { VStack(alignment: .leading, spacing: 0) { header @@ -50,14 +52,14 @@ struct ToolbarUpdatesPanel: View { } } } content: { - LazyVStack(alignment: .leading, spacing: Tokens.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 { - ResourceBadgeText(text: section.title, font: .caption.weight(.semibold)) - .padding(.horizontal, Tokens.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) @@ -65,70 +67,89 @@ struct ToolbarUpdatesPanel: View { } } } - .padding(Tokens.Space.s) + .padding(UI.Layout.Spacing.s) } - .task { await app.refreshImagesIfStale(force: true) } + .task { await app.refreshImagesIfNeeded() } } private var header: some 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") { + 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")")) { + UI.Action.Group(imageHeaderActions) + } + } + + private var imageHeaderActions: [UI.Action.Item] { + var actions = [ + UI.Action.Item(systemName: "square.and.arrow.down", help: AppText.loadImageTar) { ui.dispatch(.loadImage) onClose() - } - GlassButtonItem(systemName: "arrow.triangle.2.circlepath", help: "Check for Updates") { + }, + UI.Action.Item(systemName: "arrow.triangle.2.circlepath", help: AppText.checkForUpdates) { Task { await app.runImageUpdateSweepNow() } - } - GlassButtonItem(systemName: "trash", role: .destructive, help: "Prune Images") { + }, + UI.Action.Item(systemName: "trash", help: AppText.pruneImages, role: .destructive) { ui.dispatch(.pruneImages) onClose() - } - if showClose { - GlassButtonItem(systemName: "xmark", help: "Close", isCancel: true, action: onClose) - } } + ] + if showClose { + actions.append(UI.Action.Item(systemName: "xmark", help: AppText.close, isCancel: true, action: onClose)) } + return actions } private var emptyCard: some View { - ResourceGlassCard(size: .small, elevated: false) { - ResourceCardHeader { - ResourceCardIconChip(symbol: "checkmark.circle.fill", tint: .green) - } content: { - VStack(alignment: .leading, spacing: 1) { - ResourceCardTitleText(text: "No images") - ResourceCardSubtitleText(text: "Pull or build an image to see it here") - } - } trailing: { - EmptyView() - } + 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")) { + UI.Card.IconChip(symbol: "checkmark.circle.fill", tint: .green) + } titleAccessory: { + EmptyView() + } subtitleAccessory: { + EmptyView() + } headerAccessory: { + EmptyView() + } bodyContent: { + EmptyView() + } footerLeading: { + EmptyView() + } footerActions: { + EmptyView() + } widget: { + EmptyView() } } - private func imageRow(_ group: LocalImageTagGroup) -> some View { + private func imageRow(_ group: Core.Image.LocalTagGroup) -> some View { ToolbarImageGroupCard(group: group, isExpanded: false, onTap: { onOpenImage(group, imageFrames[group.id] ?? .zero) }, onClose: {}) + .opacity(hiddenImageGroupID == group.id ? 0 : 1) .background { 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 imageRank(_ group: LocalImageTagGroup) -> Int { + 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: Core.Image.LocalTagGroup) -> Int { switch app.imageUpdateStatus(for: group.primaryReference).state { case .updateAvailable: return 0 case .error: return 1 @@ -138,7 +159,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: @@ -154,7 +175,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 @@ -165,12 +186,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" @@ -191,3 +212,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/ToolbarFilterOptions.swift b/Sources/ContainedApp/Navigation/Toolbar/ToolbarFilterOptions.swift similarity index 59% rename from Sources/Contained/Navigation/ToolbarFilterOptions.swift rename to Sources/ContainedApp/Navigation/Toolbar/ToolbarFilterOptions.swift index 74c7a416..4ecc904c 100644 --- a/Sources/Contained/Navigation/ToolbarFilterOptions.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/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/Contained/Navigation/ToolbarViewOptions.swift b/Sources/ContainedApp/Navigation/Toolbar/ToolbarViewOptions.swift similarity index 59% rename from Sources/Contained/Navigation/ToolbarViewOptions.swift rename to Sources/ContainedApp/Navigation/Toolbar/ToolbarViewOptions.swift index 0b5df3f8..14225264 100644 --- a/Sources/Contained/Navigation/ToolbarViewOptions.swift +++ b/Sources/ContainedApp/Navigation/Toolbar/ToolbarViewOptions.swift @@ -1,47 +1,23 @@ import SwiftUI +import ContainedUI 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 { @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 { - ToolbarGlassMenuButton { + UI.Action.MenuButton { ForEach(AppSectionGroup.allCases) { group in 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) @@ -59,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) } @@ -71,7 +47,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 @@ -105,14 +81,14 @@ struct ToolbarViewOptions: View { var body: some View { @Bindable var ui = ui - return ToolbarGlassMenuButton { - Picker("Group by", selection: $ui.grouping) { + return UI.Action.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) } } .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) } @@ -120,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", + UI.Toolbar.TitleSubtitle(symbol: ui.grouping.symbol, + 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: " · ") } } @@ -152,103 +128,110 @@ struct ToolbarPageContextOptions: View { case .containers: EmptyView() case .images: - GlassButton { - GlassButtonItem(systemName: "square.and.arrow.down", help: "Load Image Tar") { + UI.Action.Group([ + UI.Action.Item(systemName: "square.and.arrow.down", help: AppText.loadImageTar) { ui.dispatch(.loadImage) - } - GlassButtonItem(systemName: "arrow.triangle.2.circlepath", help: "Check for Updates") { + }, + UI.Action.Item(systemName: "arrow.triangle.2.circlepath", help: AppText.checkForUpdates) { Task { await app.runImageUpdateSweepNow() } - } - GlassButtonItem(systemName: "trash", role: .destructive, help: "Prune Images") { + }, + UI.Action.Item(systemName: "trash", help: AppText.pruneImages, role: .destructive) { ui.dispatch(.pruneImages) } - } + ]) .help(imagesSubtitle) case .build: EmptyView() case .networks: - GlassButton { - GlassButtonItem(systemName: "plus", help: "New Network") { + UI.Action.Group([ + UI.Action.Item(systemName: "plus", help: AppText.newNetwork) { ui.dispatch(.createNetwork) - } - GlassButtonItem(systemName: "arrow.clockwise", help: "Refresh Networks") { + }, + 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: - GlassButton { - GlassButtonItem(systemName: "plus", help: "New Volume") { + UI.Action.Group([ + UI.Action.Item(systemName: "plus", help: AppText.newVolume) { ui.dispatch(.createVolume) - } - GlassButtonItem(systemName: "arrow.clockwise", help: "Refresh Volumes") { + }, + 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: 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") { + 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: - GlassButton { - GlassButtonItem(systemName: "checkmark.circle", help: "Mark all read") { + UI.Action.Group([ + UI.Action.Item(systemName: "checkmark.circle", help: AppText.markAllRead) { app.historyStore.markAllEventsRead() - } - GlassButtonItem(systemName: "trash", role: .destructive, help: "Clear activity") { + }, + UI.Action.Item(systemName: "trash", help: AppText.clearActivity, 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) - } + 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 + ui.navigate(to: .settings) } - } + }) case .templates: EmptyView() } } + private var serviceActions: [UI.Action.Item] { + guard app.serviceControlRuntimeAvailable 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() } + } + : UI.Action.Item(systemName: "play.fill", help: AppText.startService) { + Task { await app.startService() } + } + return [ + power, + UI.Action.Item(systemName: "arrow.clockwise", help: AppText.restartService) { + Task { await app.restartService() } + } + ] + } + + private var systemPageActions: [UI.Action.Item] { + SystemContent.SystemPage.allCases.map { page in + UI.Action.Item(systemName: page.systemImage, + help: page.rawValue, + tint: ui.systemPage == page ? .accentColor : nil) { + ui.systemPage = page + } + } + } + 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")" } @@ -272,9 +255,9 @@ struct ToolbarPageFilterOptions: View { NetworkViewOptions() case .activity: @Bindable var ui = ui - ToolbarGlassMenuButton { - Picker("Filter", selection: $ui.activityFilter) { - Label("All events", systemImage: "tray.full").tag(EventKind?.none) + UI.Action.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() ForEach(EventKind.allCases, id: \.self) { kind in Label(kind.rawValue.capitalized, systemImage: kind.symbol).tag(EventKind?.some(kind)) @@ -284,17 +267,19 @@ 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() } } 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: "Activity", - subtitle: ui.activityFilter?.rawValue.capitalized ?? "All events") + title: AppText.sectionActivity, + subtitle: ui.activityFilter?.rawValue.capitalized ?? AppText.string("activity.filter.allEvents", defaultValue: "All events")) } } @@ -303,21 +288,21 @@ private struct ImageViewOptions: View { var body: some View { @Bindable var ui = ui - return ToolbarGlassMenuButton { - Picker("Group by", selection: $ui.imageGrouping) { + return UI.Action.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) } } .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) } @@ -325,14 +310,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: " · ") } @@ -343,14 +328,14 @@ private struct TemplateViewOptions: View { var body: some View { @Bindable var ui = ui - return ToolbarGlassMenuButton { - Picker("Group by", selection: $ui.templateGrouping) { + return UI.Action.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) } } .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) } @@ -358,10 +343,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")) } } @@ -370,21 +355,21 @@ private struct NetworkViewOptions: View { var body: some View { @Bindable var ui = ui - return ToolbarGlassMenuButton { - Picker("Group by", selection: $ui.networkGrouping) { + return UI.Action.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) } } .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) } @@ -392,19 +377,19 @@ 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: " · ") } } -private func optionLabel(symbol: String, title: String, subtitle: String) -> some View { - ToolbarTitleSubtitleLabel(symbol: symbol, title: title, subtitle: subtitle) +@MainActor private func optionLabel(symbol: String, title: String, subtitle: String) -> some View { + UI.Toolbar.TitleSubtitle(symbol: symbol, title: title, subtitle: subtitle) } diff --git a/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase+RuntimeInventory.swift b/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase+RuntimeInventory.swift new file mode 100644 index 00000000..588e0430 --- /dev/null +++ b/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase+RuntimeInventory.swift @@ -0,0 +1,425 @@ +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 = encode(document) + let snapshotData = 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 = 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: 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 = encode(status) + if let scoped = Core.Runtime.Kind.parseScopedID(key) { + if let tag = fetch(ImageTagRecord.self).first(where: { $0.scopedID == key }) { + tag.updateStatusData = data + tag.lastCheckedAt = observedAt + tag.updatedAt = observedAt + } else { + context.insert(ImageTagRecord(scopedID: key, + imageIdentity: scoped.id, + reference: scoped.id, + runtimeKindRaw: scoped.kind.rawValue, + runtimeImageID: scoped.id, + updateStatusData: data, + isLocal: false, + lastCheckedAt: observedAt, + updatedAt: observedAt)) + } + } else 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 + } + for tag in fetch(ImageTagRecord.self) where tag.updateStatusData != nil && !seen.contains(tag.scopedID) { + tag.updateStatusData = nil + tag.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 { + recordFailure(.decodeRecord(record: "image update status \(record.identity)", + detail: String(describing: error))) + continue + } + let value = status.state == .checking ? Core.Image.UpdateStatus() : status + snapshot[record.identity] = value + snapshot[Core.Registry.ImageReference.normalizedKey(record.primaryReference)] = value + } + for tag in fetch(ImageTagRecord.self) { + guard let data = tag.updateStatusData else { continue } + let status: Core.Image.UpdateStatus + do { + status = try JSONDecoder().decode(Core.Image.UpdateStatus.self, from: data) + } catch { + recordFailure(.decodeRecord(record: "image tag update status \(tag.scopedID)", + detail: String(describing: error))) + continue + } + snapshot[tag.scopedID] = status.state == .checking ? Core.Image.UpdateStatus() : status + } + return snapshot + } + + func hiddenContainerScopedIDs() -> Set { + Set(fetch(ContainerRecord.self).filter(\.isHiddenDuringMigration).map(\.scopedID)) + } + + func linkedVolumePaths(for scopedID: String) -> [VolumeLinkedPath] { + guard let data = fetch(ContainerRecord.self).first(where: { $0.scopedID == scopedID })?.linkedVolumePathsData else { + return [] + } + do { + return try JSONDecoder().decode([VolumeLinkedPath].self, from: data) + } catch { + recordFailure(.decodeRecord(record: "linked volume paths \(scopedID)", + detail: String(describing: error))) + return [] + } + } + + func setLinkedVolumePaths(_ links: [VolumeLinkedPath], for scopedID: String, observedAt: Date = Date()) { + guard let record = fetch(ContainerRecord.self).first(where: { $0.scopedID == scopedID }) else { return } + record.linkedVolumePathsData = links.isEmpty ? nil : encode(links) + record.updatedAt = observedAt + save() + } + + 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 = 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: 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 = 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: 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 = encode(sourceDocument) + record.snapshotData = encode(source) + record.runtimeProjectionsData = encode(projections) + record.isHiddenDuringMigration = true + record.migrationStateRaw = "migrating:\(source.runtimeKind.rawValue):\(targetRuntimeKind.rawValue)" + record.isMissing = false + record.lastSeenAt = observedAt + record.updatedAt = observedAt + save() + } + + /// Saves the live recipe before recreate deletes the runtime object. Reuse the projection + /// payload so a failed restoration remains recoverable without adding another persistence model. + func markContainerRecreateStarted(source: Core.Container.Snapshot, + 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.documentData = encode(sourceDocument) + record.snapshotData = encode(source) + record.runtimeProjectionsData = encode(projections) + record.migrationStateRaw = "recreating" + record.isMissing = false + record.missingSince = nil + record.updatedAt = observedAt + save() + } + + func completeContainerRecreate(sourceScopedID: String, + replacementScopedID: String, + observedAt: Date = Date()) { + guard let source = fetch(ContainerRecord.self).first(where: { $0.scopedID == sourceScopedID }) else { return } + if sourceScopedID == replacementScopedID { + source.migrationStateRaw = "none" + source.updatedAt = observedAt + } else { + context.delete(source) + } + save() + } + + func markContainerRecreateFailed(scopedID: String, + observedAt: Date = Date()) { + guard let record = fetch(ContainerRecord.self).first(where: { $0.scopedID == scopedID }) else { return } + record.migrationStateRaw = "recreateFailed" + 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 = encode(targetDocument) + record.snapshotData = encode(target) + record.runtimeProjectionsData = 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 { + recordFailure(.decodeRecord(record: "runtime projections \(record.scopedID)", + detail: String(describing: error))) + return [:] + } + } + + private func shouldRetainMissingContainer(_ record: ContainerRecord) -> Bool { + record.isHiddenDuringMigration || + record.migrationStateRaw != "none" || + record.runtimeProjectionsData != nil || + record.linkedVolumePathsData != nil || + fetch(PersonalizationRecord.self).contains { $0.key == record.scopedID } || + fetch(HealthCheckRecord.self).contains { $0.containerScopedID == record.scopedID } + } + + private func encode(_ value: T) -> Data { + do { + return try JSONEncoder().encode(value) + } catch { + recordFailure(.encodeRecord(type: String(describing: T.self), + detail: String(describing: error))) + return Data() + } + } +} diff --git a/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase.swift b/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase.swift new file mode 100644 index 00000000..1705fe98 --- /dev/null +++ b/Sources/ContainedApp/Persistence/AppDatabase/AppDatabase.swift @@ -0,0 +1,179 @@ +import Foundation +import SwiftData +import ContainedCore + +@MainActor +final class AppDatabase { + enum Failure: LocalizedError, Equatable { + case fetch(model: String, detail: String) + case save(detail: String) + case encodeSetting(key: String, detail: String) + case decodeSetting(key: String, detail: String) + case encodeRecord(type: String, detail: String) + case decodeRecord(record: String, detail: String) + + var errorDescription: String? { + switch self { + case .fetch(let model, let detail): + return "Unable to fetch \(model): \(detail)" + case .save(let detail): + return "Unable to save app database: \(detail)" + case .encodeSetting(let key, let detail): + return "Unable to encode app setting \(key): \(detail)" + case .decodeSetting(let key, let detail): + return "Unable to decode app setting \(key): \(detail)" + case .encodeRecord(let type, let detail): + return "Unable to encode app database value \(type): \(detail)" + case .decodeRecord(let record, let detail): + return "Unable to decode app database record \(record): \(detail)" + } + } + } + + let container: ModelContainer + var context: ModelContext { container.mainContext } + private(set) var lastFailure: Failure? + + 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 { + recordFailure(.fetch(model: String(describing: T.self), detail: String(describing: error))) + return [] + } + } + + func save() { + do { + try context.save() + } catch { + recordFailure(.save(detail: String(describing: 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 { + recordFailure(.decodeSetting(key: key, detail: String(describing: error))) + return fallback + } + } + + func setSetting(_ value: T, for key: String) { + let data: Data + do { + data = try JSONEncoder().encode(value) + } catch { + recordFailure(.encodeSetting(key: key, detail: String(describing: error))) + return + } + 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 recordFailure(_ failure: Failure) { + lastFailure = failure + } + + 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 kind: Core.Runtime.Kind) { + let record = runtimeRecord(for: 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?.state == .ready + 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..34c29f68 --- /dev/null +++ b/Sources/ContainedApp/Persistence/AppDatabase/AppDatabaseModels.swift @@ -0,0 +1,292 @@ +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 linkedVolumePathsData: 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, + linkedVolumePathsData: 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.linkedVolumePathsData = linkedVolumePathsData + 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 = "recipe") { + 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 updateStatusData: Data? + var isLocal: Bool + var isMissing: Bool + var lastSeenAt: Date? + var missingSince: Date? + var lastCheckedAt: Date? + var updatedAt: Date + + init(scopedID: String, + imageIdentity: String, + reference: String, + runtimeKindRaw: String, + runtimeImageID: String, + digest: String? = nil, + resourceData: Data? = nil, + updateStatusData: Data? = nil, + isLocal: Bool = true, + isMissing: Bool = false, + lastSeenAt: Date? = nil, + missingSince: Date? = nil, + lastCheckedAt: 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.updateStatusData = updateStatusData + self.isLocal = isLocal + self.isMissing = isMissing + self.lastSeenAt = lastSeenAt + self.missingSince = missingSince + self.lastCheckedAt = lastCheckedAt + 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/AppDatabase/RecipeSupport.swift b/Sources/ContainedApp/Persistence/AppDatabase/RecipeSupport.swift new file mode 100644 index 00000000..8250855b --- /dev/null +++ b/Sources/ContainedApp/Persistence/AppDatabase/RecipeSupport.swift @@ -0,0 +1,68 @@ +import Foundation +import ContainedCore + +/// A saved container recipe — a named `ContainerFormState`, persisted (encoded) so it can prefill the edit form +/// later. Stored as a `RecipeRecord` in the app database. +extension RecipeRecord { + static func make(name: String, spec: ContainerFormState, createdAt: Date = Date()) throws -> RecipeRecord { + let data = try JSONEncoder().encode(spec) + return RecipeRecord(name: name, + createdAt: createdAt, + updatedAt: createdAt, + documentData: data, + sourceRaw: "recipe") + } + + convenience init(name: String, spec: ContainerFormState, createdAt: Date = Date()) { + let data = (try? JSONEncoder().encode(spec)) ?? Data() + self.init(name: name, + createdAt: createdAt, + updatedAt: createdAt, + documentData: data, + sourceRaw: "recipe") + } + + convenience init(snapshot: RecipeSnapshot) { + self.init(name: snapshot.name, spec: snapshot.spec, createdAt: snapshot.createdAt) + } + + var spec: ContainerFormState? { + let decoder = JSONDecoder() + return try? decoder.decode(ContainerFormState.self, from: documentData) + } +} + +struct RecipeSnapshot: Codable { + var name: String + var createdAt: Date + var spec: ContainerFormState + + init?(_ recipe: RecipeRecord) { + guard let spec = recipe.spec else { return nil } + self.name = recipe.name + self.createdAt = recipe.createdAt + self.spec = spec + } +} + +/// A few ready-to-run starters offered alongside the user's saved templates. +enum BuiltinTemplate { + 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")]), + make("nginx", symbol: "globe", image: "nginx:latest", ports: [("8080", "80")]), + make("Alpine (shell)", symbol: "terminal", image: "alpine:latest", command: "sleep infinity"), + ] + + private static func make(_ name: String, symbol: String, image: String, + command: String = "", ports: [(String, String)] = [], + env: [(String, String)] = []) -> (name: String, symbol: String, spec: ContainerFormState) { + var spec = ContainerFormState(runtimeKind: AppRuntimeIntent.placeholderKind) + spec.image = image + spec.command = command + spec.ports = ports.map { PortMap(hostPort: $0.0, containerPort: $0.1, proto: "tcp") } + spec.env = env.map { KeyValue(key: $0.0, value: $0.1) } + return (name, symbol, spec) + } +} diff --git a/Sources/Contained/History/HistoryModels.swift b/Sources/ContainedApp/Persistence/History/HistoryModels.swift similarity index 97% rename from Sources/Contained/History/HistoryModels.swift rename to Sources/ContainedApp/Persistence/History/HistoryModels.swift index 428b0dc5..8e67e6ab 100644 --- a/Sources/Contained/History/HistoryModels.swift +++ b/Sources/ContainedApp/Persistence/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: Core.Metrics.HistorySample {} +extension MetricSampleSnapshot: Core.Metrics.HistorySample {} diff --git a/Sources/Contained/History/HistoryStore.swift b/Sources/ContainedApp/Persistence/History/HistoryStore.swift similarity index 60% rename from Sources/Contained/History/HistoryStore.swift rename to Sources/ContainedApp/Persistence/History/HistoryStore.swift index c8075fb6..d0463945 100644 --- a/Sources/Contained/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,11 +36,11 @@ 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`. - 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 { @@ -61,86 +49,126 @@ 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). func markAllEventsRead() { - let unread = (try? context.fetch(FetchDescriptor( - predicate: #Predicate { !$0.isRead }))) ?? [] + let unread: [EventRecord] + do { + unread = try context.fetch(FetchDescriptor( + predicate: #Predicate { !$0.isRead })) + } catch { + database.recordFailure(.fetch(model: "EventRecord", detail: String(describing: error))) + return + } 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() + delete(EventRecord.self) + save() } // MARK: Retention func pruneOld(now: Date = Date()) { 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() + do { + try context.delete(model: MetricSample.self, where: #Predicate { $0.timestamp < cutoff }) + try context.delete(model: EventRecord.self, where: #Predicate { $0.timestamp < cutoff }) + } catch { + database.recordFailure(.save(detail: "Unable to prune history: \(error)")) + } + save() } /// Wipe all recorded metrics and events (Templates are preserved). Used by the "Clear history" /// action in Settings. func clearAll() { - try? context.delete(model: MetricSample.self) - try? context.delete(model: EventRecord.self) - try? context.save() + delete(MetricSample.self) + delete(EventRecord.self) + save() } func unreadEventCount() -> Int { - (try? context.fetchCount(FetchDescriptor( - predicate: #Predicate { !$0.isRead }))) ?? 0 + do { + return try context.fetchCount(FetchDescriptor( + predicate: #Predicate { !$0.isRead })) + } catch { + database.recordFailure(.fetch(model: "EventRecord", detail: String(describing: error))) + return 0 + } } - func templatesSnapshot() -> [TemplateSnapshot] { - ((try? context.fetch(FetchDescriptor