diff --git a/.github/workflows/ci-scripts.yml b/.github/workflows/ci-scripts.yml new file mode 100644 index 000000000..b0b95ca19 --- /dev/null +++ b/.github/workflows/ci-scripts.yml @@ -0,0 +1,53 @@ +name: CI scripts + +on: + pull_request: + paths: + - 'scripts/ci/**' + - 'scripts/sync-apiv2-fixtures*.sh' + - '.github/workflows/ci-scripts.yml' + - 'iosApp/iosApp/**' + - 'iosApp/TopShelf/**' + - 'iosApp/NotificationService/**' + - 'iosApp/DownloadsActivity/**' + - 'iosApp/Tests/**' + push: + branches: [main] + paths: + - 'scripts/ci/**' + - 'scripts/sync-apiv2-fixtures*.sh' + - '.github/workflows/ci-scripts.yml' + - 'iosApp/iosApp/**' + - 'iosApp/TopShelf/**' + - 'iosApp/NotificationService/**' + - 'iosApp/DownloadsActivity/**' + - 'iosApp/Tests/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Test release input parsing + run: | + bash scripts/ci/resolve-marketing-version.test.sh + bash scripts/ci/resolve-platform.test.sh + - name: Test release source archive + run: python3 scripts/ci/package-release-source.test.py + - name: Test subtitle packaging validation + run: python3 scripts/ci/validate-subtitle-packaging.test.py + - name: Test API v2 fixture sync + run: bash scripts/sync-apiv2-fixtures.test.sh + - name: Test the /api/v1 path guard + run: bash scripts/ci/check-no-api-v1.test.sh + - name: Block new /api/v1 paths in app sources + run: bash scripts/ci/check-no-api-v1.sh diff --git a/.github/workflows/player-regression.yml b/.github/workflows/player-regression.yml new file mode 100644 index 000000000..8c988dfc8 --- /dev/null +++ b/.github/workflows/player-regression.yml @@ -0,0 +1,202 @@ +name: Apple regression + +on: + pull_request: + paths: + - 'iosApp/**' + - '.github/workflows/player-regression.yml' + push: + branches: [main] + paths: + - 'iosApp/**' + - '.github/workflows/player-regression.yml' + workflow_dispatch: + inputs: + baseline_ref: + description: Optional unchanged upstream commit for a comparative iOS run (the tvOS test target may not exist there) + type: string + required: false + record_preview: + description: Also record a synthetic playback clip after a manually requested build + type: boolean + default: false + ios_only: + description: Build and validate only the iOS simulator app for an iOS-only change + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: player-regression-${{ github.ref }}-${{ inputs.baseline_ref || 'head' }} + cancel-in-progress: true + +jobs: + validate: + runs-on: macos-15 + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + include: >- + ${{ fromJSON((inputs.ios_only || inputs.baseline_ref) && + '[{"scheme":"Prairie","destination":"platform=iOS Simulator,name=iPhone 17 Pro","action":"test","platform":"iOS","test_target":"PrairieTests"}]' || + '[{"scheme":"Prairie","destination":"platform=iOS Simulator,name=iPhone 17 Pro","action":"test","platform":"iOS","test_target":"PrairieTests"}, + {"scheme":"PrairieTV","destination":"platform=tvOS Simulator,name=Apple TV 4K (3rd generation)","action":"test","platform":"tvOS","test_target":"PrairieTVTests"}, + {"scheme":"PrairieMac","destination":"generic/platform=macOS","action":"build"}]') }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + ref: ${{ inputs.baseline_ref || github.sha }} + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.3' + - name: Prepare simulator + if: matrix.action == 'test' + timeout-minutes: 10 + shell: bash + env: + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + # Hosted images do not always have a usable pre-created device. + # Match the selected Xcode SDK and reuse one explicit destination. + # iOS and tvOS differ only in SDK name, runtime prefix, and device. + case "$PLATFORM" in + iOS) + sdk=iphonesimulator + runtime_prefix=com.apple.CoreSimulator.SimRuntime.iOS- + device_type=com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro + destination_platform="iOS Simulator" + ;; + tvOS) + sdk=appletvsimulator + runtime_prefix=com.apple.CoreSimulator.SimRuntime.tvOS- + device_type=com.apple.CoreSimulator.SimDeviceType.Apple-TV-4K-3rd-generation-4K + destination_platform="tvOS Simulator" + ;; + *) + echo "::error::Unknown simulator platform $PLATFORM" + exit 1 + ;; + esac + runtime_version=$(xcrun --sdk "$sdk" --show-sdk-version) + find_runtime() { + xcrun simctl list runtimes --json | jq -r --arg version "$runtime_version" --arg prefix "$runtime_prefix" \ + '[.runtimes[] | select(.isAvailable and .version == $version and + (.identifier | startswith($prefix)))][0].identifier // empty' + } + runtime_id=$(find_runtime) + if [ -z "$runtime_id" ]; then + xcodebuild -downloadPlatform "$PLATFORM" -buildVersion "$runtime_version" + runtime_id=$(find_runtime) + fi + if [ -z "$runtime_id" ]; then + echo "::error::No available $PLATFORM $runtime_version simulator runtime" + exit 1 + fi + simulator_id=$(xcrun simctl create "Prairie-Player-Regression-$PLATFORM" \ + "$device_type" "$runtime_id") + echo "PRAIRIE_SIMULATOR_ID=$simulator_id" >> "$GITHUB_ENV" + echo "PRAIRIE_TEST_DESTINATION=platform=$destination_platform,id=$simulator_id" >> "$GITHUB_ENV" + xcrun simctl boot "$simulator_id" + xcrun simctl bootstatus "$simulator_id" -b + - name: Generate Xcode project + run: | + set -euo pipefail + curl -fsSL -o /tmp/xcodegen.zip https://github.com/yonaskolb/XcodeGen/releases/download/2.43.0/xcodegen.zip + # Pin the official 2.43.0 release asset before unpacking/executing it. + echo "a4847ed77d3341a4d24049bc4424a3babca4c94ff1dcaaee923eaca2b32c678f /tmp/xcodegen.zip" | shasum -a 256 -c - + unzip -q /tmp/xcodegen.zip -d /tmp/xcodegen-dist + /tmp/xcodegen-dist/xcodegen/bin/xcodegen generate --spec iosApp/project.yml + - name: Build player + id: build + env: + SCHEME: ${{ matrix.scheme }} + DESTINATION: ${{ matrix.destination }} + ACTION: ${{ matrix.action }} + run: | + set -o pipefail + if [ "$ACTION" = test ]; then + # Simulator-only ad-hoc signing supplies the application identifier + # required by Keychain. No certificate or release secret is used. + xcodebuild build-for-testing -project iosApp/Prairie.xcodeproj -scheme "$SCHEME" \ + -destination "$PRAIRIE_TEST_DESTINATION" -derivedDataPath /tmp/prairie-derived \ + CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- | tee /tmp/player-build.log + else + xcodebuild build -project iosApp/Prairie.xcodeproj -scheme "$SCHEME" \ + -destination "$DESTINATION" -derivedDataPath /tmp/prairie-derived \ + -resultBundlePath /tmp/player-validation.xcresult \ + CODE_SIGNING_ALLOWED=NO | tee /tmp/player-validation.log + fi + - name: Package simulator tests for focused visual verification + if: matrix.action == 'test' && matrix.platform == 'iOS' && !inputs.baseline_ref + run: ditto -c -k --keepParent /tmp/prairie-derived/Build/Products /tmp/Prairie-simulator-tests.zip + - name: Upload compiled simulator tests + if: matrix.action == 'test' && matrix.platform == 'iOS' && !inputs.baseline_ref + uses: actions/upload-artifact@v4 + with: + name: Prairie-simulator-tests + path: /tmp/Prairie-simulator-tests.zip + retention-days: 3 + - name: Run complete simulator suite + if: matrix.action == 'test' + timeout-minutes: 12 + env: + SCHEME: ${{ matrix.scheme }} + TEST_TARGET: ${{ matrix.test_target }} + run: | + set -o pipefail + xcodebuild test-without-building -project iosApp/Prairie.xcodeproj -scheme "$SCHEME" \ + -destination "$PRAIRIE_TEST_DESTINATION" -derivedDataPath /tmp/prairie-derived \ + -only-testing:"$TEST_TARGET" \ + -parallel-testing-enabled NO \ + -test-timeouts-enabled YES -maximum-test-execution-time-allowance 60 \ + -resultBundlePath /tmp/player-validation.xcresult \ + CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- | tee /tmp/player-validation.log + - name: Record synthetic preview expansion and successor playback + if: matrix.action == 'test' && matrix.platform == 'iOS' && !inputs.baseline_ref && steps.build.outcome == 'success' && !cancelled() && github.event_name == 'workflow_dispatch' && inputs.record_preview + timeout-minutes: 10 + shell: bash + run: | + set -euo pipefail + # Reuse the compiled test bundle. This records only the bounded + # synthetic-video test, not a second build or the full test suite. + # XCTest can shut down the simulator after the full suite. Boot the + # exact recording destination before starting the recorder. + simulator_id="$PRAIRIE_SIMULATOR_ID" + simulator_state=$(xcrun simctl list devices available --json | jq -r \ + --arg id "$simulator_id" '.devices[][] | select(.udid == $id) | .state') + if [ "$simulator_state" != Booted ]; then + xcrun simctl boot "$simulator_id" + fi + xcrun simctl bootstatus "$simulator_id" -b + xcrun simctl io "$simulator_id" recordVideo /tmp/preview-transition.mp4 & + recorder_pid=$! + trap 'kill -INT "$recorder_pid" 2>/dev/null || true; wait "$recorder_pid" || true' EXIT + xcodebuild test-without-building -project iosApp/Prairie.xcodeproj -scheme Prairie \ + -destination "platform=iOS Simulator,id=$simulator_id" \ + -derivedDataPath /tmp/prairie-derived \ + -parallel-testing-enabled NO \ + -destination-timeout 60 \ + -test-timeouts-enabled YES -maximum-test-execution-time-allowance 60 \ + -only-testing:PrairieTests/PlayerSurfaceLayoutTests/testPlayingPreviewExpandsWithoutReplacingItemOrLosingItsLayer \ + -resultBundlePath /tmp/preview-recording.xcresult CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- + kill -INT "$recorder_pid" + wait "$recorder_pid" + trap - EXIT + test -s /tmp/preview-transition.mp4 + - name: Upload validation evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: player-validation-${{ matrix.scheme }} + path: | + /tmp/player-build.log + /tmp/player-validation.log + /tmp/player-validation.xcresult + /tmp/preview-transition.mp4 + /tmp/preview-recording.xcresult + retention-days: 7 diff --git a/.github/workflows/release-source.yml b/.github/workflows/release-source.yml new file mode 100644 index 000000000..0ead6c001 --- /dev/null +++ b/.github/workflows/release-source.yml @@ -0,0 +1,60 @@ +name: Publish release source + +on: + workflow_call: + inputs: + release_tag: + required: true + type: string + outputs: + source_url: + value: ${{ jobs.source.outputs.source_url }} + +permissions: + contents: write + +concurrency: + group: release-source-${{ inputs.release_tag }} + cancel-in-progress: false + +jobs: + source: + runs-on: ubuntu-latest + outputs: + source_url: ${{ steps.publish.outputs.source_url }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Package exact app and library sources + run: python3 scripts/ci/package-release-source.py "$RUNNER_TEMP/release-source" + - name: Publish source before distributing binaries + id: publish + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + # The sideload workflow may create the same tag's release first. + FLAGS=() + if [[ "$RELEASE_TAG" == source-* ]]; then FLAGS+=(--latest=false); fi + gh release create "$RELEASE_TAG" --target "$GITHUB_SHA" \ + --title "$RELEASE_TAG" \ + "${FLAGS[@]}" \ + --notes "Release source and rebuild instructions are in the Prairie-source archive." \ + || gh release view "$RELEASE_TAG" >/dev/null + fi + IS_DRAFT="$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')" + if [[ "$IS_DRAFT" == "true" ]]; then + echo "::error::Source release $RELEASE_TAG is a draft. Publish it before distributing binaries." + exit 1 + fi + ASSET="Prairie-source-${GITHUB_SHA}.tar.gz" + gh release upload "$RELEASE_TAG" "$RUNNER_TEMP/release-source/$ASSET" --clobber + SOURCE_URL="$(gh release view "$RELEASE_TAG" --json assets --jq ".assets[] | select(.name == \"$ASSET\") | .url")" + test -n "$SOURCE_URL" + echo "source_url=$SOURCE_URL" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5dd28ab92..086bd9145 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,8 +27,8 @@ concurrency: group: testflight-release cancel-in-progress: false -# Least privilege: the jobs only check out code and talk to Apple via the ASC -# API key. They never use GITHUB_TOKEN to write to the repo. +# Apple build jobs only read the repository. The source publication job gets +# contents: write so it can attach rebuild sources before binary distribution. permissions: contents: read @@ -38,6 +38,13 @@ env: TESTFLIGHT_NOTIFY_EXTERNAL_TESTERS: "true" jobs: + source: + permissions: + contents: write + uses: ./.github/workflows/release-source.yml + with: + release_tag: ${{ github.event_name == 'push' && github.ref_name || format('source-{0}', github.sha) }} + # Resolve the target platform from either the dispatch input or the tag's # +ios / +tvos build-metadata suffix, so both trigger paths share one gate. setup: @@ -84,7 +91,7 @@ jobs: BUILD_NUMBERS_FILE="$GITHUB_OUTPUT" bundle exec fastlane ios reserve_build_numbers ios: - needs: [setup, build-numbers] + needs: [setup, build-numbers, source] # Skip only when the selection is tvOS-only. 'both' and 'ios' both run iOS. # No status function in the if -> implicit success() gate on needs, so a # failed build-numbers job skips this build. @@ -139,13 +146,14 @@ jobs: APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} TESTFLIGHT_EXTERNAL_GROUPS: ${{ secrets.TESTFLIGHT_EXTERNAL_GROUPS }} + PRAIRIE_RELEASE_SOURCE_URL: ${{ needs.source.outputs.source_url }} tvos: # No longer needs: ios — build numbers are reserved up front, so tvOS runs in # parallel with iOS rather than after it. (Trade-off: on a both run, tvOS now # releases even if iOS fails; the platforms' build-number sequences are # independent, so a gap is harmless.) - needs: [setup, build-numbers] + needs: [setup, build-numbers, source] # Skip only when the selection is iOS-only. Implicit success() gate on needs. if: ${{ needs.setup.outputs.platform != 'ios' }} runs-on: macos-15 @@ -198,3 +206,4 @@ jobs: APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} TESTFLIGHT_EXTERNAL_GROUPS: ${{ secrets.TESTFLIGHT_EXTERNAL_GROUPS }} + PRAIRIE_RELEASE_SOURCE_URL: ${{ needs.source.outputs.source_url }} diff --git a/.github/workflows/sideload-ipa.yml b/.github/workflows/sideload-ipa.yml index 1813591ab..adcd44052 100644 --- a/.github/workflows/sideload-ipa.yml +++ b/.github/workflows/sideload-ipa.yml @@ -121,13 +121,22 @@ jobs: gh release create "$TAG" \ --target "$GITHUB_SHA" \ --title "$TITLE" \ - --notes "Unsigned iOS/tvOS IPAs for third-party signing / sideloading. See docs/release/sideloading.md." + --notes "Unsigned iOS/tvOS IPAs for third-party signing / sideloading. See docs/release/sideloading.md. The Prairie-source archive contains the matching source and rebuild instructions." \ + || gh release view "$TAG" >/dev/null fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "Releasing as $TAG (build $BUILD)" + source: + needs: release + permissions: + contents: write + uses: ./.github/workflows/release-source.yml + with: + release_tag: ${{ needs.release.outputs.tag }} + build: - needs: [setup, release] + needs: [setup, release, source] runs-on: macos-15 # Only the release-upload step (tag builds) needs write; scope it to the job # rather than the whole workflow to keep the rest at least privilege. diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a46709081..47f66f9bc 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -6,7 +6,7 @@ name: Unit Tests # # Coverage gate choice: # Default scope is `/Networking/` minus live HTTP / session modules -# (ContinuumAPI, HTTPClient, store refreshers, FramedJSONSession, …). +# (PrairieAPI, HTTPClient, store refreshers, FramedJSONSession, …). # See scripts/check-xccov-coverage.sh. # # CI speed: @@ -53,7 +53,7 @@ jobs: uses: actions/cache@v6 with: path: iosApp/DerivedData - key: netci-mac-${{ runner.os }}-${{ hashFiles('iosApp/project.yml', 'iosApp/iosApp/Networking/**/*.swift', 'iosApp/iosApp/Shared/SharedStorage.swift', 'iosApp/iosApp/Shared/KeychainAudience.swift', 'iosApp/iosApp/Shared/ImageSizeSelection.swift', 'iosApp/iosApp/Shared/AppleDecodeCapabilities.swift', 'iosApp/iosApp/Shared/ProfileLaunchBehavior.swift', 'iosApp/iosApp/Shared/ProfileLaunchState.swift', 'iosApp/iosApp/Shared/RememberedProfile.swift', 'iosApp/iosApp/Shared/ProfileLaunchResolution.swift', 'iosApp/iosApp/Navigation/UICustomizationPreferences.swift', 'iosApp/iosApp/Navigation/TabRouter.swift', 'iosApp/iosApp/Navigation/AppNavPreferences.swift', 'iosApp/iosApp/Screens/Profiles/ProfileLaunchPreferences.swift', 'iosApp/iosApp/Shared/AppleDeviceIdentity.swift', 'iosApp/iosApp/Screens/Auth/AuthModels.swift', 'iosApp/iosApp/Screens/Calendar/CalendarModels.swift', 'iosApp/iosApp/Screens/Player/Subtitles/SubtitleAppearance.swift', 'iosApp/iosApp/Screens/Player/ProtocolV3/PlaybackProtocolV3Models.swift', 'iosApp/CI/NetworkingHost/**/*.swift', 'iosApp/Tests/Networking*.swift', 'iosApp/Tests/TokenStore*.swift', 'iosApp/Tests/ServerRegistry*.swift', 'iosApp/Tests/WireFormat*.swift', 'iosApp/Tests/AuthAndDevice*.swift', 'iosApp/Tests/ResponseCache*.swift', 'iosApp/Tests/PrairieFrame*.swift', 'iosApp/Tests/TrackSelection*.swift', 'iosApp/Tests/LiveTVModel*.swift', 'iosApp/Tests/AIModel*.swift', 'iosApp/Tests/PlaybackPrefs*.swift', 'iosApp/Tests/SubtitleSearch*.swift', 'iosApp/Tests/AppUpdate*.swift', 'iosApp/Tests/AIJob*.swift', 'iosApp/Tests/LibraryVisibility*.swift', 'iosApp/Tests/SettingValuesAPITests.swift', 'iosApp/Tests/SettingsConformanceTests.swift', 'iosApp/Tests/SettingsContractResolve.swift', 'iosApp/Tests/ItemVideosDecodingTests.swift', 'iosApp/Tests/Fixtures/SettingsContract/**') }} + key: netci-mac-${{ runner.os }}-${{ hashFiles('iosApp/project.yml', 'iosApp/iosApp/Networking/**/*.swift', 'iosApp/iosApp/Shared/SharedStorage.swift', 'iosApp/iosApp/Shared/KeychainAudience.swift', 'iosApp/iosApp/Shared/ImageSizeSelection.swift', 'iosApp/iosApp/Shared/AppleDecodeCapabilities.swift', 'iosApp/iosApp/Shared/ProfileLaunchBehavior.swift', 'iosApp/iosApp/Shared/ProfileLaunchState.swift', 'iosApp/iosApp/Shared/RememberedProfile.swift', 'iosApp/iosApp/Shared/ProfileLaunchResolution.swift', 'iosApp/iosApp/Navigation/UICustomizationPreferences.swift', 'iosApp/iosApp/Navigation/TabRouter.swift', 'iosApp/iosApp/Navigation/AppNavPreferences.swift', 'iosApp/iosApp/Screens/Profiles/ProfileLaunchPreferences.swift', 'iosApp/iosApp/Shared/AppleDeviceIdentity.swift', 'iosApp/iosApp/Screens/Auth/AuthModels.swift', 'iosApp/iosApp/Screens/Calendar/CalendarModels.swift', 'iosApp/iosApp/Screens/Player/Subtitles/SubtitleAppearance.swift', 'iosApp/iosApp/Screens/Player/ProtocolV3/PlaybackProtocolV3Models.swift', 'iosApp/iosApp/Shared/ArtworkURL.swift', 'iosApp/iosApp/Shared/MediaLogRedactor.swift', 'iosApp/iosApp/Shared/CatalogPathSegment.swift', 'iosApp/iosApp/Shared/AccountSessionPersistence.swift', 'iosApp/iosApp/Downloads/DownloadModels.swift', 'iosApp/iosApp/Screens/Profiles/ProfileTransitionError.swift', 'iosApp/iosApp/Navigation/SeriesDetailContext.swift', 'iosApp/iosApp/Screens/Player/StreamRequest.swift', 'iosApp/iosApp/Screens/Browse/Filtering/*.swift', 'iosApp/iosApp/Screens/Audio/AudiobookPlaybackContext.swift', 'iosApp/iosApp/Screens/Detail/AudiobookDetailFormatting.swift', 'iosApp/iosApp/WatchParty/WatchPartyInvitation.swift', 'iosApp/iosApp/WatchParty/WatchPartySocket.swift', 'iosApp/iosApp/Shared/PrairieURLScheme.swift', 'iosApp/CI/NetworkingHost/**/*.swift', 'iosApp/Tests/Networking*.swift', 'iosApp/Tests/TokenStore*.swift', 'iosApp/Tests/ServerRegistry*.swift', 'iosApp/Tests/WireFormat*.swift', 'iosApp/Tests/AuthAndDevice*.swift', 'iosApp/Tests/ResponseCache*.swift', 'iosApp/Tests/PrairieFrame*.swift', 'iosApp/Tests/TrackSelection*.swift', 'iosApp/Tests/LiveTVModel*.swift', 'iosApp/Tests/AIModel*.swift', 'iosApp/Tests/PlaybackPrefs*.swift', 'iosApp/Tests/SubtitleSearch*.swift', 'iosApp/Tests/AppUpdate*.swift', 'iosApp/Tests/AIJob*.swift', 'iosApp/Tests/LibraryVisibility*.swift', 'iosApp/Tests/SettingValuesAPITests.swift', 'iosApp/Tests/SettingsContractResolve.swift', 'iosApp/Tests/ItemVideosDecodingTests.swift', 'iosApp/Tests/Fixtures/SettingsContract/**', 'iosApp/Tests/APIv2FixtureTestSupport.swift', 'iosApp/Tests/Support/**', 'iosApp/Tests/APIv2CatalogReadProjectionTests.swift', 'iosApp/Tests/APIv2PlaybackModelsTests.swift', 'iosApp/Tests/APIv2WireModelsTests.swift', 'iosApp/Tests/APIv2CatalogQueryAndSyncTests.swift', 'iosApp/Tests/MetadataRequestPoolTests.swift', 'iosApp/Tests/ServerIdentityProbeTests.swift', 'iosApp/Tests/TokenStoreSessionTests.swift', 'iosApp/Tests/CatalogShapeModelsTests.swift', 'iosApp/Tests/WatchPartyModelsTests.swift', 'iosApp/Tests/APIv2SubtitleSearchModelsTests.swift', 'iosApp/Tests/TokenStoreFailureTests.swift', 'iosApp/Tests/ServerRegistryEdgeTests.swift', 'iosApp/Tests/PlaybackV3FixtureTestSupport.swift', 'iosApp/Tests/Fixtures/PlaybackV3/**', 'iosApp/Tests/CredentialIdentityTests.swift', 'iosApp/Tests/MediaRequestAuthorizationTests.swift', 'iosApp/Tests/MetadataSingleFlightTests.swift', 'iosApp/Tests/PersonalListsAPITests.swift', 'iosApp/Tests/ServerIdentityMatchingTests.swift', 'iosApp/Tests/SettingValueWritesTests.swift', 'iosApp/Tests/SharedDefaultsTests.swift', 'iosApp/Tests/SharedKeychainCheckedReadTests.swift', 'iosApp/Tests/WatchPartyAPITests.swift', 'iosApp/Tests/Fixtures/APIv2/**', 'iosApp/Tests/APIv2ContractTests.swift', 'iosApp/Tests/APIv2MutationOutcomeTests.swift', 'iosApp/Tests/CatalogActionsAPITests.swift', 'iosApp/Tests/CatalogV2Tests.swift', 'iosApp/Tests/CollectionsV2Tests.swift', 'iosApp/Tests/LibraryBrowseV2Tests.swift', 'iosApp/Tests/MetadataAIV2Tests.swift', 'iosApp/Tests/OnboardingV2Tests.swift', 'iosApp/Tests/TrackPreferencesV2Tests.swift') }} restore-keys: | netci-mac-${{ runner.os }}- @@ -84,9 +84,9 @@ jobs: PrairieNetworkingTests/NetworkingGateBoostTests PrairieNetworkingTests/NetworkingModelsCoverageTests PrairieNetworkingTests/NetworkingModelsGateFillTests - PrairieNetworkingTests/NetworkingSiloGateFillTests - PrairieNetworkingTests/NetworkingSiloRegistryGateFillTests - PrairieNetworkingTests/NetworkingSiloTokenStoreGateFillTests + PrairieNetworkingTests/NetworkingPrairieGateFillTests + PrairieNetworkingTests/NetworkingPrairieRegistryGateFillTests + PrairieNetworkingTests/NetworkingPrairieTokenStoreGateFillTests PrairieNetworkingTests/TokenStorePersistenceTests PrairieNetworkingTests/TokenStoreDiagnosticsTests PrairieNetworkingTests/ServerRegistryMigrationTests @@ -105,8 +105,37 @@ jobs: PrairieNetworkingTests/PrairieQualityPresetsTests PrairieNetworkingTests/SettingValueModelsGateFillTests PrairieNetworkingTests/SettingValuesAPITests - PrairieNetworkingTests/SettingsConformanceTests PrairieNetworkingTests/ItemVideosDecodingTests + PrairieNetworkingTests/APIv2ContractTests + PrairieNetworkingTests/APIv2MutationOutcomeTests + PrairieNetworkingTests/CatalogActionsAPITests + PrairieNetworkingTests/CatalogV2Tests + PrairieNetworkingTests/CollectionsV2Tests + PrairieNetworkingTests/LibraryBrowseV2Tests + PrairieNetworkingTests/MetadataAIV2Tests + PrairieNetworkingTests/OnboardingV2Tests + PrairieNetworkingTests/TrackPreferencesV2Tests + PrairieNetworkingTests/APIv2CatalogReadProjectionTests + PrairieNetworkingTests/APIv2PlaybackModelsTests + PrairieNetworkingTests/APIv2WireModelsTests + PrairieNetworkingTests/APIv2CatalogQueryAndSyncTests + PrairieNetworkingTests/MetadataRequestPoolTests + PrairieNetworkingTests/ServerIdentityProbeTests + PrairieNetworkingTests/TokenStoreSessionTests + PrairieNetworkingTests/CatalogShapeModelsTests + PrairieNetworkingTests/WatchPartyModelsTests + PrairieNetworkingTests/APIv2SubtitleSearchModelsTests + PrairieNetworkingTests/TokenStoreFailureTests + PrairieNetworkingTests/ServerRegistryEdgeTests + PrairieNetworkingTests/CredentialIdentityTests + PrairieNetworkingTests/MediaRequestAuthorizationTests + PrairieNetworkingTests/MetadataSingleFlightTests + PrairieNetworkingTests/PersonalListsAPITests + PrairieNetworkingTests/ServerIdentityMatchingTests + PrairieNetworkingTests/SettingValueWritesTests + PrairieNetworkingTests/SharedDefaultsTests + PrairieNetworkingTests/SharedKeychainCheckedReadTests + PrairieNetworkingTests/WatchPartyAPITests ) ONLY_TESTING_ARGS=() @@ -136,6 +165,24 @@ jobs: fi xcrun xccov view --report --json TestResults.xcresult > coverage.json + - name: List uncovered Networking lines + working-directory: iosApp + if: always() + run: | + # Diagnostic only: never fail the job from here. + set +e + [[ -d TestResults.xcresult ]] || exit 0 + # Print the unexecuted line ranges of each partly covered file in the + # gate scope, so a coverage drop can be traced without a Mac. + xcrun xccov view --archive --file-list TestResults.xcresult 2>/dev/null \ + | grep '/Networking/' | while read -r file; do + ranges=$(xcrun xccov view --archive --file "$file" TestResults.xcresult 2>/dev/null \ + | awk -F: '$2 ~ /^ *0( |$)/ { n = $1 + 0; if (n == last + 1) { last = n } else { if (start) printf "%s%s ", start, (last > start ? "-" last : ""); start = n; last = n } } + END { if (start) printf "%s%s", start, (last > start ? "-" last : "") }') + if [[ -n "$ranges" ]]; then echo "${file##*/iosApp/iosApp/}: $ranges"; fi + done + exit 0 + - name: Enforce 95% Networking unit-test coverage gate run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index 0c452a27f..c26ac7770 100644 --- a/.gitignore +++ b/.gitignore @@ -8,15 +8,15 @@ iosApp/.deriveddata/ # XcodeGen regenerates the project, but the exact Aether transitive graph is a # release input and must remain reviewable. -!iosApp/Silo.xcodeproj/ -iosApp/Silo.xcodeproj/* -!iosApp/Silo.xcodeproj/project.xcworkspace/ -iosApp/Silo.xcodeproj/project.xcworkspace/* -!iosApp/Silo.xcodeproj/project.xcworkspace/xcshareddata/ -iosApp/Silo.xcodeproj/project.xcworkspace/xcshareddata/* -!iosApp/Silo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ -iosApp/Silo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/* -!iosApp/Silo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +!iosApp/Prairie.xcodeproj/ +iosApp/Prairie.xcodeproj/* +!iosApp/Prairie.xcodeproj/project.xcworkspace/ +iosApp/Prairie.xcodeproj/project.xcworkspace/* +!iosApp/Prairie.xcodeproj/project.xcworkspace/xcshareddata/ +iosApp/Prairie.xcodeproj/project.xcworkspace/xcshareddata/* +!iosApp/Prairie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ +iosApp/Prairie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/* +!iosApp/Prairie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved # SwiftPM / CocoaPods .build/ @@ -44,14 +44,9 @@ fastlane/Preview.html fastlane/screenshots/ fastlane/test_output/ -# Local tooling state -# `.agents/skills` is a symlink to `.claude/skills` so non-Claude agents find the -# same files; everything else under .agents/ is per-developer scratch. -/.agents/* -!/.agents/skills -# Ignore everything in .claude/ except the committed, shared skills. -/.claude/* -!/.claude/skills/ +# Local agent/tooling state +/.agents/ +/.claude/ .codex/ .cursor/ .sweetpad/ @@ -74,4 +69,7 @@ docs/* !docs/tvos-player/ docs/tvos-player/* !docs/tvos-player/aetherengine-replacement-spec.md +!docs/tvos-player/ass-subtitles.md !docs/release/* +!docs/release/ +!docs/*-api-v2.md diff --git a/.ruby-version b/.ruby-version index e650c01d9..4d541eb20 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.9 +3.3.12 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6cab5b163..528bce1b3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -111,8 +111,8 @@ "${input:iosTestDestination}", "-derivedDataPath", ".deriveddata", - "CODE_SIGN_IDENTITY=", - "CODE_SIGNING_ALLOWED=NO" + "CODE_SIGN_IDENTITY=-", + "CODE_SIGNING_ALLOWED=YES" ], "options": { "cwd": "${workspaceFolder}/iosApp" diff --git a/AGENTS.md b/AGENTS.md index 951281446..702e7e299 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,18 +24,17 @@ repo. ## Build, Test, and Development Commands - `cd iosApp && xcodegen generate` regenerates `Prairie.xcodeproj` from `project.yml`; do this after target or source layout changes. -- `cd iosApp && xcodebuild build -project Prairie.xcodeproj -scheme Prairie -destination 'platform=iOS Simulator,name=iPhone 17 Pro' CODE_SIGNING_ALLOWED=NO` builds iOS without local signing. +- Use the `mac-builder` skill's local simulator helper for iOS/tvOS build, boot, + install, launch, and verification. Reuse the task's established simulator and + preserve signing and keychain entitlements for authenticated runs. - Use scheme `PrairieTV` with a tvOS simulator destination for tvOS builds. - Use scheme `PrairieMac` with `platform=macOS` for macOS builds. -Those commands assume a local Xcode. When developing from a Linux host, every Apple toolchain -operation runs on the remote `mac-builder` Mac instead — read the `mac-builder` skill first, and -`docs/mac-builder.md` for setup and background. - -## Skills - -Task-specific guides live in `.claude/skills/`, also reachable as `.agents/skills/` for agents -that look there. Read the one that matches the task instead of working from this file alone. +Follow `mac-builder` host routing for local and remote builds. Simulator boot and +reasonable build recovery are part of an authorized run; ask only for an ambiguous +target or a change that would lose protected state. Unsigned compilation with +`CODE_SIGNING_ALLOWED=NO` is compile-only evidence; do not install that artifact +for an authenticated run. See `docs/mac-builder.md` for setup and background. ## Coding Style & Naming Conventions @@ -48,14 +47,54 @@ manual directional focus mutation. ## Testing Guidelines -Apple tests use XCTest under `iosApp/Tests/`. Do not add tests for small changes or UI changes unless requested. For shared logic changes, add focused tests only for critical or high-risk behavior. +Apple tests use XCTest under `iosApp/Tests/`. Add focused regression tests when +they meaningfully protect changed behavior; use rendered checks for visual changes. +Avoid tests that only mirror wording or implementation. Run relevant checks while +iterating and complete repository-required gates at their stated milestone. +Broaden or repeat testing only for new changes, failures, or unresolved risks. + +## Writing + +Run a final readability pass on every human-facing issue, pull request, +document, or status update. + +- Lead with the outcome. +- Use concrete, plain language and active voice. +- Cut filler, stock framing, repetition, and promotional claims. +- Preserve meaning, evidence, citations, uncertainty, and established + terminology. +- Never rewrite exact quotations, commands, logs, identifiers, API names, or + contractual language. +- Match the tone to the audience and use only formatting that improves + readability. + +## Pull requests + +Never create a pull request unless the developer explicitly asks for one. + +Use a Conventional Commit title in plain language. Start the body with the +problem, explain the solution next, and end with the required AI disclosure, +including the exact model identifier, agent harness, and any other AI tooling. +Include repository-required issue links, validation evidence, risks, and +follow-up work. + +- Keep one concern per pull request. Split changes that solve independent + problems or can be reviewed and shipped separately. +- Include before-and-after images for UI changes. Include a short video when + motion or timing matters. +- Upload pull request evidence to GitHub. Never commit PR-only assets such as + `.github/pr-assets/`. +- When babysitting a pull request, poll checks and review comments created + after the last push. Verify bot findings against the source, fix real issues, + and dismiss false positives with a written reason. Remain quiet when nothing + new has appeared. Stop when the latest commit is green. ViewInspector (`https://github.com/nalexn/ViewInspector`) is linked to the `PrairieTests` target only (see `iosApp/project.yml`). Use it sparingly for high-risk SwiftUI screens where empty/loading/error affordances must not regress — for example Live TV channel list states — not as a default for every view. Prefer decoder and pure-logic unit tests for Networking and ViewModel code. CI enforces a 95% line-coverage gate over unit-testable `/Networking/` code via `scripts/check-xccov-coverage.sh` (not overall app UI coverage). The default include is `/Networking/`; live HTTP / session modules that need a real server -(ContinuumAPI, HTTPClient, ContinuumAI, DiagnosticsAPI, ConnectionMonitor, +(PrairieAPI, HTTPClient, PrairieAI, DiagnosticsAPI, ConnectionMonitor, FramedJSONSession, Overlay/Playback/Profile prefs stores, AICapabilities, ImageSizeCapability, ServerIdentityResolver, and feature-store refreshers including SubtitleProvidersStore) are excluded from the scoped percentage. Models, diff --git a/APPSTORE-EXCEPTION.md b/APPSTORE-EXCEPTION.md new file mode 100644 index 000000000..db6b5d1be --- /dev/null +++ b/APPSTORE-EXCEPTION.md @@ -0,0 +1,49 @@ +# Silo Apple App Store / DRM Exception + +Copyright (C) 2026 Silo Media L.L.C. and contributors + +Silo Apple is licensed under the GNU Affero General Public License, +version 3 or (at your option) any later version — see [LICENSE](LICENSE) — +with the following additional permission. + +## Additional permission under AGPL v3 section 7 + +As an additional permission under section 7 of the GNU Affero General +Public License v3 (incorporated by section 7 of any later version the +recipient elects), the copyright holders grant permission to distribute, +install, and use this Program — and any modified versions thereof — +through application stores and beta-distribution platforms operated by +third parties (including but not limited to the Apple App Store and +Apple TestFlight), even where the platform's terms impose restrictions +on: + +- signing or re-signing of the binary by anyone other than the original + developer or the platform operator; +- tamper protection, FairPlay-style DRM, or similar integrity controls + applied to the binary by the platform; +- end-user redistribution of the installed binary outside the platform's + own channels; +- end-user re-linking of the installed binary against modified versions + of the Program, + +even where such restrictions would otherwise be incompatible with +sections 4 through 6 of AGPL v3. + +This permission applies solely to the binary distribution path. It does +not waive any obligation under the License regarding source code: the +complete corresponding source for the Program, including any +modifications, remains available under AGPL v3, and recipients retain +every right that license grants with respect to that source — they may +obtain it, study it, modify it, build their own copy, and distribute +their modified version under the same license, outside the constraints +of any application store. + +As provided by section 7 of AGPL v3, any recipient redistributing the +Program may remove this additional permission from their copy. + +## Trademark note + +This permission covers copyright only. The Silo name, logo, and wordmark +are trademarks of Silo Media L.L.C. and are not licensed by it: +publishing a Silo-branded build to an app store additionally requires +written trademark permission. See [TRADEMARK.md](TRADEMARK.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..2f63898f8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Contributing to Prairie Apple + +The [Prairie contribution guide](https://github.com/Prairie-Server/.github/blob/main/CONTRIBUTING.md) +covers project-wide coordination, focused changes, evidence, AI disclosure, and +pull request expectations. Those requirements apply here; this guide adds the +Apple-specific workflow. + +## Before you start + +Open an [issue](https://github.com/Prairie-Server/prairie-apple/issues) before +implementing a feature, navigation or behavior change, large refactor, or work +that changes the shared server contract. Documentation, narrow fixes, and +well-scoped parity corrections can go straight to a pull request. + +This repository owns the iOS, tvOS, and macOS clients. Server/API work belongs +in [`prairie-server`](https://github.com/Prairie-Server/prairie-server), and shared client +behavior should be checked against +[`prairie-android`](https://github.com/Prairie-Server/prairie-android). + +## Development setup + +Read [README.md](README.md) for prerequisites, signing setup, and build commands, +then read [AGENTS.md](AGENTS.md) for module ownership and platform guidance. +Regenerate `iosApp/Prairie.xcodeproj` from `iosApp/project.yml`; never hand-edit the +generated project. + +## Validate your change + +Build every affected platform and run the focused XCTest targets. A typical +iOS build and simulator test gate is: + +```sh +cd iosApp +xcodegen generate +xcodebuild build \ + -project Prairie.xcodeproj \ + -scheme Prairie \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + CODE_SIGNING_ALLOWED=NO +xcodebuild test \ + -project Prairie.xcodeproj \ + -scheme Prairie \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- +``` + +Simulator tests use ad-hoc signing so Keychain-dependent tests have an application +identity; they do not require a distribution certificate. + +The `PrairieTests` bundle belongs to the `Prairie` scheme; there are currently no +separate tvOS or macOS test bundles. Use `PrairieTV` with a tvOS simulator for tvOS +builds and `PrairieMac` with `platform=macOS` for macOS builds. Exercise visible +changes in the affected app and include screenshots or a short recording in the +pull request. + +## Open the pull request + +Use a Conventional Commit title, explain which platforms are affected, paste +the actual validation results, and call out any server or Android coordination. +Read the [AI-assisted contribution policy](https://github.com/Prairie-Server/prairie-server/blob/main/docs/ai-contributions.md) +and include its disclosure block. + +## Licensing of contributions + +This project is licensed under `AGPL-3.0-or-later` with the App Store / +DRM additional permission in +[APPSTORE-EXCEPTION.md](APPSTORE-EXCEPTION.md). + +By submitting a contribution, you agree that it is licensed under those +same terms — the AGPL *including* the App Store additional permission — +and that the project may distribute builds containing your contribution +through application stores such as the Apple App Store and TestFlight as +that permission describes. If you cannot agree to the additional +permission, say so in your pull request before it is merged. + +You retain copyright in your contribution; no assignment is requested. + +## Practical notes + +- Repository layout, build commands, and coding conventions are in + [AGENTS.md](AGENTS.md) and the docs under `docs/`. +- Third-party components and their licenses are inventoried in + [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md); update it when a + dependency changes. + +## Instructions for coding agents + +Coding agents must read [AGENTS.md](AGENTS.md) before changing the repository +(`CLAUDE.md` points to the same guidance). The organization-wide contribution +guide and AI-assisted contribution policy apply to agent and human authors +equally. diff --git a/Gemfile b/Gemfile index 9f3e0ef5f..1b389cadb 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source "https://rubygems.org" -ruby "~> 3.2" +ruby "~> 3.3" gem "fastlane" diff --git a/Gemfile.lock b/Gemfile.lock index 711675815..4cb1418c3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -30,6 +30,7 @@ GEM base64 (0.3.0) benchmark (0.5.0) bigdecimal (4.1.2) + cgi (0.5.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -42,38 +43,26 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) + erb (6.0.7) excon (1.5.0) logger - faraday (1.10.6) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) + faraday (2.14.4) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) faraday-multipart (1.2.0) multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.4) - faraday_middleware (1.2.1) - faraday (~> 1.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) fastimage (2.4.1) - fastlane (2.237.0) + fastlane (2.240.1) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) addressable (>= 2.9.0, < 3.0.0) @@ -83,24 +72,27 @@ GEM base64 (~> 0.2) benchmark (>= 0.1.0) bundler (>= 2.4.0, < 5.0.0) + cgi (~> 0.4) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 2.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) fastimage (>= 2.1.0, < 3.0.0) fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) + google-apis-androidpublisher_v3 (~> 0.99) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-env (>= 1.6.0, < 2.4.0) google-cloud-storage (~> 1.31) highline (~> 2.0) - http-cookie (~> 1.0.5) + irb (>= 1.8) json (< 3.0.0) jwt (>= 2.10.3, < 4) logger (>= 1.6, < 2.0) @@ -113,11 +105,11 @@ GEM optparse (>= 0.1.1, < 1.0.0) ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.5) + rubyzip (>= 3.4.0, < 4.0.0) + security (~> 0.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) + terminal-table (~> 4) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -172,6 +164,12 @@ GEM domain_name (~> 0.5) httpclient (2.9.0) mutex_m + io-console (0.9.4) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jmespath (1.6.2) json (2.21.2) jwt (3.2.0) @@ -184,14 +182,31 @@ GEM mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) nkf (0.3.0) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) pstore (0.2.1) public_suffix (7.0.5) rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -199,9 +214,8 @@ GEM retriable (3.8.0) rexml (3.4.4) rouge (3.28.0) - ruby2_keywords (0.0.5) - rubyzip (2.4.1) - security (0.1.5) + rubyzip (3.4.0) + security (0.3.0) signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -210,15 +224,17 @@ GEM CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) trailblazer-option (0.1.2) + tsort (0.2.0) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) unicode-display_width (2.6.0) + uri (1.1.1) word_wrap (1.0.0) xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) @@ -256,6 +272,7 @@ CHECKSUMS benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7 + cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a @@ -266,22 +283,16 @@ CHECKSUMS domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 excon (1.5.0) sha256=c503ad1d0123bc8ab2a062ff3789dc891ec368cb9e13765ab88a9c58c8bb6d50 - faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d + faraday (2.14.4) sha256=9bb4408c44621b0dbaaaa39e5dbfc4dbe58cc6751b6eb94d16ae9e1f2f2fab6e faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb - faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 - faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 - faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 - faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b + faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 - faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 - faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 - faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 - faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 - faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa - faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 + faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588 + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 - fastlane (2.237.0) sha256=bb1e867bc070fb328741b5e6e7606d5ba596941a9ecca0478f60ef22d1009db9 + fastlane (2.240.1) sha256=cf792bc99ec6af6574ea416cda6530f519c8b4b3a46b1a3b52488ebf1696aa5c fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 google-apis-androidpublisher_v3 (0.104.0) sha256=3bf7f0dee23ae71e070e279848374834853204e304831cf6aa76fa435a4d6eff @@ -298,6 +309,8 @@ CHECKSUMS highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 @@ -309,38 +322,46 @@ CHECKSUMS mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 - ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef - rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 - security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 + rubyzip (3.4.0) sha256=6de39bc9eba302b635a476d16c9e16b0872ad24517c2f98f2b3a7ea23caff57b + security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0 signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780 simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea - terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2 trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 xcodeproj (1.27.0) sha256=8cc7a73b4505c227deab044dce118ede787041c702bc47636856a2e566f854d3 xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93 RUBY VERSION - ruby 3.2.9 + ruby 3.3.12 BUNDLED WITH 4.0.15 diff --git a/README.md b/README.md index 6cfacbffb..5cac4e4bc 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ Try the latest beta builds of the iOS and tvOS apps:

iOS home screen iOS movie detail + iOS series detail iOS movies library + iOS TV shows library

### tvOS @@ -39,7 +41,7 @@ Try the latest beta builds of the iOS and tvOS apps: ## Prerequisites -- Xcode 16+ +- Xcode 26+ - `xcodegen` - Ruby 3.2 with Bundler for release automation - A running Prairie server for local auth, browsing, and playback validation @@ -80,7 +82,7 @@ xcodebuild build \ ## VS Code The checked-in `.vscode` configuration provides recommended extensions, -unsigned build and test tasks, and SweetPad integration for building, running, +unsigned build tasks, ad-hoc-signed simulator test tasks, and SweetPad integration for building, running, debugging, simulator management, and Swift code intelligence without using the Xcode UI. @@ -128,9 +130,19 @@ Personal Apple Developer teams cannot join the production App Group, so Top Shel Fastlane lanes are defined in `fastlane/Fastfile`. All Apple IDs, team IDs, signing repo URLs, and App Store Connect credentials must come from CI environment variables. +## Contributing + +Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Features, +navigation or behavior changes, large refactors, and shared contract changes +should start as an issue. + ## License & Trademarks -Prairie Apple is licensed under `AGPL-3.0-or-later`. See [LICENSE](LICENSE). +Prairie Apple is licensed under `AGPL-3.0-or-later` with an additional +permission under AGPL section 7, inherited from upstream Silo Apple, allowing +distribution through the Apple App Store and TestFlight despite those platforms' signing, DRM, and +redistribution terms. See [LICENSE](LICENSE) and +[APPSTORE-EXCEPTION.md](APPSTORE-EXCEPTION.md). The **Prairie name, logo, wordmark, app icons, and other brand assets** are **not** covered by the AGPL. You're free to fork and redistribute the code, including with @@ -138,4 +150,6 @@ truthful notices such as "fork of Prairie," but forks and redistributions must n use the Prairie brand as their identity and must remove or replace the Prairie brand assets. See [TRADEMARK.md](TRADEMARK.md). -FFmpeg, Nuke, fastlane, and other third-party dependencies retain their own licenses. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). +Media playback is built on [AetherEngine](https://github.com/superuser404notfound/AetherEngine) +by Vincent Herbst, used under LGPL-3.0 with its Apple Store / DRM exception. +AetherEngine, FFmpeg, Nuke, fastlane, and other third-party dependencies retain their own licenses. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 744e0eef0..cceb4243e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -15,10 +15,11 @@ The authoritative dependency lock is | Component | Exact revision | Shipped form | License | | --- | --- | --- | --- | -| AetherEngine 6.34.0 | `0ae80496ab6f3fda135f43ef195ff10961c0e625` | Swift package target linked into each host app | LGPL-3.0-only with AetherEngine's Apple Store / DRM exception | -| FFmpegBuild 2.4.3 | `b2185fa842b829cd53d182a5e9a53182c1d9c84c` | Nine separately embedded dynamic frameworks | See the component table below | -| LibDovi 2.0.0 | `89be93431c2a5f2e54fb77e93059071b8d2ddb3a` | Static `Dovi.xcframework` linked through AetherEngine | MIT packaging; embedded libdovi is MIT | -| Nuke and NukeUI 12.9.0 | `83e19143355b02e9261edb2323b3e1e93287ebb9` | Swift package targets linked into each host app | MIT | +| AetherEngine 7.13.0 + Silo subtitle, HLS authorization and TrueHD Atmos patches | `a02975eda62a13c7c24a2f1ac57840a12975c0b5` | Swift package target linked into each host app | LGPL-3.0-only with AetherEngine's Apple Store / DRM exception | +| FFmpegBuild 3.4.0 | `9ee46ba4fb533e35efa6492eb1a903ca4f8058fc` | Nine separately embedded dynamic frameworks | See the component table below | +| SiloObjectAudio 1.0.0 | `645b91c072fafb60146151fd5127f8ff0ca38b6f` | Static `SiloObjectAudio.xcframework` linked through AetherEngine | Apache-2.0; embedded `truehd` and `oamd` crates are Apache-2.0 | +| LibDovi 2.1.0 | `0d7cce1d6836a30d13a3a2326e50a153af53f014` | Static `Dovi.xcframework` linked through AetherEngine | MIT packaging; embedded libdovi is MIT | +| Nuke and NukeUI 13.2.0 | `30f7a7e72e0607d304fbf69c799474bd5fb6d1ce` | Swift package targets linked into each host app | MIT | SwiftPM also resolves SMBClient 0.3.1 because AetherEngine publishes an optional `AetherEngineSMB` product. Prairie depends only on the `AetherEngine` @@ -31,11 +32,23 @@ included here. Copyright (C) 2026 Vincent Herbst. AetherEngine is licensed under GNU LGPL version 3 with its upstream Apple -Store / DRM exception. Prairie uses the source unmodified at the exact revision -above. The bundled acknowledgements include AetherEngine's complete license -and exception plus the GNU GPL version 3 text incorporated by LGPLv3. - -- Exact source: +Store / DRM exception. Prairie builds the fork revision published by the Silo +project: upstream release `7.13.0` plus Silo patches for complete native +subtitle renditions, source +timing after a media reanchor, ASS subtitle routing, and refreshable authorization +for native HLS, sidecar subtitles and font bundles, and TrueHD Atmos object +rendering to Apple Positional Audio. Raw ASS events stay on the +primary local overlay; secondary subtitles and software PiP receive normalized +text, and packaged HLS retains +its native text rendition for PiP and AirPlay. These modifications are +published under the LGPL at the exact source revision below. The bundled +acknowledgements include AetherEngine's complete license and exception plus +the GNU GPL version 3 text incorporated by LGPLv3. + +- Exact source: + (engine pull requests: , + ) +- Upstream base: - Rebuild input: `Package.swift` and the source tree at that revision - Bundled texts: `AetherEngine-LGPL-3.0-App-Store-Exception.txt`, `GPL-3.0.txt` @@ -43,8 +56,9 @@ and exception plus the GNU GPL version 3 text incorporated by LGPLv3. The exception permits Apple App Store and TestFlight distribution despite store signing, DRM, and relinking restrictions. It does not waive source-code obligations: the exact-revision link above must stay current for each release, -and any downstream modifications must be published under the LGPL. Prairie ships -the source unmodified, so the pointer is the whole obligation. +and any downstream modifications must be published under the LGPL. The exact +source revision linked above is that publication; keep it public for as long +as builds that link this revision are distributed. ## FFmpegBuild and its component libraries @@ -54,40 +68,51 @@ dynamic frameworks: | Frameworks | Upstream input | License | | --- | --- | --- | -| Libavcodec, Libavformat, Libavutil, Libswresample, Libswscale, Libavfilter | FFmpeg `n8.1.2` (currently `38b88335f99e76ed89ff3c93f877fdefce736c13`) | LGPL-2.1-or-later | -| Libdav1d | dav1d `1.5.1` (currently `42b2b24fb8819f1ed3643aa9cf2a62f03868e3aa`) | BSD-2-Clause | -| Libzimg | zimg `release-3.0.5` (currently `e5b0de6bebbcbc66732ed5afaafef6b2c7dfef87`) | WTFPL-2.0 | -| Libzvbi | libzvbi `v0.2.44` (currently `5169a428d51c3ae8ff7b0897e8a687d8e05e37b5`) | LGPL-2.0-or-later, conveyed under LGPL-2.1; `src/ure.c` is MIT | - -The exact FFmpegBuild 2.4.3 `build.sh` is the rebuild recipe and patch record: +| AetherLibavcodec, AetherLibavformat, AetherLibavutil, AetherLibswresample, AetherLibswscale, AetherLibavfilter | FFmpeg `n8.1.2` (currently `38b88335f99e76ed89ff3c93f877fdefce736c13`) | LGPL-2.1-or-later | +| AetherLibdav1d | dav1d `1.5.4` (currently `54706fc6bc0cdecab7e9593974a4039cc038fca7`) | BSD-2-Clause | +| AetherLibzimg | zimg `release-3.0.6` (currently `f819b14e8f39d1282400b0d9543e8ef73c1b2bbd`) | WTFPL-2.0 | +| AetherLibzvbi | libzvbi `v0.2.45` (currently `d3a5ee9f2b047bf16cd1ee5ccf6ec05ee75409d0`) | LGPL-2.0-or-later, conveyed under LGPL-2.1; `src/ure.c` is MIT | + +FFmpegBuild 3.0.0 renamed every target, framework bundle, and install name +with an `Aether` prefix so the build can coexist with another FFmpeg in the +same app. Earlier releases added WMV/ASF and legacy Flash support, the VC-1 +parser repair for seeking, and dSYMs for crash symbolication. The pinned 3.4.0 +release adds FFmpeg support for the `dav1` (Dolby Vision AV1) MP4 sample entry. +The exact FFmpegBuild 3.4.0 `build.sh` is the rebuild recipe and patch record: - it builds FFmpeg with dynamic linkage and does not enable GPL, version-3, or nonfree FFmpeg components; - it removes libzvbi's three GPL-family source files (`packet-830.c`, `pdc.c`, and `exp-vtx.c`) before compilation and publishes the LGPL replacement stubs; - it records its FFmpeg, dav1d, zimg, and libzvbi tags and every downstream - patch applied to those sources. + patch applied to those sources; +- it does not build the FFmpeg `concat` demuxer, which 2.5.0 removed. -The commit IDs in the table are the tags' dereferenced values observed on -2026-08-22. FFmpegBuild's script records tag names rather than immutable +libzvbi 0.2.45 is the GHSA-86rm-g7qf-j2fh security update (out-of-bounds +read, out-of-bounds write, integer underflow reachable through the teletext +decoder). The commit IDs in the table are the tags' dereferenced values +observed on 2026-09-04. FFmpegBuild's script records tag names rather than immutable upstream commit IDs; recording the dereferenced commits here pins them if the tags ever move. Forking the pinned FFmpegBuild revision is cheap, commit-immutable insurance if stronger provenance is ever wanted, but the exact-revision links satisfy the source pointer as they stand. -The checked iOS debug app embeds exactly the nine frameworks in the table, -and its FFmpeg configure strings contain `--enable-shared` without -`--enable-gpl`, `--enable-version3`, or nonfree enablement. Repeat this -inventory against each release archive; debug evidence is not a release -substitute. +Historical binary inventory (FFmpegBuild 3.0.0): a tvOS Simulator debug build, +inspected on 2026-09-04, embedded exactly the nine `Aether`-prefixed frameworks +in the table under `PrairieTV.app/Frameworks/`. Its `AetherLibavcodec` configure +string contained `--enable-shared` without `--enable-gpl`, `--enable-version3`, or +nonfree enablement, and the app binary exported no `avcodec_`/`avformat_` +symbols of its own. These observations describe that older debug artifact; +they are not a binary inventory of the current 3.4.0 dependency. Repeat this +inventory against each release archive; debug evidence is not a release substitute. - Exact packaging source and rebuild script: - + - Current upstream tag resolutions: [FFmpeg](https://github.com/FFmpeg/FFmpeg/tree/38b88335f99e76ed89ff3c93f877fdefce736c13), - [dav1d](https://code.videolan.org/videolan/dav1d/-/tree/42b2b24fb8819f1ed3643aa9cf2a62f03868e3aa), - [zimg](https://github.com/sekrit-twc/zimg/tree/e5b0de6bebbcbc66732ed5afaafef6b2c7dfef87), - and [libzvbi](https://github.com/zapping-vbi/zvbi/tree/5169a428d51c3ae8ff7b0897e8a687d8e05e37b5) + [dav1d](https://code.videolan.org/videolan/dav1d/-/tree/54706fc6bc0cdecab7e9593974a4039cc038fca7), + [zimg](https://github.com/sekrit-twc/zimg/tree/f819b14e8f39d1282400b0d9543e8ef73c1b2bbd), + and [libzvbi](https://github.com/zapping-vbi/zvbi/tree/d3a5ee9f2b047bf16cd1ee5ccf6ec05ee75409d0) - Bundled texts: `FFmpegBuild-LGPL-2.1.txt`, `dav1d-BSD-2-Clause.txt`, `zimg-WTFPL.txt`, `libzvbi-ure-MIT.txt` @@ -99,41 +124,68 @@ a substitute for a final release archive scan. LibDovi's packaging and rebuild script are MIT licensed, Copyright (c) 2026 Vincent Herbst. Its `Dovi.xcframework` contains the static `dolby_vision` -3.3.2 crate built from dovi_tool tag `libdovi-3.3.2`, exact upstream revision -`4fd2b2235c9f93582dd4a00e65ee34a07800afd7`, under the MIT license, -Copyright (c) 2025 quietvoid. +3.4.0 crate built from dovi_tool tag `libdovi-3.4.0`, exact upstream revision +`d1abe0e27ff2c7ab3339614d06db9f8a058af6b2`, under the MIT license, +Copyright (c) 2026 quietvoid. As with FFmpegBuild, LibDovi's rebuild script clones the tag name rather than an immutable commit. The commit above is the tag's value observed on -2026-08-22; preserve the actual release source alongside the static library. +2026-09-04; preserve the actual release source alongside the static library. LibDovi's packaging `LICENSE` describes the embedded crate as dual MIT or -Apache-2.0, but the exact `libdovi-3.3.2` source's `LICENSE` and Cargo manifest +Apache-2.0, but the exact `libdovi-3.4.0` source's `LICENSE` and Cargo manifest declare MIT. The bundled files reproduce the packaging license unmodified and also include quietvoid's controlling MIT text; this notice uses MIT for the embedded crate. - Exact packaging source and rebuild script: - + - Exact embedded crate source: - + - Bundled texts: `LibDovi-Packaging-MIT.txt`, `libdovi-MIT.txt` +## SiloObjectAudio and truehd + +SiloObjectAudio is the Silo project's object-audio decoder library, licensed under the Apache License +2.0. Its static `SiloObjectAudio.xcframework` contains the `truehd` crate (TrueHD/MLP +decoder) from the truehdd project at the exact commit +`45eff984e3e5ab0cf36a47d12148b1628c38bda4`, Copyright Rainbaby and the truehdd +contributors, and the `oamd` crate 0.1.0 (object audio metadata) from crates.io, +Copyright Rainbaby, both under the Apache License 2.0. It also compiles in +the Rust standard library and the crates its NOTICE lists, each under the MIT +license or the Apache License 2.0. The bundled text reproduces the NOTICE and the +Apache License. + +- Exact source and rebuild script: + +- Embedded decoder source: +- Embedded metadata parser source: +- Bundled text: `SiloObjectAudio-Apache-2.0.txt` + ## Nuke and NukeUI Nuke and NukeUI are distributed under the MIT license, Copyright (c) 2015-2026 Alexander Grebenyuk. - Exact source: - + - Bundled text: `Nuke-MIT.txt` +## ThumbHash decoder + +Prairie includes an adapted copy of the ThumbHash Swift decoder, distributed +under the MIT license, Copyright (c) 2023 Evan Wallace. Only the image decode +path is retained; the adaptation adds bounds validation, cross-platform image creation, +and a bounded asynchronous cache around the reference algorithm. + +- Exact source: + +- Bundled text: `ThumbHash-MIT.txt` + ## Release checklist -The bundled notices, license texts, and exact-revision source links above -cover the standing LGPL obligations: this matches the adopter steps -AetherEngine's own README asks for (embed FFmpeg dynamically, ship the license -texts, point at the exact source). Per release, before App Store, TestFlight, -or other external distribution: +The dynamically embedded FFmpeg libraries and the statically linked SwiftLibass +libraries use different rebuild paths. Before App Store, TestFlight, or other +external distribution: 1. verify the release archive embeds the FFmpegBuild libraries as separate dynamic frameworks and does not merge or statically link them into the app @@ -141,4 +193,58 @@ or other external distribution: 2. confirm the revisions in this file and in the bundled acknowledgements match what the release actually resolves in `Package.resolved`; 3. if AetherEngine or any LGPL component was modified, publish the modified - source under its license before shipping. + source under its license before shipping; +4. publish the matching `Prairie-source-.tar.gz` archive, with the app + source, pinned Swift packages, SwiftLibass dependency sources, revision manifest, + and rebuild instructions, before distributing a binary that links SwiftLibass. + +## Local ASS rendering + +Prairie uses SwiftLibass 1.4.0 for libass bindings and SwiftAssRenderer 1.3.1 +for image compositing on all three Apple clients. Both Swift packages are MIT. +The native libraries are libass 0.17.3 (ISC), Fontconfig 2.15.0, FreeType 2.13.2 +(FreeType License), FriBidi 1.0.14 (LGPL-2.1-or-later), HarfBuzz 8.5.0, and +libpng 1.6.43. Their full notices are bundled in `OpenSourceLicenses/`. +This product uses the FreeType project. + +The native XCFrameworks contain static `.a` libraries and headers. Prairie links +the libraries without embedding subtitle framework wrappers or generated stubs. + +- SwiftAssRenderer source: +- SwiftLibass source and binary artifacts: +- Rebuild entry point: `build-libraries.sh` at that revision. +- Transitive Swift dependencies: Combine Schedulers 1.2.2, Concurrency Extras + 1.4.1, and IssueReporting from xctest-dynamic-overlay 1.13.1, all MIT; exact revisions are in + `Package.resolved` and their notices are bundled. +- The test-only `SiloASSFixture.ttf` is an original rectangular glyph + generated by `iosApp/Tests/Fixtures/ASS/generate-font.py`; it is not shipped + in the application. + +### Source-based relinking + +FriBidi is statically linked. LGPL 2.1 section 6(a) allows the work that uses +the library to be supplied as source code or object code so a recipient can +modify the library and relink the application. Prairie uses the source-code path; +the app is distributed under the repository's AGPL license. + +The TestFlight and sideload workflows publish a source archive to GitHub +Releases before their binary jobs run. Tag-triggered builds attach it to the +same tag as the binary release. Manual TestFlight builds publish it under +`source-` and include its URL in the build's What to Test notes. +External local TestFlight uploads require `PRAIRIE_RELEASE_SOURCE_URL` to point +to the matching published archive. Other distribution channels must publish +these materials and provide their location alongside the binary as well. + +`scripts/ci/package-release-source.py` captures the committed app tree and +every resolved Swift package, plus immutable snapshots of SwiftLibass's six +native libraries recorded in `scripts/ci/swift-libass-sources.json`. The archive +includes a pinned ffmpeg-kit builder and a local build entry point that skips +its destructive checkout step, preserving a recipient's edits. `REBUILD.md` +explains how to build and relink through local package overrides. Signing +credentials and untracked configuration are excluded. `revisions.json` records +source revisions and checksums. + +SwiftLibass's upstream script clones an unpinned ffmpeg-kit branch. The builder +and native source tags recorded here match its documented prebuilt versions; +upstream does not publish a manifest establishing byte-for-byte provenance of +those prebuilt libraries. The supplied rebuild inputs are pinned independently. diff --git a/TRADEMARK.md b/TRADEMARK.md index c859a3784..8b7eda8ba 100644 --- a/TRADEMARK.md +++ b/TRADEMARK.md @@ -1,6 +1,6 @@ # Prairie Trademark & Brand Policy -Prairie is a fork of Prairie. Prairie's source code is free and open source under +Prairie is a fork of Silo. Prairie's source code is free and open source under the **GNU Affero General Public License v3.0 or later** (`AGPL-3.0-or-later`). You are welcome to use, study, modify, and redistribute the code under those terms. @@ -8,7 +8,7 @@ the code under those terms. **That freedom applies to the code, not to the Prairie name and brand.** The Prairie marks identify this project and its builds. This policy explains how they may and may not be used so users can distinguish Prairie from other forks -and from the original Prairie project. +and from the original Silo project. ## Marks covered @@ -29,7 +29,7 @@ marks.** This reservation is expressly permitted by AGPL-3.0 §7(e). or "a plugin for Prairie." Use the word to refer to this project, not to brand yours. - **Referring to the fork history truthfully** - for example, "Prairie is a - fork of Prairie" or "based on Prairie." + fork of Silo" or "based on Silo." - **Redistributing official, unmodified Prairie builds** under the Prairie name, with all notices kept intact. - **Running, modifying, and self-hosting** Prairie for yourself or your diff --git a/docs/collections-api-v2.md b/docs/collections-api-v2.md new file mode 100644 index 000000000..d9333a0e6 --- /dev/null +++ b/docs/collections-api-v2.md @@ -0,0 +1,9 @@ +# Personal collection API v2 + +Personal collection lists decode `items` and `groups`. Collection and group creation use POST. Move and group rename use PATCH; moving to Ungrouped sends an explicit `group_id: null`. + +An editor or delete confirmation first reads the canonical collection or group resource. Its ETag stays attached to the captured account and profile and is sent unchanged as `If-Match`. Background list refreshes do not replace it. A 412 keeps the sheet and draft open, blocks submission, and requires an explicit reload and review. A 428 (missing precondition) is logged as a client bug and handled the same way. Creates and edits are `non_retryable`: a write whose answer is lost is never resent; the sheet requires a reload, and a create re-reads the list. A 404 from an edit or its reload means the item is gone: the sheet closes and the list is re-read. A write's outcome only changes the sheet that sent it; if that sheet was dismissed, an uncertain outcome re-reads the list instead. Group controls require `GET /api/v2/collections/capabilities` to report `allowed`, `state: available` and `groups: true`. A failed capability read hides group controls and offers Retry instead of deciding that groups are unsupported. A 501 `capability_unsupported` from a group operation hides them. + +Apple has no existing collection/group/item reorder, membership mutation, import, artwork, or template consumers. Those actions are not added by this migration. Personal collection cards use `GET /api/v2/catalog?source=user_collection&collection_id=...` and the shared catalog card model. Each load follows opaque cursor pages of 50 under one account/profile identity. The existing complete-list views publish only the finished result; malformed continuation, a failed page, identity changes, or the 100-page safety limit fail the load. Refresh explicitly restarts after an invalid cursor. Raw membership records are never decoded as cards. + +Regular library collection listing/browsing and general catalog browsing remain on their existing transport; this slice changes only the personal collection source. diff --git a/docs/images/ios-detail-redesign/home.png b/docs/images/ios-detail-redesign/home.png new file mode 100644 index 000000000..cbaebe46e Binary files /dev/null and b/docs/images/ios-detail-redesign/home.png differ diff --git a/docs/images/ios-detail-redesign/movie-detail.png b/docs/images/ios-detail-redesign/movie-detail.png new file mode 100644 index 000000000..f94cff715 Binary files /dev/null and b/docs/images/ios-detail-redesign/movie-detail.png differ diff --git a/docs/images/ios-detail-redesign/series-detail.png b/docs/images/ios-detail-redesign/series-detail.png new file mode 100644 index 000000000..e790c3a4e Binary files /dev/null and b/docs/images/ios-detail-redesign/series-detail.png differ diff --git a/docs/images/tvos-detail-redesign/cast-and-trailers.jpg b/docs/images/tvos-detail-redesign/cast-and-trailers.jpg new file mode 100644 index 000000000..04894de2f Binary files /dev/null and b/docs/images/tvos-detail-redesign/cast-and-trailers.jpg differ diff --git a/docs/images/tvos-detail-redesign/episode-detail.jpg b/docs/images/tvos-detail-redesign/episode-detail.jpg new file mode 100644 index 000000000..c2e783e9b Binary files /dev/null and b/docs/images/tvos-detail-redesign/episode-detail.jpg differ diff --git a/docs/images/tvos-detail-redesign/favorites.jpg b/docs/images/tvos-detail-redesign/favorites.jpg new file mode 100644 index 000000000..13228252e Binary files /dev/null and b/docs/images/tvos-detail-redesign/favorites.jpg differ diff --git a/docs/images/tvos-detail-redesign/home.jpg b/docs/images/tvos-detail-redesign/home.jpg new file mode 100644 index 000000000..ff05966e0 Binary files /dev/null and b/docs/images/tvos-detail-redesign/home.jpg differ diff --git a/docs/images/tvos-detail-redesign/movie-detail.jpg b/docs/images/tvos-detail-redesign/movie-detail.jpg new file mode 100644 index 000000000..8b1727bd9 Binary files /dev/null and b/docs/images/tvos-detail-redesign/movie-detail.jpg differ diff --git a/docs/images/tvos-detail-redesign/series-detail.jpg b/docs/images/tvos-detail-redesign/series-detail.jpg new file mode 100644 index 000000000..2cda2be1d Binary files /dev/null and b/docs/images/tvos-detail-redesign/series-detail.jpg differ diff --git a/docs/native-api-v2.md b/docs/native-api-v2.md new file mode 100644 index 000000000..c214bdaf8 --- /dev/null +++ b/docs/native-api-v2.md @@ -0,0 +1,75 @@ +# Native API v2 ownership + +The stable native business API is `/api/v2`. Apple release callers use the +accepted v2 operation contracts and refuse unavailable operations. A failed or +unconfigured v2 playback request must not silently start a v1 session. The +retained `/api/v1/health` probe is the only v1 request and an operational +exception, not a fallback. Debug builds assert this in `HTTPClient`, and +`scripts/ci/check-no-api-v1.sh` holds every v1 path mention in the sources to +an exact allowlist. The server's [native API contract](https://github.com/Prairie-Server/prairie-server/blob/main/docs/architecture/api-contract.md) +defines the release boundary. + +## Displayed reads authorize card actions + +A catalog response belongs to the account, profile, credential owner and profile +proof captured for that read. Its collection, library or recommendation scope +also belongs to that response. A later tap uses this displayed owner; it does +not capture whichever profile happens to be current when an asynchronous task +runs. + +Collection, library-collection, History and recommendation cards provide this +owner to their membership model. Browse, search, person, saved-list, Home and +library landing cards retain their existing owning models or captured callbacks. +Cards without an owning read do not offer an ownerless membership mutation. +Section watched actions inherit the library or recommendation read owner; +Home continues to supply its own model-owned callbacks. + +Opaque catalog continuations retain the original query and authority. Personal +collection reads publish only after the bounded complete read succeeds. +Library-collection and History grids keep incremental paging. Legacy cached +cards without an owner cannot authorize a fresh action or seed a different +owner's display. Accepted membership flags on existing cards survive appending +the next page. + +Membership uses the accepted PUT/DELETE favorite and watchlist operations; +watched state uses POST/DELETE. Each prepared action dispatches once. Receipt +application checks the captured owner and displayed generation again, so a late +response cannot update a replacement profile's cards or caches. These callers +do not add revision headers, refresh-authentication retries or a v1 fallback. +An uncertain action stays held by its owning model across refreshes. A new read +is not proof that the earlier mutation reached a terminal outcome. These models +do not create an offline replay queue. + +## Durable commands keep their original authority + +Playback and canonical settings journals preserve exact command bytes and +captured durable ownership before dispatch. Persistent target matching and +transient dispatch checks serve different purposes: a process restart alone +must not erase an unresolved durable target barrier, while a different login +or profile cannot take ownership of an old command. + +Recovery is operation-specific. Canonical settings value writes are +`natural_idempotent`: a 401 refreshes the session once and re-sends the same +desired value under the same captured owner. They hold uncertain writes and do +not invent an idempotency or revision-precondition contract. Playback can resolve an exact retained command +where its accepted protocol explicitly supports that resolution. Neither path +may convert old queued intent bytes, infer new authorization or rebase an +unresolved operation onto a new owner. + +See [bound audiobook playback](bound-audiobook-playback.md) for timeline identity, +sequenced receipts and part-transition rules. + +## Media request credentials stay ephemeral + +A validated immutable playback plan is joined with the authority already +captured by its start or replan request. Where an accepted auxiliary transport +requires account authentication, Authorization and the matching `X-Profile-Id` +belong only in ephemeral, scoped request headers. Their use must be fenced by +the exact issued origin, session, path, pins and authority lifetime. + +The server must not echo a bearer into `stream.headers`. Clients must not add +one to durable start responses, current plans, replacement JSON or journal +records. A later ambient token/profile read cannot supply authority for an old +plan. The recipe profile remains authoritative; a selector only selects media. +Signed URL and opaque-token transports retain their own contracts. This rule +does not imply that a particular auxiliary route is implemented or available. diff --git a/docs/realtime-updates-spec.md b/docs/realtime-updates-spec.md index 8a096f1e2..627f768dc 100644 --- a/docs/realtime-updates-spec.md +++ b/docs/realtime-updates-spec.md @@ -38,7 +38,7 @@ never finds out. ### 2.1 Apple clients today (pull-only) -- Networking is REST via `HTTPClient` (actor) → `ContinuumAPI` (actor). No shared HTTP cache. +- Networking is REST via `HTTPClient` (actor) → `PrairieAPI` (actor). No shared HTTP cache. - `Screens/Home/HomeView.swift` refresh triggers: - `.task` — first load (`HomeViewModel.loadSections()` + recommendations). (~L79–83) - `.onAppear` — refetch on return to page, skipped on first appear. (~L84–91) @@ -61,7 +61,7 @@ will mirror. Key properties to reuse as a pattern: failures (~L27, surfaces a non-fatal "realtime unavailable" flag). - `http→ws` / `https→wss` scheme conversion (~L252–259). - **Generation-based binding** to cancel stale connection loops on rebind/unbind. -- `ContinuumAPI.shared.currentServerUrl()` and `.currentAccessToken()` provide base URL + token. +- `PrairieAPI.shared.currentServerUrl()` and `.currentAccessToken()` provide base URL + token. ### 2.3 Server events hub (already live — verify in `prairie-server`) @@ -186,7 +186,7 @@ actor EventsRealtimeClient { #### Connection loop (mirrors `PlaybackRealtimeClient.runConnectionLoop`) -1. Build request: base URL from `ContinuumAPI.shared.currentServerUrl()`, path +1. Build request: base URL from `PrairieAPI.shared.currentServerUrl()`, path `/api/v1/events/ws`, `http→ws` / `https→wss`, `Authorization: Bearer `. No `?token=` and no `?ticket=` (we don't subscribe to `notifications`). 2. `webSocketTask(with:)`, `resume()`. @@ -406,7 +406,7 @@ Focused tests only (per repo guidelines — no broad UI tests): bind `scenePhase`, register Home, reconnect on profile switch / stop on sign-out. - `iosApp/project.yml` — only if new folders need explicit inclusion; regenerate with `xcodegen generate`. -- (Reference only, no change) `ContinuumAPI` — already exposes `currentServerUrl()`, +- (Reference only, no change) `PrairieAPI` — already exposes `currentServerUrl()`, `currentAccessToken()`, `homeSections()`. **Server (`prairie-server`): none.** (Possible *future* opt-in: add `position_seconds` to diff --git a/docs/requests-api-v2.md b/docs/requests-api-v2.md new file mode 100644 index 000000000..d6cfdb4c0 --- /dev/null +++ b/docs/requests-api-v2.md @@ -0,0 +1,39 @@ +# Requests API v2 consumer + +The Apple clients call `/api/v2/requests` for the request capability, search, discovery, detail, create, My Requests, and cancel. The methods live in `APIv2Client+Requests.swift`; `PrairieAPI+Requests.swift` forwards to them. No request falls back to v1. + +## Scope and ownership + +Every operation is profile scoped. A call needs a selected profile, sends `X-Profile-Id` (plus `X-Profile-Token` when the profile has a proof), and runs under the owner captured when it starts. A profile, account, or server change before dispatch refuses the call; a change while it is in flight discards the response. + +## Capability + +`GET /api/v2/requests/status` returns `FeatureStatus`. Entry points show only when `requests_enabled` is true, `allowed` is true, and `state` is `available`. A blocked account gets `requests_enabled: true` with `allowed: false`, so its entry points stay hidden. The contract requires `allowed`; a response without it reads as not allowed. A failed probe keeps the previous value. + +## Reads + +- Search sends `q`, `media_type` (`movie`, `series` or `all`) and a 1-based `page`. Blank text is refused before dispatch because the server answers 422. +- Discovery decodes `{items}`. The server omits `page` because the collection is bounded. Each row may carry `next_page` when rating backfill used more than one provider page. +- Detail accepts only `movie` and `series` in the path. +- My Requests follows `page.next_cursor` with a page size of 50. A failed page, a missing or repeated cursor, an owner change, or the 100-page bound fails the load instead of showing a partial list. + +Every read requires status 200. + +## Create and cancel + +`POST /api/v2/requests` requires 201. `POST /api/v2/requests/{id}/cancel` requires 200 and percent-encodes the opaque request ID. The contract marks both `non_retryable`: the server keeps no client request identity, so a resend can act twice. + +| Outcome | Examples | Behavior | +|---|---|---| +| Definite success | 201 create, 200 cancel with a readable record | Publish the record, then re-read the detail or list. | +| Definite failure | Never sent (no connection, DNS or TLS failure), no selected profile, or any problem response | Show the error once. The user may try again. | +| Uncertain | Connection lost or timed out after sending, an unexpected 2xx, an unreadable 2xx body | Never resend. Hold the action (the detail CTA shows "Not confirmed yet"; a held cancel hides its menu item) and re-read. A successful re-read releases the hold. | +| Uncertain, owner changed | The account, credential owner or profile changed after the call captured its owner (`APIv2RequestsError.outcomeUnknownOwnerChanged`) | Never resend and hold as above, but skip the re-read: it would run under the new owner. The banner tells the user to check My Requests. | + +The transport raises the same identity error just before sending and after a response arrives, so the client cannot tell whether a mutation interrupted by an owner change reached the server. It treats every such case as uncertain. Reads keep the ordinary `HTTPError.authorityChanged`. + +## Error copy + +v2 folds "already requested", "already available" and "invalid state" into one `conflict` problem, and the quota problem carries its counts. `RequestErrorCopy` therefore shows the problem's `detail`, which the contract marks safe to show. `validation_failed` and the capability problems use fixed copy, and `client_upgrade_required` uses the app-update message. + +This covers the existing request screens only. It does not add watch-provider or history-import management. Jellyfin protocol behavior is unchanged. diff --git a/docs/requests/mockups.html b/docs/requests/mockups.html index d1ed8aa01..66f6308b0 100644 --- a/docs/requests/mockups.html +++ b/docs/requests/mockups.html @@ -617,7 +617,7 @@

04Apple TV — request from Search

- SILO + PRAIRIE
HomeMoviesSeries For YouCalendar @@ -708,7 +708,7 @@

06Apple TV — hub & entry point

- SILO + PRAIRIE
HomeMoviesSeries For YouCalendar diff --git a/docs/skyline-design-guide.md b/docs/skyline-design-guide.md index 63f391603..3cb832902 100644 --- a/docs/skyline-design-guide.md +++ b/docs/skyline-design-guide.md @@ -51,7 +51,7 @@ All dimensions in this guide are **mockup pixels at 1920×1080**, which map: | Android TV (Compose) | 1 px = **0.5 dp** (1080p TV ≈ xhdpi → 960×540 dp window) | 32 dp | Sanity check against existing code: tvOS poster `260×390 pt` ≡ Android `130×195 dp` -(already true in `ContinuumTheme.swift` / `Spacing.kt`), so existing token files +(already true in `PrairieTheme.swift` / `Spacing.kt`), so existing token files already follow this rule. Android text sizes below assume the TV app's global 0.86 font-scale stays in place — give Compose the sp values listed, not px/2 of type. @@ -61,7 +61,7 @@ already follow this rule. Android text sizes below assume the TV app's global ``` TOP BAR (persistent on all root screens) -├─ SILO wordmark (left, non-interactive) +├─ PRAIRIE wordmark (left, non-interactive) ├─ Tabs (center): │ Home │ Movies ── cascading selector on focus (§5.3) @@ -133,7 +133,7 @@ FULL-SCREEN (pushed over everything, no top bar): ## 4. Design tokens -Unchanged from the current apps (`ContinuumTheme.swift`, `Color.kt`): background +Unchanged from the current apps (`PrairieTheme.swift`, `Color.kt`): background `#000000`, surface `#0A0A0A`, elevated `#15171C`, primary text `#EDEDED`, secondary = primary @ 60%, tertiary @ 38%, hairline white @ 12%, error `#B00020`, rating amber `#FFC107`. Radii: 8 / 12 / 18 (+ pill = capsule). No chromatic accent. diff --git a/docs/skyline-implementation-prompt.md b/docs/skyline-implementation-prompt.md index 90e027bd8..64cebb2a1 100644 --- a/docs/skyline-implementation-prompt.md +++ b/docs/skyline-implementation-prompt.md @@ -2,7 +2,7 @@ > **Status:** Phase 1 (chrome) is **complete** on `feature/skyline-phase-1` > (type-derived tabs · pill row · Libraries/For You roots removed · profile -> dropdown · `ContinuumTheme.Skyline` tokens). This prompt targets **Phase 2 — +> dropdown · `PrairieTheme.Skyline` tokens). This prompt targets **Phase 2 — > the focus marquee** from guide Rev 2. Write the Phase 3 (cascading selector) > and Phase 4 (polish & parity) prompts after this phase merges. @@ -33,7 +33,7 @@ Implement **Phase 2 of the Skyline navigation redesign** for the tvOS app: the `iosApp/iosApp/Screens/Home/HomeView.swift`, `iosApp/iosApp/Screens/Home/FeaturedCarousel.swift`, `iosApp/iosApp/tvOS/Components/TVRootHeroBackdrop.swift`, - `iosApp/iosApp/Theme/ContinuumTheme.swift` (the `Skyline` namespace). + `iosApp/iosApp/Theme/PrairieTheme.swift` (the `Skyline` namespace). ## What Phase 1 already built (do not redo) @@ -57,7 +57,7 @@ Implement **Phase 2 of the Skyline navigation redesign** for the tvOS app: the 1. **`TVFocusMarquee` component** (new, tvOS-only) per guide §5.4/§5.5: two scales — Home (block top 218, title 84, eyebrow 17) and library (top 246, title 66, eyebrow 16). Eyebrow = the **source row's title**, preceded by the - `marquee.tick` dash (white @ 85%, 26×3, r 2 — add to `ContinuumTheme.Skyline`). + `marquee.tick` dash (white @ 85%, 26×3, r 2 — add to `PrairieTheme.Skyline`). Meta line: codec/HDR badges, then year·genre·runtime, or `S2 E7 · episode title · 23 min left` for episodic items. 2-line synopsis, max-width 780 (clamp to 1 line if the title wraps to 2). Title falls back to @@ -112,8 +112,8 @@ any server changes, any iOS/macOS behavior changes. Search, Calendar, Recommendations) are also compiled for iOS/macOS — guard tvOS-only changes and keep the other platforms building unchanged. - Match existing style: types `PascalCase`, the `TV` prefix for tvOS views, - tokens from `ContinuumTheme` / `Colors.swift` — no hardcoded values, add new - tokens to the theme (`ContinuumTheme.Skyline` exists from Phase 1). No new + tokens from `PrairieTheme` / `Colors.swift` — no hardcoded values, add new + tokens to the theme (`PrairieTheme.Skyline` exists from Phase 1). No new dependencies. No tests for this UI work unless shared logic changes (per `CLAUDE.md`). - Branch `feature/skyline-phase-2` stacked on `feature/skyline-phase-1` (or on @@ -131,7 +131,7 @@ The codebase already encodes several tvOS lessons — preserve them: once. Tab switches swap content under the stack; they are not pushes. - **The top menu is custom on purpose** (the system `TabView` sidebar steals leftward focus). Keep the custom bar; keep `.focusEffectDisabled()` + - chrome driven by focus state (`ContinuumFlatButtonStyle` pattern) rather + chrome driven by focus state (`PrairieFlatButtonStyle` pattern) rather than the system halo. - **The marquee never participates in focus.** No focusable subviews, no `focusSection` membership; the vertical zone model becomes top bar → pill diff --git a/docs/superpowers/plans/2026-06-14-companion-pairing-card-ios.md b/docs/superpowers/plans/2026-06-14-companion-pairing-card-ios.md index 38b3720e3..0039a2f6e 100644 --- a/docs/superpowers/plans/2026-06-14-companion-pairing-card-ios.md +++ b/docs/superpowers/plans/2026-06-14-companion-pairing-card-ios.md @@ -32,7 +32,7 @@ - Regenerate the project after adding/removing files: `xcodegen generate --spec iosApp/project.yml` (writes `iosApp/Prairie.xcodeproj`; no `cd` needed). - iOS build (no signing): `xcodebuild build -project iosApp/Prairie.xcodeproj -scheme Prairie -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO` - tvOS build: same with `-scheme PrairieTV -destination 'generic/platform=tvOS Simulator'`. -- The card deliberately uses **system blue** for its primary button and selection accents (the app's global tint is `.continuumOnSurface`, near-white). Blue matches the native iOS pairing aesthetic the card emulates. This is intentional, not a mistake. +- The card deliberately uses **system blue** for its primary button and selection accents (the app's global tint is `.prairieOnSurface`, near-white). Blue matches the native iOS pairing aesthetic the card emulates. This is intentional, not a mistake. --- @@ -360,10 +360,10 @@ struct CompanionPairingCard: View { VStack(spacing: 0) { heroGlyph.padding(.bottom, 16) Text("Set Up \(tv.name)") - .font(.continuumTitle) + .font(.prairieTitle) .multilineTextAlignment(.center) Text("Sign this Apple TV in using this iPhone.") - .font(.continuumCaption) + .font(.prairieCaption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.top, 6) @@ -400,7 +400,7 @@ struct CompanionPairingCard: View { .background(accent, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) .foregroundStyle(.white) Text(server.displayName) - .font(.continuumBody) + .font(.prairieBody) .foregroundStyle(.primary) Spacer() Image(systemName: isOn ? "checkmark.circle.fill" : "circle") @@ -410,7 +410,7 @@ struct CompanionPairingCard: View { .padding(.horizontal, 14) .padding(.vertical, 12) .background( - isOn ? Color.continuumChromeSelectedFill : Color.continuumChromeRestingFill, + isOn ? Color.prairieChromeSelectedFill : Color.prairieChromeRestingFill, in: RoundedRectangle(cornerRadius: 13, style: .continuous) ) } @@ -420,15 +420,15 @@ struct CompanionPairingCard: View { private func confirm(serverName: String, matchCode: String) -> some View { VStack(spacing: 0) { Text("Make sure your TV shows") - .font(.continuumCaption) + .font(.prairieCaption) .foregroundStyle(.secondary) Text(matchCode) - .font(.continuumPIN) + .font(.prairiePIN) .textCase(.uppercase) .tracking(8) .padding(.top, 8) Text("for \(serverName)") - .font(.continuumCaption) + .font(.prairieCaption) .foregroundStyle(.secondary) .padding(.top, 4) primaryButton("Yes, this matches") { Task { await coordinator?.confirmMatch() } } @@ -445,11 +445,11 @@ struct CompanionPairingCard: View { .foregroundStyle(.green) .padding(.bottom, 12) Text(signedIn.isEmpty ? "Nothing set up" : "Set up \(signedIn.joined(separator: ", "))") - .font(.continuumHeadline) + .font(.prairieHeadline) .multilineTextAlignment(.center) if !failed.isEmpty { Text("Couldn’t set up: \(failed.joined(separator: ", "))") - .font(.continuumCaption) + .font(.prairieCaption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.top, 4) @@ -465,7 +465,7 @@ struct CompanionPairingCard: View { .foregroundStyle(.yellow) .padding(.bottom, 12) Text(message) - .font(.continuumBody) + .font(.prairieBody) .multilineTextAlignment(.center) primaryButton("Close") { animateOut(onClose) }.padding(.top, 22) } @@ -484,8 +484,8 @@ struct CompanionPairingCard: View { HStack(spacing: 12) { Image(systemName: "appletv.fill").font(.system(size: 22)).frame(width: 40, height: 40) VStack(alignment: .leading, spacing: 2) { - Text(title).font(.continuumHeadline) - Text(subtitle).font(.continuumCaption).foregroundStyle(.secondary) + Text(title).font(.prairieHeadline) + Text(subtitle).font(.prairieCaption).foregroundStyle(.secondary) } Spacer() } @@ -494,8 +494,8 @@ struct CompanionPairingCard: View { private func progressStep(title: String, subtitle: String) -> some View { VStack(spacing: 10) { - Text(title).font(.continuumHeadline) - Text(subtitle).font(.continuumCaption).foregroundStyle(.secondary) + Text(title).font(.prairieHeadline) + Text(subtitle).font(.prairieCaption).foregroundStyle(.secondary) ProgressView().padding(.top, 4) } .padding(.vertical, 8) @@ -503,7 +503,7 @@ struct CompanionPairingCard: View { private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View { Button(action: action) { - Text(title).font(.continuumHeadline).frame(maxWidth: .infinity).padding(.vertical, 6) + Text(title).font(.prairieHeadline).frame(maxWidth: .infinity).padding(.vertical, 6) } .buttonStyle(.borderedProminent) .controlSize(.large) @@ -512,7 +512,7 @@ struct CompanionPairingCard: View { private func tertiaryButton(_ title: String, action: @escaping () -> Void) -> some View { Button(action: action) { - Text(title).font(.continuumBody).frame(maxWidth: .infinity).padding(.vertical, 8) + Text(title).font(.prairieBody).frame(maxWidth: .infinity).padding(.vertical, 8) } .buttonStyle(.plain) .foregroundStyle(accent) diff --git a/docs/superpowers/plans/2026-06-14-companion-pairing.md b/docs/superpowers/plans/2026-06-14-companion-pairing.md index 972754665..3da8b42c9 100644 --- a/docs/superpowers/plans/2026-06-14-companion-pairing.md +++ b/docs/superpowers/plans/2026-06-14-companion-pairing.md @@ -14,7 +14,7 @@ ## Decisions locked in during planning (read before starting) -1. **Pairing networking is isolated from `HTTPClient`.** `HTTPClient`/`ContinuumAPI`/`TokenStore` operate against a single *active* server and have no per-server request API. Rather than rework that, all device-auth calls during pairing go through a new `PairingDeviceAPI` (plain `URLSession`, explicit base URL, optional bearer). This keeps pairing from disturbing the running app's active server. +1. **Pairing networking is isolated from `HTTPClient`.** `HTTPClient`/`PrairieAPI`/`TokenStore` operate against a single *active* server and have no per-server request API. Rather than rework that, all device-auth calls during pairing go through a new `PairingDeviceAPI` (plain `URLSession`, explicit base URL, optional bearer). This keeps pairing from disturbing the running app's active server. 2. **The phone displays the server-authoritative match code.** After receiving a `userCode` over the channel, the phone calls `GET /auth/device?code=` (`PairingDeviceAPI.lookup`) and shows the match code **the server** returns for that code — never the match code from the channel. This is what makes the visual compare resistant to a channel MITM (design spec §6). 3. **Persist-on-success.** The TV holds a pushed server URL as a *pending candidate* and writes it to `ServerRegistry` / `TokenStore` only after the poll returns tokens. Any cancel/fail/timeout/drop discards it (design spec §5/§6/§7). 4. **Tests.** The repo has no working XCTest target (the lone `iosApp/Tests/*.swift` file is an orphaned `@main` + `precondition()` script). Per CLAUDE.md ("focused tests only for critical or high-risk behavior") we unit-test only the **pure, dependency-free** logic — `PairingProtocol` and `PairingFrame` — with standalone `swiftc`-compiled `precondition()` programs that mirror the existing pattern. The stateful coordinators are structured against injected protocols and verified by the **manual LAN end-to-end test** (Task 14). @@ -818,7 +818,7 @@ final class ReceiverPairingCoordinator { /// True while `pollTask` is active. The protocol is one-server-at-a-time; /// an overlapping PushServer is ignored. private var isPolling = false - private static let logger = Logger(subsystem: "com.continuum.app", category: "pairing.receiver") + private static let logger = Logger(subsystem: "org.prairieserver.prairie", category: "pairing.receiver") /// - Parameter onAuthenticated: called on the main actor after at least /// one server is signed in, to advance the router to profile selection. @@ -979,7 +979,7 @@ import OSLog final class TVPairingAdvertiser { private var listener: NWListener? private var busy = false - private static let logger = Logger(subsystem: "com.continuum.app", category: "pairing.advertiser") + private static let logger = Logger(subsystem: "org.prairieserver.prairie", category: "pairing.advertiser") /// - Parameter onConnection: called on the main actor with an opened /// session + its inbound stream for the coordinator to drive. @@ -1166,7 +1166,7 @@ final class CompanionPairingCoordinator { private var confirmed = false private var signedIn: [String] = [] private var failed: [String] = [] - private static let logger = Logger(subsystem: "com.continuum.app", category: "pairing.companion") + private static let logger = Logger(subsystem: "org.prairieserver.prairie", category: "pairing.companion") init(api: PairingDeviceAPI = PairingDeviceAPI(), session: PairingSession, stream: AsyncThrowingStream) { self.api = api @@ -1631,7 +1631,7 @@ git commit --allow-empty -m "Verify companion pairing end-to-end on LAN" ## Self-Review (completed during planning) -**Spec coverage:** §4 components → PairingProtocol (T2), PairingSession+frame (T4/T6), ContinuumAPI approve/lookup → PairingDeviceAPI (T7), Receiver coordinator+advertiser+UI (T8/T9), Companion coordinator+browser+banner+view (T10–T13). §5 flow + confirm-once → T10. §6 persist-on-success → T8; server-authoritative match code → T10; opportunistic TLS → T6. §7 edge cases → coordinators + T14. §8 Info.plist/permissions → T1. §9 testing → T3/T5 (pure logic) + T14 (manual). All spec sections map to a task. +**Spec coverage:** §4 components → PairingProtocol (T2), PairingSession+frame (T4/T6), PrairieAPI approve/lookup → PairingDeviceAPI (T7), Receiver coordinator+advertiser+UI (T8/T9), Companion coordinator+browser+banner+view (T10–T13). §5 flow + confirm-once → T10. §6 persist-on-success → T8; server-authoritative match code → T10; opportunistic TLS → T6. §7 edge cases → coordinators + T14. §8 Info.plist/permissions → T1. §9 testing → T3/T5 (pure logic) + T14 (manual). All spec sections map to a task. **Type consistency:** `PairingMessage` cases and field names are identical across T2 (definition), T8/T10 (producers/consumers). `PairingSession.open()` returns `AsyncThrowingStream` and is consumed with that exact type in T8–T13. `PairingDeviceAPI` method names (`start/poll/lookup/approve`) match their call sites in T8/T10. `ServerEntry`, `ServerRegistry.{normalize,serverId,addOrUpdate,switchTo,sortedEntries}`, `TokenStore.{getAccessToken(for:),saveTokens,switchActiveServer,setServerUrl}`, `AppRouter.showProfileSelection()`, and `AppleDeviceIdentity.current.{id,name,platform}` all match the verbatim signatures extracted from the codebase. diff --git a/docs/superpowers/plans/2026-06-15-ios-aurora-onboarding.md b/docs/superpowers/plans/2026-06-15-ios-aurora-onboarding.md index 173962da7..b5e5cdaf7 100644 --- a/docs/superpowers/plans/2026-06-15-ios-aurora-onboarding.md +++ b/docs/superpowers/plans/2026-06-15-ios-aurora-onboarding.md @@ -136,7 +136,7 @@ struct AuroraInputField: View { ) .shadow(color: isFocused ? Color.auroraAccent.opacity(0.4) : .clear, radius: isFocused ? 20 : 0, y: 0) - .animation(ContinuumTheme.springAnimation, value: isFocused) + .animation(PrairieTheme.springAnimation, value: isFocused) } private var displayString: String { @@ -229,7 +229,7 @@ import SwiftUI // MARK: - Field content classification (platform-neutral) -/// Mirrors the small enum pattern in the old Continuum forms so the shared +/// Mirrors the small enum pattern in the old Prairie forms so the shared /// component carries no UIKit types in its signature — macOS has no /// `UITextContentType`. enum AuroraFieldContentType { case username, password, email, oneTimeCode, url } @@ -259,8 +259,8 @@ struct AuroraErrorLabel: View { Text(text) .fixedSize(horizontal: false, vertical: true) } - .font(.continuumCaption) - .foregroundStyle(Color.continuumError) + .font(.prairieCaption) + .foregroundStyle(Color.prairieError) .frame(maxWidth: .infinity, alignment: .leading) .transition(.opacity) } @@ -338,7 +338,7 @@ struct AuroraTextField: View { ) .shadow(color: isFocused ? Color.auroraAccent.opacity(0.35) : .clear, radius: isFocused ? 16 : 0) - .animation(ContinuumTheme.springAnimation, value: isFocused) + .animation(PrairieTheme.springAnimation, value: isFocused) } } @@ -471,7 +471,7 @@ struct ServerSetupView: View { VStack(spacing: 12) { AuroraEyebrow(text: "Step 01 — Connect", centered: true) Text("Add your server") - .font(.continuumTitle) + .font(.prairieTitle) .foregroundStyle(Color.auroraInk) } .frame(maxWidth: .infinity) @@ -519,7 +519,7 @@ struct ServerSetupView: View { @ViewBuilder private var advancedDisclosure: some View { Button { - withAnimation(ContinuumTheme.springAnimation) { + withAnimation(PrairieTheme.springAnimation) { viewModel.showsAdvancedOptions.toggle() } } label: { @@ -636,7 +636,7 @@ with: ```swift Button { - withAnimation(ContinuumTheme.springAnimation) { + withAnimation(PrairieTheme.springAnimation) { viewModel.showsAdvancedOptions.toggle() } } label: { @@ -681,7 +681,7 @@ On `manualCard`, add an animation for the toggle next to the existing error anim to: ```swift .animation(.easeInOut(duration: 0.2), value: viewModel.error) - .animation(ContinuumTheme.springAnimation, value: viewModel.showsAdvancedOptions) + .animation(PrairieTheme.springAnimation, value: viewModel.showsAdvancedOptions) ``` - [ ] **Step 4: Build tvOS** @@ -731,7 +731,7 @@ struct LoginView: View { VStack(spacing: 12) { AuroraEyebrow(text: "Step 02 — Sign in", centered: true) Text("Welcome back") - .font(.continuumTitle) + .font(.prairieTitle) .foregroundStyle(Color.auroraInk) if let host = hostLabel { Text(host) @@ -865,7 +865,7 @@ struct SignupView: View { VStack(spacing: 12) { AuroraEyebrow(text: "Create account", centered: true) Text("Create your account") - .font(.continuumTitle) + .font(.prairieTitle) .foregroundStyle(Color.auroraInk) .multilineTextAlignment(.center) } @@ -965,7 +965,7 @@ In `background`, replace the `#else` branch: ```swift #else ZStack { - Color.continuumBackground.ignoresSafeArea() + Color.prairieBackground.ignoresSafeArea() RadialGradient( colors: [Color.white.opacity(0.07), Color.black.opacity(0)], center: .center, @@ -1086,11 +1086,11 @@ struct ServerNeedsSetupView: View { VStack(spacing: 12) { Text("Finish setup in your browser") - .font(.continuumTitle) + .font(.prairieTitle) .foregroundStyle(Color.auroraInk) .multilineTextAlignment(.center) Text("This server doesn't have an account yet. Open it in a browser to create the first one, then come back here to sign in.") - .font(.continuumBody) + .font(.prairieBody) .foregroundStyle(Color.auroraInkSecondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) @@ -1233,7 +1233,7 @@ In `iosApp/iosApp/ContentView.swift`, replace the whole `destinationView(for:)` title: "Finish setup in your browser", subtitle: nil ) - .continuumBackground() + .prairieBackground() #else ServerNeedsSetupView(router: router) #endif @@ -1244,7 +1244,7 @@ In `iosApp/iosApp/ContentView.swift`, replace the whole `destinationView(for:)` title: "Sign up from a phone or the web", subtitle: nil ) - .continuumBackground() + .prairieBackground() #else SignupView(router: router) #endif @@ -1262,7 +1262,7 @@ In `iosApp/iosApp/ContentView.swift`, replace the whole `destinationView(for:)` title: "Coming Soon", subtitle: "This screen is under construction." ) - .continuumBackground() + .prairieBackground() } } ``` @@ -1278,7 +1278,7 @@ with: case .serverNeedsSetup: #if os(tvOS) EmptyStateView(icon: "gearshape.2", title: "Finish setup in your browser", subtitle: nil) - .continuumBackground() + .prairieBackground() #else ServerNeedsSetupView(router: router) #endif @@ -1289,7 +1289,7 @@ And gate the `.signup` arm in `profileFlowDestination` the same way (it currentl case .signup: #if os(tvOS) EmptyStateView(icon: "person.badge.plus", title: "Sign up from a phone or the web", subtitle: nil) - .continuumBackground() + .prairieBackground() #else SignupView(router: router) #endif @@ -1346,7 +1346,7 @@ Expected: `** BUILD SUCCEEDED **` for all three. - [ ] **Step 2: Run the iOS app in the simulator and walk the flow** Boot the iPhone simulator, install, and launch the `Prairie` scheme. Verify visually: -- **Server setup**: plum Aurora backdrop with drifting ribbons + starfield, `SILO.` wordmark, "Step 01 — Connect" gold eyebrow, single glass form, "Advanced options" collapsed by default; expanding reveals Protocol + Port. Enter a known dev server and connect. +- **Server setup**: plum Aurora backdrop with drifting ribbons + starfield, `PRAIRIE.` wordmark, "Step 01 — Connect" gold eyebrow, single glass form, "Advanced options" collapsed by default; expanding reveals Protocol + Port. Enter a known dev server and connect. - **Sign in**: "Step 02 — Sign in" + "Welcome back" + host label; username/password with a working eye toggle; keyboard pushes the focused field into view (form scrolls); "Create account" appears only when signup is enabled; "Change server" returns to server setup. - **Profile**: "Who's watching?" over the Aurora backdrop; tiles tappable; a PIN profile presents the PIN sheet; "Switch server"/"Sign out" chips work. - **Sign up** (if the dev server allows it): all fields render and scroll; create-account works. diff --git a/docs/superpowers/plans/2026-06-15-resume-first-home.md b/docs/superpowers/plans/2026-06-15-resume-first-home.md index a74cc50a9..5b1e22b00 100644 --- a/docs/superpowers/plans/2026-06-15-resume-first-home.md +++ b/docs/superpowers/plans/2026-06-15-resume-first-home.md @@ -63,7 +63,7 @@ In `HomeView.swift`, inside the `#if !os(tvOS)` state block, delete the three he ```swift // DELETE these three lines: - @State private var heroTintColor: Color = .continuumBackground + @State private var heroTintColor: Color = .prairieBackground @State private var heroBackdropURL: String? @State private var heroBackdropThumbhash: String? ``` @@ -87,7 +87,7 @@ In `HomeView.swift`, in the non-tvOS `body` (the `#else` branch), the `ZStack(al ```swift ZStack(alignment: .top) { - Color.continuumBackground + Color.prairieBackground .ignoresSafeArea() Group { @@ -147,7 +147,7 @@ In `HomeView.swift`, replace the body of `scrollContent`'s `LazyVStack` so there .id(HomeFocusTarget.row(section.id)) } } - .padding(.bottom, ContinuumTheme.largePadding) + .padding(.bottom, PrairieTheme.largePadding) ``` Update the `HomeFocusTarget` enum to drop the featured case and rename the spacer: @@ -163,8 +163,8 @@ Rename the helper `noFeaturedTopSpacing(topSafeAreaInset:)` to `topRunwaySpacing ```swift private func topRunwaySpacing(topSafeAreaInset: CGFloat) -> CGFloat { - let headerContentHeight: CGFloat = 40 + (ContinuumTheme.smallPadding * 2) - return topSafeAreaInset + headerContentHeight + ContinuumTheme.largePadding + ContinuumTheme.smallPadding + let headerContentHeight: CGFloat = 40 + (PrairieTheme.smallPadding * 2) + return topSafeAreaInset + headerContentHeight + PrairieTheme.largePadding + PrairieTheme.smallPadding } ``` @@ -221,7 +221,7 @@ In `BrowseView.swift`, in `LibraryRecommendedView`'s `content`, delete the entir ```swift private var content: some View { ScrollView(.vertical, showsIndicators: false) { - LazyVStack(spacing: ContinuumTheme.largePadding) { + LazyVStack(spacing: PrairieTheme.largePadding) { ForEach(viewModel.regularSections) { section in SectionRow( section: section, @@ -229,7 +229,7 @@ In `BrowseView.swift`, in `LibraryRecommendedView`'s `content`, delete the entir ) } } - .padding(.bottom, ContinuumTheme.largePadding) + .padding(.bottom, PrairieTheme.largePadding) } // Report the distance scrolled from the resting top position. We add // the top content inset so the value starts at 0 at rest regardless @@ -250,7 +250,7 @@ In `BrowseView.swift`, `extendsBackdropToTop` is now used only by `refreshStatus ```swift private var refreshStatusTopPadding: CGFloat { - ContinuumTheme.padding + PrairieTheme.padding } ``` diff --git a/docs/superpowers/plans/2026-06-15-tvos-detail-redesign.md b/docs/superpowers/plans/2026-06-15-tvos-detail-redesign.md index 1b3009094..56a2b427e 100644 --- a/docs/superpowers/plans/2026-06-15-tvos-detail-redesign.md +++ b/docs/superpowers/plans/2026-06-15-tvos-detail-redesign.md @@ -6,7 +6,7 @@ **Architecture:** A view-layer change on the existing `TVDetailHero` + per-type detail views. Today's round controls (`Capsule` pills, `Circle` icon buttons, capsule season chips) are reshaped to one 8 pt rounded-rectangle family. A new `TVPlaybackSelectorRow` consolidates the version picker and the audio/subtitle menus (which already exist in the `⋯` menu) into a webapp-parity row, and adds an Edition selector derived from `FileVersion.edition`. The `ReadableFocusSection` focus workaround is removed; the synopsis becomes an expand-in-place control. The audiobook page gets a cover-forward cinematic hero. -**Tech Stack:** Swift 5 / SwiftUI, tvOS. XcodeGen (`project.yml` → `Prairie.xcodeproj`). Design tokens in `ContinuumTheme.swift`. +**Tech Stack:** Swift 5 / SwiftUI, tvOS. XcodeGen (`project.yml` → `Prairie.xcodeproj`). Design tokens in `PrairieTheme.swift`. --- @@ -14,7 +14,7 @@ **Read before starting any task.** -- **Design language:** Skyline — OLED black, monochrome chrome, white-at-opacity. Squared-control radius = `ContinuumTheme.smallCornerRadius` (8 pt). Spec: `docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md`. Mockups: `docs/tvos-detail-mockups/`. +- **Design language:** Skyline — OLED black, monochrome chrome, white-at-opacity. Squared-control radius = `PrairieTheme.smallCornerRadius` (8 pt). Spec: `docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md`. Mockups: `docs/tvos-detail-mockups/`. - **TDD reconciliation (important):** `CLAUDE.md` says *do not add tests for UI changes* — only focused tests for critical/high-risk shared logic. So **only Task 3 (edition grouping) is test-first.** Every other task is **build-and-verify**: make the change, build `PrairieTV`, then visually confirm in the tvOS Simulator (`admin` / `water1234` per the PrairieTV debugging notes). - **Build gate (run after every task, must succeed):** ```bash @@ -86,17 +86,17 @@ to: ```swift .overlay( - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).stroke( + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).stroke( innerBorderColor, lineWidth: innerBorderWidth ) ) .background( - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).fill(background) + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).fill(background) ) .overlay { if isFocused { - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius + 2, style: .continuous) + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius + 2, style: .continuous) .stroke(focusOutlineColor, lineWidth: focusOutlineWidth) .padding(-focusOutlineInset) } @@ -130,19 +130,19 @@ to: ```swift .background( - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).fill( + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).fill( isFocused ? .white : Color.white.opacity(0.10) ) ) .overlay( - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).stroke( + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).stroke( isFocused ? Color.black.opacity(0.12) : Color.white.opacity(0.34), lineWidth: isFocused ? 1.6 : 1.4 ) ) .overlay { if isFocused { - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius + 2, style: .continuous) + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius + 2, style: .continuous) .stroke(Color.white.opacity(0.96), lineWidth: 3) .padding(-5) } @@ -161,9 +161,9 @@ to: to: ```swift - .background(RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).fill(Color.black.opacity(0.42))) + .background(RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).fill(Color.black.opacity(0.42))) .overlay( - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).stroke(Color.white.opacity(0.16), lineWidth: 1.2) + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).stroke(Color.white.opacity(0.16), lineWidth: 1.2) ) ``` @@ -190,11 +190,11 @@ Co-Authored-By: Claude Opus 4.8 " @ViewBuilder private var background: some View { if isSelected { - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).fill(Color.white) + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).fill(Color.white) } else if isFocused { - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).fill(Color.white.opacity(0.18)) + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).fill(Color.white.opacity(0.18)) } else { - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous).stroke(Color.white.opacity(0.25), lineWidth: 1.5) + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous).stroke(Color.white.opacity(0.25), lineWidth: 1.5) } } ``` @@ -704,7 +704,7 @@ struct TVExpandableSynopsis: View { .contentShape(Rectangle()) } .buttonStyle(TVSynopsisButtonStyle()) - .animation(.easeOut(duration: ContinuumTheme.normalDuration), value: expanded) + .animation(.easeOut(duration: PrairieTheme.normalDuration), value: expanded) } } @@ -721,13 +721,13 @@ private struct TVSynopsisButtonStyle: ButtonStyle { .padding(.horizontal, 20) .padding(.vertical, 14) .background( - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous) - .fill(Color.continuumSurfaceElevated.opacity(isFocused ? 0.55 : 0)) + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous) + .fill(Color.prairieSurfaceElevated.opacity(isFocused ? 0.55 : 0)) ) .padding(.horizontal, -20) .padding(.vertical, -14) .focusEffectDisabled() - .animation(.easeOut(duration: ContinuumTheme.fastDuration), value: isFocused) + .animation(.easeOut(duration: PrairieTheme.fastDuration), value: isFocused) } } } @@ -876,7 +876,7 @@ to: .frame(maxWidth: 900, alignment: .leading) Spacer(minLength: 0) } - .padding(.horizontal, ContinuumTheme.safePadding) + .padding(.horizontal, PrairieTheme.safePadding) .padding(.bottom, 64) .padding(.top, 120) } @@ -902,13 +902,13 @@ to: .blur(radius: 60) .opacity(0.5) } else { - Color.continuumSurface.frame(height: 760) + Color.prairieSurface.frame(height: 760) } LinearGradient( stops: [ - .init(color: Color.continuumBackground.opacity(0.55), location: 0.0), - .init(color: Color.continuumBackground.opacity(0.30), location: 0.5), - .init(color: Color.continuumBackground, location: 1.0), + .init(color: Color.prairieBackground.opacity(0.55), location: 0.0), + .init(color: Color.prairieBackground.opacity(0.30), location: 0.5), + .init(color: Color.prairieBackground, location: 1.0), ], startPoint: .top, endPoint: .bottom ) @@ -963,7 +963,7 @@ Co-Authored-By: Claude Opus 4.8 " ```swift .background( - RoundedRectangle(cornerRadius: ContinuumTheme.smallCornerRadius, style: .continuous) + RoundedRectangle(cornerRadius: PrairieTheme.smallCornerRadius, style: .continuous) .fill(isFocused ? Color.white : Color.white.opacity(0.06)) ) ``` @@ -1038,10 +1038,10 @@ Co-Authored-By: Claude Opus 4.8 " ``` and change the modifier to: ```swift - .animation(reduceMotion ? nil : .easeOut(duration: ContinuumTheme.normalDuration), value: expanded) + .animation(reduceMotion ? nil : .easeOut(duration: PrairieTheme.normalDuration), value: expanded) ``` -- [ ] **Step 2: Confirm the squared button/chip focus already respects Reduce Motion** or is acceptable (the existing styles animate `isFocused` via `ContinuumTheme.springAnimation`; leave as-is unless it visibly janks — these predate this redesign). +- [ ] **Step 2: Confirm the squared button/chip focus already respects Reduce Motion** or is acceptable (the existing styles animate `isFocused` via `PrairieTheme.springAnimation`; leave as-is unless it visibly janks — these predate this redesign). - [ ] **Step 3: Full build gate.** Expected: `** BUILD SUCCEEDED **`. diff --git a/docs/superpowers/plans/2026-06-16-cast-ui-redesign.md b/docs/superpowers/plans/2026-06-16-cast-ui-redesign.md index 0a22220f9..f9e0d88a3 100644 --- a/docs/superpowers/plans/2026-06-16-cast-ui-redesign.md +++ b/docs/superpowers/plans/2026-06-16-cast-ui-redesign.md @@ -6,7 +6,7 @@ **Architecture:** Pure iOS view-layer change. `PrairieCastViews.swift` is split into four focused, `#if os(iOS)`-guarded files under `iosApp/iosApp/Cast/iOS/`. Artwork is resolved client-side from the `contentId` already in the cast state (cache → API), so no protocol field is added. The remote screen is split into a thin controller-observing wrapper plus a pure presentational view driven by plain `PrairieCastPlaybackState` + a command callback, which makes it previewable with mock data. -**Tech Stack:** Swift 5, SwiftUI, `@Observable`, XcodeGen (`project.yml`), existing app primitives (`AsyncImageView`, `PlayerTimeFormatter`, `ContinuumAPI`, `ResponseCache`, `Color.continuum*` tokens). +**Tech Stack:** Swift 5, SwiftUI, `@Observable`, XcodeGen (`project.yml`), existing app primitives (`AsyncImageView`, `PlayerTimeFormatter`, `PrairieAPI`, `ResponseCache`, `Color.prairie*` tokens). --- @@ -33,7 +33,7 @@ The artwork resolver is low-risk network/cache glue (not critical/high-risk shar ``` - **`Prairie.xcodeproj` is gitignored** (XcodeGen output) — never `git add` it. Commit only the Swift/doc files. - **Every new file under `Cast/iOS/` MUST be wrapped in `#if os(iOS) … #endif`** — the tvOS (`PrairieTV`) target also globs `iosApp/` and will otherwise try to compile iOS-only types. -- **Monochrome chrome** (spec §3): no chromatic accent. White (`continuumOnSurface`) fills, black (`continuumBackground`) glyph on the play button; artwork is the only color. +- **Monochrome chrome** (spec §3): no chromatic accent. White (`prairieOnSurface`) fills, black (`prairieBackground`) glyph on the play button; artwork is the only color. ## File structure (decomposition) @@ -85,7 +85,7 @@ final class PrairieCastArtworkResolver { } do { - let detail = try await ContinuumAPI.shared.itemDetail(contentId: contentId) + let detail = try await PrairieAPI.shared.itemDetail(contentId: contentId) apply(detail, contentId: contentId) } catch { // Degrade silently: the remote simply shows the flat background. @@ -106,7 +106,7 @@ struct PrairieCastArtworkBackground: View { var body: some View { ZStack { - Color.continuumBackground + Color.prairieBackground if let urlString, !urlString.isEmpty { AsyncImageView(url: urlString, contentMode: .fill, placeholderStyle: .clear) .id(urlString) @@ -114,7 +114,7 @@ struct PrairieCastArtworkBackground: View { .opacity(0.45) .clipped() } - Color.continuumBackground.opacity(0.55) + Color.prairieBackground.opacity(0.55) } .ignoresSafeArea() } @@ -234,21 +234,21 @@ struct PrairieCastRemoteControlView: View { private var connectingView: some View { VStack(spacing: 18) { RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.continuumSurfaceElevated) + .fill(Color.prairieSurfaceElevated) .frame(width: 150, height: 216) if let error = controller.errorMessage, !error.isEmpty { Text(error) .font(.subheadline) - .foregroundStyle(Color.continuumOnSurface) + .foregroundStyle(Color.prairieOnSurface) .multilineTextAlignment(.center) .padding(.horizontal, 14) .padding(.vertical, 10) - .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.continuumError.opacity(0.9))) + .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.prairieError.opacity(0.9))) } else { ProgressView() Text("Connecting to \(controller.activeTarget?.name ?? "Prairie TV")…") .font(.headline) - .foregroundStyle(Color.continuumSecondaryText) + .foregroundStyle(Color.prairieSecondaryText) } } .padding(24) @@ -292,12 +292,12 @@ private struct RemoteNowPlayingContent: View { AsyncImageView(url: posterURL, contentMode: .fit) } else { RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.continuumSurfaceElevated) + .fill(Color.prairieSurfaceElevated) .aspectRatio(2.0 / 3.0, contentMode: .fit) .overlay { Image(systemName: "tv") .font(.system(size: 36)) - .foregroundStyle(Color.continuumSecondaryText) + .foregroundStyle(Color.prairieSecondaryText) } } } @@ -312,13 +312,13 @@ private struct RemoteNowPlayingContent: View { .font(.title2.weight(.semibold)) .multilineTextAlignment(.center) .lineLimit(2) - .foregroundStyle(Color.continuumOnSurface) + .foregroundStyle(Color.prairieOnSurface) if let subtitle = state.subtitle, !subtitle.isEmpty { Text(subtitle) .font(.subheadline) .multilineTextAlignment(.center) .lineLimit(2) - .foregroundStyle(Color.continuumSecondaryText) + .foregroundStyle(Color.prairieSecondaryText) } } } @@ -332,10 +332,10 @@ private struct RemoteNowPlayingContent: View { Text("Playing on \(targetName)") .font(.caption.weight(.medium)) } - .foregroundStyle(Color.continuumSecondaryText) + .foregroundStyle(Color.prairieSecondaryText) .padding(.horizontal, 12) .padding(.vertical, 6) - .background(Capsule().fill(Color.continuumChromeRestingFill)) + .background(Capsule().fill(Color.prairieChromeRestingFill)) } } @@ -353,7 +353,7 @@ private struct RemoteNowPlayingContent: View { self.scrubPreview = nil } ) - .tint(Color.continuumOnSurface) + .tint(Color.prairieOnSurface) .disabled(state.duration <= 0) .accessibilityLabel("Playback position") @@ -363,7 +363,7 @@ private struct RemoteNowPlayingContent: View { Text(remainingLabel) } .font(.caption.monospacedDigit()) - .foregroundStyle(Color.continuumSecondaryText) + .foregroundStyle(Color.prairieSecondaryText) } } @@ -386,13 +386,13 @@ private struct RemoteNowPlayingContent: View { onCommand(.playPause) } label: { ZStack { - Circle().fill(Color.continuumOnSurface).frame(width: 64, height: 64) + Circle().fill(Color.prairieOnSurface).frame(width: 64, height: 64) if state.isLoading || state.isBuffering { - ProgressView().tint(Color.continuumBackground) + ProgressView().tint(Color.prairieBackground) } else { Image(systemName: state.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 28, weight: .medium)) - .foregroundStyle(Color.continuumBackground) + .foregroundStyle(Color.prairieBackground) } } } @@ -406,7 +406,7 @@ private struct RemoteNowPlayingContent: View { } .accessibilityLabel("Forward 30 seconds") } - .foregroundStyle(Color.continuumOnSurface) + .foregroundStyle(Color.prairieOnSurface) .buttonStyle(.plain) } @@ -503,12 +503,12 @@ private struct RemoteNowPlayingContent: View { private func errorBanner(_ message: String) -> some View { Text(message) .font(.footnote) - .foregroundStyle(Color.continuumOnSurface) + .foregroundStyle(Color.prairieOnSurface) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding(.vertical, 10) .padding(.horizontal, 14) - .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.continuumError.opacity(0.9))) + .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.prairieError.opacity(0.9))) } } @@ -523,7 +523,7 @@ private struct RemoteChipLabel: View { Text(caption) .font(.caption2) } - .foregroundStyle(Color.continuumOnSurface.opacity(0.9)) + .foregroundStyle(Color.prairieOnSurface.opacity(0.9)) .frame(maxWidth: .infinity) .contentShape(Rectangle()) } @@ -622,7 +622,7 @@ struct PrairieCastTargetPickerView: View { searchingState } } - .background(Color.continuumBackground.ignoresSafeArea()) + .background(Color.prairieBackground.ignoresSafeArea()) .navigationTitle("Cast") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -645,7 +645,7 @@ struct PrairieCastTargetPickerView: View { ProgressView() Text("Searching for Prairie TVs…") .font(.headline) - .foregroundStyle(Color.continuumSecondaryText) + .foregroundStyle(Color.prairieSecondaryText) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -673,16 +673,16 @@ struct PrairieCastTargetPickerView: View { HStack(spacing: 12) { Image(systemName: "tv") .font(.title3) - .foregroundStyle(Color.continuumOnSurface) + .foregroundStyle(Color.prairieOnSurface) .frame(width: 38, height: 38) - .background(Circle().fill(Color.continuumChromeRestingFill)) + .background(Circle().fill(Color.prairieChromeRestingFill)) VStack(alignment: .leading, spacing: 3) { Text(target.name).font(.headline) if let serverName = target.serverName { Text(serverName) .font(.subheadline) - .foregroundStyle(Color.continuumSecondaryText) + .foregroundStyle(Color.prairieSecondaryText) } } @@ -695,7 +695,7 @@ struct PrairieCastTargetPickerView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .listRowBackground(Color.continuumSurface) + .listRowBackground(Color.prairieSurface) } .scrollContentBackground(.hidden) } @@ -781,13 +781,13 @@ struct PrairieCastControlModeButton: View { private func buttonLabel(isActive: Bool) -> some View { Image(systemName: "airplayvideo") .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(isActive ? Color.continuumBackground : Color.continuumOnSurface) + .foregroundStyle(isActive ? Color.prairieBackground : Color.prairieOnSurface) .frame(width: 40, height: 40) .background { - Circle().fill(isActive ? Color.continuumOnSurface : Color.continuumChromeRestingFill) + Circle().fill(isActive ? Color.prairieOnSurface : Color.prairieChromeRestingFill) } .overlay { - Circle().stroke(isActive ? Color.clear : Color.continuumOutline, lineWidth: 1) + Circle().stroke(isActive ? Color.clear : Color.prairieOutline, lineWidth: 1) } .contentShape(Circle()) } @@ -799,7 +799,7 @@ struct PrairieCastControlModeButton: View { PrairieCastControlModeButton(controller: PrairieCastController(), onChooseTarget: {}) } .padding() - .background(Color.continuumBackground) + .background(Color.prairieBackground) } #endif #endif @@ -881,6 +881,6 @@ Expected: clean working tree for the cast files; `Prairie.xcodeproj` does not ap **2. Placeholder scan:** No TBD/TODO/"handle errors"/"similar to". Every code step is complete and compilable. -**3. Type consistency:** `PrairieCastArtworkResolver.resolve(contentId:)` / `.posterURL` / `.backdropURL`; `PrairieCastArtworkBackground(urlString:)`; `RemoteNowPlayingContent(state:targetName:posterURL:onCommand:)`; `RemoteChipLabel(systemImage:caption:)`; `PrairieCastPlaybackState.previewPlaying()` — all used consistently across tasks. Command factories (`.seek`, `.playPause`, `.stop`, `.selectAudioTrack`, `.selectSubtitleTrack`, `.setPlaybackSpeed`, `.setQuality`, `.setVideoGravity`, `.setHDREnabled`) match `PrairieCastControlCommand`. State fields match `PrairieCastPlaybackState`. `VideoGravity.allCases`/`.label`/`.rawValue`, `ResponseCache.shared.get`/`CacheKey.itemDetail`, `ContinuumAPI.shared.itemDetail(contentId:)`/`ItemDetail.posterUrl`/`.backdropUrl`, `AsyncImageView(url:contentMode:placeholderStyle:)`, `PlayerTimeFormatter.formatHMS` — all verified against the codebase. +**3. Type consistency:** `PrairieCastArtworkResolver.resolve(contentId:)` / `.posterURL` / `.backdropURL`; `PrairieCastArtworkBackground(urlString:)`; `RemoteNowPlayingContent(state:targetName:posterURL:onCommand:)`; `RemoteChipLabel(systemImage:caption:)`; `PrairieCastPlaybackState.previewPlaying()` — all used consistently across tasks. Command factories (`.seek`, `.playPause`, `.stop`, `.selectAudioTrack`, `.selectSubtitleTrack`, `.setPlaybackSpeed`, `.setQuality`, `.setVideoGravity`, `.setHDREnabled`) match `PrairieCastControlCommand`. State fields match `PrairieCastPlaybackState`. `VideoGravity.allCases`/`.label`/`.rawValue`, `ResponseCache.shared.get`/`CacheKey.itemDetail`, `PrairieAPI.shared.itemDetail(contentId:)`/`ItemDetail.posterUrl`/`.backdropUrl`, `AsyncImageView(url:contentMode:placeholderStyle:)`, `PlayerTimeFormatter.formatHMS` — all verified against the codebase. No gaps found. diff --git a/docs/superpowers/plans/2026-06-16-ios-tvos-cast-remote-improvements.md b/docs/superpowers/plans/2026-06-16-ios-tvos-cast-remote-improvements.md index 050368fae..f1cc37999 100644 --- a/docs/superpowers/plans/2026-06-16-ios-tvos-cast-remote-improvements.md +++ b/docs/superpowers/plans/2026-06-16-ios-tvos-cast-remote-improvements.md @@ -880,14 +880,14 @@ Replace `content` in `PrairieCastRemoteControlView` with: VStack(spacing: 18) { Image(systemName: "airplayvideo") .font(.system(size: 44, weight: .medium)) - .foregroundStyle(Color.continuumOnSurface) + .foregroundStyle(Color.prairieOnSurface) Text("Connected to \(controller.activeTarget?.name ?? "Prairie TV")") .font(.title3.weight(.semibold)) - .foregroundStyle(Color.continuumOnSurface) + .foregroundStyle(Color.prairieOnSurface) Text("Pick something from your library to start playing.") .font(.subheadline) .multilineTextAlignment(.center) - .foregroundStyle(Color.continuumSecondaryText) + .foregroundStyle(Color.prairieSecondaryText) } .padding(32) } @@ -895,7 +895,7 @@ Replace `content` in `PrairieCastRemoteControlView` with: private func statusView(title: String, showSpinner: Bool) -> some View { VStack(spacing: 14) { if showSpinner { ProgressView() } - Text(title).font(.headline).foregroundStyle(Color.continuumSecondaryText) + Text(title).font(.headline).foregroundStyle(Color.prairieSecondaryText) } .padding(32) } @@ -938,7 +938,7 @@ Wrap the scrubber + time labels in a `TimelineView(.periodic(from: .now, by: 0.2 self.scrubPreview = nil } ) - .tint(Color.continuumOnSurface) + .tint(Color.prairieOnSurface) .disabled(state.duration <= 0) .accessibilityLabel("Playback position") .accessibilityValue(PlayerTimeFormatter.formatHMS(live)) @@ -949,7 +949,7 @@ Wrap the scrubber + time labels in a `TimelineView(.periodic(from: .now, by: 0.2 Text(remainingLabel(live: live)) } .font(.caption.monospacedDigit()) - .foregroundStyle(Color.continuumSecondaryText) + .foregroundStyle(Color.prairieSecondaryText) } } } @@ -994,11 +994,11 @@ Add: ), in: 0...1 ) - .tint(Color.continuumOnSurface) + .tint(Color.prairieOnSurface) .accessibilityLabel("Volume") .accessibilityValue("\(Int((state.isMuted ? 0 : state.volume) * 100)) percent") } - .foregroundStyle(Color.continuumOnSurface) + .foregroundStyle(Color.prairieOnSurface) .buttonStyle(.plain) } ``` @@ -1064,7 +1064,7 @@ struct PrairieCastMiniBar: View { .lineLimit(1) Text("Playing on \(controller.activeTarget?.name ?? "Prairie TV")") .font(.caption) - .foregroundStyle(Color.continuumSecondaryText) + .foregroundStyle(Color.prairieSecondaryText) .lineLimit(1) } Spacer(minLength: 8) @@ -1082,8 +1082,8 @@ struct PrairieCastMiniBar: View { .padding(.vertical, 8) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.continuumOutline, lineWidth: 1)) - .foregroundStyle(Color.continuumOnSurface) + .stroke(Color.prairieOutline, lineWidth: 1)) + .foregroundStyle(Color.prairieOnSurface) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -1103,9 +1103,9 @@ struct PrairieCastMiniBar: View { .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } else { RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.continuumSurfaceElevated) + .fill(Color.prairieSurfaceElevated) .frame(width: 34, height: 50) - .overlay { Image(systemName: "tv").foregroundStyle(Color.continuumSecondaryText) } + .overlay { Image(systemName: "tv").foregroundStyle(Color.prairieSecondaryText) } } } } diff --git a/docs/superpowers/plans/2026-06-16-now-playing-shelf.md b/docs/superpowers/plans/2026-06-16-now-playing-shelf.md index ff897af9a..606c53b8e 100644 --- a/docs/superpowers/plans/2026-06-16-now-playing-shelf.md +++ b/docs/superpowers/plans/2026-06-16-now-playing-shelf.md @@ -61,7 +61,7 @@ struct NowPlayingBarChrome: ViewModifier { .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(Color.continuumOutline, lineWidth: 1) + .strokeBorder(Color.prairieOutline, lineWidth: 1) ) case .accessory: content @@ -87,8 +87,8 @@ Then replace the chrome chain. Change: .padding(.vertical, 8) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.continuumOutline, lineWidth: 1)) - .foregroundStyle(Color.continuumOnSurface) + .stroke(Color.prairieOutline, lineWidth: 1)) + .foregroundStyle(Color.prairieOnSurface) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -101,7 +101,7 @@ to: .padding(.horizontal, 14) .padding(.vertical, 8) .modifier(NowPlayingBarChrome(style: style)) - .foregroundStyle(Color.continuumOnSurface) + .foregroundStyle(Color.prairieOnSurface) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -127,7 +127,7 @@ Then replace the chrome chain. Change: .clipShape(RoundedRectangle(cornerRadius: 14)) .overlay { RoundedRectangle(cornerRadius: 14) - .strokeBorder(Color.continuumOutline, lineWidth: 1) + .strokeBorder(Color.prairieOutline, lineWidth: 1) } .overlay(alignment: .bottomLeading) { ``` @@ -289,7 +289,7 @@ git commit -m "cast(iOS): NowPlayingShelf + tab-bar accessory attachment" In `MainTabView.body`, delete the `.safeAreaInset` block that currently carries the two bars. Change: ```swift - .tint(.continuumOnSurface) + .tint(.prairieOnSurface) #if !os(macOS) .safeAreaInset(edge: .bottom, spacing: 0) { #if os(iOS) @@ -305,7 +305,7 @@ In `MainTabView.body`, delete the `.safeAreaInset` block that currently carries to: ```swift - .tint(.continuumOnSurface) + .tint(.prairieOnSurface) #if !os(macOS) .fullScreenCover(isPresented: Binding( ``` diff --git a/docs/superpowers/specs/2026-06-14-companion-pairing-design.md b/docs/superpowers/specs/2026-06-14-companion-pairing-design.md index 08933f2ed..c2e97f9e3 100644 --- a/docs/superpowers/specs/2026-06-14-companion-pairing-design.md +++ b/docs/superpowers/specs/2026-06-14-companion-pairing-design.md @@ -56,7 +56,7 @@ is still dramatically faster than thumb-typing a URL and password on a remote. | Role | Platform | Behavior | Reuses | |------|----------|----------|--------| | **Receiver** | tvOS | Advertises on the LAN; waiting to be set up | `TVServerSetupView`, `ServerSetupViewModel` (URL normalize), `QRLoginViewModel`, `AppRouter` (auth state), `AppleDeviceIdentity`, `ServerRegistry`, `TokenStore` | -| **Companion** | iOS | Browses; in-hand; already signed in | `ServerRegistry`, `TokenStore`, `ContinuumAPI`, `HTTPClient` | +| **Companion** | iOS | Browses; in-hand; already signed in | `ServerRegistry`, `TokenStore`, `PrairieAPI`, `HTTPClient` | | **Server** | Go | Mints tokens; unchanged in v1 | `/auth/device/{start,poll,approve}`, `GET /auth/device`, `GET /auth/sessions` | ### Server endpoints (already implemented — verified in prairie-server) @@ -67,7 +67,7 @@ is still dramatically faster than thumb-typing a URL and password on a remote. - State table `device_login_requests`: `pending → approved → consumed`, `pending → denied`, 10-minute TTL. ### New client method -`ContinuumAPI.approveDevice(code:)` and `ContinuumAPI.lookupDevice(code:)` — the +`PrairieAPI.approveDevice(code:)` and `PrairieAPI.lookupDevice(code:)` — the endpoints exist but approval was previously web-only (`web/src/pages/ActivateDevice.tsx`), so the iOS client needs these thin wrappers over `HTTPClient`. @@ -87,7 +87,7 @@ Each unit has one purpose, a defined interface, and is testable in isolation. discovered TVs (name, deviceId, state, endpoint). Owns Local Network permission state and foreground/background lifecycle. - **`CompanionPairingCoordinator`** — phone-side state machine. Connects to the - chosen TV, runs the per-server loop, calls `ContinuumAPI` / `ServerRegistry` / + chosen TV, runs the per-server loop, calls `PrairieAPI` / `ServerRegistry` / `TokenStore`. Publishes observable state for the UI. - **UI:** - `SetUpTVBanner` — auto-appears when a TV in `setup` state is discovered while @@ -269,7 +269,7 @@ exposure below for v1. - `PairingSession` framing over an in-memory loopback pair (partial reads, multiple messages in one buffer, oversized frame rejection). - `CompanionPairingCoordinator` and `ReceiverPairingCoordinator` state machines - against a **fake transport** + **fake `ContinuumAPI`**: happy path, multi-server, + against a **fake transport** + **fake `PrairieAPI`**: happy path, multi-server, partial failure, match-code mismatch/decline, token expiry, cancel, code expiry. Assert **persist-on-success**: no `ServerRegistry` / `TokenStore` mutation until `signedIn`, and the pending candidate is rolled back on every diff --git a/docs/superpowers/specs/2026-06-15-ios-aurora-onboarding-design.md b/docs/superpowers/specs/2026-06-15-ios-aurora-onboarding-design.md index 440c1222d..a3087bcb6 100644 --- a/docs/superpowers/specs/2026-06-15-ios-aurora-onboarding-design.md +++ b/docs/superpowers/specs/2026-06-15-ios-aurora-onboarding-design.md @@ -111,7 +111,7 @@ iOS view). ## 6. Screens -Each screen = backdrop variant + centered `SILO.` wordmark + gold eyebrow + a +Each screen = backdrop variant + centered `PRAIRIE.` wordmark + gold eyebrow + a heading block + a liquid-glass form/content card. ### 6.1 Server setup — `ServerSetupView` @@ -179,7 +179,7 @@ that reveals the protocol segments + port (focus-managed for the remote). ## 9. What stays untouched / out of scope -- `AuthService`, `ServerRegistry`, `TokenStore`, `ContinuumAPI`, `HTTPClient`. +- `AuthService`, `ServerRegistry`, `TokenStore`, `PrairieAPI`, `HTTPClient`. - `AppRouter` auth-state machine and view models (`ServerSetupViewModel`, `LoginViewModel`, `SignupViewModel`, `ProfileSelectionViewModel`, `QRLoginViewModel`). diff --git a/docs/superpowers/specs/2026-06-15-resume-first-home-design.md b/docs/superpowers/specs/2026-06-15-resume-first-home-design.md index 571b5007c..d04644299 100644 --- a/docs/superpowers/specs/2026-06-15-resume-first-home-design.md +++ b/docs/superpowers/specs/2026-06-15-resume-first-home-design.md @@ -73,7 +73,7 @@ direction A (flat background) is the honest fit. `noFeaturedTopSpacing(...)`) is rendered only in the no-featured branch to keep row 1 from sliding under the floating header. With the hero permanently gone, this spacer becomes the **permanent** first element of the stack. -- **Background:** **flat OLED** (`Color.continuumBackground` / `.continuumBackground()`). +- **Background:** **flat OLED** (`Color.prairieBackground` / `.prairieBackground()`). Remove the page-level ambient hero machinery that exists only to back the carousel: `heroTintBackground`, `heroBackdropImage`, `heroBackdropFadeMask`, `heroTintColor`, `heroBackdropURL`, `heroBackdropThumbhash`, `computedHeroHeight`, diff --git a/docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md b/docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md index cd06b96b8..27e2372c3 100644 --- a/docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md +++ b/docs/superpowers/specs/2026-06-15-tvos-detail-redesign-design.md @@ -37,7 +37,7 @@ Conceptual north star: **the detail page is the Skyline focus marquee bloomed to full screen** — the same backdrop, eyebrow "tick," title, and metadata the marquee was already previewing expand into an interactive page. -Reused from `docs/skyline-design-guide.md` and `ContinuumTheme.swift`: +Reused from `docs/skyline-design-guide.md` and `PrairieTheme.swift`: - Background OLED black `#000000`; surfaces `#0A0A0A` / elevated `#15171C`. - Monochrome chrome — primary text `#EDEDED`, secondary @ 60%, tertiary @ 38%, @@ -48,7 +48,7 @@ Reused from `docs/skyline-design-guide.md` and `ContinuumTheme.swift`: labels. - Menus, the selector menus, and the overflow menu use Skyline `glass.strong` (`#16171B` @ 86% + blur 40). -- Radii: the squared-control family is **8 pt** (`ContinuumTheme.smallCornerRadius`). +- Radii: the squared-control family is **8 pt** (`PrairieTheme.smallCornerRadius`). Cards keep 12 pt; section card backgrounds 18 pt. ## 3. Scope — locked decisions @@ -218,7 +218,7 @@ in the same family (audiobooks have square cover art, not a 16:9 still): ## 10. Motion -Reuse `ContinuumTheme` durations: focus 0.12 s, transitions 0.20 s, crossfades +Reuse `PrairieTheme` durations: focus 0.12 s, transitions 0.20 s, crossfades 0.30 s. Specifics: squared-control focus ring/scale 0.12 s; synopsis expand/ collapse 0.20 s height + fade; selector menu open = `glass.strong` scale 0.96→1.0 + fade. **Reduce Motion:** no scale/expand animation — snap; ring-only diff --git a/docs/superpowers/specs/2026-06-16-cast-ui-redesign-design.md b/docs/superpowers/specs/2026-06-16-cast-ui-redesign-design.md index ba839fed1..c9d6c9023 100644 --- a/docs/superpowers/specs/2026-06-16-cast-ui-redesign-design.md +++ b/docs/superpowers/specs/2026-06-16-cast-ui-redesign-design.md @@ -48,16 +48,16 @@ From `iosApp/iosApp/Cast/iOS/PrairieCastViews.swift`: Reuse the existing iOS tokens — do not introduce new colors or a new type scale. From `iosApp/iosApp/Theme/Colors.swift`: -- Background OLED black `continuumBackground` `#000000`; elevated surface - `continuumSurfaceElevated` `#15171C`; glass `continuumGlassStrong` +- Background OLED black `prairieBackground` `#000000`; elevated surface + `prairieSurfaceElevated` `#15171C`; glass `prairieGlassStrong` (`#16171B` @ 86%). -- Monochrome chrome — primary `continuumOnSurface` `#EDEDED`, secondary - `continuumSecondaryText` (`#EDEDED` @ 60%), hairline `continuumOutline` - (white @ 12%). Active/selected chrome uses `continuumChromeSelectedFill` - (white @ 14%) / resting `continuumChromeRestingFill` (white @ 7%). +- Monochrome chrome — primary `prairieOnSurface` `#EDEDED`, secondary + `prairieSecondaryText` (`#EDEDED` @ 60%), hairline `prairieOutline` + (white @ 12%). Active/selected chrome uses `prairieChromeSelectedFill` + (white @ 14%) / resting `prairieChromeRestingFill` (white @ 7%). - **No chromatic accent.** The play button is a white (`#EDEDED`) filled circle with a black glyph; artwork is the only place full color lives. Errors use - `continuumError`. + `prairieError`. Typography uses the system text styles already in the file (`.title2`, `.headline`, `.subheadline`, `.caption.monospacedDigit()`), not custom fonts. @@ -85,13 +85,13 @@ background: 1. **Top bar** — leading `chevron.down` (minimize: `hideRemoteControl()` + `dismiss()`, session stays alive); trailing `•••` overflow menu (see §8). The TV name moves out of the nav title into the now-playing block. -2. **Artwork card** — rounded (`continuumSurfaceElevated` placeholder), poster +2. **Artwork card** — rounded (`prairieSurfaceElevated` placeholder), poster aspect, rendered with `AsyncImageView(url:thumbhash:contentMode:)`. Modest max height so the controls always fit without scrolling on small devices. 3. **Title / subtitle** — `state.title` (`.title2`, semibold) and `state.subtitle` (`.subheadline`, secondary), centered, line-limited (2). 4. **"Playing on {TV}" pill** — `airplayvideo`/`tv` glyph + `activeTarget.name` - in a subtle `continuumChromeRestingFill` capsule. + in a subtle `prairieChromeRestingFill` capsule. 5. **Scrubber** (§6). 6. **Transport row** (§6). 7. **Secondary control row** (§7). @@ -99,7 +99,7 @@ background: ### Background A full-bleed `AsyncImageView` of the backdrop (poster fallback), `.blur(radius:)` -+ a `continuumBackground` scrim at high opacity for legibility, behind the ++ a `prairieBackground` scrim at high opacity for legibility, behind the foreground stack. This is the single biggest contributor to the native feel. ## 6. Artwork resolution, scrubber & transport @@ -111,7 +111,7 @@ A small resolver keyed on `state.contentId`: - Resolves through the **same path the detail screen uses**: check `ResponseCache.shared` under `CacheKey.itemDetail(contentId)` first (warm and instant when the user cast from a detail page), otherwise - `ContinuumAPI.shared.itemDetail(contentId:)`, reading `posterUrl` / + `PrairieAPI.shared.itemDetail(contentId:)`, reading `posterUrl` / `backdropUrl` off the returned `ItemDetail`. - Holds `posterURL` / `backdropURL` in `@State`; nil `contentId` (idle) → flat black, no artwork. Failures degrade silently to the flat background. @@ -165,7 +165,7 @@ as the entry point from Home. - **Loading / buffering** (`isLoading` / `isBuffering`): spinner replaces the play/pause glyph; scrubber disabled if `duration <= 0`. - **Error** (`state.error ?? controller.errorMessage`): compact inline banner on - `continuumSurfaceElevated` with `continuumError` text, not raw red caption. + `prairieSurfaceElevated` with `prairieError` text, not raw red caption. ## 10. Target picker (`PrairieCastTargetPickerView`) @@ -181,8 +181,8 @@ as the entry point from Home. ## 11. Control-mode button (`PrairieCastControlModeButton`, Home top bar) Keep the `airplayvideo`-in-a-circle. Replace the ad-hoc `Color.white`/`.clear` -fills with the chrome tokens (`continuumChromeSelectedFill` / -`continuumChromeRestingFill`, `continuumOutline` border). Active/connected state +fills with the chrome tokens (`prairieChromeSelectedFill` / +`prairieChromeRestingFill`, `prairieOutline` border). Active/connected state reads clearly (filled + subtle connected indicator). Behavior (menu vs. picker) is unchanged. diff --git a/docs/superpowers/specs/2026-08-11-profile-launch-and-tvos-user-mapping-design.md b/docs/superpowers/specs/2026-08-11-profile-launch-and-tvos-user-mapping-design.md index 570a4aa6f..6781903d3 100644 --- a/docs/superpowers/specs/2026-08-11-profile-launch-and-tvos-user-mapping-design.md +++ b/docs/superpowers/specs/2026-08-11-profile-launch-and-tvos-user-mapping-design.md @@ -3,7 +3,7 @@ title: Prairie Profile Launch Choice and Apple TV User Mapping description: Implementation-ready design for automatic or required profile selection and per-user tvOS profile memory. date: 2026-08-11 tags: - - silo + - prairie - apple - profiles - design-spec @@ -13,11 +13,11 @@ tags: - **Date:** 2026-08-11 - **Status:** Implemented; simulator-validated -- **Repo:** `silo-apple` (iOS, iPadOS, macOS, tvOS, and the tvOS Top Shelf extension) +- **Repo:** `prairie-apple` (iOS, iPadOS, macOS, tvOS, and the tvOS Top Shelf extension) - **Server:** No change required - **Android:** Parity follow-up recommended; not part of this implementation -- **Exploration baseline:** `silo-apple` `fdb5225`; `silo-server` `origin/main` - `2dc7d5e36`; `silo-android` `origin/main` `3efdbd90` +- **Exploration baseline:** `prairie-apple` `fdb5225`; `prairie-server` `origin/main` + `2dc7d5e36`; `prairie-android` `origin/main` `3efdbd90` ## Implementation validation @@ -374,7 +374,7 @@ picker. Do not automatically select a sibling profile. ### 8.1 Use the modern entitlement model -Add this entitlement to both the `SiloTV` app and `SiloTVTopShelf` extension targets: +Add this entitlement to both the `PrairieTV` app and `PrairieTVTopShelf` extension targets: ```xml com.apple.developer.user-management @@ -491,7 +491,7 @@ sign-out regression. ### 9.3 Personal-team signing -Add the User Management key to `SiloTV.personal.entitlements` and +Add the User Management key to `PrairieTV.personal.entitlements` and `TopShelf.personal.entitlements` only if personal provisioning supports it. If it does not, keep a documented local-signing fallback that compiles/runs without system-user separation; production entitlements remain authoritative. Never replace the paid-team production entitlements to make a @@ -683,6 +683,6 @@ Use a signed-in physical device; the Simulator does not prove Apple TV user stor - `iosApp/iosApp/tvOS/Navigation/TVMainTabView.swift` — centralized explicit switch action. - `iosApp/TopShelf/ContentProvider.swift` / `TopShelfHTTPClient.swift` — policy gating and split credential reads. -- `iosApp/SiloTV.entitlements`, `iosApp/TopShelf/TopShelf.entitlements`, and supported personal +- `iosApp/PrairieTV.entitlements`, `iosApp/TopShelf/TopShelf.entitlements`, and supported personal variants — User Management capability. - `iosApp/Tests/` — resolver, migration, identity, Top Shelf, and account-boundary coverage. diff --git a/docs/tvos-focus.md b/docs/tvos-focus.md index 2f6f5b1a7..52045bc54 100644 --- a/docs/tvos-focus.md +++ b/docs/tvos-focus.md @@ -16,14 +16,35 @@ Use this for ordinary rows, grids, button groups, sheets, and menus where each actionable item can be a real focus target. - Render stable `Button`, `NavigationLink`, or `.focusable(true)` items. -- Group related movement with `.focusSection()` and `.focusScope(...)`. + Every actionable element must be reachable by directional movement alone; + tvOS has no Tab-key or pointer fallback. +- Use `.focusSection()` on a container so directional movement can enter it + and land on its nearest focusable child, for example a sidebar column that + does not line up with the grid beside it. +- Use `.focusScope(namespace)` together with `prefersDefaultFocus(in:)` and + `resetFocus(in:)` to define where default focus lands inside that scope. + `focusScope` does not affect directional movement; `focusSection` does. - Use `@FocusState`, `prefersDefaultFocus`, `defaultFocus`, or `resetFocus` to seed or restore focus, not to fight the focus engine on every move. + `defaultFocus` is evaluated when the view first appears and on automatic + focus-state updates, not on user-driven moves, unless you pass + `priority: .userInitiated`. +- Do not move focus programmatically in response to app state unless the + focused item disappeared. Apple's Human Interface Guidelines say to avoid + changing focus without the user's interaction; the one exception is moving + focus to a neighbour when the focused item is removed. +- Rely on the system focus effect. Use `.focusEffectDisabled()` only when the + control draws its own focus appearance, and keep that appearance visually + consistent with the platform (scale, lift, highlight). - Keep the focused subtree mounted and structurally stable while moving focus. - Attach `onMoveCommand` only at intentional boundaries, such as "Up from the first card returns to the top menu." Do not intercept normal in-zone movement. - Move focus geometry with layout (`padding`, `frame`, alignment), not `.offset`, because tvOS resolves focus from layout frames. +- Focus can only reach views that are loaded. A `LazyHStack` unloads cards + outside its scroll view's bounds, so a row that pins the focused card to its + leading edge must extend the scroll view one card past the visible edge + (and mask the overflow) or the previous card is unreachable. Good local examples: @@ -31,6 +52,7 @@ Good local examples: - `TVLibraryCollectionsView` - `TVForYouDropdown` - `TVProfileDropdown` +- `TVEpisodeRail` (Series carousel: native cards pinned to the leading edge) ### Composite Focus Control @@ -38,13 +60,23 @@ Use this when the visual control is one logical selector even though it renders multiple highlighted rows or columns. A cascading selector is the main example. - Make one container the real focus target with `.focusable(true)` and a single - `@FocusState`. + `@FocusState`. On tvOS the default `interactions` set already includes + `.activate`, so `.focusable(true)` and + `.focusable(true, interactions: .activate)` behave the same; use the + explicit form only if the view is shared with macOS or iOS. - Render rows as passive labels; do not make them `Button`s and do not attach per-row `.focused(...)` bindings. - Store the highlighted row/column in ordinary `@State`. - Handle all D-pad movement for the composite with one `onMoveCommand`. + The focus engine resolves the move first. If a focusable lies beyond the + composite on an axis it owns, even diagonally or off screen, focus can + leave before the handler runs, especially on touch-surface swipes, and a + swipe can end with a stray orthogonal command. Prefer a native focus graph + for anything the focus engine can drive, such as a row of cards. - Commit the highlighted selection on Select, usually with `onTapGesture` on - the focused container. + the focused container. Use `onExitCommand` for Menu/Back and + `onPlayPauseCommand` for Play/Pause. Do not use `onKeyPress` for the Siri + Remote; Apple documents it as hardware-keyboard input only. - Add useful accessibility labels and button/selected traits to the composite or its rendered labels so VoiceOver still describes the action. @@ -75,6 +107,42 @@ bar.focusedItem -> Calendar When this happens, stop adding press interceptors. Decide which focus model the control should use, then remove the other one. +## Every Page Needs a Focus Owner + +A page that renders no focusable view leaves the focus engine with nothing +focused. The remote stops responding, and Menu/Back never reaches the shell's +`onExitCommand` either, because exit commands travel up the *focused* responder +chain. The page reads as hard-frozen and force quit is the only way out. The +top bar cannot save it: the shell disables the bar while it hands focus to +content, so a disabled bar and a placeholder page leave zero focus targets in +the window. + +Empty states, error states, and loading skeletons are the usual offenders — +they are inert by nature, so it is easy to ship one that is the whole content +of a page. Apply `tvPageFocusOwner` to any placeholder that can be a page's +entire content. It makes the placeholder a real focus target, routes Up back to +the top menu, and seeds itself from the shell's content hand-down token. + +`TVFocusWatchdogModifier` is the backstop, not the fix. Its first repair re-arms +page content, which cannot land on a page with no focus target, so a repeated +outage at the root escalates to the top menu (`tvFocusRepairAction`). + +## Settings Pane Navigation + +Settings uses two native focus sections. Right from a category enters its +first available detail control. Left from any detail row returns to that +same category, including after scrolling or dismissing a picker. Up/Down +continues through the controls in the current pane. + +Give both pane entry targets `defaultFocus` with `.userInitiated` priority. +Do not downgrade the rail's selected-category target while the detail pane +owns focus: doing so lets Left choose a different category by geometry. +The outer focus scope still chooses the active pane for entry and modal +restoration. Remember the rail's current control separately from the selected +category so cancelling Sign Out restores Sign Out, while Left from the details +still restores the category. Back returns to the category, then exits Settings +to Home. + ## Top Menu Ownership The top menu has three conceptual states: @@ -98,6 +166,28 @@ When closing a panel, choose the next owner explicitly: - Selecting a panel row closes, updates route/scope state, and then hands focus to the destination content. +## Selecting a Page from the Top Menu + +Selecting Home, another tab, or a panel destination enters the page at its +first row or top control, including when reselecting the current page after +Menu/Back. Reset the vertical scroll position before forwarding the entry +focus request. A row feed also resets its first row to the first card. + +Use `TVMenuEntryScroll` on the page's scroll view to reveal lazy entry controls +without animation. Forward focus after scroll geometry reports the top and +the entry row has had a layout pass. Animating a long scroll while claiming +focus lets intermediate rows take focus and cancel the entry row's +restoration ownership. Keep page identity and loaded data stable; +a menu selection only resets scrolling and entry focus. + +If Menu/Back returns ownership to the bar or a panel before the entry request +finishes, cancel that request. Do not replay it when menu focus clears; the +next deliberate page selection provides a new request. Cancel on disappearance +as well, so queued layout callbacks cannot focus a page that has been left. + +Returning from a card's detail page still restores the launching card. Ordinary +Up/Down navigation remains owned by the native focus engine. + ## Debugging Checklist When tvOS focus feels random, capture logs for the ownership boundary first: @@ -130,7 +220,24 @@ Unexpected signs: ## References -- Apple tvOS focus engine and remote guidance: - https://developer.apple.com/library/archive/documentation/General/Conceptual/AppleTV_PG/WorkingwiththeAppleTVRemote.html +- Human Interface Guidelines, Focus and selection (system focus effects, do + not move focus without user interaction, every tvOS element must be + reachable): + https://developer.apple.com/design/human-interface-guidelines/focus-and-selection +- UIKit, About focus interactions for Apple TV (focus engine rules; only the + engine moves focus directionally): + https://developer.apple.com/documentation/uikit/about-focus-interactions-for-apple-tv +- SwiftUI Focus overview (focusable, FocusState, focusScope, focusSection, + default focus, resetFocus, focus effects): + https://developer.apple.com/documentation/swiftui/focus - SwiftUI `focusSection()`: - https://developer.apple.com/documentation/swiftui/view/focussection%28%29 + https://developer.apple.com/documentation/swiftui/view/focussection() +- SwiftUI `focusable(_:interactions:)`: + https://developer.apple.com/documentation/swiftui/view/focusable(_:interactions:) +- SwiftUI `defaultFocus(_:_:priority:)`: + https://developer.apple.com/documentation/swiftui/view/defaultfocus(_:_:priority:) +- SwiftUI `onMoveCommand(perform:)`, `onExitCommand(perform:)`, + `onPlayPauseCommand(perform:)`: + https://developer.apple.com/documentation/swiftui/view/onmovecommand(perform:) +- Focus Cookbook sample (WWDC23, "The SwiftUI cookbook for focus"): + https://developer.apple.com/documentation/swiftui/focus-cookbook-sample diff --git a/docs/tvos-onboarding/base.css b/docs/tvos-onboarding/base.css index 421c62a6f..8ddbf96fb 100644 --- a/docs/tvos-onboarding/base.css +++ b/docs/tvos-onboarding/base.css @@ -1,5 +1,5 @@ /* ============================================================================ - Silo tvOS — First-Run Redesign Mockups + Prairie tvOS — First-Run Redesign Mockups base.css — shared design tokens (Skyline), component kit, 1920×1080 scaler Tokens sourced from docs/skyline-design-guide.md + iosApp/.../Theme/*.swift (1 tvOS pt = 1 px on the 1920×1080 canvas) @@ -38,7 +38,7 @@ --field-fill: #15171c; --field-border: rgba(255, 255, 255, 0.22); - /* Radii (ContinuumTheme.swift) */ + /* Radii (PrairieTheme.swift) */ --r-card: 12px; --r-small: 8px; --r-container: 18px; diff --git a/docs/tvos-onboarding/base.js b/docs/tvos-onboarding/base.js index 71dd1889c..7d9703b18 100644 --- a/docs/tvos-onboarding/base.js +++ b/docs/tvos-onboarding/base.js @@ -33,7 +33,7 @@ } function renderQR(el) { var n = 25; // modules - var rnd = seeded(el.getAttribute("data-qr") || "silo"); + var rnd = seeded(el.getAttribute("data-qr") || "prairie"); var cells = ""; function reserved(r, c) { return (r < 8 && c < 8) || (r < 8 && c > n - 9) || (r > n - 9 && c < 8); diff --git a/docs/tvos-onboarding/index.html b/docs/tvos-onboarding/index.html index 206666b54..c521811a3 100644 --- a/docs/tvos-onboarding/index.html +++ b/docs/tvos-onboarding/index.html @@ -99,7 +99,7 @@

Three takes on the same flow

Each renders all five screens end-to-end. All are fully dark (TV research strongly favors it) and reuse your Skyline color/spacing/motion tokens — they differ in mood, surface, and how loudly the phone-handoff idea is expressed.

-
SILO01
+
PRAIRIE01

Monolith / austere

Pure OLED black, monochrome, oversized Swiss type, maximal negative space. The most restrained and content-first — closest to today’s app DNA.

@@ -107,7 +107,7 @@

Three takes on the same flow

-
SILOAurora
+
PRAIRIEAurora

Aurora / cinematic

A film-still backdrop with frosted “liquid glass” panels and an editorial serif. The premium, marketing-forward first impression.

@@ -118,7 +118,7 @@

Three takes on the same flow

- SILO + PRAIRIE @@ -144,7 +144,7 @@

Let the phone set up the TV

- SILO + PRAIRIE @@ -256,9 +256,9 @@

The 10-foot rulebook

⚠ Cross-repo coordination (per workspace conventions)

    -
  • silo-server — owns auth/session + pairing. The companion handoff needs a pairing/token-grant endpoint (extend the existing QuickConnect/QR flow). Bonjour discovery, if pursued, is also server-advertised.
  • -
  • silo-android — TV client should stay aligned; the same handoff + phone-first sign-in pattern ought to land on Android TV ↔ Android phone. Compare before finalizing copy/flow.
  • -
  • silo-apple — this repo: new SwiftUI first-run views + an iOS “Set up Apple TV” surface. Tokens already exist in Theme/*.swift; these mockups map 1:1 (1pt = 1px).
  • +
  • prairie-server — owns auth/session + pairing. The companion handoff needs a pairing/token-grant endpoint (extend the existing QuickConnect/QR flow). Bonjour discovery, if pursued, is also server-advertised.
  • +
  • prairie-android — TV client should stay aligned; the same handoff + phone-first sign-in pattern ought to land on Android TV ↔ Android phone. Compare before finalizing copy/flow.
  • +
  • prairie-apple — this repo: new SwiftUI first-run views + an iOS “Set up Apple TV” surface. Tokens already exist in Theme/*.swift; these mockups map 1:1 (1pt = 1px).

Manual entry (your chosen baseline) ships with zero server work. The handoff is the upgrade that needs coordination — these mockups show both so the manual path is never blocked on it.

diff --git a/docs/tvos-player/07-profile7-dv-truehd-loopback-spec.md b/docs/tvos-player/07-profile7-dv-truehd-loopback-spec.md index acde41d8b..1dc0f3e36 100644 --- a/docs/tvos-player/07-profile7-dv-truehd-loopback-spec.md +++ b/docs/tvos-player/07-profile7-dv-truehd-loopback-spec.md @@ -103,9 +103,32 @@ So the target for TrueHD is not "TrueHD passthrough". It is: - preserve the 5.1/7.1 bed as PCM - let Apple TV output multichannel LPCM -TrueHD Atmos object metadata is out of scope for this target. Atmos may only be -claimed when the source is already Apple-compatible E-AC-3/JOC and that track is -preserved end to end. +TrueHD Atmos object metadata is out of scope for this lossless target. Atmos may +only be claimed when the source is already Apple-compatible E-AC-3/JOC and that +track is preserved end to end, or when the TrueHD Atmos object path (3.3) is +engaged and validated. + +### 3.3 TrueHD Atmos object path + +The Playback setting "TrueHD Atmos" (device-local, default On) selects Aether's +`LoadOptions.objectAudioRendering = .apac(.l714)` when the Apple TV's output +reports Dolby Atmos (`AVAudioSession.renderingMode == .dolbyAtmos`) or does not +report a mode. An output reporting stereo, multichannel PCM or Dolby Digital +keeps the lossless channel path, since there the heights would only be folded +back into channels. For a TrueHD track FFmpeg marks as Atmos, Aether then: + +- decodes the TrueHD object presentation (beds, objects, object positions) +- renders it into a 7.1.4 bed; the receiver or soundbar re-renders the Dolby + Atmos it receives onto its own speakers, so one bed serves every system +- encodes the bed as Apple Positional Audio (APAC), `CODECS="apac.31.LL"` +- lets tvOS decode APAC and send it to the receiver as Dolby MAT + +This keeps heights and object motion, but the audio is lossy (about 320 kbps per +bed channel) instead of lossless 7.1, and the object-to-speaker rendering happens +once in the app into 7.1.4 before the receiver maps it onto its speakers. The +receiver should show Dolby Atmos (MAT or PCM + Atmos), never TrueHD. A TrueHD +track without Atmos, an older OS, or a start-up failure keeps the lossless +channel path above. ## 4. Product contract @@ -136,13 +159,19 @@ The route may claim Dolby Vision only when: The route may claim Atmos only when: -- selected source audio is E-AC-3/JOC -- the output path preserves E-AC-3/JOC by copy or another validated - Apple-native Atmos path -- an AVR/soundbar validation run confirms the Atmos indicator +- selected source audio is E-AC-3/JOC and the output path preserves + E-AC-3/JOC by copy or another validated Apple-native Atmos path, or +- selected source audio is TrueHD Atmos, the object path (3.3) is engaged + (pipeline label `TRUEHD Atmos → APAC 7.1.4`), and +- in either case, an AVR/soundbar validation run confirms the Atmos indicator The route must not claim Atmos for TrueHD-derived PCM output. +The object path (3.3) must not be described as TrueHD passthrough, as lossless, +or as Dolby's own decoding. The accurate description is that Prairie decodes the +Atmos objects, mixes them into a 7.1.4 layout and delivers that as compressed +audio, which tvOS sends to the receiver as Dolby Atmos (MAT). + The route must not describe TrueHD-derived PCM as TrueHD passthrough. The honest label is "lossless multichannel PCM" or "multichannel LPCM". diff --git a/docs/tvos-player/2026-07-07-aetherengine-gap-audit.html b/docs/tvos-player/2026-07-07-aetherengine-gap-audit.html index d68f65893..7adffb93b 100644 --- a/docs/tvos-player/2026-07-07-aetherengine-gap-audit.html +++ b/docs/tvos-player/2026-07-07-aetherengine-gap-audit.html @@ -1,4 +1,4 @@ -AetherEngine → silo-apple: Defect Parity Audit +AetherEngine → prairie-apple: Defect Parity Audit