diff --git a/.github/workflows/barr-ci.yml b/.github/workflows/barr-ci.yml
new file mode 100644
index 0000000..23da8ba
--- /dev/null
+++ b/.github/workflows/barr-ci.yml
@@ -0,0 +1,37 @@
+name: Barr CI
+
+on:
+ pull_request:
+ paths:
+ - "barr/**"
+ - ".github/workflows/barr-ci.yml"
+ push:
+ branches: [main]
+ paths:
+ - "barr/**"
+ - ".github/workflows/barr-ci.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: macos-26
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Install XcodeGen
+ run: brew install xcodegen
+
+ - name: Generate Xcode project
+ working-directory: barr/app
+ run: xcodegen generate
+
+ - name: Test
+ run: |
+ xcodebuild test \
+ -project barr/app/Barr.xcodeproj \
+ -scheme Barr \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ CODE_SIGNING_ALLOWED=NO
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..c7960d3
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,224 @@
+name: Release native app
+
+# Tags are app-scoped so each product can version independently.
+# Examples: barr-v0.0.10, windo-v0.1.7, loadout-v0.1.3.
+on:
+ push:
+ tags:
+ - "barr-v*"
+ - "windo-v*"
+ - "loadout-v*"
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: macos-26
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Configure app from tag
+ shell: bash
+ run: |
+ tag="$GITHUB_REF_NAME"
+ case "$tag" in
+ barr-v*) app=barr; display=Barr; scheme=Barr; project=Barr.xcodeproj; workdir=barr/app ;;
+ windo-v*) app=windo; display=Windo; scheme=Windo; project=Windo.xcodeproj; workdir=windo ;;
+ loadout-v*) app=loadout; display=Loadout; scheme=Loadout; project=Loadout.xcodeproj; workdir=loadout ;;
+ *) echo "::error::Unsupported release tag: $tag"; exit 1 ;;
+ esac
+
+ {
+ echo "APP=$app"
+ echo "DISPLAY=$display"
+ echo "SCHEME=$scheme"
+ echo "PROJECT=$project"
+ echo "WORKDIR=$workdir"
+ echo "VERSION=${tag#*-v}"
+ } >> "$GITHUB_ENV"
+
+ - name: Check release secrets
+ env:
+ BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
+ P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ AC_API_KEY_BASE64: ${{ secrets.AC_API_KEY_BASE64 }}
+ AC_API_KEY_ID: ${{ secrets.AC_API_KEY_ID }}
+ AC_API_ISSUER_ID: ${{ secrets.AC_API_ISSUER_ID }}
+ HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+ shell: bash
+ run: |
+ missing=0
+ for name in BUILD_CERTIFICATE_BASE64 P12_PASSWORD APPLE_TEAM_ID \
+ AC_API_KEY_BASE64 AC_API_KEY_ID AC_API_ISSUER_ID HOMEBREW_TAP_TOKEN; do
+ if [ -z "${!name}" ]; then
+ echo "::error::Missing required Actions secret: $name"
+ missing=1
+ fi
+ done
+ exit "$missing"
+
+ - name: Show toolchain
+ run: |
+ xcodebuild -version
+ xcodebuild -showsdks | grep -i macos || true
+
+ - name: Install tools
+ run: brew install xcodegen create-dmg
+
+ - name: Import Developer ID certificate
+ env:
+ BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
+ P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
+ shell: bash
+ run: |
+ keychain="$RUNNER_TEMP/build.keychain-db"
+ keychain_password="$(uuidgen)"
+ echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
+ security create-keychain -p "$keychain_password" "$keychain"
+ security set-keychain-settings -lut 21600 "$keychain"
+ security unlock-keychain -p "$keychain_password" "$keychain"
+ security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$P12_PASSWORD" -T /usr/bin/codesign
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain" >/dev/null
+ security list-keychains -d user -s "$keychain"
+ rm "$RUNNER_TEMP/cert.p12"
+
+ - name: Generate Xcode project
+ shell: bash
+ run: |
+ cd "$WORKDIR"
+ xcodegen generate
+
+ - name: Test Barr
+ if: env.APP == 'barr'
+ shell: bash
+ run: |
+ cd "$WORKDIR"
+ xcodebuild test \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ CODE_SIGNING_ALLOWED=NO
+
+ - name: Archive
+ shell: bash
+ run: |
+ cd "$WORKDIR"
+ xcodebuild archive \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Release \
+ -archivePath "$RUNNER_TEMP/$DISPLAY.xcarchive" \
+ -destination 'generic/platform=macOS' \
+ MARKETING_VERSION="$VERSION" \
+ CURRENT_PROJECT_VERSION=${{ github.run_number }} \
+ CODE_SIGN_STYLE=Manual \
+ CODE_SIGN_IDENTITY="Developer ID Application" \
+ DEVELOPMENT_TEAM=${{ secrets.APPLE_TEAM_ID }}
+
+ - name: Export Developer ID app
+ shell: bash
+ run: |
+ cd "$WORKDIR"
+ xcodebuild -exportArchive \
+ -archivePath "$RUNNER_TEMP/$DISPLAY.xcarchive" \
+ -exportOptionsPlist ExportOptions.plist \
+ -exportPath "$RUNNER_TEMP/export"
+
+ - name: Package DMG
+ id: dmg
+ shell: bash
+ run: |
+ dmg="$RUNNER_TEMP/$DISPLAY-$VERSION.dmg"
+ create-dmg \
+ --volname "$DISPLAY $VERSION" \
+ --window-size 540 380 \
+ --icon-size 100 \
+ --icon "$DISPLAY.app" 140 190 \
+ --app-drop-link 400 190 \
+ "$dmg" "$RUNNER_TEMP/export/$DISPLAY.app"
+ echo "path=$dmg" >> "$GITHUB_OUTPUT"
+
+ - name: Sign DMG
+ run: |
+ codesign --force --sign "Developer ID Application" --timestamp "${{ steps.dmg.outputs.path }}"
+ codesign --verify --verbose=2 "${{ steps.dmg.outputs.path }}"
+
+ - name: Notarize and staple
+ env:
+ AC_API_KEY_BASE64: ${{ secrets.AC_API_KEY_BASE64 }}
+ AC_API_KEY_ID: ${{ secrets.AC_API_KEY_ID }}
+ AC_API_ISSUER_ID: ${{ secrets.AC_API_ISSUER_ID }}
+ shell: bash
+ run: |
+ key="$RUNNER_TEMP/AuthKey.p8"
+ echo "$AC_API_KEY_BASE64" | base64 --decode > "$key"
+ xcrun notarytool submit "${{ steps.dmg.outputs.path }}" \
+ --key "$key" --key-id "$AC_API_KEY_ID" --issuer "$AC_API_ISSUER_ID" --wait
+ xcrun stapler staple "${{ steps.dmg.outputs.path }}"
+ rm "$key"
+
+ - name: Publish GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ gh release create "$GITHUB_REF_NAME" \
+ "${{ steps.dmg.outputs.path }}" \
+ --title "$DISPLAY $VERSION" \
+ --generate-notes
+
+ update-tap:
+ needs: release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Configure app from tag
+ shell: bash
+ run: |
+ case "$GITHUB_REF_NAME" in
+ barr-v*) app=barr; display=Barr ;;
+ windo-v*) app=windo; display=Windo ;;
+ loadout-v*) app=loadout; display=Loadout ;;
+ *) echo "::error::Unsupported release tag: $GITHUB_REF_NAME"; exit 1 ;;
+ esac
+ {
+ echo "APP=$app"
+ echo "DISPLAY=$display"
+ echo "VERSION=${GITHUB_REF_NAME#*-v}"
+ } >> "$GITHUB_ENV"
+
+ - name: Bump Homebrew cask
+ env:
+ TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+ shell: bash
+ run: |
+ if [ -z "$TAP_TOKEN" ]; then
+ echo "::error::Missing required Actions secret: HOMEBREW_TAP_TOKEN"
+ exit 1
+ fi
+
+ url="https://github.com/zackbart/apps/releases/download/$GITHUB_REF_NAME/$DISPLAY-$VERSION.dmg"
+ curl -fSL --retry 6 --retry-delay 10 --retry-all-errors -o app.dmg "$url"
+ test -s app.dmg || { echo "::error::Downloaded DMG is empty"; exit 1; }
+ sha=$(sha256sum app.dmg | awk '{print $1}')
+ rm app.dmg
+
+ git clone "https://x-access-token:${TAP_TOKEN}@github.com/zackbart/homebrew-tap.git" tap
+ cd tap
+ cask="Casks/$APP.rb"
+ sed -i -E "s|^ version \".*\"| version \"$VERSION\"|" "$cask"
+ sed -i -E "s|^ sha256 \".*\"| sha256 \"$sha\"|" "$cask"
+ if grep -q '^ verified:' "$cask"; then
+ sed -i -E "s|^ url \".*\",| url \"https://github.com/zackbart/apps/releases/download/$APP-v#{version}/$DISPLAY-#{version}.dmg\",|" "$cask"
+ sed -i -E "s|^ verified: \"github.com/zackbart/[^/]+/\"| verified: \"github.com/zackbart/apps/\"|" "$cask"
+ else
+ sed -i -E "s|^ url \".*\"| url \"https://github.com/zackbart/apps/releases/download/$APP-v#{version}/$DISPLAY-#{version}.dmg\"|" "$cask"
+ fi
+ sed -i -E "s|^ homepage \".*\"| homepage \"https://github.com/zackbart/apps/tree/main/$APP\"|" "$cask"
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add "$cask"
+ git diff --cached --quiet || git commit -m "$APP $VERSION"
+ git push
diff --git a/README.md b/README.md
index b0e4194..9a47777 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,43 @@
-# apps
+# Native apps
+
A collection of native apps built to solve problems I have.
+
+## Apps
+
+| App | Platform | Description |
+| --- | --- | --- |
+| [Barr](barr/) | macOS | A second home for menu bar apps. |
+| [Windo](windo/) | macOS | A floating, always-on-top web window. |
+| [Loadout](loadout/) | macOS and iOS | Inspect AI-agent skills and MCP servers configured on a machine. |
+| [Livewall](livewall/) | macOS | Native live wallpapers for macOS. |
+| [MrMouse](mrmouse/) | macOS | A lightweight Logitech MX Master 3S driver. |
+| [Herdr iOS](herdr-ios/) | iOS | A SwiftUI client for Herdr. |
+| [JellyTV](jellytv/) | tvOS | A Live-TV-first Jellyfin client for Apple TV. |
+
+Each app owns its project files, documentation, and license. Run development
+commands from the app directory unless its README says otherwise.
+
+## Releases
+
+Barr, Windo, and Loadout are built, signed, notarized, published to GitHub
+Releases, and submitted to the Homebrew tap by the shared release workflow.
+Release tags are app-scoped to keep versions independent:
+
+```text
+barr-v0.0.10
+windo-v0.1.7
+loadout-v0.1.3
+```
+
+The workflow requires these repository Actions secrets:
+
+- `BUILD_CERTIFICATE_BASE64`
+- `P12_PASSWORD`
+- `APPLE_TEAM_ID`
+- `AC_API_KEY_BASE64`
+- `AC_API_KEY_ID`
+- `AC_API_ISSUER_ID`
+- `HOMEBREW_TAP_TOKEN`
+
+Historical releases remain available from the apps' former standalone
+repositories.
diff --git a/barr/.gitignore b/barr/.gitignore
new file mode 100644
index 0000000..98ed202
--- /dev/null
+++ b/barr/.gitignore
@@ -0,0 +1,15 @@
+# XcodeGen generates this from app/project.yml
+app/Barr.xcodeproj/
+
+# Build output
+build/
+DerivedData/
+app/build/
+app/.build/
+app/DerivedData/
+*.dmg
+*.app
+*.xcuserstate
+xcuserdata/
+
+.DS_Store
diff --git a/barr/CHANGELOG.md b/barr/CHANGELOG.md
new file mode 100644
index 0000000..3ee1d9d
--- /dev/null
+++ b/barr/CHANGELOG.md
@@ -0,0 +1,133 @@
+# Changelog
+
+All notable changes to Barr are documented here.
+
+## [Unreleased]
+
+## [0.0.9] - 2026-07-28
+
+### Fixed
+
+- Kept Barr's own menu-bar control visible after macOS 26 finishes re-hosting
+ its status-item scene at launch.
+
+## [0.0.8] - 2026-07-28
+
+### Fixed
+
+- Restored deterministic WindowServer menu-bar moves by picking items up from
+ the required offscreen sentinel instead of the user's cursor position.
+- Dropped items immediately beside Barr's control and parking boundary rather
+ than inside those windows, where macOS could leave them on the wrong side.
+- Removed direct activation of offscreen status items. Barr now reveals the
+ original item before activating it, then returns that exact hosted window.
+- Reduced the parking lane to the minimum offscreen overscan so moving and
+ restoring an item does less menu-bar layout work.
+- Verified that a temporarily restored item is genuinely on screen before
+ clicking it instead of treating missing WindowServer metadata as success.
+- Kept restored items in place while their menu, popover, or app-owned window
+ remains open, then returned them after the interface closes.
+- Prevented malformed Accessibility attributes from crashing activation.
+- Kept normal login launches quiet once Barr is configured while continuing to
+ show setup and permission guidance when it is needed.
+- Deferred the SwiftUI shelf window until it is actually needed and made the
+ release status control fixed-width to reduce launch-time AppKit layout work.
+- Added visible feedback when activation or membership changes fail.
+- Gave multiple unnamed or identically titled items from one app distinct,
+ persistent identities instead of collapsing them into one shelf entry.
+- Removed strict-concurrency diagnostics from the app target so background
+ scans and main-thread completions have explicit isolation boundaries.
+
+## [0.0.7] - 2026-07-28
+
+### Changed
+
+- Added foreground-app and display-aware menu-bar reconciliation, coalesced
+ shelf sizing, and cursor restoration around synthetic menu-bar events.
+
+## [0.0.6] - 2026-07-24
+
+### Changed
+
+- Eliminated Barr's persistent permission polling, coalesced overlapping menu
+ bar scans, batched WindowServer metadata reads, cached stable icon and item
+ identity data, and deferred background icon capture until the shelf is open.
+
+## [0.0.5] - 2026-07-24
+
+### Added
+
+- Added an **Open at Login** option to Barr's manager and right-click menu,
+ including a shortcut to macOS Login Items settings when approval is needed.
+
+## [0.0.4] - 2026-07-23
+
+### Fixed
+
+- Kept Barr's own menu bar control at a visible priority across quit and relaunch,
+ including on MacBooks with a notch.
+- Re-applied every persisted Barr membership after the first launch scan so
+ selected apps return to the shelf reliably after reopening Barr, regaining
+ Accessibility permission, or launching later in the session.
+- Prevented the shelf from opening beneath an unreachable status item and made
+ it dismiss when clicking elsewhere or pressing Escape.
+
+## [0.0.3] - 2026-07-23
+
+### Fixed
+
+- Allowed adding the first visible item when Barr still remembers hidden items
+ whose apps are not currently running.
+- Prevented Debug and Release instances from offering each other's controls and
+ invisible storage anchors as movable menu bar items.
+- Sized and clamped the shelf to the display containing Barr's menu bar control.
+
+## [0.0.2] - 2026-07-23
+
+### Added
+
+- A **System items** setting. macOS system items are hidden from Barr by
+ default and can be enabled explicitly.
+- A distinct `Barr Debug` app identity, badged app icon, and `DEBUG` menu bar
+ label so development builds cannot be confused with release builds.
+- Debug diagnostics for status-item movement and activation.
+
+### Changed
+
+- Increased shelf and configuration icons from 18 to 24 points, with larger
+ 32-point hit targets and roomier layouts.
+- Made configuration-row transitions optimistic and stable while items move
+ between the menu bar and Barr.
+- Preserved logical item identity, ordering, and the last valid icon while
+ macOS reparents or temporarily hides a status-item window.
+- Improved system-item activation by matching stable Accessibility identity
+ before falling back to geometry or synthetic clicks.
+- Restored items beside a live logical neighbor, with Barr's visible control
+ as a safe fallback.
+- Used readable owning-app icons when macOS Tahoe redacts hosted menu-bar
+ captures.
+
+### Fixed
+
+- Prevented stale or reused WindowServer IDs from affecting neighboring menu
+ bar items.
+- Prevented failed move retries from dragging an unobserved stale window.
+- Kept transient system controls in their physical neighbor order.
+- Repositioned the open shelf when its hidden-item storage boundary changes.
+- Marked fixed macOS surfaces such as Clock, Control Center, and active privacy
+ controls unavailable instead of allowing silent failed moves.
+
+## [0.0.1] - 2026-07-23
+
+- Initial signed and notarized release.
+
+[Unreleased]: https://github.com/zackbart/barr/compare/v0.0.9...HEAD
+[0.0.9]: https://github.com/zackbart/barr/compare/v0.0.8...v0.0.9
+[0.0.8]: https://github.com/zackbart/barr/compare/v0.0.7...v0.0.8
+[0.0.7]: https://github.com/zackbart/barr/compare/v0.0.6...v0.0.7
+[0.0.6]: https://github.com/zackbart/barr/compare/v0.0.5...v0.0.6
+[0.0.5]: https://github.com/zackbart/barr/compare/v0.0.4...v0.0.5
+[0.0.4]: https://github.com/zackbart/barr/compare/v0.0.3...v0.0.4
+[0.0.3]: https://github.com/zackbart/barr/compare/v0.0.2...v0.0.3
+[0.0.2]: https://github.com/zackbart/barr/compare/v0.0.1...v0.0.2
+[0.0.1]: https://github.com/zackbart/barr/releases/tag/v0.0.1
diff --git a/barr/LICENSE b/barr/LICENSE
new file mode 100644
index 0000000..464f050
--- /dev/null
+++ b/barr/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Cursor Kittens LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/barr/README.md b/barr/README.md
new file mode 100644
index 0000000..acc6fe7
--- /dev/null
+++ b/barr/README.md
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Barr clears space in your macOS menu bar by moving the apps you choose into a
+compact dropdown shelf. Your apps keep running, their icons stay within reach,
+and everything else remains in the native menu bar. It is especially useful on
+MacBooks where the notch leaves little room for status items.
+
+## How it works
+
+1. Open **Choose Apps** and select the menu bar items you want Barr to hold.
+2. Barr hides those items from the menu bar and mirrors them in its shelf.
+3. Click Barr's three-line icon to open the shelf, then click any item to use it
+ normally. You can move an item back to the menu bar at any time.
+
+Barr needs **Screen Recording** to mirror each selected icon and
+**Accessibility** to move and activate the original status item. Processing stays
+on your Mac.
+
+## Install
+
+Install with Homebrew:
+
+```bash
+brew tap zackbart/tap
+brew install --cask barr
+```
+
+Or grab the latest `Barr.dmg` from [Releases](https://github.com/zackbart/barr/releases/latest).
+Barr is a menu-bar utility (`LSUIElement`) and does not appear in the Dock.
+
+## Repo layout
+
+```text
+app/ XcodeGen project, Swift sources, and app assets
+.github/workflows/ Tag-driven release automation
+CHANGELOG.md Release history and notable changes
+README.md Project overview
+```
+
+## Develop
+
+```bash
+brew install xcodegen
+cd app
+xcodegen generate && open Barr.xcodeproj
+```
+
+`app/project.yml` is the source of truth; the generated Xcode project is ignored.
+Requires macOS 14+ and Xcode 16+.
+
+## Implementation note
+
+macOS has no public API for re-parenting another app's status item. Barr mirrors
+selected icons, parks their original status-item windows beyond the visible menu
+bar, and temporarily restores an original when you activate it. It uses private
+WindowServer functions and is intended for direct distribution rather than the
+Mac App Store. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
+
+## Releasing
+
+Releases are tag-driven. Push a `barr-v*` tag to build, sign, notarize, publish
+a `Barr-.dmg` asset, and update Homebrew:
+
+```bash
+git tag barr-v0.1.0
+git push origin barr-v0.1.0
+```
+
+The release workflow expects the same Apple signing secrets used by the other
+native macOS apps. Add these to `zackbart/apps` before pushing the first tag:
+
+- `BUILD_CERTIFICATE_BASE64`
+- `P12_PASSWORD`
+- `APPLE_TEAM_ID`
+- `AC_API_KEY_BASE64`
+- `AC_API_KEY_ID`
+- `AC_API_ISSUER_ID`
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/barr/THIRD_PARTY_NOTICES.md b/barr/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..6246633
--- /dev/null
+++ b/barr/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,7 @@
+# Third-party notices
+
+Barr's notch-safe status-item move technique is adapted from SaneBar's
+`AccessibilityMenuBarTeleportMove` implementation.
+
+SaneBar is Copyright © 2025–2026 SaneApps and licensed under the MIT License.
+Its source and license are available at .
diff --git a/barr/app/ExportOptions.plist b/barr/app/ExportOptions.plist
new file mode 100644
index 0000000..4f1750a
--- /dev/null
+++ b/barr/app/ExportOptions.plist
@@ -0,0 +1,9 @@
+
+
+
+
+ method developer-id
+ teamID F2J8ZU2NQJ
+ signingStyle manual
+
+
diff --git a/barr/app/Sources/AppDelegate.swift b/barr/app/Sources/AppDelegate.swift
new file mode 100644
index 0000000..b292403
--- /dev/null
+++ b/barr/app/Sources/AppDelegate.swift
@@ -0,0 +1,1302 @@
+import AppKit
+import ApplicationServices
+import OSLog
+
+private let barrMembershipLogger = Logger(
+ subsystem: "com.cursorkittens.Barr",
+ category: "Membership"
+)
+private let barrActivationLogger = Logger(
+ subsystem: "com.cursorkittens.Barr",
+ category: "Activation"
+)
+
+@MainActor
+final class AppDelegate: NSObject, NSApplicationDelegate {
+ private struct PendingItemReturn {
+ let item: MenuBarItem
+ let baselineWindowIDs: Set
+ let armedAt: TimeInterval
+ var interfaceWindowIDs: Set
+ }
+
+ private let model = ShelfModel()
+ private var statusItem: NSStatusItem!
+ private var storageAnchor: NSStatusItem!
+ private var shelfPanel: ShelfPanel?
+ private var returnMonitor: Any?
+ private var returnFallback: DispatchWorkItem?
+ private var pendingReturn: PendingItemReturn?
+ private var returnCheckGeneration = 0
+ private var shelfGlobalDismissMonitor: Any?
+ private var shelfLocalDismissMonitor: Any?
+ private var storageUpdateGeneration = 0
+ private var handledInitialRefresh = false
+ private var startupReconciliationComplete = false
+ private var startupShelfRequested = false
+ private var persistedItemReconciliationInProgress = false
+ private var runningApplicationsGeneration = 0
+ private var environmentRefreshGeneration = 0
+ private var foregroundBundleIdentifier: String?
+ private var stableStorageLength: CGFloat?
+ private var resolvedStatusWindowIDs = [String: CGWindowID]()
+ private let collapsedStorageLength: CGFloat = 2
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ if model.movedItemKeys.isEmpty {
+ model.setManaging(true)
+ }
+ DispatchQueue.main.async { [weak self] in
+ self?.finishApplicationLaunch()
+ }
+ }
+
+ private func finishApplicationLaunch() {
+ configureStatusItems()
+ foregroundBundleIdentifier =
+ NSWorkspace.shared.frontmostApplication?.bundleIdentifier
+ startupReconciliationComplete = model.movedItemKeys.isEmpty
+ model.onItemsChanged = { [weak self] in
+ self?.itemsChanged()
+ }
+ model.onLayoutChanged = { [weak self] in
+ self?.shelfPanel?.scheduleResizeToFit()
+ }
+ model.onRefreshCompleted = { [weak self] in
+ self?.refreshCompleted()
+ }
+ model.onActivate = { [weak self] item in
+ self?.activateFromShelf(item)
+ }
+ model.onRestart = { [weak self] in
+ self?.restartApplication()
+ }
+ model.onMembershipChange = { [weak self] item, moveToBarr, completion in
+ self?.changeMembership(of: item, moveToBarr: moveToBarr, completion: completion)
+ }
+
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(environmentChanged),
+ name: NSApplication.didChangeScreenParametersNotification,
+ object: nil
+ )
+ NSWorkspace.shared.notificationCenter.addObserver(
+ self,
+ selector: #selector(environmentChanged),
+ name: NSWorkspace.activeSpaceDidChangeNotification,
+ object: nil
+ )
+ NSWorkspace.shared.notificationCenter.addObserver(
+ self,
+ selector: #selector(runningApplicationsChanged),
+ name: NSWorkspace.didLaunchApplicationNotification,
+ object: nil
+ )
+ NSWorkspace.shared.notificationCenter.addObserver(
+ self,
+ selector: #selector(runningApplicationsChanged),
+ name: NSWorkspace.didTerminateApplicationNotification,
+ object: nil
+ )
+ NSWorkspace.shared.notificationCenter.addObserver(
+ self,
+ selector: #selector(foregroundApplicationChanged),
+ name: NSWorkspace.didActivateApplicationNotification,
+ object: nil
+ )
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in
+ self?.refreshScannerExclusions()
+ self?.model.refresh()
+ }
+ if
+ model.movedItemKeys.isEmpty ||
+ !model.canCaptureScreen ||
+ !model.canUseAccessibility
+ {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in
+ self?.requestStartupShelf()
+ }
+ }
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ returnHiddenItemNow()
+ removeShelfDismissMonitors()
+ NotificationCenter.default.removeObserver(self)
+ NSWorkspace.shared.notificationCenter.removeObserver(self)
+ }
+
+ func applicationShouldHandleReopen(
+ _ sender: NSApplication,
+ hasVisibleWindows flag: Bool
+ ) -> Bool {
+ guard statusItem != nil else { return true }
+ // Reopening an accessory app leaves Finder (or the launcher) in the
+ // foreground. Its activation notification can arrive after this
+ // delegate callback and close a shelf that was just shown. Let that
+ // notification settle before presenting the drawer.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in
+ self?.showShelf()
+ }
+ return true
+ }
+
+ private func itemsChanged() {
+ shelfPanel?.scheduleResizeToFit()
+ if startupReconciliationComplete {
+ updateStorageState()
+ } else {
+ storageAnchor.length = collapsedStorageLength
+ refreshScannerExclusions()
+ }
+ }
+
+ private func refreshCompleted() {
+ if !handledInitialRefresh {
+ handledInitialRefresh = true
+ }
+
+ if startupReconciliationComplete {
+ updateStorageState()
+ reconcileNewlyVisiblePersistedItems()
+ presentStartupShelfIfReady()
+ return
+ }
+
+ // Keep the parking boundary collapsed until Accessibility becomes
+ // available. The permissions UI must still be reachable, and the next
+ // successful permission refresh will resume restoration automatically.
+ guard PermissionCenter.isAccessibilityGranted else {
+ presentStartupShelfIfReady()
+ return
+ }
+ restorePersistedItemsAfterLaunch()
+ }
+
+ private func restorePersistedItemsAfterLaunch() {
+ guard !persistedItemReconciliationInProgress else { return }
+ persistedItemReconciliationInProgress = true
+ storageAnchor.length = collapsedStorageLength
+ refreshScannerExclusions()
+
+ guard
+ PermissionCenter.isAccessibilityGranted,
+ let anchorWindowID = windowID(for: storageAnchor)
+ else {
+ persistedItemReconciliationInProgress = false
+ finishStartupReconciliation()
+ return
+ }
+
+ let persistedItems = model.barrItems.filter(\.isMovableByBarr)
+ guard !persistedItems.isEmpty else {
+ persistedItemReconciliationInProgress = false
+ finishStartupReconciliation()
+ return
+ }
+
+ reconcile(
+ persistedItems,
+ beside: anchorWindowID
+ ) { [weak self] _ in
+ guard let self else { return }
+ self.persistedItemReconciliationInProgress = false
+ self.finishStartupReconciliation()
+ }
+ }
+
+ private func finishStartupReconciliation() {
+ startupReconciliationComplete = true
+ model.refresh(captureImages: false)
+ updateStorageState()
+ presentStartupShelfIfReady()
+ }
+
+ private func reconcileNewlyVisiblePersistedItems() {
+ guard
+ !persistedItemReconciliationInProgress,
+ PermissionCenter.isAccessibilityGranted,
+ let anchorWindowID = windowID(for: storageAnchor)
+ else { return }
+
+ let anchorFrame = PrivateWindowServer.frame(of: anchorWindowID)
+ let liveWindowIDs = Set(PrivateWindowServer.menuBarWindowIDs())
+ let visiblePersistedItems = model.barrItems.filter { item in
+ item.isMovableByBarr &&
+ liveWindowIDs.contains(item.windowID) &&
+ anchorFrame.map { anchor in item.frame.midX >= anchor.midX } == true
+ }
+ guard !visiblePersistedItems.isEmpty else { return }
+
+ persistedItemReconciliationInProgress = true
+ reconcile(
+ visiblePersistedItems,
+ beside: anchorWindowID
+ ) { [weak self] movedAnyItem in
+ guard let self else { return }
+ self.persistedItemReconciliationInProgress = false
+ if movedAnyItem {
+ self.model.refresh(captureImages: self.shelfPanel?.isVisible == true)
+ }
+ }
+ }
+
+ private func reconcile(
+ _ persistedItems: [MenuBarItem],
+ beside anchorWindowID: CGWindowID,
+ completion: @escaping @MainActor @Sendable (Bool) -> Void
+ ) {
+ DispatchQueue.global(qos: .userInitiated).async {
+ var movedAnyItem = false
+ for persistedItem in persistedItems {
+ for attempt in 0..<2 {
+ let scannedItems = MenuBarScanner.scan(captureImages: false)
+ guard let currentItem = scannedItems.first(where: {
+ $0.storageKey == persistedItem.storageKey
+ }) else {
+ break
+ }
+
+ let liveWindowIDs = Set(PrivateWindowServer.menuBarWindowIDs())
+ guard
+ liveWindowIDs.contains(currentItem.windowID),
+ let anchorFrame = PrivateWindowServer.frame(of: anchorWindowID),
+ currentItem.frame.midX >= anchorFrame.midX
+ else {
+ break
+ }
+
+ let targetPoint = MenuBarMoveGeometry.pointImmediatelyLeft(
+ of: anchorFrame
+ )
+ guard MenuBarMover.move(
+ windowID: currentItem.windowID,
+ sourcePID: currentItem.ownerPID,
+ beside: anchorWindowID,
+ at: targetPoint
+ ) else {
+ continue
+ }
+
+ Thread.sleep(forTimeInterval: attempt == 0 ? 0.14 : 0.22)
+ let refreshedItem = MenuBarScanner.scan(captureImages: false).first {
+ $0.storageKey == persistedItem.storageKey
+ }
+ let refreshedLiveIDs = Set(PrivateWindowServer.menuBarWindowIDs())
+ let refreshedAnchorFrame =
+ PrivateWindowServer.frame(of: anchorWindowID) ?? anchorFrame
+ let isParked = refreshedItem.map {
+ !refreshedLiveIDs.contains($0.windowID) ||
+ $0.frame.midX < refreshedAnchorFrame.midX
+ } ?? !refreshedLiveIDs.contains(currentItem.windowID)
+ if isParked {
+ movedAnyItem = true
+ break
+ }
+ }
+ }
+
+ let didMoveAnyItem = movedAnyItem
+ DispatchQueue.main.async {
+ completion(didMoveAnyItem)
+ }
+ }
+ }
+
+ private func requestStartupShelf() {
+ startupShelfRequested = true
+ presentStartupShelfIfReady()
+ }
+
+ private func presentStartupShelfIfReady() {
+ guard
+ startupShelfRequested,
+ handledInitialRefresh,
+ startupReconciliationComplete || !PermissionCenter.isAccessibilityGranted
+ else { return }
+ startupShelfRequested = false
+ showShelf(refreshItems: false)
+ }
+
+ private func activateFromShelf(_ item: MenuBarItem) {
+ returnHiddenItemNow()
+ closeShelf()
+
+ guard
+ let controlWindowID = windowID(for: statusItem),
+ let controlFrame = PrivateWindowServer.frame(of: controlWindowID)
+ else {
+ model.setActivationFailed(true)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in
+ self?.showShelf(refreshItems: false)
+ }
+ return
+ }
+
+ let revealPoint = MenuBarMoveGeometry.pointImmediatelyLeft(
+ of: controlFrame
+ )
+
+ DispatchQueue.global(qos: .userInitiated).async { [self] in
+ let scannedItems = MenuBarScanner.scan(captureImages: false)
+ let currentItem =
+ scannedItems.first { $0.windowID == item.windowID } ??
+ scannedItems.first { $0.storageKey == item.storageKey } ??
+ item
+ let moved = MenuBarMover.move(
+ windowID: currentItem.windowID,
+ sourcePID: currentItem.ownerPID,
+ beside: controlWindowID,
+ at: revealPoint
+ )
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
+ let revealedCandidates = MenuBarScanner.scan(captureImages: false).filter {
+ $0.storageKey == item.storageKey
+ }
+ let revealedItem =
+ revealedCandidates.min {
+ abs($0.frame.midX - revealPoint.x) <
+ abs($1.frame.midX - revealPoint.x)
+ } ??
+ currentItem
+ let itemIsActuallyVisible =
+ revealedItem.isOnScreen &&
+ PrivateWindowServer.menuBarWindowIDs().contains(revealedItem.windowID)
+ let baselineWindowIDs = PrivateWindowServer.onScreenWindowIDs(
+ ownedBy: revealedItem.ownerPID
+ )
+ let activated =
+ moved &&
+ itemIsActuallyVisible &&
+ MenuBarActivator.activate(revealedItem)
+#if DEBUG
+ barrActivationLogger.notice(
+ """
+ Activation target=\(item.storageKey, privacy: .public) \
+ moved=\(moved) activated=\(activated) \
+ revealedWindow=\(revealedItem.windowID)
+ """
+ )
+#endif
+ self.model.setActivationFailed(!activated)
+ guard moved, itemIsActuallyVisible else {
+ self.showShelf()
+ return
+ }
+ if activated {
+ // Carry the exact Control Center proxy that was revealed.
+ // Multiple proxy windows can share an app identity, and
+ // falling back to the first storage-key match can re-park
+ // a stale proxy while leaving the live icon visible.
+ self.armReturn(
+ item: revealedItem,
+ baselineWindowIDs: baselineWindowIDs
+ )
+ } else {
+ self.parkItem(revealedItem)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ self.showShelf()
+ }
+ }
+ }
+ }
+ }
+
+ private func armReturn(
+ item: MenuBarItem,
+ baselineWindowIDs: Set
+ ) {
+ pendingReturn = PendingItemReturn(
+ item: item,
+ baselineWindowIDs: baselineWindowIDs,
+ armedAt: ProcessInfo.processInfo.systemUptime,
+ interfaceWindowIDs: []
+ )
+
+ returnMonitor = NSEvent.addGlobalMonitorForEvents(
+ matching: [.leftMouseDown, .rightMouseDown, .keyDown]
+ ) { [weak self] _ in
+ DispatchQueue.main.async {
+ self?.scheduleReturnCheck(after: 0.35)
+ }
+ }
+
+ let fallback = DispatchWorkItem { [weak self] in
+ self?.attemptReturnHiddenItem()
+ }
+ returnFallback = fallback
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: fallback)
+ }
+
+ private func scheduleReturnCheck(after delay: TimeInterval) {
+ guard pendingReturn != nil else { return }
+ returnCheckGeneration += 1
+ let generation = returnCheckGeneration
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
+ guard
+ let self,
+ generation == self.returnCheckGeneration
+ else {
+ return
+ }
+ self.attemptReturnHiddenItem()
+ }
+ }
+
+ private func attemptReturnHiddenItem() {
+ guard var pendingReturn else { return }
+
+ if pendingReturn.interfaceWindowIDs.isEmpty {
+ let currentWindowIDs = PrivateWindowServer.onScreenWindowIDs(
+ ownedBy: pendingReturn.item.ownerPID
+ )
+ pendingReturn.interfaceWindowIDs =
+ currentWindowIDs
+ .subtracting(pendingReturn.baselineWindowIDs)
+ self.pendingReturn = pendingReturn
+ }
+
+ if
+ pendingReturn.interfaceWindowIDs.contains(where: {
+ PrivateWindowServer.interfaceWindowIsVisible(
+ $0,
+ ownedBy: pendingReturn.item.ownerPID
+ )
+ })
+ {
+ // Pop-up menus and app-owned popovers must remain attached to the
+ // original status item for their entire lifetime. Check again after
+ // the interface closes instead of yanking the item away on its first
+ // click.
+ scheduleReturnCheck(after: 0.6)
+ return
+ }
+
+ let armedDuration =
+ ProcessInfo.processInfo.systemUptime - pendingReturn.armedAt
+ if pendingReturn.interfaceWindowIDs.isEmpty, armedDuration < 0.8 {
+ // Some menu extras create their interface a few frames after AXPress.
+ // Give that window enough time to appear before treating the click as
+ // a menu that has already closed.
+ scheduleReturnCheck(after: 0.2)
+ return
+ }
+
+ returnHiddenItemNow()
+ }
+
+ private func parkItem(
+ _ originalItem: MenuBarItem,
+ attempt: Int = 0,
+ anchorPrepared: Bool = false
+ ) {
+ guard
+ let initialAnchorWindowID = windowID(for: storageAnchor),
+ let initialAnchorFrame = PrivateWindowServer.frame(of: initialAnchorWindowID),
+ let anchorScreen = storageAnchor.button?.window?.screen ?? NSScreen.main
+ else {
+ retryParking(originalItem, attempt: attempt)
+ return
+ }
+
+ if
+ !anchorPrepared,
+ let preparedLength = MenuBarMoveGeometry.preparedAnchorLength(
+ currentLength: storageAnchor.length,
+ anchorFrame: initialAnchorFrame,
+ screenFrame: anchorScreen.frame,
+ collapsedLength: collapsedStorageLength
+ )
+ {
+ // WindowServer will not accept a drop beside the off-display edge
+ // of an expanded spacer. Shorten it only enough to expose a valid
+ // insertion point. Keeping almost all of its width prevents it
+ // from crossing neighboring status items and changing its order.
+ storageUpdateGeneration += 1
+ storageAnchor.length = preparedLength
+ refreshScannerExclusions()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in
+ self?.parkItem(
+ originalItem,
+ attempt: attempt,
+ anchorPrepared: true
+ )
+ }
+ return
+ }
+
+ guard
+ let anchorWindowID = windowID(for: storageAnchor),
+ let anchorFrame = PrivateWindowServer.frame(of: anchorWindowID)
+ else {
+ retryParking(originalItem, attempt: attempt)
+ return
+ }
+
+ let targetPoint = MenuBarMoveGeometry.pointImmediatelyLeft(
+ of: anchorFrame
+ )
+ DispatchQueue.global(qos: .userInitiated).async { [self] in
+ let scannedItems = MenuBarScanner.scan(captureImages: false)
+ let currentItem =
+ scannedItems.first { $0.windowID == originalItem.windowID } ??
+ scannedItems.first { $0.storageKey == originalItem.storageKey } ??
+ originalItem
+#if DEBUG
+ barrActivationLogger.notice(
+ """
+ Park start target=\(originalItem.storageKey, privacy: .public) \
+ preferredWindow=\(originalItem.windowID) currentWindow=\(currentItem.windowID) \
+ targetPoint=\(NSStringFromPoint(targetPoint), privacy: .public)
+ """
+ )
+#endif
+ let attempted = MenuBarMover.move(
+ windowID: currentItem.windowID,
+ sourcePID: currentItem.ownerPID,
+ beside: anchorWindowID,
+ at: targetPoint
+ )
+
+ var parkedItem: MenuBarItem?
+ var parked = false
+ if attempted {
+ for delay in [0.18, 0.32, 0.5] where !parked {
+ Thread.sleep(forTimeInterval: delay)
+ let parkedCandidates = MenuBarScanner.scan(captureImages: false).filter {
+ $0.storageKey == originalItem.storageKey
+ }
+ parkedItem = parkedCandidates.min {
+ abs($0.frame.midX - targetPoint.x) <
+ abs($1.frame.midX - targetPoint.x)
+ }
+ parked = parkedItem.map {
+ $0.frame.midX < anchorFrame.midX
+ } == true
+ }
+ }
+#if DEBUG
+ barrActivationLogger.notice(
+ """
+ Park result target=\(originalItem.storageKey, privacy: .public) \
+ attempted=\(attempted) parked=\(parked) \
+ observedWindow=\(parkedItem?.windowID ?? 0) \
+ observedFrame=\(parkedItem.map { NSStringFromRect($0.frame) } ?? "none", privacy: .public)
+ """
+ )
+#endif
+
+ let itemWasParked = parked
+ DispatchQueue.main.async {
+ if itemWasParked {
+ self.updateStorageState()
+ self.model.refresh(captureImages: false)
+ } else {
+ self.retryParking(originalItem, attempt: attempt)
+ }
+ }
+ }
+ }
+
+ private func retryParking(_ item: MenuBarItem, attempt: Int) {
+ guard attempt < 1 else {
+ model.refresh(captureImages: false)
+ return
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
+ self?.parkItem(item, attempt: attempt + 1)
+ }
+ }
+
+ private func returnHiddenItemNow() {
+ if let returnMonitor {
+ NSEvent.removeMonitor(returnMonitor)
+ self.returnMonitor = nil
+ }
+ returnFallback?.cancel()
+ returnFallback = nil
+ returnCheckGeneration += 1
+ let item = pendingReturn?.item
+ pendingReturn = nil
+ if let item {
+ parkItem(item)
+ }
+ }
+
+ private func restartApplication() {
+ let bundlePath = Bundle.main.bundlePath
+ let relauncher = Process()
+ relauncher.executableURL = URL(fileURLWithPath: "/bin/sh")
+ relauncher.arguments = [
+ "-c",
+ "sleep 0.8; /usr/bin/open -n \"$1\"",
+ "barr-relauncher",
+ bundlePath
+ ]
+
+ do {
+ try relauncher.run()
+ NSApp.terminate(nil)
+ } catch {
+ NSSound.beep()
+ }
+ }
+
+ private func changeMembership(
+ of item: MenuBarItem,
+ moveToBarr: Bool,
+ anchorPrepared: Bool = false,
+ completion: @escaping @MainActor @Sendable (Bool) -> Void
+ ) {
+ guard PermissionCenter.isAccessibilityGranted else {
+ PermissionCenter.requestAccessibility()
+ completion(false)
+ return
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in
+ guard let self else { return }
+
+ if
+ moveToBarr,
+ !anchorPrepared,
+ self.prepareStorageAnchorForInsertion()
+ {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
+ self.changeMembership(
+ of: item,
+ moveToBarr: true,
+ anchorPrepared: true,
+ completion: completion
+ )
+ }
+ return
+ }
+
+ self.refreshScannerExclusions()
+ guard let (anchorWindowID, anchorFrame) = self.membershipAnchor(
+ for: item,
+ moveToBarr: moveToBarr
+ ) else {
+#if DEBUG
+ barrMembershipLogger.notice(
+ "Move has no anchor target=\(item.storageKey, privacy: .public)"
+ )
+#endif
+ completion(false)
+ self.updateStorageState()
+ return
+ }
+
+#if DEBUG
+ barrMembershipLogger.notice(
+ """
+ Move start direction=\(moveToBarr ? "into-barr" : "to-menu-bar", privacy: .public) \
+ target=\(item.storageKey, privacy: .public) window=\(item.windowID) \
+ anchor=\(anchorWindowID) frame=\(NSStringFromRect(anchorFrame), privacy: .public)
+ """
+ )
+#endif
+ let targetPoint = MenuBarMoveGeometry.pointImmediatelyLeft(
+ of: anchorFrame
+ )
+ DispatchQueue.global(qos: .userInitiated).async {
+ let baselineItems = MenuBarScanner.scan(captureImages: false)
+ let baselineSystemKeys = Set(
+ baselineItems
+ .filter { $0.isSystemItem && $0.storageKey != item.storageKey }
+ .map(\.storageKey)
+ )
+ var moved = false
+ var lastScan = baselineItems
+ for attempt in 0..<2 where !moved {
+ let currentItem = lastScan.first {
+ $0.storageKey == item.storageKey
+ } ?? (attempt == 0 ? item : nil)
+ guard let currentItem else { break }
+
+#if DEBUG
+ barrMembershipLogger.notice(
+ """
+ Move attempt=\(attempt) target=\(currentItem.storageKey, privacy: .public) \
+ window=\(currentItem.windowID) sourceFrame=\(NSStringFromRect(currentItem.frame), privacy: .public) \
+ targetPoint=\(NSStringFromPoint(targetPoint), privacy: .public)
+ """
+ )
+#endif
+ let attempted = MenuBarMover.move(
+ windowID: currentItem.windowID,
+ sourcePID: currentItem.ownerPID,
+ beside: anchorWindowID,
+ at: targetPoint
+ )
+ guard attempted else { continue }
+
+ var targetWasObserved = false
+ for delay in [0.08, 0.14, 0.22] where !moved {
+ Thread.sleep(forTimeInterval: delay)
+ lastScan = MenuBarScanner.scan(captureImages: false)
+ let liveMenuBarWindowIDs = Set(PrivateWindowServer.menuBarWindowIDs())
+ let verificationAnchorFrame =
+ PrivateWindowServer.frame(of: anchorWindowID) ?? anchorFrame
+ let movedItem = lastScan.first {
+ $0.storageKey == item.storageKey
+ }
+#if DEBUG
+ barrMembershipLogger.notice(
+ """
+ Move poll delay=\(delay) targetSeen=\(movedItem != nil) \
+ observedWindow=\(movedItem?.windowID ?? 0) \
+ observedFrame=\(movedItem.map { NSStringFromRect($0.frame) } ?? "none", privacy: .public) \
+ observedLive=\(movedItem.map { liveMenuBarWindowIDs.contains($0.windowID) } ?? false) \
+ sourceLive=\(liveMenuBarWindowIDs.contains(currentItem.windowID)) \
+ liveAnchorFrame=\(NSStringFromRect(verificationAnchorFrame), privacy: .public)
+ """
+ )
+#endif
+ targetWasObserved = targetWasObserved || movedItem != nil
+ moved = moveToBarr
+ ? movedItem.map {
+ !liveMenuBarWindowIDs.contains($0.windowID) ||
+ $0.frame.midX < verificationAnchorFrame.midX
+ } ?? !liveMenuBarWindowIDs.contains(currentItem.windowID)
+ : movedItem.map {
+ liveMenuBarWindowIDs.contains($0.windowID)
+ } == true
+ }
+
+ // If the scanner lost the target entirely, another drag
+ // could act on a stale/reused window ID and disturb a
+ // neighboring system item. Let a later refresh reconcile it.
+ if !targetWasObserved && !moved {
+ break
+ }
+ }
+
+#if DEBUG
+ barrMembershipLogger.notice(
+ """
+ Move result target=\(item.storageKey, privacy: .public) \
+ success=\(moved) observedItems=\(lastScan.count)
+ """
+ )
+ if moved && !baselineSystemKeys.isEmpty {
+ let presentKeys = Set(
+ MenuBarScanner.scan(captureImages: false).map(\.storageKey)
+ )
+ let missingKeys = baselineSystemKeys.subtracting(presentKeys)
+ if !missingKeys.isEmpty {
+ print("[Barr] System items missing after move: \(missingKeys.sorted())")
+ }
+ }
+#endif
+
+ let moveSucceeded = moved
+ DispatchQueue.main.async {
+ completion(moveSucceeded)
+ self.model.refresh()
+ }
+ }
+ }
+ }
+
+ /// Temporarily exposes the leading edge of an expanded parking boundary so
+ /// WindowServer receives an on-screen destination immediately to its left.
+ /// `updateStorageState()` restores the stable expanded length after the
+ /// membership transaction completes.
+ private func prepareStorageAnchorForInsertion() -> Bool {
+ guard
+ let anchorWindowID = windowID(for: storageAnchor),
+ let anchorFrame = PrivateWindowServer.frame(of: anchorWindowID),
+ let anchorScreen = storageAnchor.button?.window?.screen ?? NSScreen.main
+ else {
+ return false
+ }
+
+ guard
+ let preparedLength = MenuBarMoveGeometry.preparedAnchorLength(
+ currentLength: storageAnchor.length,
+ anchorFrame: anchorFrame,
+ screenFrame: anchorScreen.frame,
+ collapsedLength: collapsedStorageLength
+ )
+ else {
+ return false
+ }
+ storageUpdateGeneration += 1
+ storageAnchor.length = preparedLength
+ refreshScannerExclusions()
+ return true
+ }
+
+ private func updateStorageState() {
+ guard storageAnchor != nil else { return }
+ storageUpdateGeneration += 1
+ let generation = storageUpdateGeneration
+ let keepShelfOpen = shelfPanel?.isVisible == true
+
+ guard model.hasVisiblePersistedBarrItems else {
+ stableStorageLength = nil
+ if abs(storageAnchor.length - collapsedStorageLength) > 0.5 {
+ storageAnchor.length = collapsedStorageLength
+ refreshScannerExclusions()
+ }
+ repositionShelfIfNeeded(keepOpen: keepShelfOpen)
+ return
+ }
+
+ configureStableStorage(
+ generation: generation,
+ keepShelfOpen: keepShelfOpen
+ )
+ }
+
+ private func configureStableStorage(
+ generation: Int,
+ keepShelfOpen: Bool
+ ) {
+ if let stableStorageLength {
+ if abs(storageAnchor.length - stableStorageLength) > 1 {
+ storageAnchor.length = stableStorageLength
+ refreshScannerExclusions()
+ }
+ repositionShelfIfNeeded(keepOpen: keepShelfOpen)
+ return
+ }
+
+ // Size the parking lane once per display environment. Recomputing it
+ // from every transient frame is the feedback loop that made the menu
+ // bar walk and blink.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) { [weak self] in
+ guard
+ let self,
+ generation == self.storageUpdateGeneration,
+ let windowID = self.windowID(for: self.storageAnchor),
+ let anchorFrame = PrivateWindowServer.frame(of: windowID),
+ let screen = self.storageAnchor.button?.window?.screen ?? NSScreen.main
+ else { return }
+
+ let length = min(
+ max(
+ self.collapsedStorageLength,
+ anchorFrame.maxX - screen.frame.minX + 8
+ ),
+ screen.frame.width + 8
+ )
+ self.stableStorageLength = length
+ if abs(self.storageAnchor.length - length) > 1 {
+ self.storageAnchor.length = length
+ self.refreshScannerExclusions()
+ }
+ self.repositionShelfIfNeeded(keepOpen: keepShelfOpen)
+ }
+ }
+
+ private func repositionShelfIfNeeded(keepOpen: Bool) {
+ guard keepOpen, let button = statusItem.button else { return }
+ shelfPanel?.show(relativeTo: button)
+ }
+
+ private func configureStatusItems() {
+ // AppKit updates this preference whenever neighboring status items are
+ // reordered. That can strand Barr beneath the notch on the next launch,
+ // leaving its panel visible with no reachable control. Barr is the
+ // gateway to every parked item, so restore it to the highest visible
+ // status-item priority every time the process starts.
+ setPreferredPosition(0, autosaveName: "BarrControl", force: true)
+ // Keep this variable-length even though the release control contains
+ // only an icon. On macOS 26, a square-length status item can disappear
+ // when Control Center re-hosts its remote status-item scene after launch.
+ statusItem = NSStatusBar.system.statusItem(
+ withLength: NSStatusItem.variableLength
+ )
+ statusItem.autosaveName = "BarrControl"
+#if DEBUG
+ statusItem.button?.image = NSImage(
+ systemSymbolName: "ladybug.fill",
+ accessibilityDescription: "Barr debug build"
+ )
+ statusItem.button?.title = " DEBUG"
+ statusItem.button?.imagePosition = .imageLeading
+ statusItem.button?.font = .systemFont(ofSize: 10, weight: .bold)
+ statusItem.button?.toolTip = "Barr — Debug Build"
+#else
+ statusItem.button?.image = NSImage(
+ systemSymbolName: "line.3.horizontal",
+ accessibilityDescription: "Barr overflow shelf"
+ )
+ statusItem.button?.toolTip = "Barr"
+#endif
+ statusItem.button?.image?.isTemplate = true
+ statusItem.button?.target = self
+ statusItem.button?.action = #selector(statusItemPressed(_:))
+ statusItem.button?.sendAction(on: [.leftMouseDown, .rightMouseUp])
+
+ // A far-left parking boundary. It remains zero-width until at least one
+ // explicitly selected item has been moved to its left.
+ setPreferredPosition(1_000_000_000, autosaveName: "BarrStorageAnchor", force: true)
+ storageAnchor = NSStatusBar.system.statusItem(withLength: collapsedStorageLength)
+ storageAnchor.autosaveName = "BarrStorageAnchor"
+ if let storageButton = storageAnchor.button {
+ storageButton.image = nil
+ storageButton.title = ""
+ storageButton.toolTip = nil
+ storageButton.isEnabled = false
+ storageButton.alphaValue = 0
+ storageButton.setAccessibilityElement(false)
+ }
+ }
+
+ private func membershipAnchor(
+ for item: MenuBarItem,
+ moveToBarr: Bool
+ ) -> (CGWindowID, CGRect)? {
+ if moveToBarr {
+ guard
+ let windowID = windowID(for: storageAnchor),
+ let frame = PrivateWindowServer.frame(of: windowID)
+ else { return nil }
+ return (windowID, frame)
+ }
+
+ if
+ let neighbor = model.returnAnchor(for: item),
+ PrivateWindowServer.menuBarWindowIDs().contains(neighbor.windowID),
+ let frame = PrivateWindowServer.frame(of: neighbor.windowID)
+ {
+ return (neighbor.windowID, frame)
+ }
+
+ if
+ let windowID = windowID(for: statusItem),
+ let frame = PrivateWindowServer.frame(of: windowID)
+ {
+ return (windowID, frame)
+ }
+
+ guard
+ let windowID = windowID(for: storageAnchor),
+ let frame = PrivateWindowServer.frame(of: windowID)
+ else { return nil }
+ return (windowID, frame)
+ }
+
+ private func refreshScannerExclusions() {
+ let windowIDs = [windowID(for: statusItem), windowID(for: storageAnchor)]
+ .compactMap { $0 }
+ MenuBarScanner.setExcludedWindowIDs(Set(windowIDs))
+ }
+
+ private func setPreferredPosition(
+ _ position: CGFloat,
+ autosaveName: String,
+ force: Bool = false
+ ) {
+ let key = "NSStatusItem Preferred Position \(autosaveName)"
+ if force || UserDefaults.standard.object(forKey: key) == nil {
+ UserDefaults.standard.set(position, forKey: key)
+ }
+ }
+
+ private func windowID(for item: NSStatusItem?) -> CGWindowID? {
+ guard
+ let item,
+ let button = item.button,
+ let window = button.window
+ else { return nil }
+
+ let number = window.windowNumber
+ if
+ number > 0,
+ let windowID = CGWindowID(exactly: number),
+ PrivateWindowServer.frame(of: windowID) != nil
+ {
+ if let autosaveName = item.autosaveName {
+ resolvedStatusWindowIDs[autosaveName] = windowID
+ }
+ return windowID
+ }
+
+ // Tahoe hosts status items in another process, so NSWindow.windowNumber
+ // can be -1. Resolve our item from its geometry on the display that
+ // actually hosts the AppKit status button.
+ let buttonFrame = window.convertToScreen(button.convert(button.bounds, to: nil))
+ let displayBounds = (window.screen ?? NSScreen.main).flatMap(
+ StatusWindowGeometry.quartzDisplayBounds
+ )
+ let liveWindowIDs = PrivateWindowServer.menuBarWindowIDs()
+
+ if
+ let autosaveName = item.autosaveName,
+ let cachedWindowID = resolvedStatusWindowIDs[autosaveName],
+ liveWindowIDs.contains(cachedWindowID),
+ let cachedFrame = PrivateWindowServer.frame(of: cachedWindowID),
+ StatusWindowGeometry.matches(
+ frame: cachedFrame,
+ buttonFrame: buttonFrame,
+ displayBounds: displayBounds
+ )
+ {
+ return cachedWindowID
+ }
+
+ let match = liveWindowIDs
+ .compactMap { windowID -> (CGWindowID, CGFloat)? in
+ guard
+ let frame = PrivateWindowServer.frame(of: windowID),
+ frame.width > 0,
+ StatusWindowGeometry.matches(
+ frame: frame,
+ buttonFrame: buttonFrame,
+ displayBounds: displayBounds
+ )
+ else {
+ return nil
+ }
+ let score =
+ abs(frame.midX - buttonFrame.midX) +
+ abs(frame.width - buttonFrame.width) * 0.5
+ return (windowID, score)
+ }
+ .min { $0.1 < $1.1 }?
+ .0
+
+ if let autosaveName = item.autosaveName {
+ resolvedStatusWindowIDs[autosaveName] = match
+ }
+ return match
+ }
+
+ @objc private func statusItemPressed(_ sender: Any?) {
+ guard let event = NSApp.currentEvent else {
+ toggleShelf()
+ return
+ }
+ if event.type == .rightMouseUp || event.modifierFlags.contains(.control) {
+ showContextMenu()
+ } else {
+ toggleShelf()
+ }
+ }
+
+ private func toggleShelf() {
+ shelfPanel?.isVisible == true ? closeShelf() : showShelf()
+ }
+
+ private func showShelf(attempt: Int = 0, refreshItems: Bool = true) {
+ if attempt == 0, refreshItems {
+ refreshScannerExclusions()
+ model.refreshLoginItemStatus()
+ model.refresh()
+ }
+ guard let button = statusItem.button, button.window != nil else {
+ retryShowingShelf(after: attempt)
+ return
+ }
+ let shelfPanel = shelfPanel ?? {
+ let panel = ShelfPanel(model: model)
+ self.shelfPanel = panel
+ return panel
+ }()
+ if shelfPanel.show(relativeTo: button) {
+ installShelfDismissMonitors()
+ } else {
+ removeShelfDismissMonitors()
+ retryShowingShelf(after: attempt)
+ }
+ }
+
+ private func retryShowingShelf(after attempt: Int) {
+ guard attempt < 10 else { return }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
+ self?.showShelf(attempt: attempt + 1, refreshItems: false)
+ }
+ }
+
+ private func closeShelf() {
+ shelfPanel?.close()
+ removeShelfDismissMonitors()
+ }
+
+ private func installShelfDismissMonitors() {
+ removeShelfDismissMonitors()
+ shelfGlobalDismissMonitor = NSEvent.addGlobalMonitorForEvents(
+ matching: [.leftMouseDown, .rightMouseDown]
+ ) { [weak self] _ in
+ DispatchQueue.main.async {
+ self?.closeShelf()
+ }
+ }
+ shelfLocalDismissMonitor = NSEvent.addLocalMonitorForEvents(
+ matching: [.leftMouseDown, .rightMouseDown, .keyDown]
+ ) { [weak self] event in
+ guard let self else { return event }
+ if event.type == .keyDown, event.keyCode == 53 {
+ self.closeShelf()
+ } else if
+ event.type != .keyDown,
+ event.window !== self.shelfPanel,
+ event.window !== self.statusItem.button?.window
+ {
+ self.closeShelf()
+ }
+ return event
+ }
+ }
+
+ private func removeShelfDismissMonitors() {
+ if let shelfGlobalDismissMonitor {
+ NSEvent.removeMonitor(shelfGlobalDismissMonitor)
+ self.shelfGlobalDismissMonitor = nil
+ }
+ if let shelfLocalDismissMonitor {
+ NSEvent.removeMonitor(shelfLocalDismissMonitor)
+ self.shelfLocalDismissMonitor = nil
+ }
+ }
+
+ private func showContextMenu() {
+ model.refreshLoginItemStatus()
+ let menu = NSMenu()
+ menu.addItem(withTitle: "Refresh icons", action: #selector(refresh), keyEquivalent: "r").target = self
+ menu.addItem(.separator())
+ let openAtLoginItem = menu.addItem(
+ withTitle: "Open at Login",
+ action: #selector(toggleOpenAtLogin),
+ keyEquivalent: ""
+ )
+ openAtLoginItem.target = self
+ openAtLoginItem.state = model.opensAtLogin ? .on : .off
+ menu.addItem(.separator())
+ menu.addItem(withTitle: "Screen Recording settings…", action: #selector(openScreenRecordingSettings), keyEquivalent: "").target = self
+ menu.addItem(withTitle: "Accessibility settings…", action: #selector(openAccessibilitySettings), keyEquivalent: "").target = self
+ menu.addItem(.separator())
+ menu.addItem(withTitle: "Quit Barr", action: #selector(quit), keyEquivalent: "q").target = self
+ statusItem.menu = menu
+ statusItem.button?.performClick(nil)
+ statusItem.menu = nil
+ }
+
+ @objc private func environmentChanged(_ notification: Notification) {
+ returnHiddenItemNow()
+ closeShelf()
+ resolvedStatusWindowIDs.removeAll()
+
+ // Moving between Spaces does not change the storage geometry. Reusing
+ // the captured length avoids a needless collapse/expand flash. A real
+ // display topology change already rebuilds the system menu bar, so
+ // reset to the narrow boundary and capture one new stable length after
+ // that transition settles.
+ if notification.name == NSApplication.didChangeScreenParametersNotification {
+ stableStorageLength = nil
+ storageUpdateGeneration += 1
+ storageAnchor.length = collapsedStorageLength
+ refreshScannerExclusions()
+ }
+ scheduleEnvironmentRefresh(delays: [0.15, 0.65])
+ }
+
+ @objc private func foregroundApplicationChanged(_ notification: Notification) {
+ let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey]
+ as? NSRunningApplication
+ let bundleIdentifier = application?.bundleIdentifier
+ switch application?.bundleIdentifier {
+ case "com.cursorkittens.Barr", "com.cursorkittens.Barr.debug":
+ return
+ default:
+ break
+ }
+
+ guard bundleIdentifier != foregroundBundleIdentifier else { return }
+ foregroundBundleIdentifier = bundleIdentifier
+
+ // The foreground app controls the width of the native application-menu
+ // lane. If a revealed item opened an app-owned window, keep its status
+ // item in place until that interface closes or loses focus.
+ if pendingReturn != nil {
+ scheduleReturnCheck(after: 0.2)
+ } else {
+ returnHiddenItemNow()
+ }
+ closeShelf()
+ scheduleEnvironmentRefresh(delays: [0.2])
+ }
+
+ private func scheduleEnvironmentRefresh(delays: [TimeInterval]) {
+ environmentRefreshGeneration += 1
+ let generation = environmentRefreshGeneration
+ for delay in delays {
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
+ guard
+ let self,
+ generation == self.environmentRefreshGeneration
+ else { return }
+ self.refreshScannerExclusions()
+ self.model.refresh(captureImages: false)
+ }
+ }
+ }
+
+ @objc private func runningApplicationsChanged(_ notification: Notification) {
+ let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey]
+ as? NSRunningApplication
+ if let application {
+ switch application.bundleIdentifier {
+ case "com.cursorkittens.Barr", "com.cursorkittens.Barr.debug":
+ return
+ default:
+ break
+ }
+ if
+ notification.name == NSWorkspace.didLaunchApplicationNotification,
+ application.activationPolicy == .prohibited
+ {
+ return
+ }
+ if
+ notification.name == NSWorkspace.didTerminateApplicationNotification,
+ !model.containsItem(ownedBy: application.processIdentifier)
+ {
+ return
+ }
+ }
+
+ runningApplicationsGeneration += 1
+ let generation = runningApplicationsGeneration
+ let delays = notification.name == NSWorkspace.didTerminateApplicationNotification
+ ? [0.4]
+ : [0.6, 2.0]
+ for delay in delays {
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
+ guard
+ let self,
+ generation == self.runningApplicationsGeneration
+ else { return }
+ self.refreshScannerExclusions()
+ self.model.refresh(captureImages: self.shelfPanel?.isVisible == true)
+ }
+ }
+ }
+
+ @objc private func refresh() {
+ model.refresh()
+ }
+
+ @objc private func toggleOpenAtLogin() {
+ model.setOpensAtLogin(!model.opensAtLogin)
+ }
+
+ @objc private func openScreenRecordingSettings() {
+ PermissionCenter.openScreenRecordingSettings()
+ }
+
+ @objc private func openAccessibilitySettings() {
+ PermissionCenter.openAccessibilitySettings()
+ }
+
+ @objc private func quit() {
+ NSApp.terminate(nil)
+ }
+}
diff --git a/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..74b3caf
--- /dev/null
+++ b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ { "idiom" : "mac", "scale" : "1x", "size" : "16x16", "filename" : "icon_16.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "16x16", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "32x32", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "32x32", "filename" : "icon_64.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "128x128", "filename" : "icon_128.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "128x128", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "256x256", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "256x256", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "512x512", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "512x512", "filename" : "icon_1024.png" }
+ ],
+ "info" : { "author" : "xcode", "version" : 1 }
+}
diff --git a/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png
new file mode 100644
index 0000000..c7d6622
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ
diff --git a/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png
new file mode 100644
index 0000000..9f792bb
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png differ
diff --git a/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png
new file mode 100644
index 0000000..5982568
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png differ
diff --git a/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png
new file mode 100644
index 0000000..ab453cb
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png differ
diff --git a/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png
new file mode 100644
index 0000000..f211785
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png differ
diff --git a/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png
new file mode 100644
index 0000000..3a33a06
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png differ
diff --git a/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png
new file mode 100644
index 0000000..3ddfca6
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png differ
diff --git a/barr/app/Sources/Assets.xcassets/Contents.json b/barr/app/Sources/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..d8b757a
--- /dev/null
+++ b/barr/app/Sources/Assets.xcassets/Contents.json
@@ -0,0 +1,3 @@
+{
+ "info" : { "author" : "xcode", "version" : 1 }
+}
diff --git a/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/Contents.json b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..74b3caf
--- /dev/null
+++ b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ { "idiom" : "mac", "scale" : "1x", "size" : "16x16", "filename" : "icon_16.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "16x16", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "32x32", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "32x32", "filename" : "icon_64.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "128x128", "filename" : "icon_128.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "128x128", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "256x256", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "256x256", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "512x512", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "512x512", "filename" : "icon_1024.png" }
+ ],
+ "info" : { "author" : "xcode", "version" : 1 }
+}
diff --git a/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_1024.png b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_1024.png
new file mode 100644
index 0000000..c19201f
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_1024.png differ
diff --git a/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_128.png b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_128.png
new file mode 100644
index 0000000..92df1b6
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_128.png differ
diff --git a/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_16.png b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_16.png
new file mode 100644
index 0000000..0d01e52
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_16.png differ
diff --git a/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_256.png b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_256.png
new file mode 100644
index 0000000..e530293
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_256.png differ
diff --git a/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_32.png b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_32.png
new file mode 100644
index 0000000..b3ff4cb
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_32.png differ
diff --git a/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_512.png b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_512.png
new file mode 100644
index 0000000..fa30f2b
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_512.png differ
diff --git a/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_64.png b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_64.png
new file mode 100644
index 0000000..e4e8a6d
Binary files /dev/null and b/barr/app/Sources/Assets.xcassets/DebugAppIcon.appiconset/icon_64.png differ
diff --git a/barr/app/Sources/BarrApp.swift b/barr/app/Sources/BarrApp.swift
new file mode 100644
index 0000000..e380d8d
--- /dev/null
+++ b/barr/app/Sources/BarrApp.swift
@@ -0,0 +1,14 @@
+import AppKit
+
+@main
+enum BarrApplication {
+ @MainActor private static let delegate = AppDelegate()
+
+ @MainActor
+ static func main() {
+ let application = NSApplication.shared
+ application.delegate = delegate
+ application.setActivationPolicy(.accessory)
+ application.run()
+ }
+}
diff --git a/barr/app/Sources/MenuBar/MenuBarActivator.swift b/barr/app/Sources/MenuBar/MenuBarActivator.swift
new file mode 100644
index 0000000..1eb29c6
--- /dev/null
+++ b/barr/app/Sources/MenuBar/MenuBarActivator.swift
@@ -0,0 +1,180 @@
+import AppKit
+import ApplicationServices
+
+enum MenuBarActivator {
+ static func activate(_ item: MenuBarItem) -> Bool {
+ guard PermissionCenter.isAccessibilityGranted else {
+ PermissionCenter.requestAccessibility()
+ return false
+ }
+
+ let application = AXUIElementCreateApplication(item.ownerPID)
+ guard let extrasMenu = elementAttribute(application, kAXExtrasMenuBarAttribute as CFString) else {
+ return fallbackClick(item)
+ }
+
+ let elements = descendants(of: extrasMenu, maximumDepth: 4)
+ let identityMatch = elements
+ .compactMap { element -> (AXUIElement, Int)? in
+ identityScore(for: element, item: item).map { (element, $0) }
+ }
+ .min { $0.1 < $1.1 }?
+ .0
+
+ if
+ let identityMatch,
+ AXUIElementPerformAction(identityMatch, kAXPressAction as CFString) == .success
+ {
+ return true
+ }
+
+ let matching = elements
+ .compactMap { element -> (AXUIElement, CGFloat)? in
+ guard let frame = frame(of: element) else { return nil }
+ let dx = frame.midX - item.frame.midX
+ let dy = frame.midY - item.frame.midY
+ let distance = hypot(dx, dy)
+ let overlaps = frame.intersects(item.frame) || distance < max(item.frame.width, 30)
+ return overlaps ? (element, distance) : nil
+ }
+ .min { $0.1 < $1.1 }?.0
+
+ if let matching, AXUIElementPerformAction(matching, kAXPressAction as CFString) == .success {
+ return true
+ }
+ return fallbackClick(item)
+ }
+
+ private static func identityScore(for element: AXUIElement, item: MenuBarItem) -> Int? {
+ let stableIdentifier = item.stableIdentifier?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let identifier = stringAttribute(element, kAXIdentifierAttribute as CFString)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ if
+ let stableIdentifier,
+ !stableIdentifier.isEmpty,
+ identifier?.localizedCaseInsensitiveCompare(stableIdentifier) == .orderedSame
+ {
+ return 0
+ }
+
+ let labels = [
+ kAXTitleAttribute,
+ kAXDescriptionAttribute,
+ kAXHelpAttribute
+ ]
+ .compactMap { stringAttribute(element, $0 as CFString) }
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+
+ let identities = [stableIdentifier, item.title]
+ .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+
+ if labels.contains(where: { label in
+ identities.contains {
+ label.localizedCaseInsensitiveCompare($0) == .orderedSame
+ }
+ }) {
+ return 1
+ }
+
+ if labels.contains(where: { label in
+ identities.contains {
+ label.localizedCaseInsensitiveContains($0) ||
+ $0.localizedCaseInsensitiveContains(label)
+ }
+ }) {
+ return 2
+ }
+ return nil
+ }
+
+ private static func fallbackClick(_ item: MenuBarItem) -> Bool {
+ guard item.isOnScreen else { return false }
+ guard let originalCursorPosition = CGEvent(source: nil)?.location else { return false }
+ let point = CGPoint(x: item.frame.midX, y: item.frame.midY)
+ guard
+ let down = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left),
+ let up = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left)
+ else {
+ return false
+ }
+ down.post(tap: .cghidEventTap)
+ up.post(tap: .cghidEventTap)
+
+ // Posting a mouse event at another point also moves the global cursor.
+ // AXPress normally avoids that, but this fallback is required for menu
+ // extras that do not expose a pressable accessibility element. Put the
+ // user's pointer back immediately so activating an item from Barr does
+ // not make it appear to vanish at a display edge.
+ Thread.sleep(forTimeInterval: 0.03)
+ CGWarpMouseCursorPosition(originalCursorPosition)
+ return true
+ }
+
+ private static func descendants(of root: AXUIElement, maximumDepth: Int) -> [AXUIElement] {
+ guard maximumDepth > 0 else { return [root] }
+ let children = elementArrayAttribute(root, kAXChildrenAttribute as CFString)
+ return [root] + children.flatMap { descendants(of: $0, maximumDepth: maximumDepth - 1) }
+ }
+
+ private static func elementAttribute(_ element: AXUIElement, _ attribute: CFString) -> AXUIElement? {
+ var value: CFTypeRef?
+ guard
+ AXUIElementCopyAttributeValue(element, attribute, &value) == .success,
+ let value,
+ CFGetTypeID(value) == AXUIElementGetTypeID()
+ else {
+ return nil
+ }
+ return unsafeDowncast(value as AnyObject, to: AXUIElement.self)
+ }
+
+ private static func elementArrayAttribute(_ element: AXUIElement, _ attribute: CFString) -> [AXUIElement] {
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success else { return [] }
+ return value as? [AXUIElement] ?? []
+ }
+
+ private static func frame(of element: AXUIElement) -> CGRect? {
+ guard
+ let positionValue = valueAttribute(element, kAXPositionAttribute as CFString),
+ let sizeValue = valueAttribute(element, kAXSizeAttribute as CFString),
+ AXValueGetType(positionValue) == .cgPoint,
+ AXValueGetType(sizeValue) == .cgSize
+ else {
+ return nil
+ }
+ var position = CGPoint.zero
+ var size = CGSize.zero
+ guard
+ AXValueGetValue(positionValue, .cgPoint, &position),
+ AXValueGetValue(sizeValue, .cgSize, &size)
+ else {
+ return nil
+ }
+ return CGRect(origin: position, size: size)
+ }
+
+ private static func valueAttribute(_ element: AXUIElement, _ attribute: CFString) -> AXValue? {
+ var value: CFTypeRef?
+ guard
+ AXUIElementCopyAttributeValue(element, attribute, &value) == .success,
+ let value,
+ CFGetTypeID(value) == AXValueGetTypeID()
+ else {
+ return nil
+ }
+ return unsafeDowncast(value as AnyObject, to: AXValue.self)
+ }
+
+ private static func stringAttribute(_ element: AXUIElement, _ attribute: CFString) -> String? {
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success else {
+ return nil
+ }
+ return value as? String
+ }
+}
diff --git a/barr/app/Sources/MenuBar/MenuBarItem.swift b/barr/app/Sources/MenuBar/MenuBarItem.swift
new file mode 100644
index 0000000..3a2f53c
--- /dev/null
+++ b/barr/app/Sources/MenuBar/MenuBarItem.swift
@@ -0,0 +1,162 @@
+import AppKit
+import CoreGraphics
+
+struct MenuBarItem: Identifiable {
+ static let visualIconHeight: CGFloat = 24
+ static let iconHitTarget: CGFloat = 32
+
+ let windowID: CGWindowID
+ let ownerPID: pid_t
+ let ownerName: String
+ let bundleIdentifier: String?
+ let title: String?
+ let stableIdentifier: String?
+ let frame: CGRect
+ let isOnScreen: Bool
+ let image: NSImage?
+ let storageKey: String
+ let legacyStorageKey: String
+ private let normalizedIdentity: String
+
+ init(
+ windowID: CGWindowID,
+ ownerPID: pid_t,
+ ownerName: String,
+ bundleIdentifier: String?,
+ title: String?,
+ stableIdentifier: String?,
+ frame: CGRect,
+ isOnScreen: Bool,
+ image: NSImage?
+ ) {
+ self.windowID = windowID
+ self.ownerPID = ownerPID
+ self.ownerName = ownerName
+ self.bundleIdentifier = bundleIdentifier
+ self.title = title
+ self.stableIdentifier = stableIdentifier
+ self.frame = frame
+ self.isOnScreen = isOnScreen
+ self.image = image
+ storageKey = [bundleIdentifier ?? ownerName, stableIdentifier ?? title ?? ownerName]
+ .joined(separator: "|")
+ legacyStorageKey = [bundleIdentifier ?? ownerName, title ?? ownerName]
+ .joined(separator: "|")
+ normalizedIdentity = [stableIdentifier, title]
+ .compactMap { $0 }
+ .joined(separator: " ")
+ .lowercased()
+ }
+
+ // WindowServer can replace or re-parent a status item's window while Barr
+ // moves it. Its logical identity stays stable across those transitions.
+ var id: String { storageKey }
+
+ var displayName: String {
+ guard let title, !title.isEmpty else { return ownerName }
+ if ownerName == "Control Center" { return title }
+ return ownerName
+ }
+
+ var isSystemItem: Bool {
+ bundleIdentifier == "com.apple.controlcenter" ||
+ bundleIdentifier == "com.apple.systemuiserver"
+ }
+
+ var isMovableByBarr: Bool {
+ guard isSystemItem else { return true }
+
+ // These are permanent macOS menu-bar surfaces rather than movable
+ // status items. In particular, the clock owns Notification Center and
+ // Apple documents that it is always present.
+ return !normalizedIdentity.contains("control center") &&
+ !normalizedIdentity.contains("clock") &&
+ !normalizedIdentity.contains("audio and video controls")
+ }
+
+ var systemSymbolName: String? {
+ guard isSystemItem else { return nil }
+
+ if normalizedIdentity.contains("screen mirroring") ||
+ normalizedIdentity.contains("airplay")
+ {
+ return "rectangle.on.rectangle"
+ }
+ if normalizedIdentity.contains("keyboard brightness") { return "sun.max.fill" }
+ if normalizedIdentity.contains("display") { return "display" }
+ if normalizedIdentity.contains("bluetooth") { return "antenna.radiowaves.left.and.right" }
+ if normalizedIdentity.contains("battery") { return "battery.100" }
+ if normalizedIdentity.contains("spotlight") { return "magnifyingglass" }
+ if normalizedIdentity.contains("sound") || normalizedIdentity.contains("volume") {
+ return "speaker.wave.2.fill"
+ }
+ if
+ normalizedIdentity.contains("wi-fi") ||
+ normalizedIdentity.contains("wi‑fi") ||
+ normalizedIdentity.contains("airport")
+ {
+ return "wifi"
+ }
+ if normalizedIdentity.contains("control center") { return "switch.2" }
+ if normalizedIdentity.contains("clock") { return "clock" }
+ if normalizedIdentity.contains("now playing") ||
+ normalizedIdentity.contains("audio and video")
+ {
+ return "video.fill"
+ }
+ if normalizedIdentity.contains("focus") { return "moon.fill" }
+ if normalizedIdentity.contains("time machine") { return "clock.arrow.circlepath" }
+ if normalizedIdentity.contains("user switching") { return "person.crop.circle" }
+ if normalizedIdentity.contains("airdrop") { return "dot.radiowaves.left.and.right" }
+ return nil
+ }
+
+ var renderedIconWidth: CGFloat {
+ guard systemSymbolName == nil, let image, image.size.height > 0 else {
+ return Self.visualIconHeight
+ }
+ let aspectRatio = min(max(image.size.width / image.size.height, 0.7), 2.6)
+ return min(max(Self.visualIconHeight * aspectRatio, 18), 58)
+ }
+
+ var logicalWidth: CGFloat {
+ max(renderedIconWidth, Self.iconHitTarget)
+ }
+
+ func replacingImage(_ image: NSImage) -> MenuBarItem {
+ MenuBarItem(
+ windowID: windowID,
+ ownerPID: ownerPID,
+ ownerName: ownerName,
+ bundleIdentifier: bundleIdentifier,
+ title: title,
+ stableIdentifier: stableIdentifier,
+ frame: frame,
+ isOnScreen: isOnScreen,
+ image: image
+ )
+ }
+
+ func hasSameContent(as other: MenuBarItem) -> Bool {
+ windowID == other.windowID &&
+ ownerPID == other.ownerPID &&
+ ownerName == other.ownerName &&
+ bundleIdentifier == other.bundleIdentifier &&
+ title == other.title &&
+ stableIdentifier == other.stableIdentifier &&
+ frame == other.frame &&
+ isOnScreen == other.isOnScreen &&
+ imagesAreIdentical(image, other.image)
+ }
+
+ private func imagesAreIdentical(_ lhs: NSImage?, _ rhs: NSImage?) -> Bool {
+ switch (lhs, rhs) {
+ case (nil, nil):
+ return true
+ case let (lhs?, rhs?):
+ return lhs === rhs
+ default:
+ return false
+ }
+ }
+}
diff --git a/barr/app/Sources/MenuBar/MenuBarItemIdentity.swift b/barr/app/Sources/MenuBar/MenuBarItemIdentity.swift
new file mode 100644
index 0000000..5b439f1
--- /dev/null
+++ b/barr/app/Sources/MenuBar/MenuBarItemIdentity.swift
@@ -0,0 +1,43 @@
+import Foundation
+
+enum MenuBarItemIdentity {
+ static func disambiguatedStableIdentifiers(
+ rawIdentifiers: [String?],
+ titles: [String?]
+ ) -> [String] {
+ precondition(rawIdentifiers.count == titles.count)
+
+ let candidates = zip(rawIdentifiers, titles).map { rawIdentifier, title in
+ firstNonempty(rawIdentifier, title)
+ }
+ let totals = candidates.reduce(into: [String: Int]()) { result, candidate in
+ guard let candidate else { return }
+ result[normalized(candidate), default: 0] += 1
+ }
+ var occurrences = [String: Int]()
+ var unnamedOccurrence = 0
+
+ return candidates.map { candidate in
+ guard let candidate else {
+ unnamedOccurrence += 1
+ return "barr-unnamed-item-\(unnamedOccurrence)"
+ }
+
+ let key = normalized(candidate)
+ guard totals[key, default: 0] > 1 else { return candidate }
+ occurrences[key, default: 0] += 1
+ return "\(candidate)#\(occurrences[key, default: 0])"
+ }
+ }
+
+ private static func firstNonempty(_ values: String?...) -> String? {
+ values.first {
+ guard let value = $0 else { return false }
+ return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ } ?? nil
+ }
+
+ private static func normalized(_ value: String) -> String {
+ value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ }
+}
diff --git a/barr/app/Sources/MenuBar/MenuBarMoveGeometry.swift b/barr/app/Sources/MenuBar/MenuBarMoveGeometry.swift
new file mode 100644
index 0000000..55e29fc
--- /dev/null
+++ b/barr/app/Sources/MenuBar/MenuBarMoveGeometry.swift
@@ -0,0 +1,24 @@
+import CoreGraphics
+
+enum MenuBarMoveGeometry {
+ /// WindowServer inserts a status item on the side of the destination point.
+ /// A point inside the anchor can be normalized to either edge, so always
+ /// request the point immediately to its left.
+ static func pointImmediatelyLeft(of anchorFrame: CGRect) -> CGPoint {
+ CGPoint(x: anchorFrame.minX - 1, y: anchorFrame.midY)
+ }
+
+ static func preparedAnchorLength(
+ currentLength: CGFloat,
+ anchorFrame: CGRect,
+ screenFrame: CGRect,
+ collapsedLength: CGFloat
+ ) -> CGFloat? {
+ let insertionX = screenFrame.minX + 8
+ guard anchorFrame.minX < insertionX else { return nil }
+ return max(
+ collapsedLength,
+ currentLength - (insertionX - anchorFrame.minX)
+ )
+ }
+}
diff --git a/barr/app/Sources/MenuBar/MenuBarMover.swift b/barr/app/Sources/MenuBar/MenuBarMover.swift
new file mode 100644
index 0000000..2e58482
--- /dev/null
+++ b/barr/app/Sources/MenuBar/MenuBarMover.swift
@@ -0,0 +1,111 @@
+import AppKit
+import CoreGraphics
+
+enum MenuBarMover {
+ private static let targetedWindowField = CGEventField(rawValue: 0x33)
+ private static let offscreenStartPoint = CGPoint(x: 20_000, y: 20_000)
+ private static let moveLock = NSLock()
+
+ /// Reorders a status item with the WindowServer-routed event sequence used
+ /// by established menu-bar managers. The offscreen mouse-down is
+ /// intentional: on Tahoe, using the user's current cursor position makes
+ /// the result depend on where the pointer happens to be.
+ static func move(
+ windowID: CGWindowID,
+ sourcePID: pid_t,
+ beside anchorWindowID: CGWindowID,
+ at targetPoint: CGPoint
+ ) -> Bool {
+ moveLock.lock()
+ defer { moveLock.unlock() }
+
+ guard let targetedWindowField else { return false }
+ guard let source = CGEventSource(stateID: .hidSystemState) else { return false }
+ guard let originalCursorPosition = CGEvent(source: nil)?.location else { return false }
+ let permitted: CGEventFilterMask = [
+ .permitLocalMouseEvents,
+ .permitLocalKeyboardEvents,
+ .permitSystemDefinedEvents
+ ]
+ source.setLocalEventsFilterDuringSuppressionState(permitted, state: .eventSuppressionStateRemoteMouseDrag)
+ source.setLocalEventsFilterDuringSuppressionState(permitted, state: .eventSuppressionStateSuppressionInterval)
+ source.localEventsSuppressionInterval = 0
+
+ guard
+ let down = event(
+ source: source,
+ type: .leftMouseDown,
+ point: offscreenStartPoint,
+ windowID: windowID,
+ targetPID: sourcePID,
+ targetedWindowField: targetedWindowField,
+ flags: .maskCommand
+ ),
+ let up = event(
+ source: source,
+ type: .leftMouseUp,
+ point: targetPoint,
+ windowID: anchorWindowID,
+ targetPID: sourcePID,
+ targetedWindowField: targetedWindowField,
+ flags: []
+ )
+ else { return false }
+
+ let cursorDisplay = display(containing: originalCursorPosition)
+ let cursorWasHidden =
+ CGDisplayHideCursor(cursorDisplay) == .success
+ defer {
+ // CGEventPost queues the release in WindowServer. Restore only
+ // after that queue has drained, then balance the cursor hide even
+ // if a future early return is added above.
+ Thread.sleep(forTimeInterval: 0.06)
+ CGWarpMouseCursorPosition(originalCursorPosition)
+ if cursorWasHidden {
+ CGDisplayShowCursor(cursorDisplay)
+ }
+ }
+
+ down.post(tap: .cgSessionEventTap)
+ Thread.sleep(forTimeInterval: 0.08)
+ up.post(tap: .cgSessionEventTap)
+ return true
+ }
+
+ private static func display(containing point: CGPoint) -> CGDirectDisplayID {
+ var displayID = CGMainDisplayID()
+ var count: UInt32 = 0
+ guard
+ CGGetDisplaysWithPoint(point, 1, &displayID, &count) == .success,
+ count > 0
+ else {
+ return CGMainDisplayID()
+ }
+ return displayID
+ }
+
+ private static func event(
+ source: CGEventSource,
+ type: CGEventType,
+ point: CGPoint,
+ windowID: CGWindowID,
+ targetPID: pid_t,
+ targetedWindowField: CGEventField,
+ flags: CGEventFlags
+ ) -> CGEvent? {
+ guard let event = CGEvent(
+ mouseEventSource: source,
+ mouseType: type,
+ mouseCursorPosition: point,
+ mouseButton: .left
+ ) else { return nil }
+
+ event.flags = flags
+ event.setIntegerValueField(.eventTargetUnixProcessID, value: Int64(targetPID))
+ event.setIntegerValueField(.eventSourceUserData, value: Int64(truncatingIfNeeded: UInt64(mach_absolute_time())))
+ event.setIntegerValueField(.mouseEventWindowUnderMousePointer, value: Int64(windowID))
+ event.setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: Int64(windowID))
+ event.setIntegerValueField(targetedWindowField, value: Int64(windowID))
+ return event
+ }
+}
diff --git a/barr/app/Sources/MenuBar/MenuBarScanner.swift b/barr/app/Sources/MenuBar/MenuBarScanner.swift
new file mode 100644
index 0000000..f30d17e
--- /dev/null
+++ b/barr/app/Sources/MenuBar/MenuBarScanner.swift
@@ -0,0 +1,495 @@
+import AppKit
+import CoreGraphics
+
+enum MenuBarScanner {
+ private static let exclusionLock = NSLock()
+ private static let scanLock = NSLock()
+ private nonisolated(unsafe) static var excludedWindowIDs = Set()
+ private nonisolated(unsafe) static var windowIDBySourceKey = [String: CGWindowID]()
+ private nonisolated(unsafe) static var applicationIconByKey = [String: NSImage]()
+ private nonisolated(unsafe) static var noStatusItemCheckedAt = [pid_t: TimeInterval]()
+ // Rapid verification scans should not repeat a potentially blocking AX
+ // query for every ordinary app. One second still lets the delayed launch
+ // refresh discover status items that initialize asynchronously.
+ private static let negativeStatusItemCacheDuration: TimeInterval = 1
+
+ static func setExcludedWindowIDs(_ windowIDs: Set) {
+ exclusionLock.lock()
+ if excludedWindowIDs != windowIDs {
+ excludedWindowIDs = windowIDs
+ }
+ exclusionLock.unlock()
+ }
+
+ static func scan(captureImages: Bool = true) -> [MenuBarItem] {
+ scanLock.lock()
+ defer { scanLock.unlock() }
+ let ownPID = ProcessInfo.processInfo.processIdentifier
+ exclusionLock.lock()
+ let excluded = excludedWindowIDs
+ exclusionLock.unlock()
+ let rawWindows = rawWindows(
+ for: PrivateWindowServer.menuBarWindowIDs().filter { !excluded.contains($0) }
+ )
+ let sources = accessibilitySources().filter {
+ $0.frame.width > 0 && $0.frame.height > 0
+ }
+ let activeSourceKeys = Set(sources.map(\.sourceKey))
+ windowIDBySourceKey = windowIDBySourceKey.filter {
+ activeSourceKeys.contains($0.key)
+ }
+ var claimedWindowIDs = Set()
+ var items = [MenuBarItem]()
+
+ // Tahoe reparents third-party status windows to Control Center. Match the
+ // original app's Accessibility frame back to its WindowServer window.
+ // Match the widest, most distinctive items first. On Tahoe the AX frame
+ // describes the app's logical status item while WindowServer exposes a
+ // padded/reflowed host window, so their centers are no longer identical.
+ for source in sources.sorted(by: { $0.frame.width > $1.frame.width }) {
+ let available = rawWindows.filter { !claimedWindowIDs.contains($0.windowID) }
+ let previousMatch: RawWindow? = windowIDBySourceKey[source.sourceKey].flatMap { previousID in
+ guard !claimedWindowIDs.contains(previousID) else { return nil }
+ // WindowServer drops status-item windows from the menu-bar list
+ // once Barr parks them beyond the screen edge. Their IDs and
+ // private frames remain valid, so keep using that exact window
+ // instead of relabelling a visible neighbor with similar geometry.
+ let candidate =
+ available.first { $0.windowID == previousID } ??
+ rawWindow(previousID, assumeOnScreen: false)
+ guard let candidate, matchCost(candidate.frame, source.frame) <= 320 else {
+ windowIDBySourceKey.removeValue(forKey: source.sourceKey)
+ return nil
+ }
+ return candidate
+ }
+ let geometricMatch = available
+ .map { ($0, matchCost($0.frame, source.frame)) }
+ .filter { $0.1 <= 220 }
+ .min { $0.1 < $1.1 }?.0
+ guard let match = previousMatch ?? geometricMatch else { continue }
+
+ claimedWindowIDs.insert(match.windowID)
+ windowIDBySourceKey[source.sourceKey] = match.windowID
+ items.append(makeItem(window: match, source: source, captureImages: captureImages))
+ }
+
+ // Pre-Tahoe and a few unusual helpers still own their status windows.
+ for window in rawWindows where !claimedWindowIDs.contains(window.windowID) {
+ guard
+ window.ownerPID != ownPID,
+ window.ownerName != "Window Server",
+ let app = NSRunningApplication(processIdentifier: window.ownerPID),
+ !isBarrApplication(app)
+ else { continue }
+
+ let source = AccessibilitySource(
+ ownerPID: window.ownerPID,
+ ownerName: app.localizedName ?? window.ownerName,
+ bundleIdentifier: app.bundleIdentifier,
+ title: window.title,
+ stableIdentifier: nil,
+ frame: window.frame
+ )
+ items.append(makeItem(window: window, source: source, captureImages: captureImages))
+ }
+
+ let activeIconKeys = Set(items.map {
+ applicationIconKey(ownerPID: $0.ownerPID, bundleIdentifier: $0.bundleIdentifier)
+ })
+ applicationIconByKey = applicationIconByKey.filter {
+ activeIconKeys.contains($0.key)
+ }
+
+ return items
+ .sorted { lhs, rhs in
+ if lhs.frame.minX == rhs.frame.minX {
+ return lhs.ownerName.localizedCaseInsensitiveCompare(rhs.ownerName) == .orderedAscending
+ }
+ return lhs.frame.minX < rhs.frame.minX
+ }
+ }
+
+ static func item(windowID: CGWindowID) -> MenuBarItem? {
+ scan(captureImages: false).first { $0.windowID == windowID }
+ }
+
+ private struct RawWindow {
+ let windowID: CGWindowID
+ let ownerPID: pid_t
+ let ownerName: String
+ let title: String?
+ let frame: CGRect
+ let isOnScreen: Bool
+ }
+
+ private struct AccessibilitySource {
+ let ownerPID: pid_t
+ let ownerName: String
+ let bundleIdentifier: String?
+ let title: String?
+ let stableIdentifier: String?
+ let frame: CGRect
+
+ var sourceKey: String {
+ [bundleIdentifier ?? ownerName, stableIdentifier ?? title ?? ownerName]
+ .joined(separator: "|")
+ }
+ }
+
+ private static func rawWindows(for windowIDs: [CGWindowID]) -> [RawWindow] {
+ guard !windowIDs.isEmpty else { return [] }
+ let descriptions =
+ CGWindowListCreateDescriptionFromArray(windowIDs as CFArray)
+ as? [[CFString: Any]] ?? []
+ let descriptionsByID = descriptions.reduce(
+ into: [CGWindowID: [CFString: Any]]()
+ ) { result, description in
+ guard let number = description[kCGWindowNumber] as? NSNumber else { return }
+ result[CGWindowID(number.uint32Value)] = description
+ }
+ return windowIDs.compactMap {
+ rawWindow(
+ $0,
+ description: descriptionsByID[$0],
+ assumeOnScreen: true
+ )
+ }
+ }
+
+ private static func rawWindow(
+ _ windowID: CGWindowID,
+ assumeOnScreen: Bool
+ ) -> RawWindow? {
+ let values = [windowID] as CFArray
+ let description = (CGWindowListCreateDescriptionFromArray(values) as? [[CFString: Any]])?.first
+ return rawWindow(
+ windowID,
+ description: description,
+ assumeOnScreen: assumeOnScreen
+ )
+ }
+
+ private static func rawWindow(
+ _ windowID: CGWindowID,
+ description: [CFString: Any]?,
+ assumeOnScreen: Bool
+ ) -> RawWindow? {
+ let describedFrame = (description?[kCGWindowBounds] as? NSDictionary)
+ .flatMap(CGRect.init(dictionaryRepresentation:))
+ guard let frame = PrivateWindowServer.frame(of: windowID) ?? describedFrame else { return nil }
+
+ return RawWindow(
+ windowID: windowID,
+ ownerPID: description?[kCGWindowOwnerPID] as? pid_t ?? 0,
+ ownerName: description?[kCGWindowOwnerName] as? String ?? "Menu bar app",
+ title: description?[kCGWindowName] as? String,
+ frame: frame,
+ isOnScreen: description?[kCGWindowIsOnscreen] as? Bool ?? assumeOnScreen
+ )
+ }
+
+ private static func makeItem(
+ window: RawWindow,
+ source: AccessibilitySource,
+ captureImages: Bool
+ ) -> MenuBarItem {
+ return MenuBarItem(
+ windowID: window.windowID,
+ ownerPID: source.ownerPID,
+ ownerName: source.ownerName,
+ bundleIdentifier: source.bundleIdentifier,
+ title: source.title ?? window.title,
+ stableIdentifier: source.stableIdentifier,
+ frame: window.frame,
+ isOnScreen: window.isOnScreen,
+ image: captureImages
+ ? capture(windowID: window.windowID, source: source)
+ : nil
+ )
+ }
+
+ private static func accessibilitySources() -> [AccessibilitySource] {
+ guard PermissionCenter.isAccessibilityGranted else { return [] }
+ let ownPID = ProcessInfo.processInfo.processIdentifier
+ let runningApplications = NSWorkspace.shared.runningApplications
+ let activeProcessIdentifiers = Set(runningApplications.map(\.processIdentifier))
+ noStatusItemCheckedAt = noStatusItemCheckedAt.filter {
+ activeProcessIdentifiers.contains($0.key)
+ }
+ let now = ProcessInfo.processInfo.systemUptime
+
+ return runningApplications.flatMap { app -> [AccessibilitySource] in
+ let bundleIdentifier = app.bundleIdentifier
+ let systemStatusProvider = isSystemStatusProvider(bundleIdentifier)
+ guard
+ app.processIdentifier != ownPID,
+ !app.isTerminated,
+ app.activationPolicy != .prohibited,
+ !isBarrApplication(app)
+ else { return [] }
+
+ if
+ !systemStatusProvider,
+ let lastNegativeCheck = noStatusItemCheckedAt[app.processIdentifier],
+ now - lastNegativeCheck < negativeStatusItemCacheDuration
+ {
+ return []
+ }
+
+ let appElement = AXUIElementCreateApplication(app.processIdentifier)
+ AXUIElementSetMessagingTimeout(appElement, 0.15)
+ guard let extras = axElement(appElement, attribute: kAXExtrasMenuBarAttribute as CFString) else {
+ if !systemStatusProvider {
+ noStatusItemCheckedAt[app.processIdentifier] = now
+ }
+ return []
+ }
+
+ let children = axChildren(extras).compactMap { child -> (AXUIElement, CGRect)? in
+ guard let frame = axFrame(child), frame.width > 0, frame.height > 0 else {
+ return nil
+ }
+ return (child, frame)
+ }
+ if children.isEmpty {
+ if !systemStatusProvider {
+ noStatusItemCheckedAt[app.processIdentifier] = now
+ }
+ } else {
+ noStatusItemCheckedAt.removeValue(forKey: app.processIdentifier)
+ }
+ let useBundleIdentity = !systemStatusProvider && children.count == 1
+ let childTitles = children.map {
+ statusItemName($0.0, useSystemFallbacks: systemStatusProvider)
+ }
+ let rawIdentifiers = children.map {
+ axString($0.0, attribute: kAXIdentifierAttribute as CFString)
+ .flatMap {
+ $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ ? nil
+ : $0
+ }
+ }
+ let disambiguatedIdentifiers =
+ MenuBarItemIdentity.disambiguatedStableIdentifiers(
+ rawIdentifiers: rawIdentifiers,
+ titles: childTitles
+ )
+
+ return children.enumerated().map { index, pair in
+ let (child, frame) = pair
+ let title = childTitles[index]
+ let rawIdentifier = rawIdentifiers[index]
+ let stableIdentifier: String?
+ if systemStatusProvider {
+ stableIdentifier = statusItemIdentifier(child)
+ } else if let rawIdentifier {
+ stableIdentifier = rawIdentifier
+ } else if useBundleIdentity {
+ stableIdentifier = ""
+ } else {
+ stableIdentifier = disambiguatedIdentifiers[index]
+ }
+
+ return AccessibilitySource(
+ ownerPID: app.processIdentifier,
+ ownerName: app.localizedName ?? app.bundleIdentifier ?? "Menu bar app",
+ bundleIdentifier: bundleIdentifier,
+ title: title,
+ stableIdentifier: stableIdentifier,
+ frame: frame
+ )
+ }
+ }
+ }
+
+ private static func isBarrApplication(_ application: NSRunningApplication) -> Bool {
+ switch application.bundleIdentifier {
+ case "com.cursorkittens.Barr", "com.cursorkittens.Barr.debug":
+ return true
+ default:
+ return false
+ }
+ }
+
+ private static func isSystemStatusProvider(_ bundleIdentifier: String?) -> Bool {
+ bundleIdentifier == "com.apple.controlcenter" ||
+ bundleIdentifier == "com.apple.systemuiserver"
+ }
+
+ private static func statusItemName(
+ _ element: AXUIElement,
+ useSystemFallbacks: Bool
+ ) -> String? {
+ // Preserve third-party titles exactly. Some apps expose an empty title,
+ // and that value is part of their identity in existing Barr installs.
+ guard useSystemFallbacks else {
+ return axString(element, attribute: kAXTitleAttribute as CFString)
+ }
+
+ return [kAXTitleAttribute, kAXDescriptionAttribute, kAXHelpAttribute]
+ .compactMap { axString(element, attribute: $0 as CFString) }
+ .first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
+ }
+
+ private static func statusItemIdentifier(_ element: AXUIElement) -> String? {
+ if
+ let identifier = axString(element, attribute: kAXIdentifierAttribute as CFString),
+ !identifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ return identifier
+ }
+
+ let label = [kAXTitleAttribute, kAXDescriptionAttribute, kAXHelpAttribute]
+ .compactMap { axString(element, attribute: $0 as CFString) }
+ .first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? ""
+ let canonicalNames = [
+ "Screen Mirroring", "Keyboard Brightness", "Control Center",
+ "Now Playing", "Time Machine", "User Switching", "Bluetooth",
+ "Spotlight", "Battery", "Wi-Fi", "Wi‑Fi", "Sound", "Display",
+ "Focus", "AirDrop", "Clock", "VPN"
+ ]
+ return canonicalNames.first {
+ label.localizedCaseInsensitiveContains($0)
+ }
+ }
+
+ private static func matchCost(_ window: CGRect, _ accessibility: CGRect) -> CGFloat {
+ let horizontalDistance = abs(window.midX - accessibility.midX)
+ let verticalDistance = abs(window.midY - accessibility.midY)
+ let widthDifference = abs(window.width - accessibility.width)
+ return horizontalDistance + verticalDistance * 4 + widthDifference * 2
+ }
+
+ private static func capture(
+ windowID: CGWindowID,
+ source: AccessibilitySource
+ ) -> NSImage? {
+ if #available(macOS 26, *) {
+ return owningApplicationIcon(for: source)
+ }
+
+ var rawWindow = UnsafeRawPointer(bitPattern: UInt(windowID))
+ if
+ let array = CFArrayCreate(kCFAllocatorDefault, &rawWindow, 1, nil),
+ let image = CGImage.captureWindowList(array),
+ hasVisiblePixels(image)
+ {
+ let frameSize = PrivateWindowServer.frame(of: windowID)?.size ?? .zero
+ let pointSize = frameSize.width > 0 && frameSize.height > 0
+ ? frameSize
+ : NSSize(width: image.width, height: image.height)
+ return NSImage(cgImage: image, size: pointSize)
+ }
+
+ // A failed or redacted capture should never become an empty hit target.
+ return owningApplicationIcon(for: source)
+ }
+
+ private static func owningApplicationIcon(for source: AccessibilitySource) -> NSImage? {
+ guard #available(macOS 26, *) else {
+ return NSRunningApplication(processIdentifier: source.ownerPID)?.icon?.copy() as? NSImage
+ }
+
+ let key = applicationIconKey(
+ ownerPID: source.ownerPID,
+ bundleIdentifier: source.bundleIdentifier
+ )
+ if let cachedIcon = applicationIconByKey[key] {
+ return cachedIcon
+ }
+ let icon = NSRunningApplication(processIdentifier: source.ownerPID)?.icon?.copy() as? NSImage
+ applicationIconByKey[key] = icon
+ return icon
+ }
+
+ private static func applicationIconKey(
+ ownerPID: pid_t,
+ bundleIdentifier: String?
+ ) -> String {
+ "\(ownerPID)|\(bundleIdentifier ?? "")"
+ }
+
+ private static func hasVisiblePixels(_ image: CGImage) -> Bool {
+ let bitmap = NSBitmapImageRep(cgImage: image)
+ let xStep = max(1, image.width / 16)
+ let yStep = max(1, image.height / 16)
+
+ for y in stride(from: 0, to: image.height, by: yStep) {
+ for x in stride(from: 0, to: image.width, by: xStep) {
+ if bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0 > 0.05 {
+ return true
+ }
+ }
+ }
+ return false
+ }
+}
+
+private func axElement(_ element: AXUIElement, attribute: CFString) -> AXUIElement? {
+ var value: CFTypeRef?
+ guard
+ AXUIElementCopyAttributeValue(element, attribute, &value) == .success,
+ let value,
+ CFGetTypeID(value) == AXUIElementGetTypeID()
+ else { return nil }
+ return unsafeDowncast(value as AnyObject, to: AXUIElement.self)
+}
+
+private func axChildren(_ element: AXUIElement) -> [AXUIElement] {
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &value) == .success else {
+ return []
+ }
+ return value as? [AXUIElement] ?? []
+}
+
+private func axFrame(_ element: AXUIElement) -> CGRect? {
+ var positionRef: CFTypeRef?
+ var sizeRef: CFTypeRef?
+ guard
+ AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionRef) == .success,
+ AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeRef) == .success,
+ let positionRef,
+ let sizeRef,
+ CFGetTypeID(positionRef) == AXValueGetTypeID(),
+ CFGetTypeID(sizeRef) == AXValueGetTypeID()
+ else { return nil }
+
+ let positionValue = unsafeDowncast(positionRef as AnyObject, to: AXValue.self)
+ let sizeValue = unsafeDowncast(sizeRef as AnyObject, to: AXValue.self)
+ var position = CGPoint.zero
+ var size = CGSize.zero
+ guard
+ AXValueGetValue(positionValue, .cgPoint, &position),
+ AXValueGetValue(sizeValue, .cgSize, &size)
+ else { return nil }
+ return CGRect(origin: position, size: size)
+}
+
+private func axString(_ element: AXUIElement, attribute: CFString) -> String? {
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success else { return nil }
+ return value as? String
+}
+
+private protocol WindowListCapturing {
+ init?(
+ windowListFromArrayScreenBounds screenBounds: CGRect,
+ windowArray: CFArray,
+ imageOption: CGWindowImageOption
+ )
+}
+
+private extension WindowListCapturing {
+ static func captureWindowList(_ windowArray: CFArray) -> Self? {
+ Self(
+ windowListFromArrayScreenBounds: .null,
+ windowArray: windowArray,
+ imageOption: [.boundsIgnoreFraming, .bestResolution]
+ )
+ }
+}
+
+extension CGImage: WindowListCapturing {}
diff --git a/barr/app/Sources/MenuBar/PrivateWindowServer.swift b/barr/app/Sources/MenuBar/PrivateWindowServer.swift
new file mode 100644
index 0000000..e3b84a2
--- /dev/null
+++ b/barr/app/Sources/MenuBar/PrivateWindowServer.swift
@@ -0,0 +1,102 @@
+import AppKit
+import CoreGraphics
+
+private typealias CGSConnectionID = Int32
+
+@_silgen_name("CGSMainConnectionID")
+private func CGSMainConnectionID() -> CGSConnectionID
+
+@_silgen_name("CGSGetWindowCount")
+private func CGSGetWindowCount(
+ _ connection: CGSConnectionID,
+ _ targetConnection: CGSConnectionID,
+ _ count: inout Int32
+) -> CGError
+
+@_silgen_name("CGSGetProcessMenuBarWindowList")
+private func CGSGetProcessMenuBarWindowList(
+ _ connection: CGSConnectionID,
+ _ targetConnection: CGSConnectionID,
+ _ capacity: Int32,
+ _ windows: UnsafeMutablePointer,
+ _ count: inout Int32
+) -> CGError
+
+@_silgen_name("CGSGetScreenRectForWindow")
+private func CGSGetScreenRectForWindow(
+ _ connection: CGSConnectionID,
+ _ window: CGWindowID,
+ _ rect: inout CGRect
+) -> CGError
+
+enum PrivateWindowServer {
+ static func menuBarWindowIDs() -> [CGWindowID] {
+ let connection = CGSMainConnectionID()
+ var capacity: Int32 = 0
+ guard CGSGetWindowCount(connection, 0, &capacity) == .success, capacity > 0 else {
+ return []
+ }
+
+ var windows = [CGWindowID](repeating: 0, count: Int(capacity))
+ var count: Int32 = 0
+ let result = windows.withUnsafeMutableBufferPointer { buffer in
+ guard let baseAddress = buffer.baseAddress else { return CGError.failure }
+ return CGSGetProcessMenuBarWindowList(
+ connection,
+ 0,
+ capacity,
+ baseAddress,
+ &count
+ )
+ }
+ guard result == .success, count > 0 else { return [] }
+ return Array(windows.prefix(Int(count)))
+ }
+
+ static func frame(of windowID: CGWindowID) -> CGRect? {
+ var frame = CGRect.zero
+ guard CGSGetScreenRectForWindow(CGSMainConnectionID(), windowID, &frame) == .success else {
+ return nil
+ }
+ return frame
+ }
+
+ static func onScreenWindowIDs(ownedBy processIdentifier: pid_t) -> Set {
+ let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
+ let descriptions =
+ CGWindowListCopyWindowInfo(options, kCGNullWindowID)
+ as? [[CFString: Any]] ?? []
+ return Set(descriptions.compactMap { description in
+ guard
+ description[kCGWindowOwnerPID] as? pid_t == processIdentifier,
+ let number = description[kCGWindowNumber] as? NSNumber
+ else {
+ return nil
+ }
+ return CGWindowID(number.uint32Value)
+ })
+ }
+
+ static func interfaceWindowIsVisible(
+ _ windowID: CGWindowID,
+ ownedBy processIdentifier: pid_t
+ ) -> Bool {
+ let values = [windowID] as CFArray
+ guard
+ let description =
+ (CGWindowListCreateDescriptionFromArray(values)
+ as? [[CFString: Any]])?.first,
+ description[kCGWindowOwnerPID] as? pid_t == processIdentifier,
+ description[kCGWindowIsOnscreen] as? Bool == true
+ else {
+ return false
+ }
+
+ let layer = (description[kCGWindowLayer] as? NSNumber)?.int32Value ?? 0
+ let popupLayer = CGWindowLevelForKey(.popUpMenuWindow)
+ if layer == popupLayer {
+ return true
+ }
+ return NSRunningApplication(processIdentifier: processIdentifier)?.isActive == true
+ }
+}
diff --git a/barr/app/Sources/MenuBar/StatusWindowGeometry.swift b/barr/app/Sources/MenuBar/StatusWindowGeometry.swift
new file mode 100644
index 0000000..2493966
--- /dev/null
+++ b/barr/app/Sources/MenuBar/StatusWindowGeometry.swift
@@ -0,0 +1,37 @@
+import AppKit
+import CoreGraphics
+
+enum StatusWindowGeometry {
+ static func matches(
+ frame: CGRect,
+ buttonFrame: CGRect,
+ displayBounds: CGRect?
+ ) -> Bool {
+ guard abs(frame.height - buttonFrame.height) < 20 else { return false }
+ if
+ let displayBounds,
+ !displayBounds.contains(CGPoint(x: frame.midX, y: frame.midY))
+ {
+ return false
+ }
+
+ // Hosted status windows include padding around the button, so their
+ // widths need not be identical. Their horizontal centers do remain
+ // tightly aligned. A center bound prevents a neighboring status item
+ // (or an identically positioned item on a vertically stacked display)
+ // from becoming Barr's control or parking anchor.
+ let centerTolerance = max(
+ 10,
+ min(80, (frame.width + buttonFrame.width) * 0.25)
+ )
+ return abs(frame.midX - buttonFrame.midX) <= centerTolerance
+ }
+
+ static func quartzDisplayBounds(for screen: NSScreen) -> CGRect? {
+ let screenNumberKey = NSDeviceDescriptionKey("NSScreenNumber")
+ guard
+ let screenNumber = screen.deviceDescription[screenNumberKey] as? NSNumber
+ else { return nil }
+ return CGDisplayBounds(CGDirectDisplayID(screenNumber.uint32Value))
+ }
+}
diff --git a/barr/app/Sources/Model/ShelfModel.swift b/barr/app/Sources/Model/ShelfModel.swift
new file mode 100644
index 0000000..374b447
--- /dev/null
+++ b/barr/app/Sources/Model/ShelfModel.swift
@@ -0,0 +1,396 @@
+import AppKit
+import ServiceManagement
+
+@MainActor
+final class ShelfModel: ObservableObject {
+ @Published private(set) var items: [MenuBarItem] = []
+ @Published private(set) var movedItemKeys: Set
+ @Published private var pendingMembershipChange: PendingMembershipChange?
+ @Published var isManaging = false
+ @Published private(set) var isRefreshing = false
+ @Published private(set) var canCaptureScreen = PermissionCenter.canCaptureScreen
+ @Published private(set) var canUseAccessibility = PermissionCenter.isAccessibilityGranted
+ @Published private(set) var screenCaptureNeedsRestart = false
+ @Published private(set) var activationFailed = false
+ @Published private(set) var membershipChangeFailed = false
+ @Published private(set) var showsSystemItems: Bool
+ @Published private(set) var opensAtLogin: Bool
+ @Published private(set) var loginItemRequiresApproval: Bool
+ @Published private(set) var loginItemError: String?
+
+ var onItemsChanged: (() -> Void)?
+ var onLayoutChanged: (() -> Void)?
+ var onRefreshCompleted: (() -> Void)?
+ var onActivate: ((MenuBarItem) -> Void)?
+ var onRestart: (() -> Void)?
+ var onMembershipChange:
+ ((MenuBarItem, Bool, @escaping @MainActor @Sendable (Bool) -> Void) -> Void)?
+ private var refreshInProgress = false
+ private var refreshRequested = false
+ private var refreshRequestedCaptureImages = false
+ private var permissionCheckGeneration = 0
+ private var requestedScreenCaptureThisLaunch = false
+ private var itemOrder: [String]
+ private var itemOrderIndexes: [String: Int]
+
+ private struct PendingMembershipChange {
+ let itemKey: String
+ let moveToBarr: Bool
+ }
+
+ init() {
+ let loginItemStatus = SMAppService.mainApp.status
+ let storedItemOrder = UserDefaults.standard.stringArray(forKey: "BarrItemOrder") ?? []
+ movedItemKeys = Set(UserDefaults.standard.stringArray(forKey: "BarrMovedItemKeys") ?? [])
+ itemOrder = storedItemOrder
+ itemOrderIndexes = Self.orderIndexes(for: storedItemOrder)
+ showsSystemItems = UserDefaults.standard.bool(forKey: "BarrShowsSystemItems")
+ opensAtLogin = Self.isLoginItemRequested(loginItemStatus)
+ loginItemRequiresApproval = loginItemStatus == .requiresApproval
+ loginItemError = nil
+ }
+
+ var barrItems: [MenuBarItem] {
+ ordered(items.filter { isInBarr($0.storageKey) })
+ }
+
+ var hasVisiblePersistedBarrItems: Bool {
+ items.contains { movedItemKeys.contains($0.storageKey) }
+ }
+
+ var menuBarItems: [MenuBarItem] {
+ ordered(
+ items.filter {
+ !isInBarr($0.storageKey) && (showsSystemItems || !$0.isSystemItem)
+ }
+ )
+ }
+
+ var movingItemKeys: Set {
+ pendingMembershipChange.map { [$0.itemKey] } ?? []
+ }
+
+ func containsItem(ownedBy processIdentifier: pid_t) -> Bool {
+ items.contains { $0.ownerPID == processIdentifier }
+ }
+
+ func refresh(captureImages: Bool = true) {
+ guard !refreshInProgress else {
+ refreshRequested = true
+ refreshRequestedCaptureImages =
+ refreshRequestedCaptureImages || captureImages
+ return
+ }
+ beginRefresh(captureImages: captureImages)
+ }
+
+ private func beginRefresh(captureImages: Bool) {
+ refreshInProgress = true
+ if !isRefreshing {
+ isRefreshing = true
+ }
+ updatePermissionState(refreshWhenReady: false)
+
+ DispatchQueue.global(qos: .userInitiated).async { [self] in
+ let found = MenuBarScanner.scan(captureImages: captureImages)
+ DispatchQueue.main.async {
+ self.migrateLegacyKeys(using: found)
+ let existingItems = Dictionary(
+ self.items.map { ($0.storageKey, $0) },
+ uniquingKeysWith: { first, _ in first }
+ )
+ let stableItems = found.map { item in
+ guard item.image == nil, let previousImage = existingItems[item.storageKey]?.image else {
+ return item
+ }
+ return item.replacingImage(previousImage)
+ }
+ let itemsChanged = !self.items.elementsEqual(
+ stableItems,
+ by: { $0.hasSameContent(as: $1) }
+ )
+ if itemsChanged {
+ self.items = stableItems
+ }
+ self.mergeItemOrder(stableItems)
+ self.updatePermissionState(refreshWhenReady: false)
+ if itemsChanged {
+ self.onItemsChanged?()
+ }
+ self.onRefreshCompleted?()
+#if DEBUG
+ print("[Barr] Found \(found.count) menu bar app item(s)")
+#endif
+
+ if self.refreshRequested {
+ let nextRefreshCapturesImages = self.refreshRequestedCaptureImages
+ self.refreshRequested = false
+ self.refreshRequestedCaptureImages = false
+ self.beginRefresh(captureImages: nextRefreshCapturesImages)
+ } else {
+ self.refreshInProgress = false
+ self.isRefreshing = false
+ }
+ }
+ }
+ }
+
+ func activate(_ item: MenuBarItem) {
+ guard PermissionCenter.isAccessibilityGranted else {
+ PermissionCenter.requestAccessibility()
+ return
+ }
+ setActivationFailed(false)
+ onActivate?(item)
+ }
+
+ func setActivationFailed(_ failed: Bool) {
+ guard activationFailed != failed else { return }
+ activationFailed = failed
+ onLayoutChanged?()
+ }
+
+ func moveToBarr(_ item: MenuBarItem) {
+ guard item.isMovableByBarr else { return }
+ changeMembership(of: item, moveToBarr: true)
+ }
+
+ func returnToMenuBar(_ item: MenuBarItem) {
+ changeMembership(of: item, moveToBarr: false)
+ }
+
+ func setManaging(_ managing: Bool) {
+ guard isManaging != managing else { return }
+ isManaging = managing
+ onLayoutChanged?()
+ }
+
+ func setShowsSystemItems(_ showsSystemItems: Bool) {
+ guard self.showsSystemItems != showsSystemItems else { return }
+ self.showsSystemItems = showsSystemItems
+ UserDefaults.standard.set(showsSystemItems, forKey: "BarrShowsSystemItems")
+ }
+
+ func setOpensAtLogin(_ opensAtLogin: Bool) {
+ let service = SMAppService.mainApp
+ loginItemError = nil
+
+ do {
+ if opensAtLogin {
+ try service.register()
+ } else {
+ try service.unregister()
+ }
+ } catch {
+ loginItemError = error.localizedDescription
+ }
+
+ refreshLoginItemStatus()
+ }
+
+ func refreshLoginItemStatus() {
+ let status = SMAppService.mainApp.status
+ let shouldOpenAtLogin = Self.isLoginItemRequested(status)
+ let requiresApproval = status == .requiresApproval
+ if opensAtLogin != shouldOpenAtLogin {
+ opensAtLogin = shouldOpenAtLogin
+ }
+ if loginItemRequiresApproval != requiresApproval {
+ loginItemRequiresApproval = requiresApproval
+ }
+ }
+
+ func openLoginItemsSettings() {
+ SMAppService.openSystemSettingsLoginItems()
+ }
+
+ private static func isLoginItemRequested(_ status: SMAppService.Status) -> Bool {
+ status == .enabled || status == .requiresApproval
+ }
+
+ func returnAnchor(for item: MenuBarItem) -> MenuBarItem? {
+ guard let itemIndex = itemOrder.firstIndex(of: item.storageKey) else { return nil }
+ let currentByKey = items.reduce(into: [String: MenuBarItem]()) { result, current in
+ result[current.storageKey] = current
+ }
+ // Command-dragging beside a status item inserts after that item on
+ // current macOS releases, so anchor to the closest visible predecessor.
+ return itemOrder.prefix(itemIndex).reversed().lazy
+ .filter { !self.movedItemKeys.contains($0) }
+ .compactMap { currentByKey[$0] }
+ .first
+ }
+
+ private func changeMembership(of item: MenuBarItem, moveToBarr: Bool) {
+ guard pendingMembershipChange == nil, let onMembershipChange else { return }
+ setMembershipChangeFailed(false)
+ pendingMembershipChange = PendingMembershipChange(
+ itemKey: item.storageKey,
+ moveToBarr: moveToBarr
+ )
+ onItemsChanged?()
+
+ onMembershipChange(item, moveToBarr) { [weak self] success in
+ guard let self else { return }
+ if success {
+ if moveToBarr {
+ self.movedItemKeys.insert(item.storageKey)
+ } else {
+ self.movedItemKeys.remove(item.storageKey)
+ }
+ UserDefaults.standard.set(self.movedItemKeys.sorted(), forKey: "BarrMovedItemKeys")
+ } else {
+ self.setMembershipChangeFailed(true)
+ }
+ self.pendingMembershipChange = nil
+ self.onItemsChanged?()
+ }
+ }
+
+ private func setMembershipChangeFailed(_ failed: Bool) {
+ guard membershipChangeFailed != failed else { return }
+ membershipChangeFailed = failed
+ onLayoutChanged?()
+ }
+
+ private func isInBarr(_ itemKey: String) -> Bool {
+ if pendingMembershipChange?.itemKey == itemKey {
+ return pendingMembershipChange?.moveToBarr == true
+ }
+ return movedItemKeys.contains(itemKey)
+ }
+
+ private func ordered(_ source: [MenuBarItem]) -> [MenuBarItem] {
+ return source.sorted {
+ let lhs = itemOrderIndexes[$0.storageKey] ?? Int.max
+ let rhs = itemOrderIndexes[$1.storageKey] ?? Int.max
+ if lhs == rhs { return $0.frame.minX < $1.frame.minX }
+ return lhs < rhs
+ }
+ }
+
+ private static func orderIndexes(for order: [String]) -> [String: Int] {
+ order.enumerated().reduce(into: [String: Int]()) { result, entry in
+ if result[entry.element] == nil {
+ result[entry.element] = entry.offset
+ }
+ }
+ }
+
+ private func mergeItemOrder(_ found: [MenuBarItem]) {
+ var known = Set(itemOrder)
+ var changed = false
+ let foundKeys = found.reduce(into: [String]()) { result, item in
+ if !result.contains(item.storageKey) {
+ result.append(item.storageKey)
+ }
+ }
+
+ for (foundIndex, key) in foundKeys.enumerated() where known.insert(key).inserted {
+ let precedingKey = foundKeys[..()
+ itemOrder = itemOrder.filter { seen.insert($0).inserted }
+ itemOrderIndexes = Self.orderIndexes(for: itemOrder)
+ UserDefaults.standard.set(itemOrder, forKey: "BarrItemOrder")
+ }
+ if movedChanged {
+ UserDefaults.standard.set(movedItemKeys.sorted(), forKey: "BarrMovedItemKeys")
+ }
+ }
+
+ func requestScreenCapture() {
+ PermissionCenter.requestScreenCapture { [weak self] _ in
+ guard let self else { return }
+ // macOS may return false until the process relaunches even after the
+ // user has enabled Barr in Screen Recording settings.
+ self.requestedScreenCaptureThisLaunch = true
+ self.updatePermissionState()
+ self.schedulePermissionChecks()
+ }
+ }
+
+ func requestAccessibility() {
+ PermissionCenter.requestAccessibility()
+ schedulePermissionChecks()
+ }
+
+ func restartBarr() {
+ onRestart?()
+ }
+
+ private func schedulePermissionChecks() {
+ guard !canCaptureScreen || !canUseAccessibility else { return }
+ permissionCheckGeneration += 1
+ let generation = permissionCheckGeneration
+ for delay in [0.5, 1.5, 3.0, 6.0, 12.0] {
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
+ guard
+ let self,
+ generation == self.permissionCheckGeneration
+ else { return }
+ self.updatePermissionState()
+ }
+ }
+ }
+
+ private func updatePermissionState(refreshWhenReady: Bool = true) {
+ let capture = PermissionCenter.canCaptureScreen
+ let accessibility = PermissionCenter.isAccessibilityGranted
+ let needsRestart = requestedScreenCaptureThisLaunch && !capture
+ guard
+ capture != canCaptureScreen ||
+ accessibility != canUseAccessibility ||
+ needsRestart != screenCaptureNeedsRestart
+ else { return }
+
+ let becameReady = capture && accessibility && (!canCaptureScreen || !canUseAccessibility)
+ canCaptureScreen = capture
+ canUseAccessibility = accessibility
+ screenCaptureNeedsRestart = needsRestart
+ onLayoutChanged?()
+
+ if capture && accessibility {
+ permissionCheckGeneration += 1
+ }
+ if becameReady && refreshWhenReady {
+ refresh()
+ }
+ }
+}
diff --git a/barr/app/Sources/Permissions/PermissionCenter.swift b/barr/app/Sources/Permissions/PermissionCenter.swift
new file mode 100644
index 0000000..b319fe2
--- /dev/null
+++ b/barr/app/Sources/Permissions/PermissionCenter.swift
@@ -0,0 +1,41 @@
+import AppKit
+@preconcurrency import ApplicationServices
+import CoreGraphics
+
+enum PermissionCenter {
+ static var hasScreenCaptureAuthorization: Bool {
+ CGPreflightScreenCaptureAccess()
+ }
+
+ static var canCaptureScreen: Bool {
+ CGPreflightScreenCaptureAccess()
+ }
+
+ static var isAccessibilityGranted: Bool {
+ AXIsProcessTrusted()
+ }
+
+ static func requestScreenCapture(completion: @escaping (Bool) -> Void) {
+ // TCC presents the system consent UI for this call. Keep it on the main
+ // thread so the prompt is attached to Barr instead of being suppressed.
+ completion(CGRequestScreenCaptureAccess())
+ }
+
+ static func requestAccessibility() {
+ let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
+ AXIsProcessTrustedWithOptions(options)
+ }
+
+ static func openScreenRecordingSettings() {
+ openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")
+ }
+
+ static func openAccessibilitySettings() {
+ openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
+ }
+
+ private static func openSettings(_ string: String) {
+ guard let url = URL(string: string) else { return }
+ NSWorkspace.shared.open(url)
+ }
+}
diff --git a/barr/app/Sources/UI/ShelfPanel.swift b/barr/app/Sources/UI/ShelfPanel.swift
new file mode 100644
index 0000000..a22903a
--- /dev/null
+++ b/barr/app/Sources/UI/ShelfPanel.swift
@@ -0,0 +1,124 @@
+import AppKit
+import SwiftUI
+
+@MainActor
+final class ShelfPanel: NSPanel {
+ private let model: ShelfModel
+ private let hostingController: NSHostingController
+ private var resizeGeneration = 0
+
+ init(model: ShelfModel) {
+ self.model = model
+ self.hostingController = NSHostingController(
+ rootView: ShelfView(model: model)
+ )
+ super.init(
+ contentRect: NSRect(x: 0, y: 0, width: 320, height: 68),
+ styleMask: [.borderless, .nonactivatingPanel, .fullSizeContentView],
+ backing: .buffered,
+ defer: false
+ )
+
+ title = "Barr"
+ isOpaque = false
+ backgroundColor = .clear
+ hasShadow = true
+ level = .mainMenu + 1
+ animationBehavior = .utilityWindow
+ collectionBehavior = [.fullScreenAuxiliary, .ignoresCycle, .moveToActiveSpace, .transient]
+ isFloatingPanel = true
+ hidesOnDeactivate = false
+ becomesKeyOnlyIfNeeded = true
+ contentViewController = hostingController
+ }
+
+ func scheduleResizeToFit() {
+ resizeGeneration += 1
+ let generation = resizeGeneration
+ DispatchQueue.main.async { [weak self] in
+ guard
+ let self,
+ generation == self.resizeGeneration
+ else { return }
+ self.resizeToFit()
+ }
+ }
+
+ @discardableResult
+ func show(relativeTo button: NSStatusBarButton) -> Bool {
+ guard let buttonWindow = button.window, let screen = buttonWindow.screen ?? NSScreen.main else {
+ return false
+ }
+ let buttonFrame = buttonWindow.convertToScreen(button.convert(button.bounds, to: nil))
+ guard Self.isVisibleMenuBarFrame(buttonFrame, on: screen) else {
+ close()
+ return false
+ }
+
+ resizeGeneration += 1
+ resizeToFit(on: screen)
+ let minimumX = screen.visibleFrame.minX + 8
+ let maximumX = max(minimumX, screen.visibleFrame.maxX - frame.width - 8)
+ let x = min(max(minimumX, buttonFrame.midX - frame.width / 2), maximumX)
+ let y = buttonFrame.minY - frame.height - 5
+ setFrameOrigin(NSPoint(x: x, y: y))
+ orderFrontRegardless()
+ makeKey()
+ return true
+ }
+
+ private static func isVisibleMenuBarFrame(_ frame: CGRect, on screen: NSScreen) -> Bool {
+ guard frame.width > 0, frame.height > 0 else { return false }
+
+ let visibleWidth = screen.frame.intersection(frame).width
+ guard visibleWidth >= min(frame.width * 0.8, frame.width - 1) else {
+ return false
+ }
+
+ let menuBarAreas = [
+ screen.auxiliaryTopLeftArea,
+ screen.auxiliaryTopRightArea
+ ].compactMap { $0 }
+ guard !menuBarAreas.isEmpty else { return true }
+
+ // A status item's NSWindow still has valid on-screen geometry while it
+ // sits beneath a MacBook notch. Only anchor the shelf to an item that
+ // actually intersects one of the visible menu-bar regions.
+ return menuBarAreas.contains {
+ $0.intersection(frame).width >= min(frame.width * 0.8, frame.width - 1)
+ }
+ }
+
+ private func resizeToFit(on targetScreen: NSScreen? = nil) {
+ let sizingScreen = targetScreen ?? (isVisible ? screen : nil) ?? NSScreen.main
+ let screenWidth = max((sizingScreen?.visibleFrame.width ?? 800) - 32, 200)
+ let desiredWidth: CGFloat
+ let desiredHeight: CGFloat
+
+ if !model.canCaptureScreen || !model.canUseAccessibility {
+ desiredWidth = 390
+ desiredHeight = 174
+ } else if model.isManaging {
+ desiredWidth = 520
+ desiredHeight = model.membershipChangeFailed ? 236 : 210
+ } else if model.barrItems.isEmpty {
+ desiredWidth = 310
+ desiredHeight = 74
+ } else {
+ desiredWidth = model.barrItems.reduce(62) { $0 + $1.logicalWidth + 8 }
+ desiredHeight = model.activationFailed ? 92 : 66
+ }
+
+ let desiredSize = NSSize(
+ width: min(desiredWidth, screenWidth),
+ height: desiredHeight
+ )
+ guard
+ abs(frame.width - desiredSize.width) > 0.5 ||
+ abs(frame.height - desiredSize.height) > 0.5
+ else { return }
+ setContentSize(desiredSize)
+ }
+
+ override var canBecomeKey: Bool { true }
+}
diff --git a/barr/app/Sources/UI/ShelfView.swift b/barr/app/Sources/UI/ShelfView.swift
new file mode 100644
index 0000000..fb3d9de
--- /dev/null
+++ b/barr/app/Sources/UI/ShelfView.swift
@@ -0,0 +1,361 @@
+import SwiftUI
+
+struct ShelfView: View {
+ @ObservedObject var model: ShelfModel
+
+ var body: some View {
+ Group {
+ if !model.canCaptureScreen || !model.canUseAccessibility {
+ permissions
+ } else if model.isManaging {
+ manager
+ } else if model.barrItems.isEmpty {
+ emptyShelf
+ } else {
+ shelf
+ }
+ }
+ .background(
+ Color(red: 0.075, green: 0.082, blue: 0.095).opacity(0.97),
+ in: RoundedRectangle(cornerRadius: 14, style: .continuous)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .stroke(.white.opacity(0.16), lineWidth: 0.5)
+ }
+ .environment(\.colorScheme, .dark)
+ .padding(5)
+ }
+
+ private var shelf: some View {
+ VStack(spacing: 0) {
+ if model.activationFailed {
+ errorBanner("That item didn’t open. Try it again.")
+ }
+
+ HStack(spacing: 4) {
+ ScrollView(.horizontal, showsIndicators: false) {
+ iconRow(items: model.barrItems, action: model.activate)
+ }
+
+ Divider()
+ .frame(height: 24)
+
+ Button {
+ model.setManaging(true)
+ } label: {
+ Image(systemName: "slider.horizontal.3")
+ .font(.system(size: 15, weight: .medium))
+ .frame(
+ width: MenuBarItem.iconHitTarget,
+ height: MenuBarItem.iconHitTarget
+ )
+ }
+ .buttonStyle(ShelfButtonStyle())
+ .help("Choose menu bar apps")
+ }
+ .padding(.horizontal, 7)
+ }
+ }
+
+ private var manager: some View {
+ VStack(spacing: 0) {
+ if model.membershipChangeFailed {
+ errorBanner("Barr couldn’t move that item. Try it again.")
+ }
+
+ laneHeader("In Barr", detail: "Click to return") {
+ Button("Done") { model.setManaging(false) }
+ .controlSize(.small)
+ }
+
+ if model.barrItems.isEmpty {
+ lanePlaceholder("Choose an app from the menu bar below")
+ } else {
+ ScrollView(.horizontal, showsIndicators: false) {
+ iconRow(items: model.barrItems, action: model.returnToMenuBar)
+ }
+ .frame(height: 48)
+ }
+
+ Divider()
+ .padding(.horizontal, 10)
+
+ laneHeader("Menu Bar", detail: "Click to move into Barr") {
+ Toggle(
+ "System items",
+ isOn: Binding(
+ get: { model.showsSystemItems },
+ set: { model.setShowsSystemItems($0) }
+ )
+ )
+ .font(.system(size: 10))
+ .toggleStyle(.switch)
+ .controlSize(.mini)
+ .help("Include macOS system menu bar items")
+ }
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ iconRow(
+ items: model.menuBarItems,
+ action: model.moveToBarr,
+ canInteract: { $0.isMovableByBarr }
+ )
+ }
+ .frame(height: 48)
+
+ Divider()
+ .padding(.horizontal, 10)
+
+ launchAtLoginSetting
+ }
+ .padding(.vertical, 7)
+ }
+
+ private var launchAtLoginSetting: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "power")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 18)
+
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Open at Login")
+ .font(.system(size: 11, weight: .medium))
+ Text(loginItemDetail)
+ .font(.system(size: 10))
+ .foregroundStyle(
+ model.loginItemError == nil ? Color.secondary : Color.red
+ )
+ .lineLimit(1)
+ }
+
+ Spacer()
+
+ if model.loginItemRequiresApproval {
+ Button("Review…") {
+ model.openLoginItemsSettings()
+ }
+ .controlSize(.small)
+ }
+
+ Toggle(
+ "Open at Login",
+ isOn: Binding(
+ get: { model.opensAtLogin },
+ set: { model.setOpensAtLogin($0) }
+ )
+ )
+ .labelsHidden()
+ .toggleStyle(.switch)
+ .controlSize(.mini)
+ }
+ .padding(.horizontal, 12)
+ .frame(height: 38)
+ }
+
+ private var loginItemDetail: String {
+ if let error = model.loginItemError {
+ return error
+ }
+ if model.loginItemRequiresApproval {
+ return "Approval required in System Settings"
+ }
+ return "Start Barr automatically when you log in"
+ }
+
+ private func laneHeader(
+ _ title: String,
+ detail: String,
+ @ViewBuilder trailing: () -> Trailing
+ ) -> some View {
+ HStack(spacing: 7) {
+ Text(title.uppercased())
+ .font(.system(size: 9, weight: .bold, design: .rounded))
+ .tracking(0.7)
+ .foregroundStyle(.secondary)
+ Text(detail)
+ .font(.system(size: 10))
+ .foregroundStyle(.tertiary)
+ Spacer()
+ trailing()
+ }
+ .padding(.horizontal, 12)
+ .frame(height: 25)
+ }
+
+ private func lanePlaceholder(_ text: String) -> some View {
+ HStack {
+ Text(text)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ Spacer()
+ }
+ .padding(.horizontal, 12)
+ .frame(height: 48)
+ }
+
+ private func errorBanner(_ text: String) -> some View {
+ HStack(spacing: 6) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ Text(text)
+ .font(.system(size: 10, weight: .medium))
+ Spacer()
+ }
+ .padding(.horizontal, 10)
+ .frame(height: 26)
+ }
+
+ private func iconRow(
+ items: [MenuBarItem],
+ action: @escaping (MenuBarItem) -> Void,
+ canInteract: @escaping (MenuBarItem) -> Bool = { _ in true }
+ ) -> some View {
+ HStack(spacing: 2) {
+ ForEach(items) { item in
+ Button {
+ action(item)
+ } label: {
+ Group {
+ if let symbolName = item.systemSymbolName {
+ Image(systemName: symbolName)
+ .font(.system(size: 17, weight: .medium))
+ .foregroundStyle(.primary)
+ } else if let image = item.image {
+ Image(nsImage: image)
+ .renderingMode(image.isTemplate ? .template : .original)
+ .resizable()
+ .interpolation(.high)
+ .scaledToFit()
+ .foregroundStyle(.primary)
+ } else {
+ Image(systemName: "app.dashed")
+ .foregroundStyle(.secondary)
+ }
+ }
+ .frame(
+ width: item.renderedIconWidth,
+ height: MenuBarItem.visualIconHeight
+ )
+ .frame(
+ width: item.logicalWidth,
+ height: MenuBarItem.iconHitTarget
+ )
+ .contentShape(Rectangle())
+ .padding(.horizontal, 3)
+ .padding(.vertical, 5)
+ .opacity(
+ model.movingItemKeys.contains(item.storageKey) || !canInteract(item)
+ ? 0.35
+ : 1
+ )
+ }
+ .buttonStyle(ShelfButtonStyle())
+ .disabled(!model.movingItemKeys.isEmpty || !canInteract(item))
+ .help(
+ canInteract(item)
+ ? item.displayName
+ : "\(item.displayName) is fixed by macOS"
+ )
+ .accessibilityLabel(item.displayName)
+ .accessibilityHint(canInteract(item) ? "" : "Fixed by macOS")
+ }
+ }
+ .padding(.horizontal, 6)
+ }
+
+ private var permissions: some View {
+ VStack(alignment: .leading, spacing: 13) {
+ HStack(spacing: 10) {
+ Image(systemName: "line.3.horizontal")
+ .font(.system(size: 19, weight: .semibold))
+ .frame(width: 28, height: 28)
+ .background(.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Two permissions, one private shelf")
+ .font(.system(size: 13, weight: .semibold))
+ Text("Barr reads menu bar icons. Nothing leaves your Mac.")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ permissionRow(
+ title: "Screen Recording",
+ detail: model.screenCaptureNeedsRestart ? "Restart after allowing" : "Mirror each icon",
+ granted: model.canCaptureScreen,
+ actionTitle: model.screenCaptureNeedsRestart ? "Restart Barr" : "Allow",
+ action: model.screenCaptureNeedsRestart ? model.restartBarr : model.requestScreenCapture
+ )
+ permissionRow(
+ title: "Accessibility",
+ detail: "Open the original app menu",
+ granted: model.canUseAccessibility,
+ actionTitle: "Allow",
+ action: model.requestAccessibility
+ )
+
+ HStack {
+ Spacer()
+ Button("Check again") { model.refresh() }
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+
+ private func permissionRow(
+ title: String,
+ detail: String,
+ granted: Bool,
+ actionTitle: String,
+ action: @escaping () -> Void
+ ) -> some View {
+ HStack(spacing: 8) {
+ Image(systemName: granted ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(granted ? .green : .secondary)
+ Text(title)
+ .font(.system(size: 12, weight: .medium))
+ Text(detail)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ Spacer()
+ if !granted {
+ Button(actionTitle, action: action)
+ .controlSize(.small)
+ }
+ }
+ }
+
+ private var emptyShelf: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "rectangle.stack")
+ .font(.system(size: 17, weight: .medium))
+ .foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(model.isRefreshing ? "Looking for menu bar apps…" : "The shelf is empty")
+ .font(.system(size: 12, weight: .semibold))
+ Text("Choose which apps live in Barr.")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button("Choose Apps") { model.setManaging(true) }
+ .controlSize(.small)
+ }
+ .padding(.horizontal, 16)
+ }
+}
+
+private struct ShelfButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .background(
+ configuration.isPressed ? Color.white.opacity(0.20) : Color.white.opacity(0.075),
+ in: RoundedRectangle(cornerRadius: 8, style: .continuous)
+ )
+ .scaleEffect(configuration.isPressed ? 0.94 : 1)
+ .animation(.easeOut(duration: 0.11), value: configuration.isPressed)
+ }
+}
diff --git a/barr/app/Tests/MenuBarItemIdentityTests.swift b/barr/app/Tests/MenuBarItemIdentityTests.swift
new file mode 100644
index 0000000..191f3b8
--- /dev/null
+++ b/barr/app/Tests/MenuBarItemIdentityTests.swift
@@ -0,0 +1,39 @@
+import XCTest
+
+@testable import Barr
+
+final class MenuBarItemIdentityTests: XCTestCase {
+ func testKeepsUniqueIdentifiersUnchanged() {
+ XCTAssertEqual(
+ MenuBarItemIdentity.disambiguatedStableIdentifiers(
+ rawIdentifiers: ["primary", nil],
+ titles: ["One", "Secondary"]
+ ),
+ ["primary", "Secondary"]
+ )
+ }
+
+ func testDisambiguatesDuplicateTitles() {
+ XCTAssertEqual(
+ MenuBarItemIdentity.disambiguatedStableIdentifiers(
+ rawIdentifiers: [nil, nil],
+ titles: ["Status", "Status"]
+ ),
+ ["Status#1", "Status#2"]
+ )
+ }
+
+ func testDisambiguatesUnnamedItems() {
+ XCTAssertEqual(
+ MenuBarItemIdentity.disambiguatedStableIdentifiers(
+ rawIdentifiers: [nil, " ", nil],
+ titles: ["", nil, " "]
+ ),
+ [
+ "barr-unnamed-item-1",
+ "barr-unnamed-item-2",
+ "barr-unnamed-item-3"
+ ]
+ )
+ }
+}
diff --git a/barr/app/Tests/MenuBarMoveGeometryTests.swift b/barr/app/Tests/MenuBarMoveGeometryTests.swift
new file mode 100644
index 0000000..91c167a
--- /dev/null
+++ b/barr/app/Tests/MenuBarMoveGeometryTests.swift
@@ -0,0 +1,45 @@
+import CoreGraphics
+import XCTest
+
+@testable import Barr
+
+final class MenuBarMoveGeometryTests: XCTestCase {
+ func testMoveTargetIsImmediatelyOutsideLeadingEdge() {
+ let anchor = CGRect(x: 320, y: 0, width: 24, height: 30)
+
+ XCTAssertEqual(
+ MenuBarMoveGeometry.pointImmediatelyLeft(of: anchor),
+ CGPoint(x: 319, y: 15)
+ )
+ }
+
+ func testMoveTargetNeverFallsInsideExpandedAnchor() {
+ let anchor = CGRect(x: 8, y: 0, width: 1_200, height: 30)
+ let target = MenuBarMoveGeometry.pointImmediatelyLeft(of: anchor)
+
+ XCTAssertLessThan(target.x, anchor.minX)
+ XCTAssertEqual(target.y, anchor.midY)
+ }
+
+ func testExpandedAnchorIsShortenedToExposeInsertionPoint() {
+ let length = MenuBarMoveGeometry.preparedAnchorLength(
+ currentLength: 1_216,
+ anchorFrame: CGRect(x: -8, y: 0, width: 1_216, height: 30),
+ screenFrame: CGRect(x: 0, y: 0, width: 1_920, height: 1_080),
+ collapsedLength: 2
+ )
+
+ XCTAssertEqual(length, 1_200)
+ }
+
+ func testVisibleAnchorNeedsNoPreparation() {
+ XCTAssertNil(
+ MenuBarMoveGeometry.preparedAnchorLength(
+ currentLength: 24,
+ anchorFrame: CGRect(x: 32, y: 0, width: 24, height: 30),
+ screenFrame: CGRect(x: 0, y: 0, width: 1_920, height: 1_080),
+ collapsedLength: 2
+ )
+ )
+ }
+}
diff --git a/barr/app/Tests/StatusWindowGeometryTests.swift b/barr/app/Tests/StatusWindowGeometryTests.swift
new file mode 100644
index 0000000..bad0fc4
--- /dev/null
+++ b/barr/app/Tests/StatusWindowGeometryTests.swift
@@ -0,0 +1,46 @@
+import CoreGraphics
+import XCTest
+
+@testable import Barr
+
+final class StatusWindowGeometryTests: XCTestCase {
+ func testAcceptsHostedPaddingAroundSameButtonCenter() {
+ XCTAssertTrue(
+ StatusWindowGeometry.matches(
+ frame: CGRect(x: 1_300, y: 0, width: 18, height: 30),
+ buttonFrame: CGRect(x: 1_308, y: 1_050, width: 2, height: 30),
+ displayBounds: CGRect(x: 0, y: 0, width: 1_920, height: 1_080)
+ )
+ )
+ }
+
+ func testRejectsNeighboringStatusItem() {
+ XCTAssertFalse(
+ StatusWindowGeometry.matches(
+ frame: CGRect(x: 1_321, y: 0, width: 38, height: 30),
+ buttonFrame: CGRect(x: 1_308, y: 1_050, width: 2, height: 30),
+ displayBounds: CGRect(x: 0, y: 0, width: 1_920, height: 1_080)
+ )
+ )
+ }
+
+ func testRejectsMatchingHorizontalPositionOnAnotherDisplay() {
+ XCTAssertFalse(
+ StatusWindowGeometry.matches(
+ frame: CGRect(x: 1_300, y: -1_080, width: 18, height: 30),
+ buttonFrame: CGRect(x: 1_308, y: 1_050, width: 2, height: 30),
+ displayBounds: CGRect(x: 0, y: 0, width: 1_920, height: 1_080)
+ )
+ )
+ }
+
+ func testAcceptsExpandedParkingAnchor() {
+ XCTAssertTrue(
+ StatusWindowGeometry.matches(
+ frame: CGRect(x: -8, y: 0, width: 1_329, height: 30),
+ buttonFrame: CGRect(x: 0, y: 1_050, width: 1_305, height: 30),
+ displayBounds: CGRect(x: 0, y: 0, width: 1_920, height: 1_080)
+ )
+ )
+ }
+}
diff --git a/barr/app/icon-source.png b/barr/app/icon-source.png
new file mode 100644
index 0000000..3dee265
Binary files /dev/null and b/barr/app/icon-source.png differ
diff --git a/barr/app/project.yml b/barr/app/project.yml
new file mode 100644
index 0000000..4150ea1
--- /dev/null
+++ b/barr/app/project.yml
@@ -0,0 +1,61 @@
+name: Barr
+options:
+ bundleIdPrefix: dev.zackbart
+ createIntermediateGroups: true
+ deploymentTarget:
+ macOS: "14.0"
+targets:
+ Barr:
+ type: application
+ platform: macOS
+ sources:
+ - path: Sources
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.cursorkittens.Barr
+ PRODUCT_NAME: Barr
+ MARKETING_VERSION: "0.1.0"
+ CURRENT_PROJECT_VERSION: "1"
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ SWIFT_VERSION: "5.0"
+ GENERATE_INFOPLIST_FILE: YES
+ INFOPLIST_KEY_CFBundleDisplayName: Barr
+ INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.utilities"
+ INFOPLIST_KEY_LSUIElement: YES
+ INFOPLIST_KEY_NSScreenCaptureUsageDescription: "Barr mirrors menu bar app icons in its overflow shelf."
+ INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Cursor Kittens LLC"
+ ENABLE_HARDENED_RUNTIME: YES
+ CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO
+ CODE_SIGN_STYLE: Automatic
+ DEVELOPMENT_TEAM: F2J8ZU2NQJ
+ configs:
+ Debug:
+ ASSETCATALOG_COMPILER_APPICON_NAME: DebugAppIcon
+ INFOPLIST_KEY_CFBundleDisplayName: "Barr Debug"
+ PRODUCT_BUNDLE_IDENTIFIER: com.cursorkittens.Barr.debug
+ PRODUCT_MODULE_NAME: Barr
+ PRODUCT_NAME: BarrDebug
+ BarrTests:
+ type: bundle.unit-test
+ platform: macOS
+ sources:
+ - path: Tests
+ dependencies:
+ - target: Barr
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.cursorkittens.BarrTests
+ GENERATE_INFOPLIST_FILE: YES
+ configs:
+ Debug:
+ BUNDLE_LOADER: "$(TEST_HOST)"
+ TEST_HOST: "$(BUILT_PRODUCTS_DIR)/BarrDebug.app/Contents/MacOS/BarrDebug"
+schemes:
+ Barr:
+ build:
+ targets:
+ Barr: all
+ BarrTests: [test]
+ test:
+ targets:
+ - BarrTests
diff --git a/barr/todo.md b/barr/todo.md
new file mode 100644
index 0000000..3d654a3
--- /dev/null
+++ b/barr/todo.md
@@ -0,0 +1,29 @@
+# Barr TODO
+
+## Known issues
+
+- [x] Remove the visible flicker when moving an item from the **Menu Bar** row to the **In Barr** row in Settings. The source row, destination row, and compact shelf should update as one stable transition without briefly hiding, duplicating, or reordering icons.
+ - [x] Update lane membership optimistically while the WindowServer move is in flight, with rollback when the move fails.
+ - [x] Keep SwiftUI icon identity stable across WindowServer re-parenting and retain the last valid capture when a refresh temporarily returns no image.
+ - [x] Smoke-test third-party and system items in both directions; verify the two manager rows and compact shelf remain visually stable.
+- [x] Refine Barr's rendering and icon sizing. Keep mirrored icons crisp, consistently scaled, vertically centered, and readable against the shelf background across Retina scale factors and mixed icon aspect ratios.
+ - [x] Preserve each WindowServer capture's point dimensions instead of treating backing pixels as points.
+ - [x] Normalize icons to a 24-point visual height with bounded aspect-ratio-aware widths and consistent 32-point hit targets.
+ - [x] Fall back to the owning application's full-color icon when Tahoe redacts a hosted status-window capture instead of rendering an empty button.
+ - [x] Visually verify monochrome, full-color, square, and wide icons on the current Retina display; sizing remains point-based per display scale.
+- [x] Harden system menu-item behavior. Verify movable system items can move, activate, return, persist, and retain their logical order without causing other system icons to disappear.
+ - [x] Prune and validate cached WindowServer matches so a stale window ID cannot be assigned to a different system item.
+ - [x] Activate items by stable Accessibility identity before falling back to frame matching or synthetic clicks.
+ - [x] Return items beside a live logical predecessor, with Barr's visible control as the safe fallback anchor.
+ - [x] Insert newly appearing transient items beside their current neighbors instead of appending them to persisted order.
+ - [x] Avoid retrying a move against an unobserved stale window, and report collateral system-item loss in Debug builds.
+ - [x] Verify Display, Screen Mirroring, Sound, Bluetooth, Wi-Fi, and Battery in both directions, plus activation and persistence after relaunch.
+- [x] Treat Control Center, Clock, and active Audio/Video privacy controls as fixed macOS surfaces, with disabled controls and explicit accessibility help instead of silent failed moves.
+- [x] Confirm newly appearing transient controls retain their physical neighbor order.
+- [x] Hide system items from Barr's picker by default, with an opt-in **System items** setting for users who want them.
+
+## Debug builds
+
+- [x] Give Debug builds a separate bundle identifier and product name so they cannot be confused with or overwrite Release permissions, defaults, or running instances.
+- [x] Show a ladybug plus **DEBUG** in the menu bar.
+- [x] Use a visibly badged Debug app icon and the display name **Barr Debug**.
diff --git a/herdr-ios/.gitignore b/herdr-ios/.gitignore
new file mode 100644
index 0000000..902bc27
--- /dev/null
+++ b/herdr-ios/.gitignore
@@ -0,0 +1,13 @@
+# Xcode / SwiftPM
+.DS_Store
+*.xcodeproj
+!project.yml
+xcuserdata/
+*.xcworkspace
+DerivedData/
+.build/
+.swiftpm/
+Package.resolved
+
+# Generated by XcodeGen — regenerate with `xcodegen generate`
+Herdr.xcodeproj/
diff --git a/herdr-ios/App/Herdr/App/AppModel.swift b/herdr-ios/App/Herdr/App/AppModel.swift
new file mode 100644
index 0000000..7966298
--- /dev/null
+++ b/herdr-ios/App/Herdr/App/AppModel.swift
@@ -0,0 +1,69 @@
+import Foundation
+import HerdrKit
+
+/// Top-level app state: owns the connection lifecycle and, once connected,
+/// vends a `SessionModel` for the screens to read.
+@MainActor
+@Observable
+final class AppModel {
+ enum Phase {
+ case disconnected
+ case connecting(String)
+ case connected(SessionModel)
+ case failed(String)
+ }
+
+ var phase: Phase = .disconnected
+ let connections = ConnectionStore()
+
+ var isConnecting: Bool {
+ if case .connecting = phase { return true }
+ return false
+ }
+
+ /// Boot the app against in-memory sample data — the default entry point
+ /// while the SSH transport is being completed.
+ func connectDemo() async {
+ await connect(label: "Demo · Mock data") {
+ HerdrClient(transport: MockTransport())
+ }
+ }
+
+ /// Connect to a saved host over SSH, bridging to its Herdr Unix socket.
+ func connect(to host: Host) async {
+ let credential = connections.credential(for: host)
+ let connections = connections
+ await connect(label: host.displayName) {
+ HerdrClient(transport: SSHTransport(host: host, credential: credential) { key in
+ Task { @MainActor in connections.pinHostKey(key, for: host) }
+ })
+ }
+ }
+
+ func disconnect() async {
+ if case .connected(let session) = phase {
+ await session.client.disconnect()
+ }
+ phase = .disconnected
+ }
+
+ private func connect(label: String, makeClient: () -> HerdrClient) async {
+ phase = .connecting(label)
+ let client = makeClient()
+ do {
+ try await client.connect()
+ let session = SessionModel(client: client, label: label)
+ await session.start()
+ phase = .connected(session)
+ } catch {
+ phase = .failed(friendlyMessage(for: error))
+ }
+ }
+
+ private func friendlyMessage(for error: Error) -> String {
+ switch error {
+ case HerdrError.connectionFailed(let message): return message
+ default: return String(describing: error)
+ }
+ }
+}
diff --git a/herdr-ios/App/Herdr/App/HerdrApp.swift b/herdr-ios/App/Herdr/App/HerdrApp.swift
new file mode 100644
index 0000000..3a7b1d7
--- /dev/null
+++ b/herdr-ios/App/Herdr/App/HerdrApp.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+
+@main
+struct HerdrApp: App {
+ @State private var app = AppModel()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environment(app)
+ }
+ }
+}
+
+/// Switches between the connect screen and the connected session.
+struct RootView: View {
+ @Environment(AppModel.self) private var app
+
+ var body: some View {
+ switch app.phase {
+ case .connected(let session):
+ WorkspaceListView()
+ .environment(session)
+ default:
+ ConnectView()
+ }
+ }
+}
diff --git a/herdr-ios/App/Herdr/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/herdr-ios/App/Herdr/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
new file mode 100644
index 0000000..6ada650
Binary files /dev/null and b/herdr-ios/App/Herdr/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ
diff --git a/herdr-ios/App/Herdr/Assets.xcassets/AppIcon.appiconset/Contents.json b/herdr-ios/App/Herdr/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..f22e10c
--- /dev/null
+++ b/herdr-ios/App/Herdr/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "AppIcon-1024.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/herdr-ios/App/Herdr/Assets.xcassets/Contents.json b/herdr-ios/App/Herdr/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/herdr-ios/App/Herdr/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/herdr-ios/App/Herdr/Assets.xcassets/Logo.imageset/Contents.json b/herdr-ios/App/Herdr/Assets.xcassets/Logo.imageset/Contents.json
new file mode 100644
index 0000000..5dea135
--- /dev/null
+++ b/herdr-ios/App/Herdr/Assets.xcassets/Logo.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "herdr-logo.png"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/herdr-ios/App/Herdr/Assets.xcassets/Logo.imageset/herdr-logo.png b/herdr-ios/App/Herdr/Assets.xcassets/Logo.imageset/herdr-logo.png
new file mode 100644
index 0000000..6ada650
Binary files /dev/null and b/herdr-ios/App/Herdr/Assets.xcassets/Logo.imageset/herdr-logo.png differ
diff --git a/herdr-ios/App/Herdr/Connection/ConnectView.swift b/herdr-ios/App/Herdr/Connection/ConnectView.swift
new file mode 100644
index 0000000..1047973
--- /dev/null
+++ b/herdr-ios/App/Herdr/Connection/ConnectView.swift
@@ -0,0 +1,246 @@
+import SwiftUI
+import HerdrKit
+
+/// Entry screen: pick a saved host to connect over SSH, add a new one, or open
+/// the in-memory demo.
+struct ConnectView: View {
+ @Environment(AppModel.self) private var app
+ @State private var editingHost: Host?
+ @State private var showingNewHost = false
+
+ private var store: ConnectionStore { app.connections }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ BrandHero()
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets(top: 28, leading: 16, bottom: 12, trailing: 16))
+ }
+
+ Section {
+ Button {
+ Task { await app.connectDemo() }
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: "play.fill")
+ .font(.footnote.weight(.bold))
+ .foregroundStyle(Theme.prompt)
+ .frame(width: 24)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Open demo workspace")
+ .font(.body.weight(.medium))
+ .foregroundStyle(.primary)
+ Text("Realistic sample data — no server")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(app.isConnecting)
+ } header: {
+ SectionEyebrow("quick start")
+ }
+
+ Section {
+ if store.hosts.isEmpty {
+ Text("No hosts yet. Add the machine where Herdr runs.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ ForEach(store.hosts) { host in
+ Button {
+ Task { await app.connect(to: host) }
+ } label: {
+ HostRow(host: host)
+ }
+ .buttonStyle(.plain)
+ .swipeActions {
+ Button(role: .destructive) { store.remove(host) } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ Button { editingHost = host } label: {
+ Label("Edit", systemImage: "pencil")
+ }.tint(Theme.ink)
+ }
+ }
+ } header: {
+ SectionEyebrow("hosts")
+ }
+
+ if case .failed(let message) = app.phase {
+ Section {
+ Label(message, systemImage: "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(Theme.blocked)
+ }
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button { showingNewHost = true } label: { Image(systemName: "plus") }
+ .tint(Theme.ink)
+ }
+ }
+ .overlay {
+ if case .connecting(let label) = app.phase {
+ ConnectingOverlay(label: label)
+ }
+ }
+ .sheet(isPresented: $showingNewHost) {
+ HostEditor(host: Host()) { host, secret in
+ store.upsert(host, secret: secret)
+ }
+ }
+ .sheet(item: $editingHost) { host in
+ HostEditor(host: host) { updated, secret in
+ store.upsert(updated, secret: secret)
+ }
+ }
+ }
+ .tint(Theme.prompt)
+ }
+}
+
+/// The logo mark, mono wordmark, and tagline — the app's identity, shown once
+/// at the top of the connect screen.
+private struct BrandHero: View {
+ var body: some View {
+ VStack(spacing: 12) {
+ Image("Logo")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 78, height: 78)
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(Theme.ink.opacity(0.08))
+ )
+ .shadow(color: Theme.ink.opacity(0.18), radius: 10, y: 5)
+
+ VStack(spacing: 3) {
+ Text("herdr")
+ .font(Theme.mono(32, .bold))
+ .tracking(0.5)
+ .foregroundStyle(Theme.ink)
+ Text("mind the flock from anywhere")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+}
+
+private struct HostRow: View {
+ let host: Host
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: "terminal.fill")
+ .font(.callout)
+ .foregroundStyle(Theme.ink.opacity(0.55))
+ .frame(width: 24)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(host.displayName).font(.body.weight(.medium))
+ Text(host.subtitle).font(Theme.mono(12)).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.vertical, 4)
+ .contentShape(Rectangle())
+ }
+}
+
+private struct ConnectingOverlay: View {
+ let label: String
+ var body: some View {
+ ZStack {
+ Color(.systemBackground).opacity(0.75).ignoresSafeArea()
+ VStack(spacing: 14) {
+ ProgressView()
+ Text("Connecting to \(label)…")
+ .font(Theme.mono(13))
+ .foregroundStyle(.secondary)
+ }
+ .padding(28)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
+ }
+ }
+}
+
+/// Add/edit a host. The secret field stores the private key or password in the
+/// Keychain on save.
+private struct HostEditor: View {
+ @Environment(\.dismiss) private var dismiss
+ @State var host: Host
+ @State private var secret: String = ""
+ let onSave: (Host, String?) -> Void
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Connection") {
+ TextField("Nickname (optional)", text: $host.nickname)
+ TextField("Hostname", text: $host.hostname)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Username", text: $host.username)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Port", value: $host.port, format: .number)
+ .keyboardType(.numberPad)
+ }
+
+ Section("Authentication") {
+ Picker("Method", selection: $host.authMethod) {
+ ForEach(AuthMethod.allCases) { Text($0.title).tag($0) }
+ }
+ switch host.authMethod {
+ case .privateKey:
+ TextField("Paste private key (PEM)", text: $secret, axis: .vertical)
+ .font(.caption.monospaced())
+ .lineLimit(3...8)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ case .password:
+ SecureField("Password", text: $secret)
+ }
+ }
+
+ Section {
+ TextField("Socket path (optional)", text: $host.socketPath)
+ .font(.caption.monospaced())
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ } header: {
+ Text("Herdr socket")
+ } footer: {
+ Text("Leave blank to auto-detect. Herdr's socket is found automatically under ~/.config/herdr — set this only to target a specific session or a non-standard path.")
+ }
+ }
+ .navigationTitle(host.hostname.isEmpty ? "New host" : host.displayName)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ onSave(host, secret.isEmpty ? nil : secret)
+ dismiss()
+ }
+ .disabled(host.hostname.isEmpty || host.username.isEmpty)
+ }
+ }
+ }
+ }
+}
diff --git a/herdr-ios/App/Herdr/Connection/ConnectionStore.swift b/herdr-ios/App/Herdr/Connection/ConnectionStore.swift
new file mode 100644
index 0000000..c698122
--- /dev/null
+++ b/herdr-ios/App/Herdr/Connection/ConnectionStore.swift
@@ -0,0 +1,67 @@
+import Foundation
+
+/// Persists saved hosts (non-secret fields in `UserDefaults`) and brokers their
+/// secrets through the Keychain.
+@MainActor
+@Observable
+final class ConnectionStore {
+ private(set) var hosts: [Host] = []
+
+ private let defaultsKey = "herdr.hosts.v1"
+ private let keychain = KeychainStore(service: "dev.herdr.client")
+
+ init() {
+ load()
+ }
+
+ /// Add or update a host. `secret` is the private key or password to stash in
+ /// the Keychain (pass `nil` to leave any existing secret untouched).
+ func upsert(_ host: Host, secret: String?) {
+ if let index = hosts.firstIndex(where: { $0.id == host.id }) {
+ hosts[index] = host
+ } else {
+ hosts.append(host)
+ }
+ persist()
+ if let secret, !secret.isEmpty {
+ keychain.set(secret, account: host.id.uuidString)
+ }
+ }
+
+ func remove(_ host: Host) {
+ hosts.removeAll { $0.id == host.id }
+ persist()
+ keychain.delete(account: host.id.uuidString)
+ }
+
+ /// Record the SSH host key trusted on first connect (TOFU). Persists so later
+ /// connections can detect a changed key. No-op if the host is gone or already
+ /// pinned to this key.
+ func pinHostKey(_ key: String, for host: Host) {
+ guard let index = hosts.firstIndex(where: { $0.id == host.id }),
+ hosts[index].knownHostKey != key else { return }
+ hosts[index].knownHostKey = key
+ persist()
+ }
+
+ func credential(for host: Host) -> Credential {
+ let secret = keychain.get(account: host.id.uuidString)
+ switch host.authMethod {
+ case .password:
+ return Credential(password: secret)
+ case .privateKey:
+ return Credential(privateKey: secret)
+ }
+ }
+
+ private func load() {
+ guard let data = UserDefaults.standard.data(forKey: defaultsKey),
+ let decoded = try? JSONDecoder().decode([Host].self, from: data) else { return }
+ hosts = decoded
+ }
+
+ private func persist() {
+ guard let data = try? JSONEncoder().encode(hosts) else { return }
+ UserDefaults.standard.set(data, forKey: defaultsKey)
+ }
+}
diff --git a/herdr-ios/App/Herdr/Connection/Host.swift b/herdr-ios/App/Herdr/Connection/Host.swift
new file mode 100644
index 0000000..8fd0681
--- /dev/null
+++ b/herdr-ios/App/Herdr/Connection/Host.swift
@@ -0,0 +1,51 @@
+import Foundation
+
+/// How we authenticate the SSH connection to a host.
+enum AuthMethod: String, Codable, Sendable, CaseIterable, Identifiable {
+ case privateKey
+ case password
+
+ var id: String { rawValue }
+ var title: String {
+ switch self {
+ case .privateKey: return "Private key"
+ case .password: return "Password"
+ }
+ }
+}
+
+/// A saved SSH connection to a machine running Herdr. Non-secret fields are
+/// persisted in `UserDefaults`; the secret (key or password) lives in the
+/// Keychain keyed by `id`.
+struct Host: Identifiable, Codable, Hashable, Sendable {
+ var id = UUID()
+ var nickname: String = ""
+ var hostname: String = ""
+ var port: Int = 22
+ var username: String = ""
+ var authMethod: AuthMethod = .privateKey
+ /// Optional override for the remote Herdr socket. Blank = auto-detect on
+ /// connect: the default session (`~/.config/herdr/herdr.sock`), or the sole
+ /// running session under `~/.config/herdr/sessions//`. Set this only to
+ /// target a specific named session or a non-standard path.
+ var socketPath: String = ""
+ /// The SSH host key trusted on first connect (TOFU), as an OpenSSH
+ /// `"algo base64"` string. Non-secret. `nil` until the first successful
+ /// connection pins it; later connects reject a key that doesn't match.
+ var knownHostKey: String?
+
+ var displayName: String {
+ nickname.isEmpty ? "\(username)@\(hostname)" : nickname
+ }
+
+ var subtitle: String {
+ "\(username)@\(hostname):\(port)"
+ }
+}
+
+/// The secret material for a host, fetched from the Keychain at connect time.
+struct Credential: Sendable {
+ var password: String?
+ var privateKey: String?
+ var passphrase: String?
+}
diff --git a/herdr-ios/App/Herdr/Connection/KeychainStore.swift b/herdr-ios/App/Herdr/Connection/KeychainStore.swift
new file mode 100644
index 0000000..eab6323
--- /dev/null
+++ b/herdr-ios/App/Herdr/Connection/KeychainStore.swift
@@ -0,0 +1,45 @@
+import Foundation
+import Security
+
+/// Thin wrapper over the iOS Keychain for storing per-host SSH secrets (a
+/// private key or password) as generic-password items keyed by account.
+struct KeychainStore {
+ let service: String
+
+ func set(_ value: String, account: String) {
+ let data = Data(value.utf8)
+ // Replace any existing item.
+ delete(account: account)
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ]
+ SecItemAdd(query as CFDictionary, nil)
+ }
+
+ func get(account: String) -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ var item: CFTypeRef?
+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
+ let data = item as? Data else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
+ func delete(account: String) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+}
diff --git a/herdr-ios/App/Herdr/Connection/SSHTransport.swift b/herdr-ios/App/Herdr/Connection/SSHTransport.swift
new file mode 100644
index 0000000..1491207
--- /dev/null
+++ b/herdr-ios/App/Herdr/Connection/SSHTransport.swift
@@ -0,0 +1,315 @@
+import Foundation
+import Citadel
+import Crypto // `Insecure` namespace + Curve25519
+import NIOCore // `ByteBuffer`, `EventLoopPromise`
+import NIOSSH // `NIOSSHPublicKey` + host-key validation delegate (TOFU pinning)
+import HerdrKit
+
+/// SSH-bridged transport to a remote Herdr Unix socket.
+///
+/// Herdr exposes no network port — its API is a local Unix domain socket
+/// (`~/.config/herdr/herdr.sock`) — and it is **one-request-per-connection**:
+/// the server closes the socket after each RPC reply; only `events.subscribe`
+/// stays open to stream events. So each RPC opens its own short-lived SSH exec
+/// channel that bridges stdio to the socket with `nc -U` (or `socat`), and
+/// subscriptions get a dedicated long-lived channel. Host-key validation
+/// currently accepts any key (TOFU pinning is a follow-up).
+public actor SSHTransport: HerdrTransport {
+ private let host: Host
+ private let credential: Credential
+ /// Called with the OpenSSH host-key string after a first successful connect,
+ /// so the caller can pin it (TOFU). No-op for hosts already pinned.
+ private let pinHostKey: @Sendable (String) -> Void
+
+ private var client: SSHClient?
+ private var socketPath: String?
+
+ init(host: Host, credential: Credential, pinHostKey: @escaping @Sendable (String) -> Void = { _ in }) {
+ self.host = host
+ self.credential = credential
+ self.pinHostKey = pinHostKey
+ }
+
+ // MARK: Lifecycle
+
+ public func connect() async throws {
+ guard client == nil else { return }
+ guard !host.hostname.isEmpty, !host.username.isEmpty else {
+ throw HerdrError.connectionFailed("This host is missing a hostname or username.")
+ }
+
+ let auth = try authenticationMethod()
+ // TOFU host-key pinning: trust the key on first connect and remember it;
+ // on later connects reject any key that doesn't match the pinned one.
+ let knownKey = host.knownHostKey
+ let recorder = HostKeyRecorder()
+ let client: SSHClient
+ do {
+ client = try await SSHClient.connect(
+ host: host.hostname,
+ port: host.port,
+ authenticationMethod: auth,
+ hostKeyValidator: .custom(TOFUHostKeyValidator(expected: knownKey, recorder: recorder)),
+ reconnect: .never
+ )
+ } catch {
+ // A recorded key that differs from the pinned one means the validator
+ // rejected it — surface that distinctly from a generic failure.
+ if let seen = recorder.seenKey, let knownKey, seen != knownKey {
+ throw HerdrError.connectionFailed(
+ "The SSH host key for \(host.displayName) has changed since you last connected. "
+ + "This can happen if the server was reinstalled — but it can also mean the "
+ + "connection is being intercepted. If you trust the change, remove and re-add this host."
+ )
+ }
+ throw HerdrError.connectionFailed("Couldn't connect to \(host.displayName): \(error)")
+ }
+ // First successful connect: pin the key we just trusted.
+ if knownKey == nil, let seen = recorder.seenKey { pinHostKey(seen) }
+
+ // Resolve the socket path before publishing any state, so a discovery
+ // failure can't leave the actor half-connected (client set, socketPath
+ // nil) with the SSH session leaked.
+ let resolved: String
+ let override = host.socketPath.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !override.isEmpty {
+ resolved = override
+ } else {
+ do {
+ let found = try await discoverSocketPaths(client: client)
+ guard let chosen = found.first else {
+ throw HerdrError.connectionFailed(
+ "Couldn't find a running Herdr socket on \(host.displayName) (looked under "
+ + "~/.config/herdr). Is Herdr running there?"
+ )
+ }
+ resolved = chosen
+ } catch {
+ try? await client.close()
+ throw error
+ }
+ }
+ self.client = client
+ self.socketPath = resolved
+ }
+
+ public func disconnect() async {
+ if let client { try? await client.close() }
+ client = nil
+ socketPath = nil
+ }
+
+ // MARK: Request / response (one-shot per connection)
+
+ /// How long to wait for a one-shot reply before giving up. Herdr replies and
+ /// closes immediately; this only guards against a wedged bridge/connection.
+ private static let requestTimeout: Duration = .seconds(20)
+
+ public func request(_ request: RPCRequest) async throws -> RPCResponse {
+ guard let client, let socketPath else { throw HerdrError.notConnected }
+ let command = Self.bridgeCommand(socketPath: socketPath)
+ let frame = try NDJSON.frame(request)
+ return try await withThrowingTaskGroup(of: RPCResponse.self) { group in
+ group.addTask { try await Self.roundTrip(client: client, command: command, frame: frame) }
+ group.addTask {
+ try await Task.sleep(for: Self.requestTimeout)
+ throw HerdrError.connectionFailed("The Herdr request timed out (no reply from the host).")
+ }
+ defer { group.cancelAll() }
+ return try await group.next()!
+ }
+ }
+
+ /// Open a one-shot bridge channel, send the request, and return the first
+ /// decoded reply. The post-reply channel close is expected; a close with no
+ /// reply preserves the underlying failure.
+ private static func roundTrip(client: SSHClient, command: String, frame: Data) async throws -> RPCResponse {
+ let collector = Collector()
+ do {
+ try await client.withExec(command) { inbound, outbound in
+ try await outbound.write(ByteBuffer(bytes: frame))
+ do {
+ for try await chunk in inbound {
+ guard case .stdout(let buffer) = chunk else { continue }
+ for line in collector.buffer.append(Self.data(buffer)) {
+ if collector.response == nil,
+ case .response(let response)? = try? IncomingMessage.decode(line: line) {
+ collector.response = response
+ }
+ }
+ }
+ } catch {
+ collector.failure = error
+ }
+ }
+ } catch {
+ collector.failure = collector.failure ?? error
+ }
+ if let response = collector.response { return response }
+ if let failure = collector.failure {
+ throw HerdrError.connectionFailed(
+ "The Herdr socket bridge failed: \(failure). Check that `nc` (or `socat`) is "
+ + "available on the host and the socket path is correct."
+ )
+ }
+ throw HerdrError.transportClosed
+ }
+
+ // MARK: Events (persistent subscription channel)
+
+ public nonisolated func events(_ subscribeRequest: RPCRequest) -> AsyncStream {
+ AsyncStream { continuation in
+ let task = Task { [weak self] in
+ guard let self, let conn = await self.connection() else {
+ continuation.finish(); return
+ }
+ let command = Self.bridgeCommand(socketPath: conn.socketPath)
+ let frame = (try? NDJSON.frame(subscribeRequest)) ?? Data()
+ let collector = Collector()
+ do {
+ try await conn.client.withExec(command) { inbound, outbound in
+ try await outbound.write(ByteBuffer(bytes: frame))
+ for try await chunk in inbound {
+ guard case .stdout(let buffer) = chunk else { continue }
+ for line in collector.buffer.append(Self.data(buffer)) {
+ if let message = try? IncomingMessage.decode(line: line) {
+ continuation.yield(message)
+ }
+ }
+ }
+ }
+ } catch {
+ // Subscription channel closed (disconnect / cancel / server).
+ }
+ continuation.finish()
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
+ private func connection() -> (client: SSHClient, socketPath: String)? {
+ guard let client, let socketPath else { return nil }
+ return (client, socketPath)
+ }
+
+ /// Accumulates bytes per channel and holds the first decoded reply.
+ private final class Collector: @unchecked Sendable {
+ var buffer = LineBuffer()
+ var response: RPCResponse?
+ var failure: Error?
+ }
+
+ /// Captures the host key the server presented, so `connect` can pin it after
+ /// a first connect or distinguish a mismatch from a generic failure. Written
+ /// on the handshake's event loop and read only after `connect` returns, so
+ /// the access never races.
+ private final class HostKeyRecorder: @unchecked Sendable {
+ var seenKey: String?
+ }
+
+ /// Trust-on-first-use host-key validator. Records the presented key, then
+ /// accepts it if nothing is pinned yet (`expected == nil`) or it matches the
+ /// pinned key; otherwise rejects, so a changed key aborts the handshake.
+ private struct TOFUHostKeyValidator: NIOSSHClientServerAuthenticationDelegate {
+ let expected: String?
+ let recorder: HostKeyRecorder
+
+ func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise) {
+ let seen = String(openSSHPublicKey: hostKey)
+ recorder.seenKey = seen
+ if expected == nil || expected == seen {
+ validationCompletePromise.succeed(())
+ } else {
+ validationCompletePromise.fail(HerdrError.connectionFailed("SSH host key mismatch."))
+ }
+ }
+ }
+
+ private static func data(_ buffer: ByteBuffer) -> Data {
+ Data(buffer.getBytes(at: buffer.readerIndex, length: buffer.readableBytes) ?? [])
+ }
+
+ // MARK: Helpers
+
+ private func authenticationMethod() throws -> SSHAuthenticationMethod {
+ switch host.authMethod {
+ case .password:
+ guard let password = credential.password, !password.isEmpty else {
+ throw HerdrError.connectionFailed("No password saved for \(host.displayName).")
+ }
+ return .passwordBased(username: host.username, password: password)
+
+ case .privateKey:
+ guard let pem = credential.privateKey, !pem.isEmpty else {
+ throw HerdrError.connectionFailed("No private key saved for \(host.displayName).")
+ }
+ let key = pem.trimmingCharacters(in: .whitespacesAndNewlines)
+ let decryptionKey = credential.passphrase
+ .flatMap { $0.isEmpty ? nil : $0 }
+ .map { Data($0.utf8) }
+
+ // OpenSSH-format keys (`BEGIN OPENSSH PRIVATE KEY`) can hold either an
+ // ed25519 or RSA key; classic PEM (`BEGIN RSA PRIVATE KEY`) is RSA.
+ // Try ed25519 first, then RSA, so any common key type works.
+ if let ed = try? Curve25519.Signing.PrivateKey(sshEd25519: key, decryptionKey: decryptionKey) {
+ return .ed25519(username: host.username, privateKey: ed)
+ }
+ do {
+ let rsa = try Insecure.RSA.PrivateKey(sshRsa: key, decryptionKey: decryptionKey)
+ return .rsa(username: host.username, privateKey: rsa)
+ } catch {
+ throw HerdrError.connectionFailed(
+ "Couldn't read this private key. Supported types are OpenSSH ed25519 and RSA"
+ + " — if the key is encrypted, add its passphrase, or use password auth."
+ )
+ }
+ }
+ }
+
+ /// Probe the remote host for live Herdr sockets, most-preferred first. Mirrors
+ /// Herdr's documented resolution order: `HERDR_SOCKET_PATH`, then the default
+ /// session socket, then named sessions under `~/.config/herdr/sessions//`.
+ /// Wrapped in `sh -c` (POSIX, any login shell) and ended with `; true` so the
+ /// command always exits 0 — Citadel's `executeCommand` throws on non-zero exit,
+ /// and an unmatched `sessions/*` glob makes the final `[ -S … ]` test fail.
+ private func discoverSocketPaths(client: SSHClient) async throws -> [String] {
+ let probe = #"sh -c 'for p in "$HERDR_SOCKET_PATH" "$HOME/.config/herdr/herdr.sock" "$HOME"/.config/herdr/sessions/*/herdr.sock; do [ -S "$p" ] && echo "$p"; done; true'"#
+ let output: ByteBuffer
+ do {
+ output = try await client.executeCommand(probe)
+ } catch {
+ throw HerdrError.connectionFailed(
+ "Couldn't search for the Herdr socket on \(host.displayName): \(error)"
+ )
+ }
+ let text = output.getString(at: output.readerIndex, length: output.readableBytes) ?? ""
+ var seen = Set()
+ return text
+ .split(separator: "\n")
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty && seen.insert($0).inserted }
+ }
+
+ /// Shell command run on the remote host to bridge stdio to the Herdr socket.
+ /// A leading `~` is rewritten to `$HOME` so the remote shell expands it
+ /// (tilde expansion doesn't fire mid-word, but `$HOME` does). For the
+ /// one-shot model, `nc -U` is sufficient; `socat` is used if present.
+ static func bridgeCommand(socketPath: String) -> String {
+ // Build a shell-safe target. A leading `~` becomes an unquoted `"$HOME"`
+ // (so the remote shell expands it); the remainder is single-quoted so an
+ // override path can't inject shell syntax.
+ let target: String
+ if socketPath.hasPrefix("~") {
+ target = "\"$HOME\"" + singleQuoted(String(socketPath.dropFirst()))
+ } else {
+ target = singleQuoted(socketPath)
+ }
+ return "socat - UNIX-CONNECT:\(target) || nc -U \(target)"
+ }
+
+ /// POSIX single-quote escaping: wrap in `'…'`, closing/escaping/reopening for
+ /// any embedded single quote.
+ private static func singleQuoted(_ string: String) -> String {
+ "'" + string.replacingOccurrences(of: "'", with: "'\\''") + "'"
+ }
+}
diff --git a/herdr-ios/App/Herdr/DesignSystem/StatusBadge.swift b/herdr-ios/App/Herdr/DesignSystem/StatusBadge.swift
new file mode 100644
index 0000000..5f1c153
--- /dev/null
+++ b/herdr-ios/App/Herdr/DesignSystem/StatusBadge.swift
@@ -0,0 +1,86 @@
+import SwiftUI
+import HerdrKit
+
+/// A small colored dot for a single agent status, optionally pulsing while the
+/// agent is actively working.
+struct StatusDot: View {
+ let status: AgentStatus
+ var size: CGFloat = 9
+ var pulses: Bool = false
+ @State private var animate = false
+
+ var body: some View {
+ Circle()
+ .fill(status.color)
+ .frame(width: size, height: size)
+ .opacity(pulses && status == .working ? (animate ? 0.35 : 1) : 1)
+ .animation(
+ pulses && status == .working
+ ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true)
+ : .default,
+ value: animate
+ )
+ .onAppear { animate = true }
+ .accessibilityLabel(status.label)
+ }
+}
+
+/// A compact row of " " pairs summarizing how many agents sit in
+/// each status within a workspace.
+struct StatusSummary: View {
+ let counts: [AgentStatus: Int]
+
+ private var ordered: [(AgentStatus, Int)] {
+ AgentStatus.allCases
+ .compactMap { status in counts[status].map { (status, $0) } }
+ .filter { $0.1 > 0 }
+ .sorted { $0.0.priority > $1.0.priority }
+ }
+
+ var body: some View {
+ HStack(spacing: 10) {
+ ForEach(ordered, id: \.0) { status, count in
+ HStack(spacing: 4) {
+ StatusDot(status: status, size: 7)
+ Text("\(count)")
+ .font(Theme.mono(12, .medium))
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityLabel("\(count) \(status.label)")
+ }
+ }
+ }
+}
+
+/// A status pill — a pulsing dot plus a mono label on a faintly tinted capsule.
+/// Used in pane rows and the pane toolbar.
+struct StatusTag: View {
+ let status: AgentStatus
+
+ var body: some View {
+ HStack(spacing: 5) {
+ StatusDot(status: status, size: 7, pulses: true)
+ Text(status.label.uppercased())
+ .font(Theme.mono(10, .semibold))
+ .tracking(0.5)
+ .foregroundStyle(status.color)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(status.color.opacity(0.13), in: Capsule())
+ }
+}
+
+/// A small uppercase monospace section label — the structural eyebrow used for
+/// list section headers.
+struct SectionEyebrow: View {
+ let text: String
+ init(_ text: String) { self.text = text }
+
+ var body: some View {
+ Text(text.uppercased())
+ .font(Theme.mono(11, .semibold))
+ .tracking(1.5)
+ .foregroundStyle(.secondary)
+ }
+}
diff --git a/herdr-ios/App/Herdr/DesignSystem/Theme.swift b/herdr-ios/App/Herdr/DesignSystem/Theme.swift
new file mode 100644
index 0000000..3343bfc
--- /dev/null
+++ b/herdr-ios/App/Herdr/DesignSystem/Theme.swift
@@ -0,0 +1,83 @@
+import SwiftUI
+import HerdrKit
+
+/// Design tokens for Herdr. The app is terminal-native: light, legible chrome
+/// for scanning the flock, a genuinely dark terminal surface in the pane view,
+/// and monospace type wherever machine data appears (hosts, ids, output).
+enum Theme {
+ // Brand neutrals, pulled from the ram mark.
+ static let ink = Color(hex: 0x23272B)
+
+ // Terminal surface (PaneView) — light mode: a clean near-white paper with
+ // dark ink, matching the rest of the app.
+ static let terminalBG = Color(hex: 0xFCFCFA)
+ static let terminalSurface = Color(hex: 0xEDECE8)
+ static let terminalText = Color(hex: 0x23272B)
+ static let terminalDim = Color(hex: 0x8A9099)
+ /// The prompt accent — echoes the `>-` terminal-prompt eye in the logo.
+ static let prompt = Color(hex: 0x57B89E)
+
+ // Status palette — refined tones, but keeping Herdr's documented legend
+ // semantics (blocked/working/done/idle/unknown).
+ static let blocked = Color(hex: 0xE5484D)
+ static let working = Color(hex: 0xE0A52E)
+ static let done = Color(hex: 0x5B8DEF)
+ static let idle = Color(hex: 0x4FA46B)
+ static let unknown = Color(hex: 0x868D95)
+
+ // Type. `monospaced` is the scrollback face; `mono(_:_:)` is the utility
+ // face for hosts, ids, counts, eyebrows, and the wordmark.
+ static let monospaced = Font.system(.callout, design: .monospaced)
+ static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
+ .system(size: size, weight: weight, design: .monospaced)
+ }
+}
+
+extension Color {
+ /// Build a color from a 24-bit RGB hex literal, e.g. `Color(hex: 0x23272B)`.
+ init(hex: UInt32) {
+ self.init(
+ .sRGB,
+ red: Double((hex >> 16) & 0xFF) / 255,
+ green: Double((hex >> 8) & 0xFF) / 255,
+ blue: Double(hex & 0xFF) / 255,
+ opacity: 1
+ )
+ }
+}
+
+// Status presentation, matching Herdr's sidebar legend:
+// 🔴 blocked · 🟡 working · 🔵 done · 🟢 idle · ⚪️ unknown.
+extension AgentStatus {
+ var color: Color {
+ switch self {
+ case .blocked: return Theme.blocked
+ case .working: return Theme.working
+ case .done: return Theme.done
+ case .idle: return Theme.idle
+ case .unknown: return Theme.unknown
+ }
+ }
+
+ /// Short human label for badges and accessibility.
+ var label: String {
+ switch self {
+ case .blocked: return "Blocked"
+ case .working: return "Working"
+ case .done: return "Done"
+ case .idle: return "Idle"
+ case .unknown: return "Unknown"
+ }
+ }
+
+ /// SF Symbol used alongside the status dot.
+ var symbol: String {
+ switch self {
+ case .blocked: return "exclamationmark.circle.fill"
+ case .working: return "circle.dotted"
+ case .done: return "checkmark.circle.fill"
+ case .idle: return "moon.zzz.fill"
+ case .unknown: return "questionmark.circle"
+ }
+ }
+}
diff --git a/herdr-ios/App/Herdr/Features/Pane/PaneView.swift b/herdr-ios/App/Herdr/Features/Pane/PaneView.swift
new file mode 100644
index 0000000..7f0dfa5
--- /dev/null
+++ b/herdr-ios/App/Herdr/Features/Pane/PaneView.swift
@@ -0,0 +1,563 @@
+import Foundation
+import SwiftUI
+import HerdrKit
+
+/// How a pane's terminal output is laid out on a phone screen. A terminal is a
+/// fixed-width grid; reflowing it to phone width scrambles box-drawn / columnar
+/// TUI layouts, so the grid modes (`fit`, `scroll`) render the raw grid faithfully
+/// and only `reader` reflows (for long plain prose).
+enum PaneRenderMode: String, CaseIterable {
+ /// Faithful grid, font auto-shrunk so the whole width fits — no scrolling.
+ case fit
+ /// Faithful grid at a fixed readable font; pan left/right to see the rest.
+ case scroll
+ /// Cleaned, unwrapped output reflowed to phone width — layout not preserved.
+ case reader
+
+ var label: String {
+ switch self {
+ case .fit: return "Fit"
+ case .scroll: return "Scroll"
+ case .reader: return "Reader"
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .fit: return "arrow.down.right.and.arrow.up.left"
+ case .scroll: return "arrow.left.and.right"
+ case .reader: return "text.alignleft"
+ }
+ }
+
+ /// Next mode in the cycle, for the single toggle button.
+ var next: PaneRenderMode {
+ let all = Self.allCases
+ return all[(all.firstIndex(of: self)! + 1) % all.count]
+ }
+}
+
+/// Screen 3: read a pane's output and send input, rendered as a light terminal.
+/// Output renders in one of three `PaneRenderMode`s (cycle button in the toolbar); an
+/// iSH-style key bar (sticky Ctrl, Esc, arrows) rides above the keyboard. Grid
+/// modes read the raw hard-wrapped grid (`recent`, with history; `visible` for
+/// alt-screen TUIs); reader reads `recent_unwrapped`.
+struct PaneView: View {
+ @Environment(SessionModel.self) private var session
+ let paneID: PaneID
+
+ @State private var input: String = ""
+ @State private var ctrlActive = false
+ @AppStorage("paneRenderMode") private var mode: PaneRenderMode = .fit
+ /// Readable font size for Scroll/Reader modes (Fit auto-sizes, so it's exempt).
+ @AppStorage("paneFontSize") private var fontSize: Double = 13
+ /// Raw terminal grid lines for the `fit`/`scroll` modes (uncleaned `recent`).
+ @State private var gridLines: [String] = []
+ /// Whether the scroll view is parked near the bottom — gates auto-stick so new
+ /// output doesn't yank the user off history they've scrolled up to read.
+ @State private var isPinned = true
+ /// Bumped on every accepted key/send to drive one-shot haptic feedback.
+ @State private var hapticTick = 0
+ @FocusState private var inputFocused: Bool
+
+ private var pane: Pane? { session.pane(paneID) }
+ private var lines: [String] { session.outputs[paneID] ?? [] }
+ /// Monospace advance ≈ 0.6em for the system monospaced font.
+ // ponytail: fixed ratio, not measured — fine for SF Mono; revisit if a
+ // proportional or CJK-heavy font ever sneaks in.
+ private let monoAdvance = 0.6
+
+ var body: some View {
+ VStack(spacing: 0) {
+ content
+ Rectangle()
+ .fill(Theme.terminalDim.opacity(0.18))
+ .frame(height: 1)
+ if inputFocused {
+ keyControlBar
+ }
+ inputBar
+ }
+ .background(Theme.terminalBG, ignoresSafeAreaEdges: .bottom)
+ .sensoryFeedback(.impact(weight: .light), trigger: hapticTick)
+ .navigationTitle(pane?.title ?? paneID.rawValue)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ if mode != .fit { fontSizeButtons }
+ modeButton
+ if let pane, pane.isAgent {
+ StatusTag(status: pane.status)
+ }
+ }
+ }
+ // Keep the pane live by re-reading whenever it emits new output. The
+ // socket API pushes no pane-output events, but `pane.wait_for_output`
+ // lets us block until the screen changes (or the wait times out) instead
+ // of polling on a fixed timer — instant on activity, quiet while idle.
+ // Re-keyed on `mode` so flipping modes re-reads the right source. `.task`
+ // cancels on disappear / id change.
+ .task(id: pollKey) {
+ while !Task.isCancelled {
+ if mode == .reader {
+ await session.refreshPaneDisplay(for: paneID, isAgent: pane?.isAgent == true)
+ await session.awaitOutput(for: paneID, source: PaneReadSource.recentUnwrapped)
+ } else {
+ let fresh = await session.rawTerminal(for: paneID)
+ if Task.isCancelled { return } // don't clobber a newer pane/mode's grid
+ // Keep the last good grid only on a read *failure* (nil), as
+ // Reader does via its `outputs[pane]` fallback — but let a
+ // genuinely empty screen through so a cleared pane isn't pinned.
+ if let fresh { gridLines = fresh }
+ // Wait on `recent` — the source the grid now reads — so new
+ // scrollback wakes us. (Alt-screen panes, served from `visible`,
+ // just fall back to the wait's timeout poll.)
+ await session.awaitOutput(for: paneID, source: PaneReadSource.recent)
+ }
+ }
+ }
+ }
+
+ /// Restart the read loop when the pane or render mode changes — each mode
+ /// reads a different `pane.read` source.
+ private var pollKey: String { "\(paneID.rawValue)|\(mode.rawValue)" }
+
+ /// Single toolbar button that cycles fit → scroll → reader, replacing the
+ /// space-hungry segmented control. Shows the current mode so the next tap is
+ /// predictable.
+ private var modeButton: some View {
+ Button { mode = mode.next } label: {
+ HStack(spacing: 4) {
+ Image(systemName: mode.icon)
+ Text(mode.label)
+ }
+ .font(Theme.mono(11, .semibold))
+ }
+ .tint(Theme.prompt)
+ .accessibilityLabel("Layout: \(mode.label). Tap to change.")
+ }
+
+ /// A−/A+ pair for the Scroll/Reader font, clamped to a legible range.
+ private var fontSizeButtons: some View {
+ HStack(spacing: 2) {
+ Button { fontSize = max(9, fontSize - 1) } label: { Image(systemName: "textformat.size.smaller") }
+ .disabled(fontSize <= 9)
+ .accessibilityLabel("Smaller text")
+ Button { fontSize = min(22, fontSize + 1) } label: { Image(systemName: "textformat.size.larger") }
+ .disabled(fontSize >= 22)
+ .accessibilityLabel("Larger text")
+ }
+ .tint(Theme.prompt)
+ }
+
+ /// The user-sized monospace face for Scroll/Reader.
+ private var paneFont: Font { .system(size: fontSize, design: .monospaced) }
+
+ /// Background probe: the content's bottom edge sits at/above the viewport
+ /// bottom (plus an 80pt slack) ⇒ we're pinned. Lives in `.background` so it's
+ /// always measured regardless of lazy realization.
+ private func pinReader(viewportHeight: CGFloat) -> some View {
+ GeometryReader { geo in
+ Color.clear.preference(
+ key: PinnedToBottomKey.self,
+ value: geo.frame(in: .named(scrollSpace)).maxY <= viewportHeight + 80
+ )
+ }
+ }
+
+ @ViewBuilder private var content: some View {
+ switch mode {
+ case .fit: fitGrid
+ case .scroll: scrollGrid
+ case .reader: scrollback
+ }
+ }
+
+ private var scrollback: some View {
+ GeometryReader { geo in
+ ScrollViewReader { proxy in
+ // Mobile transcript: cleaned output (frames stripped) wraps
+ // vertically — no horizontal scroll. Color preserved.
+ ScrollView(.vertical) {
+ LazyVStack(alignment: .leading, spacing: 4) {
+ if lines.isEmpty {
+ Text("— no output yet —")
+ .font(paneFont)
+ .foregroundStyle(Theme.terminalDim)
+ }
+ ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
+ Text(line.ansiAttributed(defaultColor: Theme.terminalText, surface: Theme.terminalBG))
+ .font(paneFont)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Color.clear.frame(height: 1).id(bottomAnchor)
+ }
+ .padding(14)
+ .background(pinReader(viewportHeight: geo.size.height))
+ }
+ .background(Theme.terminalBG)
+ .coordinateSpace(.named(scrollSpace))
+ .onPreferenceChange(PinnedToBottomKey.self) { isPinned = $0 }
+ .onChange(of: lines.count) {
+ if isPinned { withAnimation { proxy.scrollTo(bottomAnchor, anchor: .bottom) } }
+ }
+ .onAppear { proxy.scrollTo(bottomAnchor, anchor: .bottom) }
+ }
+ }
+ }
+
+ /// Fit mode: the raw grid with the font auto-shrunk so the widest line fits
+ /// the screen — vertical scroll only, no horizontal pan. Faithful layout,
+ /// small font at high column counts (≈7–8pt at 80 cols on a phone).
+ private var fitGrid: some View {
+ GeometryReader { geo in
+ // Size the font from the widest line so cols * advance * size fits.
+ // 0.97 leaves a hair of slack against advance-ratio error.
+ // ponytail: Character count, not terminal display width — CJK/emoji
+ // (width 2) would under-count and overflow slightly. Agent TUIs are
+ // overwhelmingly width-1 box/ASCII; add a wcwidth pass if that breaks.
+ let cols = max(1, gridLines.map { TerminalText.stripANSI($0).count }.max() ?? 1)
+ // Floor at 4pt (not 5) so a ~124-col agent grid fits the width fully
+ // instead of clipping its last chars — small is the point of Fit.
+ let size = max(4, min(15, (geo.size.width - 28) * 0.97 / (Double(cols) * monoAdvance)))
+ ScrollViewReader { proxy in
+ ScrollView(.vertical) {
+ // Eager VStack — the grid is bounded (a few hundred rows at
+ // most) so lazy layout buys nothing and complicates sizing.
+ VStack(alignment: .leading, spacing: 0) {
+ gridPlaceholder
+ ForEach(Array(gridLines.enumerated()), id: \.offset) { _, line in
+ // One uniform size for every row keeps columns aligned —
+ // no per-row minimumScaleFactor (it would scale wide rows
+ // independently and break the grid). A pathologically wide
+ // line truncates rather than shrinking out of alignment.
+ Text(line.ansiAttributed(defaultColor: Theme.terminalText, surface: Theme.terminalBG))
+ .font(.system(size: size, design: .monospaced))
+ .lineLimit(1)
+ }
+ Color.clear.frame(height: 1).id(bottomAnchor)
+ }
+ .padding(14)
+ .background(pinReader(viewportHeight: geo.size.height))
+ }
+ .coordinateSpace(.named(scrollSpace))
+ .onPreferenceChange(PinnedToBottomKey.self) { isPinned = $0 }
+ .onChange(of: gridLines.count) {
+ if isPinned { withAnimation { proxy.scrollTo(bottomAnchor, anchor: .bottom) } }
+ }
+ }
+ }
+ .background(Theme.terminalBG)
+ }
+
+ /// Scroll mode: the raw grid at a fixed readable font, panned in both axes —
+ /// the faithful terminal view (what the agent's screen literally looks like).
+ private var scrollGrid: some View {
+ ScrollView([.vertical, .horizontal]) {
+ // Eager VStack, spacing 0 so multi-row ANSI backgrounds tile without
+ // gaps. (LazyVStack also mis-measures width inside a two-axis
+ // ScrollView, so eager is the safe choice here regardless.)
+ VStack(alignment: .leading, spacing: 0) {
+ gridPlaceholder
+ ForEach(Array(gridLines.enumerated()), id: \.offset) { _, line in
+ Text(line.ansiAttributed(defaultColor: Theme.terminalText, surface: Theme.terminalBG))
+ .font(paneFont)
+ .textSelection(.enabled)
+ .lineLimit(1)
+ .fixedSize(horizontal: true, vertical: false)
+ }
+ }
+ .padding(14)
+ }
+ .background(Theme.terminalBG)
+ }
+
+ @ViewBuilder private var gridPlaceholder: some View {
+ if gridLines.isEmpty {
+ Text("— no output yet —")
+ .font(Theme.monospaced)
+ .foregroundStyle(Theme.terminalDim)
+ }
+ }
+
+ /// iSH-style key row pinned directly above the input field — and thus above
+ /// the keyboard when it's up, since keyboard avoidance lifts the whole stack.
+ /// Living in the layout flow (rather than a `.keyboard` accessory, whose
+ /// height SwiftUI doesn't fold into the inset) keeps it from overlapping the
+ /// input bar. `Ctrl` is sticky and modifies the next key (a bar key, or the
+ /// next typed letter).
+ private var keyControlBar: some View {
+ HStack(spacing: 0) {
+ Button { ctrlActive.toggle() } label: {
+ Image(systemName: "control")
+ .fontWeight(.semibold)
+ .foregroundStyle(ctrlActive ? Color.white : Theme.prompt)
+ .padding(.horizontal, 9)
+ .padding(.vertical, 5)
+ .background(ctrlActive ? Theme.prompt : Color.clear,
+ in: RoundedRectangle(cornerRadius: 7))
+ .frame(maxWidth: .infinity, minHeight: 38)
+ }
+ keyButton("escape", sends: "Esc")
+ keyButton("arrow.left", sends: "Left")
+ keyButton("arrow.up", sends: "Up")
+ keyButton("arrow.down", sends: "Down")
+ keyButton("arrow.right", sends: "Right")
+ keyButton("return", sends: "Enter")
+ Button { inputFocused = false } label: {
+ Image(systemName: "keyboard.chevron.compact.down")
+ .foregroundStyle(Theme.prompt)
+ .frame(maxWidth: .infinity, minHeight: 38)
+ }
+ }
+ .padding(.horizontal, 8)
+ .background(Theme.terminalSurface)
+ }
+
+ private func keyButton(_ symbol: String, sends key: String) -> some View {
+ Button { sendBarKey(key) } label: {
+ Image(systemName: symbol)
+ .foregroundStyle(Theme.prompt)
+ .frame(maxWidth: .infinity, minHeight: 38)
+ }
+ }
+
+ private var inputBar: some View {
+ HStack(spacing: 10) {
+ HStack(spacing: 7) {
+ Text(ctrlActive ? "^" : ">")
+ .font(Theme.mono(15, .bold))
+ .foregroundStyle(Theme.prompt)
+ TextField("", text: $input,
+ prompt: Text("send input…").foregroundColor(Theme.terminalDim),
+ axis: .vertical)
+ .font(Theme.monospaced)
+ .foregroundStyle(Theme.terminalText)
+ .focused($inputFocused)
+ .lineLimit(1...4)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .tint(Theme.prompt)
+ .onChange(of: input) { old, new in handleCtrlTyping(old, new) }
+ .onSubmit(send)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 9)
+ .background(Theme.terminalSurface, in: RoundedRectangle(cornerRadius: 11, style: .continuous))
+
+ Button(action: send) {
+ Image(systemName: "arrow.up")
+ .font(.body.weight(.bold))
+ .foregroundStyle(Theme.terminalBG)
+ .frame(width: 38, height: 38)
+ .background(canSend ? Theme.prompt : Theme.terminalDim, in: Circle())
+ }
+ .disabled(!canSend)
+ }
+ .padding(14)
+ .background(Theme.terminalBG)
+ }
+
+ private var canSend: Bool {
+ !input.trimmingCharacters(in: .whitespaces).isEmpty
+ }
+
+ private let bottomAnchor = "herdr.pane.bottom"
+ private let scrollSpace = "herdr.pane.scroll"
+
+ /// Send a bar key, applying a pending sticky Ctrl as `ctrl+`.
+ private func sendBarKey(_ key: String) {
+ let resolved = ctrlActive ? "ctrl+\(key.lowercased())" : key
+ ctrlActive = false
+ hapticTick += 1
+ Task { await session.sendKeys(resolved, to: paneID) }
+ }
+
+ /// When Ctrl is armed, the next typed character is sent as `ctrl+`
+ /// instead of being inserted.
+ private func handleCtrlTyping(_ old: String, _ new: String) {
+ guard ctrlActive else { return }
+ guard new.count == old.count + 1, let ch = new.last, ch.isLetter || ch.isNumber else {
+ if new.count != old.count { ctrlActive = false } // backspace/paste cancels Ctrl
+ return
+ }
+ ctrlActive = false
+ input = String(new.dropLast())
+ hapticTick += 1
+ let combo = "ctrl+\(String(ch).lowercased())"
+ Task { await session.sendKeys(combo, to: paneID) }
+ }
+
+ private func send() {
+ let text = input
+ input = ""
+ hapticTick += 1
+ Task { await session.submit(text, to: paneID) }
+ }
+}
+
+/// Reports whether a vertical scroll view is parked near its bottom. Read from a
+/// `.background` GeometryReader (always laid out, unlike a lazy child sentinel) so
+/// it stays correct even when the bottom row is recycled out of a `LazyVStack`.
+private struct PinnedToBottomKey: PreferenceKey {
+ static let defaultValue = true
+ static func reduce(value: inout Bool, nextValue: () -> Bool) { value = nextValue() }
+}
+
+extension String {
+ /// Parse ANSI SGR sequences — fg/bg (16-color, 256-color, 24-bit truecolor),
+ /// inverse video, and dim — into an `AttributedString`, dropping every other
+ /// escape (cursor moves, erase, …). Renders both foreground and background so
+ /// filled/inverse regions (e.g. an agent's block-art logo) look right.
+ /// `defaultColor` is the fallback fg; `surface` is the terminal background,
+ /// used to resolve inverse video. Bold is not weight-rendered.
+ func ansiAttributed(defaultColor: Color, surface: Color) -> AttributedString {
+ guard contains("\u{1B}") else {
+ var plain = AttributedString(self)
+ plain.foregroundColor = defaultColor
+ return plain
+ }
+ // Match the full CSI parameter range (`[0-?]`, incl. private `?`) so a
+ // non-SGR sequence like ESC[?25l is consumed, not rendered literally.
+ // Style is only applied when the final byte is `m` (see below).
+ let pattern = "\u{1B}\\[([0-?]*)([ -/]*[@-~])"
+ guard let regex = try? NSRegularExpression(pattern: pattern) else {
+ var plain = AttributedString(TerminalText.stripANSI(self))
+ plain.foregroundColor = defaultColor
+ return plain
+ }
+ let ns = self as NSString
+ var out = AttributedString()
+ var cursor = 0
+ var style = ANSIStyle()
+
+ func appendText(_ s: String) {
+ guard !s.isEmpty else { return }
+ var seg = AttributedString(s)
+ let (fg, bg) = style.resolved(defaultFg: defaultColor, surface: surface)
+ seg.foregroundColor = fg
+ if let bg { seg.backgroundColor = bg }
+ out.append(seg)
+ }
+
+ regex.enumerateMatches(in: self, range: NSRange(location: 0, length: ns.length)) { match, _, _ in
+ guard let match else { return }
+ if match.range.location > cursor {
+ appendText(ns.substring(with: NSRange(location: cursor, length: match.range.location - cursor)))
+ }
+ cursor = match.range.location + match.range.length
+ // Only SGR ('m') affects style; other final bytes are consumed and dropped.
+ guard ns.substring(with: match.range(at: 2)) == "m" else { return }
+ style.applySGR(ns.substring(with: match.range(at: 1)))
+ }
+ if cursor < ns.length { appendText(ns.substring(from: cursor)) }
+ return out
+ }
+}
+
+/// Mutable SGR style state accumulated while scanning a line: foreground,
+/// background, inverse, and dim. `nil` fg/bg mean "use the defaults".
+private struct ANSIStyle {
+ var fg: Color?
+ var bg: Color?
+ var inverse = false
+ var dim = false
+
+ /// Resolve to a concrete (foreground, optional background) for a run —
+ /// applying inverse (swap fg/bg, defaulting bg to the surface) and dim (fade).
+ func resolved(defaultFg: Color, surface: Color) -> (Color, Color?) {
+ var f = inverse ? (bg ?? surface) : (fg ?? defaultFg)
+ let b: Color? = inverse ? (fg ?? defaultFg) : bg
+ if dim { f = f.opacity(0.55) }
+ return (f, b)
+ }
+
+ mutating func applySGR(_ params: String) {
+ // Keep empty fields (`ESC[31;m` → 31 then an empty reset param == 0).
+ let codes = params.split(separator: ";", omittingEmptySubsequences: false).map { Int($0) ?? 0 }
+ if codes.isEmpty { self = ANSIStyle(); return } // bare ESC[m == reset
+ var i = 0
+ while i < codes.count {
+ let c = codes[i]
+ switch c {
+ case 0: self = ANSIStyle()
+ case 1: dim = false // bold: not weight-rendered, but clears dim
+ case 2: dim = true
+ case 22: dim = false
+ case 7: inverse = true
+ case 27: inverse = false
+ case 30...37: fg = ANSIColor.palette[c - 30]
+ case 90...97: fg = ANSIColor.palette[8 + (c - 90)]
+ case 39: fg = nil
+ case 40...47: bg = ANSIColor.palette[c - 40]
+ case 100...107: bg = ANSIColor.palette[8 + (c - 100)]
+ case 49: bg = nil
+ // On a malformed/truncated 38/48 spec, consume the rest rather than
+ // letting a stray operand (e.g. the `2` in `ESC[38;2m`) act as an SGR.
+ case 38: if let (col, adv) = ANSIColor.extended(codes, i) { fg = col; i += adv } else { i = codes.count }
+ case 48: if let (col, adv) = ANSIColor.extended(codes, i) { bg = col; i += adv } else { i = codes.count }
+ default: break
+ }
+ i += 1
+ }
+ }
+}
+
+/// ANSI SGR color resolution, tuned to stay legible on the light terminal
+/// background.
+private enum ANSIColor {
+ /// Standard + bright 16-color palette (indices 0–7 then 8–15), darkened where
+ /// needed so light colors remain readable on a near-white surface.
+ static let palette: [Color] = [
+ Color(red: 0.15, green: 0.15, blue: 0.15), // black
+ Color(red: 0.78, green: 0.18, blue: 0.18), // red
+ Color(red: 0.13, green: 0.55, blue: 0.13), // green
+ Color(red: 0.65, green: 0.45, blue: 0.00), // yellow → amber
+ Color(red: 0.15, green: 0.40, blue: 0.85), // blue
+ Color(red: 0.66, green: 0.20, blue: 0.66), // magenta
+ Color(red: 0.00, green: 0.50, blue: 0.55), // cyan → teal
+ Color(red: 0.30, green: 0.30, blue: 0.30), // white → dark gray (legible)
+ Color(red: 0.40, green: 0.40, blue: 0.40), // bright black → gray
+ Color(red: 0.85, green: 0.25, blue: 0.25), // bright red
+ Color(red: 0.20, green: 0.62, blue: 0.20), // bright green
+ Color(red: 0.72, green: 0.52, blue: 0.05), // bright yellow
+ Color(red: 0.22, green: 0.48, blue: 0.92), // bright blue
+ Color(red: 0.74, green: 0.28, blue: 0.74), // bright magenta
+ Color(red: 0.05, green: 0.58, blue: 0.62), // bright cyan
+ Color(red: 0.20, green: 0.20, blue: 0.20), // bright white → near-black
+ ]
+
+ /// Parse an extended-color spec (`38/48;5;n` or `38/48;2;r;g;b`) where `i` is
+ /// the `38`/`48` index. Returns the color and how many extra codes it consumed.
+ /// Truecolor is used verbatim (not palette-darkened) so e.g. a `0;0;0` logo
+ /// background renders truly black.
+ static func extended(_ codes: [Int], _ i: Int) -> (Color, Int)? {
+ if i + 2 < codes.count, codes[i + 1] == 5 {
+ let n = codes[i + 2]
+ return (0...255).contains(n) ? (from256(n), 2) : nil
+ } else if i + 4 < codes.count, codes[i + 1] == 2 {
+ return (Color(.sRGB,
+ red: Double(codes[i + 2]) / 255,
+ green: Double(codes[i + 3]) / 255,
+ blue: Double(codes[i + 4]) / 255), 4)
+ }
+ return nil
+ }
+
+ /// xterm 256-color index → RGB (16 base + 6×6×6 cube + 24 grays).
+ private static func from256(_ n: Int) -> Color {
+ if n < 16 { return palette[n] }
+ if n < 232 {
+ let c = n - 16
+ let steps = [0.0, 95, 135, 175, 215, 255]
+ return Color(.sRGB,
+ red: steps[(c / 36) % 6] / 255,
+ green: steps[(c / 6) % 6] / 255,
+ blue: steps[c % 6] / 255)
+ }
+ let gray = Double(8 + (n - 232) * 10) / 255
+ return Color(.sRGB, red: gray, green: gray, blue: gray)
+ }
+}
diff --git a/herdr-ios/App/Herdr/Features/Panes/WorkspaceDetailView.swift b/herdr-ios/App/Herdr/Features/Panes/WorkspaceDetailView.swift
new file mode 100644
index 0000000..d09753a
--- /dev/null
+++ b/herdr-ios/App/Herdr/Features/Panes/WorkspaceDetailView.swift
@@ -0,0 +1,191 @@
+import SwiftUI
+import HerdrKit
+
+/// Screen 2: the tabs and panes/agents inside a single workspace, each with its
+/// live status. Reads from the shared `SessionModel`, so status updates animate
+/// in place.
+struct WorkspaceDetailView: View {
+ @Environment(SessionModel.self) private var session
+ let workspaceID: WorkspaceID
+ @State private var showingNewTab = false
+ @State private var pendingClose: PendingClose?
+
+ /// A tab or pane queued for a confirmed close (both routed through one dialog).
+ private enum PendingClose: Identifiable {
+ case tab(HerdrKit.Tab), pane(Pane)
+ var id: String {
+ switch self {
+ case .tab(let t): return "t-\(t.id.rawValue)"
+ case .pane(let p): return "p-\(p.id.rawValue)"
+ }
+ }
+ var label: String {
+ switch self {
+ case .tab(let t): return t.label
+ case .pane(let p): return p.title
+ }
+ }
+ }
+
+ private var workspace: Workspace? { session.workspace(workspaceID) }
+
+ var body: some View {
+ Group {
+ if let workspace {
+ List {
+ ForEach(workspace.tabs) { tab in
+ Section {
+ ForEach(tab.panes) { pane in
+ NavigationLink(value: pane.id) {
+ PaneRow(pane: pane)
+ }
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) { pendingClose = .pane(pane) } label: {
+ Label("Close", systemImage: "xmark")
+ }
+ }
+ }
+ } header: {
+ SectionEyebrow(tab.label)
+ .contextMenu {
+ Button(role: .destructive) { pendingClose = .tab(tab) } label: {
+ Label("Close tab", systemImage: "xmark")
+ }
+ }
+ }
+ }
+ }
+ .confirmationDialog(
+ "Close “\(pendingClose?.label ?? "")”?",
+ isPresented: Binding(get: { pendingClose != nil }, set: { if !$0 { pendingClose = nil } }),
+ titleVisibility: .visible,
+ presenting: pendingClose
+ ) { target in
+ Button("Close", role: .destructive) {
+ Task {
+ switch target {
+ case .tab(let t): await session.closeTab(t.id)
+ case .pane(let p): await session.closePane(p.id)
+ }
+ }
+ }
+ } message: { _ in
+ Text("This kills the running terminal process.")
+ }
+ .navigationTitle(workspace.label)
+ .navigationBarTitleDisplayMode(.inline)
+ .sheet(isPresented: $showingNewTab) {
+ NewTabSheet(workspaceID: workspaceID)
+ }
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button { showingNewTab = true } label: { Image(systemName: "plus") }
+ .tint(Theme.ink)
+ .accessibilityLabel("New tab")
+ }
+ }
+ } else {
+ ContentUnavailableView("Workspace closed", systemImage: "xmark.rectangle",
+ description: Text("This workspace is no longer available."))
+ }
+ }
+ }
+}
+
+/// Sheet for `tab.create` in a fixed workspace. Label is optional; the new tab
+/// appears as a section once the post-create refresh lands. Failures stay inline.
+private struct NewTabSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(SessionModel.self) private var session
+ let workspaceID: WorkspaceID
+ @State private var label = ""
+ @State private var isCreating = false
+ @State private var error: String?
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ TextField("Label (optional)", text: $label)
+ .autocorrectionDisabled()
+ } header: {
+ SectionEyebrow("tab")
+ } footer: {
+ Text("Optional. Leave blank to use the server's default name.")
+ }
+
+ if let error {
+ Section {
+ Label(error, systemImage: "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(Theme.blocked)
+ }
+ }
+ }
+ .navigationTitle("New tab")
+ .navigationBarTitleDisplayMode(.inline)
+ .interactiveDismissDisabled(isCreating)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }.disabled(isCreating)
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ if isCreating {
+ ProgressView()
+ } else {
+ Button("Create") { create() }
+ }
+ }
+ }
+ }
+ .tint(Theme.prompt)
+ }
+
+ private func create() {
+ isCreating = true
+ error = nil
+ Task {
+ do {
+ try await session.createTab(
+ label: label.trimmingCharacters(in: .whitespacesAndNewlines),
+ in: workspaceID
+ )
+ dismiss()
+ } catch {
+ self.error = String(describing: error)
+ isCreating = false
+ }
+ }
+ }
+}
+
+private struct PaneRow: View {
+ let pane: Pane
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: pane.isAgent ? "cpu" : "terminal")
+ .font(.callout)
+ .foregroundStyle(pane.isAgent ? Theme.ink : Color.secondary)
+ .frame(width: 24)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(pane.title).font(.body.weight(.medium)).lineLimit(1)
+ HStack(spacing: 8) {
+ Text(pane.id.rawValue)
+ .font(Theme.mono(11))
+ .foregroundStyle(.tertiary)
+ if let agent = pane.agent {
+ Text(agent)
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ Spacer(minLength: 8)
+ if pane.isAgent {
+ StatusTag(status: pane.status)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
diff --git a/herdr-ios/App/Herdr/Features/Workspaces/WorkspaceListView.swift b/herdr-ios/App/Herdr/Features/Workspaces/WorkspaceListView.swift
new file mode 100644
index 0000000..93dbaad
--- /dev/null
+++ b/herdr-ios/App/Herdr/Features/Workspaces/WorkspaceListView.swift
@@ -0,0 +1,223 @@
+import SwiftUI
+import HerdrKit
+
+/// Screen 1: the list of workspaces with live aggregate agent status. Hosts the
+/// `NavigationStack` and registers destinations for the drill-down screens.
+struct WorkspaceListView: View {
+ @Environment(SessionModel.self) private var session
+ @Environment(AppModel.self) private var app
+ @State private var path = NavigationPath()
+ @State private var showingNewWorkspace = false
+ @State private var pendingClose: Workspace?
+ @AppStorage(AgentNotifier.enabledKey) private var notify = false
+
+ var body: some View {
+ NavigationStack(path: $path) {
+ List {
+ if let error = session.loadError {
+ Label(error, systemImage: "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(Theme.blocked)
+ }
+ ForEach(session.workspaces) { workspace in
+ NavigationLink(value: workspace.id) {
+ WorkspaceRow(workspace: workspace)
+ }
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) { pendingClose = workspace } label: {
+ Label("Close", systemImage: "xmark")
+ }
+ }
+ }
+ }
+ .confirmationDialog(
+ "Close “\(pendingClose?.label ?? "")”?",
+ isPresented: Binding(get: { pendingClose != nil }, set: { if !$0 { pendingClose = nil } }),
+ titleVisibility: .visible,
+ presenting: pendingClose
+ ) { workspace in
+ Button("Close workspace", role: .destructive) {
+ Task { await session.closeWorkspace(workspace.id) }
+ }
+ } message: { _ in
+ Text("This kills every terminal and agent running in the workspace.")
+ }
+ .navigationDestination(for: WorkspaceID.self) { id in
+ WorkspaceDetailView(workspaceID: id)
+ }
+ .navigationDestination(for: PaneID.self) { id in
+ PaneView(paneID: id)
+ }
+ .refreshable { await session.refresh() }
+ .overlay {
+ if session.workspaces.isEmpty && session.loadError == nil {
+ ContentUnavailableView("No workspaces", systemImage: "rectangle.3.group",
+ description: Text("Tap + to create your first workspace."))
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .sheet(isPresented: $showingNewWorkspace) {
+ NewWorkspaceSheet { newID in path.append(newID) }
+ }
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Button { Task { await app.disconnect() } } label: {
+ Image(systemName: "rectangle.portrait.and.arrow.right")
+ }
+ .tint(Theme.ink)
+ .accessibilityLabel("Disconnect")
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button { toggleNotify() } label: {
+ Image(systemName: notify ? "bell.fill" : "bell.slash")
+ }
+ .tint(notify ? Theme.prompt : Theme.ink)
+ .accessibilityLabel(notify ? "Blocked-agent alerts on" : "Blocked-agent alerts off")
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button { showingNewWorkspace = true } label: { Image(systemName: "plus") }
+ .tint(Theme.ink)
+ .accessibilityLabel("New workspace")
+ }
+ ToolbarItem(placement: .principal) {
+ VStack(spacing: 2) {
+ Text("Workspaces").font(.headline)
+ Button { Task { await session.reconnect() } } label: {
+ HStack(spacing: 5) {
+ Circle()
+ .fill(session.link == .live ? Theme.idle : Theme.blocked)
+ .frame(width: 6, height: 6)
+ Text(session.link == .live ? session.label : "Reconnecting…")
+ .font(Theme.mono(10))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(session.link == .live)
+ .accessibilityLabel(session.link == .live
+ ? "Connected to \(session.label)" : "Connection lost. Tap to retry.")
+ }
+ }
+ }
+ }
+ .tint(Theme.prompt)
+ }
+
+ /// Flip the alerts pref. Turning on requests system authorization and only
+ /// sticks if granted, so the toggle never lies about whether alerts can fire.
+ private func toggleNotify() {
+ if notify { notify = false; return }
+ Task { notify = await AgentNotifier.requestAuthorization() }
+ }
+}
+
+private struct WorkspaceRow: View {
+ let workspace: Workspace
+
+ private var agentCount: Int { workspace.agentPanes.count }
+
+ var body: some View {
+ HStack(spacing: 12) {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(workspace.aggregateStatus.color)
+ .frame(width: 3)
+
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(workspace.label)
+ .font(.body.weight(.semibold))
+ .lineLimit(1)
+ Spacer(minLength: 8)
+ Text("\(agentCount) agent\(agentCount == 1 ? "" : "s")")
+ .font(Theme.mono(11))
+ .foregroundStyle(.tertiary)
+ }
+ if let cwd = workspace.cwd {
+ Text(cwd)
+ .font(Theme.mono(11))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.head)
+ }
+ StatusSummary(counts: workspace.agentCounts())
+ }
+ }
+ .padding(.vertical, 6)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+}
+
+/// Sheet for `workspace.create`. Label and cwd are both optional (the server
+/// defaults them); on success it dismisses and hands the new id back so the list
+/// can navigate into it. Failures stay inline so the user can fix and retry.
+private struct NewWorkspaceSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(SessionModel.self) private var session
+ @State private var label = ""
+ @State private var cwd = ""
+ @State private var isCreating = false
+ @State private var error: String?
+ let onCreated: (WorkspaceID) -> Void
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ TextField("Label (optional)", text: $label)
+ .autocorrectionDisabled()
+ TextField("Working directory (optional)", text: $cwd)
+ .font(.caption.monospaced())
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ } header: {
+ SectionEyebrow("workspace")
+ } footer: {
+ Text("Both optional. The working directory is a path on the Herdr host (e.g. ~/project); leave blank to use the server's default.")
+ }
+
+ if let error {
+ Section {
+ Label(error, systemImage: "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(Theme.blocked)
+ }
+ }
+ }
+ .navigationTitle("New workspace")
+ .navigationBarTitleDisplayMode(.inline)
+ .interactiveDismissDisabled(isCreating)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }.disabled(isCreating)
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ if isCreating {
+ ProgressView()
+ } else {
+ Button("Create") { create() }
+ }
+ }
+ }
+ }
+ .tint(Theme.prompt)
+ }
+
+ private func create() {
+ isCreating = true
+ error = nil
+ Task {
+ do {
+ let id = try await session.createWorkspace(
+ label: label.trimmingCharacters(in: .whitespacesAndNewlines),
+ cwd: cwd.trimmingCharacters(in: .whitespacesAndNewlines)
+ )
+ dismiss()
+ if let id { onCreated(id) }
+ } catch {
+ self.error = String(describing: error)
+ isCreating = false
+ }
+ }
+ }
+}
diff --git a/herdr-ios/App/Herdr/Session/AgentNotifier.swift b/herdr-ios/App/Herdr/Session/AgentNotifier.swift
new file mode 100644
index 0000000..2cfb3b2
--- /dev/null
+++ b/herdr-ios/App/Herdr/Session/AgentNotifier.swift
@@ -0,0 +1,34 @@
+import Foundation
+import UserNotifications
+
+/// Local notifications for agents that need attention. Opt-in: the user turns it
+/// on (which requests authorization) from the workspaces toolbar, and we only
+/// fire on the transition *into* `.blocked`. iOS suppresses the banner while the
+/// app is foregrounded — exactly the "don't nag me while I'm watching" behavior,
+/// so there's no app-state check here.
+// ponytail: relies on iOS's default foreground suppression instead of a
+// UNUserNotificationCenterDelegate; add one only if we later want in-app banners.
+enum AgentNotifier {
+ static let enabledKey = "notifyOnBlocked"
+
+ static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) }
+
+ /// Request authorization; returns whether it was granted. Call from the toggle
+ /// so the system prompt is tied to an explicit user action, not a stray event.
+ @discardableResult
+ static func requestAuthorization() async -> Bool {
+ (try? await UNUserNotificationCenter.current()
+ .requestAuthorization(options: [.alert, .sound, .badge])) ?? false
+ }
+
+ /// Fire a "needs input" notification for a pane that just became blocked.
+ static func notifyBlocked(agent: String, workspace: String) {
+ guard isEnabled else { return }
+ let content = UNMutableNotificationContent()
+ content.title = "\(agent) needs input"
+ content.body = workspace
+ content.sound = .default
+ let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
+ UNUserNotificationCenter.current().add(request)
+ }
+}
diff --git a/herdr-ios/App/Herdr/Session/SessionModel.swift b/herdr-ios/App/Herdr/Session/SessionModel.swift
new file mode 100644
index 0000000..872b6e7
--- /dev/null
+++ b/herdr-ios/App/Herdr/Session/SessionModel.swift
@@ -0,0 +1,254 @@
+import Foundation
+import HerdrKit
+
+/// The single source of truth for a connected Herdr session. Holds the live
+/// workspace tree and per-pane scrollback, applies server-pushed events, and
+/// exposes the actions the screens need. Injected into the view hierarchy via
+/// SwiftUI's environment.
+@MainActor
+@Observable
+final class SessionModel {
+ let client: HerdrClient
+ let label: String
+
+ var workspaces: [Workspace] = []
+ /// Scrollback lines per pane, populated by `loadOutput` and grown by events.
+ var outputs: [PaneID: [String]] = [:]
+ var loadError: String?
+
+ /// Health of the link to the server, derived from whether RPCs are landing.
+ /// Drives the toolbar indicator; `.lost` kicks off a backoff reconnect loop.
+ enum LinkState { case live, lost }
+ private(set) var link: LinkState = .live
+
+ private var eventTask: Task?
+ private var refreshTask: Task?
+ private var reconnectTask: Task?
+ /// Last status seen per pane, so we only notify on the edge *into* blocked.
+ private var lastStatus: [PaneID: AgentStatus] = [:]
+ private var subscribedTopology = false
+ private var subscribedPanes: Set = []
+
+ init(client: HerdrClient, label: String) {
+ self.client = client
+ self.label = label
+ }
+
+ /// Load the initial workspace list, begin observing live events, and
+ /// subscribe to topology + per-agent-pane status changes.
+ func start() async {
+ await refresh()
+ observeEvents()
+ await syncSubscriptions()
+ }
+
+ /// Subscribe to topology once, plus agent-status for any agent panes we
+ /// haven't subscribed to yet. Safe to call after each refresh.
+ private func syncSubscriptions() async {
+ var subscriptions: [EventSubscription] = []
+ let needsTopology = !subscribedTopology
+ if needsTopology { subscriptions.append(.topology) }
+ let agentPanes = Set(workspaces.flatMap(\.panes).filter(\.isAgent).map(\.id))
+ let fresh = agentPanes.subtracting(subscribedPanes)
+ subscriptions += fresh.map { .paneAgentStatus($0) }
+ guard !subscriptions.isEmpty else { return }
+ do {
+ try await client.subscribe(subscriptions)
+ // Mark as subscribed only on success → a failure is retried on the
+ // next refresh instead of being silently lost.
+ if needsTopology { subscribedTopology = true }
+ subscribedPanes.formUnion(fresh)
+ } catch {
+ // Leave unmarked; the next refresh will retry.
+ }
+ }
+
+ func refresh() async {
+ do {
+ workspaces = try await client.listWorkspaces()
+ loadError = nil
+ link = .live
+ // Seed the baseline from the listing so the first status *event* for a
+ // pane notifies only on a real change, not the initial sync.
+ for pane in workspaces.flatMap(\.panes) { lastStatus[pane.id] = pane.status }
+ reconnectTask?.cancel()
+ reconnectTask = nil
+ } catch {
+ loadError = String(describing: error)
+ link = .lost
+ scheduleReconnect()
+ }
+ }
+
+ /// Manual "tap the dot to retry now" — just re-runs the listing.
+ func reconnect() async { await refresh() }
+
+ /// While the link is lost, keep retrying the listing on an exponential
+ /// backoff (capped) until one succeeds — a successful `refresh` cancels us.
+ /// ponytail: re-lists over the existing transport; an SSH session that has
+ /// actually dropped is re-established by the transport on its next request.
+ private func scheduleReconnect() {
+ guard reconnectTask == nil else { return }
+ reconnectTask = Task { [weak self] in
+ var delay: Duration = .seconds(2)
+ while !Task.isCancelled {
+ try? await Task.sleep(for: delay)
+ guard !Task.isCancelled else { return }
+ await self?.refresh()
+ if self?.link == .live { return }
+ delay = min(delay * 2, .seconds(30))
+ }
+ }
+ }
+
+ /// Refresh a pane's display: read the scrollback and project it into a
+ /// readable mobile transcript. One region — the agent's own status footer
+ /// rides inline in the transcript like any other terminal output, rather
+ /// than being scraped out into a separate pinned strip (the old `detection`
+ /// scrape mis-classified full-screen TUI prompts as "status"). Best-effort —
+ /// a failed read leaves the last snapshot in place.
+ /// `isAgent` is unused now but kept so the call site needn't special-case.
+ func refreshPaneDisplay(for pane: PaneID, isAgent: Bool) async {
+ let recent = (try? await client.readPane(pane)) ?? outputs[pane] ?? []
+ outputs[pane] = TerminalText.clean(recent)
+ }
+
+ /// Block until the pane produces new output (or the server's wait times out),
+ /// so the view can re-read the instant the screen changes instead of polling
+ /// on a fixed timer. Falls back to a short sleep if the wait itself fails, so
+ /// a transport error can't turn the caller's loop into a hot spin.
+ func awaitOutput(for pane: PaneID, source: String = PaneReadSource.recentUnwrapped) async {
+ do {
+ _ = try await client.waitForOutput(pane, source: source)
+ } catch {
+ try? await Task.sleep(for: .seconds(3))
+ }
+ }
+
+ /// The raw terminal grid for a pane (uncleaned), for the Fit/Scroll modes.
+ /// Returns `nil` on a read failure so the caller can keep its last grid, vs.
+ /// an empty array for a genuinely blank screen.
+ func rawTerminal(for pane: PaneID) async -> [String]? {
+ try? await client.readRawTerminal(pane)
+ }
+
+ /// Submit a line of input (text + Enter), echoing it optimistically.
+ func submit(_ text: String, to pane: PaneID) async {
+ guard !text.isEmpty else { return }
+ appendOutput("❯ \(text)", to: pane)
+ try? await client.submitLine(text, to: pane)
+ }
+
+ func sendKeys(_ keys: String, to pane: PaneID) async {
+ try? await client.sendKeys(keys, to: pane)
+ }
+
+ /// Create a workspace, then re-list so the new one is in `workspaces` before
+ /// the caller navigates into it. Returns its id when the server reports one.
+ /// Throws on failure so the presenting sheet can surface it inline (errors
+ /// here are transient and sheet-local, unlike the persistent `loadError`).
+ func createWorkspace(label: String?, cwd: String?) async throws -> WorkspaceID? {
+ let id = try await client.createWorkspace(label: label, cwd: cwd)
+ await refresh()
+ await syncSubscriptions()
+ return id
+ }
+
+ /// Create a tab in `workspace`, then re-list so it appears in the detail view.
+ @discardableResult
+ func createTab(label: String?, in workspace: WorkspaceID) async throws -> TabID? {
+ let id = try await client.createTab(label: label, in: workspace)
+ await refresh()
+ await syncSubscriptions()
+ return id
+ }
+
+ /// Close a workspace/tab/pane, then re-list. Optimistically drops it from the
+ /// tree first so the row disappears immediately; the refresh reconciles (and
+ /// the `*.closed` topology event would anyway). Best-effort — a failed close
+ /// is surfaced by the next refresh putting it back.
+ func closeWorkspace(_ id: WorkspaceID) async {
+ workspaces.removeAll { $0.id == id }
+ try? await client.closeWorkspace(id)
+ await refresh()
+ }
+
+ func closeTab(_ id: TabID) async {
+ for w in workspaces.indices { workspaces[w].tabs.removeAll { $0.id == id } }
+ try? await client.closeTab(id)
+ await refresh()
+ }
+
+ func closePane(_ id: PaneID) async {
+ for w in workspaces.indices {
+ for t in workspaces[w].tabs.indices { workspaces[w].tabs[t].panes.removeAll { $0.id == id } }
+ }
+ try? await client.closePane(id)
+ await refresh()
+ }
+
+ // MARK: Lookups
+
+ func workspace(_ id: WorkspaceID) -> Workspace? {
+ workspaces.first { $0.id == id }
+ }
+
+ func pane(_ id: PaneID) -> Pane? {
+ workspaces.flatMap(\.panes).first { $0.id == id }
+ }
+
+ // MARK: Event handling
+
+ private func observeEvents() {
+ guard eventTask == nil else { return }
+ eventTask = Task { [weak self] in
+ guard let stream = await self?.client.eventStream else { return }
+ for await event in stream {
+ self?.apply(event)
+ }
+ }
+ }
+
+ private func apply(_ event: HerdrEvent) {
+ switch event {
+ case .agentStatus(let pane, let status):
+ updateStatus(status, for: pane)
+ case .topologyChanged:
+ scheduleRefresh()
+ }
+ }
+
+ /// Coalesce bursty topology events (a workspace close emits tab + pane
+ /// closes too) into a single debounced re-list + re-subscribe.
+ private func scheduleRefresh() {
+ refreshTask?.cancel()
+ refreshTask = Task { [weak self] in
+ try? await Task.sleep(for: .milliseconds(300))
+ guard !Task.isCancelled else { return }
+ await self?.refresh()
+ await self?.syncSubscriptions()
+ }
+ }
+
+ private func updateStatus(_ status: AgentStatus, for paneID: PaneID) {
+ let previous = lastStatus[paneID]
+ lastStatus[paneID] = status
+ for w in workspaces.indices {
+ for t in workspaces[w].tabs.indices {
+ for p in workspaces[w].tabs[t].panes.indices
+ where workspaces[w].tabs[t].panes[p].id == paneID {
+ workspaces[w].tabs[t].panes[p].status = status
+ if status == .blocked, previous != .blocked {
+ let pane = workspaces[w].tabs[t].panes[p]
+ AgentNotifier.notifyBlocked(agent: pane.agent ?? pane.title,
+ workspace: workspaces[w].label)
+ }
+ }
+ }
+ }
+ }
+
+ private func appendOutput(_ chunk: String, to pane: PaneID) {
+ outputs[pane, default: []].append(chunk)
+ }
+}
diff --git a/herdr-ios/CLAUDE.md b/herdr-ios/CLAUDE.md
new file mode 100644
index 0000000..bd94b42
--- /dev/null
+++ b/herdr-ios/CLAUDE.md
@@ -0,0 +1,74 @@
+# Herdr iOS — agent guide
+
+Native iOS (SwiftUI) client for [Herdr](https://herdr.dev), a terminal-native agent
+multiplexer. The app today runs fully on an in-memory **Mock** transport; the SSH
+transport is scaffolded but not wired (see below).
+
+## Layout
+
+Two layers, cleanly separated so the whole UI runs on the Mock and SSH is a drop-in swap.
+
+- **`Sources/HerdrKit/`** — platform-independent core. Foundation + Swift Concurrency
+ only, no SwiftUI, no third-party deps. Builds and unit-tests with `swift test` on
+ macOS or Linux. Subdirs: `Models/`, `Protocol/` (NDJSON JSON-RPC codec; all wire
+ method strings live in `Method.swift`), `Transport/`, `Client/` (actor that
+ correlates replies and demuxes events), `Mock/`.
+- **`App/Herdr/`** — the SwiftUI app target. State via `@Observable`; a single
+ `SessionModel` is the source of truth, injected through the environment.
+ `AppModel` boots on `MockTransport` — swapping to `SSHTransport` is a one-line change.
+- **`Tests/HerdrKitTests/`** — unit tests for the core (codec + client).
+
+## Build & test
+
+```sh
+swift build # build HerdrKit core
+swift test # core unit tests — no Apple SDK needed
+
+xcodegen generate # regenerate Herdr.xcodeproj from project.yml (REQUIRED before building the app)
+xcodebuild -project Herdr.xcodeproj -scheme Herdr \
+ -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' build
+```
+
+Requires Xcode 15+ / iOS 17 deployment target. `xcodegen` is at `/opt/homebrew/bin/xcodegen`.
+
+## Conventions & gotchas
+
+- **`project.yml` is the source of truth**, not `Herdr.xcodeproj`. Edit `project.yml`
+ then re-run `xcodegen generate`. The `.xcodeproj` is generated — don't hand-edit it,
+ and treat it as disposable.
+- **App target is a single module.** Don't mark app-target types/members `public` —
+ it's unnecessary and a `public` member exposing an `internal` type (e.g. `Host`,
+ `Credential`) is a compile error. `public` belongs in `HerdrKit` (a real library),
+ not in `App/Herdr`.
+- **`HerdrKit` has zero external dependencies** — keep it that way so it stays
+ Linux-testable. Apple-SDK and third-party code (Citadel) belong in the app target.
+- The ID types (`PaneID`, etc.) conform to `ExpressibleByStringLiteral`, so
+ `.map(SomeID.init)` is ambiguous — use `.map { SomeID($0) }`.
+
+## SSH transport
+
+`App/Herdr/Connection/SSHTransport.swift` is implemented with **Citadel** (SwiftNIO
+SSH). Herdr has no network API — its socket is a local Unix domain socket
+(`~/.config/herdr/herdr.sock`), so the client SSHes in and bridges a `withExec`
+channel running `socat - UNIX-CONNECT: || nc -U `, writing
+`NDJSON.frame(request)` to stdin and feeding stdout through `LineBuffer` →
+`IncomingMessage.decode`. Auth uses `SSHClient.connect` (password or RSA key).
+
+The socket path is **auto-detected** unless `Host.socketPath` is a non-blank
+override: a one-shot `executeCommand` probe mirrors Herdr's documented resolution
+order (`$HERDR_SOCKET_PATH` → `~/.config/herdr/herdr.sock` → named sessions under
+`~/.config/herdr/sessions/*/`) and picks the first live socket. So `Host.socketPath`
+defaults to `""` (auto) — don't reintroduce a hardcoded default.
+
+Package deps live in `project.yml`: `Citadel`, plus `NIOCore` (for `ByteBuffer`)
+and `Crypto` (for the `Insecure` namespace Citadel extends) — both are Citadel
+transitive deps that `SSHTransport` imports directly, so they're linked
+explicitly. Re-run `xcodegen generate` after touching `project.yml`.
+
+Known limitations (intentional, tracked in README): key auth is OpenSSH ed25519
+or RSA (ECDSA not wired). Host keys are pinned **trust-on-first-use** — a custom
+`NIOSSHClientServerAuthenticationDelegate` records the key on first connect and
+rejects a mismatch later; the pinned key lives on `Host.knownHostKey`
+(`ConnectionStore.pinHostKey`). This is why `NIOSSH` is linked explicitly in
+`project.yml` — same fork/range Citadel pins, since it has no NIOSSH re-export.
+Verify method strings against .
diff --git a/herdr-ios/Package.swift b/herdr-ios/Package.swift
new file mode 100644
index 0000000..926ddd0
--- /dev/null
+++ b/herdr-ios/Package.swift
@@ -0,0 +1,23 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+// HerdrKit is the platform-independent core of the Herdr iOS client: domain
+// models, the newline-delimited JSON-RPC codec, the transport abstraction, the
+// high-level client actor, and an in-memory Mock transport. It has no Apple-SDK
+// or third-party dependencies, so it builds and unit-tests with `swift test` on
+// macOS or Linux. The SwiftUI app target (see project.yml) depends on this
+// package plus Citadel for SSH.
+let package = Package(
+ name: "HerdrKit",
+ platforms: [
+ .iOS(.v17),
+ .macOS(.v13),
+ ],
+ products: [
+ .library(name: "HerdrKit", targets: ["HerdrKit"]),
+ ],
+ targets: [
+ .target(name: "HerdrKit"),
+ .testTarget(name: "HerdrKitTests", dependencies: ["HerdrKit"]),
+ ]
+)
diff --git a/herdr-ios/README.md b/herdr-ios/README.md
new file mode 100644
index 0000000..965c3f4
--- /dev/null
+++ b/herdr-ios/README.md
@@ -0,0 +1,154 @@
+
+
+
+
+Herdr iOS
+
+
+ Join the TestFlight beta →
+
+
+
+
+
+
+
+
+A native iOS (SwiftUI) client for [Herdr](https://herdr.dev), the terminal-native
+**agent multiplexer**. Browse your workspaces, watch live agent status, and read
+or drive any pane from your phone.
+
+> **Unofficial client.** Herdr and its branding are the work of its author (see
+> [Credits](#credits)); this project is an independent iOS front-end for it.
+
+> **Status:** the full app runs on an in-memory **Mock** transport with realistic
+> data and live status updates, *and* over a real **SSH** connection that bridges
+> to the remote Herdr Unix socket (see [SSH transport](#ssh-transport)). Known
+> limitations: key auth currently supports OpenSSH ed25519 and RSA keys. Host
+> keys are pinned trust-on-first-use (TOFU): the key is remembered on first
+> connect and a later mismatch aborts with a clear warning.
+
+## Why SSH?
+
+Herdr has **no network API and no official mobile app** by design. Its socket API
+is **newline-delimited JSON-RPC over a local Unix domain socket**
+(`~/.config/herdr/herdr.sock`; named sessions under
+`~/.config/herdr/sessions//herdr.sock`). Remote use is officially "SSH into
+the box and run herdr." So this client reaches the socket the same way: over SSH,
+by bridging an exec channel to the Unix socket and speaking JSON-RPC directly —
+which keeps live event subscriptions working.
+
+## Architecture
+
+Two cleanly separated layers, so the entire UI runs on a Mock and the real SSH
+transport is a drop-in swap.
+
+### `HerdrKit` — platform-independent core (`Sources/HerdrKit`)
+
+No SwiftUI, no third-party deps, Foundation + Swift Concurrency only → builds and
+unit-tests with `swift test` on macOS or Linux.
+
+| Area | Files |
+| --- | --- |
+| Models | `Models/{IDs,AgentStatus,Pane,Workspace}.swift` — ids are non-durable strings; status is `idle/working/blocked/done/unknown` |
+| Protocol | `Protocol/{JSONValue,RPC,NDJSON,Method}.swift` — NDJSON JSON-RPC codec; every wire `method` string lives in `Method.swift` |
+| Transport | `Transport/HerdrTransport.swift` — dumb in/out channel protocol |
+| Client | `Client/{HerdrClient,HerdrEvent}.swift` — actor that correlates replies and demuxes events into one `eventStream` |
+| Mock | `Mock/{MockTransport,MockData}.swift` — answers requests and emits live status/output events |
+
+### `Herdr` — SwiftUI app (`App/Herdr`)
+
+State via `@Observable`. A single `SessionModel` is the source of truth, injected
+through the environment.
+
+- **Connection** — `Host`, `ConnectionStore` (UserDefaults), `KeychainStore`
+ (key/password in the Keychain), `SSHTransport` (stub), `ConnectView`.
+- **Screen 1 — Workspaces** — `Features/Workspaces/WorkspaceListView.swift`:
+ live aggregate status, per-status counts, pull-to-refresh.
+- **Screen 2 — Panes/agents** — `Features/Panes/WorkspaceDetailView.swift`:
+ tabs and their panes with per-agent status.
+- **Screen 3 — Pane** — `Features/Pane/PaneView.swift`: monospaced scrollback
+ (ANSI-stripped, live-appended) and an input bar (text + Enter / quick keys).
+ The transcript re-reads whenever the pane emits new output: rather than poll on
+ a fixed timer, the loop blocks on `pane.wait_for_output` (matching any output)
+ and refreshes the instant the screen changes — instant on activity, quiet while
+ idle. Output gating only; agent status stays live via pushed events.
+
+### Data flow
+
+`HerdrClient` (actor) owns a `HerdrTransport`. `SessionModel` calls typed async
+methods and consumes `client.eventStream` to update `@Observable` state →
+SwiftUI re-renders. Boots on `MockTransport`; swapping to `SSHTransport` is a
+one-line change in `AppModel`.
+
+## Build & run
+
+Requires Xcode 15+ (iOS 17 deployment target) on macOS.
+
+```sh
+# 1. Core unit tests (no Apple SDK needed — runs on macOS or Linux)
+swift test
+
+# 2. Generate and open the app project
+brew install xcodegen
+xcodegen generate
+open Herdr.xcodeproj
+# Build & run the "Herdr" scheme on an iOS 17 simulator.
+```
+
+On launch, tap **Open demo workspace** to explore against sample data: the
+workspace list shows live status badges flipping, drill into a workspace to see
+its panes/agents, and open a pane to watch streamed output and send input.
+
+## SSH transport
+
+`App/Herdr/Connection/SSHTransport.swift` implements the bridge with **Citadel**
+(SwiftNIO SSH):
+
+1. `connect()` opens an `SSHClient` connection authenticated with the host's
+ `Credential` (password, or an OpenSSH-format RSA private key, from the
+ Keychain).
+2. Unless the host has an explicit socket-path override, it **auto-detects** the
+ socket: a one-shot remote probe mirrors Herdr's documented resolution order —
+ `$HERDR_SOCKET_PATH`, then the default `~/.config/herdr/herdr.sock`, then any
+ named session under `~/.config/herdr/sessions//` — and picks the first
+ live socket (`test -S`). Users normally don't configure a path at all.
+3. It then opens a `withExec` channel running
+ `socat - UNIX-CONNECT: || nc -U ` and suspends until
+ the channel is live. The channel's stdout is fed through the existing
+ `LineBuffer` → `IncomingMessage.decode` → `continuation.yield`; `send(_:)`
+ writes `NDJSON.frame(request)` to the channel's stdin. A leading `~` in an
+ overridden socket path is rewritten to `$HOME` so the remote shell expands it.
+
+To switch the app onto SSH, point `AppModel.connect(to:)` at a saved `Host` (it
+already builds an `SSHTransport`); the demo entry point stays on the Mock.
+
+**Follow-ups:**
+
+- Key auth handles OpenSSH **ed25519** and **RSA** keys (tried in that order);
+ ECDSA isn't wired yet. Password auth works for everything in the meantime.
+- Host keys are pinned **trust-on-first-use**: `SSHTransport`'s custom validator
+ records the key on first connect (`ConnectionStore` persists it on `Host`) and
+ rejects a changed key on later connects. Re-add the host to re-pin after a
+ legitimate server key change. No UI to inspect/manage pinned keys yet.
+- Socket auto-detect picks the default session, or the sole running one. When a
+ host has *multiple* named sessions and no default, it currently picks the first;
+ a session picker is a possible follow-up (override the socket path to choose for
+ now).
+- Confirm the exact socket `method` strings and subscribe/event names in
+ `Sources/HerdrKit/Protocol/Method.swift` against
+ .
+
+## Credits
+
+All credit for **Herdr** itself — the terminal-native agent multiplexer this app
+is a client for — goes to its creator,
+[@ogulcancelik](https://github.com/ogulcancelik) ([herdr.dev](https://herdr.dev)).
+Herdr's design, socket API, name, and branding (the ram mark and prompt logo this
+app's icon echoes) are theirs. This repository is an independent, unofficial iOS
+client and is not affiliated with or endorsed by the Herdr project.
+
+## References
+
+- Docs: · Socket API:
+- Source: (`README.md`, `SKILL.md`)
diff --git a/herdr-ios/Sources/HerdrKit/Client/HerdrClient.swift b/herdr-ios/Sources/HerdrKit/Client/HerdrClient.swift
new file mode 100644
index 0000000..4b7723a
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Client/HerdrClient.swift
@@ -0,0 +1,320 @@
+import Foundation
+
+/// High-level, typed API over a `HerdrTransport`.
+///
+/// Responsibilities:
+/// - generate request ids and correlate replies to the awaiting caller,
+/// - demultiplex server-pushed events into a single `events` stream the UI
+/// can observe for live status/output updates,
+/// - expose ergonomic async methods (`listWorkspaces`, `readPane`, …).
+///
+/// It is an `actor`, so all id/continuation bookkeeping is serialized without
+/// locks.
+public actor HerdrClient {
+ private let transport: HerdrTransport
+
+ private var nextID = 0
+ private var subscriptionTasks: [Task] = []
+
+ private let events: AsyncStream
+ private let eventsContinuation: AsyncStream.Continuation
+
+ public init(transport: HerdrTransport) {
+ self.transport = transport
+ var continuation: AsyncStream.Continuation!
+ self.events = AsyncStream(bufferingPolicy: .unbounded) { continuation = $0 }
+ self.eventsContinuation = continuation
+ }
+
+ /// Live stream of domain events. Observe this to react to status/output
+ /// changes. Multiple awaits share one underlying stream.
+ public var eventStream: AsyncStream { events }
+
+ // MARK: Lifecycle
+
+ public func connect() async throws {
+ try await transport.connect()
+ // No request/event channels yet — RPCs open one channel each, and
+ // `subscribe(_:)` opens the persistent event channel.
+ }
+
+ public func disconnect() async {
+ for task in subscriptionTasks { task.cancel() }
+ subscriptionTasks.removeAll()
+ await transport.disconnect()
+ eventsContinuation.finish()
+ }
+
+ // MARK: Typed API
+
+ public func ping() async throws {
+ _ = try await call(Method.ping)
+ }
+
+ /// Build the nested workspace tree from Herdr's flat, granular endpoints:
+ /// `workspace.list` + a single global `pane.list` + `tab.list` per workspace
+ /// + best-effort `agent.list` (for agent names). `HerdrClient` is the
+ /// anti-corruption layer; the UI keeps seeing a nested tree.
+ public func listWorkspaces() async throws -> [Workspace] {
+ let wsList = try await call(Method.workspaceList).decodedSnake(WorkspaceListResult.self)
+ let paneList = try await call(Method.paneList).decodedSnake(PaneListResult.self)
+ let agentNames = await agentNameMap()
+ let panesByTab = Dictionary(grouping: paneList.panes, by: \.tabId)
+
+ var workspaces: [Workspace] = []
+ for ws in wsList.workspaces {
+ let tabList = try await call(
+ Method.tabList, .object(["workspace_id": .string(ws.workspaceId)])
+ ).decodedSnake(TabListResult.self)
+
+ let tabs = tabList.tabs.map { tab in
+ Tab(
+ id: TabID(tab.tabId),
+ label: tab.label,
+ panes: (panesByTab[tab.tabId] ?? []).map { makePane($0, agentNames) }
+ )
+ }
+ let allPanes = tabs.flatMap(\.panes)
+ let cwd = (allPanes.first(where: \.isFocused) ?? allPanes.first)?.cwd
+ workspaces.append(Workspace(id: WorkspaceID(ws.workspaceId), label: ws.label, cwd: cwd, tabs: tabs))
+ }
+ return workspaces
+ }
+
+ /// Read a pane's scrollback as logical lines (`recent_unwrapped`, so the
+ /// server's terminal-width soft-wrapping isn't baked in — the right source to
+ /// re-wrap for a narrow screen). `TerminalText.clean` makes it mobile-ready.
+ public func readPane(_ pane: PaneID, lines: Int = 200) async throws -> [String] {
+ try await readLines(pane, source: PaneReadSource.recentUnwrapped, lines: lines,
+ format: PaneReadFormat.ansi, stripAnsi: false)
+ }
+
+ /// Read the terminal grid hard-wrapped to the server width — backs the
+ /// Fit/Scroll modes (Reader uses `readPane`). Prefers `recent` (the full
+ /// scrollback) so the grid modes can scroll back through history, and falls
+ /// back to `visible` (the live on-screen grid) for alternate-screen TUIs,
+ /// whose scrollback is empty. ANSI is kept so the UI can render fg/bg/inverse
+ /// cells (e.g. an agent's logo).
+ public func readRawTerminal(_ pane: PaneID, lines: Int = 500) async throws -> [String] {
+ let recent = try await readLines(pane, source: PaneReadSource.recent, lines: lines,
+ format: PaneReadFormat.ansi, stripAnsi: false)
+ if !recent.isEmpty { return recent }
+ return try await readLines(pane, source: PaneReadSource.visible, lines: lines,
+ format: PaneReadFormat.ansi, stripAnsi: false)
+ }
+
+ private func readLines(_ pane: PaneID, source: String, lines: Int?,
+ format: String? = nil, stripAnsi: Bool? = nil) async throws -> [String] {
+ var params: [String: JSONValue] = [
+ "pane_id": .string(pane.rawValue),
+ "source": .string(source),
+ ]
+ if let lines { params["lines"] = .int(lines) }
+ if let format { params["format"] = .string(format) }
+ if let stripAnsi { params["strip_ansi"] = .bool(stripAnsi) }
+ let result = try await call(Method.paneRead, .object(params))
+ guard let text = try result.decodedSnake(PaneReadResult.self).read.text else { return [] }
+ // Split on the LF unicode scalar, NOT `text.split(separator: "\n")`:
+ // grid rows arrive CRLF-terminated, and Swift fuses "\r\n" into a single
+ // grapheme, so a Character-level split on "\n" never matches and collapses
+ // the whole grid into one 1800-char line (the bug behind blank grid modes).
+ var split = text.unicodeScalars
+ .split(separator: "\n", omittingEmptySubsequences: false)
+ .map { String(String.UnicodeScalarView($0)) }
+ // Drop the CR left on each row by the CRLF terminator so it doesn't leak
+ // into rendering or inflate the fit-mode width count.
+ for i in split.indices where split[i].hasSuffix("\r") { split[i].removeLast() }
+ if split.last == "" { split.removeLast() } // drop the artifact of a trailing newline
+ return split
+ }
+
+ /// Block until the pane emits **new** output, or `timeoutMS` elapses. Returns
+ /// `true` if output arrived, `false` on a clean timeout. This is the
+ /// event-driven alternative to fixed-interval polling: it holds one channel
+ /// open and returns the instant the screen changes, staying quiet while idle.
+ ///
+ /// `match` is a regex that matches any single character (incl. newlines), so
+ /// any new output satisfies it — `pane.wait_for_output` is otherwise a
+ /// targeted wait (substring/regex). A timeout comes back as an RPC error with
+ /// code `timeout`, which we treat as a normal "nothing happened" result.
+ @discardableResult
+ public func waitForOutput(
+ _ pane: PaneID,
+ source: String = PaneReadSource.recentUnwrapped,
+ timeoutMS: Int = 15_000
+ ) async throws -> Bool {
+ do {
+ _ = try await call(Method.paneWaitForOutput, .object([
+ "pane_id": .string(pane.rawValue),
+ "source": .string(source),
+ "timeout_ms": .int(timeoutMS),
+ "match": .object(["type": .string("regex"), "value": .string("(?s:.)")]),
+ ]))
+ return true
+ } catch HerdrError.rpc(let error) where error.code == "timeout" {
+ return false
+ }
+ }
+
+ /// Send literal text to a pane without a trailing newline.
+ public func sendText(_ text: String, to pane: PaneID) async throws {
+ _ = try await call(Method.paneSendText, .object([
+ "pane_id": .string(pane.rawValue),
+ "text": .string(text),
+ ]))
+ }
+
+ /// Send one or more named key presses to a pane. Key names use Herdr's
+ /// syntax: plain names (`Enter`, `Esc`, `Tab`, `Up`…) and modifier combos
+ /// with `+` (`ctrl+b`, `ctrl+c`). The wire field is a sequence.
+ public func sendKeys(_ keys: String..., to pane: PaneID) async throws {
+ _ = try await call(Method.paneSendKeys, .object([
+ "pane_id": .string(pane.rawValue),
+ "keys": .array(keys.map(JSONValue.string)),
+ ]))
+ }
+
+ /// Convenience: submit a line of input (text + Enter), as the pane view does.
+ public func submitLine(_ text: String, to pane: PaneID) async throws {
+ try await sendText(text, to: pane)
+ try await sendKeys("Enter", to: pane)
+ }
+
+ /// Create a new workspace. Both params are optional — the server fills in
+ /// defaults (an omitted `cwd` uses the server's working directory). Returns
+ /// the new workspace's id when the server reports one, so the caller can
+ /// navigate straight into it; `nil` if the result omits it (the tree still
+ /// re-lists, both via the explicit refresh and the `workspace_created` event).
+ public func createWorkspace(label: String? = nil, cwd: String? = nil) async throws -> WorkspaceID? {
+ var params: [String: JSONValue] = [:]
+ if let label, !label.isEmpty { params["label"] = .string(label) }
+ if let cwd, !cwd.isEmpty { params["cwd"] = .string(cwd) }
+ let result = try await call(Method.workspaceCreate, .object(params))
+ return Self.extractID(result, idKey: "workspace_id", nested: "workspace").map { WorkspaceID($0) }
+ }
+
+ /// Create a new tab in `workspace`. `label` is optional. Returns the new
+ /// tab's id when reported (same lenient parse / re-list contract as above).
+ public func createTab(label: String? = nil, in workspace: WorkspaceID) async throws -> TabID? {
+ var params: [String: JSONValue] = ["workspace_id": .string(workspace.rawValue)]
+ if let label, !label.isEmpty { params["label"] = .string(label) }
+ let result = try await call(Method.tabCreate, .object(params))
+ return Self.extractID(result, idKey: "tab_id", nested: "tab").map { TabID($0) }
+ }
+
+ /// Close a workspace (and everything in it). Fire-and-forget: the tree
+ /// re-lists via the `workspace.closed` topology event (and the caller's
+ /// explicit refresh). Closing kills the live processes inside — the UI
+ /// confirms before calling this.
+ public func closeWorkspace(_ id: WorkspaceID) async throws {
+ _ = try await call(Method.workspaceClose, .object(["workspace_id": .string(id.rawValue)]))
+ }
+
+ /// Close a single tab within a workspace.
+ public func closeTab(_ id: TabID) async throws {
+ _ = try await call(Method.tabClose, .object(["tab_id": .string(id.rawValue)]))
+ }
+
+ /// Close a single pane (terminal process).
+ public func closePane(_ id: PaneID) async throws {
+ _ = try await call(Method.paneClose, .object(["pane_id": .string(id.rawValue)]))
+ }
+
+ /// Pull a created resource's id out of a create result, tolerating the shapes
+ /// Herdr might use: a top-level `_id`, a nested `{"":{…}}`
+ /// (mirroring `*.get`'s `{"type":"pane_info","pane":{…}}`), or a bare `id`.
+ /// Create's result body isn't pinned down in the docs, so parse defensively.
+ private static func extractID(_ result: JSONValue, idKey: String, nested: String) -> String? {
+ result[idKey]?.stringValue
+ ?? result[nested]?[idKey]?.stringValue
+ ?? result["id"]?.stringValue
+ ?? result[nested]?["id"]?.stringValue
+ }
+
+ /// Open live subscriptions on a persistent event channel. Each call opens
+ /// its own channel (Herdr streams events per subscription connection); the
+ /// pushed events are funnelled into `eventStream`.
+ public func subscribe(_ subscriptions: [EventSubscription]) async throws {
+ let objects = subscriptions.flatMap(\.jsonObjects)
+ guard !objects.isEmpty else { return }
+ nextID += 1
+ let request = RPCRequest(
+ id: "sub_\(nextID)",
+ method: Method.eventsSubscribe,
+ params: .object(["subscriptions": .array(objects)])
+ )
+ let stream = transport.events(request)
+ // Confirm the channel opened (first message = the `subscription_started`
+ // ack) before returning, so a failed subscription throws and the caller
+ // can retry — then keep funnelling events in the background.
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ let task = Task { [weak self] in
+ var opened = false
+ for await message in stream {
+ if !opened {
+ opened = true
+ if case .response(let response) = message, let error = response.error {
+ continuation.resume(throwing: HerdrError.rpc(error))
+ return
+ }
+ continuation.resume()
+ }
+ if case .event(let raw) = message, let domain = HerdrEvent(raw) {
+ await self?.emit(domain)
+ }
+ }
+ if !opened {
+ continuation.resume(throwing: HerdrError.connectionFailed(
+ "The event subscription closed before it started."))
+ }
+ }
+ subscriptionTasks.append(task)
+ }
+ }
+
+ private func emit(_ event: HerdrEvent) { eventsContinuation.yield(event) }
+
+ // MARK: Assembly helpers
+
+ /// Best-effort `pane_id → agent name` map. `agent.list`'s shape isn't pinned
+ /// down (it's empty unless agents run), so parse defensively and tolerate any
+ /// shape — names are enrichment, not correctness.
+ private func agentNameMap() async -> [String: String] {
+ guard let result = try? await call(Method.agentList),
+ let agents = result["agents"]?.arrayValue else { return [:] }
+ var map: [String: String] = [:]
+ for agent in agents {
+ guard let paneID = agent["pane_id"]?.stringValue else { continue }
+ let name = agent["name"]?.stringValue ?? agent["agent"]?.stringValue
+ ?? agent["kind"]?.stringValue ?? agent["title"]?.stringValue
+ if let name { map[paneID] = name }
+ }
+ return map
+ }
+
+ private func makePane(_ dto: PaneInfoDTO, _ agentNames: [String: String]) -> Pane {
+ let status = dto.agentStatus.flatMap(AgentStatus.init(rawValue:)) ?? .unknown
+ let name = agentNames[dto.paneId]
+ let isAgent = name != nil || status != .unknown
+ let title = name ?? "shell"
+ return Pane(
+ id: PaneID(dto.paneId),
+ title: title,
+ agent: name,
+ status: status,
+ isFocused: dto.focused ?? false,
+ cwd: dto.foregroundCwd ?? dto.cwd,
+ isAgent: isAgent
+ )
+ }
+
+ // MARK: Request plumbing
+
+ private func call(_ method: String, _ params: JSONValue = .object([:])) async throws -> JSONValue {
+ nextID += 1
+ let request = RPCRequest(id: "req_\(nextID)", method: method, params: params)
+ let response = try await transport.request(request)
+ if let error = response.error { throw HerdrError.rpc(error) }
+ return response.result ?? .null
+ }
+}
diff --git a/herdr-ios/Sources/HerdrKit/Client/HerdrEvent.swift b/herdr-ios/Sources/HerdrKit/Client/HerdrEvent.swift
new file mode 100644
index 0000000..bd1ddbd
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Client/HerdrEvent.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// A decoded, domain-level event surfaced by `HerdrClient` to the UI. Raw
+/// `RPCEvent`s from the socket are translated into these so views never touch
+/// JSON.
+public enum HerdrEvent: Sendable {
+ /// An agent in a pane changed status.
+ case agentStatus(pane: PaneID, status: AgentStatus)
+ /// Topology changed; the client should re-list workspaces.
+ case topologyChanged
+
+ /// Translate a raw socket event, or `nil` if it isn't one we model. Event
+ /// names are the underscored wire form (e.g. `pane_agent_status_changed`).
+ init?(_ event: RPCEvent) {
+ // Herdr's wire is dot-namespaced (`pane.agent_status_changed`), but the
+ // pushed-event name form isn't pinned down in the docs and the Mock uses
+ // underscores. Normalize dots→underscores so either form matches — else a
+ // real server pushing the dot form drops every status/topology event and
+ // the UI's status never updates live.
+ switch event.method.replacingOccurrences(of: ".", with: "_") {
+ case EventName.paneAgentStatusChanged:
+ guard let pane = event.params["pane_id"]?.stringValue else { return nil }
+ let raw = event.params["agent_status"]?.stringValue ?? event.params["status"]?.stringValue
+ let status = raw.flatMap(AgentStatus.init(rawValue:)) ?? .unknown
+ self = .agentStatus(pane: PaneID(pane), status: status)
+
+ case let name where EventName.topology.contains(name):
+ self = .topologyChanged
+
+ default:
+ return nil
+ }
+ }
+}
diff --git a/herdr-ios/Sources/HerdrKit/Mock/MockData.swift b/herdr-ios/Sources/HerdrKit/Mock/MockData.swift
new file mode 100644
index 0000000..40287c1
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Mock/MockData.swift
@@ -0,0 +1,71 @@
+import Foundation
+
+/// Realistic sample data so the entire UI is exercisable without a live Herdr
+/// server.
+public enum MockData {
+ public static let workspaces: [Workspace] = [
+ Workspace(
+ id: "1",
+ label: "herdr-ios",
+ cwd: "~/code/herdr-ios",
+ tabs: [
+ Tab(id: "1:1", label: "agents", panes: [
+ Pane(id: "1-1", title: "claude · build UI", agent: "claude", status: .working, isFocused: true, cwd: "~/code/herdr-ios"),
+ Pane(id: "1-2", title: "codex · write tests", agent: "codex", status: .blocked, cwd: "~/code/herdr-ios"),
+ ]),
+ Tab(id: "1:2", label: "shell", panes: [
+ Pane(id: "1-3", title: "zsh", agent: nil, status: .unknown, cwd: "~/code/herdr-ios"),
+ ]),
+ ]
+ ),
+ Workspace(
+ id: "2",
+ label: "api-server",
+ cwd: "~/code/api",
+ tabs: [
+ Tab(id: "2:1", label: "main", panes: [
+ Pane(id: "2-1", title: "claude · refactor auth", agent: "claude", status: .done, cwd: "~/code/api"),
+ Pane(id: "2-2", title: "claude · migrations", agent: "claude", status: .idle, cwd: "~/code/api"),
+ Pane(id: "2-3", title: "logs", agent: nil, status: .unknown, cwd: "~/code/api"),
+ ]),
+ ]
+ ),
+ Workspace(
+ id: "3",
+ label: "infra",
+ cwd: "~/ops",
+ tabs: [
+ Tab(id: "3:1", label: "deploy", panes: [
+ Pane(id: "3-1", title: "codex · terraform plan", agent: "codex", status: .working, cwd: "~/ops"),
+ ]),
+ ]
+ ),
+ ]
+
+ /// Canned recent scrollback per pane.
+ public static let output: [PaneID: [String]] = [
+ "1-1": [
+ "● Building SwiftUI views…",
+ " Created WorkspaceListView.swift",
+ " Created PaneView.swift",
+ "● Wiring the HerdrClient event stream",
+ " Subscribed to agent-status events",
+ "▌",
+ ],
+ "1-2": [
+ "● Writing tests for the NDJSON codec",
+ " ? Should events without an id be treated as notifications?",
+ " Waiting for your confirmation to proceed…",
+ ],
+ "2-1": [
+ "● Refactor complete.",
+ " 12 files changed, 340 insertions(+), 210 deletions(-)",
+ "✓ All checks passed.",
+ ],
+ "3-1": [
+ "● terraform plan",
+ " ~ aws_instance.app will be updated in-place",
+ " Plan: 0 to add, 1 to change, 0 to destroy.",
+ ],
+ ]
+}
diff --git a/herdr-ios/Sources/HerdrKit/Mock/MockTransport.swift b/herdr-ios/Sources/HerdrKit/Mock/MockTransport.swift
new file mode 100644
index 0000000..c28019f
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Mock/MockTransport.swift
@@ -0,0 +1,222 @@
+import Foundation
+
+/// In-memory `HerdrTransport` that answers requests from sample data and streams
+/// a trickle of live status events, so the app behaves like it's connected to a
+/// busy Herdr server. Mirrors the real wire shapes (type-tagged responses,
+/// `{"event":…}` envelopes) and the real one-request-per-connection model. This
+/// is the default transport the app boots on.
+public actor MockTransport: HerdrTransport {
+ private var workspaces: [Workspace]
+ private let output: [PaneID: [String]]
+ private let agentPaneIDs: [PaneID]
+ private let tickInterval: Duration
+
+ public init(
+ workspaces: [Workspace] = MockData.workspaces,
+ output: [PaneID: [String]] = MockData.output,
+ tickInterval: Duration = .seconds(3)
+ ) {
+ self.workspaces = workspaces
+ self.output = output
+ self.agentPaneIDs = workspaces.flatMap(\.agentPanes).map(\.id)
+ self.tickInterval = tickInterval
+ }
+
+ public func connect() async throws {}
+ public func disconnect() async {}
+
+ public func request(_ request: RPCRequest) async throws -> RPCResponse {
+ if request.method == Method.paneWaitForOutput {
+ // The demo has static scrollback, so model an idle pane: wait briefly,
+ // then report the server's real idle response (a `timeout` error). The
+ // poll loop re-reads on its normal cadence; the live feel comes from
+ // the streamed agent-status events.
+ try? await Task.sleep(for: .seconds(2))
+ return RPCResponse(id: request.id, result: nil,
+ error: RPCError(code: "timeout", message: "timed out waiting for output match"))
+ }
+ return makeResponse(for: request)
+ }
+
+ /// Persistent subscription: acks with `subscription_started`, then emits
+ /// agent-status changes (the real event shape) **only for the panes the
+ /// request subscribed to** via `pane.agent_status_changed`. A topology-only
+ /// subscription gets the ack and no status events — mirroring the server, so
+ /// tests exercise the real subscription wiring.
+ public nonisolated func events(_ subscribeRequest: RPCRequest) -> AsyncStream {
+ let subscribedPanes: [PaneID] = (subscribeRequest.params["subscriptions"]?.arrayValue ?? [])
+ .compactMap { sub in
+ sub["type"]?.stringValue == SubscriptionType.paneAgentStatusChanged
+ ? sub["pane_id"]?.stringValue.map { PaneID($0) }
+ : nil
+ }
+ return AsyncStream { continuation in
+ let task = Task { [weak self] in
+ guard let self else { continuation.finish(); return }
+ continuation.yield(.response(RPCResponse(
+ id: subscribeRequest.id,
+ result: .object(["type": .string("subscription_started")]),
+ error: nil
+ )))
+ let interval = self.tickInterval
+ let agentPanes = self.agentPaneIDs
+ let targets = subscribedPanes.filter(agentPanes.contains)
+ guard !targets.isEmpty else { return } // topology-only: ack, no status ticks
+ while !Task.isCancelled {
+ try? await Task.sleep(for: interval)
+ if Task.isCancelled { break }
+ guard let pane = targets.randomElement() else { continue }
+ let status = AgentStatus.allCases.filter { $0 != .unknown }.randomElement() ?? .working
+ continuation.yield(.event(RPCEvent(
+ method: EventName.paneAgentStatusChanged,
+ params: .object([
+ "pane_id": .string(pane.rawValue),
+ "agent_status": .string(status.rawValue),
+ ])
+ )))
+ }
+ continuation.finish()
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
+ /// A fake agent status footer (the `detection` snapshot) with ANSI color, so
+ /// the demo exercises the pane's live status strip. Empty for non-agent panes.
+ private func mockStatus(for pane: PaneID?) -> String {
+ guard let pane, agentPaneIDs.contains(pane) else { return "" }
+ let e = "\u{1B}["
+ return [
+ "free-screentime-app · Opus 4.8",
+ "\(e)36mcontext\(e)0m ▓▓░░░░░░░░ 17% \(e)32mgit:main*\(e)0m",
+ "\(e)33m🔨 Build [heavy]\(e)0m critic:auto 1m50s",
+ " └ Build Free Screentime v1 per docs/V1_SCOPE.md…",
+ "\(e)35m▶▶ bypass permissions on\(e)0m (shift+tab to cycle)",
+ ].joined(separator: "\n")
+ }
+
+ /// All panes flattened, paired with their workspace/tab ids.
+ private func flatPanes() -> [(workspace: Workspace, tab: Tab, pane: Pane)] {
+ workspaces.flatMap { ws in ws.tabs.flatMap { tab in tab.panes.map { (ws, tab, $0) } } }
+ }
+
+ private func makeResponse(for request: RPCRequest) -> RPCResponse {
+ let result: JSONValue
+ switch request.method {
+ case Method.workspaceList:
+ result = .object(["type": .string("workspace_list"), "workspaces": .array(
+ workspaces.map { ws in .object([
+ "workspace_id": .string(ws.id.rawValue),
+ "label": .string(ws.label),
+ "active_tab_id": ws.tabs.first.map { .string($0.id.rawValue) } ?? .null,
+ "agent_status": .string(ws.aggregateStatus.rawValue),
+ ]) }
+ )])
+
+ case Method.tabList:
+ let wsID = request.params["workspace_id"]?.stringValue
+ let tabs = workspaces.first { $0.id.rawValue == wsID }?.tabs ?? []
+ result = .object(["type": .string("tab_list"), "tabs": .array(
+ tabs.map { tab in .object([
+ "tab_id": .string(tab.id.rawValue),
+ "workspace_id": .string(wsID ?? ""),
+ "label": .string(tab.label),
+ "agent_status": .string(AgentStatus.mostUrgent(tab.panes.map(\.status)).rawValue),
+ ]) }
+ )])
+
+ case Method.paneList:
+ result = .object(["type": .string("pane_list"), "panes": .array(
+ flatPanes().map { entry in .object([
+ "pane_id": .string(entry.pane.id.rawValue),
+ "workspace_id": .string(entry.workspace.id.rawValue),
+ "tab_id": .string(entry.tab.id.rawValue),
+ "cwd": entry.pane.cwd.map { .string($0) } ?? .null,
+ "agent_status": .string(entry.pane.status.rawValue),
+ "focused": .bool(entry.pane.isFocused),
+ ]) }
+ )])
+
+ case Method.agentList:
+ // Surface agent names so the demo shows them (the real server's
+ // shape is unconfirmed; the client parses this defensively).
+ result = .object(["type": .string("agent_list"), "agents": .array(
+ flatPanes().filter { $0.pane.isAgent }.compactMap { entry in
+ entry.pane.agent.map { name in .object([
+ "pane_id": .string(entry.pane.id.rawValue),
+ "name": .string(name),
+ "status": .string(entry.pane.status.rawValue),
+ ]) }
+ }
+ )])
+
+ case Method.paneRead:
+ let pane = request.params["pane_id"]?.stringValue.map { PaneID($0) }
+ let text: String
+ if request.params["source"]?.stringValue == PaneReadSource.detection {
+ text = mockStatus(for: pane) // agent footer (ANSI-colored), else empty
+ } else {
+ text = (pane.flatMap { output[$0] } ?? []).joined(separator: "\n")
+ }
+ result = .object(["type": .string("pane_read"), "read": .object([
+ "text": .string(text),
+ "format": .string("text"),
+ ])])
+
+ case Method.workspaceCreate:
+ let cwd = request.params["cwd"]?.stringValue
+ let id = "ws-mock\(workspaces.count + 1)"
+ let label = request.params["label"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 } ?? id
+ let pane = Pane(id: PaneID("\(id)-p1"), title: "shell", isFocused: true, cwd: cwd)
+ let workspace = Workspace(id: WorkspaceID(id), label: label, cwd: cwd,
+ tabs: [Tab(id: TabID("\(id)-t1"), label: "main", panes: [pane])])
+ workspaces.append(workspace)
+ result = .object(["type": .string("workspace_info"), "workspace": .object([
+ "workspace_id": .string(id),
+ "label": .string(label),
+ ])])
+
+ case Method.tabCreate:
+ let wsID = request.params["workspace_id"]?.stringValue ?? ""
+ guard let idx = workspaces.firstIndex(where: { $0.id.rawValue == wsID }) else {
+ return RPCResponse(id: request.id, result: nil, error: RPCError(
+ code: "not_found", message: "No such workspace: \(wsID)"))
+ }
+ let number = workspaces[idx].tabs.count + 1
+ let tabID = "\(wsID)-t\(number)"
+ let label = request.params["label"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 } ?? "tab \(number)"
+ let pane = Pane(id: PaneID("\(tabID)-p1"), title: "shell", cwd: workspaces[idx].cwd)
+ workspaces[idx].tabs.append(Tab(id: TabID(tabID), label: label, panes: [pane]))
+ result = .object(["type": .string("tab_info"), "tab": .object([
+ "tab_id": .string(tabID),
+ "workspace_id": .string(wsID),
+ "label": .string(label),
+ ])])
+
+ case Method.workspaceClose:
+ let id = request.params["workspace_id"]?.stringValue
+ workspaces.removeAll { $0.id.rawValue == id }
+ result = .object(["type": .string("ok")])
+
+ case Method.tabClose:
+ let id = request.params["tab_id"]?.stringValue
+ for i in workspaces.indices { workspaces[i].tabs.removeAll { $0.id.rawValue == id } }
+ workspaces.removeAll { $0.tabs.isEmpty } // a workspace with no tabs is gone
+ result = .object(["type": .string("ok")])
+
+ case Method.paneClose:
+ let id = request.params["pane_id"]?.stringValue
+ for w in workspaces.indices {
+ for t in workspaces[w].tabs.indices { workspaces[w].tabs[t].panes.removeAll { $0.id.rawValue == id } }
+ workspaces[w].tabs.removeAll { $0.panes.isEmpty }
+ }
+ workspaces.removeAll { $0.tabs.isEmpty }
+ result = .object(["type": .string("ok")])
+
+ default:
+ // send_text / send_keys / ping and anything else: ack.
+ result = .object(["type": .string("ok")])
+ }
+ return RPCResponse(id: request.id, result: result, error: nil)
+ }
+}
diff --git a/herdr-ios/Sources/HerdrKit/Models/AgentStatus.swift b/herdr-ios/Sources/HerdrKit/Models/AgentStatus.swift
new file mode 100644
index 0000000..9b98caf
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Models/AgentStatus.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// The semantic state Herdr reports for an agent running in a pane.
+///
+/// Mirrors the values documented in the socket API / `SKILL.md`:
+/// `idle`, `working`, `blocked`, `done`, `unknown`.
+public enum AgentStatus: String, Codable, Sendable, CaseIterable {
+ /// Completed and seen by the user.
+ case idle
+ /// Actively running.
+ case working
+ /// Needs input — the most urgent state.
+ case blocked
+ /// Completed but not yet seen.
+ case done
+ /// Not enough signal to classify (or not an agent).
+ case unknown
+
+ /// Ordering used when collapsing several panes into one badge: the most
+ /// attention-worthy status wins (blocked > working > done > idle > unknown).
+ public var priority: Int {
+ switch self {
+ case .blocked: return 4
+ case .working: return 3
+ case .done: return 2
+ case .idle: return 1
+ case .unknown: return 0
+ }
+ }
+
+ public static func mostUrgent(_ statuses: [AgentStatus]) -> AgentStatus {
+ statuses.max(by: { $0.priority < $1.priority }) ?? .unknown
+ }
+}
diff --git a/herdr-ios/Sources/HerdrKit/Models/IDs.swift b/herdr-ios/Sources/HerdrKit/Models/IDs.swift
new file mode 100644
index 0000000..21c70d7
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Models/IDs.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+// Herdr ids are short strings that *compact* when workspaces/tabs/panes close
+// (workspace `1`, tab `1:1`, pane `1-1`). They are NOT durable — never persist
+// them or assume they survive a refresh. We model them as distinct value types
+// so a pane id can't be passed where a workspace id is expected.
+
+/// Workspace identifier, e.g. `"1"`, `"2"`.
+public struct WorkspaceID: Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral {
+ public let rawValue: String
+ public init(_ rawValue: String) { self.rawValue = rawValue }
+ public init(stringLiteral value: String) { self.rawValue = value }
+ public init(from decoder: Decoder) throws { rawValue = try decoder.singleValueContainer().decode(String.self) }
+ public func encode(to encoder: Encoder) throws { var c = encoder.singleValueContainer(); try c.encode(rawValue) }
+ public var description: String { rawValue }
+}
+
+/// Tab identifier, e.g. `"1:1"`, `"1:2"`.
+public struct TabID: Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral {
+ public let rawValue: String
+ public init(_ rawValue: String) { self.rawValue = rawValue }
+ public init(stringLiteral value: String) { self.rawValue = value }
+ public init(from decoder: Decoder) throws { rawValue = try decoder.singleValueContainer().decode(String.self) }
+ public func encode(to encoder: Encoder) throws { var c = encoder.singleValueContainer(); try c.encode(rawValue) }
+ public var description: String { rawValue }
+}
+
+/// Pane identifier, e.g. `"1-1"`, `"2-1"`.
+public struct PaneID: Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral {
+ public let rawValue: String
+ public init(_ rawValue: String) { self.rawValue = rawValue }
+ public init(stringLiteral value: String) { self.rawValue = value }
+ public init(from decoder: Decoder) throws { rawValue = try decoder.singleValueContainer().decode(String.self) }
+ public func encode(to encoder: Encoder) throws { var c = encoder.singleValueContainer(); try c.encode(rawValue) }
+ public var description: String { rawValue }
+}
diff --git a/herdr-ios/Sources/HerdrKit/Models/Pane.swift b/herdr-ios/Sources/HerdrKit/Models/Pane.swift
new file mode 100644
index 0000000..b679497
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Models/Pane.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+/// A pane is a real terminal process inside a tab. It may host an identified
+/// agent (e.g. Claude Code, Codex), in which case `agent` carries its name and
+/// `status` reflects the agent's live state.
+public struct Pane: Identifiable, Codable, Hashable, Sendable {
+ public let id: PaneID
+ /// Human-facing title (process / command / agent label).
+ public var title: String
+ /// Name of the detected agent, e.g. `"claude"`. `nil` when the pane is a
+ /// plain shell rather than a recognized agent.
+ public var agent: String?
+ public var status: AgentStatus
+ /// Whether this pane currently holds focus within its tab.
+ public var isFocused: Bool
+ public var cwd: String?
+ /// True when this pane hosts a recognized agent. Stored (not `agent != nil`)
+ /// because the real API can report an agent status for a pane before its
+ /// name is known — we still want the UI to treat it as an agent pane.
+ public var isAgent: Bool
+
+ public init(
+ id: PaneID,
+ title: String,
+ agent: String? = nil,
+ status: AgentStatus = .unknown,
+ isFocused: Bool = false,
+ cwd: String? = nil,
+ isAgent: Bool? = nil
+ ) {
+ self.id = id
+ self.title = title
+ self.agent = agent
+ self.status = status
+ self.isFocused = isFocused
+ self.cwd = cwd
+ self.isAgent = isAgent ?? (agent != nil)
+ }
+}
diff --git a/herdr-ios/Sources/HerdrKit/Models/Workspace.swift b/herdr-ios/Sources/HerdrKit/Models/Workspace.swift
new file mode 100644
index 0000000..bf5bad4
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Models/Workspace.swift
@@ -0,0 +1,46 @@
+import Foundation
+
+/// A tab groups one or more panes within a workspace.
+public struct Tab: Identifiable, Codable, Hashable, Sendable {
+ public let id: TabID
+ public var label: String
+ public var panes: [Pane]
+
+ public init(id: TabID, label: String, panes: [Pane]) {
+ self.id = id
+ self.label = label
+ self.panes = panes
+ }
+}
+
+/// A workspace is a project container holding tabs (which hold panes).
+public struct Workspace: Identifiable, Codable, Hashable, Sendable {
+ public let id: WorkspaceID
+ public var label: String
+ public var cwd: String?
+ public var tabs: [Tab]
+
+ public init(id: WorkspaceID, label: String, cwd: String? = nil, tabs: [Tab]) {
+ self.id = id
+ self.label = label
+ self.cwd = cwd
+ self.tabs = tabs
+ }
+
+ /// All panes across every tab, flattened.
+ public var panes: [Pane] { tabs.flatMap(\.panes) }
+
+ /// Only the panes that host a recognized agent.
+ public var agentPanes: [Pane] { panes.filter(\.isAgent) }
+
+ /// A single status summarizing the workspace for the list row — the most
+ /// urgent agent status present (blocked beats working beats done…).
+ public var aggregateStatus: AgentStatus {
+ AgentStatus.mostUrgent(agentPanes.map(\.status))
+ }
+
+ /// Count of agent panes per status, for compact badges.
+ public func agentCounts() -> [AgentStatus: Int] {
+ Dictionary(grouping: agentPanes, by: \.status).mapValues(\.count)
+ }
+}
diff --git a/herdr-ios/Sources/HerdrKit/Protocol/JSONValue.swift b/herdr-ios/Sources/HerdrKit/Protocol/JSONValue.swift
new file mode 100644
index 0000000..aa61ec9
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Protocol/JSONValue.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+/// A type-erased JSON value, used for RPC `params` and `result` payloads whose
+/// shape we don't want to model statically. Lets us pass through arbitrary
+/// objects while still building/typed-decoding the parts we care about.
+public enum JSONValue: Codable, Hashable, Sendable {
+ case null
+ case bool(Bool)
+ case int(Int)
+ case double(Double)
+ case string(String)
+ case array([JSONValue])
+ case object([String: JSONValue])
+
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.singleValueContainer()
+ if c.decodeNil() { self = .null; return }
+ if let b = try? c.decode(Bool.self) { self = .bool(b); return }
+ if let i = try? c.decode(Int.self) { self = .int(i); return }
+ if let d = try? c.decode(Double.self) { self = .double(d); return }
+ if let s = try? c.decode(String.self) { self = .string(s); return }
+ if let a = try? c.decode([JSONValue].self) { self = .array(a); return }
+ if let o = try? c.decode([String: JSONValue].self) { self = .object(o); return }
+ throw DecodingError.dataCorruptedError(in: c, debugDescription: "Unsupported JSON value")
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var c = encoder.singleValueContainer()
+ switch self {
+ case .null: try c.encodeNil()
+ case .bool(let b): try c.encode(b)
+ case .int(let i): try c.encode(i)
+ case .double(let d): try c.encode(d)
+ case .string(let s): try c.encode(s)
+ case .array(let a): try c.encode(a)
+ case .object(let o): try c.encode(o)
+ }
+ }
+}
+
+public extension JSONValue {
+ /// Object member access: `value["pane"]`.
+ subscript(_ key: String) -> JSONValue? {
+ if case .object(let o) = self { return o[key] }
+ return nil
+ }
+
+ var stringValue: String? { if case .string(let s) = self { return s } else { return nil } }
+ var arrayValue: [JSONValue]? { if case .array(let a) = self { return a } else { return nil } }
+
+ /// Decode using Herdr's snake_case wire keys (`workspace_id` → `workspaceId`).
+ func decodedSnake(_ type: T.Type) throws -> T {
+ let data = try JSONEncoder().encode(self)
+ let decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ return try decoder.decode(type, from: data)
+ }
+}
diff --git a/herdr-ios/Sources/HerdrKit/Protocol/Method.swift b/herdr-ios/Sources/HerdrKit/Protocol/Method.swift
new file mode 100644
index 0000000..edf67aa
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Protocol/Method.swift
@@ -0,0 +1,90 @@
+import Foundation
+
+/// Socket RPC method names, verified against a live Herdr server (protocol 14).
+/// Methods are dot-namespaced; parameter keys are snake_case (`pane_id`, …).
+public enum Method {
+ public static let ping = "ping"
+
+ public static let workspaceList = "workspace.list"
+ public static let workspaceCreate = "workspace.create"
+ public static let workspaceClose = "workspace.close"
+ public static let tabList = "tab.list"
+ public static let tabCreate = "tab.create"
+ public static let tabClose = "tab.close"
+ public static let paneList = "pane.list"
+ public static let paneClose = "pane.close"
+ public static let agentList = "agent.list"
+
+ public static let paneRead = "pane.read"
+ public static let paneWaitForOutput = "pane.wait_for_output"
+ public static let paneSendText = "pane.send_text"
+ public static let paneSendKeys = "pane.send_keys"
+
+ /// Open a live subscription; the server then streams events on the socket.
+ public static let eventsSubscribe = "events.subscribe"
+}
+
+/// Valid `source` values for `pane.read`.
+public enum PaneReadSource {
+ /// The live on-screen grid, including alternate-screen TUIs (agent UIs).
+ public static let visible = "visible"
+ public static let recent = "recent"
+ public static let recentUnwrapped = "recent_unwrapped"
+ public static let detection = "detection"
+}
+
+/// Valid `format` values for `pane.read`. `ansi` keeps SGR color/style escapes
+/// (pair with `strip_ansi: false`); `text` is plain.
+public enum PaneReadFormat {
+ public static let text = "text"
+ public static let ansi = "ansi"
+}
+
+/// Subscription `type` strings (dot-namespaced) sent inside
+/// `events.subscribe`'s `subscriptions` array.
+public enum SubscriptionType {
+ public static let paneAgentStatusChanged = "pane.agent_status_changed"
+
+ /// Topology-changing subscriptions that don't need a resource id — any of
+ /// these means "re-list". (Per-resource events like `pane.focused` require a
+ /// `pane_id` and are intentionally omitted.)
+ public static let topology = [
+ "workspace.created", "workspace.updated", "workspace.closed", "workspace.renamed",
+ "tab.created", "tab.closed", "tab.renamed",
+ "pane.created", "pane.closed", "pane.moved", "pane.exited", "pane.agent_detected",
+ ]
+}
+
+/// A subscription request, expanded into the wire `subscriptions` objects.
+public enum EventSubscription: Sendable {
+ /// All topology-changing events (re-list trigger).
+ case topology
+ /// Agent-status changes for a specific pane.
+ case paneAgentStatus(PaneID)
+
+ var jsonObjects: [JSONValue] {
+ switch self {
+ case .topology:
+ return SubscriptionType.topology.map { .object(["type": .string($0)]) }
+ case .paneAgentStatus(let pane):
+ return [.object([
+ "type": .string(SubscriptionType.paneAgentStatusChanged),
+ "pane_id": .string(pane.rawValue),
+ ])]
+ }
+ }
+}
+
+/// Canonical (underscored) internal form of pushed-event names. The wire may
+/// spell them dot-namespaced (`pane.agent_status_changed`) or underscored;
+/// `HerdrEvent.init` normalizes dots→underscores before matching against these.
+public enum EventName {
+ public static let paneAgentStatusChanged = "pane_agent_status_changed"
+
+ /// Pushed events that imply the workspace/tab/pane tree changed.
+ public static let topology: Set = [
+ "workspace_created", "workspace_updated", "workspace_closed", "workspace_renamed",
+ "tab_created", "tab_closed", "tab_renamed",
+ "pane_created", "pane_closed", "pane_moved", "pane_exited", "pane_agent_detected",
+ ]
+}
diff --git a/herdr-ios/Sources/HerdrKit/Protocol/NDJSON.swift b/herdr-ios/Sources/HerdrKit/Protocol/NDJSON.swift
new file mode 100644
index 0000000..c1da2e8
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Protocol/NDJSON.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// Newline-delimited JSON framing helpers.
+public enum NDJSON {
+ public static let newline: UInt8 = 0x0A
+
+ /// Encode a value to a single JSON line terminated by `\n`.
+ public static func frame(_ value: T) throws -> Data {
+ var data = try JSONEncoder().encode(value)
+ data.append(newline)
+ return data
+ }
+}
+
+/// Accumulates incoming bytes and yields complete `\n`-terminated lines as they
+/// arrive. Used by stream transports (e.g. the SSH channel bridge) to turn a
+/// byte stream into discrete JSON messages.
+public struct LineBuffer {
+ private var buffer = Data()
+ public init() {}
+
+ /// Append a chunk and return any complete lines now available (without their
+ /// trailing newline). Partial trailing data is retained for the next call.
+ public mutating func append(_ chunk: Data) -> [Data] {
+ buffer.append(chunk)
+ var lines: [Data] = []
+ while let newlineIndex = buffer.firstIndex(of: NDJSON.newline) {
+ let line = buffer[buffer.startIndex..","data":{…}}` (no id/result);
+ /// replies carry `result`/`error` and echo the request `id`. The legacy
+ /// `{"method":…}` form (no id) is still treated as an event for the Mock.
+ public static func decode(line: Data) throws -> IncomingMessage {
+ let raw = try JSONDecoder().decode(RawMessage.self, from: line)
+ if let event = raw.event {
+ return .event(RPCEvent(method: event, params: raw.data ?? .object([:])))
+ }
+ if raw.result != nil || raw.error != nil {
+ return .response(RPCResponse(id: raw.id, result: raw.result, error: raw.error))
+ }
+ if let method = raw.method, raw.id == nil {
+ return .event(RPCEvent(method: method, params: raw.params ?? .object([:])))
+ }
+ // Bare ack: an id with no result body.
+ return .response(RPCResponse(id: raw.id, result: raw.params, error: nil))
+ }
+
+ private struct RawMessage: Decodable {
+ let id: String?
+ let method: String?
+ let params: JSONValue?
+ let result: JSONValue?
+ let error: RPCError?
+ /// Pushed-event name and payload (`{"event":…,"data":…}`).
+ let event: String?
+ let data: JSONValue?
+ }
+}
diff --git a/herdr-ios/Sources/HerdrKit/Protocol/Wire.swift b/herdr-ios/Sources/HerdrKit/Protocol/Wire.swift
new file mode 100644
index 0000000..7641f80
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Protocol/Wire.swift
@@ -0,0 +1,45 @@
+import Foundation
+
+// Data-transfer objects mirroring Herdr's real (flat, type-tagged) socket
+// responses, verified against a live server (protocol 14). Decoded with
+// `JSONValue.decodedSnake` so wire keys like `workspace_id` map to `workspaceId`.
+// `HerdrClient` assembles these into the app's nested domain tree, so the rest
+// of the app never sees the wire shape.
+
+/// `workspace.list` / `workspace.get` element.
+struct WorkspaceSummaryDTO: Decodable {
+ let workspaceId: String
+ let label: String
+ let activeTabId: String?
+ let agentStatus: String?
+}
+
+/// `tab.list` / `tab.get` element.
+struct TabSummaryDTO: Decodable {
+ let tabId: String
+ let workspaceId: String
+ let label: String
+ let agentStatus: String?
+}
+
+/// `pane.list` / `pane.get` element.
+struct PaneInfoDTO: Decodable {
+ let paneId: String
+ let workspaceId: String
+ let tabId: String
+ let cwd: String?
+ let foregroundCwd: String?
+ let agentStatus: String?
+ let focused: Bool?
+}
+
+/// `pane.read` payload (`result.read`).
+struct PaneReadDTO: Decodable {
+ let text: String?
+ let format: String?
+}
+
+struct WorkspaceListResult: Decodable { let workspaces: [WorkspaceSummaryDTO] }
+struct TabListResult: Decodable { let tabs: [TabSummaryDTO] }
+struct PaneListResult: Decodable { let panes: [PaneInfoDTO] }
+struct PaneReadResult: Decodable { let read: PaneReadDTO }
diff --git a/herdr-ios/Sources/HerdrKit/TerminalText.swift b/herdr-ios/Sources/HerdrKit/TerminalText.swift
new file mode 100644
index 0000000..f81ee6a
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/TerminalText.swift
@@ -0,0 +1,68 @@
+import Foundation
+
+/// Projects raw terminal output into a readable mobile transcript: drops the
+/// box-drawing frames a TUI agent (Claude Code, etc.) draws for a wide grid,
+/// unwraps `│ content │` side borders, collapses blank runs, and de-duplicates
+/// the current-screen footer that both `recent` and `detection` reads contain.
+///
+/// Color is *not* touched here — these functions preserve any ANSI SGR escapes
+/// inside the kept text so the UI can still colorize it; ANSI is only stripped
+/// internally for classification/comparison. Pure Foundation, so it unit-tests
+/// on Linux alongside the rest of HerdrKit.
+public enum TerminalText {
+ private static let ansiPattern = "\u{1B}\\[[0-9;?]*[ -/]*[@-~]"
+
+ /// Strip ANSI/VT escape sequences — used to inspect a line's visible text.
+ public static func stripANSI(_ s: String) -> String {
+ guard s.contains("\u{1B}") else { return s }
+ return s.replacingOccurrences(of: ansiPattern, with: "", options: .regularExpression)
+ }
+
+ /// A line whose visible text is nothing but frame/rule characters (box
+ /// drawing, or a run of `-`/`=`/`_`) — i.e. a border or horizontal rule we
+ /// drop entirely on mobile.
+ public static func isFramingLine(_ visible: String) -> Bool {
+ let t = visible.trimmingCharacters(in: .whitespaces)
+ guard t.count >= 2 else { return false }
+ return t.unicodeScalars.allSatisfy { s in
+ (0x2500...0x257F).contains(s.value) // box drawing
+ || s == "-" || s == "=" || s == "_" || s == " "
+ }
+ }
+
+ /// If a line is framed as `│ content │`, drop the outer borders and one pad
+ /// space on each side, preserving inner ANSI. Lines without matching side
+ /// borders are returned unchanged.
+ public static func unwrapSides(_ raw: String) -> String {
+ let v = stripANSI(raw).trimmingCharacters(in: .whitespaces)
+ guard let first = v.first, let last = v.last,
+ "│┃|".contains(first), "│┃|".contains(last), v.count >= 2 else { return raw }
+ let ansi = "(?:\(ansiPattern))*"
+ var s = raw.replacingOccurrences(
+ of: "^\\s*\(ansi)[│┃|]\\s?", with: "", options: .regularExpression)
+ s = s.replacingOccurrences(
+ of: "\\s?[│┃|]\(ansi)\\s*$", with: "", options: .regularExpression)
+ return s
+ }
+
+ /// Clean a block for mobile reading: drop framing lines, unwrap side borders,
+ /// right-trim grid padding, and collapse runs of blank lines (and leading /
+ /// trailing blanks) so the transcript reads without the grid's empty space.
+ public static func clean(_ lines: [String]) -> [String] {
+ var out: [String] = []
+ var pendingBlank = false
+ for raw in lines {
+ let visible = stripANSI(raw)
+ if visible.trimmingCharacters(in: .whitespaces).isEmpty {
+ pendingBlank = !out.isEmpty
+ continue
+ }
+ if isFramingLine(visible) { continue }
+ var line = unwrapSides(raw)
+ while let last = line.last, last == " " || last == "\t" { line.removeLast() }
+ if pendingBlank { out.append(""); pendingBlank = false }
+ out.append(line)
+ }
+ return out
+ }
+}
diff --git a/herdr-ios/Sources/HerdrKit/Transport/HerdrTransport.swift b/herdr-ios/Sources/HerdrKit/Transport/HerdrTransport.swift
new file mode 100644
index 0000000..7af645c
--- /dev/null
+++ b/herdr-ios/Sources/HerdrKit/Transport/HerdrTransport.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+/// A connection to a Herdr socket.
+///
+/// Herdr's socket is **one-request-per-connection** for RPC: you open a
+/// connection, send one request, read its reply, and the server closes it.
+/// Only `events.subscribe` keeps a connection open (to stream events). The
+/// transport models exactly that: `request` is a one-shot round-trip, `events`
+/// opens a persistent subscription stream. Request/response correlation isn't
+/// needed — each request has its own connection, so its reply is unambiguous.
+public protocol HerdrTransport: Sendable {
+ /// Establish the underlying connection (e.g. the SSH session). Per-request
+ /// channels are opened lazily.
+ func connect() async throws
+
+ /// One-shot request/response: open a channel, send the request, read the
+ /// single reply, and let the server close the channel.
+ func request(_ request: RPCRequest) async throws -> RPCResponse
+
+ /// Open a persistent subscription: send `subscribeRequest`, then stream every
+ /// pushed message until the channel closes or the stream is cancelled.
+ func events(_ subscribeRequest: RPCRequest) -> AsyncStream
+
+ /// Close the connection.
+ func disconnect() async
+}
+
+public enum HerdrError: Error, Sendable {
+ case notConnected
+ case transportClosed
+ case rpc(RPCError)
+ /// A human-readable SSH connection problem — bad credentials, unreachable
+ /// host, or a socket bridge that couldn't start. Carries a message safe to
+ /// show the user.
+ case connectionFailed(String)
+}
diff --git a/herdr-ios/Tests/HerdrKitTests/ClientTests.swift b/herdr-ios/Tests/HerdrKitTests/ClientTests.swift
new file mode 100644
index 0000000..51b48ef
--- /dev/null
+++ b/herdr-ios/Tests/HerdrKitTests/ClientTests.swift
@@ -0,0 +1,167 @@
+import XCTest
+@testable import HerdrKit
+
+final class ClientTests: XCTestCase {
+ /// Exercises assembly: workspace.list + pane.list + tab.list + agent.list →
+ /// nested tree.
+ func testListWorkspacesAssemblesTree() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+
+ let workspaces = try await client.listWorkspaces()
+ XCTAssertEqual(workspaces.map(\.label), ["herdr-ios", "api-server", "infra"])
+ XCTAssertEqual(workspaces[0].aggregateStatus, .blocked, "blocked agent should win the badge")
+
+ // Panes are grouped under the right tabs from the global pane.list.
+ XCTAssertEqual(workspaces[0].tabs.map(\.label), ["agents", "shell"])
+ XCTAssertEqual(workspaces[0].tabs[0].panes.count, 2)
+ let claude = workspaces[0].tabs[0].panes.first { $0.id == "1-1" }
+ XCTAssertEqual(claude?.agent, "claude")
+ XCTAssertTrue(claude?.isAgent == true)
+ }
+
+ func testReadPaneReturnsLines() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+
+ let lines = try await client.readPane("1-2")
+ XCTAssertTrue(lines.contains { $0.contains("Waiting for your confirmation") })
+ }
+
+ /// The mock models an idle pane, so `waitForOutput` returns `false` on the
+ /// server's `timeout` error rather than throwing — the gate the live poll
+ /// loop relies on to keep looping instead of erroring out.
+ func testWaitForOutputReturnsFalseOnTimeout() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+ let matched = try await client.waitForOutput("1-1", timeoutMS: 50)
+ XCTAssertFalse(matched, "an idle-pane timeout is a normal false, not a throw")
+ }
+
+ /// Subscribing to a pane's status opens the persistent event channel and
+ /// delivers that pane's status changes as typed `HerdrEvent`s.
+ func testSubscribeDeliversStatusChangesForSubscribedPane() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .milliseconds(20)))
+ try await client.connect()
+ try await client.subscribe([.paneAgentStatus("1-1")])
+
+ let received = Task { () -> HerdrEvent? in
+ for await event in await client.eventStream {
+ if case .agentStatus(let pane, _) = event, pane == "1-1" { return event }
+ }
+ return nil
+ }
+ let event = await received.value
+ guard case .agentStatus(let pane, _)? = event else {
+ return XCTFail("expected an agentStatus event for the subscribed pane")
+ }
+ XCTAssertEqual(pane, "1-1")
+ }
+
+ /// A topology-only subscription gets the ack but no status events (the mock
+ /// mirrors the server), so `subscribe` still returns without hanging.
+ func testTopologyOnlySubscriptionSucceeds() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .milliseconds(20)))
+ try await client.connect()
+ try await client.subscribe([.topology]) // must not throw or hang
+ }
+
+ /// `workspace.create` returns the new id and a subsequent list reflects it.
+ func testCreateWorkspaceReturnsIDAndAppears() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+ let before = try await client.listWorkspaces().count
+
+ let id = try await client.createWorkspace(label: "scratch", cwd: "~/tmp")
+ XCTAssertNotNil(id, "the mock reports the new workspace id")
+
+ let after = try await client.listWorkspaces()
+ XCTAssertEqual(after.count, before + 1)
+ let created = after.first { $0.id == id }
+ XCTAssertEqual(created?.label, "scratch")
+ XCTAssertEqual(created?.cwd, "~/tmp")
+ }
+
+ /// `tab.create` adds a tab to the target workspace; an empty label is dropped
+ /// from the request so the server names it.
+ func testCreateTabAddsTabToWorkspace() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+ let workspace = try await client.listWorkspaces()[0]
+ let tabsBefore = workspace.tabs.count
+
+ let tabID = try await client.createTab(label: "", in: workspace.id)
+ XCTAssertNotNil(tabID)
+
+ let updated = try await client.listWorkspaces().first { $0.id == workspace.id }
+ XCTAssertEqual(updated?.tabs.count, tabsBefore + 1)
+ XCTAssertEqual(updated?.tabs.last?.id, tabID)
+ }
+
+ /// Creating a tab in a non-existent workspace surfaces the server's RPC error.
+ func testCreateTabInUnknownWorkspaceThrows() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+ do {
+ _ = try await client.createTab(label: "x", in: "no-such-ws")
+ XCTFail("expected an RPC error for an unknown workspace")
+ } catch let HerdrError.rpc(error) {
+ XCTAssertEqual(error.code, "not_found")
+ }
+ }
+
+ /// `pane.close` drops the pane, and an emptied tab/workspace is pruned with
+ /// it — so closing the only pane removes the whole workspace from the list.
+ func testClosePanePrunesEmptyTabAndWorkspace() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+
+ // "infra" has a single tab with a single pane — closing it empties both.
+ let infra = try await client.listWorkspaces().first { $0.label == "infra" }
+ let onlyPane = try XCTUnwrap(infra?.tabs.first?.panes.first)
+ try await client.closePane(onlyPane.id)
+
+ let after = try await client.listWorkspaces()
+ XCTAssertNil(after.first { $0.label == "infra" }, "an emptied workspace is pruned")
+ }
+
+ /// `workspace.close` removes the whole workspace.
+ func testCloseWorkspaceRemovesIt() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+ let first = try await client.listWorkspaces().first
+ let target = try XCTUnwrap(first)
+ try await client.closeWorkspace(target.id)
+
+ let after = try await client.listWorkspaces()
+ XCTAssertNil(after.first { $0.id == target.id })
+ }
+
+ /// Regression: grid rows arrive CRLF-terminated, and Swift fuses "\r\n" into
+ /// one grapheme, so a Character-level `split(separator: "\n")` collapses the
+ /// whole screen into a single line. `readRawTerminal` must split on the LF
+ /// scalar and return one entry per row. (The Mock uses LF only, so it can't
+ /// catch this — hence the dedicated CRLF transport.)
+ func testReadRawTerminalSplitsCRLFRows() async throws {
+ let client = HerdrClient(transport: CRLFTransport(text: "row one\r\nrow two\r\nrow three\r\n"))
+ try await client.connect()
+ let lines = try await client.readRawTerminal("1-1")
+ XCTAssertEqual(lines, ["row one", "row two", "row three"])
+ }
+}
+
+/// Minimal transport that answers every `pane.read` with a fixed CRLF body.
+private struct CRLFTransport: HerdrTransport {
+ let text: String
+ func connect() async throws {}
+ func disconnect() async {}
+ func request(_ request: RPCRequest) async throws -> RPCResponse {
+ RPCResponse(id: request.id, result: .object([
+ "type": .string("pane_read"),
+ "read": .object(["text": .string(text), "format": .string("text")]),
+ ]), error: nil)
+ }
+ func events(_ subscribeRequest: RPCRequest) -> AsyncStream {
+ AsyncStream { $0.finish() }
+ }
+}
diff --git a/herdr-ios/Tests/HerdrKitTests/CodecTests.swift b/herdr-ios/Tests/HerdrKitTests/CodecTests.swift
new file mode 100644
index 0000000..4a8de8c
--- /dev/null
+++ b/herdr-ios/Tests/HerdrKitTests/CodecTests.swift
@@ -0,0 +1,112 @@
+import XCTest
+@testable import HerdrKit
+
+final class CodecTests: XCTestCase {
+ func testRequestFramingMatchesDocumentedShape() throws {
+ let request = RPCRequest(id: "req_1", method: "ping", params: .object([:]))
+ let line = try NDJSON.frame(request)
+
+ XCTAssertEqual(line.last, NDJSON.newline, "frames must be newline-terminated")
+
+ let object = try JSONSerialization.jsonObject(with: line.dropLast()) as? [String: Any]
+ XCTAssertEqual(object?["id"] as? String, "req_1")
+ XCTAssertEqual(object?["method"] as? String, "ping")
+ }
+
+ func testDecodeResponseMessage() throws {
+ let line = Data(#"{"id":"req_1","result":{"type":"pong"}}"#.utf8)
+ guard case .response(let response) = try IncomingMessage.decode(line: line) else {
+ return XCTFail("expected a response")
+ }
+ XCTAssertEqual(response.id, "req_1")
+ XCTAssertEqual(response.result?["type"]?.stringValue, "pong")
+ XCTAssertNil(response.error)
+ }
+
+ /// Herdr pushes events as `{"event":"…","data":{…}}` (real wire sample).
+ func testDecodePushedStatusEvent() throws {
+ let line = Data(#"{"event":"pane_agent_status_changed","data":{"type":"pane_agent_status_changed","pane_id":"w4:p1","agent_status":"working"}}"#.utf8)
+ guard case .event(let event) = try IncomingMessage.decode(line: line) else {
+ return XCTFail("expected an event")
+ }
+ XCTAssertEqual(event.method, "pane_agent_status_changed")
+ XCTAssertEqual(HerdrEvent(event).map(String.init(describing:)),
+ String(describing: HerdrEvent.agentStatus(pane: "w4:p1", status: .working)))
+ }
+
+ /// The real server is dot-namespaced (`pane.agent_status_changed`); the Mock
+ /// uses underscores. `HerdrEvent` normalizes dots→underscores, so the
+ /// dot-spelled pushed event must still map to `.agentStatus` — else live
+ /// status updates silently stop on a real host.
+ func testDecodeDotNamespacedStatusEvent() throws {
+ let line = Data(#"{"event":"pane.agent_status_changed","data":{"pane_id":"w4:p1","agent_status":"blocked"}}"#.utf8)
+ guard case .event(let event) = try IncomingMessage.decode(line: line) else {
+ return XCTFail("expected an event")
+ }
+ XCTAssertEqual(HerdrEvent(event).map(String.init(describing:)),
+ String(describing: HerdrEvent.agentStatus(pane: "w4:p1", status: .blocked)))
+ }
+
+ /// A topology event maps to `.topologyChanged` — in both the Mock's
+ /// underscore form and the real server's dot form.
+ func testDecodeTopologyEvent() throws {
+ for name in ["tab_closed", "tab.closed"] {
+ let line = Data(#"{"event":"\#(name)","data":{"tab_id":"w4:t2","workspace_id":"w4"}}"#.utf8)
+ guard case .event(let event) = try IncomingMessage.decode(line: line),
+ case .topologyChanged? = HerdrEvent(event) else {
+ return XCTFail("expected a topologyChanged event for \(name)")
+ }
+ }
+ }
+
+ /// Herdr returns string error codes; decoding must not drop the message.
+ func testDecodeErrorResponseWithStringCode() throws {
+ let line = Data(#"{"id":"r","error":{"code":"invalid_request","message":"missing field `pane_id`"}}"#.utf8)
+ guard case .response(let response) = try IncomingMessage.decode(line: line) else {
+ return XCTFail("expected a response")
+ }
+ XCTAssertEqual(response.error?.code, "invalid_request")
+ XCTAssertEqual(response.error?.message, "missing field `pane_id`")
+ }
+
+ // MARK: Real wire fixtures (captured from a live server, protocol 14)
+
+ func testWorkspaceListDecodesRealShape() throws {
+ let line = Data(#"{"type":"workspace_list","workspaces":[{"workspace_id":"w4","number":1,"label":"~","focused":true,"pane_count":1,"tab_count":1,"active_tab_id":"w4:t1","agent_status":"unknown"}]}"#.utf8)
+ let value = try JSONDecoder().decode(JSONValue.self, from: line)
+ let result = try value.decodedSnake(WorkspaceListResult.self)
+ XCTAssertEqual(result.workspaces.count, 1)
+ XCTAssertEqual(result.workspaces[0].workspaceId, "w4")
+ XCTAssertEqual(result.workspaces[0].activeTabId, "w4:t1")
+ XCTAssertEqual(result.workspaces[0].agentStatus, "unknown")
+ }
+
+ func testPaneReadDecodesRealShape() throws {
+ let line = Data(#"{"type":"pane_read","read":{"pane_id":"w4:p1","source":"recent","format":"text","text":"line a\nline b\n","truncated":false}}"#.utf8)
+ let value = try JSONDecoder().decode(JSONValue.self, from: line)
+ let read = try value.decodedSnake(PaneReadResult.self).read
+ XCTAssertEqual(read.text, "line a\nline b\n")
+ }
+
+ func testLineBufferSplitsAndRetainsPartials() {
+ var buffer = LineBuffer()
+ XCTAssertEqual(buffer.append(Data(#"{"a":1}"#.utf8)).count, 0, "no newline yet → no lines")
+ let lines = buffer.append(Data("\n{\"b\":2}\n{\"c\"".utf8))
+ XCTAssertEqual(lines.count, 2)
+ XCTAssertEqual(String(data: lines[0], encoding: .utf8), #"{"a":1}"#)
+ XCTAssertEqual(String(data: lines[1], encoding: .utf8), #"{"b":2}"#)
+ // The trailing partial is retained until its newline arrives.
+ let rest = buffer.append(Data(":3}\n".utf8))
+ XCTAssertEqual(String(data: rest[0], encoding: .utf8), #"{"c":3}"#)
+ }
+
+ func testJSONValueRoundTrip() throws {
+ let value = JSONValue.object([
+ "s": .string("x"), "i": .int(7), "b": .bool(true),
+ "a": .array([.int(1), .null]),
+ ])
+ let data = try JSONEncoder().encode(value)
+ let decoded = try JSONDecoder().decode(JSONValue.self, from: data)
+ XCTAssertEqual(decoded, value)
+ }
+}
diff --git a/herdr-ios/Tests/HerdrKitTests/TerminalTextTests.swift b/herdr-ios/Tests/HerdrKitTests/TerminalTextTests.swift
new file mode 100644
index 0000000..85e1073
--- /dev/null
+++ b/herdr-ios/Tests/HerdrKitTests/TerminalTextTests.swift
@@ -0,0 +1,38 @@
+import XCTest
+@testable import HerdrKit
+
+final class TerminalTextTests: XCTestCase {
+ func testStripANSIRemovesColor() {
+ let colored = "\u{1B}[32mgreen\u{1B}[0m"
+ XCTAssertEqual(TerminalText.stripANSI(colored), "green")
+ }
+
+ func testIsFramingLine() {
+ XCTAssertTrue(TerminalText.isFramingLine("┌──────────┐"))
+ XCTAssertTrue(TerminalText.isFramingLine("──────────"))
+ XCTAssertTrue(TerminalText.isFramingLine("----"))
+ XCTAssertFalse(TerminalText.isFramingLine("> commit v1 to a branch"))
+ XCTAssertFalse(TerminalText.isFramingLine("context 25%"))
+ XCTAssertFalse(TerminalText.isFramingLine("-")) // too short to be a rule
+ }
+
+ func testCleanDropsFramesAndUnwrapsSides() {
+ let input = [
+ "┌────────────────────┐",
+ "│ hello world │",
+ "│ second line │",
+ "└────────────────────┘",
+ ]
+ XCTAssertEqual(TerminalText.clean(input), ["hello world", "second line"])
+ }
+
+ func testCleanCollapsesBlankRunsAndTrimsEdges() {
+ let input = ["", "", "alpha", "", "", "beta", "", ""]
+ XCTAssertEqual(TerminalText.clean(input), ["alpha", "", "beta"])
+ }
+
+ func testCleanPreservesInnerANSI() {
+ let cleaned = TerminalText.clean(["│ \u{1B}[36mctx\u{1B}[0m │"])
+ XCTAssertEqual(cleaned, ["\u{1B}[36mctx\u{1B}[0m"])
+ }
+}
diff --git a/herdr-ios/project.yml b/herdr-ios/project.yml
new file mode 100644
index 0000000..2af9b45
--- /dev/null
+++ b/herdr-ios/project.yml
@@ -0,0 +1,82 @@
+# XcodeGen project definition — the maintained source of truth for the iOS app
+# target. Generate the Xcode project with:
+#
+# brew install xcodegen
+# xcodegen generate
+# open Herdr.xcodeproj
+#
+# The platform-independent core lives in the local Swift package `HerdrKit`
+# (see Package.swift) and is consumed here as a local package dependency.
+name: Herdr
+options:
+ bundleIdPrefix: dev.herdr
+ createIntermediateGroups: true
+ deploymentTarget:
+ iOS: "17.0"
+
+packages:
+ HerdrKit:
+ path: .
+ # SSH transport dependency — powers the real socket bridge in
+ # App/Herdr/Connection/SSHTransport.swift.
+ Citadel:
+ url: https://github.com/orlandos-nl/Citadel.git
+ from: "0.7.0"
+ # Citadel's transitive deps that SSHTransport imports directly: NIOCore for
+ # `ByteBuffer`, NIOSSH for host-key pinning (the `NIOSSHPublicKey` /
+ # `NIOSSHClientServerAuthenticationDelegate` TOFU validator), swift-crypto for
+ # the `Insecure` namespace Citadel extends.
+ swift-nio:
+ url: https://github.com/apple/swift-nio.git
+ from: "2.0.0"
+ # Same fork + range Citadel pins (it has no NIOSSH re-export, so we depend on
+ # the identical package to avoid a duplicate `NIOSSH` product). Used by the
+ # TOFU host-key validator in SSHTransport.
+ swift-nio-ssh:
+ url: https://github.com/Wellz26/swift-nio-ssh.git
+ from: "0.3.4"
+ swift-crypto:
+ url: https://github.com/apple/swift-crypto.git
+ from: "3.0.0"
+
+targets:
+ Herdr:
+ type: application
+ platform: iOS
+ sources:
+ - App/Herdr
+ dependencies:
+ - package: HerdrKit
+ product: HerdrKit
+ - package: Citadel
+ product: Citadel
+ - package: swift-nio
+ product: NIOCore
+ - package: swift-nio-ssh
+ product: NIOSSH
+ - package: swift-crypto
+ product: Crypto
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: dev.herdr.client
+ PRODUCT_NAME: Herdr
+ MARKETING_VERSION: "0.1.0"
+ CURRENT_PROJECT_VERSION: "1"
+ SWIFT_VERSION: "5.9"
+ TARGETED_DEVICE_FAMILY: "1,2"
+ # Distribution signing for TestFlight: automatic signing under the team.
+ DEVELOPMENT_TEAM: F2J8ZU2NQJ
+ CODE_SIGN_STYLE: Automatic
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ GENERATE_INFOPLIST_FILE: YES
+ INFOPLIST_KEY_CFBundleDisplayName: Herdr
+ # iOS gates connections to LAN hosts (private IPs / .local) behind Local
+ # Network Privacy — required so SSHing into a Herdr box on your network works.
+ INFOPLIST_KEY_NSLocalNetworkUsageDescription: "Herdr connects over SSH to machines on your local network to control their terminal sessions."
+ # Standard SSH crypto only → exempt. Declared so uploads skip the manual
+ # export-compliance prompt.
+ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
+ INFOPLIST_KEY_UILaunchScreen_Generation: YES
+ INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait"
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
diff --git a/jellytv/.gitignore b/jellytv/.gitignore
new file mode 100644
index 0000000..ba30d67
--- /dev/null
+++ b/jellytv/.gitignore
@@ -0,0 +1,60 @@
+# macOS
+.DS_Store
+.AppleDouble
+.LSOverride
+Icon?
+
+# Xcode user data
+xcuserdata/
+*.xcuserstate
+*.xcuserdatad/
+*.mode1v3
+*.mode2v3
+*.perspectivev3
+*.pbxuser
+!default.pbxuser
+!default.mode1v3
+!default.mode2v3
+!default.perspectivev3
+
+# Xcode build
+build/
+DerivedData/
+*.moved-aside
+*.hmap
+*.ipa
+*.dSYM.zip
+*.dSYM
+
+# Swift Package Manager
+.build/
+.swiftpm/
+Package.resolved
+Packages/*/.build/
+Packages/*/.swiftpm/
+
+# CocoaPods (not using, but just in case)
+Pods/
+
+# Carthage
+Carthage/Build/
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots/**/*.png
+fastlane/test_output
+
+# Env
+.env
+.env.local
+*.local
+
+# Motif workflow state
+.motif/
+
+# Editor
+.vscode/
+.idea/
+*.swp
+*~
diff --git a/jellytv/AGENTS.md b/jellytv/AGENTS.md
new file mode 120000
index 0000000..681311e
--- /dev/null
+++ b/jellytv/AGENTS.md
@@ -0,0 +1 @@
+CLAUDE.md
\ No newline at end of file
diff --git a/jellytv/CLAUDE.md b/jellytv/CLAUDE.md
new file mode 100644
index 0000000..949c4dd
--- /dev/null
+++ b/jellytv/CLAUDE.md
@@ -0,0 +1,44 @@
+# JellyTV
+
+Native SwiftUI **tvOS 18** Jellyfin client (Apple TV 4K only). Goal: a modern replacement for SwiftFin tvOS. Live TV (guide, on-now, channel zapping) is the flagship feature. `softplan.md` is the founding design doc — read it for the player strategy, DeviceProfile, and tvOS gotchas.
+
+## Architecture
+
+Thin app target + SPM packages:
+
+- `JellyTV/` — app target (JellyTVApp, RootView, Info.plist, assets)
+- `Packages/JellyfinAPI` — hand-rolled `actor JellyfinClient` (URLSession + async/await + Codable DTOs); `JellyfinClientAPI` protocol is the seam for test fakes
+- `Packages/LiveTV` — On Now, Guide grid, Recordings, player state machine (`PlayerViewModel`), `AVPlayerViewController` host
+- `Packages/Library` — Home (hero + shelves)
+- `Packages/DesignSystem` — `LiveTVTheme`, `LiveTVTypography`, `PosterCard`, `Shelf`, `ChannelLogoView`, `JellyfinImage` (Nuke), `ChannelDominantColor`
+- `Packages/Settings` — server connect, sign in, `SessionStore`
+- `Packages/Persistence` — Keychain wrapper
+
+## Hard rules (from softplan.md — do not relitigate)
+
+- **Playback:** `AVPlayerViewController` only. No custom chrome, no AVPlayerLayer, no VLCKit/MPVKit.
+- **UI:** SwiftUI + `@Observable` + Swift Concurrency. No Combine `ObservableObject`, no coordinators, no TCA.
+- **Focus:** trust the focus engine — `.buttonStyle(.card)`/`.borderless` for focus effects; never hand-roll `scaleEffect`/stroke focus indicators on top of them. `.focusSection()` on shelf rows; stable IDs so focus survives reloads.
+- **Images:** Nuke `LazyImage` (never `AsyncImage`).
+- **Models:** created in the owning root view via `State(initialValue:)`, passed down as `@Bindable`; client injected as `let client: JellyfinClientAPI`.
+
+## Build & test
+
+```bash
+# Unit tests (Swift Testing, per package)
+swift test --package-path Packages/LiveTV
+swift test --package-path Packages/JellyfinAPI
+
+# Full app build
+xcodebuild -project JellyTV/JellyTV.xcodeproj -scheme JellyTV \
+ -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)'
+```
+
+Test doubles live next to the tests: `FakeJellyfinClient` (Result-based stubs that capture call args), `MockPlayerHost`/`MockNetworkMonitor` (AsyncStream continuations).
+
+## Conventions
+
+- DTOs decode defensively; unknown server fields are dropped silently.
+- Live TV state machine: `.idle → .resolving → .splash → .buffering → .playing`; channel zap is debounced 400ms (intentional UX — keep it).
+- Focus propagation pattern: leaf `@FocusState` → `.focusedValue(\.key, isFocused ? value : nil)` → parent `@FocusedValue` (see `FocusedOnNowChannelKey`, `FocusedGuideProgramKey`).
+- `AGENTS.md` is a symlink to this file — edit `CLAUDE.md` only.
diff --git a/jellytv/JellyTV/Info.plist b/jellytv/JellyTV/Info.plist
new file mode 100644
index 0000000..0880aa4
--- /dev/null
+++ b/jellytv/JellyTV/Info.plist
@@ -0,0 +1,30 @@
+
+
+
+
+
+ ITSAppUsesNonExemptEncryption
+
+ NSLocalNetworkUsageDescription
+ JellyTV needs access to your local network to connect to your Jellyfin media server.
+ NSBonjourServices
+
+ _jellyfin._tcp
+
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+ NSAllowsArbitraryLoadsForMedia
+
+
+
+
diff --git a/jellytv/JellyTV/JellyTV.xcodeproj/project.pbxproj b/jellytv/JellyTV/JellyTV.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..e3543c9
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV.xcodeproj/project.pbxproj
@@ -0,0 +1,430 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ D8321AB32F8554190046BB1A /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = D8321AB22F8554190046BB1A /* JellyfinAPI */; };
+ D8321AB62F85542C0046BB1A /* DesignSystem in Frameworks */ = {isa = PBXBuildFile; productRef = D8321AB52F85542C0046BB1A /* DesignSystem */; };
+ D8321AB92F8554390046BB1A /* Library in Frameworks */ = {isa = PBXBuildFile; productRef = D8321AB82F8554390046BB1A /* Library */; };
+ D8321ABC2F8554460046BB1A /* Persistence in Frameworks */ = {isa = PBXBuildFile; productRef = D8321ABB2F8554460046BB1A /* Persistence */; };
+ D8321ABF2F8554530046BB1A /* Player in Frameworks */ = {isa = PBXBuildFile; productRef = D8321ABE2F8554530046BB1A /* Player */; };
+ D8321AC22F85545F0046BB1A /* Settings in Frameworks */ = {isa = PBXBuildFile; productRef = D8321AC12F85545F0046BB1A /* Settings */; };
+ D8CC7A6B2F857D9300B0060A /* LiveTV in Frameworks */ = {isa = PBXBuildFile; productRef = D8CC7A6A2F857D9300B0060A /* LiveTV */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ D8EF31792F8478A60082D493 /* JellyTV.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = JellyTV.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ D8EF317B2F8478A60082D493 /* JellyTV */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = JellyTV;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ D8EF31762F8478A60082D493 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ D8321ABF2F8554530046BB1A /* Player in Frameworks */,
+ D8321ABC2F8554460046BB1A /* Persistence in Frameworks */,
+ D8321AB92F8554390046BB1A /* Library in Frameworks */,
+ D8321AC22F85545F0046BB1A /* Settings in Frameworks */,
+ D8321AB32F8554190046BB1A /* JellyfinAPI in Frameworks */,
+ D8CC7A6B2F857D9300B0060A /* LiveTV in Frameworks */,
+ D8321AB62F85542C0046BB1A /* DesignSystem in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ D8EF31702F8478A60082D493 = {
+ isa = PBXGroup;
+ children = (
+ D8EF317B2F8478A60082D493 /* JellyTV */,
+ D8EF317A2F8478A60082D493 /* Products */,
+ );
+ sourceTree = "";
+ };
+ D8EF317A2F8478A60082D493 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ D8EF31792F8478A60082D493 /* JellyTV.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ D8EF31782F8478A60082D493 /* JellyTV */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = D8EF31842F8478A60082D493 /* Build configuration list for PBXNativeTarget "JellyTV" */;
+ buildPhases = (
+ D8EF31752F8478A60082D493 /* Sources */,
+ D8EF31762F8478A60082D493 /* Frameworks */,
+ D8EF31772F8478A60082D493 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ D8EF317B2F8478A60082D493 /* JellyTV */,
+ );
+ name = JellyTV;
+ packageProductDependencies = (
+ D8321AB22F8554190046BB1A /* JellyfinAPI */,
+ D8321AB52F85542C0046BB1A /* DesignSystem */,
+ D8321AB82F8554390046BB1A /* Library */,
+ D8321ABB2F8554460046BB1A /* Persistence */,
+ D8321ABE2F8554530046BB1A /* Player */,
+ D8321AC12F85545F0046BB1A /* Settings */,
+ D8CC7A6A2F857D9300B0060A /* LiveTV */,
+ );
+ productName = JellyTV;
+ productReference = D8EF31792F8478A60082D493 /* JellyTV.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ D8EF31712F8478A60082D493 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2630;
+ LastUpgradeCheck = 2630;
+ TargetAttributes = {
+ D8EF31782F8478A60082D493 = {
+ CreatedOnToolsVersion = 26.3;
+ };
+ };
+ };
+ buildConfigurationList = D8EF31742F8478A60082D493 /* Build configuration list for PBXProject "JellyTV" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = D8EF31702F8478A60082D493;
+ minimizedProjectReferenceProxies = 1;
+ packageReferences = (
+ D8321AB12F8554190046BB1A /* XCLocalSwiftPackageReference "../Packages/JellyfinAPI" */,
+ D8321AB42F85542C0046BB1A /* XCLocalSwiftPackageReference "../Packages/DesignSystem" */,
+ D8321AB72F8554390046BB1A /* XCLocalSwiftPackageReference "../Packages/Library" */,
+ D8321ABA2F8554460046BB1A /* XCLocalSwiftPackageReference "../Packages/Persistence" */,
+ D8321ABD2F8554530046BB1A /* XCLocalSwiftPackageReference "../Packages/Player" */,
+ D8321AC02F85545F0046BB1A /* XCLocalSwiftPackageReference "../Packages/Settings" */,
+ D8CC7A692F857D9300B0060A /* XCLocalSwiftPackageReference "../Packages/LiveTV" */,
+ );
+ preferredProjectObjectVersion = 77;
+ productRefGroup = D8EF317A2F8478A60082D493 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ D8EF31782F8478A60082D493 /* JellyTV */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ D8EF31772F8478A60082D493 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ D8EF31752F8478A60082D493 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ D8EF31822F8478A60082D493 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = F2J8ZU2NQJ;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = appletvos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ TVOS_DEPLOYMENT_TARGET = 26.2;
+ };
+ name = Debug;
+ };
+ D8EF31832F8478A60082D493 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = F2J8ZU2NQJ;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = appletvos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ TVOS_DEPLOYMENT_TARGET = 26.2;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ D8EF31852F8478A60082D493 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = F2J8ZU2NQJ;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "Jelly TV";
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.cursorkittens.JellyTV;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ };
+ name = Debug;
+ };
+ D8EF31862F8478A60082D493 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = F2J8ZU2NQJ;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = "Jelly TV";
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UIUserInterfaceStyle = Automatic;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.cursorkittens.JellyTV;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = 3;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ D8EF31742F8478A60082D493 /* Build configuration list for PBXProject "JellyTV" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D8EF31822F8478A60082D493 /* Debug */,
+ D8EF31832F8478A60082D493 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ D8EF31842F8478A60082D493 /* Build configuration list for PBXNativeTarget "JellyTV" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D8EF31852F8478A60082D493 /* Debug */,
+ D8EF31862F8478A60082D493 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ D8321AB12F8554190046BB1A /* XCLocalSwiftPackageReference "../Packages/JellyfinAPI" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../Packages/JellyfinAPI;
+ };
+ D8321AB42F85542C0046BB1A /* XCLocalSwiftPackageReference "../Packages/DesignSystem" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../Packages/DesignSystem;
+ };
+ D8321AB72F8554390046BB1A /* XCLocalSwiftPackageReference "../Packages/Library" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../Packages/Library;
+ };
+ D8321ABA2F8554460046BB1A /* XCLocalSwiftPackageReference "../Packages/Persistence" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../Packages/Persistence;
+ };
+ D8321ABD2F8554530046BB1A /* XCLocalSwiftPackageReference "../Packages/Player" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../Packages/Player;
+ };
+ D8321AC02F85545F0046BB1A /* XCLocalSwiftPackageReference "../Packages/Settings" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../Packages/Settings;
+ };
+ D8CC7A692F857D9300B0060A /* XCLocalSwiftPackageReference "../Packages/LiveTV" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../Packages/LiveTV;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ D8321AB22F8554190046BB1A /* JellyfinAPI */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = JellyfinAPI;
+ };
+ D8321AB52F85542C0046BB1A /* DesignSystem */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = DesignSystem;
+ };
+ D8321AB82F8554390046BB1A /* Library */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = Library;
+ };
+ D8321ABB2F8554460046BB1A /* Persistence */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = Persistence;
+ };
+ D8321ABE2F8554530046BB1A /* Player */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = Player;
+ };
+ D8321AC12F85545F0046BB1A /* Settings */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = Settings;
+ };
+ D8CC7A6A2F857D9300B0060A /* LiveTV */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = LiveTV;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = D8EF31712F8478A60082D493 /* Project object */;
+}
diff --git a/jellytv/JellyTV/JellyTV.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/jellytv/JellyTV/JellyTV.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/AccentColor.colorset/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png
new file mode 100644
index 0000000..22c48d3
Binary files /dev/null and b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back.png differ
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..1542e46
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "Back.png",
+ "idiom" : "tv"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json
new file mode 100644
index 0000000..de59d88
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json
@@ -0,0 +1,17 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "layers" : [
+ {
+ "filename" : "Front.imagestacklayer"
+ },
+ {
+ "filename" : "Middle.imagestacklayer"
+ },
+ {
+ "filename" : "Back.imagestacklayer"
+ }
+ ]
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..d128032
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "Front.png",
+ "idiom" : "tv"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png
new file mode 100644
index 0000000..22c48d3
Binary files /dev/null and b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front.png differ
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..2e00335
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png
new file mode 100644
index 0000000..8d37676
Binary files /dev/null and b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back.png differ
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png
new file mode 100644
index 0000000..2ff2f3a
Binary files /dev/null and b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back@2x.png differ
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..0b2a6fe
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {
+ "filename" : "Back.png",
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "Back@2x.png",
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json
new file mode 100644
index 0000000..de59d88
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json
@@ -0,0 +1,17 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "layers" : [
+ {
+ "filename" : "Front.imagestacklayer"
+ },
+ {
+ "filename" : "Middle.imagestacklayer"
+ },
+ {
+ "filename" : "Back.imagestacklayer"
+ }
+ ]
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..39d6030
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {
+ "filename" : "Front.png",
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "Front@2x.png",
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png
new file mode 100644
index 0000000..8d37676
Binary files /dev/null and b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front.png differ
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png
new file mode 100644
index 0000000..2ff2f3a
Binary files /dev/null and b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front@2x.png differ
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
new file mode 100644
index 0000000..795cce1
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json
new file mode 100644
index 0000000..f47ba43
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json
@@ -0,0 +1,32 @@
+{
+ "assets" : [
+ {
+ "filename" : "App Icon - App Store.imagestack",
+ "idiom" : "tv",
+ "role" : "primary-app-icon",
+ "size" : "1280x768"
+ },
+ {
+ "filename" : "App Icon.imagestack",
+ "idiom" : "tv",
+ "role" : "primary-app-icon",
+ "size" : "400x240"
+ },
+ {
+ "filename" : "Top Shelf Image Wide.imageset",
+ "idiom" : "tv",
+ "role" : "top-shelf-image-wide",
+ "size" : "2320x720"
+ },
+ {
+ "filename" : "Top Shelf Image.imageset",
+ "idiom" : "tv",
+ "role" : "top-shelf-image",
+ "size" : "1920x720"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json
new file mode 100644
index 0000000..4b5bd13
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {
+ "filename" : "TopShelfWide.png",
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "TopShelfWide@2x.png",
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide.png b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide.png
new file mode 100644
index 0000000..07717d2
Binary files /dev/null and b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide.png differ
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide@2x.png b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide@2x.png
new file mode 100644
index 0000000..505c3c7
Binary files /dev/null and b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelfWide@2x.png differ
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json
new file mode 100644
index 0000000..f5ad0b9
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {
+ "filename" : "TopShelf.png",
+ "idiom" : "tv",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "TopShelf@2x.png",
+ "idiom" : "tv",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf.png b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf.png
new file mode 100644
index 0000000..583fb75
Binary files /dev/null and b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf.png differ
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf@2x.png b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf@2x.png
new file mode 100644
index 0000000..b7ec3a7
Binary files /dev/null and b/jellytv/JellyTV/JellyTV/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/TopShelf@2x.png differ
diff --git a/jellytv/JellyTV/JellyTV/Assets.xcassets/Contents.json b/jellytv/JellyTV/JellyTV/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/JellyTVApp.swift b/jellytv/JellyTV/JellyTV/JellyTVApp.swift
new file mode 100644
index 0000000..7791dfa
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/JellyTVApp.swift
@@ -0,0 +1,32 @@
+//
+// JellyTVApp.swift
+// JellyTV
+//
+
+import SwiftUI
+import JellyfinAPI
+import Persistence
+import Settings
+
+@main
+struct JellyTVApp: App {
+ @State private var sessionStore: SessionStore = Self.makeSessionStore()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView(sessionStore: sessionStore)
+ .task {
+ await sessionStore.restore()
+ }
+ }
+ }
+
+ /// Wires up the live `JellyfinClient` + `CredentialsStore` and seeds them
+ /// into a `SessionStore` for the app to share.
+ private static func makeSessionStore() -> SessionStore {
+ let credentials = CredentialsStore()
+ let deviceId = (try? credentials.deviceId()) ?? UUID().uuidString
+ let client = JellyfinClient(deviceId: deviceId)
+ return SessionStore(client: client, credentials: credentials)
+ }
+}
diff --git a/jellytv/JellyTV/JellyTV/RootView.swift b/jellytv/JellyTV/JellyTV/RootView.swift
new file mode 100644
index 0000000..b126fb5
--- /dev/null
+++ b/jellytv/JellyTV/JellyTV/RootView.swift
@@ -0,0 +1,93 @@
+//
+// RootView.swift
+// JellyTV
+//
+// Top-level view that switches between sign-in, signed-in, and reconnecting
+// states based on the SessionStore phase.
+//
+
+import SwiftUI
+import JellyfinAPI
+import Settings
+import LiveTV
+
+struct RootView: View {
+ @Bindable var sessionStore: SessionStore
+
+ var body: some View {
+ Group {
+ switch sessionStore.phase {
+ case .loading:
+ ProgressView()
+ .controlSize(.large)
+ case .signedOut:
+ SignInFlowView(sessionStore: sessionStore)
+ case .signedIn(let user):
+ SignedInRootView(user: user, sessionStore: sessionStore)
+ case .reconnecting(let user):
+ ReconnectingView(lastKnownUser: user, sessionStore: sessionStore)
+ }
+ }
+ }
+}
+
+/// Wraps `SignInView` and bridges its terminal `.signedIn` state into the
+/// `SessionStore` so the rest of the app reacts.
+private struct SignInFlowView: View {
+ let sessionStore: SessionStore
+ @State private var model: SignInModel
+
+ init(sessionStore: SessionStore) {
+ self.sessionStore = sessionStore
+ _model = State(initialValue: SignInModel(
+ client: sessionStore.client,
+ credentials: sessionStore.credentials
+ ))
+ }
+
+ var body: some View {
+ SignInView(model: model)
+ .onChange(of: model.state) { _, newState in
+ if case .signedIn(let user) = newState {
+ sessionStore.didSignIn(user: user)
+ }
+ }
+ }
+}
+
+/// Post sign-in: the Live TV experience IS the app — `LiveTVRootView` owns
+/// the two-tab shell (Home / Live).
+private struct SignedInRootView: View {
+ let user: UserDto
+ let sessionStore: SessionStore
+
+ var body: some View {
+ LiveTVRootView(client: sessionStore.client)
+ }
+}
+
+private struct ReconnectingView: View {
+ let lastKnownUser: UserDto?
+ let sessionStore: SessionStore
+
+ var body: some View {
+ VStack(spacing: 40) {
+ ProgressView()
+ .controlSize(.large)
+ Text("Reconnecting…")
+ .font(.title)
+ Text("Couldn't reach the server. Your sign-in is still saved.")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ HStack(spacing: 30) {
+ Button("Try Again") {
+ Task { await sessionStore.restore() }
+ }
+ Button("Sign Out") {
+ Task { await sessionStore.signOut() }
+ }
+ }
+ }
+ .padding(80)
+ }
+}
diff --git a/jellytv/Packages/DesignSystem/Package.swift b/jellytv/Packages/DesignSystem/Package.swift
new file mode 100644
index 0000000..e3a9bc9
--- /dev/null
+++ b/jellytv/Packages/DesignSystem/Package.swift
@@ -0,0 +1,24 @@
+// swift-tools-version: 6.2
+import PackageDescription
+
+let package = Package(
+ name: "DesignSystem",
+ platforms: [.tvOS(.v26), .macOS(.v15)],
+ products: [
+ .library(name: "DesignSystem", targets: ["DesignSystem"]),
+ ],
+ dependencies: [
+ .package(url: "https://github.com/kean/Nuke", from: "12.0.0"),
+ .package(path: "../JellyfinAPI"),
+ ],
+ targets: [
+ .target(
+ name: "DesignSystem",
+ dependencies: [
+ .product(name: "Nuke", package: "Nuke"),
+ .product(name: "NukeUI", package: "Nuke"),
+ .product(name: "JellyfinAPI", package: "JellyfinAPI"),
+ ]
+ ),
+ ]
+)
diff --git a/jellytv/Packages/DesignSystem/Sources/DesignSystem/ChannelDominantColor.swift b/jellytv/Packages/DesignSystem/Sources/DesignSystem/ChannelDominantColor.swift
new file mode 100644
index 0000000..7241727
--- /dev/null
+++ b/jellytv/Packages/DesignSystem/Sources/DesignSystem/ChannelDominantColor.swift
@@ -0,0 +1,152 @@
+import SwiftUI
+import Nuke
+
+#if os(tvOS) || os(iOS)
+import UIKit
+typealias PlatformImage = UIImage
+#else
+import AppKit
+typealias PlatformImage = NSImage
+#endif
+
+/// Extracts a "dominant" color from a channel logo for use as the splash
+/// background gradient. Uses an HSL histogram (24 hue buckets × 15°) weighted
+/// by saturation ≥ 0.4 and luminance in [0.2, 0.8] — a simple `CIAreaAverage`
+/// produces gray for white-on-transparent logos (the common case for broadcast
+/// channels), so we explicitly throw out neutral pixels.
+///
+/// Cached in-memory keyed on URL string. Cache hits are synchronous on
+/// `Task.value` reuse via `actor`-isolated state. Misses fetch from Nuke's
+/// memory cache first (already-loaded logo → near-instant), falling back to
+/// network fetch via `ImagePipeline.shared`.
+public actor ChannelDominantColor {
+ public static let shared = ChannelDominantColor()
+
+ private var cache: [String: Color?] = [:]
+
+ private init() {}
+
+ /// Returns a dominant color for the given logo URL, or `nil` if no
+ /// qualifying pixel was found (e.g., logo is purely white-on-transparent).
+ /// Callers should fall back to a neutral background when nil.
+ public func extract(logoURL: URL?) async -> Color? {
+ guard let logoURL else { return nil }
+ let key = logoURL.absoluteString
+ if let cached = cache[key] { return cached }
+
+ let image = await loadImage(from: logoURL)
+ let color = image.flatMap { Self.dominantColor(from: $0) }
+ cache[key] = color
+ return color
+ }
+
+ private func loadImage(from url: URL) async -> PlatformImage? {
+ let request = ImageRequest(url: url)
+ if let cached = ImagePipeline.shared.cache.cachedImage(for: request) {
+ return cached.image
+ }
+ do {
+ let response = try await ImagePipeline.shared.image(for: request)
+ return response
+ } catch {
+ return nil
+ }
+ }
+
+ // MARK: - Color analysis
+
+ /// Downscale, iterate pixels, build a 24-bucket hue histogram weighted by
+ /// saturation × (1 if luminance is in [0.2, 0.8] else 0). Returns the
+ /// representative color of the most-weighted bucket.
+ nonisolated static func dominantColor(from image: PlatformImage) -> Color? {
+ guard let cgImage = cgImage(from: image) else { return nil }
+ let width = 64
+ let height = 64
+
+ // Render into a fixed-size RGBA8 bitmap so iteration is uniform.
+ let bytesPerPixel = 4
+ let bytesPerRow = width * bytesPerPixel
+ let colorSpace = CGColorSpaceCreateDeviceRGB()
+ var pixels = [UInt8](repeating: 0, count: width * height * bytesPerPixel)
+ guard let context = pixels.withUnsafeMutableBytes({ ptr -> CGContext? in
+ CGContext(
+ data: ptr.baseAddress,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: bytesPerRow,
+ space: colorSpace,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+ }) else { return nil }
+ context.interpolationQuality = .medium
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
+
+ // 24 hue buckets at 15° each.
+ let bucketCount = 24
+ var weights = [Double](repeating: 0, count: bucketCount)
+ var hueAccumR = [Double](repeating: 0, count: bucketCount)
+ var hueAccumG = [Double](repeating: 0, count: bucketCount)
+ var hueAccumB = [Double](repeating: 0, count: bucketCount)
+
+ for y in 0..= 0.4, l >= 0.2, l <= 0.8 else { continue }
+
+ let bucket = min(Int(h * Double(bucketCount)), bucketCount - 1)
+ let weight = s * a
+ weights[bucket] += weight
+ hueAccumR[bucket] += r * weight
+ hueAccumG[bucket] += g * weight
+ hueAccumB[bucket] += b * weight
+ }
+ }
+
+ guard let bestBucket = weights.indices.max(by: { weights[$0] < weights[$1] }),
+ weights[bestBucket] > 0 else {
+ return nil
+ }
+ let totalWeight = weights[bestBucket]
+ let r = hueAccumR[bestBucket] / totalWeight
+ let g = hueAccumG[bestBucket] / totalWeight
+ let b = hueAccumB[bestBucket] / totalWeight
+ return Color(red: r, green: g, blue: b)
+ }
+
+ nonisolated private static func cgImage(from image: PlatformImage) -> CGImage? {
+ #if os(tvOS) || os(iOS)
+ return image.cgImage
+ #else
+ var rect = CGRect(origin: .zero, size: image.size)
+ return image.cgImage(forProposedRect: &rect, context: nil, hints: nil)
+ #endif
+ }
+
+ /// Returns hue (0–1, where 0=red), saturation (0–1), luminance (0–1).
+ nonisolated private static func rgbToHSL(r: Double, g: Double, b: Double) -> (h: Double, s: Double, l: Double) {
+ let maxC = max(r, g, b)
+ let minC = min(r, g, b)
+ let l = (maxC + minC) / 2.0
+ guard maxC != minC else { return (0, 0, l) }
+ let d = maxC - minC
+ let s = l > 0.5 ? d / (2.0 - maxC - minC) : d / (maxC + minC)
+ var h: Double
+ if maxC == r {
+ h = (g - b) / d + (g < b ? 6 : 0)
+ } else if maxC == g {
+ h = (b - r) / d + 2
+ } else {
+ h = (r - g) / d + 4
+ }
+ h /= 6.0
+ return (h, s, l)
+ }
+}
diff --git a/jellytv/Packages/DesignSystem/Sources/DesignSystem/ChannelLogoView.swift b/jellytv/Packages/DesignSystem/Sources/DesignSystem/ChannelLogoView.swift
new file mode 100644
index 0000000..6a15c05
--- /dev/null
+++ b/jellytv/Packages/DesignSystem/Sources/DesignSystem/ChannelLogoView.swift
@@ -0,0 +1,102 @@
+import SwiftUI
+import NukeUI
+import JellyfinAPI
+
+/// Renders a channel's logo with a graceful "letter-bug" fallback when the
+/// server has no image for the channel. Designed for Live TV guide rows and
+/// channel-tile shelves; the parent decides the frame size.
+public struct ChannelLogoView: View {
+ public let channel: LiveTvChannel
+ public let serverURL: URL
+ public var maxWidth: Int
+
+ public init(channel: LiveTvChannel, serverURL: URL, maxWidth: Int = 320) {
+ self.channel = channel
+ self.serverURL = serverURL
+ self.maxWidth = maxWidth
+ }
+
+ public var body: some View {
+ if let url = channel.logoURL(serverURL: serverURL, maxWidth: maxWidth) {
+ LazyImage(url: url) { state in
+ if let image = state.image {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ } else {
+ fallback
+ }
+ }
+ } else {
+ fallback
+ }
+ }
+
+ private var fallback: some View {
+ let initials = String(channel.name.prefix(2)).uppercased()
+ return ZStack {
+ RoundedRectangle(cornerRadius: 8)
+ .fill(LinearGradient(
+ colors: [Color.accentColor.opacity(0.65), Color.accentColor.opacity(0.35)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ ))
+ Text(initials)
+ .font(.title3.weight(.bold))
+ .foregroundStyle(.white)
+ .minimumScaleFactor(0.5)
+ .lineLimit(1)
+ .padding(8)
+ }
+ }
+}
+
+/// Live red dot — pulses to communicate "this is happening right now."
+public struct LiveBadge: View {
+ public var label: String
+
+ public init(label: String = "LIVE") {
+ self.label = label
+ }
+
+ @State private var pulse = false
+
+ public var body: some View {
+ HStack(spacing: 6) {
+ Circle()
+ .fill(Color.red)
+ .frame(width: 8, height: 8)
+ .scaleEffect(pulse ? 1.15 : 0.85)
+ .animation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true), value: pulse)
+ Text(label)
+ .font(.caption2.weight(.heavy))
+ .foregroundStyle(.white)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .background(.black.opacity(0.5), in: Capsule())
+ .onAppear { pulse = true }
+ }
+}
+
+/// Compact start/end time string formatter for EPG cells.
+public enum LiveTvFormat {
+ public static let timeFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.timeStyle = .short
+ f.dateStyle = .none
+ return f
+ }()
+
+ public static func timeRange(start: Date?, end: Date?) -> String? {
+ guard let start, let end else { return nil }
+ return "\(timeFormatter.string(from: start)) – \(timeFormatter.string(from: end))"
+ }
+
+ public static func progressFraction(start: Date?, end: Date?, now: Date) -> Double? {
+ guard let start, let end, end > start else { return nil }
+ let total = end.timeIntervalSince(start)
+ let elapsed = now.timeIntervalSince(start)
+ return max(0, min(1, elapsed / total))
+ }
+}
diff --git a/jellytv/Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift b/jellytv/Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift
new file mode 100644
index 0000000..806fc10
--- /dev/null
+++ b/jellytv/Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift
@@ -0,0 +1,20 @@
+// DesignSystem module placeholder.
+// Phase 2 adds shelf/card primitives, focus styles, colors, and typography here.
+
+import SwiftUI
+import JellyfinAPI
+
+public enum DesignSystem {
+ public static let version = "0.0.1"
+}
+
+public struct FocusedHomeItemKey: FocusedValueKey {
+ public typealias Value = BaseItemDto
+}
+
+public extension FocusedValues {
+ var focusedHomeItem: BaseItemDto? {
+ get { self[FocusedHomeItemKey.self] }
+ set { self[FocusedHomeItemKey.self] = newValue }
+ }
+}
diff --git a/jellytv/Packages/DesignSystem/Sources/DesignSystem/HeroSection.swift b/jellytv/Packages/DesignSystem/Sources/DesignSystem/HeroSection.swift
new file mode 100644
index 0000000..0aa2742
--- /dev/null
+++ b/jellytv/Packages/DesignSystem/Sources/DesignSystem/HeroSection.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+import Nuke
+import NukeUI
+import JellyfinAPI
+
+private enum HeroFocusNamespace: Hashable {}
+
+public struct HeroSection: View {
+ let item: BaseItemDto
+ let serverURL: URL
+ let onPlay: () -> Void
+ let onDetail: () -> Void
+
+ public init(
+ item: BaseItemDto,
+ serverURL: URL,
+ onPlay: @escaping () -> Void,
+ onDetail: @escaping () -> Void
+ ) {
+ self.item = item
+ self.serverURL = serverURL
+ self.onPlay = onPlay
+ self.onDetail = onDetail
+ }
+
+ public var body: some View {
+ ZStack(alignment: .bottomLeading) {
+ if let backdropURL = item.imageURL(serverURL: serverURL, type: .backdrop, maxWidth: 1920) {
+ LazyImage(url: backdropURL) { state in
+ if let image = state.image {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } else {
+ Color.gray.opacity(0.3)
+ }
+ }
+ .aspectRatio(16/9, contentMode: .fill)
+ .animation(.easeInOut(duration: 0.35), value: backdropURL)
+ } else {
+ Color.gray.opacity(0.3)
+ .aspectRatio(16/9, contentMode: .fill)
+ }
+
+ LinearGradient(
+ colors: [.clear, .black.opacity(0.8)],
+ startPoint: .center,
+ endPoint: .bottom
+ )
+
+ VStack(alignment: .leading, spacing: 16) {
+ Text(item.name)
+ .font(.largeTitle)
+ .fontWeight(.bold)
+ .lineLimit(2)
+
+ if let overview = item.overview {
+ Text(overview)
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .lineLimit(3)
+ }
+
+ HStack(spacing: 20) {
+ Button(action: onPlay) {
+ HStack(spacing: 8) {
+ Image(systemName: "play.fill")
+ Text("Play")
+ }
+ .font(.headline)
+ .foregroundStyle(.black)
+ .padding(.horizontal, 32)
+ .padding(.vertical, 12)
+ .background(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ .buttonStyle(.borderless)
+
+ Button(action: onDetail) {
+ HStack(spacing: 8) {
+ Image(systemName: "info.circle")
+ Text("More Info")
+ }
+ .font(.headline)
+ .foregroundStyle(.white)
+ .padding(.horizontal, 24)
+ .padding(.vertical, 12)
+ .background(.ultraThinMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+ .padding(40)
+ }
+ .containerRelativeFrame(.horizontal)
+ }
+}
\ No newline at end of file
diff --git a/jellytv/Packages/DesignSystem/Sources/DesignSystem/JellyfinImage.swift b/jellytv/Packages/DesignSystem/Sources/DesignSystem/JellyfinImage.swift
new file mode 100644
index 0000000..e78eaeb
--- /dev/null
+++ b/jellytv/Packages/DesignSystem/Sources/DesignSystem/JellyfinImage.swift
@@ -0,0 +1,157 @@
+import Foundation
+import JellyfinAPI
+
+public enum JellyfinImage {
+ public static func url(
+ serverURL: URL,
+ itemId: String,
+ type: ImageType,
+ tag: String?,
+ maxWidth: Int? = nil,
+ maxHeight: Int? = nil
+ ) -> URL? {
+ var components = URLComponents(
+ url: serverURL.appendingPathComponent("Items/\(itemId)/Images/\(type.rawValue)"),
+ resolvingAgainstBaseURL: false
+ )
+
+ var queryItems: [URLQueryItem] = []
+ if let tag = tag {
+ queryItems.append(URLQueryItem(name: "tag", value: tag))
+ }
+ if let maxWidth = maxWidth {
+ queryItems.append(URLQueryItem(name: "maxWidth", value: String(maxWidth)))
+ }
+ if let maxHeight = maxHeight {
+ queryItems.append(URLQueryItem(name: "maxHeight", value: String(maxHeight)))
+ }
+ if !queryItems.isEmpty {
+ components?.queryItems = queryItems
+ }
+
+ return components?.url
+ }
+
+ public enum ImageType: String, Sendable {
+ case primary = "Primary"
+ case backdrop = "Backdrop"
+ case thumb = "Thumb"
+ case logo = "Logo"
+ case art = "Art"
+ }
+}
+
+public extension BaseItemDto {
+ func imageURL(
+ serverURL: URL,
+ type: JellyfinImage.ImageType,
+ maxWidth: Int? = nil
+ ) -> URL? {
+ let tag: String?
+ switch type {
+ case .primary:
+ tag = imageTags?["Primary"]
+ case .backdrop:
+ tag = backdropImageTags?.first
+ case .thumb:
+ tag = imageTags?["Thumb"]
+ case .logo:
+ tag = imageTags?["Logo"]
+ case .art:
+ tag = imageTags?["Art"]
+ }
+ guard let tag = tag else { return nil }
+ return JellyfinImage.url(
+ serverURL: serverURL,
+ itemId: id,
+ type: type,
+ tag: tag,
+ maxWidth: maxWidth
+ )
+ }
+}
+
+public extension LiveTvChannel {
+ /// Channel logo URL. Falls back through Logo → Primary → Thumb tags so we
+ /// surface whatever image the tuner / listings provider supplied.
+ func logoURL(serverURL: URL, maxWidth: Int? = 320) -> URL? {
+ if let tag = imageTags?["Logo"] {
+ return JellyfinImage.url(
+ serverURL: serverURL,
+ itemId: id,
+ type: .logo,
+ tag: tag,
+ maxWidth: maxWidth
+ )
+ }
+ if let tag = imageTags?["Primary"] {
+ return JellyfinImage.url(
+ serverURL: serverURL,
+ itemId: id,
+ type: .primary,
+ tag: tag,
+ maxWidth: maxWidth
+ )
+ }
+ if let tag = imageTags?["Thumb"] {
+ return JellyfinImage.url(
+ serverURL: serverURL,
+ itemId: id,
+ type: .thumb,
+ tag: tag,
+ maxWidth: maxWidth
+ )
+ }
+ return nil
+ }
+}
+
+public extension LiveTvProgram {
+ /// Backdrop / thumbnail URL for a program. Prefers `Thumb` (typical
+ /// landscape EPG art), falls back to `Primary`, then to the channel's
+ /// Primary tag (Jellyfin populates `ChannelPrimaryImageTag` on programs
+ /// when the program itself has no art).
+ func tileImageURL(serverURL: URL, maxWidth: Int? = 600) -> URL? {
+ if let tag = imageTags?["Thumb"] {
+ return JellyfinImage.url(
+ serverURL: serverURL,
+ itemId: id,
+ type: .thumb,
+ tag: tag,
+ maxWidth: maxWidth
+ )
+ }
+ if let tag = imageTags?["Primary"] {
+ return JellyfinImage.url(
+ serverURL: serverURL,
+ itemId: id,
+ type: .primary,
+ tag: tag,
+ maxWidth: maxWidth
+ )
+ }
+ if let channelId, let tag = channelPrimaryImageTag {
+ return JellyfinImage.url(
+ serverURL: serverURL,
+ itemId: channelId,
+ type: .primary,
+ tag: tag,
+ maxWidth: maxWidth
+ )
+ }
+ return nil
+ }
+
+ func backdropURL(serverURL: URL, maxWidth: Int? = 1920) -> URL? {
+ if let tag = imageTags?["Backdrop"] {
+ return JellyfinImage.url(
+ serverURL: serverURL,
+ itemId: id,
+ type: .backdrop,
+ tag: tag,
+ maxWidth: maxWidth
+ )
+ }
+ return tileImageURL(serverURL: serverURL, maxWidth: maxWidth)
+ }
+}
\ No newline at end of file
diff --git a/jellytv/Packages/DesignSystem/Sources/DesignSystem/LiveTVTheme.swift b/jellytv/Packages/DesignSystem/Sources/DesignSystem/LiveTVTheme.swift
new file mode 100644
index 0000000..d2f62c5
--- /dev/null
+++ b/jellytv/Packages/DesignSystem/Sources/DesignSystem/LiveTVTheme.swift
@@ -0,0 +1,36 @@
+import SwiftUI
+
+/// Low-key "Marquee" palette: one warm-gray ink on near-black, nothing else.
+/// The serif typography and the native focus lift carry the design — the only
+/// chromatic color on screen is the desaturated brick-red live indicator.
+/// Plain SwiftUI `Color` constants — no Asset Catalog indirection (tvOS-only
+/// single theme, dark-mode-always).
+public enum LiveTVTheme {
+ /// The single ink. Warm gray-white (#E8E5DF) — everything legible is a
+ /// tint of this.
+ public static let ink = Color(red: 0.910, green: 0.898, blue: 0.875)
+
+ /// Deepest background — full-bleed page background (#09090A).
+ public static let background = Color(red: 0.035, green: 0.035, blue: 0.039)
+
+ /// Slightly lifted surface for cards / overlays / selected rows.
+ public static let surface = ink.opacity(0.06)
+
+ /// Focus and emphasis accent — pale ink. Monochrome by design: focused
+ /// borders and primary actions read as "lighter", never as a new color.
+ public static let accent = ink.opacity(0.85)
+
+ /// "On air" indicator — desaturated brick red (#B3473D), exclusively for
+ /// the live dot, the now-line, and other "happening right now"
+ /// affordances. The one color in the system.
+ public static let live = Color(red: 0.702, green: 0.278, blue: 0.239)
+
+ /// Body text.
+ public static let text = ink
+
+ /// De-emphasized text — captions, time-ranges, secondary metadata.
+ public static let secondaryText = ink.opacity(0.45)
+
+ /// Hairline divider between rows / sections.
+ public static let divider = ink.opacity(0.16)
+}
diff --git a/jellytv/Packages/DesignSystem/Sources/DesignSystem/LiveTVTypography.swift b/jellytv/Packages/DesignSystem/Sources/DesignSystem/LiveTVTypography.swift
new file mode 100644
index 0000000..ae2ea57
--- /dev/null
+++ b/jellytv/Packages/DesignSystem/Sources/DesignSystem/LiveTVTypography.swift
@@ -0,0 +1,49 @@
+import SwiftUI
+
+/// Typography ramp for the low-key "Marquee" design. Serif (New York) display
+/// for hero titles, channel numbers, and shelf labels — light weights, italic
+/// accents. Chrome and captions are tracked-out uppercase sans; anything
+/// time-shaped keeps monospaced digits so columns align.
+public enum LiveTVTypography {
+ /// Display-weight headline — channel splash channel name, hero titles.
+ /// Light serif: the signature of the design.
+ public static let display: Font = .system(size: 64, weight: .light, design: .serif)
+
+ /// The big Home-hero title. Larger than `display`, same voice.
+ public static let heroDisplay: Font = .system(size: 84, weight: .light, design: .serif)
+
+ /// Playbill now-playing panel title.
+ public static let playbillTitle: Font = .system(size: 60, weight: .light, design: .serif)
+
+ /// Serif-italic channel number — the marquee signature ("7", "21").
+ public static let serifChannelNumber: Font = .system(size: 32, weight: .light, design: .serif).italic()
+
+ /// Serif-italic shelf / section label ("On Now", "Latest").
+ public static let shelfLabel: Font = .system(size: 32, weight: .regular, design: .serif).italic()
+
+ /// Tracked-uppercase kicker line ("YOU WERE WATCHING · CHANNEL 7").
+ /// Apply `.tracking(6)` (or similar) and `.textCase(.uppercase)` at the
+ /// usage site — Font can't carry tracking.
+ public static let kicker: Font = .system(size: 21, weight: .semibold)
+
+ /// Strong title — section headers, error-card title.
+ public static let strongTitle: Font = .title2.weight(.bold)
+
+ /// Time labels in the EPG header and elapsed/remaining.
+ public static let timeLabel: Font = .headline.monospacedDigit()
+
+ /// Channel number in the guide column — monospaced so numbers align.
+ public static let channelNumber: Font = .caption.monospacedDigit().weight(.semibold)
+
+ /// Channel name in the guide channel column.
+ public static let channelName: Font = .headline
+
+ /// Program title in the splash and HUD.
+ public static let programTitle: Font = .title3.weight(.semibold)
+
+ /// Program time-range under the title.
+ public static let programTime: Font = .subheadline.monospacedDigit()
+
+ /// "LIVE" / "PREMIERE" / "REPEAT" tag pills.
+ public static let tag: Font = .caption2.weight(.heavy)
+}
diff --git a/jellytv/Packages/DesignSystem/Sources/DesignSystem/PosterCard.swift b/jellytv/Packages/DesignSystem/Sources/DesignSystem/PosterCard.swift
new file mode 100644
index 0000000..109b99a
--- /dev/null
+++ b/jellytv/Packages/DesignSystem/Sources/DesignSystem/PosterCard.swift
@@ -0,0 +1,79 @@
+import SwiftUI
+import Nuke
+import NukeUI
+import JellyfinAPI
+
+public struct PosterCard: View {
+ let item: Item
+ let title: String
+ let imageURL: URL?
+ public let action: () -> Void
+ private var focusedValue: ((Item) -> BaseItemDto?)?
+
+ @FocusState private var isFocused: Bool
+
+ public init(
+ item: Item,
+ title: String,
+ imageURL: URL?,
+ action: @escaping () -> Void
+ ) {
+ self.item = item
+ self.title = title
+ self.imageURL = imageURL
+ self.action = action
+ }
+
+ public func focusedValue(_ provider: @escaping (Item) -> BaseItemDto?) -> Self {
+ var copy = self
+ copy.focusedValue = provider
+ return copy
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Button(action: action) {
+ Group {
+ if let url = imageURL {
+ LazyImage(url: url) { state in
+ if let image = state.image {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } else {
+ placeholderView
+ }
+ }
+ } else {
+ placeholderView
+ }
+ }
+ .aspectRatio(2/3, contentMode: .fit)
+ .frame(width: 240)
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ .focusedValue(\.focusedHomeItem, isFocused ? focusedValue?(item) : nil)
+
+ Text(title)
+ .font(.subheadline)
+ .lineLimit(1)
+ .foregroundStyle(isFocused ? .primary : .secondary)
+ .frame(width: 240, alignment: .leading)
+ }
+ }
+
+ private var placeholderView: some View {
+ Rectangle()
+ .fill(Color.secondary.opacity(0.2))
+ .overlay {
+ Image(systemName: "film")
+ .font(.largeTitle)
+ .foregroundStyle(.tertiary)
+ }
+ }
+}
\ No newline at end of file
diff --git a/jellytv/Packages/DesignSystem/Sources/DesignSystem/Shelf.swift b/jellytv/Packages/DesignSystem/Sources/DesignSystem/Shelf.swift
new file mode 100644
index 0000000..1e3e3ba
--- /dev/null
+++ b/jellytv/Packages/DesignSystem/Sources/DesignSystem/Shelf.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+import JellyfinAPI
+
+public struct Shelf: View {
+ let title: String
+ let items: [Item]
+ let itemTitle: (Item) -> String
+ let imageURL: (Item) -> URL?
+ let onItemTap: (Item) -> Void
+ private var focusedValue: ((Item) -> BaseItemDto?)?
+
+ public init(
+ title: String,
+ items: [Item],
+ itemTitle: @escaping (Item) -> String,
+ imageURL: @escaping (Item) -> URL?,
+ onItemTap: @escaping (Item) -> Void
+ ) {
+ self.title = title
+ self.items = items
+ self.itemTitle = itemTitle
+ self.imageURL = imageURL
+ self.onItemTap = onItemTap
+ }
+
+ public func focusedValue(_ provider: @escaping (Item) -> BaseItemDto?) -> Self {
+ var copy = self
+ copy.focusedValue = provider
+ return copy
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text(title)
+ .font(.title3)
+ .fontWeight(.semibold)
+ .padding(.horizontal, 40)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 40) {
+ ForEach(items) { item in
+ if let provider = focusedValue {
+ PosterCard(
+ item: item,
+ title: itemTitle(item),
+ imageURL: imageURL(item)
+ ) {
+ onItemTap(item)
+ }
+ .focusedValue(provider)
+ } else {
+ PosterCard(
+ item: item,
+ title: itemTitle(item),
+ imageURL: imageURL(item)
+ ) {
+ onItemTap(item)
+ }
+ }
+ }
+ }
+ .scrollClipDisabled()
+ }
+ .scrollClipDisabled()
+ }
+ .focusSection()
+ }
+}
\ No newline at end of file
diff --git a/jellytv/Packages/JellyfinAPI/Package.swift b/jellytv/Packages/JellyfinAPI/Package.swift
new file mode 100644
index 0000000..c4617f5
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Package.swift
@@ -0,0 +1,14 @@
+// swift-tools-version: 6.2
+import PackageDescription
+
+let package = Package(
+ name: "JellyfinAPI",
+ platforms: [.tvOS(.v26), .macOS(.v15)],
+ products: [
+ .library(name: "JellyfinAPI", targets: ["JellyfinAPI"]),
+ ],
+ targets: [
+ .target(name: "JellyfinAPI"),
+ .testTarget(name: "JellyfinAPITests", dependencies: ["JellyfinAPI"]),
+ ]
+)
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellyfinClient.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellyfinClient.swift
new file mode 100644
index 0000000..8422e54
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellyfinClient.swift
@@ -0,0 +1,760 @@
+import Foundation
+
+@available(tvOS 15.0, macOS 12.0, *)
+public actor JellyfinClient: JellyfinClientAPI {
+
+ // MARK: - State
+
+ private var serverURL: URL?
+ private var accessToken: String?
+ /// Lazily fetched + cached user id. Some endpoints (e.g. PlaybackInfo)
+ /// require it as a query parameter even though the auth header already
+ /// identifies the user. Cleared when access token changes.
+ private var cachedUserId: String?
+
+ // MARK: - Immutable configuration
+
+ private let deviceId: String
+ private let clientName: String
+ private let clientVersion: String
+ private let deviceName: String
+
+ // MARK: - Networking
+
+ private let session: URLSession
+ private let decoder: JSONDecoder
+ private let encoder: JSONEncoder
+
+ // MARK: - Init
+
+ public init(
+ deviceId: String,
+ clientName: String = "JellyTV",
+ clientVersion: String = "1.0",
+ deviceName: String = "Apple TV",
+ session: URLSession? = nil
+ ) {
+ self.deviceId = deviceId
+ self.clientName = clientName
+ self.clientVersion = clientVersion
+ self.deviceName = deviceName
+
+ if let session {
+ self.session = session
+ } else {
+ self.session = URLSession(configuration: .ephemeral)
+ }
+
+ let dec = JSONDecoder()
+ dec.dateDecodingStrategy = .iso8601
+ self.decoder = dec
+
+ self.encoder = JSONEncoder()
+ }
+
+ // MARK: - Protocol: State setters
+
+ public func setServerURL(_ url: URL?) async {
+ serverURL = url
+ }
+
+ public nonisolated func currentServerURL() async -> URL? {
+ await getServerURL()
+ }
+
+ private func getServerURL() -> URL? {
+ return serverURL
+ }
+
+ public func setAccessToken(_ token: String?) async {
+ accessToken = token
+ cachedUserId = nil
+ }
+
+ /// Returns the current user id, caching it on the actor. Throws if not
+ /// signed in. Used by endpoints that require a `userId` query param.
+ private func resolveUserId() async throws -> String {
+ if let cachedUserId { return cachedUserId }
+ let user = try await currentUser()
+ cachedUserId = user.id
+ return user.id
+ }
+
+ // MARK: - Protocol: Endpoints
+
+ public func getPublicSystemInfo() async throws -> PublicSystemInfo {
+ let request = try buildRequest(path: "/System/Info/Public")
+ return try await send(request, as: PublicSystemInfo.self)
+ }
+
+ public func authenticateByName(username: String, password: String) async throws -> AuthenticationResult {
+ let body = try encoder.encode(AuthenticationRequest(username: username, pw: password))
+ let request = try buildRequest(path: "/Users/AuthenticateByName", method: "POST", body: body)
+ return try await send(request, as: AuthenticationResult.self)
+ }
+
+ public func quickConnectEnabled() async throws -> Bool {
+ let request = try buildRequest(path: "/QuickConnect/Enabled")
+ return try await send(request, as: Bool.self)
+ }
+
+ public func quickConnectInitiate() async throws -> QuickConnectResult {
+ let request = try buildRequest(path: "/QuickConnect/Initiate", method: "POST")
+ do {
+ return try await send(request, as: QuickConnectResult.self)
+ } catch JellyfinError.unauthenticated {
+ throw JellyfinError.quickConnectDisabled
+ }
+ }
+
+ public func quickConnectStatus(secret: String) async throws -> QuickConnectResult {
+ let request = try buildRequest(
+ path: "/QuickConnect/Connect",
+ queryItems: [URLQueryItem(name: "secret", value: secret)]
+ )
+ do {
+ return try await send(request, as: QuickConnectResult.self)
+ } catch JellyfinError.http(status: 404, _) {
+ throw JellyfinError.quickConnectExpired
+ }
+ }
+
+ public func authenticateWithQuickConnect(secret: String) async throws -> AuthenticationResult {
+ let body = try encoder.encode(QuickConnectAuthRequest(secret: secret))
+ let request = try buildRequest(path: "/Users/AuthenticateWithQuickConnect", method: "POST", body: body)
+ return try await send(request, as: AuthenticationResult.self)
+ }
+
+ public func currentUser() async throws -> UserDto {
+ let request = try buildRequest(path: "/Users/Me")
+ return try await send(request, as: UserDto.self)
+ }
+
+ public func logout() async throws {
+ let request = try buildRequest(path: "/Sessions/Logout", method: "POST")
+ try await sendIgnoringResponse(request)
+ }
+
+ // MARK: - Home
+
+ public func userViews() async throws -> [BaseItemDto] {
+ let request = try buildRequest(path: "/UserViews")
+ let result = try await send(request, as: BaseItemDtoQueryResult.self)
+ return result.items
+ }
+
+ public func resumeItems(limit: Int) async throws -> [BaseItemDto] {
+ let request = try buildRequest(
+ path: "/UserItems/Resume",
+ queryItems: [URLQueryItem(name: "limit", value: String(limit))]
+ )
+ let result = try await send(request, as: BaseItemDtoQueryResult.self)
+ return result.items
+ }
+
+ public func nextUp(limit: Int) async throws -> [BaseItemDto] {
+ let request = try buildRequest(
+ path: "/Shows/NextUp",
+ queryItems: [URLQueryItem(name: "limit", value: String(limit))]
+ )
+ let result = try await send(request, as: BaseItemDtoQueryResult.self)
+ return result.items
+ }
+
+ public func latestItems(parentId: String?, limit: Int) async throws -> [BaseItemDto] {
+ var queryItems = [URLQueryItem(name: "limit", value: String(limit))]
+ if let parentId = parentId {
+ queryItems.append(URLQueryItem(name: "parentId", value: parentId))
+ }
+ let request = try buildRequest(path: "/Items/Latest", queryItems: queryItems)
+ return try await send(request, as: [BaseItemDto].self)
+ }
+
+ // MARK: - Live TV
+
+ public func liveTvChannels() async throws -> [LiveTvChannel] {
+ let queryItems = [
+ URLQueryItem(name: "enableImages", value: "true"),
+ URLQueryItem(name: "enableImageTypes", value: "Primary"),
+ URLQueryItem(name: "sortBy", value: "SortName"),
+ URLQueryItem(name: "sortOrder", value: "Ascending"),
+ ]
+ let request = try buildRequest(path: "/LiveTv/Channels", queryItems: queryItems)
+ let result = try await send(request, as: LiveTvChannelQueryResult.self)
+ return result.items
+ }
+
+ public func liveTvChannels(
+ filters: LiveTvChannelFilters,
+ addCurrentProgram: Bool
+ ) async throws -> [LiveTvChannel] {
+ var queryItems: [URLQueryItem] = [
+ URLQueryItem(name: "enableImages", value: "true"),
+ URLQueryItem(name: "enableImageTypes", value: "Primary,Logo,Backdrop,Thumb"),
+ URLQueryItem(name: "enableUserData", value: "true"),
+ URLQueryItem(name: "addCurrentProgram", value: addCurrentProgram ? "true" : "false"),
+ ]
+ if let value = filters.sortBy {
+ queryItems.append(URLQueryItem(name: "sortBy", value: value))
+ }
+ if let value = filters.sortOrder {
+ queryItems.append(URLQueryItem(name: "sortOrder", value: value))
+ }
+ if let value = filters.isMovie {
+ queryItems.append(URLQueryItem(name: "isMovie", value: value ? "true" : "false"))
+ }
+ if let value = filters.isSeries {
+ queryItems.append(URLQueryItem(name: "isSeries", value: value ? "true" : "false"))
+ }
+ if let value = filters.isNews {
+ queryItems.append(URLQueryItem(name: "isNews", value: value ? "true" : "false"))
+ }
+ if let value = filters.isKids {
+ queryItems.append(URLQueryItem(name: "isKids", value: value ? "true" : "false"))
+ }
+ if let value = filters.isSports {
+ queryItems.append(URLQueryItem(name: "isSports", value: value ? "true" : "false"))
+ }
+ if let value = filters.isFavorite {
+ queryItems.append(URLQueryItem(name: "isFavorite", value: value ? "true" : "false"))
+ }
+ if let value = filters.isAiringNow {
+ queryItems.append(URLQueryItem(name: "isAiring", value: value ? "true" : "false"))
+ }
+ if let value = filters.startIndex {
+ queryItems.append(URLQueryItem(name: "startIndex", value: String(value)))
+ }
+ if let value = filters.limit {
+ queryItems.append(URLQueryItem(name: "limit", value: String(value)))
+ }
+ let request = try buildRequest(path: "/LiveTv/Channels", queryItems: queryItems)
+ let result = try await send(request, as: LiveTvChannelQueryResult.self)
+ return result.items
+ }
+
+ public func liveTvPrograms(
+ channelIds: [String],
+ minStartDate: Date,
+ maxStartDate: Date
+ ) async throws -> [LiveTvProgram] {
+ if channelIds.isEmpty {
+ return []
+ }
+ let isoFormatter = ISO8601DateFormatter()
+ isoFormatter.formatOptions = [.withInternetDateTime]
+ var queryItems: [URLQueryItem] = channelIds.map {
+ URLQueryItem(name: "channelIds", value: $0)
+ }
+ queryItems.append(URLQueryItem(name: "minStartDate", value: isoFormatter.string(from: minStartDate)))
+ queryItems.append(URLQueryItem(name: "maxStartDate", value: isoFormatter.string(from: maxStartDate)))
+ queryItems.append(URLQueryItem(name: "sortBy", value: "StartDate"))
+ queryItems.append(URLQueryItem(name: "sortOrder", value: "Ascending"))
+ queryItems.append(URLQueryItem(name: "enableImages", value: "false"))
+ queryItems.append(URLQueryItem(name: "enableTotalRecordCount", value: "false"))
+ queryItems.append(URLQueryItem(name: "fields", value: "Overview"))
+ queryItems.append(URLQueryItem(name: "limit", value: "2000"))
+ let request = try buildRequest(path: "/LiveTv/Programs", queryItems: queryItems)
+ let result = try await send(request, as: LiveTvProgramQueryResult.self)
+ return result.items
+ }
+
+ public func liveTvPrograms(
+ channelIds: [String]?,
+ minStartDate: Date?,
+ maxStartDate: Date?,
+ filters: LiveTvProgramFilters
+ ) async throws -> [LiveTvProgram] {
+ let isoFormatter = ISO8601DateFormatter()
+ isoFormatter.formatOptions = [.withInternetDateTime]
+ var queryItems: [URLQueryItem] = []
+ if let channelIds, !channelIds.isEmpty {
+ for id in channelIds {
+ queryItems.append(URLQueryItem(name: "channelIds", value: id))
+ }
+ }
+ if let minStartDate {
+ queryItems.append(URLQueryItem(name: "minStartDate", value: isoFormatter.string(from: minStartDate)))
+ }
+ if let maxStartDate {
+ queryItems.append(URLQueryItem(name: "maxStartDate", value: isoFormatter.string(from: maxStartDate)))
+ }
+ if let value = filters.isAiring {
+ queryItems.append(URLQueryItem(name: "isAiring", value: value ? "true" : "false"))
+ }
+ if let value = filters.hasAired {
+ queryItems.append(URLQueryItem(name: "hasAired", value: value ? "true" : "false"))
+ }
+ if let value = filters.isMovie {
+ queryItems.append(URLQueryItem(name: "isMovie", value: value ? "true" : "false"))
+ }
+ if let value = filters.isSeries {
+ queryItems.append(URLQueryItem(name: "isSeries", value: value ? "true" : "false"))
+ }
+ if let value = filters.isNews {
+ queryItems.append(URLQueryItem(name: "isNews", value: value ? "true" : "false"))
+ }
+ if let value = filters.isKids {
+ queryItems.append(URLQueryItem(name: "isKids", value: value ? "true" : "false"))
+ }
+ if let value = filters.isSports {
+ queryItems.append(URLQueryItem(name: "isSports", value: value ? "true" : "false"))
+ }
+ if let genres = filters.genres, !genres.isEmpty {
+ queryItems.append(URLQueryItem(name: "genres", value: genres.joined(separator: "|")))
+ }
+ if let sortBy = filters.sortBy, !sortBy.isEmpty {
+ queryItems.append(URLQueryItem(name: "sortBy", value: sortBy.joined(separator: ",")))
+ } else {
+ queryItems.append(URLQueryItem(name: "sortBy", value: "StartDate"))
+ }
+ if let sortOrder = filters.sortOrder {
+ queryItems.append(URLQueryItem(name: "sortOrder", value: sortOrder))
+ } else {
+ queryItems.append(URLQueryItem(name: "sortOrder", value: "Ascending"))
+ }
+ queryItems.append(URLQueryItem(name: "enableImages", value: "true"))
+ queryItems.append(URLQueryItem(name: "enableImageTypes", value: "Primary,Thumb,Backdrop"))
+ queryItems.append(URLQueryItem(name: "fields", value: "Overview,Genres"))
+ queryItems.append(URLQueryItem(name: "enableTotalRecordCount", value: "false"))
+ if let limit = filters.limit {
+ queryItems.append(URLQueryItem(name: "limit", value: String(limit)))
+ } else {
+ queryItems.append(URLQueryItem(name: "limit", value: "500"))
+ }
+ let request = try buildRequest(path: "/LiveTv/Programs", queryItems: queryItems)
+ let result = try await send(request, as: LiveTvProgramQueryResult.self)
+ return result.items
+ }
+
+ public func liveTvRecommendedPrograms(
+ filters: LiveTvProgramFilters
+ ) async throws -> [LiveTvProgram] {
+ var queryItems: [URLQueryItem] = []
+ if let value = filters.isAiring {
+ queryItems.append(URLQueryItem(name: "isAiring", value: value ? "true" : "false"))
+ }
+ if let value = filters.hasAired {
+ queryItems.append(URLQueryItem(name: "hasAired", value: value ? "true" : "false"))
+ }
+ if let value = filters.isMovie {
+ queryItems.append(URLQueryItem(name: "isMovie", value: value ? "true" : "false"))
+ }
+ if let value = filters.isSeries {
+ queryItems.append(URLQueryItem(name: "isSeries", value: value ? "true" : "false"))
+ }
+ if let value = filters.isNews {
+ queryItems.append(URLQueryItem(name: "isNews", value: value ? "true" : "false"))
+ }
+ if let value = filters.isKids {
+ queryItems.append(URLQueryItem(name: "isKids", value: value ? "true" : "false"))
+ }
+ if let value = filters.isSports {
+ queryItems.append(URLQueryItem(name: "isSports", value: value ? "true" : "false"))
+ }
+ if let genres = filters.genres, !genres.isEmpty {
+ queryItems.append(URLQueryItem(name: "genres", value: genres.joined(separator: "|")))
+ }
+ if let limit = filters.limit {
+ queryItems.append(URLQueryItem(name: "limit", value: String(limit)))
+ } else {
+ queryItems.append(URLQueryItem(name: "limit", value: "30"))
+ }
+ queryItems.append(URLQueryItem(name: "enableImages", value: "true"))
+ queryItems.append(URLQueryItem(name: "enableImageTypes", value: "Primary,Thumb,Backdrop"))
+ queryItems.append(URLQueryItem(name: "fields", value: "Overview,Genres"))
+ queryItems.append(URLQueryItem(name: "enableTotalRecordCount", value: "false"))
+ let request = try buildRequest(path: "/LiveTv/Programs/Recommended", queryItems: queryItems)
+ let result = try await send(request, as: LiveTvProgramQueryResult.self)
+ return result.items
+ }
+
+ public func liveTvProgram(programId: String) async throws -> LiveTvProgram {
+ let request = try buildRequest(path: "/LiveTv/Programs/\(programId)")
+ return try await send(request, as: LiveTvProgram.self)
+ }
+
+ // MARK: - Recordings
+
+ public func liveTvRecordings(
+ isInProgress: Bool?,
+ seriesTimerId: String?,
+ limit: Int?
+ ) async throws -> [BaseItemDto] {
+ var queryItems: [URLQueryItem] = [
+ URLQueryItem(name: "enableImages", value: "true"),
+ URLQueryItem(name: "enableImageTypes", value: "Primary,Thumb,Backdrop"),
+ URLQueryItem(name: "enableTotalRecordCount", value: "false"),
+ URLQueryItem(name: "fields", value: "Overview,Genres,ChannelInfo"),
+ ]
+ if let isInProgress {
+ queryItems.append(URLQueryItem(name: "isInProgress", value: isInProgress ? "true" : "false"))
+ }
+ if let seriesTimerId {
+ queryItems.append(URLQueryItem(name: "seriesTimerId", value: seriesTimerId))
+ }
+ if let limit {
+ queryItems.append(URLQueryItem(name: "limit", value: String(limit)))
+ }
+ let request = try buildRequest(path: "/LiveTv/Recordings", queryItems: queryItems)
+ let result = try await send(request, as: BaseItemDtoQueryResult.self)
+ return result.items
+ }
+
+ public func deleteLiveTvRecording(recordingId: String) async throws {
+ let request = try buildRequest(path: "/LiveTv/Recordings/\(recordingId)", method: "DELETE")
+ try await sendIgnoringResponse(request)
+ }
+
+ // MARK: - Timers
+
+ public func liveTvTimers() async throws -> [TimerInfoDto] {
+ let request = try buildRequest(path: "/LiveTv/Timers")
+ let result = try await send(request, as: TimerInfoDtoQueryResult.self)
+ return result.items
+ }
+
+ public func liveTvSeriesTimers() async throws -> [SeriesTimerInfoDto] {
+ let request = try buildRequest(path: "/LiveTv/SeriesTimers")
+ let result = try await send(request, as: SeriesTimerInfoDtoQueryResult.self)
+ return result.items
+ }
+
+ public func liveTvTimerDefaults(programId: String?) async throws -> Data {
+ var queryItems: [URLQueryItem] = []
+ if let programId {
+ queryItems.append(URLQueryItem(name: "programId", value: programId))
+ }
+ let request = try buildRequest(
+ path: "/LiveTv/Timers/Defaults",
+ queryItems: queryItems.isEmpty ? nil : queryItems
+ )
+ return try await sendRaw(request)
+ }
+
+ public func createLiveTvTimer(body: Data) async throws {
+ let request = try buildRequest(path: "/LiveTv/Timers", method: "POST", body: body)
+ try await sendIgnoringResponse(request)
+ }
+
+ public func createLiveTvSeriesTimer(body: Data) async throws {
+ let request = try buildRequest(path: "/LiveTv/SeriesTimers", method: "POST", body: body)
+ try await sendIgnoringResponse(request)
+ }
+
+ public func cancelLiveTvTimer(timerId: String) async throws {
+ let request = try buildRequest(path: "/LiveTv/Timers/\(timerId)", method: "DELETE")
+ try await sendIgnoringResponse(request)
+ }
+
+ public func cancelLiveTvSeriesTimer(timerId: String) async throws {
+ let request = try buildRequest(path: "/LiveTv/SeriesTimers/\(timerId)", method: "DELETE")
+ try await sendIgnoringResponse(request)
+ }
+
+ // MARK: - Favorites
+
+ public func setFavorite(itemId: String, isFavorite: Bool) async throws {
+ let method = isFavorite ? "POST" : "DELETE"
+ let request = try buildRequest(path: "/UserFavoriteItems/\(itemId)", method: method)
+ try await sendIgnoringResponse(request)
+ }
+
+ // MARK: - Streams
+
+ public func liveTvOpenStream(channelId: String) async throws -> LiveStreamPlayback {
+ try await liveTvOpenStream(channelId: channelId, forceTranscoding: false)
+ }
+
+ /// Open a live stream. When `forceTranscoding == true`, sends
+ /// `enableDirectPlay=false` so the server won't hand back a progressive
+ /// `/Videos/{id}/stream.{container}` URL — used by the player as a
+ /// DirectPlay-fallback retry when AVPlayer can't consume the direct stream
+ /// for live media.
+ public func liveTvOpenStream(
+ channelId: String,
+ forceTranscoding: Bool
+ ) async throws -> LiveStreamPlayback {
+ JellytvLog.liveTV.info("liveTvOpenStream(channelId: \(channelId, privacy: .public), forceTranscoding: \(forceTranscoding))")
+ guard let token = accessToken else {
+ JellytvLog.liveTV.error("liveTvOpenStream: no access token — not signed in")
+ throw JellyfinError.unauthenticated
+ }
+ guard let serverURL else {
+ JellytvLog.liveTV.error("liveTvOpenStream: no server URL configured")
+ throw JellyfinError.notConfigured
+ }
+ let userId = try await resolveUserId()
+
+ // PlaybackInfo is the unified stream-open path that Swiftfin and the
+ // Jellyfin web client use for both VOD and live TV. Setting
+ // autoOpenLiveStream=true on a TvChannel item makes the server open
+ // the live stream as part of the call.
+ let body = try encoder.encode(PlaybackInfoBody(deviceProfile: .liveTvDefault))
+ let queryItems = [
+ URLQueryItem(name: "userId", value: userId),
+ URLQueryItem(name: "autoOpenLiveStream", value: "true"),
+ URLQueryItem(name: "maxStreamingBitrate", value: "120000000"),
+ URLQueryItem(name: "startTimeTicks", value: "0"),
+ URLQueryItem(name: "enableDirectPlay", value: forceTranscoding ? "false" : "true"),
+ URLQueryItem(name: "enableDirectStream", value: forceTranscoding ? "false" : "true"),
+ URLQueryItem(name: "enableTranscoding", value: "true"),
+ URLQueryItem(name: "allowVideoStreamCopy", value: "true"),
+ URLQueryItem(name: "allowAudioStreamCopy", value: "true"),
+ ]
+ let request = try buildRequest(
+ path: "/Items/\(channelId)/PlaybackInfo",
+ method: "POST",
+ queryItems: queryItems,
+ body: body
+ )
+ let response = try await send(request, as: LiveStreamResponse.self)
+ guard let source = response.primary else {
+ JellytvLog.liveTV.error("liveTvOpenStream: response had neither MediaSource nor MediaSources[0]")
+ throw JellyfinError.decoding(
+ DecodingError.dataCorrupted(
+ .init(codingPath: [], debugDescription: "LiveStreamResponse missing MediaSource")
+ )
+ )
+ }
+ JellytvLog.liveTV.debug("liveTvOpenStream: source id=\(source.id ?? "?", privacy: .public) container=\(source.container ?? "?", privacy: .public) transcoding=\(source.transcodingUrl ?? "", privacy: .public) liveStreamId=\(source.liveStreamId ?? "", privacy: .public)")
+ let playbackURL = try makePlaybackURL(source: source, serverURL: serverURL, token: token)
+ JellytvLog.liveTV.info("liveTvOpenStream: resolved playback URL \(playbackURL.absoluteString, privacy: .public)")
+ return LiveStreamPlayback(playbackURL: playbackURL, liveStreamId: source.liveStreamId)
+ }
+
+ public func liveTvCloseStream(liveStreamId: String) async throws {
+ JellytvLog.liveTV.info("liveTvCloseStream(liveStreamId: \(liveStreamId, privacy: .public))")
+ let request = try buildRequest(
+ path: "/LiveStreams/Close",
+ method: "POST",
+ queryItems: [URLQueryItem(name: "liveStreamId", value: liveStreamId)]
+ )
+ try await sendIgnoringResponse(request)
+ }
+
+ /// Build a playback URL from a `MediaSourceInfo`. Prefers the server-supplied
+ /// `transcodingUrl` (which already contains baked auth params); falls back to
+ /// constructing a direct-stream URL via `URLComponents`. NEVER uses
+ /// `appendingPathComponent` with a query-bearing string — that percent-encodes
+ /// the `?` and breaks the URL.
+ ///
+ /// For live streams, the device profile in `liveTvOpenStream` requests an HLS
+ /// transcode (`container=ts`, `protocol=hls`, `breakOnNonKeyFrames=true`), so
+ /// Jellyfin returns a `transcodingUrl` that already points at the
+ /// `/videos/{id}/master.m3u8` endpoint with the right HLS query params baked
+ /// in. We resolve that relative URL against `serverURL` and play it as-is.
+ /// The only client-side fix-up is stripping empty-name query items the server
+ /// occasionally emits (`?&...`) which break some HLS clients' query parsing.
+ private func makePlaybackURL(
+ source: MediaSourceInfo,
+ serverURL: URL,
+ token: String
+ ) throws -> URL {
+ if let transcodingUrl = source.transcodingUrl {
+ // Resolve the relative URL against the server. Do NOT append api_key —
+ // Jellyfin bakes auth into transcodingUrl when it constructs it.
+ guard let resolved = URL(string: transcodingUrl, relativeTo: serverURL)?.absoluteURL else {
+ throw JellyfinError.decoding(
+ DecodingError.dataCorrupted(
+ .init(codingPath: [], debugDescription: "Invalid transcodingUrl: \(transcodingUrl)")
+ )
+ )
+ }
+ // For live streams, strip empty-name query items — Jellyfin's
+ // TranscodingUrl sometimes starts with `?&` which leaves a stray
+ // empty parameter that some HLS clients reject. The path itself is
+ // honored verbatim (no rewrites — the device profile drove the server
+ // to emit a real master.m3u8 URL).
+ if source.liveStreamId != nil,
+ var components = URLComponents(url: resolved, resolvingAgainstBaseURL: false) {
+ if let items = components.queryItems {
+ let cleaned = items.filter { !$0.name.isEmpty }
+ components.queryItems = cleaned.isEmpty ? nil : cleaned
+ }
+ if let cleanedURL = components.url {
+ return cleanedURL
+ }
+ }
+ return resolved
+ }
+
+ guard let id = source.id, let container = source.container else {
+ throw JellyfinError.decoding(
+ DecodingError.dataCorrupted(
+ .init(codingPath: [], debugDescription: "MediaSource missing Id or Container for direct-stream fallback")
+ )
+ )
+ }
+
+ var components = URLComponents()
+ components.scheme = serverURL.scheme
+ components.host = serverURL.host
+ components.port = serverURL.port
+ components.path = "/Videos/\(id)/stream.\(container)"
+ var items: [URLQueryItem] = [
+ URLQueryItem(name: "MediaSourceId", value: id),
+ URLQueryItem(name: "static", value: "true"),
+ URLQueryItem(name: "api_key", value: token),
+ ]
+ if let liveStreamId = source.liveStreamId {
+ items.append(URLQueryItem(name: "LiveStreamId", value: liveStreamId))
+ }
+ components.queryItems = items
+ guard let url = components.url else {
+ throw JellyfinError.invalidServerURL
+ }
+ return url
+ }
+
+ // MARK: - Private: Authorization header
+
+ private func authorizationHeaderValue() -> String {
+ var parts = [
+ "Client=\"\(percentEncode(clientName))\"",
+ "Device=\"\(percentEncode(deviceName))\"",
+ "DeviceId=\"\(percentEncode(deviceId))\"",
+ "Version=\"\(percentEncode(clientVersion))\"",
+ ]
+ if let token = accessToken {
+ parts.append("Token=\"\(percentEncode(token))\"")
+ }
+ return "MediaBrowser " + parts.joined(separator: ", ")
+ }
+
+ private func percentEncode(_ value: String) -> String {
+ value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
+ }
+
+ // MARK: - Private: Request builder
+
+ private func buildRequest(
+ path: String,
+ method: String = "GET",
+ queryItems: [URLQueryItem]? = nil,
+ body: Data? = nil
+ ) throws -> URLRequest {
+ guard let serverURL else { throw JellyfinError.notConfigured }
+ var components = URLComponents(
+ url: serverURL.appendingPathComponent(path),
+ resolvingAgainstBaseURL: false
+ )
+ if let queryItems, !queryItems.isEmpty {
+ components?.queryItems = queryItems
+ }
+ guard let url = components?.url else { throw JellyfinError.invalidServerURL }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = method
+ request.setValue(authorizationHeaderValue(), forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ if let body {
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+ }
+ return request
+ }
+
+ // MARK: - Private: Send helpers
+
+ private func send(_ request: URLRequest, as type: T.Type = T.self) async throws -> T {
+ let method = request.httpMethod ?? "GET"
+ let path = request.url?.path ?? ""
+ JellytvLog.api.debug("→ \(method, privacy: .public) \(path, privacy: .public)")
+
+ let data: Data
+ let response: URLResponse
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch let urlError as URLError {
+ JellytvLog.api.error("✗ \(method, privacy: .public) \(path, privacy: .public) network error: \(urlError.localizedDescription, privacy: .public) (code: \(urlError.code.rawValue))")
+ throw JellyfinError.network(urlError)
+ } catch {
+ JellytvLog.api.error("✗ \(method, privacy: .public) \(path, privacy: .public) unknown error: \(String(describing: error), privacy: .public)")
+ throw JellyfinError.network(URLError(.unknown))
+ }
+
+ guard let http = response as? HTTPURLResponse else {
+ JellytvLog.api.error("✗ \(method, privacy: .public) \(path, privacy: .public) bad server response (not HTTP)")
+ throw JellyfinError.network(URLError(.badServerResponse))
+ }
+
+ switch http.statusCode {
+ case 200..<300:
+ JellytvLog.api.debug("← \(http.statusCode) \(method, privacy: .public) \(path, privacy: .public) (\(data.count) bytes)")
+ do {
+ return try decoder.decode(T.self, from: data)
+ } catch let decodingError as DecodingError {
+ let bodySnippet = String(data: data.prefix(512), encoding: .utf8) ?? ""
+ JellytvLog.api.error("✗ \(method, privacy: .public) \(path, privacy: .public) decode failed: \(String(describing: decodingError), privacy: .public)\nbody: \(bodySnippet, privacy: .public)")
+ throw JellyfinError.decoding(decodingError)
+ }
+ case 401:
+ JellytvLog.api.error("✗ \(method, privacy: .public) \(path, privacy: .public) 401 unauthenticated")
+ throw JellyfinError.unauthenticated
+ default:
+ let problem = try? decoder.decode(ProblemDetails.self, from: data)
+ let bodySnippet = String(data: data.prefix(512), encoding: .utf8) ?? ""
+ JellytvLog.api.error("✗ \(method, privacy: .public) \(path, privacy: .public) HTTP \(http.statusCode) \(problem?.title ?? "", privacy: .public) — \(problem?.detail ?? bodySnippet, privacy: .public)")
+ throw JellyfinError.http(status: http.statusCode, problem: problem)
+ }
+ }
+
+ /// Send a request and return the raw response body. Used for endpoints
+ /// (e.g. `/LiveTv/Timers/Defaults`) where the response is an arbitrary
+ /// JSON document that callers will round-trip back to a sibling endpoint.
+ private func sendRaw(_ request: URLRequest) async throws -> Data {
+ let method = request.httpMethod ?? "GET"
+ let path = request.url?.path ?? ""
+ JellytvLog.api.debug("→ \(method, privacy: .public) \(path, privacy: .public)")
+
+ let data: Data
+ let response: URLResponse
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch let urlError as URLError {
+ throw JellyfinError.network(urlError)
+ } catch {
+ throw JellyfinError.network(URLError(.unknown))
+ }
+
+ guard let http = response as? HTTPURLResponse else {
+ throw JellyfinError.network(URLError(.badServerResponse))
+ }
+
+ switch http.statusCode {
+ case 200..<300:
+ return data
+ case 401:
+ throw JellyfinError.unauthenticated
+ default:
+ let problem = try? decoder.decode(ProblemDetails.self, from: data)
+ throw JellyfinError.http(status: http.statusCode, problem: problem)
+ }
+ }
+
+ private func sendIgnoringResponse(_ request: URLRequest) async throws {
+ let data: Data
+ let response: URLResponse
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch let urlError as URLError {
+ throw JellyfinError.network(urlError)
+ } catch {
+ throw JellyfinError.network(URLError(.unknown))
+ }
+
+ guard let http = response as? HTTPURLResponse else {
+ throw JellyfinError.network(URLError(.badServerResponse))
+ }
+
+ switch http.statusCode {
+ case 200..<300:
+ return
+ case 401:
+ throw JellyfinError.unauthenticated
+ default:
+ let problem = try? decoder.decode(ProblemDetails.self, from: data)
+ throw JellyfinError.http(status: http.statusCode, problem: problem)
+ }
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellyfinClientAPI.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellyfinClientAPI.swift
new file mode 100644
index 0000000..ec15402
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellyfinClientAPI.swift
@@ -0,0 +1,203 @@
+import Foundation
+
+public protocol JellyfinClientAPI: Sendable {
+ /// Set or change the server URL. Pass nil to clear.
+ func setServerURL(_ url: URL?) async
+
+ /// Get the current server URL, if configured.
+ func currentServerURL() async -> URL?
+
+ /// Set or clear the access token used by authenticated calls.
+ func setAccessToken(_ token: String?) async
+
+ // MARK: - Auth
+
+ func getPublicSystemInfo() async throws -> PublicSystemInfo
+ func authenticateByName(username: String, password: String) async throws -> AuthenticationResult
+
+ func quickConnectEnabled() async throws -> Bool
+ func quickConnectInitiate() async throws -> QuickConnectResult
+ func quickConnectStatus(secret: String) async throws -> QuickConnectResult
+ func authenticateWithQuickConnect(secret: String) async throws -> AuthenticationResult
+
+ func currentUser() async throws -> UserDto
+ func logout() async throws
+
+ // MARK: - Home
+
+ /// GET /UserViews — returns libraries (collections like Movies, TV Shows, etc.)
+ func userViews() async throws -> [BaseItemDto]
+
+ /// GET /UserItems/Resume?limit=... — Continue Watching
+ func resumeItems(limit: Int) async throws -> [BaseItemDto]
+
+ /// GET /Shows/NextUp?limit=... — Next Up episodes
+ func nextUp(limit: Int) async throws -> [BaseItemDto]
+
+ /// GET /Items/Latest?parentId=...&limit=... — Latest items per library
+ func latestItems(parentId: String?, limit: Int) async throws -> [BaseItemDto]
+
+ // MARK: - Live TV
+
+ /// GET /LiveTv/Channels — list of TV channels (default: all, sorted by name,
+ /// no current-program enrichment).
+ func liveTvChannels() async throws -> [LiveTvChannel]
+
+ /// GET /LiveTv/Channels — filtered + optionally enriched with the
+ /// currently-airing program on each channel (`addCurrentProgram=true`).
+ /// Powers the "On Now" landing as well as the favorites / category filters.
+ func liveTvChannels(
+ filters: LiveTvChannelFilters,
+ addCurrentProgram: Bool
+ ) async throws -> [LiveTvChannel]
+
+ /// GET /LiveTv/Programs — EPG entries for the given channels in the time window.
+ /// `minStartDate` and `maxStartDate` filter on each program's start time.
+ /// To capture programs already in progress at the window start, callers should
+ /// pass a `minStartDate` somewhat earlier than the visible window start.
+ func liveTvPrograms(
+ channelIds: [String],
+ minStartDate: Date,
+ maxStartDate: Date
+ ) async throws -> [LiveTvProgram]
+
+ /// GET /LiveTv/Programs — full filter surface (genre / category filters, sort).
+ func liveTvPrograms(
+ channelIds: [String]?,
+ minStartDate: Date?,
+ maxStartDate: Date?,
+ filters: LiveTvProgramFilters
+ ) async throws -> [LiveTvProgram]
+
+ /// GET /LiveTv/Programs/Recommended — server-curated upcoming program list.
+ /// Used for the "Recommended for You" shelf on the Live TV landing.
+ func liveTvRecommendedPrograms(
+ filters: LiveTvProgramFilters
+ ) async throws -> [LiveTvProgram]
+
+ /// GET /LiveTv/Programs/{programId} — detailed info for a single program.
+ func liveTvProgram(programId: String) async throws -> LiveTvProgram
+
+ /// GET /LiveTv/Recordings — completed and in-progress recordings.
+ /// Pass `isInProgress=true` to fetch only currently-recording timers.
+ func liveTvRecordings(
+ isInProgress: Bool?,
+ seriesTimerId: String?,
+ limit: Int?
+ ) async throws -> [BaseItemDto]
+
+ /// DELETE /LiveTv/Recordings/{recordingId}.
+ func deleteLiveTvRecording(recordingId: String) async throws
+
+ /// GET /LiveTv/Timers — one-shot scheduled recordings.
+ func liveTvTimers() async throws -> [TimerInfoDto]
+
+ /// GET /LiveTv/SeriesTimers — recurring (series) recording rules.
+ func liveTvSeriesTimers() async throws -> [SeriesTimerInfoDto]
+
+ /// GET /LiveTv/Timers/Defaults?programId=... — server-suggested defaults
+ /// for a new timer. Submit the result back to `createLiveTvTimer` to record.
+ func liveTvTimerDefaults(programId: String?) async throws -> Data
+
+ /// POST /LiveTv/Timers — schedule a one-shot recording. `body` is the JSON
+ /// returned by `liveTvTimerDefaults` (potentially mutated by the caller).
+ func createLiveTvTimer(body: Data) async throws
+
+ /// POST /LiveTv/SeriesTimers — schedule a series recording.
+ func createLiveTvSeriesTimer(body: Data) async throws
+
+ /// DELETE /LiveTv/Timers/{timerId} — cancel a one-shot timer.
+ func cancelLiveTvTimer(timerId: String) async throws
+
+ /// DELETE /LiveTv/SeriesTimers/{timerId} — cancel a series timer.
+ func cancelLiveTvSeriesTimer(timerId: String) async throws
+
+ /// POST /UserFavoriteItems/{itemId} — mark an item (e.g. a channel) as a
+ /// favorite for the current user.
+ func setFavorite(itemId: String, isFavorite: Bool) async throws
+
+ /// POST /LiveTv/LiveStreams/Open — open a live TV stream for the given channel.
+ /// Returns a `LiveStreamPlayback` with a fully-resolved playback URL (the
+ /// access token is baked into the URL — callers should never need to add it).
+ func liveTvOpenStream(channelId: String) async throws -> LiveStreamPlayback
+}
+
+// MARK: - Live TV stream extensions
+//
+// These methods are intentionally added in the default-implementation extension
+// rather than the protocol body so existing explicit conformers (FakeJellyfinClient,
+// MockJellyfinClient) continue to compile without per-conformer stubs. Conformers
+// that need real behavior (the actor `JellyfinClient`) override; everything else
+// gets a sensible default.
+
+// Default forwarding so existing conformers (tests, mocks) keep compiling
+// after the protocol grew. New conformers should override every method.
+public extension JellyfinClientAPI {
+ func liveTvChannels(
+ filters: LiveTvChannelFilters,
+ addCurrentProgram: Bool
+ ) async throws -> [LiveTvChannel] {
+ try await liveTvChannels()
+ }
+
+ func liveTvPrograms(
+ channelIds: [String]?,
+ minStartDate: Date?,
+ maxStartDate: Date?,
+ filters: LiveTvProgramFilters
+ ) async throws -> [LiveTvProgram] {
+ []
+ }
+
+ func liveTvRecommendedPrograms(
+ filters: LiveTvProgramFilters
+ ) async throws -> [LiveTvProgram] {
+ []
+ }
+
+ func liveTvProgram(programId: String) async throws -> LiveTvProgram {
+ throw JellyfinError.notConfigured
+ }
+
+ func liveTvRecordings(
+ isInProgress: Bool?,
+ seriesTimerId: String?,
+ limit: Int?
+ ) async throws -> [BaseItemDto] {
+ []
+ }
+
+ func deleteLiveTvRecording(recordingId: String) async throws {}
+
+ func liveTvTimers() async throws -> [TimerInfoDto] { [] }
+
+ func liveTvSeriesTimers() async throws -> [SeriesTimerInfoDto] { [] }
+
+ func liveTvTimerDefaults(programId: String?) async throws -> Data { Data() }
+
+ func createLiveTvTimer(body: Data) async throws {}
+
+ func createLiveTvSeriesTimer(body: Data) async throws {}
+
+ func cancelLiveTvTimer(timerId: String) async throws {}
+
+ func cancelLiveTvSeriesTimer(timerId: String) async throws {}
+
+ func setFavorite(itemId: String, isFavorite: Bool) async throws {}
+
+ /// Open a live stream, optionally forcing the server to transcode (skipping
+ /// any DirectPlay path). Used by the player as a fallback when DirectPlay
+ /// produces a URL AVPlayer can't consume for live media.
+ /// Default impl ignores the flag and forwards to `liveTvOpenStream(channelId:)`.
+ func liveTvOpenStream(
+ channelId: String,
+ forceTranscoding: Bool
+ ) async throws -> LiveStreamPlayback {
+ try await liveTvOpenStream(channelId: channelId)
+ }
+
+ /// POST /LiveStreams/Close?liveStreamId=… — best-effort tell the server to
+ /// kill the transcoder session. Saves server CPU + frees HDHomeRun tuners
+ /// for fast channel-switching. Default impl is a no-op so mocks compile.
+ func liveTvCloseStream(liveStreamId: String) async throws {}
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellyfinError.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellyfinError.swift
new file mode 100644
index 0000000..fb98f92
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellyfinError.swift
@@ -0,0 +1,12 @@
+import Foundation
+
+public enum JellyfinError: Error, Sendable {
+ case invalidServerURL
+ case notConfigured
+ case network(URLError)
+ case http(status: Int, problem: ProblemDetails?)
+ case decoding(DecodingError)
+ case unauthenticated
+ case quickConnectDisabled
+ case quickConnectExpired
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellytvLog.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellytvLog.swift
new file mode 100644
index 0000000..54b50ca
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/JellytvLog.swift
@@ -0,0 +1,12 @@
+import Foundation
+import os
+
+/// Shared `os.Logger` instances for the JellyTV app. Logs from these show up
+/// in Xcode's console (when running through Xcode), in `Console.app` filtered
+/// by subsystem `tv.jelly.JellyTV`, and in the device log.
+public enum JellytvLog {
+ public static let api = Logger(subsystem: "tv.jelly.JellyTV", category: "api")
+ public static let liveTV = Logger(subsystem: "tv.jelly.JellyTV", category: "livetv")
+ public static let player = Logger(subsystem: "tv.jelly.JellyTV", category: "player")
+ public static let session = Logger(subsystem: "tv.jelly.JellyTV", category: "session")
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/AuthenticationRequest.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/AuthenticationRequest.swift
new file mode 100644
index 0000000..685217a
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/AuthenticationRequest.swift
@@ -0,0 +1,14 @@
+public struct AuthenticationRequest: Encodable, Sendable {
+ public let username: String
+ public let pw: String
+
+ public init(username: String, pw: String) {
+ self.username = username
+ self.pw = pw
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case username = "Username"
+ case pw = "Pw"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/AuthenticationResult.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/AuthenticationResult.swift
new file mode 100644
index 0000000..9f3d5e5
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/AuthenticationResult.swift
@@ -0,0 +1,25 @@
+public struct AuthenticationResult: Decodable, Sendable, Equatable {
+ public let user: UserDto
+ public let sessionInfo: SessionInfoDto?
+ public let accessToken: String
+ public let serverId: String
+
+ public init(
+ user: UserDto,
+ sessionInfo: SessionInfoDto? = nil,
+ accessToken: String,
+ serverId: String
+ ) {
+ self.user = user
+ self.sessionInfo = sessionInfo
+ self.accessToken = accessToken
+ self.serverId = serverId
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case user = "User"
+ case sessionInfo = "SessionInfo"
+ case accessToken = "AccessToken"
+ case serverId = "ServerId"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/BaseItemDto.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/BaseItemDto.swift
new file mode 100644
index 0000000..eaef5df
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/BaseItemDto.swift
@@ -0,0 +1,86 @@
+import Foundation
+
+public struct BaseItemDto: Decodable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let name: String
+ public let type: String?
+ public let serverId: String?
+ public let parentId: String?
+ public let imageTags: [String: String]?
+ public let backdropImageTags: [String]?
+ public let overview: String?
+ public let productionYear: Int?
+ public let userData: UserItemDataDto?
+ public let runTimeTicks: Int64?
+ public let seriesName: String?
+ public let seasonName: String?
+ public let indexNumber: Int?
+ public let communityRating: Double?
+
+ public init(
+ id: String,
+ name: String,
+ type: String? = nil,
+ serverId: String? = nil,
+ parentId: String? = nil,
+ imageTags: [String: String]? = nil,
+ backdropImageTags: [String]? = nil,
+ overview: String? = nil,
+ productionYear: Int? = nil,
+ userData: UserItemDataDto? = nil,
+ runTimeTicks: Int64? = nil,
+ seriesName: String? = nil,
+ seasonName: String? = nil,
+ indexNumber: Int? = nil,
+ communityRating: Double? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.type = type
+ self.serverId = serverId
+ self.parentId = parentId
+ self.imageTags = imageTags
+ self.backdropImageTags = backdropImageTags
+ self.overview = overview
+ self.productionYear = productionYear
+ self.userData = userData
+ self.runTimeTicks = runTimeTicks
+ self.seriesName = seriesName
+ self.seasonName = seasonName
+ self.indexNumber = indexNumber
+ self.communityRating = communityRating
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case id = "Id"
+ case name = "Name"
+ case type = "Type"
+ case serverId = "ServerId"
+ case parentId = "ParentId"
+ case imageTags = "ImageTags"
+ case backdropImageTags = "BackdropImageTags"
+ case overview = "Overview"
+ case productionYear = "ProductionYear"
+ case userData = "UserData"
+ case runTimeTicks = "RunTimeTicks"
+ case seriesName = "SeriesName"
+ case seasonName = "SeasonName"
+ case indexNumber = "IndexNumber"
+ case communityRating = "CommunityRating"
+ }
+}
+
+public struct BaseItemDtoQueryResult: Decodable, Sendable, Equatable {
+ public let items: [BaseItemDto]
+ public let totalRecordCount: Int?
+
+ public init(items: [BaseItemDto], totalRecordCount: Int? = nil) {
+ self.items = items
+ self.totalRecordCount = totalRecordCount
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case items = "Items"
+ case totalRecordCount = "TotalRecordCount"
+ }
+}
\ No newline at end of file
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveStreamOpenRequest.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveStreamOpenRequest.swift
new file mode 100644
index 0000000..3083ac0
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveStreamOpenRequest.swift
@@ -0,0 +1,156 @@
+import Foundation
+
+/// Request body for `POST /LiveTv/LiveStreams/Open`. Encode-only.
+/// (Currently unused — `liveTvOpenStream` switched to PlaybackInfo, see
+/// `PlaybackInfoBody`. Kept here in case we need the lower-level endpoint.)
+public struct LiveStreamOpenRequest: Encodable, Sendable {
+ public let openToken: String
+ public let deviceProfile: DeviceProfileBody
+
+ public init(openToken: String, deviceProfile: DeviceProfileBody) {
+ self.openToken = openToken
+ self.deviceProfile = deviceProfile
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case openToken = "OpenToken"
+ case deviceProfile = "DeviceProfile"
+ }
+}
+
+/// Request body for `POST /Items/{itemId}/PlaybackInfo`. Encode-only.
+public struct PlaybackInfoBody: Encodable, Sendable {
+ public let deviceProfile: DeviceProfileBody
+
+ public init(deviceProfile: DeviceProfileBody) {
+ self.deviceProfile = deviceProfile
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case deviceProfile = "DeviceProfile"
+ }
+}
+
+public struct DeviceProfileBody: Encodable, Sendable {
+ public let name: String
+ public let maxStreamingBitrate: Int
+ public let maxStaticBitrate: Int
+ public let directPlayProfiles: [DirectPlayProfileBody]
+ public let transcodingProfiles: [TranscodingProfileBody]
+
+ public init(
+ name: String,
+ maxStreamingBitrate: Int,
+ maxStaticBitrate: Int,
+ directPlayProfiles: [DirectPlayProfileBody],
+ transcodingProfiles: [TranscodingProfileBody]
+ ) {
+ self.name = name
+ self.maxStreamingBitrate = maxStreamingBitrate
+ self.maxStaticBitrate = maxStaticBitrate
+ self.directPlayProfiles = directPlayProfiles
+ self.transcodingProfiles = transcodingProfiles
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case name = "Name"
+ case maxStreamingBitrate = "MaxStreamingBitrate"
+ case maxStaticBitrate = "MaxStaticBitrate"
+ case directPlayProfiles = "DirectPlayProfiles"
+ case transcodingProfiles = "TranscodingProfiles"
+ }
+
+ /// Minimum-viable profile for tvOS Live TV. DirectPlay covers MPEG-TS
+ /// (HDHomeRun's native container) plus common Jellyfin formats. The HLS
+ /// transcode profile uses container=ts with BreakOnNonKeyFrames so the
+ /// server emits a real `master.m3u8` URL natively for live TV — matching
+ /// Jellyfin's web client live-TV profile.
+ public static let liveTvDefault = DeviceProfileBody(
+ name: "JellyTV",
+ maxStreamingBitrate: 120_000_000,
+ maxStaticBitrate: 100_000_000,
+ directPlayProfiles: [
+ DirectPlayProfileBody(
+ container: "ts,m2ts,mkv,mp4,m4v,mov",
+ type: "Video",
+ videoCodec: "h264,hevc",
+ audioCodec: "aac,ac3,eac3,mp3"
+ ),
+ ],
+ transcodingProfiles: [
+ TranscodingProfileBody(
+ container: "ts",
+ type: "Video",
+ videoCodec: "h264,hevc",
+ audioCodec: "aac,mp3,ac3,eac3",
+ protocol: "hls",
+ context: "Streaming",
+ minSegments: 1,
+ breakOnNonKeyFrames: true
+ ),
+ ]
+ )
+}
+
+public struct DirectPlayProfileBody: Encodable, Sendable {
+ public let container: String
+ public let type: String
+ public let videoCodec: String?
+ public let audioCodec: String?
+
+ public init(container: String, type: String, videoCodec: String? = nil, audioCodec: String? = nil) {
+ self.container = container
+ self.type = type
+ self.videoCodec = videoCodec
+ self.audioCodec = audioCodec
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case container = "Container"
+ case type = "Type"
+ case videoCodec = "VideoCodec"
+ case audioCodec = "AudioCodec"
+ }
+}
+
+public struct TranscodingProfileBody: Encodable, Sendable {
+ public let container: String
+ public let type: String
+ public let videoCodec: String
+ public let audioCodec: String
+ public let `protocol`: String
+ public let context: String
+ public let minSegments: Int
+ public let breakOnNonKeyFrames: Bool
+
+ public init(
+ container: String,
+ type: String,
+ videoCodec: String,
+ audioCodec: String,
+ protocol: String,
+ context: String,
+ minSegments: Int,
+ breakOnNonKeyFrames: Bool
+ ) {
+ self.container = container
+ self.type = type
+ self.videoCodec = videoCodec
+ self.audioCodec = audioCodec
+ self.`protocol` = `protocol`
+ self.context = context
+ self.minSegments = minSegments
+ self.breakOnNonKeyFrames = breakOnNonKeyFrames
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case container = "Container"
+ case type = "Type"
+ case videoCodec = "VideoCodec"
+ case audioCodec = "AudioCodec"
+ case `protocol` = "Protocol"
+ case context = "Context"
+ case minSegments = "MinSegments"
+ case breakOnNonKeyFrames = "BreakOnNonKeyFrames"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveStreamPlayback.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveStreamPlayback.swift
new file mode 100644
index 0000000..156050f
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveStreamPlayback.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+/// Resolved playback information for a live stream. The actor builds the
+/// playback URL with auth baked in so callers never see the access token.
+public struct LiveStreamPlayback: Sendable, Equatable {
+ public let playbackURL: URL
+ public let liveStreamId: String?
+
+ public init(playbackURL: URL, liveStreamId: String? = nil) {
+ self.playbackURL = playbackURL
+ self.liveStreamId = liveStreamId
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveStreamResponse.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveStreamResponse.swift
new file mode 100644
index 0000000..980fabd
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveStreamResponse.swift
@@ -0,0 +1,57 @@
+import Foundation
+
+public struct MediaSourceInfo: Decodable, Sendable, Equatable {
+ public let id: String?
+ public let path: String?
+ public let transcodingUrl: String?
+ public let container: String?
+ public let liveStreamId: String?
+ public let supportsTranscoding: Bool?
+
+ public init(
+ id: String? = nil,
+ path: String? = nil,
+ transcodingUrl: String? = nil,
+ container: String? = nil,
+ liveStreamId: String? = nil,
+ supportsTranscoding: Bool? = nil
+ ) {
+ self.id = id
+ self.path = path
+ self.transcodingUrl = transcodingUrl
+ self.container = container
+ self.liveStreamId = liveStreamId
+ self.supportsTranscoding = supportsTranscoding
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case id = "Id"
+ case path = "Path"
+ case transcodingUrl = "TranscodingUrl"
+ case container = "Container"
+ case liveStreamId = "LiveStreamId"
+ case supportsTranscoding = "SupportsTranscoding"
+ }
+}
+
+/// Response from `POST /LiveTv/LiveStreams/Open`. Different Jellyfin versions
+/// return either a singular `MediaSource` or a plural `MediaSources` array,
+/// so we decode both and expose `primary` for callers.
+public struct LiveStreamResponse: Decodable, Sendable, Equatable {
+ public let mediaSource: MediaSourceInfo?
+ public let mediaSources: [MediaSourceInfo]?
+
+ public init(mediaSource: MediaSourceInfo? = nil, mediaSources: [MediaSourceInfo]? = nil) {
+ self.mediaSource = mediaSource
+ self.mediaSources = mediaSources
+ }
+
+ public var primary: MediaSourceInfo? {
+ mediaSource ?? mediaSources?.first
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case mediaSource = "MediaSource"
+ case mediaSources = "MediaSources"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveTvChannel.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveTvChannel.swift
new file mode 100644
index 0000000..f6426b2
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveTvChannel.swift
@@ -0,0 +1,66 @@
+import Foundation
+
+public struct LiveTvChannel: Decodable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let name: String
+ public let number: String?
+ public let channelType: String?
+ public let serverId: String?
+ public let imageTags: [String: String]?
+ public let userData: UserItemDataDto?
+ /// The program currently airing on this channel. Server populates this
+ /// when `addCurrentProgram=true` is sent on `/LiveTv/Channels`.
+ public let currentProgram: LiveTvProgram?
+
+ public init(
+ id: String,
+ name: String,
+ number: String? = nil,
+ channelType: String? = nil,
+ serverId: String? = nil,
+ imageTags: [String: String]? = nil,
+ userData: UserItemDataDto? = nil,
+ currentProgram: LiveTvProgram? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.number = number
+ self.channelType = channelType
+ self.serverId = serverId
+ self.imageTags = imageTags
+ self.userData = userData
+ self.currentProgram = currentProgram
+ }
+
+ /// Convenience: tag for the channel's primary image, used to build the
+ /// channel logo URL.
+ public var primaryImageTag: String? { imageTags?["Primary"] }
+
+ public var isFavorite: Bool { userData?.isFavorite ?? false }
+
+ enum CodingKeys: String, CodingKey {
+ case id = "Id"
+ case name = "Name"
+ case number = "Number"
+ case channelType = "ChannelType"
+ case serverId = "ServerId"
+ case imageTags = "ImageTags"
+ case userData = "UserData"
+ case currentProgram = "CurrentProgram"
+ }
+}
+
+public struct LiveTvChannelQueryResult: Decodable, Sendable, Equatable {
+ public let items: [LiveTvChannel]
+ public let totalRecordCount: Int?
+
+ public init(items: [LiveTvChannel], totalRecordCount: Int? = nil) {
+ self.items = items
+ self.totalRecordCount = totalRecordCount
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case items = "Items"
+ case totalRecordCount = "TotalRecordCount"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveTvFilters.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveTvFilters.swift
new file mode 100644
index 0000000..4c8af9c
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveTvFilters.swift
@@ -0,0 +1,92 @@
+import Foundation
+
+/// Filters for `GET /LiveTv/Channels`. All fields are optional; nil means
+/// "no constraint." Mirrors the Jellyfin filter parameters.
+public struct LiveTvChannelFilters: Sendable, Equatable {
+ public var isMovie: Bool?
+ public var isSeries: Bool?
+ public var isNews: Bool?
+ public var isKids: Bool?
+ public var isSports: Bool?
+ public var isFavorite: Bool?
+ /// When set, only channels whose program currently airing matches this filter.
+ public var isAiringNow: Bool?
+ public var sortBy: String?
+ public var sortOrder: String?
+ public var startIndex: Int?
+ public var limit: Int?
+
+ public init(
+ isMovie: Bool? = nil,
+ isSeries: Bool? = nil,
+ isNews: Bool? = nil,
+ isKids: Bool? = nil,
+ isSports: Bool? = nil,
+ isFavorite: Bool? = nil,
+ isAiringNow: Bool? = nil,
+ sortBy: String? = "SortName",
+ sortOrder: String? = "Ascending",
+ startIndex: Int? = nil,
+ limit: Int? = nil
+ ) {
+ self.isMovie = isMovie
+ self.isSeries = isSeries
+ self.isNews = isNews
+ self.isKids = isKids
+ self.isSports = isSports
+ self.isFavorite = isFavorite
+ self.isAiringNow = isAiringNow
+ self.sortBy = sortBy
+ self.sortOrder = sortOrder
+ self.startIndex = startIndex
+ self.limit = limit
+ }
+
+ public static let `default` = LiveTvChannelFilters()
+ public static let favorites = LiveTvChannelFilters(isFavorite: true)
+ public static let movies = LiveTvChannelFilters(isMovie: true)
+ public static let sports = LiveTvChannelFilters(isSports: true)
+ public static let news = LiveTvChannelFilters(isNews: true)
+ public static let kids = LiveTvChannelFilters(isKids: true)
+}
+
+/// Filters for `GET /LiveTv/Programs` and `/LiveTv/Programs/Recommended`.
+public struct LiveTvProgramFilters: Sendable, Equatable {
+ public var isAiring: Bool?
+ public var hasAired: Bool?
+ public var isMovie: Bool?
+ public var isSeries: Bool?
+ public var isNews: Bool?
+ public var isKids: Bool?
+ public var isSports: Bool?
+ public var genres: [String]?
+ public var sortBy: [String]?
+ public var sortOrder: String?
+ public var limit: Int?
+
+ public init(
+ isAiring: Bool? = nil,
+ hasAired: Bool? = nil,
+ isMovie: Bool? = nil,
+ isSeries: Bool? = nil,
+ isNews: Bool? = nil,
+ isKids: Bool? = nil,
+ isSports: Bool? = nil,
+ genres: [String]? = nil,
+ sortBy: [String]? = nil,
+ sortOrder: String? = nil,
+ limit: Int? = nil
+ ) {
+ self.isAiring = isAiring
+ self.hasAired = hasAired
+ self.isMovie = isMovie
+ self.isSeries = isSeries
+ self.isNews = isNews
+ self.isKids = isKids
+ self.isSports = isSports
+ self.genres = genres
+ self.sortBy = sortBy
+ self.sortOrder = sortOrder
+ self.limit = limit
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveTvProgram.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveTvProgram.swift
new file mode 100644
index 0000000..0c43195
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/LiveTvProgram.swift
@@ -0,0 +1,129 @@
+import Foundation
+
+public struct LiveTvProgram: Decodable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let name: String
+ public let channelId: String?
+ public let channelName: String?
+ public let channelNumber: String?
+ public let channelPrimaryImageTag: String?
+ public let overview: String?
+ public let startDate: Date?
+ public let endDate: Date?
+ public let isLive: Bool?
+ public let isNews: Bool?
+ public let isSports: Bool?
+ public let isKids: Bool?
+ public let isMovie: Bool?
+ public let isSeries: Bool?
+ public let isRepeat: Bool?
+ public let isPremiere: Bool?
+ public let episodeTitle: String?
+ public let seriesName: String?
+ public let productionYear: Int?
+ public let genres: [String]?
+ public let communityRating: Double?
+ public let officialRating: String?
+ public let imageTags: [String: String]?
+ public let runTimeTicks: Int64?
+
+ public init(
+ id: String,
+ name: String,
+ channelId: String? = nil,
+ channelName: String? = nil,
+ channelNumber: String? = nil,
+ channelPrimaryImageTag: String? = nil,
+ overview: String? = nil,
+ startDate: Date? = nil,
+ endDate: Date? = nil,
+ isLive: Bool? = nil,
+ isNews: Bool? = nil,
+ isSports: Bool? = nil,
+ isKids: Bool? = nil,
+ isMovie: Bool? = nil,
+ isSeries: Bool? = nil,
+ isRepeat: Bool? = nil,
+ isPremiere: Bool? = nil,
+ episodeTitle: String? = nil,
+ seriesName: String? = nil,
+ productionYear: Int? = nil,
+ genres: [String]? = nil,
+ communityRating: Double? = nil,
+ officialRating: String? = nil,
+ imageTags: [String: String]? = nil,
+ runTimeTicks: Int64? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.channelId = channelId
+ self.channelName = channelName
+ self.channelNumber = channelNumber
+ self.channelPrimaryImageTag = channelPrimaryImageTag
+ self.overview = overview
+ self.startDate = startDate
+ self.endDate = endDate
+ self.isLive = isLive
+ self.isNews = isNews
+ self.isSports = isSports
+ self.isKids = isKids
+ self.isMovie = isMovie
+ self.isSeries = isSeries
+ self.isRepeat = isRepeat
+ self.isPremiere = isPremiere
+ self.episodeTitle = episodeTitle
+ self.seriesName = seriesName
+ self.productionYear = productionYear
+ self.genres = genres
+ self.communityRating = communityRating
+ self.officialRating = officialRating
+ self.imageTags = imageTags
+ self.runTimeTicks = runTimeTicks
+ }
+
+ public var primaryImageTag: String? { imageTags?["Primary"] }
+ public var thumbImageTag: String? { imageTags?["Thumb"] }
+
+ enum CodingKeys: String, CodingKey {
+ case id = "Id"
+ case name = "Name"
+ case channelId = "ChannelId"
+ case channelName = "ChannelName"
+ case channelNumber = "ChannelNumber"
+ case channelPrimaryImageTag = "ChannelPrimaryImageTag"
+ case overview = "Overview"
+ case startDate = "StartDate"
+ case endDate = "EndDate"
+ case isLive = "IsLive"
+ case isNews = "IsNews"
+ case isSports = "IsSports"
+ case isKids = "IsKids"
+ case isMovie = "IsMovie"
+ case isSeries = "IsSeries"
+ case isRepeat = "IsRepeat"
+ case isPremiere = "IsPremiere"
+ case episodeTitle = "EpisodeTitle"
+ case seriesName = "SeriesName"
+ case productionYear = "ProductionYear"
+ case genres = "Genres"
+ case communityRating = "CommunityRating"
+ case officialRating = "OfficialRating"
+ case imageTags = "ImageTags"
+ case runTimeTicks = "RunTimeTicks"
+ }
+}
+
+public struct LiveTvProgramQueryResult: Decodable, Sendable, Equatable {
+ public let items: [LiveTvProgram]
+ public let totalRecordCount: Int?
+
+ public init(items: [LiveTvProgram], totalRecordCount: Int? = nil) {
+ self.items = items
+ self.totalRecordCount = totalRecordCount
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case items = "Items"
+ case totalRecordCount = "TotalRecordCount"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/ProblemDetails.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/ProblemDetails.swift
new file mode 100644
index 0000000..55182bb
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/ProblemDetails.swift
@@ -0,0 +1,23 @@
+// RFC 7807 error envelope returned by Jellyfin for 4xx responses.
+// Wire keys are lowercase — no CodingKeys needed since Swift property names match.
+public struct ProblemDetails: Decodable, Sendable, Equatable {
+ public let type: String?
+ public let title: String?
+ public let status: Int?
+ public let detail: String?
+ public let instance: String?
+
+ public init(
+ type: String? = nil,
+ title: String? = nil,
+ status: Int? = nil,
+ detail: String? = nil,
+ instance: String? = nil
+ ) {
+ self.type = type
+ self.title = title
+ self.status = status
+ self.detail = detail
+ self.instance = instance
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/PublicSystemInfo.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/PublicSystemInfo.swift
new file mode 100644
index 0000000..6396900
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/PublicSystemInfo.swift
@@ -0,0 +1,33 @@
+public struct PublicSystemInfo: Decodable, Sendable, Equatable {
+ public let serverName: String?
+ public let version: String?
+ public let id: String?
+ public let productName: String?
+ public let localAddress: String?
+ public let startupWizardCompleted: Bool?
+
+ public init(
+ serverName: String? = nil,
+ version: String? = nil,
+ id: String? = nil,
+ productName: String? = nil,
+ localAddress: String? = nil,
+ startupWizardCompleted: Bool? = nil
+ ) {
+ self.serverName = serverName
+ self.version = version
+ self.id = id
+ self.productName = productName
+ self.localAddress = localAddress
+ self.startupWizardCompleted = startupWizardCompleted
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case serverName = "ServerName"
+ case version = "Version"
+ case id = "Id"
+ case productName = "ProductName"
+ case localAddress = "LocalAddress"
+ case startupWizardCompleted = "StartupWizardCompleted"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/QuickConnectAuthRequest.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/QuickConnectAuthRequest.swift
new file mode 100644
index 0000000..5a24f81
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/QuickConnectAuthRequest.swift
@@ -0,0 +1,11 @@
+public struct QuickConnectAuthRequest: Encodable, Sendable {
+ public let secret: String
+
+ public init(secret: String) {
+ self.secret = secret
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case secret = "Secret"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/QuickConnectResult.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/QuickConnectResult.swift
new file mode 100644
index 0000000..5e26a05
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/QuickConnectResult.swift
@@ -0,0 +1,43 @@
+import Foundation
+
+public struct QuickConnectResult: Decodable, Sendable, Equatable {
+ public let authenticated: Bool
+ public let secret: String
+ public let code: String
+ public let deviceId: String?
+ public let deviceName: String?
+ public let appName: String?
+ public let appVersion: String?
+ public let dateAdded: Date?
+
+ public init(
+ authenticated: Bool,
+ secret: String,
+ code: String,
+ deviceId: String? = nil,
+ deviceName: String? = nil,
+ appName: String? = nil,
+ appVersion: String? = nil,
+ dateAdded: Date? = nil
+ ) {
+ self.authenticated = authenticated
+ self.secret = secret
+ self.code = code
+ self.deviceId = deviceId
+ self.deviceName = deviceName
+ self.appName = appName
+ self.appVersion = appVersion
+ self.dateAdded = dateAdded
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case authenticated = "Authenticated"
+ case secret = "Secret"
+ case code = "Code"
+ case deviceId = "DeviceId"
+ case deviceName = "DeviceName"
+ case appName = "AppName"
+ case appVersion = "AppVersion"
+ case dateAdded = "DateAdded"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/SeriesTimerInfoDto.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/SeriesTimerInfoDto.swift
new file mode 100644
index 0000000..f974ffe
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/SeriesTimerInfoDto.swift
@@ -0,0 +1,107 @@
+import Foundation
+
+/// `/LiveTv/SeriesTimers` entry — a recurring (series) recording rule.
+public struct SeriesTimerInfoDto: Decodable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let serverId: String?
+ public let channelId: String?
+ public let channelName: String?
+ public let channelPrimaryImageTag: String?
+ public let programId: String?
+ public let name: String?
+ public let overview: String?
+ public let startDate: Date?
+ public let endDate: Date?
+ public let serviceName: String?
+ public let priority: Int?
+ public let prePaddingSeconds: Int?
+ public let postPaddingSeconds: Int?
+ public let recordAnyTime: Bool?
+ public let recordAnyChannel: Bool?
+ public let recordNewOnly: Bool?
+ public let skipEpisodesInLibrary: Bool?
+ public let keepUpTo: Int?
+ public let days: [String]?
+
+ public init(
+ id: String,
+ serverId: String? = nil,
+ channelId: String? = nil,
+ channelName: String? = nil,
+ channelPrimaryImageTag: String? = nil,
+ programId: String? = nil,
+ name: String? = nil,
+ overview: String? = nil,
+ startDate: Date? = nil,
+ endDate: Date? = nil,
+ serviceName: String? = nil,
+ priority: Int? = nil,
+ prePaddingSeconds: Int? = nil,
+ postPaddingSeconds: Int? = nil,
+ recordAnyTime: Bool? = nil,
+ recordAnyChannel: Bool? = nil,
+ recordNewOnly: Bool? = nil,
+ skipEpisodesInLibrary: Bool? = nil,
+ keepUpTo: Int? = nil,
+ days: [String]? = nil
+ ) {
+ self.id = id
+ self.serverId = serverId
+ self.channelId = channelId
+ self.channelName = channelName
+ self.channelPrimaryImageTag = channelPrimaryImageTag
+ self.programId = programId
+ self.name = name
+ self.overview = overview
+ self.startDate = startDate
+ self.endDate = endDate
+ self.serviceName = serviceName
+ self.priority = priority
+ self.prePaddingSeconds = prePaddingSeconds
+ self.postPaddingSeconds = postPaddingSeconds
+ self.recordAnyTime = recordAnyTime
+ self.recordAnyChannel = recordAnyChannel
+ self.recordNewOnly = recordNewOnly
+ self.skipEpisodesInLibrary = skipEpisodesInLibrary
+ self.keepUpTo = keepUpTo
+ self.days = days
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case id = "Id"
+ case serverId = "ServerId"
+ case channelId = "ChannelId"
+ case channelName = "ChannelName"
+ case channelPrimaryImageTag = "ChannelPrimaryImageTag"
+ case programId = "ProgramId"
+ case name = "Name"
+ case overview = "Overview"
+ case startDate = "StartDate"
+ case endDate = "EndDate"
+ case serviceName = "ServiceName"
+ case priority = "Priority"
+ case prePaddingSeconds = "PrePaddingSeconds"
+ case postPaddingSeconds = "PostPaddingSeconds"
+ case recordAnyTime = "RecordAnyTime"
+ case recordAnyChannel = "RecordAnyChannel"
+ case recordNewOnly = "RecordNewOnly"
+ case skipEpisodesInLibrary = "SkipEpisodesInLibrary"
+ case keepUpTo = "KeepUpTo"
+ case days = "Days"
+ }
+}
+
+public struct SeriesTimerInfoDtoQueryResult: Decodable, Sendable, Equatable {
+ public let items: [SeriesTimerInfoDto]
+ public let totalRecordCount: Int?
+
+ public init(items: [SeriesTimerInfoDto], totalRecordCount: Int? = nil) {
+ self.items = items
+ self.totalRecordCount = totalRecordCount
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case items = "Items"
+ case totalRecordCount = "TotalRecordCount"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/SessionInfoDto.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/SessionInfoDto.swift
new file mode 100644
index 0000000..3f65c4b
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/SessionInfoDto.swift
@@ -0,0 +1,29 @@
+public struct SessionInfoDto: Decodable, Sendable, Equatable {
+ public let id: String?
+ public let userId: String?
+ public let userName: String?
+ public let deviceId: String?
+ public let deviceName: String?
+
+ public init(
+ id: String? = nil,
+ userId: String? = nil,
+ userName: String? = nil,
+ deviceId: String? = nil,
+ deviceName: String? = nil
+ ) {
+ self.id = id
+ self.userId = userId
+ self.userName = userName
+ self.deviceId = deviceId
+ self.deviceName = deviceName
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case id = "Id"
+ case userId = "UserId"
+ case userName = "UserName"
+ case deviceId = "DeviceId"
+ case deviceName = "DeviceName"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/TimerInfoDto.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/TimerInfoDto.swift
new file mode 100644
index 0000000..dd1addc
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/TimerInfoDto.swift
@@ -0,0 +1,95 @@
+import Foundation
+
+/// `/LiveTv/Timers` entry — a one-shot scheduled recording.
+public struct TimerInfoDto: Decodable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let serverId: String?
+ public let channelId: String?
+ public let channelName: String?
+ public let channelPrimaryImageTag: String?
+ public let programId: String?
+ public let name: String?
+ public let overview: String?
+ public let startDate: Date?
+ public let endDate: Date?
+ public let serviceName: String?
+ public let priority: Int?
+ public let prePaddingSeconds: Int?
+ public let postPaddingSeconds: Int?
+ public let status: String?
+ public let seriesTimerId: String?
+ public let runTimeTicks: Int64?
+
+ public init(
+ id: String,
+ serverId: String? = nil,
+ channelId: String? = nil,
+ channelName: String? = nil,
+ channelPrimaryImageTag: String? = nil,
+ programId: String? = nil,
+ name: String? = nil,
+ overview: String? = nil,
+ startDate: Date? = nil,
+ endDate: Date? = nil,
+ serviceName: String? = nil,
+ priority: Int? = nil,
+ prePaddingSeconds: Int? = nil,
+ postPaddingSeconds: Int? = nil,
+ status: String? = nil,
+ seriesTimerId: String? = nil,
+ runTimeTicks: Int64? = nil
+ ) {
+ self.id = id
+ self.serverId = serverId
+ self.channelId = channelId
+ self.channelName = channelName
+ self.channelPrimaryImageTag = channelPrimaryImageTag
+ self.programId = programId
+ self.name = name
+ self.overview = overview
+ self.startDate = startDate
+ self.endDate = endDate
+ self.serviceName = serviceName
+ self.priority = priority
+ self.prePaddingSeconds = prePaddingSeconds
+ self.postPaddingSeconds = postPaddingSeconds
+ self.status = status
+ self.seriesTimerId = seriesTimerId
+ self.runTimeTicks = runTimeTicks
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case id = "Id"
+ case serverId = "ServerId"
+ case channelId = "ChannelId"
+ case channelName = "ChannelName"
+ case channelPrimaryImageTag = "ChannelPrimaryImageTag"
+ case programId = "ProgramId"
+ case name = "Name"
+ case overview = "Overview"
+ case startDate = "StartDate"
+ case endDate = "EndDate"
+ case serviceName = "ServiceName"
+ case priority = "Priority"
+ case prePaddingSeconds = "PrePaddingSeconds"
+ case postPaddingSeconds = "PostPaddingSeconds"
+ case status = "Status"
+ case seriesTimerId = "SeriesTimerId"
+ case runTimeTicks = "RunTimeTicks"
+ }
+}
+
+public struct TimerInfoDtoQueryResult: Decodable, Sendable, Equatable {
+ public let items: [TimerInfoDto]
+ public let totalRecordCount: Int?
+
+ public init(items: [TimerInfoDto], totalRecordCount: Int? = nil) {
+ self.items = items
+ self.totalRecordCount = totalRecordCount
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case items = "Items"
+ case totalRecordCount = "TotalRecordCount"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/UserDto.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/UserDto.swift
new file mode 100644
index 0000000..9037ec9
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/UserDto.swift
@@ -0,0 +1,43 @@
+import Foundation
+
+public struct UserDto: Decodable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let name: String
+ public let serverId: String?
+ public let primaryImageTag: String?
+ public let hasPassword: Bool?
+ public let hasConfiguredPassword: Bool?
+ public let lastLoginDate: Date?
+ public let lastActivityDate: Date?
+
+ public init(
+ id: String,
+ name: String,
+ serverId: String? = nil,
+ primaryImageTag: String? = nil,
+ hasPassword: Bool? = nil,
+ hasConfiguredPassword: Bool? = nil,
+ lastLoginDate: Date? = nil,
+ lastActivityDate: Date? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.serverId = serverId
+ self.primaryImageTag = primaryImageTag
+ self.hasPassword = hasPassword
+ self.hasConfiguredPassword = hasConfiguredPassword
+ self.lastLoginDate = lastLoginDate
+ self.lastActivityDate = lastActivityDate
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case id = "Id"
+ case name = "Name"
+ case serverId = "ServerId"
+ case primaryImageTag = "PrimaryImageTag"
+ case hasPassword = "HasPassword"
+ case hasConfiguredPassword = "HasConfiguredPassword"
+ case lastLoginDate = "LastLoginDate"
+ case lastActivityDate = "LastActivityDate"
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/UserItemDataDto.swift b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/UserItemDataDto.swift
new file mode 100644
index 0000000..e1fa054
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Sources/JellyfinAPI/Models/UserItemDataDto.swift
@@ -0,0 +1,43 @@
+import Foundation
+
+public struct UserItemDataDto: Decodable, Sendable, Equatable {
+ public let playbackPositionTicks: Int64?
+ public let played: Bool?
+ public let playedPercentage: Double?
+ public let isFavorite: Bool?
+ public let like: Bool?
+ public let lastPlayedDate: Date?
+ public let playCount: Int?
+ public let repeatMode: String?
+
+ public init(
+ playbackPositionTicks: Int64? = nil,
+ played: Bool? = nil,
+ playedPercentage: Double? = nil,
+ isFavorite: Bool? = nil,
+ like: Bool? = nil,
+ lastPlayedDate: Date? = nil,
+ playCount: Int? = nil,
+ repeatMode: String? = nil
+ ) {
+ self.playbackPositionTicks = playbackPositionTicks
+ self.played = played
+ self.playedPercentage = playedPercentage
+ self.isFavorite = isFavorite
+ self.like = like
+ self.lastPlayedDate = lastPlayedDate
+ self.playCount = playCount
+ self.repeatMode = repeatMode
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case playbackPositionTicks = "PlaybackPositionTicks"
+ case played = "Played"
+ case playedPercentage = "PlayedPercentage"
+ case isFavorite = "IsFavorite"
+ case like = "Like"
+ case lastPlayedDate = "LastPlayedDate"
+ case playCount = "PlayCount"
+ case repeatMode = "RepeatMode"
+ }
+}
\ No newline at end of file
diff --git a/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/DTODecodingTests.swift b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/DTODecodingTests.swift
new file mode 100644
index 0000000..f5b783b
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/DTODecodingTests.swift
@@ -0,0 +1,168 @@
+import Testing
+import Foundation
+@testable import JellyfinAPI
+
+@Suite("DTO Decoding")
+struct DTODecodingTests {
+
+ private var decoder: JSONDecoder {
+ let d = JSONDecoder()
+ d.dateDecodingStrategy = .iso8601
+ return d
+ }
+
+ @Test func decodesPublicSystemInfo() throws {
+ let json = """
+ {
+ "ServerName": "My Jellyfin Server",
+ "Version": "10.11.8",
+ "Id": "abc123",
+ "ProductName": "Jellyfin Server",
+ "LocalAddress": "http://192.168.1.50:8096",
+ "StartupWizardCompleted": true
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let info = try decoder.decode(PublicSystemInfo.self, from: data)
+ #expect(info.serverName == "My Jellyfin Server")
+ #expect(info.version == "10.11.8")
+ #expect(info.id == "abc123")
+ #expect(info.productName == "Jellyfin Server")
+ #expect(info.localAddress == "http://192.168.1.50:8096")
+ #expect(info.startupWizardCompleted == true)
+ }
+
+ @Test func decodesAuthenticationResult() throws {
+ let json = """
+ {
+ "User": {
+ "Id": "user-001",
+ "Name": "alice",
+ "ServerId": "srv-001",
+ "HasPassword": true,
+ "HasConfiguredPassword": true
+ },
+ "SessionInfo": {
+ "Id": "session-001",
+ "UserId": "user-001",
+ "UserName": "alice",
+ "DeviceId": "device-001",
+ "DeviceName": "Apple TV"
+ },
+ "AccessToken": "tok-abc",
+ "ServerId": "srv-001"
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let result = try decoder.decode(AuthenticationResult.self, from: data)
+ #expect(result.accessToken == "tok-abc")
+ #expect(result.serverId == "srv-001")
+ #expect(result.user.id == "user-001")
+ #expect(result.user.name == "alice")
+ #expect(result.sessionInfo?.deviceName == "Apple TV")
+ }
+
+ @Test func decodesQuickConnectResult() throws {
+ let json = """
+ {
+ "Authenticated": false,
+ "Secret": "secret-xyz",
+ "Code": "ABC123",
+ "DeviceId": "device-001",
+ "DeviceName": "Apple TV",
+ "AppName": "JellyTV",
+ "AppVersion": "1.0"
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let result = try decoder.decode(QuickConnectResult.self, from: data)
+ #expect(result.authenticated == false)
+ #expect(result.secret == "secret-xyz")
+ #expect(result.code == "ABC123")
+ #expect(result.deviceId == "device-001")
+ #expect(result.appName == "JellyTV")
+ #expect(result.dateAdded == nil)
+ }
+
+ @Test func decodesQuickConnectResultWithDateAdded() throws {
+ let isoString = "2024-03-15T10:30:00.0000000Z"
+ let json = """
+ {
+ "Authenticated": true,
+ "Secret": "secret-xyz",
+ "Code": "ABC123",
+ "DateAdded": "\(isoString)"
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let result = try decoder.decode(QuickConnectResult.self, from: data)
+ #expect(result.authenticated == true)
+ #expect(result.dateAdded != nil)
+
+ // Verify the date round-trips through iso8601 correctly
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ let expectedDate = try #require(formatter.date(from: isoString))
+ #expect(result.dateAdded == expectedDate)
+ }
+
+ @Test func decodesProblemDetails() throws {
+ let json = """
+ {
+ "type": "about:blank",
+ "title": "Bad Request",
+ "status": 400,
+ "detail": "Missing field"
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let problem = try decoder.decode(ProblemDetails.self, from: data)
+ #expect(problem.type == "about:blank")
+ #expect(problem.title == "Bad Request")
+ #expect(problem.status == 400)
+ #expect(problem.detail == "Missing field")
+ #expect(problem.instance == nil)
+ }
+
+ @Test func encodesAuthenticationRequestWithPwField() throws {
+ let request = AuthenticationRequest(username: "alice", pw: "secret")
+ let encoder = JSONEncoder()
+ let data = try encoder.encode(request)
+ let dict = try JSONDecoder().decode([String: String].self, from: data)
+ #expect(dict["Pw"] == "secret")
+ #expect(dict["Username"] == "alice")
+ #expect(dict["Password"] == nil)
+ }
+
+ @Test func encodesQuickConnectAuthRequest() throws {
+ let request = QuickConnectAuthRequest(secret: "abc-secret")
+ let encoder = JSONEncoder()
+ let data = try encoder.encode(request)
+ let dict = try JSONDecoder().decode([String: String].self, from: data)
+ #expect(dict["Secret"] == "abc-secret")
+ }
+
+ @Test func decodesUserDtoIncludingNewFields() throws {
+ let json = """
+ {
+ "Id": "user-002",
+ "Name": "bob",
+ "ServerId": "srv-002",
+ "PrimaryImageTag": "img-tag-001",
+ "HasPassword": true,
+ "HasConfiguredPassword": false,
+ "LastLoginDate": "2024-01-10T08:00:00.0000000Z",
+ "LastActivityDate": "2024-01-10T09:00:00.0000000Z"
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let user = try decoder.decode(UserDto.self, from: data)
+ #expect(user.id == "user-002")
+ #expect(user.name == "bob")
+ #expect(user.primaryImageTag == "img-tag-001")
+ #expect(user.hasPassword == true)
+ #expect(user.hasConfiguredPassword == false)
+ #expect(user.lastLoginDate != nil)
+ #expect(user.lastActivityDate != nil)
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientLiveTvExtendedTests.swift b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientLiveTvExtendedTests.swift
new file mode 100644
index 0000000..388e360
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientLiveTvExtendedTests.swift
@@ -0,0 +1,268 @@
+import Testing
+import Foundation
+@testable import JellyfinAPI
+
+/// Dedicated stub class so this suite's static handler doesn't race with
+/// other suites (Swift Testing parallelizes across suites).
+final class LiveTvExtendedStubURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var handler: ((URLRequest) -> (HTTPURLResponse, Data))?
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ guard let handler = LiveTvExtendedStubURLProtocol.handler else {
+ fatalError("LiveTvExtendedStubURLProtocol.handler not set")
+ }
+ let (response, data) = handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+
+ override func stopLoading() {}
+}
+
+@Suite("JellyfinClient LiveTV Extended", .serialized)
+struct JellyfinClientLiveTvExtendedTests {
+
+ private let serverURL = URL(string: "http://192.168.1.50:8096")!
+
+ private func makeStubbedClient(
+ handler: @escaping (URLRequest) -> (HTTPURLResponse, Data)
+ ) -> JellyfinClient {
+ LiveTvExtendedStubURLProtocol.handler = handler
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [LiveTvExtendedStubURLProtocol.self]
+ let session = URLSession(configuration: config)
+ return JellyfinClient(
+ deviceId: "test-device-id",
+ clientName: "JellyTV",
+ clientVersion: "1.0",
+ deviceName: "Apple TV",
+ session: session
+ )
+ }
+
+ private func ok(_ json: String, url: URL) -> (HTTPURLResponse, Data) {
+ (HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, Data(json.utf8))
+ }
+
+ private func okData(_ data: Data, url: URL) -> (HTTPURLResponse, Data) {
+ (HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, data)
+ }
+
+ // MARK: - Channels with filters
+
+ @Test func liveTvChannelsWithFiltersIncludesFlags() async throws {
+ var capturedURL: URL?
+ let client = makeStubbedClient { request in
+ capturedURL = request.url
+ return self.ok("""
+ { "Items": [], "TotalRecordCount": 0 }
+ """, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ _ = try await client.liveTvChannels(
+ filters: LiveTvChannelFilters(
+ isMovie: nil,
+ isSports: true,
+ isFavorite: true,
+ isAiringNow: true,
+ limit: 50
+ ),
+ addCurrentProgram: true
+ )
+
+ let url = try #require(capturedURL)
+ #expect(url.path == "/LiveTv/Channels")
+ let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let nameMap: [String: String] = Dictionary(uniqueKeysWithValues: items.compactMap { item in
+ guard let v = item.value else { return nil }
+ return (item.name, v)
+ })
+ #expect(nameMap["isSports"] == "true")
+ #expect(nameMap["isFavorite"] == "true")
+ #expect(nameMap["isAiring"] == "true")
+ #expect(nameMap["addCurrentProgram"] == "true")
+ #expect(nameMap["limit"] == "50")
+ #expect(nameMap["enableImages"] == "true")
+ }
+
+ // MARK: - Recommended Programs
+
+ @Test func liveTvRecommendedProgramsHitsCorrectPath() async throws {
+ var capturedURL: URL?
+ let client = makeStubbedClient { request in
+ capturedURL = request.url
+ return self.ok("""
+ { "Items": [
+ { "Id": "p1", "Name": "Up Next" }
+ ] }
+ """, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let programs = try await client.liveTvRecommendedPrograms(
+ filters: LiveTvProgramFilters(hasAired: false, isMovie: true, limit: 12)
+ )
+ #expect(programs.count == 1)
+ #expect(programs[0].name == "Up Next")
+
+ let url = try #require(capturedURL)
+ #expect(url.path == "/LiveTv/Programs/Recommended")
+ let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let nameMap: [String: String] = Dictionary(uniqueKeysWithValues: items.compactMap { item in
+ guard let v = item.value else { return nil }
+ return (item.name, v)
+ })
+ #expect(nameMap["isMovie"] == "true")
+ #expect(nameMap["hasAired"] == "false")
+ #expect(nameMap["limit"] == "12")
+ }
+
+ // MARK: - Recordings
+
+ @Test func liveTvRecordingsInProgress() async throws {
+ var capturedURL: URL?
+ let client = makeStubbedClient { request in
+ capturedURL = request.url
+ return self.ok("""
+ { "Items": [
+ { "Id": "rec-1", "Name": "Recording" }
+ ] }
+ """, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let recordings = try await client.liveTvRecordings(isInProgress: true, seriesTimerId: nil, limit: 10)
+ #expect(recordings.count == 1)
+ #expect(recordings[0].id == "rec-1")
+
+ let url = try #require(capturedURL)
+ #expect(url.path == "/LiveTv/Recordings")
+ let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let nameMap: [String: String] = Dictionary(uniqueKeysWithValues: items.compactMap { item in
+ guard let v = item.value else { return nil }
+ return (item.name, v)
+ })
+ #expect(nameMap["isInProgress"] == "true")
+ #expect(nameMap["limit"] == "10")
+ }
+
+ @Test func deleteLiveTvRecordingHitsDeletePath() async throws {
+ var capturedURL: URL?
+ var capturedMethod: String?
+ let client = makeStubbedClient { request in
+ capturedURL = request.url
+ capturedMethod = request.httpMethod
+ return self.okData(Data(), url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ try await client.deleteLiveTvRecording(recordingId: "rec-42")
+
+ let url = try #require(capturedURL)
+ #expect(url.path == "/LiveTv/Recordings/rec-42")
+ #expect(capturedMethod == "DELETE")
+ }
+
+ // MARK: - Timers
+
+ @Test func liveTvTimersDecodesItems() async throws {
+ let client = makeStubbedClient { request in
+ self.ok("""
+ { "Items": [
+ { "Id": "t1", "Name": "Game", "ChannelId": "ch-1", "Status": "New" },
+ { "Id": "t2", "Name": "News", "ChannelId": "ch-2" }
+ ] }
+ """, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let timers = try await client.liveTvTimers()
+ #expect(timers.count == 2)
+ #expect(timers[0].id == "t1")
+ #expect(timers[0].status == "New")
+ #expect(timers[1].channelId == "ch-2")
+ }
+
+ @Test func liveTvSeriesTimersDecodesItems() async throws {
+ let client = makeStubbedClient { request in
+ self.ok("""
+ { "Items": [
+ { "Id": "s1", "Name": "Series A", "RecordNewOnly": true }
+ ] }
+ """, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let timers = try await client.liveTvSeriesTimers()
+ #expect(timers.count == 1)
+ #expect(timers[0].id == "s1")
+ #expect(timers[0].recordNewOnly == true)
+ }
+
+ @Test func cancelLiveTvTimerHitsDelete() async throws {
+ var capturedURL: URL?
+ var capturedMethod: String?
+ let client = makeStubbedClient { request in
+ capturedURL = request.url
+ capturedMethod = request.httpMethod
+ return self.okData(Data(), url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ try await client.cancelLiveTvTimer(timerId: "t-99")
+ let url = try #require(capturedURL)
+ #expect(url.path == "/LiveTv/Timers/t-99")
+ #expect(capturedMethod == "DELETE")
+ }
+
+ @Test func cancelSeriesTimerHitsDelete() async throws {
+ var capturedURL: URL?
+ var capturedMethod: String?
+ let client = makeStubbedClient { request in
+ capturedURL = request.url
+ capturedMethod = request.httpMethod
+ return self.okData(Data(), url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ try await client.cancelLiveTvSeriesTimer(timerId: "s-99")
+ let url = try #require(capturedURL)
+ #expect(url.path == "/LiveTv/SeriesTimers/s-99")
+ #expect(capturedMethod == "DELETE")
+ }
+
+ @Test func liveTvTimerDefaultsRoundTripsBody() async throws {
+ let payload = #"{"ChannelId":"ch-1","ProgramId":"prog-1"}"#
+ let client = makeStubbedClient { request in
+ self.ok(payload, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let body = try await client.liveTvTimerDefaults(programId: "prog-1")
+ #expect(body == Data(payload.utf8))
+ }
+
+ // MARK: - Favorites
+
+ @Test func setFavoriteTrueIsPost() async throws {
+ var capturedURL: URL?
+ var capturedMethod: String?
+ let client = makeStubbedClient { request in
+ capturedURL = request.url
+ capturedMethod = request.httpMethod
+ return self.okData(Data(), url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ try await client.setFavorite(itemId: "ch-7", isFavorite: true)
+ let url = try #require(capturedURL)
+ #expect(url.path == "/UserFavoriteItems/ch-7")
+ #expect(capturedMethod == "POST")
+ }
+
+ @Test func setFavoriteFalseIsDelete() async throws {
+ var capturedMethod: String?
+ let client = makeStubbedClient { request in
+ capturedMethod = request.httpMethod
+ return self.okData(Data(), url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ try await client.setFavorite(itemId: "ch-7", isFavorite: false)
+ #expect(capturedMethod == "DELETE")
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientLiveTvStreamTests.swift b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientLiveTvStreamTests.swift
new file mode 100644
index 0000000..be6fbe0
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientLiveTvStreamTests.swift
@@ -0,0 +1,370 @@
+import Testing
+import Foundation
+@testable import JellyfinAPI
+
+// Dedicated stub class so this suite's static handler doesn't race with the
+// shared URL protocols used by other suites (Swift Testing runs different
+// suites in parallel — `.serialized` only orders within a suite).
+final class LiveTvStreamStubURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var handler: ((URLRequest) -> (HTTPURLResponse, Data))?
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ guard let handler = LiveTvStreamStubURLProtocol.handler else {
+ fatalError("LiveTvStreamStubURLProtocol.handler not set")
+ }
+ let (response, data) = handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+
+ override func stopLoading() {}
+}
+
+@Suite("JellyfinClient LiveTV Stream", .serialized)
+struct JellyfinClientLiveTvStreamTests {
+
+ private let serverURL = URL(string: "http://192.168.1.50:8096")!
+
+ private func makeStubbedClient(
+ handler: @escaping (URLRequest) -> (HTTPURLResponse, Data)
+ ) -> JellyfinClient {
+ LiveTvStreamStubURLProtocol.handler = handler
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [LiveTvStreamStubURLProtocol.self]
+ let session = URLSession(configuration: config)
+ return JellyfinClient(
+ deviceId: "test-device-id",
+ clientName: "JellyTV",
+ clientVersion: "1.0",
+ deviceName: "Apple TV",
+ session: session
+ )
+ }
+
+ private func ok(_ json: String, url: URL) -> (HTTPURLResponse, Data) {
+ (HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, Data(json.utf8))
+ }
+
+ private func status(_ code: Int, url: URL) -> (HTTPURLResponse, Data) {
+ (HTTPURLResponse(url: url, statusCode: code, httpVersion: "HTTP/1.1", headerFields: nil)!, Data())
+ }
+
+ /// Reads body from `httpBodyStream` (URLSession moves `httpBody` →
+ /// `httpBodyStream` for intercepted requests, mirroring the pattern in
+ /// JellyfinClientTests.swift:186-200).
+ private func readBody(_ request: URLRequest) -> Data? {
+ if let stream = request.httpBodyStream {
+ stream.open()
+ var data = Data()
+ let bufferSize = 1024
+ let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
+ defer { buffer.deallocate() }
+ while stream.hasBytesAvailable {
+ let bytesRead = stream.read(buffer, maxLength: bufferSize)
+ if bytesRead > 0 {
+ data.append(buffer, count: bytesRead)
+ }
+ }
+ stream.close()
+ return data
+ }
+ return request.httpBody
+ }
+
+ private let userMeJSON = """
+ {
+ "Id": "user-001",
+ "Name": "alice",
+ "ServerId": "srv-001",
+ "HasPassword": true,
+ "HasConfiguredPassword": true
+ }
+ """
+
+ private let transcodingResponseJSON = """
+ {
+ "MediaSource": {
+ "Id": "src-001",
+ "TranscodingUrl": "/videos/abc/master.m3u8?&DeviceId=test&MediaSourceId=src-001&LiveStreamId=ls-001&api_key=baked",
+ "Container": "ts",
+ "LiveStreamId": "ls-001",
+ "SupportsTranscoding": true
+ }
+ }
+ """
+
+ /// Fixture for the regression test: simulates a Jellyfin server that
+ /// (despite our HLS-requesting profile) still returns a progressive
+ /// `/stream` TranscodingUrl. The client must NOT silently rewrite this —
+ /// the regression test asserts the path passes through untouched.
+ private let progressiveTranscodingResponseJSON = """
+ {
+ "MediaSource": {
+ "Id": "src-001",
+ "TranscodingUrl": "/videos/abc/stream?&DeviceId=test&MediaSourceId=src-001&LiveStreamId=ls-001&api_key=baked",
+ "Container": "ts",
+ "LiveStreamId": "ls-001",
+ "SupportsTranscoding": true
+ }
+ }
+ """
+
+ private let directStreamResponseJSON = """
+ {
+ "MediaSource": {
+ "Id": "src-002",
+ "Container": "ts",
+ "LiveStreamId": "ls-002"
+ }
+ }
+ """
+
+ private let pluralResponseJSON = """
+ {
+ "MediaSources": [
+ {
+ "Id": "src-003",
+ "Container": "ts"
+ }
+ ]
+ }
+ """
+
+ /// Stub handler that dispatches by path: serves a fixed user from
+ /// `/Users/Me` (so the actor's lazy userId resolution works) and the
+ /// supplied playback JSON from `/Items/.../PlaybackInfo`.
+ private func playbackStub(
+ playbackJSON: String,
+ capture: ((URLRequest) -> Void)? = nil
+ ) -> (URLRequest) -> (HTTPURLResponse, Data) {
+ return { request in
+ let path = request.url?.path ?? ""
+ if path == "/Users/Me" {
+ return self.ok(self.userMeJSON, url: request.url!)
+ }
+ capture?(request)
+ return self.ok(playbackJSON, url: request.url!)
+ }
+ }
+
+ @Test func liveTvOpenStreamHitsCorrectPathAndMethod() async throws {
+ var capturedRequest: URLRequest?
+ let client = makeStubbedClient(handler: playbackStub(playbackJSON: transcodingResponseJSON) { req in
+ capturedRequest = req
+ })
+ await client.setServerURL(serverURL)
+ await client.setAccessToken("tok-test")
+ _ = try await client.liveTvOpenStream(channelId: "ch-001")
+
+ let req = try #require(capturedRequest)
+ #expect(req.httpMethod == "POST")
+ #expect(req.url?.path == "/Items/ch-001/PlaybackInfo")
+ let query = req.url?.query ?? ""
+ #expect(query.contains("userId=user-001"))
+ #expect(query.contains("autoOpenLiveStream=true"))
+ #expect(query.contains("enableDirectPlay=true"))
+ #expect(query.contains("enableDirectStream=true"))
+ #expect(query.contains("enableTranscoding=true"))
+ #expect(query.contains("allowVideoStreamCopy=true"))
+ #expect(query.contains("allowAudioStreamCopy=true"))
+ }
+
+ @Test func liveTvOpenStreamSendsCorrectJSONBody() async throws {
+ var capturedBody: Data?
+ let client = makeStubbedClient(handler: playbackStub(playbackJSON: transcodingResponseJSON) { req in
+ capturedBody = self.readBody(req)
+ })
+ await client.setServerURL(serverURL)
+ await client.setAccessToken("tok-test")
+ _ = try await client.liveTvOpenStream(channelId: "ch-007")
+
+ let body = try #require(capturedBody)
+ let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
+ // PlaybackInfo body is just { DeviceProfile: ... } — no OpenToken.
+ #expect(json["OpenToken"] == nil)
+ let profile = try #require(json["DeviceProfile"] as? [String: Any])
+ let directProfiles = try #require(profile["DirectPlayProfiles"] as? [[String: Any]])
+ #expect(!directProfiles.isEmpty)
+ #expect(directProfiles[0]["Container"] as? String == "ts,m2ts,mkv,mp4,m4v,mov")
+ let transcodingProfiles = try #require(profile["TranscodingProfiles"] as? [[String: Any]])
+ #expect(!transcodingProfiles.isEmpty)
+ let tp = transcodingProfiles[0]
+ #expect(tp["Protocol"] as? String == "hls")
+ // The HLS transcode profile must request container=ts so Jellyfin
+ // emits a real master.m3u8 URL for live TV (not the progressive
+ // /videos/{id}/stream endpoint).
+ #expect(tp["Container"] as? String == "ts")
+ #expect(tp["VideoCodec"] as? String == "h264,hevc")
+ #expect(tp["AudioCodec"] as? String == "aac,mp3,ac3,eac3")
+ // MinSegments=1 (was 2 in Phase C) lets AVPlayer start as soon as
+ // the first 10s segment is buffered, halving channel-change latency.
+ // Network resilience comes from the player's auto-retry + reconnect
+ // paths, not from a thicker initial buffer.
+ #expect(tp["MinSegments"] as? Int == 1)
+ // Must serialize as JSON bool, not string — Jellyfin's OpenAPI
+ // schema declares this as boolean.
+ #expect(tp["BreakOnNonKeyFrames"] as? Bool == true)
+ }
+
+ @Test func liveTvOpenStreamUsesServerSuppliedHlsTranscodingUrl() async throws {
+ let client = makeStubbedClient(handler: playbackStub(playbackJSON: transcodingResponseJSON))
+ await client.setServerURL(serverURL)
+ await client.setAccessToken("tok-different-from-baked")
+ let playback = try await client.liveTvOpenStream(channelId: "ch-001")
+
+ let urlString = playback.playbackURL.absoluteString
+ // The transcoding URL is resolved against the server, so the host is preserved.
+ #expect(urlString.contains("192.168.1.50:8096"))
+ // The server emits master.m3u8 natively now (driven by the device
+ // profile's container=ts/protocol=hls). The client must honor the
+ // server-supplied path verbatim.
+ #expect(urlString.contains("/videos/abc/master.m3u8"))
+ // Empty leading `?&` from Jellyfin's TranscodingUrl must be stripped —
+ // the cleaned URL starts the query with a real param, not `?&`.
+ #expect(!urlString.contains("master.m3u8?&"))
+ #expect(urlString.contains("master.m3u8?DeviceId=test"))
+ // The `?` must NOT be percent-encoded into `%3F`.
+ #expect(!urlString.contains("%3F"))
+ // `api_key=baked` came from Jellyfin's transcodingUrl. We must not have
+ // appended `tok-different-from-baked` again — count the api_key occurrences.
+ let apiKeyCount = urlString.components(separatedBy: "api_key=").count - 1
+ #expect(apiKeyCount == 1)
+ #expect(urlString.contains("api_key=baked"))
+ #expect(!urlString.contains("api_key=tok-different-from-baked"))
+ // Existing query params must be preserved
+ #expect(urlString.contains("MediaSourceId=src-001"))
+ #expect(urlString.contains("LiveStreamId=ls-001"))
+ #expect(playback.liveStreamId == "ls-001")
+ }
+
+ /// Regression test: pins the behavior change that removed the client-side
+ /// `/stream → /master.m3u8` path rewrite. If the server (despite our
+ /// HLS-requesting profile) ever returns a progressive `/stream` URL, the
+ /// client must pass it through untouched so AVPlayer fails loudly with
+ /// the underlying problem rather than receiving a silently-broken HLS URL.
+ /// The empty-name query cleanup still runs.
+ @Test func liveTvOpenStreamPreservesProgressiveTranscodingUrl() async throws {
+ let client = makeStubbedClient(handler: playbackStub(playbackJSON: progressiveTranscodingResponseJSON))
+ await client.setServerURL(serverURL)
+ await client.setAccessToken("tok-different-from-baked")
+ let playback = try await client.liveTvOpenStream(channelId: "ch-001")
+
+ let urlString = playback.playbackURL.absoluteString
+ // Path must NOT be rewritten — server's /stream stays /stream.
+ #expect(playback.playbackURL.path == "/videos/abc/stream")
+ #expect(!urlString.contains("/master.m3u8"))
+ // Empty-name query cleanup still ran (no `?&` after stream).
+ #expect(!urlString.contains("stream?&"))
+ #expect(urlString.contains("stream?DeviceId=test"))
+ // No double api_key, no percent-encoded `?`.
+ let apiKeyCount = urlString.components(separatedBy: "api_key=").count - 1
+ #expect(apiKeyCount == 1)
+ #expect(urlString.contains("api_key=baked"))
+ #expect(!urlString.contains("%3F"))
+ #expect(playback.liveStreamId == "ls-001")
+ }
+
+ @Test func liveTvOpenStreamFallsBackToDirectStreamUrl() async throws {
+ let client = makeStubbedClient(handler: playbackStub(playbackJSON: directStreamResponseJSON))
+ await client.setServerURL(serverURL)
+ await client.setAccessToken("tok-fallback")
+ let playback = try await client.liveTvOpenStream(channelId: "ch-002")
+
+ let urlString = playback.playbackURL.absoluteString
+ #expect(urlString.contains("192.168.1.50:8096"))
+ #expect(urlString.contains("/Videos/src-002/stream.ts"))
+ #expect(urlString.contains("MediaSourceId=src-002"))
+ #expect(urlString.contains("static=true"))
+ #expect(urlString.contains("api_key=tok-fallback"))
+ #expect(urlString.contains("LiveStreamId=ls-002"))
+ // Exactly one api_key in the URL
+ let apiKeyCount = urlString.components(separatedBy: "api_key=").count - 1
+ #expect(apiKeyCount == 1)
+ // No percent-encoded ?
+ #expect(!urlString.contains("%3F"))
+ #expect(playback.liveStreamId == "ls-002")
+ }
+
+ @Test func liveTvOpenStreamUsesPluralMediaSourcesWhenSingularMissing() async throws {
+ let client = makeStubbedClient(handler: playbackStub(playbackJSON: pluralResponseJSON))
+ await client.setServerURL(serverURL)
+ await client.setAccessToken("tok-plural")
+ let playback = try await client.liveTvOpenStream(channelId: "ch-003")
+
+ let urlString = playback.playbackURL.absoluteString
+ #expect(urlString.contains("/Videos/src-003/stream.ts"))
+ #expect(urlString.contains("api_key=tok-plural"))
+ }
+
+ @Test func liveTvOpenStreamUnauthenticatedWhenTokenMissing() async throws {
+ let client = makeStubbedClient(handler: playbackStub(playbackJSON: transcodingResponseJSON))
+ await client.setServerURL(serverURL)
+ // Do NOT set access token
+ do {
+ _ = try await client.liveTvOpenStream(channelId: "ch-001")
+ Issue.record("Expected unauthenticated to be thrown")
+ } catch JellyfinError.unauthenticated {
+ // expected
+ } catch {
+ Issue.record("Wrong error thrown: \(error)")
+ }
+ }
+
+ @Test func liveTvOpenStream401MapsToError() async throws {
+ let client = makeStubbedClient { request in
+ self.status(401, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ await client.setAccessToken("tok-test")
+ do {
+ _ = try await client.liveTvOpenStream(channelId: "ch-001")
+ Issue.record("Expected unauthenticated to be thrown")
+ } catch JellyfinError.unauthenticated {
+ // expected
+ } catch {
+ Issue.record("Wrong error thrown: \(error)")
+ }
+ }
+
+ /// Phase D: when forceTranscoding=true, enableDirectPlay flips to "false"
+ /// and enableDirectStream also flips. Used by the player as a DirectPlay
+ /// fallback when AVPlayer can't consume the direct-stream URL for live media.
+ @Test func liveTvOpenStreamWithForceTranscodingDisablesDirectPlay() async throws {
+ var capturedURL: URL?
+ let client = makeStubbedClient(handler: playbackStub(playbackJSON: transcodingResponseJSON) { req in
+ capturedURL = req.url
+ })
+ await client.setServerURL(serverURL)
+ await client.setAccessToken("tok-test")
+ _ = try await client.liveTvOpenStream(channelId: "ch-fallback", forceTranscoding: true)
+
+ let url = try #require(capturedURL)
+ let query = url.query ?? ""
+ #expect(query.contains("enableDirectPlay=false"))
+ #expect(query.contains("enableDirectStream=false"))
+ #expect(query.contains("enableTranscoding=true"))
+ }
+
+ /// Phase D: liveTvCloseStream POSTs /LiveStreams/Close?liveStreamId=… so
+ /// Jellyfin can kill the transcoder session immediately on player dismiss.
+ @Test func liveTvCloseStreamSendsLiveStreamIdQueryParam() async throws {
+ var capturedRequest: URLRequest?
+ let client = makeStubbedClient { request in
+ capturedRequest = request
+ return self.ok("", url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ await client.setAccessToken("tok-test")
+ try await client.liveTvCloseStream(liveStreamId: "ls-zzz")
+
+ let req = try #require(capturedRequest)
+ #expect(req.httpMethod == "POST")
+ #expect(req.url?.path == "/LiveStreams/Close")
+ let query = req.url?.query ?? ""
+ #expect(query.contains("liveStreamId=ls-zzz"))
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientLiveTvTests.swift b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientLiveTvTests.swift
new file mode 100644
index 0000000..6588fb8
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientLiveTvTests.swift
@@ -0,0 +1,267 @@
+import Testing
+import Foundation
+@testable import JellyfinAPI
+
+// Dedicated stub class so this suite's static handler doesn't race with the
+// shared `StubURLProtocol` used by `JellyfinClientTests` (Swift Testing runs
+// different suites in parallel — `.serialized` only orders within a suite).
+final class LiveTvStubURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var handler: ((URLRequest) -> (HTTPURLResponse, Data))?
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ guard let handler = LiveTvStubURLProtocol.handler else {
+ fatalError("LiveTvStubURLProtocol.handler not set")
+ }
+ let (response, data) = handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+
+ override func stopLoading() {}
+}
+
+@Suite("JellyfinClient LiveTV", .serialized)
+struct JellyfinClientLiveTvTests {
+
+ private let serverURL = URL(string: "http://192.168.1.50:8096")!
+
+ private func makeStubbedClient(
+ handler: @escaping (URLRequest) -> (HTTPURLResponse, Data)
+ ) -> JellyfinClient {
+ LiveTvStubURLProtocol.handler = handler
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [LiveTvStubURLProtocol.self]
+ let session = URLSession(configuration: config)
+ return JellyfinClient(
+ deviceId: "test-device-id",
+ clientName: "JellyTV",
+ clientVersion: "1.0",
+ deviceName: "Apple TV",
+ session: session
+ )
+ }
+
+ private func ok(_ json: String, url: URL) -> (HTTPURLResponse, Data) {
+ (HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, Data(json.utf8))
+ }
+
+ private func status(_ code: Int, url: URL) -> (HTTPURLResponse, Data) {
+ (HTTPURLResponse(url: url, statusCode: code, httpVersion: "HTTP/1.1", headerFields: nil)!, Data())
+ }
+
+ private let channelsJSON = """
+ {
+ "Items": [
+ { "Id": "ch-001", "Name": "MLB Network", "Number": "215", "ChannelType": "TV" },
+ { "Id": "ch-002", "Name": "ESPN", "Number": "206", "ChannelType": "TV" }
+ ],
+ "TotalRecordCount": 2
+ }
+ """
+
+ private let programsJSON = """
+ {
+ "Items": [
+ {
+ "Id": "prog-001",
+ "Name": "Yankees vs Red Sox",
+ "ChannelId": "ch-001",
+ "StartDate": "2026-04-07T19:00:00.0000000Z",
+ "EndDate": "2026-04-07T22:00:00.0000000Z",
+ "IsLive": true,
+ "IsSports": true
+ },
+ {
+ "Id": "prog-002",
+ "Name": "SportsCenter",
+ "ChannelId": "ch-002",
+ "StartDate": "2026-04-07T19:30:00.0000000Z",
+ "EndDate": "2026-04-07T20:00:00.0000000Z"
+ }
+ ]
+ }
+ """
+
+ // MARK: - Channels
+
+ @Test func liveTvChannelsHitsCorrectPathAndQuery() async throws {
+ var capturedURL: URL?
+ let client = makeStubbedClient { request in
+ capturedURL = request.url
+ return self.ok(self.channelsJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ _ = try await client.liveTvChannels()
+
+ let url = try #require(capturedURL)
+ #expect(url.path == "/LiveTv/Channels")
+ let query = url.query ?? ""
+ #expect(query.contains("enableImages=true"))
+ #expect(query.contains("enableImageTypes=Primary"))
+ #expect(query.contains("sortBy=SortName"))
+ #expect(query.contains("sortOrder=Ascending"))
+ }
+
+ @Test func liveTvChannelsParsesItems() async throws {
+ let client = makeStubbedClient { request in
+ self.ok(self.channelsJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let channels = try await client.liveTvChannels()
+ #expect(channels.count == 2)
+ #expect(channels[0].id == "ch-001")
+ #expect(channels[0].name == "MLB Network")
+ #expect(channels[0].number == "215")
+ #expect(channels[1].id == "ch-002")
+ }
+
+ @Test func liveTvChannelsUnauthorizedMapsToError() async throws {
+ let client = makeStubbedClient { request in
+ self.status(401, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ do {
+ _ = try await client.liveTvChannels()
+ Issue.record("Expected unauthenticated to be thrown")
+ } catch JellyfinError.unauthenticated {
+ // expected
+ } catch {
+ Issue.record("Wrong error thrown: \(error)")
+ }
+ }
+
+ // MARK: - Programs
+
+ @Test func liveTvProgramsHitsCorrectPathWithRepeatedChannelIds() async throws {
+ var capturedURL: URL?
+ let client = makeStubbedClient { request in
+ capturedURL = request.url
+ return self.ok(self.programsJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let start = Date(timeIntervalSince1970: 1_807_300_800) // 2027-04-07T16:00:00Z, just a stable date
+ let end = start.addingTimeInterval(12 * 3600)
+ _ = try await client.liveTvPrograms(
+ channelIds: ["ch-a", "ch-b", "ch-c"],
+ minStartDate: start,
+ maxStartDate: end
+ )
+
+ let url = try #require(capturedURL)
+ #expect(url.path == "/LiveTv/Programs")
+
+ // Use URLComponents to count repeated channelIds
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
+ let items = components?.queryItems ?? []
+ let channelItems = items.filter { $0.name == "channelIds" }
+ #expect(channelItems.count == 3)
+ #expect(channelItems.map(\.value) == ["ch-a", "ch-b", "ch-c"])
+
+ let names = items.map(\.name)
+ #expect(names.contains("minStartDate"))
+ #expect(names.contains("maxStartDate"))
+ #expect(names.contains("sortBy"))
+ #expect(names.contains("sortOrder"))
+ #expect(names.contains("enableImages"))
+ #expect(names.contains("enableTotalRecordCount"))
+ #expect(names.contains("limit"))
+
+ let valueFor: (String) -> String? = { name in items.first(where: { $0.name == name })?.value }
+ #expect(valueFor("sortBy") == "StartDate")
+ #expect(valueFor("sortOrder") == "Ascending")
+ #expect(valueFor("enableImages") == "false")
+ #expect(valueFor("enableTotalRecordCount") == "false")
+ #expect(valueFor("limit") == "2000")
+ #expect(valueFor("fields") == "Overview")
+ }
+
+ @Test func liveTvProgramsEmptyChannelIdsReturnsEmptyWithoutRequest() async throws {
+ var didCallNetwork = false
+ let client = makeStubbedClient { request in
+ didCallNetwork = true
+ return self.ok(self.programsJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let start = Date()
+ let end = start.addingTimeInterval(3600)
+ let programs = try await client.liveTvPrograms(
+ channelIds: [],
+ minStartDate: start,
+ maxStartDate: end
+ )
+ #expect(programs.isEmpty)
+ #expect(didCallNetwork == false)
+ }
+
+ @Test func liveTvProgramsParsesItems() async throws {
+ let client = makeStubbedClient { request in
+ self.ok(self.programsJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let start = Date()
+ let end = start.addingTimeInterval(12 * 3600)
+ let programs = try await client.liveTvPrograms(
+ channelIds: ["ch-001", "ch-002"],
+ minStartDate: start,
+ maxStartDate: end
+ )
+ #expect(programs.count == 2)
+ #expect(programs[0].name == "Yankees vs Red Sox")
+ #expect(programs[0].channelId == "ch-001")
+ #expect(programs[0].isLive == true)
+ #expect(programs[1].channelId == "ch-002")
+ }
+
+ @Test func liveTvProgramsUnauthorizedMapsToError() async throws {
+ let client = makeStubbedClient { request in
+ self.status(401, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ do {
+ _ = try await client.liveTvPrograms(
+ channelIds: ["ch-001"],
+ minStartDate: Date(),
+ maxStartDate: Date().addingTimeInterval(3600)
+ )
+ Issue.record("Expected unauthenticated to be thrown")
+ } catch JellyfinError.unauthenticated {
+ // expected
+ } catch {
+ Issue.record("Wrong error thrown: \(error)")
+ }
+ }
+
+ @Test func liveTvProgramsDateFormatIsISO8601() async throws {
+ var capturedURL: URL?
+ let client = makeStubbedClient { request in
+ capturedURL = request.url
+ return self.ok(self.programsJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ // Stable known date: 2026-04-07 19:00:00 UTC
+ var components = DateComponents()
+ components.year = 2026
+ components.month = 4
+ components.day = 7
+ components.hour = 19
+ components.timeZone = TimeZone(identifier: "UTC")
+ let start = try #require(Calendar(identifier: .gregorian).date(from: components))
+ let end = start.addingTimeInterval(12 * 3600)
+ _ = try await client.liveTvPrograms(
+ channelIds: ["ch-001"],
+ minStartDate: start,
+ maxStartDate: end
+ )
+ let url = try #require(capturedURL)
+ let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
+ let items = comps?.queryItems ?? []
+ let minStart = items.first(where: { $0.name == "minStartDate" })?.value
+ let maxStart = items.first(where: { $0.name == "maxStartDate" })?.value
+ #expect(minStart == "2026-04-07T19:00:00Z")
+ #expect(maxStart == "2026-04-08T07:00:00Z")
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientTests.swift b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientTests.swift
new file mode 100644
index 0000000..887069e
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/JellyfinClientTests.swift
@@ -0,0 +1,329 @@
+import Testing
+import Foundation
+@testable import JellyfinAPI
+
+// MARK: - URLProtocol stub
+
+final class StubURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var handler: ((URLRequest) -> (HTTPURLResponse, Data))?
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ guard let handler = StubURLProtocol.handler else {
+ fatalError("StubURLProtocol.handler not set")
+ }
+ let (response, data) = handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+
+ override func stopLoading() {}
+}
+
+final class FailingURLProtocol: URLProtocol, @unchecked Sendable {
+ nonisolated(unsafe) static var error: URLError = URLError(.notConnectedToInternet)
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ client?.urlProtocol(self, didFailWithError: FailingURLProtocol.error)
+ }
+
+ override func stopLoading() {}
+}
+
+// MARK: - Helpers
+
+private func makeStubbedClient(
+ deviceName: String = "Apple TV",
+ handler: @escaping (URLRequest) -> (HTTPURLResponse, Data)
+) -> JellyfinClient {
+ StubURLProtocol.handler = handler
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [StubURLProtocol.self]
+ let session = URLSession(configuration: config)
+ return JellyfinClient(
+ deviceId: "test-device-id",
+ clientName: "JellyTV",
+ clientVersion: "1.0",
+ deviceName: deviceName,
+ session: session
+ )
+}
+
+private func makeFailingClient() -> JellyfinClient {
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [FailingURLProtocol.self]
+ let session = URLSession(configuration: config)
+ return JellyfinClient(
+ deviceId: "test-device-id",
+ clientName: "JellyTV",
+ clientVersion: "1.0",
+ deviceName: "Apple TV",
+ session: session
+ )
+}
+
+private func ok(_ json: String, url: URL) -> (HTTPURLResponse, Data) {
+ (HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, Data(json.utf8))
+}
+
+private func status(_ code: Int, body: String = "", url: URL) -> (HTTPURLResponse, Data) {
+ (HTTPURLResponse(url: url, statusCode: code, httpVersion: "HTTP/1.1", headerFields: nil)!, Data(body.utf8))
+}
+
+private let publicSystemInfoJSON = """
+{
+ "ServerName": "Test Server",
+ "Version": "10.11.8",
+ "Id": "abc",
+ "ProductName": "Jellyfin Server",
+ "StartupWizardCompleted": true
+}
+"""
+
+private let authResultJSON = """
+{
+ "User": {
+ "Id": "user-001",
+ "Name": "alice",
+ "ServerId": "srv-001",
+ "HasPassword": true,
+ "HasConfiguredPassword": true
+ },
+ "SessionInfo": {
+ "Id": "session-001",
+ "UserId": "user-001",
+ "UserName": "alice",
+ "DeviceId": "device-001",
+ "DeviceName": "Apple TV"
+ },
+ "AccessToken": "tok-abc",
+ "ServerId": "srv-001"
+}
+"""
+
+private let quickConnectResultJSON = """
+{
+ "Authenticated": false,
+ "Secret": "secret-xyz",
+ "Code": "ABC-123"
+}
+"""
+
+private let userDtoJSON = """
+{
+ "Id": "user-001",
+ "Name": "alice",
+ "ServerId": "srv-001",
+ "HasPassword": true,
+ "HasConfiguredPassword": true
+}
+"""
+
+// MARK: - Tests
+
+@Suite("JellyfinClient", .serialized)
+struct JellyfinClientTests {
+
+ private let serverURL = URL(string: "http://192.168.1.50:8096")!
+
+ @Test func authHeaderHasCorrectFormatWithoutToken() async throws {
+ var capturedAuth: String?
+ let client = makeStubbedClient { request in
+ capturedAuth = request.value(forHTTPHeaderField: "Authorization")
+ return ok(publicSystemInfoJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ _ = try await client.getPublicSystemInfo()
+
+ let auth = try #require(capturedAuth)
+ #expect(auth.hasPrefix("MediaBrowser "))
+ #expect(auth.contains("Client=\"JellyTV\""))
+ #expect(auth.contains("Device=\"Apple%20TV\""))
+ #expect(auth.contains("DeviceId=\"test-device-id\""))
+ #expect(auth.contains("Version=\"1.0\""))
+ #expect(!auth.contains("Token="))
+ }
+
+ @Test func authHeaderIncludesTokenWhenSet() async throws {
+ var capturedAuth: String?
+ let client = makeStubbedClient { request in
+ capturedAuth = request.value(forHTTPHeaderField: "Authorization")
+ return ok(userDtoJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ await client.setAccessToken("my-secret-token")
+ _ = try await client.currentUser()
+
+ let auth = try #require(capturedAuth)
+ #expect(auth.contains("Token=\"my-secret-token\""))
+ }
+
+ @Test func authHeaderUrlEncodesSpecialChars() async throws {
+ var capturedAuth: String?
+ let client = makeStubbedClient(deviceName: "John's % Apple TV") { request in
+ capturedAuth = request.value(forHTTPHeaderField: "Authorization")
+ return ok(publicSystemInfoJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ _ = try await client.getPublicSystemInfo()
+
+ let auth = try #require(capturedAuth)
+ // "John's % Apple TV" — apostrophe, percent, spaces all encoded
+ #expect(auth.contains("Device=\"John's%20%25%20Apple%20TV\"") || auth.contains("Device="))
+ // At minimum the space must be encoded
+ #expect(!auth.contains("Device=\"John's % Apple TV\""))
+ }
+
+ @Test func authenticateByNameSendsPwField() async throws {
+ var capturedBody: Data?
+ let client = makeStubbedClient { request in
+ // URLSession moves httpBody to httpBodyStream for intercepted requests
+ if let stream = request.httpBodyStream {
+ stream.open()
+ var data = Data()
+ let bufferSize = 1024
+ let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
+ defer { buffer.deallocate() }
+ while stream.hasBytesAvailable {
+ let bytesRead = stream.read(buffer, maxLength: bufferSize)
+ if bytesRead > 0 {
+ data.append(buffer, count: bytesRead)
+ }
+ }
+ stream.close()
+ capturedBody = data
+ } else {
+ capturedBody = request.httpBody
+ }
+ return ok(authResultJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ _ = try await client.authenticateByName(username: "alice", password: "secret123")
+
+ let body = try #require(capturedBody)
+ let dict = try JSONDecoder().decode([String: String].self, from: body)
+ #expect(dict["Pw"] == "secret123")
+ #expect(dict["Username"] == "alice")
+ #expect(dict["Password"] == nil)
+ }
+
+ @Test func authenticateByNameReturnsResult() async throws {
+ let client = makeStubbedClient { request in
+ ok(authResultJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let result = try await client.authenticateByName(username: "alice", password: "secret123")
+ #expect(result.accessToken == "tok-abc")
+ #expect(result.user.name == "alice")
+ }
+
+ @Test func quickConnectInitiate401MapsToQuickConnectDisabled() async throws {
+ let client = makeStubbedClient { request in
+ status(401, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ do {
+ _ = try await client.quickConnectInitiate()
+ Issue.record("Expected quickConnectDisabled to be thrown")
+ } catch JellyfinError.quickConnectDisabled {
+ // expected
+ } catch {
+ Issue.record("Wrong error thrown: \(error)")
+ }
+ }
+
+ @Test func quickConnectStatus404MapsToQuickConnectExpired() async throws {
+ let client = makeStubbedClient { request in
+ status(404, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ do {
+ _ = try await client.quickConnectStatus(secret: "foo")
+ Issue.record("Expected quickConnectExpired to be thrown")
+ } catch JellyfinError.quickConnectExpired {
+ // expected
+ } catch {
+ Issue.record("Wrong error thrown: \(error)")
+ }
+ }
+
+ @Test func quickConnectStatusPassesSecretQueryParam() async throws {
+ var capturedURL: URL?
+ let client = makeStubbedClient { request in
+ capturedURL = request.url
+ return ok(quickConnectResultJSON, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ _ = try await client.quickConnectStatus(secret: "mysecret")
+
+ let url = try #require(capturedURL)
+ #expect(url.absoluteString.contains("secret=mysecret"))
+ }
+
+ @Test func currentUser401MapsToUnauthenticated() async throws {
+ let client = makeStubbedClient { request in
+ status(401, url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ do {
+ _ = try await client.currentUser()
+ Issue.record("Expected unauthenticated to be thrown")
+ } catch JellyfinError.unauthenticated {
+ // expected — NOT quickConnectDisabled
+ } catch {
+ Issue.record("Wrong error thrown: \(error)")
+ }
+ }
+
+ @Test func notConfiguredErrorWhenNoServerURL() async throws {
+ // Use a plain client with no session stub — buildRequest throws before URLSession is called
+ let client = JellyfinClient(deviceId: "test-device-id")
+ // Do NOT call setServerURL
+ do {
+ _ = try await client.getPublicSystemInfo()
+ Issue.record("Expected notConfigured to be thrown")
+ } catch JellyfinError.notConfigured {
+ // expected
+ } catch {
+ Issue.record("Wrong error thrown: \(error)")
+ }
+ }
+
+ @Test func networkErrorMapsToNetworkCase() async throws {
+ FailingURLProtocol.error = URLError(.notConnectedToInternet)
+ let client = makeFailingClient()
+ await client.setServerURL(serverURL)
+ do {
+ _ = try await client.getPublicSystemInfo()
+ Issue.record("Expected network error to be thrown")
+ } catch JellyfinError.network(let urlError) {
+ #expect(urlError.code == .notConnectedToInternet)
+ } catch {
+ Issue.record("Wrong error thrown: \(error)")
+ }
+ }
+
+ @Test func quickConnectEnabledDecodesBareBool() async throws {
+ let client = makeStubbedClient { request in
+ ok("true", url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let enabled = try await client.quickConnectEnabled()
+ #expect(enabled == true)
+ }
+
+ @Test func quickConnectEnabledDecodesBareBoolFalse() async throws {
+ let client = makeStubbedClient { request in
+ ok("false", url: request.url!)
+ }
+ await client.setServerURL(serverURL)
+ let enabled = try await client.quickConnectEnabled()
+ #expect(enabled == false)
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/LiveTvDecodingTests.swift b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/LiveTvDecodingTests.swift
new file mode 100644
index 0000000..8ee0e68
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/LiveTvDecodingTests.swift
@@ -0,0 +1,163 @@
+import Testing
+import Foundation
+@testable import JellyfinAPI
+
+@Suite("LiveTV DTO Decoding")
+struct LiveTvDecodingTests {
+
+ private var decoder: JSONDecoder {
+ let d = JSONDecoder()
+ d.dateDecodingStrategy = .iso8601
+ return d
+ }
+
+ @Test func decodesLiveTvChannel() throws {
+ let json = """
+ {
+ "Id": "ch-001",
+ "Name": "MLB Network",
+ "Number": "215",
+ "ChannelType": "TV",
+ "ServerId": "srv-001",
+ "ImageTags": {
+ "Primary": "abc123"
+ }
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let channel = try decoder.decode(LiveTvChannel.self, from: data)
+ #expect(channel.id == "ch-001")
+ #expect(channel.name == "MLB Network")
+ #expect(channel.number == "215")
+ #expect(channel.channelType == "TV")
+ #expect(channel.serverId == "srv-001")
+ #expect(channel.imageTags?["Primary"] == "abc123")
+ }
+
+ @Test func decodesLiveTvChannelMinimal() throws {
+ let json = """
+ {
+ "Id": "ch-002",
+ "Name": "ESPN"
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let channel = try decoder.decode(LiveTvChannel.self, from: data)
+ #expect(channel.id == "ch-002")
+ #expect(channel.name == "ESPN")
+ #expect(channel.number == nil)
+ #expect(channel.channelType == nil)
+ }
+
+ @Test func decodesLiveTvChannelQueryResult() throws {
+ let json = """
+ {
+ "Items": [
+ { "Id": "ch-001", "Name": "MLB Network" },
+ { "Id": "ch-002", "Name": "ESPN" }
+ ],
+ "TotalRecordCount": 2
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let result = try decoder.decode(LiveTvChannelQueryResult.self, from: data)
+ #expect(result.items.count == 2)
+ #expect(result.totalRecordCount == 2)
+ #expect(result.items[0].name == "MLB Network")
+ }
+
+ /// Verifies the existing `.iso8601` strategy handles .NET's 7-digit fractional second
+ /// ISO8601 format used by Jellyfin's `/LiveTv/Programs` response.
+ @Test func decodesLiveTvProgramWith7DigitFractionalSeconds() throws {
+ let json = """
+ {
+ "Id": "prog-001",
+ "Name": "Yankees vs Red Sox",
+ "ChannelId": "ch-001",
+ "Overview": "MLB baseball.",
+ "StartDate": "2026-04-07T19:00:00.0000000Z",
+ "EndDate": "2026-04-07T22:00:00.0000000Z",
+ "IsLive": true,
+ "IsSports": true,
+ "IsRepeat": false
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let program = try decoder.decode(LiveTvProgram.self, from: data)
+ #expect(program.id == "prog-001")
+ #expect(program.name == "Yankees vs Red Sox")
+ #expect(program.channelId == "ch-001")
+ #expect(program.isLive == true)
+ #expect(program.isSports == true)
+ #expect(program.isRepeat == false)
+
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ let expectedStart = try #require(formatter.date(from: "2026-04-07T19:00:00.0000000Z"))
+ let expectedEnd = try #require(formatter.date(from: "2026-04-07T22:00:00.0000000Z"))
+ #expect(program.startDate == expectedStart)
+ #expect(program.endDate == expectedEnd)
+ }
+
+ @Test func decodesLiveTvProgramWithoutFractionalSeconds() throws {
+ let json = """
+ {
+ "Id": "prog-002",
+ "Name": "SportsCenter",
+ "ChannelId": "ch-002",
+ "StartDate": "2026-04-07T19:30:00Z",
+ "EndDate": "2026-04-07T20:00:00Z"
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let program = try decoder.decode(LiveTvProgram.self, from: data)
+ #expect(program.id == "prog-002")
+ #expect(program.startDate != nil)
+ #expect(program.endDate != nil)
+ }
+
+ @Test func decodesLiveTvProgramMinimal() throws {
+ let json = """
+ {
+ "Id": "prog-003",
+ "Name": "Unknown Show"
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let program = try decoder.decode(LiveTvProgram.self, from: data)
+ #expect(program.id == "prog-003")
+ #expect(program.name == "Unknown Show")
+ #expect(program.channelId == nil)
+ #expect(program.startDate == nil)
+ #expect(program.endDate == nil)
+ }
+
+ @Test func decodesLiveTvProgramQueryResult() throws {
+ let json = """
+ {
+ "Items": [
+ {
+ "Id": "prog-001",
+ "Name": "Yankees vs Red Sox",
+ "ChannelId": "ch-001",
+ "StartDate": "2026-04-07T19:00:00.0000000Z",
+ "EndDate": "2026-04-07T22:00:00.0000000Z"
+ },
+ {
+ "Id": "prog-002",
+ "Name": "Postgame",
+ "ChannelId": "ch-001",
+ "StartDate": "2026-04-07T22:00:00.0000000Z",
+ "EndDate": "2026-04-07T22:30:00.0000000Z"
+ }
+ ],
+ "TotalRecordCount": 2
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let result = try decoder.decode(LiveTvProgramQueryResult.self, from: data)
+ #expect(result.items.count == 2)
+ #expect(result.items[0].name == "Yankees vs Red Sox")
+ #expect(result.items[1].channelId == "ch-001")
+ }
+}
diff --git a/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/LiveTvTimerDecodingTests.swift b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/LiveTvTimerDecodingTests.swift
new file mode 100644
index 0000000..fe7c61d
--- /dev/null
+++ b/jellytv/Packages/JellyfinAPI/Tests/JellyfinAPITests/LiveTvTimerDecodingTests.swift
@@ -0,0 +1,106 @@
+import Testing
+import Foundation
+@testable import JellyfinAPI
+
+@Suite("LiveTV Timer DTO Decoding")
+struct LiveTvTimerDecodingTests {
+
+ private var decoder: JSONDecoder {
+ let d = JSONDecoder()
+ d.dateDecodingStrategy = .iso8601
+ return d
+ }
+
+ @Test func decodesTimerInfoDto() throws {
+ let json = """
+ {
+ "Id": "timer-001",
+ "ChannelId": "ch-001",
+ "ChannelName": "MLB Network",
+ "ChannelPrimaryImageTag": "abc123",
+ "ProgramId": "prog-001",
+ "Name": "Yankees vs Red Sox",
+ "Overview": "Live MLB action.",
+ "StartDate": "2026-04-07T19:00:00.0000000Z",
+ "EndDate": "2026-04-07T22:00:00.0000000Z",
+ "Status": "New",
+ "PrePaddingSeconds": 60,
+ "PostPaddingSeconds": 120
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let timer = try decoder.decode(TimerInfoDto.self, from: data)
+ #expect(timer.id == "timer-001")
+ #expect(timer.channelId == "ch-001")
+ #expect(timer.channelName == "MLB Network")
+ #expect(timer.programId == "prog-001")
+ #expect(timer.status == "New")
+ #expect(timer.prePaddingSeconds == 60)
+ #expect(timer.postPaddingSeconds == 120)
+ }
+
+ @Test func decodesTimerInfoDtoQueryResult() throws {
+ let json = """
+ {
+ "Items": [
+ { "Id": "t1", "Name": "Show 1" },
+ { "Id": "t2", "Name": "Show 2" }
+ ]
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let result = try decoder.decode(TimerInfoDtoQueryResult.self, from: data)
+ #expect(result.items.count == 2)
+ #expect(result.items[0].id == "t1")
+ }
+
+ @Test func decodesSeriesTimerInfoDto() throws {
+ let json = """
+ {
+ "Id": "ser-001",
+ "Name": "MLB on FOX",
+ "ChannelId": "ch-001",
+ "RecordAnyTime": true,
+ "RecordAnyChannel": false,
+ "RecordNewOnly": true,
+ "SkipEpisodesInLibrary": false,
+ "KeepUpTo": 5,
+ "Days": ["Sunday", "Saturday"]
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let timer = try decoder.decode(SeriesTimerInfoDto.self, from: data)
+ #expect(timer.id == "ser-001")
+ #expect(timer.recordAnyTime == true)
+ #expect(timer.recordAnyChannel == false)
+ #expect(timer.recordNewOnly == true)
+ #expect(timer.keepUpTo == 5)
+ #expect(timer.days == ["Sunday", "Saturday"])
+ }
+
+ @Test func decodesLiveTvChannelWithCurrentProgram() throws {
+ let json = """
+ {
+ "Id": "ch-001",
+ "Name": "MLB Network",
+ "Number": "215",
+ "ImageTags": { "Primary": "abc" },
+ "UserData": { "IsFavorite": true },
+ "CurrentProgram": {
+ "Id": "prog-001",
+ "Name": "Live Game",
+ "ChannelId": "ch-001",
+ "StartDate": "2026-04-07T19:00:00.0000000Z",
+ "EndDate": "2026-04-07T22:00:00.0000000Z",
+ "IsLive": true
+ }
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let channel = try decoder.decode(LiveTvChannel.self, from: data)
+ #expect(channel.isFavorite == true)
+ #expect(channel.currentProgram?.id == "prog-001")
+ #expect(channel.currentProgram?.name == "Live Game")
+ #expect(channel.currentProgram?.isLive == true)
+ }
+}
diff --git a/jellytv/Packages/Library/Package.swift b/jellytv/Packages/Library/Package.swift
new file mode 100644
index 0000000..098402d
--- /dev/null
+++ b/jellytv/Packages/Library/Package.swift
@@ -0,0 +1,28 @@
+// swift-tools-version: 6.2
+import PackageDescription
+
+let package = Package(
+ name: "Library",
+ platforms: [.tvOS(.v26), .macOS(.v15)],
+ products: [
+ .library(name: "Library", targets: ["Library"]),
+ ],
+ dependencies: [
+ .package(path: "../JellyfinAPI"),
+ .package(path: "../DesignSystem"),
+ .package(path: "../Settings"),
+ ],
+ targets: [
+ .target(
+ name: "Library",
+ dependencies: [
+ .product(name: "JellyfinAPI", package: "JellyfinAPI"),
+ .product(name: "DesignSystem", package: "DesignSystem"),
+ ]
+ ),
+ .testTarget(
+ name: "LibraryTests",
+ dependencies: ["Library", "Settings"]
+ ),
+ ]
+)
diff --git a/jellytv/Packages/Library/Sources/Library/HomeModel.swift b/jellytv/Packages/Library/Sources/Library/HomeModel.swift
new file mode 100644
index 0000000..776af11
--- /dev/null
+++ b/jellytv/Packages/Library/Sources/Library/HomeModel.swift
@@ -0,0 +1,92 @@
+import Foundation
+import Observation
+import JellyfinAPI
+
+@MainActor
+@Observable
+public final class HomeModel {
+ public enum State: Equatable, Sendable {
+ case loading
+ case loaded(HomeContent)
+ case failed(String)
+ }
+
+ public private(set) var state: State = .loading
+
+ private let client: any JellyfinClientAPI
+
+ public init(client: any JellyfinClientAPI) {
+ self.client = client
+ }
+
+ public func load() async {
+ state = .loading
+
+ guard let serverURL = await client.currentServerURL() else {
+ state = .failed("Not signed in")
+ return
+ }
+
+ do {
+ async let libraries = client.userViews()
+ async let resumeItems = client.resumeItems(limit: 10)
+ async let nextUpItems = client.nextUp(limit: 10)
+
+ let (librariesResult, resumeResult, nextUpResult) = try await (libraries, resumeItems, nextUpItems)
+
+ var latestPerLibrary: [String: [BaseItemDto]] = [:]
+ try await withThrowingTaskGroup(of: (String, [BaseItemDto]).self) { group in
+ for library in librariesResult {
+ let libraryId = library.id
+ group.addTask {
+ let latest = try await self.client.latestItems(parentId: libraryId, limit: 10)
+ return (libraryId, latest)
+ }
+ }
+ for try await (libraryId, latest) in group {
+ if !latest.isEmpty {
+ latestPerLibrary[libraryId] = latest
+ }
+ }
+ }
+
+ let content = HomeContent(
+ serverURL: serverURL,
+ libraries: librariesResult,
+ resumeItems: resumeResult,
+ nextUp: nextUpResult,
+ latestPerLibrary: latestPerLibrary
+ )
+
+ state = .loaded(content)
+ } catch JellyfinError.network {
+ state = .failed("Couldn't reach the server.")
+ } catch JellyfinError.unauthenticated {
+ state = .failed("Session expired. Please sign in again.")
+ } catch {
+ state = .failed("Something went wrong: \(error)")
+ }
+ }
+}
+
+public struct HomeContent: Equatable, Sendable {
+ public let serverURL: URL
+ public let libraries: [BaseItemDto]
+ public let resumeItems: [BaseItemDto]
+ public let nextUp: [BaseItemDto]
+ public let latestPerLibrary: [String: [BaseItemDto]]
+
+ public init(
+ serverURL: URL,
+ libraries: [BaseItemDto],
+ resumeItems: [BaseItemDto],
+ nextUp: [BaseItemDto],
+ latestPerLibrary: [String: [BaseItemDto]]
+ ) {
+ self.serverURL = serverURL
+ self.libraries = libraries
+ self.resumeItems = resumeItems
+ self.nextUp = nextUp
+ self.latestPerLibrary = latestPerLibrary
+ }
+}
\ No newline at end of file
diff --git a/jellytv/Packages/Library/Sources/Library/HomeView.swift b/jellytv/Packages/Library/Sources/Library/HomeView.swift
new file mode 100644
index 0000000..eaa0902
--- /dev/null
+++ b/jellytv/Packages/Library/Sources/Library/HomeView.swift
@@ -0,0 +1,149 @@
+import SwiftUI
+import JellyfinAPI
+import DesignSystem
+
+public struct HomeView: View {
+ @Bindable var model: HomeModel
+ @FocusedValue(\.focusedHomeItem) private var focusedItem
+
+ public init(model: HomeModel) {
+ self.model = model
+ }
+
+ public var body: some View {
+ Group {
+ switch model.state {
+ case .loading:
+ ProgressView()
+ .controlSize(.large)
+ case .loaded(let content):
+ homeContent(content)
+ case .failed(let message):
+ failedView(message)
+ }
+ }
+ .task {
+ await model.load()
+ }
+ }
+
+ @ViewBuilder
+ private func homeContent(_ content: HomeContent) -> some View {
+ let displayedHeroItem = focusedItem ?? content.heroItem
+ if content.isEmpty {
+ emptyState(libraryCount: content.libraries.count)
+ } else {
+ loadedScroll(content: content, displayedHeroItem: displayedHeroItem)
+ }
+ }
+
+ private func emptyState(libraryCount: Int) -> some View {
+ VStack(spacing: 24) {
+ Image(systemName: "film.stack")
+ .font(.system(size: 80))
+ .foregroundStyle(.secondary)
+ Text("Nothing to show yet")
+ .font(.title)
+ Text(libraryCount == 0
+ ? "Your server has no libraries."
+ : "Your libraries are empty, or have no recently added items.")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ Button("Reload") {
+ Task { await model.load() }
+ }
+ }
+ .padding(60)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ @ViewBuilder
+ private func loadedScroll(content: HomeContent, displayedHeroItem: BaseItemDto?) -> some View {
+ ScrollView(.vertical, showsIndicators: false) {
+ LazyVStack(alignment: .leading, spacing: 60) {
+ if let heroItem = displayedHeroItem {
+ HeroSection(
+ item: heroItem,
+ serverURL: content.serverURL,
+ onPlay: { /* Phase 4: play */ },
+ onDetail: { /* Phase 3: detail */ }
+ )
+ }
+
+ if !content.resumeItems.isEmpty {
+ Shelf(
+ title: "Continue Watching",
+ items: content.resumeItems,
+ itemTitle: { $0.name },
+ imageURL: { $0.imageURL(serverURL: content.serverURL, type: .primary, maxWidth: 300) }
+ ) { item in
+ print("Resume: \(item.name)")
+ }
+ .focusedValue { $0 }
+ }
+
+ if !content.nextUp.isEmpty {
+ Shelf(
+ title: "Next Up",
+ items: content.nextUp,
+ itemTitle: { $0.name },
+ imageURL: { $0.imageURL(serverURL: content.serverURL, type: .primary, maxWidth: 300) }
+ ) { item in
+ print("Next Up: \(item.name)")
+ }
+ .focusedValue { $0 }
+ }
+
+ ForEach(content.libraries, id: \.id) { library in
+ if let latestItems = content.latestPerLibrary[library.id], !latestItems.isEmpty {
+ Shelf(
+ title: library.name,
+ items: latestItems,
+ itemTitle: { $0.name },
+ imageURL: { $0.imageURL(serverURL: content.serverURL, type: .primary, maxWidth: 300) }
+ ) { item in
+ print("Latest: \(item.name)")
+ }
+ .focusedValue { $0 }
+ }
+ }
+ }
+ .scrollTargetLayout()
+ }
+ .scrollClipDisabled()
+ .scrollTargetBehavior(.viewAligned)
+ }
+
+ private func failedView(_ message: String) -> some View {
+ VStack(spacing: 20) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.system(size: 60))
+ .foregroundStyle(.secondary)
+ Text(message)
+ .font(.title2)
+ .multilineTextAlignment(.center)
+ Button("Retry") {
+ Task { await model.load() }
+ }
+ .buttonStyle(.bordered)
+ }
+ .padding(40)
+ }
+}
+
+extension HomeContent {
+ var heroItem: BaseItemDto? {
+ if let first = resumeItems.first {
+ return first
+ }
+ if let first = nextUp.first {
+ return first
+ }
+ return nil
+ }
+
+ var isEmpty: Bool {
+ resumeItems.isEmpty && nextUp.isEmpty && latestPerLibrary.values.allSatisfy(\.isEmpty)
+ }
+}
\ No newline at end of file
diff --git a/jellytv/Packages/Library/Sources/Library/Library.swift b/jellytv/Packages/Library/Sources/Library/Library.swift
new file mode 100644
index 0000000..d4704da
--- /dev/null
+++ b/jellytv/Packages/Library/Sources/Library/Library.swift
@@ -0,0 +1,6 @@
+// Library module placeholder.
+// Phase 2/3 adds Home, library grid, item detail, and search views here.
+
+public enum Library {
+ public static let version = "0.0.1"
+}
diff --git a/jellytv/Packages/Library/Tests/LibraryTests/HomeModelScrollRegressionTests.swift b/jellytv/Packages/Library/Tests/LibraryTests/HomeModelScrollRegressionTests.swift
new file mode 100644
index 0000000..171796a
--- /dev/null
+++ b/jellytv/Packages/Library/Tests/LibraryTests/HomeModelScrollRegressionTests.swift
@@ -0,0 +1,174 @@
+import Testing
+import Foundation
+import JellyfinAPI
+import Library
+
+@MainActor
+final class HomeModelScrollRegressionTests {
+ private func makeMock() -> (HomeModel, TestScrollRegressionClient) {
+ let mock = TestScrollRegressionClient()
+ let model = HomeModel(client: mock)
+ return (model, mock)
+ }
+
+ @Test
+ func testNoDuplicateIdsInLatestShelves() async throws {
+ let (model, mock) = makeMock()
+
+ mock.currentServerURL_ = URL(string: "http://localhost:8096")
+
+ var libraryItems: [BaseItemDto] = []
+ for i in 0..<500 {
+ libraryItems.append(BaseItemDto(
+ id: "item-\(i)",
+ name: "Movie \(i)",
+ type: "Movie",
+ serverId: nil,
+ parentId: nil,
+ imageTags: nil,
+ backdropImageTags: nil,
+ overview: nil,
+ productionYear: nil,
+ userData: nil,
+ runTimeTicks: nil,
+ seriesName: nil,
+ seasonName: nil,
+ indexNumber: nil,
+ communityRating: nil
+ ))
+ }
+
+ mock.userViewsResult = .success([
+ BaseItemDto(id: "lib1", name: "Movies", type: "Folder", serverId: nil, parentId: nil, imageTags: nil, backdropImageTags: nil, overview: nil, productionYear: nil, userData: nil, runTimeTicks: nil, seriesName: nil, seasonName: nil, indexNumber: nil, communityRating: nil)
+ ])
+ mock.resumeItemsResult = .success([])
+ mock.nextUpResult = .success([])
+ mock.latestItemsResult = .success(libraryItems)
+
+ await model.load()
+
+ guard case .loaded(let content) = model.state else {
+ Issue.record("Expected loaded state")
+ return
+ }
+
+ let latestItems = content.latestPerLibrary["lib1"] ?? []
+ let ids = latestItems.map { $0.id }
+ let uniqueIds = Set(ids)
+
+ #expect(ids.count == uniqueIds.count)
+ }
+
+ @Test
+ func testLargeLibraryDoesNotCrash() async throws {
+ let (model, mock) = makeMock()
+
+ mock.currentServerURL_ = URL(string: "http://localhost:8096")
+
+ var libraryItems: [BaseItemDto] = []
+ for i in 0..<500 {
+ libraryItems.append(BaseItemDto(
+ id: "item-\(i)",
+ name: "Movie \(i)",
+ type: "Movie",
+ serverId: nil,
+ parentId: nil,
+ imageTags: nil,
+ backdropImageTags: nil,
+ overview: nil,
+ productionYear: nil,
+ userData: nil,
+ runTimeTicks: nil,
+ seriesName: nil,
+ seasonName: nil,
+ indexNumber: nil,
+ communityRating: nil
+ ))
+ }
+
+ mock.userViewsResult = .success([
+ BaseItemDto(id: "lib1", name: "Movies", type: "Folder", serverId: nil, parentId: nil, imageTags: nil, backdropImageTags: nil, overview: nil, productionYear: nil, userData: nil, runTimeTicks: nil, seriesName: nil, seasonName: nil, indexNumber: nil, communityRating: nil)
+ ])
+ mock.resumeItemsResult = .success([])
+ mock.nextUpResult = .success([])
+ mock.latestItemsResult = .success(libraryItems)
+
+ await model.load()
+
+ switch model.state {
+ case .loaded:
+ return
+ case .loading:
+ Issue.record("Never left loading state")
+ case .failed(let msg):
+ Issue.record("Failed: \(msg)")
+ }
+ }
+}
+
+private final class TestScrollRegressionClient: JellyfinClientAPI, @unchecked Sendable {
+ var setServerURLCalls: [URL?] = []
+ var setAccessTokenCalls: [String?] = []
+ var currentServerURL_: URL? = nil
+
+ var userViewsResult: Result<[BaseItemDto], Error> = .success([])
+ var resumeItemsResult: Result<[BaseItemDto], Error> = .success([])
+ var nextUpResult: Result<[BaseItemDto], Error> = .success([])
+ var latestItemsResult: Result<[BaseItemDto], Error> = .success([])
+
+ func setServerURL(_ url: URL?) async {
+ setServerURLCalls.append(url)
+ currentServerURL_ = url
+ }
+
+ func currentServerURL() async -> URL? {
+ currentServerURL_
+ }
+
+ func setAccessToken(_ token: String?) async {
+ setAccessTokenCalls.append(token)
+ }
+
+ func getPublicSystemInfo() async throws -> PublicSystemInfo {
+ PublicSystemInfo(serverName: "Test", version: "1.0", id: nil, productName: nil, localAddress: nil, startupWizardCompleted: nil)
+ }
+
+ func authenticateByName(username: String, password: String) async throws -> AuthenticationResult {
+ throw JellyfinError.unauthenticated
+ }
+
+ func quickConnectEnabled() async throws -> Bool { false }
+ func quickConnectInitiate() async throws -> QuickConnectResult { throw JellyfinError.unauthenticated }
+ func quickConnectStatus(secret: String) async throws -> QuickConnectResult { throw JellyfinError.unauthenticated }
+ func authenticateWithQuickConnect(secret: String) async throws -> AuthenticationResult { throw JellyfinError.unauthenticated }
+ func currentUser() async throws -> UserDto { throw JellyfinError.unauthenticated }
+ func logout() async throws {}
+
+ func userViews() async throws -> [BaseItemDto] {
+ try userViewsResult.get()
+ }
+
+ func resumeItems(limit: Int) async throws -> [BaseItemDto] {
+ try resumeItemsResult.get()
+ }
+
+ func nextUp(limit: Int) async throws -> [BaseItemDto] {
+ try nextUpResult.get()
+ }
+
+ func latestItems(parentId: String?, limit: Int) async throws -> [BaseItemDto] {
+ try latestItemsResult.get()
+ }
+
+ func liveTvChannels() async throws -> [LiveTvChannel] { [] }
+
+ func liveTvPrograms(
+ channelIds: [String],
+ minStartDate: Date,
+ maxStartDate: Date
+ ) async throws -> [LiveTvProgram] { [] }
+
+ func liveTvOpenStream(channelId: String) async throws -> LiveStreamPlayback {
+ LiveStreamPlayback(playbackURL: URL(string: "http://test/stream")!, liveStreamId: nil)
+ }
+}
\ No newline at end of file
diff --git a/jellytv/Packages/Library/Tests/LibraryTests/HomeModelTests.swift b/jellytv/Packages/Library/Tests/LibraryTests/HomeModelTests.swift
new file mode 100644
index 0000000..f60b35a
--- /dev/null
+++ b/jellytv/Packages/Library/Tests/LibraryTests/HomeModelTests.swift
@@ -0,0 +1,154 @@
+import Testing
+import Foundation
+import JellyfinAPI
+import Library
+
+@MainActor
+final class HomeModelTests {
+ private func makeMock() -> (HomeModel, TestMockClient) {
+ let mock = TestMockClient()
+ let model = HomeModel(client: mock)
+ return (model, mock)
+ }
+
+ @Test
+ func loadSuccess() async throws {
+ let (model, mock) = makeMock()
+
+ mock.userViewsResult = .success([
+ BaseItemDto(id: "lib1", name: "Movies", type: "Folder", serverId: nil, parentId: nil, imageTags: nil, backdropImageTags: nil, overview: nil, productionYear: nil, userData: nil, runTimeTicks: nil, seriesName: nil, seasonName: nil, indexNumber: nil, communityRating: nil)
+ ])
+ mock.resumeItemsResult = .success([
+ BaseItemDto(id: "item1", name: "In Progress", type: "Movie", serverId: nil, parentId: nil, imageTags: ["Primary": "tag1"], backdropImageTags: nil, overview: "A movie", productionYear: 2024, userData: nil, runTimeTicks: 7200000000, seriesName: nil, seasonName: nil, indexNumber: nil, communityRating: nil)
+ ])
+ mock.nextUpResult = .success([])
+ mock.latestItemsResult = .success([])
+
+ mock.currentServerURL_ = URL(string: "http://localhost:8096")
+
+ await model.load()
+
+ switch model.state {
+ case .loaded:
+ return
+ case .loading:
+ Issue.record("Never left loading state")
+ case .failed(let msg):
+ Issue.record("Failed: \(msg)")
+ }
+ }
+
+ @Test
+ func loadNetworkError() async throws {
+ let (model, mock) = makeMock()
+
+ mock.userViewsResult = .failure(JellyfinError.network(URLError(.notConnectedToInternet)))
+ mock.currentServerURL_ = URL(string: "http://localhost:8096")
+
+ await model.load()
+
+ guard case .failed(let message) = model.state else {
+ Issue.record("Expected failed state")
+ return
+ }
+ #expect(message.contains("reach"))
+ }
+
+ @Test
+ func loadNoServerURL() async throws {
+ let (model, mock) = makeMock()
+
+ mock.currentServerURL_ = nil
+
+ await model.load()
+
+ guard case .failed(let message) = model.state else {
+ Issue.record("Expected failed state")
+ return
+ }
+ #expect(message.contains("signed in"))
+ }
+
+ @Test
+ func loadUnauthorized() async throws {
+ let (model, mock) = makeMock()
+
+ mock.userViewsResult = .failure(JellyfinError.unauthenticated)
+ mock.currentServerURL_ = URL(string: "http://localhost:8096")
+
+ await model.load()
+
+ guard case .failed(let message) = model.state else {
+ Issue.record("Expected failed state")
+ return
+ }
+ #expect(message.contains("Session"))
+ }
+}
+
+private final class TestMockClient: JellyfinClientAPI, @unchecked Sendable {
+ var setServerURLCalls: [URL?] = []
+ var setAccessTokenCalls: [String?] = []
+ var currentServerURL_: URL? = nil
+
+ var userViewsResult: Result<[BaseItemDto], Error> = .success([])
+ var resumeItemsResult: Result<[BaseItemDto], Error> = .success([])
+ var nextUpResult: Result<[BaseItemDto], Error> = .success([])
+ var latestItemsResult: Result<[BaseItemDto], Error> = .success([])
+
+ func setServerURL(_ url: URL?) async {
+ setServerURLCalls.append(url)
+ currentServerURL_ = url
+ }
+
+ func currentServerURL() async -> URL? {
+ currentServerURL_
+ }
+
+ func setAccessToken(_ token: String?) async {
+ setAccessTokenCalls.append(token)
+ }
+
+ func getPublicSystemInfo() async throws -> PublicSystemInfo {
+ PublicSystemInfo(serverName: "Test", version: "1.0", id: nil, productName: nil, localAddress: nil, startupWizardCompleted: nil)
+ }
+
+ func authenticateByName(username: String, password: String) async throws -> AuthenticationResult {
+ throw JellyfinError.unauthenticated
+ }
+
+ func quickConnectEnabled() async throws -> Bool { false }
+ func quickConnectInitiate() async throws -> QuickConnectResult { throw JellyfinError.unauthenticated }
+ func quickConnectStatus(secret: String) async throws -> QuickConnectResult { throw JellyfinError.unauthenticated }
+ func authenticateWithQuickConnect(secret: String) async throws -> AuthenticationResult { throw JellyfinError.unauthenticated }
+ func currentUser() async throws -> UserDto { throw JellyfinError.unauthenticated }
+ func logout() async throws {}
+
+ func userViews() async throws -> [BaseItemDto] {
+ try userViewsResult.get()
+ }
+
+ func resumeItems(limit: Int) async throws -> [BaseItemDto] {
+ try resumeItemsResult.get()
+ }
+
+ func nextUp(limit: Int) async throws -> [BaseItemDto] {
+ try nextUpResult.get()
+ }
+
+ func latestItems(parentId: String?, limit: Int) async throws -> [BaseItemDto] {
+ try latestItemsResult.get()
+ }
+
+ func liveTvChannels() async throws -> [LiveTvChannel] { [] }
+
+ func liveTvPrograms(
+ channelIds: [String],
+ minStartDate: Date,
+ maxStartDate: Date
+ ) async throws -> [LiveTvProgram] { [] }
+
+ func liveTvOpenStream(channelId: String) async throws -> LiveStreamPlayback {
+ LiveStreamPlayback(playbackURL: URL(string: "http://test/stream")!, liveStreamId: nil)
+ }
+}
\ No newline at end of file
diff --git a/jellytv/Packages/LiveTV/Package.swift b/jellytv/Packages/LiveTV/Package.swift
new file mode 100644
index 0000000..e78c09d
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Package.swift
@@ -0,0 +1,29 @@
+// swift-tools-version: 6.2
+import PackageDescription
+
+let package = Package(
+ name: "LiveTV",
+ platforms: [.tvOS(.v26), .macOS(.v15)],
+ products: [
+ .library(name: "LiveTV", targets: ["LiveTV"]),
+ ],
+ dependencies: [
+ .package(path: "../JellyfinAPI"),
+ .package(path: "../DesignSystem"),
+ .package(url: "https://github.com/kean/Nuke", from: "12.0.0"),
+ ],
+ targets: [
+ .target(
+ name: "LiveTV",
+ dependencies: [
+ .product(name: "JellyfinAPI", package: "JellyfinAPI"),
+ .product(name: "DesignSystem", package: "DesignSystem"),
+ .product(name: "NukeUI", package: "Nuke"),
+ ]
+ ),
+ .testTarget(
+ name: "LiveTVTests",
+ dependencies: ["LiveTV"]
+ ),
+ ]
+)
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/EPGStore.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/EPGStore.swift
new file mode 100644
index 0000000..0a2ce02
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/EPGStore.swift
@@ -0,0 +1,224 @@
+import Foundation
+import Observation
+import JellyfinAPI
+import DesignSystem
+
+/// Cache key for a `/LiveTv/Channels` query. Captures all parameters that
+/// affect the server response so different queries never collide.
+public struct ChannelQueryKey: Hashable, Sendable {
+ /// true = unfiltered `liveTvChannels()` overload; false = filtered overload
+ public let isUnfiltered: Bool
+
+ // Filtered-overload parameters (nil when isUnfiltered == true)
+ public let isMovie: Bool?
+ public let isSeries: Bool?
+ public let isNews: Bool?
+ public let isKids: Bool?
+ public let isSports: Bool?
+ public let isFavorite: Bool?
+ public let isAiringNow: Bool?
+ public let sortBy: String?
+ public let sortOrder: String?
+ public let startIndex: Int?
+ public let limit: Int?
+ public let addCurrentProgram: Bool?
+
+ /// Key for the unfiltered `liveTvChannels()` call.
+ public static let unfiltered = ChannelQueryKey(
+ isUnfiltered: true,
+ isMovie: nil, isSeries: nil, isNews: nil, isKids: nil, isSports: nil,
+ isFavorite: nil, isAiringNow: nil, sortBy: nil, sortOrder: nil,
+ startIndex: nil, limit: nil, addCurrentProgram: nil
+ )
+
+ /// Key for a filtered `liveTvChannels(filters:addCurrentProgram:)` call.
+ public static func filtered(_ filters: LiveTvChannelFilters, addCurrentProgram: Bool) -> ChannelQueryKey {
+ ChannelQueryKey(
+ isUnfiltered: false,
+ isMovie: filters.isMovie,
+ isSeries: filters.isSeries,
+ isNews: filters.isNews,
+ isKids: filters.isKids,
+ isSports: filters.isSports,
+ isFavorite: filters.isFavorite,
+ isAiringNow: filters.isAiringNow,
+ sortBy: filters.sortBy,
+ sortOrder: filters.sortOrder,
+ startIndex: filters.startIndex,
+ limit: filters.limit,
+ addCurrentProgram: addCurrentProgram
+ )
+ }
+}
+
+private struct CacheEntry {
+ let channels: [LiveTvChannel]
+ let fetchedAt: Date
+}
+
+/// Request-coalescing, TTL-keyed cache for `/LiveTv/Channels` calls.
+///
+/// Multiple concurrent callers asking for the same key share a single
+/// in-flight `Task`. After the task completes the result is stored and
+/// served from cache for 5 minutes. After TTL expiry the next caller
+/// triggers a fresh fetch.
+///
+/// Owns a sorted, deduplicated copy of the unfiltered channel list via
+/// `unfilteredChannels` — suitable as the player zap list.
+@MainActor
+@Observable
+public final class EPGStore {
+
+ // MARK: - Public observable state
+
+ /// Sorted unfiltered channel list (post-first-fetch). Empty before first load.
+ public private(set) var unfilteredChannels: [LiveTvChannel] = []
+ /// True while the very first unfiltered fetch is in progress.
+ public private(set) var isLoadingUnfiltered: Bool = false
+ /// Last error from the unfiltered fetch (for root-view error presentation).
+ public private(set) var lastError: String?
+
+ // MARK: - Configuration
+
+ /// Injectable clock — replace in tests to fast-forward time.
+ public var now: @Sendable () -> Date
+
+ // MARK: - Private state
+
+ private let client: any JellyfinClientAPI
+ private var cache: [ChannelQueryKey: CacheEntry] = [:]
+ private var inFlight: [ChannelQueryKey: Task<[LiveTvChannel], Error>] = [:]
+
+ private static let ttl: TimeInterval = 5 * 60
+
+ // MARK: - Init
+
+ public init(
+ client: any JellyfinClientAPI,
+ now: @escaping @Sendable () -> Date = { Date() }
+ ) {
+ self.client = client
+ self.now = now
+ }
+
+ // MARK: - Public API — mirrors the two client overloads exactly
+
+ /// Fetch all channels (unfiltered). Mirrors `client.liveTvChannels()`.
+ public func channels() async throws -> [LiveTvChannel] {
+ try await fetch(key: .unfiltered)
+ }
+
+ /// Fetch channels with filters. Mirrors `client.liveTvChannels(filters:addCurrentProgram:)`.
+ public func channels(
+ filters: LiveTvChannelFilters,
+ addCurrentProgram: Bool
+ ) async throws -> [LiveTvChannel] {
+ try await fetch(key: .filtered(filters, addCurrentProgram: addCurrentProgram))
+ }
+
+ // MARK: - Pre-warm
+
+ /// Pre-warm the unfiltered channel list, updating `unfilteredChannels` when done.
+ /// Safe to call multiple times — coalesced by the normal in-flight dedup.
+ public func prewarm() async {
+ guard !isLoadingUnfiltered else { return }
+ isLoadingUnfiltered = true
+ do {
+ let result = try await channels()
+ unfilteredChannels = ChannelOrdering.sortedByChannelNumber(result)
+ lastError = nil
+ } catch {
+ lastError = String(describing: error)
+ JellytvLog.liveTV.error("EPGStore.prewarm: \(String(describing: error), privacy: .public)")
+ }
+ isLoadingUnfiltered = false
+ }
+
+ // MARK: - Dominant-color pre-warm
+
+ /// Maximum number of channels to pre-warm dominant colors for.
+ static let dominantColorPrewarmLimit = 50
+
+ /// Returns the zap-list channels ordered for dominant-color pre-warming:
+ /// favorites first (preserving channel order within each group), then the
+ /// rest, capped at `dominantColorPrewarmLimit`.
+ static func channelsForColorPrewarm(_ channels: [LiveTvChannel]) -> [LiveTvChannel] {
+ let favorites = channels.filter { $0.userData?.isFavorite == true }
+ let rest = channels.filter { $0.userData?.isFavorite != true }
+ return Array((favorites + rest).prefix(dominantColorPrewarmLimit))
+ }
+
+ /// Fire-and-forget background pre-warm of `ChannelDominantColor` for the
+ /// top channels in the zap list. Favorites are processed first; total
+ /// capped at `dominantColorPrewarmLimit`. Errors are silently swallowed.
+ ///
+ /// Must be called after `unfilteredChannels` is populated (i.e. after
+ /// `prewarm()` completes) and once `serverURL` is known.
+ func prewarmDominantColors(serverURL: URL) {
+ let ordered = Self.channelsForColorPrewarm(unfilteredChannels)
+ Task(priority: .background) {
+ for channel in ordered {
+ let url = channel.logoURL(serverURL: serverURL, maxWidth: 256)
+ _ = await ChannelDominantColor.shared.extract(logoURL: url)
+ }
+ }
+ }
+
+ // MARK: - Core fetch / coalesce / cache
+
+ private func fetch(key: ChannelQueryKey) async throws -> [LiveTvChannel] {
+ // 1. Cache hit within TTL?
+ if let entry = cache[key], now().timeIntervalSince(entry.fetchedAt) < Self.ttl {
+ return entry.channels
+ }
+
+ // 2. Already in flight for this key — await the same task.
+ if let existing = inFlight[key] {
+ return try await existing.value
+ }
+
+ // 3. Kick off a new fetch.
+ let task = Task<[LiveTvChannel], Error> { [weak self] in
+ guard let self else { throw CancellationError() }
+ let result: [LiveTvChannel]
+ if key.isUnfiltered {
+ result = try await self.client.liveTvChannels()
+ } else {
+ let filters = LiveTvChannelFilters(
+ isMovie: key.isMovie,
+ isSeries: key.isSeries,
+ isNews: key.isNews,
+ isKids: key.isKids,
+ isSports: key.isSports,
+ isFavorite: key.isFavorite,
+ isAiringNow: key.isAiringNow,
+ sortBy: key.sortBy,
+ sortOrder: key.sortOrder,
+ startIndex: key.startIndex,
+ limit: key.limit
+ )
+ result = try await self.client.liveTvChannels(
+ filters: filters,
+ addCurrentProgram: key.addCurrentProgram ?? false
+ )
+ }
+ return result
+ }
+
+ inFlight[key] = task
+
+ do {
+ let channels = try await task.value
+ cache[key] = CacheEntry(channels: channels, fetchedAt: now())
+ inFlight.removeValue(forKey: key)
+ // Keep unfilteredChannels up to date when the unfiltered key is fetched.
+ if key.isUnfiltered {
+ unfilteredChannels = ChannelOrdering.sortedByChannelNumber(channels)
+ }
+ return channels
+ } catch {
+ inFlight.removeValue(forKey: key)
+ throw error
+ }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Guide/GuideGridView.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Guide/GuideGridView.swift
new file mode 100644
index 0000000..a17eb9d
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Guide/GuideGridView.swift
@@ -0,0 +1,571 @@
+import SwiftUI
+import NukeUI
+import JellyfinAPI
+import DesignSystem
+
+/// The actual EPG grid: sticky channel column + horizontally-scrolling time
+/// grid + a sticky "focused program" detail strip at the bottom that shows
+/// the title, time, and overview of whatever cell currently has focus.
+struct GuideGridView: View {
+ let content: GuideContent
+ let onWatchChannel: (LiveTvChannel) -> Void
+ let onSelectProgram: (LiveTvProgram) -> Void
+ @Binding var lastWatchedChannelId: String?
+
+ @FocusedValue(\.focusedGuideProgram) private var focusedProgram
+ @FocusedValue(\.focusedGuideChannel) private var focusedChannel
+ @FocusState private var focusedChannelId: String?
+
+ var body: some View {
+ VStack(spacing: 0) {
+ ScrollViewReader { proxy in
+ ScrollView(.vertical, showsIndicators: false) {
+ HStack(alignment: .top, spacing: 0) {
+ channelColumn
+ programArea
+ }
+ }
+ .scrollClipDisabled()
+ .onChange(of: lastWatchedChannelId) { _, newId in
+ guard let newId else { return }
+ Task { @MainActor in
+ // Let SwiftUI render the dismissed-to view first.
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ withAnimation(.easeInOut(duration: 0.25)) {
+ proxy.scrollTo(newId, anchor: .center)
+ }
+ focusedChannelId = newId
+ }
+ }
+ }
+
+ FocusedProgramFooter(
+ program: focusedProgram,
+ channel: focusedChannel,
+ serverURL: content.serverURL
+ )
+ }
+ .background(LiveTVTheme.background)
+ }
+
+ // MARK: - Channel column
+
+ private var channelColumn: some View {
+ VStack(spacing: 0) {
+ Color.clear.frame(height: GuideLayout.timeHeaderHeight)
+ ForEach(content.channels) { channel in
+ ChannelRowHeader(
+ channel: channel,
+ serverURL: content.serverURL,
+ onTap: { onWatchChannel(channel) }
+ )
+ .frame(height: GuideLayout.rowHeight)
+ // Inset the cell so .buttonStyle(.card)'s focus scale (~1.1×)
+ // stays inside the column instead of overflowing into the
+ // program grid on the right.
+ .padding(.horizontal, 12)
+ .focused($focusedChannelId, equals: channel.id)
+ .id(channel.id)
+ }
+ }
+ .frame(width: GuideLayout.channelColumnWidth)
+ .focusSection()
+ }
+
+ // MARK: - Program area
+
+ private var programArea: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 0) {
+ TimeHeader(windowStart: content.windowStart, windowEnd: content.windowEnd)
+ .frame(height: GuideLayout.timeHeaderHeight)
+ ForEach(content.channels) { channel in
+ GuideChannelLane(
+ channel: channel,
+ programs: content.programs(for: channel.id),
+ windowStart: content.windowStart,
+ windowEnd: content.windowEnd,
+ onSelectProgram: onSelectProgram
+ )
+ }
+ }
+ .overlay(alignment: .topLeading) {
+ nowLine(windowStart: content.windowStart)
+ }
+ }
+ .scrollClipDisabled()
+ }
+
+ /// Vertical "now" indicator. Wrapped in `TimelineView` so the line position
+ /// updates once per minute. Critical: only the line itself is inside the
+ /// timeline closure — the program grid is a sibling, so timeline ticks
+ /// don't rebuild program cells (which would drop tvOS focus).
+ ///
+ /// Phase D: thicker (5pt), amber→broadcast-red gradient, with a 1.2s
+ /// auto-reversing opacity pulse so the live edge feels alive.
+ private func nowLine(windowStart: Date) -> some View {
+ TimelineView(.periodic(from: .now, by: 60)) { context in
+ let secondsSinceStart = context.date.timeIntervalSince(windowStart)
+ let x = GuideLayout.offset(forSecondsSinceWindowStart: secondsSinceStart)
+ NowLineMarker()
+ .offset(x: x - 2.5)
+ .allowsHitTesting(false)
+ }
+ }
+}
+
+/// Pulsing now-line marker — separated into its own view so the
+/// auto-reversing animation lives next to the layer it animates without
+/// the parent TimelineView restarting it on the periodic tick.
+private struct NowLineMarker: View {
+ @State private var pulsing: Bool = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Color.clear.frame(height: GuideLayout.timeHeaderHeight)
+ Rectangle()
+ .fill(LinearGradient(
+ colors: [LiveTVTheme.accent, LiveTVTheme.live],
+ startPoint: .top,
+ endPoint: .bottom
+ ))
+ .frame(width: 5)
+ .frame(maxHeight: .infinity)
+ .opacity(pulsing ? 1.0 : 0.6)
+ .shadow(color: LiveTVTheme.live.opacity(0.7), radius: 12, x: 0, y: 0)
+ .animation(
+ .easeInOut(duration: 1.2).repeatForever(autoreverses: true),
+ value: pulsing
+ )
+ }
+ .onAppear { pulsing = true }
+ }
+}
+
+// MARK: - Channel row header (left column)
+
+private struct ChannelRowHeader: View {
+ let channel: LiveTvChannel
+ let serverURL: URL
+ let onTap: () -> Void
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Button(action: onTap) {
+ HStack(spacing: 12) {
+ ChannelLogoView(channel: channel, serverURL: serverURL, maxWidth: 240)
+ .frame(width: 64, height: 44)
+ VStack(alignment: .leading, spacing: 2) {
+ if let number = channel.number, !number.isEmpty {
+ Text(number)
+ .font(LiveTVTypography.channelNumber)
+ .foregroundStyle(LiveTVTheme.accent)
+ }
+ Text(channel.name)
+ .font(LiveTVTypography.channelName)
+ .lineLimit(1)
+ .foregroundStyle(isFocused ? LiveTVTheme.text : LiveTVTheme.secondaryText)
+ }
+ Spacer(minLength: 0)
+ if channel.isFavorite {
+ Image(systemName: "star.fill")
+ .foregroundStyle(LiveTVTheme.accent)
+ .font(.caption)
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ .focusedValue(\.focusedGuideChannel, isFocused ? channel : nil)
+ }
+}
+
+// MARK: - Time header
+
+private struct TimeHeader: View {
+ let windowStart: Date
+ let windowEnd: Date
+
+ var body: some View {
+ let slots = halfHourSlots(from: windowStart, to: windowEnd)
+ ZStack(alignment: .topLeading) {
+ ForEach(slots, id: \.self) { slot in
+ let offset = GuideLayout.offset(
+ forSecondsSinceWindowStart: slot.timeIntervalSince(windowStart)
+ )
+ VStack(alignment: .leading, spacing: 4) {
+ Text(LiveTvFormat.timeFormatter.string(from: slot))
+ .font(LiveTVTypography.timeLabel)
+ .foregroundStyle(LiveTVTheme.text)
+ Rectangle()
+ .fill(LiveTVTheme.divider)
+ .frame(width: 1, height: 10)
+ }
+ .offset(x: offset, y: 16)
+ }
+ }
+ .frame(width: totalWidth, alignment: .topLeading)
+ }
+
+ private var totalWidth: CGFloat {
+ let minutes = windowEnd.timeIntervalSince(windowStart) / 60.0
+ return CGFloat(minutes) * GuideLayout.pixelsPerMinute
+ }
+
+ private func halfHourSlots(from start: Date, to end: Date) -> [Date] {
+ var slots: [Date] = []
+ let calendar = Calendar(identifier: .gregorian)
+ let comps = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: start)
+ var rounded = calendar.date(from: comps) ?? start
+ if let minute = comps.minute, minute > 0 && minute < 30 {
+ rounded = rounded.addingTimeInterval(TimeInterval((30 - minute) * 60))
+ } else if let minute = comps.minute, minute > 30 {
+ rounded = rounded.addingTimeInterval(TimeInterval((60 - minute) * 60))
+ }
+ var slot = rounded
+ while slot < end {
+ slots.append(slot)
+ slot = slot.addingTimeInterval(30 * 60)
+ }
+ return slots
+ }
+}
+
+// MARK: - Single channel lane (row of program cells)
+
+private struct GuideChannelLane: View {
+ let channel: LiveTvChannel
+ let programs: [LiveTvProgram]
+ let windowStart: Date
+ let windowEnd: Date
+ let onSelectProgram: (LiveTvProgram) -> Void
+
+ var body: some View {
+ LazyHStack(alignment: .top, spacing: 0) {
+ if programs.isEmpty {
+ emptyLane
+ } else {
+ ForEach(programs) { program in
+ cell(for: program)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ .frame(height: GuideLayout.rowHeight, alignment: .topLeading)
+ .focusSection()
+ }
+
+ @ViewBuilder
+ private func cell(for program: LiveTvProgram) -> some View {
+ if let start = program.startDate, let end = program.endDate, end > start {
+ let visibleStart = max(start, windowStart)
+ let duration = end.timeIntervalSince(visibleStart)
+ let cellWidth = GuideLayout.width(forDuration: duration)
+ FocusableProgramCell(
+ program: program,
+ width: cellWidth,
+ onSelect: { onSelectProgram(program) }
+ )
+ } else {
+ FocusableProgramCell(
+ program: program,
+ width: GuideLayout.minimumProgramCellWidth,
+ onSelect: { onSelectProgram(program) }
+ )
+ }
+ }
+
+ private var emptyLane: some View {
+ Text("No information")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 16)
+ .frame(width: laneWidth, height: GuideLayout.rowHeight, alignment: .leading)
+ .background(.white.opacity(0.04))
+ }
+
+ private var laneWidth: CGFloat {
+ let minutes = windowEnd.timeIntervalSince(windowStart) / 60.0
+ return CGFloat(minutes) * GuideLayout.pixelsPerMinute
+ }
+}
+
+// MARK: - Program cell (focusable)
+
+private struct FocusableProgramCell: View {
+ let program: LiveTvProgram
+ let width: CGFloat
+ let onSelect: () -> Void
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Button(action: onSelect) {
+ content
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ .focusedValue(\.focusedGuideProgram, isFocused ? program : nil)
+ }
+
+ // Static content lives OUTSIDE any TimelineView so @FocusState changes
+ // are reflected immediately. Only the time-dependent overlay (airing-now
+ // tint, progress bar, LIVE badge) uses its own periodic TimelineView.
+ private var content: some View {
+ ZStack(alignment: .topLeading) {
+ // Focus-driven background tint — responds to @FocusState instantly.
+ focusBackground
+
+ // Static text content: title and time range never change per minute.
+ VStack(alignment: .leading, spacing: 4) {
+ // Badge row placeholder — LIVE badge is rendered in the overlay
+ // below so it stays in sync with the time-keyed airing state.
+ // Premiere/Repeat tags are static metadata and live here.
+ HStack(spacing: 6) {
+ if program.isPremiere == true {
+ tag("PREMIERE", color: .pink)
+ } else if program.isRepeat == true {
+ tag("REPEAT", color: .gray)
+ }
+ }
+
+ Text(program.name)
+ .font(.headline)
+ .lineLimit(2)
+ .foregroundStyle(.primary.opacity(isFocused ? 1.0 : 0.9))
+ if let timeRange = LiveTvFormat.timeRange(start: program.startDate, end: program.endDate) {
+ Text(timeRange)
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(12)
+
+ // Time-dependent overlay: airing-now background tint, LIVE badge,
+ // and progress bar — each rebuild once per minute at most.
+ ProgramLiveOverlay(program: program)
+ }
+ .frame(width: width, height: GuideLayout.rowHeight, alignment: .topLeading)
+ .padding(.horizontal, 2)
+ }
+
+ // Focus-tint layer — reads isFocused directly, never inside a closure.
+ @ViewBuilder
+ private var focusBackground: some View {
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color.white.opacity(isFocused ? 0.18 : 0.08))
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(isFocused ? .white.opacity(0.6) : .white.opacity(0.10), lineWidth: 1)
+ )
+ }
+
+ private func tag(_ text: String, color: Color) -> some View {
+ Text(text)
+ .font(.caption2.weight(.heavy))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(color, in: Capsule())
+ }
+}
+
+// MARK: - Live overlay (time-dependent parts only)
+
+/// Renders the airing-now background tint, LIVE badge, and progress bar.
+/// Contains its own small TimelineView so only these time-driven elements
+/// rebuild on the periodic tick — the surrounding static cell content is
+/// unaffected.
+private struct ProgramLiveOverlay: View {
+ let program: LiveTvProgram
+
+ var body: some View {
+ TimelineView(.periodic(from: .now, by: 60)) { context in
+ let now = context.date
+ let isAiringNow: Bool = {
+ guard let start = program.startDate, let end = program.endDate else { return false }
+ return now >= start && now < end
+ }()
+ let progress = LiveTvFormat.progressFraction(
+ start: program.startDate,
+ end: program.endDate,
+ now: now
+ )
+
+ ZStack(alignment: .topLeading) {
+ // Airing-now background tint layer (below the badge/progress).
+ if isAiringNow {
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color.accentColor.opacity(0.30))
+ }
+
+ // LIVE badge in the top-left badge row position.
+ if isAiringNow {
+ HStack(spacing: 6) {
+ LiveBadge(label: "LIVE")
+ }
+ .padding(12)
+ }
+
+ // Progress bar pinned to the bottom.
+ if isAiringNow, let progress {
+ GeometryReader { geo in
+ Rectangle()
+ .fill(LiveTVTheme.live.opacity(0.8))
+ .frame(width: geo.size.width * progress, height: 3)
+ .frame(maxHeight: .infinity, alignment: .bottom)
+ }
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Focused-program footer
+
+private struct FocusedProgramFooter: View {
+ let program: LiveTvProgram?
+ let channel: LiveTvChannel?
+ let serverURL: URL
+
+ var body: some View {
+ Group {
+ if let program {
+ HStack(alignment: .top, spacing: 16) {
+ if let channel {
+ ChannelLogoView(channel: channel, serverURL: serverURL, maxWidth: 240)
+ .frame(width: 80, height: 56)
+ }
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 8) {
+ Text(program.name)
+ .font(.title3.weight(.semibold))
+ .lineLimit(1)
+ if let year = program.productionYear {
+ Text(String(year))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ if let rating = program.officialRating {
+ Text(rating)
+ .font(.caption.weight(.semibold))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 1)
+ .background(.white.opacity(0.15), in: RoundedRectangle(cornerRadius: 4))
+ }
+ }
+ if let episodeTitle = program.episodeTitle {
+ Text(episodeTitle)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ if let overview = program.overview {
+ Text(overview)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 60)
+ .padding(.vertical, 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.ultraThinMaterial)
+ } else {
+ Color.clear.frame(height: 1)
+ }
+ }
+ .animation(.easeInOut(duration: 0.18), value: program?.id)
+ }
+}
+
+// MARK: - Filter pill bar
+
+struct CategoryFilterBar: View {
+ let selected: GuideCategory
+ let onSelect: (GuideCategory) -> Void
+
+ var body: some View {
+ HStack(spacing: 12) {
+ ForEach(GuideCategory.allCases) { category in
+ FilterPill(
+ title: category.title,
+ icon: category.icon,
+ isSelected: category == selected
+ ) {
+ onSelect(category)
+ }
+ }
+ }
+ .focusSection()
+ }
+}
+
+private struct FilterPill: View {
+ let title: String
+ let icon: String
+ let isSelected: Bool
+ let action: () -> Void
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 8) {
+ Image(systemName: icon)
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(background, in: Capsule())
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ }
+
+ private var background: Color {
+ if isSelected { return LiveTVTheme.accent.opacity(0.7) }
+ if isFocused { return Color.white.opacity(0.18) }
+ return LiveTVTheme.surface
+ }
+}
+
+// MARK: - Focus values
+
+struct FocusedGuideProgramKey: FocusedValueKey {
+ typealias Value = LiveTvProgram
+}
+
+struct FocusedGuideChannelKey: FocusedValueKey {
+ typealias Value = LiveTvChannel
+}
+
+extension FocusedValues {
+ var focusedGuideProgram: LiveTvProgram? {
+ get { self[FocusedGuideProgramKey.self] }
+ set { self[FocusedGuideProgramKey.self] = newValue }
+ }
+ var focusedGuideChannel: LiveTvChannel? {
+ get { self[FocusedGuideChannelKey.self] }
+ set { self[FocusedGuideChannelKey.self] = newValue }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/GuideContent.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/GuideContent.swift
new file mode 100644
index 0000000..363d9ee
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/GuideContent.swift
@@ -0,0 +1,36 @@
+import Foundation
+import JellyfinAPI
+
+/// The fully-loaded EPG snapshot powering `GuideView`.
+public struct GuideContent: Equatable, Sendable {
+ public let serverURL: URL
+ public let windowStart: Date
+ public let windowEnd: Date
+ public let channels: [LiveTvChannel]
+ /// Programs grouped by `channelId`. Channels with no programs in the
+ /// window are absent from this dictionary (callers should fall back to an
+ /// empty array via `programs(for:)`).
+ public let programsByChannel: [String: [LiveTvProgram]]
+
+ public init(
+ serverURL: URL,
+ windowStart: Date,
+ windowEnd: Date,
+ channels: [LiveTvChannel],
+ programsByChannel: [String: [LiveTvProgram]]
+ ) {
+ self.serverURL = serverURL
+ self.windowStart = windowStart
+ self.windowEnd = windowEnd
+ self.channels = channels
+ self.programsByChannel = programsByChannel
+ }
+
+ public func programs(for channelId: String) -> [LiveTvProgram] {
+ programsByChannel[channelId] ?? []
+ }
+
+ public var isEmpty: Bool {
+ channels.isEmpty
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/GuideLayout.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/GuideLayout.swift
new file mode 100644
index 0000000..60c5fa3
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/GuideLayout.swift
@@ -0,0 +1,43 @@
+import Foundation
+import CoreGraphics
+
+/// Layout constants shared between `GuideModel` and `GuideView`.
+public enum GuideLayout {
+ /// Horizontal pixel density of the time grid: 8 points per minute.
+ /// 12 hours of programming = 12 * 60 * 8 = 5760 pt of grid width.
+ public static let pixelsPerMinute: CGFloat = 8
+
+ /// Width of the sticky channel column on the left.
+ public static let channelColumnWidth: CGFloat = 240
+
+ /// Height of every channel row in the grid. Channel column cells and program
+ /// rows must use this same height to stay aligned.
+ public static let rowHeight: CGFloat = 100
+
+ /// Height of the time-of-day header strip above the program grid.
+ public static let timeHeaderHeight: CGFloat = 60
+
+ /// Minimum width of a single program cell. Programs shorter than this number
+ /// of minutes are widened so the title remains legible.
+ public static let minimumProgramCellWidth: CGFloat = 60
+
+ /// How far back from "now" we ask the server for programs, so that programs
+ /// already in progress at the window start are included. Jellyfin's
+ /// `MinStartDate` filter is "programs starting after this time", so we widen
+ /// the lower bound to catch in-progress programs.
+ public static let pastWindowSeconds: TimeInterval = 4 * 3600
+
+ /// How far ahead of "now" the visible time window extends. The default 12h
+ /// is fetched in a single request.
+ public static let futureWindowSeconds: TimeInterval = 12 * 3600
+
+ /// Convenience: convert a duration in seconds to grid points.
+ public static func width(forDuration seconds: TimeInterval) -> CGFloat {
+ max(minimumProgramCellWidth, CGFloat(seconds / 60.0) * pixelsPerMinute)
+ }
+
+ /// Convenience: convert an offset from `windowStart` (in seconds) to grid points.
+ public static func offset(forSecondsSinceWindowStart seconds: TimeInterval) -> CGFloat {
+ max(0, CGFloat(seconds / 60.0) * pixelsPerMinute)
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/GuideModel.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/GuideModel.swift
new file mode 100644
index 0000000..9d20f47
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/GuideModel.swift
@@ -0,0 +1,161 @@
+import Foundation
+import Observation
+import JellyfinAPI
+
+/// Top-level filter on the EPG guide. Drives both the channel-list query
+/// (`isMovie` / `isSports` / etc.) and the chrome of `CategoryFilterBar`.
+public enum GuideCategory: String, CaseIterable, Sendable, Identifiable {
+ case all
+ case favorites
+ case movies
+ case sports
+ case news
+ case kids
+
+ public var id: String { rawValue }
+
+ public var title: String {
+ switch self {
+ case .all: return "All"
+ case .favorites: return "Favorites"
+ case .movies: return "Movies"
+ case .sports: return "Sports"
+ case .news: return "News"
+ case .kids: return "Kids"
+ }
+ }
+
+ public var icon: String {
+ switch self {
+ case .all: return "square.grid.2x2"
+ case .favorites: return "star.fill"
+ case .movies: return "film"
+ case .sports: return "sportscourt"
+ case .news: return "newspaper"
+ case .kids: return "figure.and.child.holdinghands"
+ }
+ }
+
+ public var channelFilters: LiveTvChannelFilters {
+ switch self {
+ case .all: return .default
+ case .favorites: return .favorites
+ case .movies: return .movies
+ case .sports: return .sports
+ case .news: return .news
+ case .kids: return .kids
+ }
+ }
+}
+
+@MainActor
+@Observable
+public final class GuideModel {
+ public enum State: Equatable, Sendable {
+ case loading
+ case loaded(GuideContent)
+ case failed(String)
+ }
+
+ public private(set) var state: State = .loading
+ public private(set) var categoryFilter: GuideCategory = .all
+
+ private let client: any JellyfinClientAPI
+ private let store: EPGStore
+ private let now: @Sendable () -> Date
+
+ public init(
+ client: any JellyfinClientAPI,
+ store: EPGStore,
+ now: @escaping @Sendable () -> Date = { Date() }
+ ) {
+ self.client = client
+ self.store = store
+ self.now = now
+ }
+
+ public func load() async {
+ await load(filter: categoryFilter)
+ }
+
+ public func applyFilter(_ filter: GuideCategory) async {
+ categoryFilter = filter
+ await load(filter: filter)
+ }
+
+ private func load(filter: GuideCategory) async {
+ JellytvLog.liveTV.info("GuideModel.load(filter: \(filter.rawValue, privacy: .public))")
+ state = .loading
+
+ guard let serverURL = await client.currentServerURL() else {
+ JellytvLog.liveTV.error("GuideModel.load: not signed in (no serverURL)")
+ state = .failed("Not signed in")
+ return
+ }
+
+ let windowStart = now()
+ let windowEnd = windowStart.addingTimeInterval(GuideLayout.futureWindowSeconds)
+ let fetchStart = windowStart.addingTimeInterval(-GuideLayout.pastWindowSeconds)
+
+ do {
+ let channels: [LiveTvChannel]
+ if filter == .all {
+ channels = try await store.channels()
+ } else {
+ channels = try await store.channels(
+ filters: filter.channelFilters,
+ addCurrentProgram: false
+ )
+ }
+ let validChannelIds = Set(channels.map(\.id))
+ let programs: [LiveTvProgram]
+ if channels.isEmpty {
+ programs = []
+ } else {
+ programs = try await client.liveTvPrograms(
+ channelIds: channels.map(\.id),
+ minStartDate: fetchStart,
+ maxStartDate: windowEnd
+ )
+ }
+
+ var grouped: [String: [LiveTvProgram]] = [:]
+ for program in programs {
+ guard let channelId = program.channelId,
+ validChannelIds.contains(channelId) else { continue }
+ if let endDate = program.endDate, endDate <= windowStart { continue }
+ grouped[channelId, default: []].append(program)
+ }
+ for (channelId, list) in grouped {
+ grouped[channelId] = list.sorted { lhs, rhs in
+ (lhs.startDate ?? .distantPast) < (rhs.startDate ?? .distantPast)
+ }
+ }
+
+ let content = GuideContent(
+ serverURL: serverURL,
+ windowStart: windowStart,
+ windowEnd: windowEnd,
+ channels: channels,
+ programsByChannel: grouped
+ )
+ JellytvLog.liveTV.info("GuideModel.load: loaded \(channels.count) channels, \(programs.count) programs")
+ state = .loaded(content)
+ } catch JellyfinError.network {
+ JellytvLog.liveTV.error("GuideModel.load: network failure")
+ state = .failed("Couldn't reach the server.")
+ } catch JellyfinError.unauthenticated {
+ JellytvLog.liveTV.error("GuideModel.load: unauthenticated")
+ state = .failed("Session expired. Please sign in again.")
+ } catch {
+ JellytvLog.liveTV.error("GuideModel.load: \(String(describing: error), privacy: .public)")
+ state = .failed("Something went wrong: \(error)")
+ }
+ }
+
+ /// Resolves the live playback URL for a channel. Delegates to the
+ /// underlying client so that token + URL construction stay encapsulated.
+ public func openStream(channelId: String) async throws -> LiveStreamPlayback {
+ try await client.liveTvOpenStream(channelId: channelId)
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/GuideView.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/GuideView.swift
new file mode 100644
index 0000000..3d32292
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/GuideView.swift
@@ -0,0 +1,189 @@
+import SwiftUI
+import JellyfinAPI
+import DesignSystem
+
+/// Plex-style EPG guide. Channels run as rows; the time grid runs as a
+/// horizontally-scrolling lane on the right. Programs are focusable so users
+/// can drill into a `ProgramDetailView` from the guide. A category-filter
+/// pill bar at the top scopes the channel list (All / Favorites / Movies /
+/// Sports / News / Kids).
+///
+/// Selection is callback-driven so this view can be embedded inside the
+/// `LiveTVRootView` tab shell, which centralizes player + detail
+/// presentation.
+public struct GuideView: View {
+ @Bindable var model: GuideModel
+ let onWatchChannel: (LiveTvChannel) -> Void
+ let onSelectProgram: (LiveTvProgram) -> Void
+ @Binding var lastWatchedChannelId: String?
+
+ public init(
+ model: GuideModel,
+ onWatchChannel: @escaping (LiveTvChannel) -> Void = { _ in },
+ onSelectProgram: @escaping (LiveTvProgram) -> Void = { _ in },
+ lastWatchedChannelId: Binding = .constant(nil)
+ ) {
+ self.model = model
+ self.onWatchChannel = onWatchChannel
+ self.onSelectProgram = onSelectProgram
+ self._lastWatchedChannelId = lastWatchedChannelId
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ CategoryFilterBar(
+ selected: model.categoryFilter,
+ onSelect: { filter in
+ Task { await model.applyFilter(filter) }
+ }
+ )
+ .padding(.horizontal, 60)
+ .padding(.top, 30)
+ .padding(.bottom, 16)
+
+ content
+ }
+ .task {
+ if case .loading = model.state {
+ await model.load()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ Group {
+ switch model.state {
+ case .loading:
+ loadingState
+ case .loaded(let snapshot):
+ if snapshot.isEmpty {
+ emptyState
+ } else {
+ GuideGridView(
+ content: snapshot,
+ onWatchChannel: onWatchChannel,
+ onSelectProgram: onSelectProgram,
+ lastWatchedChannelId: $lastWatchedChannelId
+ )
+ }
+ case .failed(let message):
+ failedView(message)
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: isLoading)
+ }
+
+ private var isLoading: Bool {
+ if case .loading = model.state { return true }
+ return false
+ }
+
+ private var loadingState: some View {
+ GuideSkeletonView()
+ }
+
+ private var emptyState: some View {
+ VStack(spacing: 24) {
+ Image(systemName: "tv.slash")
+ .font(.system(size: 80))
+ .foregroundStyle(.secondary)
+ Text("No channels match this filter")
+ .font(.title)
+ Text("Try a different category, or check that your Jellyfin server has Live TV configured.")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ Button("Reload") {
+ Task { await model.load() }
+ }
+ }
+ .padding(60)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private func failedView(_ message: String) -> some View {
+ VStack(spacing: 24) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.system(size: 60))
+ .foregroundStyle(.secondary)
+ Text(message)
+ .font(.title2)
+ .multilineTextAlignment(.center)
+ Button("Retry") {
+ Task { await model.load() }
+ }
+ }
+ .padding(40)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+// MARK: - Guide skeleton
+
+private struct GuideSkeletonView: View {
+ private let rowCount = 8
+
+ var body: some View {
+ ScrollView(.vertical, showsIndicators: false) {
+ HStack(alignment: .top, spacing: 0) {
+ // Channel column placeholder
+ channelColumnSkeleton
+
+ // Program lane placeholder
+ programLaneSkeleton
+ }
+ }
+ .scrollClipDisabled()
+ .focusable(false)
+ .allowsHitTesting(false)
+ .transition(.opacity)
+ }
+
+ private var channelColumnSkeleton: some View {
+ VStack(spacing: 0) {
+ // Time header spacer
+ Color.clear.frame(height: GuideLayout.timeHeaderHeight)
+ ForEach(0.. LiveStreamPlayback
+ public let closeStream: @Sendable (String) async -> Void
+ public let onDismiss: () -> Void
+ public let onChannelChanged: (LiveTvChannel) -> Void
+
+ public init(
+ initialChannel: LiveTvChannel,
+ channels: [LiveTvChannel],
+ serverURL: URL,
+ initialProgram: LiveTvProgram?,
+ openStream: @escaping @Sendable (LiveTvChannel, _ forceTranscoding: Bool) async throws -> LiveStreamPlayback,
+ closeStream: @escaping @Sendable (String) async -> Void,
+ onDismiss: @escaping () -> Void,
+ onChannelChanged: @escaping (LiveTvChannel) -> Void
+ ) {
+ self.initialChannel = initialChannel
+ self.channels = channels
+ self.serverURL = serverURL
+ self.initialProgram = initialProgram
+ self.openStream = openStream
+ self.closeStream = closeStream
+ self.onDismiss = onDismiss
+ self.onChannelChanged = onChannelChanged
+ }
+
+ public var body: some View {
+ #if os(tvOS)
+ TVOSPlayerHost(
+ initialChannel: initialChannel,
+ channels: channels,
+ serverURL: serverURL,
+ initialProgram: initialProgram,
+ openStream: openStream,
+ closeStream: closeStream,
+ onDismiss: onDismiss,
+ onChannelChanged: onChannelChanged
+ )
+ .ignoresSafeArea()
+ #else
+ macOSStub
+ #endif
+ }
+
+ #if !os(tvOS)
+ private var macOSStub: some View {
+ VStack(spacing: 16) {
+ Text("Live TV playback is tvOS only.")
+ .font(.title2)
+ Button("Dismiss") { onDismiss() }
+ }
+ .padding(60)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ #endif
+}
+
+#if os(tvOS)
+
+/// tvOS-only inner view that owns the `@State` `PlayerViewModel` and the
+/// `AVKitPlayerHost`. Renders AVPlayerViewController via a representable +
+/// layered SwiftUI overlays per state.
+@MainActor
+private struct TVOSPlayerHost: View {
+ let initialChannel: LiveTvChannel
+ let channels: [LiveTvChannel]
+ let serverURL: URL
+ let initialProgram: LiveTvProgram?
+ let openStream: @Sendable (LiveTvChannel, Bool) async throws -> LiveStreamPlayback
+ let closeStream: @Sendable (String) async -> Void
+ let onDismiss: () -> Void
+ let onChannelChanged: (LiveTvChannel) -> Void
+
+ @State private var host: AVKitPlayerHost = AVKitPlayerHost()
+ @State private var viewModel: PlayerViewModel? = nil
+
+ var body: some View {
+ ZStack {
+ Color.black.ignoresSafeArea()
+
+ // AVPlayerViewController layer — visible whenever we have a
+ // playback URL committed (splash overlays it during warm-up,
+ // playing shows it directly, reconnecting overlays a toast).
+ AVPVCRepresentable(host: host)
+
+ if let viewModel {
+ overlayLayers(for: viewModel)
+ }
+ }
+ .task {
+ await ensureViewModel()
+ }
+ .onDisappear {
+ // Backstop: if any dismissal path bypasses onDismissRequested
+ // (e.g. programmatic sheet dismissal), dismiss() is idempotent.
+ viewModel?.dismiss()
+ }
+ .onChange(of: viewModel?.state) { _, newState in
+ if let channel = newState?.channel {
+ onChannelChanged(channel)
+ }
+ // Keep AVPVC's external metadata in sync with current channel/program.
+ if let channel = newState?.channel {
+ host.controller.player?.currentItem?.externalMetadata =
+ LiveTVMetadata.make(channel: channel, program: viewModel?.currentProgram)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func overlayLayers(for viewModel: PlayerViewModel) -> some View {
+ // Splash (resolving / splash / buffering)
+ if viewModel.state.showsSplash, let channel = viewModel.state.channel {
+ ChannelSplashView(
+ channel: channel,
+ serverURL: serverURL,
+ program: viewModel.currentProgram
+ )
+ .transition(.opacity)
+ }
+
+ // Reconnecting toast over playing video
+ if case .reconnecting = viewModel.state {
+ ReconnectingToast(isVisible: true)
+ }
+
+ // Channel-info HUD over playing video
+ if case .playing = viewModel.state, let channel = viewModel.state.channel {
+ ChannelInfoHUD(
+ channel: channel,
+ serverURL: serverURL,
+ program: viewModel.currentProgram,
+ isVisible: viewModel.hudVisible
+ )
+ }
+
+ // Error card replaces everything else
+ if case .error(let channel, let message, let detail) = viewModel.state {
+ PlayerErrorCard(
+ channel: channel,
+ message: message,
+ detail: detail,
+ onRetry: { Task { await viewModel.retry() } },
+ onDismiss: {
+ viewModel.dismiss()
+ onDismiss()
+ }
+ )
+ .transition(.opacity)
+ }
+ }
+
+ private func ensureViewModel() async {
+ if viewModel != nil { return }
+ let net = NWPathNetworkMonitor()
+ let vm = PlayerViewModel(
+ initialChannel: initialChannel,
+ channels: channels,
+ serverURL: serverURL,
+ program: initialProgram,
+ openStream: { channel, force in try await openStream(channel, force) },
+ closeStream: { id in await closeStream(id) },
+ host: host,
+ networkMonitor: net
+ )
+ // Wire channel up/down from the host controller to the view model.
+ host.onChannelUp = { [weak vm] in vm?.channelUp() }
+ host.onChannelDown = { [weak vm] in vm?.channelDown() }
+ // Wire Menu-button dismiss so the stream is closed before the
+ // AVPlayerViewController disappears. dismiss() is idempotent, so
+ // the .onDisappear backstop is safe to also call it.
+ host.controller.onDismissRequested = { [weak vm] in
+ vm?.dismiss()
+ onDismiss()
+ }
+ viewModel = vm
+ }
+}
+
+private struct AVPVCRepresentable: UIViewControllerRepresentable {
+ let host: AVKitPlayerHost
+
+ func makeUIViewController(context: Context) -> PlayerHostingController {
+ host.controller
+ }
+
+ func updateUIViewController(_ controller: PlayerHostingController, context: Context) {}
+
+ static func dismantleUIViewController(
+ _ controller: PlayerHostingController,
+ coordinator: ()
+ ) {
+ // PlayerViewModel.dismiss() handles host.tearDown(); nothing to do here.
+ }
+}
+
+/// Builds `AVPlayerItem.externalMetadata` so the tvOS press-up info panel
+/// shows program title / channel / overview / genre — the same affordance
+/// Plex Live TV provides via the OSD. AVKit lays this out for free.
+enum LiveTVMetadata {
+ static func make(channel: LiveTvChannel, program: LiveTvProgram?) -> [AVMetadataItem] {
+ var items: [AVMetadataItem] = []
+ let title = program?.name ?? channel.name
+ items.append(metadata(identifier: .commonIdentifierTitle, value: title))
+
+ var subtitleParts: [String] = []
+ if let number = channel.number, !number.isEmpty {
+ subtitleParts.append("CH \(number)")
+ }
+ subtitleParts.append(channel.name)
+ if let episodeTitle = program?.episodeTitle, !episodeTitle.isEmpty {
+ subtitleParts.append(episodeTitle)
+ }
+ items.append(metadata(
+ identifier: .iTunesMetadataTrackSubTitle,
+ value: subtitleParts.joined(separator: " · ")
+ ))
+
+ if let overview = program?.overview, !overview.isEmpty {
+ items.append(metadata(identifier: .commonIdentifierDescription, value: overview))
+ }
+ if let genres = program?.genres, !genres.isEmpty {
+ items.append(metadata(identifier: .quickTimeMetadataGenre, value: genres.joined(separator: ", ")))
+ }
+ if let year = program?.productionYear {
+ items.append(metadata(identifier: .commonIdentifierCreationDate, value: String(year)))
+ }
+ return items
+ }
+
+ private static func metadata(identifier: AVMetadataIdentifier, value: String) -> AVMetadataItem {
+ let item = AVMutableMetadataItem()
+ item.identifier = identifier
+ item.value = value as NSString
+ item.extendedLanguageTag = "und"
+ return item.copy() as! AVMetadataItem
+ }
+}
+
+#endif
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/LiveTVRootView.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/LiveTVRootView.swift
new file mode 100644
index 0000000..23bc747
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/LiveTVRootView.swift
@@ -0,0 +1,163 @@
+import SwiftUI
+import JellyfinAPI
+import DesignSystem
+
+/// Top-level Live TV experience and the app's whole signed-in UI: two tabs —
+/// Home (live-first Marquee hero + On Now shelf) and Live (the playbill).
+/// The full guide grid is reachable from the Home hero's Guide button as a
+/// cover. Each tab owns an `@Observable` model pulling through the shared
+/// `EPGStore` so channel data is fetched once and reused.
+///
+/// The per-tab models are held as `@State` so they survive `body`
+/// re-evaluations across tab changes — recreating them inline would discard
+/// loaded content every render.
+public struct LiveTVRootView: View {
+ public let client: any JellyfinClientAPI
+
+ @State private var epgStore: EPGStore
+ @State private var onNowModel: OnNowModel
+ @State private var playbillModel: PlaybillModel
+ @State private var guideModel: GuideModel
+ @State private var selectedChannel: LiveTvChannel?
+ @State private var selectedProgram: LiveTvProgram?
+ @State private var showGuide = false
+ @State private var serverURL: URL?
+
+ /// Last-watched channel id, persisted across launches so the Home hero
+ /// can greet the user with "You were watching".
+ @AppStorage("jellytv.lastWatchedChannelId") private var lastWatchedStorage: String = ""
+
+ public init(client: any JellyfinClientAPI) {
+ self.client = client
+ let store = EPGStore(client: client)
+ _epgStore = State(initialValue: store)
+ _onNowModel = State(initialValue: OnNowModel(client: client, store: store))
+ _playbillModel = State(initialValue: PlaybillModel(store: store))
+ _guideModel = State(initialValue: GuideModel(client: client, store: store))
+ }
+
+ /// Bridge `@AppStorage`'s non-optional String to the Binding the
+ /// guide and player presentations expect ("" means none).
+ private var lastWatchedChannelId: Binding {
+ Binding(
+ get: { lastWatchedStorage.isEmpty ? nil : lastWatchedStorage },
+ set: { lastWatchedStorage = $0 ?? "" }
+ )
+ }
+
+ public var body: some View {
+ TabView {
+ MarqueeHomeView(
+ model: onNowModel,
+ lastWatchedChannelId: lastWatchedChannelId.wrappedValue,
+ onWatchChannel: { selectedChannel = $0 },
+ onOpenGuide: { showGuide = true }
+ )
+ .tabItem {
+ Label("Home", systemImage: "house.fill")
+ }
+
+ PlaybillView(
+ model: playbillModel,
+ lastWatchedChannelId: lastWatchedChannelId.wrappedValue,
+ onWatchChannel: { selectedChannel = $0 }
+ )
+ .tabItem {
+ Label("Live", systemImage: "dot.radiowaves.left.and.right")
+ }
+ }
+ .task {
+ // Resolve server URL once for the player. Pre-warm the unfiltered
+ // channel list so the zap list is ready before the first tab loads.
+ serverURL = await client.currentServerURL()
+ await epgStore.prewarm()
+ // After both serverURL and unfilteredChannels are available,
+ // kick off a background dominant-color pre-warm so channel
+ // splash backgrounds render instantly on first zap.
+ if let url = serverURL {
+ epgStore.prewarmDominantColors(serverURL: url)
+ }
+ }
+ .modifier(GuideCover(isPresented: $showGuide) {
+ ZStack {
+ LiveTVTheme.background.ignoresSafeArea()
+ GuideView(
+ model: guideModel,
+ onWatchChannel: { channel in
+ showGuide = false
+ selectedChannel = channel
+ },
+ onSelectProgram: { program in
+ showGuide = false
+ selectedProgram = program
+ },
+ lastWatchedChannelId: lastWatchedChannelId
+ )
+ }
+ })
+ .modifier(LiveTVPresentations(
+ selectedChannel: $selectedChannel,
+ selectedProgram: $selectedProgram,
+ channels: epgStore.unfilteredChannels,
+ serverURL: serverURL,
+ lastWatchedChannelId: lastWatchedChannelId,
+ client: client
+ ))
+ }
+}
+
+/// Full-screen guide presentation on tvOS; falls back to a sheet on other
+/// platforms (the package also builds for macOS in `swift test`).
+private struct GuideCover: ViewModifier {
+ @Binding var isPresented: Bool
+ @ViewBuilder let coverContent: () -> CoverContent
+
+ init(isPresented: Binding, @ViewBuilder content: @escaping () -> CoverContent) {
+ self._isPresented = isPresented
+ self.coverContent = content
+ }
+
+ func body(content: Content) -> some View {
+ #if os(tvOS)
+ content.fullScreenCover(isPresented: $isPresented, content: coverContent)
+ #else
+ content.sheet(isPresented: $isPresented, content: coverContent)
+ #endif
+ }
+}
+
+/// Centralized full-screen / sheet presentation so each tab doesn't need to
+/// own player + program-detail navigation independently.
+private struct LiveTVPresentations: ViewModifier {
+ @Binding var selectedChannel: LiveTvChannel?
+ @Binding var selectedProgram: LiveTvProgram?
+ let channels: [LiveTvChannel]
+ let serverURL: URL?
+ @Binding var lastWatchedChannelId: String?
+ let client: any JellyfinClientAPI
+
+ func body(content: Content) -> some View {
+ content
+ .modifier(ChannelPlayerPresentation(
+ selectedChannel: $selectedChannel,
+ channels: channels,
+ serverURL: serverURL ?? URL(string: "about:blank")!,
+ program: nil,
+ openStream: { [client] channel, force in
+ try await client.liveTvOpenStream(channelId: channel.id, forceTranscoding: force)
+ },
+ closeStream: { [client] id in
+ try? await client.liveTvCloseStream(liveStreamId: id)
+ },
+ lastWatchedChannelId: $lastWatchedChannelId
+ ))
+ .modifier(ProgramDetailPresentation(
+ selectedProgram: $selectedProgram,
+ client: client,
+ onWatchChannel: { channel in
+ selectedProgram = nil
+ selectedChannel = channel
+ }
+ ))
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Marquee/MarqueeHomeView.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Marquee/MarqueeHomeView.swift
new file mode 100644
index 0000000..cb9bd17
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Marquee/MarqueeHomeView.swift
@@ -0,0 +1,381 @@
+import SwiftUI
+import JellyfinAPI
+import DesignSystem
+import NukeUI
+
+/// The Home tab, live-first: a full-bleed hero for the channel you were last
+/// watching (falling back to the best on-now pick), then an "On Now" shelf of
+/// typographic landscape cards with progress hairlines. Reuses `OnNowModel`'s
+/// loaded content; the hero Watch button tunes straight in, Guide opens the
+/// full grid.
+public struct MarqueeHomeView: View {
+ @Bindable var model: OnNowModel
+ let lastWatchedChannelId: String?
+ let onWatchChannel: (LiveTvChannel) -> Void
+ let onOpenGuide: () -> Void
+
+ public init(
+ model: OnNowModel,
+ lastWatchedChannelId: String? = nil,
+ onWatchChannel: @escaping (LiveTvChannel) -> Void = { _ in },
+ onOpenGuide: @escaping () -> Void = {}
+ ) {
+ self.model = model
+ self.lastWatchedChannelId = lastWatchedChannelId
+ self.onWatchChannel = onWatchChannel
+ self.onOpenGuide = onOpenGuide
+ }
+
+ public var body: some View {
+ ZStack {
+ LiveTVTheme.background.ignoresSafeArea()
+ switch model.state {
+ case .loading:
+ HomeSkeleton()
+ case .failed(let message):
+ MarqueeErrorView(message: message) {
+ Task { await model.load() }
+ }
+ case .loaded(let content):
+ if content.isEmpty {
+ MarqueeErrorView(message: "Nothing is airing right now.", retryLabel: "Reload") {
+ Task { await model.load() }
+ }
+ } else {
+ loaded(content)
+ }
+ }
+ }
+ .task {
+ if case .loading = model.state { await model.load() }
+ }
+ }
+
+ // MARK: - Loaded layout
+
+ private func loaded(_ content: OnNowContent) -> some View {
+ let hero = resolveHero(content)
+ return ZStack(alignment: .bottomLeading) {
+ backdrop(for: hero, serverURL: content.serverURL)
+ VStack(alignment: .leading, spacing: 0) {
+ Spacer()
+ if let hero {
+ HomeHero(
+ channel: hero,
+ isLastWatched: hero.id == lastWatchedChannelId,
+ onWatch: { onWatchChannel(hero) },
+ onGuide: onOpenGuide
+ )
+ .padding(.horizontal, 80)
+ }
+ OnNowShelf(
+ channels: shelfChannels(content, hero: hero),
+ onWatchChannel: onWatchChannel
+ )
+ .padding(.top, 44)
+ .padding(.bottom, 60)
+ }
+ }
+ }
+
+ /// Last-watched channel if it's still airing something we know about,
+ /// otherwise the content's default hero pick.
+ private func resolveHero(_ content: OnNowContent) -> LiveTvChannel? {
+ if let id = lastWatchedChannelId {
+ let pools = [content.onNow, content.favorites, content.movies, content.sports, content.news, content.kids]
+ for pool in pools {
+ if let match = pool.first(where: { $0.id == id }) { return match }
+ }
+ }
+ return content.heroChannel
+ }
+
+ /// Shelf = on-now lineup (favorites first), hero excluded so the first
+ /// card isn't a duplicate of the thing above it.
+ private func shelfChannels(_ content: OnNowContent, hero: LiveTvChannel?) -> [LiveTvChannel] {
+ let combined = content.favorites + content.onNow
+ var seen = Set()
+ return combined.filter { channel in
+ guard channel.id != hero?.id, !seen.contains(channel.id) else { return false }
+ seen.insert(channel.id)
+ return true
+ }
+ }
+
+ @ViewBuilder
+ private func backdrop(for hero: LiveTvChannel?, serverURL: URL) -> some View {
+ GeometryReader { geo in
+ ZStack {
+ if let url = hero?.currentProgram?.backdropURL(serverURL: serverURL, maxWidth: 1920) {
+ LazyImage(url: url) { state in
+ if let image = state.image {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .transition(.opacity)
+ } else {
+ Color.clear
+ }
+ }
+ .animation(.easeInOut(duration: 0.4), value: hero?.id)
+ }
+ // Heavy monochrome scrims: keep the artwork present but quiet.
+ LinearGradient(
+ stops: [
+ .init(color: LiveTVTheme.background.opacity(0.55), location: 0),
+ .init(color: LiveTVTheme.background.opacity(0.25), location: 0.3),
+ .init(color: LiveTVTheme.background.opacity(0.6), location: 0.55),
+ .init(color: LiveTVTheme.background.opacity(0.98), location: 0.85),
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ LinearGradient(
+ colors: [LiveTVTheme.background.opacity(0.75), .clear],
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ }
+ .frame(width: geo.size.width, height: geo.size.height)
+ .clipped()
+ }
+ .ignoresSafeArea()
+ }
+}
+
+// MARK: - Hero
+
+private struct HomeHero: View {
+ let channel: LiveTvChannel
+ let isLastWatched: Bool
+ let onWatch: () -> Void
+ let onGuide: () -> Void
+
+ var body: some View {
+ let program = channel.currentProgram
+ VStack(alignment: .leading, spacing: 0) {
+ Text(kicker)
+ .font(LiveTVTypography.kicker)
+ .tracking(7)
+ .textCase(.uppercase)
+ .foregroundStyle(LiveTVTheme.secondaryText)
+ Text(program?.name ?? channel.name)
+ .font(LiveTVTypography.heroDisplay)
+ .foregroundStyle(LiveTVTheme.text)
+ .lineLimit(2)
+ .padding(.top, 22)
+ metaRow(program: program)
+ .padding(.top, 18)
+ HStack(spacing: 22) {
+ MarqueeButton(title: "Watch", isPrimary: true, action: onWatch)
+ MarqueeButton(title: "Guide", isPrimary: false, action: onGuide)
+ }
+ .padding(.top, 34)
+ .focusSection()
+ }
+ }
+
+ private var kicker: String {
+ let prefix = isLastWatched ? "You were watching" : "On now"
+ if let number = channel.number {
+ return "\(prefix) · Channel \(number)"
+ }
+ return "\(prefix) · \(channel.name)"
+ }
+
+ @ViewBuilder
+ private func metaRow(program: LiveTvProgram?) -> some View {
+ HStack(spacing: 30) {
+ Text(channel.name)
+ if let range = LiveTvFormat.timeRange(start: program?.startDate, end: program?.endDate) {
+ Text(range)
+ }
+ if program?.isLive == true {
+ HStack(spacing: 10) {
+ Circle().fill(LiveTVTheme.live).frame(width: 9, height: 9)
+ Text("Live")
+ }
+ }
+ }
+ .font(.system(size: 24))
+ .foregroundStyle(LiveTVTheme.secondaryText)
+ }
+}
+
+/// Low-key hero action button: tracked uppercase label, primary = filled ink,
+/// secondary = hairline outline. Focus handling stays native (.card).
+private struct MarqueeButton: View {
+ let title: String
+ let isPrimary: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(title)
+ .font(.system(size: 22, weight: .semibold))
+ .tracking(5)
+ .textCase(.uppercase)
+ .foregroundStyle(isPrimary ? LiveTVTheme.background : LiveTVTheme.text)
+ .padding(.horizontal, 44)
+ .padding(.vertical, 18)
+ .background(isPrimary ? LiveTVTheme.ink : Color.clear)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(isPrimary ? Color.clear : LiveTVTheme.divider, lineWidth: 1)
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ }
+}
+
+// MARK: - On Now shelf
+
+private struct OnNowShelf: View {
+ let channels: [LiveTvChannel]
+ let onWatchChannel: (LiveTvChannel) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 26) {
+ HStack(alignment: .center, spacing: 28) {
+ Text("On Now")
+ .font(LiveTVTypography.shelfLabel)
+ .foregroundStyle(LiveTVTheme.text)
+ Rectangle()
+ .fill(LiveTVTheme.divider)
+ .frame(height: 1)
+ }
+ .padding(.horizontal, 80)
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 36) {
+ ForEach(channels) { channel in
+ OnNowCard(channel: channel) {
+ onWatchChannel(channel)
+ }
+ }
+ }
+ .padding(.horizontal, 80)
+ .padding(.vertical, 16)
+ }
+ .scrollClipDisabled()
+ }
+ .focusSection()
+ }
+}
+
+private struct OnNowCard: View {
+ let channel: LiveTvChannel
+ let action: () -> Void
+
+ var body: some View {
+ let program = channel.currentProgram
+ Button(action: action) {
+ VStack(alignment: .leading, spacing: 0) {
+ Text(channel.number ?? "·")
+ .font(LiveTVTypography.serifChannelNumber)
+ .foregroundStyle(LiveTVTheme.secondaryText)
+ Spacer(minLength: 14)
+ Text(program?.name ?? channel.name)
+ .font(.system(size: 25, weight: .medium))
+ .foregroundStyle(LiveTVTheme.text)
+ .lineLimit(1)
+ Text(caption(program: program))
+ .font(.system(size: 16, weight: .semibold))
+ .tracking(3)
+ .textCase(.uppercase)
+ .foregroundStyle(LiveTVTheme.secondaryText)
+ .lineLimit(1)
+ .padding(.top, 10)
+ progressHairline(program: program)
+ .padding(.top, 16)
+ }
+ .padding(26)
+ .frame(width: 380, height: 214, alignment: .leading)
+ .background(LiveTVTheme.surface)
+ .overlay(
+ RoundedRectangle(cornerRadius: 10)
+ .stroke(LiveTVTheme.ink.opacity(0.09), lineWidth: 1)
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .accessibilityLabel("\(channel.name), \(program?.name ?? "no program info")")
+ }
+
+ private func caption(program: LiveTvProgram?) -> String {
+ if let end = program?.endDate {
+ return "\(channel.name) · until \(LiveTvFormat.timeFormatter.string(from: end))"
+ }
+ return channel.name
+ }
+
+ @ViewBuilder
+ private func progressHairline(program: LiveTvProgram?) -> some View {
+ TimelineView(.periodic(from: .now, by: 60)) { context in
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ Rectangle()
+ .fill(LiveTVTheme.divider)
+ .frame(height: 1)
+ if let fraction = LiveTvFormat.progressFraction(
+ start: program?.startDate,
+ end: program?.endDate,
+ now: context.date
+ ) {
+ Rectangle()
+ .fill(LiveTVTheme.ink.opacity(0.65))
+ .frame(width: geo.size.width * fraction, height: 2)
+ }
+ }
+ }
+ .frame(height: 2)
+ }
+ }
+}
+
+// MARK: - Skeleton
+
+private struct HomeSkeleton: View {
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Spacer()
+ VStack(alignment: .leading, spacing: 24) {
+ bar(width: 300, height: 20)
+ bar(width: 760, height: 84)
+ bar(width: 420, height: 22)
+ HStack(spacing: 22) {
+ bar(width: 190, height: 58)
+ bar(width: 190, height: 58)
+ }
+ }
+ .padding(.horizontal, 80)
+ HStack(spacing: 36) {
+ ForEach(0..<5, id: \.self) { _ in
+ RoundedRectangle(cornerRadius: 10)
+ .fill(LiveTVTheme.surface)
+ .frame(width: 380, height: 214)
+ }
+ }
+ .padding(.horizontal, 80)
+ .padding(.top, 60)
+ .padding(.bottom, 60)
+ }
+ .redacted(reason: .placeholder)
+ .focusable(false)
+ .allowsHitTesting(false)
+ }
+
+ private func bar(width: CGFloat, height: CGFloat) -> some View {
+ RoundedRectangle(cornerRadius: 6)
+ .fill(LiveTVTheme.surface)
+ .frame(width: width, height: height)
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Marquee/PlaybillModel.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Marquee/PlaybillModel.swift
new file mode 100644
index 0000000..110d023
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Marquee/PlaybillModel.swift
@@ -0,0 +1,41 @@
+import Foundation
+import Observation
+import JellyfinAPI
+
+/// Backs `PlaybillView` — the full channel lineup with each channel's
+/// currently-airing program, sorted by channel number. Pulls through the
+/// shared `EPGStore` so tab switches hit cache.
+@MainActor
+@Observable
+public final class PlaybillModel {
+ public enum State: Equatable, Sendable {
+ case loading
+ case loaded([LiveTvChannel])
+ case failed(String)
+ }
+
+ public private(set) var state: State = .loading
+ private let store: EPGStore
+
+ public init(store: EPGStore) {
+ self.store = store
+ }
+
+ public func load() async {
+ state = .loading
+ do {
+ let channels = try await store.channels(
+ filters: LiveTvChannelFilters(sortBy: "SortName", sortOrder: "Ascending"),
+ addCurrentProgram: true
+ )
+ state = .loaded(ChannelOrdering.sortedByChannelNumber(channels))
+ } catch JellyfinError.network {
+ state = .failed("Couldn't reach the server.")
+ } catch JellyfinError.unauthenticated {
+ state = .failed("Session expired. Please sign in again.")
+ } catch {
+ JellytvLog.liveTV.error("PlaybillModel.load: \(String(describing: error), privacy: .public)")
+ state = .failed("Something went wrong loading channels.")
+ }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Marquee/PlaybillView.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Marquee/PlaybillView.swift
new file mode 100644
index 0000000..3166f04
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Marquee/PlaybillView.swift
@@ -0,0 +1,265 @@
+import SwiftUI
+import JellyfinAPI
+import DesignSystem
+
+/// The Live tab: a typographic playbill. Big now-playing panel on the left
+/// reflecting the focused channel; vertical channel list on the right —
+/// serif-italic number, channel name, current program. Select to tune.
+public struct PlaybillView: View {
+ @Bindable var model: PlaybillModel
+ let lastWatchedChannelId: String?
+ let onWatchChannel: (LiveTvChannel) -> Void
+
+ @FocusedValue(\.playbillChannel) private var focusedChannel: LiveTvChannel?
+
+ public init(
+ model: PlaybillModel,
+ lastWatchedChannelId: String? = nil,
+ onWatchChannel: @escaping (LiveTvChannel) -> Void = { _ in }
+ ) {
+ self.model = model
+ self.lastWatchedChannelId = lastWatchedChannelId
+ self.onWatchChannel = onWatchChannel
+ }
+
+ public var body: some View {
+ ZStack {
+ LiveTVTheme.background.ignoresSafeArea()
+ switch model.state {
+ case .loading:
+ PlaybillSkeleton()
+ case .failed(let message):
+ MarqueeErrorView(message: message) {
+ Task { await model.load() }
+ }
+ case .loaded(let channels):
+ if channels.isEmpty {
+ MarqueeErrorView(message: "No live channels on this server.", retryLabel: "Reload") {
+ Task { await model.load() }
+ }
+ } else {
+ loaded(channels)
+ }
+ }
+ }
+ .task {
+ if case .loading = model.state { await model.load() }
+ }
+ }
+
+ private func loaded(_ channels: [LiveTvChannel]) -> some View {
+ let displayed = focusedChannel
+ ?? channels.first(where: { $0.id == lastWatchedChannelId })
+ ?? channels[0]
+ return HStack(alignment: .bottom, spacing: 80) {
+ NowPlayingPanel(channel: displayed)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
+ channelList(channels)
+ .frame(width: 700)
+ }
+ .padding(.horizontal, 80)
+ .padding(.vertical, 60)
+ .animation(.easeInOut(duration: 0.18), value: displayed.id)
+ }
+
+ private func channelList(_ channels: [LiveTvChannel]) -> some View {
+ ScrollView(.vertical, showsIndicators: false) {
+ LazyVStack(spacing: 4) {
+ ForEach(channels) { channel in
+ PlaybillRow(
+ channel: channel,
+ isLastWatched: channel.id == lastWatchedChannelId
+ ) {
+ onWatchChannel(channel)
+ }
+ }
+ }
+ .padding(.vertical, 20)
+ }
+ .focusSection()
+ }
+}
+
+// MARK: - Now-playing panel
+
+private struct NowPlayingPanel: View {
+ let channel: LiveTvChannel
+
+ var body: some View {
+ let program = channel.currentProgram
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(spacing: 14) {
+ Circle()
+ .fill(LiveTVTheme.live)
+ .frame(width: 12, height: 12)
+ Text("On Air · \(channel.name)")
+ .font(LiveTVTypography.kicker)
+ .tracking(7)
+ .textCase(.uppercase)
+ .foregroundStyle(LiveTVTheme.secondaryText)
+ }
+ Text(program?.name ?? channel.name)
+ .font(LiveTVTypography.playbillTitle)
+ .foregroundStyle(LiveTVTheme.text)
+ .lineLimit(3)
+ .padding(.top, 26)
+ if let range = LiveTvFormat.timeRange(start: program?.startDate, end: program?.endDate) {
+ Text(range)
+ .font(LiveTVTypography.programTime)
+ .foregroundStyle(LiveTVTheme.secondaryText)
+ .padding(.top, 14)
+ }
+ progress(program: program)
+ .padding(.top, 44)
+ }
+ }
+
+ @ViewBuilder
+ private func progress(program: LiveTvProgram?) -> some View {
+ TimelineView(.periodic(from: .now, by: 30)) { context in
+ if let fraction = LiveTvFormat.progressFraction(
+ start: program?.startDate,
+ end: program?.endDate,
+ now: context.date
+ ) {
+ VStack(alignment: .leading, spacing: 12) {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ Rectangle()
+ .fill(LiveTVTheme.divider)
+ .frame(height: 1)
+ Rectangle()
+ .fill(LiveTVTheme.ink.opacity(0.75))
+ .frame(width: geo.size.width * fraction, height: 2)
+ }
+ }
+ .frame(height: 2)
+ HStack {
+ timeText(program?.startDate)
+ Spacer()
+ timeText(program?.endDate)
+ }
+ }
+ }
+ }
+ }
+
+ private func timeText(_ date: Date?) -> some View {
+ Text(date.map { LiveTvFormat.timeFormatter.string(from: $0) } ?? "")
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(LiveTVTheme.secondaryText)
+ .tracking(2)
+ }
+}
+
+// MARK: - Channel row
+
+private struct PlaybillRow: View {
+ let channel: LiveTvChannel
+ let isLastWatched: Bool
+ let action: () -> Void
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Button(action: action) {
+ HStack(alignment: .firstTextBaseline, spacing: 24) {
+ Text(channel.number ?? "·")
+ .font(LiveTVTypography.serifChannelNumber)
+ .foregroundStyle(isFocused ? LiveTVTheme.text : LiveTVTheme.secondaryText)
+ .frame(width: 70, alignment: .trailing)
+ Text(channel.name)
+ .font(.system(size: 27, weight: .medium))
+ .foregroundStyle(LiveTVTheme.text)
+ .lineLimit(1)
+ Spacer(minLength: 20)
+ Text(channel.currentProgram?.name ?? "")
+ .font(.system(size: 21))
+ .foregroundStyle(LiveTVTheme.secondaryText)
+ .lineLimit(1)
+ }
+ .padding(.horizontal, 28)
+ .padding(.vertical, 18)
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ .focusedValue(\.playbillChannel, isFocused ? channel : nil)
+ .accessibilityLabel("\(channel.name), \(channel.currentProgram?.name ?? "no program info")")
+ }
+}
+
+// MARK: - Skeleton
+
+private struct PlaybillSkeleton: View {
+ var body: some View {
+ HStack(alignment: .bottom, spacing: 80) {
+ VStack(alignment: .leading, spacing: 24) {
+ bar(width: 220, height: 20)
+ bar(width: 560, height: 64)
+ bar(width: 300, height: 20)
+ Rectangle().fill(LiveTVTheme.divider).frame(height: 2)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
+ VStack(spacing: 16) {
+ ForEach(0..<8, id: \.self) { _ in
+ bar(width: 640, height: 52)
+ }
+ }
+ .frame(width: 700)
+ }
+ .padding(.horizontal, 80)
+ .padding(.vertical, 60)
+ .redacted(reason: .placeholder)
+ .focusable(false)
+ .allowsHitTesting(false)
+ }
+
+ private func bar(width: CGFloat, height: CGFloat) -> some View {
+ RoundedRectangle(cornerRadius: 6)
+ .fill(LiveTVTheme.surface)
+ .frame(width: width, height: height)
+ }
+}
+
+// MARK: - Shared error view
+
+/// Minimal monochrome error state shared by the Marquee screens.
+struct MarqueeErrorView: View {
+ let message: String
+ var retryLabel: String = "Try Again"
+ let onRetry: () -> Void
+
+ var body: some View {
+ VStack(spacing: 30) {
+ Text(message)
+ .font(LiveTVTypography.strongTitle)
+ .foregroundStyle(LiveTVTheme.text)
+ .multilineTextAlignment(.center)
+ Button(retryLabel, action: onRetry)
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .font(LiveTVTypography.kicker)
+ }
+ .padding(80)
+ }
+}
+
+// MARK: - Focused-value plumbing
+
+public struct FocusedPlaybillChannelKey: FocusedValueKey {
+ public typealias Value = LiveTvChannel
+}
+
+public extension FocusedValues {
+ var playbillChannel: LiveTvChannel? {
+ get { self[FocusedPlaybillChannelKey.self] }
+ set { self[FocusedPlaybillChannelKey.self] = newValue }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/OnNow/OnNowModel.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/OnNow/OnNowModel.swift
new file mode 100644
index 0000000..7e9023f
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/OnNow/OnNowModel.swift
@@ -0,0 +1,151 @@
+import Foundation
+import Observation
+import JellyfinAPI
+
+/// Snapshot powering `OnNowView`. Each field corresponds to a horizontal
+/// shelf in the Plex-style landing. Built from a fan-out of LiveTV API
+/// queries: channels with addCurrentProgram, recommended programs, and a
+/// recordings sample.
+public struct OnNowContent: Sendable, Equatable {
+ public let serverURL: URL
+ public let onNow: [LiveTvChannel]
+ public let movies: [LiveTvChannel]
+ public let sports: [LiveTvChannel]
+ public let news: [LiveTvChannel]
+ public let kids: [LiveTvChannel]
+ public let favorites: [LiveTvChannel]
+ public let upNext: [LiveTvProgram]
+ public let recentRecordings: [BaseItemDto]
+
+ public init(
+ serverURL: URL,
+ onNow: [LiveTvChannel] = [],
+ movies: [LiveTvChannel] = [],
+ sports: [LiveTvChannel] = [],
+ news: [LiveTvChannel] = [],
+ kids: [LiveTvChannel] = [],
+ favorites: [LiveTvChannel] = [],
+ upNext: [LiveTvProgram] = [],
+ recentRecordings: [BaseItemDto] = []
+ ) {
+ self.serverURL = serverURL
+ self.onNow = onNow
+ self.movies = movies
+ self.sports = sports
+ self.news = news
+ self.kids = kids
+ self.favorites = favorites
+ self.upNext = upNext
+ self.recentRecordings = recentRecordings
+ }
+
+ public var heroChannel: LiveTvChannel? {
+ favorites.first ?? onNow.first ?? movies.first ?? sports.first
+ }
+
+ public var isEmpty: Bool {
+ onNow.isEmpty && movies.isEmpty && sports.isEmpty && news.isEmpty
+ && kids.isEmpty && favorites.isEmpty && upNext.isEmpty
+ && recentRecordings.isEmpty
+ }
+}
+
+@MainActor
+@Observable
+public final class OnNowModel {
+ public enum State: Equatable, Sendable {
+ case loading
+ case loaded(OnNowContent)
+ case failed(String)
+ }
+
+ public private(set) var state: State = .loading
+ private let client: any JellyfinClientAPI
+ private let store: EPGStore
+
+ public init(client: any JellyfinClientAPI, store: EPGStore) {
+ self.client = client
+ self.store = store
+ }
+
+ public func load() async {
+ JellytvLog.liveTV.info("OnNowModel.load() begin")
+ state = .loading
+
+ guard let serverURL = await client.currentServerURL() else {
+ JellytvLog.liveTV.error("OnNowModel.load: not signed in")
+ state = .failed("Not signed in")
+ return
+ }
+
+ do {
+ async let onNowTask = store.channels(
+ filters: LiveTvChannelFilters(isAiringNow: true, sortBy: "SortName", sortOrder: "Ascending", limit: 60),
+ addCurrentProgram: true
+ )
+ async let moviesTask = store.channels(
+ filters: LiveTvChannelFilters(isMovie: true, isAiringNow: true, limit: 24),
+ addCurrentProgram: true
+ )
+ async let sportsTask = store.channels(
+ filters: LiveTvChannelFilters(isSports: true, isAiringNow: true, limit: 24),
+ addCurrentProgram: true
+ )
+ async let newsTask = store.channels(
+ filters: LiveTvChannelFilters(isNews: true, isAiringNow: true, limit: 24),
+ addCurrentProgram: true
+ )
+ async let kidsTask = store.channels(
+ filters: LiveTvChannelFilters(isKids: true, isAiringNow: true, limit: 24),
+ addCurrentProgram: true
+ )
+ async let favoritesTask = store.channels(
+ filters: LiveTvChannelFilters(isFavorite: true, sortBy: "SortName", sortOrder: "Ascending", limit: 24),
+ addCurrentProgram: true
+ )
+ async let upNextTask = client.liveTvRecommendedPrograms(
+ filters: LiveTvProgramFilters(hasAired: false, limit: 24)
+ )
+ async let recordingsTask = client.liveTvRecordings(isInProgress: nil, seriesTimerId: nil, limit: 24)
+
+ let (onNow, movies, sports, news, kids, favorites, upNext, recordings) = try await (
+ onNowTask,
+ moviesTask,
+ sportsTask,
+ newsTask,
+ kidsTask,
+ favoritesTask,
+ upNextTask,
+ recordingsTask
+ )
+
+ // Filter sub-shelves to channels not already in `favorites` so the
+ // top "Favorites" row doesn't duplicate the same tile lower down.
+ let favoriteIds = Set(favorites.map(\.id))
+ let dedup: ([LiveTvChannel]) -> [LiveTvChannel] = { channels in
+ channels.filter { !favoriteIds.contains($0.id) }
+ }
+
+ let content = OnNowContent(
+ serverURL: serverURL,
+ onNow: dedup(onNow),
+ movies: dedup(movies),
+ sports: dedup(sports),
+ news: dedup(news),
+ kids: dedup(kids),
+ favorites: favorites,
+ upNext: upNext,
+ recentRecordings: recordings
+ )
+ JellytvLog.liveTV.info("OnNowModel.load: onNow=\(onNow.count) movies=\(movies.count) sports=\(sports.count) news=\(news.count) kids=\(kids.count) fav=\(favorites.count) upNext=\(upNext.count) recordings=\(recordings.count)")
+ state = .loaded(content)
+ } catch JellyfinError.network {
+ state = .failed("Couldn't reach the server.")
+ } catch JellyfinError.unauthenticated {
+ state = .failed("Session expired. Please sign in again.")
+ } catch {
+ JellytvLog.liveTV.error("OnNowModel.load: \(String(describing: error), privacy: .public)")
+ state = .failed("Something went wrong loading Live TV.")
+ }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/OnNow/OnNowView.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/OnNow/OnNowView.swift
new file mode 100644
index 0000000..e7141a0
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/OnNow/OnNowView.swift
@@ -0,0 +1,783 @@
+import SwiftUI
+import NukeUI
+import JellyfinAPI
+import DesignSystem
+
+/// Plex-style "Live TV Home." A focusable hero (currently-airing channel) on
+/// top, then horizontally-scrolling shelves: Favorites / On Now / Movies /
+/// Sports / News / Kids / Up Next / Recent Recordings.
+public struct OnNowView: View {
+ @Bindable var model: OnNowModel
+ let onWatchChannel: (LiveTvChannel) -> Void
+ let onSelectProgram: (LiveTvProgram) -> Void
+
+ @FocusedValue(\.focusedOnNowChannel) private var focusedChannel
+ @FocusedValue(\.focusedOnNowProgram) private var focusedProgram
+
+ public init(
+ model: OnNowModel,
+ onWatchChannel: @escaping (LiveTvChannel) -> Void = { _ in },
+ onSelectProgram: @escaping (LiveTvProgram) -> Void = { _ in }
+ ) {
+ self.model = model
+ self.onWatchChannel = onWatchChannel
+ self.onSelectProgram = onSelectProgram
+ }
+
+ private var isLoading: Bool {
+ if case .loading = model.state { return true }
+ return false
+ }
+
+ public var body: some View {
+ Group {
+ switch model.state {
+ case .loading:
+ OnNowSkeleton()
+ case .loaded(let content):
+ if content.isEmpty {
+ emptyState
+ } else {
+ loaded(content: content)
+ }
+ case .failed(let message):
+ failedView(message)
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: isLoading)
+ .background(LiveTVTheme.background)
+ .task {
+ if case .loading = model.state {
+ await model.load()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func loaded(content: OnNowContent) -> some View {
+ let heroChannel = focusedChannel ?? content.heroChannel
+ ScrollView(.vertical, showsIndicators: false) {
+ LazyVStack(alignment: .leading, spacing: 50) {
+ if let heroChannel {
+ OnNowHero(
+ channel: heroChannel,
+ focusedProgram: focusedProgram,
+ serverURL: content.serverURL,
+ onWatch: { onWatchChannel(heroChannel) },
+ onMoreInfo: {
+ if let program = heroChannel.currentProgram ?? focusedProgram {
+ onSelectProgram(program)
+ }
+ }
+ )
+ }
+
+ if !content.favorites.isEmpty {
+ ChannelShelf(
+ title: "Favorite Channels",
+ icon: "star.fill",
+ channels: content.favorites,
+ serverURL: content.serverURL,
+ onTap: onWatchChannel
+ )
+ }
+
+ if !content.onNow.isEmpty {
+ ChannelShelf(
+ title: "On Now",
+ icon: "dot.radiowaves.left.and.right",
+ channels: content.onNow,
+ serverURL: content.serverURL,
+ onTap: onWatchChannel
+ )
+ }
+
+ if !content.sports.isEmpty {
+ ChannelShelf(
+ title: "Sports",
+ icon: "sportscourt",
+ channels: content.sports,
+ serverURL: content.serverURL,
+ onTap: onWatchChannel
+ )
+ }
+
+ if !content.movies.isEmpty {
+ ChannelShelf(
+ title: "Movies",
+ icon: "film",
+ channels: content.movies,
+ serverURL: content.serverURL,
+ onTap: onWatchChannel
+ )
+ }
+
+ if !content.news.isEmpty {
+ ChannelShelf(
+ title: "News",
+ icon: "newspaper",
+ channels: content.news,
+ serverURL: content.serverURL,
+ onTap: onWatchChannel
+ )
+ }
+
+ if !content.kids.isEmpty {
+ ChannelShelf(
+ title: "Kids",
+ icon: "figure.and.child.holdinghands",
+ channels: content.kids,
+ serverURL: content.serverURL,
+ onTap: onWatchChannel
+ )
+ }
+
+ if !content.upNext.isEmpty {
+ ProgramShelf(
+ title: "Up Next",
+ icon: "calendar.badge.clock",
+ programs: content.upNext,
+ serverURL: content.serverURL,
+ onTap: onSelectProgram
+ )
+ }
+
+ if !content.recentRecordings.isEmpty {
+ RecordingShelf(
+ title: "Recordings",
+ icon: "record.circle",
+ items: content.recentRecordings,
+ serverURL: content.serverURL
+ )
+ }
+
+ Spacer(minLength: 60)
+ }
+ .padding(.vertical, 30)
+ }
+ .scrollClipDisabled()
+ }
+
+ private var emptyState: some View {
+ VStack(spacing: 24) {
+ Image(systemName: "antenna.radiowaves.left.and.right.slash")
+ .font(.system(size: 80))
+ .foregroundStyle(.secondary)
+ Text("No live programming")
+ .font(.title)
+ Text("Configure a tuner or listings provider in Jellyfin to see channels here.")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ Button("Reload") {
+ Task { await model.load() }
+ }
+ }
+ .padding(60)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private func failedView(_ message: String) -> some View {
+ VStack(spacing: 24) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.system(size: 60))
+ .foregroundStyle(.secondary)
+ Text(message)
+ .font(.title2)
+ .multilineTextAlignment(.center)
+ Button("Retry") {
+ Task { await model.load() }
+ }
+ }
+ .padding(40)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+// MARK: - Hero
+
+private struct OnNowHero: View {
+ let channel: LiveTvChannel
+ let focusedProgram: LiveTvProgram?
+ let serverURL: URL
+ let onWatch: () -> Void
+ let onMoreInfo: () -> Void
+
+ private var program: LiveTvProgram? {
+ focusedProgram ?? channel.currentProgram
+ }
+
+ var body: some View {
+ ZStack(alignment: .bottomLeading) {
+ backdrop
+ LinearGradient(
+ colors: [.clear, .black.opacity(0.85)],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ VStack(alignment: .leading, spacing: 18) {
+ HStack(spacing: 12) {
+ LiveBadge(label: "LIVE")
+ HStack(spacing: 8) {
+ ChannelLogoView(channel: channel, serverURL: serverURL, maxWidth: 240)
+ .frame(width: 64, height: 36)
+ VStack(alignment: .leading, spacing: 0) {
+ Text(channel.name)
+ .font(.headline)
+ if let number = channel.number, !number.isEmpty {
+ Text("CH " + number)
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(.black.opacity(0.4), in: RoundedRectangle(cornerRadius: 12))
+ }
+
+ Text(program?.name ?? channel.name)
+ .font(.system(size: 64, weight: .heavy))
+ .lineLimit(2)
+
+ if let program {
+ HStack(spacing: 12) {
+ if let timeRange = LiveTvFormat.timeRange(start: program.startDate, end: program.endDate) {
+ Text(timeRange)
+ .font(.title3.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ if let year = program.productionYear {
+ Text(String(year))
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ }
+ if let rating = program.officialRating {
+ Text(rating)
+ .font(.subheadline.weight(.semibold))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 2)
+ .background(.white.opacity(0.18), in: RoundedRectangle(cornerRadius: 4))
+ }
+ }
+ if let overview = program.overview {
+ Text(overview)
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .lineLimit(3)
+ .frame(maxWidth: 980, alignment: .leading)
+ }
+ }
+
+ HStack(spacing: 16) {
+ HeroButton(title: "Watch", systemImage: "play.fill", isPrimary: true, action: onWatch)
+ if program != nil {
+ HeroButton(title: "More Info", systemImage: "info.circle", isPrimary: false, action: onMoreInfo)
+ }
+ }
+ .focusSection()
+ }
+ .padding(.horizontal, 60)
+ .padding(.bottom, 40)
+ .padding(.top, 80)
+ }
+ .frame(height: 560)
+ .containerRelativeFrame(.horizontal)
+ .clipped()
+ .animation(.easeInOut(duration: 0.35), value: program?.id)
+ .animation(.easeInOut(duration: 0.35), value: channel.id)
+ }
+
+ @ViewBuilder
+ private var backdrop: some View {
+ if let url = program?.backdropURL(serverURL: serverURL, maxWidth: 1920) {
+ LazyImage(url: url) { state in
+ if let image = state.image {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } else {
+ fallbackBackdrop
+ }
+ }
+ } else {
+ fallbackBackdrop
+ }
+ }
+
+ private var fallbackBackdrop: some View {
+ LinearGradient(
+ colors: [Color.accentColor.opacity(0.4), Color.black],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ }
+}
+
+private struct HeroButton: View {
+ let title: String
+ let systemImage: String
+ let isPrimary: Bool
+ let action: () -> Void
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 8) {
+ Image(systemName: systemImage)
+ Text(title)
+ }
+ .font(.headline)
+ .padding(.horizontal, 28)
+ .padding(.vertical, 14)
+ .background(background, in: RoundedRectangle(cornerRadius: 10))
+ .foregroundStyle(foreground)
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ }
+
+ private var background: Color {
+ if isPrimary {
+ return isFocused ? .white : .white.opacity(0.95)
+ }
+ return isFocused ? .white.opacity(0.30) : .white.opacity(0.18)
+ }
+
+ private var foreground: Color {
+ isPrimary ? .black : .white
+ }
+}
+
+// MARK: - Channel shelf (tile = channel logo + current program)
+
+private struct ChannelShelf: View {
+ let title: String
+ let icon: String
+ let channels: [LiveTvChannel]
+ let serverURL: URL
+ let onTap: (LiveTvChannel) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ ShelfHeader(title: title, icon: icon)
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 24) {
+ ForEach(channels) { channel in
+ ChannelTile(channel: channel, serverURL: serverURL) {
+ onTap(channel)
+ }
+ }
+ }
+ .padding(.horizontal, 60)
+ }
+ .scrollClipDisabled()
+ }
+ .focusSection()
+ }
+}
+
+private struct ChannelTile: View {
+ let channel: LiveTvChannel
+ let serverURL: URL
+ let action: () -> Void
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Button(action: action) {
+ VStack(alignment: .leading, spacing: 10) {
+ ZStack(alignment: .topTrailing) {
+ backdrop
+ LinearGradient(
+ colors: [.clear, .black.opacity(0.7)],
+ startPoint: .center,
+ endPoint: .bottom
+ )
+ HStack(alignment: .top) {
+ ChannelLogoView(channel: channel, serverURL: serverURL, maxWidth: 240)
+ .frame(width: 56, height: 36)
+ .padding(8)
+ .background(.black.opacity(0.45), in: RoundedRectangle(cornerRadius: 8))
+ .padding(10)
+ Spacer(minLength: 0)
+ if isAiringNow {
+ LiveBadge(label: "LIVE")
+ .padding(10)
+ }
+ }
+ VStack(alignment: .leading, spacing: 4) {
+ Spacer(minLength: 0)
+ if let program = channel.currentProgram {
+ Text(program.name)
+ .font(.headline)
+ .lineLimit(2)
+ .foregroundStyle(.white)
+ } else {
+ Text(channel.name)
+ .font(.headline)
+ .lineLimit(1)
+ .foregroundStyle(.white)
+ }
+ if let timeRange = LiveTvFormat.timeRange(
+ start: channel.currentProgram?.startDate,
+ end: channel.currentProgram?.endDate
+ ) {
+ Text(timeRange)
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(.white.opacity(0.85))
+ }
+ }
+ .padding(14)
+ }
+ .frame(width: 360, height: 200)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+
+ HStack(spacing: 6) {
+ if let number = channel.number, !number.isEmpty {
+ Text(number)
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ Text(channel.name)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+ .foregroundStyle(isFocused ? .primary : .secondary)
+ }
+ .frame(width: 360, alignment: .leading)
+ }
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ .focusedValue(\.focusedOnNowChannel, isFocused ? channel : nil)
+ .focusedValue(\.focusedOnNowProgram, isFocused ? channel.currentProgram : nil)
+ }
+
+ private var isAiringNow: Bool {
+ guard let start = channel.currentProgram?.startDate,
+ let end = channel.currentProgram?.endDate else { return false }
+ let now = Date()
+ return now >= start && now < end
+ }
+
+ @ViewBuilder
+ private var backdrop: some View {
+ if let url = channel.currentProgram?.tileImageURL(serverURL: serverURL, maxWidth: 720) {
+ LazyImage(url: url) { state in
+ if let image = state.image {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } else {
+ fallback
+ }
+ }
+ } else {
+ fallback
+ }
+ }
+
+ private var fallback: some View {
+ LinearGradient(
+ colors: [Color.accentColor.opacity(0.5), Color.black.opacity(0.85)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ }
+}
+
+// MARK: - Program shelf (Up Next)
+
+private struct ProgramShelf: View {
+ let title: String
+ let icon: String
+ let programs: [LiveTvProgram]
+ let serverURL: URL
+ let onTap: (LiveTvProgram) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ ShelfHeader(title: title, icon: icon)
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 24) {
+ ForEach(programs) { program in
+ ProgramTile(program: program, serverURL: serverURL) {
+ onTap(program)
+ }
+ }
+ }
+ .padding(.horizontal, 60)
+ }
+ .scrollClipDisabled()
+ }
+ .focusSection()
+ }
+}
+
+private struct ProgramTile: View {
+ let program: LiveTvProgram
+ let serverURL: URL
+ let action: () -> Void
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Button(action: action) {
+ VStack(alignment: .leading, spacing: 10) {
+ tileBody
+ .frame(width: 320, height: 180)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ Text(program.name)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+ .foregroundStyle(isFocused ? .primary : .secondary)
+ .frame(width: 320, alignment: .leading)
+ if let start = program.startDate {
+ Text(LiveTvFormat.timeFormatter.string(from: start))
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(.tertiary)
+ .frame(width: 320, alignment: .leading)
+ }
+ }
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ .focusedValue(\.focusedOnNowProgram, isFocused ? program : nil)
+ }
+
+ @ViewBuilder
+ private var tileBody: some View {
+ if let url = program.tileImageURL(serverURL: serverURL, maxWidth: 640) {
+ LazyImage(url: url) { state in
+ if let image = state.image {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } else {
+ fallback
+ }
+ }
+ } else {
+ fallback
+ }
+ }
+
+ private var fallback: some View {
+ ZStack {
+ LinearGradient(
+ colors: [Color.accentColor.opacity(0.6), Color.black],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ VStack(spacing: 8) {
+ Image(systemName: "tv")
+ .font(.system(size: 36))
+ .foregroundStyle(.white.opacity(0.65))
+ Text(program.name)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(2)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 12)
+ .foregroundStyle(.white)
+ }
+ }
+ }
+}
+
+// MARK: - Recording shelf
+
+private struct RecordingShelf: View {
+ let title: String
+ let icon: String
+ let items: [BaseItemDto]
+ let serverURL: URL
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ ShelfHeader(title: title, icon: icon)
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 24) {
+ ForEach(items, id: \.id) { item in
+ RecordingTile(item: item, serverURL: serverURL)
+ }
+ }
+ .padding(.horizontal, 60)
+ }
+ .scrollClipDisabled()
+ }
+ .focusSection()
+ }
+}
+
+private struct RecordingTile: View {
+ let item: BaseItemDto
+ let serverURL: URL
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Button {
+ // Recording playback — Phase 4 (post-MVP).
+ } label: {
+ VStack(alignment: .leading, spacing: 10) {
+ Group {
+ if let url = item.imageURL(serverURL: serverURL, type: .thumb, maxWidth: 640)
+ ?? item.imageURL(serverURL: serverURL, type: .primary, maxWidth: 640)
+ ?? item.imageURL(serverURL: serverURL, type: .backdrop, maxWidth: 640) {
+ LazyImage(url: url) { state in
+ if let image = state.image {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } else {
+ fallback
+ }
+ }
+ } else {
+ fallback
+ }
+ }
+ .frame(width: 320, height: 180)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+
+ Text(item.name)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+ .foregroundStyle(isFocused ? .primary : .secondary)
+ .frame(width: 320, alignment: .leading)
+ }
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ }
+
+ private var fallback: some View {
+ ZStack {
+ LinearGradient(
+ colors: [Color.red.opacity(0.6), Color.black],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ Image(systemName: "record.circle")
+ .font(.system(size: 36))
+ .foregroundStyle(.white.opacity(0.7))
+ }
+ }
+}
+
+// MARK: - Shelf header
+
+private struct ShelfHeader: View {
+ let title: String
+ let icon: String
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: icon)
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ Text(title)
+ .font(.title3.weight(.semibold))
+ }
+ .padding(.horizontal, 60)
+ }
+}
+
+// MARK: - Skeleton loading state
+
+private struct OnNowSkeleton: View {
+ var body: some View {
+ ScrollView(.vertical, showsIndicators: false) {
+ LazyVStack(alignment: .leading, spacing: 50) {
+ // Hero placeholder (matches OnNowHero's 560pt height)
+ RoundedRectangle(cornerRadius: 0)
+ .fill(LiveTVTheme.surface)
+ .frame(maxWidth: .infinity)
+ .frame(height: 560)
+ .redacted(reason: .placeholder)
+
+ // Shelf row 1
+ skeletonShelf(tileWidth: 360, tileHeight: 200)
+
+ // Shelf row 2
+ skeletonShelf(tileWidth: 360, tileHeight: 200)
+
+ Spacer(minLength: 60)
+ }
+ .padding(.vertical, 30)
+ }
+ .scrollClipDisabled()
+ .focusable(false)
+ .allowsHitTesting(false)
+ .transition(.opacity)
+ }
+
+ @ViewBuilder
+ private func skeletonShelf(tileWidth: CGFloat, tileHeight: CGFloat) -> some View {
+ VStack(alignment: .leading, spacing: 16) {
+ // ShelfHeader placeholder
+ HStack(spacing: 10) {
+ RoundedRectangle(cornerRadius: 4)
+ .fill(LiveTVTheme.surface)
+ .frame(width: 20, height: 20)
+ RoundedRectangle(cornerRadius: 4)
+ .fill(LiveTVTheme.surface)
+ .frame(width: 160, height: 22)
+ }
+ .padding(.horizontal, 60)
+ .redacted(reason: .placeholder)
+
+ // Tile row
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 24) {
+ ForEach(0..<5, id: \.self) { _ in
+ RoundedRectangle(cornerRadius: 14)
+ .fill(LiveTVTheme.surface)
+ .frame(width: tileWidth, height: tileHeight)
+ .redacted(reason: .placeholder)
+ }
+ }
+ .padding(.horizontal, 60)
+ }
+ .scrollClipDisabled()
+ }
+ }
+}
+
+// MARK: - Focus values
+
+struct FocusedOnNowChannelKey: FocusedValueKey {
+ typealias Value = LiveTvChannel
+}
+
+struct FocusedOnNowProgramKey: FocusedValueKey {
+ typealias Value = LiveTvProgram
+}
+
+extension FocusedValues {
+ var focusedOnNowChannel: LiveTvChannel? {
+ get { self[FocusedOnNowChannelKey.self] }
+ set { self[FocusedOnNowChannelKey.self] = newValue }
+ }
+ var focusedOnNowProgram: LiveTvProgram? {
+ get { self[FocusedOnNowProgramKey.self] }
+ set { self[FocusedOnNowProgramKey.self] = newValue }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelInfoHUD.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelInfoHUD.swift
new file mode 100644
index 0000000..45851e2
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelInfoHUD.swift
@@ -0,0 +1,83 @@
+import SwiftUI
+import JellyfinAPI
+import DesignSystem
+
+/// Top-strip overlay shown over live video for ~3s on every tune and on
+/// remote-tap. Auto-hides after 3s of inactivity. Channel logo + number/name +
+/// current program title + thin progress bar.
+struct ChannelInfoHUD: View {
+ let channel: LiveTvChannel
+ let serverURL: URL
+ let program: LiveTvProgram?
+ let isVisible: Bool
+
+ var body: some View {
+ VStack {
+ if isVisible {
+ content
+ .padding(.horizontal, 36)
+ .padding(.vertical, 18)
+ .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
+ .overlay(
+ RoundedRectangle(cornerRadius: 16)
+ .stroke(LiveTVTheme.divider, lineWidth: 1)
+ )
+ .shadow(color: .black.opacity(0.4), radius: 16, y: 6)
+ .padding(.horizontal, 60)
+ .padding(.top, 60)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ Spacer()
+ }
+ .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isVisible)
+ .allowsHitTesting(false)
+ }
+
+ private var content: some View {
+ HStack(alignment: .center, spacing: 18) {
+ ChannelLogoView(channel: channel, serverURL: serverURL, maxWidth: 200)
+ .frame(width: 80, height: 50)
+
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 8) {
+ if let number = channel.number, !number.isEmpty {
+ Text(number)
+ .font(LiveTVTypography.channelNumber)
+ .foregroundStyle(LiveTVTheme.accent)
+ }
+ Text(channel.name)
+ .font(LiveTVTypography.channelName)
+ .foregroundStyle(LiveTVTheme.text)
+ }
+ if let program {
+ Text(program.name)
+ .font(LiveTVTypography.programTitle)
+ .foregroundStyle(LiveTVTheme.text)
+ .lineLimit(1)
+ if let progress = LiveTvFormat.progressFraction(
+ start: program.startDate,
+ end: program.endDate,
+ now: Date()
+ ) {
+ progressBar(progress: progress)
+ }
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+
+ private func progressBar(progress: Double) -> some View {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ Capsule()
+ .fill(LiveTVTheme.divider)
+ Capsule()
+ .fill(LiveTVTheme.live)
+ .frame(width: max(0, geo.size.width * progress))
+ }
+ }
+ .frame(height: 3)
+ .frame(maxWidth: 320)
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelOrdering.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelOrdering.swift
new file mode 100644
index 0000000..07593d3
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelOrdering.swift
@@ -0,0 +1,62 @@
+import Foundation
+import JellyfinAPI
+
+/// Pure helper that returns the next/previous channel by channel number,
+/// wrapping around at the ends. Channels with numeric `number` fields sort
+/// ascending by that number; channels without a numeric number sort
+/// lexicographically after all numeric channels.
+public enum ChannelOrdering {
+ /// Returns the channel sorted immediately after `current` in the canonical
+ /// order, wrapping to the first channel if `current` is last. Returns nil
+ /// if `channels` is empty or the current channel isn't in the list.
+ public static func next(after current: LiveTvChannel, in channels: [LiveTvChannel]) -> LiveTvChannel? {
+ let sorted = sortedByChannelNumber(channels)
+ guard !sorted.isEmpty,
+ let idx = sorted.firstIndex(where: { $0.id == current.id }) else { return nil }
+ let nextIdx = (idx + 1) % sorted.count
+ return sorted[nextIdx]
+ }
+
+ /// Returns the channel sorted immediately before `current`, wrapping to
+ /// the last channel if `current` is first.
+ public static func previous(before current: LiveTvChannel, in channels: [LiveTvChannel]) -> LiveTvChannel? {
+ let sorted = sortedByChannelNumber(channels)
+ guard !sorted.isEmpty,
+ let idx = sorted.firstIndex(where: { $0.id == current.id }) else { return nil }
+ let prevIdx = (idx - 1 + sorted.count) % sorted.count
+ return sorted[prevIdx]
+ }
+
+ /// Sort channels by `number` numerically when possible, falling back to
+ /// lexicographic compare for non-numeric numbers. Stable: ties broken by
+ /// channel name.
+ public static func sortedByChannelNumber(_ channels: [LiveTvChannel]) -> [LiveTvChannel] {
+ channels.sorted { lhs, rhs in
+ let lhsKey = sortKey(for: lhs)
+ let rhsKey = sortKey(for: rhs)
+ if lhsKey != rhsKey { return lhsKey < rhsKey }
+ return lhs.name < rhs.name
+ }
+ }
+
+ /// Numeric channels sort first by their integer value (e.g. "101" → 101).
+ /// Non-numeric channels sort after all numeric channels by lexicographic
+ /// compare on the original number string. Channels missing `number`
+ /// entirely sort last by name.
+ private static func sortKey(for channel: LiveTvChannel) -> String {
+ guard let number = channel.number, !number.isEmpty else {
+ // Triple-Z prefix puts these after even non-numeric channels.
+ return "ZZZ\(channel.name)"
+ }
+ // Try to parse the leading numeric prefix (handles "101", "101.1", etc.)
+ let leadingDigits = number.prefix { $0.isNumber || $0 == "." }
+ if let value = Double(leadingDigits) {
+ // Pad numeric value so string compare yields numeric order.
+ // Width 12 covers any realistic channel number.
+ return String(format: "0%011.4f", value)
+ }
+ // Z prefix puts non-numeric channels after all numeric ones, but
+ // before number-missing channels.
+ return "Z\(number)"
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelPlayerPresentation.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelPlayerPresentation.swift
new file mode 100644
index 0000000..66d7e97
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelPlayerPresentation.swift
@@ -0,0 +1,67 @@
+import SwiftUI
+import JellyfinAPI
+
+/// Presents `LiveTVPlayerView` full-screen on tvOS (as a `fullScreenCover`)
+/// and as a sheet on macOS so the LiveTV package compiles cleanly for both
+/// platforms. Centralized here so any view that surfaces channels can wire
+/// the same playback path simply by binding `selectedChannel`.
+///
+/// Phase D widening: the `openStream` closure now returns `LiveStreamPlayback`
+/// (id + URL) so the player can call `/LiveStreams/Close` on dismiss. Adds
+/// `closeStream`, `channels` (for in-player channel up/down), an optional
+/// `program` for the splash/HUD on the program-detail entry, and a
+/// `lastWatchedChannelId` binding for focus restoration after dismiss.
+public struct ChannelPlayerPresentation: ViewModifier {
+ @Binding var selectedChannel: LiveTvChannel?
+ let channels: [LiveTvChannel]
+ let serverURL: URL
+ let program: LiveTvProgram?
+ let openStream: @Sendable (LiveTvChannel, _ forceTranscoding: Bool) async throws -> LiveStreamPlayback
+ let closeStream: @Sendable (String) async -> Void
+ @Binding var lastWatchedChannelId: String?
+
+ public init(
+ selectedChannel: Binding,
+ channels: [LiveTvChannel],
+ serverURL: URL,
+ program: LiveTvProgram? = nil,
+ openStream: @escaping @Sendable (LiveTvChannel, Bool) async throws -> LiveStreamPlayback,
+ closeStream: @escaping @Sendable (String) async -> Void,
+ lastWatchedChannelId: Binding
+ ) {
+ self._selectedChannel = selectedChannel
+ self.channels = channels
+ self.serverURL = serverURL
+ self.program = program
+ self.openStream = openStream
+ self.closeStream = closeStream
+ self._lastWatchedChannelId = lastWatchedChannelId
+ }
+
+ public func body(content: Content) -> some View {
+ #if os(tvOS)
+ content.fullScreenCover(item: $selectedChannel) { channel in
+ playerView(for: channel)
+ }
+ #else
+ content.sheet(item: $selectedChannel) { channel in
+ playerView(for: channel)
+ }
+ #endif
+ }
+
+ private func playerView(for channel: LiveTvChannel) -> some View {
+ LiveTVPlayerView(
+ initialChannel: channel,
+ channels: channels,
+ serverURL: serverURL,
+ initialProgram: program ?? channel.currentProgram,
+ openStream: openStream,
+ closeStream: closeStream,
+ onDismiss: { selectedChannel = nil },
+ onChannelChanged: { newChannel in
+ lastWatchedChannelId = newChannel.id
+ }
+ )
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelSplashView.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelSplashView.swift
new file mode 100644
index 0000000..3417105
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ChannelSplashView.swift
@@ -0,0 +1,127 @@
+import SwiftUI
+import JellyfinAPI
+import DesignSystem
+
+/// Full-screen "tuning…" splash shown while the player is resolving the
+/// stream URL and waiting for AVPlayer to render the first frame. Dismissed
+/// by `PlayerViewModel` when state transitions to `.playing`.
+///
+/// Background: vertical gradient from the channel logo's extracted dominant
+/// color (top) to `LiveTVTheme.background` (bottom). If extraction fails or
+/// the logo isn't loaded yet, the gradient starts at `LiveTVTheme.surface`
+/// so the screen still feels intentional rather than "missing background".
+struct ChannelSplashView: View {
+ let channel: LiveTvChannel
+ let serverURL: URL
+ let program: LiveTvProgram?
+
+ @State private var topColor: Color = LiveTVTheme.surface
+ @State private var pulsing: Bool = false
+
+ var body: some View {
+ ZStack {
+ backgroundGradient
+
+ VStack(spacing: 28) {
+ Spacer()
+
+ ChannelLogoView(channel: channel, serverURL: serverURL, maxWidth: 480)
+ .frame(width: 220, height: 140)
+ .shadow(color: .black.opacity(0.4), radius: 18, x: 0, y: 10)
+
+ channelLine
+
+ if let program {
+ programLines(program)
+ }
+
+ Spacer()
+
+ tuningIndicator
+ .padding(.bottom, 80)
+ }
+ .padding(.horizontal, 80)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .task(id: channel.id) {
+ await loadDominantColor()
+ }
+ .onAppear { pulsing = true }
+ }
+
+ private var backgroundGradient: some View {
+ LinearGradient(
+ colors: [topColor, LiveTVTheme.background],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .ignoresSafeArea()
+ .animation(.easeInOut(duration: 0.6), value: topColor)
+ }
+
+ private var channelLine: some View {
+ HStack(spacing: 12) {
+ if let number = channel.number, !number.isEmpty {
+ Text(number)
+ .font(LiveTVTypography.display)
+ .monospacedDigit()
+ .foregroundStyle(LiveTVTheme.accent)
+ Text("·")
+ .font(LiveTVTypography.display)
+ .foregroundStyle(LiveTVTheme.text.opacity(0.5))
+ }
+ Text(channel.name)
+ .font(LiveTVTypography.display)
+ .foregroundStyle(LiveTVTheme.text)
+ }
+ .lineLimit(1)
+ .minimumScaleFactor(0.6)
+ }
+
+ @ViewBuilder
+ private func programLines(_ program: LiveTvProgram) -> some View {
+ VStack(spacing: 6) {
+ Text(program.name)
+ .font(LiveTVTypography.programTitle)
+ .foregroundStyle(LiveTVTheme.text)
+ .lineLimit(1)
+ if let timeRange = LiveTvFormat.timeRange(start: program.startDate, end: program.endDate) {
+ Text(timeRange)
+ .font(LiveTVTypography.programTime)
+ .foregroundStyle(LiveTVTheme.secondaryText)
+ }
+ }
+ }
+
+ private var tuningIndicator: some View {
+ HStack(spacing: 16) {
+ HStack(spacing: 8) {
+ ForEach(0..<3) { i in
+ Circle()
+ .fill(LiveTVTheme.accent)
+ .frame(width: 10, height: 10)
+ .opacity(pulsing ? 1.0 : 0.3)
+ .scaleEffect(pulsing ? 1.3 : 1.0)
+ .animation(
+ .easeInOut(duration: 0.5)
+ .repeatForever(autoreverses: true)
+ .delay(Double(i) * 0.18),
+ value: pulsing
+ )
+ }
+ }
+ Text("Tuning\u{2026}")
+ .font(LiveTVTypography.timeLabel)
+ .foregroundStyle(LiveTVTheme.text)
+ }
+ }
+
+ private func loadDominantColor() async {
+ let logoURL = channel.logoURL(serverURL: serverURL, maxWidth: 256)
+ if let extracted = await ChannelDominantColor.shared.extract(logoURL: logoURL) {
+ // Tone the extracted color down a touch so it doesn't dominate
+ // the channel name's contrast — multiply alpha 0.7.
+ topColor = extracted.opacity(0.7)
+ }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Player/NetworkMonitor.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/NetworkMonitor.swift
new file mode 100644
index 0000000..cb087d8
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/NetworkMonitor.swift
@@ -0,0 +1,58 @@
+import Foundation
+import Network
+
+/// Abstracts `NWPathMonitor` so tests can drive path-status changes
+/// synchronously. Used by `PlayerViewModel` as a *secondary* signal — the
+/// primary trigger for the reconnect path is `AVPlayerItem.playbackBufferEmpty`
+/// for >5s. Network-restored events here just nudge the model to retry sooner
+/// if it's already reconnecting; they don't tear down a healthy stream.
+@MainActor
+public protocol NetworkMonitor: AnyObject, Sendable {
+ /// Emits true when the network is satisfied (reachable), false when it
+ /// drops or becomes unsatisfied.
+ var pathSatisfiedStream: AsyncStream { get }
+
+ /// Begin observation. Idempotent.
+ func start()
+
+ /// Stop observation; release underlying resources.
+ func stop()
+}
+
+/// Real implementation backed by `NWPathMonitor`.
+@MainActor
+public final class NWPathNetworkMonitor: NetworkMonitor {
+ public let pathSatisfiedStream: AsyncStream
+ private var continuation: AsyncStream.Continuation?
+
+ private let monitor: NWPathMonitor
+ private let queue: DispatchQueue
+ private var started = false
+
+ public init() {
+ self.monitor = NWPathMonitor()
+ self.queue = DispatchQueue(label: "tv.jelly.JellyTV.networkmonitor")
+ var cont: AsyncStream.Continuation!
+ self.pathSatisfiedStream = AsyncStream { cont = $0 }
+ self.continuation = cont
+ }
+
+ public func start() {
+ guard !started else { return }
+ started = true
+ monitor.pathUpdateHandler = { [weak self] path in
+ guard let self else { return }
+ let satisfied = path.status == .satisfied
+ Task { @MainActor in
+ self.continuation?.yield(satisfied)
+ }
+ }
+ monitor.start(queue: queue)
+ }
+
+ public func stop() {
+ monitor.cancel()
+ continuation?.finish()
+ started = false
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerErrorCard.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerErrorCard.swift
new file mode 100644
index 0000000..bc86e81
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerErrorCard.swift
@@ -0,0 +1,81 @@
+import SwiftUI
+import JellyfinAPI
+import DesignSystem
+
+/// Full-screen error card shown when stream-open fails twice (auto-retry-once
+/// already exhausted) or AVPlayer surfaces a fatal error after one retry.
+/// Two actions: Retry (re-enters resolving state) and Dismiss (closes player).
+struct PlayerErrorCard: View {
+ let channel: LiveTvChannel
+ let message: String
+ let detail: String?
+ let onRetry: () -> Void
+ let onDismiss: () -> Void
+
+ @FocusState private var focused: Action?
+
+ enum Action: Hashable { case retry, dismiss }
+
+ var body: some View {
+ ZStack {
+ LiveTVTheme.background.ignoresSafeArea()
+
+ VStack(spacing: 32) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 64, weight: .bold))
+ .foregroundStyle(LiveTVTheme.accent)
+
+ VStack(spacing: 12) {
+ Text(message)
+ .font(LiveTVTypography.strongTitle)
+ .foregroundStyle(LiveTVTheme.text)
+ .multilineTextAlignment(.center)
+
+ Text("Couldn't tune \(channel.name).")
+ .font(.title3)
+ .foregroundStyle(LiveTVTheme.secondaryText)
+
+ if let detail, !detail.isEmpty {
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(LiveTVTheme.secondaryText.opacity(0.7))
+ .multilineTextAlignment(.center)
+ .padding(.top, 8)
+ .frame(maxWidth: 700)
+ }
+ }
+
+ HStack(spacing: 24) {
+ Button(action: onRetry) {
+ Label("Retry", systemImage: "arrow.clockwise")
+ .font(.headline)
+ .frame(minWidth: 200)
+ }
+ .focused($focused, equals: .retry)
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.borderedProminent)
+ #endif
+
+ Button(action: onDismiss) {
+ Text("Dismiss")
+ .font(.headline)
+ .frame(minWidth: 200)
+ }
+ .focused($focused, equals: .dismiss)
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.bordered)
+ #endif
+ }
+ .padding(.top, 20)
+ }
+ .padding(80)
+ }
+ .onAppear {
+ focused = .retry
+ }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerHost.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerHost.swift
new file mode 100644
index 0000000..99277e6
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerHost.swift
@@ -0,0 +1,211 @@
+import Foundation
+#if os(tvOS)
+import AVKit
+import AVFoundation
+import UIKit
+#endif
+
+/// Abstracts the AVKit-shaped lifecycle the `PlayerViewModel` needs into a
+/// protocol so tests can drive state transitions deterministically without
+/// instantiating an `AVPlayer`. The real implementation
+/// (`AVKitPlayerHost`) wraps `AVPlayerViewController` + `AVPlayer` + KVO; the
+/// mock implementation (used in `PlayerViewModelTests`) emits state changes
+/// synchronously via `AsyncStream` continuations the test holds onto.
+@MainActor
+public protocol PlayerHost: AnyObject, Sendable {
+ /// Async stream of `AVPlayerItem.Status` raw values. Emits when the
+ /// underlying item transitions to `.readyToPlay` (status == 1) or
+ /// `.failed` (status == 2).
+ var statusStream: AsyncStream { get }
+
+ /// Async stream of `AVPlayerLayer.readyForDisplay` — true when the first
+ /// frame is decoded and ready to render. Used to dismiss the splash only
+ /// after pixels actually appear (not just on `.readyToPlay`).
+ var readyForDisplayStream: AsyncStream { get }
+
+ /// Async stream of `AVPlayerItem.playbackBufferEmpty`. Emits true when
+ /// the buffer drains mid-playback (likely network drop or transcoder
+ /// stall) and false when it refills.
+ var bufferEmptyStream: AsyncStream { get }
+
+ /// Async stream of `AVPlayerItemFailedToPlayToEndTime` notifications.
+ /// Emits the underlying error (or nil if not surfaced).
+ var failedToPlayStream: AsyncStream { get }
+
+ /// Replace the currently-playing item with one for the given URL.
+ /// First-time use opens the player at this URL.
+ func replaceItem(url: URL)
+
+ /// Tear down KVO + observers + the underlying AVPlayer. Called on dismiss.
+ func tearDown()
+}
+
+/// Type-erased error wrapper because `Error` is not directly `Sendable`.
+/// The real impl wraps `NSError`; the mock can pass a synthetic NSError.
+public struct PlayerHostError: Sendable {
+ public let domain: String
+ public let code: Int
+ public let localizedDescription: String
+
+ public init(domain: String, code: Int, localizedDescription: String) {
+ self.domain = domain
+ self.code = code
+ self.localizedDescription = localizedDescription
+ }
+
+ public init(_ error: Error) {
+ let ns = error as NSError
+ self.domain = ns.domain
+ self.code = ns.code
+ self.localizedDescription = ns.localizedDescription
+ }
+}
+
+#if os(tvOS)
+
+/// Real `PlayerHost` implementation: owns an `AVPlayer` + `AVPlayerLayer` for
+/// `readyForDisplay` KVO, and exposes an `AVPlayerViewController` for the
+/// SwiftUI view layer to render (via `UIViewControllerRepresentable`).
+///
+/// Status / readyForDisplay / bufferEmpty / failedToPlay are surfaced as
+/// `AsyncStream`s so the view model can `for-await` them inside its
+/// `start()` task.
+@MainActor
+public final class AVKitPlayerHost: NSObject, PlayerHost {
+ let controller: PlayerHostingController
+ private let player: AVPlayer
+
+ /// Wire these up before presenting; they're called when the user presses
+ /// up/down on the Siri Remote or vertical-swipes on the player view.
+ var onChannelUp: (() -> Void)? {
+ get { controller.onChannelUp }
+ set { controller.onChannelUp = newValue }
+ }
+ var onChannelDown: (() -> Void)? {
+ get { controller.onChannelDown }
+ set { controller.onChannelDown = newValue }
+ }
+
+ private var statusContinuation: AsyncStream.Continuation?
+ private var readyForDisplayContinuation: AsyncStream.Continuation?
+ private var bufferEmptyContinuation: AsyncStream.Continuation?
+ private var failedToPlayContinuation: AsyncStream.Continuation?
+
+ public let statusStream: AsyncStream
+ public let readyForDisplayStream: AsyncStream
+ public let bufferEmptyStream: AsyncStream
+ public let failedToPlayStream: AsyncStream
+
+ private var statusObservation: NSKeyValueObservation?
+ private var readyForDisplayObservation: NSKeyValueObservation?
+ private var bufferEmptyObservation: NSKeyValueObservation?
+ private var failedObserver: NSObjectProtocol?
+
+ public override init() {
+ self.player = AVPlayer()
+ self.controller = PlayerHostingController()
+ self.controller.player = player
+
+ var statusCont: AsyncStream.Continuation!
+ self.statusStream = AsyncStream { statusCont = $0 }
+ var readyCont: AsyncStream.Continuation!
+ self.readyForDisplayStream = AsyncStream { readyCont = $0 }
+ var bufferCont: AsyncStream.Continuation!
+ self.bufferEmptyStream = AsyncStream { bufferCont = $0 }
+ var failedCont: AsyncStream.Continuation!
+ self.failedToPlayStream = AsyncStream { failedCont = $0 }
+
+ super.init()
+
+ self.statusContinuation = statusCont
+ self.readyForDisplayContinuation = readyCont
+ self.bufferEmptyContinuation = bufferCont
+ self.failedToPlayContinuation = failedCont
+
+ // KVO on AVPlayer.currentItem.status — set up once; we rebind the
+ // observation each time replaceItem swaps the item.
+ observeReadyForDisplay()
+ }
+
+ public func replaceItem(url: URL) {
+ statusObservation?.invalidate()
+ bufferEmptyObservation?.invalidate()
+ if let failedObserver {
+ NotificationCenter.default.removeObserver(failedObserver)
+ self.failedObserver = nil
+ }
+
+ let item = AVPlayerItem(url: url)
+ // Reduce initial buffering to ~2s so the first frame appears quickly.
+ // Keep automaticallyWaitsToMinimizeStalling at its default (true) —
+ // changing it to false conflicts with the bufferEmpty reconnect logic.
+ item.preferredForwardBufferDuration = 2.0
+ player.replaceCurrentItem(with: item)
+
+ statusObservation = item.observe(\.status, options: [.new]) { [weak self] item, _ in
+ guard let self else { return }
+ Task { @MainActor in
+ self.statusContinuation?.yield(item.status.rawValue)
+ }
+ }
+ bufferEmptyObservation = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] item, _ in
+ guard let self else { return }
+ Task { @MainActor in
+ self.bufferEmptyContinuation?.yield(item.isPlaybackBufferEmpty)
+ }
+ }
+ failedObserver = NotificationCenter.default.addObserver(
+ forName: .AVPlayerItemFailedToPlayToEndTime,
+ object: item,
+ queue: .main
+ ) { [weak self] note in
+ guard let self else { return }
+ let underlying = note.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
+ let wrapped = underlying.map { PlayerHostError($0) }
+ Task { @MainActor in
+ self.failedToPlayContinuation?.yield(wrapped)
+ }
+ }
+
+ player.play()
+ }
+
+ public func tearDown() {
+ statusObservation?.invalidate()
+ statusObservation = nil
+ readyForDisplayObservation?.invalidate()
+ readyForDisplayObservation = nil
+ bufferEmptyObservation?.invalidate()
+ bufferEmptyObservation = nil
+ if let failedObserver {
+ NotificationCenter.default.removeObserver(failedObserver)
+ self.failedObserver = nil
+ }
+ player.pause()
+ player.replaceCurrentItem(with: nil)
+ controller.player = nil
+ statusContinuation?.finish()
+ readyForDisplayContinuation?.finish()
+ bufferEmptyContinuation?.finish()
+ failedToPlayContinuation?.finish()
+ }
+
+ private func observeReadyForDisplay() {
+ // Watch the AVPlayerViewController-owned layer's readyForDisplay.
+ // The controller exposes the layer indirectly; the cleanest hook is
+ // KVO on contentOverlayView.layer? — but simpler: poll once via
+ // statusStream's .readyToPlay AND a follow-up from
+ // AVPlayer.timeControlStatus, which transitions to .playing once the
+ // first frame renders.
+ readyForDisplayObservation = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
+ guard let self else { return }
+ // .playing means the player is actually rendering frames.
+ let isPlaying = player.timeControlStatus == .playing
+ Task { @MainActor in
+ self.readyForDisplayContinuation?.yield(isPlaying)
+ }
+ }
+ }
+}
+
+#endif
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerHostingController.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerHostingController.swift
new file mode 100644
index 0000000..5845257
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerHostingController.swift
@@ -0,0 +1,88 @@
+#if os(tvOS)
+import UIKit
+import AVKit
+import AVFoundation
+
+/// Custom `AVPlayerViewController` subclass that:
+/// - Enables Picture-in-Picture (`allowsPictureInPicturePlayback = true`).
+/// - Sets `AVAudioSession` category to `.playback` on appear and restores the
+/// prior category on disappear, so PiP can suspend/resume cleanly without
+/// permanently changing the app-wide session state.
+/// - Intercepts `.upArrow` and `.downArrow` Siri Remote presses for in-player
+/// channel up/down. We deliberately do NOT call `super.pressesBegan` for
+/// those two keys (otherwise AVPlayerViewController fires its own info
+/// overlay simultaneously with our channel change). All other presses
+/// (Menu, Select, Play/Pause, etc.) are forwarded to `super`.
+/// - Adds vertical-swipe gesture recognizers as a second input affordance.
+final class PlayerHostingController: AVPlayerViewController {
+ var onChannelUp: (() -> Void)?
+ var onChannelDown: (() -> Void)?
+ /// Called when the user presses the Menu button, before the press is
+ /// forwarded to `super` (which dismisses the AVPlayerViewController).
+ /// Wire this to `PlayerViewModel.dismiss()` + the SwiftUI `onDismiss`
+ /// callback so the live stream is torn down before the view disappears.
+ var onDismissRequested: (() -> Void)?
+
+ private var priorAudioSessionCategory: AVAudioSession.Category?
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ allowsPictureInPicturePlayback = true
+
+ let upSwipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeUp))
+ upSwipe.direction = .up
+ view.addGestureRecognizer(upSwipe)
+
+ let downSwipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeDown))
+ downSwipe.direction = .down
+ view.addGestureRecognizer(downSwipe)
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ let session = AVAudioSession.sharedInstance()
+ priorAudioSessionCategory = session.category
+ try? session.setCategory(.playback, mode: .moviePlayback)
+ try? session.setActive(true)
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+ let session = AVAudioSession.sharedInstance()
+ if let prior = priorAudioSessionCategory {
+ try? session.setCategory(prior)
+ }
+ try? session.setActive(false, options: [.notifyOthersOnDeactivation])
+ }
+
+ override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) {
+ // Filter out up/down arrow presses we want to handle ourselves —
+ // forward everything else to super so Menu/Select/Play-Pause still
+ // work normally.
+ var handled: Set = []
+ for press in presses {
+ switch press.type {
+ case .upArrow:
+ onChannelUp?()
+ handled.insert(press)
+ case .downArrow:
+ onChannelDown?()
+ handled.insert(press)
+ case .menu:
+ // Initiate teardown BEFORE forwarding to super so the stream
+ // closes before AVPlayerViewController dismisses the view.
+ onDismissRequested?()
+ default:
+ break
+ }
+ }
+ let forwarded = presses.subtracting(handled)
+ if !forwarded.isEmpty {
+ super.pressesBegan(forwarded, with: event)
+ }
+ }
+
+ @objc private func handleSwipeUp() { onChannelUp?() }
+ @objc private func handleSwipeDown() { onChannelDown?() }
+}
+#endif
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerViewModel.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerViewModel.swift
new file mode 100644
index 0000000..e66e27f
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/PlayerViewModel.swift
@@ -0,0 +1,412 @@
+import Foundation
+import Observation
+import JellyfinAPI
+
+/// State machine driving the Live TV player. Owns:
+/// - The current Jellyfin live-stream session (id + URL)
+/// - AVPlayer lifecycle, abstracted via `PlayerHost`
+/// - Network monitoring as a secondary signal, abstracted via `NetworkMonitor`
+/// - Auto-retry policy: retry once on transient failures; reset counter on
+/// every successful transition to `.playing` AND on user-initiated channel
+/// change. DirectPlay-fallback is a separate one-shot retry that flips
+/// `forceTranscoding=true` if the first attempt produced a direct-stream
+/// URL AVPlayer couldn't decode.
+/// - Channel up/down with 400ms debounce.
+/// - Stream close on dismiss (held in a static `Set` so view-dealloc
+/// doesn't kill it mid-flight).
+@MainActor
+@Observable
+public final class PlayerViewModel {
+
+ // MARK: - State
+
+ public enum State: Equatable, Sendable {
+ case idle
+ case resolving(LiveTvChannel)
+ case splash(LiveTvChannel, LiveStreamPlayback)
+ case buffering(LiveTvChannel, LiveStreamPlayback)
+ case playing(LiveTvChannel, LiveStreamPlayback)
+ case reconnecting(LiveTvChannel, LiveStreamPlayback)
+ case error(channel: LiveTvChannel, message: String, detail: String?)
+
+ public var channel: LiveTvChannel? {
+ switch self {
+ case .idle: return nil
+ case .resolving(let c): return c
+ case .splash(let c, _): return c
+ case .buffering(let c, _): return c
+ case .playing(let c, _): return c
+ case .reconnecting(let c, _): return c
+ case .error(let c, _, _): return c
+ }
+ }
+
+ public var playback: LiveStreamPlayback? {
+ switch self {
+ case .splash(_, let p), .buffering(_, let p), .playing(_, let p), .reconnecting(_, let p):
+ return p
+ default:
+ return nil
+ }
+ }
+
+ /// True when the splash overlay should be visible.
+ public var showsSplash: Bool {
+ switch self {
+ case .resolving, .splash, .buffering: return true
+ default: return false
+ }
+ }
+ }
+
+ // MARK: - Public state
+
+ public private(set) var state: State = .idle
+
+ /// Whether the channel-info HUD is currently shown over playing video.
+ /// Auto-shown for ~3s on each tune; refreshed on remote-tap.
+ public private(set) var hudVisible: Bool = false
+
+ /// The full ordered channel list — used for channel up/down.
+ public var channels: [LiveTvChannel]
+
+ /// Optional program info passed in for the splash + HUD. Updated when
+ /// channel changes if the new channel has `currentProgram`.
+ public private(set) var currentProgram: LiveTvProgram?
+
+ public let serverURL: URL
+
+ // MARK: - Dependencies
+
+ public typealias OpenStream = @MainActor (LiveTvChannel, _ forceTranscoding: Bool) async throws -> LiveStreamPlayback
+ public typealias CloseStream = @MainActor (String) async -> Void
+
+ private let openStream: OpenStream
+ private let closeStream: CloseStream
+ private let host: any PlayerHost
+ private let networkMonitor: any NetworkMonitor
+
+ // MARK: - Internal state
+
+ private var retryCount: Int = 0
+ private var directPlayFallbackUsed: Bool = false
+ private var debounceTask: Task?
+ private var pendingChannel: LiveTvChannel?
+ private var bufferEmptyStartedAt: Date?
+ private var reconnectTimeoutTask: Task?
+ private var hudHideTask: Task?
+ private var observationTasks: [Task] = []
+ /// The Task created in init to tune the initial channel. Cancelled on dismiss.
+ private var initTuneTask: Task?
+ /// Guard against re-entrant dismiss() calls (Menu press + onDisappear backstop).
+ private var isDismissed: Bool = false
+
+ /// Keeps in-flight closeStream tasks alive past view-dealloc so the
+ /// server-side session is actually torn down. Tasks remove themselves
+ /// from the set on completion.
+ private static var inFlightCloseTasks: Set> = []
+
+ // Debounce window for rapid channel up/down presses.
+ private let debounceMillis: UInt64 = 400_000_000
+ // Buffer-empty must persist for >5s before we trigger reconnect.
+ private let bufferEmptyThresholdSeconds: TimeInterval = 5
+ // Reconnect must succeed within 10s or escalate to error card.
+ private let reconnectTimeoutSeconds: TimeInterval = 10
+
+ // MARK: - Init
+
+ public init(
+ initialChannel: LiveTvChannel,
+ channels: [LiveTvChannel],
+ serverURL: URL,
+ program: LiveTvProgram?,
+ openStream: @escaping OpenStream,
+ closeStream: @escaping CloseStream,
+ host: any PlayerHost,
+ networkMonitor: any NetworkMonitor
+ ) {
+ self.channels = channels
+ self.serverURL = serverURL
+ self.currentProgram = program
+ self.openStream = openStream
+ self.closeStream = closeStream
+ self.host = host
+ self.networkMonitor = networkMonitor
+ self.state = .idle
+
+ startObservers()
+ initTuneTask = Task { await tune(initialChannel, isUserInitiated: true) }
+ }
+
+ // MARK: - Public API
+
+ /// Tune to a specific channel. Closes the current session, opens a new one,
+ /// transitions through resolving → splash → buffering → playing.
+ public func tune(_ channel: LiveTvChannel, isUserInitiated: Bool = true) async {
+ if isUserInitiated {
+ retryCount = 0
+ directPlayFallbackUsed = false
+ }
+ await closeCurrentStreamIfNeeded()
+
+ state = .resolving(channel)
+ currentProgram = channel.currentProgram
+ await openAndStart(channel: channel, forceTranscoding: false)
+ }
+
+ /// Channel-up debounced — if pressed rapidly multiple times, only the
+ /// last press triggers a tune.
+ public func channelUp() {
+ scheduleDebouncedTune { current, channels in
+ ChannelOrdering.next(after: current, in: channels)
+ }
+ }
+
+ /// Channel-down debounced.
+ public func channelDown() {
+ scheduleDebouncedTune { current, channels in
+ ChannelOrdering.previous(before: current, in: channels)
+ }
+ }
+
+ /// User-triggered retry from the error card.
+ public func retry() async {
+ guard let channel = state.channel else { return }
+ retryCount = 0
+ directPlayFallbackUsed = false
+ await tune(channel, isUserInitiated: true)
+ }
+
+ /// Show the channel-info HUD for ~3s.
+ public func pulseHUD() {
+ hudVisible = true
+ hudHideTask?.cancel()
+ hudHideTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: 3_000_000_000)
+ guard !Task.isCancelled else { return }
+ self?.hudVisible = false
+ }
+ }
+
+ /// Tear everything down — called on view dismiss. Schedules the close
+ /// call on a long-lived task, stops the network monitor, tears down the
+ /// player host.
+ ///
+ /// Idempotent: safe to call multiple times (Menu-press handler + onDisappear
+ /// backstop may both fire). Only the first call performs teardown.
+ public func dismiss() {
+ guard !isDismissed else { return }
+ isDismissed = true
+
+ initTuneTask?.cancel()
+ initTuneTask = nil
+ debounceTask?.cancel()
+ reconnectTimeoutTask?.cancel()
+ hudHideTask?.cancel()
+ observationTasks.forEach { $0.cancel() }
+ // Capture liveStreamId synchronously BEFORE clearing state — otherwise
+ // the async close call reads state.playback after we've already
+ // transitioned to .idle and finds nothing to close.
+ if let id = state.playback?.liveStreamId {
+ scheduleClose(liveStreamId: id)
+ }
+ networkMonitor.stop()
+ host.tearDown()
+ state = .idle
+ }
+
+ // MARK: - Internal: tuning & state transitions
+
+ private func openAndStart(channel: LiveTvChannel, forceTranscoding: Bool) async {
+ do {
+ let playback = try await openStream(channel, forceTranscoding)
+ // The state may have changed while awaiting (user pressed channel
+ // up again). If so, abandon this open's result.
+ guard case .resolving(let resolvingChannel) = state, resolvingChannel.id == channel.id else {
+ // The new tune already kicked off — close the just-opened
+ // stream so we don't leak it.
+ if let id = playback.liveStreamId {
+ scheduleClose(liveStreamId: id)
+ }
+ return
+ }
+ state = .splash(channel, playback)
+ host.replaceItem(url: playback.playbackURL)
+ } catch {
+ await handleOpenFailure(channel: channel, error: error, forceTranscoding: forceTranscoding)
+ }
+ }
+
+ private func handleOpenFailure(channel: LiveTvChannel, error: Error, forceTranscoding: Bool) async {
+ JellytvLog.player.error("PlayerViewModel: openStream failed for \(channel.id, privacy: .public): \(String(describing: error), privacy: .public)")
+ // First failure on the original (no-fallback) path: try forceTranscoding.
+ if !forceTranscoding && !directPlayFallbackUsed {
+ directPlayFallbackUsed = true
+ JellytvLog.player.info("PlayerViewModel: retrying with forceTranscoding=true")
+ await openAndStart(channel: channel, forceTranscoding: true)
+ return
+ }
+ // Otherwise consume one general retry budget.
+ if retryCount < 1 {
+ retryCount += 1
+ JellytvLog.player.info("PlayerViewModel: auto-retry (count=\(self.retryCount))")
+ await openAndStart(channel: channel, forceTranscoding: directPlayFallbackUsed)
+ return
+ }
+ // No more retries — show the error card.
+ let nsError = error as NSError
+ state = .error(channel: channel, message: "Couldn't tune \(channel.name)", detail: nsError.localizedDescription)
+ }
+
+ private func handlePlaybackFailure(error: PlayerHostError?) async {
+ guard let channel = state.channel, state.playback != nil else { return }
+ // Treat mid-playback failure as one retry budget — same policy as
+ // open failure. Reset DirectPlay fallback so we don't double-flip.
+ if retryCount < 1 {
+ retryCount += 1
+ JellytvLog.player.info("PlayerViewModel: AVPlayer failure, retrying (count=\(self.retryCount))")
+ await closeCurrentStreamIfNeeded()
+ state = .resolving(channel)
+ await openAndStart(channel: channel, forceTranscoding: directPlayFallbackUsed)
+ return
+ }
+ let detail = error?.localizedDescription
+ state = .error(channel: channel, message: "Playback stopped", detail: detail)
+ }
+
+ // MARK: - Internal: channel up/down debounce
+
+ private func scheduleDebouncedTune(
+ _ resolver: @escaping @MainActor (LiveTvChannel, [LiveTvChannel]) -> LiveTvChannel?
+ ) {
+ guard let current = state.channel ?? pendingChannel else { return }
+ // Compute candidate from resolver, store, restart timer.
+ let candidate = resolver(pendingChannel ?? current, channels)
+ guard let candidate else { return }
+ pendingChannel = candidate
+ debounceTask?.cancel()
+ debounceTask = Task { @MainActor [weak self, debounceMillis] in
+ try? await Task.sleep(nanoseconds: debounceMillis)
+ guard let self, !Task.isCancelled else { return }
+ guard let final = self.pendingChannel else { return }
+ self.pendingChannel = nil
+ await self.tune(final, isUserInitiated: true)
+ self.pulseHUD()
+ }
+ }
+
+ // MARK: - Internal: stream close lifecycle
+
+ private func closeCurrentStreamIfNeeded() async {
+ guard let oldId = state.playback?.liveStreamId else { return }
+ scheduleClose(liveStreamId: oldId)
+ }
+
+ private func scheduleClose(liveStreamId: String) {
+ let close = closeStream
+ let task = Task { @MainActor in
+ await close(liveStreamId)
+ }
+ Self.registerCloseTask(task)
+ }
+
+ @MainActor
+ private static func registerCloseTask(_ task: Task) {
+ inFlightCloseTasks.insert(task)
+ Task { @MainActor in
+ await task.value
+ inFlightCloseTasks.remove(task)
+ }
+ }
+
+ // MARK: - Internal: observer plumbing
+
+ private func startObservers() {
+ observationTasks.forEach { $0.cancel() }
+ observationTasks.removeAll(keepingCapacity: true)
+ observationTasks.append(Task { @MainActor [weak self] in await self?.consumeStatusStream() })
+ observationTasks.append(Task { @MainActor [weak self] in await self?.consumeReadyForDisplayStream() })
+ observationTasks.append(Task { @MainActor [weak self] in await self?.consumeBufferEmptyStream() })
+ observationTasks.append(Task { @MainActor [weak self] in await self?.consumeFailedToPlayStream() })
+ observationTasks.append(Task { @MainActor [weak self] in await self?.consumeNetworkStream() })
+ networkMonitor.start()
+ }
+
+ private func consumeStatusStream() async {
+ for await rawStatus in host.statusStream {
+ // .failed = 2
+ if rawStatus == 2 {
+ await handlePlaybackFailure(error: nil)
+ }
+ }
+ }
+
+ private func consumeReadyForDisplayStream() async {
+ for await ready in host.readyForDisplayStream where ready {
+ // First frame rendered — exit splash.
+ switch state {
+ case .splash(let c, let p), .buffering(let c, let p):
+ state = .playing(c, p)
+ retryCount = 0
+ pulseHUD()
+ default:
+ break
+ }
+ }
+ }
+
+ private func consumeBufferEmptyStream() async {
+ for await empty in host.bufferEmptyStream {
+ if empty {
+ bufferEmptyStartedAt = Date()
+ Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: UInt64((self?.bufferEmptyThresholdSeconds ?? 5) * 1_000_000_000))
+ guard let self else { return }
+ // If still empty after threshold, switch to reconnecting.
+ guard let started = self.bufferEmptyStartedAt,
+ Date().timeIntervalSince(started) >= self.bufferEmptyThresholdSeconds else {
+ return
+ }
+ if case .playing(let c, let p) = self.state {
+ self.state = .reconnecting(c, p)
+ self.scheduleReconnectEscalation()
+ }
+ }
+ } else {
+ bufferEmptyStartedAt = nil
+ reconnectTimeoutTask?.cancel()
+ if case .reconnecting(let c, let p) = state {
+ state = .playing(c, p)
+ }
+ }
+ }
+ }
+
+ private func consumeFailedToPlayStream() async {
+ for await error in host.failedToPlayStream {
+ await handlePlaybackFailure(error: error)
+ }
+ }
+
+ private func consumeNetworkStream() async {
+ for await satisfied in networkMonitor.pathSatisfiedStream {
+ // NetworkMonitor is a SECONDARY signal — only act if we're already
+ // reconnecting. This avoids fighting AVPlayer's own HLS retry.
+ if satisfied, case .reconnecting(let c, _) = state {
+ JellytvLog.player.info("PlayerViewModel: network restored during reconnect, retrying")
+ await tune(c, isUserInitiated: false)
+ }
+ }
+ }
+
+ private func scheduleReconnectEscalation() {
+ reconnectTimeoutTask?.cancel()
+ reconnectTimeoutTask = Task { @MainActor [weak self, reconnectTimeoutSeconds] in
+ try? await Task.sleep(nanoseconds: UInt64(reconnectTimeoutSeconds * 1_000_000_000))
+ guard let self, !Task.isCancelled else { return }
+ if case .reconnecting(let c, _) = self.state {
+ self.state = .error(channel: c, message: "Lost connection", detail: "The stream couldn't recover after \(Int(reconnectTimeoutSeconds))s.")
+ }
+ }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ReconnectingToast.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ReconnectingToast.swift
new file mode 100644
index 0000000..57e4192
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Player/ReconnectingToast.swift
@@ -0,0 +1,36 @@
+import SwiftUI
+import DesignSystem
+
+/// Non-blocking top-floating toast shown over live video when AVPlayer's
+/// `playbackBufferEmpty` has been true for >5s (network drop / transcoder
+/// stall). Disappears when buffer refills. If recovery doesn't happen
+/// within ~10s, the player escalates to the full `PlayerErrorCard`.
+struct ReconnectingToast: View {
+ let isVisible: Bool
+
+ var body: some View {
+ VStack {
+ if isVisible {
+ HStack(spacing: 14) {
+ ProgressView()
+ .controlSize(.small)
+ .tint(LiveTVTheme.text)
+ Text("Reconnecting\u{2026}")
+ .font(LiveTVTypography.timeLabel)
+ .foregroundStyle(LiveTVTheme.text)
+ }
+ .padding(.horizontal, 24)
+ .padding(.vertical, 14)
+ .background(.ultraThinMaterial, in: Capsule())
+ .overlay(
+ Capsule().stroke(LiveTVTheme.divider, lineWidth: 1)
+ )
+ .padding(.top, 60)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ Spacer()
+ }
+ .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isVisible)
+ .allowsHitTesting(false)
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/ProgramDetail/ProgramDetailModel.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/ProgramDetail/ProgramDetailModel.swift
new file mode 100644
index 0000000..418d3a9
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/ProgramDetail/ProgramDetailModel.swift
@@ -0,0 +1,75 @@
+import Foundation
+import Observation
+import JellyfinAPI
+
+@MainActor
+@Observable
+public final class ProgramDetailModel {
+ public enum RecordState: Equatable, Sendable {
+ case unknown
+ case notScheduled
+ case scheduled(timerId: String)
+ case recording(timerId: String)
+ case error(String)
+ }
+
+ public private(set) var program: LiveTvProgram
+ public private(set) var recordState: RecordState = .unknown
+ public private(set) var isWorking: Bool = false
+
+ private let client: any JellyfinClientAPI
+
+ public init(program: LiveTvProgram, client: any JellyfinClientAPI) {
+ self.program = program
+ self.client = client
+ }
+
+ /// Refreshes the timer state (used by the Record button) by checking
+ /// `/LiveTv/Timers` for an entry whose programId matches this program.
+ public func refreshRecordState() async {
+ do {
+ let timers = try await client.liveTvTimers()
+ if let timer = timers.first(where: { $0.programId == program.id }) {
+ let status = timer.status?.lowercased() ?? ""
+ if status == "inprogress" || status == "recording" {
+ recordState = .recording(timerId: timer.id)
+ } else {
+ recordState = .scheduled(timerId: timer.id)
+ }
+ } else {
+ recordState = .notScheduled
+ }
+ } catch {
+ JellytvLog.liveTV.error("ProgramDetailModel.refreshRecordState: \(String(describing: error), privacy: .public)")
+ recordState = .notScheduled
+ }
+ }
+
+ /// Toggle: schedule a recording if not scheduled, cancel it if it is.
+ /// Round-trips `liveTvTimerDefaults(programId:)` → `createLiveTvTimer(body:)`
+ /// because Jellyfin's timer endpoint expects the full defaults document
+ /// echoed back, with the programId pre-bound by the server.
+ public func toggleRecording() async {
+ if isWorking { return }
+ isWorking = true
+ defer { isWorking = false }
+
+ switch recordState {
+ case .scheduled(let timerId), .recording(let timerId):
+ do {
+ try await client.cancelLiveTvTimer(timerId: timerId)
+ recordState = .notScheduled
+ } catch {
+ recordState = .error(error.localizedDescription)
+ }
+ case .notScheduled, .unknown, .error:
+ do {
+ let body = try await client.liveTvTimerDefaults(programId: program.id)
+ try await client.createLiveTvTimer(body: body)
+ await refreshRecordState()
+ } catch {
+ recordState = .error(error.localizedDescription)
+ }
+ }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/ProgramDetail/ProgramDetailView.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/ProgramDetail/ProgramDetailView.swift
new file mode 100644
index 0000000..36256d9
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/ProgramDetail/ProgramDetailView.swift
@@ -0,0 +1,362 @@
+import SwiftUI
+import NukeUI
+import JellyfinAPI
+import DesignSystem
+
+/// Full-screen program detail. Backdrop, title, time, overview, then a
+/// horizontal action row: Watch (if airing now) and Record (toggle).
+public struct ProgramDetailView: View {
+ @Bindable var model: ProgramDetailModel
+ let serverURL: URL
+ let onWatchChannel: (String) -> Void
+ let onDismiss: () -> Void
+
+ public init(
+ model: ProgramDetailModel,
+ serverURL: URL,
+ onWatchChannel: @escaping (String) -> Void,
+ onDismiss: @escaping () -> Void
+ ) {
+ self.model = model
+ self.serverURL = serverURL
+ self.onWatchChannel = onWatchChannel
+ self.onDismiss = onDismiss
+ }
+
+ public var body: some View {
+ ZStack(alignment: .bottomLeading) {
+ backdrop
+ LinearGradient(
+ colors: [.clear, .black.opacity(0.85)],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ VStack(alignment: .leading, spacing: 18) {
+ headerBadges
+ Text(model.program.name)
+ .font(.system(size: 56, weight: .heavy))
+ .lineLimit(2)
+ metaRow
+ if let overview = model.program.overview {
+ Text(overview)
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .lineLimit(6)
+ .frame(maxWidth: 1100, alignment: .leading)
+ }
+ actionRow
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 80)
+ .padding(.bottom, 80)
+ .padding(.top, 100)
+ }
+ .ignoresSafeArea()
+ .task {
+ await model.refreshRecordState()
+ }
+ }
+
+ private func channelHeaderText(channelName: String) -> String {
+ if let number = model.program.channelNumber, !number.isEmpty {
+ return "\(channelName) · CH \(number)"
+ }
+ return channelName
+ }
+
+ private var headerBadges: some View {
+ HStack(spacing: 10) {
+ if isAiringNow {
+ LiveBadge(label: "LIVE")
+ }
+ if model.program.isPremiere == true {
+ tag("PREMIERE", color: .pink)
+ } else if model.program.isRepeat == true {
+ tag("REPEAT", color: .gray)
+ }
+ if let channelName = model.program.channelName {
+ HStack(spacing: 6) {
+ Image(systemName: "tv")
+ Text(channelHeaderText(channelName: channelName))
+ .monospacedDigit()
+ }
+ .font(.subheadline.weight(.semibold))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(.black.opacity(0.45), in: RoundedRectangle(cornerRadius: 10))
+ }
+ }
+ }
+
+ private var metaRow: some View {
+ HStack(spacing: 12) {
+ if let timeRange = LiveTvFormat.timeRange(start: model.program.startDate, end: model.program.endDate) {
+ Label(timeRange, systemImage: "clock")
+ .labelStyle(.titleAndIcon)
+ .font(.title3.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ if let year = model.program.productionYear {
+ Text(String(year))
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ }
+ if let rating = model.program.officialRating {
+ Text(rating)
+ .font(.subheadline.weight(.semibold))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 2)
+ .background(.white.opacity(0.18), in: RoundedRectangle(cornerRadius: 4))
+ }
+ if let genres = model.program.genres, !genres.isEmpty {
+ Text(genres.prefix(3).joined(separator: " · "))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ }
+
+ private var actionRow: some View {
+ HStack(spacing: 16) {
+ if isAiringNow, let channelId = model.program.channelId {
+ ActionButton(
+ title: "Watch Live",
+ systemImage: "play.fill",
+ isPrimary: true
+ ) {
+ onWatchChannel(channelId)
+ }
+ }
+ ActionButton(
+ title: recordButtonTitle,
+ systemImage: recordButtonIcon,
+ isPrimary: !isAiringNow,
+ tint: recordButtonTint
+ ) {
+ Task { await model.toggleRecording() }
+ }
+ ActionButton(
+ title: "Close",
+ systemImage: "xmark",
+ isPrimary: false
+ ) {
+ onDismiss()
+ }
+ if model.isWorking {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .padding(.leading, 8)
+ }
+ Spacer(minLength: 0)
+ }
+ .focusSection()
+ }
+
+ private var isAiringNow: Bool {
+ guard let start = model.program.startDate,
+ let end = model.program.endDate else { return false }
+ let now = Date()
+ return now >= start && now < end
+ }
+
+ private var recordButtonTitle: String {
+ switch model.recordState {
+ case .scheduled: return "Cancel Recording"
+ case .recording: return "Stop Recording"
+ case .notScheduled, .unknown, .error: return "Record"
+ }
+ }
+
+ private var recordButtonIcon: String {
+ switch model.recordState {
+ case .scheduled, .recording: return "record.circle.fill"
+ case .notScheduled, .unknown, .error: return "record.circle"
+ }
+ }
+
+ private var recordButtonTint: Color? {
+ switch model.recordState {
+ case .scheduled, .recording: return .red
+ default: return nil
+ }
+ }
+
+ private func tag(_ text: String, color: Color) -> some View {
+ Text(text)
+ .font(.caption.weight(.heavy))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 4)
+ .background(color, in: Capsule())
+ }
+
+ @ViewBuilder
+ private var backdrop: some View {
+ if let url = model.program.backdropURL(serverURL: serverURL, maxWidth: 1920) {
+ LazyImage(url: url) { state in
+ if let image = state.image {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .ignoresSafeArea()
+ } else {
+ fallback
+ }
+ }
+ } else {
+ fallback
+ }
+ }
+
+ private var fallback: some View {
+ LinearGradient(
+ colors: [Color.accentColor.opacity(0.4), Color.black],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ .ignoresSafeArea()
+ }
+}
+
+private struct ActionButton: View {
+ let title: String
+ let systemImage: String
+ let isPrimary: Bool
+ var tint: Color? = nil
+ let action: () -> Void
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 8) {
+ Image(systemName: systemImage)
+ Text(title)
+ }
+ .font(.headline)
+ .padding(.horizontal, 26)
+ .padding(.vertical, 14)
+ .background(background, in: RoundedRectangle(cornerRadius: 10))
+ .foregroundStyle(foreground)
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ }
+
+ private var background: Color {
+ if let tint {
+ return isFocused ? tint : tint.opacity(0.85)
+ }
+ if isPrimary {
+ return isFocused ? .white : .white.opacity(0.95)
+ }
+ return isFocused ? .white.opacity(0.30) : .white.opacity(0.18)
+ }
+
+ private var foreground: Color {
+ if tint != nil { return .white }
+ return isPrimary ? .black : .white
+ }
+}
+
+// MARK: - Presentation modifier
+
+/// Sheet/full-screen presentation for `ProgramDetailView`. Shared by every
+/// view that surfaces programs (Guide cells, On Now hero, Up Next shelf).
+public struct ProgramDetailPresentation: ViewModifier {
+ @Binding var selectedProgram: LiveTvProgram?
+ let client: any JellyfinClientAPI
+ let onWatchChannel: (LiveTvChannel) -> Void
+
+ public init(
+ selectedProgram: Binding,
+ client: any JellyfinClientAPI,
+ onWatchChannel: @escaping (LiveTvChannel) -> Void
+ ) {
+ self._selectedProgram = selectedProgram
+ self.client = client
+ self.onWatchChannel = onWatchChannel
+ }
+
+ public func body(content: Content) -> some View {
+ #if os(tvOS)
+ content.fullScreenCover(item: $selectedProgram) { program in
+ detailView(for: program)
+ }
+ #else
+ content.sheet(item: $selectedProgram) { program in
+ detailView(for: program)
+ }
+ #endif
+ }
+
+ @ViewBuilder
+ private func detailView(for program: LiveTvProgram) -> some View {
+ ProgramDetailContainer(
+ program: program,
+ client: client,
+ onWatchChannel: onWatchChannel,
+ onDismiss: { selectedProgram = nil }
+ )
+ }
+}
+
+private struct ProgramDetailContainer: View {
+ let program: LiveTvProgram
+ let client: any JellyfinClientAPI
+ let onWatchChannel: (LiveTvChannel) -> Void
+ let onDismiss: () -> Void
+
+ @State private var serverURL: URL?
+ @State private var model: ProgramDetailModel
+
+ init(
+ program: LiveTvProgram,
+ client: any JellyfinClientAPI,
+ onWatchChannel: @escaping (LiveTvChannel) -> Void,
+ onDismiss: @escaping () -> Void
+ ) {
+ self.program = program
+ self.client = client
+ self.onWatchChannel = onWatchChannel
+ self.onDismiss = onDismiss
+ _model = State(initialValue: ProgramDetailModel(program: program, client: client))
+ }
+
+ var body: some View {
+ Group {
+ if let serverURL {
+ ProgramDetailView(
+ model: model,
+ serverURL: serverURL,
+ onWatchChannel: { channelId in
+ // Synthesize a LiveTvChannel from the program metadata,
+ // and crucially attach the program itself as
+ // `currentProgram` so the player splash + HUD render
+ // program info instead of empty state.
+ let channel = LiveTvChannel(
+ id: channelId,
+ name: program.channelName ?? "Live TV",
+ number: program.channelNumber,
+ imageTags: program.channelPrimaryImageTag.map { ["Primary": $0] },
+ currentProgram: program
+ )
+ onWatchChannel(channel)
+ },
+ onDismiss: onDismiss
+ )
+ } else {
+ ProgressView()
+ .controlSize(.large)
+ }
+ }
+ .task {
+ serverURL = await client.currentServerURL()
+ }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Recordings/RecordingsModel.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Recordings/RecordingsModel.swift
new file mode 100644
index 0000000..c78e387
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Recordings/RecordingsModel.swift
@@ -0,0 +1,115 @@
+import Foundation
+import Observation
+import JellyfinAPI
+
+public struct RecordingsContent: Sendable, Equatable {
+ public let serverURL: URL
+ public let recording: [BaseItemDto]
+ public let library: [BaseItemDto]
+ public let scheduled: [TimerInfoDto]
+ public let series: [SeriesTimerInfoDto]
+
+ public init(
+ serverURL: URL,
+ recording: [BaseItemDto] = [],
+ library: [BaseItemDto] = [],
+ scheduled: [TimerInfoDto] = [],
+ series: [SeriesTimerInfoDto] = []
+ ) {
+ self.serverURL = serverURL
+ self.recording = recording
+ self.library = library
+ self.scheduled = scheduled
+ self.series = series
+ }
+
+ public var isEmpty: Bool {
+ recording.isEmpty && library.isEmpty && scheduled.isEmpty && series.isEmpty
+ }
+}
+
+@MainActor
+@Observable
+public final class RecordingsModel {
+ public enum State: Equatable, Sendable {
+ case loading
+ case loaded(RecordingsContent)
+ case failed(String)
+ }
+
+ public private(set) var state: State = .loading
+ private let client: any JellyfinClientAPI
+
+ public init(client: any JellyfinClientAPI) {
+ self.client = client
+ }
+
+ public func load() async {
+ state = .loading
+ guard let serverURL = await client.currentServerURL() else {
+ state = .failed("Not signed in")
+ return
+ }
+ do {
+ async let inProgressTask = client.liveTvRecordings(isInProgress: true, seriesTimerId: nil, limit: 60)
+ async let libraryTask = client.liveTvRecordings(isInProgress: false, seriesTimerId: nil, limit: 100)
+ async let timersTask = client.liveTvTimers()
+ async let seriesTask = client.liveTvSeriesTimers()
+
+ let (inProgress, library, timers, series) = try await (
+ inProgressTask, libraryTask, timersTask, seriesTask
+ )
+
+ // Filter out scheduled timers that are already recording (those
+ // appear in `inProgress`) so the "Scheduled" row only shows future
+ // recordings.
+ let now = Date()
+ let upcoming = timers.filter { timer in
+ guard let start = timer.startDate else { return true }
+ return start > now
+ }
+
+ let content = RecordingsContent(
+ serverURL: serverURL,
+ recording: inProgress,
+ library: library,
+ scheduled: upcoming,
+ series: series
+ )
+ state = .loaded(content)
+ } catch JellyfinError.network {
+ state = .failed("Couldn't reach the server.")
+ } catch JellyfinError.unauthenticated {
+ state = .failed("Session expired. Please sign in again.")
+ } catch {
+ state = .failed("Couldn't load recordings.")
+ }
+ }
+
+ public func deleteRecording(_ item: BaseItemDto) async {
+ do {
+ try await client.deleteLiveTvRecording(recordingId: item.id)
+ await load()
+ } catch {
+ JellytvLog.liveTV.error("RecordingsModel.deleteRecording: \(String(describing: error), privacy: .public)")
+ }
+ }
+
+ public func cancelTimer(_ timer: TimerInfoDto) async {
+ do {
+ try await client.cancelLiveTvTimer(timerId: timer.id)
+ await load()
+ } catch {
+ JellytvLog.liveTV.error("RecordingsModel.cancelTimer: \(String(describing: error), privacy: .public)")
+ }
+ }
+
+ public func cancelSeriesTimer(_ timer: SeriesTimerInfoDto) async {
+ do {
+ try await client.cancelLiveTvSeriesTimer(timerId: timer.id)
+ await load()
+ } catch {
+ JellytvLog.liveTV.error("RecordingsModel.cancelSeriesTimer: \(String(describing: error), privacy: .public)")
+ }
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Sources/LiveTV/Recordings/RecordingsView.swift b/jellytv/Packages/LiveTV/Sources/LiveTV/Recordings/RecordingsView.swift
new file mode 100644
index 0000000..fa98c52
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Sources/LiveTV/Recordings/RecordingsView.swift
@@ -0,0 +1,462 @@
+import SwiftUI
+import NukeUI
+import JellyfinAPI
+import DesignSystem
+
+public struct RecordingsView: View {
+ @Bindable var model: RecordingsModel
+
+ public init(model: RecordingsModel) {
+ self.model = model
+ }
+
+ private var isLoading: Bool {
+ if case .loading = model.state { return true }
+ return false
+ }
+
+ public var body: some View {
+ Group {
+ switch model.state {
+ case .loading:
+ RecordingsSkeleton()
+ case .loaded(let content):
+ if content.isEmpty {
+ emptyState
+ } else {
+ loaded(content: content)
+ }
+ case .failed(let message):
+ failedView(message)
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: isLoading)
+ .task {
+ if case .loading = model.state {
+ await model.load()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func loaded(content: RecordingsContent) -> some View {
+ ScrollView(.vertical, showsIndicators: false) {
+ LazyVStack(alignment: .leading, spacing: 50) {
+ if !content.recording.isEmpty {
+ RecordingGrid(
+ title: "Recording Now",
+ icon: "record.circle.fill",
+ accent: .red,
+ items: content.recording,
+ serverURL: content.serverURL,
+ onDelete: { item in Task { await model.deleteRecording(item) } }
+ )
+ }
+ if !content.scheduled.isEmpty {
+ ScheduledSection(
+ timers: content.scheduled,
+ serverURL: content.serverURL,
+ onCancel: { timer in Task { await model.cancelTimer(timer) } }
+ )
+ }
+ if !content.series.isEmpty {
+ SeriesSection(
+ timers: content.series,
+ serverURL: content.serverURL,
+ onCancel: { timer in Task { await model.cancelSeriesTimer(timer) } }
+ )
+ }
+ if !content.library.isEmpty {
+ RecordingGrid(
+ title: "Recorded",
+ icon: "tray.full",
+ accent: .secondary,
+ items: content.library,
+ serverURL: content.serverURL,
+ onDelete: { item in Task { await model.deleteRecording(item) } }
+ )
+ }
+ Spacer(minLength: 60)
+ }
+ .padding(.vertical, 30)
+ }
+ .scrollClipDisabled()
+ }
+
+ private var emptyState: some View {
+ VStack(spacing: 24) {
+ Image(systemName: "record.circle")
+ .font(.system(size: 80))
+ .foregroundStyle(.secondary)
+ Text("No recordings yet")
+ .font(.title)
+ Text("Schedule a recording from the Guide or a program detail to see it here.")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ Button("Reload") {
+ Task { await model.load() }
+ }
+ }
+ .padding(60)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private func failedView(_ message: String) -> some View {
+ VStack(spacing: 24) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.system(size: 60))
+ .foregroundStyle(.secondary)
+ Text(message)
+ .font(.title2)
+ .multilineTextAlignment(.center)
+ Button("Retry") {
+ Task { await model.load() }
+ }
+ }
+ .padding(40)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+// MARK: - Skeleton loading state
+
+private struct RecordingsSkeleton: View {
+ var body: some View {
+ ScrollView(.vertical, showsIndicators: false) {
+ LazyVStack(alignment: .leading, spacing: 50) {
+ skeletonSection
+ skeletonSection
+ Spacer(minLength: 60)
+ }
+ .padding(.vertical, 30)
+ }
+ .scrollClipDisabled()
+ .focusable(false)
+ .allowsHitTesting(false)
+ .transition(.opacity)
+ }
+
+ private var skeletonSection: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(spacing: 10) {
+ RoundedRectangle(cornerRadius: 4)
+ .fill(LiveTVTheme.surface)
+ .frame(width: 20, height: 20)
+ RoundedRectangle(cornerRadius: 4)
+ .fill(LiveTVTheme.surface)
+ .frame(width: 160, height: 22)
+ }
+ .padding(.horizontal, 60)
+ .redacted(reason: .placeholder)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 24) {
+ ForEach(0..<4, id: \.self) { _ in
+ RoundedRectangle(cornerRadius: 14)
+ .fill(LiveTVTheme.surface)
+ .frame(width: 360, height: 200)
+ .redacted(reason: .placeholder)
+ }
+ }
+ .padding(.horizontal, 60)
+ }
+ .scrollClipDisabled()
+ }
+ }
+}
+
+// MARK: - Sections
+
+private struct RecordingGrid: View {
+ let title: String
+ let icon: String
+ let accent: Color
+ let items: [BaseItemDto]
+ let serverURL: URL
+ let onDelete: (BaseItemDto) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(spacing: 10) {
+ Image(systemName: icon)
+ .foregroundStyle(accent)
+ Text(title)
+ .font(.title3.weight(.semibold))
+ }
+ .padding(.horizontal, 60)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 24) {
+ ForEach(items, id: \.id) { item in
+ RecordingCard(item: item, serverURL: serverURL, onDelete: { onDelete(item) })
+ }
+ }
+ .padding(.horizontal, 60)
+ }
+ .scrollClipDisabled()
+ }
+ .focusSection()
+ }
+}
+
+private struct RecordingCard: View {
+ let item: BaseItemDto
+ let serverURL: URL
+ let onDelete: () -> Void
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Button(action: {}) {
+ VStack(alignment: .leading, spacing: 10) {
+ Group {
+ if let url = item.imageURL(serverURL: serverURL, type: .thumb, maxWidth: 720)
+ ?? item.imageURL(serverURL: serverURL, type: .primary, maxWidth: 720)
+ ?? item.imageURL(serverURL: serverURL, type: .backdrop, maxWidth: 720) {
+ LazyImage(url: url) { state in
+ if let image = state.image {
+ image.resizable().aspectRatio(contentMode: .fill)
+ } else {
+ fallback
+ }
+ }
+ } else {
+ fallback
+ }
+ }
+ .frame(width: 360, height: 200)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ Text(item.name)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+ .foregroundStyle(isFocused ? .primary : .secondary)
+ .frame(width: 360, alignment: .leading)
+ }
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ .contextMenu {
+ Button(role: .destructive, action: onDelete) {
+ Label("Delete Recording", systemImage: "trash")
+ }
+ }
+ }
+
+ private var fallback: some View {
+ ZStack {
+ LinearGradient(
+ colors: [Color.red.opacity(0.5), Color.black],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ Image(systemName: "record.circle")
+ .font(.system(size: 36))
+ .foregroundStyle(.white.opacity(0.7))
+ }
+ }
+}
+
+private struct ScheduledSection: View {
+ let timers: [TimerInfoDto]
+ let serverURL: URL
+ let onCancel: (TimerInfoDto) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(spacing: 10) {
+ Image(systemName: "calendar.badge.clock")
+ .foregroundStyle(.orange)
+ Text("Scheduled")
+ .font(.title3.weight(.semibold))
+ }
+ .padding(.horizontal, 60)
+
+ VStack(spacing: 8) {
+ ForEach(timers) { timer in
+ TimerRow(timer: timer, serverURL: serverURL, onCancel: { onCancel(timer) })
+ }
+ }
+ .padding(.horizontal, 60)
+ }
+ .focusSection()
+ }
+}
+
+private struct TimerRow: View {
+ let timer: TimerInfoDto
+ let serverURL: URL
+ let onCancel: () -> Void
+
+ @FocusState private var isFocused: Bool
+
+ private var timerSubtitle: String {
+ var parts: [String] = []
+ if let channelName = timer.channelName, !channelName.isEmpty {
+ parts.append(channelName)
+ }
+ if let timeRange = LiveTvFormat.timeRange(start: timer.startDate, end: timer.endDate) {
+ parts.append(timeRange)
+ }
+ return parts.joined(separator: " · ")
+ }
+
+ var body: some View {
+ Button(action: onCancel) {
+ HStack(alignment: .center, spacing: 16) {
+ channelLogo
+ .frame(width: 60, height: 40)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(timer.name ?? "Untitled")
+ .font(.headline)
+ .lineLimit(1)
+ Text(timerSubtitle)
+ .font(.subheadline.monospacedDigit())
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 0)
+ HStack(spacing: 8) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.red)
+ Text("Cancel")
+ .font(.subheadline.weight(.semibold))
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 8)
+ .background(.white.opacity(isFocused ? 0.2 : 0.08), in: Capsule())
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.white.opacity(isFocused ? 0.12 : 0.04))
+ )
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ }
+
+ @ViewBuilder
+ private var channelLogo: some View {
+ if let channelId = timer.channelId, let tag = timer.channelPrimaryImageTag,
+ let url = JellyfinImage.url(
+ serverURL: serverURL,
+ itemId: channelId,
+ type: .primary,
+ tag: tag,
+ maxWidth: 240
+ ) {
+ LazyImage(url: url) { state in
+ if let image = state.image {
+ image.resizable().aspectRatio(contentMode: .fit)
+ } else {
+ placeholder
+ }
+ }
+ } else {
+ placeholder
+ }
+ }
+
+ private var placeholder: some View {
+ Image(systemName: "tv")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ }
+}
+
+private struct SeriesSection: View {
+ let timers: [SeriesTimerInfoDto]
+ let serverURL: URL
+ let onCancel: (SeriesTimerInfoDto) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(spacing: 10) {
+ Image(systemName: "rectangle.stack.badge.play")
+ .foregroundStyle(.purple)
+ Text("Series Recordings")
+ .font(.title3.weight(.semibold))
+ }
+ .padding(.horizontal, 60)
+
+ VStack(spacing: 8) {
+ ForEach(timers) { timer in
+ SeriesRow(timer: timer, onCancel: { onCancel(timer) })
+ }
+ }
+ .padding(.horizontal, 60)
+ }
+ .focusSection()
+ }
+}
+
+private struct SeriesRow: View {
+ let timer: SeriesTimerInfoDto
+ let onCancel: () -> Void
+
+ @FocusState private var isFocused: Bool
+
+ private var seriesSubtitle: String {
+ var parts: [String] = []
+ if timer.recordAnyChannel == true {
+ parts.append("Any channel")
+ } else if let channelName = timer.channelName {
+ parts.append(channelName)
+ }
+ if timer.recordAnyTime == true { parts.append("Any time") }
+ if timer.recordNewOnly == true { parts.append("New only") }
+ return parts.joined(separator: " · ")
+ }
+
+ var body: some View {
+ Button(action: onCancel) {
+ HStack(alignment: .center, spacing: 16) {
+ Image(systemName: "rectangle.stack.badge.play")
+ .font(.title3)
+ .foregroundStyle(.purple)
+ .frame(width: 60)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(timer.name ?? "Series")
+ .font(.headline)
+ .lineLimit(1)
+ Text(seriesSubtitle)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 0)
+ HStack(spacing: 8) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.red)
+ Text("Cancel")
+ .font(.subheadline.weight(.semibold))
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 8)
+ .background(.white.opacity(isFocused ? 0.2 : 0.08), in: Capsule())
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.white.opacity(isFocused ? 0.12 : 0.04))
+ )
+ }
+ #if os(tvOS)
+ .buttonStyle(.card)
+ #else
+ .buttonStyle(.plain)
+ #endif
+ .focused($isFocused)
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Tests/LiveTVTests/ChannelOrderingTests.swift b/jellytv/Packages/LiveTV/Tests/LiveTVTests/ChannelOrderingTests.swift
new file mode 100644
index 0000000..ced497a
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Tests/LiveTVTests/ChannelOrderingTests.swift
@@ -0,0 +1,83 @@
+import Testing
+import Foundation
+import JellyfinAPI
+@testable import LiveTV
+
+@Suite("ChannelOrdering")
+struct ChannelOrderingTests {
+
+ private func ch(_ id: String, number: String? = nil, name: String = "Channel") -> LiveTvChannel {
+ LiveTvChannel(id: id, name: name, number: number)
+ }
+
+ @Test func sortsByNumericChannelNumberAscending() {
+ let unsorted = [ch("c", number: "103"), ch("a", number: "101"), ch("b", number: "102")]
+ let sorted = ChannelOrdering.sortedByChannelNumber(unsorted)
+ #expect(sorted.map(\.id) == ["a", "b", "c"])
+ }
+
+ @Test func nonNumericChannelsSortAfterNumeric() {
+ let unsorted = [ch("a", number: "ABC"), ch("b", number: "101"), ch("c", number: "201")]
+ let sorted = ChannelOrdering.sortedByChannelNumber(unsorted)
+ #expect(sorted.map(\.id) == ["b", "c", "a"])
+ }
+
+ @Test func channelsMissingNumberSortLastByName() {
+ let unsorted = [
+ ch("a", number: nil, name: "Zulu"),
+ ch("b", number: "101", name: "Alpha"),
+ ch("c", number: nil, name: "Bravo"),
+ ]
+ let sorted = ChannelOrdering.sortedByChannelNumber(unsorted)
+ #expect(sorted.map(\.id) == ["b", "c", "a"])
+ }
+
+ @Test func nextWrapsAroundAtEnd() {
+ let channels = [ch("a", number: "101"), ch("b", number: "102"), ch("c", number: "103")]
+ let next = ChannelOrdering.next(after: channels[2], in: channels)
+ #expect(next?.id == "a")
+ }
+
+ @Test func previousWrapsAroundAtStart() {
+ let channels = [ch("a", number: "101"), ch("b", number: "102"), ch("c", number: "103")]
+ let prev = ChannelOrdering.previous(before: channels[0], in: channels)
+ #expect(prev?.id == "c")
+ }
+
+ @Test func nextOnSingleChannelReturnsItself() {
+ let channels = [ch("a", number: "101")]
+ let next = ChannelOrdering.next(after: channels[0], in: channels)
+ #expect(next?.id == "a")
+ }
+
+ @Test func nextWithCurrentNotInListReturnsNil() {
+ let channels = [ch("a", number: "101"), ch("b", number: "102")]
+ let phantom = ch("z", number: "999")
+ let next = ChannelOrdering.next(after: phantom, in: channels)
+ #expect(next == nil)
+ }
+
+ @Test func nextOnEmptyChannelsReturnsNil() {
+ let phantom = ch("a", number: "101")
+ let next = ChannelOrdering.next(after: phantom, in: [])
+ #expect(next == nil)
+ }
+
+ @Test func decimalChannelNumbersSortInOrder() {
+ // 101.1 should sort between 101 and 102, not with strings starting with "1".
+ let unsorted = [
+ ch("a", number: "102"),
+ ch("b", number: "101.1"),
+ ch("c", number: "101"),
+ ]
+ let sorted = ChannelOrdering.sortedByChannelNumber(unsorted)
+ #expect(sorted.map(\.id) == ["c", "b", "a"])
+ }
+
+ @Test func nextStepsByChannelNumberOrderNotInputOrder() {
+ // Input order is unsorted; next() should follow channel-number order.
+ let channels = [ch("c", number: "103"), ch("a", number: "101"), ch("b", number: "102")]
+ let next = ChannelOrdering.next(after: channels[1], in: channels) // after "a" / 101
+ #expect(next?.id == "b") // → 102
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Tests/LiveTVTests/EPGStoreTests.swift b/jellytv/Packages/LiveTV/Tests/LiveTVTests/EPGStoreTests.swift
new file mode 100644
index 0000000..cd82691
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Tests/LiveTVTests/EPGStoreTests.swift
@@ -0,0 +1,203 @@
+import Testing
+import Foundation
+@testable import LiveTV
+@testable import JellyfinAPI
+
+/// Sendable mutable clock for injecting into EPGStore in tests.
+final class TestClock: @unchecked Sendable {
+ private var _now: Date
+ private let lock = NSLock()
+
+ init(_ initial: Date = Date(timeIntervalSinceReferenceDate: 0)) {
+ _now = initial
+ }
+
+ var now: Date {
+ lock.withLock { _now }
+ }
+
+ func advance(by interval: TimeInterval) {
+ lock.withLock { _now = _now.addingTimeInterval(interval) }
+ }
+}
+
+@Suite("EPGStore")
+@MainActor
+struct EPGStoreTests {
+
+ private func ch(_ id: String) -> LiveTvChannel {
+ LiveTvChannel(id: id, name: "Channel \(id)")
+ }
+
+ // MARK: - Coalescing
+
+ @Test func concurrentUnfilteredRequestsShareOneClientCall() async throws {
+ let mock = FakeJellyfinClient()
+ mock.liveTvChannelsResult = .success([ch("a"), ch("b")])
+ let store = EPGStore(client: mock)
+
+ // Fire 5 concurrent requests for the same key.
+ async let r1 = store.channels()
+ async let r2 = store.channels()
+ async let r3 = store.channels()
+ async let r4 = store.channels()
+ async let r5 = store.channels()
+
+ let results = try await [r1, r2, r3, r4, r5]
+ #expect(results.count == 5)
+ #expect(results.allSatisfy { $0.count == 2 })
+ // Only one actual client call despite 5 concurrent callers.
+ #expect(mock.liveTvChannelsCallCount == 1)
+ }
+
+ @Test func concurrentFilteredRequestsSameKeyShareOneClientCall() async throws {
+ let mock = FakeJellyfinClient()
+ mock.liveTvFilteredChannelsResult = .success([ch("x")])
+ let store = EPGStore(client: mock)
+
+ let filters = LiveTvChannelFilters(isSports: true, isAiringNow: true, limit: 24)
+ async let r1 = store.channels(filters: filters, addCurrentProgram: true)
+ async let r2 = store.channels(filters: filters, addCurrentProgram: true)
+ async let r3 = store.channels(filters: filters, addCurrentProgram: true)
+
+ let results = try await [r1, r2, r3]
+ #expect(results.count == 3)
+ #expect(results.allSatisfy { $0.count == 1 })
+ // Only one actual client call.
+ #expect(mock.liveTvChannelsFilteredCallCount == 1)
+ }
+
+ // MARK: - TTL
+
+ @Test func secondRequestWithinTTLHitsCache() async throws {
+ let clock = TestClock()
+ let mock = FakeJellyfinClient()
+ mock.liveTvChannelsResult = .success([ch("a")])
+ let store = EPGStore(client: mock, now: { clock.now })
+
+ // First fetch.
+ _ = try await store.channels()
+ #expect(mock.liveTvChannelsCallCount == 1)
+
+ // Advance time by 4 minutes (within 5-minute TTL).
+ clock.advance(by: 4 * 60)
+
+ // Second fetch — should hit cache.
+ _ = try await store.channels()
+ #expect(mock.liveTvChannelsCallCount == 1, "Expected cache hit; count should remain 1")
+ }
+
+ @Test func requestAfterTTLRefetches() async throws {
+ let clock = TestClock()
+ let mock = FakeJellyfinClient()
+ mock.liveTvChannelsResult = .success([ch("a")])
+ let store = EPGStore(client: mock, now: { clock.now })
+
+ // First fetch.
+ _ = try await store.channels()
+ #expect(mock.liveTvChannelsCallCount == 1)
+
+ // Advance time past TTL (5 min + 1 sec).
+ clock.advance(by: 5 * 60 + 1)
+
+ // Second fetch — TTL expired, should call client again.
+ _ = try await store.channels()
+ #expect(mock.liveTvChannelsCallCount == 2, "Expected TTL expiry refetch; count should be 2")
+ }
+
+ // MARK: - Per-key isolation
+
+ @Test func differentFilterKeysProduceSeparateFetches() async throws {
+ let mock = FakeJellyfinClient()
+ mock.liveTvFilteredChannelsResult = .success([ch("filtered")])
+ let store = EPGStore(client: mock)
+
+ let sportsFilters = LiveTvChannelFilters(isSports: true, isAiringNow: true, limit: 24)
+ let newsFilters = LiveTvChannelFilters(isNews: true, isAiringNow: true, limit: 24)
+
+ _ = try await store.channels(filters: sportsFilters, addCurrentProgram: true)
+ _ = try await store.channels(filters: newsFilters, addCurrentProgram: true)
+
+ // Two different keys → two separate client calls.
+ #expect(mock.liveTvChannelsFilteredCallCount == 2)
+ }
+
+ @Test func unfilteredAndFilteredKeysAreIsolated() async throws {
+ let mock = FakeJellyfinClient()
+ mock.liveTvChannelsResult = .success([ch("all")])
+ mock.liveTvFilteredChannelsResult = .success([ch("filtered")])
+ let store = EPGStore(client: mock)
+
+ let unfiltered = try await store.channels()
+ let filtered = try await store.channels(
+ filters: LiveTvChannelFilters(isMovie: true),
+ addCurrentProgram: false
+ )
+
+ #expect(unfiltered.map(\.id) == ["all"])
+ #expect(filtered.map(\.id) == ["filtered"])
+ #expect(mock.liveTvChannelsCallCount == 1)
+ #expect(mock.liveTvChannelsFilteredCallCount == 1)
+ }
+
+ // MARK: - Dominant-color pre-warm ordering
+
+ @Test func channelsForColorPrewarmFavoritesComeFirst() {
+ let fav = LiveTvChannel(
+ id: "fav1", name: "Fav 1", number: "10",
+ userData: UserItemDataDto(isFavorite: true)
+ )
+ let reg1 = LiveTvChannel(id: "reg1", name: "Reg 1", number: "20")
+ let reg2 = LiveTvChannel(id: "reg2", name: "Reg 2", number: "30")
+ // Input is already sorted by channel number; favorite is channel 10.
+ let ordered = EPGStore.channelsForColorPrewarm([fav, reg1, reg2])
+ #expect(ordered.map(\.id) == ["fav1", "reg1", "reg2"])
+ }
+
+ @Test func channelsForColorPrewarmFavoritesLeadAmongMixed() {
+ let fav1 = LiveTvChannel(
+ id: "fav1", name: "Fav 1", number: "30",
+ userData: UserItemDataDto(isFavorite: true)
+ )
+ let fav2 = LiveTvChannel(
+ id: "fav2", name: "Fav 2", number: "50",
+ userData: UserItemDataDto(isFavorite: true)
+ )
+ let reg1 = LiveTvChannel(id: "reg1", name: "Reg 1", number: "10")
+ let reg2 = LiveTvChannel(id: "reg2", name: "Reg 2", number: "20")
+ let reg3 = LiveTvChannel(id: "reg3", name: "Reg 3", number: "40")
+ // Sorted input: reg1, reg2, fav1, reg3, fav2
+ let ordered = EPGStore.channelsForColorPrewarm([reg1, reg2, fav1, reg3, fav2])
+ // Favorites come first (in their original order), then non-favorites.
+ #expect(ordered.map(\.id) == ["fav1", "fav2", "reg1", "reg2", "reg3"])
+ }
+
+ @Test func channelsForColorPrewarmCapsAtLimit() {
+ // Build 60 channels, first 5 as favorites.
+ let channels: [LiveTvChannel] = (1...60).map { i in
+ LiveTvChannel(
+ id: "ch\(i)", name: "Channel \(i)", number: String(i),
+ userData: i <= 5 ? UserItemDataDto(isFavorite: true) : nil
+ )
+ }
+ let ordered = EPGStore.channelsForColorPrewarm(channels)
+ #expect(ordered.count == EPGStore.dominantColorPrewarmLimit)
+ // First 5 should be the favorites.
+ #expect(ordered.prefix(5).map(\.id) == ["ch1", "ch2", "ch3", "ch4", "ch5"])
+ }
+
+ // MARK: - unfilteredChannels sorted property
+
+ @Test func unfilteredChannelsIsSortedByChannelNumber() async throws {
+ let mock = FakeJellyfinClient()
+ mock.liveTvChannelsResult = .success([
+ LiveTvChannel(id: "c", name: "C", number: "103"),
+ LiveTvChannel(id: "a", name: "A", number: "101"),
+ LiveTvChannel(id: "b", name: "B", number: "102"),
+ ])
+ let store = EPGStore(client: mock)
+
+ _ = try await store.channels()
+ #expect(store.unfilteredChannels.map(\.id) == ["a", "b", "c"])
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Tests/LiveTVTests/FakeJellyfinClient.swift b/jellytv/Packages/LiveTV/Tests/LiveTVTests/FakeJellyfinClient.swift
new file mode 100644
index 0000000..29d5481
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Tests/LiveTVTests/FakeJellyfinClient.swift
@@ -0,0 +1,114 @@
+import Foundation
+@testable import JellyfinAPI
+
+/// Fake `JellyfinClientAPI` conformance for `GuideModel` tests. Mirrors the
+/// `Result<…>`-stub pattern used by `Library/Tests/.../HomeModelTests.swift`.
+final class FakeJellyfinClient: JellyfinClientAPI, @unchecked Sendable {
+ var currentServerURL_: URL? = URL(string: "http://192.168.1.50:8096")
+
+ var liveTvChannelsResult: Result<[LiveTvChannel], Error> = .success([])
+ var liveTvFilteredChannelsResult: Result<[LiveTvChannel], Error>?
+ var liveTvProgramsResult: Result<[LiveTvProgram], Error> = .success([])
+ var liveTvRecommendedResult: Result<[LiveTvProgram], Error> = .success([])
+ var liveTvRecordingsResult: Result<[BaseItemDto], Error> = .success([])
+ var liveTvTimersResult: Result<[TimerInfoDto], Error> = .success([])
+ var liveTvSeriesTimersResult: Result<[SeriesTimerInfoDto], Error> = .success([])
+ var liveTvOpenStreamResult: Result = .success(
+ LiveStreamPlayback(playbackURL: URL(string: "http://test/stream")!, liveStreamId: nil)
+ )
+
+ /// Captured arguments to `liveTvPrograms` for assertions.
+ private(set) var lastChannelIds: [String]?
+ private(set) var lastMinStartDate: Date?
+ private(set) var lastMaxStartDate: Date?
+ private(set) var lastOpenStreamChannelId: String?
+ private(set) var lastChannelFilters: LiveTvChannelFilters?
+ private(set) var lastAddCurrentProgram: Bool?
+
+ // MARK: - Call counters
+ private(set) var liveTvChannelsCallCount: Int = 0
+ private(set) var liveTvChannelsFilteredCallCount: Int = 0
+
+ func setServerURL(_ url: URL?) async { currentServerURL_ = url }
+ func currentServerURL() async -> URL? { currentServerURL_ }
+ func setAccessToken(_ token: String?) async {}
+
+ func getPublicSystemInfo() async throws -> PublicSystemInfo {
+ PublicSystemInfo(serverName: "Fake", version: "1.0", id: nil, productName: nil, localAddress: nil, startupWizardCompleted: nil)
+ }
+
+ func authenticateByName(username: String, password: String) async throws -> AuthenticationResult {
+ throw JellyfinError.unauthenticated
+ }
+
+ func quickConnectEnabled() async throws -> Bool { false }
+ func quickConnectInitiate() async throws -> QuickConnectResult { throw JellyfinError.unauthenticated }
+ func quickConnectStatus(secret: String) async throws -> QuickConnectResult { throw JellyfinError.unauthenticated }
+ func authenticateWithQuickConnect(secret: String) async throws -> AuthenticationResult { throw JellyfinError.unauthenticated }
+ func currentUser() async throws -> UserDto { throw JellyfinError.unauthenticated }
+ func logout() async throws {}
+
+ func userViews() async throws -> [BaseItemDto] { [] }
+ func resumeItems(limit: Int) async throws -> [BaseItemDto] { [] }
+ func nextUp(limit: Int) async throws -> [BaseItemDto] { [] }
+ func latestItems(parentId: String?, limit: Int) async throws -> [BaseItemDto] { [] }
+
+ func liveTvChannels() async throws -> [LiveTvChannel] {
+ liveTvChannelsCallCount += 1
+ return try liveTvChannelsResult.get()
+ }
+
+ func liveTvPrograms(
+ channelIds: [String],
+ minStartDate: Date,
+ maxStartDate: Date
+ ) async throws -> [LiveTvProgram] {
+ lastChannelIds = channelIds
+ lastMinStartDate = minStartDate
+ lastMaxStartDate = maxStartDate
+ return try liveTvProgramsResult.get()
+ }
+
+ func liveTvChannels(
+ filters: LiveTvChannelFilters,
+ addCurrentProgram: Bool
+ ) async throws -> [LiveTvChannel] {
+ liveTvChannelsFilteredCallCount += 1
+ lastChannelFilters = filters
+ lastAddCurrentProgram = addCurrentProgram
+ if let result = liveTvFilteredChannelsResult {
+ return try result.get()
+ }
+ return try liveTvChannelsResult.get()
+ }
+
+ func liveTvRecommendedPrograms(filters: LiveTvProgramFilters) async throws -> [LiveTvProgram] {
+ try liveTvRecommendedResult.get()
+ }
+
+ func liveTvRecordings(
+ isInProgress: Bool?,
+ seriesTimerId: String?,
+ limit: Int?
+ ) async throws -> [BaseItemDto] {
+ try liveTvRecordingsResult.get()
+ }
+
+ func liveTvTimers() async throws -> [TimerInfoDto] {
+ try liveTvTimersResult.get()
+ }
+
+ func liveTvSeriesTimers() async throws -> [SeriesTimerInfoDto] {
+ try liveTvSeriesTimersResult.get()
+ }
+
+ func liveTvOpenStream(channelId: String) async throws -> LiveStreamPlayback {
+ lastOpenStreamChannelId = channelId
+ return try liveTvOpenStreamResult.get()
+ }
+
+ func liveTvOpenStream(channelId: String, forceTranscoding: Bool) async throws -> LiveStreamPlayback {
+ lastOpenStreamChannelId = channelId
+ return try liveTvOpenStreamResult.get()
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Tests/LiveTVTests/GuideModelTests.swift b/jellytv/Packages/LiveTV/Tests/LiveTVTests/GuideModelTests.swift
new file mode 100644
index 0000000..2f0186d
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Tests/LiveTVTests/GuideModelTests.swift
@@ -0,0 +1,221 @@
+import Testing
+import Foundation
+@testable import LiveTV
+@testable import JellyfinAPI
+
+@Suite("GuideModel")
+@MainActor
+struct GuideModelTests {
+
+ /// Stable "now" used by every test so date math is deterministic.
+ /// 2026-04-07 19:00:00 UTC
+ private var fixedNow: Date {
+ var components = DateComponents()
+ components.year = 2026
+ components.month = 4
+ components.day = 7
+ components.hour = 19
+ components.timeZone = TimeZone(identifier: "UTC")
+ return Calendar(identifier: .gregorian).date(from: components)!
+ }
+
+ private func makeChannel(id: String, name: String) -> LiveTvChannel {
+ LiveTvChannel(id: id, name: name)
+ }
+
+ private func makeProgram(
+ id: String,
+ channelId: String,
+ startOffset: TimeInterval,
+ durationMinutes: Double,
+ from now: Date
+ ) -> LiveTvProgram {
+ let start = now.addingTimeInterval(startOffset)
+ let end = start.addingTimeInterval(durationMinutes * 60)
+ return LiveTvProgram(
+ id: id,
+ name: "Program \(id)",
+ channelId: channelId,
+ startDate: start,
+ endDate: end
+ )
+ }
+
+ @Test func loadSuccess() async throws {
+ let now = fixedNow
+ let mock = FakeJellyfinClient()
+ mock.liveTvChannelsResult = .success([
+ makeChannel(id: "ch-1", name: "MLB Network"),
+ makeChannel(id: "ch-2", name: "ESPN"),
+ ])
+ mock.liveTvProgramsResult = .success([
+ // ch-1: program starting at now, 60 min long
+ makeProgram(id: "p1", channelId: "ch-1", startOffset: 0, durationMinutes: 60, from: now),
+ // ch-1: program starting in 60 min, 30 min long
+ makeProgram(id: "p2", channelId: "ch-1", startOffset: 60 * 60, durationMinutes: 30, from: now),
+ // ch-2: program starting at now, 30 min long
+ makeProgram(id: "p3", channelId: "ch-2", startOffset: 0, durationMinutes: 30, from: now),
+ ])
+
+ let store = EPGStore(client: mock, now: { now })
+ let model = GuideModel(client: mock, store: store, now: { now })
+ await model.load()
+
+ guard case .loaded(let content) = model.state else {
+ Issue.record("Expected .loaded, got \(model.state)")
+ return
+ }
+ #expect(content.channels.count == 2)
+ #expect(content.windowStart == now)
+ #expect(content.windowEnd == now.addingTimeInterval(GuideLayout.futureWindowSeconds))
+ #expect(content.programs(for: "ch-1").count == 2)
+ #expect(content.programs(for: "ch-2").count == 1)
+ // First channel's programs are sorted by startDate ascending
+ #expect(content.programs(for: "ch-1").map(\.id) == ["p1", "p2"])
+ // Verify the model widened minStartDate by pastWindowSeconds when calling the API
+ #expect(mock.lastMinStartDate == now.addingTimeInterval(-GuideLayout.pastWindowSeconds))
+ #expect(mock.lastMaxStartDate == now.addingTimeInterval(GuideLayout.futureWindowSeconds))
+ #expect(mock.lastChannelIds == ["ch-1", "ch-2"])
+ }
+
+ @Test func loadEmptyChannelsSkipsProgramsCall() async throws {
+ let now = fixedNow
+ let mock = FakeJellyfinClient()
+ mock.liveTvChannelsResult = .success([])
+ // If liveTvPrograms is called when channels is empty, the test would fail
+ // because lastChannelIds gets set.
+ mock.liveTvProgramsResult = .success([
+ makeProgram(id: "should-not-appear", channelId: "ch-1", startOffset: 0, durationMinutes: 30, from: now)
+ ])
+
+ let store = EPGStore(client: mock, now: { now })
+ let model = GuideModel(client: mock, store: store, now: { now })
+ await model.load()
+
+ guard case .loaded(let content) = model.state else {
+ Issue.record("Expected .loaded, got \(model.state)")
+ return
+ }
+ #expect(content.isEmpty)
+ #expect(content.channels.isEmpty)
+ #expect(mock.lastChannelIds == nil)
+ }
+
+ @Test func networkErrorMapsToFailed() async throws {
+ let now = fixedNow
+ let mock = FakeJellyfinClient()
+ mock.liveTvChannelsResult = .failure(JellyfinError.network(URLError(.notConnectedToInternet)))
+
+ let store = EPGStore(client: mock, now: { now })
+ let model = GuideModel(client: mock, store: store, now: { now })
+ await model.load()
+
+ guard case .failed(let message) = model.state else {
+ Issue.record("Expected .failed, got \(model.state)")
+ return
+ }
+ #expect(message.contains("Couldn't reach"))
+ }
+
+ @Test func unauthorizedMapsToFailed() async throws {
+ let now = fixedNow
+ let mock = FakeJellyfinClient()
+ mock.liveTvChannelsResult = .failure(JellyfinError.unauthenticated)
+
+ let store = EPGStore(client: mock, now: { now })
+ let model = GuideModel(client: mock, store: store, now: { now })
+ await model.load()
+
+ guard case .failed(let message) = model.state else {
+ Issue.record("Expected .failed, got \(model.state)")
+ return
+ }
+ #expect(message.contains("Session"))
+ }
+
+ @Test func notConfiguredMapsToFailed() async throws {
+ let now = fixedNow
+ let mock = FakeJellyfinClient()
+ mock.currentServerURL_ = nil
+
+ let store = EPGStore(client: mock, now: { now })
+ let model = GuideModel(client: mock, store: store, now: { now })
+ await model.load()
+
+ guard case .failed(let message) = model.state else {
+ Issue.record("Expected .failed, got \(model.state)")
+ return
+ }
+ #expect(message.contains("Not signed in"))
+ }
+
+ @Test func programsEndingBeforeWindowStartAreDropped() async throws {
+ let now = fixedNow
+ let mock = FakeJellyfinClient()
+ mock.liveTvChannelsResult = .success([makeChannel(id: "ch-1", name: "MLB")])
+ mock.liveTvProgramsResult = .success([
+ // Already ended 30 min ago: should be dropped
+ makeProgram(id: "ended", channelId: "ch-1", startOffset: -2 * 3600, durationMinutes: 90, from: now),
+ // In progress (started 30 min ago, ends in 30 min): should be kept
+ makeProgram(id: "live", channelId: "ch-1", startOffset: -30 * 60, durationMinutes: 60, from: now),
+ // Future: should be kept
+ makeProgram(id: "future", channelId: "ch-1", startOffset: 60 * 60, durationMinutes: 30, from: now),
+ ])
+
+ let store = EPGStore(client: mock, now: { now })
+ let model = GuideModel(client: mock, store: store, now: { now })
+ await model.load()
+
+ guard case .loaded(let content) = model.state else {
+ Issue.record("Expected .loaded, got \(model.state)")
+ return
+ }
+ let ids = content.programs(for: "ch-1").map(\.id)
+ #expect(ids == ["live", "future"])
+ }
+
+ @Test func applyFilterUsesFilteredChannelEndpoint() async throws {
+ let now = fixedNow
+ let mock = FakeJellyfinClient()
+ // Default channels endpoint returns one set; the filtered version
+ // returns a different set so we can prove the model called the right
+ // method when a non-`.all` filter is applied.
+ mock.liveTvChannelsResult = .success([makeChannel(id: "default", name: "Default")])
+ mock.liveTvFilteredChannelsResult = .success([makeChannel(id: "sports", name: "ESPN")])
+ mock.liveTvProgramsResult = .success([])
+
+ let store = EPGStore(client: mock, now: { now })
+ let model = GuideModel(client: mock, store: store, now: { now })
+ await model.applyFilter(.sports)
+
+ guard case .loaded(let content) = model.state else {
+ Issue.record("Expected .loaded, got \(model.state)")
+ return
+ }
+ #expect(content.channels.map(\.id) == ["sports"])
+ #expect(model.categoryFilter == .sports)
+ #expect(mock.lastChannelFilters?.isSports == true)
+ #expect(mock.lastAddCurrentProgram == false)
+ }
+
+ @Test func programsForUnknownChannelsAreIgnored() async throws {
+ let now = fixedNow
+ let mock = FakeJellyfinClient()
+ mock.liveTvChannelsResult = .success([makeChannel(id: "ch-1", name: "MLB")])
+ mock.liveTvProgramsResult = .success([
+ makeProgram(id: "ok", channelId: "ch-1", startOffset: 0, durationMinutes: 30, from: now),
+ makeProgram(id: "orphan", channelId: "ch-99", startOffset: 0, durationMinutes: 30, from: now),
+ ])
+
+ let store = EPGStore(client: mock, now: { now })
+ let model = GuideModel(client: mock, store: store, now: { now })
+ await model.load()
+
+ guard case .loaded(let content) = model.state else {
+ Issue.record("Expected .loaded, got \(model.state)")
+ return
+ }
+ #expect(content.programsByChannel.keys.sorted() == ["ch-1"])
+ #expect(content.programs(for: "ch-1").count == 1)
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Tests/LiveTVTests/OnNowModelTests.swift b/jellytv/Packages/LiveTV/Tests/LiveTVTests/OnNowModelTests.swift
new file mode 100644
index 0000000..f34e061
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Tests/LiveTVTests/OnNowModelTests.swift
@@ -0,0 +1,112 @@
+import Testing
+import Foundation
+@testable import LiveTV
+@testable import JellyfinAPI
+
+@Suite("OnNowModel")
+@MainActor
+struct OnNowModelTests {
+
+ private func channel(_ id: String, name: String, favorite: Bool = false) -> LiveTvChannel {
+ LiveTvChannel(
+ id: id,
+ name: name,
+ userData: UserItemDataDto(isFavorite: favorite),
+ currentProgram: LiveTvProgram(id: "p-\(id)", name: "Program \(id)")
+ )
+ }
+
+ @Test func loadFansOutAndDeduplicates() async throws {
+ let mock = FakeJellyfinClient()
+ let favorite = channel("fav", name: "Favorite", favorite: true)
+ let onNow = channel("on1", name: "On Now")
+ let movies = channel("m1", name: "Movies")
+ // The favorite channel also appears in On Now — `OnNowModel`
+ // should drop the duplicate from `onNow` (and other sub-shelves).
+ mock.liveTvFilteredChannelsResult = .success([favorite, onNow])
+ mock.liveTvChannelsResult = .success([favorite, onNow, movies])
+ mock.liveTvRecommendedResult = .success([
+ LiveTvProgram(id: "p1", name: "Up Next 1"),
+ LiveTvProgram(id: "p2", name: "Up Next 2"),
+ ])
+ mock.liveTvRecordingsResult = .success([])
+
+ let store = EPGStore(client: mock)
+ let model = OnNowModel(client: mock, store: store)
+ await model.load()
+
+ guard case .loaded(let content) = model.state else {
+ Issue.record("Expected loaded state, got \(model.state)")
+ return
+ }
+ #expect(content.upNext.count == 2)
+ // Favorite channel should be in `favorites`, and *not* duplicated in the
+ // other channel shelves (filtered out by id).
+ #expect(content.onNow.contains(where: { $0.id == "fav" }) == false)
+ }
+
+ @Test func loadWithoutServerURLFailsCleanly() async throws {
+ let mock = FakeJellyfinClient()
+ mock.currentServerURL_ = nil
+
+ let store = EPGStore(client: mock)
+ let model = OnNowModel(client: mock, store: store)
+ await model.load()
+ guard case .failed(let message) = model.state else {
+ Issue.record("Expected failed state, got \(model.state)")
+ return
+ }
+ #expect(message.contains("Not signed in"))
+ }
+}
+
+@Suite("RecordingsModel")
+@MainActor
+struct RecordingsModelTests {
+
+ @Test func loadHydratesAllSections() async throws {
+ let mock = FakeJellyfinClient()
+ mock.liveTvRecordingsResult = .success([
+ BaseItemDto(id: "rec-1", name: "Rec 1"),
+ ])
+ mock.liveTvTimersResult = .success([
+ TimerInfoDto(id: "t-1", name: "Future", startDate: Date().addingTimeInterval(3600)),
+ ])
+ mock.liveTvSeriesTimersResult = .success([
+ SeriesTimerInfoDto(id: "ser-1", name: "Series 1", recordNewOnly: true),
+ ])
+
+ let model = RecordingsModel(client: mock)
+ await model.load()
+
+ guard case .loaded(let content) = model.state else {
+ Issue.record("Expected loaded, got \(model.state)")
+ return
+ }
+ #expect(content.scheduled.count == 1)
+ #expect(content.series.count == 1)
+ // `library` and `recording` come from the same recordings stub here,
+ // both should resolve.
+ #expect(content.library.count == 1)
+ #expect(content.recording.count == 1)
+ }
+
+ @Test func pastTimersAreFilteredOut() async throws {
+ let mock = FakeJellyfinClient()
+ mock.liveTvRecordingsResult = .success([])
+ mock.liveTvTimersResult = .success([
+ TimerInfoDto(id: "old", name: "Yesterday", startDate: Date().addingTimeInterval(-3600)),
+ TimerInfoDto(id: "new", name: "Tomorrow", startDate: Date().addingTimeInterval(3600)),
+ ])
+ mock.liveTvSeriesTimersResult = .success([])
+
+ let model = RecordingsModel(client: mock)
+ await model.load()
+
+ guard case .loaded(let content) = model.state else {
+ Issue.record("Expected loaded")
+ return
+ }
+ #expect(content.scheduled.map(\.id) == ["new"])
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Tests/LiveTVTests/PlaybillModelTests.swift b/jellytv/Packages/LiveTV/Tests/LiveTVTests/PlaybillModelTests.swift
new file mode 100644
index 0000000..2c1c22c
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Tests/LiveTVTests/PlaybillModelTests.swift
@@ -0,0 +1,49 @@
+import Testing
+import Foundation
+@testable import LiveTV
+import JellyfinAPI
+
+@MainActor
+@Suite("PlaybillModel")
+struct PlaybillModelTests {
+ private func makeChannel(id: String, name: String, number: String?) -> LiveTvChannel {
+ LiveTvChannel(id: id, name: name, number: number)
+ }
+
+ @Test
+ func loadSortsChannelsByNumberAndRequestsCurrentProgram() async {
+ let fake = FakeJellyfinClient()
+ fake.liveTvFilteredChannelsResult = .success([
+ makeChannel(id: "b", name: "Beta", number: "21"),
+ makeChannel(id: "a", name: "Alpha", number: "2"),
+ makeChannel(id: "c", name: "Gamma", number: "7"),
+ ])
+
+ let store = EPGStore(client: fake)
+ let model = PlaybillModel(store: store)
+ await model.load()
+
+ guard case .loaded(let channels) = model.state else {
+ Issue.record("Expected loaded state, got \(model.state)")
+ return
+ }
+ #expect(channels.map(\.number) == ["2", "7", "21"])
+ #expect(fake.lastAddCurrentProgram == true)
+ }
+
+ @Test
+ func loadFailureSurfacesMessage() async {
+ let fake = FakeJellyfinClient()
+ fake.liveTvFilteredChannelsResult = .failure(JellyfinError.network(URLError(.notConnectedToInternet)))
+
+ let store = EPGStore(client: fake)
+ let model = PlaybillModel(store: store)
+ await model.load()
+
+ guard case .failed(let message) = model.state else {
+ Issue.record("Expected failed state, got \(model.state)")
+ return
+ }
+ #expect(message == "Couldn't reach the server.")
+ }
+}
diff --git a/jellytv/Packages/LiveTV/Tests/LiveTVTests/PlayerViewModelTests.swift b/jellytv/Packages/LiveTV/Tests/LiveTVTests/PlayerViewModelTests.swift
new file mode 100644
index 0000000..510931d
--- /dev/null
+++ b/jellytv/Packages/LiveTV/Tests/LiveTVTests/PlayerViewModelTests.swift
@@ -0,0 +1,298 @@
+import Testing
+import Foundation
+import JellyfinAPI
+@testable import LiveTV
+
+// MARK: - Mocks
+
+@MainActor
+final class MockPlayerHost: PlayerHost {
+ let statusStream: AsyncStream
+ let readyForDisplayStream: AsyncStream
+ let bufferEmptyStream: AsyncStream
+ let failedToPlayStream: AsyncStream
+
+ private let statusCont: AsyncStream.Continuation
+ private let readyCont: AsyncStream.Continuation
+ private let bufferCont: AsyncStream.Continuation
+ private let failedCont: AsyncStream.Continuation
+
+ var replacedURLs: [URL] = []
+ var torndownCount = 0
+
+ init() {
+ var statusC: AsyncStream.Continuation!
+ self.statusStream = AsyncStream { statusC = $0 }
+ self.statusCont = statusC
+ var readyC: AsyncStream.Continuation!
+ self.readyForDisplayStream = AsyncStream { readyC = $0 }
+ self.readyCont = readyC
+ var bufferC: AsyncStream.Continuation!
+ self.bufferEmptyStream = AsyncStream { bufferC = $0 }
+ self.bufferCont = bufferC
+ var failedC: AsyncStream.Continuation!
+ self.failedToPlayStream = AsyncStream { failedC = $0 }
+ self.failedCont = failedC
+ }
+
+ func replaceItem(url: URL) { replacedURLs.append(url) }
+ func tearDown() { torndownCount += 1 }
+
+ func emitStatus(_ raw: Int) { statusCont.yield(raw) }
+ func emitReadyForDisplay(_ ready: Bool) { readyCont.yield(ready) }
+ func emitBufferEmpty(_ empty: Bool) { bufferCont.yield(empty) }
+ func emitFailedToPlay(_ err: PlayerHostError?) { failedCont.yield(err) }
+}
+
+@MainActor
+final class MockNetworkMonitor: NetworkMonitor {
+ let pathSatisfiedStream: AsyncStream
+ private let cont: AsyncStream.Continuation
+ var startedCount = 0
+ var stoppedCount = 0
+
+ init() {
+ var c: AsyncStream.Continuation!
+ self.pathSatisfiedStream = AsyncStream { c = $0 }
+ self.cont = c
+ }
+
+ func start() { startedCount += 1 }
+ func stop() { stoppedCount += 1 }
+ func emit(_ satisfied: Bool) { cont.yield(satisfied) }
+}
+
+// MARK: - Tests
+
+@Suite("PlayerViewModel", .serialized)
+@MainActor
+struct PlayerViewModelTests {
+
+ private let serverURL = URL(string: "http://10.1.1.12:8096")!
+
+ private func channel(_ id: String, num: String = "101", name: String = "Test") -> LiveTvChannel {
+ LiveTvChannel(id: id, name: name, number: num)
+ }
+
+ private func playback(_ urlString: String, liveStreamId: String?) -> LiveStreamPlayback {
+ LiveStreamPlayback(playbackURL: URL(string: urlString)!, liveStreamId: liveStreamId)
+ }
+
+ /// Wait for the model's state to satisfy a predicate, polling on the run
+ /// loop. We can't `await` a property change on @Observable directly, so
+ /// we busy-wait via Task.yield up to a generous timeout.
+ private func waitForState(
+ _ vm: PlayerViewModel,
+ timeout: TimeInterval = 1.0,
+ predicate: (PlayerViewModel.State) -> Bool
+ ) async {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline && !predicate(vm.state) {
+ await Task.yield()
+ try? await Task.sleep(nanoseconds: 5_000_000) // 5ms
+ }
+ }
+
+ @Test func happyPath_resolvingToSplashToPlaying() async {
+ let host = MockPlayerHost()
+ let net = MockNetworkMonitor()
+ let ch = channel("c1")
+ let pb = playback("http://example/m.m3u8", liveStreamId: "ls-1")
+ var openCalls: [(LiveTvChannel, Bool)] = []
+ let vm = PlayerViewModel(
+ initialChannel: ch,
+ channels: [ch],
+ serverURL: serverURL,
+ program: nil,
+ openStream: { c, force in openCalls.append((c, force)); return pb },
+ closeStream: { _ in },
+ host: host,
+ networkMonitor: net
+ )
+ // initial async tune
+ await waitForState(vm) { if case .splash = $0 { return true }; return false }
+ if case .splash(_, let p) = vm.state {
+ #expect(p.liveStreamId == "ls-1")
+ } else { Issue.record("expected splash, got \(vm.state)") }
+ #expect(host.replacedURLs.count == 1)
+ #expect(openCalls.count == 1)
+ #expect(openCalls[0].1 == false) // not forceTranscoding
+
+ // Simulate first frame rendered
+ host.emitReadyForDisplay(true)
+ await waitForState(vm) { if case .playing = $0 { return true }; return false }
+ }
+
+ @Test func openStreamFailure_directPlayFallbackThenSuccess() async {
+ let host = MockPlayerHost()
+ let net = MockNetworkMonitor()
+ let ch = channel("c2")
+ let pb = playback("http://example/m.m3u8", liveStreamId: "ls-2")
+ var openCalls: [(LiveTvChannel, Bool)] = []
+ let vm = PlayerViewModel(
+ initialChannel: ch,
+ channels: [ch],
+ serverURL: serverURL,
+ program: nil,
+ openStream: { c, force in
+ openCalls.append((c, force))
+ if !force { throw NSError(domain: "test", code: 1) }
+ return pb
+ },
+ closeStream: { _ in },
+ host: host,
+ networkMonitor: net
+ )
+ await waitForState(vm) { if case .splash = $0 { return true }; return false }
+ // First call without force, second with force
+ #expect(openCalls.count == 2)
+ #expect(openCalls[0].1 == false)
+ #expect(openCalls[1].1 == true)
+ }
+
+ @Test func openStreamPersistentFailure_endsInError() async {
+ let host = MockPlayerHost()
+ let net = MockNetworkMonitor()
+ let ch = channel("c3")
+ var openCalls = 0
+ let vm = PlayerViewModel(
+ initialChannel: ch,
+ channels: [ch],
+ serverURL: serverURL,
+ program: nil,
+ openStream: { _, _ in openCalls += 1; throw NSError(domain: "test", code: 1) },
+ closeStream: { _ in },
+ host: host,
+ networkMonitor: net
+ )
+ await waitForState(vm, timeout: 2.0) {
+ if case .error = $0 { return true }; return false
+ }
+ // Original attempt + DirectPlay fallback + 1 retry = 3 calls.
+ #expect(openCalls == 3)
+ if case .error(_, let msg, _) = vm.state {
+ #expect(msg.contains("Couldn't tune"))
+ } else { Issue.record("expected error state") }
+ }
+
+ @Test func channelUpDebouncesRapidPresses() async {
+ let host = MockPlayerHost()
+ let net = MockNetworkMonitor()
+ let channels = [channel("a", num: "101"), channel("b", num: "102"), channel("c", num: "103")]
+ let pb = playback("http://x/m.m3u8", liveStreamId: "ls")
+ var openCalls = 0
+ var lastChannel: LiveTvChannel?
+ let vm = PlayerViewModel(
+ initialChannel: channels[0],
+ channels: channels,
+ serverURL: serverURL,
+ program: nil,
+ openStream: { c, _ in openCalls += 1; lastChannel = c; return pb },
+ closeStream: { _ in },
+ host: host,
+ networkMonitor: net
+ )
+ await waitForState(vm) { if case .splash = $0 { return true }; return false }
+ let initialOpens = openCalls
+
+ // Three rapid channel-up presses within debounce window.
+ vm.channelUp()
+ vm.channelUp()
+ vm.channelUp()
+ // Wait past debounce + a buffer.
+ try? await Task.sleep(nanoseconds: 700_000_000)
+
+ // Only ONE additional open should have fired.
+ #expect(openCalls == initialOpens + 1)
+ // And it should be channel "a" again (a → b → c → a wrap-around).
+ #expect(lastChannel?.id == "a")
+ }
+
+ @Test func bufferEmptyOver5sTriggersReconnecting() async {
+ let host = MockPlayerHost()
+ let net = MockNetworkMonitor()
+ let ch = channel("c4")
+ let pb = playback("http://x/m.m3u8", liveStreamId: "ls-4")
+ let vm = PlayerViewModel(
+ initialChannel: ch,
+ channels: [ch],
+ serverURL: serverURL,
+ program: nil,
+ openStream: { _, _ in pb },
+ closeStream: { _ in },
+ host: host,
+ networkMonitor: net
+ )
+ await waitForState(vm) { if case .splash = $0 { return true }; return false }
+ host.emitReadyForDisplay(true)
+ await waitForState(vm) { if case .playing = $0 { return true }; return false }
+
+ // Buffer empty — won't transition immediately (5s threshold).
+ host.emitBufferEmpty(true)
+ try? await Task.sleep(nanoseconds: 100_000_000) // 100ms — well below threshold
+ if case .reconnecting = vm.state { Issue.record("transitioned too early") }
+
+ // Buffer recovers — should not transition to reconnecting.
+ host.emitBufferEmpty(false)
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ if case .reconnecting = vm.state { Issue.record("recovered but still reconnecting") }
+ }
+
+ @Test func dismissIsIdempotent_closeStreamCalledOnce() async {
+ let host = MockPlayerHost()
+ let net = MockNetworkMonitor()
+ let ch = channel("c6")
+ let pb = playback("http://x/m.m3u8", liveStreamId: "ls-6")
+ var closedIds: [String] = []
+ let vm = PlayerViewModel(
+ initialChannel: ch,
+ channels: [ch],
+ serverURL: serverURL,
+ program: nil,
+ openStream: { _, _ in pb },
+ closeStream: { id in closedIds.append(id) },
+ host: host,
+ networkMonitor: net
+ )
+ await waitForState(vm) { if case .splash = $0 { return true }; return false }
+
+ // Call dismiss() twice — simulates Menu-press + onDisappear both firing.
+ vm.dismiss()
+ vm.dismiss()
+
+ // Let the async close task execute.
+ try? await Task.sleep(nanoseconds: 200_000_000)
+
+ // closeStream must have been called exactly once.
+ #expect(closedIds == ["ls-6"])
+ // host.tearDown() must have been called exactly once.
+ #expect(host.torndownCount == 1)
+ // networkMonitor.stop() must have been called exactly once.
+ #expect(net.stoppedCount == 1)
+ }
+
+ @Test func dismissCallsCloseStreamAndStopsNetwork() async {
+ let host = MockPlayerHost()
+ let net = MockNetworkMonitor()
+ let ch = channel("c5")
+ let pb = playback("http://x/m.m3u8", liveStreamId: "ls-5")
+ var closedIds: [String] = []
+ let vm = PlayerViewModel(
+ initialChannel: ch,
+ channels: [ch],
+ serverURL: serverURL,
+ program: nil,
+ openStream: { _, _ in pb },
+ closeStream: { id in closedIds.append(id) },
+ host: host,
+ networkMonitor: net
+ )
+ await waitForState(vm) { if case .splash = $0 { return true }; return false }
+ vm.dismiss()
+ // closeStream is async — let it run.
+ try? await Task.sleep(nanoseconds: 200_000_000)
+ #expect(closedIds == ["ls-5"])
+ #expect(host.torndownCount == 1)
+ #expect(net.stoppedCount == 1)
+ }
+}
diff --git a/jellytv/Packages/Persistence/Package.swift b/jellytv/Packages/Persistence/Package.swift
new file mode 100644
index 0000000..fb27bfa
--- /dev/null
+++ b/jellytv/Packages/Persistence/Package.swift
@@ -0,0 +1,14 @@
+// swift-tools-version: 6.2
+import PackageDescription
+
+let package = Package(
+ name: "Persistence",
+ platforms: [.tvOS(.v26), .macOS(.v15)],
+ products: [
+ .library(name: "Persistence", targets: ["Persistence"]),
+ ],
+ targets: [
+ .target(name: "Persistence"),
+ .testTarget(name: "PersistenceTests", dependencies: ["Persistence"]),
+ ]
+)
diff --git a/jellytv/Packages/Persistence/Sources/Persistence/CredentialsStore.swift b/jellytv/Packages/Persistence/Sources/Persistence/CredentialsStore.swift
new file mode 100644
index 0000000..28a74da
--- /dev/null
+++ b/jellytv/Packages/Persistence/Sources/Persistence/CredentialsStore.swift
@@ -0,0 +1,95 @@
+import Foundation
+
+/// Typed wrapper around `Keychain` that stores the three Phase 1 credentials
+/// for JellyTV: server URL, access token, and device ID.
+///
+/// The `service` is injectable so tests can use a unique service per test,
+/// avoiding cross-test pollution.
+public struct CredentialsStore {
+
+ // MARK: Storage keys
+
+ private static let keyServerURL = "serverURL"
+ private static let keyAccessToken = "accessToken"
+ private static let keyDeviceId = "deviceId"
+
+ // MARK: Properties
+
+ public let service: String
+
+ // MARK: Init
+
+ public init(service: String = "com.cursorkittens.jellytv") {
+ self.service = service
+ }
+
+ // MARK: Server URL
+
+ /// Returns the stored server URL, or `nil` if none has been set.
+ /// Throws if the stored string cannot be parsed as a `URL` (corrupted state).
+ public func serverURL() throws -> URL? {
+ guard let raw = try Keychain.get(forKey: Self.keyServerURL, service: service) else {
+ return nil
+ }
+ guard let url = URL(string: raw) else {
+ throw CredentialsStoreError.corruptedValue(key: Self.keyServerURL)
+ }
+ return url
+ }
+
+ /// Persists `url`. Pass `nil` to remove the stored value.
+ public func setServerURL(_ url: URL?) throws {
+ if let url {
+ try Keychain.set(url.absoluteString, forKey: Self.keyServerURL, service: service)
+ } else {
+ try Keychain.delete(forKey: Self.keyServerURL, service: service)
+ }
+ }
+
+ // MARK: Access Token
+
+ /// Returns the stored access token, or `nil` if none has been set.
+ public func accessToken() throws -> String? {
+ try Keychain.get(forKey: Self.keyAccessToken, service: service)
+ }
+
+ /// Persists `token`. Pass `nil` to remove the stored value.
+ public func setAccessToken(_ token: String?) throws {
+ if let token {
+ try Keychain.set(token, forKey: Self.keyAccessToken, service: service)
+ } else {
+ try Keychain.delete(forKey: Self.keyAccessToken, service: service)
+ }
+ }
+
+ // MARK: Device ID
+
+ /// Returns the device ID. On first access, generates a new `UUID` string,
+ /// persists it, and returns it. Subsequent calls return the same value.
+ ///
+ /// This is a `func` (not a computed property) because the persist step can throw.
+ public func deviceId() throws -> String {
+ if let existing = try Keychain.get(forKey: Self.keyDeviceId, service: service) {
+ return existing
+ }
+ let newId = UUID().uuidString
+ try Keychain.set(newId, forKey: Self.keyDeviceId, service: service)
+ return newId
+ }
+
+ // MARK: Clear
+
+ /// Removes all three stored credentials. After calling this, `deviceId()` will
+ /// generate a fresh UUID on the next access.
+ public func clear() throws {
+ try Keychain.delete(forKey: Self.keyServerURL, service: service)
+ try Keychain.delete(forKey: Self.keyAccessToken, service: service)
+ try Keychain.delete(forKey: Self.keyDeviceId, service: service)
+ }
+}
+
+// MARK: - CredentialsStoreError
+
+public enum CredentialsStoreError: Error {
+ case corruptedValue(key: String)
+}
diff --git a/jellytv/Packages/Persistence/Sources/Persistence/Keychain.swift b/jellytv/Packages/Persistence/Sources/Persistence/Keychain.swift
new file mode 100644
index 0000000..14ba6f7
--- /dev/null
+++ b/jellytv/Packages/Persistence/Sources/Persistence/Keychain.swift
@@ -0,0 +1,90 @@
+import Foundation
+import Security
+
+// MARK: - Error
+
+public enum KeychainError: Error {
+ case unhandled(OSStatus)
+}
+
+// MARK: - Keychain
+
+public enum Keychain {
+
+ // MARK: Set
+
+ /// Stores `value` (UTF-8 encoded) for the given `key` in the specified `service`.
+ /// Overwrites any existing value for the same key+service pair.
+ public static func set(_ value: String, forKey key: String, service: String) throws {
+ guard let data = value.data(using: .utf8) else {
+ throw KeychainError.unhandled(errSecParam)
+ }
+
+ // Delete first so we can always do a clean add.
+ let deleteQuery: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key,
+ ]
+ let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
+ guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
+ throw KeychainError.unhandled(deleteStatus)
+ }
+
+ let addQuery: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key,
+ kSecValueData: data,
+ kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
+ ]
+ let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
+ guard addStatus == errSecSuccess else {
+ throw KeychainError.unhandled(addStatus)
+ }
+ }
+
+ // MARK: Get
+
+ /// Returns the stored string for `key` in `service`, or `nil` if no item exists.
+ /// Throws `KeychainError.unhandled` for any other non-success status.
+ public static func get(forKey key: String, service: String) throws -> String? {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key,
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne,
+ ]
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ switch status {
+ case errSecSuccess:
+ guard let data = result as? Data, let string = String(data: data, encoding: .utf8) else {
+ throw KeychainError.unhandled(errSecDecode)
+ }
+ return string
+ case errSecItemNotFound:
+ return nil
+ default:
+ throw KeychainError.unhandled(status)
+ }
+ }
+
+ // MARK: Delete
+
+ /// Removes the item for `key` in `service`. Idempotent — does not throw if the item does not exist.
+ public static func delete(forKey key: String, service: String) throws {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: key,
+ ]
+ let status = SecItemDelete(query as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw KeychainError.unhandled(status)
+ }
+ }
+}
diff --git a/jellytv/Packages/Persistence/Tests/PersistenceTests/CredentialsStoreTests.swift b/jellytv/Packages/Persistence/Tests/PersistenceTests/CredentialsStoreTests.swift
new file mode 100644
index 0000000..e8c85b7
--- /dev/null
+++ b/jellytv/Packages/Persistence/Tests/PersistenceTests/CredentialsStoreTests.swift
@@ -0,0 +1,69 @@
+import Testing
+import Foundation
+@testable import Persistence
+
+@Suite("CredentialsStore")
+struct CredentialsStoreTests {
+
+ @Test func deviceIdGeneratesAndPersists() throws {
+ let store = CredentialsStore(service: "com.cursorkittens.jellytv.test.\(UUID().uuidString)")
+ defer { try? store.clear() }
+
+ let first = try store.deviceId()
+ let second = try store.deviceId()
+
+ #expect(first == second, "deviceId should return the same value on repeated calls")
+ // Verify it's a valid UUID.
+ #expect(UUID(uuidString: first) != nil, "deviceId should be a valid UUID string")
+ }
+
+ @Test func serverURLRoundTrip() throws {
+ let store = CredentialsStore(service: "com.cursorkittens.jellytv.test.\(UUID().uuidString)")
+ defer { try? store.clear() }
+
+ let url = URL(string: "http://192.168.1.50:8096")!
+ try store.setServerURL(url)
+ let retrieved = try store.serverURL()
+ #expect(retrieved == url)
+
+ // Clear it and confirm nil.
+ try store.setServerURL(nil)
+ let afterClear = try store.serverURL()
+ #expect(afterClear == nil)
+ }
+
+ @Test func accessTokenRoundTrip() throws {
+ let store = CredentialsStore(service: "com.cursorkittens.jellytv.test.\(UUID().uuidString)")
+ defer { try? store.clear() }
+
+ let token = "abc123-access-token"
+ try store.setAccessToken(token)
+ let retrieved = try store.accessToken()
+ #expect(retrieved == token)
+
+ try store.setAccessToken(nil)
+ let afterClear = try store.accessToken()
+ #expect(afterClear == nil)
+ }
+
+ @Test func clearWipesAll() throws {
+ let store = CredentialsStore(service: "com.cursorkittens.jellytv.test.\(UUID().uuidString)")
+ defer { try? store.clear() }
+
+ // Set all three.
+ try store.setServerURL(URL(string: "http://192.168.1.50:8096")!)
+ try store.setAccessToken("some-token")
+ let preClearDeviceId = try store.deviceId()
+
+ // Clear everything.
+ try store.clear()
+
+ #expect(try store.serverURL() == nil)
+ #expect(try store.accessToken() == nil)
+
+ // deviceId should regenerate to a NEW value after clear.
+ let postClearDeviceId = try store.deviceId()
+ #expect(postClearDeviceId != preClearDeviceId, "deviceId should regenerate after clear()")
+ #expect(UUID(uuidString: postClearDeviceId) != nil, "regenerated deviceId should be a valid UUID")
+ }
+}
diff --git a/jellytv/Packages/Persistence/Tests/PersistenceTests/KeychainTests.swift b/jellytv/Packages/Persistence/Tests/PersistenceTests/KeychainTests.swift
new file mode 100644
index 0000000..6dde82c
--- /dev/null
+++ b/jellytv/Packages/Persistence/Tests/PersistenceTests/KeychainTests.swift
@@ -0,0 +1,48 @@
+import Testing
+import Foundation
+@testable import Persistence
+
+@Suite("Keychain")
+struct KeychainTests {
+
+ @Test func roundTripSetGetDelete() throws {
+ let service = "com.cursorkittens.jellytv.test.\(UUID().uuidString)"
+ let key = "testKey"
+ let value = "hello-keychain"
+
+ defer { try? Keychain.delete(forKey: key, service: service) }
+
+ try Keychain.set(value, forKey: key, service: service)
+ let retrieved = try Keychain.get(forKey: key, service: service)
+ #expect(retrieved == value)
+
+ try Keychain.delete(forKey: key, service: service)
+ let afterDelete = try Keychain.get(forKey: key, service: service)
+ #expect(afterDelete == nil)
+ }
+
+ @Test func overwriteExistingValue() throws {
+ let service = "com.cursorkittens.jellytv.test.\(UUID().uuidString)"
+ let key = "overwriteKey"
+
+ defer { try? Keychain.delete(forKey: key, service: service) }
+
+ try Keychain.set("first", forKey: key, service: service)
+ try Keychain.set("second", forKey: key, service: service)
+ let retrieved = try Keychain.get(forKey: key, service: service)
+ #expect(retrieved == "second")
+ }
+
+ @Test func deleteIsIdempotent() throws {
+ let service = "com.cursorkittens.jellytv.test.\(UUID().uuidString)"
+ let key = "neverSetKey"
+ // Should not throw even though nothing was stored.
+ try Keychain.delete(forKey: key, service: service)
+ }
+
+ @Test func returnsNilOnMissingKey() throws {
+ let service = "com.cursorkittens.jellytv.test.\(UUID().uuidString)"
+ let result = try Keychain.get(forKey: "ghostKey", service: service)
+ #expect(result == nil)
+ }
+}
diff --git a/jellytv/Packages/Player/Package.swift b/jellytv/Packages/Player/Package.swift
new file mode 100644
index 0000000..feef6f2
--- /dev/null
+++ b/jellytv/Packages/Player/Package.swift
@@ -0,0 +1,13 @@
+// swift-tools-version: 6.2
+import PackageDescription
+
+let package = Package(
+ name: "Player",
+ platforms: [.tvOS(.v26)],
+ products: [
+ .library(name: "Player", targets: ["Player"]),
+ ],
+ targets: [
+ .target(name: "Player"),
+ ]
+)
diff --git a/jellytv/Packages/Player/Sources/Player/Player.swift b/jellytv/Packages/Player/Sources/Player/Player.swift
new file mode 100644
index 0000000..8f8672c
--- /dev/null
+++ b/jellytv/Packages/Player/Sources/Player/Player.swift
@@ -0,0 +1,6 @@
+// Player module placeholder.
+// Phase 4 adds the AVPlayerViewController host, progress reporter, and metadata injector here.
+
+public enum Player {
+ public static let version = "0.0.1"
+}
diff --git a/jellytv/Packages/Settings/Package.swift b/jellytv/Packages/Settings/Package.swift
new file mode 100644
index 0000000..8018874
--- /dev/null
+++ b/jellytv/Packages/Settings/Package.swift
@@ -0,0 +1,29 @@
+// swift-tools-version: 6.2
+import PackageDescription
+
+let package = Package(
+ name: "Settings",
+ platforms: [.tvOS(.v26), .macOS(.v15)],
+ products: [
+ .library(name: "Settings", targets: ["Settings"]),
+ ],
+ dependencies: [
+ .package(path: "../JellyfinAPI"),
+ .package(path: "../Persistence"),
+ .package(path: "../DesignSystem"),
+ ],
+ targets: [
+ .target(
+ name: "Settings",
+ dependencies: [
+ .product(name: "JellyfinAPI", package: "JellyfinAPI"),
+ .product(name: "Persistence", package: "Persistence"),
+ .product(name: "DesignSystem", package: "DesignSystem"),
+ ]
+ ),
+ .testTarget(
+ name: "SettingsTests",
+ dependencies: ["Settings", "JellyfinAPI"]
+ ),
+ ]
+)
diff --git a/jellytv/Packages/Settings/Sources/Settings/SessionStore.swift b/jellytv/Packages/Settings/Sources/Settings/SessionStore.swift
new file mode 100644
index 0000000..6052db5
--- /dev/null
+++ b/jellytv/Packages/Settings/Sources/Settings/SessionStore.swift
@@ -0,0 +1,87 @@
+import Foundation
+import Observation
+import JellyfinAPI
+import Persistence
+
+/// Owns the live `JellyfinClient` and `CredentialsStore` and tracks the auth lifecycle.
+///
+/// Lives at the app root and is shared with `SignInModel` (which writes credentials on
+/// successful sign-in) so the same actor instance and Keychain backing store are used end-to-end.
+@MainActor
+@Observable
+public final class SessionStore {
+
+ public enum Phase: Equatable, Sendable {
+ case loading
+ case signedOut
+ case signedIn(UserDto)
+ case reconnecting(UserDto?)
+ }
+
+ public private(set) var phase: Phase = .loading
+ public let client: any JellyfinClientAPI
+ public let credentials: CredentialsStore
+
+ public init(client: any JellyfinClientAPI, credentials: CredentialsStore) {
+ self.client = client
+ self.credentials = credentials
+ }
+
+ /// Reads stored credentials and validates them against the server. Distinguishes
+ /// token-expired (sign out) from transient network/server errors (reconnecting).
+ /// Per critic C8: a network blip on launch must NOT sign the user out.
+ public func restore() async {
+ let storedURL: URL?
+ let storedToken: String?
+ do {
+ storedURL = try credentials.serverURL()
+ storedToken = try credentials.accessToken()
+ } catch {
+ phase = .signedOut
+ return
+ }
+
+ guard let serverURL = storedURL, let token = storedToken else {
+ phase = .signedOut
+ return
+ }
+
+ await client.setServerURL(serverURL)
+ await client.setAccessToken(token)
+
+ do {
+ let user = try await client.currentUser()
+ phase = .signedIn(user)
+ } catch JellyfinError.unauthenticated {
+ try? credentials.clear()
+ await client.setAccessToken(nil)
+ phase = .signedOut
+ } catch JellyfinError.network {
+ phase = .reconnecting(nil)
+ } catch JellyfinError.http(let status, _) where (500..<600).contains(status) {
+ phase = .reconnecting(nil)
+ } catch {
+ // Decoding errors, unexpected 4xx, etc. — sign out for safety.
+ try? credentials.clear()
+ await client.setAccessToken(nil)
+ phase = .signedOut
+ }
+ }
+
+ /// Called by the app after `SignInModel` reaches `.signedIn`. The model already
+ /// persisted credentials and pushed the token onto the actor; this just propagates
+ /// the user into our phase.
+ public func didSignIn(user: UserDto) {
+ phase = .signedIn(user)
+ }
+
+ /// Best-effort logout: tells the server, clears keychain, resets actor state.
+ /// Always ends in `.signedOut` regardless of server response.
+ public func signOut() async {
+ try? await client.logout()
+ try? credentials.clear()
+ await client.setAccessToken(nil)
+ await client.setServerURL(nil)
+ phase = .signedOut
+ }
+}
diff --git a/jellytv/Packages/Settings/Sources/Settings/SignInModel.swift b/jellytv/Packages/Settings/Sources/Settings/SignInModel.swift
new file mode 100644
index 0000000..30b4453
--- /dev/null
+++ b/jellytv/Packages/Settings/Sources/Settings/SignInModel.swift
@@ -0,0 +1,158 @@
+import Foundation
+import Observation
+import JellyfinAPI
+import Persistence
+
+@MainActor
+@Observable
+public final class SignInModel {
+ public private(set) var state: SignInState = .enteringServerURL
+ public var serverURLInput: String = ""
+ public var username: String = ""
+ public var password: String = ""
+
+ private let client: any JellyfinClientAPI
+ private let credentials: CredentialsStore
+ @ObservationIgnored nonisolated(unsafe) private var pollingTask: Task?
+
+ // Internal so tests can override for faster polling.
+ var pollInterval: Duration = .seconds(2)
+
+ public init(client: any JellyfinClientAPI, credentials: CredentialsStore) {
+ self.client = client
+ self.credentials = credentials
+ }
+
+ /// Validates and sets the server URL on the client. Transitions to `.chooseMode` on success.
+ public func connectToServer() async {
+ state = .validatingServer
+ do {
+ let url = try Self.normalizeServerURL(serverURLInput)
+ await client.setServerURL(url)
+ let info = try await client.getPublicSystemInfo()
+ state = .chooseMode(serverName: info.serverName ?? url.host ?? "Jellyfin")
+ } catch JellyfinError.invalidServerURL {
+ state = .failed(.invalidServerURL)
+ } catch let error as SignInError {
+ state = .failed(error)
+ } catch JellyfinError.network {
+ state = .failed(.serverUnreachable)
+ } catch JellyfinError.http(let status, _) where (500..<600).contains(status) {
+ state = .failed(.serverUnreachable)
+ } catch {
+ state = .failed(.unknown(String(describing: error)))
+ }
+ }
+
+ /// Normalizes a user-typed server URL: trims whitespace,
+ /// prepends http:// if no scheme, requires the result to have a non-nil host.
+ public static func normalizeServerURL(_ raw: String) throws -> URL {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { throw SignInError.invalidServerURL }
+ let withScheme: String
+ if trimmed.contains("://") {
+ withScheme = trimmed
+ } else {
+ withScheme = "http://" + trimmed
+ }
+ guard let url = URL(string: withScheme), url.host != nil else {
+ throw SignInError.invalidServerURL
+ }
+ return url
+ }
+
+ public func chooseQuickConnect() async {
+ state = .quickConnectStarting
+ do {
+ let result = try await client.quickConnectInitiate()
+ state = .quickConnect(code: result.code, secret: result.secret)
+ startPollingQuickConnect(secret: result.secret)
+ } catch JellyfinError.quickConnectDisabled {
+ state = .failed(.quickConnectDisabled)
+ } catch JellyfinError.network {
+ state = .failed(.serverUnreachable)
+ } catch {
+ state = .failed(.unknown(String(describing: error)))
+ }
+ }
+
+ public func choosePassword() {
+ state = .enteringPassword
+ }
+
+ /// Polls /QuickConnect/Connect until Authenticated, then exchanges for a token.
+ /// Internal so tests can call it directly without the spawned Task.
+ func pollQuickConnectLoop(secret: String) async {
+ do {
+ while !Task.isCancelled {
+ try await Task.sleep(for: pollInterval)
+ let status = try await client.quickConnectStatus(secret: secret)
+ if status.authenticated {
+ state = .authenticating
+ let result = try await client.authenticateWithQuickConnect(secret: secret)
+ await finishSignIn(result: result)
+ return
+ }
+ }
+ } catch is CancellationError {
+ return
+ } catch JellyfinError.quickConnectExpired {
+ state = .failed(.quickConnectExpired)
+ } catch JellyfinError.network {
+ state = .failed(.serverUnreachable)
+ } catch {
+ state = .failed(.unknown(String(describing: error)))
+ }
+ }
+
+ private func startPollingQuickConnect(secret: String) {
+ pollingTask?.cancel()
+ pollingTask = Task { [weak self] in
+ await self?.pollQuickConnectLoop(secret: secret)
+ }
+ }
+
+ public func cancelQuickConnect() {
+ pollingTask?.cancel()
+ pollingTask = nil
+ state = .enteringServerURL
+ }
+
+ public func signInWithPassword() async {
+ state = .authenticating
+ do {
+ let result = try await client.authenticateByName(username: username, password: password)
+ await finishSignIn(result: result)
+ } catch JellyfinError.unauthenticated {
+ state = .failed(.invalidCredentials)
+ } catch JellyfinError.http(let status, _) where status == 400 || status == 401 {
+ state = .failed(.invalidCredentials)
+ } catch JellyfinError.network {
+ state = .failed(.serverUnreachable)
+ } catch {
+ state = .failed(.unknown(String(describing: error)))
+ }
+ }
+
+ private func finishSignIn(result: AuthenticationResult) async {
+ do {
+ let url = try Self.normalizeServerURL(serverURLInput)
+ try credentials.setServerURL(url)
+ try credentials.setAccessToken(result.accessToken)
+ await client.setAccessToken(result.accessToken)
+ state = .signedIn(result.user)
+ } catch {
+ state = .failed(.persistFailed)
+ }
+ }
+
+ public func reset() {
+ pollingTask?.cancel()
+ pollingTask = nil
+ state = .enteringServerURL
+ }
+
+ deinit {
+ pollingTask?.cancel()
+ }
+}
diff --git a/jellytv/Packages/Settings/Sources/Settings/SignInState.swift b/jellytv/Packages/Settings/Sources/Settings/SignInState.swift
new file mode 100644
index 0000000..ca9912f
--- /dev/null
+++ b/jellytv/Packages/Settings/Sources/Settings/SignInState.swift
@@ -0,0 +1,42 @@
+import JellyfinAPI
+
+public enum SignInState: Equatable, Sendable {
+ case enteringServerURL
+ case validatingServer
+ case chooseMode(serverName: String)
+ case quickConnectStarting
+ case quickConnect(code: String, secret: String)
+ case enteringPassword
+ case authenticating
+ case signedIn(UserDto)
+ case failed(SignInError)
+}
+
+public enum SignInError: Error, Equatable, Sendable {
+ case invalidServerURL
+ case serverUnreachable
+ case quickConnectDisabled
+ case quickConnectExpired
+ case invalidCredentials
+ case persistFailed
+ case unknown(String)
+
+ public var message: String {
+ switch self {
+ case .invalidServerURL:
+ return "That doesn't look like a valid server URL. Try http://192.168.1.50:8096"
+ case .serverUnreachable:
+ return "Couldn't reach the server. Check the URL and that the server is running."
+ case .quickConnectDisabled:
+ return "Quick Connect is not enabled on this server. Use username and password instead."
+ case .quickConnectExpired:
+ return "The Quick Connect code expired. Please try again."
+ case .invalidCredentials:
+ return "Wrong username or password."
+ case .persistFailed:
+ return "Couldn't save your sign-in. Try again."
+ case .unknown(let m):
+ return "Sign-in failed: \(m)"
+ }
+ }
+}
diff --git a/jellytv/Packages/Settings/Sources/Settings/SignInView.swift b/jellytv/Packages/Settings/Sources/Settings/SignInView.swift
new file mode 100644
index 0000000..12d64da
--- /dev/null
+++ b/jellytv/Packages/Settings/Sources/Settings/SignInView.swift
@@ -0,0 +1,173 @@
+import SwiftUI
+import JellyfinAPI
+
+fileprivate enum FocusField: Hashable {
+ case serverURL, username, password
+}
+
+public struct SignInView: View {
+ @State private var model: SignInModel
+
+ public init(model: SignInModel) {
+ self._model = State(initialValue: model)
+ }
+
+ public var body: some View {
+ Group {
+ switch model.state {
+ case .enteringServerURL, .validatingServer, .failed:
+ ServerURLEntryView(model: model)
+ case .chooseMode(let serverName):
+ ChooseModeView(serverName: serverName, model: model)
+ case .quickConnectStarting:
+ ProgressView("Starting Quick Connect\u{2026}")
+ case .quickConnect(let code, _):
+ QuickConnectCodeView(code: code, model: model)
+ case .enteringPassword, .authenticating:
+ PasswordSignInView(model: model)
+ case .signedIn(let user):
+ SignedInPlaceholderView(user: user)
+ }
+ }
+ .padding(80)
+ }
+}
+
+// MARK: - ServerURLEntryView
+
+private struct ServerURLEntryView: View {
+ @Bindable var model: SignInModel
+ @FocusState private var focus: FocusField?
+
+ var body: some View {
+ VStack(spacing: 40) {
+ Text("Connect to your Jellyfin server")
+ .font(.title)
+ .multilineTextAlignment(.center)
+
+ TextField("http://192.168.1.50:8096", text: $model.serverURLInput)
+ .textContentType(.URL)
+ .autocorrectionDisabled()
+ .focused($focus, equals: .serverURL)
+ .onSubmit {
+ Task { await model.connectToServer() }
+ }
+
+ if case .failed(let err) = model.state {
+ Text(err.message)
+ .foregroundStyle(.red)
+ .multilineTextAlignment(.center)
+ }
+
+ Button("Connect") {
+ Task { await model.connectToServer() }
+ }
+ .disabled(model.state == .validatingServer)
+ }
+ .task { focus = .serverURL }
+ }
+}
+
+// MARK: - ChooseModeView
+
+private struct ChooseModeView: View {
+ let serverName: String
+ let model: SignInModel
+
+ @Namespace private var ns
+
+ var body: some View {
+ VStack(spacing: 40) {
+ Text("Sign in to \(serverName)")
+ .font(.title)
+
+ Button("Use Quick Connect") {
+ Task { await model.chooseQuickConnect() }
+ }
+ .prefersDefaultFocus(in: ns)
+
+ Button("Sign in with username & password") {
+ model.choosePassword()
+ }
+ }
+ .focusScope(ns)
+ }
+}
+
+// MARK: - QuickConnectCodeView
+
+private struct QuickConnectCodeView: View {
+ let code: String
+ let model: SignInModel
+
+ var body: some View {
+ VStack(spacing: 32) {
+ Text(code)
+ .font(.system(size: 96, weight: .bold, design: .monospaced))
+
+ Text("Open Jellyfin in your browser, go to your profile, and enter this code.")
+ .multilineTextAlignment(.center)
+ .font(.body)
+
+ ProgressView()
+
+ Button("Cancel") {
+ model.cancelQuickConnect()
+ }
+ }
+ }
+}
+
+// MARK: - PasswordSignInView
+
+private struct PasswordSignInView: View {
+ @Bindable var model: SignInModel
+ @FocusState private var focus: FocusField?
+
+ var body: some View {
+ VStack(spacing: 32) {
+ Text("Sign in")
+ .font(.title)
+
+ TextField("Username", text: $model.username)
+ .textContentType(.username)
+ .autocorrectionDisabled()
+ .focused($focus, equals: .username)
+ .onSubmit { focus = .password }
+
+ SecureField("Password", text: $model.password)
+ .textContentType(.password)
+ .focused($focus, equals: .password)
+ .onSubmit {
+ Task { await model.signInWithPassword() }
+ }
+
+ if case .failed(let err) = model.state {
+ Text(err.message)
+ .foregroundStyle(.red)
+ .multilineTextAlignment(.center)
+ }
+
+ Button("Sign In") {
+ Task { await model.signInWithPassword() }
+ }
+ .disabled(model.state == .authenticating)
+ }
+ .task { focus = .username }
+ }
+}
+
+// MARK: - SignedInPlaceholderView
+
+struct SignedInPlaceholderView: View {
+ let user: UserDto
+
+ var body: some View {
+ VStack(spacing: 24) {
+ Text("Signed in as \(user.name)")
+ .font(.title)
+ Text("Phase 1.5 wires this to a real Sign Out button.")
+ .foregroundStyle(.secondary)
+ }
+ }
+}
diff --git a/jellytv/Packages/Settings/Tests/SettingsTests/MockJellyfinClient.swift b/jellytv/Packages/Settings/Tests/SettingsTests/MockJellyfinClient.swift
new file mode 100644
index 0000000..6f30459
--- /dev/null
+++ b/jellytv/Packages/Settings/Tests/SettingsTests/MockJellyfinClient.swift
@@ -0,0 +1,115 @@
+import Foundation
+@testable import JellyfinAPI
+
+final class MockJellyfinClient: JellyfinClientAPI, @unchecked Sendable {
+ // Stubs — set by tests before calling.
+ var publicSystemInfoResult: Result = .success(
+ PublicSystemInfo(serverName: "MockServer", version: "10.11.8", id: "mock", productName: nil, localAddress: nil, startupWizardCompleted: true)
+ )
+ var quickConnectInitiateResult: Result = .success(
+ QuickConnectResult(authenticated: false, secret: "mock-secret", code: "ABC123", deviceId: nil, deviceName: nil, appName: nil, appVersion: nil, dateAdded: nil)
+ )
+ var quickConnectStatusResults: [Result] = []
+ var authenticateByNameResult: Result = .failure(JellyfinError.unauthenticated)
+ var authenticateWithQuickConnectResult: Result = .failure(JellyfinError.unauthenticated)
+ /// When non-nil, `currentUser()` returns this. When nil, returns a default mock user.
+ var currentUserResult: Result?
+ var logoutResult: Result = .success(())
+
+ // Home endpoint stubs
+ var userViewsResult: Result<[BaseItemDto], Error> = .success([])
+ var resumeItemsResult: Result<[BaseItemDto], Error> = .success([])
+ var nextUpResult: Result<[BaseItemDto], Error> = .success([])
+ var latestItemsResult: Result<[BaseItemDto], Error> = .success([])
+
+ var setServerURLCalls: [URL?] = []
+ var setAccessTokenCalls: [String?] = []
+ var currentServerURL: URL? = nil
+ var quickConnectStatusCallCount: Int = 0
+ var currentUserCallCount: Int = 0
+ var logoutCallCount: Int = 0
+
+ func setServerURL(_ url: URL?) async {
+ setServerURLCalls.append(url)
+ currentServerURL = url
+ }
+
+ func currentServerURL() async -> URL? {
+ currentServerURL
+ }
+
+ func setAccessToken(_ token: String?) async {
+ setAccessTokenCalls.append(token)
+ }
+
+ func getPublicSystemInfo() async throws -> PublicSystemInfo {
+ try publicSystemInfoResult.get()
+ }
+
+ func authenticateByName(username: String, password: String) async throws -> AuthenticationResult {
+ try authenticateByNameResult.get()
+ }
+
+ func quickConnectEnabled() async throws -> Bool { true }
+
+ func quickConnectInitiate() async throws -> QuickConnectResult {
+ try quickConnectInitiateResult.get()
+ }
+
+ func quickConnectStatus(secret: String) async throws -> QuickConnectResult {
+ quickConnectStatusCallCount += 1
+ if !quickConnectStatusResults.isEmpty {
+ return try quickConnectStatusResults.removeFirst().get()
+ }
+ return try quickConnectInitiateResult.get()
+ }
+
+ func authenticateWithQuickConnect(secret: String) async throws -> AuthenticationResult {
+ try authenticateWithQuickConnectResult.get()
+ }
+
+ func currentUser() async throws -> UserDto {
+ currentUserCallCount += 1
+ if let currentUserResult {
+ return try currentUserResult.get()
+ }
+ return UserDto(id: "u1", name: "Mock User", serverId: nil, primaryImageTag: nil, hasPassword: true, hasConfiguredPassword: true, lastLoginDate: nil, lastActivityDate: nil)
+ }
+
+ func logout() async throws {
+ logoutCallCount += 1
+ try logoutResult.get()
+ }
+
+ // MARK: - Home
+
+ func userViews() async throws -> [BaseItemDto] {
+ try userViewsResult.get()
+ }
+
+ func resumeItems(limit: Int) async throws -> [BaseItemDto] {
+ try resumeItemsResult.get()
+ }
+
+ func nextUp(limit: Int) async throws -> [BaseItemDto] {
+ try nextUpResult.get()
+ }
+
+ func latestItems(parentId: String?, limit: Int) async throws -> [BaseItemDto] {
+ try latestItemsResult.get()
+ }
+
+ // MARK: - Live TV
+
+ func liveTvChannels() async throws -> [LiveTvChannel] { [] }
+
+ func liveTvPrograms(
+ channelIds: [String],
+ minStartDate: Date,
+ maxStartDate: Date
+ ) async throws -> [LiveTvProgram] { [] }
+
+ func liveTvOpenStream(channelId: String) async throws -> LiveStreamPlayback {
+ LiveStreamPlayback(playbackURL: URL(string: "http://test/stream")!, liveStreamId: nil)
+ }
+}
diff --git a/jellytv/Packages/Settings/Tests/SettingsTests/SessionStoreRestorationTests.swift b/jellytv/Packages/Settings/Tests/SettingsTests/SessionStoreRestorationTests.swift
new file mode 100644
index 0000000..c4eef69
--- /dev/null
+++ b/jellytv/Packages/Settings/Tests/SettingsTests/SessionStoreRestorationTests.swift
@@ -0,0 +1,138 @@
+import Testing
+import Foundation
+@testable import Settings
+@testable import JellyfinAPI
+@testable import Persistence
+
+@MainActor
+@Suite("SessionStore restoration")
+struct SessionStoreRestorationTests {
+
+ private func makeStore(
+ client: MockJellyfinClient,
+ prefilled: (URL, String)? = nil
+ ) throws -> (SessionStore, CredentialsStore) {
+ let credentials = CredentialsStore(service: "com.cursorkittens.jellytv.test.\(UUID().uuidString)")
+ if let (url, token) = prefilled {
+ try credentials.setServerURL(url)
+ try credentials.setAccessToken(token)
+ }
+ return (SessionStore(client: client, credentials: credentials), credentials)
+ }
+
+ @Test func startsInLoading() throws {
+ let (store, _) = try makeStore(client: MockJellyfinClient())
+ #expect(store.phase == .loading)
+ }
+
+ @Test func noCredentialsTransitionsToSignedOut() async throws {
+ let client = MockJellyfinClient()
+ let (store, _) = try makeStore(client: client)
+ await store.restore()
+ #expect(store.phase == .signedOut)
+ #expect(client.currentUserCallCount == 0)
+ }
+
+ @Test func validCredentialsTransitionToSignedIn() async throws {
+ let client = MockJellyfinClient()
+ let user = UserDto(
+ id: "u1", name: "Alice", serverId: "s1",
+ primaryImageTag: nil, hasPassword: true, hasConfiguredPassword: true,
+ lastLoginDate: nil, lastActivityDate: nil
+ )
+ client.currentUserResult = .success(user)
+ let (store, creds) = try makeStore(
+ client: client,
+ prefilled: (URL(string: "http://192.168.1.50:8096")!, "tok-123")
+ )
+ defer { try? creds.clear() }
+
+ await store.restore()
+
+ #expect(store.phase == .signedIn(user))
+ // Client was configured from credentials.
+ #expect(client.setServerURLCalls.last == URL(string: "http://192.168.1.50:8096")!)
+ #expect(client.setAccessTokenCalls.last == "tok-123")
+ }
+
+ @Test func unauthenticatedClearsCredentialsAndSignsOut() async throws {
+ let client = MockJellyfinClient()
+ client.currentUserResult = .failure(JellyfinError.unauthenticated)
+ let (store, creds) = try makeStore(
+ client: client,
+ prefilled: (URL(string: "http://server")!, "expired-token")
+ )
+
+ await store.restore()
+
+ #expect(store.phase == .signedOut)
+ #expect(try creds.accessToken() == nil)
+ #expect(try creds.serverURL() == nil)
+ }
+
+ @Test func networkErrorKeepsCredentialsAndShowsReconnecting() async throws {
+ let client = MockJellyfinClient()
+ client.currentUserResult = .failure(JellyfinError.network(URLError(.notConnectedToInternet)))
+ let (store, creds) = try makeStore(
+ client: client,
+ prefilled: (URL(string: "http://server")!, "tok")
+ )
+ defer { try? creds.clear() }
+
+ await store.restore()
+
+ if case .reconnecting = store.phase {
+ // pass
+ } else {
+ Issue.record("expected .reconnecting, got \(store.phase)")
+ }
+ // Critically: credentials are NOT cleared on network error.
+ #expect(try creds.accessToken() == "tok")
+ #expect(try creds.serverURL()?.absoluteString == "http://server")
+ }
+
+ @Test func httpServerErrorKeepsCredentialsAndShowsReconnecting() async throws {
+ let client = MockJellyfinClient()
+ client.currentUserResult = .failure(JellyfinError.http(status: 503, problem: nil))
+ let (store, creds) = try makeStore(
+ client: client,
+ prefilled: (URL(string: "http://server")!, "tok")
+ )
+ defer { try? creds.clear() }
+
+ await store.restore()
+
+ if case .reconnecting = store.phase {
+ // pass
+ } else {
+ Issue.record("expected .reconnecting, got \(store.phase)")
+ }
+ #expect(try creds.accessToken() == "tok")
+ }
+
+ @Test func didSignInTransitionsToSignedIn() throws {
+ let user = UserDto(
+ id: "u2", name: "Bob", serverId: nil,
+ primaryImageTag: nil, hasPassword: nil, hasConfiguredPassword: nil,
+ lastLoginDate: nil, lastActivityDate: nil
+ )
+ let (store, _) = try makeStore(client: MockJellyfinClient())
+ store.didSignIn(user: user)
+ #expect(store.phase == .signedIn(user))
+ }
+
+ @Test func signOutClearsCredentialsAndTransitions() async throws {
+ let client = MockJellyfinClient()
+ let (store, creds) = try makeStore(
+ client: client,
+ prefilled: (URL(string: "http://server")!, "tok")
+ )
+
+ await store.signOut()
+
+ #expect(store.phase == .signedOut)
+ #expect(client.logoutCallCount == 1)
+ #expect(try creds.accessToken() == nil)
+ #expect(try creds.serverURL() == nil)
+ }
+}
diff --git a/jellytv/Packages/Settings/Tests/SettingsTests/SignInModelTests.swift b/jellytv/Packages/Settings/Tests/SettingsTests/SignInModelTests.swift
new file mode 100644
index 0000000..8e5ab52
--- /dev/null
+++ b/jellytv/Packages/Settings/Tests/SettingsTests/SignInModelTests.swift
@@ -0,0 +1,113 @@
+import Testing
+import Foundation
+@testable import Settings
+@testable import JellyfinAPI
+@testable import Persistence
+
+@MainActor
+@Suite("SignInModel")
+struct SignInModelTests {
+
+ private func makeModel(client: MockJellyfinClient = MockJellyfinClient()) -> (SignInModel, CredentialsStore) {
+ let store = CredentialsStore(service: "com.cursorkittens.jellytv.test.\(UUID().uuidString)")
+ let model = SignInModel(client: client, credentials: store)
+ return (model, store)
+ }
+
+ @Test func startsInEnteringServerURL() {
+ let (model, _) = makeModel()
+ #expect(model.state == .enteringServerURL)
+ }
+
+ @Test func normalizeServerURLPrependsHttp() throws {
+ let url = try SignInModel.normalizeServerURL("192.168.1.50:8096")
+ #expect(url.absoluteString == "http://192.168.1.50:8096")
+ }
+
+ @Test func normalizeServerURLPreservesHttps() throws {
+ let url = try SignInModel.normalizeServerURL("https://my.server")
+ #expect(url.absoluteString == "https://my.server")
+ }
+
+ @Test func normalizeServerURLRejectsEmpty() {
+ #expect(throws: SignInError.invalidServerURL) {
+ _ = try SignInModel.normalizeServerURL(" ")
+ }
+ }
+
+ @Test func connectToServerSuccess() async {
+ let (model, _) = makeModel()
+ model.serverURLInput = "192.168.1.50:8096"
+ await model.connectToServer()
+ if case .chooseMode(let name) = model.state {
+ #expect(name == "MockServer")
+ } else {
+ Issue.record("expected .chooseMode, got \(model.state)")
+ }
+ }
+
+ @Test func connectToServerInvalidURL() async {
+ let (model, _) = makeModel()
+ model.serverURLInput = ""
+ await model.connectToServer()
+ #expect(model.state == .failed(.invalidServerURL))
+ }
+
+ @Test func connectToServerNetworkError() async {
+ let client = MockJellyfinClient()
+ client.publicSystemInfoResult = .failure(JellyfinError.network(URLError(.notConnectedToInternet)))
+ let (model, _) = makeModel(client: client)
+ model.serverURLInput = "192.168.1.50:8096"
+ await model.connectToServer()
+ #expect(model.state == .failed(.serverUnreachable))
+ }
+
+ @Test func quickConnectDisabledMaps() async {
+ let client = MockJellyfinClient()
+ client.quickConnectInitiateResult = .failure(JellyfinError.quickConnectDisabled)
+ let (model, _) = makeModel(client: client)
+ await model.chooseQuickConnect()
+ #expect(model.state == .failed(.quickConnectDisabled))
+ }
+
+ @Test func quickConnectExpiredMapsViaPollLoop() async {
+ let client = MockJellyfinClient()
+ client.quickConnectInitiateResult = .success(
+ QuickConnectResult(authenticated: false, secret: "s1", code: "C1", deviceId: nil, deviceName: nil, appName: nil, appVersion: nil, dateAdded: nil)
+ )
+ client.quickConnectStatusResults = [.failure(JellyfinError.quickConnectExpired)]
+ let (model, _) = makeModel(client: client)
+ model.pollInterval = .milliseconds(10)
+ model.serverURLInput = "192.168.1.50:8096"
+ await model.pollQuickConnectLoop(secret: "s1")
+ #expect(model.state == .failed(.quickConnectExpired))
+ }
+
+ @Test func passwordSignInSuccess() async throws {
+ let client = MockJellyfinClient()
+ let user = UserDto(id: "u1", name: "Alice", serverId: "s1", primaryImageTag: nil, hasPassword: true, hasConfiguredPassword: true, lastLoginDate: nil, lastActivityDate: nil)
+ client.authenticateByNameResult = .success(
+ AuthenticationResult(user: user, sessionInfo: nil, accessToken: "tok-123", serverId: "s1")
+ )
+ let (model, store) = makeModel(client: client)
+ defer { try? store.clear() }
+ model.serverURLInput = "192.168.1.50:8096"
+ model.username = "alice"
+ model.password = "secret"
+ await model.signInWithPassword()
+ #expect(model.state == .signedIn(user))
+ #expect(try store.accessToken() == "tok-123")
+ #expect(try store.serverURL()?.absoluteString == "http://192.168.1.50:8096")
+ }
+
+ @Test func passwordSignInWrongCredentials() async {
+ let client = MockJellyfinClient()
+ client.authenticateByNameResult = .failure(JellyfinError.unauthenticated)
+ let (model, _) = makeModel(client: client)
+ model.serverURLInput = "192.168.1.50:8096"
+ model.username = "alice"
+ model.password = "wrong"
+ await model.signInWithPassword()
+ #expect(model.state == .failed(.invalidCredentials))
+ }
+}
diff --git a/jellytv/README.md b/jellytv/README.md
new file mode 100644
index 0000000..157b81b
--- /dev/null
+++ b/jellytv/README.md
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+A native SwiftUI **tvOS 18** Jellyfin client for Apple TV 4K — a modern replacement
+for SwiftFin tvOS, built **Live-TV first**: live guide, on-now, and instant channel
+zapping, with playback that leans entirely on the system player.
+
+> **Unofficial client.** Jellyfin and its branding belong to the Jellyfin project;
+> this is an independent tvOS front-end for it.
+
+## Features
+
+- **Live TV** — On Now rail, full EPG guide grid, recordings, and debounced channel zapping
+- **System-native playback** — `AVPlayerViewController` only; no custom chrome, no VLCKit/MPVKit
+- **Home** — hero section with poster shelves
+- **Focus-engine native** — trusts the tvOS focus engine end to end (card/borderless styles, focus sections, stable IDs)
+- **Fast images** — Nuke `LazyImage` throughout, with per-channel dominant-color theming
+
+## Architecture
+
+Thin app target + Swift Package Manager modules:
+
+| Package | Responsibility |
+|---|---|
+| `JellyfinAPI` | hand-rolled `actor JellyfinClient` (URLSession + async/await + Codable DTOs); `JellyfinClientAPI` is the seam for test fakes |
+| `LiveTV` | On Now, guide grid, recordings, player state machine, `AVPlayerViewController` host |
+| `Library` | Home (hero + shelves) |
+| `DesignSystem` | theme, typography, `PosterCard`, `Shelf`, `ChannelLogoView`, `JellyfinImage` |
+| `Settings` | server connect, sign in, `SessionStore` |
+| `Persistence` | Keychain wrapper |
+
+The app target (`JellyTV/`) is intentionally thin: `JellyTVApp`, `RootView`, assets.
+`softplan.md` is the founding design doc — read it for the player strategy, DeviceProfile,
+and tvOS gotchas.
+
+## Build & test
+
+```bash
+# Unit tests (Swift Testing, per package)
+swift test --package-path Packages/LiveTV
+swift test --package-path Packages/JellyfinAPI
+
+# Full app build
+xcodebuild -project JellyTV/JellyTV.xcodeproj -scheme JellyTV \
+ -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)'
+```
+
+Stack: SwiftUI + `@Observable` + Swift Concurrency (no Combine, no coordinators, no TCA).
+See `CLAUDE.md` for the architectural ground rules.
diff --git a/jellytv/docs/manual-smoke.md b/jellytv/docs/manual-smoke.md
new file mode 100644
index 0000000..84db276
--- /dev/null
+++ b/jellytv/docs/manual-smoke.md
@@ -0,0 +1,104 @@
+# JellyTV — Manual Smoke Test Checklist
+
+After Phase 0/1 completes, run this checklist on a real Apple TV 4K (or the
+tvOS Simulator) against a real LAN Jellyfin server. The motif workflow
+verifies the package boundary with unit tests and the build with `xcodebuild`,
+but a live server is the only way to confirm the actual auth flow works
+end-to-end.
+
+## Setup
+
+1. Have a Jellyfin server running on your LAN, version 10.9 or later. Note its
+ URL (e.g. `http://192.168.1.50:8096`).
+2. In the Jellyfin admin dashboard → **Quick Connect** → **Enable Quick Connect**
+ so you can test the QC flow.
+3. Open `JellyTV/JellyTV.xcodeproj` in Xcode.
+4. Select the **JellyTV** scheme + **Apple TV 4K (3rd generation)** simulator
+ destination, hit **⌘R**.
+
+## Phase 1 acceptance criteria — must all pass
+
+### Connect flow
+
+- [ ] On first launch, the app shows the **server URL entry** screen.
+- [ ] The TextField has initial focus (Siri Remote / arrow keys land on it).
+- [ ] Type a URL **without a scheme** (e.g. `192.168.1.50:8096`) and tap
+ Connect → server validates successfully (the `normalizeServerURL` helper
+ should prepend `http://`).
+- [ ] Type a URL with a scheme (`http://192.168.1.50:8096`) → also works.
+- [ ] Type a clearly invalid URL (`asdf`) → friendly error message
+ ("That doesn't look like a valid server URL…").
+- [ ] Type a URL pointing at nothing reachable (`http://10.99.99.99`) → friendly
+ "Couldn't reach the server" error after the request times out.
+- [ ] Successfully connecting transitions to the **choose mode** screen showing
+ the server name (or host fallback).
+
+### Quick Connect flow
+
+- [ ] On the choose-mode screen, "Use Quick Connect" has default focus.
+- [ ] Tap "Use Quick Connect" → the 6-character code displays in large
+ monospaced type, with a polling spinner beneath it.
+- [ ] Open Jellyfin in a browser → log in → user menu → Quick Connect → enter
+ the code → approve.
+- [ ] Within ~2 seconds, the app picks up the approval, shows
+ "Signed in as \", and a Sign Out button.
+- [ ] If you wait too long (5+ minutes) without approving the code, the app
+ transitions to a `quickConnectExpired` failure state with a retry path —
+ it does NOT spin forever (this is the regression test for the #B1
+ critic finding).
+
+### Quick Connect disabled
+
+- [ ] Disable Quick Connect in the Jellyfin admin → tap "Use Quick Connect" in
+ the app → user-friendly "Quick Connect is not enabled on this server.
+ Use username and password instead." message. The app does NOT show a
+ generic "auth failed" error (this is the regression test for the #C4
+ critic finding — the actor's per-callsite remap of 401 → quickConnectDisabled).
+
+### Password sign-in flow
+
+- [ ] On the choose-mode screen, tap "Sign in with username & password".
+- [ ] Username + password fields appear; typing into username then **Submit**
+ moves focus to password.
+- [ ] Wrong password → "Wrong username or password" friendly error.
+- [ ] Right password → transitions to "Signed in as \".
+
+### Session persistence (#1657 regression test)
+
+- [ ] After signing in, **force-quit the app** in the simulator (Cmd+Shift+H,
+ then swipe up on the JellyTV preview).
+- [ ] Relaunch the app from the home screen.
+- [ ] App goes briefly through `loading` then directly to "Signed in as \" — does NOT show the sign-in screen again.
+
+### Network blip on launch (critic C8 regression test)
+
+- [ ] Sign in successfully.
+- [ ] **Stop the Jellyfin server** (or pull the LAN cable).
+- [ ] Force-quit and relaunch the app.
+- [ ] App shows the **Reconnecting** screen with "Try Again" + "Sign Out"
+ buttons. It does NOT silently sign you out and dump you back to the URL
+ entry screen.
+- [ ] Restart the server, tap **Try Again** → app transitions to "Signed in as
+ \".
+
+### Sign out
+
+- [ ] On the signed-in screen, tap **Sign Out**.
+- [ ] App returns to the URL entry screen.
+- [ ] Force-quit and relaunch → app starts at URL entry (credentials cleared,
+ not silently restored).
+
+## "Must not regress" checklist (from softplan §7)
+
+These are bugs the SwiftFin tvOS App Store build still has on v1.0.1. Phase 1
+must not have any of them. Most are not testable until Phase 2/3/4 ships, but
+the Phase 1 ones are:
+
+- [ ] **No crash on Connect / sign in** — happy-path Connect → sign-in
+ completes without an `EXC_BAD_ACCESS` or fatal-error.
+- [ ] **Persistent login** — relaunch lands you in the signed-in state. ✓
+ (covered above)
+
+The other items (audio tracks, FF/RW, scrolling crashes, etc.) are deferred to
+their respective phases.
diff --git a/jellytv/softplan.md b/jellytv/softplan.md
new file mode 100644
index 0000000..05bfe2a
--- /dev/null
+++ b/jellytv/softplan.md
@@ -0,0 +1,451 @@
+# JellyTV — Soft Plan
+
+**Goal:** Build a modern, native SwiftUI **tvOS** Jellyfin client that doesn't suck. A replacement for SwiftFin tvOS, which is stuck on a year-old App Store build (v1.0.1) and has well-known live bugs.
+
+**Constraints (decided):**
+- **Hardware:** Apple TV 4K only (A10X / A12 / A15). Drop Apple TV HD (A8).
+- **Deployment target:** tvOS 18.
+- **Networking:** LAN-only for v1. Plan for remote/HTTPS in a later phase.
+- **Accounts:** Single user, single server. Multi-user/multi-server deferred.
+
+**Date:** 2026-04-06
+
+---
+
+## 1. Why this project exists
+
+### The tvOS Jellyfin landscape (April 2026)
+
+- **SwiftFin tvOS App Store build is stuck on v1.0.1** (build 70, over a year old). Every 2026 crash report lists 1.0.1. The iOS target shipped 1.4 in Dec 2025; the tvOS binary has not shipped a real update in over a year.
+- The next SwiftFin tvOS release is gated on two unfinished pieces of work:
+ - [#1774 Device Profile rewrite](https://github.com/jellyfin/Swiftfin/issues/1774) — iOS got a device-profile fix in PRs #519/#1169 that was never backported to tvOS. Until that lands, tvOS asks the server for wrong codecs and transcoding breaks.
+ - [#1853](https://github.com/jellyfin/Swiftfin/issues/1853) + [PR #1902](https://github.com/jellyfin/Swiftfin/pull/1902) — maintainers are rebuilding the tvOS player from scratch on `AVPlayerLayer` with custom transport chrome. Open since late 2025, last updated 2026-03-27, unmerged.
+- **No other native tvOS Jellyfin client exists.** Streamyfin's tvOS support is an open issue with zero implementation ([streamyfin#137](https://github.com/streamyfin/streamyfin/issues/137)). Macfin is abandoned. Jellyfin Media Player is Electron+mpv (desktop only).
+- **Infuse** (closed source) is the UX benchmark to match.
+
+### Live bugs on SwiftFin tvOS 1.0.1
+
+These become our "must not regress" acceptance criteria:
+
+| Issue | Symptom |
+|---|---|
+| [#1755](https://github.com/jellyfin/Swiftfin/issues/1755) | All audio tracks playing simultaneously |
+| [#1862](https://github.com/jellyfin/Swiftfin/issues/1862) | Crashes on "Connect" |
+| [#1906](https://github.com/jellyfin/Swiftfin/issues/1906) | Timeout + crash after scrolling Movies on Jellyfin 10.11 |
+| [#780](https://github.com/jellyfin/Swiftfin/issues/780) | External subtitles don't play |
+| [#1962](https://github.com/jellyfin/Swiftfin/issues/1962), [#1515](https://github.com/jellyfin/Swiftfin/issues/1515) | Touchpad FF/RW broken |
+| [#1948](https://github.com/jellyfin/Swiftfin/issues/1948) | Live TV LAN-only (out of v1 scope, tracked for later) |
+| [#1872](https://github.com/jellyfin/Swiftfin/issues/1872) | No audio for ~1s on resume |
+
+### Architectural lessons from SwiftFin (what NOT to copy)
+
+- **Coordinator-based navigation** — predates `NavigationStack`/`NavigationSplitView`. Legacy.
+- **Custom `@Stateful` macro + Combine + Factory DI** — too much bespoke machinery. Use `@Observable` + Swift Concurrency.
+- **CoreStore (Core Data wrapper)** — use SwiftData instead.
+- **Dual AVPlayer+VLCKit abstraction leaks** through DeviceProfile and causes real bugs ([#1852](https://github.com/jellyfin/Swiftfin/issues/1852), [#1943](https://github.com/jellyfin/Swiftfin/issues/1943)).
+- **Rebuilding the tvOS player on `AVPlayerLayer` with custom chrome** (PR #1902 direction) — throws away everything `AVPlayerViewController` gives you for free on tvOS.
+- **UIKit-isms** sprinkled through `Shared/` (`UIScreen.main.*`, `UIFocusGuide` bridges).
+
+### Architectural lessons from SwiftFin (what IS worth reading as reference)
+
+Do *not* fork — read as design reference:
+
+- `Shared/Objects/PlaybackDeviceProfile.swift` + `PlaybackCapabilities.swift` + `CustomDeviceProfileAction.swift` — DeviceProfile with user overrides
+- `Shared/Services/Keychain.swift` — token storage
+- `Shared/SwiftfinStore/V2Schema/` — multi-server/user store shape
+- `Shared/ServerDiscovery/` — UDP broadcast discovery
+- `Swiftfin tvOS/Views/HomeView/`, `PagingLibraryView/`, `ItemView/`, `SelectUserView/`, `UserSignInView.swift` — current tvOS UI patterns (reference only; many will change)
+
+---
+
+## 2. Technical decisions (locked)
+
+| Layer | Choice | Why |
+|---|---|---|
+| **Deployment target** | tvOS 18, Apple TV 4K only | A8 lacks HEVC HW decode; tvOS 18 layout APIs (`TabView(.sidebarAdaptable)`, `containerRelativeFrame`, `onScrollVisibilityChange`, `scrollTargetBehavior`) are transformational |
+| **UI** | SwiftUI, `@Observable` (Observation framework) | 2026 idiomatic; skip `ObservableObject`, skip TCA, skip coordinators |
+| **Navigation** | `NavigationStack` + `TabView(.sidebarAdaptable)` | Matches Apple TV app pattern |
+| **Project layout** | Thin app target + SPM packages | Faster incremental builds, enforced module boundaries |
+| **Persistence** | SwiftData | Skip CoreStore |
+| **Networking** | Hand-rolled `JellyfinClient` actor (~500 LOC, ~20 endpoints), `URLSession` + async/await + Codable | Avoid pinning to pre-1.0 churning `jellyfin-sdk-swift`; clean DTOs for SwiftData |
+| **Auth header** | `Authorization: MediaBrowser Client="JellyTV", Device="", DeviceId="", Version="", Token=""` | Old `X-Emby-Authorization` / `?api_key=` deprecated, removed in Jellyfin 12.0 |
+| **Secrets** | `KeychainAccess` SPM package — server URL + access token + persistent DeviceId UUID | Keyed by server URL so multi-server works later |
+| **Image cache** | Nuke (`LazyImage`) with downsampling to poster size | `AsyncImage` has no caching — grid will OOM without this |
+| **Player** | `AVPlayerViewController` only, with `externalMetadata`, `navigationMarkerGroups`, `contentProposalViewController` | See §3 |
+| **VLCKit / MPVKit** | **None in v1.** Reconsider in v1.1 only if users have Profile 7 DV / PGS / TrueHD | Avoid the dual-player abstraction leak that bit SwiftFin |
+
+### Proposed SPM structure
+
+```
+JellyTV/ (app target, tvOS 18)
+ JellyTVApp.swift
+ RootView.swift
+ Info.plist (NSLocalNetworkUsageDescription, NSBonjourServices)
+
+Packages/
+ JellyfinAPI/ actor JellyfinClient + Codable DTOs + DeviceProfile
+ DesignSystem/ colors, typography, shelf/card primitives, focus styles
+ Library/ Home (hero + shelves), library grid, item detail, search
+ Player/ AVPlayerViewController host + progress reporter + metadata injector
+ Settings/ server connect, sign in, about
+ TopShelf/ TVTopShelfContentProvider extension + shared App Group
+ Persistence/ SwiftData models + KeychainAccess wrapper
+```
+
+---
+
+## 3. The player, in detail
+
+**Decision:** `AVPlayerViewController` only. Do NOT build custom chrome. Do NOT follow SwiftFin PR #1902 down the `AVPlayerLayer` path.
+
+### What `AVPlayerViewController` gives you free on tvOS (and only tvOS)
+
+- Transport scrubber with thumbnail preview
+- Audio / subtitle picker panel
+- Chapter list (inject via `AVPlayerItem.navigationMarkerGroups` + `AVNavigationMarkersGroup`)
+- **Info panel** (press up on Siri Remote) — inject metadata via `AVPlayerItem.externalMetadata` with `AVMetadataItem`s carrying `identifier`, `value`, `extendedLanguageTag`
+- **Up Next / Skip Intro / Skip Credits / Next Episode** — sanctioned path is `contentProposalViewController` + `AVContentProposal` (WWDC21 session 10191)
+- `transportBarCustomMenuItems` (tvOS 15+) — custom menu items like "Audio Delay" in the transport bar
+- `customInfoViewController` — arbitrary SwiftUI in the Info panel's right pane (cast list, related items)
+- Automatic frame-rate matching (`appliesPreferredDisplayCriteriaAutomatically = true`)
+- Automatic HDR / Dolby Vision switching on HLS streams tagged with `VIDEO-RANGE`
+- Siri Remote touchpad scrubbing
+- `AVInterstitialTimeRange` for ad/promo markers
+
+**None of this is reachable from `AVPlayerLayer`, VLCKit, or MPVKit.** Reproducing it is a months-long project and will feel wrong.
+
+### Codec / HDR matrix (Apple TV 4K only)
+
+| Codec / feature | Apple TV 4K gen 1/2 (A10X/A12) | Apple TV 4K gen 3 (A15) |
+|---|---|---|
+| H.264 High@L4.2 | HW, 1080p60 | HW, 1080p60 |
+| HEVC Main/Main10 | HW, 2160p60 10-bit | HW, 2160p60 10-bit |
+| AV1 | ❌ | ❌ SW only (no HW AV1 on any Apple TV as of April 2026) |
+| VP9 | ❌ (AVFoundation) | ❌ (AVFoundation) |
+| HDR10 / HDR10+ / HLG | ✅ | ✅ |
+| Dolby Vision Profile 5, 8.1, 8.4 | ✅ | ✅ |
+| **Dolby Vision Profile 7** (UHD BD rips) | ❌ | ❌ |
+| E-AC-3 / Dolby Digital Plus | ✅ | ✅ |
+| Atmos (E-AC-3 JOC in HLS) | ✅ | ✅ |
+| **TrueHD / DTS / DTS-HD MA** | ❌ (no AVFoundation passthrough) | ❌ |
+| AAC, ALAC, FLAC, Opus | ✅ | ✅ |
+| WebVTT / CEA-608/708 subs | ✅ | ✅ |
+| **PGS / ASS / SSA subs** | ❌ (must server-encode/burn-in) | ❌ |
+
+For anything that falls off this matrix, let Jellyfin server-side transcode to HLS. Set a generous `MaxStreamingBitrate` in the DeviceProfile.
+
+### Reference DeviceProfile (for `POST /Items/{id}/PlaybackInfo`)
+
+Modeled on jellyfin-web's `isAppleTv()` branch and SwiftFin [PR #519](https://github.com/jellyfin/Swiftfin/pull/519):
+
+```json
+{
+ "Name": "JellyTV tvOS (Native)",
+ "MaxStreamingBitrate": 120000000,
+ "MaxStaticBitrate": 100000000,
+ "MusicStreamingTranscodingBitrate": 384000,
+ "DirectPlayProfiles": [
+ { "Container": "mp4,m4v,mov", "Type": "Video",
+ "VideoCodec": "h264,hevc",
+ "AudioCodec": "aac,ac3,eac3,mp3,alac,flac,opus" },
+ { "Container": "mp3,aac,m4a,flac,alac,wav,opus", "Type": "Audio" }
+ ],
+ "TranscodingProfiles": [
+ { "Container": "mp4", "Type": "Video", "Protocol": "hls",
+ "VideoCodec": "h264,hevc", "AudioCodec": "aac,ac3,eac3",
+ "Context": "Streaming", "MinSegments": 2, "BreakOnNonKeyFrames": true }
+ ],
+ "CodecProfiles": [
+ { "Type": "Video", "Codec": "h264",
+ "Conditions": [
+ { "Condition": "LessThanEqual", "Property": "VideoLevel", "Value": "52", "IsRequired": true },
+ { "Condition": "LessThanEqual", "Property": "VideoBitDepth", "Value": "8" }
+ ]},
+ { "Type": "Video", "Codec": "hevc",
+ "Conditions": [
+ { "Condition": "LessThanEqual", "Property": "VideoLevel", "Value": "153" },
+ { "Condition": "LessThanEqual", "Property": "VideoBitDepth", "Value": "10" }
+ ]}
+ ],
+ "SubtitleProfiles": [
+ { "Format": "vtt", "Method": "External" },
+ { "Format": "ttml", "Method": "External" },
+ { "Format": "srt", "Method": "External" },
+ { "Format": "cc_dec", "Method": "Embed" },
+ { "Format": "pgssub", "Method": "Encode" },
+ { "Format": "ass", "Method": "Encode" },
+ { "Format": "ssa", "Method": "Encode" }
+ ],
+ "ResponseProfiles": [
+ { "Type": "Video", "Container": "m4v", "MimeType": "video/mp4" }
+ ]
+}
+```
+
+### Playback reporting cadence
+
+- `POST /Sessions/Playing` on start
+- `POST /Sessions/Playing/Progress` every ~10s with `PositionTicks` (seconds × 10,000,000) and `IsPaused`
+- `POST /Sessions/Playing/Stopped` on end
+- Route an `actor` to serialize these so the main actor doesn't block
+
+---
+
+## 4. UI / Layout
+
+### The canonical tvOS 18 shelf+hero pattern
+
+From Apple's [tvOS media catalog SwiftUI sample](https://developer.apple.com/documentation/SwiftUI/Creating-a-tvOS-media-catalog-app-in-SwiftUI):
+
+```swift
+ScrollView(.vertical) {
+ LazyVStack(alignment: .leading, spacing: 60) {
+ HeroSection()
+ .onScrollVisibilityChange { belowFold = !$0 }
+ Shelf("Continue Watching", items: resume)
+ Shelf("Latest Movies", items: latest)
+ Shelf("Recommended", items: recs)
+ }
+ .scrollTargetLayout()
+}
+.scrollClipDisabled() // critical — focused posters clip without this
+.scrollTargetBehavior(.viewAligned) // snap shelves
+
+// each Shelf:
+VStack(alignment: .leading) {
+ Text(title).font(.title3)
+ ScrollView(.horizontal) {
+ LazyHStack(spacing: 40) {
+ ForEach(items) { item in
+ Button { open(item) } label: {
+ item.poster
+ .aspectRatio(2/3, contentMode: .fit)
+ .containerRelativeFrame(.horizontal, count: 6, spacing: 40)
+ Text(item.title)
+ }
+ }
+ }
+ }
+ .scrollClipDisabled()
+ .buttonStyle(.borderless) // free parallax/lift/shadow — do NOT roll your own
+}
+.focusSection() // critical — keeps up/down between shelves
+```
+
+### Focus engine rules
+
+- Use `.buttonStyle(.borderless)` (or `.card`) for focus effects. Do NOT hand-roll `scaleEffect` + `shadow`.
+- `.focusSection()` on every shelf row and nav cluster.
+- `@FocusState` + `.focused($id, equals:)` for programmatic focus.
+- `.prefersDefaultFocus($state, in: ns)` + `@Namespace` on the Play button in detail views.
+- `@FocusedValue(\.item)` to propagate the currently-focused item up the tree for the hero backdrop fade.
+- Stable IDs on all list/grid data so focus survives reloads.
+- Avoid `.onMoveCommand` — flaky on tvOS 18.0. Prefer focus sections.
+
+### Search
+
+- Use `.searchable(text: $query)` + `.searchSuggestions`. The tvOS system keyboard is good enough — do NOT build a custom one.
+- Landing state: `query.isEmpty` → show "Recent searches" + "Top results" LazyVGrid.
+
+### Detail page
+
+- Backdrop image `.background` with gradient mask
+- Metadata row + Play button with `.prefersDefaultFocus`
+- Episode picker (for series) as a horizontal `LazyHStack` of episode cards
+- Segmented picker: Episodes / Extras / More Like This / Cast
+
+---
+
+## 5. tvOS-specific gotchas
+
+1. **`NSLocalNetworkUsageDescription` required** in Info.plist (tvOS 17+). Add `NSBonjourServices` entries for Jellyfin's `_jellyfin._tcp` if we do Bonjour discovery. SwiftFin hits real first-run edge cases here ([#467](https://github.com/jellyfin/Swiftfin/issues/467)).
+2. **No PiP, no AirPlay-from-tvOS.** Both don't exist. Don't wire the UI. (`AVPictureInPictureController.isPictureInPictureSupported` returns false on tvOS.)
+3. **No background playback.** Don't try. tvOS suspends aggressively by design.
+4. **Caches directory is volatile.** System can evict at any time. Design poster cache with re-fetch as the normal path, not the error path.
+5. **4K HDR memory pressure.** 3–4 GB RAM. Clear Nuke cache before pushing the player; cap `AVPlayerItem.preferredPeakBitRate` and `preferredMaximumResolution` based on TV resolution.
+6. **Top Shelf extension is the single biggest engagement lever.** `TVTopShelfContentProvider` reading Continue Watching from a shared App Group container. App Group ID decided during scaffolding.
+7. **Siri Remote contract:** if you consume `.onExitCommand`, you MUST provide a visible way back. Never swallow on the root screen.
+8. **Game controller support:** skip it for v1. Near-zero user base for Jellyfin use cases.
+9. **`onPlayPauseCommand` only fires when the view is focused** — chain `.focusable(true)` on container views that listen for it.
+
+---
+
+## 6. Phased plan
+
+### Phase 0 — Scaffolding (day 1)
+
+The existing `Jelly TV/` project is a fresh tvOS SwiftUI scaffold. Keep the target but set it up properly.
+
+- [ ] Set deployment target to tvOS 18, Apple TV 4K only
+- [ ] Add SPM packages: `JellyfinAPI`, `DesignSystem`, `Library`, `Player`, `Settings`, `Persistence`
+- [ ] Add external deps: `Nuke`, `KeychainAccess`
+- [ ] Info.plist: `NSLocalNetworkUsageDescription`, `NSBonjourServices`
+- [ ] App Group entitlement (ID: `group.com..jellytv`) — reserved for Top Shelf in Phase 7
+- [ ] Set up an `.env`/config-free pattern for dev server URL
+- [ ] Basic CI (xcodebuild + unit test target) — optional but cheap
+
+### Phase 1 — Connect + sign in (vertical slice foundation)
+
+**Goal:** user can point the app at their LAN Jellyfin server and log in. Token persists across launches.
+
+- [ ] `JellyfinClient` actor skeleton (`URLSession`, async/await, Codable DTOs)
+- [ ] `MediaBrowserAuthorization` middleware builds the `Authorization: MediaBrowser …` header
+- [ ] Persist a `DeviceId` UUID in Keychain on first launch
+- [ ] `GET /System/Info/Public` — validate server reachability + version
+- [ ] `POST /Users/AuthenticateByName` — username/password flow
+- [ ] `GET /QuickConnect/Enabled` + `/Initiate` + `/Connect` polling + `AuthenticateWithQuickConnect` — Quick Connect flow (arguably easier than typing a password on a Siri Remote)
+- [ ] Store access token in Keychain keyed by server URL
+- [ ] `GET /Users/Me` — sanity check on app launch, auto-restore session
+- [ ] Simple Settings screen: server URL, signed-in user, sign out
+- [ ] **Must-not-regress test:** [#1862](https://github.com/jellyfin/Swiftfin/issues/1862) crash on Connect, [#1657](https://github.com/jellyfin/Swiftfin/pull/1657) persistent login
+
+### Phase 2 — Home screen (hero + shelves)
+
+**Goal:** signed-in user sees their libraries, Continue Watching, Next Up, Latest.
+
+- [ ] `GET /UserViews` — libraries
+- [ ] `GET /UserItems/Resume` — Continue Watching
+- [ ] `GET /Shows/NextUp` — Next Up
+- [ ] `GET /Items/Latest?parentId=…` — Latest per library
+- [ ] `DesignSystem`: `PosterCard`, `Shelf`, `HeroSection` primitives
+- [ ] Home view: `ScrollView` + `LazyVStack` + hero + shelves, with `.focusSection()` wiring
+- [ ] `@FocusedValue` → hero backdrop crossfade
+- [ ] Nuke `LazyImage` + downsampling helper for poster / backdrop images
+- [ ] Image URL builder from item's `ImageTags[.Primary]` + `?maxWidth=`
+- [ ] **Must-not-regress test:** [#1906](https://github.com/jellyfin/Swiftfin/issues/1906) Movies scroll crash
+
+### Phase 3 — Library browse + search + item detail
+
+**Goal:** user can browse a full library grid, search, open an item, see metadata.
+
+- [ ] `GET /Items?parentId=…&includeItemTypes=…&recursive=true&sortBy=…` with paging
+- [ ] Library grid view: `LazyVGrid`, poster cards, filters (genre, year, unplayed)
+- [ ] `.searchable` + `GET /Items?searchTerm=…` — search across libraries
+- [ ] `GET /Items/{id}?fields=Overview,Genres,People,Studios,Chapters,MediaSources`
+- [ ] Item detail view: backdrop, metadata, Play button (`.prefersDefaultFocus`), segmented tabs (Episodes/Extras/More Like This/Cast) — scope "Extras" and "More Like This" as nice-to-haves for this phase
+- [ ] For Series: `GET /Shows/{id}/Seasons` + `/Episodes?seasonId=…`, season/episode picker
+
+### Phase 4 — Playback (the hard part)
+
+**Goal:** user presses Play, the video plays with correct audio, subs, and progress reporting.
+
+- [ ] DeviceProfile builder matching §3 spec
+- [ ] `POST /Items/{id}/PlaybackInfo` with DeviceProfile → receive `MediaSources` with ready `TranscodingUrl` / DirectPlay URL
+- [ ] `PlayerHost`: `UIViewControllerRepresentable` wrapping `AVPlayerViewController`
+- [ ] Inject `AVPlayerItem.externalMetadata` (title, overview, artwork, year, genre)
+- [ ] Inject chapters via `AVPlayerItem.navigationMarkerGroups`
+- [ ] `PlaybackReporter` actor: start → progress (10s tick) → stopped, with `PositionTicks`
+- [ ] Mark watched on reaching 90% (`POST /UserPlayedItems/{itemId}`)
+- [ ] `MPNowPlayingInfoCenter` + `MPRemoteCommandCenter` wiring
+- [ ] Memory pressure: clear Nuke cache before pushing player
+- [ ] **Must-not-regress tests:** [#1755](https://github.com/jellyfin/Swiftfin/issues/1755) single audio track, [#780](https://github.com/jellyfin/Swiftfin/issues/780) external subs, [#1962](https://github.com/jellyfin/Swiftfin/issues/1962) touchpad FF/RW, [#1872](https://github.com/jellyfin/Swiftfin/issues/1872) no audio on resume
+
+### Phase 5 — Polish pass
+
+- [ ] Error states (network down, server unreachable, login failed, playback failed)
+- [ ] Empty states (no libraries, no results)
+- [ ] Loading skeletons for shelves
+- [ ] Pull-the-thread on focus bugs: scroll-past-focus on LazyHStack, focus loss on reload, clip edges
+- [ ] Settings: DeviceProfile override sliders (bitrate cap, force transcode)
+- [ ] Sign out / switch server
+
+### Phase 6 — Skip Intro / Up Next
+
+- [ ] `AVContentProposal` + `contentProposalViewController` subclass
+- [ ] Read Jellyfin's intro markers (if available — check `Chapters` for "Intro Start"/"Intro End" or the Intro Skipper plugin format)
+- [ ] Next episode auto-queue on end-of-episode for series
+
+### Phase 7 — Top Shelf extension
+
+- [ ] `TVTopShelfContentProvider` target
+- [ ] App Group container — main app writes Continue Watching JSON on every refresh
+- [ ] Extension reads from App Group, returns `TVTopShelfSectionedContent`
+- [ ] Deep link back into the app → jump to item detail / resume playback
+
+### Phase 8 — Remote access (post-v1)
+
+- [ ] HTTPS support (already works via `URLSession`; verify TLS cert handling, self-signed cert UX)
+- [ ] Jellyfin "Remote Access" server URL storage — separate from LAN URL, with fallback logic
+- [ ] Adaptive bitrate for slower connections (dynamic `MaxStreamingBitrate`)
+- [ ] Reachability detection to pick LAN vs remote URL automatically
+- [ ] Potentially: UDP broadcast discovery via `Shared/ServerDiscovery/` pattern from SwiftFin
+
+### Deferred (not in v1, probably not v1.1)
+
+- Multi-user / multi-server
+- Offline downloads (tvOS's volatile caches directory + no real persistent storage makes this painful)
+- Live TV (needs its own DeviceProfile work and SwiftFin's [#1948](https://github.com/jellyfin/Swiftfin/issues/1948) WAN bug to learn from)
+- VLCKit / MPVKit fallback player — only if real users have Profile 7 DV / PGS / TrueHD / DTS libraries
+- Game controller support
+- visionOS target
+
+---
+
+## 7. "Must not regress" acceptance checklist
+
+Every one of these is a real SwiftFin tvOS bug on the v1.0.1 App Store build. v1 of JellyTV ships when all are green on a real Apple TV 4K against a real Jellyfin server:
+
+- [ ] No crash on Connect / sign in
+- [ ] No crash / timeout scrolling a large Movies library
+- [ ] Only one audio track plays at a time
+- [ ] External SRT / VTT subtitles render correctly
+- [ ] Touchpad FF / RW works during playback
+- [ ] Persistent login — app relaunches into the last session
+- [ ] No audio dropouts on resume
+- [ ] DeviceProfile causes DirectPlay when possible, HLS transcode otherwise
+- [ ] Progress reporting shows up on server's "Now Playing" immediately
+- [ ] Continue Watching surfaces the thing you were just watching within 10s
+- [ ] Focus survives content reload in all grids/shelves
+- [ ] `.onExitCommand` always returns to a sensible place
+
+---
+
+## 8. Reading list (do this before writing code)
+
+### Apple sources
+- [Apple sample: "Creating a tvOS media catalog app in SwiftUI"](https://developer.apple.com/documentation/SwiftUI/Creating-a-tvOS-media-catalog-app-in-SwiftUI) — *the* layout pattern
+- [Apple sample: "Destination Video"](https://developer.apple.com/documentation/visionOS/destination-video) — `PlayerView` / `PlayerModel` wrapping `AVPlayerViewController` with `externalMetadata`
+- [WWDC24 10207 "Migrate your TVML app to SwiftUI"](https://developer.apple.com/videos/play/wwdc2024/10207/) — best 2024 tvOS layout session
+- [WWDC24 10144 "What's new in SwiftUI"](https://developer.apple.com/videos/play/wwdc2024/10144/) — new Tab/TabView syntax, sidebar
+- [WWDC23 10162 "The SwiftUI cookbook for focus"](https://developer.apple.com/videos/play/wwdc2023/10162/) — canonical focus reference
+- [WWDC21 10191 "Deliver a great playback experience on tvOS"](https://developer.apple.com/videos/play/wwdc2021/10191/) — `externalMetadata`, content proposals, transport bar items
+- [Apple HDR + Dolby Vision PDF](https://developer.apple.com/av-foundation/Incorporating-HDR-video-with-Dolby-Vision-into-your-apps.pdf)
+- [TVTopShelfContentProvider docs](https://developer.apple.com/documentation/tvservices/tvtopshelfcontentprovider)
+
+### Jellyfin sources
+- [Jellyfin codec support matrix](https://jellyfin.org/docs/general/clients/codec-support/)
+- [Jellyfin OpenAPI spec](https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json)
+- [nielsvanvelzen Jellyfin Authorization gist](https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f) — canonical auth header + deprecations
+- [jellyfin-web DeviceProfile reference](https://github.com/jellyfin/jellyfin-web/blob/master/src/scripts/browserDeviceProfile.js) — `isAppleTv()` branch
+- [Jellyfin Quick Connect docs](https://jellyfin.org/docs/general/server/quick-connect/)
+- [jmshrv.com "The Jellyfin API"](https://jmshrv.com/posts/jellyfin-api/) — practical API walkthrough
+
+### SwiftFin (reference only, do not fork)
+- [jellyfin/Swiftfin](https://github.com/jellyfin/Swiftfin)
+- [Discussion #1294 — tvOS status](https://github.com/jellyfin/Swiftfin/discussions/1294)
+- [PR #519 — DeviceProfile revamp](https://github.com/jellyfin/Swiftfin/pull/519)
+- [PR #1902 — tvOS Media Player rewrite](https://github.com/jellyfin/Swiftfin/pull/1902) (the direction we're NOT taking)
+
+### Community
+- [jellyfin/jellyfin-sdk-swift](https://github.com/jellyfin/jellyfin-sdk-swift) — live API reference
+- [Showmax: "Our experience with SwiftUI on tvOS"](https://showmax.engineering/articles/our-experience-with-swiftui-on-tvos) — candid rough edges
+- [streamyfin/streamyfin](https://github.com/streamyfin/streamyfin) — RN/MPVKit, no tvOS yet ([#137](https://github.com/streamyfin/streamyfin/issues/137))
+- [kean/Nuke](https://github.com/kean/Nuke)
+- [kishikawakatsumi/KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess)
+
+---
+
+## 9. Open questions / risks
+
+- **Server Jellyfin version matrix.** We should test against Jellyfin 10.9 and 10.11 both. 10.11 is where [#1906](https://github.com/jellyfin/Swiftfin/issues/1906) surfaces on SwiftFin.
+- **Quick Connect vs username/password.** Quick Connect is dramatically better UX on tvOS (no Siri Remote typing). Should be the default sign-in path with password as fallback. Confirm the server has it enabled via `GET /QuickConnect/Enabled`.
+- **Intro Skipper plugin format.** Jellyfin has a community "Intro Skipper" plugin that exposes intro chapters. Format needs verification before Phase 6.
+- **Bonjour discovery vs manual URL entry.** SwiftFin's `Shared/ServerDiscovery/` does UDP broadcast. On LAN this is nicer UX but adds complexity and a known Bonjour failure mode ([SwiftFin #467](https://github.com/jellyfin/Swiftfin/issues/467)). Start with manual URL; add Bonjour in polish pass.
+- **Chapter thumbnails.** Jellyfin exposes `Chapters[].ImageTag` for chapter images but you have to build the URL yourself. Worth the polish for the scrubber thumbnail preview.
+- **App Store review.** No blockers foreseen, but first submission should budget a week of review churn.
+
+---
+
+## 10. Next step
+
+Drop into `/motif:dev build a tvOS Jellyfin client — Phase 0 and Phase 1` to run the formal Plan stage on the first executable chunk, with tradeoff analysis and an approval gate, before any code gets written. This doc becomes the research artifact the Plan stage cites from.
diff --git a/livewall/.gitignore b/livewall/.gitignore
new file mode 100644
index 0000000..37f7782
--- /dev/null
+++ b/livewall/.gitignore
@@ -0,0 +1,46 @@
+# Secrets
+.env
+
+# Xcode
+## User settings
+xcuserdata/
+*.xcuserstate
+*.xcuserdatad/
+
+## Build products
+build/
+DerivedData/
+*.xcscmblueprint
+*.xccheckout
+
+## Xcode patches
+*.moved-aside
+
+## Swift Package Manager
+.swiftpm/
+.build/
+Package.resolved
+
+## CocoaPods
+Pods/
+
+## Carthage
+Carthage/Build/
+
+# macOS
+.DS_Store
+.AppleDouble
+.LSOverride
+Icon
+
+# Misc
+*.swp
+*.swo
+*~
+*.log
+
+# Motif workflow state
+.motif/
+
+# Claude
+.claude/
diff --git a/livewall/CLAUDE.md b/livewall/CLAUDE.md
new file mode 100644
index 0000000..15bddeb
--- /dev/null
+++ b/livewall/CLAUDE.md
@@ -0,0 +1,122 @@
+# CLAUDE.md
+
+Guidance for Claude Code working on the livewall repo.
+
+## What this is
+
+Native macOS live wallpaper app for **macOS 26 (Tahoe)**. SwiftUI + AVFoundation + Liquid Glass. Plays muted looping videos at `desktopWindow + 1` level across multiple displays. Single-user app, no backend, no accounts. See `README.md` for the feature list.
+
+## Build
+
+```bash
+xcodebuild -scheme livewall -configuration Debug build
+```
+
+Scheme: `livewall`. Target: macOS 26.2. No tests, no linter configured.
+
+For a clean build (recommended when changing `@MainActor` annotations or adding new source files):
+
+```bash
+xcodebuild -scheme livewall -configuration Debug clean build
+```
+
+Filter logs:
+
+```bash
+log stream --predicate 'subsystem == "com.cursorkittens.livewall"' --level debug
+```
+
+## Development workflow
+
+The user works through the **motif workflow** (`/motif:dev `) for non-trivial changes. The pattern is Research → Plan → (2 or 3 parallel Critics for medium/heavy) → Build → Validate. The plan is the single approval gate; the user is often in `--auto` mode so the plan auto-approves and the full cycle runs hands-off. **Respect the plan gate anyway** — write a real plan, address critic findings explicitly, and don't skip Validate.
+
+For small, clearly-scoped fixes (one file, one bug, no design question), you can skip the workflow and just edit directly.
+
+## Architecture cheat sheet
+
+### Scenes (`livewallApp.swift`)
+- `WindowGroup(id: "main")` → `GalleryView()`
+- `WindowGroup(id: "settings")` → `SettingsView()` — **not** `Settings {}` scene (that would break `openWindow(id: "settings")` callers in the gallery and menu bar)
+- `WindowGroup(id: "import")` → `ImportView()`
+- `MenuBarExtra(.window)` → `MenuBarExtraView()`
+- `.appErrorAlert()` is attached once per `WindowGroup` at the scene root
+
+### Services (singletons, all under `Services/`)
+- `WallpaperEngine` — owns per-display `WallpaperWindow`s, playback control, battery monitoring, stale-file check (`ensureAvailable`)
+- `WallpaperWindow` — NSWindow subclass at `desktop+1` level; contains `WallpaperPlayerView` (NSView with AVPlayerLayer)
+- `WallpaperCatalog` — loads `Resources/catalog.json` + `~/Library/Application Support/livewall/imported/` wallpapers; computes `staleLocalWallpaperIDs`; owns `addLocalWallpaper` and `removeLocalWallpaper`
+- `DownloadManager` — **`@MainActor`** — downloads catalog wallpapers to `~/Library/Application Support/livewall/wallpapers/`, publishes per-wallpaper `DownloadState`
+- `DisplayManager` — wraps `NSScreen.screens`, builds `DisplayInfo` from `CGDirectDisplayID`
+- `SettingsManager` — `@Published` UserDefaults-backed properties; owns `launchAtLogin` via `SMAppService.mainApp`
+- `ThumbnailGenerator` — `@Observable`, generates thumbnails for imported videos using `AVAssetImageGenerator.image(at:)` (async)
+- `VideoPreviewPool` — **`@MainActor`** — caps concurrent `AVPlayer`s at 2, LRU eviction, keyed by wallpaper ID
+- `AppLogger` — `os.Logger` namespace (categories: `app`, `engine`, `catalog`, `download`, `playback`, `thumbnail`, `settings`)
+- `AppErrorPresenter` — **`@MainActor`** — `@Published var currentError: AppError?` with `title + message` dedup; exposes `nonisolated static func report(title:message:recoverySuggestion:)` for call sites in any context
+
+### Views
+- `GalleryView` — `@ObservedObject` on catalog/engine/downloadManager; `LazyVStack` with a **non-pinned** "Now Playing" section above a **pinned** tag-strip section header; `.searchable(.toolbar)`; drag-drop via `.onDrop`; welcome sheet gated by `@AppStorage("hasSeenWelcome")`
+- `WallpaperCardView` — takes `wallpaper`, `isActive`, `isStale: Bool = false`, `onTap`; hover preview via `VideoPreviewPlayer` only for cached/local files; `isActive` is passed in (cards don't observe engine directly)
+- `WallpaperDetailView` — inline `VideoPreviewPlayer` for cached/local files; static hero with "unlocks after Apply" hint otherwise; per-display Apply/Remove buttons react to `DownloadManager` state; when `isStale`, shows "Remove Missing Entry" instead of Apply
+- `VideoPreviewPlayer` — `NSViewRepresentable` wrapping `AVPlayerLayer`; `Coordinator` stashes `wallpaperID` so `dismantleNSView` can release from the pool
+
+## Conventions and gotchas
+
+### Liquid Glass rules
+- `.glassEffect(.regular, in: shape)` is for **chrome and controls**: toolbar, tag chips, badges, buttons, row backgrounds.
+- **Do not** apply glass to wallpaper cards or other content surfaces — reduces contrast and fights Apple's HIG.
+- `GlassEffectContainer` + `glassEffectID` morphing is only for small sibling groups (2–5 items), **never** wrap a `LazyVGrid` with it.
+- Buttons use `.buttonStyle(.glass)` for secondary and `.buttonStyle(.glassProminent)` for primary CTAs.
+- `ToolbarSpacer(.flexible)` separates toolbar groups if needed.
+
+### Reactive state
+- Singletons (`WallpaperEngine.shared`, etc.) that are `ObservableObject` must be referenced via `@ObservedObject`, **never** `@StateObject`. `@StateObject` with an existing instance creates a shadow copy that silently diverges from `.shared`.
+- Views that need reactive updates from multiple services declare one `@ObservedObject` per service.
+- Cards in a lazy grid should not observe services directly — pass the narrow piece of state they need (like `isActive: Bool`) from a parent that does.
+
+### `os.Logger` privacy interpolation
+Every file that uses `AppLogger..error("\(value, privacy: .public)")` must `import os` at the top, **in addition to** the existing `AppLogger` enum. The privacy string-interpolation machinery lives in the `os` module. Forgetting this causes a dozen confusing "method is not available due to missing import" errors.
+
+### `AppErrorPresenter.report` naming
+Do not name a free function `presentError` — it shadows `NSResponder.presentError(_:)` inside any NSView/NSWindow subclass. Use the static `AppErrorPresenter.report(title:message:recoverySuggestion:)` instead. It's `nonisolated` with an internal `Task { @MainActor in }` hop, so it's safe to call from any context including KVO callbacks on arbitrary queues.
+
+### AVPlayer lifecycle
+- `NSKeyValueObservation` tokens from `AVPlayerItem.observe(\.status, ...)` **must** be stored on a property or they deallocate immediately and never fire. See `WallpaperPlayerView.statusObservation`.
+- `AVPlayer`, `AVAsset`, and `AVPlayerLayer` must be configured on the main thread. `VideoPreviewPool` is `@MainActor` to enforce this.
+- Use `AVURLAsset(url:)`, not `AVAsset(url:)` — the latter is deprecated in macOS 15+. For duration/natural-size, use the async `load(.naturalSize)` / `load(.duration)` APIs.
+
+### Stale-file handling
+- `WallpaperCatalog.staleLocalWallpaperIDs: Set` is the source of truth.
+- `GalleryView` passes `isStale:` down to each `WallpaperCardView`.
+- `WallpaperEngine.ensureAvailable(_:)` runs at the top of `apply(_:scope:)` and at the top of the public `setWallpaper(_:forDisplay:)`. The internal `setWallpaperInternal` hot path skips the check. This guarantees "Apply to All" surfaces exactly **one** error dialog even across many displays.
+
+### `@MainActor` and service boundaries
+- `DownloadManager`, `AppErrorPresenter`, and `VideoPreviewPool` are `@MainActor`.
+- `WallpaperEngine`, `WallpaperCatalog`, `WallpaperWindow`, `ThumbnailGenerator`, `SettingsManager` are plain classes.
+- When a plain-class service needs to surface an error to the user, it calls `AppErrorPresenter.report(...)` which handles the hop internally.
+- Do **not** call `AppErrorPresenter.shared.present(...)` directly from a non-main context.
+
+### Not a git repo → now is
+The repo was originally not under git. Initial commit is `bf7d244`. Remote: `origin → https://github.com/zackbart/livewall.git`. The `.gitignore` excludes Xcode user state, build products, `.motif/` workflow state, and `.claude/`.
+
+### Deliberately NOT doing
+- No lock-screen wallpaper feature (requires a separate macOS 26 API surface)
+- No AI/semantic search (infrastructure project)
+- No AVPlayer stall detection (`timeControlStatus`/`isPlaybackLikelyToKeepUp`) — only explicit `.failed` status is surfaced
+- No `URLSessionDownloadDelegate` for real download progress — current indicator is indeterminate
+- No crash reporter / telemetry / analytics
+- No curated gallery expansion (content pipeline is a separate concern)
+- No `Settings {}` scene conversion (would break `openWindow(id: "settings")`)
+- No `NavigationStack` in the gallery (feels wrong on macOS — we use a `@State selectedWallpaper` swap instead)
+- No red-alarm UI for stale cards (subtle grayscale + orange pill)
+- No automated tests (no test target exists)
+
+## When adding new features
+
+1. If it's a bug fix or single-file change, edit directly.
+2. If it's a feature or touches more than 2 files, use `/motif:dev `.
+3. Run `xcodebuild -scheme livewall -configuration Debug build` after changes. It's fast; use it liberally as a checkpoint.
+4. New services that need to log: add a new category to `AppLogger`.
+5. New services that can fail user-visibly: call `AppErrorPresenter.report(...)`.
+6. New NSView/NSWindow subclasses that need error surfacing: same — but remember the `NSResponder.presentError` name clash.
+7. New `ObservableObject` singletons: views observe them with `@ObservedObject`, never `@StateObject`.
+8. If you add a new file that uses `AppLogger..("\(value, privacy: .public)")`, `import os` at the top of the file.
diff --git a/livewall/README.md b/livewall/README.md
new file mode 100644
index 0000000..1e8116c
--- /dev/null
+++ b/livewall/README.md
@@ -0,0 +1,96 @@
+# livewall
+
+A native macOS live wallpaper app for macOS 26 (Tahoe), built with SwiftUI + AVFoundation and Apple's Liquid Glass design system.
+
+Play muted, looping video wallpapers behind your desktop across any number of displays. Import your own MP4/MOV files, browse a curated catalog, or drop videos onto the window. Previews play live on hover and in the detail view. Designed to be lightweight and battery-aware.
+
+## Features
+
+- **Multi-display playback** — set a different wallpaper on each connected display, or apply one to all at once. Windows sit at `desktopWindow + 1` level and stay behind everything else.
+- **Video preview on card hover** — cached wallpapers play a muted preview when you hover. Pooled to cap concurrent players, debounced to avoid thrash.
+- **Live preview in detail view** — the full detail pane plays the wallpaper inline so you can see exactly what you're about to apply.
+- **Drag-and-drop import** — drop any MP4 or MOV onto the gallery window. A glass drop overlay confirms the target.
+- **"Now Playing" section** — shows what's currently set on each display, with per-display stop buttons and a Pause All/Resume toggle.
+- **Liquid Glass chrome** — toolbar, tag filter, badges, buttons, and row backgrounds use macOS 26's `.glassEffect` + `.buttonStyle(.glass)`. Cards themselves stay opaque per Apple HIG for content legibility.
+- **First-launch welcome sheet** — brief orientation and a one-click path into the gallery.
+- **Menu bar extra** — quick pause/resume, active wallpaper list, and shortcuts to Import, Settings, and quit.
+- **Battery-aware auto-pause** — automatically pauses playback when you unplug or enable Low Power Mode (configurable in Settings). Polls every 30 seconds + observes `NSProcessInfoPowerStateDidChange` and `NSWorkspace.didWakeNotification`.
+- **Stale file detection** — imported wallpapers whose files have been moved or deleted are shown with a subtle "Missing" badge; the detail view offers a one-click "Remove Missing Entry".
+- **Centralized error surfacing** — one `AppErrorPresenter` attached once per window. Services log via `os.Logger` (subsystem `com.cursorkittens.livewall`) and surface user-visible errors through a single deduped alert path.
+
+## Requirements
+
+- **macOS 26.0 (Tahoe) or later** — the app uses Liquid Glass APIs (`glassEffect`, `buttonStyle(.glass)`, etc.) that are only available on macOS 26.
+- **Xcode 17** or later (for the macOS 26 SDK).
+- Apple Silicon or Intel Mac.
+
+## Building
+
+Clone the repo and open in Xcode, or build from the command line:
+
+```bash
+git clone https://github.com/zackbart/livewall.git
+cd livewall
+xcodebuild -scheme livewall -configuration Debug build
+```
+
+To run:
+
+1. Open `livewall.xcodeproj` in Xcode 17+.
+2. Select the `livewall` scheme.
+3. ⌘R.
+
+## Project structure
+
+```
+livewall/
+├── livewallApp.swift # App entry — 3 WindowGroup scenes + MenuBarExtra
+├── Models/
+│ └── Wallpaper.swift # Wallpaper model + catalog/local variants
+├── Services/ # Singleton services (@MainActor where needed)
+│ ├── AppLogger.swift # os.Logger namespace (engine, catalog, playback, ...)
+│ ├── AppErrorPresenter.swift # Shared error state + .appErrorAlert() modifier
+│ ├── WallpaperEngine.swift # Per-display NSWindow lifecycle, battery monitor
+│ ├── WallpaperWindow.swift # NSWindow at desktop+1 level, AVPlayerLayer playback
+│ ├── WallpaperCatalog.swift # catalog.json + local imports + stale-file detection
+│ ├── DownloadManager.swift # Catalog wallpaper downloads (with state publishing)
+│ ├── DisplayManager.swift # NSScreen enumeration
+│ ├── SettingsManager.swift # UserDefaults-backed settings (@Published)
+│ ├── ThumbnailGenerator.swift # AVAssetImageGenerator thumbnails for local imports
+│ └── VideoPreviewPool.swift # Pooled AVPlayer instances for hover previews (cap: 2)
+├── Views/
+│ ├── GalleryView.swift # Main browser + toolbar + Now Playing + drag-drop
+│ ├── WallpaperCardView.swift # Grid card with hover preview + stale badge
+│ ├── WallpaperDetailView.swift # Full detail + live preview + apply/remove
+│ ├── SettingsView.swift # TabView: General / Displays / About
+│ ├── MenuBarExtraView.swift # Menu bar popover
+│ ├── ImportView.swift # File picker import flow
+│ ├── WelcomeSheet.swift # First-launch welcome
+│ └── VideoPreviewPlayer.swift # NSViewRepresentable around AVPlayerLayer + pool
+└── Resources/
+ └── catalog.json # Bundled seed catalog (optional)
+```
+
+## Architecture notes
+
+- **Scenes**: three `WindowGroup`s (`main`, `settings`, `import`) plus a `MenuBarExtra(.window)`. Settings is a regular `WindowGroup` (not the `Settings {}` scene) so `openWindow(id: "settings")` continues to work from the menu bar and gallery toolbar.
+- **Services as singletons**: `WallpaperEngine.shared`, `WallpaperCatalog.shared`, `DownloadManager.shared`, `SettingsManager.shared`, `ThumbnailGenerator.shared`, `VideoPreviewPool.shared`. Views observe with `@ObservedObject`, **not** `@StateObject` — `@StateObject` with a singleton silently creates a shadow copy.
+- **Display detection of active wallpapers** is scoped: `GalleryView` computes `activeWallpaperIDs: Set` once and passes it to each card as a plain `Bool`, so card re-renders are localized when the engine updates.
+- **Hover video previews** only play for files that already exist on disk (local imports or cached downloads). Remote URLs never auto-stream on hover. The pool caps at 2 concurrent players.
+- **Error handling**: services call `AppErrorPresenter.report(title:message:recoverySuggestion:)`, a `nonisolated static` method that handles the main-actor hop internally. One `.appErrorAlert()` modifier is attached per WindowGroup at the scene root in `livewallApp.swift` — never per-view. The presenter dedupes by `title + message` so simultaneous failures from multiple displays don't stomp each other.
+- **Catalog stale detection**: `WallpaperCatalog.loadLocalWallpapers()` flags any imported wallpaper whose backing file is missing. Cards show a subtle grayscale + "Missing" badge. `WallpaperEngine.apply(_:scope:)` also guards with `ensureAvailable(_:)` so a missing file surfaces a single alert regardless of how many displays were targeted.
+- **Battery monitoring**: `WallpaperEngine.setupBatteryMonitoring()` combines `NSWorkspace.didWakeNotification`, `NSProcessInfoPowerStateDidChange`, and a 30-second polling `Timer` to catch plug/unplug events between notifications. Auto-pause is gated on `SettingsManager.shared.pauseOnBattery`.
+
+## Logs
+
+Filter livewall's logs in Console.app by **Subsystem: `com.cursorkittens.livewall`**, or from the terminal:
+
+```bash
+log stream --predicate 'subsystem == "com.cursorkittens.livewall"' --level debug
+```
+
+Categories: `app`, `engine`, `catalog`, `download`, `playback`, `thumbnail`, `settings`.
+
+## License
+
+Not yet specified — all rights reserved for now.
diff --git a/livewall/Tools/README.md b/livewall/Tools/README.md
new file mode 100644
index 0000000..8c0375f
--- /dev/null
+++ b/livewall/Tools/README.md
@@ -0,0 +1,38 @@
+# Tools
+
+Standalone scripts for livewall maintenance. Run from the repo root.
+
+## seed-catalog.swift
+
+Fetches a few hundred videos from Pexels and writes them as a livewall
+seed catalog.
+
+```sh
+PEXELS_API_KEY=your_key_here swift Tools/seed-catalog.swift
+```
+
+Get a free Pexels API key at . The free tier
+allows 200 requests per hour; this script makes 8 requests per run (one per
+curated query term).
+
+Output goes to `livewall/Resources/catalog.generated.json`. The existing
+`catalog.json` is **not** overwritten — review the generated file and rename
+it manually if you want to replace the bundled seed catalog. This avoids
+breaking any wallpaper IDs that users have already applied (those IDs are
+persisted in UserDefaults).
+
+The script:
+
+- Hits `https://api.pexels.com/videos/search` for a curated query list
+ (`nature`, `ocean`, `space`, `abstract`, `city skyline`, `forest`,
+ `aurora`, `underwater`).
+- Picks the highest-quality MP4 file under 4K for each video.
+- Maps Pexels metadata to the livewall `Wallpaper` JSON schema:
+ `id`, `title`, `thumbnailURL`, `videoURL`, `resolution`, `tags`,
+ `source`, `duration`.
+- Deduplicates by Pexels video ID across queries.
+- Writes atomically (temp file + rename) so a partial run won't corrupt the
+ output.
+
+To change the query list or pull more results per query, edit the
+`queries` and `resultsPerQuery` constants near the top of the script.
diff --git a/livewall/Tools/seed-catalog.swift b/livewall/Tools/seed-catalog.swift
new file mode 100644
index 0000000..a969625
--- /dev/null
+++ b/livewall/Tools/seed-catalog.swift
@@ -0,0 +1,253 @@
+#!/usr/bin/env swift
+//
+// seed-catalog.swift
+//
+// Generate a Pexels-backed seed catalog for livewall.
+//
+// Usage:
+// PEXELS_API_KEY=your_key_here swift Tools/seed-catalog.swift
+//
+// Reads PEXELS_API_KEY from the environment, hits the Pexels Videos API
+// across a small curated set of search terms, deduplicates by video ID, and
+// writes the result to livewall/Resources/catalog.generated.json in the
+// schema consumed by `WallpaperCatalog.loadSeedCatalog`.
+//
+// The generated file is intentionally NOT written to catalog.json directly —
+// the existing catalog.json contains hand-curated entries that may already be
+// referenced by users' applied wallpapers (persisted by ID in UserDefaults).
+// Review the generated file and rename it manually if you want to replace
+// the bundled seed catalog.
+//
+// Pexels free tier: 200 requests/hour. This script makes one request per
+// query term (8 total) and is well under that limit.
+//
+
+import Foundation
+
+// MARK: - Configuration
+
+let queries = [
+ "nature",
+ "ocean",
+ "space",
+ "abstract",
+ "city skyline",
+ "forest",
+ "aurora",
+ "underwater"
+]
+let resultsPerQuery = 40
+let throttleBetweenQueries: TimeInterval = 0.3
+
+let outputPath = "livewall/Resources/catalog.generated.json"
+
+// MARK: - Argument / env handling
+
+guard let apiKey = ProcessInfo.processInfo.environment["PEXELS_API_KEY"], !apiKey.isEmpty else {
+ FileHandle.standardError.write(Data("Error: PEXELS_API_KEY environment variable is not set.\n\nUsage:\n PEXELS_API_KEY=your_key swift Tools/seed-catalog.swift\n\nGet a free API key at https://www.pexels.com/api/\n".utf8))
+ exit(1)
+}
+
+// MARK: - Pexels response models
+
+struct PexelsResponse: Decodable {
+ let videos: [PexelsVideo]
+}
+
+struct PexelsVideo: Decodable {
+ let id: Int
+ let width: Int
+ let height: Int
+ let duration: Int
+ let url: String
+ let image: String
+ let user: PexelsUser
+ let videoFiles: [PexelsVideoFile]
+
+ enum CodingKeys: String, CodingKey {
+ case id, width, height, duration, url, image, user
+ case videoFiles = "video_files"
+ }
+}
+
+struct PexelsUser: Decodable {
+ let name: String
+}
+
+struct PexelsVideoFile: Decodable {
+ let id: Int
+ let quality: String
+ let fileType: String
+ let width: Int?
+ let height: Int?
+ let link: String
+
+ enum CodingKeys: String, CodingKey {
+ case id, quality, link
+ case fileType = "file_type"
+ case width, height
+ }
+}
+
+// MARK: - livewall catalog models (mirrors livewall/Models/Wallpaper.swift)
+
+struct CatalogWallpaper: Encodable {
+ let id: String
+ let title: String
+ let thumbnailURL: String
+ let videoURL: String
+ let resolution: String
+ let tags: [String]
+ let source: String
+ let duration: Double
+}
+
+struct CatalogFile: Encodable {
+ let wallpapers: [CatalogWallpaper]
+}
+
+// MARK: - Sync HTTP helper
+
+func fetchJSON(url: URL, headers: [String: String]) -> Data? {
+ var request = URLRequest(url: url)
+ for (k, v) in headers { request.setValue(v, forHTTPHeaderField: k) }
+
+ let semaphore = DispatchSemaphore(value: 0)
+ var resultData: Data?
+ var resultError: Error?
+
+ URLSession.shared.dataTask(with: request) { data, response, error in
+ defer { semaphore.signal() }
+ if let error {
+ resultError = error
+ return
+ }
+ if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
+ FileHandle.standardError.write(Data("HTTP \(http.statusCode) for \(url.absoluteString)\n".utf8))
+ return
+ }
+ resultData = data
+ }.resume()
+
+ semaphore.wait()
+ if let resultError {
+ FileHandle.standardError.write(Data("Network error: \(resultError.localizedDescription)\n".utf8))
+ return nil
+ }
+ return resultData
+}
+
+// MARK: - Mapping helpers
+
+/// Pick the highest-quality MP4 file under or equal to 4K, preferring "hd".
+func bestVideoFile(from files: [PexelsVideoFile]) -> PexelsVideoFile? {
+ let mp4 = files.filter { $0.fileType.contains("mp4") }
+ if mp4.isEmpty { return nil }
+ // Sort by area descending, capped at 4K (3840x2160 = 8_294_400 px).
+ return mp4
+ .filter { ($0.width ?? 0) <= 3840 && ($0.height ?? 0) <= 2160 }
+ .max { lhs, rhs in
+ let la = (lhs.width ?? 0) * (lhs.height ?? 0)
+ let ra = (rhs.width ?? 0) * (rhs.height ?? 0)
+ return la < ra
+ } ?? mp4.first
+}
+
+func mapResolution(width: Int?, height: Int?) -> String {
+ guard let w = width, let h = height else { return "unknown" }
+ if w >= 3840 || h >= 2160 { return "3840x2160" }
+ if w >= 2560 || h >= 1440 { return "2560x1440" }
+ if w >= 1920 || h >= 1080 { return "1920x1080" }
+ return "unknown"
+}
+
+// MARK: - Fetch loop
+
+var seenIDs = Set()
+var collected: [CatalogWallpaper] = []
+
+print("Fetching from Pexels across \(queries.count) queries...")
+
+for (index, query) in queries.enumerated() {
+ let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
+ guard let url = URL(string: "https://api.pexels.com/videos/search?query=\(escaped)&per_page=\(resultsPerQuery)&page=1") else {
+ continue
+ }
+
+ print(" [\(index + 1)/\(queries.count)] \"\(query)\"...", terminator: "")
+ fflush(stdout)
+
+ guard let data = fetchJSON(url: url, headers: ["Authorization": apiKey]) else {
+ print(" failed")
+ continue
+ }
+
+ do {
+ let response = try JSONDecoder().decode(PexelsResponse.self, from: data)
+ var added = 0
+ for video in response.videos where !seenIDs.contains(video.id) {
+ seenIDs.insert(video.id)
+ guard let file = bestVideoFile(from: video.videoFiles) else { continue }
+ let resolution = mapResolution(width: file.width, height: file.height)
+ let wallpaper = CatalogWallpaper(
+ id: "pexels-\(video.id)",
+ title: "\(video.user.name) — \(query.capitalized)",
+ thumbnailURL: video.image,
+ videoURL: file.link,
+ resolution: resolution,
+ tags: [query, "video"],
+ source: "catalog",
+ duration: Double(video.duration)
+ )
+ collected.append(wallpaper)
+ added += 1
+ }
+ print(" +\(added) (total \(collected.count))")
+ } catch {
+ print(" parse error: \(error.localizedDescription)")
+ }
+
+ if index < queries.count - 1 {
+ Thread.sleep(forTimeInterval: throttleBetweenQueries)
+ }
+}
+
+// MARK: - Write output (atomic)
+
+let catalog = CatalogFile(wallpapers: collected)
+let encoder = JSONEncoder()
+encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+let outputData: Data
+do {
+ outputData = try encoder.encode(catalog)
+} catch {
+ FileHandle.standardError.write(Data("Failed to encode catalog: \(error.localizedDescription)\n".utf8))
+ exit(2)
+}
+
+let cwd = FileManager.default.currentDirectoryPath
+let outputURL = URL(fileURLWithPath: cwd).appendingPathComponent(outputPath)
+let parentDir = outputURL.deletingLastPathComponent()
+
+do {
+ try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true)
+} catch {
+ FileHandle.standardError.write(Data("Failed to create output directory: \(error.localizedDescription)\n".utf8))
+ exit(3)
+}
+
+let tempURL = parentDir.appendingPathComponent(".catalog.generated.json.tmp")
+do {
+ try outputData.write(to: tempURL)
+ if FileManager.default.fileExists(atPath: outputURL.path) {
+ try FileManager.default.removeItem(at: outputURL)
+ }
+ try FileManager.default.moveItem(at: tempURL, to: outputURL)
+} catch {
+ FileHandle.standardError.write(Data("Failed to write output: \(error.localizedDescription)\n".utf8))
+ exit(4)
+}
+
+print("\nFetched \(collected.count) videos across \(queries.count) queries.")
+print("Wrote to \(outputPath)")
+print("Review and rename to catalog.json to apply.")
diff --git a/livewall/livewall.xcodeproj/project.pbxproj b/livewall/livewall.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..3b9e130
--- /dev/null
+++ b/livewall/livewall.xcodeproj/project.pbxproj
@@ -0,0 +1,455 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXFileReference section */
+ D8FCFA592F7C155C002C5DA4 /* livewall.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = livewall.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ FF000000000000000000A005 /* livewallTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = livewallTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ D8FCFA5B2F7C155C002C5DA4 /* livewall */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = livewall;
+ sourceTree = "";
+ };
+ FF000000000000000000A001 /* livewallTests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = livewallTests;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ D8FCFA562F7C155C002C5DA4 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ FF000000000000000000A004 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ D8FCFA502F7C155C002C5DA4 = {
+ isa = PBXGroup;
+ children = (
+ D8FCFA5B2F7C155C002C5DA4 /* livewall */,
+ FF000000000000000000A001 /* livewallTests */,
+ D8FCFA5A2F7C155C002C5DA4 /* Products */,
+ );
+ sourceTree = "";
+ };
+ D8FCFA5A2F7C155C002C5DA4 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ D8FCFA592F7C155C002C5DA4 /* livewall.app */,
+ FF000000000000000000A005 /* livewallTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ D8FCFA582F7C155C002C5DA4 /* livewall */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = D8FCFA642F7C155D002C5DA4 /* Build configuration list for PBXNativeTarget "livewall" */;
+ buildPhases = (
+ D8FCFA552F7C155C002C5DA4 /* Sources */,
+ D8FCFA562F7C155C002C5DA4 /* Frameworks */,
+ D8FCFA572F7C155C002C5DA4 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ D8FCFA5B2F7C155C002C5DA4 /* livewall */,
+ );
+ name = livewall;
+ packageProductDependencies = (
+ );
+ productName = livewall;
+ productReference = D8FCFA592F7C155C002C5DA4 /* livewall.app */;
+ productType = "com.apple.product-type.application";
+ };
+ FF000000000000000000A002 /* livewallTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = FF000000000000000000A008 /* Build configuration list for PBXNativeTarget "livewallTests" */;
+ buildPhases = (
+ FF000000000000000000A003 /* Sources */,
+ FF000000000000000000A004 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ FF000000000000000000A00A /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ FF000000000000000000A001 /* livewallTests */,
+ );
+ name = livewallTests;
+ packageProductDependencies = (
+ );
+ productName = livewallTests;
+ productReference = FF000000000000000000A005 /* livewallTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ D8FCFA512F7C155C002C5DA4 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2630;
+ LastUpgradeCheck = 2630;
+ TargetAttributes = {
+ D8FCFA582F7C155C002C5DA4 = {
+ CreatedOnToolsVersion = 26.3;
+ };
+ FF000000000000000000A002 = {
+ CreatedOnToolsVersion = 26.3;
+ TestTargetID = D8FCFA582F7C155C002C5DA4;
+ };
+ };
+ };
+ buildConfigurationList = D8FCFA542F7C155C002C5DA4 /* Build configuration list for PBXProject "livewall" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = D8FCFA502F7C155C002C5DA4;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = D8FCFA5A2F7C155C002C5DA4 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ D8FCFA582F7C155C002C5DA4 /* livewall */,
+ FF000000000000000000A002 /* livewallTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ D8FCFA572F7C155C002C5DA4 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ D8FCFA552F7C155C002C5DA4 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ FF000000000000000000A003 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ FF000000000000000000A00A /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = D8FCFA582F7C155C002C5DA4 /* livewall */;
+ targetProxy = FF000000000000000000A009 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXContainerItemProxy section */
+ FF000000000000000000A009 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = D8FCFA512F7C155C002C5DA4 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = D8FCFA582F7C155C002C5DA4;
+ remoteInfo = livewall;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin XCBuildConfiguration section */
+ D8FCFA622F7C155D002C5DA4 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = F2J8ZU2NQJ;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.2;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ D8FCFA632F7C155D002C5DA4 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = F2J8ZU2NQJ;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.2;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ };
+ name = Release;
+ };
+ D8FCFA652F7C155D002C5DA4 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = F2J8ZU2NQJ;
+ ENABLE_APP_SANDBOX = NO;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.cursorkittens.livewall;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ REGISTER_APP_GROUPS = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ D8FCFA662F7C155D002C5DA4 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = F2J8ZU2NQJ;
+ ENABLE_APP_SANDBOX = NO;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.cursorkittens.livewall;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ REGISTER_APP_GROUPS = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ FF000000000000000000A006 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = F2J8ZU2NQJ;
+ ENABLE_TESTABILITY = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.2;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.cursorkittens.livewallTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/livewall.app/Contents/MacOS/livewall";
+ };
+ name = Debug;
+ };
+ FF000000000000000000A007 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = F2J8ZU2NQJ;
+ ENABLE_TESTABILITY = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.2;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.cursorkittens.livewallTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/livewall.app/Contents/MacOS/livewall";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ D8FCFA542F7C155C002C5DA4 /* Build configuration list for PBXProject "livewall" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D8FCFA622F7C155D002C5DA4 /* Debug */,
+ D8FCFA632F7C155D002C5DA4 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ D8FCFA642F7C155D002C5DA4 /* Build configuration list for PBXNativeTarget "livewall" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D8FCFA652F7C155D002C5DA4 /* Debug */,
+ D8FCFA662F7C155D002C5DA4 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ FF000000000000000000A008 /* Build configuration list for PBXNativeTarget "livewallTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ FF000000000000000000A006 /* Debug */,
+ FF000000000000000000A007 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = D8FCFA512F7C155C002C5DA4 /* Project object */;
+}
diff --git a/livewall/livewall.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/livewall/livewall.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/livewall/livewall.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/livewall/livewall/Assets.xcassets/AccentColor.colorset/Contents.json b/livewall/livewall/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/livewall/livewall/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/livewall/livewall/Assets.xcassets/AppIcon.appiconset/Contents.json b/livewall/livewall/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..3f00db4
--- /dev/null
+++ b/livewall/livewall/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,58 @@
+{
+ "images" : [
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "16x16"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "32x32"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "32x32"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "128x128"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "128x128"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "256x256"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "256x256"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "512x512"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "512x512"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/livewall/livewall/Assets.xcassets/Contents.json b/livewall/livewall/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/livewall/livewall/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/livewall/livewall/Models/Wallpaper.swift b/livewall/livewall/Models/Wallpaper.swift
new file mode 100644
index 0000000..f3e5dc6
--- /dev/null
+++ b/livewall/livewall/Models/Wallpaper.swift
@@ -0,0 +1,65 @@
+import Foundation
+import Observation
+
+enum WallpaperSource: String, Codable {
+ case catalog
+ case local
+}
+
+enum WallpaperResolution: String, Codable {
+ case hd = "1920x1080"
+ case qhd = "2560x1440"
+ case uhd4k = "3840x2160"
+ case unknown = "unknown"
+}
+
+struct Wallpaper: Identifiable, Codable, Equatable {
+ let id: String
+ var title: String
+ var thumbnailURL: String?
+ var videoURL: String
+ var resolution: WallpaperResolution
+ var tags: [String]
+ var source: WallpaperSource
+ var duration: TimeInterval?
+
+ var isLocal: Bool {
+ source == .local
+ }
+
+ var localFileURL: URL? {
+ guard isLocal else { return nil }
+ return URL(fileURLWithPath: videoURL)
+ }
+
+ var remoteURL: URL? {
+ guard !isLocal, let url = URL(string: videoURL) else { return nil }
+ return url
+ }
+
+ static func local(title: String, fileURL: URL, resolution: WallpaperResolution = .unknown, duration: TimeInterval? = nil) -> Wallpaper {
+ Wallpaper(
+ id: UUID().uuidString,
+ title: title,
+ thumbnailURL: nil,
+ videoURL: fileURL.path,
+ resolution: resolution,
+ tags: [],
+ source: .local,
+ duration: duration
+ )
+ }
+
+ static func catalog(id: String, title: String, thumbnailURL: String, videoURL: String, resolution: WallpaperResolution = .uhd4k, tags: [String] = [], duration: TimeInterval? = nil) -> Wallpaper {
+ Wallpaper(
+ id: id,
+ title: title,
+ thumbnailURL: thumbnailURL,
+ videoURL: videoURL,
+ resolution: resolution,
+ tags: tags,
+ source: .catalog,
+ duration: duration
+ )
+ }
+}
diff --git a/livewall/livewall/Resources/catalog.json b/livewall/livewall/Resources/catalog.json
new file mode 100644
index 0000000..4504052
--- /dev/null
+++ b/livewall/livewall/Resources/catalog.json
@@ -0,0 +1,54 @@
+{
+ "wallpapers": [
+ {
+ "id": "867468486919849",
+ "title": "Gaze",
+ "thumbnailURL": "https://s4.wallspace.app/wallpaper/867468486919849/thumbnail.webp",
+ "videoURL": "https://s4.wallspace.app/wallpaper/867468486919849/previewhd.mp4",
+ "resolution": "3840x2160",
+ "tags": ["abstract", "colors", "smooth"],
+ "source": "catalog",
+ "duration": 15
+ },
+ {
+ "id": "821646237254581",
+ "title": "Green Leaves",
+ "thumbnailURL": "https://s4.wallspace.app/wallpaper/821646237254581/thumbnail.webp",
+ "videoURL": "https://s4.wallspace.app/wallpaper/821646237254581/previewhd.mp4",
+ "resolution": "3840x2160",
+ "tags": ["nature", "plants", "calm"],
+ "source": "catalog",
+ "duration": 10
+ },
+ {
+ "id": "811688348618467",
+ "title": "Black Hole",
+ "thumbnailURL": "https://s4.wallspace.app/wallpaper/811688348618467/thumbnail.webp",
+ "videoURL": "https://s4.wallspace.app/wallpaper/811688348618467/previewhd.mp4",
+ "resolution": "3840x2160",
+ "tags": ["space", "dark", "dramatic"],
+ "source": "catalog",
+ "duration": 20
+ },
+ {
+ "id": "198899832529485",
+ "title": "Minecraft",
+ "thumbnailURL": "https://s4.wallspace.app/wallpaper/198899832529485/thumbnail.webp",
+ "videoURL": "https://s4.wallspace.app/wallpaper/198899832529485/previewhd.mp4",
+ "resolution": "3840x2160",
+ "tags": ["gaming", "pixel", "fun"],
+ "source": "catalog",
+ "duration": 30
+ },
+ {
+ "id": "213924118113496",
+ "title": "Frog Couple",
+ "thumbnailURL": "https://s4.wallspace.app/wallpaper/213924118113496/thumbnail.webp",
+ "videoURL": "https://s4.wallspace.app/wallpaper/213924118113496/previewhd.mp4",
+ "resolution": "3840x2160",
+ "tags": ["nature", "animals", "cute"],
+ "source": "catalog",
+ "duration": 12
+ }
+ ]
+}
diff --git a/livewall/livewall/Services/AppErrorPresenter.swift b/livewall/livewall/Services/AppErrorPresenter.swift
new file mode 100644
index 0000000..f902ba6
--- /dev/null
+++ b/livewall/livewall/Services/AppErrorPresenter.swift
@@ -0,0 +1,130 @@
+import SwiftUI
+import AppKit
+import Combine
+
+/// A user-visible error with a clear title, explanation, and optional recovery hint.
+struct AppError: Identifiable, Equatable {
+ let id = UUID()
+ let title: String
+ let message: String
+ let recoverySuggestion: String?
+
+ static func == (lhs: AppError, rhs: AppError) -> Bool {
+ lhs.title == rhs.title && lhs.message == rhs.message
+ }
+}
+
+/// Shared, single-slot error state surfaced by services. Presents as a SwiftUI
+/// alert via the `.appErrorAlert()` modifier attached once at each window root.
+///
+/// Services should not call `present` directly — use `AppErrorPresenter.report(...)`
+/// which handles the main-actor hop from any context.
+@MainActor
+final class AppErrorPresenter: ObservableObject {
+ static let shared = AppErrorPresenter()
+
+ @Published var currentError: AppError?
+
+ /// Internal so test code can construct an isolated presenter without
+ /// mutating `.shared`. App code should always go through `.shared`.
+ init() {}
+
+ /// Present an error. If an identical error is already showing, this is a no-op
+ /// (prevents alert stomping when several displays fail simultaneously).
+ ///
+ /// If no SwiftUI `WindowGroup` window is currently visible to host the
+ /// `.appErrorAlert()` modifier — for example, when the user is interacting
+ /// only with the menu bar popover — falls back to a synchronous `NSAlert`
+ /// so the error is never silently dropped.
+ func present(_ error: AppError) {
+ if let current = currentError, current == error { return }
+ currentError = error
+
+ if !hasAlertHostWindow() {
+ presentViaNSAlert(error)
+ }
+ }
+
+ func dismiss() {
+ currentError = nil
+ }
+
+ /// True iff there is a visible SwiftUI `WindowGroup` window able to host
+ /// the SwiftUI alert modifier. Popovers (MenuBarExtra) do not satisfy
+ /// this — `.alert(...)` does not propagate through MenuBarExtra content.
+ private func hasAlertHostWindow() -> Bool {
+ NSApp.windows.contains { window in
+ window.isVisible && window.styleMask.contains(.titled)
+ }
+ }
+
+ private func presentViaNSAlert(_ error: AppError) {
+ let alert = NSAlert()
+ alert.messageText = error.title
+ if let recovery = error.recoverySuggestion {
+ alert.informativeText = "\(error.message)\n\n\(recovery)"
+ } else {
+ alert.informativeText = error.message
+ }
+ alert.alertStyle = .warning
+ alert.addButton(withTitle: "OK")
+ NSApp.activate(ignoringOtherApps: true)
+ alert.runModal()
+ // The user has acknowledged this error via the modal — clear so the
+ // SwiftUI binding doesn't re-present it the next time a window opens.
+ currentError = nil
+ }
+}
+
+extension AppErrorPresenter {
+ /// Non-isolated convenience for services and KVO callbacks to surface an
+ /// error from any context. Returns immediately — the hop to the main
+ /// actor is internal.
+ ///
+ /// This is a `nonisolated` static method on the presenter (rather than a
+ /// top-level function) to avoid colliding with `NSResponder.presentError(_:)`
+ /// when called from NSView subclasses.
+ nonisolated static func report(
+ title: String,
+ message: String,
+ recoverySuggestion: String? = nil
+ ) {
+ let error = AppError(title: title, message: message, recoverySuggestion: recoverySuggestion)
+ Task { @MainActor in
+ AppErrorPresenter.shared.present(error)
+ }
+ }
+}
+
+// MARK: - View modifier
+
+extension View {
+ /// Attach once per window root. Observes the shared presenter and shows a
+ /// SwiftUI alert whenever `currentError` is non-nil.
+ func appErrorAlert() -> some View {
+ modifier(AppErrorAlertModifier())
+ }
+}
+
+private struct AppErrorAlertModifier: ViewModifier {
+ @ObservedObject private var presenter = AppErrorPresenter.shared
+
+ func body(content: Content) -> some View {
+ content.alert(
+ presenter.currentError?.title ?? "Error",
+ isPresented: Binding(
+ get: { presenter.currentError != nil },
+ set: { if !$0 { presenter.dismiss() } }
+ ),
+ presenting: presenter.currentError
+ ) { _ in
+ Button("OK", role: .cancel) { presenter.dismiss() }
+ } message: { error in
+ if let recovery = error.recoverySuggestion {
+ Text("\(error.message)\n\n\(recovery)")
+ } else {
+ Text(error.message)
+ }
+ }
+ }
+}
diff --git a/livewall/livewall/Services/AppLogger.swift b/livewall/livewall/Services/AppLogger.swift
new file mode 100644
index 0000000..5dff1b9
--- /dev/null
+++ b/livewall/livewall/Services/AppLogger.swift
@@ -0,0 +1,19 @@
+import Foundation
+import os
+
+/// Centralized `os.Logger` namespace for livewall.
+///
+/// Filter in Console.app with `subsystem == "com.cursorkittens.livewall"`
+/// or on the command line:
+/// `log stream --predicate 'subsystem == "com.cursorkittens.livewall"' --level debug`
+enum AppLogger {
+ static let subsystem = "com.cursorkittens.livewall"
+
+ static let app = Logger(subsystem: subsystem, category: "app")
+ static let engine = Logger(subsystem: subsystem, category: "engine")
+ static let catalog = Logger(subsystem: subsystem, category: "catalog")
+ static let download = Logger(subsystem: subsystem, category: "download")
+ static let playback = Logger(subsystem: subsystem, category: "playback")
+ static let thumbnail = Logger(subsystem: subsystem, category: "thumbnail")
+ static let settings = Logger(subsystem: subsystem, category: "settings")
+}
diff --git a/livewall/livewall/Services/DisplayManager.swift b/livewall/livewall/Services/DisplayManager.swift
new file mode 100644
index 0000000..3a55151
--- /dev/null
+++ b/livewall/livewall/Services/DisplayManager.swift
@@ -0,0 +1,57 @@
+import AppKit
+import Combine
+
+struct DisplayInfo: Identifiable, Equatable {
+ let id: String
+ let name: String
+ let localizedName: String
+ let frame: CGRect
+ let resolution: CGSize
+
+ static func == (lhs: DisplayInfo, rhs: DisplayInfo) -> Bool {
+ lhs.id == rhs.id
+ }
+}
+
+final class DisplayManager: ObservableObject {
+ static let shared = DisplayManager()
+
+ @Published var displays: [DisplayInfo] = []
+
+ init() {
+ refreshDisplays()
+ NotificationCenter.default.addObserver(
+ forName: NSApplication.didChangeScreenParametersNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.refreshDisplays()
+ }
+ }
+
+ func refreshDisplays() {
+ var result: [DisplayInfo] = []
+ for screen in NSScreen.screens {
+ guard let displayID = screen.deviceDescription[.init("NSScreenNumber")] as? CGDirectDisplayID else {
+ continue
+ }
+
+ let uuid = String(displayID)
+ let name = screen.localizedName
+ let frame = screen.frame
+ let resolution = CGSize(
+ width: frame.width * screen.backingScaleFactor,
+ height: frame.height * screen.backingScaleFactor
+ )
+
+ result.append(DisplayInfo(
+ id: uuid,
+ name: name,
+ localizedName: name,
+ frame: frame,
+ resolution: resolution
+ ))
+ }
+ displays = result
+ }
+}
diff --git a/livewall/livewall/Services/DownloadManager.swift b/livewall/livewall/Services/DownloadManager.swift
new file mode 100644
index 0000000..e706958
--- /dev/null
+++ b/livewall/livewall/Services/DownloadManager.swift
@@ -0,0 +1,190 @@
+import Foundation
+import Combine
+import os
+
+enum DownloadState: Equatable {
+ case idle
+ case downloading(progress: Double)
+ case completed(localURL: URL)
+ case failed(String)
+
+ static func == (lhs: DownloadState, rhs: DownloadState) -> Bool {
+ switch (lhs, rhs) {
+ case (.idle, .idle): return true
+ case (.downloading(let lp), .downloading(let rp)): return lp == rp
+ case (.completed(let ll), .completed(let rl)): return ll == rl
+ case (.failed(let le), .failed(let re)): return le == re
+ default: return false
+ }
+ }
+
+ var isActive: Bool {
+ if case .downloading = self { return true }
+ return false
+ }
+
+ /// Convenience accessor for the active progress fraction (0...1) when in
+ /// the `.downloading` state. Returns nil otherwise.
+ var progressFraction: Double? {
+ if case .downloading(let p) = self { return p }
+ return nil
+ }
+}
+
+@MainActor
+final class DownloadManager: ObservableObject {
+ static let shared = DownloadManager()
+
+ @Published var downloads: [String: DownloadState] = [:]
+
+ private let downloadDirectory: URL
+
+ init() {
+ let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ self.downloadDirectory = support.appendingPathComponent("livewall/wallpapers", isDirectory: true)
+ do {
+ try FileManager.default.createDirectory(at: downloadDirectory, withIntermediateDirectories: true)
+ } catch {
+ AppLogger.download.error("Could not create download directory: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+
+ func download(wallpaper: Wallpaper) async {
+ guard let url = wallpaper.remoteURL else { return }
+
+ let localURL = downloadDirectory.appendingPathComponent("\(wallpaper.id).mp4")
+
+ if FileManager.default.fileExists(atPath: localURL.path) {
+ downloads[wallpaper.id] = .completed(localURL: localURL)
+ return
+ }
+
+ downloads[wallpaper.id] = .downloading(progress: 0)
+ AppLogger.download.info("Starting download \(wallpaper.id, privacy: .public)")
+
+ do {
+ let tempFile = try await runDownload(url: url, wallpaperID: wallpaper.id)
+ try FileManager.default.moveItem(at: tempFile, to: localURL)
+ downloads[wallpaper.id] = .completed(localURL: localURL)
+ AppLogger.download.info("Completed download \(wallpaper.id, privacy: .public)")
+ } catch {
+ AppLogger.download.error("Download failed for \(wallpaper.id, privacy: .public): \(error.localizedDescription, privacy: .public)")
+ downloads[wallpaper.id] = .failed(error.localizedDescription)
+ }
+ }
+
+ /// Internal so the progress delegate (and unit tests) can publish progress
+ /// updates back onto the main actor.
+ func updateProgress(id: String, fraction: Double) {
+ let clamped = min(max(fraction, 0), 1)
+ downloads[id] = .downloading(progress: clamped)
+ }
+
+ func localURL(for wallpaperID: String) -> URL? {
+ let localURL = downloadDirectory.appendingPathComponent("\(wallpaperID).mp4")
+ return FileManager.default.fileExists(atPath: localURL.path) ? localURL : nil
+ }
+
+ func isDownloaded(wallpaperID: String) -> Bool {
+ localURL(for: wallpaperID) != nil
+ }
+
+ // MARK: - Delegate-driven download
+
+ /// Bridges the delegate-based URLSession download API to async/await while
+ /// streaming progress updates back to `updateProgress(id:fraction:)`.
+ /// Resumes its continuation exactly once, in `urlSession(_:task:didCompleteWithError:)`.
+ private func runDownload(url: URL, wallpaperID: String) async throws -> URL {
+ let delegate = ProgressDelegate(wallpaperID: wallpaperID, manager: self)
+ let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
+
+ defer { session.finishTasksAndInvalidate() }
+
+ return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ delegate.continuation = continuation
+ let task = session.downloadTask(with: url)
+ task.resume()
+ }
+ }
+}
+
+/// Non-isolated `URLSessionDownloadDelegate` that streams progress callbacks
+/// back to its owning `DownloadManager` via main-actor hops, then bridges the
+/// terminal `didCompleteWithError` callback into a `CheckedContinuation`.
+///
+/// Why a separate class? `DownloadManager` is `@MainActor`, but URLSession
+/// delivers its delegate callbacks on an arbitrary serial queue. Conforming
+/// to `URLSessionDownloadDelegate` directly on `DownloadManager` would
+/// violate actor isolation. The delegate is `final` and `@unchecked Sendable`
+/// because all of its mutable state (`movedFileURL`, `continuation`) is only
+/// touched from the URLSession's serial delegate queue, which Swift's
+/// concurrency checker can't see through.
+private final class ProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
+ private let wallpaperID: String
+ private weak var manager: DownloadManager?
+
+ /// Stable temp URL captured in `didFinishDownloadingTo` (the system
+ /// deletes the original temp file the moment that delegate method
+ /// returns, so we move it synchronously here and remember the new path).
+ var movedFileURL: URL?
+ var continuation: CheckedContinuation?
+
+ init(wallpaperID: String, manager: DownloadManager) {
+ self.wallpaperID = wallpaperID
+ self.manager = manager
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didWriteData bytesWritten: Int64,
+ totalBytesWritten: Int64,
+ totalBytesExpectedToWrite: Int64
+ ) {
+ let denominator = max(totalBytesExpectedToWrite, 1)
+ let fraction = Double(totalBytesWritten) / Double(denominator)
+ let id = wallpaperID
+ Task { @MainActor [weak manager] in
+ manager?.updateProgress(id: id, fraction: fraction)
+ }
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didFinishDownloadingTo location: URL
+ ) {
+ // We must move the file synchronously here — URLSession deletes the
+ // temp file as soon as this method returns. Stash the new URL for the
+ // terminal `didCompleteWithError` callback to consume.
+ let stableURL = FileManager.default.temporaryDirectory.appendingPathComponent("livewall-\(wallpaperID)-\(UUID().uuidString).mp4")
+ do {
+ try FileManager.default.moveItem(at: location, to: stableURL)
+ movedFileURL = stableURL
+ } catch {
+ AppLogger.download.error("Failed to move downloaded file to temp: \(error.localizedDescription, privacy: .public)")
+ // Don't resume the continuation here — leave it to didCompleteWithError.
+ }
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ task: URLSessionTask,
+ didCompleteWithError error: Error?
+ ) {
+ // Terminal callback. Resume exactly once and clear the continuation
+ // so any spurious follow-up call can't double-resume.
+ guard let cont = continuation else { return }
+ continuation = nil
+
+ if let error {
+ cont.resume(throwing: error)
+ return
+ }
+ if let url = movedFileURL {
+ cont.resume(returning: url)
+ return
+ }
+ cont.resume(throwing: URLError(.cannotCreateFile))
+ }
+}
diff --git a/livewall/livewall/Services/SettingsManager.swift b/livewall/livewall/Services/SettingsManager.swift
new file mode 100644
index 0000000..9af793c
--- /dev/null
+++ b/livewall/livewall/Services/SettingsManager.swift
@@ -0,0 +1,75 @@
+import Foundation
+import Combine
+import ServiceManagement
+import os
+
+final class SettingsManager: ObservableObject {
+ static let shared = SettingsManager()
+
+ @Published var pauseOnBattery: Bool {
+ didSet { UserDefaults.standard.set(pauseOnBattery, forKey: "pauseOnBattery") }
+ }
+
+ @Published var launchAtLogin: Bool {
+ didSet {
+ guard !isUpdatingLoginItem else { return }
+ UserDefaults.standard.set(launchAtLogin, forKey: "launchAtLogin")
+ updateLoginItem()
+ }
+ }
+
+ @Published var lowPowerMode: Bool {
+ didSet { UserDefaults.standard.set(lowPowerMode, forKey: "lowPowerMode") }
+ }
+
+ @Published var displayAssignments: [String: String] {
+ didSet {
+ do {
+ let data = try JSONEncoder().encode(displayAssignments)
+ UserDefaults.standard.set(data, forKey: "displayAssignments")
+ } catch {
+ AppLogger.settings.error("Failed to encode display assignments: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ }
+
+ /// Re-entry guard for `launchAtLogin`'s didSet when we revert the toggle
+ /// after a failed `SMAppService` call.
+ private var isUpdatingLoginItem = false
+
+ init() {
+ self.pauseOnBattery = UserDefaults.standard.object(forKey: "pauseOnBattery") as? Bool ?? true
+ self.launchAtLogin = UserDefaults.standard.object(forKey: "launchAtLogin") as? Bool ?? false
+ self.lowPowerMode = UserDefaults.standard.object(forKey: "lowPowerMode") as? Bool ?? false
+
+ if let data = UserDefaults.standard.data(forKey: "displayAssignments") {
+ do {
+ self.displayAssignments = try JSONDecoder().decode([String: String].self, from: data)
+ } catch {
+ AppLogger.settings.warning("Couldn't decode saved display assignments; resetting: \(error.localizedDescription, privacy: .public)")
+ self.displayAssignments = [:]
+ }
+ } else {
+ self.displayAssignments = [:]
+ }
+ }
+
+ private func updateLoginItem() {
+ if #unavailable(macOS 13.0) { return }
+ do {
+ if launchAtLogin {
+ try SMAppService.mainApp.register()
+ } else {
+ try SMAppService.mainApp.unregister()
+ }
+ AppLogger.settings.info("Launch-at-login updated to \(self.launchAtLogin, privacy: .public)")
+ } catch {
+ AppLogger.settings.error("Failed to update login item: \(error.localizedDescription, privacy: .public)")
+ // Revert the UI toggle so it reflects reality, without re-triggering updateLoginItem().
+ isUpdatingLoginItem = true
+ launchAtLogin.toggle()
+ UserDefaults.standard.set(launchAtLogin, forKey: "launchAtLogin")
+ isUpdatingLoginItem = false
+ }
+ }
+}
diff --git a/livewall/livewall/Services/ThumbnailGenerator.swift b/livewall/livewall/Services/ThumbnailGenerator.swift
new file mode 100644
index 0000000..2c9d259
--- /dev/null
+++ b/livewall/livewall/Services/ThumbnailGenerator.swift
@@ -0,0 +1,57 @@
+import Foundation
+import AppKit
+import AVFoundation
+import Observation
+import os
+
+@Observable
+final class ThumbnailGenerator {
+ static let shared = ThumbnailGenerator()
+
+ private let cacheDirectory: URL
+
+ init() {
+ let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ let dir = support.appendingPathComponent("livewall/thumbnails", isDirectory: true)
+ self.cacheDirectory = dir
+ do {
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ } catch {
+ AppLogger.thumbnail.error("Could not create thumbnail cache directory: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+
+ func generateThumbnail(for videoURL: URL, wallpaperID: String) async -> URL? {
+ let cachedPath = cacheDirectory.appendingPathComponent("\(wallpaperID).jpg")
+ if FileManager.default.fileExists(atPath: cachedPath.path) {
+ return cachedPath
+ }
+
+ let asset = AVURLAsset(url: videoURL)
+ let generator = AVAssetImageGenerator(asset: asset)
+ generator.appliesPreferredTrackTransform = true
+ generator.maximumSize = CGSize(width: 400, height: 250)
+
+ let time = CMTime(seconds: 1, preferredTimescale: 600)
+ do {
+ let (cgImage, _) = try await generator.image(at: time)
+ let nsImage = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
+
+ if let tiffData = nsImage.tiffRepresentation,
+ let bitmap = NSBitmapImageRep(data: tiffData),
+ let jpegData = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.8]) {
+ try jpegData.write(to: cachedPath)
+ return cachedPath
+ }
+ } catch {
+ AppLogger.thumbnail.error("Thumbnail generation failed for \(wallpaperID, privacy: .public): \(error.localizedDescription, privacy: .public)")
+ }
+
+ return nil
+ }
+
+ func cachedThumbnail(for wallpaperID: String) -> URL? {
+ let path = cacheDirectory.appendingPathComponent("\(wallpaperID).jpg")
+ return FileManager.default.fileExists(atPath: path.path) ? path : nil
+ }
+}
diff --git a/livewall/livewall/Services/VideoPreviewPool.swift b/livewall/livewall/Services/VideoPreviewPool.swift
new file mode 100644
index 0000000..a2e97bb
--- /dev/null
+++ b/livewall/livewall/Services/VideoPreviewPool.swift
@@ -0,0 +1,91 @@
+import AVFoundation
+import Foundation
+
+/// Pooled AVPlayer manager for lightweight in-gallery video previews.
+///
+/// The pool caps the number of concurrent AVPlayer instances to avoid thrashing memory
+/// and the decode pipeline when the user rapidly hovers across many wallpaper cards.
+/// Players are keyed by wallpaper ID and evicted LRU-style when the cap is exceeded.
+///
+/// All access is main-actor isolated because AVPlayer / AVPlayerLayer must be
+/// configured on the main thread.
+@MainActor
+final class VideoPreviewPool {
+ static let shared = VideoPreviewPool()
+
+ private let maxConcurrent = 2
+
+ private struct Entry {
+ let player: AVPlayer
+ var loopObserver: NSObjectProtocol?
+ }
+
+ private var entries: [String: Entry] = [:]
+ private var lruOrder: [String] = []
+
+ private init() {}
+
+ /// Returns a muted, looping AVPlayer for the given wallpaper.
+ /// Reuses an existing player if one is already pooled for this id.
+ func player(for id: String, url: URL) -> AVPlayer {
+ if let existing = entries[id] {
+ touch(id)
+ return existing.player
+ }
+
+ // Evict LRU if at capacity.
+ while entries.count >= maxConcurrent, let oldest = lruOrder.first {
+ evict(id: oldest)
+ }
+
+ let item = AVPlayerItem(url: url)
+ let player = AVPlayer(playerItem: item)
+ player.isMuted = true
+ player.actionAtItemEnd = .none
+ player.automaticallyWaitsToMinimizeStalling = false
+
+ let observer = NotificationCenter.default.addObserver(
+ forName: .AVPlayerItemDidPlayToEndTime,
+ object: item,
+ queue: .main
+ ) { [weak player] _ in
+ player?.seek(to: .zero)
+ player?.play()
+ }
+
+ entries[id] = Entry(player: player, loopObserver: observer)
+ lruOrder.append(id)
+ return player
+ }
+
+ /// Marks the pooled player as a candidate for future eviction,
+ /// but does not tear it down immediately — the next `player(for:url:)`
+ /// call for a different id may evict it.
+ func release(id: String) {
+ guard let index = lruOrder.firstIndex(of: id) else { return }
+ lruOrder.remove(at: index)
+ lruOrder.insert(id, at: 0) // move to front of eviction queue
+ }
+
+ /// Stops playback for a given id without removing the entry from the pool.
+ func pause(id: String) {
+ entries[id]?.player.pause()
+ }
+
+ private func touch(_ id: String) {
+ if let index = lruOrder.firstIndex(of: id) {
+ lruOrder.remove(at: index)
+ }
+ lruOrder.append(id)
+ }
+
+ private func evict(id: String) {
+ guard let entry = entries.removeValue(forKey: id) else { return }
+ if let observer = entry.loopObserver {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ entry.player.pause()
+ entry.player.replaceCurrentItem(with: nil)
+ lruOrder.removeAll { $0 == id }
+ }
+}
diff --git a/livewall/livewall/Services/WallpaperCatalog.swift b/livewall/livewall/Services/WallpaperCatalog.swift
new file mode 100644
index 0000000..0a52952
--- /dev/null
+++ b/livewall/livewall/Services/WallpaperCatalog.swift
@@ -0,0 +1,228 @@
+import Foundation
+import AppKit
+import AVFoundation
+import Combine
+import os
+
+final class WallpaperCatalog: ObservableObject {
+ static let shared = WallpaperCatalog()
+
+ @Published var wallpapers: [Wallpaper] = []
+ @Published var localWallpapers: [Wallpaper] = []
+
+ /// IDs of local wallpapers whose backing file was missing when the catalog
+ /// was last loaded (or became missing since). Gallery UI uses this set to
+ /// show a stale badge and a "Remove Missing Entry" affordance.
+ @Published var staleLocalWallpaperIDs: Set = []
+
+ init() {
+ loadSeedCatalog()
+ loadLocalWallpapers()
+ }
+
+ var allWallpapers: [Wallpaper] {
+ localWallpapers + wallpapers
+ }
+
+ // MARK: - Catalog load (seed)
+
+ func loadSeedCatalog() {
+ guard let url = Bundle.main.url(forResource: "catalog", withExtension: "json") else {
+ AppLogger.catalog.info("No bundled catalog.json found; starting with empty seed catalog")
+ return
+ }
+
+ let data: Data
+ do {
+ data = try Data(contentsOf: url)
+ } catch {
+ AppLogger.catalog.error("Couldn't read catalog.json: \(error.localizedDescription, privacy: .public)")
+ AppErrorPresenter.report(
+ title: "Couldn't Load Catalog",
+ message: "The bundled wallpaper catalog couldn't be read from disk.",
+ recoverySuggestion: "Your imported wallpapers are still available."
+ )
+ return
+ }
+
+ do {
+ let catalog = try JSONDecoder().decode(CatalogData.self, from: data)
+ wallpapers = catalog.wallpapers
+ AppLogger.catalog.info("Loaded \(self.wallpapers.count, privacy: .public) catalog wallpapers")
+ } catch {
+ AppLogger.catalog.error("Catalog JSON invalid: \(error.localizedDescription, privacy: .public)")
+ AppErrorPresenter.report(
+ title: "Catalog Format Error",
+ message: "The wallpaper catalog file has an invalid format.",
+ recoverySuggestion: "Your imported wallpapers are still available."
+ )
+ }
+ }
+
+ // MARK: - Local wallpapers
+
+ func loadLocalWallpapers() {
+ let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ let localDir = support.appendingPathComponent("livewall/imported", isDirectory: true)
+
+ let contents: [URL]
+ do {
+ contents = try FileManager.default.contentsOfDirectory(at: localDir, includingPropertiesForKeys: nil)
+ } catch {
+ // Missing directory on fresh install is normal; don't surface.
+ AppLogger.catalog.debug("No imported-wallpapers directory yet: \(error.localizedDescription, privacy: .public)")
+ localWallpapers = []
+ staleLocalWallpaperIDs = []
+ return
+ }
+
+ var loaded: [Wallpaper] = []
+ var stale: Set = []
+ for fileURL in contents where fileURL.pathExtension == "mp4" || fileURL.pathExtension == "mov" {
+ let wallpaper = Wallpaper.local(
+ title: fileURL.deletingPathExtension().lastPathComponent,
+ fileURL: fileURL
+ )
+ if !FileManager.default.fileExists(atPath: fileURL.path) {
+ stale.insert(wallpaper.id)
+ }
+ loaded.append(wallpaper)
+ }
+
+ localWallpapers = loaded
+ staleLocalWallpaperIDs = stale
+ if !stale.isEmpty {
+ AppLogger.catalog.warning("Found \(stale.count, privacy: .public) stale local wallpaper entries")
+ }
+ }
+
+ /// Re-check all local wallpapers for file presence. Call when the user
+ /// suspects files have moved or been deleted.
+ func refreshStaleStatus() {
+ var stale: Set = []
+ for wallpaper in localWallpapers {
+ if let url = wallpaper.localFileURL,
+ !FileManager.default.fileExists(atPath: url.path) {
+ stale.insert(wallpaper.id)
+ }
+ }
+ staleLocalWallpaperIDs = stale
+ }
+
+ /// Remove a local wallpaper from the library. Prunes the in-memory array
+ /// and deletes the backing file if it still exists. Safe to call on stale
+ /// entries whose file has already been deleted.
+ func removeLocalWallpaper(_ wallpaper: Wallpaper) {
+ guard wallpaper.isLocal else { return }
+
+ localWallpapers.removeAll { $0.id == wallpaper.id }
+ staleLocalWallpaperIDs.remove(wallpaper.id)
+
+ if let fileURL = wallpaper.localFileURL,
+ FileManager.default.fileExists(atPath: fileURL.path) {
+ do {
+ try FileManager.default.removeItem(at: fileURL)
+ AppLogger.catalog.info("Removed local wallpaper file: \(fileURL.lastPathComponent, privacy: .public)")
+ } catch {
+ AppLogger.catalog.warning("Couldn't delete local wallpaper file: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ }
+
+ // MARK: - Import
+
+ func addLocalWallpaper(fileURL: URL) async -> Wallpaper? {
+ let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ let destDir = support.appendingPathComponent("livewall/imported", isDirectory: true)
+
+ do {
+ try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true)
+ } catch {
+ AppLogger.catalog.error("Couldn't create imports directory: \(error.localizedDescription, privacy: .public)")
+ AppErrorPresenter.report(
+ title: "Import Failed",
+ message: "Couldn't create the imports folder in Application Support.",
+ recoverySuggestion: "Check that livewall has permission to write to your Application Support directory."
+ )
+ return nil
+ }
+
+ let destURL = destDir.appendingPathComponent(fileURL.lastPathComponent)
+
+ do {
+ if FileManager.default.fileExists(atPath: destURL.path) {
+ try FileManager.default.removeItem(at: destURL)
+ }
+ try FileManager.default.copyItem(at: fileURL, to: destURL)
+
+ let asset = AVURLAsset(url: destURL)
+ let duration = try await asset.load(.duration).seconds
+ let tracks = try await asset.loadTracks(withMediaType: .video)
+ let resolution: CGSize
+ if let firstTrack = tracks.first {
+ resolution = try await firstTrack.load(.naturalSize)
+ } else {
+ resolution = .zero
+ }
+
+ let wallpaperRes: WallpaperResolution
+ if resolution.width >= 3840 {
+ wallpaperRes = .uhd4k
+ } else if resolution.width >= 2560 {
+ wallpaperRes = .qhd
+ } else if resolution.width >= 1920 {
+ wallpaperRes = .hd
+ } else {
+ wallpaperRes = .unknown
+ }
+
+ let wallpaper = Wallpaper.local(
+ title: fileURL.deletingPathExtension().lastPathComponent,
+ fileURL: destURL,
+ resolution: wallpaperRes,
+ duration: duration
+ )
+
+ localWallpapers.append(wallpaper)
+ AppLogger.catalog.info("Imported local wallpaper: \(wallpaper.title, privacy: .public)")
+
+ Task {
+ _ = await ThumbnailGenerator.shared.generateThumbnail(for: destURL, wallpaperID: wallpaper.id)
+ }
+
+ return wallpaper
+ } catch {
+ AppLogger.catalog.error("Failed to import wallpaper: \(error.localizedDescription, privacy: .public)")
+ AppErrorPresenter.report(
+ title: "Import Failed",
+ message: error.localizedDescription,
+ recoverySuggestion: "Make sure the file is a valid MP4 or MOV and that you have enough disk space."
+ )
+ return nil
+ }
+ }
+
+ // MARK: - Search / filter
+
+ func search(query: String) -> [Wallpaper] {
+ guard !query.isEmpty else { return allWallpapers }
+ let lowercased = query.lowercased()
+ return allWallpapers.filter { wp in
+ wp.title.lowercased().contains(lowercased) ||
+ wp.tags.contains { $0.lowercased().contains(lowercased) }
+ }
+ }
+
+ func filter(by tag: String) -> [Wallpaper] {
+ guard !tag.isEmpty else { return allWallpapers }
+ return allWallpapers.filter { $0.tags.contains(tag) }
+ }
+
+ var allTags: [String] {
+ Set(allWallpapers.flatMap(\.tags)).sorted()
+ }
+}
+
+struct CatalogData: Codable {
+ let wallpapers: [Wallpaper]
+}
diff --git a/livewall/livewall/Services/WallpaperEngine.swift b/livewall/livewall/Services/WallpaperEngine.swift
new file mode 100644
index 0000000..02d2cbf
--- /dev/null
+++ b/livewall/livewall/Services/WallpaperEngine.swift
@@ -0,0 +1,254 @@
+import AppKit
+import Combine
+import IOKit.ps
+import os
+
+enum WallpaperApplyScope {
+ case allDisplays
+ case specificDisplay(displayID: String)
+}
+
+final class WallpaperEngine: ObservableObject {
+ static let shared = WallpaperEngine(displayManager: .shared)
+
+ @Published var activeWallpapers: [String: Wallpaper] = [:]
+ @Published var isPaused = false
+
+ private var windows: [String: WallpaperWindow] = [:]
+ private var displayManager: DisplayManager
+ private var powerPollTimer: Timer?
+
+ init(displayManager: DisplayManager) {
+ self.displayManager = displayManager
+ setupBatteryMonitoring()
+ }
+
+ deinit {
+ powerPollTimer?.invalidate()
+ NotificationCenter.default.removeObserver(self)
+ NSWorkspace.shared.notificationCenter.removeObserver(self)
+ }
+
+ static func makeDefault() -> WallpaperEngine {
+ WallpaperEngine(displayManager: .shared)
+ }
+
+ // MARK: - Apply / set
+
+ func apply(_ wallpaper: Wallpaper, scope: WallpaperApplyScope) {
+ guard ensureAvailable(wallpaper) else { return }
+
+ AppLogger.engine.debug("apply \(wallpaper.title, privacy: .public) to \(String(describing: scope), privacy: .public), displays=\(self.displayManager.displays.count, privacy: .public)")
+
+ switch scope {
+ case .allDisplays:
+ for display in displayManager.displays {
+ setWallpaperInternal(wallpaper, forDisplay: display.id)
+ }
+ case .specificDisplay(let displayID):
+ setWallpaperInternal(wallpaper, forDisplay: displayID)
+ }
+ }
+
+ /// Public entry point for single-display apply (from the detail view).
+ /// Runs the same availability check as `apply(_:scope:)`.
+ func setWallpaper(_ wallpaper: Wallpaper, forDisplay displayID: String) {
+ guard ensureAvailable(wallpaper) else { return }
+ setWallpaperInternal(wallpaper, forDisplay: displayID)
+ }
+
+ /// Internal hot-path — no availability check. Only call after `ensureAvailable`
+ /// has been verified once at the top level.
+ private func setWallpaperInternal(_ wallpaper: Wallpaper, forDisplay displayID: String) {
+ activeWallpapers[displayID] = wallpaper
+ updateWindow(forDisplay: displayID, wallpaper: wallpaper)
+ }
+
+ /// Returns true if the wallpaper's backing file is reachable. For stale
+ /// local wallpapers, logs a warning and surfaces a single user-visible
+ /// error (deduped by the presenter).
+ private func ensureAvailable(_ wallpaper: Wallpaper) -> Bool {
+ if wallpaper.isLocal,
+ let url = wallpaper.localFileURL,
+ !FileManager.default.fileExists(atPath: url.path) {
+ AppLogger.engine.warning("Stale local wallpaper: \(wallpaper.title, privacy: .public) at \(url.path, privacy: .public)")
+ AppErrorPresenter.report(
+ title: "Wallpaper File Missing",
+ message: "\"\(wallpaper.title)\" was moved or deleted since you imported it.",
+ recoverySuggestion: "Remove it from your library or re-import the file."
+ )
+ return false
+ }
+ return true
+ }
+
+ // MARK: - Playback control
+
+ func pauseAll() {
+ isPaused = true
+ windows.values.forEach { $0.pause() }
+ }
+
+ func resumeAll() {
+ isPaused = false
+ windows.values.forEach { $0.resume() }
+ }
+
+ func stopAll() {
+ windows.values.forEach { $0.stop() }
+ windows.values.forEach { $0.orderOut(nil) }
+ windows.removeAll()
+ activeWallpapers.removeAll()
+ }
+
+ /// Removes the wallpaper from a specific display, hiding its window.
+ func stop(forDisplay displayID: String) {
+ if let window = windows[displayID] {
+ window.stop()
+ window.orderOut(nil)
+ windows.removeValue(forKey: displayID)
+ }
+ activeWallpapers.removeValue(forKey: displayID)
+ }
+
+ func refreshDisplays() {
+ displayManager.refreshDisplays()
+ rebuildWindows()
+ }
+
+ var displays: [DisplayInfo] {
+ displayManager.displays
+ }
+
+ // MARK: - Window management
+
+ private func updateWindow(forDisplay displayID: String, wallpaper: Wallpaper) {
+ guard let display = displayManager.displays.first(where: { $0.id == displayID }) else {
+ AppLogger.engine.warning("No display found for id \(displayID, privacy: .public)")
+ return
+ }
+
+ AppLogger.engine.debug("updateWindow display=\(displayID, privacy: .public)")
+
+ let window: WallpaperWindow
+ if let existing = windows[displayID] {
+ window = existing
+ } else {
+ window = WallpaperWindow(contentRect: display.frame)
+ windows[displayID] = window
+ }
+
+ window.updateFrame(display.frame)
+
+ guard let url = resolveVideoURL(for: wallpaper) else {
+ AppLogger.engine.error("No reachable video URL for wallpaper \(wallpaper.title, privacy: .public)")
+ AppErrorPresenter.report(
+ title: "Can't Play This Wallpaper",
+ message: "The video source for \"\(wallpaper.title)\" isn't reachable.",
+ recoverySuggestion: "Try a different wallpaper, or re-import it if it's a local file."
+ )
+ return
+ }
+
+ window.play(url: url)
+ window.orderBack(nil)
+ }
+
+ /// Resolves the playable URL for a wallpaper: local file → cached download → remote → raw videoURL.
+ private func resolveVideoURL(for wallpaper: Wallpaper) -> URL? {
+ if let localURL = wallpaper.localFileURL {
+ return localURL
+ }
+
+ let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ let downloadDir = support.appendingPathComponent("livewall/wallpapers", isDirectory: true)
+ let cachedURL = downloadDir.appendingPathComponent("\(wallpaper.id).mp4")
+
+ if FileManager.default.fileExists(atPath: cachedURL.path) {
+ return cachedURL
+ }
+ if let remoteURL = wallpaper.remoteURL {
+ return remoteURL
+ }
+ if let videoURL = URL(string: wallpaper.videoURL) {
+ return videoURL
+ }
+ return nil
+ }
+
+ private func rebuildWindows() {
+ let currentDisplayIDs = Set(displayManager.displays.map { $0.id })
+ let staleIDs = Set(windows.keys).subtracting(currentDisplayIDs)
+
+ for staleID in staleIDs {
+ AppLogger.engine.info("Display disconnected: \(staleID, privacy: .public)")
+ windows[staleID]?.stop()
+ windows[staleID]?.close()
+ windows.removeValue(forKey: staleID)
+ activeWallpapers.removeValue(forKey: staleID)
+ }
+
+ for display in displayManager.displays {
+ if let wallpaper = activeWallpapers[display.id] {
+ updateWindow(forDisplay: display.id, wallpaper: wallpaper)
+ }
+ }
+ }
+
+ // MARK: - Battery monitoring
+
+ private func setupBatteryMonitoring() {
+ NSWorkspace.shared.notificationCenter.addObserver(
+ forName: NSWorkspace.didWakeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.handlePowerChange()
+ }
+
+ NotificationCenter.default.addObserver(
+ forName: .NSProcessInfoPowerStateDidChange,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.handlePowerChange()
+ }
+
+ // Poll every 30s to catch plug/unplug events between notifications.
+ powerPollTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
+ self?.handlePowerChange()
+ }
+ }
+
+ private func handlePowerChange() {
+ guard SettingsManager.shared.pauseOnBattery else { return }
+
+ let onBattery = ProcessInfo.processInfo.isLowPowerModeEnabled || isOnBattery()
+
+ if onBattery {
+ pauseAll()
+ } else {
+ resumeAll()
+ }
+ }
+
+ private func isOnBattery() -> Bool {
+ guard let info = IOPSCopyPowerSourcesInfo()?.takeRetainedValue() else {
+ return false
+ }
+ guard let sources = IOPSCopyPowerSourcesList(info)?.takeRetainedValue() as? [CFTypeRef] else {
+ return false
+ }
+
+ for source in sources {
+ guard let desc = IOPSGetPowerSourceDescription(info, source)?.takeUnretainedValue() as? [String: Any],
+ let type = desc[kIOPSPowerSourceStateKey] as? String else {
+ continue
+ }
+ if type == kIOPSOffLineValue {
+ return true
+ }
+ }
+ return false
+ }
+}
diff --git a/livewall/livewall/Services/WallpaperWindow.swift b/livewall/livewall/Services/WallpaperWindow.swift
new file mode 100644
index 0000000..f1a91c1
--- /dev/null
+++ b/livewall/livewall/Services/WallpaperWindow.swift
@@ -0,0 +1,237 @@
+import AppKit
+import AVFoundation
+import os
+
+final class WallpaperPlayerView: NSView {
+ private var playerLayer: AVPlayerLayer?
+ private var player: AVPlayer?
+ private var notificationObserver: NSObjectProtocol?
+ private var statusObservation: NSKeyValueObservation?
+
+ // Stall detection state. The KVO closure for `timeControlStatus` hops to
+ // `@MainActor` before touching any of these — they are only mutated from
+ // the main actor.
+ private var stallObservation: NSKeyValueObservation?
+ private var hasPlayedAtLeastOnce: Bool = false
+ private var stallTask: Task?
+ private var lastStallReport: Date?
+
+ /// Minimum time the player must be in `.waitingToPlayAtSpecifiedRate`
+ /// before we consider it a stall worth surfacing to the user.
+ private static let stallThreshold: Duration = .seconds(15)
+ /// Don't surface more than one stall alert per player within this window.
+ private static let stallReportCooldown: TimeInterval = 60
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ wantsLayer = true
+ layerContentsRedrawPolicy = .never
+ autoresizingMask = [.width, .height]
+
+ let layer = AVPlayerLayer()
+ layer.videoGravity = .resizeAspectFill
+ layer.frame = bounds
+ layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
+ self.layer?.addSublayer(layer)
+ playerLayer = layer
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func play(url: URL) {
+ stop()
+
+ let asset = AVURLAsset(url: url)
+ let playerItem = AVPlayerItem(asset: asset)
+ let newPlayer = AVPlayer(playerItem: playerItem)
+ newPlayer.actionAtItemEnd = .none
+ newPlayer.isMuted = true
+
+ playerLayer?.player = newPlayer
+ player = newPlayer
+
+ notificationObserver = NotificationCenter.default.addObserver(
+ forName: .AVPlayerItemDidPlayToEndTime,
+ object: playerItem,
+ queue: .main
+ ) { _ in
+ playerItem.seek(to: .zero) { _ in
+ newPlayer.play()
+ }
+ }
+
+ // Observe the item's playback readiness so we can surface failures.
+ // Token is stored as a property so the observation stays alive.
+ statusObservation = playerItem.observe(\.status, options: [.new]) { item, _ in
+ switch item.status {
+ case .failed:
+ let message = item.error?.localizedDescription ?? "Unknown playback error"
+ AppLogger.playback.error("Playback failed: \(message, privacy: .public)")
+ AppErrorPresenter.report(
+ title: "Playback Failed",
+ message: message,
+ recoverySuggestion: "The file may be corrupt or use an unsupported codec."
+ )
+ case .readyToPlay:
+ AppLogger.playback.debug("Player item ready")
+ case .unknown:
+ break
+ @unknown default:
+ break
+ }
+ }
+
+ // Observe `timeControlStatus` to surface silent stalls. The KVO
+ // callback fires on an arbitrary thread, so it must hop to the main
+ // actor before touching any view state.
+ stallObservation = newPlayer.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
+ let status = player.timeControlStatus
+ Task { @MainActor [weak self] in
+ self?.handleTimeControlStatus(status)
+ }
+ }
+
+ newPlayer.play()
+ }
+
+ @MainActor
+ private func handleTimeControlStatus(_ status: AVPlayer.TimeControlStatus) {
+ switch status {
+ case .playing:
+ hasPlayedAtLeastOnce = true
+ cancelStallTimer()
+ case .waitingToPlayAtSpecifiedRate:
+ // Suppress the very first buffering pass — every newly created
+ // player enters this state before the first frame is decoded.
+ guard hasPlayedAtLeastOnce else { return }
+ armStallTimer()
+ case .paused:
+ cancelStallTimer()
+ @unknown default:
+ break
+ }
+ }
+
+ @MainActor
+ private func armStallTimer() {
+ cancelStallTimer()
+ stallTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(for: WallpaperPlayerView.stallThreshold)
+ guard !Task.isCancelled, let self else { return }
+ // Re-check the player is still wedged before we cry wolf.
+ if self.player?.timeControlStatus == .waitingToPlayAtSpecifiedRate {
+ self.reportStall()
+ }
+ }
+ }
+
+ @MainActor
+ private func cancelStallTimer() {
+ stallTask?.cancel()
+ stallTask = nil
+ }
+
+ @MainActor
+ private func reportStall() {
+ if let last = lastStallReport, Date().timeIntervalSince(last) < WallpaperPlayerView.stallReportCooldown {
+ return
+ }
+ lastStallReport = Date()
+ AppLogger.playback.warning("Wallpaper playback stalled (waiting to play)")
+ AppErrorPresenter.report(
+ title: "Playback Stalled",
+ message: "A wallpaper has been buffering for an unusually long time.",
+ recoverySuggestion: "Check your network connection."
+ )
+ }
+
+ func pause() {
+ player?.pause()
+ }
+
+ func resume() {
+ player?.play()
+ }
+
+ func stop() {
+ if let observer = notificationObserver {
+ NotificationCenter.default.removeObserver(observer)
+ notificationObserver = nil
+ }
+ statusObservation?.invalidate()
+ statusObservation = nil
+ stallObservation?.invalidate()
+ stallObservation = nil
+ cancelStallTimer()
+ hasPlayedAtLeastOnce = false
+ lastStallReport = nil
+ player?.pause()
+ playerLayer?.player = nil
+ player = nil
+ }
+
+ override func layout() {
+ super.layout()
+ playerLayer?.frame = bounds
+ }
+}
+
+final class WallpaperWindow: NSWindow {
+ let playerView: WallpaperPlayerView
+
+ init(contentRect: CGRect) {
+ playerView = WallpaperPlayerView(frame: NSRect(origin: .zero, size: contentRect.size))
+
+ super.init(
+ contentRect: contentRect,
+ styleMask: [.borderless],
+ backing: .buffered,
+ defer: false
+ )
+
+ configureWindow()
+ }
+
+ private func configureWindow() {
+ level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1)
+ isOpaque = true
+ backgroundColor = .black
+ ignoresMouseEvents = true
+ collectionBehavior = [
+ .canJoinAllSpaces,
+ .stationary,
+ .ignoresCycle,
+ .fullScreenAuxiliary
+ ]
+ hasShadow = false
+ isReleasedWhenClosed = false
+ acceptsMouseMovedEvents = false
+ canHide = false
+ hidesOnDeactivate = false
+ animationBehavior = .none
+
+ contentView = playerView
+ }
+
+ func play(url: URL) {
+ playerView.play(url: url)
+ }
+
+ func pause() {
+ playerView.pause()
+ }
+
+ func resume() {
+ playerView.resume()
+ }
+
+ func stop() {
+ playerView.stop()
+ }
+
+ func updateFrame(_ rect: CGRect) {
+ setFrame(rect, display: true)
+ }
+}
diff --git a/livewall/livewall/Views/DesignSystem.swift b/livewall/livewall/Views/DesignSystem.swift
new file mode 100644
index 0000000..5859ee8
--- /dev/null
+++ b/livewall/livewall/Views/DesignSystem.swift
@@ -0,0 +1,56 @@
+import SwiftUI
+
+enum Metrics {
+ static let spacingXS: CGFloat = 4
+ static let spacingS: CGFloat = 8
+ static let spacingM: CGFloat = 12
+ static let spacingL: CGFloat = 16
+ static let spacingXL: CGFloat = 20
+ static let spacingXXL: CGFloat = 28
+
+ static let radiusS: CGFloat = 10
+ static let radiusM: CGFloat = 16
+ static let radiusL: CGFloat = 22
+ static let radiusXL: CGFloat = 28
+}
+
+enum Palette {
+ static let activeGreenTint = Color.green.opacity(0.24)
+}
+
+struct StatusPill: View {
+ let title: String
+ var systemImage: String? = nil
+ var tint: Color? = nil
+
+ var body: some View {
+ Group {
+ if let systemImage {
+ Label(title, systemImage: systemImage)
+ } else {
+ Text(title)
+ }
+ }
+ .font(.caption.weight(.medium))
+ .padding(.horizontal, Metrics.spacingM)
+ .padding(.vertical, 7)
+ .glassEffect(
+ tint.map { .regular.tint($0.opacity(0.24)) } ?? .regular,
+ in: .capsule
+ )
+ }
+}
+
+struct ActiveBadge: View {
+ var text: String = "Active"
+ var compact: Bool = false
+
+ var body: some View {
+ Label(text, systemImage: "checkmark.circle.fill")
+ .font(compact ? .system(size: 10, weight: .semibold) : .caption.weight(.semibold))
+ .foregroundStyle(.primary)
+ .padding(.horizontal, compact ? Metrics.spacingS : Metrics.spacingM)
+ .padding(.vertical, compact ? 4 : 6)
+ .glassEffect(.regular.tint(Palette.activeGreenTint), in: .capsule)
+ }
+}
diff --git a/livewall/livewall/Views/GalleryView.swift b/livewall/livewall/Views/GalleryView.swift
new file mode 100644
index 0000000..447a66a
--- /dev/null
+++ b/livewall/livewall/Views/GalleryView.swift
@@ -0,0 +1,628 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct GalleryView: View {
+ @State private var selectedWallpaper: Wallpaper?
+ @State private var searchQuery = ""
+ @State private var selectedTag: String = ""
+ @State private var showWelcome = false
+ @State private var isDropTargeted = false
+
+ @AppStorage("hasSeenWelcome") private var hasSeenWelcome = false
+
+ @Environment(\.openWindow) private var openWindow
+
+ @ObservedObject private var catalog = WallpaperCatalog.shared
+ @ObservedObject private var engine = WallpaperEngine.shared
+ @ObservedObject private var downloadManager = DownloadManager.shared
+ @ObservedObject private var displayManager = DisplayManager.shared
+
+ private let columns = [
+ GridItem(.adaptive(minimum: 220, maximum: 260), spacing: Metrics.spacingXXL)
+ ]
+
+ var body: some View {
+ ZStack {
+ AppAmbientBackground()
+
+ Group {
+ if let selected = selectedWallpaper {
+ detailPane(for: selected)
+ } else {
+ galleryPane
+ }
+ }
+ }
+ .frame(minWidth: 760, minHeight: 560)
+ .sheet(isPresented: $showWelcome) {
+ WelcomeSheet(onDismiss: {
+ hasSeenWelcome = true
+ showWelcome = false
+ })
+ }
+ .onAppear {
+ if !hasSeenWelcome {
+ // Defer to next runloop so the sheet lands after the window is presented.
+ DispatchQueue.main.async {
+ showWelcome = true
+ }
+ }
+ }
+ }
+
+ // MARK: - Gallery
+
+ private var activeWallpaperIDs: Set {
+ Set(engine.activeWallpapers.values.map(\.id))
+ }
+
+ private var localWallpaperCount: Int {
+ catalog.allWallpapers.filter(\.isLocal).count
+ }
+
+ private var displayCount: Int {
+ max(displayManager.displays.count, 1)
+ }
+
+ @ViewBuilder
+ private var galleryPane: some View {
+ Group {
+ if filteredWallpapers.isEmpty {
+ emptyStateView
+ } else {
+ ScrollView {
+ LazyVStack(spacing: Metrics.spacingXXL, pinnedViews: [.sectionHeaders]) {
+ heroSection
+
+ if !engine.activeWallpapers.isEmpty {
+ nowPlayingSection
+ }
+
+ Section {
+ LazyVGrid(columns: columns, spacing: Metrics.spacingXXL) {
+ ForEach(filteredWallpapers) { wallpaper in
+ WallpaperCardView(
+ wallpaper: wallpaper,
+ isActive: activeWallpaperIDs.contains(wallpaper.id),
+ isStale: catalog.staleLocalWallpaperIDs.contains(wallpaper.id)
+ ) {
+ withAnimation(.spring(duration: 0.35, bounce: 0.1)) {
+ selectedWallpaper = wallpaper
+ }
+ }
+ }
+ }
+ .padding(.horizontal, Metrics.spacingXL)
+ .padding(.bottom, Metrics.spacingXL)
+ } header: {
+ if !catalog.allTags.isEmpty || !searchQuery.isEmpty {
+ tagFilterStrip
+ }
+ }
+ }
+ .padding(.top, Metrics.spacingXL)
+ .padding(.bottom, Metrics.spacingXXL)
+ }
+ }
+ }
+ .searchable(
+ text: $searchQuery,
+ placement: .toolbar,
+ prompt: "Search wallpapers"
+ )
+ .onDrop(of: [UTType.movie.identifier, UTType.mpeg4Movie.identifier, UTType.quickTimeMovie.identifier], isTargeted: $isDropTargeted) { providers in
+ handleDrop(providers: providers)
+ }
+ .overlay {
+ if isDropTargeted {
+ dropTargetOverlay
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .navigation) {
+ Label("LiveWall", systemImage: "sparkles.tv.fill")
+ .font(.headline.weight(.semibold))
+ }
+
+ ToolbarItemGroup(placement: .primaryAction) {
+ Button {
+ openWindow(id: "import")
+ } label: {
+ Label("Import", systemImage: "square.and.arrow.down")
+ }
+ .buttonStyle(.glassProminent)
+ .help("Import a video from your Mac — or drop one onto the window")
+
+ Button {
+ openWindow(id: "settings")
+ } label: {
+ Label("Settings", systemImage: "gearshape")
+ }
+ .buttonStyle(.glass)
+ .help("Preferences and displays")
+ }
+ }
+ .navigationTitle("LiveWall")
+ }
+
+ private var heroSection: some View {
+ VStack(alignment: .leading, spacing: Metrics.spacingL) {
+ HStack(alignment: .top, spacing: Metrics.spacingL) {
+ VStack(alignment: .leading, spacing: Metrics.spacingS) {
+ Text("Make your desktop feel alive.")
+ .font(.largeTitle.weight(.bold))
+
+ Text("Browse the catalog, import your own loops, and send motion to every display.")
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Spacer(minLength: 0)
+
+ VStack(alignment: .trailing, spacing: Metrics.spacingS) {
+ heroMetric(
+ value: "\(catalog.allWallpapers.count)",
+ title: "Wallpapers",
+ systemImage: "rectangle.stack.fill"
+ )
+ heroMetric(
+ value: "\(displayCount)",
+ title: displayCount == 1 ? "Display Ready" : "Displays Ready",
+ systemImage: "display.2"
+ )
+ }
+ }
+
+ HStack(spacing: Metrics.spacingM) {
+ Button {
+ openWindow(id: "import")
+ } label: {
+ Label("Import a Video", systemImage: "square.and.arrow.down")
+ .frame(minWidth: 160)
+ }
+ .buttonStyle(.glassProminent)
+ .controlSize(.large)
+
+ StatusPill(
+ title: activeWallpaperIDs.isEmpty ? "Ready to apply" : "\(activeWallpaperIDs.count) active",
+ systemImage: activeWallpaperIDs.isEmpty ? "sparkles" : "play.circle.fill",
+ tint: activeWallpaperIDs.isEmpty ? nil : .green
+ )
+ StatusPill(
+ title: "\(localWallpaperCount) imported",
+ systemImage: "folder.fill"
+ )
+
+ Spacer(minLength: 0)
+ }
+ }
+ .padding(Metrics.spacingXXL)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusXL, style: .continuous)
+ .fill(.regularMaterial)
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusXL, style: .continuous)
+ .fill(
+ LinearGradient(
+ colors: [
+ Color.accentColor.opacity(0.08),
+ Color.clear
+ ],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusXL, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.10), lineWidth: 1)
+ }
+ .shadow(color: .black.opacity(0.06), radius: 18, x: 0, y: 10)
+ }
+ .padding(.horizontal, Metrics.spacingXL)
+ }
+
+ private func heroMetric(value: String, title: String, systemImage: String) -> some View {
+ HStack(spacing: Metrics.spacingS) {
+ Image(systemName: systemImage)
+ .font(.headline)
+ .foregroundStyle(.tint)
+ .frame(width: 22)
+
+ VStack(alignment: .leading, spacing: 1) {
+ Text(value)
+ .font(.headline.weight(.semibold))
+ Text(title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.horizontal, Metrics.spacingM)
+ .padding(.vertical, Metrics.spacingS)
+ .glassEffect(.regular, in: .capsule)
+ }
+
+ // MARK: - Now Playing
+
+ private var nowPlayingSection: some View {
+ VStack(alignment: .leading, spacing: Metrics.spacingM) {
+ HStack(alignment: .top, spacing: Metrics.spacingM) {
+ VStack(alignment: .leading, spacing: Metrics.spacingXS) {
+ Text("Now Playing")
+ .font(.title3.weight(.semibold))
+
+ Text(engine.isPaused ? "Playback is paused across your desktop." : "Quick access to every live wallpaper currently running.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Button(engine.isPaused ? "Resume All" : "Pause All") {
+ if engine.isPaused {
+ engine.resumeAll()
+ } else {
+ engine.pauseAll()
+ }
+ }
+ .buttonStyle(.glass)
+ .controlSize(.regular)
+ }
+ .padding(.horizontal, Metrics.spacingXL)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: Metrics.spacingM) {
+ ForEach(orderedNowPlaying, id: \.displayID) { entry in
+ nowPlayingCard(displayID: entry.displayID, wallpaper: entry.wallpaper)
+ }
+ }
+ .padding(.horizontal, Metrics.spacingXL)
+ .padding(.vertical, Metrics.spacingXS)
+ }
+ .mask {
+ HStack(spacing: 0) {
+ LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing)
+ .frame(width: Metrics.spacingXL)
+ Rectangle().fill(.black)
+ LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing)
+ .frame(width: Metrics.spacingXL)
+ }
+ }
+ }
+ }
+
+ private var orderedNowPlaying: [(displayID: String, wallpaper: Wallpaper)] {
+ let ordered = displayManager.displays.compactMap { display -> (String, Wallpaper)? in
+ guard let wp = engine.activeWallpapers[display.id] else { return nil }
+ return (display.id, wp)
+ }
+ if !ordered.isEmpty { return ordered }
+ // Fallback: if displayManager hasn't populated for some reason, use sorted keys.
+ return engine.activeWallpapers.keys.sorted().compactMap { key in
+ engine.activeWallpapers[key].map { (key, $0) }
+ }
+ }
+
+ private func nowPlayingCard(displayID: String, wallpaper: Wallpaper) -> some View {
+ VStack(alignment: .leading, spacing: Metrics.spacingM) {
+ Button {
+ withAnimation(.spring(duration: 0.35, bounce: 0.1)) {
+ selectedWallpaper = wallpaper
+ }
+ } label: {
+ VStack(alignment: .leading, spacing: Metrics.spacingS) {
+ HStack(spacing: Metrics.spacingS) {
+ Image(systemName: "display")
+ .font(.title3)
+ .foregroundStyle(.tint)
+ .frame(width: 24)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(displayName(for: displayID))
+ .font(.caption.weight(.semibold))
+ Text(wallpaper.title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+
+ HStack(spacing: Metrics.spacingXS) {
+ StatusPill(
+ title: wallpaper.isLocal ? "Imported" : "Catalog",
+ systemImage: wallpaper.isLocal ? "folder" : "sparkles.rectangle.stack"
+ )
+
+ if let duration = wallpaper.duration {
+ StatusPill(
+ title: String(format: "%.0fs", duration),
+ systemImage: "clock"
+ )
+ }
+ }
+ }
+ .frame(width: 220, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+
+ HStack {
+ Button {
+ engine.stop(forDisplay: displayID)
+ } label: {
+ Label("Remove", systemImage: "stop.circle")
+ }
+ .buttonStyle(.glass)
+ .controlSize(.small)
+ .help("Remove wallpaper from \(displayName(for: displayID))")
+
+ Spacer()
+
+ Image(systemName: "waveform")
+ .font(.caption)
+ .foregroundStyle(
+ engine.isPaused
+ ? AnyShapeStyle(.secondary)
+ : AnyShapeStyle(.tint)
+ )
+ }
+ }
+ .padding(Metrics.spacingL)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .fill(.regularMaterial)
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
+ }
+ }
+ }
+
+ private func displayName(for id: String) -> String {
+ displayManager.displays.first(where: { $0.id == id })?.localizedName ?? "Display"
+ }
+
+ // MARK: - Tag strip
+
+ private var tagFilterStrip: some View {
+ VStack(alignment: .leading, spacing: Metrics.spacingM) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(searchQuery.isEmpty ? "Filter by vibe" : "Refine your search")
+ .font(.headline)
+ Text(selectedTag.isEmpty ? "Choose a tag to tighten the gallery." : "Showing \(selectedTag.lowercased()) wallpapers.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ StatusPill(title: "\(filteredWallpapers.count) shown")
+ }
+ .padding(.horizontal, Metrics.spacingXL)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: Metrics.spacingS) {
+ tagChip(title: "All", tag: "")
+ ForEach(catalog.allTags, id: \.self) { tag in
+ tagChip(title: tag, tag: tag)
+ }
+ }
+ .padding(.horizontal, Metrics.spacingXL)
+ .padding(.bottom, Metrics.spacingS)
+ }
+ }
+ .padding(.top, Metrics.spacingM)
+ .background {
+ Rectangle()
+ .fill(.regularMaterial)
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .fill(Color.primary.opacity(0.08))
+ .frame(height: 0.5)
+ }
+ }
+ }
+
+ private func tagChip(title: String, tag: String) -> some View {
+ let isSelected = selectedTag == tag
+ return Button {
+ withAnimation(.easeOut(duration: 0.15)) {
+ selectedTag = tag
+ }
+ } label: {
+ Text(title)
+ .font(.caption.weight(.medium))
+ .padding(.horizontal, Metrics.spacingM)
+ .padding(.vertical, Metrics.spacingXS + 2)
+ }
+ .buttonStyle(.plain)
+ .glassEffect(
+ isSelected ? .regular.tint(.accentColor.opacity(0.55)) : .regular,
+ in: .capsule
+ )
+ .foregroundStyle(isSelected ? Color.white : .primary)
+ }
+
+ // MARK: - Detail
+
+ @ViewBuilder
+ private func detailPane(for wallpaper: Wallpaper) -> some View {
+ VStack(spacing: 0) {
+ HStack(spacing: Metrics.spacingM) {
+ Button {
+ withAnimation(.spring(duration: 0.35, bounce: 0.05)) {
+ selectedWallpaper = nil
+ }
+ } label: {
+ Label("Gallery", systemImage: "chevron.left")
+ }
+ .buttonStyle(.glass)
+ .controlSize(.regular)
+ .keyboardShortcut(.cancelAction)
+
+ Text(wallpaper.title)
+ .font(.headline)
+ .lineLimit(1)
+
+ Spacer()
+
+ if activeWallpaperIDs.contains(wallpaper.id) {
+ StatusPill(
+ title: "Live on your desktop",
+ systemImage: "play.circle.fill",
+ tint: .green
+ )
+ }
+ }
+ .padding(.horizontal, Metrics.spacingXL)
+ .padding(.top, Metrics.spacingM)
+ .padding(.bottom, Metrics.spacingS)
+
+ WallpaperDetailView(wallpaper: wallpaper) {
+ withAnimation(.spring(duration: 0.35, bounce: 0.05)) {
+ selectedWallpaper = nil
+ }
+ }
+ }
+ }
+
+ // MARK: - Data
+
+ private var filteredWallpapers: [Wallpaper] {
+ var results = catalog.allWallpapers
+
+ if !searchQuery.isEmpty {
+ results = catalog.search(query: searchQuery)
+ }
+
+ if !selectedTag.isEmpty {
+ results = results.filter { $0.tags.contains(selectedTag) }
+ }
+
+ return results
+ }
+
+ private var emptyStateView: some View {
+ ContentUnavailableView {
+ Label(
+ searchQuery.isEmpty ? "Start Your Collection" : "No Matches",
+ systemImage: searchQuery.isEmpty ? "sparkles.rectangle.stack" : "magnifyingglass"
+ )
+ } description: {
+ Text(searchQuery.isEmpty
+ ? "Import a video from your Mac, or drop an MP4 or MOV onto this window."
+ : "Try a different search term or clear the active tag filter.")
+ } actions: {
+ if searchQuery.isEmpty {
+ Button {
+ openWindow(id: "import")
+ } label: {
+ Label("Import Wallpaper", systemImage: "square.and.arrow.down")
+ }
+ .buttonStyle(.glassProminent)
+ .controlSize(.large)
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(Metrics.spacingXXL)
+ }
+
+ // MARK: - Drop target overlay
+
+ private var dropTargetOverlay: some View {
+ ZStack {
+ Rectangle()
+ .fill(.ultraThinMaterial)
+
+ Rectangle()
+ .fill(Color.accentColor.opacity(0.18))
+
+ VStack(spacing: Metrics.spacingM) {
+ Image(systemName: "square.and.arrow.down.on.square.fill")
+ .font(.system(size: 56, weight: .light))
+ .foregroundStyle(.tint)
+
+ Text("Drop to Import")
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(.primary)
+
+ Text("MP4 or MOV")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(Metrics.spacingXXL)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .fill(.regularMaterial)
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .strokeBorder(Color.accentColor.opacity(0.6), style: StrokeStyle(lineWidth: 2, dash: [8, 6]))
+ }
+ }
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(false)
+ .transition(.opacity)
+ }
+
+ // MARK: - Drop handler
+
+ private func handleDrop(providers: [NSItemProvider]) -> Bool {
+ guard let provider = providers.first else {
+ AppErrorPresenter.report(
+ title: "Couldn't Import",
+ message: "No file was dropped.",
+ recoverySuggestion: "Try dragging the video file again."
+ )
+ return false
+ }
+
+ let targetTypes = [UTType.quickTimeMovie.identifier, UTType.mpeg4Movie.identifier, UTType.movie.identifier]
+ guard let matchingType = targetTypes.first(where: { provider.hasItemConformingToTypeIdentifier($0) }) else {
+ AppErrorPresenter.report(
+ title: "Unsupported File",
+ message: "That file isn't a supported video format.",
+ recoverySuggestion: "Drop an MP4 or MOV file."
+ )
+ return false
+ }
+
+ provider.loadItem(forTypeIdentifier: matchingType, options: nil) { item, error in
+ if let error = error {
+ AppErrorPresenter.report(
+ title: "Couldn't Import",
+ message: error.localizedDescription,
+ recoverySuggestion: "Try dragging the file again."
+ )
+ return
+ }
+
+ var url: URL?
+ if let directURL = item as? URL {
+ url = directURL
+ } else if let data = item as? Data {
+ url = URL(dataRepresentation: data, relativeTo: nil)
+ }
+
+ guard let fileURL = url else {
+ AppErrorPresenter.report(
+ title: "Couldn't Import",
+ message: "Couldn't read the dropped file's location.",
+ recoverySuggestion: "Try importing via the toolbar button instead."
+ )
+ return
+ }
+
+ Task { @MainActor in
+ // catalog.addLocalWallpaper surfaces its own errors via the presenter on failure.
+ if let wallpaper = await catalog.addLocalWallpaper(fileURL: fileURL) {
+ engine.apply(wallpaper, scope: .allDisplays)
+ }
+ }
+ }
+
+ return true
+ }
+}
diff --git a/livewall/livewall/Views/ImportView.swift b/livewall/livewall/Views/ImportView.swift
new file mode 100644
index 0000000..af9aff0
--- /dev/null
+++ b/livewall/livewall/Views/ImportView.swift
@@ -0,0 +1,129 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct ImportView: View {
+ @State private var isShowingFilePicker = false
+ @State private var isImporting = false
+ @Environment(\.dismiss) private var dismiss
+
+ private let catalog = WallpaperCatalog.shared
+ private let engine = WallpaperEngine.shared
+
+ var body: some View {
+ ZStack {
+ AppAmbientBackground()
+
+ VStack(spacing: 0) {
+ Spacer(minLength: 0)
+
+ VStack(spacing: Metrics.spacingL) {
+ Image(systemName: "square.and.arrow.down.on.square")
+ .font(.system(size: 56, weight: .light))
+ .symbolRenderingMode(.hierarchical)
+ .foregroundStyle(.tint)
+
+ VStack(spacing: Metrics.spacingS) {
+ Text("Import a Wallpaper")
+ .font(.largeTitle.weight(.bold))
+
+ Text("Select an MP4 or MOV video file from your Mac and LiveWall will import it into your personal library.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 360)
+ }
+
+ HStack(spacing: Metrics.spacingS) {
+ StatusPill(title: "MP4 + MOV", systemImage: "film")
+ StatusPill(title: "Applies to all displays", systemImage: "display.2")
+ StatusPill(title: "Local-first", systemImage: "folder.fill")
+ }
+
+ Text("Tip: short seamless loops feel best as wallpapers.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ VStack(spacing: Metrics.spacingM) {
+ if isImporting {
+ ProgressView("Importing…")
+ .controlSize(.regular)
+ .padding(.horizontal, Metrics.spacingL)
+ .padding(.vertical, Metrics.spacingM)
+ .glassEffect(.regular, in: .capsule)
+ } else {
+ Button {
+ isShowingFilePicker = true
+ } label: {
+ Label("Choose Video File", systemImage: "folder")
+ .frame(minWidth: 200)
+ }
+ .buttonStyle(.glassProminent)
+ .controlSize(.large)
+ .keyboardShortcut(.defaultAction)
+
+ Button("Cancel") {
+ dismiss()
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ .font(.callout)
+ .keyboardShortcut(.cancelAction)
+ }
+ }
+ }
+ .padding(Metrics.spacingXXL + Metrics.spacingM)
+ .frame(maxWidth: 520)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusXL, style: .continuous)
+ .fill(.regularMaterial)
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusXL, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.10), lineWidth: 1)
+ }
+ .shadow(color: .black.opacity(0.08), radius: 20, x: 0, y: 12)
+ }
+
+ Spacer(minLength: 0)
+ }
+ .padding(Metrics.spacingXL)
+ }
+ .frame(minWidth: 520, minHeight: 440)
+ .fileImporter(
+ isPresented: $isShowingFilePicker,
+ allowedContentTypes: [.mpeg4Movie, .quickTimeMovie, .video],
+ allowsMultipleSelection: false
+ ) { result in
+ Task {
+ switch result {
+ case .success(let urls):
+ if let url = urls.first {
+ await importFile(url)
+ }
+ case .failure(let error):
+ AppErrorPresenter.report(
+ title: "Couldn't Open File",
+ message: error.localizedDescription,
+ recoverySuggestion: "Try selecting a different file."
+ )
+ }
+ }
+ }
+ }
+
+ private func importFile(_ url: URL) async {
+ isImporting = true
+ defer { isImporting = false }
+
+ let accessGranted = url.startAccessingSecurityScopedResource()
+ defer {
+ if accessGranted {
+ url.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ if let wallpaper = await catalog.addLocalWallpaper(fileURL: url) {
+ engine.apply(wallpaper, scope: .allDisplays)
+ dismiss()
+ }
+ }
+}
diff --git a/livewall/livewall/Views/MenuBarExtraView.swift b/livewall/livewall/Views/MenuBarExtraView.swift
new file mode 100644
index 0000000..9d25137
--- /dev/null
+++ b/livewall/livewall/Views/MenuBarExtraView.swift
@@ -0,0 +1,161 @@
+import SwiftUI
+
+struct MenuBarExtraView: View {
+ @Environment(\.openWindow) private var openWindow
+ @ObservedObject private var engine = WallpaperEngine.shared
+ @ObservedObject private var displayManager = DisplayManager.shared
+
+ private var orderedActive: [(display: DisplayInfo, wallpaper: Wallpaper)] {
+ displayManager.displays.compactMap { display in
+ engine.activeWallpapers[display.id].map { (display, $0) }
+ }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Metrics.spacingM) {
+ header
+
+ Button(engine.isPaused ? "Resume Wallpapers" : "Pause Wallpapers") {
+ if engine.isPaused {
+ engine.resumeAll()
+ } else {
+ engine.pauseAll()
+ }
+ }
+ .buttonStyle(.glassProminent)
+ .controlSize(.regular)
+ .keyboardShortcut("p", modifiers: [.command, .shift])
+ .frame(maxWidth: .infinity)
+
+ if !orderedActive.isEmpty {
+ activeWallpapersSection
+ }
+
+ VStack(spacing: Metrics.spacingS) {
+ menuAction(title: "Open LiveWall", systemImage: "sparkles.tv.fill") {
+ openWindow(id: "main")
+ }
+ .keyboardShortcut("o", modifiers: .command)
+
+ menuAction(title: "Import Wallpaper…", systemImage: "square.and.arrow.down") {
+ openWindow(id: "import")
+ }
+
+ menuAction(title: "Settings…", systemImage: "gearshape") {
+ openWindow(id: "settings")
+ }
+ .keyboardShortcut(",", modifiers: .command)
+ }
+ .padding(Metrics.spacingXS)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusM, style: .continuous)
+ .fill(Color.primary.opacity(0.04))
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusM, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
+ }
+ }
+
+ Button {
+ NSApplication.shared.terminate(nil)
+ } label: {
+ Label("Quit LiveWall", systemImage: "power")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.glass)
+ .controlSize(.regular)
+ .tint(.red)
+ .keyboardShortcut("q", modifiers: .command)
+ }
+ .padding(Metrics.spacingL)
+ .frame(width: 320)
+ }
+
+ private var header: some View {
+ HStack(alignment: .center) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("LiveWall")
+ .font(.headline)
+ Text(statusText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: engine.isPaused ? "pause.circle.fill" : "play.circle.fill")
+ .font(.title2)
+ .foregroundStyle(
+ engine.isPaused
+ ? AnyShapeStyle(.secondary)
+ : AnyShapeStyle(.tint)
+ )
+ }
+ }
+
+ private var statusText: String {
+ if engine.isPaused { return "Playback paused" }
+ let count = engine.activeWallpapers.count
+ return count == 1 ? "1 active wallpaper" : "\(count) active wallpapers"
+ }
+
+ private var activeWallpapersSection: some View {
+ VStack(alignment: .leading, spacing: Metrics.spacingS) {
+ Text("Active Wallpapers")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ VStack(spacing: Metrics.spacingXS) {
+ ForEach(orderedActive, id: \.display.id) { entry in
+ activeRow(display: entry.display, wallpaper: entry.wallpaper)
+ }
+ }
+ }
+ }
+
+ private func activeRow(display: DisplayInfo, wallpaper: Wallpaper) -> some View {
+ HStack(spacing: Metrics.spacingS) {
+ Image(systemName: "display")
+ .foregroundStyle(.tint)
+ .frame(width: 18)
+
+ VStack(alignment: .leading, spacing: 1) {
+ Text(wallpaper.title)
+ .font(.callout)
+ .lineLimit(1)
+ Text(display.localizedName)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer()
+ }
+ .padding(.horizontal, Metrics.spacingM)
+ .padding(.vertical, Metrics.spacingS)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusS, style: .continuous)
+ .fill(Palette.activeGreenTint)
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusS, style: .continuous)
+ .strokeBorder(Color.green.opacity(0.30), lineWidth: 1)
+ }
+ }
+ }
+
+ private func menuAction(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ HStack(spacing: Metrics.spacingS) {
+ Image(systemName: systemImage)
+ .foregroundStyle(.tint)
+ .frame(width: 18)
+ Text(title)
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ .padding(.horizontal, Metrics.spacingS)
+ .padding(.vertical, Metrics.spacingS)
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/livewall/livewall/Views/SettingsView.swift b/livewall/livewall/Views/SettingsView.swift
new file mode 100644
index 0000000..14175a5
--- /dev/null
+++ b/livewall/livewall/Views/SettingsView.swift
@@ -0,0 +1,316 @@
+import SwiftUI
+
+struct SettingsView: View {
+ @ObservedObject private var settings = SettingsManager.shared
+ @ObservedObject private var engine = WallpaperEngine.shared
+ @ObservedObject private var displayManager = DisplayManager.shared
+
+ var body: some View {
+ ZStack {
+ AppAmbientBackground()
+
+ TabView {
+ generalTab
+ .tabItem {
+ Label("General", systemImage: "gearshape")
+ }
+
+ displaysTab
+ .tabItem {
+ Label("Displays", systemImage: "display.2")
+ }
+
+ aboutTab
+ .tabItem {
+ Label("About", systemImage: "info.circle")
+ }
+ }
+ }
+ .frame(minWidth: 520, minHeight: 360)
+ }
+
+ private var generalTab: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: Metrics.spacingXL) {
+ settingsHero(
+ title: "Playback, without the clutter.",
+ detail: "Keep LiveWall responsive, battery-aware, and quietly present in the background.",
+ systemImage: "dial.low"
+ )
+
+ settingsSection(
+ title: "Performance",
+ detail: "Control how aggressively LiveWall yields to power conditions."
+ ) {
+ preferenceRow(
+ title: "Pause on battery or low power",
+ detail: "Automatically pause wallpapers when running on battery power or in Low Power Mode.",
+ systemImage: "bolt.slash",
+ isOn: $settings.pauseOnBattery
+ )
+
+ Divider()
+
+ preferenceRow(
+ title: "Low power mode",
+ detail: "Reduce CPU usage by pausing wallpaper playback while you focus on heavier tasks.",
+ systemImage: "leaf",
+ isOn: $settings.lowPowerMode
+ )
+ }
+
+ settingsSection(
+ title: "System",
+ detail: "Choose whether LiveWall should already be waiting for you after login."
+ ) {
+ preferenceRow(
+ title: "Launch at login",
+ detail: "Automatically start LiveWall when you log in.",
+ systemImage: "power",
+ isOn: $settings.launchAtLogin
+ )
+ }
+ }
+ .padding(Metrics.spacingXXL)
+ }
+ }
+
+ private var displaysTab: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: Metrics.spacingXL) {
+ settingsHero(
+ title: "Displays at a glance.",
+ detail: "See what is connected, what is playing, and refresh the layout when your setup changes.",
+ systemImage: "display.2"
+ )
+
+ HStack(spacing: Metrics.spacingS) {
+ StatusPill(
+ title: "\(displayManager.displays.count) connected",
+ systemImage: "display"
+ )
+ StatusPill(
+ title: "\(engine.activeWallpapers.count) active",
+ systemImage: "play.circle.fill",
+ tint: engine.activeWallpapers.isEmpty ? nil : .green
+ )
+
+ Spacer()
+
+ Button {
+ displayManager.refreshDisplays()
+ engine.refreshDisplays()
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ .buttonStyle(.glass)
+ .controlSize(.regular)
+ }
+
+ if displayManager.displays.isEmpty {
+ ContentUnavailableView(
+ "No Displays Detected",
+ systemImage: "display.trianglebadge.exclamationmark",
+ description: Text("Connect a display and tap Refresh.")
+ )
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, Metrics.spacingXXL)
+ } else {
+ VStack(spacing: Metrics.spacingS) {
+ ForEach(displayManager.displays) { display in
+ displayRow(display)
+ }
+ }
+ }
+ }
+ .padding(Metrics.spacingXXL)
+ }
+ }
+
+ private var aboutTab: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: Metrics.spacingXL) {
+ settingsHero(
+ title: "LiveWall",
+ detail: "A lightweight live wallpaper app for macOS that keeps the desktop cinematic without turning the rest of the system into noise.",
+ systemImage: "sparkles.tv.fill"
+ )
+
+ settingsSection(
+ title: "What it does well",
+ detail: "The product is intentionally focused and desktop-native."
+ ) {
+ aboutRow(
+ title: "Multi-display playback",
+ detail: "Run a different live wallpaper on each connected screen.",
+ systemImage: "rectangle.on.rectangle"
+ )
+
+ Divider()
+
+ aboutRow(
+ title: "Hover and inline previews",
+ detail: "See motion before applying whenever the video is already on disk.",
+ systemImage: "play.rectangle"
+ )
+
+ Divider()
+
+ aboutRow(
+ title: "Power-aware behavior",
+ detail: "Pause automatically on battery or low power when you want it to.",
+ systemImage: "bolt.badge.clock"
+ )
+ }
+
+ HStack(spacing: Metrics.spacingS) {
+ StatusPill(title: "Version 1.0", systemImage: "app.badge")
+ StatusPill(title: "macOS 26+", systemImage: "desktopcomputer")
+ }
+ }
+ .padding(Metrics.spacingXXL)
+ }
+ }
+
+ private func settingsHero(title: String, detail: String, systemImage: String) -> some View {
+ HStack(alignment: .top, spacing: Metrics.spacingL) {
+ VStack(alignment: .leading, spacing: Metrics.spacingS) {
+ Text(title)
+ .font(.largeTitle.weight(.bold))
+
+ Text(detail)
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Spacer(minLength: 0)
+
+ Image(systemName: systemImage)
+ .font(.system(size: 28, weight: .semibold))
+ .foregroundStyle(.tint)
+ .padding(Metrics.spacingL)
+ .glassEffect(.regular, in: .rect(cornerRadius: Metrics.radiusM))
+ }
+ .padding(Metrics.spacingXL)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .fill(.regularMaterial)
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
+ }
+ }
+ }
+
+ private func settingsSection(title: String, detail: String, @ViewBuilder content: () -> Content) -> some View {
+ VStack(alignment: .leading, spacing: Metrics.spacingM) {
+ VStack(alignment: .leading, spacing: Metrics.spacingXS) {
+ Text(title)
+ .font(.headline)
+ Text(detail)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+
+ VStack(spacing: 0) {
+ content()
+ }
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .fill(Color.primary.opacity(0.04))
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
+ }
+ }
+ }
+ }
+
+ private func preferenceRow(title: String, detail: String, systemImage: String, isOn: Binding) -> some View {
+ Toggle(isOn: isOn) {
+ HStack(alignment: .top, spacing: Metrics.spacingM) {
+ Image(systemName: systemImage)
+ .font(.title3)
+ .foregroundStyle(.tint)
+ .frame(width: 26)
+
+ VStack(alignment: .leading, spacing: Metrics.spacingXS) {
+ Text(title)
+ .font(.callout.weight(.semibold))
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .padding(Metrics.spacingL)
+ }
+ .toggleStyle(.switch)
+ }
+
+ private func displayRow(_ display: DisplayInfo) -> some View {
+ let activeWP = engine.activeWallpapers[display.id]
+ return HStack(spacing: Metrics.spacingM) {
+ Image(systemName: "display")
+ .font(.title3)
+ .foregroundStyle(activeWP == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tint))
+ .frame(width: 24)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(display.localizedName)
+ .font(.callout.weight(.medium))
+ Text(String(format: "%.0f × %.0f", display.resolution.width, display.resolution.height))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ if let activeWP {
+ Label(activeWP.title, systemImage: "play.circle.fill")
+ .labelStyle(.titleAndIcon)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ } else {
+ Text("No wallpaper")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .padding(Metrics.spacingL)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusM, style: .continuous)
+ .fill(activeWP == nil ? Color.primary.opacity(0.04) : Palette.activeGreenTint)
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusM, style: .continuous)
+ .strokeBorder(
+ activeWP == nil ? Color.primary.opacity(0.08) : Color.green.opacity(0.35),
+ lineWidth: 1
+ )
+ }
+ }
+ }
+
+ private func aboutRow(title: String, detail: String, systemImage: String) -> some View {
+ HStack(alignment: .top, spacing: Metrics.spacingM) {
+ Image(systemName: systemImage)
+ .font(.title3)
+ .foregroundStyle(.tint)
+ .frame(width: 26)
+
+ VStack(alignment: .leading, spacing: Metrics.spacingXS) {
+ Text(title)
+ .font(.callout.weight(.semibold))
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Spacer(minLength: 0)
+ }
+ .padding(Metrics.spacingL)
+ }
+}
diff --git a/livewall/livewall/Views/VideoPreviewPlayer.swift b/livewall/livewall/Views/VideoPreviewPlayer.swift
new file mode 100644
index 0000000..e8a9b71
--- /dev/null
+++ b/livewall/livewall/Views/VideoPreviewPlayer.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+import AVFoundation
+import AppKit
+
+/// Lightweight in-app video preview backed by the shared `VideoPreviewPool`.
+///
+/// Renders a muted, looping `AVPlayerLayer` scaled to fill its SwiftUI frame.
+/// Players are pooled across cards and detail views, so hovering across a grid
+/// doesn't churn the decode pipeline.
+struct VideoPreviewPlayer: NSViewRepresentable {
+ let wallpaperID: String
+ let url: URL
+ var isPlaying: Bool = true
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(wallpaperID: wallpaperID)
+ }
+
+ func makeNSView(context: Context) -> PlayerContainerView {
+ let view = PlayerContainerView()
+ view.wantsLayer = true
+ view.layer = CALayer()
+ view.layer?.backgroundColor = NSColor.black.cgColor
+ view.layer?.masksToBounds = true
+ let playerLayer = AVPlayerLayer()
+ playerLayer.videoGravity = .resizeAspectFill
+ playerLayer.frame = view.bounds
+ playerLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
+ view.layer?.addSublayer(playerLayer)
+ view.playerLayer = playerLayer
+ return view
+ }
+
+ func updateNSView(_ nsView: PlayerContainerView, context: Context) {
+ context.coordinator.wallpaperID = wallpaperID
+ if isPlaying {
+ let player = VideoPreviewPool.shared.player(for: wallpaperID, url: url)
+ if nsView.playerLayer?.player !== player {
+ nsView.playerLayer?.player = player
+ }
+ player.play()
+ } else {
+ VideoPreviewPool.shared.pause(id: wallpaperID)
+ }
+ }
+
+ static func dismantleNSView(_ nsView: PlayerContainerView, coordinator: Coordinator) {
+ nsView.playerLayer?.player = nil
+ let id = coordinator.wallpaperID
+ Task { @MainActor in
+ VideoPreviewPool.shared.release(id: id)
+ }
+ }
+
+ final class Coordinator {
+ var wallpaperID: String
+ init(wallpaperID: String) { self.wallpaperID = wallpaperID }
+ }
+
+ final class PlayerContainerView: NSView {
+ var playerLayer: AVPlayerLayer?
+
+ override func layout() {
+ super.layout()
+ playerLayer?.frame = bounds
+ }
+ }
+}
diff --git a/livewall/livewall/Views/WallpaperCardView.swift b/livewall/livewall/Views/WallpaperCardView.swift
new file mode 100644
index 0000000..e07217e
--- /dev/null
+++ b/livewall/livewall/Views/WallpaperCardView.swift
@@ -0,0 +1,222 @@
+import SwiftUI
+
+struct WallpaperCardView: View {
+ let wallpaper: Wallpaper
+ let isActive: Bool
+ var isStale: Bool = false
+ let onTap: () -> Void
+
+ @State private var isHovering = false
+ @State private var showPreview = false
+ @State private var hoverTask: Task?
+
+ /// Only show the hover preview if we already have the video locally —
+ /// we never start a remote stream on hover. Stale wallpapers never preview.
+ private var previewURL: URL? {
+ guard !isStale else { return nil }
+ if let local = wallpaper.localFileURL {
+ return local
+ }
+ if let cached = DownloadManager.shared.localURL(for: wallpaper.id) {
+ return cached
+ }
+ return nil
+ }
+
+ var body: some View {
+ Button(action: onTap) {
+ VStack(spacing: 0) {
+ thumbnailArea
+ metadataArea
+ }
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .fill(.regularMaterial)
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .strokeBorder(
+ isHovering ? Color.primary.opacity(0.18) : Color.primary.opacity(0.06),
+ lineWidth: 1
+ )
+ }
+ .clipShape(RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous))
+ .compositingGroup()
+ .shadow(color: .black.opacity(isHovering ? 0.14 : 0.06), radius: isHovering ? 16 : 8, x: 0, y: isHovering ? 8 : 4)
+ .scaleEffect(isHovering ? 1.01 : 1)
+ .animation(.easeOut(duration: 0.18), value: isHovering)
+ }
+ .buttonStyle(.plain)
+ .onHover { hovering in
+ isHovering = hovering
+ handleHoverChange(hovering)
+ }
+ .onDisappear {
+ hoverTask?.cancel()
+ showPreview = false
+ VideoPreviewPool.shared.release(id: wallpaper.id)
+ }
+ }
+
+ // MARK: - Thumbnail area
+
+ private var thumbnailArea: some View {
+ ZStack {
+ ZStack {
+ staticThumbnail
+ .grayscale(isStale ? 0.75 : 0)
+ .opacity(isStale ? 0.75 : 1.0)
+
+ LinearGradient(
+ colors: [
+ Color.clear,
+ Color.black.opacity(0.06)
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+
+ if showPreview, let url = previewURL {
+ VideoPreviewPlayer(
+ wallpaperID: wallpaper.id,
+ url: url,
+ isPlaying: true
+ )
+ .frame(maxWidth: .infinity)
+ .frame(height: 154)
+ .clipped()
+ .transition(.opacity.animation(.easeInOut(duration: 0.25)))
+ }
+ }
+
+ VStack {
+ HStack(alignment: .top) {
+ sourceBadge
+ Spacer(minLength: 0)
+ if isActive && !isStale {
+ ActiveBadge(compact: true)
+ } else if isStale {
+ staleBadge
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(Metrics.spacingS)
+ }
+ .frame(height: 154)
+ }
+
+ @ViewBuilder
+ private var staticThumbnail: some View {
+ if let thumbnailURL = wallpaper.thumbnailURL, let url = URL(string: thumbnailURL) {
+ AsyncImage(url: url) { image in
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(maxWidth: .infinity)
+ .frame(height: 154)
+ .clipped()
+ } placeholder: {
+ placeholderView
+ }
+ } else if let cachedThumb = ThumbnailGenerator.shared.cachedThumbnail(for: wallpaper.id),
+ let nsImage = NSImage(contentsOf: cachedThumb) {
+ Image(nsImage: nsImage)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(maxWidth: .infinity)
+ .frame(height: 154)
+ .clipped()
+ } else {
+ placeholderView
+ }
+ }
+
+ private var staleBadge: some View {
+ Label("Missing", systemImage: "exclamationmark.triangle.fill")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(.primary)
+ .padding(.horizontal, Metrics.spacingS)
+ .padding(.vertical, 4)
+ .glassEffect(.regular.tint(.orange.opacity(0.45)), in: .capsule)
+ }
+
+ private var sourceBadge: some View {
+ Label(wallpaper.isLocal ? "Imported" : wallpaper.resolution.rawValue, systemImage: wallpaper.isLocal ? "folder.fill" : "sparkles.rectangle.stack.fill")
+ .font(.system(size: 10, weight: .semibold))
+ .padding(.horizontal, Metrics.spacingS)
+ .padding(.vertical, 4)
+ .glassEffect(.regular.tint(.black.opacity(0.18)), in: .capsule)
+ .foregroundStyle(.primary)
+ .opacity(isStale ? 0.7 : 1)
+ }
+
+ private var placeholderView: some View {
+ ZStack {
+ Rectangle()
+ .fill(.quaternary)
+ Image(systemName: "film.stack")
+ .font(.system(size: 28, weight: .light))
+ .foregroundStyle(.tertiary)
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 154)
+ }
+
+ // MARK: - Metadata area
+
+ private var metadataArea: some View {
+ VStack(alignment: .leading, spacing: Metrics.spacingS) {
+ Text(wallpaper.title)
+ .font(.callout.weight(.semibold))
+ .lineLimit(2)
+ .foregroundStyle(.primary)
+
+ HStack(alignment: .center, spacing: Metrics.spacingM) {
+ if let duration = wallpaper.duration {
+ cardMetaLabel(title: String(format: "%.0fs", duration), systemImage: "clock")
+ }
+ if previewURL != nil && !isActive {
+ cardMetaLabel(title: "Preview", systemImage: "play.circle")
+ }
+
+ Spacer(minLength: 0)
+
+ if wallpaper.isLocal {
+ Image(systemName: "externaldrive.badge.checkmark")
+ .font(.system(size: 11))
+ .foregroundStyle(.tertiary)
+ .accessibilityLabel("Imported wallpaper")
+ }
+ }
+ }
+ .padding(Metrics.spacingM)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private func cardMetaLabel(title: String, systemImage: String) -> some View {
+ Label(title, systemImage: systemImage)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ // MARK: - Hover debouncing
+
+ private func handleHoverChange(_ hovering: Bool) {
+ hoverTask?.cancel()
+ if hovering && previewURL != nil {
+ hoverTask = Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 500_000_000) // 500ms debounce
+ guard !Task.isCancelled else { return }
+ withAnimation(.easeInOut(duration: 0.25)) {
+ showPreview = true
+ }
+ }
+ } else {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ showPreview = false
+ }
+ VideoPreviewPool.shared.release(id: wallpaper.id)
+ }
+ }
+}
diff --git a/livewall/livewall/Views/WallpaperDetailView.swift b/livewall/livewall/Views/WallpaperDetailView.swift
new file mode 100644
index 0000000..d6fec57
--- /dev/null
+++ b/livewall/livewall/Views/WallpaperDetailView.swift
@@ -0,0 +1,472 @@
+import SwiftUI
+import AVKit
+
+struct WallpaperDetailView: View {
+ let wallpaper: Wallpaper
+ var onRequestClose: (() -> Void)? = nil
+
+ @ObservedObject private var engine = WallpaperEngine.shared
+ @ObservedObject private var downloadManager = DownloadManager.shared
+ @ObservedObject private var catalog = WallpaperCatalog.shared
+ @ObservedObject private var displayManager = DisplayManager.shared
+
+ private var isStale: Bool {
+ catalog.staleLocalWallpaperIDs.contains(wallpaper.id)
+ }
+
+ private var previewURL: URL? {
+ if let local = wallpaper.localFileURL {
+ return local
+ }
+ return downloadManager.localURL(for: wallpaper.id)
+ }
+
+ private var activeDisplayCount: Int {
+ engine.activeWallpapers.values.filter { $0.id == wallpaper.id }.count
+ }
+
+ private var wallpaperSummary: String {
+ if isStale {
+ return "The source file is missing, so this entry can no longer be played."
+ }
+ if wallpaper.isLocal {
+ return "Imported from your Mac and ready to apply anywhere."
+ }
+ if previewURL != nil {
+ return "Cached locally for instant preview and faster applying."
+ }
+ return "Preview becomes fully interactive after the first download."
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: Metrics.spacingXXL) {
+ heroPreview
+
+ summaryPanel
+
+ if isStale {
+ staleNotice
+ } else {
+ displayPicker
+ }
+ }
+ .padding(Metrics.spacingXXL)
+ }
+ .frame(minWidth: 440, minHeight: 520)
+ }
+
+ private var summaryPanel: some View {
+ VStack(alignment: .leading, spacing: Metrics.spacingL) {
+ HStack(alignment: .top, spacing: Metrics.spacingL) {
+ VStack(alignment: .leading, spacing: Metrics.spacingS) {
+ Text(wallpaper.title)
+ .font(.largeTitle.weight(.bold))
+
+ Text(wallpaperSummary)
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Spacer(minLength: 0)
+
+ if activeDisplayCount > 0 {
+ StatusPill(
+ title: "\(activeDisplayCount) active",
+ systemImage: "play.circle.fill",
+ tint: .green
+ )
+ }
+ }
+
+ HStack(spacing: Metrics.spacingS) {
+ StatusPill(title: wallpaper.resolution.rawValue, systemImage: "display")
+ if let duration = wallpaper.duration {
+ StatusPill(title: String(format: "%.0fs loop", duration), systemImage: "clock")
+ }
+ StatusPill(
+ title: wallpaper.isLocal ? "Imported" : (previewURL != nil ? "Cached" : "Download on apply"),
+ systemImage: wallpaper.isLocal ? "folder.fill" : "arrow.down.circle"
+ )
+ }
+
+ if !wallpaper.tags.isEmpty {
+ FlowLayout(spacing: Metrics.spacingS) {
+ ForEach(wallpaper.tags, id: \.self) { tag in
+ StatusPill(title: tag)
+ }
+ }
+ }
+ }
+ .padding(Metrics.spacingXL)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .fill(.regularMaterial)
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
+ }
+ }
+ }
+
+ // MARK: - Stale notice
+
+ private var staleNotice: some View {
+ VStack(alignment: .leading, spacing: Metrics.spacingM) {
+ Label {
+ VStack(alignment: .leading, spacing: Metrics.spacingXS) {
+ Text("File is missing")
+ .font(.headline)
+ Text("This wallpaper was moved or deleted from your Mac since you imported it. You can remove the entry from your library.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ } icon: {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.title2)
+ .foregroundStyle(.orange)
+ }
+ .padding(Metrics.spacingL)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusM, style: .continuous)
+ .fill(Color.orange.opacity(0.12))
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusM, style: .continuous)
+ .strokeBorder(Color.orange.opacity(0.25), lineWidth: 1)
+ }
+ }
+
+ HStack {
+ Spacer()
+ Button {
+ catalog.removeLocalWallpaper(wallpaper)
+ onRequestClose?()
+ } label: {
+ Label("Remove Missing Entry", systemImage: "trash")
+ }
+ .buttonStyle(.glassProminent)
+ .controlSize(.regular)
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ }
+
+ // MARK: - Hero preview
+
+ @ViewBuilder
+ private var heroPreview: some View {
+ if let url = previewURL {
+ VideoPreviewPlayer(
+ wallpaperID: "detail-\(wallpaper.id)",
+ url: url,
+ isPlaying: true
+ )
+ .frame(maxWidth: .infinity)
+ .frame(height: 240)
+ .clipShape(RoundedRectangle(cornerRadius: Metrics.radiusM, style: .continuous))
+ .overlay(alignment: .bottomLeading) {
+ Label("Live preview", systemImage: "dot.radiowaves.left.and.right")
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(.white)
+ .padding(.horizontal, Metrics.spacingS)
+ .padding(.vertical, 5)
+ .glassEffect(.regular.tint(.black.opacity(0.45)), in: .capsule)
+ .padding(Metrics.spacingM)
+ }
+ .overlay(alignment: .topLeading) {
+ StatusPill(
+ title: wallpaper.isLocal ? "Imported" : "Catalog",
+ systemImage: wallpaper.isLocal ? "folder.fill" : "sparkles.rectangle.stack.fill"
+ )
+ .padding(Metrics.spacingM)
+ }
+ } else {
+ staticHero
+ }
+ }
+
+ private var heroPlaceholder: some View {
+ Rectangle()
+ .fill(.quaternary)
+ .overlay {
+ Image(systemName: "film.stack")
+ .font(.system(size: 40, weight: .light))
+ .foregroundStyle(.tertiary)
+ }
+ }
+
+ @ViewBuilder
+ private var staticHero: some View {
+ let shape = RoundedRectangle(cornerRadius: Metrics.radiusM, style: .continuous)
+
+ ZStack {
+ if let thumbnailURL = wallpaper.thumbnailURL, let url = URL(string: thumbnailURL) {
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .success(let image):
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ case .empty, .failure:
+ heroPlaceholder
+ @unknown default:
+ heroPlaceholder
+ }
+ }
+ } else {
+ heroPlaceholder
+ }
+
+ if downloadManager.downloads[wallpaper.id]?.isActive == true {
+ Color.black.opacity(0.35)
+ if let fraction = downloadManager.downloads[wallpaper.id]?.progressFraction {
+ ProgressView(value: fraction) {
+ Text("Downloading preview…")
+ .foregroundStyle(.white)
+ }
+ .progressViewStyle(.linear)
+ .tint(.white)
+ .frame(maxWidth: 220)
+ .padding(.horizontal, 24)
+ } else {
+ ProgressView("Downloading preview…")
+ .controlSize(.regular)
+ .foregroundStyle(.white)
+ }
+ } else {
+ Label("Preview unlocks after Apply", systemImage: "play.circle")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.white)
+ .padding(.horizontal, Metrics.spacingM)
+ .padding(.vertical, 6)
+ .glassEffect(.regular.tint(.black.opacity(0.5)), in: .capsule)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 240)
+ .clipShape(shape)
+ }
+
+ // MARK: - Display picker
+
+ private var displayPicker: some View {
+ VStack(alignment: .leading, spacing: Metrics.spacingM) {
+ HStack {
+ VStack(alignment: .leading, spacing: Metrics.spacingXS) {
+ Text("Apply to Display")
+ .font(.headline)
+ Text("Choose where this wallpaper should play right now.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Button {
+ applyToAllDisplays()
+ } label: {
+ Label("Apply to All", systemImage: "rectangle.on.rectangle")
+ }
+ .buttonStyle(.glassProminent)
+ .controlSize(.regular)
+ .keyboardShortcut(.defaultAction)
+ .disabled(isAnyDownloadInFlight)
+ }
+
+ if displayManager.displays.isEmpty {
+ ContentUnavailableView(
+ "No Displays Detected",
+ systemImage: "display.trianglebadge.exclamationmark",
+ description: Text("Connect a display and try again.")
+ )
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, Metrics.spacingL)
+ } else {
+ VStack(spacing: Metrics.spacingS) {
+ ForEach(displayManager.displays) { display in
+ displayRow(display)
+ }
+ }
+ }
+ }
+ }
+
+ private var isAnyDownloadInFlight: Bool {
+ downloadManager.downloads[wallpaper.id]?.isActive == true
+ }
+
+ private func displayRow(_ display: DisplayInfo) -> some View {
+ let isActive = engine.activeWallpapers[display.id]?.id == wallpaper.id
+ let otherActive = engine.activeWallpapers[display.id]
+ let downloadState = downloadManager.downloads[wallpaper.id]
+
+ return HStack(spacing: Metrics.spacingM) {
+ Image(systemName: "display")
+ .font(.title3)
+ .foregroundStyle(
+ isActive
+ ? AnyShapeStyle(.tint)
+ : AnyShapeStyle(.secondary)
+ )
+ .frame(width: 24)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(display.localizedName)
+ .font(.callout.weight(.medium))
+ Text(String(format: "%.0f × %.0f", display.resolution.width, display.resolution.height))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: Metrics.spacingXS) {
+ if isActive {
+ Label("Currently Live", systemImage: "checkmark.circle.fill")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.green)
+ } else if let other = otherActive, other.id != wallpaper.id {
+ Text(other.title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .frame(maxWidth: 120, alignment: .trailing)
+ }
+
+ applyButton(for: display, isActive: isActive, state: downloadState)
+ }
+ }
+ .padding(Metrics.spacingL)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusM, style: .continuous)
+ .fill(isActive ? Palette.activeGreenTint : Color.primary.opacity(0.04))
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusM, style: .continuous)
+ .strokeBorder(
+ isActive ? Color.green.opacity(0.35) : Color.primary.opacity(0.08),
+ lineWidth: 1
+ )
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func applyButton(for display: DisplayInfo, isActive: Bool, state: DownloadState?) -> some View {
+ switch state {
+ case .downloading(let fraction):
+ VStack(alignment: .trailing, spacing: 2) {
+ Text("Downloading…")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ ProgressView(value: fraction)
+ .progressViewStyle(.linear)
+ .controlSize(.small)
+ .frame(minWidth: 84)
+ }
+ case .failed(let message):
+ HStack(spacing: 4) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ .help(message)
+ Button("Retry") {
+ applyToDisplay(display.id)
+ }
+ .buttonStyle(.glass)
+ }
+ default:
+ if isActive {
+ Button {
+ engine.stop(forDisplay: display.id)
+ } label: {
+ Label("Remove", systemImage: "stop.circle")
+ .frame(minWidth: 72)
+ }
+ .buttonStyle(.glass)
+ } else {
+ Button {
+ applyToDisplay(display.id)
+ } label: {
+ Text("Apply")
+ .frame(minWidth: 72)
+ }
+ .buttonStyle(.glassProminent)
+ }
+ }
+ }
+
+ // MARK: - Actions
+
+ private func applyToAllDisplays() {
+ let wp = wallpaper
+ Task {
+ if !wp.isLocal {
+ await downloadManager.download(wallpaper: wp)
+ }
+ engine.apply(wp, scope: .allDisplays)
+ }
+ }
+
+ private func applyToDisplay(_ displayID: String) {
+ let wp = wallpaper
+ Task {
+ if !wp.isLocal {
+ await downloadManager.download(wallpaper: wp)
+ }
+ engine.setWallpaper(wp, forDisplay: displayID)
+ }
+ }
+}
+
+// MARK: - FlowLayout
+
+struct FlowLayout: Layout {
+ var spacing: CGFloat = 8
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let result = layout(proposal: proposal, subviews: subviews)
+ return result.size
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let result = layout(proposal: proposal, subviews: subviews)
+ for (index, position) in result.positions.enumerated() {
+ subviews[index].place(
+ at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
+ anchor: .topLeading,
+ proposal: .unspecified
+ )
+ }
+ }
+
+ private func layout(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
+ let maxWidth = proposal.width ?? .infinity
+ var positions: [CGPoint] = []
+ var currentX: CGFloat = 0
+ var currentY: CGFloat = 0
+ var lineHeight: CGFloat = 0
+ var maxLineWidth: CGFloat = 0
+
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+
+ if currentX + size.width > maxWidth && currentX > 0 {
+ maxLineWidth = max(maxLineWidth, currentX - spacing)
+ currentX = 0
+ currentY += lineHeight + spacing
+ lineHeight = 0
+ }
+
+ positions.append(CGPoint(x: currentX, y: currentY))
+ currentX += size.width + spacing
+ lineHeight = max(lineHeight, size.height)
+ }
+
+ maxLineWidth = max(maxLineWidth, currentX - spacing)
+ let totalHeight = currentY + lineHeight
+ return (CGSize(width: maxLineWidth, height: totalHeight), positions)
+ }
+}
diff --git a/livewall/livewall/Views/WelcomeSheet.swift b/livewall/livewall/Views/WelcomeSheet.swift
new file mode 100644
index 0000000..1d73200
--- /dev/null
+++ b/livewall/livewall/Views/WelcomeSheet.swift
@@ -0,0 +1,114 @@
+import SwiftUI
+
+struct WelcomeSheet: View {
+ let onDismiss: () -> Void
+
+ var body: some View {
+ ZStack {
+ AppAmbientBackground()
+
+ VStack(spacing: 0) {
+ Spacer(minLength: 0)
+
+ VStack(spacing: Metrics.spacingL) {
+ Image(systemName: "sparkles.tv.fill")
+ .font(.system(size: 72, weight: .light))
+ .symbolRenderingMode(.hierarchical)
+ .foregroundStyle(.tint)
+
+ VStack(spacing: Metrics.spacingS) {
+ Text("Welcome to LiveWall")
+ .font(.largeTitle.weight(.bold))
+
+ Text("Browse the gallery, preview on hover, and apply a living wallpaper to any of your displays.")
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ HStack(spacing: Metrics.spacingS) {
+ StatusPill(title: "Hover previews", systemImage: "play.circle")
+ StatusPill(title: "Multi-display", systemImage: "display.2")
+ StatusPill(title: "Battery-aware", systemImage: "bolt.badge.clock")
+ }
+
+ VStack(alignment: .leading, spacing: Metrics.spacingM) {
+ welcomeRow(
+ icon: "bolt.fill",
+ title: "Battery friendly",
+ detail: "Pauses automatically when you unplug or enable Low Power."
+ )
+ welcomeRow(
+ icon: "square.and.arrow.down",
+ title: "Import your own",
+ detail: "Drop any MP4 or MOV into the gallery window."
+ )
+ welcomeRow(
+ icon: "rectangle.on.rectangle",
+ title: "Multi-display",
+ detail: "Set a different wallpaper on each screen."
+ )
+ }
+ .padding(Metrics.spacingL)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .fill(Color.primary.opacity(0.04))
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusL, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
+ }
+ }
+
+ Button {
+ onDismiss()
+ } label: {
+ Text("Let's Pick a Wallpaper")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.glassProminent)
+ .controlSize(.extraLarge)
+ .keyboardShortcut(.defaultAction)
+ .padding(.top, Metrics.spacingS)
+ }
+ .padding(.horizontal, Metrics.spacingXXL + Metrics.spacingM)
+ .padding(.vertical, Metrics.spacingXXL)
+ .frame(maxWidth: 440)
+ .background {
+ RoundedRectangle(cornerRadius: Metrics.radiusXL, style: .continuous)
+ .fill(.regularMaterial)
+ .overlay {
+ RoundedRectangle(cornerRadius: Metrics.radiusXL, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.10), lineWidth: 1)
+ }
+ .shadow(color: .black.opacity(0.08), radius: 20, x: 0, y: 12)
+ }
+
+ Spacer(minLength: 0)
+ }
+ .padding(Metrics.spacingXL)
+ }
+ .frame(minWidth: 480, minHeight: 580)
+ }
+
+ private func welcomeRow(icon: String, title: String, detail: String) -> some View {
+ HStack(alignment: .top, spacing: Metrics.spacingM) {
+ Image(systemName: icon)
+ .font(.title3)
+ .foregroundStyle(.tint)
+ .frame(width: 28)
+ .padding(.top, 2)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.callout.weight(.semibold))
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Spacer(minLength: 0)
+ }
+ }
+}
diff --git a/livewall/livewall/livewallApp.swift b/livewall/livewall/livewallApp.swift
new file mode 100644
index 0000000..52e12af
--- /dev/null
+++ b/livewall/livewall/livewallApp.swift
@@ -0,0 +1,115 @@
+import SwiftUI
+import AppKit
+
+@main
+struct LiveWallApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ var body: some Scene {
+ WindowGroup(id: "main") {
+ GalleryView()
+ .appErrorAlert()
+ }
+ .windowToolbarStyle(.unified)
+ .commands {
+ CommandGroup(replacing: .newItem) { }
+ }
+
+ WindowGroup(id: "settings") {
+ SettingsView()
+ .appErrorAlert()
+ }
+ .windowToolbarStyle(.unified)
+ .commands {
+ CommandGroup(replacing: .newItem) { }
+ }
+
+ WindowGroup(id: "import") {
+ ImportView()
+ .appErrorAlert()
+ }
+ .windowToolbarStyle(.unified)
+ .commands {
+ CommandGroup(replacing: .newItem) { }
+ }
+
+ MenuBarExtra {
+ MenuBarExtraView()
+ } label: {
+ Image(systemName: "desktopcomputer")
+ }
+ .menuBarExtraStyle(.window)
+ }
+}
+
+struct AppAmbientBackground: View {
+ var body: some View {
+ ZStack {
+ LinearGradient(
+ colors: [
+ Color(nsColor: .windowBackgroundColor),
+ Color(nsColor: .underPageBackgroundColor),
+ Color.accentColor.opacity(0.08)
+ ],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+
+ RadialGradient(
+ colors: [
+ Color.white.opacity(0.32),
+ Color.clear
+ ],
+ center: .topLeading,
+ startRadius: 20,
+ endRadius: 420
+ )
+ .blendMode(.plusLighter)
+
+ RadialGradient(
+ colors: [
+ Color.accentColor.opacity(0.18),
+ Color.clear
+ ],
+ center: .bottomTrailing,
+ startRadius: 40,
+ endRadius: 460
+ )
+ }
+ .ignoresSafeArea()
+ }
+}
+
+class AppDelegate: NSObject, NSApplicationDelegate {
+ private var didBecomeActiveObserver: NSObjectProtocol?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ WallpaperEngine.shared.refreshDisplays()
+ WallpaperCatalog.shared.refreshStaleStatus()
+
+ // Re-check stale-file status whenever the user returns to the app —
+ // catches files that were moved or deleted while we were inactive.
+ didBecomeActiveObserver = NotificationCenter.default.addObserver(
+ forName: NSApplication.didBecomeActiveNotification,
+ object: nil,
+ queue: .main
+ ) { _ in
+ WallpaperCatalog.shared.refreshStaleStatus()
+ }
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ if let observer = didBecomeActiveObserver {
+ NotificationCenter.default.removeObserver(observer)
+ didBecomeActiveObserver = nil
+ }
+ }
+
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+ false
+ }
+
+ func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
+ true
+ }
+}
diff --git a/livewall/livewallTests/AppErrorPresenterTests.swift b/livewall/livewallTests/AppErrorPresenterTests.swift
new file mode 100644
index 0000000..b476066
--- /dev/null
+++ b/livewall/livewallTests/AppErrorPresenterTests.swift
@@ -0,0 +1,56 @@
+import Testing
+@testable import livewall
+
+@MainActor
+struct AppErrorPresenterTests {
+
+ @Test
+ func dedupsIdenticalErrors() {
+ let presenter = AppErrorPresenter()
+ let error = AppError(title: "Boom", message: "Something failed", recoverySuggestion: nil)
+
+ presenter.present(error)
+ let firstID = presenter.currentError?.id
+
+ presenter.present(error)
+ let secondID = presenter.currentError?.id
+
+ // Same identity instance — dedup short-circuited the second present.
+ #expect(firstID != nil)
+ #expect(firstID == secondID)
+ }
+
+ @Test
+ func presentsDistinctErrors() {
+ let presenter = AppErrorPresenter()
+ let first = AppError(title: "A", message: "alpha", recoverySuggestion: nil)
+ let second = AppError(title: "B", message: "beta", recoverySuggestion: nil)
+
+ presenter.present(first)
+ let firstID = presenter.currentError?.id
+
+ presenter.present(second)
+ let secondID = presenter.currentError?.id
+
+ #expect(firstID != nil)
+ #expect(secondID != nil)
+ #expect(firstID != secondID)
+ #expect(presenter.currentError?.title == "B")
+ }
+
+ @Test
+ func allowsRepresentAfterDismiss() {
+ let presenter = AppErrorPresenter()
+ let error = AppError(title: "Same", message: "again", recoverySuggestion: nil)
+
+ presenter.present(error)
+ #expect(presenter.currentError != nil)
+
+ presenter.dismiss()
+ #expect(presenter.currentError == nil)
+
+ presenter.present(error)
+ #expect(presenter.currentError != nil)
+ #expect(presenter.currentError?.title == "Same")
+ }
+}
diff --git a/livewall/livewallTests/DownloadManagerProgressTests.swift b/livewall/livewallTests/DownloadManagerProgressTests.swift
new file mode 100644
index 0000000..ab3a96f
--- /dev/null
+++ b/livewall/livewallTests/DownloadManagerProgressTests.swift
@@ -0,0 +1,34 @@
+import Foundation
+import Testing
+@testable import livewall
+
+@MainActor
+struct DownloadManagerProgressTests {
+
+ @Test
+ func updateProgressPublishesDownloadingState() {
+ let manager = DownloadManager()
+ manager.updateProgress(id: "abc", fraction: 0.5)
+
+ #expect(manager.downloads["abc"] == .downloading(progress: 0.5))
+ }
+
+ @Test
+ func updateProgressClampsOutOfRangeValues() {
+ let manager = DownloadManager()
+
+ manager.updateProgress(id: "low", fraction: -0.25)
+ #expect(manager.downloads["low"] == .downloading(progress: 0.0))
+
+ manager.updateProgress(id: "high", fraction: 1.75)
+ #expect(manager.downloads["high"] == .downloading(progress: 1.0))
+ }
+
+ @Test
+ func updateProgressOverwritesPriorFraction() {
+ let manager = DownloadManager()
+ manager.updateProgress(id: "x", fraction: 0.1)
+ manager.updateProgress(id: "x", fraction: 0.9)
+ #expect(manager.downloads["x"] == .downloading(progress: 0.9))
+ }
+}
diff --git a/livewall/livewallTests/WallpaperCatalogStaleTests.swift b/livewall/livewallTests/WallpaperCatalogStaleTests.swift
new file mode 100644
index 0000000..fd60e85
--- /dev/null
+++ b/livewall/livewallTests/WallpaperCatalogStaleTests.swift
@@ -0,0 +1,55 @@
+import Foundation
+import Testing
+@testable import livewall
+
+@MainActor
+struct WallpaperCatalogStaleTests {
+
+ @Test
+ func detectsMissingFiles() throws {
+ let tempRoot = FileManager.default.temporaryDirectory
+ .appendingPathComponent("livewall-tests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: tempRoot) }
+
+ let presentFile = tempRoot.appendingPathComponent("present.mp4")
+ let missingFile = tempRoot.appendingPathComponent("missing.mp4")
+ try Data().write(to: presentFile)
+
+ let presentWallpaper = Wallpaper.local(title: "present", fileURL: presentFile)
+ let missingWallpaper = Wallpaper.local(title: "missing", fileURL: missingFile)
+
+ let catalog = WallpaperCatalog()
+ catalog.localWallpapers = [presentWallpaper, missingWallpaper]
+ catalog.staleLocalWallpaperIDs = []
+
+ catalog.refreshStaleStatus()
+
+ #expect(catalog.staleLocalWallpaperIDs.contains(missingWallpaper.id))
+ #expect(!catalog.staleLocalWallpaperIDs.contains(presentWallpaper.id))
+ }
+
+ @Test
+ func clearsStaleSetWhenAllFilesPresent() throws {
+ let tempRoot = FileManager.default.temporaryDirectory
+ .appendingPathComponent("livewall-tests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: tempRoot) }
+
+ let fileA = tempRoot.appendingPathComponent("a.mp4")
+ let fileB = tempRoot.appendingPathComponent("b.mp4")
+ try Data().write(to: fileA)
+ try Data().write(to: fileB)
+
+ let wpA = Wallpaper.local(title: "a", fileURL: fileA)
+ let wpB = Wallpaper.local(title: "b", fileURL: fileB)
+
+ let catalog = WallpaperCatalog()
+ catalog.localWallpapers = [wpA, wpB]
+ catalog.staleLocalWallpaperIDs = [wpA.id, wpB.id] // pretend both were stale
+
+ catalog.refreshStaleStatus()
+
+ #expect(catalog.staleLocalWallpaperIDs.isEmpty)
+ }
+}
diff --git a/loadout/.gitignore b/loadout/.gitignore
new file mode 100644
index 0000000..c71ac83
--- /dev/null
+++ b/loadout/.gitignore
@@ -0,0 +1,14 @@
+# Xcode / build
+*.xcodeproj
+DerivedData/
+build/
+.build/
+*.xcuserstate
+xcuserdata/
+
+# SwiftPM
+.swiftpm/
+
+# macOS
+.DS_Store
+.motif/
diff --git a/loadout/CHANGELOG.md b/loadout/CHANGELOG.md
new file mode 100644
index 0000000..26fd42b
--- /dev/null
+++ b/loadout/CHANGELOG.md
@@ -0,0 +1,51 @@
+# Changelog
+
+All notable changes to Loadout are documented here. Format follows
+[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
+[Semantic Versioning](https://semver.org/).
+
+## [0.0.4] - 2026-06-22
+
+### Added
+- **Agents — work in progress.** A new "Agents" segment beside Skills and MCP
+ that talks to the local Herdr socket, lists live agent panes, and renders a
+ selected pane's Claude transcript as a T3 Code–style structured thread:
+ prose-forward messages, a collapsed/expandable tool "work log", inline diffs,
+ and a plan view. Local socket only for now.
+
+### Notes
+- Agents is early and actively evolving — expect rough edges. Still to come:
+ remote machines over SSH, live status updates, the permission/approval flow,
+ timestamps, and the tasks rail.
+
+## [0.0.3] - 2026-06-19
+
+### Added
+- **Remote machines over SSH.** Scan another machine's skills read-only via the
+ new "Machine" sidebar section (`user@host` or an `~/.ssh/config` alias). Uses
+ your existing ssh keys first; if none work, prompts for a password kept only
+ for the session (never stored). Remote scopes are global-only and read-only.
+
+### Changed
+- Internals: filesystem/process access now goes through a `HostIO` seam
+ (`LocalHostIO`/`RemoteHostIO`, see DECISIONS D7); local behavior is unchanged.
+- App icons re-compressed losslessly (pixel-identical, ~13% smaller).
+
+## [0.0.2] - 2026-06-17
+
+### Added
+- App icon — a Fortnite-style supply-drop crate.
+
+## [0.0.1] - 2026-06-17
+
+### Added
+- Initial release: scans every agent's global skill directories plus the
+ `~/.agents/skills` canonical store, dedupes by symlink-resolved path, parses
+ `SKILL.md` frontmatter, reads `.skill-lock.json` provenance, surfaces
+ declared-vs-wired drift, and full-text search.
+- Tag-driven release pipeline: notarized Developer ID `.dmg` published to GitHub
+ Releases, with a Homebrew cask in `zackbart/homebrew-tap`.
+
+[0.0.3]: https://github.com/zackbart/loadout/releases/tag/v0.0.3
+[0.0.2]: https://github.com/zackbart/loadout/releases/tag/v0.0.2
+[0.0.1]: https://github.com/zackbart/loadout/releases/tag/v0.0.1
diff --git a/loadout/DECISIONS.md b/loadout/DECISIONS.md
new file mode 100644
index 0000000..ac7aba0
--- /dev/null
+++ b/loadout/DECISIONS.md
@@ -0,0 +1,132 @@
+# Loadout — Decisions
+
+A running log of the choices that shape the app, so future work doesn't re-litigate them.
+
+## D1 — Clean-room build, MIT licensed (not a fork of Chops)
+`Shpigford/chops` does ~everything we want but is **FSL-1.1-MIT** (fair-source); a derivative
+can't be relicensed to true OSS until it auto-converts to MIT (~2028). Goal is personal use **+**
+open source, so we built fresh, MIT-licensed, using `RESEARCH.md` as the spec and Chops only as a
+UX reference (no code/assets copied). Codex concurred.
+
+## D2 — UI direction: Inspector (primary) + Menu-bar companion, Matrix as a view toggle
+Chosen from three mockups (`~/.scratch/Dev/projects/tooling/skillsseer/ui-*.html`).
+- **Inspector** is the main window: three panes — sidebar (kind switcher / library / agents / sources)
+ → skill list → rich detail (provenance, drift card with one-click fix, inline SKILL.md editor).
+- **Menu-bar companion** later: glanceable drop-down, drift badge, ⌘K to run/install.
+- **Matrix** later: a third view-toggle for the bird's-eye declared-vs-wired drift map.
+- Signature element across all: the **wired / declared / active / diverged** status system, with the
+ four agent colors (Claude orange, OpenCode red, Codex green, Pi cyan) as the only chroma.
+
+## D3 — Leave the door open for MCP servers (skills first, MCP later)
+MCP management is **not** in v1, but the design must not foreclose it. Accommodations:
+
+**Information architecture.** A top-level **kind switcher** in the sidebar: `Skills | MCP`
+(shown as "SOON" in the Inspector mockup). Everything below it — agent filters, sources, the
+list, the detail pane, the drift system — is reused per kind.
+
+**Shared model seam.** Introduce an `AgentResource` abstraction that both `Skill` and a future
+`McpServer` satisfy, so the list/detail/agent-chip/drift UI is kind-agnostic:
+```
+protocol AgentResource { // Skill conforms now; McpServer later
+ var id: String { get } // canonical identity
+ var name: String { get }
+ var summary: String? { get }
+ var scope: ResourceScope { get } // .global / .project(root)
+ var wiredAgents: Set { get }
+ var declaredAgents: Set { get } // drift = declared − wired
+ var provenance: SkillProvenance? { get }
+}
+enum ResourceKind { case skill, mcp }
+```
+And a provider seam so discovery differs by kind but the UI doesn't care:
+```
+protocol ResourceProvider { func scanGlobal() -> [AgentResource]; func scanProject(_ root: URL) -> [AgentResource] }
+// SkillProvider (now) → McpProvider (later)
+```
+
+**Key difference to remember.** Skills are **directories** (`SKILL.md` + bundled files) discovered
+by scanning the filesystem. MCP servers are **entries inside shared config files**, so discovery is
+parse-config, not scan-dir, and "wired/active" means "present & enabled in that agent's config":
+| Agent | MCP config (per the `mcp-sync` skill + agent docs) |
+|---|---|
+| Claude Code | project `.mcp.json`; user-scope in `~/.claude.json` |
+| OpenCode | `opencode.json` / `~/.config/opencode/opencode.json` (`mcp` key) |
+| Codex | `~/.codex/config.toml` / project `.codex/config.toml` (`mcp_servers`) |
+| Pi | `~/.pi/agent/settings.json` (to verify) |
+
+There's even an existing `mcp-sync` skill that already encodes the cross-agent source-of-truth
+pattern (`MCP.md` → mirror into each config) — useful prior art / possible integration point.
+
+**Cost now:** the kind switcher in the IA + naming the model `AgentResource` instead of hard-coding
+`Skill` everywhere. We do **not** build MCP discovery, parsing, or editing yet.
+
+## D7 — `HostIO` seam: scan local OR remote (over SSH) behind one protocol
+To let Loadout inventory skills/MCP on **remote machines** (not just the local one), all
+local-bound IO sits behind a small `HostIO` protocol — filesystem reads, symlink resolution,
+process spawns, and the host's `home`/`xdgConfigHome`. `LocalHostIO` wraps today's
+Foundation/`Process` calls verbatim-equivalently (so introducing the seam changed no local
+behavior); a `RemoteHostIO` runs the same operations over the user's `ssh` with a multiplexed
+ControlMaster socket.
+
+**Carrying the host.** A `Host` value (`.local` / `.remote(user,host,alias?)`) lives on the
+resource (`Skill.host`/`McpServer.host`), not on `ResourceScope` (left untouched). It folds into
+`id` only for remote (`idTag == nil` locally → ids unchanged), so the same canonical skill on two
+machines stays distinct, and `host != .local` gates mutation/file-open affordances.
+
+**Scope of the seam.** Threaded through the **skill-scan path only** (Agent host-anchoring,
+SkillScanner, SkillLockReader, GitStatusService, SkillsCLIService). McpScanner/codec/write-engine
+are deliberately NOT threaded yet — remote MCP and any remote **mutation** (writes lose atomicity
+over SSH; FSEvents has no remote analog) are out of scope until a later slice; `RemoteHostIO` is
+read-only. **Supersedes D3's never-built `ResourceProvider`** — that was an orthogonal *scan-provider*
+seam; this is an *IO-primitive* seam, and discovery stayed in the existing static-enum scanners.
+
+## D5 — Install rule: write `.agents/skills`, symlink only `.claude` (verified)
+Confirmed against each agent's own docs that 3 of 4 read the `.agents/skills` canonical store at BOTH global and project scope; Claude Code is the sole exception.
+
+| Agent | global `~/.agents/skills` | project `/.agents/skills` | needs own symlink | source |
+|---|---|---|---|---|
+| OpenCode | reads | reads | no | opencode.ai/docs/skills |
+| Codex | reads (`$HOME/.agents/skills`) | reads (cwd→repo root) | no | developers.openai.com/codex/skills |
+| Pi | reads | reads (**after project trust**) | no (CLI also symlinks `.pi/skills`) | pi-mono docs |
+| Claude Code | NO — only `~/.claude/skills` | NO — only `.claude/skills` | **YES** | code.claude.com/docs/skills |
+
+**The rule (what `npx skills` does, minimized):**
+- GLOBAL install: write real files → `~/.agents/skills//`; relative symlink `~/.claude/skills/` → `../../.agents/skills/`.
+- PROJECT install: write real files → `/.agents/skills//`; relative symlink `/.claude/skills/` → `../../.agents/skills/`.
+- That's it — Codex/OpenCode/Pi pick it up from `.agents/skills` directly. Only Claude needs the symlink.
+- Uninstall = reverse (rm canonical dir + the `.claude` symlink).
+
+**Caveats to surface (not blockers to the rule):**
+- Pi loads PROJECT skills only after the project is **trusted** — canonical presence isn't enough until then.
+- "Discovered" ≠ "active": OpenCode gates via `opencode.json` permissions (allow/deny/ask); Claude has precedence rules. The install rule controls discovery, not activation.
+- For REMOTE installs (from a GitHub repo) prefer shelling out to `skills add` so the lock file + fetch are handled; for local "ensure wired everywhere" / drift-fix, do write+symlink directly.
+
+**App consequence:** with `readsCanonicalNatively` correct, drift for a canonical-present skill collapses to a single case — "Claude Code symlink missing" — so the drift-fix is one button ("Wire into Claude Code"), already built.
+
+## D6 — Releases: tag-driven, notarized Developer ID `.dmg` via GitHub Actions
+Distribution is a notarized `.dmg` published to GitHub Releases (no App Store, no Sparkle yet).
+- **The tag is the *only* CI trigger.** The workflow runs on `push: tags: ['v*']` and nothing
+ else — no `push`/`pull_request` triggers. Day-to-day commits, branches, and PRs never invoke it.
+ The build/sign/notarize pipeline only exists to cut a release.
+- **Tags are only pushed from a release-bump merge — never off an arbitrary commit.** The flow is:
+ open a small "Release vX.Y.Z" PR that bumps `MARKETING_VERSION` in `project.yml` (and records
+ notes if/when a `CHANGELOG.md` exists) → merge to `main` → tag *that* merge commit and push the
+ tag. So every tag points at a deliberate, reviewed release commit; you never tag mid-feature.
+ (CI still injects the version from the tag at build time; the `project.yml` bump is the human
+ marker that makes the merge self-describing.)
+- **Pipeline.** Push `vX.Y.Z` → `.github/workflows/release.yml` builds on `macos-26`,
+ signs with the **Developer ID Application** cert, notarizes (App Store Connect API key), staples,
+ and publishes the `.dmg` with auto-generated notes.
+- **Signing identity is "Cursor Kittens LLC"** (team `F2J8ZU2NQJ`) — that's what Gatekeeper shows.
+- Notarization-ready by construction: hardened runtime is on, app is non-sandboxed (no entitlements
+ file), so nothing extra is needed.
+- Six repo secrets hold the credentials (`BUILD_CERTIFICATE_BASE64`, `P12_PASSWORD`,
+ `AC_API_KEY_BASE64`, `AC_API_KEY_ID`, `AC_API_ISSUER_ID`, `APPLE_TEAM_ID`).
+- **Distribution is a Homebrew cask** in `zackbart/homebrew-tap` (`Casks/loadout.rb`) —
+ `brew install --cask zackbart/tap/loadout`. The release workflow's `update-tap` job
+ auto-bumps the cask's `version` + `sha256` after each release. It authenticates with a
+ seventh secret, `HOMEBREW_TAP_TOKEN` (a non-expiring PAT with Contents:write on the tap,
+ shared with seer's tap automation) — `GITHUB_TOKEN` can't push cross-repo. **Already set.**
+- **`CHANGELOG.md`** is hand-curated (Keep a Changelog) and bumped as part of each release-bump
+ merge. GitHub release notes are still auto-generated; the changelog is the human-readable record.
+- **Deferred:** auto-update (Sparkle), `.dmg` background art. Add when there are users to update.
diff --git a/loadout/DESIGN.md b/loadout/DESIGN.md
new file mode 100644
index 0000000..bd4ed90
--- /dev/null
+++ b/loadout/DESIGN.md
@@ -0,0 +1,265 @@
+# Loadout — Unified Design
+
+> One native Swift app (macOS + iOS) that manages **running agents** (via Herdr),
+> **installed skills**, and **MCP servers** — from a shared core. Consolidates the
+> existing `loadout` (macOS) and `herdr-ios` apps into a single product called
+> **Loadout**.
+
+---
+
+## 1. Vision
+
+Loadout is the home base for agent work. It shows what your agents are *equipped
+with* (skills, MCP servers, config drift) and what they're *doing right now*
+(live panes, status, output), in one app that runs natively on macOS and iOS.
+
+Three non-negotiable experience goals:
+
+1. **Native speed against the local Herdr.** On the Mac, the app talks directly
+ to the local Herdr Unix socket — no network, no relay, no daemon in the path.
+ Managing your local session should feel instant.
+2. **Easy keyboard shortcuts.** First-class keybindings for navigating
+ workspaces/panes, sending input, and answering agent prompts.
+3. **T3 Code–style rendering.** For agent panes, render structured content
+ (messages, tool calls, diffs, plan, permission prompts) as clean native UI —
+ not raw terminal scrollback — so it's genuinely nice to use.
+
+---
+
+## 2. What we're consolidating
+
+### `loadout` (existing, macOS) — this repo
+Native macOS SwiftUI app. Scans agent skill/MCP config across Claude Code,
+OpenCode, Codex, Cursor, Pi at global + project scope; dedupes skills by
+canonical (symlink-resolved) path; parses `SKILL.md` frontmatter (Yams); reads
+`.skill-lock.json` provenance; surfaces declared-vs-wired drift. SwiftPM, signed/
+notarized, Homebrew cask.
+
+**Keep:** the scanners and the drift logic — that's its real value.
+
+### `herdr-ios` (existing, iOS)
+Native iOS SwiftUI client for Herdr. Clean two-layer split:
+`HerdrKit` (platform-independent core: protocol, transport, client, models —
+Foundation + concurrency, builds on macOS/Linux) and a thin SwiftUI app. Swappable
+transport (`HerdrTransport`) with Mock + SSH (Citadel/SwiftNIO SSH) implementations.
+Three render modes (`fit`, `scroll`, `reader`). Keychain creds, TOFU host pinning.
+
+**Keep:** the entire architecture. `HerdrKit` is already the shared core we'd
+otherwise have to design from scratch.
+
+---
+
+## 3. What the research established
+
+### 3.1 Happy / T3 Code are nice *because of their data source*, not a render trick
+Happy and T3 Code don't render terminals. They consume the agent's **structured
+event stream** (typed messages, tool calls with parsed input/output, plan,
+permission requests) and render each with a purpose-built native component (bash
+card, diff view, todo list, markdown). There is no terminal-rendering path to a
+T3 Code–style UI — it requires structured data.
+
+### 3.2 Herdr is a control plane, not just a terminal to scrape
+- **RPC is one-request-per-connection.** Open → one request → one reply → close.
+ Only `events.subscribe` stays open (streams events).
+- **`events.subscribe` pushes structured lifecycle events:** `pane.agent_status_changed`,
+ topology (`workspace.*`, `tab.*`, `pane.*`), `worktree.*`. Status is pushed.
+- **`agent_session` is exposed** on `pane.get`/`agent.get`/`pane.list`/`agent.list` —
+ the pane → transcript correlation key. **(Confirmed live — see §7.)**
+- **`foreground_cwd` is exposed** alongside it.
+- **Output is pull-on-signal:** liveness via `pane.wait_for_output` (held-open,
+ returns on a regex match).
+- **`blocked` is screen-detected.** The *content* of the permission lives in the
+ transcript; Herdr also exposes a reported `--message` on `report-agent`.
+- **Input:** `pane.send_text` / `pane.send_keys` inject keystrokes into the TUI.
+- **License:** Herdr is **AGPL-3.0-or-later**. A separate client talking to its
+ documented socket is fine; do not bundle or derive from its code.
+
+---
+
+## 4. Conclusions
+
+### 4.1 Structured rendering (Path B, no terminal fallback)
+Agent panes render as a **T3 Code–style structured view** — the only renderer.
+Resolve the transcript path from `agent_session`, tail the Claude Code JSONL, and
+render native blocks (messages, tool calls, diffs, plan, permission prompts). No
+terminal/scrollback view ships in the app. Non-agent panes (plain shells, dev
+servers) appear only as a minimal status row in the workspace tree.
+
+### 4.2 The four data planes (Herdr owns three)
+| Plane | Source | Notes |
+| --- | --- | --- |
+| **Lifecycle / status** | Herdr `events.subscribe` + `*.list` | Consume; don't reimplement. |
+| **Identity / correlation** | `agent_session` + `foreground_cwd` | Deterministic pane → session → JSONL. |
+| **Content** | Claude Code JSONL transcript | Our part. Structured blocks. |
+| **Input** | `send_text` / `send_keys` + per-agent keymap | Keystroke injection; `blocked` triggers the permission UI. |
+
+### 4.3 No daemon on the remote — smarts live in the shared library
+The only process on a remote machine is Herdr (plus `sshd`). All intelligence —
+collapsing round-trips, `wait_for_output` loops, resolving `agent_session`,
+tailing transcripts, merging streams — lives client-side in the shared Swift
+packages. The remote stays commodity.
+
+### 4.4 Transport: local-native now, SSH now, relay-ready later
+| Transport | Use | Status |
+| --- | --- | --- |
+| `LocalSocketTransport` | macOS app → **local** Herdr Unix socket. Native speed. | Build now. |
+| `SSHTransport` | Remote Herdr over SSH (control + content + loadout on one connection). | Exists; extend. |
+| `MockTransport` | Dev / demo. | Exists. |
+| `RelayTransport` | Future: outbound rendezvous. | Seam only. |
+
+Local path connects straight to `~/.config/herdr/herdr.sock`. Remote: one SSH
+connection does triple duty — **control** (exec channel → Unix socket),
+**content** (`tail -f` the JSONL), **loadout** (`cat`/`find`/`skills list --json`).
+
+### 4.5 Relay is deferred, the seam is honest
+Herdr only listens on a local socket — it never dials out. So {no overlay, relay,
+only-Herdr} can't all be true at once. A relay's outbound bridge lives **off the
+remote** (a VPS) or **as a Herdr plugin**. Until then: SSH / direct, with
+`RelayTransport` a drop-in we can add without touching the app or the core.
+
+---
+
+## 5. Package architecture
+
+One repo, SwiftPM, thin app targets over shared packages.
+
+```
+Loadout/
+├─ Packages/
+│ ├─ HerdrKit/ # control plane core (from herdr-ios; bump past protocol 14 → 0.7.x)
+│ │ ├─ Protocol/ # NDJSON JSON-RPC, Method, events (+ pane.get/agent.get, agent_session, worktree)
+│ │ ├─ Transport/ # HerdrTransport protocol
+│ │ ├─ Client/ # HerdrClient actor, event demux
+│ │ └─ Models/ # Workspace/Tab/Pane/AgentStatus/AgentSession
+│ ├─ Transports/ # LocalSocketTransport, SSHTransport, MockTransport, (RelayTransport later)
+│ ├─ AgentContentKit/ # NEW: transcript tail + parse → structured blocks; per-agent keymaps
+│ ├─ LoadoutKit/ # from this repo: skill/MCP scanners, SKILL.md (Yams), drift — behind a FileSource protocol
+│ └─ LoadoutUI/ # shared SwiftUI: SessionModel, workspace/pane views, content blocks, theming, keymap/shortcuts
+├─ Apps/
+│ ├─ Loadout-macOS/ # local-socket default, Homebrew cask
+│ └─ Loadout-iOS/ # key bar, TestFlight, SSH default
+```
+
+Key refactors:
+- **`HerdrKit`** — add `pane.get`/`agent.get`, parse `agent_session`, subscribe to
+ worktree events, bump protocol (herdr **0.7.0** live). Transport-agnostic
+ transcript source so content composes the same over local/SSH/relay.
+- **`LoadoutKit`** — replace hardcoded local-FS access with a `FileSource` protocol
+ so scanners run unchanged against a remote over SSH.
+- **`LoadoutUI`** — isolate platform divergences (menus, key bar, window chrome)
+ behind `#if os(...)`.
+
+**UI integration:** the existing app is a 3-column `NavigationSplitView` with a
+segmented **Skills / MCP** kind-picker. Agents lands as a **third segment**,
+reusing the same sidebar → list → detail shape and color discipline (grayscale
+chrome; chroma only for agent-identity dots + the amber drift token). Mockup:
+`~/.scratch/Dev/projects/tooling/loadout/agent-pane-mockup-v2.html`.
+
+---
+
+## 6. UX requirements
+
+- **Native local speed:** macOS defaults to `LocalSocketTransport`; collapse the
+ multi-round-trip refresh by issuing `*.list` concurrently and merging client-side.
+ Convert `wait_for_output` into a single internal push feed the views observe.
+- **Keyboard shortcuts:** shared shortcut layer — navigate workspaces/tabs/panes,
+ focus input, submit, answer prompts (accept/deny) via the per-agent keymap.
+ iOS keeps the sticky key bar; macOS uses real menu + key-equivalents.
+- **T3 Code–style rendering:** structured blocks — message stream, tool-call cards,
+ inline diffs, plan view, permission prompts as real buttons (wired to the keymap).
+ This is the content surface; there is no terminal renderer.
+
+---
+
+## 7. Open questions — status
+
+### 7.1 `agent_session` shape (keystone) — ✅ RESOLVED (live probe, herdr 0.7.0, 2026-06-21)
+On every live Claude Code pane, `pane get` / `agent list` return:
+```json
+"agent_session": { "agent":"claude", "kind":"id", "source":"herdr:claude",
+ "value":"7671f37d-7258-4d2a-a51c-d5674bdb0afc" }
+```
+`kind:"id"` — a real session id, **not** an opaque restore token. It resolves
+deterministically to the transcript:
+`~/.claude/projects//.jsonl` — verified against a live
+343 KB / 107-line JSONL. `agent_session` is present even on **idle** panes, so
+correlation is not focus-gated. The reporting API also carries an explicit
+`--agent-session-path` (`pane report-agent` / `report-agent-session`), so when an
+integration reports the absolute path we use it directly; hash-derivation is the
+fallback.
+
+**Confirmed structured content** (same probe): the transcript carries `text`,
+`thinking`, `tool_use`, `tool_result` blocks with structured input —
+`Bash{command,description}`, `Edit{file_path,old_string,new_string}` (compute the
+diff client-side), `Write`, `Skill`, `Agent`, `AskUserQuestion`, plus `TodoWrite`
+for the plan view. Every block the mockup renders is real data already on disk.
+
+### 7.2 Protocol bump — ✅ CONFIRMED NEEDED
+Live herdr is **0.7.0**; `herdr-ios` `Method.swift` is pinned to "protocol 14".
+Methods to wire: `pane get`, `agent get`/`list`, `report-agent-session`, worktree
+subcommands. Verified available: `pane send-keys`/`send-text` (input),
+`agent wait --status blocked`, `pane report-agent --state blocked --message`.
+
+### 7.3 Transcript schema fragility — accepted risk
+Claude Code's JSONL is unversioned (housekeeping records `last-prompt`, `mode`,
+`attachment`, `file-history-snapshot` interleave with messages). Isolate all
+parsing in `AgentContentKit`. Same risk Happy/T3 Code accept.
+
+### 7.4 Per-agent keymaps
+Start with Claude Code (accept/deny/menu keys); add others as needed. Input is
+keystroke injection, not an `accept()` API — the least-robust piece; the keymap
+must track the on-screen permission menu layout.
+
+### 7.5 Worktree edge (new, from live probe)
+A worktree pane has `cwd` = launch dir but `foreground_cwd` = the worktree path.
+The transcript hash derives from the **launch `cwd`**, not `foreground_cwd` — or
+just use the reported `agent_session_path` when present.
+
+### 7.6 License hygiene
+Keep every transport a clean, separate socket client of Herdr. Do not bundle or
+derive from Herdr (AGPL).
+
+---
+
+## 8. Phased plan
+
+1. **Monorepo + extraction.** SwiftPM workspace; move `HerdrKit` in as-is; extract
+ `LoadoutKit` behind `FileSource`; create `LoadoutUI` from the existing SwiftUI.
+2. **Local-native control.** `LocalSocketTransport`; macOS manages the local Herdr
+ at native speed; concurrent-list refresh; internal output push feed.
+3. **Structured content (vertical slice).** `AgentContentKit`: resolve
+ `agent_session`, tail one Claude Code transcript, render a single thread with
+ tool-call cards + plan + permission buttons. Validate it feels like T3 Code.
+4. **Keyboard + rendering.** Shortcut layer; per-agent keymap; the full structured
+ renderer (message stream, tool-call cards, diffs, plan, permission buttons).
+5. **Remote over SSH.** Extend `SSHTransport` to carry control + content + loadout
+ reads on one connection. iOS defaults here.
+6. **Loadout views.** Surface skills/MCP/drift in-app, local and remote.
+7. **Relay (later).** Add `RelayTransport` + an off-remote or plugin bridge if/when
+ the no-port-forward tradeoff is worth it.
+
+---
+
+### One-line summary
+Merge both apps into **Loadout**: a shared-package Swift app where `HerdrKit` +
+`LoadoutKit` + `AgentContentKit` do the work client-side, the remote runs nothing
+but Herdr, the Mac talks to the local socket at native speed, agent panes get a
+T3 Code–style structured view, and the transport layer starts at local/SSH with a
+clean seam for a future relay.
+
+---
+
+## 9. Known follow-ups (next cycle)
+
+From the first build cycle's review (motif validator + Codex cross-model pass). The
+in-scope correctness fixes were applied; these are deferred with the deferred scope:
+
+- **`events()` cancellation doesn't unblock the blocking `read()`** (`LocalSocketTransport`).
+ Latent until we wire the live topology subscription — fix together with that (close the
+ fd on `onTermination` so the read loop exits). Tracks §8 phase 4.
+- **Agents shows the shared remote Machine selector but always uses `LocalSocketTransport`.**
+ Selecting a remote host can "lie." Gate the Machine picker for Agents until SSH lands (§8 phase 5).
+- **`HerdrClient.eventStream` is single-consumer**, not a multicast bus (pre-existing upstream
+ HerdrKit; the doc comment overstates it). Revisit when multiple views observe events.
+- **`refresh()`/`select()` share one `status` field** — a late `refresh` can clear a detail
+ error. Minor; split per-surface status if it bites.
diff --git a/loadout/ExportOptions.plist b/loadout/ExportOptions.plist
new file mode 100644
index 0000000..7edab3f
--- /dev/null
+++ b/loadout/ExportOptions.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ method
+ developer-id
+ teamID
+ F2J8ZU2NQJ
+ signingStyle
+ manual
+
+
diff --git a/loadout/LICENSE b/loadout/LICENSE
new file mode 100644
index 0000000..8af4617
--- /dev/null
+++ b/loadout/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Zack Bartolome
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/loadout/Packages/HerdrKit/Package.swift b/loadout/Packages/HerdrKit/Package.swift
new file mode 100644
index 0000000..0e43748
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Package.swift
@@ -0,0 +1,23 @@
+// swift-tools-version:6.2
+import PackageDescription
+
+// HerdrKit vendored into Loadout: the platform-independent core of the Herdr
+// client — domain models, the newline-delimited JSON-RPC codec, the transport
+// abstraction, the high-level client actor, and an in-memory Mock transport.
+// Foundation-only, no third-party dependencies. Shared by the macOS and iOS
+// Loadout apps in this monorepo.
+let package = Package(
+ name: "HerdrKit",
+ platforms: [
+ .macOS(.v26),
+ .iOS(.v17),
+ ],
+ products: [
+ .library(name: "HerdrKit", targets: ["HerdrKit"]),
+ ],
+ targets: [
+ .target(name: "HerdrKit"),
+ .testTarget(name: "HerdrKitTests", dependencies: ["HerdrKit"]),
+ ],
+ swiftLanguageModes: [.v5]
+)
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Client/HerdrClient.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Client/HerdrClient.swift
new file mode 100644
index 0000000..f8cd33d
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Client/HerdrClient.swift
@@ -0,0 +1,341 @@
+import Foundation
+
+/// High-level, typed API over a `HerdrTransport`.
+///
+/// Responsibilities:
+/// - generate request ids and correlate replies to the awaiting caller,
+/// - demultiplex server-pushed events into a single `events` stream the UI
+/// can observe for live status/output updates,
+/// - expose ergonomic async methods (`listWorkspaces`, `readPane`, …).
+///
+/// It is an `actor`, so all id/continuation bookkeeping is serialized without
+/// locks.
+public actor HerdrClient {
+ private let transport: HerdrTransport
+
+ private var nextID = 0
+ private var subscriptionTasks: [Task] = []
+
+ private let events: AsyncStream
+ private let eventsContinuation: AsyncStream.Continuation
+
+ public init(transport: HerdrTransport) {
+ self.transport = transport
+ var continuation: AsyncStream.Continuation!
+ self.events = AsyncStream(bufferingPolicy: .unbounded) { continuation = $0 }
+ self.eventsContinuation = continuation
+ }
+
+ /// Live stream of domain events. Observe this to react to status/output
+ /// changes. Multiple awaits share one underlying stream.
+ public var eventStream: AsyncStream { events }
+
+ // MARK: Lifecycle
+
+ public func connect() async throws {
+ try await transport.connect()
+ // No request/event channels yet — RPCs open one channel each, and
+ // `subscribe(_:)` opens the persistent event channel.
+ }
+
+ public func disconnect() async {
+ for task in subscriptionTasks { task.cancel() }
+ subscriptionTasks.removeAll()
+ await transport.disconnect()
+ eventsContinuation.finish()
+ }
+
+ // MARK: Typed API
+
+ public func ping() async throws {
+ _ = try await call(Method.ping)
+ }
+
+ /// Typed `agent.list` (verified live against herdr 0.7.0). Returns one
+ /// `AgentInfo` per running agent, carrying the `agent_session` pointer the
+ /// app uses to correlate a pane to its transcript. Empty when no agents run.
+ public func listAgents() async throws -> [AgentInfo] {
+ try await call(Method.agentList)
+ .decodedSnake(AgentListResult.self)
+ .agents
+ .map(Self.makeAgentInfo)
+ }
+
+ private static func makeAgentInfo(_ dto: AgentInfoDTO) -> AgentInfo {
+ AgentInfo(
+ paneID: PaneID(dto.paneId),
+ agent: dto.agent,
+ status: dto.agentStatus.flatMap(AgentStatus.init(rawValue:)) ?? .unknown,
+ cwd: dto.cwd,
+ foregroundCwd: dto.foregroundCwd,
+ agentSession: dto.agentSession,
+ tabID: dto.tabId.map { TabID($0) },
+ workspaceID: dto.workspaceId.map { WorkspaceID($0) },
+ terminalID: dto.terminalId,
+ isFocused: dto.focused ?? false
+ )
+ }
+
+ /// Build the nested workspace tree from Herdr's flat, granular endpoints:
+ /// `workspace.list` + a single global `pane.list` + `tab.list` per workspace
+ /// + best-effort `agent.list` (for agent names). `HerdrClient` is the
+ /// anti-corruption layer; the UI keeps seeing a nested tree.
+ public func listWorkspaces() async throws -> [Workspace] {
+ let wsList = try await call(Method.workspaceList).decodedSnake(WorkspaceListResult.self)
+ let paneList = try await call(Method.paneList).decodedSnake(PaneListResult.self)
+ let agentNames = await agentNameMap()
+ let panesByTab = Dictionary(grouping: paneList.panes, by: \.tabId)
+
+ var workspaces: [Workspace] = []
+ for ws in wsList.workspaces {
+ let tabList = try await call(
+ Method.tabList, .object(["workspace_id": .string(ws.workspaceId)])
+ ).decodedSnake(TabListResult.self)
+
+ let tabs = tabList.tabs.map { tab in
+ Tab(
+ id: TabID(tab.tabId),
+ label: tab.label,
+ panes: (panesByTab[tab.tabId] ?? []).map { makePane($0, agentNames) }
+ )
+ }
+ let allPanes = tabs.flatMap(\.panes)
+ let cwd = (allPanes.first(where: \.isFocused) ?? allPanes.first)?.cwd
+ workspaces.append(Workspace(id: WorkspaceID(ws.workspaceId), label: ws.label, cwd: cwd, tabs: tabs))
+ }
+ return workspaces
+ }
+
+ /// Read a pane's scrollback as logical lines (`recent_unwrapped`, so the
+ /// server's terminal-width soft-wrapping isn't baked in — the right source to
+ /// re-wrap for a narrow screen). `TerminalText.clean` makes it mobile-ready.
+ public func readPane(_ pane: PaneID, lines: Int = 200) async throws -> [String] {
+ try await readLines(pane, source: PaneReadSource.recentUnwrapped, lines: lines,
+ format: PaneReadFormat.ansi, stripAnsi: false)
+ }
+
+ /// Read the terminal grid hard-wrapped to the server width — backs the
+ /// Fit/Scroll modes (Reader uses `readPane`). Prefers `recent` (the full
+ /// scrollback) so the grid modes can scroll back through history, and falls
+ /// back to `visible` (the live on-screen grid) for alternate-screen TUIs,
+ /// whose scrollback is empty. ANSI is kept so the UI can render fg/bg/inverse
+ /// cells (e.g. an agent's logo).
+ public func readRawTerminal(_ pane: PaneID, lines: Int = 500) async throws -> [String] {
+ let recent = try await readLines(pane, source: PaneReadSource.recent, lines: lines,
+ format: PaneReadFormat.ansi, stripAnsi: false)
+ if !recent.isEmpty { return recent }
+ return try await readLines(pane, source: PaneReadSource.visible, lines: lines,
+ format: PaneReadFormat.ansi, stripAnsi: false)
+ }
+
+ private func readLines(_ pane: PaneID, source: String, lines: Int?,
+ format: String? = nil, stripAnsi: Bool? = nil) async throws -> [String] {
+ var params: [String: JSONValue] = [
+ "pane_id": .string(pane.rawValue),
+ "source": .string(source),
+ ]
+ if let lines { params["lines"] = .int(lines) }
+ if let format { params["format"] = .string(format) }
+ if let stripAnsi { params["strip_ansi"] = .bool(stripAnsi) }
+ let result = try await call(Method.paneRead, .object(params))
+ guard let text = try result.decodedSnake(PaneReadResult.self).read.text else { return [] }
+ // Split on the LF unicode scalar, NOT `text.split(separator: "\n")`:
+ // grid rows arrive CRLF-terminated, and Swift fuses "\r\n" into a single
+ // grapheme, so a Character-level split on "\n" never matches and collapses
+ // the whole grid into one 1800-char line (the bug behind blank grid modes).
+ var split = text.unicodeScalars
+ .split(separator: "\n", omittingEmptySubsequences: false)
+ .map { String(String.UnicodeScalarView($0)) }
+ // Drop the CR left on each row by the CRLF terminator so it doesn't leak
+ // into rendering or inflate the fit-mode width count.
+ for i in split.indices where split[i].hasSuffix("\r") { split[i].removeLast() }
+ if split.last == "" { split.removeLast() } // drop the artifact of a trailing newline
+ return split
+ }
+
+ /// Block until the pane emits **new** output, or `timeoutMS` elapses. Returns
+ /// `true` if output arrived, `false` on a clean timeout. This is the
+ /// event-driven alternative to fixed-interval polling: it holds one channel
+ /// open and returns the instant the screen changes, staying quiet while idle.
+ ///
+ /// `match` is a regex that matches any single character (incl. newlines), so
+ /// any new output satisfies it — `pane.wait_for_output` is otherwise a
+ /// targeted wait (substring/regex). A timeout comes back as an RPC error with
+ /// code `timeout`, which we treat as a normal "nothing happened" result.
+ @discardableResult
+ public func waitForOutput(
+ _ pane: PaneID,
+ source: String = PaneReadSource.recentUnwrapped,
+ timeoutMS: Int = 15_000
+ ) async throws -> Bool {
+ do {
+ _ = try await call(Method.paneWaitForOutput, .object([
+ "pane_id": .string(pane.rawValue),
+ "source": .string(source),
+ "timeout_ms": .int(timeoutMS),
+ "match": .object(["type": .string("regex"), "value": .string("(?s:.)")]),
+ ]))
+ return true
+ } catch HerdrError.rpc(let error) where error.code == "timeout" {
+ return false
+ }
+ }
+
+ /// Send literal text to a pane without a trailing newline.
+ public func sendText(_ text: String, to pane: PaneID) async throws {
+ _ = try await call(Method.paneSendText, .object([
+ "pane_id": .string(pane.rawValue),
+ "text": .string(text),
+ ]))
+ }
+
+ /// Send one or more named key presses to a pane. Key names use Herdr's
+ /// syntax: plain names (`Enter`, `Esc`, `Tab`, `Up`…) and modifier combos
+ /// with `+` (`ctrl+b`, `ctrl+c`). The wire field is a sequence.
+ public func sendKeys(_ keys: String..., to pane: PaneID) async throws {
+ _ = try await call(Method.paneSendKeys, .object([
+ "pane_id": .string(pane.rawValue),
+ "keys": .array(keys.map(JSONValue.string)),
+ ]))
+ }
+
+ /// Convenience: submit a line of input (text + Enter), as the pane view does.
+ public func submitLine(_ text: String, to pane: PaneID) async throws {
+ try await sendText(text, to: pane)
+ try await sendKeys("Enter", to: pane)
+ }
+
+ /// Create a new workspace. Both params are optional — the server fills in
+ /// defaults (an omitted `cwd` uses the server's working directory). Returns
+ /// the new workspace's id when the server reports one, so the caller can
+ /// navigate straight into it; `nil` if the result omits it (the tree still
+ /// re-lists, both via the explicit refresh and the `workspace_created` event).
+ public func createWorkspace(label: String? = nil, cwd: String? = nil) async throws -> WorkspaceID? {
+ var params: [String: JSONValue] = [:]
+ if let label, !label.isEmpty { params["label"] = .string(label) }
+ if let cwd, !cwd.isEmpty { params["cwd"] = .string(cwd) }
+ let result = try await call(Method.workspaceCreate, .object(params))
+ return Self.extractID(result, idKey: "workspace_id", nested: "workspace").map { WorkspaceID($0) }
+ }
+
+ /// Create a new tab in `workspace`. `label` is optional. Returns the new
+ /// tab's id when reported (same lenient parse / re-list contract as above).
+ public func createTab(label: String? = nil, in workspace: WorkspaceID) async throws -> TabID? {
+ var params: [String: JSONValue] = ["workspace_id": .string(workspace.rawValue)]
+ if let label, !label.isEmpty { params["label"] = .string(label) }
+ let result = try await call(Method.tabCreate, .object(params))
+ return Self.extractID(result, idKey: "tab_id", nested: "tab").map { TabID($0) }
+ }
+
+ /// Close a workspace (and everything in it). Fire-and-forget: the tree
+ /// re-lists via the `workspace.closed` topology event (and the caller's
+ /// explicit refresh). Closing kills the live processes inside — the UI
+ /// confirms before calling this.
+ public func closeWorkspace(_ id: WorkspaceID) async throws {
+ _ = try await call(Method.workspaceClose, .object(["workspace_id": .string(id.rawValue)]))
+ }
+
+ /// Close a single tab within a workspace.
+ public func closeTab(_ id: TabID) async throws {
+ _ = try await call(Method.tabClose, .object(["tab_id": .string(id.rawValue)]))
+ }
+
+ /// Close a single pane (terminal process).
+ public func closePane(_ id: PaneID) async throws {
+ _ = try await call(Method.paneClose, .object(["pane_id": .string(id.rawValue)]))
+ }
+
+ /// Pull a created resource's id out of a create result, tolerating the shapes
+ /// Herdr might use: a top-level `_id`, a nested `{"":{…}}`
+ /// (mirroring `*.get`'s `{"type":"pane_info","pane":{…}}`), or a bare `id`.
+ /// Create's result body isn't pinned down in the docs, so parse defensively.
+ private static func extractID(_ result: JSONValue, idKey: String, nested: String) -> String? {
+ result[idKey]?.stringValue
+ ?? result[nested]?[idKey]?.stringValue
+ ?? result["id"]?.stringValue
+ ?? result[nested]?["id"]?.stringValue
+ }
+
+ /// Open live subscriptions on a persistent event channel. Each call opens
+ /// its own channel (Herdr streams events per subscription connection); the
+ /// pushed events are funnelled into `eventStream`.
+ public func subscribe(_ subscriptions: [EventSubscription]) async throws {
+ let objects = subscriptions.flatMap(\.jsonObjects)
+ guard !objects.isEmpty else { return }
+ nextID += 1
+ let request = RPCRequest(
+ id: "sub_\(nextID)",
+ method: Method.eventsSubscribe,
+ params: .object(["subscriptions": .array(objects)])
+ )
+ let stream = transport.events(request)
+ // Confirm the channel opened (first message = the `subscription_started`
+ // ack) before returning, so a failed subscription throws and the caller
+ // can retry — then keep funnelling events in the background.
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ let task = Task { [weak self] in
+ var opened = false
+ for await message in stream {
+ if !opened {
+ opened = true
+ if case .response(let response) = message, let error = response.error {
+ continuation.resume(throwing: HerdrError.rpc(error))
+ return
+ }
+ continuation.resume()
+ }
+ if case .event(let raw) = message, let domain = HerdrEvent(raw) {
+ await self?.emit(domain)
+ }
+ }
+ if !opened {
+ continuation.resume(throwing: HerdrError.connectionFailed(
+ "The event subscription closed before it started."))
+ }
+ }
+ subscriptionTasks.append(task)
+ }
+ }
+
+ private func emit(_ event: HerdrEvent) { eventsContinuation.yield(event) }
+
+ // MARK: Assembly helpers
+
+ /// Best-effort `pane_id → agent name` map. `agent.list`'s shape isn't pinned
+ /// down (it's empty unless agents run), so parse defensively and tolerate any
+ /// shape — names are enrichment, not correctness.
+ private func agentNameMap() async -> [String: String] {
+ guard let agents = try? await listAgents() else { return [:] }
+ var map: [String: String] = [:]
+ for agent in agents {
+ if let name = agent.agent { map[agent.paneID.rawValue] = name }
+ }
+ return map
+ }
+
+ private func makePane(_ dto: PaneInfoDTO, _ agentNames: [String: String]) -> Pane {
+ let status = dto.agentStatus.flatMap(AgentStatus.init(rawValue:)) ?? .unknown
+ let name = agentNames[dto.paneId]
+ let isAgent = name != nil || status != .unknown
+ let title = name ?? "shell"
+ return Pane(
+ id: PaneID(dto.paneId),
+ title: title,
+ agent: name,
+ status: status,
+ isFocused: dto.focused ?? false,
+ cwd: dto.foregroundCwd ?? dto.cwd,
+ isAgent: isAgent
+ )
+ }
+
+ // MARK: Request plumbing
+
+ private func call(_ method: String, _ params: JSONValue = .object([:])) async throws -> JSONValue {
+ nextID += 1
+ let request = RPCRequest(id: "req_\(nextID)", method: method, params: params)
+ let response = try await transport.request(request)
+ if let error = response.error { throw HerdrError.rpc(error) }
+ return response.result ?? .null
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Client/HerdrEvent.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Client/HerdrEvent.swift
new file mode 100644
index 0000000..bd1ddbd
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Client/HerdrEvent.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// A decoded, domain-level event surfaced by `HerdrClient` to the UI. Raw
+/// `RPCEvent`s from the socket are translated into these so views never touch
+/// JSON.
+public enum HerdrEvent: Sendable {
+ /// An agent in a pane changed status.
+ case agentStatus(pane: PaneID, status: AgentStatus)
+ /// Topology changed; the client should re-list workspaces.
+ case topologyChanged
+
+ /// Translate a raw socket event, or `nil` if it isn't one we model. Event
+ /// names are the underscored wire form (e.g. `pane_agent_status_changed`).
+ init?(_ event: RPCEvent) {
+ // Herdr's wire is dot-namespaced (`pane.agent_status_changed`), but the
+ // pushed-event name form isn't pinned down in the docs and the Mock uses
+ // underscores. Normalize dots→underscores so either form matches — else a
+ // real server pushing the dot form drops every status/topology event and
+ // the UI's status never updates live.
+ switch event.method.replacingOccurrences(of: ".", with: "_") {
+ case EventName.paneAgentStatusChanged:
+ guard let pane = event.params["pane_id"]?.stringValue else { return nil }
+ let raw = event.params["agent_status"]?.stringValue ?? event.params["status"]?.stringValue
+ let status = raw.flatMap(AgentStatus.init(rawValue:)) ?? .unknown
+ self = .agentStatus(pane: PaneID(pane), status: status)
+
+ case let name where EventName.topology.contains(name):
+ self = .topologyChanged
+
+ default:
+ return nil
+ }
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Mock/MockData.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Mock/MockData.swift
new file mode 100644
index 0000000..40287c1
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Mock/MockData.swift
@@ -0,0 +1,71 @@
+import Foundation
+
+/// Realistic sample data so the entire UI is exercisable without a live Herdr
+/// server.
+public enum MockData {
+ public static let workspaces: [Workspace] = [
+ Workspace(
+ id: "1",
+ label: "herdr-ios",
+ cwd: "~/code/herdr-ios",
+ tabs: [
+ Tab(id: "1:1", label: "agents", panes: [
+ Pane(id: "1-1", title: "claude · build UI", agent: "claude", status: .working, isFocused: true, cwd: "~/code/herdr-ios"),
+ Pane(id: "1-2", title: "codex · write tests", agent: "codex", status: .blocked, cwd: "~/code/herdr-ios"),
+ ]),
+ Tab(id: "1:2", label: "shell", panes: [
+ Pane(id: "1-3", title: "zsh", agent: nil, status: .unknown, cwd: "~/code/herdr-ios"),
+ ]),
+ ]
+ ),
+ Workspace(
+ id: "2",
+ label: "api-server",
+ cwd: "~/code/api",
+ tabs: [
+ Tab(id: "2:1", label: "main", panes: [
+ Pane(id: "2-1", title: "claude · refactor auth", agent: "claude", status: .done, cwd: "~/code/api"),
+ Pane(id: "2-2", title: "claude · migrations", agent: "claude", status: .idle, cwd: "~/code/api"),
+ Pane(id: "2-3", title: "logs", agent: nil, status: .unknown, cwd: "~/code/api"),
+ ]),
+ ]
+ ),
+ Workspace(
+ id: "3",
+ label: "infra",
+ cwd: "~/ops",
+ tabs: [
+ Tab(id: "3:1", label: "deploy", panes: [
+ Pane(id: "3-1", title: "codex · terraform plan", agent: "codex", status: .working, cwd: "~/ops"),
+ ]),
+ ]
+ ),
+ ]
+
+ /// Canned recent scrollback per pane.
+ public static let output: [PaneID: [String]] = [
+ "1-1": [
+ "● Building SwiftUI views…",
+ " Created WorkspaceListView.swift",
+ " Created PaneView.swift",
+ "● Wiring the HerdrClient event stream",
+ " Subscribed to agent-status events",
+ "▌",
+ ],
+ "1-2": [
+ "● Writing tests for the NDJSON codec",
+ " ? Should events without an id be treated as notifications?",
+ " Waiting for your confirmation to proceed…",
+ ],
+ "2-1": [
+ "● Refactor complete.",
+ " 12 files changed, 340 insertions(+), 210 deletions(-)",
+ "✓ All checks passed.",
+ ],
+ "3-1": [
+ "● terraform plan",
+ " ~ aws_instance.app will be updated in-place",
+ " Plan: 0 to add, 1 to change, 0 to destroy.",
+ ],
+ ]
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Mock/MockTransport.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Mock/MockTransport.swift
new file mode 100644
index 0000000..bfe6d8e
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Mock/MockTransport.swift
@@ -0,0 +1,229 @@
+import Foundation
+
+/// In-memory `HerdrTransport` that answers requests from sample data and streams
+/// a trickle of live status events, so the app behaves like it's connected to a
+/// busy Herdr server. Mirrors the real wire shapes (type-tagged responses,
+/// `{"event":…}` envelopes) and the real one-request-per-connection model. This
+/// is the default transport the app boots on.
+public actor MockTransport: HerdrTransport {
+ private var workspaces: [Workspace]
+ private let output: [PaneID: [String]]
+ private let agentPaneIDs: [PaneID]
+ private let tickInterval: Duration
+
+ public init(
+ workspaces: [Workspace] = MockData.workspaces,
+ output: [PaneID: [String]] = MockData.output,
+ tickInterval: Duration = .seconds(3)
+ ) {
+ self.workspaces = workspaces
+ self.output = output
+ self.agentPaneIDs = workspaces.flatMap(\.agentPanes).map(\.id)
+ self.tickInterval = tickInterval
+ }
+
+ public func connect() async throws {}
+ public func disconnect() async {}
+
+ public func request(_ request: RPCRequest) async throws -> RPCResponse {
+ if request.method == Method.paneWaitForOutput {
+ // The demo has static scrollback, so model an idle pane: wait briefly,
+ // then report the server's real idle response (a `timeout` error). The
+ // poll loop re-reads on its normal cadence; the live feel comes from
+ // the streamed agent-status events.
+ try? await Task.sleep(for: .seconds(2))
+ return RPCResponse(id: request.id, result: nil,
+ error: RPCError(code: "timeout", message: "timed out waiting for output match"))
+ }
+ return makeResponse(for: request)
+ }
+
+ /// Persistent subscription: acks with `subscription_started`, then emits
+ /// agent-status changes (the real event shape) **only for the panes the
+ /// request subscribed to** via `pane.agent_status_changed`. A topology-only
+ /// subscription gets the ack and no status events — mirroring the server, so
+ /// tests exercise the real subscription wiring.
+ public nonisolated func events(_ subscribeRequest: RPCRequest) -> AsyncStream {
+ let subscribedPanes: [PaneID] = (subscribeRequest.params["subscriptions"]?.arrayValue ?? [])
+ .compactMap { sub in
+ sub["type"]?.stringValue == SubscriptionType.paneAgentStatusChanged
+ ? sub["pane_id"]?.stringValue.map { PaneID($0) }
+ : nil
+ }
+ return AsyncStream { continuation in
+ let task = Task { [weak self] in
+ guard let self else { continuation.finish(); return }
+ continuation.yield(.response(RPCResponse(
+ id: subscribeRequest.id,
+ result: .object(["type": .string("subscription_started")]),
+ error: nil
+ )))
+ let interval = self.tickInterval
+ let agentPanes = self.agentPaneIDs
+ let targets = subscribedPanes.filter(agentPanes.contains)
+ guard !targets.isEmpty else { return } // topology-only: ack, no status ticks
+ while !Task.isCancelled {
+ try? await Task.sleep(for: interval)
+ if Task.isCancelled { break }
+ guard let pane = targets.randomElement() else { continue }
+ let status = AgentStatus.allCases.filter { $0 != .unknown }.randomElement() ?? .working
+ continuation.yield(.event(RPCEvent(
+ method: EventName.paneAgentStatusChanged,
+ params: .object([
+ "pane_id": .string(pane.rawValue),
+ "agent_status": .string(status.rawValue),
+ ])
+ )))
+ }
+ continuation.finish()
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
+ /// A fake agent status footer (the `detection` snapshot) with ANSI color, so
+ /// the demo exercises the pane's live status strip. Empty for non-agent panes.
+ private func mockStatus(for pane: PaneID?) -> String {
+ guard let pane, agentPaneIDs.contains(pane) else { return "" }
+ let e = "\u{1B}["
+ return [
+ "free-screentime-app · Opus 4.8",
+ "\(e)36mcontext\(e)0m ▓▓░░░░░░░░ 17% \(e)32mgit:main*\(e)0m",
+ "\(e)33m🔨 Build [heavy]\(e)0m critic:auto 1m50s",
+ " └ Build Free Screentime v1 per docs/V1_SCOPE.md…",
+ "\(e)35m▶▶ bypass permissions on\(e)0m (shift+tab to cycle)",
+ ].joined(separator: "\n")
+ }
+
+ /// All panes flattened, paired with their workspace/tab ids.
+ private func flatPanes() -> [(workspace: Workspace, tab: Tab, pane: Pane)] {
+ workspaces.flatMap { ws in ws.tabs.flatMap { tab in tab.panes.map { (ws, tab, $0) } } }
+ }
+
+ private func makeResponse(for request: RPCRequest) -> RPCResponse {
+ let result: JSONValue
+ switch request.method {
+ case Method.workspaceList:
+ result = .object(["type": .string("workspace_list"), "workspaces": .array(
+ workspaces.map { ws in .object([
+ "workspace_id": .string(ws.id.rawValue),
+ "label": .string(ws.label),
+ "active_tab_id": ws.tabs.first.map { .string($0.id.rawValue) } ?? .null,
+ "agent_status": .string(ws.aggregateStatus.rawValue),
+ ]) }
+ )])
+
+ case Method.tabList:
+ let wsID = request.params["workspace_id"]?.stringValue
+ let tabs = workspaces.first { $0.id.rawValue == wsID }?.tabs ?? []
+ result = .object(["type": .string("tab_list"), "tabs": .array(
+ tabs.map { tab in .object([
+ "tab_id": .string(tab.id.rawValue),
+ "workspace_id": .string(wsID ?? ""),
+ "label": .string(tab.label),
+ "agent_status": .string(AgentStatus.mostUrgent(tab.panes.map(\.status)).rawValue),
+ ]) }
+ )])
+
+ case Method.paneList:
+ result = .object(["type": .string("pane_list"), "panes": .array(
+ flatPanes().map { entry in .object([
+ "pane_id": .string(entry.pane.id.rawValue),
+ "workspace_id": .string(entry.workspace.id.rawValue),
+ "tab_id": .string(entry.tab.id.rawValue),
+ "cwd": entry.pane.cwd.map { .string($0) } ?? .null,
+ "agent_status": .string(entry.pane.status.rawValue),
+ "focused": .bool(entry.pane.isFocused),
+ ]) }
+ )])
+
+ case Method.agentList:
+ // Surface agent names so the demo shows them. Mirrors the live wire
+ // shape (verified against herdr 0.7.0): `agent` + `agent_status` +
+ // `agent_session`, keyed by `pane_id`.
+ result = .object(["type": .string("agent_list"), "agents": .array(
+ flatPanes().filter { $0.pane.isAgent }.compactMap { entry in
+ entry.pane.agent.map { name in .object([
+ "pane_id": .string(entry.pane.id.rawValue),
+ "agent": .string(name),
+ "agent_status": .string(entry.pane.status.rawValue),
+ "agent_session": .object([
+ "agent": .string(name),
+ "kind": .string("id"),
+ "source": .string("herdr:\(name)"),
+ "value": .string(entry.pane.id.rawValue),
+ ]),
+ ]) }
+ }
+ )])
+
+ case Method.paneRead:
+ let pane = request.params["pane_id"]?.stringValue.map { PaneID($0) }
+ let text: String
+ if request.params["source"]?.stringValue == PaneReadSource.detection {
+ text = mockStatus(for: pane) // agent footer (ANSI-colored), else empty
+ } else {
+ text = (pane.flatMap { output[$0] } ?? []).joined(separator: "\n")
+ }
+ result = .object(["type": .string("pane_read"), "read": .object([
+ "text": .string(text),
+ "format": .string("text"),
+ ])])
+
+ case Method.workspaceCreate:
+ let cwd = request.params["cwd"]?.stringValue
+ let id = "ws-mock\(workspaces.count + 1)"
+ let label = request.params["label"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 } ?? id
+ let pane = Pane(id: PaneID("\(id)-p1"), title: "shell", isFocused: true, cwd: cwd)
+ let workspace = Workspace(id: WorkspaceID(id), label: label, cwd: cwd,
+ tabs: [Tab(id: TabID("\(id)-t1"), label: "main", panes: [pane])])
+ workspaces.append(workspace)
+ result = .object(["type": .string("workspace_info"), "workspace": .object([
+ "workspace_id": .string(id),
+ "label": .string(label),
+ ])])
+
+ case Method.tabCreate:
+ let wsID = request.params["workspace_id"]?.stringValue ?? ""
+ guard let idx = workspaces.firstIndex(where: { $0.id.rawValue == wsID }) else {
+ return RPCResponse(id: request.id, result: nil, error: RPCError(
+ code: "not_found", message: "No such workspace: \(wsID)"))
+ }
+ let number = workspaces[idx].tabs.count + 1
+ let tabID = "\(wsID)-t\(number)"
+ let label = request.params["label"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 } ?? "tab \(number)"
+ let pane = Pane(id: PaneID("\(tabID)-p1"), title: "shell", cwd: workspaces[idx].cwd)
+ workspaces[idx].tabs.append(Tab(id: TabID(tabID), label: label, panes: [pane]))
+ result = .object(["type": .string("tab_info"), "tab": .object([
+ "tab_id": .string(tabID),
+ "workspace_id": .string(wsID),
+ "label": .string(label),
+ ])])
+
+ case Method.workspaceClose:
+ let id = request.params["workspace_id"]?.stringValue
+ workspaces.removeAll { $0.id.rawValue == id }
+ result = .object(["type": .string("ok")])
+
+ case Method.tabClose:
+ let id = request.params["tab_id"]?.stringValue
+ for i in workspaces.indices { workspaces[i].tabs.removeAll { $0.id.rawValue == id } }
+ workspaces.removeAll { $0.tabs.isEmpty } // a workspace with no tabs is gone
+ result = .object(["type": .string("ok")])
+
+ case Method.paneClose:
+ let id = request.params["pane_id"]?.stringValue
+ for w in workspaces.indices {
+ for t in workspaces[w].tabs.indices { workspaces[w].tabs[t].panes.removeAll { $0.id.rawValue == id } }
+ workspaces[w].tabs.removeAll { $0.panes.isEmpty }
+ }
+ workspaces.removeAll { $0.tabs.isEmpty }
+ result = .object(["type": .string("ok")])
+
+ default:
+ // send_text / send_keys / ping and anything else: ack.
+ result = .object(["type": .string("ok")])
+ }
+ return RPCResponse(id: request.id, result: result, error: nil)
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/AgentInfo.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/AgentInfo.swift
new file mode 100644
index 0000000..a0b01ea
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/AgentInfo.swift
@@ -0,0 +1,53 @@
+import Foundation
+
+/// A single agent reported by `agent.list`, correlated to the pane it runs in.
+///
+/// This is the typed domain model the app uses to map a pane to its Claude
+/// transcript: `agentSession?.value` is the session UUID and `agentSession?.kind`
+/// is `"id"`. `cwd` / `foregroundCwd` locate the project on disk.
+public struct AgentInfo: Sendable, Equatable {
+ /// The pane this agent runs in.
+ public let paneID: PaneID
+ /// The agent name (e.g. `"claude"`), if reported.
+ public let agent: String?
+ /// The agent's current status; `.unknown` when absent/unrecognized.
+ public let status: AgentStatus
+ /// The pane's working directory at launch.
+ public let cwd: String?
+ /// The foreground process's working directory (may differ in a worktree).
+ public let foregroundCwd: String?
+ /// The session pointer used to resolve the transcript.
+ public let agentSession: AgentSession?
+ /// The tab the pane belongs to, if reported.
+ public let tabID: TabID?
+ /// The workspace the pane belongs to, if reported.
+ public let workspaceID: WorkspaceID?
+ /// The backing terminal id, if reported.
+ public let terminalID: String?
+ /// Whether the pane is focused.
+ public let isFocused: Bool
+
+ public init(
+ paneID: PaneID,
+ agent: String? = nil,
+ status: AgentStatus = .unknown,
+ cwd: String? = nil,
+ foregroundCwd: String? = nil,
+ agentSession: AgentSession? = nil,
+ tabID: TabID? = nil,
+ workspaceID: WorkspaceID? = nil,
+ terminalID: String? = nil,
+ isFocused: Bool = false
+ ) {
+ self.paneID = paneID
+ self.agent = agent
+ self.status = status
+ self.cwd = cwd
+ self.foregroundCwd = foregroundCwd
+ self.agentSession = agentSession
+ self.tabID = tabID
+ self.workspaceID = workspaceID
+ self.terminalID = terminalID
+ self.isFocused = isFocused
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/AgentSession.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/AgentSession.swift
new file mode 100644
index 0000000..51add5b
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/AgentSession.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+/// The agent's session pointer, as reported in `agent.list` (`agent_session`).
+///
+/// For Claude, this is `{agent:"claude", kind:"id", source:"herdr:claude",
+/// value:""}` — `value` is the transcript session UUID and `kind == "id"`.
+/// Every field is optional and unknown keys are tolerated (Codable ignores keys
+/// it doesn't model), so a partial or future-extended `agent_session` still
+/// decodes — this is enrichment, not correctness.
+public struct AgentSession: Codable, Sendable, Equatable {
+ /// The agent name the session belongs to (e.g. `"claude"`).
+ public let agent: String?
+ /// How `value` should be interpreted (e.g. `"id"`).
+ public let kind: String?
+ /// Provenance of the session (e.g. `"herdr:claude"`).
+ public let source: String?
+ /// The session payload — for `kind == "id"`, the transcript UUID.
+ public let value: String?
+
+ public init(agent: String? = nil, kind: String? = nil, source: String? = nil, value: String? = nil) {
+ self.agent = agent
+ self.kind = kind
+ self.source = source
+ self.value = value
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/AgentStatus.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/AgentStatus.swift
new file mode 100644
index 0000000..9b98caf
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/AgentStatus.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// The semantic state Herdr reports for an agent running in a pane.
+///
+/// Mirrors the values documented in the socket API / `SKILL.md`:
+/// `idle`, `working`, `blocked`, `done`, `unknown`.
+public enum AgentStatus: String, Codable, Sendable, CaseIterable {
+ /// Completed and seen by the user.
+ case idle
+ /// Actively running.
+ case working
+ /// Needs input — the most urgent state.
+ case blocked
+ /// Completed but not yet seen.
+ case done
+ /// Not enough signal to classify (or not an agent).
+ case unknown
+
+ /// Ordering used when collapsing several panes into one badge: the most
+ /// attention-worthy status wins (blocked > working > done > idle > unknown).
+ public var priority: Int {
+ switch self {
+ case .blocked: return 4
+ case .working: return 3
+ case .done: return 2
+ case .idle: return 1
+ case .unknown: return 0
+ }
+ }
+
+ public static func mostUrgent(_ statuses: [AgentStatus]) -> AgentStatus {
+ statuses.max(by: { $0.priority < $1.priority }) ?? .unknown
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/IDs.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/IDs.swift
new file mode 100644
index 0000000..21c70d7
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/IDs.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+// Herdr ids are short strings that *compact* when workspaces/tabs/panes close
+// (workspace `1`, tab `1:1`, pane `1-1`). They are NOT durable — never persist
+// them or assume they survive a refresh. We model them as distinct value types
+// so a pane id can't be passed where a workspace id is expected.
+
+/// Workspace identifier, e.g. `"1"`, `"2"`.
+public struct WorkspaceID: Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral {
+ public let rawValue: String
+ public init(_ rawValue: String) { self.rawValue = rawValue }
+ public init(stringLiteral value: String) { self.rawValue = value }
+ public init(from decoder: Decoder) throws { rawValue = try decoder.singleValueContainer().decode(String.self) }
+ public func encode(to encoder: Encoder) throws { var c = encoder.singleValueContainer(); try c.encode(rawValue) }
+ public var description: String { rawValue }
+}
+
+/// Tab identifier, e.g. `"1:1"`, `"1:2"`.
+public struct TabID: Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral {
+ public let rawValue: String
+ public init(_ rawValue: String) { self.rawValue = rawValue }
+ public init(stringLiteral value: String) { self.rawValue = value }
+ public init(from decoder: Decoder) throws { rawValue = try decoder.singleValueContainer().decode(String.self) }
+ public func encode(to encoder: Encoder) throws { var c = encoder.singleValueContainer(); try c.encode(rawValue) }
+ public var description: String { rawValue }
+}
+
+/// Pane identifier, e.g. `"1-1"`, `"2-1"`.
+public struct PaneID: Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral {
+ public let rawValue: String
+ public init(_ rawValue: String) { self.rawValue = rawValue }
+ public init(stringLiteral value: String) { self.rawValue = value }
+ public init(from decoder: Decoder) throws { rawValue = try decoder.singleValueContainer().decode(String.self) }
+ public func encode(to encoder: Encoder) throws { var c = encoder.singleValueContainer(); try c.encode(rawValue) }
+ public var description: String { rawValue }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/Pane.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/Pane.swift
new file mode 100644
index 0000000..b679497
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/Pane.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+/// A pane is a real terminal process inside a tab. It may host an identified
+/// agent (e.g. Claude Code, Codex), in which case `agent` carries its name and
+/// `status` reflects the agent's live state.
+public struct Pane: Identifiable, Codable, Hashable, Sendable {
+ public let id: PaneID
+ /// Human-facing title (process / command / agent label).
+ public var title: String
+ /// Name of the detected agent, e.g. `"claude"`. `nil` when the pane is a
+ /// plain shell rather than a recognized agent.
+ public var agent: String?
+ public var status: AgentStatus
+ /// Whether this pane currently holds focus within its tab.
+ public var isFocused: Bool
+ public var cwd: String?
+ /// True when this pane hosts a recognized agent. Stored (not `agent != nil`)
+ /// because the real API can report an agent status for a pane before its
+ /// name is known — we still want the UI to treat it as an agent pane.
+ public var isAgent: Bool
+
+ public init(
+ id: PaneID,
+ title: String,
+ agent: String? = nil,
+ status: AgentStatus = .unknown,
+ isFocused: Bool = false,
+ cwd: String? = nil,
+ isAgent: Bool? = nil
+ ) {
+ self.id = id
+ self.title = title
+ self.agent = agent
+ self.status = status
+ self.isFocused = isFocused
+ self.cwd = cwd
+ self.isAgent = isAgent ?? (agent != nil)
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/Workspace.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/Workspace.swift
new file mode 100644
index 0000000..bf5bad4
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Models/Workspace.swift
@@ -0,0 +1,46 @@
+import Foundation
+
+/// A tab groups one or more panes within a workspace.
+public struct Tab: Identifiable, Codable, Hashable, Sendable {
+ public let id: TabID
+ public var label: String
+ public var panes: [Pane]
+
+ public init(id: TabID, label: String, panes: [Pane]) {
+ self.id = id
+ self.label = label
+ self.panes = panes
+ }
+}
+
+/// A workspace is a project container holding tabs (which hold panes).
+public struct Workspace: Identifiable, Codable, Hashable, Sendable {
+ public let id: WorkspaceID
+ public var label: String
+ public var cwd: String?
+ public var tabs: [Tab]
+
+ public init(id: WorkspaceID, label: String, cwd: String? = nil, tabs: [Tab]) {
+ self.id = id
+ self.label = label
+ self.cwd = cwd
+ self.tabs = tabs
+ }
+
+ /// All panes across every tab, flattened.
+ public var panes: [Pane] { tabs.flatMap(\.panes) }
+
+ /// Only the panes that host a recognized agent.
+ public var agentPanes: [Pane] { panes.filter(\.isAgent) }
+
+ /// A single status summarizing the workspace for the list row — the most
+ /// urgent agent status present (blocked beats working beats done…).
+ public var aggregateStatus: AgentStatus {
+ AgentStatus.mostUrgent(agentPanes.map(\.status))
+ }
+
+ /// Count of agent panes per status, for compact badges.
+ public func agentCounts() -> [AgentStatus: Int] {
+ Dictionary(grouping: agentPanes, by: \.status).mapValues(\.count)
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/JSONValue.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/JSONValue.swift
new file mode 100644
index 0000000..aa61ec9
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/JSONValue.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+/// A type-erased JSON value, used for RPC `params` and `result` payloads whose
+/// shape we don't want to model statically. Lets us pass through arbitrary
+/// objects while still building/typed-decoding the parts we care about.
+public enum JSONValue: Codable, Hashable, Sendable {
+ case null
+ case bool(Bool)
+ case int(Int)
+ case double(Double)
+ case string(String)
+ case array([JSONValue])
+ case object([String: JSONValue])
+
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.singleValueContainer()
+ if c.decodeNil() { self = .null; return }
+ if let b = try? c.decode(Bool.self) { self = .bool(b); return }
+ if let i = try? c.decode(Int.self) { self = .int(i); return }
+ if let d = try? c.decode(Double.self) { self = .double(d); return }
+ if let s = try? c.decode(String.self) { self = .string(s); return }
+ if let a = try? c.decode([JSONValue].self) { self = .array(a); return }
+ if let o = try? c.decode([String: JSONValue].self) { self = .object(o); return }
+ throw DecodingError.dataCorruptedError(in: c, debugDescription: "Unsupported JSON value")
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var c = encoder.singleValueContainer()
+ switch self {
+ case .null: try c.encodeNil()
+ case .bool(let b): try c.encode(b)
+ case .int(let i): try c.encode(i)
+ case .double(let d): try c.encode(d)
+ case .string(let s): try c.encode(s)
+ case .array(let a): try c.encode(a)
+ case .object(let o): try c.encode(o)
+ }
+ }
+}
+
+public extension JSONValue {
+ /// Object member access: `value["pane"]`.
+ subscript(_ key: String) -> JSONValue? {
+ if case .object(let o) = self { return o[key] }
+ return nil
+ }
+
+ var stringValue: String? { if case .string(let s) = self { return s } else { return nil } }
+ var arrayValue: [JSONValue]? { if case .array(let a) = self { return a } else { return nil } }
+
+ /// Decode using Herdr's snake_case wire keys (`workspace_id` → `workspaceId`).
+ func decodedSnake(_ type: T.Type) throws -> T {
+ let data = try JSONEncoder().encode(self)
+ let decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ return try decoder.decode(type, from: data)
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/Method.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/Method.swift
new file mode 100644
index 0000000..6152ecc
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/Method.swift
@@ -0,0 +1,91 @@
+import Foundation
+
+/// Socket RPC method names, verified against a live Herdr server (protocol 14,
+/// re-verified against herdr 0.7.0 — `agent.list` returns `agent_session` live).
+/// Methods are dot-namespaced; parameter keys are snake_case (`pane_id`, …).
+public enum Method {
+ public static let ping = "ping"
+
+ public static let workspaceList = "workspace.list"
+ public static let workspaceCreate = "workspace.create"
+ public static let workspaceClose = "workspace.close"
+ public static let tabList = "tab.list"
+ public static let tabCreate = "tab.create"
+ public static let tabClose = "tab.close"
+ public static let paneList = "pane.list"
+ public static let paneClose = "pane.close"
+ public static let agentList = "agent.list"
+
+ public static let paneRead = "pane.read"
+ public static let paneWaitForOutput = "pane.wait_for_output"
+ public static let paneSendText = "pane.send_text"
+ public static let paneSendKeys = "pane.send_keys"
+
+ /// Open a live subscription; the server then streams events on the socket.
+ public static let eventsSubscribe = "events.subscribe"
+}
+
+/// Valid `source` values for `pane.read`.
+public enum PaneReadSource {
+ /// The live on-screen grid, including alternate-screen TUIs (agent UIs).
+ public static let visible = "visible"
+ public static let recent = "recent"
+ public static let recentUnwrapped = "recent_unwrapped"
+ public static let detection = "detection"
+}
+
+/// Valid `format` values for `pane.read`. `ansi` keeps SGR color/style escapes
+/// (pair with `strip_ansi: false`); `text` is plain.
+public enum PaneReadFormat {
+ public static let text = "text"
+ public static let ansi = "ansi"
+}
+
+/// Subscription `type` strings (dot-namespaced) sent inside
+/// `events.subscribe`'s `subscriptions` array.
+public enum SubscriptionType {
+ public static let paneAgentStatusChanged = "pane.agent_status_changed"
+
+ /// Topology-changing subscriptions that don't need a resource id — any of
+ /// these means "re-list". (Per-resource events like `pane.focused` require a
+ /// `pane_id` and are intentionally omitted.)
+ public static let topology = [
+ "workspace.created", "workspace.updated", "workspace.closed", "workspace.renamed",
+ "tab.created", "tab.closed", "tab.renamed",
+ "pane.created", "pane.closed", "pane.moved", "pane.exited", "pane.agent_detected",
+ ]
+}
+
+/// A subscription request, expanded into the wire `subscriptions` objects.
+public enum EventSubscription: Sendable {
+ /// All topology-changing events (re-list trigger).
+ case topology
+ /// Agent-status changes for a specific pane.
+ case paneAgentStatus(PaneID)
+
+ var jsonObjects: [JSONValue] {
+ switch self {
+ case .topology:
+ return SubscriptionType.topology.map { .object(["type": .string($0)]) }
+ case .paneAgentStatus(let pane):
+ return [.object([
+ "type": .string(SubscriptionType.paneAgentStatusChanged),
+ "pane_id": .string(pane.rawValue),
+ ])]
+ }
+ }
+}
+
+/// Canonical (underscored) internal form of pushed-event names. The wire may
+/// spell them dot-namespaced (`pane.agent_status_changed`) or underscored;
+/// `HerdrEvent.init` normalizes dots→underscores before matching against these.
+public enum EventName {
+ public static let paneAgentStatusChanged = "pane_agent_status_changed"
+
+ /// Pushed events that imply the workspace/tab/pane tree changed.
+ public static let topology: Set = [
+ "workspace_created", "workspace_updated", "workspace_closed", "workspace_renamed",
+ "tab_created", "tab_closed", "tab_renamed",
+ "pane_created", "pane_closed", "pane_moved", "pane_exited", "pane_agent_detected",
+ ]
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/NDJSON.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/NDJSON.swift
new file mode 100644
index 0000000..c1da2e8
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/NDJSON.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// Newline-delimited JSON framing helpers.
+public enum NDJSON {
+ public static let newline: UInt8 = 0x0A
+
+ /// Encode a value to a single JSON line terminated by `\n`.
+ public static func frame(_ value: T) throws -> Data {
+ var data = try JSONEncoder().encode(value)
+ data.append(newline)
+ return data
+ }
+}
+
+/// Accumulates incoming bytes and yields complete `\n`-terminated lines as they
+/// arrive. Used by stream transports (e.g. the SSH channel bridge) to turn a
+/// byte stream into discrete JSON messages.
+public struct LineBuffer {
+ private var buffer = Data()
+ public init() {}
+
+ /// Append a chunk and return any complete lines now available (without their
+ /// trailing newline). Partial trailing data is retained for the next call.
+ public mutating func append(_ chunk: Data) -> [Data] {
+ buffer.append(chunk)
+ var lines: [Data] = []
+ while let newlineIndex = buffer.firstIndex(of: NDJSON.newline) {
+ let line = buffer[buffer.startIndex..","data":{…}}` (no id/result);
+ /// replies carry `result`/`error` and echo the request `id`. The legacy
+ /// `{"method":…}` form (no id) is still treated as an event for the Mock.
+ public static func decode(line: Data) throws -> IncomingMessage {
+ let raw = try JSONDecoder().decode(RawMessage.self, from: line)
+ if let event = raw.event {
+ return .event(RPCEvent(method: event, params: raw.data ?? .object([:])))
+ }
+ if raw.result != nil || raw.error != nil {
+ return .response(RPCResponse(id: raw.id, result: raw.result, error: raw.error))
+ }
+ if let method = raw.method, raw.id == nil {
+ return .event(RPCEvent(method: method, params: raw.params ?? .object([:])))
+ }
+ // Bare ack: an id with no result body.
+ return .response(RPCResponse(id: raw.id, result: raw.params, error: nil))
+ }
+
+ private struct RawMessage: Decodable {
+ let id: String?
+ let method: String?
+ let params: JSONValue?
+ let result: JSONValue?
+ let error: RPCError?
+ /// Pushed-event name and payload (`{"event":…,"data":…}`).
+ let event: String?
+ let data: JSONValue?
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/Wire.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/Wire.swift
new file mode 100644
index 0000000..ac551a8
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Protocol/Wire.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+// Data-transfer objects mirroring Herdr's real (flat, type-tagged) socket
+// responses, verified against a live server (protocol 14). Decoded with
+// `JSONValue.decodedSnake` so wire keys like `workspace_id` map to `workspaceId`.
+// `HerdrClient` assembles these into the app's nested domain tree, so the rest
+// of the app never sees the wire shape.
+
+/// `workspace.list` / `workspace.get` element.
+struct WorkspaceSummaryDTO: Decodable {
+ let workspaceId: String
+ let label: String
+ let activeTabId: String?
+ let agentStatus: String?
+}
+
+/// `tab.list` / `tab.get` element.
+struct TabSummaryDTO: Decodable {
+ let tabId: String
+ let workspaceId: String
+ let label: String
+ let agentStatus: String?
+}
+
+/// `pane.list` / `pane.get` element.
+struct PaneInfoDTO: Decodable {
+ let paneId: String
+ let workspaceId: String
+ let tabId: String
+ let cwd: String?
+ let foregroundCwd: String?
+ let agentStatus: String?
+ let focused: Bool?
+}
+
+/// `agent.list` element. Unlike the other DTOs this carries the `agent_session`
+/// pointer (verified live against herdr 0.7.0) so the app can resolve a pane to
+/// its agent's transcript. `agentSession` is the public, lenient `AgentSession`.
+struct AgentInfoDTO: Decodable {
+ let paneId: String
+ let agent: String?
+ let agentStatus: String?
+ let cwd: String?
+ let foregroundCwd: String?
+ let agentSession: AgentSession?
+ let tabId: String?
+ let workspaceId: String?
+ let terminalId: String?
+ let focused: Bool?
+}
+
+/// `pane.read` payload (`result.read`).
+struct PaneReadDTO: Decodable {
+ let text: String?
+ let format: String?
+}
+
+struct WorkspaceListResult: Decodable { let workspaces: [WorkspaceSummaryDTO] }
+struct TabListResult: Decodable { let tabs: [TabSummaryDTO] }
+struct PaneListResult: Decodable { let panes: [PaneInfoDTO] }
+struct PaneReadResult: Decodable { let read: PaneReadDTO }
+struct AgentListResult: Decodable { let agents: [AgentInfoDTO] }
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/TerminalText.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/TerminalText.swift
new file mode 100644
index 0000000..f81ee6a
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/TerminalText.swift
@@ -0,0 +1,68 @@
+import Foundation
+
+/// Projects raw terminal output into a readable mobile transcript: drops the
+/// box-drawing frames a TUI agent (Claude Code, etc.) draws for a wide grid,
+/// unwraps `│ content │` side borders, collapses blank runs, and de-duplicates
+/// the current-screen footer that both `recent` and `detection` reads contain.
+///
+/// Color is *not* touched here — these functions preserve any ANSI SGR escapes
+/// inside the kept text so the UI can still colorize it; ANSI is only stripped
+/// internally for classification/comparison. Pure Foundation, so it unit-tests
+/// on Linux alongside the rest of HerdrKit.
+public enum TerminalText {
+ private static let ansiPattern = "\u{1B}\\[[0-9;?]*[ -/]*[@-~]"
+
+ /// Strip ANSI/VT escape sequences — used to inspect a line's visible text.
+ public static func stripANSI(_ s: String) -> String {
+ guard s.contains("\u{1B}") else { return s }
+ return s.replacingOccurrences(of: ansiPattern, with: "", options: .regularExpression)
+ }
+
+ /// A line whose visible text is nothing but frame/rule characters (box
+ /// drawing, or a run of `-`/`=`/`_`) — i.e. a border or horizontal rule we
+ /// drop entirely on mobile.
+ public static func isFramingLine(_ visible: String) -> Bool {
+ let t = visible.trimmingCharacters(in: .whitespaces)
+ guard t.count >= 2 else { return false }
+ return t.unicodeScalars.allSatisfy { s in
+ (0x2500...0x257F).contains(s.value) // box drawing
+ || s == "-" || s == "=" || s == "_" || s == " "
+ }
+ }
+
+ /// If a line is framed as `│ content │`, drop the outer borders and one pad
+ /// space on each side, preserving inner ANSI. Lines without matching side
+ /// borders are returned unchanged.
+ public static func unwrapSides(_ raw: String) -> String {
+ let v = stripANSI(raw).trimmingCharacters(in: .whitespaces)
+ guard let first = v.first, let last = v.last,
+ "│┃|".contains(first), "│┃|".contains(last), v.count >= 2 else { return raw }
+ let ansi = "(?:\(ansiPattern))*"
+ var s = raw.replacingOccurrences(
+ of: "^\\s*\(ansi)[│┃|]\\s?", with: "", options: .regularExpression)
+ s = s.replacingOccurrences(
+ of: "\\s?[│┃|]\(ansi)\\s*$", with: "", options: .regularExpression)
+ return s
+ }
+
+ /// Clean a block for mobile reading: drop framing lines, unwrap side borders,
+ /// right-trim grid padding, and collapse runs of blank lines (and leading /
+ /// trailing blanks) so the transcript reads without the grid's empty space.
+ public static func clean(_ lines: [String]) -> [String] {
+ var out: [String] = []
+ var pendingBlank = false
+ for raw in lines {
+ let visible = stripANSI(raw)
+ if visible.trimmingCharacters(in: .whitespaces).isEmpty {
+ pendingBlank = !out.isEmpty
+ continue
+ }
+ if isFramingLine(visible) { continue }
+ var line = unwrapSides(raw)
+ while let last = line.last, last == " " || last == "\t" { line.removeLast() }
+ if pendingBlank { out.append(""); pendingBlank = false }
+ out.append(line)
+ }
+ return out
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Sources/HerdrKit/Transport/HerdrTransport.swift b/loadout/Packages/HerdrKit/Sources/HerdrKit/Transport/HerdrTransport.swift
new file mode 100644
index 0000000..7af645c
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Sources/HerdrKit/Transport/HerdrTransport.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+/// A connection to a Herdr socket.
+///
+/// Herdr's socket is **one-request-per-connection** for RPC: you open a
+/// connection, send one request, read its reply, and the server closes it.
+/// Only `events.subscribe` keeps a connection open (to stream events). The
+/// transport models exactly that: `request` is a one-shot round-trip, `events`
+/// opens a persistent subscription stream. Request/response correlation isn't
+/// needed — each request has its own connection, so its reply is unambiguous.
+public protocol HerdrTransport: Sendable {
+ /// Establish the underlying connection (e.g. the SSH session). Per-request
+ /// channels are opened lazily.
+ func connect() async throws
+
+ /// One-shot request/response: open a channel, send the request, read the
+ /// single reply, and let the server close the channel.
+ func request(_ request: RPCRequest) async throws -> RPCResponse
+
+ /// Open a persistent subscription: send `subscribeRequest`, then stream every
+ /// pushed message until the channel closes or the stream is cancelled.
+ func events(_ subscribeRequest: RPCRequest) -> AsyncStream
+
+ /// Close the connection.
+ func disconnect() async
+}
+
+public enum HerdrError: Error, Sendable {
+ case notConnected
+ case transportClosed
+ case rpc(RPCError)
+ /// A human-readable SSH connection problem — bad credentials, unreachable
+ /// host, or a socket bridge that couldn't start. Carries a message safe to
+ /// show the user.
+ case connectionFailed(String)
+}
diff --git a/loadout/Packages/HerdrKit/Tests/HerdrKitTests/AgentListTests.swift b/loadout/Packages/HerdrKit/Tests/HerdrKitTests/AgentListTests.swift
new file mode 100644
index 0000000..5a98901
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Tests/HerdrKitTests/AgentListTests.swift
@@ -0,0 +1,82 @@
+import XCTest
+@testable import HerdrKit
+
+/// Decodes the real captured `agent.list` response (`.motif/fixtures/agent-list.json`)
+/// inlined here so the test needs no SwiftPM resource. Asserts the typed
+/// `agent.list` path surfaces `agent_session` and friends.
+final class AgentListTests: XCTestCase {
+ /// A real `herdr agent list` reply, verbatim (herdr 0.7.0).
+ private let fixture = #"""
+ {"id":"cli:agent:list","result":{"agents":[{"agent":"claude","agent_session":{"agent":"claude","kind":"id","source":"herdr:claude","value":"0af0a380-8159-4af4-99aa-82892611e863"},"agent_status":"idle","cwd":"/Users/zackbart/Dev/bepresent/screentox-webunnel","focused":false,"foreground_cwd":"/Users/zackbart/Dev/bepresent/screentox-webunnel","pane_id":"wR:p1","revision":0,"tab_id":"wR:t1","terminal_id":"term_6549e3d9490431c","workspace_id":"wR"},{"agent":"claude","agent_session":{"agent":"claude","kind":"id","source":"herdr:claude","value":"7671f37d-7258-4d2a-a51c-d5674bdb0afc"},"agent_status":"working","cwd":"/Users/zackbart/Dev/projects/tooling/loadout","focused":true,"foreground_cwd":"/Users/zackbart/Dev/projects/tooling/loadout","pane_id":"wZ:p1","revision":0,"tab_id":"wZ:t1","terminal_id":"term_654c61112abdc24","workspace_id":"wZ"}],"type":"agent_list"}}
+ """#
+
+ /// Decode the `result` object the way `HerdrClient` does (`decodedSnake`).
+ private func decodeAgents() throws -> [AgentInfo] {
+ let message = Data(fixture.utf8)
+ guard case .response(let response) = try IncomingMessage.decode(line: message),
+ let result = response.result else {
+ XCTFail("expected a response with a result")
+ return []
+ }
+ return try result.decodedSnake(AgentListResult.self).agents.map {
+ AgentInfo(
+ paneID: PaneID($0.paneId),
+ agent: $0.agent,
+ status: $0.agentStatus.flatMap(AgentStatus.init(rawValue:)) ?? .unknown,
+ cwd: $0.cwd,
+ foregroundCwd: $0.foregroundCwd,
+ agentSession: $0.agentSession,
+ tabID: $0.tabId.map { TabID($0) },
+ workspaceID: $0.workspaceId.map { WorkspaceID($0) },
+ terminalID: $0.terminalId,
+ isFocused: $0.focused ?? false
+ )
+ }
+ }
+
+ func testDecodesAtLeastOneAgent() throws {
+ let agents = try decodeAgents()
+ XCTAssertGreaterThanOrEqual(agents.count, 1)
+ }
+
+ func testLoadoutPaneSessionIsAUUIDWithKindID() throws {
+ let agents = try decodeAgents()
+ guard let loadout = agents.first(where: { $0.cwd == "/Users/zackbart/Dev/projects/tooling/loadout" }) else {
+ return XCTFail("expected the loadout pane")
+ }
+ XCTAssertEqual(loadout.agentSession?.kind, "id")
+ let value = try XCTUnwrap(loadout.agentSession?.value)
+ XCTAssertNotNil(UUID(uuidString: value), "agent_session.value must be a UUID")
+ XCTAssertEqual(value, "7671f37d-7258-4d2a-a51c-d5674bdb0afc")
+ }
+
+ func testForegroundCwdAndStatusParsed() throws {
+ let agents = try decodeAgents()
+ let loadout = try XCTUnwrap(agents.first(where: { $0.paneID == PaneID("wZ:p1") }))
+ XCTAssertEqual(loadout.foregroundCwd, "/Users/zackbart/Dev/projects/tooling/loadout")
+ XCTAssertEqual(loadout.status, .working)
+
+ let other = try XCTUnwrap(agents.first(where: { $0.paneID == PaneID("wR:p1") }))
+ XCTAssertEqual(other.status, .idle)
+ XCTAssertEqual(other.foregroundCwd, "/Users/zackbart/Dev/bepresent/screentox-webunnel")
+ }
+
+ // MARK: Lenient decode
+
+ /// An unknown extra key in `agent_session` must not break decoding.
+ func testAgentSessionTitlesExtraUnknownKeyStillDecodes() throws {
+ let json = Data(#"{"agent":"claude","kind":"id","source":"herdr:claude","value":"abc","future_field":42}"#.utf8)
+ let session = try JSONDecoder().decode(AgentSession.self, from: json)
+ XCTAssertEqual(session.value, "abc")
+ XCTAssertEqual(session.kind, "id")
+ }
+
+ /// A missing `source` (and `agent`) must still decode — every field optional.
+ func testAgentSessionMissingSourceStillDecodes() throws {
+ let json = Data(#"{"kind":"id","value":"abc"}"#.utf8)
+ let session = try JSONDecoder().decode(AgentSession.self, from: json)
+ XCTAssertNil(session.source)
+ XCTAssertNil(session.agent)
+ XCTAssertEqual(session.value, "abc")
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Tests/HerdrKitTests/ClientTests.swift b/loadout/Packages/HerdrKit/Tests/HerdrKitTests/ClientTests.swift
new file mode 100644
index 0000000..51b48ef
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Tests/HerdrKitTests/ClientTests.swift
@@ -0,0 +1,167 @@
+import XCTest
+@testable import HerdrKit
+
+final class ClientTests: XCTestCase {
+ /// Exercises assembly: workspace.list + pane.list + tab.list + agent.list →
+ /// nested tree.
+ func testListWorkspacesAssemblesTree() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+
+ let workspaces = try await client.listWorkspaces()
+ XCTAssertEqual(workspaces.map(\.label), ["herdr-ios", "api-server", "infra"])
+ XCTAssertEqual(workspaces[0].aggregateStatus, .blocked, "blocked agent should win the badge")
+
+ // Panes are grouped under the right tabs from the global pane.list.
+ XCTAssertEqual(workspaces[0].tabs.map(\.label), ["agents", "shell"])
+ XCTAssertEqual(workspaces[0].tabs[0].panes.count, 2)
+ let claude = workspaces[0].tabs[0].panes.first { $0.id == "1-1" }
+ XCTAssertEqual(claude?.agent, "claude")
+ XCTAssertTrue(claude?.isAgent == true)
+ }
+
+ func testReadPaneReturnsLines() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+
+ let lines = try await client.readPane("1-2")
+ XCTAssertTrue(lines.contains { $0.contains("Waiting for your confirmation") })
+ }
+
+ /// The mock models an idle pane, so `waitForOutput` returns `false` on the
+ /// server's `timeout` error rather than throwing — the gate the live poll
+ /// loop relies on to keep looping instead of erroring out.
+ func testWaitForOutputReturnsFalseOnTimeout() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+ let matched = try await client.waitForOutput("1-1", timeoutMS: 50)
+ XCTAssertFalse(matched, "an idle-pane timeout is a normal false, not a throw")
+ }
+
+ /// Subscribing to a pane's status opens the persistent event channel and
+ /// delivers that pane's status changes as typed `HerdrEvent`s.
+ func testSubscribeDeliversStatusChangesForSubscribedPane() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .milliseconds(20)))
+ try await client.connect()
+ try await client.subscribe([.paneAgentStatus("1-1")])
+
+ let received = Task { () -> HerdrEvent? in
+ for await event in await client.eventStream {
+ if case .agentStatus(let pane, _) = event, pane == "1-1" { return event }
+ }
+ return nil
+ }
+ let event = await received.value
+ guard case .agentStatus(let pane, _)? = event else {
+ return XCTFail("expected an agentStatus event for the subscribed pane")
+ }
+ XCTAssertEqual(pane, "1-1")
+ }
+
+ /// A topology-only subscription gets the ack but no status events (the mock
+ /// mirrors the server), so `subscribe` still returns without hanging.
+ func testTopologyOnlySubscriptionSucceeds() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .milliseconds(20)))
+ try await client.connect()
+ try await client.subscribe([.topology]) // must not throw or hang
+ }
+
+ /// `workspace.create` returns the new id and a subsequent list reflects it.
+ func testCreateWorkspaceReturnsIDAndAppears() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+ let before = try await client.listWorkspaces().count
+
+ let id = try await client.createWorkspace(label: "scratch", cwd: "~/tmp")
+ XCTAssertNotNil(id, "the mock reports the new workspace id")
+
+ let after = try await client.listWorkspaces()
+ XCTAssertEqual(after.count, before + 1)
+ let created = after.first { $0.id == id }
+ XCTAssertEqual(created?.label, "scratch")
+ XCTAssertEqual(created?.cwd, "~/tmp")
+ }
+
+ /// `tab.create` adds a tab to the target workspace; an empty label is dropped
+ /// from the request so the server names it.
+ func testCreateTabAddsTabToWorkspace() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+ let workspace = try await client.listWorkspaces()[0]
+ let tabsBefore = workspace.tabs.count
+
+ let tabID = try await client.createTab(label: "", in: workspace.id)
+ XCTAssertNotNil(tabID)
+
+ let updated = try await client.listWorkspaces().first { $0.id == workspace.id }
+ XCTAssertEqual(updated?.tabs.count, tabsBefore + 1)
+ XCTAssertEqual(updated?.tabs.last?.id, tabID)
+ }
+
+ /// Creating a tab in a non-existent workspace surfaces the server's RPC error.
+ func testCreateTabInUnknownWorkspaceThrows() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+ do {
+ _ = try await client.createTab(label: "x", in: "no-such-ws")
+ XCTFail("expected an RPC error for an unknown workspace")
+ } catch let HerdrError.rpc(error) {
+ XCTAssertEqual(error.code, "not_found")
+ }
+ }
+
+ /// `pane.close` drops the pane, and an emptied tab/workspace is pruned with
+ /// it — so closing the only pane removes the whole workspace from the list.
+ func testClosePanePrunesEmptyTabAndWorkspace() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+
+ // "infra" has a single tab with a single pane — closing it empties both.
+ let infra = try await client.listWorkspaces().first { $0.label == "infra" }
+ let onlyPane = try XCTUnwrap(infra?.tabs.first?.panes.first)
+ try await client.closePane(onlyPane.id)
+
+ let after = try await client.listWorkspaces()
+ XCTAssertNil(after.first { $0.label == "infra" }, "an emptied workspace is pruned")
+ }
+
+ /// `workspace.close` removes the whole workspace.
+ func testCloseWorkspaceRemovesIt() async throws {
+ let client = HerdrClient(transport: MockTransport(tickInterval: .seconds(3600)))
+ try await client.connect()
+ let first = try await client.listWorkspaces().first
+ let target = try XCTUnwrap(first)
+ try await client.closeWorkspace(target.id)
+
+ let after = try await client.listWorkspaces()
+ XCTAssertNil(after.first { $0.id == target.id })
+ }
+
+ /// Regression: grid rows arrive CRLF-terminated, and Swift fuses "\r\n" into
+ /// one grapheme, so a Character-level `split(separator: "\n")` collapses the
+ /// whole screen into a single line. `readRawTerminal` must split on the LF
+ /// scalar and return one entry per row. (The Mock uses LF only, so it can't
+ /// catch this — hence the dedicated CRLF transport.)
+ func testReadRawTerminalSplitsCRLFRows() async throws {
+ let client = HerdrClient(transport: CRLFTransport(text: "row one\r\nrow two\r\nrow three\r\n"))
+ try await client.connect()
+ let lines = try await client.readRawTerminal("1-1")
+ XCTAssertEqual(lines, ["row one", "row two", "row three"])
+ }
+}
+
+/// Minimal transport that answers every `pane.read` with a fixed CRLF body.
+private struct CRLFTransport: HerdrTransport {
+ let text: String
+ func connect() async throws {}
+ func disconnect() async {}
+ func request(_ request: RPCRequest) async throws -> RPCResponse {
+ RPCResponse(id: request.id, result: .object([
+ "type": .string("pane_read"),
+ "read": .object(["text": .string(text), "format": .string("text")]),
+ ]), error: nil)
+ }
+ func events(_ subscribeRequest: RPCRequest) -> AsyncStream {
+ AsyncStream { $0.finish() }
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Tests/HerdrKitTests/CodecTests.swift b/loadout/Packages/HerdrKit/Tests/HerdrKitTests/CodecTests.swift
new file mode 100644
index 0000000..4a8de8c
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Tests/HerdrKitTests/CodecTests.swift
@@ -0,0 +1,112 @@
+import XCTest
+@testable import HerdrKit
+
+final class CodecTests: XCTestCase {
+ func testRequestFramingMatchesDocumentedShape() throws {
+ let request = RPCRequest(id: "req_1", method: "ping", params: .object([:]))
+ let line = try NDJSON.frame(request)
+
+ XCTAssertEqual(line.last, NDJSON.newline, "frames must be newline-terminated")
+
+ let object = try JSONSerialization.jsonObject(with: line.dropLast()) as? [String: Any]
+ XCTAssertEqual(object?["id"] as? String, "req_1")
+ XCTAssertEqual(object?["method"] as? String, "ping")
+ }
+
+ func testDecodeResponseMessage() throws {
+ let line = Data(#"{"id":"req_1","result":{"type":"pong"}}"#.utf8)
+ guard case .response(let response) = try IncomingMessage.decode(line: line) else {
+ return XCTFail("expected a response")
+ }
+ XCTAssertEqual(response.id, "req_1")
+ XCTAssertEqual(response.result?["type"]?.stringValue, "pong")
+ XCTAssertNil(response.error)
+ }
+
+ /// Herdr pushes events as `{"event":"…","data":{…}}` (real wire sample).
+ func testDecodePushedStatusEvent() throws {
+ let line = Data(#"{"event":"pane_agent_status_changed","data":{"type":"pane_agent_status_changed","pane_id":"w4:p1","agent_status":"working"}}"#.utf8)
+ guard case .event(let event) = try IncomingMessage.decode(line: line) else {
+ return XCTFail("expected an event")
+ }
+ XCTAssertEqual(event.method, "pane_agent_status_changed")
+ XCTAssertEqual(HerdrEvent(event).map(String.init(describing:)),
+ String(describing: HerdrEvent.agentStatus(pane: "w4:p1", status: .working)))
+ }
+
+ /// The real server is dot-namespaced (`pane.agent_status_changed`); the Mock
+ /// uses underscores. `HerdrEvent` normalizes dots→underscores, so the
+ /// dot-spelled pushed event must still map to `.agentStatus` — else live
+ /// status updates silently stop on a real host.
+ func testDecodeDotNamespacedStatusEvent() throws {
+ let line = Data(#"{"event":"pane.agent_status_changed","data":{"pane_id":"w4:p1","agent_status":"blocked"}}"#.utf8)
+ guard case .event(let event) = try IncomingMessage.decode(line: line) else {
+ return XCTFail("expected an event")
+ }
+ XCTAssertEqual(HerdrEvent(event).map(String.init(describing:)),
+ String(describing: HerdrEvent.agentStatus(pane: "w4:p1", status: .blocked)))
+ }
+
+ /// A topology event maps to `.topologyChanged` — in both the Mock's
+ /// underscore form and the real server's dot form.
+ func testDecodeTopologyEvent() throws {
+ for name in ["tab_closed", "tab.closed"] {
+ let line = Data(#"{"event":"\#(name)","data":{"tab_id":"w4:t2","workspace_id":"w4"}}"#.utf8)
+ guard case .event(let event) = try IncomingMessage.decode(line: line),
+ case .topologyChanged? = HerdrEvent(event) else {
+ return XCTFail("expected a topologyChanged event for \(name)")
+ }
+ }
+ }
+
+ /// Herdr returns string error codes; decoding must not drop the message.
+ func testDecodeErrorResponseWithStringCode() throws {
+ let line = Data(#"{"id":"r","error":{"code":"invalid_request","message":"missing field `pane_id`"}}"#.utf8)
+ guard case .response(let response) = try IncomingMessage.decode(line: line) else {
+ return XCTFail("expected a response")
+ }
+ XCTAssertEqual(response.error?.code, "invalid_request")
+ XCTAssertEqual(response.error?.message, "missing field `pane_id`")
+ }
+
+ // MARK: Real wire fixtures (captured from a live server, protocol 14)
+
+ func testWorkspaceListDecodesRealShape() throws {
+ let line = Data(#"{"type":"workspace_list","workspaces":[{"workspace_id":"w4","number":1,"label":"~","focused":true,"pane_count":1,"tab_count":1,"active_tab_id":"w4:t1","agent_status":"unknown"}]}"#.utf8)
+ let value = try JSONDecoder().decode(JSONValue.self, from: line)
+ let result = try value.decodedSnake(WorkspaceListResult.self)
+ XCTAssertEqual(result.workspaces.count, 1)
+ XCTAssertEqual(result.workspaces[0].workspaceId, "w4")
+ XCTAssertEqual(result.workspaces[0].activeTabId, "w4:t1")
+ XCTAssertEqual(result.workspaces[0].agentStatus, "unknown")
+ }
+
+ func testPaneReadDecodesRealShape() throws {
+ let line = Data(#"{"type":"pane_read","read":{"pane_id":"w4:p1","source":"recent","format":"text","text":"line a\nline b\n","truncated":false}}"#.utf8)
+ let value = try JSONDecoder().decode(JSONValue.self, from: line)
+ let read = try value.decodedSnake(PaneReadResult.self).read
+ XCTAssertEqual(read.text, "line a\nline b\n")
+ }
+
+ func testLineBufferSplitsAndRetainsPartials() {
+ var buffer = LineBuffer()
+ XCTAssertEqual(buffer.append(Data(#"{"a":1}"#.utf8)).count, 0, "no newline yet → no lines")
+ let lines = buffer.append(Data("\n{\"b\":2}\n{\"c\"".utf8))
+ XCTAssertEqual(lines.count, 2)
+ XCTAssertEqual(String(data: lines[0], encoding: .utf8), #"{"a":1}"#)
+ XCTAssertEqual(String(data: lines[1], encoding: .utf8), #"{"b":2}"#)
+ // The trailing partial is retained until its newline arrives.
+ let rest = buffer.append(Data(":3}\n".utf8))
+ XCTAssertEqual(String(data: rest[0], encoding: .utf8), #"{"c":3}"#)
+ }
+
+ func testJSONValueRoundTrip() throws {
+ let value = JSONValue.object([
+ "s": .string("x"), "i": .int(7), "b": .bool(true),
+ "a": .array([.int(1), .null]),
+ ])
+ let data = try JSONEncoder().encode(value)
+ let decoded = try JSONDecoder().decode(JSONValue.self, from: data)
+ XCTAssertEqual(decoded, value)
+ }
+}
diff --git a/loadout/Packages/HerdrKit/Tests/HerdrKitTests/TerminalTextTests.swift b/loadout/Packages/HerdrKit/Tests/HerdrKitTests/TerminalTextTests.swift
new file mode 100644
index 0000000..85e1073
--- /dev/null
+++ b/loadout/Packages/HerdrKit/Tests/HerdrKitTests/TerminalTextTests.swift
@@ -0,0 +1,38 @@
+import XCTest
+@testable import HerdrKit
+
+final class TerminalTextTests: XCTestCase {
+ func testStripANSIRemovesColor() {
+ let colored = "\u{1B}[32mgreen\u{1B}[0m"
+ XCTAssertEqual(TerminalText.stripANSI(colored), "green")
+ }
+
+ func testIsFramingLine() {
+ XCTAssertTrue(TerminalText.isFramingLine("┌──────────┐"))
+ XCTAssertTrue(TerminalText.isFramingLine("──────────"))
+ XCTAssertTrue(TerminalText.isFramingLine("----"))
+ XCTAssertFalse(TerminalText.isFramingLine("> commit v1 to a branch"))
+ XCTAssertFalse(TerminalText.isFramingLine("context 25%"))
+ XCTAssertFalse(TerminalText.isFramingLine("-")) // too short to be a rule
+ }
+
+ func testCleanDropsFramesAndUnwrapsSides() {
+ let input = [
+ "┌────────────────────┐",
+ "│ hello world │",
+ "│ second line │",
+ "└────────────────────┘",
+ ]
+ XCTAssertEqual(TerminalText.clean(input), ["hello world", "second line"])
+ }
+
+ func testCleanCollapsesBlankRunsAndTrimsEdges() {
+ let input = ["", "", "alpha", "", "", "beta", "", ""]
+ XCTAssertEqual(TerminalText.clean(input), ["alpha", "", "beta"])
+ }
+
+ func testCleanPreservesInnerANSI() {
+ let cleaned = TerminalText.clean(["│ \u{1B}[36mctx\u{1B}[0m │"])
+ XCTAssertEqual(cleaned, ["\u{1B}[36mctx\u{1B}[0m"])
+ }
+}
diff --git a/loadout/README.md b/loadout/README.md
new file mode 100644
index 0000000..6561216
--- /dev/null
+++ b/loadout/README.md
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+A native macOS app that shows every AI-agent **skill** and **MCP server** configured on your
+machine — across **Claude Code, OpenCode, Codex, Cursor, and Pi** — at both the global and
+project level, with tight integration to the [skills.sh](https://skills.sh) / `npx skills`
+ecosystem.
+
+Stop digging through dotfiles to see what each agent is actually loaded with.
+
+> Clean-room, MIT-licensed. Informed by `RESEARCH.md` (verified ecosystem research) and
+> by Chops (`Shpigford/chops`) as a UX reference only — no code or assets are copied.
+
+## Install
+
+Via [Homebrew](https://brew.sh) (the `zackbart/tap/loadout` shorthand taps automatically):
+
+```bash
+brew install --cask zackbart/tap/loadout
+```
+
+Or tap first, then install by name:
+
+```bash
+brew tap zackbart/tap
+brew install --cask loadout
+```
+
+Upgrade to the latest release with `brew upgrade --cask loadout`, and remove with
+`brew uninstall --cask loadout`.
+
+The app is signed and notarized, so it opens with a normal double-click — no Gatekeeper
+right-click dance. Requires **macOS 26+**.
+
+## What it does (v0.1)
+
+- Scans each agent's global skill directories plus the `~/.agents/skills` canonical store.
+- **Dedupes by canonical (symlink-resolved) path** — one skill, with badges for every agent
+ that references it.
+- Parses `SKILL.md` frontmatter with a real YAML parser (Yams).
+- Reads `~/.agents/.skill-lock.json` for **provenance** (source repo, hash, timestamps).
+- Surfaces **declared-vs-wired drift**: skills the `skills` CLI declares for an agent but that
+ aren't actually symlinked on disk.
+- Full-text search across name, description, and body.
+
+## Roadmap
+
+- Project scope (walk cwd → git root across `.claude/skills`, `.opencode/skills`,
+ `.agents/skills`, `.codex/skills`, `.pi/skills`).
+- Live FSEvents watching.
+- Built-in `SKILL.md` editor.
+- Active-state resolution (OpenCode permission rules, Pi project trust).
+- Two-way mutations via the `skills` CLI (add / remove / update / init).
+
+## Build
+
+```bash
+brew install xcodegen # one-time
+xcodegen generate # generates Loadout.xcodeproj from project.yml
+open Loadout.xcodeproj # then ⌘R
+```
+
+The Xcode project is generated — edit `project.yml`, not the `.xcodeproj`.
+
+## Requirements
+
+- macOS 14+, Xcode 16+. Sole dependency: [Yams](https://github.com/jpsim/Yams) (via SPM).
+- Runs non-sandboxed to read agent dotfiles in your home directory.
+
+## Releasing
+
+Releases are **tag-driven**. Pushing a `loadout-v*` tag runs the repository release workflow, which builds,
+signs (Developer ID), notarizes, and publishes a notarized `.dmg` to GitHub Releases.
+
+**Tags are only cut from a release-bump merge — never off a random commit:**
+
+1. Open a "Release vX.Y.Z" PR that bumps `MARKETING_VERSION` in `project.yml`, and merge it to `main`.
+2. Tag that merge commit and push the tag:
+
+```bash
+git tag loadout-v0.1.3
+git push origin loadout-v0.1.3
+```
+
+CI injects the version from the tag at build time, so the `project.yml` bump is just the human
+marker that makes the release commit self-describing. Release notes are auto-generated from commits.
+
+**One-time setup** (already done): seven repo secrets. Six hold the Apple credentials —
+`BUILD_CERTIFICATE_BASE64`, `P12_PASSWORD`, `AC_API_KEY_BASE64`, `AC_API_KEY_ID`,
+`AC_API_ISSUER_ID`, `APPLE_TEAM_ID` (regenerate if the Developer ID cert (≈5 yr) or the App Store
+Connect API key is rotated). The seventh, `HOMEBREW_TAP_TOKEN`, lets the `update-tap` job push the
+cask to `zackbart/homebrew-tap` (non-expiring). See `DECISIONS.md` (D6).
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/loadout/RESEARCH.md b/loadout/RESEARCH.md
new file mode 100644
index 0000000..9e08482
--- /dev/null
+++ b/loadout/RESEARCH.md
@@ -0,0 +1,253 @@
+# Loadout — Research & Context
+
+> Deep-research synthesis for a native macOS app that discovers, visualizes, and manages
+> AI-agent **skills** across Claude Code, OpenCode, Codex, and Pi — built on the
+> skills.sh / `npx skills` ecosystem. MCP integration is explicitly out of scope for now.
+>
+> Sources: a fan-out deep-research workflow (25 sources fetched, 123 claims, 24 verified via
+> 3-vote adversarial checks) **plus ground truth captured directly from this machine**
+> (`skills` CLI v1.5.10, all four agents installed). Where the two disagree, machine truth wins
+> and is noted.
+
+---
+
+## 1. The ecosystem in one picture
+
+skills.sh uses a **single canonical store + symlink fan-out**. There is one real copy of each
+skill; every agent's skills directory holds a *relative symlink* back to it.
+
+```
+~/.agents/skills//SKILL.md ← canonical store (REAL files) [source of truth]
+~/.agents/.skill-lock.json ← manifest (v3): provenance, hashes, timestamps
+~/.agents/plugins/ ← plugin bundles + marketplace.json
+
+~/.claude/skills/ → ../../.agents/skills/ (symlink)
+~/.pi/agent/skills/ → ../../../.agents/skills/ (symlink)
+~/.codex/skills/ → built-ins only in .system/ on this machine
+~/.config/opencode/skills/ → does NOT exist on this machine (OpenCode reads ~/.agents directly)
+```
+
+**Key consequence for the app:** the same skill appears under multiple agents as symlinks to
+one canonical path. **Dedupe by resolving symlinks** (`realpath` / `URL.resolvingSymlinksInPath`)
+back to `~/.agents/skills/`, and treat that canonical dir as the skill's identity.
+
+---
+
+## 2. The `npx skills` CLI (skills.sh)
+
+- **Package:** `skills` on npm (`npx skills`), by **Vercel Labs**, MIT, **v1.5.11** (2026-06-11;
+ v1.5.10 installed here at `/opt/homebrew/bin/skills`). Maintainers rauchg + quuu. Repo:
+ `github.com/vercel-labs/skills`. Bins: `skills`, `add-skill`. Supports 70+ agents.
+- **Commands** (verified against the local `--help`):
+ - `add /` (alias `a`) — install. Flags: `-g/--global`, `-p/--project`,
+ `-a/--agent `, `-s/--skill `, `-l/--list`, `--copy` (copy instead of
+ symlink), `-y/--yes`, `--all`, `--full-depth`.
+ - `use @` — generate a prompt to use a skill *without* installing.
+ - `remove [skills]`, `list`/`ls`, `find [query]`, `update [skills...]` (alias `upgrade`).
+ - `init [name]` — scaffold `/SKILL.md`.
+ - `experimental_install` — restore from `skills-lock.json`.
+ - `experimental_sync` — sync skills from `node_modules` into agent dirs.
+- **Machine-readable enumeration (use this!):**
+ - `skills list -g --json` → array of `{ name, path, scope, agents[] }` for global skills.
+ - `skills list --json` → project skills (run with project cwd).
+ - ⚠️ The `agents[]` array is the CLI's **intended/declared** targets, *not* a guarantee the
+ skill is symlinked into that agent on disk. On this machine several skills list
+ `"Codex"`/`"OpenCode"` yet have no symlink in `~/.codex/skills`/`~/.config/opencode/skills`
+ — because those agents read the canonical `~/.agents/skills` (OpenCode) or aren't fanned out.
+ **The app must compute "actually active per agent" from each agent's own discovery dirs, not
+ trust this field.**
+- **`--copy` vs symlink:** verified in source (`src/add.ts`, `src/installer.ts`): default is
+ symlink to a canonical copy; `--copy` makes independent copies; auto-falls-back to copy when
+ symlinks unsupported (Windows w/o Developer Mode).
+
+### The lock file — `~/.agents/.skill-lock.json` (v3)
+
+Per skill: `source` (e.g. `anthropics/skills`), `sourceType` (`github`), `sourceUrl`,
+`skillPath` (path to SKILL.md within the source repo), `skillFolderHash` (sha1 — drift/update
+detection), `installedAt`, `updatedAt`, optional `pluginName`. Top level also has `dismissed`
+flags and `lastSelectedAgents[]`. **This is the provenance + update-status layer** for the app.
+
+---
+
+## 3. The SKILL.md standard (Anthropic "Agent Skills")
+
+- Open spec, canonical home **agentskills.io**; reference impl `github.com/anthropics/skills`
+ (spec at `spec/agent-skills-spec.md`). Released as an open standard (~Dec 2025), multi-vendor.
+- A **skill = a directory** whose required entrypoint is **`SKILL.md`** = YAML frontmatter
+ between `---` markers + a Markdown body. Optional bundled resources (`scripts/`, `references/`,
+ `assets/`, `examples/`) live alongside and are referenced from the body.
+- **Required frontmatter:** only `name` and `description`. Constraints: `name` ≤64 chars,
+ `[a-z0-9-]` only, no XML tags, cannot contain `anthropic`/`claude`; `description` non-empty,
+ ≤1024 chars, no XML tags. (Good basis for an in-app linter.)
+- **Three-level progressive disclosure:** L1 metadata (name/description, ~100 tok, always
+ loaded), L2 SKILL.md body (loaded on trigger, target <5k tok), L3 bundled files (loaded on
+ demand). Figures are guidance, not hard limits.
+- **Agent-specific extension fields exist** — a robust parser must tolerate unknown keys:
+ - *Claude Code* adds ~16 optional fields: `when_to_use`, `argument-hint`, `arguments`,
+ `disable-model-invocation`, `user-invocable`, `allowed-tools`, `disallowed-tools`, `model`,
+ `effort`, `context`, `agent`, `hooks`, `paths`, `shell`.
+ - *OpenCode* recognizes: `name`, `description`, `license`, `compatibility`, `metadata`
+ (string→string map); ignores unknown fields; `name` must match the directory name.
+
+---
+
+## 4. Per-agent on-disk map (global + project)
+
+> Combine each agent's **native discovery dirs** with the skills-CLI **install targets** and
+> scan the union; dedupe by symlink target.
+
+### Claude Code ✅ verified + on-disk here
+- Global: `~/.claude/skills//SKILL.md`
+- Project: `.claude/skills//SKILL.md`
+- Plugins: `~/.claude/plugins/` — parse `installed_plugins.json`, then scan each enabled
+ plugin's `skills/` subdir; plugin skills are namespaced `plugin-name:skill-name`.
+- Precedence on name collision: enterprise > personal > project; plugin skills can't conflict.
+
+### OpenCode ✅ verified
+- Global: `~/.config/opencode/skills/`, `~/.claude/skills/`, `~/.agents/skills/`
+- Project (walk cwd→git root): `.opencode/skills/`, `.claude/skills/`, `.agents/skills/`
+- **Reads `.claude/skills` and `.agents/skills` natively** → a skill can be visible to OpenCode
+ with no OpenCode-specific symlink (explains the machine discrepancy above).
+- "Active" is gated by `opencode.json` `permission` rules: `allow` / `deny` / `ask`, wildcards
+ (e.g. `internal-*`), per-agent overrides. To show truly-active skills, parse & apply these.
+
+### Codex (OpenAI Codex CLI) ⚠️ medium confidence on native paths
+- Global: `~/.codex/skills/` (built-ins under `.system/`, flagged by `.codex-system-skills.marker`;
+ e.g. skill-creator, plugin-creator, skill-installer, openai-docs, imagegen).
+- Project: **scan both** `.codex/skills/` (native, per secondary sources) **and** `.agents/skills/`
+ (the skills-CLI install target). Primary OpenAI source: `developers.openai.com/codex/skills`.
+- Caveat: on this machine no global user skills were fanned out to `~/.codex/skills` — verify
+ behavior against a current Codex version before relying on it.
+
+### Pi ✅ verified + on-disk here
+- = `github.com/badlogic/pi-mono`, npm `@earendil-works/pi-coding-agent`. Config root is
+ `~/.pi/agent/`.
+- Global: `~/.pi/agent/skills/` (symlinks → `~/.agents/skills/`), `~/.agents/skills/`
+- Project (walk cwd→git root): `.pi/skills/`, `.agents/skills/` — **only after the project is
+ trusted** (per-project trust flag). The app may want to surface trust state.
+
+---
+
+## 5. How the app discovers & reads skills
+
+**Read layer (fast, offline, ground-truth):**
+1. Scan the canonical `~/.agents/skills/*/SKILL.md` + each agent's discovery dirs above (and
+ walk cwd→git root for project scope).
+2. Parse YAML frontmatter + body; tolerate agent-specific extension fields.
+3. Resolve symlinks → dedupe to canonical identity; record which agents each canonical skill is
+ wired into (presence) vs. declared (`agents[]` in lock).
+4. Join with `~/.agents/.skill-lock.json` for source repo, hash, install/update times.
+5. Compute *active* state per agent: OpenCode permission rules, Pi project-trust, Claude
+ precedence/plugin-enabled.
+
+**Optionally** shell out to `skills list --json` as a cross-check / convenience source.
+
+**Write layer (two-way integration):** shell out to the `skills` CLI for mutations
+(`add`/`remove`/`update`/`init`), so the app stays in lockstep with ecosystem behavior instead
+of reimplementing symlink fan-out. Direct file edits for editing a skill's own SKILL.md.
+
+**Live updates:** watch the canonical store + each agent dir with FSEvents.
+
+---
+
+## 6. Swift / macOS implementation guidance
+
+- **UI:** SwiftUI; `MenuBarExtra` for a menu-bar app, or a normal `WindowGroup`. (A menu-bar
+ presence + a main browser window is a natural fit.)
+- **YAML / frontmatter parsing:**
+ - `jpsim/Yams` — de-facto Swift YAML (used by SwiftLint). Parse the frontmatter block.
+ - `SwiftToolkit/frontmatter` — splits frontmatter + Markdown body in one step.
+- **Directory watching:** the FSEvents C API, or a Swift wrapper — `Eonil/FSEvents`,
+ `okooo5km/FSWatcher`; alternatively a `DispatchSource` file-descriptor watch per directory.
+ (alexwlchan has a current write-up on watching files on macOS.)
+- **Sandbox / file access:** the agent dirs (`~/.claude`, `~/.codex`, `~/.config/opencode`,
+ `~/.pi`, `~/.agents`) are **outside any app container**. Two options:
+ - **Non-sandboxed, Developer ID-signed, distributed outside the Mac App Store** — simplest for
+ a personal/dev tool; full home-dir read access. **Recommended to start.**
+ - **Sandboxed** — requires user to grant access via `NSOpenPanel` and persist
+ **security-scoped bookmarks**; more friction for dotfile dirs.
+- **Dev-binary-on-PATH:** per the user's convention, symlink the built app/CLI helper as
+ `*-dev` into `~/.local/bin` (don't clobber any released binary).
+
+---
+
+## 7. Prior art (differentiate against)
+
+- `crossoverJie/SkillDeck`, `yibie/skills-manager`, `Karanjot786/agent-skills-cli` — existing
+ skill managers/CLIs/GUIs.
+- skills.sh itself (`vercel-labs/skills`) is the CLI; `find` has interactive search.
+- **Differentiation angle:** a *native macOS* unified view across **all four agents at once**,
+ global **and** per-project, that exposes the gap between *declared* vs *actually-wired/active*
+ skills (drift detection), shows provenance from the lock file, and offers two-way edit/install.
+ No verified native-macOS multi-agent GUI surfaced — likely open space.
+
+---
+
+## 8. Open questions / to verify before/while building
+
+1. Current OpenAI **Codex** native project/global skill discovery paths (resolve
+ `.codex/skills` vs `.agents/skills`) against a live Codex version.
+2. skills.sh **registry/index format** behind `find`/`add` — is there a queryable API or local
+ cache to enumerate *installable* (not just installed) skills?
+3. Exact `MenuBarExtra` vs window UX, and whether to ship non-sandboxed (recommended) or invest
+ in security-scoped bookmarks.
+
+---
+
+## 9. Recommended architecture (starting point)
+
+**Hybrid, non-sandboxed SwiftUI app:**
+- **Front end:** SwiftUI (`MenuBarExtra` + main window), Developer ID-signed, non-sandboxed.
+- **Read:** direct FS scan of `~/.agents/skills` + per-agent dirs (+ walk to git root for
+ projects), Yams for frontmatter, symlink-resolution dedupe, join with `.skill-lock.json`,
+ FSEvents for live refresh.
+- **Write:** shell out to the `skills` CLI for install/remove/update; direct edits for SKILL.md.
+- **Model:** one canonical `Skill` (identity = canonical path) with per-agent
+ `presence`/`active` derived state and provenance from the lock.
+
+This keeps us in lockstep with skills.sh for mutations while owning a fast, accurate read/visualize
+layer — which is the actual product differentiator.
+
+---
+
+## npx skills — verified install algorithm (from the CLI bundle)
+
+Source of truth: `/opt/homebrew/lib/node_modules/skills/dist/cli.mjs` (skills v1.5.10) — the
+code `npx skills` actually runs. This supersedes the earlier medium-confidence Codex caveat.
+
+```js
+getCanonicalSkillsDir(global, cwd) = join(global ? ~ : cwd, ".agents", "skills")
+isUniversalAgent(type) = agents[type].skillsDir === ".agents/skills"
+getAgentBaseDir(type, global, cwd):
+ if isUniversalAgent(type) -> getCanonicalSkillsDir(global, cwd) // reads .agents directly
+ else (global) -> agents[type].globalSkillsDir
+ else (project) -> join(cwd, agents[type].skillsDir)
+install: write canonical to .agents/skills/; for NON-universal agents create a
+ RELATIVE symlink (relative(linkDir, target)) in the agent's dir. mode defaults to
+ "symlink" (`--copy` makes independent copies).
+```
+
+**The canonical store is `.agents/skills`** (`~/.agents/skills` global, `/.agents/skills`
+project). A "**universal agent**" (its `skillsDir === ".agents/skills"`) reads that store
+directly at BOTH scopes — its `globalSkillsDir`, if defined, is unused. ~45 of the registry's
+agents are universal.
+
+### The four agents (what reads `.agents/skills`)
+| Agent | project `skillsDir` | `globalSkillsDir` | Universal? | Needs own symlink? |
+|---|---|---|---|---|
+| Claude Code | `.claude/skills` | `~/.claude/skills` | **No** | **Yes** |
+| OpenCode | `.agents/skills` | (unused) `~/.config/opencode/skills` | **Yes** | No — reads canonical |
+| Codex | `.agents/skills` | (unused) `~/.codex/skills` | **Yes** | No — reads canonical |
+| Pi | `.pi/skills` | `~/.pi/agent/skills` | No (CLI) — but reads `.agents` per Pi's own docs | CLI also symlinks |
+
+**Correction:** Codex **does** read `~/.agents/skills` (it is universal). The earlier caveat that
+Codex might not read `.agents` was wrong. Its `~/.codex/skills` only holds built-in `.system`
+skills; user skills are reached via the canonical store.
+
+### Implication (confirmed design)
+To make a skill available everywhere: **write it to `.agents/skills`, then symlink only the
+NON-universal agents** — Claude Code always; Pi as belt-and-suspenders. Universal agents
+(Codex, OpenCode, + amp/cline/cursor/…) need nothing. This is exactly what `npx skills` does,
+and what Loadout's drift-fix now mirrors (relative symlink into `.claude/skills`).
+So at global scope, the only agent that legitimately shows drift for a canonical-present skill
+is **Claude Code**.
diff --git a/loadout/apps/iOS/README.md b/loadout/apps/iOS/README.md
new file mode 100644
index 0000000..7e40a05
--- /dev/null
+++ b/loadout/apps/iOS/README.md
@@ -0,0 +1,148 @@
+
+
+
+
+Herdr iOS
+
+
+ Join the TestFlight beta →
+
+
+A native iOS (SwiftUI) client for [Herdr](https://herdr.dev), the terminal-native
+**agent multiplexer**. Browse your workspaces, watch live agent status, and read
+or drive any pane from your phone.
+
+> **Unofficial client.** Herdr and its branding are the work of its author (see
+> [Credits](#credits)); this project is an independent iOS front-end for it.
+
+> **Status:** the full app runs on an in-memory **Mock** transport with realistic
+> data and live status updates, *and* over a real **SSH** connection that bridges
+> to the remote Herdr Unix socket (see [SSH transport](#ssh-transport)). Known
+> limitations: key auth currently supports OpenSSH ed25519 and RSA keys. Host
+> keys are pinned trust-on-first-use (TOFU): the key is remembered on first
+> connect and a later mismatch aborts with a clear warning.
+
+## Why SSH?
+
+Herdr has **no network API and no official mobile app** by design. Its socket API
+is **newline-delimited JSON-RPC over a local Unix domain socket**
+(`~/.config/herdr/herdr.sock`; named sessions under
+`~/.config/herdr/sessions//herdr.sock`). Remote use is officially "SSH into
+the box and run herdr." So this client reaches the socket the same way: over SSH,
+by bridging an exec channel to the Unix socket and speaking JSON-RPC directly —
+which keeps live event subscriptions working.
+
+## Architecture
+
+Two cleanly separated layers, so the entire UI runs on a Mock and the real SSH
+transport is a drop-in swap.
+
+### `HerdrKit` — platform-independent core (`Sources/HerdrKit`)
+
+No SwiftUI, no third-party deps, Foundation + Swift Concurrency only → builds and
+unit-tests with `swift test` on macOS or Linux.
+
+| Area | Files |
+| --- | --- |
+| Models | `Models/{IDs,AgentStatus,Pane,Workspace}.swift` — ids are non-durable strings; status is `idle/working/blocked/done/unknown` |
+| Protocol | `Protocol/{JSONValue,RPC,NDJSON,Method}.swift` — NDJSON JSON-RPC codec; every wire `method` string lives in `Method.swift` |
+| Transport | `Transport/HerdrTransport.swift` — dumb in/out channel protocol |
+| Client | `Client/{HerdrClient,HerdrEvent}.swift` — actor that correlates replies and demuxes events into one `eventStream` |
+| Mock | `Mock/{MockTransport,MockData}.swift` — answers requests and emits live status/output events |
+
+### `Herdr` — SwiftUI app (`App/Herdr`)
+
+State via `@Observable`. A single `SessionModel` is the source of truth, injected
+through the environment.
+
+- **Connection** — `Host`, `ConnectionStore` (UserDefaults), `KeychainStore`
+ (key/password in the Keychain), `SSHTransport` (stub), `ConnectView`.
+- **Screen 1 — Workspaces** — `Features/Workspaces/WorkspaceListView.swift`:
+ live aggregate status, per-status counts, pull-to-refresh.
+- **Screen 2 — Panes/agents** — `Features/Panes/WorkspaceDetailView.swift`:
+ tabs and their panes with per-agent status.
+- **Screen 3 — Pane** — `Features/Pane/PaneView.swift`: monospaced scrollback
+ (ANSI-stripped, live-appended) and an input bar (text + Enter / quick keys).
+ The transcript re-reads whenever the pane emits new output: rather than poll on
+ a fixed timer, the loop blocks on `pane.wait_for_output` (matching any output)
+ and refreshes the instant the screen changes — instant on activity, quiet while
+ idle. Output gating only; agent status stays live via pushed events.
+
+### Data flow
+
+`HerdrClient` (actor) owns a `HerdrTransport`. `SessionModel` calls typed async
+methods and consumes `client.eventStream` to update `@Observable` state →
+SwiftUI re-renders. Boots on `MockTransport`; swapping to `SSHTransport` is a
+one-line change in `AppModel`.
+
+## Build & run
+
+Requires Xcode 15+ (iOS 17 deployment target) on macOS.
+
+```sh
+# 1. Core unit tests (no Apple SDK needed — runs on macOS or Linux)
+swift test
+
+# 2. Generate and open the app project
+brew install xcodegen
+xcodegen generate
+open Herdr.xcodeproj
+# Build & run the "Herdr" scheme on an iOS 17 simulator.
+```
+
+On launch, tap **Open demo workspace** to explore against sample data: the
+workspace list shows live status badges flipping, drill into a workspace to see
+its panes/agents, and open a pane to watch streamed output and send input.
+
+## SSH transport
+
+`App/Herdr/Connection/SSHTransport.swift` implements the bridge with **Citadel**
+(SwiftNIO SSH):
+
+1. `connect()` opens an `SSHClient` connection authenticated with the host's
+ `Credential` (password, or an OpenSSH-format RSA private key, from the
+ Keychain).
+2. Unless the host has an explicit socket-path override, it **auto-detects** the
+ socket: a one-shot remote probe mirrors Herdr's documented resolution order —
+ `$HERDR_SOCKET_PATH`, then the default `~/.config/herdr/herdr.sock`, then any
+ named session under `~/.config/herdr/sessions//` — and picks the first
+ live socket (`test -S`). Users normally don't configure a path at all.
+3. It then opens a `withExec` channel running
+ `socat - UNIX-CONNECT: || nc -U ` and suspends until
+ the channel is live. The channel's stdout is fed through the existing
+ `LineBuffer` → `IncomingMessage.decode` → `continuation.yield`; `send(_:)`
+ writes `NDJSON.frame(request)` to the channel's stdin. A leading `~` in an
+ overridden socket path is rewritten to `$HOME` so the remote shell expands it.
+
+To switch the app onto SSH, point `AppModel.connect(to:)` at a saved `Host` (it
+already builds an `SSHTransport`); the demo entry point stays on the Mock.
+
+**Follow-ups:**
+
+- Key auth handles OpenSSH **ed25519** and **RSA** keys (tried in that order);
+ ECDSA isn't wired yet. Password auth works for everything in the meantime.
+- Host keys are pinned **trust-on-first-use**: `SSHTransport`'s custom validator
+ records the key on first connect (`ConnectionStore` persists it on `Host`) and
+ rejects a changed key on later connects. Re-add the host to re-pin after a
+ legitimate server key change. No UI to inspect/manage pinned keys yet.
+- Socket auto-detect picks the default session, or the sole running one. When a
+ host has *multiple* named sessions and no default, it currently picks the first;
+ a session picker is a possible follow-up (override the socket path to choose for
+ now).
+- Confirm the exact socket `method` strings and subscribe/event names in
+ `Sources/HerdrKit/Protocol/Method.swift` against
+ .
+
+## Credits
+
+All credit for **Herdr** itself — the terminal-native agent multiplexer this app
+is a client for — goes to its creator,
+[@ogulcancelik](https://github.com/ogulcancelik) ([herdr.dev](https://herdr.dev)).
+Herdr's design, socket API, name, and branding (the ram mark and prompt logo this
+app's icon echoes) are theirs. This repository is an independent, unofficial iOS
+client and is not affiliated with or endorsed by the Herdr project.
+
+## References
+
+- Docs: · Socket API:
+- Source: (`README.md`, `SKILL.md`)
diff --git a/loadout/apps/iOS/Sources/App/AppModel.swift b/loadout/apps/iOS/Sources/App/AppModel.swift
new file mode 100644
index 0000000..7966298
--- /dev/null
+++ b/loadout/apps/iOS/Sources/App/AppModel.swift
@@ -0,0 +1,69 @@
+import Foundation
+import HerdrKit
+
+/// Top-level app state: owns the connection lifecycle and, once connected,
+/// vends a `SessionModel` for the screens to read.
+@MainActor
+@Observable
+final class AppModel {
+ enum Phase {
+ case disconnected
+ case connecting(String)
+ case connected(SessionModel)
+ case failed(String)
+ }
+
+ var phase: Phase = .disconnected
+ let connections = ConnectionStore()
+
+ var isConnecting: Bool {
+ if case .connecting = phase { return true }
+ return false
+ }
+
+ /// Boot the app against in-memory sample data — the default entry point
+ /// while the SSH transport is being completed.
+ func connectDemo() async {
+ await connect(label: "Demo · Mock data") {
+ HerdrClient(transport: MockTransport())
+ }
+ }
+
+ /// Connect to a saved host over SSH, bridging to its Herdr Unix socket.
+ func connect(to host: Host) async {
+ let credential = connections.credential(for: host)
+ let connections = connections
+ await connect(label: host.displayName) {
+ HerdrClient(transport: SSHTransport(host: host, credential: credential) { key in
+ Task { @MainActor in connections.pinHostKey(key, for: host) }
+ })
+ }
+ }
+
+ func disconnect() async {
+ if case .connected(let session) = phase {
+ await session.client.disconnect()
+ }
+ phase = .disconnected
+ }
+
+ private func connect(label: String, makeClient: () -> HerdrClient) async {
+ phase = .connecting(label)
+ let client = makeClient()
+ do {
+ try await client.connect()
+ let session = SessionModel(client: client, label: label)
+ await session.start()
+ phase = .connected(session)
+ } catch {
+ phase = .failed(friendlyMessage(for: error))
+ }
+ }
+
+ private func friendlyMessage(for error: Error) -> String {
+ switch error {
+ case HerdrError.connectionFailed(let message): return message
+ default: return String(describing: error)
+ }
+ }
+}
diff --git a/loadout/apps/iOS/Sources/App/LoadoutApp.swift b/loadout/apps/iOS/Sources/App/LoadoutApp.swift
new file mode 100644
index 0000000..98622f5
--- /dev/null
+++ b/loadout/apps/iOS/Sources/App/LoadoutApp.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+
+@main
+struct LoadoutApp: App {
+ @State private var app = AppModel()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environment(app)
+ }
+ }
+}
+
+/// Switches between the connect screen and the connected session.
+struct RootView: View {
+ @Environment(AppModel.self) private var app
+
+ var body: some View {
+ switch app.phase {
+ case .connected(let session):
+ WorkspaceListView()
+ .environment(session)
+ default:
+ ConnectView()
+ }
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/loadout/apps/iOS/Sources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
new file mode 100644
index 0000000..fd5ee56
Binary files /dev/null and b/loadout/apps/iOS/Sources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ
diff --git a/loadout/apps/iOS/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/loadout/apps/iOS/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..f22e10c
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "AppIcon-1024.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Assets.xcassets/Contents.json b/loadout/apps/iOS/Sources/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Assets.xcassets/Logo.imageset/Contents.json b/loadout/apps/iOS/Sources/Assets.xcassets/Logo.imageset/Contents.json
new file mode 100644
index 0000000..5dea135
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Assets.xcassets/Logo.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "herdr-logo.png"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Assets.xcassets/Logo.imageset/herdr-logo.png b/loadout/apps/iOS/Sources/Assets.xcassets/Logo.imageset/herdr-logo.png
new file mode 100644
index 0000000..6ada650
Binary files /dev/null and b/loadout/apps/iOS/Sources/Assets.xcassets/Logo.imageset/herdr-logo.png differ
diff --git a/loadout/apps/iOS/Sources/Connection/ConnectView.swift b/loadout/apps/iOS/Sources/Connection/ConnectView.swift
new file mode 100644
index 0000000..1047973
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Connection/ConnectView.swift
@@ -0,0 +1,246 @@
+import SwiftUI
+import HerdrKit
+
+/// Entry screen: pick a saved host to connect over SSH, add a new one, or open
+/// the in-memory demo.
+struct ConnectView: View {
+ @Environment(AppModel.self) private var app
+ @State private var editingHost: Host?
+ @State private var showingNewHost = false
+
+ private var store: ConnectionStore { app.connections }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ BrandHero()
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets(top: 28, leading: 16, bottom: 12, trailing: 16))
+ }
+
+ Section {
+ Button {
+ Task { await app.connectDemo() }
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: "play.fill")
+ .font(.footnote.weight(.bold))
+ .foregroundStyle(Theme.prompt)
+ .frame(width: 24)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Open demo workspace")
+ .font(.body.weight(.medium))
+ .foregroundStyle(.primary)
+ Text("Realistic sample data — no server")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(app.isConnecting)
+ } header: {
+ SectionEyebrow("quick start")
+ }
+
+ Section {
+ if store.hosts.isEmpty {
+ Text("No hosts yet. Add the machine where Herdr runs.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ ForEach(store.hosts) { host in
+ Button {
+ Task { await app.connect(to: host) }
+ } label: {
+ HostRow(host: host)
+ }
+ .buttonStyle(.plain)
+ .swipeActions {
+ Button(role: .destructive) { store.remove(host) } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ Button { editingHost = host } label: {
+ Label("Edit", systemImage: "pencil")
+ }.tint(Theme.ink)
+ }
+ }
+ } header: {
+ SectionEyebrow("hosts")
+ }
+
+ if case .failed(let message) = app.phase {
+ Section {
+ Label(message, systemImage: "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(Theme.blocked)
+ }
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button { showingNewHost = true } label: { Image(systemName: "plus") }
+ .tint(Theme.ink)
+ }
+ }
+ .overlay {
+ if case .connecting(let label) = app.phase {
+ ConnectingOverlay(label: label)
+ }
+ }
+ .sheet(isPresented: $showingNewHost) {
+ HostEditor(host: Host()) { host, secret in
+ store.upsert(host, secret: secret)
+ }
+ }
+ .sheet(item: $editingHost) { host in
+ HostEditor(host: host) { updated, secret in
+ store.upsert(updated, secret: secret)
+ }
+ }
+ }
+ .tint(Theme.prompt)
+ }
+}
+
+/// The logo mark, mono wordmark, and tagline — the app's identity, shown once
+/// at the top of the connect screen.
+private struct BrandHero: View {
+ var body: some View {
+ VStack(spacing: 12) {
+ Image("Logo")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 78, height: 78)
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(Theme.ink.opacity(0.08))
+ )
+ .shadow(color: Theme.ink.opacity(0.18), radius: 10, y: 5)
+
+ VStack(spacing: 3) {
+ Text("herdr")
+ .font(Theme.mono(32, .bold))
+ .tracking(0.5)
+ .foregroundStyle(Theme.ink)
+ Text("mind the flock from anywhere")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+}
+
+private struct HostRow: View {
+ let host: Host
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: "terminal.fill")
+ .font(.callout)
+ .foregroundStyle(Theme.ink.opacity(0.55))
+ .frame(width: 24)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(host.displayName).font(.body.weight(.medium))
+ Text(host.subtitle).font(Theme.mono(12)).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.vertical, 4)
+ .contentShape(Rectangle())
+ }
+}
+
+private struct ConnectingOverlay: View {
+ let label: String
+ var body: some View {
+ ZStack {
+ Color(.systemBackground).opacity(0.75).ignoresSafeArea()
+ VStack(spacing: 14) {
+ ProgressView()
+ Text("Connecting to \(label)…")
+ .font(Theme.mono(13))
+ .foregroundStyle(.secondary)
+ }
+ .padding(28)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
+ }
+ }
+}
+
+/// Add/edit a host. The secret field stores the private key or password in the
+/// Keychain on save.
+private struct HostEditor: View {
+ @Environment(\.dismiss) private var dismiss
+ @State var host: Host
+ @State private var secret: String = ""
+ let onSave: (Host, String?) -> Void
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Connection") {
+ TextField("Nickname (optional)", text: $host.nickname)
+ TextField("Hostname", text: $host.hostname)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Username", text: $host.username)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Port", value: $host.port, format: .number)
+ .keyboardType(.numberPad)
+ }
+
+ Section("Authentication") {
+ Picker("Method", selection: $host.authMethod) {
+ ForEach(AuthMethod.allCases) { Text($0.title).tag($0) }
+ }
+ switch host.authMethod {
+ case .privateKey:
+ TextField("Paste private key (PEM)", text: $secret, axis: .vertical)
+ .font(.caption.monospaced())
+ .lineLimit(3...8)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ case .password:
+ SecureField("Password", text: $secret)
+ }
+ }
+
+ Section {
+ TextField("Socket path (optional)", text: $host.socketPath)
+ .font(.caption.monospaced())
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ } header: {
+ Text("Herdr socket")
+ } footer: {
+ Text("Leave blank to auto-detect. Herdr's socket is found automatically under ~/.config/herdr — set this only to target a specific session or a non-standard path.")
+ }
+ }
+ .navigationTitle(host.hostname.isEmpty ? "New host" : host.displayName)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ onSave(host, secret.isEmpty ? nil : secret)
+ dismiss()
+ }
+ .disabled(host.hostname.isEmpty || host.username.isEmpty)
+ }
+ }
+ }
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Connection/ConnectionStore.swift b/loadout/apps/iOS/Sources/Connection/ConnectionStore.swift
new file mode 100644
index 0000000..c698122
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Connection/ConnectionStore.swift
@@ -0,0 +1,67 @@
+import Foundation
+
+/// Persists saved hosts (non-secret fields in `UserDefaults`) and brokers their
+/// secrets through the Keychain.
+@MainActor
+@Observable
+final class ConnectionStore {
+ private(set) var hosts: [Host] = []
+
+ private let defaultsKey = "herdr.hosts.v1"
+ private let keychain = KeychainStore(service: "dev.herdr.client")
+
+ init() {
+ load()
+ }
+
+ /// Add or update a host. `secret` is the private key or password to stash in
+ /// the Keychain (pass `nil` to leave any existing secret untouched).
+ func upsert(_ host: Host, secret: String?) {
+ if let index = hosts.firstIndex(where: { $0.id == host.id }) {
+ hosts[index] = host
+ } else {
+ hosts.append(host)
+ }
+ persist()
+ if let secret, !secret.isEmpty {
+ keychain.set(secret, account: host.id.uuidString)
+ }
+ }
+
+ func remove(_ host: Host) {
+ hosts.removeAll { $0.id == host.id }
+ persist()
+ keychain.delete(account: host.id.uuidString)
+ }
+
+ /// Record the SSH host key trusted on first connect (TOFU). Persists so later
+ /// connections can detect a changed key. No-op if the host is gone or already
+ /// pinned to this key.
+ func pinHostKey(_ key: String, for host: Host) {
+ guard let index = hosts.firstIndex(where: { $0.id == host.id }),
+ hosts[index].knownHostKey != key else { return }
+ hosts[index].knownHostKey = key
+ persist()
+ }
+
+ func credential(for host: Host) -> Credential {
+ let secret = keychain.get(account: host.id.uuidString)
+ switch host.authMethod {
+ case .password:
+ return Credential(password: secret)
+ case .privateKey:
+ return Credential(privateKey: secret)
+ }
+ }
+
+ private func load() {
+ guard let data = UserDefaults.standard.data(forKey: defaultsKey),
+ let decoded = try? JSONDecoder().decode([Host].self, from: data) else { return }
+ hosts = decoded
+ }
+
+ private func persist() {
+ guard let data = try? JSONEncoder().encode(hosts) else { return }
+ UserDefaults.standard.set(data, forKey: defaultsKey)
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Connection/Host.swift b/loadout/apps/iOS/Sources/Connection/Host.swift
new file mode 100644
index 0000000..8fd0681
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Connection/Host.swift
@@ -0,0 +1,51 @@
+import Foundation
+
+/// How we authenticate the SSH connection to a host.
+enum AuthMethod: String, Codable, Sendable, CaseIterable, Identifiable {
+ case privateKey
+ case password
+
+ var id: String { rawValue }
+ var title: String {
+ switch self {
+ case .privateKey: return "Private key"
+ case .password: return "Password"
+ }
+ }
+}
+
+/// A saved SSH connection to a machine running Herdr. Non-secret fields are
+/// persisted in `UserDefaults`; the secret (key or password) lives in the
+/// Keychain keyed by `id`.
+struct Host: Identifiable, Codable, Hashable, Sendable {
+ var id = UUID()
+ var nickname: String = ""
+ var hostname: String = ""
+ var port: Int = 22
+ var username: String = ""
+ var authMethod: AuthMethod = .privateKey
+ /// Optional override for the remote Herdr socket. Blank = auto-detect on
+ /// connect: the default session (`~/.config/herdr/herdr.sock`), or the sole
+ /// running session under `~/.config/herdr/sessions//`. Set this only to
+ /// target a specific named session or a non-standard path.
+ var socketPath: String = ""
+ /// The SSH host key trusted on first connect (TOFU), as an OpenSSH
+ /// `"algo base64"` string. Non-secret. `nil` until the first successful
+ /// connection pins it; later connects reject a key that doesn't match.
+ var knownHostKey: String?
+
+ var displayName: String {
+ nickname.isEmpty ? "\(username)@\(hostname)" : nickname
+ }
+
+ var subtitle: String {
+ "\(username)@\(hostname):\(port)"
+ }
+}
+
+/// The secret material for a host, fetched from the Keychain at connect time.
+struct Credential: Sendable {
+ var password: String?
+ var privateKey: String?
+ var passphrase: String?
+}
diff --git a/loadout/apps/iOS/Sources/Connection/KeychainStore.swift b/loadout/apps/iOS/Sources/Connection/KeychainStore.swift
new file mode 100644
index 0000000..eab6323
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Connection/KeychainStore.swift
@@ -0,0 +1,45 @@
+import Foundation
+import Security
+
+/// Thin wrapper over the iOS Keychain for storing per-host SSH secrets (a
+/// private key or password) as generic-password items keyed by account.
+struct KeychainStore {
+ let service: String
+
+ func set(_ value: String, account: String) {
+ let data = Data(value.utf8)
+ // Replace any existing item.
+ delete(account: account)
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ]
+ SecItemAdd(query as CFDictionary, nil)
+ }
+
+ func get(account: String) -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ var item: CFTypeRef?
+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
+ let data = item as? Data else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
+ func delete(account: String) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Connection/SSHTransport.swift b/loadout/apps/iOS/Sources/Connection/SSHTransport.swift
new file mode 100644
index 0000000..1491207
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Connection/SSHTransport.swift
@@ -0,0 +1,315 @@
+import Foundation
+import Citadel
+import Crypto // `Insecure` namespace + Curve25519
+import NIOCore // `ByteBuffer`, `EventLoopPromise`
+import NIOSSH // `NIOSSHPublicKey` + host-key validation delegate (TOFU pinning)
+import HerdrKit
+
+/// SSH-bridged transport to a remote Herdr Unix socket.
+///
+/// Herdr exposes no network port — its API is a local Unix domain socket
+/// (`~/.config/herdr/herdr.sock`) — and it is **one-request-per-connection**:
+/// the server closes the socket after each RPC reply; only `events.subscribe`
+/// stays open to stream events. So each RPC opens its own short-lived SSH exec
+/// channel that bridges stdio to the socket with `nc -U` (or `socat`), and
+/// subscriptions get a dedicated long-lived channel. Host-key validation
+/// currently accepts any key (TOFU pinning is a follow-up).
+public actor SSHTransport: HerdrTransport {
+ private let host: Host
+ private let credential: Credential
+ /// Called with the OpenSSH host-key string after a first successful connect,
+ /// so the caller can pin it (TOFU). No-op for hosts already pinned.
+ private let pinHostKey: @Sendable (String) -> Void
+
+ private var client: SSHClient?
+ private var socketPath: String?
+
+ init(host: Host, credential: Credential, pinHostKey: @escaping @Sendable (String) -> Void = { _ in }) {
+ self.host = host
+ self.credential = credential
+ self.pinHostKey = pinHostKey
+ }
+
+ // MARK: Lifecycle
+
+ public func connect() async throws {
+ guard client == nil else { return }
+ guard !host.hostname.isEmpty, !host.username.isEmpty else {
+ throw HerdrError.connectionFailed("This host is missing a hostname or username.")
+ }
+
+ let auth = try authenticationMethod()
+ // TOFU host-key pinning: trust the key on first connect and remember it;
+ // on later connects reject any key that doesn't match the pinned one.
+ let knownKey = host.knownHostKey
+ let recorder = HostKeyRecorder()
+ let client: SSHClient
+ do {
+ client = try await SSHClient.connect(
+ host: host.hostname,
+ port: host.port,
+ authenticationMethod: auth,
+ hostKeyValidator: .custom(TOFUHostKeyValidator(expected: knownKey, recorder: recorder)),
+ reconnect: .never
+ )
+ } catch {
+ // A recorded key that differs from the pinned one means the validator
+ // rejected it — surface that distinctly from a generic failure.
+ if let seen = recorder.seenKey, let knownKey, seen != knownKey {
+ throw HerdrError.connectionFailed(
+ "The SSH host key for \(host.displayName) has changed since you last connected. "
+ + "This can happen if the server was reinstalled — but it can also mean the "
+ + "connection is being intercepted. If you trust the change, remove and re-add this host."
+ )
+ }
+ throw HerdrError.connectionFailed("Couldn't connect to \(host.displayName): \(error)")
+ }
+ // First successful connect: pin the key we just trusted.
+ if knownKey == nil, let seen = recorder.seenKey { pinHostKey(seen) }
+
+ // Resolve the socket path before publishing any state, so a discovery
+ // failure can't leave the actor half-connected (client set, socketPath
+ // nil) with the SSH session leaked.
+ let resolved: String
+ let override = host.socketPath.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !override.isEmpty {
+ resolved = override
+ } else {
+ do {
+ let found = try await discoverSocketPaths(client: client)
+ guard let chosen = found.first else {
+ throw HerdrError.connectionFailed(
+ "Couldn't find a running Herdr socket on \(host.displayName) (looked under "
+ + "~/.config/herdr). Is Herdr running there?"
+ )
+ }
+ resolved = chosen
+ } catch {
+ try? await client.close()
+ throw error
+ }
+ }
+ self.client = client
+ self.socketPath = resolved
+ }
+
+ public func disconnect() async {
+ if let client { try? await client.close() }
+ client = nil
+ socketPath = nil
+ }
+
+ // MARK: Request / response (one-shot per connection)
+
+ /// How long to wait for a one-shot reply before giving up. Herdr replies and
+ /// closes immediately; this only guards against a wedged bridge/connection.
+ private static let requestTimeout: Duration = .seconds(20)
+
+ public func request(_ request: RPCRequest) async throws -> RPCResponse {
+ guard let client, let socketPath else { throw HerdrError.notConnected }
+ let command = Self.bridgeCommand(socketPath: socketPath)
+ let frame = try NDJSON.frame(request)
+ return try await withThrowingTaskGroup(of: RPCResponse.self) { group in
+ group.addTask { try await Self.roundTrip(client: client, command: command, frame: frame) }
+ group.addTask {
+ try await Task.sleep(for: Self.requestTimeout)
+ throw HerdrError.connectionFailed("The Herdr request timed out (no reply from the host).")
+ }
+ defer { group.cancelAll() }
+ return try await group.next()!
+ }
+ }
+
+ /// Open a one-shot bridge channel, send the request, and return the first
+ /// decoded reply. The post-reply channel close is expected; a close with no
+ /// reply preserves the underlying failure.
+ private static func roundTrip(client: SSHClient, command: String, frame: Data) async throws -> RPCResponse {
+ let collector = Collector()
+ do {
+ try await client.withExec(command) { inbound, outbound in
+ try await outbound.write(ByteBuffer(bytes: frame))
+ do {
+ for try await chunk in inbound {
+ guard case .stdout(let buffer) = chunk else { continue }
+ for line in collector.buffer.append(Self.data(buffer)) {
+ if collector.response == nil,
+ case .response(let response)? = try? IncomingMessage.decode(line: line) {
+ collector.response = response
+ }
+ }
+ }
+ } catch {
+ collector.failure = error
+ }
+ }
+ } catch {
+ collector.failure = collector.failure ?? error
+ }
+ if let response = collector.response { return response }
+ if let failure = collector.failure {
+ throw HerdrError.connectionFailed(
+ "The Herdr socket bridge failed: \(failure). Check that `nc` (or `socat`) is "
+ + "available on the host and the socket path is correct."
+ )
+ }
+ throw HerdrError.transportClosed
+ }
+
+ // MARK: Events (persistent subscription channel)
+
+ public nonisolated func events(_ subscribeRequest: RPCRequest) -> AsyncStream {
+ AsyncStream { continuation in
+ let task = Task { [weak self] in
+ guard let self, let conn = await self.connection() else {
+ continuation.finish(); return
+ }
+ let command = Self.bridgeCommand(socketPath: conn.socketPath)
+ let frame = (try? NDJSON.frame(subscribeRequest)) ?? Data()
+ let collector = Collector()
+ do {
+ try await conn.client.withExec(command) { inbound, outbound in
+ try await outbound.write(ByteBuffer(bytes: frame))
+ for try await chunk in inbound {
+ guard case .stdout(let buffer) = chunk else { continue }
+ for line in collector.buffer.append(Self.data(buffer)) {
+ if let message = try? IncomingMessage.decode(line: line) {
+ continuation.yield(message)
+ }
+ }
+ }
+ }
+ } catch {
+ // Subscription channel closed (disconnect / cancel / server).
+ }
+ continuation.finish()
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
+ private func connection() -> (client: SSHClient, socketPath: String)? {
+ guard let client, let socketPath else { return nil }
+ return (client, socketPath)
+ }
+
+ /// Accumulates bytes per channel and holds the first decoded reply.
+ private final class Collector: @unchecked Sendable {
+ var buffer = LineBuffer()
+ var response: RPCResponse?
+ var failure: Error?
+ }
+
+ /// Captures the host key the server presented, so `connect` can pin it after
+ /// a first connect or distinguish a mismatch from a generic failure. Written
+ /// on the handshake's event loop and read only after `connect` returns, so
+ /// the access never races.
+ private final class HostKeyRecorder: @unchecked Sendable {
+ var seenKey: String?
+ }
+
+ /// Trust-on-first-use host-key validator. Records the presented key, then
+ /// accepts it if nothing is pinned yet (`expected == nil`) or it matches the
+ /// pinned key; otherwise rejects, so a changed key aborts the handshake.
+ private struct TOFUHostKeyValidator: NIOSSHClientServerAuthenticationDelegate {
+ let expected: String?
+ let recorder: HostKeyRecorder
+
+ func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise) {
+ let seen = String(openSSHPublicKey: hostKey)
+ recorder.seenKey = seen
+ if expected == nil || expected == seen {
+ validationCompletePromise.succeed(())
+ } else {
+ validationCompletePromise.fail(HerdrError.connectionFailed("SSH host key mismatch."))
+ }
+ }
+ }
+
+ private static func data(_ buffer: ByteBuffer) -> Data {
+ Data(buffer.getBytes(at: buffer.readerIndex, length: buffer.readableBytes) ?? [])
+ }
+
+ // MARK: Helpers
+
+ private func authenticationMethod() throws -> SSHAuthenticationMethod {
+ switch host.authMethod {
+ case .password:
+ guard let password = credential.password, !password.isEmpty else {
+ throw HerdrError.connectionFailed("No password saved for \(host.displayName).")
+ }
+ return .passwordBased(username: host.username, password: password)
+
+ case .privateKey:
+ guard let pem = credential.privateKey, !pem.isEmpty else {
+ throw HerdrError.connectionFailed("No private key saved for \(host.displayName).")
+ }
+ let key = pem.trimmingCharacters(in: .whitespacesAndNewlines)
+ let decryptionKey = credential.passphrase
+ .flatMap { $0.isEmpty ? nil : $0 }
+ .map { Data($0.utf8) }
+
+ // OpenSSH-format keys (`BEGIN OPENSSH PRIVATE KEY`) can hold either an
+ // ed25519 or RSA key; classic PEM (`BEGIN RSA PRIVATE KEY`) is RSA.
+ // Try ed25519 first, then RSA, so any common key type works.
+ if let ed = try? Curve25519.Signing.PrivateKey(sshEd25519: key, decryptionKey: decryptionKey) {
+ return .ed25519(username: host.username, privateKey: ed)
+ }
+ do {
+ let rsa = try Insecure.RSA.PrivateKey(sshRsa: key, decryptionKey: decryptionKey)
+ return .rsa(username: host.username, privateKey: rsa)
+ } catch {
+ throw HerdrError.connectionFailed(
+ "Couldn't read this private key. Supported types are OpenSSH ed25519 and RSA"
+ + " — if the key is encrypted, add its passphrase, or use password auth."
+ )
+ }
+ }
+ }
+
+ /// Probe the remote host for live Herdr sockets, most-preferred first. Mirrors
+ /// Herdr's documented resolution order: `HERDR_SOCKET_PATH`, then the default
+ /// session socket, then named sessions under `~/.config/herdr/sessions//`.
+ /// Wrapped in `sh -c` (POSIX, any login shell) and ended with `; true` so the
+ /// command always exits 0 — Citadel's `executeCommand` throws on non-zero exit,
+ /// and an unmatched `sessions/*` glob makes the final `[ -S … ]` test fail.
+ private func discoverSocketPaths(client: SSHClient) async throws -> [String] {
+ let probe = #"sh -c 'for p in "$HERDR_SOCKET_PATH" "$HOME/.config/herdr/herdr.sock" "$HOME"/.config/herdr/sessions/*/herdr.sock; do [ -S "$p" ] && echo "$p"; done; true'"#
+ let output: ByteBuffer
+ do {
+ output = try await client.executeCommand(probe)
+ } catch {
+ throw HerdrError.connectionFailed(
+ "Couldn't search for the Herdr socket on \(host.displayName): \(error)"
+ )
+ }
+ let text = output.getString(at: output.readerIndex, length: output.readableBytes) ?? ""
+ var seen = Set()
+ return text
+ .split(separator: "\n")
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty && seen.insert($0).inserted }
+ }
+
+ /// Shell command run on the remote host to bridge stdio to the Herdr socket.
+ /// A leading `~` is rewritten to `$HOME` so the remote shell expands it
+ /// (tilde expansion doesn't fire mid-word, but `$HOME` does). For the
+ /// one-shot model, `nc -U` is sufficient; `socat` is used if present.
+ static func bridgeCommand(socketPath: String) -> String {
+ // Build a shell-safe target. A leading `~` becomes an unquoted `"$HOME"`
+ // (so the remote shell expands it); the remainder is single-quoted so an
+ // override path can't inject shell syntax.
+ let target: String
+ if socketPath.hasPrefix("~") {
+ target = "\"$HOME\"" + singleQuoted(String(socketPath.dropFirst()))
+ } else {
+ target = singleQuoted(socketPath)
+ }
+ return "socat - UNIX-CONNECT:\(target) || nc -U \(target)"
+ }
+
+ /// POSIX single-quote escaping: wrap in `'…'`, closing/escaping/reopening for
+ /// any embedded single quote.
+ private static func singleQuoted(_ string: String) -> String {
+ "'" + string.replacingOccurrences(of: "'", with: "'\\''") + "'"
+ }
+}
diff --git a/loadout/apps/iOS/Sources/DesignSystem/StatusBadge.swift b/loadout/apps/iOS/Sources/DesignSystem/StatusBadge.swift
new file mode 100644
index 0000000..5f1c153
--- /dev/null
+++ b/loadout/apps/iOS/Sources/DesignSystem/StatusBadge.swift
@@ -0,0 +1,86 @@
+import SwiftUI
+import HerdrKit
+
+/// A small colored dot for a single agent status, optionally pulsing while the
+/// agent is actively working.
+struct StatusDot: View {
+ let status: AgentStatus
+ var size: CGFloat = 9
+ var pulses: Bool = false
+ @State private var animate = false
+
+ var body: some View {
+ Circle()
+ .fill(status.color)
+ .frame(width: size, height: size)
+ .opacity(pulses && status == .working ? (animate ? 0.35 : 1) : 1)
+ .animation(
+ pulses && status == .working
+ ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true)
+ : .default,
+ value: animate
+ )
+ .onAppear { animate = true }
+ .accessibilityLabel(status.label)
+ }
+}
+
+/// A compact row of " " pairs summarizing how many agents sit in
+/// each status within a workspace.
+struct StatusSummary: View {
+ let counts: [AgentStatus: Int]
+
+ private var ordered: [(AgentStatus, Int)] {
+ AgentStatus.allCases
+ .compactMap { status in counts[status].map { (status, $0) } }
+ .filter { $0.1 > 0 }
+ .sorted { $0.0.priority > $1.0.priority }
+ }
+
+ var body: some View {
+ HStack(spacing: 10) {
+ ForEach(ordered, id: \.0) { status, count in
+ HStack(spacing: 4) {
+ StatusDot(status: status, size: 7)
+ Text("\(count)")
+ .font(Theme.mono(12, .medium))
+ .foregroundStyle(.secondary)
+ }
+ .accessibilityLabel("\(count) \(status.label)")
+ }
+ }
+ }
+}
+
+/// A status pill — a pulsing dot plus a mono label on a faintly tinted capsule.
+/// Used in pane rows and the pane toolbar.
+struct StatusTag: View {
+ let status: AgentStatus
+
+ var body: some View {
+ HStack(spacing: 5) {
+ StatusDot(status: status, size: 7, pulses: true)
+ Text(status.label.uppercased())
+ .font(Theme.mono(10, .semibold))
+ .tracking(0.5)
+ .foregroundStyle(status.color)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(status.color.opacity(0.13), in: Capsule())
+ }
+}
+
+/// A small uppercase monospace section label — the structural eyebrow used for
+/// list section headers.
+struct SectionEyebrow: View {
+ let text: String
+ init(_ text: String) { self.text = text }
+
+ var body: some View {
+ Text(text.uppercased())
+ .font(Theme.mono(11, .semibold))
+ .tracking(1.5)
+ .foregroundStyle(.secondary)
+ }
+}
diff --git a/loadout/apps/iOS/Sources/DesignSystem/Theme.swift b/loadout/apps/iOS/Sources/DesignSystem/Theme.swift
new file mode 100644
index 0000000..3343bfc
--- /dev/null
+++ b/loadout/apps/iOS/Sources/DesignSystem/Theme.swift
@@ -0,0 +1,83 @@
+import SwiftUI
+import HerdrKit
+
+/// Design tokens for Herdr. The app is terminal-native: light, legible chrome
+/// for scanning the flock, a genuinely dark terminal surface in the pane view,
+/// and monospace type wherever machine data appears (hosts, ids, output).
+enum Theme {
+ // Brand neutrals, pulled from the ram mark.
+ static let ink = Color(hex: 0x23272B)
+
+ // Terminal surface (PaneView) — light mode: a clean near-white paper with
+ // dark ink, matching the rest of the app.
+ static let terminalBG = Color(hex: 0xFCFCFA)
+ static let terminalSurface = Color(hex: 0xEDECE8)
+ static let terminalText = Color(hex: 0x23272B)
+ static let terminalDim = Color(hex: 0x8A9099)
+ /// The prompt accent — echoes the `>-` terminal-prompt eye in the logo.
+ static let prompt = Color(hex: 0x57B89E)
+
+ // Status palette — refined tones, but keeping Herdr's documented legend
+ // semantics (blocked/working/done/idle/unknown).
+ static let blocked = Color(hex: 0xE5484D)
+ static let working = Color(hex: 0xE0A52E)
+ static let done = Color(hex: 0x5B8DEF)
+ static let idle = Color(hex: 0x4FA46B)
+ static let unknown = Color(hex: 0x868D95)
+
+ // Type. `monospaced` is the scrollback face; `mono(_:_:)` is the utility
+ // face for hosts, ids, counts, eyebrows, and the wordmark.
+ static let monospaced = Font.system(.callout, design: .monospaced)
+ static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
+ .system(size: size, weight: weight, design: .monospaced)
+ }
+}
+
+extension Color {
+ /// Build a color from a 24-bit RGB hex literal, e.g. `Color(hex: 0x23272B)`.
+ init(hex: UInt32) {
+ self.init(
+ .sRGB,
+ red: Double((hex >> 16) & 0xFF) / 255,
+ green: Double((hex >> 8) & 0xFF) / 255,
+ blue: Double(hex & 0xFF) / 255,
+ opacity: 1
+ )
+ }
+}
+
+// Status presentation, matching Herdr's sidebar legend:
+// 🔴 blocked · 🟡 working · 🔵 done · 🟢 idle · ⚪️ unknown.
+extension AgentStatus {
+ var color: Color {
+ switch self {
+ case .blocked: return Theme.blocked
+ case .working: return Theme.working
+ case .done: return Theme.done
+ case .idle: return Theme.idle
+ case .unknown: return Theme.unknown
+ }
+ }
+
+ /// Short human label for badges and accessibility.
+ var label: String {
+ switch self {
+ case .blocked: return "Blocked"
+ case .working: return "Working"
+ case .done: return "Done"
+ case .idle: return "Idle"
+ case .unknown: return "Unknown"
+ }
+ }
+
+ /// SF Symbol used alongside the status dot.
+ var symbol: String {
+ switch self {
+ case .blocked: return "exclamationmark.circle.fill"
+ case .working: return "circle.dotted"
+ case .done: return "checkmark.circle.fill"
+ case .idle: return "moon.zzz.fill"
+ case .unknown: return "questionmark.circle"
+ }
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Features/Pane/PaneView.swift b/loadout/apps/iOS/Sources/Features/Pane/PaneView.swift
new file mode 100644
index 0000000..7f0dfa5
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Features/Pane/PaneView.swift
@@ -0,0 +1,563 @@
+import Foundation
+import SwiftUI
+import HerdrKit
+
+/// How a pane's terminal output is laid out on a phone screen. A terminal is a
+/// fixed-width grid; reflowing it to phone width scrambles box-drawn / columnar
+/// TUI layouts, so the grid modes (`fit`, `scroll`) render the raw grid faithfully
+/// and only `reader` reflows (for long plain prose).
+enum PaneRenderMode: String, CaseIterable {
+ /// Faithful grid, font auto-shrunk so the whole width fits — no scrolling.
+ case fit
+ /// Faithful grid at a fixed readable font; pan left/right to see the rest.
+ case scroll
+ /// Cleaned, unwrapped output reflowed to phone width — layout not preserved.
+ case reader
+
+ var label: String {
+ switch self {
+ case .fit: return "Fit"
+ case .scroll: return "Scroll"
+ case .reader: return "Reader"
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .fit: return "arrow.down.right.and.arrow.up.left"
+ case .scroll: return "arrow.left.and.right"
+ case .reader: return "text.alignleft"
+ }
+ }
+
+ /// Next mode in the cycle, for the single toggle button.
+ var next: PaneRenderMode {
+ let all = Self.allCases
+ return all[(all.firstIndex(of: self)! + 1) % all.count]
+ }
+}
+
+/// Screen 3: read a pane's output and send input, rendered as a light terminal.
+/// Output renders in one of three `PaneRenderMode`s (cycle button in the toolbar); an
+/// iSH-style key bar (sticky Ctrl, Esc, arrows) rides above the keyboard. Grid
+/// modes read the raw hard-wrapped grid (`recent`, with history; `visible` for
+/// alt-screen TUIs); reader reads `recent_unwrapped`.
+struct PaneView: View {
+ @Environment(SessionModel.self) private var session
+ let paneID: PaneID
+
+ @State private var input: String = ""
+ @State private var ctrlActive = false
+ @AppStorage("paneRenderMode") private var mode: PaneRenderMode = .fit
+ /// Readable font size for Scroll/Reader modes (Fit auto-sizes, so it's exempt).
+ @AppStorage("paneFontSize") private var fontSize: Double = 13
+ /// Raw terminal grid lines for the `fit`/`scroll` modes (uncleaned `recent`).
+ @State private var gridLines: [String] = []
+ /// Whether the scroll view is parked near the bottom — gates auto-stick so new
+ /// output doesn't yank the user off history they've scrolled up to read.
+ @State private var isPinned = true
+ /// Bumped on every accepted key/send to drive one-shot haptic feedback.
+ @State private var hapticTick = 0
+ @FocusState private var inputFocused: Bool
+
+ private var pane: Pane? { session.pane(paneID) }
+ private var lines: [String] { session.outputs[paneID] ?? [] }
+ /// Monospace advance ≈ 0.6em for the system monospaced font.
+ // ponytail: fixed ratio, not measured — fine for SF Mono; revisit if a
+ // proportional or CJK-heavy font ever sneaks in.
+ private let monoAdvance = 0.6
+
+ var body: some View {
+ VStack(spacing: 0) {
+ content
+ Rectangle()
+ .fill(Theme.terminalDim.opacity(0.18))
+ .frame(height: 1)
+ if inputFocused {
+ keyControlBar
+ }
+ inputBar
+ }
+ .background(Theme.terminalBG, ignoresSafeAreaEdges: .bottom)
+ .sensoryFeedback(.impact(weight: .light), trigger: hapticTick)
+ .navigationTitle(pane?.title ?? paneID.rawValue)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ if mode != .fit { fontSizeButtons }
+ modeButton
+ if let pane, pane.isAgent {
+ StatusTag(status: pane.status)
+ }
+ }
+ }
+ // Keep the pane live by re-reading whenever it emits new output. The
+ // socket API pushes no pane-output events, but `pane.wait_for_output`
+ // lets us block until the screen changes (or the wait times out) instead
+ // of polling on a fixed timer — instant on activity, quiet while idle.
+ // Re-keyed on `mode` so flipping modes re-reads the right source. `.task`
+ // cancels on disappear / id change.
+ .task(id: pollKey) {
+ while !Task.isCancelled {
+ if mode == .reader {
+ await session.refreshPaneDisplay(for: paneID, isAgent: pane?.isAgent == true)
+ await session.awaitOutput(for: paneID, source: PaneReadSource.recentUnwrapped)
+ } else {
+ let fresh = await session.rawTerminal(for: paneID)
+ if Task.isCancelled { return } // don't clobber a newer pane/mode's grid
+ // Keep the last good grid only on a read *failure* (nil), as
+ // Reader does via its `outputs[pane]` fallback — but let a
+ // genuinely empty screen through so a cleared pane isn't pinned.
+ if let fresh { gridLines = fresh }
+ // Wait on `recent` — the source the grid now reads — so new
+ // scrollback wakes us. (Alt-screen panes, served from `visible`,
+ // just fall back to the wait's timeout poll.)
+ await session.awaitOutput(for: paneID, source: PaneReadSource.recent)
+ }
+ }
+ }
+ }
+
+ /// Restart the read loop when the pane or render mode changes — each mode
+ /// reads a different `pane.read` source.
+ private var pollKey: String { "\(paneID.rawValue)|\(mode.rawValue)" }
+
+ /// Single toolbar button that cycles fit → scroll → reader, replacing the
+ /// space-hungry segmented control. Shows the current mode so the next tap is
+ /// predictable.
+ private var modeButton: some View {
+ Button { mode = mode.next } label: {
+ HStack(spacing: 4) {
+ Image(systemName: mode.icon)
+ Text(mode.label)
+ }
+ .font(Theme.mono(11, .semibold))
+ }
+ .tint(Theme.prompt)
+ .accessibilityLabel("Layout: \(mode.label). Tap to change.")
+ }
+
+ /// A−/A+ pair for the Scroll/Reader font, clamped to a legible range.
+ private var fontSizeButtons: some View {
+ HStack(spacing: 2) {
+ Button { fontSize = max(9, fontSize - 1) } label: { Image(systemName: "textformat.size.smaller") }
+ .disabled(fontSize <= 9)
+ .accessibilityLabel("Smaller text")
+ Button { fontSize = min(22, fontSize + 1) } label: { Image(systemName: "textformat.size.larger") }
+ .disabled(fontSize >= 22)
+ .accessibilityLabel("Larger text")
+ }
+ .tint(Theme.prompt)
+ }
+
+ /// The user-sized monospace face for Scroll/Reader.
+ private var paneFont: Font { .system(size: fontSize, design: .monospaced) }
+
+ /// Background probe: the content's bottom edge sits at/above the viewport
+ /// bottom (plus an 80pt slack) ⇒ we're pinned. Lives in `.background` so it's
+ /// always measured regardless of lazy realization.
+ private func pinReader(viewportHeight: CGFloat) -> some View {
+ GeometryReader { geo in
+ Color.clear.preference(
+ key: PinnedToBottomKey.self,
+ value: geo.frame(in: .named(scrollSpace)).maxY <= viewportHeight + 80
+ )
+ }
+ }
+
+ @ViewBuilder private var content: some View {
+ switch mode {
+ case .fit: fitGrid
+ case .scroll: scrollGrid
+ case .reader: scrollback
+ }
+ }
+
+ private var scrollback: some View {
+ GeometryReader { geo in
+ ScrollViewReader { proxy in
+ // Mobile transcript: cleaned output (frames stripped) wraps
+ // vertically — no horizontal scroll. Color preserved.
+ ScrollView(.vertical) {
+ LazyVStack(alignment: .leading, spacing: 4) {
+ if lines.isEmpty {
+ Text("— no output yet —")
+ .font(paneFont)
+ .foregroundStyle(Theme.terminalDim)
+ }
+ ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
+ Text(line.ansiAttributed(defaultColor: Theme.terminalText, surface: Theme.terminalBG))
+ .font(paneFont)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Color.clear.frame(height: 1).id(bottomAnchor)
+ }
+ .padding(14)
+ .background(pinReader(viewportHeight: geo.size.height))
+ }
+ .background(Theme.terminalBG)
+ .coordinateSpace(.named(scrollSpace))
+ .onPreferenceChange(PinnedToBottomKey.self) { isPinned = $0 }
+ .onChange(of: lines.count) {
+ if isPinned { withAnimation { proxy.scrollTo(bottomAnchor, anchor: .bottom) } }
+ }
+ .onAppear { proxy.scrollTo(bottomAnchor, anchor: .bottom) }
+ }
+ }
+ }
+
+ /// Fit mode: the raw grid with the font auto-shrunk so the widest line fits
+ /// the screen — vertical scroll only, no horizontal pan. Faithful layout,
+ /// small font at high column counts (≈7–8pt at 80 cols on a phone).
+ private var fitGrid: some View {
+ GeometryReader { geo in
+ // Size the font from the widest line so cols * advance * size fits.
+ // 0.97 leaves a hair of slack against advance-ratio error.
+ // ponytail: Character count, not terminal display width — CJK/emoji
+ // (width 2) would under-count and overflow slightly. Agent TUIs are
+ // overwhelmingly width-1 box/ASCII; add a wcwidth pass if that breaks.
+ let cols = max(1, gridLines.map { TerminalText.stripANSI($0).count }.max() ?? 1)
+ // Floor at 4pt (not 5) so a ~124-col agent grid fits the width fully
+ // instead of clipping its last chars — small is the point of Fit.
+ let size = max(4, min(15, (geo.size.width - 28) * 0.97 / (Double(cols) * monoAdvance)))
+ ScrollViewReader { proxy in
+ ScrollView(.vertical) {
+ // Eager VStack — the grid is bounded (a few hundred rows at
+ // most) so lazy layout buys nothing and complicates sizing.
+ VStack(alignment: .leading, spacing: 0) {
+ gridPlaceholder
+ ForEach(Array(gridLines.enumerated()), id: \.offset) { _, line in
+ // One uniform size for every row keeps columns aligned —
+ // no per-row minimumScaleFactor (it would scale wide rows
+ // independently and break the grid). A pathologically wide
+ // line truncates rather than shrinking out of alignment.
+ Text(line.ansiAttributed(defaultColor: Theme.terminalText, surface: Theme.terminalBG))
+ .font(.system(size: size, design: .monospaced))
+ .lineLimit(1)
+ }
+ Color.clear.frame(height: 1).id(bottomAnchor)
+ }
+ .padding(14)
+ .background(pinReader(viewportHeight: geo.size.height))
+ }
+ .coordinateSpace(.named(scrollSpace))
+ .onPreferenceChange(PinnedToBottomKey.self) { isPinned = $0 }
+ .onChange(of: gridLines.count) {
+ if isPinned { withAnimation { proxy.scrollTo(bottomAnchor, anchor: .bottom) } }
+ }
+ }
+ }
+ .background(Theme.terminalBG)
+ }
+
+ /// Scroll mode: the raw grid at a fixed readable font, panned in both axes —
+ /// the faithful terminal view (what the agent's screen literally looks like).
+ private var scrollGrid: some View {
+ ScrollView([.vertical, .horizontal]) {
+ // Eager VStack, spacing 0 so multi-row ANSI backgrounds tile without
+ // gaps. (LazyVStack also mis-measures width inside a two-axis
+ // ScrollView, so eager is the safe choice here regardless.)
+ VStack(alignment: .leading, spacing: 0) {
+ gridPlaceholder
+ ForEach(Array(gridLines.enumerated()), id: \.offset) { _, line in
+ Text(line.ansiAttributed(defaultColor: Theme.terminalText, surface: Theme.terminalBG))
+ .font(paneFont)
+ .textSelection(.enabled)
+ .lineLimit(1)
+ .fixedSize(horizontal: true, vertical: false)
+ }
+ }
+ .padding(14)
+ }
+ .background(Theme.terminalBG)
+ }
+
+ @ViewBuilder private var gridPlaceholder: some View {
+ if gridLines.isEmpty {
+ Text("— no output yet —")
+ .font(Theme.monospaced)
+ .foregroundStyle(Theme.terminalDim)
+ }
+ }
+
+ /// iSH-style key row pinned directly above the input field — and thus above
+ /// the keyboard when it's up, since keyboard avoidance lifts the whole stack.
+ /// Living in the layout flow (rather than a `.keyboard` accessory, whose
+ /// height SwiftUI doesn't fold into the inset) keeps it from overlapping the
+ /// input bar. `Ctrl` is sticky and modifies the next key (a bar key, or the
+ /// next typed letter).
+ private var keyControlBar: some View {
+ HStack(spacing: 0) {
+ Button { ctrlActive.toggle() } label: {
+ Image(systemName: "control")
+ .fontWeight(.semibold)
+ .foregroundStyle(ctrlActive ? Color.white : Theme.prompt)
+ .padding(.horizontal, 9)
+ .padding(.vertical, 5)
+ .background(ctrlActive ? Theme.prompt : Color.clear,
+ in: RoundedRectangle(cornerRadius: 7))
+ .frame(maxWidth: .infinity, minHeight: 38)
+ }
+ keyButton("escape", sends: "Esc")
+ keyButton("arrow.left", sends: "Left")
+ keyButton("arrow.up", sends: "Up")
+ keyButton("arrow.down", sends: "Down")
+ keyButton("arrow.right", sends: "Right")
+ keyButton("return", sends: "Enter")
+ Button { inputFocused = false } label: {
+ Image(systemName: "keyboard.chevron.compact.down")
+ .foregroundStyle(Theme.prompt)
+ .frame(maxWidth: .infinity, minHeight: 38)
+ }
+ }
+ .padding(.horizontal, 8)
+ .background(Theme.terminalSurface)
+ }
+
+ private func keyButton(_ symbol: String, sends key: String) -> some View {
+ Button { sendBarKey(key) } label: {
+ Image(systemName: symbol)
+ .foregroundStyle(Theme.prompt)
+ .frame(maxWidth: .infinity, minHeight: 38)
+ }
+ }
+
+ private var inputBar: some View {
+ HStack(spacing: 10) {
+ HStack(spacing: 7) {
+ Text(ctrlActive ? "^" : ">")
+ .font(Theme.mono(15, .bold))
+ .foregroundStyle(Theme.prompt)
+ TextField("", text: $input,
+ prompt: Text("send input…").foregroundColor(Theme.terminalDim),
+ axis: .vertical)
+ .font(Theme.monospaced)
+ .foregroundStyle(Theme.terminalText)
+ .focused($inputFocused)
+ .lineLimit(1...4)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .tint(Theme.prompt)
+ .onChange(of: input) { old, new in handleCtrlTyping(old, new) }
+ .onSubmit(send)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 9)
+ .background(Theme.terminalSurface, in: RoundedRectangle(cornerRadius: 11, style: .continuous))
+
+ Button(action: send) {
+ Image(systemName: "arrow.up")
+ .font(.body.weight(.bold))
+ .foregroundStyle(Theme.terminalBG)
+ .frame(width: 38, height: 38)
+ .background(canSend ? Theme.prompt : Theme.terminalDim, in: Circle())
+ }
+ .disabled(!canSend)
+ }
+ .padding(14)
+ .background(Theme.terminalBG)
+ }
+
+ private var canSend: Bool {
+ !input.trimmingCharacters(in: .whitespaces).isEmpty
+ }
+
+ private let bottomAnchor = "herdr.pane.bottom"
+ private let scrollSpace = "herdr.pane.scroll"
+
+ /// Send a bar key, applying a pending sticky Ctrl as `ctrl+`.
+ private func sendBarKey(_ key: String) {
+ let resolved = ctrlActive ? "ctrl+\(key.lowercased())" : key
+ ctrlActive = false
+ hapticTick += 1
+ Task { await session.sendKeys(resolved, to: paneID) }
+ }
+
+ /// When Ctrl is armed, the next typed character is sent as `ctrl+`
+ /// instead of being inserted.
+ private func handleCtrlTyping(_ old: String, _ new: String) {
+ guard ctrlActive else { return }
+ guard new.count == old.count + 1, let ch = new.last, ch.isLetter || ch.isNumber else {
+ if new.count != old.count { ctrlActive = false } // backspace/paste cancels Ctrl
+ return
+ }
+ ctrlActive = false
+ input = String(new.dropLast())
+ hapticTick += 1
+ let combo = "ctrl+\(String(ch).lowercased())"
+ Task { await session.sendKeys(combo, to: paneID) }
+ }
+
+ private func send() {
+ let text = input
+ input = ""
+ hapticTick += 1
+ Task { await session.submit(text, to: paneID) }
+ }
+}
+
+/// Reports whether a vertical scroll view is parked near its bottom. Read from a
+/// `.background` GeometryReader (always laid out, unlike a lazy child sentinel) so
+/// it stays correct even when the bottom row is recycled out of a `LazyVStack`.
+private struct PinnedToBottomKey: PreferenceKey {
+ static let defaultValue = true
+ static func reduce(value: inout Bool, nextValue: () -> Bool) { value = nextValue() }
+}
+
+extension String {
+ /// Parse ANSI SGR sequences — fg/bg (16-color, 256-color, 24-bit truecolor),
+ /// inverse video, and dim — into an `AttributedString`, dropping every other
+ /// escape (cursor moves, erase, …). Renders both foreground and background so
+ /// filled/inverse regions (e.g. an agent's block-art logo) look right.
+ /// `defaultColor` is the fallback fg; `surface` is the terminal background,
+ /// used to resolve inverse video. Bold is not weight-rendered.
+ func ansiAttributed(defaultColor: Color, surface: Color) -> AttributedString {
+ guard contains("\u{1B}") else {
+ var plain = AttributedString(self)
+ plain.foregroundColor = defaultColor
+ return plain
+ }
+ // Match the full CSI parameter range (`[0-?]`, incl. private `?`) so a
+ // non-SGR sequence like ESC[?25l is consumed, not rendered literally.
+ // Style is only applied when the final byte is `m` (see below).
+ let pattern = "\u{1B}\\[([0-?]*)([ -/]*[@-~])"
+ guard let regex = try? NSRegularExpression(pattern: pattern) else {
+ var plain = AttributedString(TerminalText.stripANSI(self))
+ plain.foregroundColor = defaultColor
+ return plain
+ }
+ let ns = self as NSString
+ var out = AttributedString()
+ var cursor = 0
+ var style = ANSIStyle()
+
+ func appendText(_ s: String) {
+ guard !s.isEmpty else { return }
+ var seg = AttributedString(s)
+ let (fg, bg) = style.resolved(defaultFg: defaultColor, surface: surface)
+ seg.foregroundColor = fg
+ if let bg { seg.backgroundColor = bg }
+ out.append(seg)
+ }
+
+ regex.enumerateMatches(in: self, range: NSRange(location: 0, length: ns.length)) { match, _, _ in
+ guard let match else { return }
+ if match.range.location > cursor {
+ appendText(ns.substring(with: NSRange(location: cursor, length: match.range.location - cursor)))
+ }
+ cursor = match.range.location + match.range.length
+ // Only SGR ('m') affects style; other final bytes are consumed and dropped.
+ guard ns.substring(with: match.range(at: 2)) == "m" else { return }
+ style.applySGR(ns.substring(with: match.range(at: 1)))
+ }
+ if cursor < ns.length { appendText(ns.substring(from: cursor)) }
+ return out
+ }
+}
+
+/// Mutable SGR style state accumulated while scanning a line: foreground,
+/// background, inverse, and dim. `nil` fg/bg mean "use the defaults".
+private struct ANSIStyle {
+ var fg: Color?
+ var bg: Color?
+ var inverse = false
+ var dim = false
+
+ /// Resolve to a concrete (foreground, optional background) for a run —
+ /// applying inverse (swap fg/bg, defaulting bg to the surface) and dim (fade).
+ func resolved(defaultFg: Color, surface: Color) -> (Color, Color?) {
+ var f = inverse ? (bg ?? surface) : (fg ?? defaultFg)
+ let b: Color? = inverse ? (fg ?? defaultFg) : bg
+ if dim { f = f.opacity(0.55) }
+ return (f, b)
+ }
+
+ mutating func applySGR(_ params: String) {
+ // Keep empty fields (`ESC[31;m` → 31 then an empty reset param == 0).
+ let codes = params.split(separator: ";", omittingEmptySubsequences: false).map { Int($0) ?? 0 }
+ if codes.isEmpty { self = ANSIStyle(); return } // bare ESC[m == reset
+ var i = 0
+ while i < codes.count {
+ let c = codes[i]
+ switch c {
+ case 0: self = ANSIStyle()
+ case 1: dim = false // bold: not weight-rendered, but clears dim
+ case 2: dim = true
+ case 22: dim = false
+ case 7: inverse = true
+ case 27: inverse = false
+ case 30...37: fg = ANSIColor.palette[c - 30]
+ case 90...97: fg = ANSIColor.palette[8 + (c - 90)]
+ case 39: fg = nil
+ case 40...47: bg = ANSIColor.palette[c - 40]
+ case 100...107: bg = ANSIColor.palette[8 + (c - 100)]
+ case 49: bg = nil
+ // On a malformed/truncated 38/48 spec, consume the rest rather than
+ // letting a stray operand (e.g. the `2` in `ESC[38;2m`) act as an SGR.
+ case 38: if let (col, adv) = ANSIColor.extended(codes, i) { fg = col; i += adv } else { i = codes.count }
+ case 48: if let (col, adv) = ANSIColor.extended(codes, i) { bg = col; i += adv } else { i = codes.count }
+ default: break
+ }
+ i += 1
+ }
+ }
+}
+
+/// ANSI SGR color resolution, tuned to stay legible on the light terminal
+/// background.
+private enum ANSIColor {
+ /// Standard + bright 16-color palette (indices 0–7 then 8–15), darkened where
+ /// needed so light colors remain readable on a near-white surface.
+ static let palette: [Color] = [
+ Color(red: 0.15, green: 0.15, blue: 0.15), // black
+ Color(red: 0.78, green: 0.18, blue: 0.18), // red
+ Color(red: 0.13, green: 0.55, blue: 0.13), // green
+ Color(red: 0.65, green: 0.45, blue: 0.00), // yellow → amber
+ Color(red: 0.15, green: 0.40, blue: 0.85), // blue
+ Color(red: 0.66, green: 0.20, blue: 0.66), // magenta
+ Color(red: 0.00, green: 0.50, blue: 0.55), // cyan → teal
+ Color(red: 0.30, green: 0.30, blue: 0.30), // white → dark gray (legible)
+ Color(red: 0.40, green: 0.40, blue: 0.40), // bright black → gray
+ Color(red: 0.85, green: 0.25, blue: 0.25), // bright red
+ Color(red: 0.20, green: 0.62, blue: 0.20), // bright green
+ Color(red: 0.72, green: 0.52, blue: 0.05), // bright yellow
+ Color(red: 0.22, green: 0.48, blue: 0.92), // bright blue
+ Color(red: 0.74, green: 0.28, blue: 0.74), // bright magenta
+ Color(red: 0.05, green: 0.58, blue: 0.62), // bright cyan
+ Color(red: 0.20, green: 0.20, blue: 0.20), // bright white → near-black
+ ]
+
+ /// Parse an extended-color spec (`38/48;5;n` or `38/48;2;r;g;b`) where `i` is
+ /// the `38`/`48` index. Returns the color and how many extra codes it consumed.
+ /// Truecolor is used verbatim (not palette-darkened) so e.g. a `0;0;0` logo
+ /// background renders truly black.
+ static func extended(_ codes: [Int], _ i: Int) -> (Color, Int)? {
+ if i + 2 < codes.count, codes[i + 1] == 5 {
+ let n = codes[i + 2]
+ return (0...255).contains(n) ? (from256(n), 2) : nil
+ } else if i + 4 < codes.count, codes[i + 1] == 2 {
+ return (Color(.sRGB,
+ red: Double(codes[i + 2]) / 255,
+ green: Double(codes[i + 3]) / 255,
+ blue: Double(codes[i + 4]) / 255), 4)
+ }
+ return nil
+ }
+
+ /// xterm 256-color index → RGB (16 base + 6×6×6 cube + 24 grays).
+ private static func from256(_ n: Int) -> Color {
+ if n < 16 { return palette[n] }
+ if n < 232 {
+ let c = n - 16
+ let steps = [0.0, 95, 135, 175, 215, 255]
+ return Color(.sRGB,
+ red: steps[(c / 36) % 6] / 255,
+ green: steps[(c / 6) % 6] / 255,
+ blue: steps[c % 6] / 255)
+ }
+ let gray = Double(8 + (n - 232) * 10) / 255
+ return Color(.sRGB, red: gray, green: gray, blue: gray)
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Features/Panes/WorkspaceDetailView.swift b/loadout/apps/iOS/Sources/Features/Panes/WorkspaceDetailView.swift
new file mode 100644
index 0000000..d09753a
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Features/Panes/WorkspaceDetailView.swift
@@ -0,0 +1,191 @@
+import SwiftUI
+import HerdrKit
+
+/// Screen 2: the tabs and panes/agents inside a single workspace, each with its
+/// live status. Reads from the shared `SessionModel`, so status updates animate
+/// in place.
+struct WorkspaceDetailView: View {
+ @Environment(SessionModel.self) private var session
+ let workspaceID: WorkspaceID
+ @State private var showingNewTab = false
+ @State private var pendingClose: PendingClose?
+
+ /// A tab or pane queued for a confirmed close (both routed through one dialog).
+ private enum PendingClose: Identifiable {
+ case tab(HerdrKit.Tab), pane(Pane)
+ var id: String {
+ switch self {
+ case .tab(let t): return "t-\(t.id.rawValue)"
+ case .pane(let p): return "p-\(p.id.rawValue)"
+ }
+ }
+ var label: String {
+ switch self {
+ case .tab(let t): return t.label
+ case .pane(let p): return p.title
+ }
+ }
+ }
+
+ private var workspace: Workspace? { session.workspace(workspaceID) }
+
+ var body: some View {
+ Group {
+ if let workspace {
+ List {
+ ForEach(workspace.tabs) { tab in
+ Section {
+ ForEach(tab.panes) { pane in
+ NavigationLink(value: pane.id) {
+ PaneRow(pane: pane)
+ }
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) { pendingClose = .pane(pane) } label: {
+ Label("Close", systemImage: "xmark")
+ }
+ }
+ }
+ } header: {
+ SectionEyebrow(tab.label)
+ .contextMenu {
+ Button(role: .destructive) { pendingClose = .tab(tab) } label: {
+ Label("Close tab", systemImage: "xmark")
+ }
+ }
+ }
+ }
+ }
+ .confirmationDialog(
+ "Close “\(pendingClose?.label ?? "")”?",
+ isPresented: Binding(get: { pendingClose != nil }, set: { if !$0 { pendingClose = nil } }),
+ titleVisibility: .visible,
+ presenting: pendingClose
+ ) { target in
+ Button("Close", role: .destructive) {
+ Task {
+ switch target {
+ case .tab(let t): await session.closeTab(t.id)
+ case .pane(let p): await session.closePane(p.id)
+ }
+ }
+ }
+ } message: { _ in
+ Text("This kills the running terminal process.")
+ }
+ .navigationTitle(workspace.label)
+ .navigationBarTitleDisplayMode(.inline)
+ .sheet(isPresented: $showingNewTab) {
+ NewTabSheet(workspaceID: workspaceID)
+ }
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button { showingNewTab = true } label: { Image(systemName: "plus") }
+ .tint(Theme.ink)
+ .accessibilityLabel("New tab")
+ }
+ }
+ } else {
+ ContentUnavailableView("Workspace closed", systemImage: "xmark.rectangle",
+ description: Text("This workspace is no longer available."))
+ }
+ }
+ }
+}
+
+/// Sheet for `tab.create` in a fixed workspace. Label is optional; the new tab
+/// appears as a section once the post-create refresh lands. Failures stay inline.
+private struct NewTabSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(SessionModel.self) private var session
+ let workspaceID: WorkspaceID
+ @State private var label = ""
+ @State private var isCreating = false
+ @State private var error: String?
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ TextField("Label (optional)", text: $label)
+ .autocorrectionDisabled()
+ } header: {
+ SectionEyebrow("tab")
+ } footer: {
+ Text("Optional. Leave blank to use the server's default name.")
+ }
+
+ if let error {
+ Section {
+ Label(error, systemImage: "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(Theme.blocked)
+ }
+ }
+ }
+ .navigationTitle("New tab")
+ .navigationBarTitleDisplayMode(.inline)
+ .interactiveDismissDisabled(isCreating)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }.disabled(isCreating)
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ if isCreating {
+ ProgressView()
+ } else {
+ Button("Create") { create() }
+ }
+ }
+ }
+ }
+ .tint(Theme.prompt)
+ }
+
+ private func create() {
+ isCreating = true
+ error = nil
+ Task {
+ do {
+ try await session.createTab(
+ label: label.trimmingCharacters(in: .whitespacesAndNewlines),
+ in: workspaceID
+ )
+ dismiss()
+ } catch {
+ self.error = String(describing: error)
+ isCreating = false
+ }
+ }
+ }
+}
+
+private struct PaneRow: View {
+ let pane: Pane
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: pane.isAgent ? "cpu" : "terminal")
+ .font(.callout)
+ .foregroundStyle(pane.isAgent ? Theme.ink : Color.secondary)
+ .frame(width: 24)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(pane.title).font(.body.weight(.medium)).lineLimit(1)
+ HStack(spacing: 8) {
+ Text(pane.id.rawValue)
+ .font(Theme.mono(11))
+ .foregroundStyle(.tertiary)
+ if let agent = pane.agent {
+ Text(agent)
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ Spacer(minLength: 8)
+ if pane.isAgent {
+ StatusTag(status: pane.status)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Features/Workspaces/WorkspaceListView.swift b/loadout/apps/iOS/Sources/Features/Workspaces/WorkspaceListView.swift
new file mode 100644
index 0000000..93dbaad
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Features/Workspaces/WorkspaceListView.swift
@@ -0,0 +1,223 @@
+import SwiftUI
+import HerdrKit
+
+/// Screen 1: the list of workspaces with live aggregate agent status. Hosts the
+/// `NavigationStack` and registers destinations for the drill-down screens.
+struct WorkspaceListView: View {
+ @Environment(SessionModel.self) private var session
+ @Environment(AppModel.self) private var app
+ @State private var path = NavigationPath()
+ @State private var showingNewWorkspace = false
+ @State private var pendingClose: Workspace?
+ @AppStorage(AgentNotifier.enabledKey) private var notify = false
+
+ var body: some View {
+ NavigationStack(path: $path) {
+ List {
+ if let error = session.loadError {
+ Label(error, systemImage: "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(Theme.blocked)
+ }
+ ForEach(session.workspaces) { workspace in
+ NavigationLink(value: workspace.id) {
+ WorkspaceRow(workspace: workspace)
+ }
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) { pendingClose = workspace } label: {
+ Label("Close", systemImage: "xmark")
+ }
+ }
+ }
+ }
+ .confirmationDialog(
+ "Close “\(pendingClose?.label ?? "")”?",
+ isPresented: Binding(get: { pendingClose != nil }, set: { if !$0 { pendingClose = nil } }),
+ titleVisibility: .visible,
+ presenting: pendingClose
+ ) { workspace in
+ Button("Close workspace", role: .destructive) {
+ Task { await session.closeWorkspace(workspace.id) }
+ }
+ } message: { _ in
+ Text("This kills every terminal and agent running in the workspace.")
+ }
+ .navigationDestination(for: WorkspaceID.self) { id in
+ WorkspaceDetailView(workspaceID: id)
+ }
+ .navigationDestination(for: PaneID.self) { id in
+ PaneView(paneID: id)
+ }
+ .refreshable { await session.refresh() }
+ .overlay {
+ if session.workspaces.isEmpty && session.loadError == nil {
+ ContentUnavailableView("No workspaces", systemImage: "rectangle.3.group",
+ description: Text("Tap + to create your first workspace."))
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .sheet(isPresented: $showingNewWorkspace) {
+ NewWorkspaceSheet { newID in path.append(newID) }
+ }
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Button { Task { await app.disconnect() } } label: {
+ Image(systemName: "rectangle.portrait.and.arrow.right")
+ }
+ .tint(Theme.ink)
+ .accessibilityLabel("Disconnect")
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button { toggleNotify() } label: {
+ Image(systemName: notify ? "bell.fill" : "bell.slash")
+ }
+ .tint(notify ? Theme.prompt : Theme.ink)
+ .accessibilityLabel(notify ? "Blocked-agent alerts on" : "Blocked-agent alerts off")
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button { showingNewWorkspace = true } label: { Image(systemName: "plus") }
+ .tint(Theme.ink)
+ .accessibilityLabel("New workspace")
+ }
+ ToolbarItem(placement: .principal) {
+ VStack(spacing: 2) {
+ Text("Workspaces").font(.headline)
+ Button { Task { await session.reconnect() } } label: {
+ HStack(spacing: 5) {
+ Circle()
+ .fill(session.link == .live ? Theme.idle : Theme.blocked)
+ .frame(width: 6, height: 6)
+ Text(session.link == .live ? session.label : "Reconnecting…")
+ .font(Theme.mono(10))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(session.link == .live)
+ .accessibilityLabel(session.link == .live
+ ? "Connected to \(session.label)" : "Connection lost. Tap to retry.")
+ }
+ }
+ }
+ }
+ .tint(Theme.prompt)
+ }
+
+ /// Flip the alerts pref. Turning on requests system authorization and only
+ /// sticks if granted, so the toggle never lies about whether alerts can fire.
+ private func toggleNotify() {
+ if notify { notify = false; return }
+ Task { notify = await AgentNotifier.requestAuthorization() }
+ }
+}
+
+private struct WorkspaceRow: View {
+ let workspace: Workspace
+
+ private var agentCount: Int { workspace.agentPanes.count }
+
+ var body: some View {
+ HStack(spacing: 12) {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(workspace.aggregateStatus.color)
+ .frame(width: 3)
+
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(workspace.label)
+ .font(.body.weight(.semibold))
+ .lineLimit(1)
+ Spacer(minLength: 8)
+ Text("\(agentCount) agent\(agentCount == 1 ? "" : "s")")
+ .font(Theme.mono(11))
+ .foregroundStyle(.tertiary)
+ }
+ if let cwd = workspace.cwd {
+ Text(cwd)
+ .font(Theme.mono(11))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.head)
+ }
+ StatusSummary(counts: workspace.agentCounts())
+ }
+ }
+ .padding(.vertical, 6)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+}
+
+/// Sheet for `workspace.create`. Label and cwd are both optional (the server
+/// defaults them); on success it dismisses and hands the new id back so the list
+/// can navigate into it. Failures stay inline so the user can fix and retry.
+private struct NewWorkspaceSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(SessionModel.self) private var session
+ @State private var label = ""
+ @State private var cwd = ""
+ @State private var isCreating = false
+ @State private var error: String?
+ let onCreated: (WorkspaceID) -> Void
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ TextField("Label (optional)", text: $label)
+ .autocorrectionDisabled()
+ TextField("Working directory (optional)", text: $cwd)
+ .font(.caption.monospaced())
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ } header: {
+ SectionEyebrow("workspace")
+ } footer: {
+ Text("Both optional. The working directory is a path on the Herdr host (e.g. ~/project); leave blank to use the server's default.")
+ }
+
+ if let error {
+ Section {
+ Label(error, systemImage: "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(Theme.blocked)
+ }
+ }
+ }
+ .navigationTitle("New workspace")
+ .navigationBarTitleDisplayMode(.inline)
+ .interactiveDismissDisabled(isCreating)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }.disabled(isCreating)
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ if isCreating {
+ ProgressView()
+ } else {
+ Button("Create") { create() }
+ }
+ }
+ }
+ }
+ .tint(Theme.prompt)
+ }
+
+ private func create() {
+ isCreating = true
+ error = nil
+ Task {
+ do {
+ let id = try await session.createWorkspace(
+ label: label.trimmingCharacters(in: .whitespacesAndNewlines),
+ cwd: cwd.trimmingCharacters(in: .whitespacesAndNewlines)
+ )
+ dismiss()
+ if let id { onCreated(id) }
+ } catch {
+ self.error = String(describing: error)
+ isCreating = false
+ }
+ }
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Session/AgentNotifier.swift b/loadout/apps/iOS/Sources/Session/AgentNotifier.swift
new file mode 100644
index 0000000..2cfb3b2
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Session/AgentNotifier.swift
@@ -0,0 +1,34 @@
+import Foundation
+import UserNotifications
+
+/// Local notifications for agents that need attention. Opt-in: the user turns it
+/// on (which requests authorization) from the workspaces toolbar, and we only
+/// fire on the transition *into* `.blocked`. iOS suppresses the banner while the
+/// app is foregrounded — exactly the "don't nag me while I'm watching" behavior,
+/// so there's no app-state check here.
+// ponytail: relies on iOS's default foreground suppression instead of a
+// UNUserNotificationCenterDelegate; add one only if we later want in-app banners.
+enum AgentNotifier {
+ static let enabledKey = "notifyOnBlocked"
+
+ static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) }
+
+ /// Request authorization; returns whether it was granted. Call from the toggle
+ /// so the system prompt is tied to an explicit user action, not a stray event.
+ @discardableResult
+ static func requestAuthorization() async -> Bool {
+ (try? await UNUserNotificationCenter.current()
+ .requestAuthorization(options: [.alert, .sound, .badge])) ?? false
+ }
+
+ /// Fire a "needs input" notification for a pane that just became blocked.
+ static func notifyBlocked(agent: String, workspace: String) {
+ guard isEnabled else { return }
+ let content = UNMutableNotificationContent()
+ content.title = "\(agent) needs input"
+ content.body = workspace
+ content.sound = .default
+ let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
+ UNUserNotificationCenter.current().add(request)
+ }
+}
diff --git a/loadout/apps/iOS/Sources/Session/SessionModel.swift b/loadout/apps/iOS/Sources/Session/SessionModel.swift
new file mode 100644
index 0000000..872b6e7
--- /dev/null
+++ b/loadout/apps/iOS/Sources/Session/SessionModel.swift
@@ -0,0 +1,254 @@
+import Foundation
+import HerdrKit
+
+/// The single source of truth for a connected Herdr session. Holds the live
+/// workspace tree and per-pane scrollback, applies server-pushed events, and
+/// exposes the actions the screens need. Injected into the view hierarchy via
+/// SwiftUI's environment.
+@MainActor
+@Observable
+final class SessionModel {
+ let client: HerdrClient
+ let label: String
+
+ var workspaces: [Workspace] = []
+ /// Scrollback lines per pane, populated by `loadOutput` and grown by events.
+ var outputs: [PaneID: [String]] = [:]
+ var loadError: String?
+
+ /// Health of the link to the server, derived from whether RPCs are landing.
+ /// Drives the toolbar indicator; `.lost` kicks off a backoff reconnect loop.
+ enum LinkState { case live, lost }
+ private(set) var link: LinkState = .live
+
+ private var eventTask: Task?
+ private var refreshTask: Task?
+ private var reconnectTask: Task?
+ /// Last status seen per pane, so we only notify on the edge *into* blocked.
+ private var lastStatus: [PaneID: AgentStatus] = [:]
+ private var subscribedTopology = false
+ private var subscribedPanes: Set = []
+
+ init(client: HerdrClient, label: String) {
+ self.client = client
+ self.label = label
+ }
+
+ /// Load the initial workspace list, begin observing live events, and
+ /// subscribe to topology + per-agent-pane status changes.
+ func start() async {
+ await refresh()
+ observeEvents()
+ await syncSubscriptions()
+ }
+
+ /// Subscribe to topology once, plus agent-status for any agent panes we
+ /// haven't subscribed to yet. Safe to call after each refresh.
+ private func syncSubscriptions() async {
+ var subscriptions: [EventSubscription] = []
+ let needsTopology = !subscribedTopology
+ if needsTopology { subscriptions.append(.topology) }
+ let agentPanes = Set(workspaces.flatMap(\.panes).filter(\.isAgent).map(\.id))
+ let fresh = agentPanes.subtracting(subscribedPanes)
+ subscriptions += fresh.map { .paneAgentStatus($0) }
+ guard !subscriptions.isEmpty else { return }
+ do {
+ try await client.subscribe(subscriptions)
+ // Mark as subscribed only on success → a failure is retried on the
+ // next refresh instead of being silently lost.
+ if needsTopology { subscribedTopology = true }
+ subscribedPanes.formUnion(fresh)
+ } catch {
+ // Leave unmarked; the next refresh will retry.
+ }
+ }
+
+ func refresh() async {
+ do {
+ workspaces = try await client.listWorkspaces()
+ loadError = nil
+ link = .live
+ // Seed the baseline from the listing so the first status *event* for a
+ // pane notifies only on a real change, not the initial sync.
+ for pane in workspaces.flatMap(\.panes) { lastStatus[pane.id] = pane.status }
+ reconnectTask?.cancel()
+ reconnectTask = nil
+ } catch {
+ loadError = String(describing: error)
+ link = .lost
+ scheduleReconnect()
+ }
+ }
+
+ /// Manual "tap the dot to retry now" — just re-runs the listing.
+ func reconnect() async { await refresh() }
+
+ /// While the link is lost, keep retrying the listing on an exponential
+ /// backoff (capped) until one succeeds — a successful `refresh` cancels us.
+ /// ponytail: re-lists over the existing transport; an SSH session that has
+ /// actually dropped is re-established by the transport on its next request.
+ private func scheduleReconnect() {
+ guard reconnectTask == nil else { return }
+ reconnectTask = Task { [weak self] in
+ var delay: Duration = .seconds(2)
+ while !Task.isCancelled {
+ try? await Task.sleep(for: delay)
+ guard !Task.isCancelled else { return }
+ await self?.refresh()
+ if self?.link == .live { return }
+ delay = min(delay * 2, .seconds(30))
+ }
+ }
+ }
+
+ /// Refresh a pane's display: read the scrollback and project it into a
+ /// readable mobile transcript. One region — the agent's own status footer
+ /// rides inline in the transcript like any other terminal output, rather
+ /// than being scraped out into a separate pinned strip (the old `detection`
+ /// scrape mis-classified full-screen TUI prompts as "status"). Best-effort —
+ /// a failed read leaves the last snapshot in place.
+ /// `isAgent` is unused now but kept so the call site needn't special-case.
+ func refreshPaneDisplay(for pane: PaneID, isAgent: Bool) async {
+ let recent = (try? await client.readPane(pane)) ?? outputs[pane] ?? []
+ outputs[pane] = TerminalText.clean(recent)
+ }
+
+ /// Block until the pane produces new output (or the server's wait times out),
+ /// so the view can re-read the instant the screen changes instead of polling
+ /// on a fixed timer. Falls back to a short sleep if the wait itself fails, so
+ /// a transport error can't turn the caller's loop into a hot spin.
+ func awaitOutput(for pane: PaneID, source: String = PaneReadSource.recentUnwrapped) async {
+ do {
+ _ = try await client.waitForOutput(pane, source: source)
+ } catch {
+ try? await Task.sleep(for: .seconds(3))
+ }
+ }
+
+ /// The raw terminal grid for a pane (uncleaned), for the Fit/Scroll modes.
+ /// Returns `nil` on a read failure so the caller can keep its last grid, vs.
+ /// an empty array for a genuinely blank screen.
+ func rawTerminal(for pane: PaneID) async -> [String]? {
+ try? await client.readRawTerminal(pane)
+ }
+
+ /// Submit a line of input (text + Enter), echoing it optimistically.
+ func submit(_ text: String, to pane: PaneID) async {
+ guard !text.isEmpty else { return }
+ appendOutput("❯ \(text)", to: pane)
+ try? await client.submitLine(text, to: pane)
+ }
+
+ func sendKeys(_ keys: String, to pane: PaneID) async {
+ try? await client.sendKeys(keys, to: pane)
+ }
+
+ /// Create a workspace, then re-list so the new one is in `workspaces` before
+ /// the caller navigates into it. Returns its id when the server reports one.
+ /// Throws on failure so the presenting sheet can surface it inline (errors
+ /// here are transient and sheet-local, unlike the persistent `loadError`).
+ func createWorkspace(label: String?, cwd: String?) async throws -> WorkspaceID? {
+ let id = try await client.createWorkspace(label: label, cwd: cwd)
+ await refresh()
+ await syncSubscriptions()
+ return id
+ }
+
+ /// Create a tab in `workspace`, then re-list so it appears in the detail view.
+ @discardableResult
+ func createTab(label: String?, in workspace: WorkspaceID) async throws -> TabID? {
+ let id = try await client.createTab(label: label, in: workspace)
+ await refresh()
+ await syncSubscriptions()
+ return id
+ }
+
+ /// Close a workspace/tab/pane, then re-list. Optimistically drops it from the
+ /// tree first so the row disappears immediately; the refresh reconciles (and
+ /// the `*.closed` topology event would anyway). Best-effort — a failed close
+ /// is surfaced by the next refresh putting it back.
+ func closeWorkspace(_ id: WorkspaceID) async {
+ workspaces.removeAll { $0.id == id }
+ try? await client.closeWorkspace(id)
+ await refresh()
+ }
+
+ func closeTab(_ id: TabID) async {
+ for w in workspaces.indices { workspaces[w].tabs.removeAll { $0.id == id } }
+ try? await client.closeTab(id)
+ await refresh()
+ }
+
+ func closePane(_ id: PaneID) async {
+ for w in workspaces.indices {
+ for t in workspaces[w].tabs.indices { workspaces[w].tabs[t].panes.removeAll { $0.id == id } }
+ }
+ try? await client.closePane(id)
+ await refresh()
+ }
+
+ // MARK: Lookups
+
+ func workspace(_ id: WorkspaceID) -> Workspace? {
+ workspaces.first { $0.id == id }
+ }
+
+ func pane(_ id: PaneID) -> Pane? {
+ workspaces.flatMap(\.panes).first { $0.id == id }
+ }
+
+ // MARK: Event handling
+
+ private func observeEvents() {
+ guard eventTask == nil else { return }
+ eventTask = Task { [weak self] in
+ guard let stream = await self?.client.eventStream else { return }
+ for await event in stream {
+ self?.apply(event)
+ }
+ }
+ }
+
+ private func apply(_ event: HerdrEvent) {
+ switch event {
+ case .agentStatus(let pane, let status):
+ updateStatus(status, for: pane)
+ case .topologyChanged:
+ scheduleRefresh()
+ }
+ }
+
+ /// Coalesce bursty topology events (a workspace close emits tab + pane
+ /// closes too) into a single debounced re-list + re-subscribe.
+ private func scheduleRefresh() {
+ refreshTask?.cancel()
+ refreshTask = Task { [weak self] in
+ try? await Task.sleep(for: .milliseconds(300))
+ guard !Task.isCancelled else { return }
+ await self?.refresh()
+ await self?.syncSubscriptions()
+ }
+ }
+
+ private func updateStatus(_ status: AgentStatus, for paneID: PaneID) {
+ let previous = lastStatus[paneID]
+ lastStatus[paneID] = status
+ for w in workspaces.indices {
+ for t in workspaces[w].tabs.indices {
+ for p in workspaces[w].tabs[t].panes.indices
+ where workspaces[w].tabs[t].panes[p].id == paneID {
+ workspaces[w].tabs[t].panes[p].status = status
+ if status == .blocked, previous != .blocked {
+ let pane = workspaces[w].tabs[t].panes[p]
+ AgentNotifier.notifyBlocked(agent: pane.agent ?? pane.title,
+ workspace: workspaces[w].label)
+ }
+ }
+ }
+ }
+ }
+
+ private func appendOutput(_ chunk: String, to pane: PaneID) {
+ outputs[pane, default: []].append(chunk)
+ }
+}
diff --git a/loadout/apps/macOS/Sources/App/AppState.swift b/loadout/apps/macOS/Sources/App/AppState.swift
new file mode 100644
index 0000000..c562208
--- /dev/null
+++ b/loadout/apps/macOS/Sources/App/AppState.swift
@@ -0,0 +1,864 @@
+import SwiftUI
+import AppKit
+
+enum ScopeMode: String, CaseIterable, Hashable {
+ case global, project
+ var label: String { self == .global ? "Global" : "Project" }
+}
+
+enum LibraryFilter: String, CaseIterable, Hashable {
+ case all, drift, diverged
+ var label: String { rawValue.prefix(1).uppercased() + rawValue.dropFirst() }
+}
+
+/// Identifies which mutation a status refers to, so per-row UI reacts only to its OWN
+/// action (one global `actionStatus` would otherwise light up every wire button at once,
+/// and let a background reload dismiss the install sheet).
+enum ActionID: Equatable {
+ case install
+ case fixAllDrift
+ case wire(Skill.ID, Agent)
+ case update(Skill.ID)
+ case remove(Skill.ID)
+ // MCP mutations
+ case mcpAdd
+ case mcpEdit(McpServer.ID)
+ case mcpRemove(McpServer.ID)
+ case mcpApply(McpServer.ID)
+ case mcpToggle(McpServer.ID, McpHarness)
+}
+
+/// Status of an in-flight mutation — drives spinners, inline confirmations and the alert.
+/// Each case carries the `ActionID` it belongs to.
+enum ActionStatus: Equatable {
+ case idle
+ case running(ActionID, String)
+ case success(ActionID, String)
+ case failure(ActionID, String)
+
+ /// Any mutation in flight — used for single-flight disabling across the whole UI.
+ var isRunning: Bool { if case .running = self { return true }; return false }
+
+ /// Is THIS specific action the one currently running? (scopes spinners to one button)
+ func isRunning(_ id: ActionID) -> Bool {
+ if case .running(let a, _) = self { return a == id }
+ return false
+ }
+
+ /// The running label, but only for the given action.
+ func runningLabel(_ id: ActionID) -> String? {
+ if case .running(let a, let label) = self, a == id { return label }
+ return nil
+ }
+
+ /// The failure message, but only for the given action.
+ func failureMessage(_ id: ActionID) -> String? {
+ if case .failure(let a, let msg) = self, a == id { return msg }
+ return nil
+ }
+
+ /// Did the given action just succeed? (used by the install sheet to self-dismiss)
+ func didSucceed(_ id: ActionID) -> Bool {
+ if case .success(let a, _) = self { return a == id }
+ return false
+ }
+}
+
+/// The one active sidebar filter. Single-selection like a native macOS sidebar:
+/// choosing one clears the others. `.library(.all)` is the unfiltered default.
+enum SidebarFilter: Hashable {
+ case library(LibraryFilter)
+ case agent(Agent)
+ case source(String)
+ case mcpHarness(McpHarness)
+}
+
+@MainActor
+final class AppState: ObservableObject {
+ // Navigation / filters
+ @Published var kind: ResourceKind = .skill { didSet { if oldValue != kind { switchedKind() } } }
+ @Published var scopeMode: ScopeMode = .global { didSet { persistScope() } }
+ @Published var selectedProject: URL?
+ /// Projects the user has saved — persisted indefinitely, switchable, removable.
+ @Published var savedProjects: [URL] = []
+ /// Remote machines the user has added (ssh targets / `~/.ssh/config` aliases), persisted.
+ @Published var savedHosts: [String] = []
+ /// The selected remote target, or nil for the local machine. Not persisted — every
+ /// launch starts Local so the app never tries to reach out to a host on open.
+ @Published var selectedHostTarget: String?
+ // "Add remote host…" prompt state.
+ @Published var showAddHost = false
+ @Published var newHostInput = ""
+ // Password prompt state — non-nil target shows the secure prompt (key auth was refused).
+ @Published var passwordPromptTarget: String?
+ @Published var passwordInput = ""
+ // Single-select sidebar filter (native macOS list selection). Reconciles the skill
+ // selection on change so the detail pane never shows a filtered-out skill.
+ @Published var sidebarFilter: SidebarFilter = .library(.all) { didSet { reconcileSelection() } }
+
+ // Data — skills
+ @Published var skills: [Skill] = []
+ @Published var selection: Skill.ID?
+ // Data — MCP servers (a parallel axis, kept honest rather than forced through skill state)
+ @Published var mcpServers: [McpServer] = []
+ @Published var mcpSelection: McpServer.ID?
+ @Published var mcpIssues: [McpConfigIssue] = []
+ @Published var searchText = "" { didSet { reconcileSelection() } }
+ @Published var isLoading = false
+ @Published var cliAvailable = false
+ @Published var gitAvailable = false
+
+ // Mutation surface
+ @Published var actionStatus: ActionStatus = .idle // drives spinners / inline confirmations
+ @Published var lastError: String? // alert binding (non-nil ⇒ show alert)
+
+ @Published var pendingSelectName: String? // name to select once it appears post-reload
+ @Published var pendingMcpSelectName: String? // MCP equivalent (re-select after a write)
+
+ private var watcher: FileWatcher?
+ private let projectsKey = "recentProjects" // key kept for continuity with existing data
+ private let activeProjectKey = "activeProject"
+ private let scopeKey = "scopeMode"
+ private let hostsKey = "remoteHosts"
+ /// Bumped on every reload; a detached scan only applies if it's still the latest.
+ private var reloadGeneration = 0
+ /// Scan-cache key: a scope is only unique WITHIN a host, so remote and local scans of
+ /// the "same" scope don't collide. (Slice 1 makes `currentHost` non-local.)
+ private struct ScanKey: Hashable { let host: Host; let scope: ResourceScope }
+ /// Last-known scan per (host, scope). Seeded into `skills` instantly on a switch, then a
+ /// fresh scan always follows and overwrites — a pure render optimization, not a staleness risk.
+ private var scanCache: [ScanKey: [Skill]] = [:]
+ /// Which key `skills` currently reflects — drives the seed-on-switch path.
+ private var skillsScope: ScanKey?
+ /// MCP equivalents of the above (same seed-on-switch / generation-guard discipline).
+ private var mcpScanCache: [ScanKey: McpScanResult] = [:]
+ private var mcpScope: ScanKey?
+ /// Skill dirs the latest scan looked at; what the FileWatcher should watch. Captured
+ /// from the scan (incl. nested ancestor/descendant bases) so nested edits fire reloads.
+ private var watchPaths: [String] = []
+
+ init() {
+ savedProjects = (UserDefaults.standard.array(forKey: projectsKey) as? [String])?
+ .map { URL(fileURLWithPath: $0) } ?? []
+ savedHosts = (UserDefaults.standard.array(forKey: hostsKey) as? [String]) ?? []
+ // Remember the last active project so switching to the Project tab is instant
+ // (only restore it if it's still a saved project).
+ if let path = UserDefaults.standard.string(forKey: activeProjectKey),
+ savedProjects.contains(where: { $0.path == path }) {
+ selectedProject = URL(fileURLWithPath: path)
+ } else {
+ selectedProject = savedProjects.first
+ }
+ // Resume the last scope — but never strand in Project scope with no project.
+ if let raw = UserDefaults.standard.string(forKey: scopeKey),
+ let saved = ScopeMode(rawValue: raw) {
+ scopeMode = saved
+ }
+ if scopeMode == .project && selectedProject == nil { scopeMode = .global }
+ watcher = FileWatcher { [weak self] in self?.reload() }
+ }
+
+ // MARK: - Derived
+
+ var driftCount: Int { skills.filter { !$0.driftMissing.isEmpty }.count }
+ var divergedCount: Int { skills.filter { $0.diverged }.count }
+
+ // MCP-side counts (mirror the skill counts; used by the kind-aware sidebar).
+ var mcpDivergedCount: Int { mcpServers.filter { $0.definitionDiverges }.count }
+ var mcpDriftCount: Int { mcpServers.filter { !$0.supportedButMissing.isEmpty }.count }
+ func mcpCount(for harness: McpHarness) -> Int {
+ mcpServers.filter { $0.presentIn.contains(harness) }.count
+ }
+
+ /// Badge count — must use the SAME predicate as `filteredSkills`' agent clause
+ /// (available OR declared) so the sidebar number matches the produced list.
+ func count(for agent: Agent) -> Int {
+ skills.filter { $0.availableAgents.contains(agent) || $0.declaredAgents.contains(agent) }.count
+ }
+
+ /// UI scope → the `ResourceScope` the CLI wrappers expect.
+ var currentScope: ResourceScope {
+ if scopeMode == .project, let root = selectedProject { return .project(root: root.path) }
+ return .global
+ }
+
+ /// The host the current scope scans. Always `.local` today; Slice 1 makes it real.
+ /// The machine the current scan targets: a saved remote, or local.
+ var currentHost: Host {
+ selectedHostTarget.flatMap(Host.parse) ?? .local
+ }
+
+ /// Remote scopes are read-only and global-only (see D7) — drives UI gating.
+ var isRemote: Bool { currentHost != .local }
+
+ // MARK: - Machine selection
+
+ /// Switch the scan to a remote host (read-only, global). Forces Global scope: remote
+ /// project discovery isn't modelled yet (D7), so we never strand in Project scope.
+ /// Probes connectivity first; key auth is tried, and on refusal the user is prompted
+ /// for a password (held only for the session).
+ func selectHost(_ target: String) {
+ resetFilters()
+ selectedHostTarget = target
+ scopeMode = .global
+ probeAndReload(target)
+ }
+
+ /// Probe `target` off-main; reload on success, prompt for a password on auth refusal,
+ /// or surface a connection error.
+ private func probeAndReload(_ target: String) {
+ isLoading = true
+ lastError = nil
+ Task.detached(priority: .userInitiated) {
+ let result = RemoteHostIO(target: target).connect()
+ await MainActor.run {
+ guard self.selectedHostTarget == target else { return } // user moved on
+ switch result {
+ case .ok:
+ self.reload()
+ case .needsPassword:
+ self.isLoading = false
+ self.passwordInput = ""
+ self.passwordPromptTarget = target
+ case .failed(let msg):
+ self.isLoading = false
+ self.lastError = "Couldn’t connect to \(target): \(msg)"
+ self.selectLocal()
+ }
+ }
+ }
+ }
+
+ /// User submitted a password for the prompted host: keep it for the session and retry.
+ func submitPassword() {
+ guard let target = passwordPromptTarget, !passwordInput.isEmpty else { return }
+ RemoteCredentials.set(passwordInput, for: target)
+ passwordInput = ""
+ passwordPromptTarget = nil
+ probeAndReload(target)
+ }
+
+ /// User cancelled the password prompt: drop back to Local.
+ func cancelPassword() {
+ passwordPromptTarget = nil
+ passwordInput = ""
+ selectLocal()
+ }
+
+ /// Switch back to the local machine.
+ func selectLocal() {
+ guard selectedHostTarget != nil else { return }
+ resetFilters()
+ selectedHostTarget = nil
+ reload()
+ }
+
+ /// Add (and select) a remote host from user input ("user@host" or an ssh alias).
+ func addRemoteHost(_ input: String) {
+ guard let host = Host.parse(input) else { return }
+ let target = host.target
+ if !savedHosts.contains(target) {
+ savedHosts.append(target)
+ UserDefaults.standard.set(savedHosts, forKey: hostsKey)
+ }
+ selectHost(target)
+ }
+
+ /// Forget a saved remote host; clear any session password and fall back to Local.
+ func removeHost(_ target: String) {
+ savedHosts.removeAll { $0 == target }
+ UserDefaults.standard.set(savedHosts, forKey: hostsKey)
+ RemoteCredentials.clear(target)
+ if selectedHostTarget == target { selectLocal() }
+ }
+
+ var sources: [(name: String, count: Int)] {
+ var m: [String: Int] = [:]
+ for s in skills {
+ for g in s.sourceGroups { m[g, default: 0] += 1 }
+ }
+ // CLI sources first, then the "Local · …" folder groups; alphabetical within each.
+ return m.sorted { a, b in
+ let al = a.key.hasPrefix("Local"), bl = b.key.hasPrefix("Local")
+ if al != bl { return !al }
+ return a.key.localizedCaseInsensitiveCompare(b.key) == .orderedAscending
+ }.map { ($0.key, $0.value) }
+ }
+
+ var filteredSkills: [Skill] {
+ var list = skills
+ switch sidebarFilter {
+ case .library(.all): break
+ case .library(.drift): list = list.filter { !$0.driftMissing.isEmpty }
+ case .library(.diverged): list = list.filter { $0.diverged }
+ case .agent(let agent):
+ list = list.filter { $0.availableAgents.contains(agent) || $0.declaredAgents.contains(agent) }
+ case .source(let src):
+ list = list.filter { $0.sourceGroups.contains(src) }
+ case .mcpHarness: break // MCP-only filter; ignored in skill mode
+ }
+ if !searchText.isEmpty {
+ let q = searchText.lowercased()
+ list = list.filter { $0.searchHaystack.contains(q) }
+ }
+ return list
+ }
+
+ /// Resolve against the VISIBLE list so the detail pane never shows a filtered-out skill.
+ var selectedSkill: Skill? {
+ guard let selection else { return nil }
+ return filteredSkills.first { $0.id == selection }
+ }
+
+ var filteredMcpServers: [McpServer] {
+ var list = mcpServers
+ switch sidebarFilter {
+ case .library(.all): break
+ case .library(.diverged): list = list.filter { $0.definitionDiverges }
+ case .library(.drift): list = list.filter { !$0.supportedButMissing.isEmpty }
+ case .mcpHarness(let h): list = list.filter { $0.presentIn.contains(h) }
+ case .agent, .source: break // skill-only filters; ignored in MCP mode
+ }
+ if !searchText.isEmpty {
+ let q = searchText.lowercased()
+ list = list.filter {
+ $0.name.lowercased().contains(q) || ($0.summary?.lowercased().contains(q) ?? false)
+ }
+ }
+ return list
+ }
+
+ var selectedMcpServer: McpServer? {
+ guard let mcpSelection else { return nil }
+ return filteredMcpServers.first { $0.id == mcpSelection }
+ }
+
+ // MARK: - Selection / filter coherence
+
+ /// Clear the selection if it's no longer in the visible list (called from filter didSets).
+ /// Reconciles whichever axis is active so the detail pane never shows a filtered-out row.
+ func reconcileSelection() {
+ switch kind {
+ case .skill:
+ if let sel = selection, !filteredSkills.contains(where: { $0.id == sel }) {
+ selection = nil
+ }
+ case .mcp:
+ if let sel = mcpSelection, !filteredMcpServers.contains(where: { $0.id == sel }) {
+ mcpSelection = nil
+ }
+ }
+ }
+
+ /// Reset all filters + selection. Called on scope/project/kind change so carried-over
+ /// agent/source/lib/search state can't silently empty the list. Selections are set last
+ /// so the net result is always `selection == nil` regardless of didSet ordering.
+ func resetFilters() {
+ sidebarFilter = .library(.all)
+ searchText = ""
+ selection = nil
+ mcpSelection = nil
+ }
+
+ /// Switching the kind switcher resets filters and loads the new axis. A `.source` filter
+ /// (skill-only) can't survive into MCP mode, and `resetFilters` already clears it.
+ private func switchedKind() {
+ resetFilters()
+ // A pending post-mutation selection belongs to the axis it was set on; switching axes
+ // strands it (each reloader only reads its own), so clear both to avoid a stale re-select.
+ pendingSelectName = nil
+ pendingMcpSelectName = nil
+ reload()
+ }
+
+ // MARK: - Project selection
+
+ /// Enter Project scope WITHOUT nagging: reuse the active project, else the first saved
+ /// one, and only pop the picker on a genuine first run (no projects at all).
+ func enterProjectScope() {
+ if selectedProject != nil {
+ reload()
+ } else if let first = savedProjects.first {
+ setProject(first)
+ } else {
+ chooseProject()
+ }
+ }
+
+ /// Open the folder picker to add/switch a project. Only the explicit "Add project…"
+ /// action and first-run should call this — never a bare tab toggle.
+ func chooseProject() {
+ // Present on the NEXT runloop tick. Running a modal panel synchronously from inside
+ // a SwiftUI view update can silently no-op; deferring escapes the update cycle and
+ // activating brings the panel to the front.
+ Task { @MainActor in
+ NSApp.activate(ignoringOtherApps: true)
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = true
+ panel.canChooseFiles = false
+ panel.allowsMultipleSelection = false
+ panel.prompt = "Choose Project"
+ panel.message = "Choose a project directory to scan its skills."
+ if panel.runModal() == .OK, let url = panel.url {
+ self.setProject(url)
+ } else if self.selectedProject == nil {
+ // Cancelled with nothing to show: activate a saved project, else go Global.
+ if let first = self.savedProjects.first {
+ self.setProject(first)
+ } else {
+ self.scopeMode = .global
+ self.reload()
+ }
+ }
+ // Cancelled but a project is already active → just keep showing it.
+ }
+ }
+
+ /// Switch to `url`. New projects are saved (persist indefinitely); switching to an
+ /// already-saved one keeps the list order stable so positions don't jump around.
+ func setProject(_ url: URL) {
+ resetFilters()
+ selectedProject = url
+ scopeMode = .project
+ if !savedProjects.contains(where: { $0.path == url.path }) {
+ savedProjects.insert(url, at: 0)
+ persistProjects()
+ }
+ persistActiveProject()
+ reload()
+ }
+
+ /// Forget a saved project. If it was the current one, fall back to another saved
+ /// project, or to Global if none remain.
+ func removeProject(_ url: URL) {
+ savedProjects.removeAll { $0.path == url.path }
+ persistProjects()
+ if selectedProject?.path == url.path {
+ selectedProject = savedProjects.first
+ persistActiveProject()
+ if selectedProject == nil { scopeMode = .global }
+ reload()
+ }
+ }
+
+ private func persistProjects() {
+ UserDefaults.standard.set(savedProjects.map(\.path), forKey: projectsKey)
+ }
+
+ private func persistActiveProject() {
+ UserDefaults.standard.set(selectedProject?.path, forKey: activeProjectKey)
+ }
+
+ private func persistScope() {
+ UserDefaults.standard.set(scopeMode.rawValue, forKey: scopeKey)
+ }
+
+ // MARK: - Loading
+
+ func reload() {
+ switch kind {
+ case .skill: reloadSkills()
+ case .mcp: reloadMcp()
+ }
+ }
+
+ private func reloadSkills() {
+ let key = ScanKey(host: currentHost, scope: currentScope)
+ // On a scope/project SWITCH, paint the target scope's last-known list immediately
+ // (instant feel), or clear the previous scope's list so a stale wrong-project list
+ // never lingers. The background scan below always refreshes it.
+ if skillsScope != key {
+ skills = scanCache[key] ?? []
+ skillsScope = key
+ reconcileSelection()
+ }
+ isLoading = true
+ reloadGeneration &+= 1
+ let gen = reloadGeneration
+ let mode = scopeMode
+ let project = selectedProject
+ let host = currentHost
+ Task.detached(priority: .userInitiated) {
+ let scanned: [Skill]
+ switch mode {
+ case .global: scanned = SkillScanner.scanGlobal(host: host)
+ case .project: scanned = project.map { SkillScanner.scanProject(root: $0, host: host) } ?? []
+ }
+ // Always watch the canonical store; in project scope also watch every skill dir
+ // the scan touched (incl. nested bases) so nested edits trigger a reload.
+ // Watcher paths are local-only (FSEvents), so resolve against LocalHostIO.
+ var built = Agent.allCases.flatMap { $0.globalSkillDirs(LocalHostIO()).map(\.path) }
+ if mode == .project, let project {
+ built += SkillScanner.projectSkillDirPaths(from: project, io: LocalHostIO())
+ }
+ let watch = built
+ let cli = SkillsCLIService.isAvailable(host.makeIO())
+ let git = GitStatusService.isAvailable(host.makeIO())
+ await MainActor.run {
+ // Drop a scan that a newer reload (e.g. a scope switch) has superseded,
+ // so a slow global scan can't overwrite the current project's list.
+ guard gen == self.reloadGeneration else { return }
+ self.skills = scanned
+ self.scanCache[key] = scanned
+ self.skillsScope = key
+ self.watchPaths = watch
+ self.cliAvailable = cli
+ self.gitAvailable = git
+ self.isLoading = false
+ // Resolve a pending post-mutation selection by NAME (first canonical match).
+ if let nm = self.pendingSelectName {
+ self.selection = scanned.first { $0.name == nm }?.id
+ self.pendingSelectName = nil
+ }
+ if let sel = self.selection, !scanned.contains(where: { $0.id == sel }) {
+ self.selection = nil
+ }
+ // If a source filter's source vanished from the scan, fall back to All.
+ if case .source(let src) = self.sidebarFilter,
+ !self.sources.contains(where: { $0.name == src }) {
+ self.sidebarFilter = .library(.all)
+ }
+ self.reconcileSelection()
+ self.updateWatcher()
+ }
+ }
+ }
+
+ private func reloadMcp() {
+ // MCP scanning is local-only in Slice 1 (McpScanner isn't host-threaded, D7) — never
+ // run it against a remote selection, which would silently show LOCAL servers.
+ if isRemote {
+ mcpServers = []; mcpIssues = []; mcpSelection = nil; isLoading = false
+ watcher?.stop()
+ return
+ }
+ let key = ScanKey(host: currentHost, scope: currentScope)
+ if mcpScope != key {
+ let cached = mcpScanCache[key]
+ mcpServers = cached?.servers ?? []
+ mcpIssues = cached?.issues ?? []
+ mcpScope = key
+ reconcileSelection()
+ }
+ isLoading = true
+ reloadGeneration &+= 1
+ let gen = reloadGeneration
+ let mode = scopeMode
+ let project = selectedProject
+ Task.detached(priority: .userInitiated) {
+ let result: McpScanResult
+ switch mode {
+ case .global: result = McpScanner.scanGlobal()
+ case .project: result = project.map { McpScanner.scanProject(root: $0) } ?? .empty
+ }
+ let watch = McpScanner.configWatchPaths(
+ global: mode == .global, root: mode == .project ? project : nil)
+ let git = GitStatusService.isAvailable(LocalHostIO())
+ await MainActor.run {
+ guard gen == self.reloadGeneration else { return }
+ self.mcpServers = result.servers
+ self.mcpIssues = result.issues
+ self.mcpScanCache[key] = result
+ self.mcpScope = key
+ self.watchPaths = watch
+ self.gitAvailable = git
+ self.isLoading = false
+ if let nm = self.pendingMcpSelectName {
+ self.mcpSelection = result.servers.first { $0.name == nm }?.id
+ self.pendingMcpSelectName = nil
+ }
+ if let sel = self.mcpSelection, !result.servers.contains(where: { $0.id == sel }) {
+ self.mcpSelection = nil
+ }
+ self.reconcileSelection()
+ self.updateWatcher()
+ }
+ }
+ }
+
+ private func updateWatcher() {
+ // FSEvents is local-only (no SSH analog, D7): remote scopes refresh via the manual
+ // Refresh button, not a live watcher.
+ guard !isRemote else { watcher?.stop(); return }
+ // Watch exactly the dirs the latest scan looked at (canonical store + every project
+ // skill dir incl. nested bases), captured in `watchPaths` so a symlinked skill's
+ // real files and nested-package skills both fire reloads.
+ watcher?.start(paths: watchPaths)
+ }
+
+ // MARK: - Mutations
+
+ /// Centralizes the off-main CLI run + status reporting. `work` runs detached; status
+ /// and reload land back on the main actor. `onSuccessSelect` re-selects a skill by
+ /// name once it (re)appears after the reload.
+ private func perform(_ id: ActionID, _ label: String, onSuccessSelect: String? = nil, _ work: @escaping () -> CLIResult) {
+ guard !isRemote else { return } // remote scopes are read-only (D7)
+ actionStatus = .running(id, label)
+ lastError = nil
+ Task.detached(priority: .userInitiated) {
+ let r = work()
+ await MainActor.run {
+ if r.ok {
+ self.actionStatus = .success(id, label)
+ if let sel = onSuccessSelect { self.pendingSelectName = sel }
+ } else {
+ self.actionStatus = .failure(id, r.message)
+ self.lastError = r.message
+ }
+ self.reload()
+ }
+ }
+ }
+
+ /// INSTALL a package or specific skill from a source ref into chosen agents (CLI only).
+ func install(ref: String, skill: String? = nil, agents: [Agent] = [], copy: Bool = false) {
+ let scope = currentScope
+ let selectHint = skill ?? Self.lastPathComponent(of: ref)
+ perform(.install, "Installing", onSuccessSelect: selectHint) {
+ SkillsCLIService.add(ref: ref, skill: skill, agents: agents, scope: scope, copy: copy, io: LocalHostIO())
+ }
+ }
+
+ /// UPDATE an installed skill to the latest version of its source.
+ func updateSkill(_ skill: Skill) {
+ let scope = skill.scope
+ let name = skill.name
+ perform(.update(skill.id), "Updating \(name)") { SkillsCLIService.update(name: name, scope: scope, io: LocalHostIO()) }
+ }
+
+ /// REMOVE a skill fully, or unwire it from specific agents when `agents` is non-empty.
+ func removeSkill(_ skill: Skill, agents: [Agent] = []) {
+ let scope = skill.scope
+ let name = skill.name
+ let label = agents.isEmpty ? "Removing \(name)" : "Unwiring \(name)"
+ perform(.remove(skill.id), label) { SkillsCLIService.remove(name: name, agents: agents, scope: scope, io: LocalHostIO()) }
+ }
+
+ /// "owner/repo" → "repo" select hint for post-install reselection.
+ private static func lastPathComponent(of ref: String) -> String {
+ ref.split(separator: "/").last.map(String.init) ?? ref
+ }
+
+ /// Wire a skill into an agent by creating the missing symlink to its canonical dir,
+ /// mirroring `npx skills`: write the canonical files to `.agents/skills`, then a
+ /// RELATIVE symlink (e.g. `../../.agents/skills/`) in the agent's own dir.
+ /// Only non-universal agents (Claude Code) ever need this; reversible.
+ ///
+ /// Uses the raw relative-symlink path (offline, correct for the single Claude-Code-
+ /// missing drift case) rather than the CLI — `skills add ` is built for
+ /// remote refs and may re-clone an already-canonical skill.
+ func wire(_ skill: Skill, into agent: Agent) {
+ guard !isRemote else { return } // remote scopes are read-only (D7)
+ let id = ActionID.wire(skill.id, agent)
+ actionStatus = .running(id, "Wiring \(agent.displayName)")
+ lastError = nil
+ Task.detached(priority: .userInitiated) {
+ let r = Self.rawSymlinkWire(skill, into: agent)
+ await MainActor.run {
+ if r.ok { self.actionStatus = .success(id, "Wired \(agent.displayName)") }
+ else { self.actionStatus = .failure(id, r.message); self.lastError = r.message }
+ self.reload()
+ }
+ }
+ }
+
+ /// Batch-wire every skill that has missing-agent drift.
+ func fixAllDrift() {
+ guard !isRemote else { return } // remote scopes are read-only (D7)
+ let targets = skills.filter { !$0.driftMissing.isEmpty }
+ guard !targets.isEmpty else { return }
+ actionStatus = .running(.fixAllDrift, "Fixing drift")
+ lastError = nil
+ Task.detached(priority: .userInitiated) {
+ var failures = 0
+ var done = 0
+ for s in targets {
+ for agent in s.driftMissing {
+ let r = Self.rawSymlinkWire(s, into: agent)
+ if !r.ok { failures += 1 }
+ }
+ done += 1
+ let progress = done, total = targets.count
+ await MainActor.run { self.actionStatus = .running(.fixAllDrift, "Fixed \(progress) of \(total)") }
+ }
+ let failureCount = failures, total = targets.count
+ await MainActor.run {
+ self.actionStatus = failureCount == 0
+ ? .success(.fixAllDrift, "Fixed drift on \(total) skills")
+ : .failure(.fixAllDrift, "\(failureCount) failed")
+ if failureCount > 0 { self.lastError = "\(failureCount) skill(s) failed to wire." }
+ self.reload()
+ }
+ }
+ }
+
+ /// Create the relative agent-dir symlink for a skill. Returns a CLIResult-shaped
+ /// outcome (do/catch, NOT try?) so any FileManager failure surfaces to the UI.
+ /// `nonisolated` — touches only FileManager/strings, and is called off the main
+ /// actor from the detached tasks in wire()/fixAllDrift().
+ nonisolated static func rawSymlinkWire(_ skill: Skill, into agent: Agent) -> CLIResult {
+ let dir: URL
+ if skill.scope.isGlobal {
+ guard let g = agent.globalSkillDirs(LocalHostIO()).first else {
+ return CLIResult(exitCode: 1, stdout: "", stderr: "No global skills dir for \(agent.displayName).")
+ }
+ dir = g
+ } else if let root = skill.scope.projectRoot, let rel = agent.projectSkillDirs.first {
+ dir = URL(fileURLWithPath: root).appendingPathComponent(rel)
+ } else {
+ return CLIResult(exitCode: 1, stdout: "", stderr: "No skills dir for \(agent.displayName).")
+ }
+
+ let fm = FileManager.default
+ let link = dir.appendingPathComponent(skill.name)
+ let relTarget = relativePath(from: dir.path, to: skill.canonicalPath)
+ do {
+ try fm.createDirectory(at: dir, withIntermediateDirectories: true)
+ // Only clear an existing SYMLINK (stale/broken) — never recursively delete a
+ // real file or directory that happens to sit at the link path. lstat semantics:
+ // attributesOfItem does not follow the link, so a symlink reports .typeSymbolicLink.
+ if let attrs = try? fm.attributesOfItem(atPath: link.path) {
+ if (attrs[.type] as? FileAttributeType) == .typeSymbolicLink {
+ try fm.removeItem(at: link)
+ } else {
+ return CLIResult(exitCode: 1, stdout: "",
+ stderr: "A real file already exists at \(link.path); refusing to overwrite it.")
+ }
+ }
+ try fm.createSymbolicLink(atPath: link.path, withDestinationPath: relTarget)
+ return CLIResult(exitCode: 0, stdout: "Wired \(skill.name) → \(agent.displayName)", stderr: "")
+ } catch {
+ return CLIResult(exitCode: 1, stdout: "", stderr: error.localizedDescription)
+ }
+ }
+
+ /// Relative path from a directory to a target (so symlinks stay portable, like the CLI's).
+ nonisolated private static func relativePath(from base: String, to target: String) -> String {
+ let b = base.split(separator: "/").map(String.init)
+ let t = target.split(separator: "/").map(String.init)
+ var i = 0
+ while i < b.count, i < t.count, b[i] == t[i] { i += 1 }
+ let up = Array(repeating: "..", count: b.count - i)
+ return (up + t[i...]).joined(separator: "/")
+ }
+
+ // MARK: - MCP mutations
+
+ /// Add a new server into the chosen harnesses' configs at the current scope (root base).
+ func addMcpServer(name: String, def: PortableMcpDefinition, targets: [McpHarness]) {
+ let scope = currentScope
+ let jobs: [(McpWriteEngine.Op, McpConfigLocation)] = targets.compactMap { h in
+ guard let loc = mcpLocation(h, scope: scope, logicalLocation: "") else { return nil }
+ return (.upsert(name: name, def: def, enabled: true), loc)
+ }
+ runMcpWrites(.mcpAdd, "Adding \(name)", jobs, selectName: name)
+ }
+
+ /// Re-apply an edited definition into every harness the server already lives in,
+ /// preserving each harness's current enabled state (and its agent-local fields).
+ func editMcpServer(_ server: McpServer, def: PortableMcpDefinition) {
+ var jobs: [(McpWriteEngine.Op, McpConfigLocation)] = []
+ for h in server.presentIn {
+ guard let loc = server.origins[h]?.first(where: { $0.isPrimary }) ?? server.origins[h]?.first
+ else { continue }
+ let enabled = server.entries[h]?.enabled ?? true
+ jobs.append((.upsert(name: server.name, def: def, enabled: enabled), loc))
+ }
+ runMcpWrites(.mcpEdit(server.id), "Saving \(server.name)", jobs, selectName: server.name)
+ }
+
+ /// Copy the server into every harness that supports its transport but doesn't have it.
+ func applyToSupported(_ server: McpServer) {
+ guard let def = server.representativePortable else { return }
+ let jobs: [(McpWriteEngine.Op, McpConfigLocation)] = server.supportedButMissing.compactMap { h in
+ guard let loc = mcpLocation(h, scope: server.scope, logicalLocation: server.logicalLocation)
+ else { return nil }
+ return (.upsert(name: server.name, def: def, enabled: true), loc)
+ }
+ runMcpWrites(.mcpApply(server.id), "Applying \(server.name)", jobs, selectName: server.name)
+ }
+
+ /// Remove the server from the given harnesses (all of their origin files), or from every
+ /// harness it lives in when `harnesses` is empty.
+ func removeMcpServer(_ server: McpServer, from harnesses: [McpHarness] = []) {
+ let targets = harnesses.isEmpty ? Array(server.presentIn) : harnesses
+ var jobs: [(McpWriteEngine.Op, McpConfigLocation)] = []
+ for h in targets {
+ for loc in server.origins[h] ?? [] { jobs.append((.remove(name: server.name), loc)) }
+ }
+ let keepsSome = !Set(targets).isSuperset(of: server.presentIn)
+ runMcpWrites(.mcpRemove(server.id), "Removing \(server.name)", jobs,
+ selectName: keepsSome ? server.name : nil)
+ }
+
+ /// Enable / disable the server in a single harness (only opencode & Codex can express this).
+ func setMcpEnabled(_ server: McpServer, harness: McpHarness, enabled: Bool) {
+ guard let def = server.entries[harness]?.portable,
+ let loc = server.origins[harness]?.first(where: { $0.isPrimary }) ?? server.origins[harness]?.first
+ else { return }
+ runMcpWrites(.mcpToggle(server.id, harness), enabled ? "Enabling" : "Disabling",
+ [(.upsert(name: server.name, def: def, enabled: enabled), loc)],
+ selectName: server.name)
+ }
+
+ /// Resolve the primary config location for a harness at a scope + project subpackage.
+ private func mcpLocation(_ h: McpHarness, scope: ResourceScope, logicalLocation: String) -> McpConfigLocation? {
+ switch scope {
+ case .global:
+ return McpConfigDescriptor.globalLocations(h).first
+ case .project(let root):
+ var base = URL(fileURLWithPath: root)
+ if !logicalLocation.isEmpty && !logicalLocation.hasPrefix("↑") {
+ base = base.appendingPathComponent(logicalLocation)
+ }
+ return McpConfigDescriptor.projectLocations(h, base: base).first
+ }
+ }
+
+ /// Run a batch of config writes off the main actor, aggregating failures, then reload.
+ /// Mirrors `perform` but for the MCP write engine (FileManager writes, not the CLI).
+ private func runMcpWrites(_ id: ActionID, _ label: String,
+ _ jobs: [(McpWriteEngine.Op, McpConfigLocation)],
+ selectName: String?) {
+ guard !isRemote else { return } // remote scopes are read-only (D7)
+ guard !jobs.isEmpty else { return }
+ actionStatus = .running(id, label)
+ lastError = nil
+ Task.detached(priority: .userInitiated) {
+ var collected: [String] = []
+ for (op, loc) in jobs {
+ do { try McpWriteEngine.apply(op, at: loc) }
+ catch { collected.append("\(loc.harness.displayName): \(error)") }
+ }
+ let failures = collected
+ await MainActor.run {
+ if failures.isEmpty {
+ self.actionStatus = .success(id, label)
+ if let nm = selectName { self.pendingMcpSelectName = nm }
+ } else {
+ let msg = failures.joined(separator: "\n")
+ self.actionStatus = .failure(id, msg)
+ self.lastError = msg
+ }
+ self.reload()
+ }
+ }
+ }
+
+ func openInEditor(_ skill: Skill) {
+ // Remote skills' URLs are remote paths, not local files — opening them would point
+ // at the wrong place (and "edit" is a write affordance, out of scope for remote).
+ guard skill.host == .local else { return }
+ NSWorkspace.shared.open(skill.skillMdURL)
+ }
+
+ /// Open a bundled file in its default app, or reveal a packaged subdirectory in Finder.
+ func openBundledFile(_ file: BundledFile) {
+ guard !isRemote else { return }
+ if file.isDirectory {
+ NSWorkspace.shared.activateFileViewerSelecting([file.url])
+ } else {
+ NSWorkspace.shared.open(file.url)
+ }
+ }
+}
diff --git a/loadout/apps/macOS/Sources/App/ContentView.swift b/loadout/apps/macOS/Sources/App/ContentView.swift
new file mode 100644
index 0000000..8aa79d3
--- /dev/null
+++ b/loadout/apps/macOS/Sources/App/ContentView.swift
@@ -0,0 +1,93 @@
+import SwiftUI
+
+struct ContentView: View {
+ @EnvironmentObject var state: AppState
+
+ var body: some View {
+ NavigationSplitView {
+ SidebarView()
+ .navigationSplitViewColumnWidth(min: 210, ideal: 232)
+ } content: {
+ SkillListView()
+ .navigationSplitViewColumnWidth(min: 300, ideal: 350)
+ } detail: {
+ switch state.kind {
+ case .skill:
+ if let skill = state.selectedSkill {
+ SkillDetailView(skill: skill)
+ } else {
+ ContentUnavailableView(
+ "Select a skill",
+ systemImage: "sparkles",
+ description: Text("\(state.skills.count) \(state.scopeMode.label.lowercased()) skills across your agents")
+ )
+ }
+ case .mcp:
+ if let server = state.selectedMcpServer {
+ McpDetailView(server: server)
+ } else {
+ ContentUnavailableView(
+ "Select an MCP server",
+ systemImage: "puzzlepiece.extension",
+ description: Text("\(state.mcpServers.count) \(state.scopeMode.label.lowercased()) MCP servers across your harnesses")
+ )
+ }
+ }
+ }
+ .safeAreaInset(edge: .bottom, spacing: 0) { StatusBar() }
+ .task { if state.skills.isEmpty { state.reload() } }
+ // App-wide surfacing of any mutation failure (no longer swallowed by try?).
+ .alert("Action failed",
+ isPresented: Binding(get: { state.lastError != nil },
+ set: { if !$0 { state.lastError = nil } })) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(state.lastError ?? "")
+ }
+ }
+}
+
+struct StatusBar: View {
+ @EnvironmentObject var state: AppState
+
+ var body: some View {
+ HStack(spacing: 10) {
+ switch state.kind {
+ case .skill:
+ Text("\(state.skills.count) skills")
+ dot
+ Text("\(state.driftCount) drift")
+ .foregroundStyle(state.driftCount > 0 ? Theme.drift : .secondary)
+ case .mcp:
+ Text("\(state.mcpServers.count) servers")
+ dot
+ Text("\(state.mcpDivergedCount) diverged")
+ .foregroundStyle(state.mcpDivergedCount > 0 ? Theme.drift : .secondary)
+ if !state.mcpIssues.isEmpty {
+ dot
+ Label("\(state.mcpIssues.count) unreadable",
+ systemImage: "exclamationmark.triangle")
+ .foregroundStyle(Theme.drift)
+ }
+ }
+ dot
+ Text(state.scopeMode == .global ? "Global" : (state.selectedProject?.lastPathComponent ?? "Project"))
+ Spacer()
+ if !state.cliAvailable {
+ Label("no skills CLI", systemImage: "exclamationmark.triangle")
+ .foregroundStyle(Theme.drift)
+ }
+ Label(state.gitAvailable ? "watching" : "git off",
+ systemImage: "dot.radiowaves.left.and.right")
+ .foregroundStyle(state.gitAvailable ? Color(hex: 0x2BA160) : .secondary)
+ }
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(.bar)
+ .overlay(alignment: .top) { Divider() }
+ }
+
+ private var dot: some View { Text("·").foregroundStyle(.tertiary) }
+}
diff --git a/loadout/apps/macOS/Sources/App/LoadoutApp.swift b/loadout/apps/macOS/Sources/App/LoadoutApp.swift
new file mode 100644
index 0000000..e2426aa
--- /dev/null
+++ b/loadout/apps/macOS/Sources/App/LoadoutApp.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+@main
+struct LoadoutApp: App {
+ @StateObject private var state = AppState()
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .environmentObject(state)
+ .frame(minWidth: 1000, minHeight: 600)
+ }
+ .commands {
+ CommandGroup(after: .toolbar) {
+ Button("Refresh Skills") { state.reload() }
+ .keyboardShortcut("r", modifiers: .command)
+ }
+ }
+ }
+}
diff --git a/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..6282ea8
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ { "idiom" : "mac", "scale" : "1x", "size" : "16x16", "filename" : "icon_16.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "16x16", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "32x32", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "32x32", "filename" : "icon_64.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "128x128", "filename" : "icon_128.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "128x128", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "256x256", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "256x256", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "scale" : "1x", "size" : "512x512", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "scale" : "2x", "size" : "512x512", "filename" : "icon_1024.png" }
+ ],
+ "info" : { "author" : "xcode", "version" : 1 }
+}
diff --git a/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png
new file mode 100644
index 0000000..fd5ee56
Binary files /dev/null and b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ
diff --git a/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png
new file mode 100644
index 0000000..b19e151
Binary files /dev/null and b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png differ
diff --git a/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png
new file mode 100644
index 0000000..4548a97
Binary files /dev/null and b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png differ
diff --git a/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png
new file mode 100644
index 0000000..ca3d8c9
Binary files /dev/null and b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png differ
diff --git a/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png
new file mode 100644
index 0000000..c0823f4
Binary files /dev/null and b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png differ
diff --git a/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png
new file mode 100644
index 0000000..99aed6e
Binary files /dev/null and b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png differ
diff --git a/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png
new file mode 100644
index 0000000..8213986
Binary files /dev/null and b/loadout/apps/macOS/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png differ
diff --git a/loadout/apps/macOS/Sources/Assets.xcassets/Contents.json b/loadout/apps/macOS/Sources/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..d8b757a
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Assets.xcassets/Contents.json
@@ -0,0 +1,3 @@
+{
+ "info" : { "author" : "xcode", "version" : 1 }
+}
diff --git a/loadout/apps/macOS/Sources/Models/Agent.swift b/loadout/apps/macOS/Sources/Models/Agent.swift
new file mode 100644
index 0000000..40c82bb
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Models/Agent.swift
@@ -0,0 +1,91 @@
+import SwiftUI
+
+/// The coding agents Loadout understands, plus the skills.sh canonical store.
+/// Paths are verified ground truth (see RESEARCH.md). `agents` is the
+/// `~/.agents/skills` canonical store that skills.sh symlinks every agent back into.
+enum Agent: String, CaseIterable, Identifiable, Hashable {
+ case claude
+ case opencode
+ case codex
+ case pi
+ case agents // the ~/.agents/skills canonical store
+
+ var id: String { rawValue }
+
+ /// Agents worth their own sidebar filter row. OpenCode, Codex and Pi read the canonical
+ /// store natively, so their counts always equal Canonical's — listing them is redundant.
+ /// Only Claude Code (needs its own symlink) and the Canonical store are distinct.
+ static let sidebarAgents: [Agent] = [.claude, .agents]
+
+ var displayName: String {
+ switch self {
+ case .claude: return "Claude Code"
+ case .opencode: return "OpenCode"
+ case .codex: return "Codex"
+ case .pi: return "Pi"
+ case .agents: return "Canonical"
+ }
+ }
+
+ /// Agent colors — the only chroma in the UI (hex tokens from the design language).
+ var color: Color {
+ switch self {
+ case .claude: return Color(hex: 0xD97757)
+ case .opencode: return Color(hex: 0xE5484D)
+ case .codex: return Color(hex: 0x2BA160)
+ case .pi: return Color(hex: 0x0BA5C7)
+ case .agents: return Color(hex: 0x9AA0A6)
+ }
+ }
+
+ /// Whether the agent reads the `.agents/skills` canonical store directly (no own
+ /// symlink needed). Mirrors the `skills` CLI's `isUniversalAgent` rule — an agent is
+ /// "universal" when its `skillsDir === ".agents/skills"`, in which case the CLI routes
+ /// it straight to the canonical dir at BOTH global and project scope:
+ /// - OpenCode, Codex → universal (skillsDir = ".agents/skills") → read canonical.
+ /// - Pi → not universal in the CLI, but its own docs list `.agents/skills` as a
+ /// discovery dir, so it reaches canonical skills too.
+ /// - Claude Code → NOT universal (skillsDir = ".claude/skills") → needs a symlink.
+ /// Only Claude Code shows drift when a skill is in the canonical store but unwired.
+ var readsCanonicalNatively: Bool {
+ switch self {
+ case .opencode, .codex, .pi, .agents: return true
+ case .claude: return false
+ }
+ }
+
+ /// Map a skills-CLI `agents[]` display string to an Agent (declared intent).
+ static func from(cliDisplayName name: String) -> Agent? {
+ switch name {
+ case "Claude Code": return .claude
+ case "OpenCode": return .opencode
+ case "Codex": return .codex
+ case "Pi": return .pi
+ default: return nil // Zed, OpenClaw, etc. are out of scope
+ }
+ }
+
+ /// Global/user-level skill discovery directories, anchored at the host's home /
+ /// XDG config dir (so a remote host resolves them remotely).
+ func globalSkillDirs(_ io: HostIO) -> [URL] {
+ let home = io.home
+ switch self {
+ case .claude: return [home.appendingPathComponent(".claude/skills")]
+ case .opencode: return [io.xdgConfigHome.appendingPathComponent("opencode/skills")]
+ case .codex: return [home.appendingPathComponent(".codex/skills")]
+ case .pi: return [home.appendingPathComponent(".pi/agent/skills")]
+ case .agents: return [home.appendingPathComponent(".agents/skills")]
+ }
+ }
+
+ /// Project-relative skill directories (scanned by walking cwd → git root).
+ var projectSkillDirs: [String] {
+ switch self {
+ case .claude: return [".claude/skills"]
+ case .opencode: return [".opencode/skills"]
+ case .codex: return [".codex/skills"]
+ case .pi: return [".pi/skills"]
+ case .agents: return [".agents/skills"]
+ }
+ }
+}
diff --git a/loadout/apps/macOS/Sources/Models/AgentResource.swift b/loadout/apps/macOS/Sources/Models/AgentResource.swift
new file mode 100644
index 0000000..e5dfc9d
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Models/AgentResource.swift
@@ -0,0 +1,84 @@
+import SwiftUI
+
+/// The kinds of resource Loadout can manage: agent skills (directories) and MCP
+/// servers (entries inside shared config files). See DECISIONS D3.
+enum ResourceKind: String, Hashable, CaseIterable {
+ case skill
+ case mcp
+
+ var displayName: String {
+ switch self {
+ case .skill: return "Skills"
+ case .mcp: return "MCP"
+ }
+ }
+}
+
+/// Scope a resource was discovered in.
+enum ResourceScope: Hashable {
+ case global
+ case project(root: String)
+
+ var isGlobal: Bool { if case .global = self { return true }; return false }
+ var projectRoot: String? { if case .project(let r) = self { return r }; return nil }
+}
+
+/// Where a resource sits relative to git — the "is this committed / shared?" signal.
+/// Reflects the resource's CANONICAL files (see Skill.gitStatus); symlink-wrapper
+/// divergence is tracked separately by `Skill.linksDiverge`.
+enum GitStatus: String, Hashable {
+ case tracked
+ case untracked
+ case ignored
+ case notInRepo
+
+ var label: String {
+ switch self {
+ case .tracked: return "Tracked"
+ case .untracked: return "Untracked"
+ case .ignored: return "Ignored"
+ case .notInRepo: return "Not in a repo"
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .tracked: return "checkmark.seal.fill"
+ case .untracked: return "questionmark.circle"
+ case .ignored: return "eye.slash"
+ case .notInRepo: return "minus.circle"
+ }
+ }
+
+ /// One-line explanation for hover tooltips.
+ var helpText: String {
+ switch self {
+ case .tracked: return "Committed to git — versioned and shared with the repo."
+ case .untracked: return "Inside a git repo but not committed yet."
+ case .ignored: return "Inside a git repo but excluded by .gitignore."
+ case .notInRepo: return "Not inside any git repository (e.g. the global ~/.agents store)."
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .tracked: return Color(hex: 0x2BA160)
+ case .untracked: return Theme.drift
+ case .ignored, .notInRepo: return Color(hex: 0x9AA0A6)
+ }
+ }
+}
+
+/// The minimal shape every managed resource shares — `Skill` and `McpServer`.
+/// Deliberately just identity: anything axis-specific stays on the concrete type.
+/// Skills wire into the `Agent` axis (`wiredAgents`/`declaredAgents`, filesystem
+/// concepts); MCP servers live in the distinct `McpHarness` axis (present/enabled
+/// per harness). Forcing either set of words onto the other would lie about the
+/// model, so the protocol carries neither.
+protocol AgentResource: Identifiable, Hashable {
+ var id: String { get }
+ var name: String { get }
+ var summary: String? { get }
+ var scope: ResourceScope { get }
+ var kind: ResourceKind { get }
+}
diff --git a/loadout/apps/macOS/Sources/Models/McpConfigDescriptor.swift b/loadout/apps/macOS/Sources/Models/McpConfigDescriptor.swift
new file mode 100644
index 0000000..08ab123
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Models/McpConfigDescriptor.swift
@@ -0,0 +1,124 @@
+import Foundation
+
+/// On-disk encoding of a harness's MCP config. JSONC = JSON with comments / trailing
+/// commas tolerated (opencode); TOML for Codex; strict JSON elsewhere.
+enum McpConfigFormat: Hashable { case json, jsonc, toml }
+
+/// opencode's two schema shapes. v1 keeps servers directly under `mcp` and disables with
+/// `enabled:false`; v2 nests them under `mcp.servers` and disables with `disabled:true`.
+/// The codec detects which a file uses; the writer preserves it (and a NEW file gets v1).
+enum OpencodeSchema { case v1, v2 }
+
+/// Normalized transport kinds across dialects. Remote sub-transports are retained for
+/// display, but they collapse to a "remote" family for DEFINITION-divergence comparison —
+/// harnesses infer/spell remote transports differently (Claude `http` vs Codex
+/// `streamable_http` for the same URL), and treating that as divergence would be a
+/// false positive (the spec's central correctness concern).
+enum McpTransport: String, Hashable {
+ case stdio
+ case http
+ case streamableHttp
+ case sse
+ case ws
+
+ var isRemote: Bool { self != .stdio }
+
+ var label: String {
+ switch self {
+ case .stdio: return "stdio"
+ case .http: return "http"
+ case .streamableHttp: return "streamable-http"
+ case .sse: return "sse"
+ case .ws: return "ws"
+ }
+ }
+}
+
+/// A concrete place a harness keeps its server map: a file plus the key path within the
+/// parsed document where the `{ name: definition }` map lives, plus a short UI label and
+/// whether this represents the canonical/primary store for that harness+scope.
+///
+/// `keyPath` examples:
+/// - Claude project `.mcp.json` → `["mcpServers"]`
+/// - Claude user `~/.claude.json` (user) → `["mcpServers"]`
+/// - Claude user `~/.claude.json` (local) → `["projects", "", "mcpServers"]`
+/// - opencode → `["mcp"]` (codec then detects v1 vs v2)
+/// - Codex → `["mcp_servers"]`
+struct McpConfigLocation: Hashable {
+ let harness: McpHarness
+ let url: URL
+ let format: McpConfigFormat
+ let keyPath: [String]
+ let label: String
+ /// Primary store for the harness at this scope (project `.mcp.json` beats the nested
+ /// `~/.claude.json` local map; `opencode.json` beats `opencode.jsonc`). The scanner
+ /// prefers the primary when a harness has more than one origin.
+ let isPrimary: Bool
+}
+
+/// Resolves WHERE each harness keeps its MCP servers for a given scope/base. Reading and
+/// writing both consult this so the two stay in lockstep. Verified per-harness facts from
+/// the task spec — project files at the base, user files under `$HOME`/`$XDG_CONFIG_HOME`.
+enum McpConfigDescriptor {
+ private static var home: URL { FileManager.default.homeDirectoryForCurrentUser }
+
+ private static var xdgConfigHome: URL {
+ if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], !xdg.isEmpty {
+ return URL(fileURLWithPath: xdg)
+ }
+ return home.appendingPathComponent(".config")
+ }
+
+ /// Global / user-scope config locations for a harness.
+ static func globalLocations(_ h: McpHarness) -> [McpConfigLocation] {
+ switch h {
+ case .claudeCode:
+ return [loc(h, home.appendingPathComponent(".claude.json"), .json,
+ ["mcpServers"], "user ~/.claude.json", primary: true)]
+ case .opencode:
+ return [loc(h, xdgConfigHome.appendingPathComponent("opencode/opencode.json"), .jsonc,
+ ["mcp"], "user opencode.json", primary: true)]
+ case .codex:
+ return [loc(h, home.appendingPathComponent(".codex/config.toml"), .toml,
+ ["mcp_servers"], "user config.toml", primary: true)]
+ case .cursor:
+ return [loc(h, home.appendingPathComponent(".cursor/mcp.json"), .json,
+ ["mcpServers"], "user ~/.cursor/mcp.json", primary: true)]
+ }
+ }
+
+ /// Project-scope config locations for a harness within a given base directory. May
+ /// return several origins for one harness (Claude's project `.mcp.json` PLUS the
+ /// local-scope map nested in `~/.claude.json`; opencode's `.json` AND `.jsonc`).
+ static func projectLocations(_ h: McpHarness, base: URL) -> [McpConfigLocation] {
+ let basePath = base.standardizedFileURL.path
+ switch h {
+ case .claudeCode:
+ return [
+ loc(h, base.appendingPathComponent(".mcp.json"), .json,
+ ["mcpServers"], "project .mcp.json", primary: true),
+ loc(h, home.appendingPathComponent(".claude.json"), .json,
+ ["projects", basePath, "mcpServers"], "user ~/.claude.json (local)", primary: false),
+ ]
+ case .opencode:
+ return [
+ loc(h, base.appendingPathComponent("opencode.json"), .jsonc,
+ ["mcp"], "project opencode.json", primary: true),
+ loc(h, base.appendingPathComponent("opencode.jsonc"), .jsonc,
+ ["mcp"], "project opencode.jsonc", primary: false),
+ ]
+ case .codex:
+ return [loc(h, base.appendingPathComponent(".codex/config.toml"), .toml,
+ ["mcp_servers"], "project .codex/config.toml", primary: true)]
+ case .cursor:
+ return [loc(h, base.appendingPathComponent(".cursor/mcp.json"), .json,
+ ["mcpServers"], "project .cursor/mcp.json", primary: true)]
+ }
+ }
+
+ private static func loc(_ h: McpHarness, _ url: URL, _ fmt: McpConfigFormat,
+ _ keyPath: [String], _ label: String, primary: Bool) -> McpConfigLocation {
+ McpConfigLocation(harness: h, url: url.standardizedFileURL, format: fmt,
+ keyPath: keyPath, label: label, isPrimary: primary)
+ }
+}
diff --git a/loadout/apps/macOS/Sources/Models/McpHarness.swift b/loadout/apps/macOS/Sources/Models/McpHarness.swift
new file mode 100644
index 0000000..7e588fa
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Models/McpHarness.swift
@@ -0,0 +1,58 @@
+import SwiftUI
+
+/// The coding harnesses Loadout manages MCP servers for. This is a DISTINCT axis from
+/// `Agent` (the skills axis), and the difference is the whole point: Pi is a skills-only
+/// agent and is absent here; Cursor is an MCP-only target and is absent from `Agent`.
+/// Modelling MCP through `Agent` capability flags couldn't even represent Cursor, so the
+/// two resource kinds keep separate harness lists (see the task spec / DECISIONS D3).
+enum McpHarness: String, CaseIterable, Identifiable, Hashable {
+ case claudeCode
+ case opencode
+ case codex
+ case cursor
+
+ var id: String { rawValue }
+
+ var displayName: String {
+ switch self {
+ case .claudeCode: return "Claude Code"
+ case .opencode: return "OpenCode"
+ case .codex: return "Codex"
+ case .cursor: return "Cursor"
+ }
+ }
+
+ /// Reuse the agent chroma where the harness IS one of the skill agents (so a server
+ /// in Claude Code reads the same orange as a Claude skill); Cursor — which has no
+ /// `Agent` — gets its own violet token, distinct from the four agent colors.
+ var color: Color {
+ switch self {
+ case .claudeCode: return Color(hex: 0xD97757) // == Agent.claude
+ case .opencode: return Color(hex: 0xE5484D) // == Agent.opencode
+ case .codex: return Color(hex: 0x2BA160) // == Agent.codex
+ case .cursor: return Color(hex: 0x6E56CF) // cursor-only: violet
+ }
+ }
+
+ /// Normalized transports this harness can actually express. Used to mark a server
+ /// "unsupported by this harness" (a real cross-harness state, NOT "missing"): e.g. an
+ /// SSE server can't live in Codex (stdio + streamable-HTTP only), so Codex shows it as
+ /// locked-unsupported rather than offering to add it.
+ var transportSupport: Set {
+ switch self {
+ case .claudeCode: return [.stdio, .http, .streamableHttp, .sse, .ws]
+ case .codex: return [.stdio, .streamableHttp] // NO sse, NO ws, NO ws
+ case .opencode: return [.stdio, .http, .streamableHttp, .sse]
+ case .cursor: return [.stdio, .http, .streamableHttp, .sse]
+ }
+ }
+
+ /// The on-disk encoding of this harness's config file.
+ var configFormat: McpConfigFormat {
+ switch self {
+ case .claudeCode, .cursor: return .json
+ case .opencode: return .jsonc
+ case .codex: return .toml
+ }
+ }
+}
diff --git a/loadout/apps/macOS/Sources/Models/McpServer.swift b/loadout/apps/macOS/Sources/Models/McpServer.swift
new file mode 100644
index 0000000..9e776a5
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Models/McpServer.swift
@@ -0,0 +1,122 @@
+import SwiftUI
+
+/// How a server stands in one harness — the FOUR cross-harness states (not two). Rendered
+/// distinctly: `unsupported` is never shown as "missing" or "diverged".
+enum McpServerState: Hashable {
+ case enabled // present and on
+ case disabled // present but turned off (opencode `enabled:false`, Codex `enabled=false`)
+ case missing // absent, but this harness COULD host it
+ case unsupported // absent, and this harness can't express its transport
+
+ var label: String {
+ switch self {
+ case .enabled: return "enabled"
+ case .disabled: return "disabled"
+ case .missing: return "missing"
+ case .unsupported: return "unsupported"
+ }
+ }
+
+ var systemImage: String {
+ switch self {
+ case .enabled: return "checkmark.circle.fill"
+ case .disabled: return "pause.circle"
+ case .missing: return "circle.dashed"
+ case .unsupported: return "nosign"
+ }
+ }
+}
+
+/// An MCP server collapsed across every harness that knows about it, within one scope.
+///
+/// Identity is `scope + logicalLocation + serverName`, so the same-named server in two
+/// monorepo subpackages (or an ancestor dir) stays distinct, while the same server defined
+/// in several harnesses' configs at one location collapses into a single row.
+struct McpServer: AgentResource {
+ let name: String
+ var scope: ResourceScope
+ /// Project-relative subpackage the config(s) live in. "" = the chosen project root;
+ /// "↑ " = an ancestor above it; empty for global scope. Part of identity.
+ var logicalLocation: String
+
+ /// Which machine this server was discovered on. McpScanner isn't threaded yet, so this
+ /// stays `.local` everywhere; local renders `idTag == nil` → id unchanged (see D7).
+ var host: Host = .local
+ var id: String { "\(host.idTag.map { $0 + "::" } ?? "")\(scope.projectRoot ?? "global")::\(logicalLocation)::\(name)" }
+ var kind: ResourceKind { .mcp }
+
+ /// Per-harness parsed entry, only for harnesses where the server is actually present.
+ var entries: [McpHarness: AgentMcpEntry]
+ /// Where each present harness's definition was read from (≥1; >1 when origins coexist,
+ /// e.g. Claude project `.mcp.json` and the `~/.claude.json` local map).
+ var origins: [McpHarness: [McpConfigLocation]]
+ /// Git status of each present harness's primary config file.
+ var gitStatusByHarness: [McpHarness: GitStatus]
+ /// Harnesses where two origins for the SAME harness disagree on the definition
+ /// (e.g. Claude `.mcp.json` vs `~/.claude.json` local) — a conflict to surface, not fix.
+ /// (Whole-file malformed configs can't be attributed to a single server, so those are
+ /// reported separately as scanner-level `McpConfigIssue`s.)
+ var conflictedHarnesses: Set
+
+ // MARK: AgentResource
+
+ var summary: String? {
+ representativePortable?.summary ?? "—"
+ }
+
+ // MARK: - Derived
+
+ var presentIn: Set { Set(entries.keys) }
+ var enabledIn: Set { Set(entries.filter { $0.value.enabled }.keys) }
+
+ /// A representative portable definition (the first present harness that parsed one),
+ /// used for the summary and for the transport an absent harness is judged against.
+ var representativePortable: PortableMcpDefinition? {
+ for h in McpHarness.allCases {
+ if let p = entries[h]?.portable { return p }
+ }
+ return nil
+ }
+
+ var transport: McpTransport? { representativePortable?.transport }
+
+ /// True when the parsed portable definitions disagree across the harnesses that have
+ /// one — a *definition* divergence, kept separate from mere availability differences.
+ var definitionDiverges: Bool {
+ let sigs = entries.values.compactMap { $0.portable?.signature }
+ return Set(sigs).count > 1
+ }
+
+ /// Harnesses where the server is present but with auth/secret-bearing fields — the
+ /// writer must preserve these on edit, never strip them.
+ var carriesAuth: Bool { entries.values.contains { !$0.agentLocalFields.isEmpty } }
+
+ /// The cross-harness state for `h`: present → enabled/disabled; otherwise missing unless
+ /// the server's transport is one this harness can't express → unsupported.
+ func state(_ h: McpHarness) -> McpServerState {
+ if let e = entries[h] { return e.enabled ? .enabled : .disabled }
+ if let t = transport, !h.transportSupport.contains(t) { return .unsupported }
+ return .missing
+ }
+
+ /// Harnesses that support this server's transport but don't yet have it (drift you could
+ /// fix with "apply to supported harnesses"). Drives the supported-subset actions later.
+ var supportedButMissing: Set {
+ Set(McpHarness.allCases.filter { state($0) == .missing })
+ }
+
+ /// Harnesses locked out by transport — rendered distinctly, never offered an "add".
+ var unsupportedHarnesses: Set {
+ Set(McpHarness.allCases.filter { state($0) == .unsupported })
+ }
+
+ /// Worst-case git status across present harnesses, for the row glyph (untracked beats
+ /// tracked as the "needs attention" signal, matching the skill side's intent).
+ var gitStatus: GitStatus {
+ let statuses = Set(gitStatusByHarness.values)
+ for s in [GitStatus.untracked, .ignored, .tracked, .notInRepo] where statuses.contains(s) {
+ return s
+ }
+ return .notInRepo
+ }
+}
diff --git a/loadout/apps/macOS/Sources/Models/PortableMcpDefinition.swift b/loadout/apps/macOS/Sources/Models/PortableMcpDefinition.swift
new file mode 100644
index 0000000..148d94b
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Models/PortableMcpDefinition.swift
@@ -0,0 +1,114 @@
+import Foundation
+
+/// A config value parsed into a typed interpolation expression, so the SAME logical value
+/// expressed in different dialects compares equal. `${VAR}` (Claude/Cursor) and `{env:VAR}`
+/// (opencode) both normalize to `.envVar("VAR")`; Codex's literal strings stay `.literal`.
+/// This is what stops cosmetic dialect differences from reading as divergence.
+enum McpValueExpr: Hashable {
+ case literal(String)
+ case envVar(String)
+ case envVarDefault(String, String)
+ case fileRef(String)
+
+ /// How a harness spells interpolation inside its config values.
+ enum Dialect { case dollar, opencode, literal }
+
+ static func parse(_ raw: String, dialect: Dialect) -> McpValueExpr {
+ switch dialect {
+ case .literal:
+ return .literal(raw)
+ case .dollar:
+ // ${VAR} / ${VAR:-default}; anything else is a literal (incl. partial interps).
+ guard raw.hasPrefix("${"), raw.hasSuffix("}"), raw.count >= 4 else { return .literal(raw) }
+ let inner = String(raw.dropFirst(2).dropLast())
+ if let r = inner.range(of: ":-") {
+ let name = String(inner[..= 3 else { return .literal(raw) }
+ let inner = String(raw.dropFirst().dropLast())
+ if inner.hasPrefix("env:") {
+ let name = String(inner.dropFirst(4))
+ return isVarName(name) ? .envVar(name) : .literal(raw)
+ }
+ if inner.hasPrefix("file:") {
+ return .fileRef(String(inner.dropFirst(5)))
+ }
+ return .literal(raw)
+ }
+ }
+
+ private static func isVarName(_ s: String) -> Bool {
+ !s.isEmpty && s.allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" }
+ }
+}
+
+/// A harness-agnostic MCP server definition. Built by normalizing each harness's raw entry
+/// so equivalent servers compare equal regardless of dialect:
+/// - opencode `command: ["npx","-y","pkg"]` == Claude `command: "npx", args: ["-y","pkg"]`.
+/// - `env` (Claude/Codex/Cursor) vs `environment` (opencode) compare as the same map.
+/// - interpolation is parsed into `McpValueExpr`, not raw strings.
+/// Agent-local auth/header fields are deliberately NOT carried here — they are never part of
+/// portable divergence (tracked on `AgentMcpEntry.agentLocalFields` instead).
+struct PortableMcpDefinition: Hashable {
+ enum Kind: Hashable { case stdio, remote }
+
+ var kind: Kind
+ // stdio
+ var command: String?
+ var args: [String]
+ var env: [String: McpValueExpr]
+ var cwd: String?
+ // remote
+ var url: String?
+ /// The remote sub-transport, for display only — excluded from `signature`.
+ var remoteTransport: McpTransport?
+
+ /// The normalized transport (stdio, or the remote sub-transport, defaulting to http).
+ var transport: McpTransport {
+ kind == .stdio ? .stdio : (remoteTransport ?? .http)
+ }
+
+ /// The fields compared for cross-harness DEFINITION divergence. Excludes the remote
+ /// sub-transport (harnesses infer/spell it differently) and all agent-local fields.
+ struct Signature: Hashable {
+ let kind: Kind
+ let command: String?
+ let args: [String]
+ let env: [String: McpValueExpr]
+ let cwd: String?
+ let url: String?
+ }
+
+ var signature: Signature {
+ Signature(kind: kind, command: command, args: args, env: env, cwd: cwd, url: url)
+ }
+
+ /// One-line human summary of the connection ("stdio · npx -y @linear/mcp", "http · https://…").
+ var summary: String {
+ switch kind {
+ case .stdio:
+ let cmd = ([command].compactMap { $0 } + args).joined(separator: " ")
+ return "stdio · \(cmd)"
+ case .remote:
+ return "\(transport.label) · \(url ?? "—")"
+ }
+ }
+}
+
+/// One harness's view of a single server: its parsed portable definition (nil when the raw
+/// entry couldn't be normalized), whether it's enabled, and the NAMES of any agent-local /
+/// secret-bearing fields present (so the writer knows to preserve them and the UI can say
+/// "carries auth" without ever surfacing the secret).
+struct AgentMcpEntry: Hashable {
+ var portable: PortableMcpDefinition?
+ var enabled: Bool
+ /// Non-portable, harness-local field names present on the raw entry (oauth, headers,
+ /// headersHelper, bearer_token_env_var, http_headers, env_http_headers, Cursor `auth`,
+ /// `envFile`, …). Key names only — never values.
+ var agentLocalFields: [String]
+}
diff --git a/loadout/apps/macOS/Sources/Models/Skill.swift b/loadout/apps/macOS/Sources/Models/Skill.swift
new file mode 100644
index 0000000..e0b31ad
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Models/Skill.swift
@@ -0,0 +1,148 @@
+import Foundation
+
+/// Provenance from a lock file. Best-effort: any field may be absent.
+/// Global lock (`~/.agents/.skill-lock.json`, v3) uses `skillFolderHash`;
+/// project lock (`/skills-lock.json`, v1) uses `computedHash` — both map here.
+struct SkillProvenance: Hashable {
+ var source: String
+ var sourceURL: String?
+ var skillPath: String?
+ var folderHash: String?
+ var installedAt: String?
+ var updatedAt: String?
+ var pluginName: String?
+}
+
+/// A skill, identified by its canonical (symlink-resolved) directory **within a scope**.
+/// The composite `id` keeps the same canonical skill from colliding between global and
+/// project scopes (they are never shown together, but the IDs must still be distinct).
+struct Skill: AgentResource {
+ let canonicalPath: String
+ var scope: ResourceScope
+ /// Which machine this skill was discovered on. Local renders `idTag == nil`, so the
+ /// composite id stays byte-identical to before the HostIO seam existed (see D7).
+ var host: Host = .local
+ var id: String { "\(host.idTag.map { $0 + "::" } ?? "")\(scope.projectRoot ?? "global")::\(canonicalPath)" }
+
+ let name: String
+ let directoryURL: URL
+ let skillMdURL: URL
+
+ var summary: String?
+ var bodyMarkdown: String
+ var frontmatterKeys: [String]
+ var rawFrontmatter: String
+
+ /// Lowercased name + summary + body, precomputed once at scan time so search filtering is
+ /// a single `contains` instead of re-lowercasing the full markdown body on every keystroke.
+ var searchHaystack = ""
+
+ /// Top-level files/dirs packaged alongside SKILL.md in the canonical skill dir
+ /// (reference docs, scripts, templates, assets). Excludes SKILL.md itself. Sorted
+ /// dirs-first then by name; `isDirectory` lets the UI pick a folder vs file glyph.
+ var bundledFiles: [BundledFile] = []
+
+ var kind: ResourceKind { .skill }
+
+ /// Agents whose own dir references this skill on disk (real dir OR symlink).
+ var wiredAgents: Set
+ /// Subset of `wiredAgents` whose reference is an actual SYMLINK (resolving elsewhere,
+ /// usually the canonical store). The complement live as real directories in the
+ /// agent's own dir — so the UI can say "symlinked" vs "local" honestly.
+ var symlinkedAgents: Set = []
+ /// Agents the skills CLI declares as targets (intent).
+ var declaredAgents: Set = []
+
+ var provenance: SkillProvenance?
+
+ /// Project-relative subpackage paths this skill is referenced from (project scope only;
+ /// empty for global). "" means the chosen project root; "↑ …" means an ancestor dir.
+ /// A monorepo skill referenced from one place has a single entry.
+ var projectLocations: [String] = []
+
+ /// A short label for where this lives in the project, or nil in global scope.
+ var locationBadge: String? {
+ guard !projectLocations.isEmpty else { return nil }
+ let labels = projectLocations.map { $0.isEmpty ? "· root" : $0 }
+ return labels.count == 1 ? labels[0] : "\(labels[0]) +\(labels.count - 1)"
+ }
+
+ /// Tooltip for the location badge: the project folder(s) this skill is referenced from.
+ var locationHelp: String? {
+ guard !projectLocations.isEmpty else { return nil }
+ let names = projectLocations.map { $0.isEmpty ? "the project root" : $0 }
+ return "Referenced from " + names.joined(separator: ", ")
+ }
+
+ /// Sidebar source groups. CLI-managed skills group by their provenance source; local
+ /// (manual) skills group by the project folder they live in ("Local · "),
+ /// so manual skills stay browsable by origin. Global-scope locals fall into one "Local".
+ var sourceGroups: [String] {
+ if let src = provenance?.source, !src.isEmpty { return [src] }
+ if projectLocations.isEmpty { return ["Local"] }
+ return projectLocations.map { "Local · " + ($0.isEmpty ? "root" : $0) }
+ }
+
+ // Derived signals (filled in by the scanner)
+ var gitStatus: GitStatus = .notInRepo
+ /// True when the agent-dir symlinks differ in tracked-ness from the canonical files.
+ var linksDiverge: Bool = false
+ var isCLIManaged: Bool = false
+ /// True when ≥2 skills in the same scope share this name across distinct canonical paths.
+ var diverged: Bool = false
+
+ /// True when the skill exists in the canonical `.agents/skills` store.
+ var canonicalPresent: Bool { wiredAgents.contains(.agents) }
+
+ /// Agents that can actually USE this skill: either wired into their own dir, or
+ /// reading the canonical store natively when it's present. This is the key fix —
+ /// a canonical skill is available to Codex/OpenCode/Pi even with no per-agent symlink.
+ var availableAgents: Set {
+ var result = wiredAgents
+ if canonicalPresent {
+ for agent in Agent.allCases where agent.readsCanonicalNatively {
+ result.insert(agent)
+ }
+ }
+ return result
+ }
+
+ /// How a given agent reaches this skill.
+ func access(_ agent: Agent) -> AgentAccess {
+ if wiredAgents.contains(agent) { return .wired }
+ if agent.readsCanonicalNatively && canonicalPresent { return .viaCanonical }
+ return .none
+ }
+
+ /// True when `agent`'s reference is a real directory living in its own skills dir,
+ /// not a symlink to the canonical store (e.g. a hand-made project skill in `.claude/skills`).
+ func isLocalDir(_ agent: Agent) -> Bool {
+ wiredAgents.contains(agent) && !symlinkedAgents.contains(agent)
+ }
+
+ /// Agents the CLI declares but that genuinely can't reach the skill (drift).
+ var driftMissing: Set { declaredAgents.subtracting(availableAgents) }
+}
+
+/// A file or directory packaged inside a skill, surfaced in the detail view.
+struct BundledFile: Hashable, Identifiable {
+ let url: URL
+ let isDirectory: Bool
+ var id: String { url.path }
+ var name: String { url.lastPathComponent }
+}
+
+/// How an agent gets access to a skill.
+enum AgentAccess: Hashable {
+ case wired // a symlink/entry in the agent's own dir
+ case viaCanonical // reached through the shared .agents/skills store
+ case none
+
+ var label: String {
+ switch self {
+ case .wired: return "symlinked"
+ case .viaCanonical: return "via .agents"
+ case .none: return "not available"
+ }
+ }
+}
diff --git a/loadout/apps/macOS/Sources/Services/FileWatcher.swift b/loadout/apps/macOS/Sources/Services/FileWatcher.swift
new file mode 100644
index 0000000..bce65b3
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Services/FileWatcher.swift
@@ -0,0 +1,81 @@
+import Foundation
+import CoreServices
+
+/// Watches a set of directories with FSEvents and fires a debounced callback on the
+/// main queue. Watches both the agent dirs and the canonical store so edits to a
+/// symlinked skill's real files (under `~/.agents/skills`) are caught too.
+final class FileWatcher {
+ private var stream: FSEventStreamRef?
+ private let queue = DispatchQueue(label: "dev.zackbart.loadout.fswatch")
+ private var debounceItem: DispatchWorkItem?
+ private let debounce: TimeInterval
+ private let onChange: () -> Void
+
+ init(debounce: TimeInterval = 0.3, onChange: @escaping () -> Void) {
+ self.debounce = debounce
+ self.onChange = onChange
+ }
+
+ func start(paths: [String]) {
+ stop()
+ let existing = Array(Set(paths.filter { FileManager.default.fileExists(atPath: $0) }))
+ guard !existing.isEmpty else { return }
+
+ var context = FSEventStreamContext(
+ version: 0,
+ info: Unmanaged.passUnretained(self).toOpaque(),
+ retain: nil, release: nil, copyDescription: nil
+ )
+ let callback: FSEventStreamCallback = { _, info, _, _, _, _ in
+ guard let info else { return }
+ Unmanaged.fromOpaque(info).takeUnretainedValue().schedule()
+ }
+
+ guard let stream = FSEventStreamCreate(
+ kCFAllocatorDefault,
+ callback,
+ &context,
+ existing as CFArray,
+ FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
+ 0.5,
+ FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagNoDefer)
+ ) else { return }
+
+ FSEventStreamSetDispatchQueue(stream, queue)
+ // Publish + start on `queue` so `stream`/`debounceItem` are only ever touched from
+ // the one thread the FSEvents callback and debounce also run on — no cross-thread race.
+ queue.sync {
+ self.stream = stream
+ FSEventStreamStart(stream)
+ }
+ }
+
+ private func schedule() {
+ // Runs on `queue` (the FSEvents callback thread) — same queue as start/stop, so the
+ // debounce item is single-threaded.
+ debounceItem?.cancel()
+ let item = DispatchWorkItem { [weak self] in
+ guard let self else { return }
+ DispatchQueue.main.async { self.onChange() }
+ }
+ debounceItem = item
+ queue.asyncAfter(deadline: .now() + debounce, execute: item)
+ }
+
+ func stop() {
+ // Tear down on `queue`: serial execution guarantees no callback/debounce is mid-flight,
+ // cancelling stops a queued debounce from firing after teardown (a use-after-free if the
+ // watcher is then released), and confining the state here removes the cross-thread race.
+ queue.sync {
+ debounceItem?.cancel()
+ debounceItem = nil
+ guard let stream else { return }
+ FSEventStreamStop(stream)
+ FSEventStreamInvalidate(stream)
+ FSEventStreamRelease(stream)
+ self.stream = nil
+ }
+ }
+
+ deinit { stop() }
+}
diff --git a/loadout/apps/macOS/Sources/Services/FrontmatterParser.swift b/loadout/apps/macOS/Sources/Services/FrontmatterParser.swift
new file mode 100644
index 0000000..e26cf0c
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Services/FrontmatterParser.swift
@@ -0,0 +1,49 @@
+import Foundation
+import Yams
+
+struct ParsedSkill {
+ var frontmatter: [String: Any]
+ var rawFrontmatter: String
+ var body: String
+}
+
+/// Splits a SKILL.md into YAML frontmatter (parsed with Yams) and a markdown body.
+/// Tolerates files without frontmatter and unknown/agent-specific keys.
+enum FrontmatterParser {
+ static func parse(_ content: String) -> ParsedSkill {
+ let normalized = content.replacingOccurrences(of: "\r\n", with: "\n")
+ guard normalized.hasPrefix("---") else {
+ return ParsedSkill(frontmatter: [:], rawFrontmatter: "", body: content)
+ }
+
+ let lines = normalized.components(separatedBy: "\n")
+ // Line 0 is the opening "---"; find the closing fence.
+ var closing: Int?
+ var i = 1
+ while i < lines.count {
+ if lines[i].trimmingCharacters(in: .whitespaces) == "---" {
+ closing = i
+ break
+ }
+ i += 1
+ }
+
+ guard let end = closing else {
+ return ParsedSkill(frontmatter: [:], rawFrontmatter: "", body: content)
+ }
+
+ let rawFrontmatter = lines[1.. Bool { gitPath(io) != nil }
+
+ static func gitPath(_ io: HostIO) -> String? {
+ for p in ["/usr/bin/git", "/opt/homebrew/bin/git", "/usr/local/bin/git"]
+ where io.exists(p) { return p }
+ return nil
+ }
+
+ /// Returns a status for every input path. Paths in the same repo share repo-root
+ /// lookups and are classified together in two batched git calls.
+ static func classify(paths: [String], io: HostIO) -> [String: GitStatus] {
+ guard gitPath(io) != nil else { return [:] }
+ var result: [String: GitStatus] = [:]
+
+ // 1. Resolve each path's repo root via its PARENT context (so the path itself
+ // being a symlink/nested-repo entry doesn't classify from a child repo).
+ // Memoized per parent dir. No repo → notInRepo, recorded now.
+ var repoCache: [String: String?] = [:]
+ func repoRoot(forParent dir: String) -> String? {
+ if let cached = repoCache[dir] { return cached }
+ let r = run(["-C", dir, "rev-parse", "--show-toplevel"], io: io)
+ let root: String? = (r.code == 0)
+ ? r.out.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
+ : nil
+ repoCache[dir] = root
+ return root
+ }
+
+ var byRepo: [String: [String]] = [:] // repoRoot -> [absolute paths]
+ for path in Set(paths) {
+ if let root = repoRoot(forParent: parentDir(path)) {
+ byRepo[root, default: []].append(path)
+ } else {
+ result[path] = .notInRepo
+ }
+ }
+
+ for (root, group) in byRepo {
+ // Repo-relative paths (normalized, no trailing slash), with a reverse map.
+ var rels: [String] = []
+ var relToAbs: [String: String] = [:]
+ for abs in group {
+ let rel = relative(abs, to: root, io: io)
+ // If the path didn't reduce to repo-relative (symlink resolution escaped the
+ // root), don't hand git an absolute pathspec it can't match — report honestly.
+ if rel.hasPrefix("/") { result[abs] = .notInRepo; continue }
+ rels.append(rel)
+ relToAbs[rel] = abs
+ }
+
+ // 2. Tracked? ONE ls-files over all pathspecs. A queried dir is tracked if any
+ // listed entry equals it OR sits beneath it (exact-or-prefix — `ls-files --
+ // dir` emits descendants, so equality alone would miss tracked directories).
+ // `--literal-pathspecs` so names with pathspec magic aren't reinterpreted.
+ // ls-files exits 0 even when nothing matches → empty output means "none".
+ let ls = run(["-C", root, "--literal-pathspecs", "ls-files", "-z", "--"] + rels, io: io)
+ let listed = ls.out.split(separator: "\0", omittingEmptySubsequences: true).map(String.init)
+ var trackedRels = Set()
+ if !listed.isEmpty {
+ for rel in rels where listed.contains(where: { $0 == rel || $0.hasPrefix(rel + "/") }) {
+ trackedRels.insert(rel)
+ }
+ }
+
+ // 3. Ignored? ONE check-ignore over the not-yet-tracked paths (via stdin, NUL-
+ // delimited, so arbitrary counts and odd names are safe). For real dirs we
+ // also query "/" to catch directory-only ignore rules (`foo/`); for
+ // symlink entries we query only the bare pathname (never the target dir).
+ // `--no-index` consults ignore rules irrespective of the index; tracked
+ // precedence already applied above. Exit 0 = some ignored (parse stdout),
+ // 1 = none, 128 = fatal (fall back to per-path for exactness).
+ let notTracked = rels.filter { !trackedRels.contains($0) }
+ var ignoredRels = Set()
+ if !notTracked.isEmpty {
+ var queryToRel: [String: String] = [:]
+ for rel in notTracked {
+ queryToRel[rel] = rel
+ if isRealDirectory(relToAbs[rel]!, io: io) { queryToRel[rel + "/"] = rel }
+ }
+ let queries = Array(queryToRel.keys)
+ let stdin = (queries.joined(separator: "\0") + "\0").data(using: .utf8)
+ let ci = run(["-C", root, "check-ignore", "--stdin", "--no-index", "-z"], io: io, stdin: stdin)
+ if ci.code == 0 {
+ for hit in ci.out.split(separator: "\0", omittingEmptySubsequences: true).map(String.init) {
+ if let rel = queryToRel[hit] { ignoredRels.insert(rel) }
+ }
+ } else if ci.code == 128 {
+ for rel in notTracked where run(["-C", root, "check-ignore", "-q", rel], io: io).code == 0 {
+ ignoredRels.insert(rel)
+ }
+ }
+ }
+
+ // 4. Resolve precedence: tracked > ignored > untracked.
+ for rel in rels {
+ let abs = relToAbs[rel]!
+ if trackedRels.contains(rel) { result[abs] = .tracked }
+ else if ignoredRels.contains(rel) { result[abs] = .ignored }
+ else { result[abs] = .untracked }
+ }
+ }
+ return result
+ }
+
+ private static func isRealDirectory(_ path: String, io: HostIO) -> Bool {
+ // lstat semantics: a symlink-to-dir is NOT a real dir. listDir reports the entry's
+ // OWN type (isSymlink from lstat, isDir following), so a real dir is dir && !symlink.
+ // Only real dirs get the "/" query.
+ let parent = parentDir(path)
+ let name = URL(fileURLWithPath: path).lastPathComponent
+ guard let entries = try? io.listDir(parent),
+ let entry = entries.first(where: { $0.name == name })
+ else { return false }
+ return entry.isDir && !entry.isSymlink
+ }
+
+ private static func parentDir(_ path: String) -> String {
+ URL(fileURLWithPath: path).deletingLastPathComponent().path
+ }
+
+ private static func relative(_ path: String, to root: String, io: HostIO) -> String {
+ // `rev-parse --show-toplevel` returns the realpath, so resolve symlinks in the
+ // PARENT to match it (e.g. /tmp → /private/tmp, or a project under a symlinked
+ // dir) — otherwise the prefix strip below fails and we'd hand git an absolute
+ // pathspec. Keep the final component unresolved: a symlinked skill entry is
+ // tracked/ignored at its OWN pathname, not its target's.
+ let url = URL(fileURLWithPath: path)
+ let resolved = URL(fileURLWithPath: io.realpath(url.deletingLastPathComponent().path))
+ .appendingPathComponent(url.lastPathComponent).path
+ if resolved == root { return "." }
+ if resolved.hasPrefix(root + "/") { return String(resolved.dropFirst(root.count + 1)) }
+ return resolved
+ }
+
+ @discardableResult
+ private static func run(_ args: [String], io: HostIO, stdin: Data? = nil) -> (out: String, code: Int32) {
+ guard let git = gitPath(io) else { return ("", -1) }
+ let r = io.run([git] + args, cwd: nil, stdin: stdin)
+ return (String(data: r.stdout, encoding: .utf8) ?? "", r.exit)
+ }
+}
+
+private extension String {
+ var nonEmpty: String? { isEmpty ? nil : self }
+}
diff --git a/loadout/apps/macOS/Sources/Services/HostIO.swift b/loadout/apps/macOS/Sources/Services/HostIO.swift
new file mode 100644
index 0000000..b1a7fb8
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Services/HostIO.swift
@@ -0,0 +1,177 @@
+import Foundation
+
+/// One directory entry as the scan path needs it: name plus the lstat-derived flags
+/// (the entry's OWN type, not its target's) and the symlink target when it is one.
+/// Sendable so a `Host`-derived scan can cross the `Task.detached` boundary.
+struct DirEntry: Sendable {
+ let name: String
+ let isDir: Bool
+ let isSymlink: Bool
+ let linkTarget: String?
+}
+
+enum HostIOError: Error {
+ case readOnly
+ case io(String)
+}
+
+/// The per-host IO seam (filesystem + process). `LocalHostIO` wraps today's Foundation
+/// calls verbatim-equivalently; a future `RemoteHostIO` runs the same operations over SSH.
+/// Sendable so a host-scoped scan can run off the main actor.
+protocol HostIO: Sendable {
+ var home: URL { get }
+ var xdgConfigHome: URL { get }
+ func exists(_ path: String) -> Bool
+ func readFile(_ path: String) throws -> Data
+ func listDir(_ path: String) throws -> [DirEntry]
+ func realpath(_ path: String) -> String
+ func run(_ argv: [String], cwd: String?, stdin: Data?) -> (exit: Int32, stdout: Data, stderr: Data)
+}
+
+/// Which machine a resource was discovered on. Carried separately from `ResourceScope`
+/// so resource identities stay unique per host; local renders `idTag == nil`, keeping
+/// local ids byte-identical to before the seam existed.
+enum Host: Sendable, Hashable {
+ case local
+ case remote(user: String, host: String, alias: String?)
+
+ /// What `ssh` connects to: an `~/.ssh/config` alias if given, else `user@host`.
+ var target: String {
+ switch self {
+ case .local: return ""
+ case .remote(let u, let h, let a): return a ?? "\(u)@\(h)"
+ }
+ }
+
+ /// Identity prefix for resource ids; `nil` for local so local ids are unchanged.
+ var idTag: String? {
+ switch self {
+ case .local: return nil
+ case .remote: return target
+ }
+ }
+
+ /// Human label for the UI (the ssh target).
+ var displayName: String { self == .local ? "Local" : target }
+
+ func makeIO() -> HostIO {
+ switch self {
+ case .local: return LocalHostIO()
+ case .remote: return RemoteHostIO(target: target)
+ }
+ }
+
+ /// Parse user input into a host. `user@host` → a literal target; anything else is
+ /// treated as an `~/.ssh/config` alias. Returns nil for empty input.
+ static func parse(_ input: String) -> Host? {
+ let s = input.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !s.isEmpty else { return nil }
+ if let at = s.firstIndex(of: "@"), at != s.startIndex, s.index(after: at) != s.endIndex {
+ return .remote(user: String(s[.. Bool {
+ FileManager.default.fileExists(atPath: path)
+ }
+
+ func readFile(_ path: String) throws -> Data {
+ try Data(contentsOf: URL(fileURLWithPath: path))
+ }
+
+ func listDir(_ path: String) throws -> [DirEntry] {
+ let fm = FileManager.default
+ // Skip hidden files (names starting "."), matching the `.skipsHiddenFiles` option
+ // used at every former call site.
+ let names = try fm.contentsOfDirectory(atPath: path).filter { !$0.hasPrefix(".") }
+ return names.map { name in
+ let full = (path as NSString).appendingPathComponent(name)
+ // lstat via attributesOfItem (doesn't follow links) for the entry's own type.
+ let attrs = try? fm.attributesOfItem(atPath: full)
+ let isSymlink = (attrs?[.type] as? FileAttributeType) == .typeSymbolicLink
+ // isDir via resourceValues (FOLLOWS links) — matches the descendantRoots /
+ // bundledFiles use of `.isDirectoryKey`.
+ let isDir = (try? URL(fileURLWithPath: full)
+ .resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
+ let linkTarget = isSymlink
+ ? (try? fm.destinationOfSymbolicLink(atPath: full))
+ : nil
+ return DirEntry(name: name, isDir: isDir, isSymlink: isSymlink, linkTarget: linkTarget)
+ }
+ }
+
+ func realpath(_ path: String) -> String {
+ // resolvingSymlinksInPath also standardizes `..`/`~`, which `readlink -f` alone
+ // wouldn't — keeping local canonicalization byte-identical to the prior code.
+ URL(fileURLWithPath: path).resolvingSymlinksInPath().path
+ }
+
+ func run(_ argv: [String], cwd: String?, stdin: Data?) -> (exit: Int32, stdout: Data, stderr: Data) {
+ spawnProcess(argv, cwd: cwd, stdin: stdin, env: nil)
+ }
+}
+
+/// Mutable reference box so the two concurrent pipe-reader closures each write a distinct
+/// slot; `group.wait()` establishes the happens-before before we read them.
+private final class DataBox: @unchecked Sendable { var data = Data() }
+
+/// Spawn a process, draining stdout+stderr concurrently and feeding stdin off-thread (so a
+/// large input can't deadlock against our reads). `env`, when non-nil, is merged over the
+/// inherited environment — RemoteHostIO uses it to inject SSH_ASKPASS for password auth.
+/// Shared by LocalHostIO.run and RemoteHostIO so both get the same deadlock-safe behavior.
+func spawnProcess(_ argv: [String], cwd: String?, stdin: Data?,
+ env: [String: String]?) -> (exit: Int32, stdout: Data, stderr: Data) {
+ guard let first = argv.first else { return (-1, Data(), Data()) }
+ let p = Process()
+ p.executableURL = URL(fileURLWithPath: first)
+ p.arguments = Array(argv.dropFirst())
+ if let cwd { p.currentDirectoryURL = URL(fileURLWithPath: cwd) }
+ if let env {
+ var merged = ProcessInfo.processInfo.environment
+ for (k, v) in env { merged[k] = v }
+ p.environment = merged
+ }
+
+ let out = Pipe(), err = Pipe()
+ p.standardOutput = out
+ p.standardError = err
+ let inPipe: Pipe?
+ if stdin != nil { inPipe = Pipe(); p.standardInput = inPipe } else { inPipe = nil }
+
+ do { try p.run() } catch { return (-1, Data(), Data()) }
+
+ // `write(contentsOf:)` throws on a broken pipe (the child closing stdin early); the
+ // older `write(_:)` traps and would crash the app.
+ if let inPipe, let stdin {
+ DispatchQueue.global().async {
+ try? inPipe.fileHandleForWriting.write(contentsOf: stdin)
+ try? inPipe.fileHandleForWriting.close()
+ }
+ }
+
+ let outBox = DataBox(), errBox = DataBox()
+ let group = DispatchGroup()
+ DispatchQueue.global().async(group: group) {
+ outBox.data = out.fileHandleForReading.readDataToEndOfFile()
+ }
+ DispatchQueue.global().async(group: group) {
+ errBox.data = err.fileHandleForReading.readDataToEndOfFile()
+ }
+ group.wait()
+ p.waitUntilExit()
+ return (p.terminationStatus, outBox.data, errBox.data)
+}
diff --git a/loadout/apps/macOS/Sources/Services/Mcp/JsonSurgeon.swift b/loadout/apps/macOS/Sources/Services/Mcp/JsonSurgeon.swift
new file mode 100644
index 0000000..75686fc
--- /dev/null
+++ b/loadout/apps/macOS/Sources/Services/Mcp/JsonSurgeon.swift
@@ -0,0 +1,303 @@
+import Foundation
+
+/// Byte-level, format-preserving JSON / JSONC editor. It scans the source to locate the
+/// exact scalar range of one object member (or its value), then splices ONLY that range —
+/// every comment, trailing comma, key order and indentation outside the touched member is
+/// preserved verbatim. It never round-trips through `JSONSerialization` (which would discard
+/// all of that), and it never re-renders a whole object it wasn't asked to change.
+///
+/// Works on `[Unicode.Scalar]` with `Int` indices so splices are trivial and lossless.
+/// Tolerates JSONC (`//`, `/* */`, trailing commas) while scanning, so it is safe on both
+/// strict `.json` (Claude / Cursor) and `.jsonc` (opencode).
+enum JsonSurgeon {
+ struct SurgeonError: Error { let message: String }
+
+ // MARK: - Public model
+
+ /// A located object member: the spans of its key, its value, and the full member
+ /// (key-start … value-end, excluding any trailing comma / surrounding whitespace).
+ struct Member {
+ let key: String
+ let valueRange: Range
+ let memberRange: Range
+ }
+
+ /// The content object found at a key path: the index of its `{` and its members.
+ struct Located {
+ let braceOpen: Int // index of `{`
+ let braceClose: Int // index of matching `}`
+ let members: [Member]
+ }
+
+ // MARK: - Navigation
+
+ /// Locate the object reached by following `keyPath` from the document root. Returns nil
+ /// if any segment is absent (a legitimate "not here yet"), throws only on malformed JSON.
+ static func locate(_ source: String, keyPath: [String]) throws -> Located? {
+ let s = Array(source.unicodeScalars)
+ var i = skipTrivia(s, 0)
+ guard i < s.count, s[i] == "{" else {
+ if i >= s.count { return nil } // empty document → nothing to locate
+ throw SurgeonError(message: "root is not a JSON object")
+ }
+ var objStart = i
+ var path = keyPath
+ while true {
+ let located = try readObject(s, objStart)
+ guard let seg = path.first else { return located }
+ path.removeFirst()
+ guard let m = located.members.first(where: { $0.key == seg }) else { return nil }
+ i = skipTrivia(s, m.valueRange.lowerBound)
+ guard i < s.count, s[i] == "{" else { return nil } // value isn't an object
+ objStart = i
+ }
+ }
+
+ // MARK: - Edits (each returns the full new document text)
+
+ /// Replace `key`'s value with `valueText`, or insert the member if absent. `valueText`
+ /// must be a fully-rendered JSON value at the correct indentation depth.
+ static func upsertMember(_ source: String, keyPath: [String], key: String,
+ valueText: String, indentUnit: String = " ") throws -> String {
+ let s = Array(source.unicodeScalars)
+ guard let located = try locate(source, keyPath: keyPath) else {
+ throw SurgeonError(message: "container \(keyPath.joined(separator: ".")) not found")
+ }
+ if let m = located.members.first(where: { $0.key == key }) {
+ return splice(s, m.valueRange, with: valueText)
+ }
+ return insertMember(s, into: located, key: key, valueText: valueText, indentUnit: indentUnit)
+ }
+
+ /// Remove `key` from the object at `keyPath`. No-op (returns source) if already absent.
+ static func removeMember(_ source: String, keyPath: [String], key: String) throws -> String {
+ let s = Array(source.unicodeScalars)
+ guard let located = try locate(source, keyPath: keyPath),
+ let idx = located.members.firstIndex(where: { $0.key == key }) else { return source }
+ let m = located.members[idx]
+
+ // Extend the cut to swallow exactly one separating comma + the whitespace/newline of
+ // the member's own line, so neither a dangling comma nor a blank line is left behind.
+ var lo = lineStart(s, m.memberRange.lowerBound)
+ var hi = m.memberRange.upperBound
+ // trailing comma after the value?
+ let j = skipTrivia(s, hi)
+ if j < s.count, s[j] == "," {
+ hi = j + 1
+ // consume to end of that line (incl. newline) so the line vanishes cleanly
+ var k = hi
+ while k < s.count, s[k] == " " || s[k] == "\t" { k += 1 }
+ if k < s.count, s[k] == "\r" { k += 1 }
+ if k < s.count, s[k] == "\n" { k += 1; hi = k }
+ } else {
+ // Last member: drop the PRECEDING comma (and the whitespace/newline between it
+ // and this member), but LEAVE this member's own trailing newline so the closing
+ // brace stays on its own line.
+ var p = lo - 1
+ while p >= 0, s[p] == " " || s[p] == "\t" || s[p] == "\n" || s[p] == "\r" { p -= 1 }
+ if p >= 0, s[p] == "," { lo = p }
+ }
+ var out = s
+ out.removeSubrange(lo.. String {
+ let keyJson = renderJsonString(key)
+ if located.members.isEmpty {
+ // Expand `{}` (in any inline form) into a two-line object.
+ let closeIndent = leadingIndentString(s, located.braceOpen)
+ let memberIndent = closeIndent + indentUnit
+ let body = "{\n\(memberIndent)\(keyJson): \(valueText)\n\(closeIndent)}"
+ return splice(s, located.braceOpen..<(located.braceClose + 1), with: body)
+ }
+ // Insert after the last member, matching its indentation.
+ let last = located.members[located.members.count - 1]
+ let memberIndent = leadingIndentString(s, last.memberRange.lowerBound)
+ // Insertion point: right after the last member's value (before any trailing comma).
+ let insertAt = last.valueRange.upperBound
+ // Is there already a trailing comma between last value and `}`?
+ let j = skipTrivia(s, insertAt)
+ let hasTrailingComma = (j < s.count && s[j] == ",")
+ let prefix = hasTrailingComma ? "" : ","
+ let addition = "\(prefix)\n\(memberIndent)\(keyJson): \(valueText)"
+ return splice(s, insertAt.. Located {
+ var i = open + 1
+ var members: [Member] = []
+ while true {
+ i = skipTrivia(s, i)
+ guard i < s.count else { throw SurgeonError(message: "unterminated object") }
+ if s[i] == "}" { return Located(braceOpen: open, braceClose: i, members: members) }
+ // key (string)
+ guard s[i] == "\"" else { throw SurgeonError(message: "expected string key at \(i)") }
+ let keyStart = i
+ let keyEnd = try scanString(s, i)
+ let key = decodeJsonString(Array(s[keyStart.. Int {
+ guard i < s.count else { throw SurgeonError(message: "expected value") }
+ switch s[i] {
+ case "{": return try scanBracketed(s, i, open: "{", close: "}")
+ case "[": return try scanBracketed(s, i, open: "[", close: "]")
+ case "\"": return try scanString(s, i)
+ default:
+ // number / true / false / null — read until a structural delimiter.
+ var j = i
+ while j < s.count, !isDelimiter(s[j]) { j += 1 }
+ return j
+ }
+ }
+
+ private static func scanBracketed(_ s: [Unicode.Scalar], _ i: Int,
+ open: Unicode.Scalar, close: Unicode.Scalar) throws -> Int {
+ var depth = 0
+ var j = i
+ while j < s.count {
+ let c = s[j]
+ if c == "\"" { j = try scanString(s, j); continue }
+ if c == "/" , j + 1 < s.count, s[j + 1] == "/" || s[j + 1] == "*" {
+ j = skipTrivia(s, j); continue
+ }
+ if c == open { depth += 1 }
+ else if c == close { depth -= 1; if depth == 0 { return j + 1 } }
+ j += 1
+ }
+ throw SurgeonError(message: "unterminated \(open)")
+ }
+
+ private static func scanString(_ s: [Unicode.Scalar], _ i: Int) throws -> Int {
+ var j = i + 1
+ while j < s.count {
+ let c = s[j]
+ if c == "\\" { j += 2; continue }
+ if c == "\"" { return j + 1 }
+ j += 1
+ }
+ throw SurgeonError(message: "unterminated string")
+ }
+
+ // MARK: - Trivia
+
+ static func skipTrivia(_ s: [Unicode.Scalar], _ i: Int) -> Int {
+ var j = i
+ while j < s.count {
+ let c = s[j]
+ if c == " " || c == "\t" || c == "\n" || c == "\r" { j += 1 }
+ else if c == "/", j + 1 < s.count, s[j + 1] == "/" {
+ j += 2; while j < s.count, s[j] != "\n" { j += 1 }
+ } else if c == "/", j + 1 < s.count, s[j + 1] == "*" {
+ j += 2; while j + 1 < s.count, !(s[j] == "*" && s[j + 1] == "/") { j += 1 }
+ j += 2
+ } else { break }
+ }
+ return j
+ }
+
+ private static func isDelimiter(_ c: Unicode.Scalar) -> Bool {
+ c == "," || c == "}" || c == "]" || c == " " || c == "\t" || c == "\n" || c == "\r" || c == "/"
+ }
+
+ // MARK: - Indentation helpers
+
+ private static func lineStart(_ s: [Unicode.Scalar], _ i: Int) -> Int {
+ var j = i
+ while j > 0, s[j - 1] != "\n" { j -= 1 }
+ return j
+ }
+
+ /// The whitespace run from the start of `i`'s line up to `i` (the indentation).
+ private static func leadingIndentString(_ s: [Unicode.Scalar], _ i: Int) -> String {
+ let start = lineStart(s, i)
+ var view = String.UnicodeScalarView()
+ var j = start
+ while j < i, s[j] == " " || s[j] == "\t" { view.append(s[j]); j += 1 }
+ return String(view)
+ }
+
+ // MARK: - Splice + render
+
+ private static func splice(_ s: [Unicode.Scalar], _ range: Range