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 + +

+ +

+ + Download the latest Barr.dmg + + + Latest release + + + License + + + Stars + +

+ +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 app icon +

+ +

Herdr iOS

+ +

+ Join the TestFlight beta → +

+ +

+ + Stars + +

+ +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 @@ +

+ + JellyTV + +

+ +

+ + Stars + +

+ +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 @@ +

+ + Loadout + +

+ +

+ + Download the latest Loadout.dmg + + + Latest release + + + License + + + Stars + +

+ +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 app icon +

+ +

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, with text: String) -> String { + var out = s + out.replaceSubrange(range, with: Array(text.unicodeScalars)) + return String(String.UnicodeScalarView(out)) + } + + /// Render a Swift string as a JSON string literal (minimal, correct escaping). + static func renderJsonString(_ value: String) -> String { + var out = "\"" + for ch in value.unicodeScalars { + switch ch { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\t": out += "\\t" + case "\r": out += "\\r" + default: + if ch.value < 0x20 { + out += String(format: "\\u%04x", ch.value) + } else { + out.unicodeScalars.append(ch) + } + } + } + out += "\"" + return out + } + + /// Decode a JSON string literal's scalars (incl. surrounding quotes) back to a Swift + /// string — used only for KEY comparison, so it handles the common escapes. + private static func decodeJsonString(_ scalars: [Unicode.Scalar]) -> String { + guard scalars.count >= 2 else { return "" } + var out = String.UnicodeScalarView() + var i = 1 + let end = scalars.count - 1 + while i < end { + let c = scalars[i] + if c == "\\", i + 1 < end { + let n = scalars[i + 1] + switch n { + case "n": out.append("\n") + case "t": out.append("\t") + case "r": out.append("\r") + case "\"": out.append("\"") + case "\\": out.append("\\") + case "/": out.append("/") + case "u": + if i + 5 < end + 1 { + let hex = String(String.UnicodeScalarView(scalars[(i + 2).. = + ["command", "args", "env", "environment", "type", "url", "cwd", "enabled", "disabled"] + + /// Insert or replace `name` in the server map at `keyPath`, creating any missing + /// containers (and the document itself) along the way. + static func upsert(source: String, harness: McpHarness, keyPath: [String], name: String, + def: PortableMcpDefinition, enabled: Bool, schema: OpencodeSchema) throws -> String { + let ensured = try ensureContainer(source, keyPath) + + // Preserve members of an existing entry that we don't manage (verbatim source text). + var preserved: [String] = [] + if let existing = try JsonSurgeon.locate(ensured, keyPath: keyPath + [name]) { + let s = Array(ensured.unicodeScalars) + for m in existing.members where !managedKeys.contains(m.key) { + preserved.append(String(String.UnicodeScalarView(s[m.memberRange]))) + } + } + + let valueText = renderValue(harness: harness, def: def, enabled: enabled, + schema: schema, preserved: preserved) + return try JsonSurgeon.upsertMember(ensured, keyPath: keyPath, key: name, valueText: valueText) + } + + static func remove(source: String, keyPath: [String], name: String) throws -> String { + try JsonSurgeon.removeMember(source, keyPath: keyPath, key: name) + } + + // MARK: - Container creation + + /// Ensure every segment of `keyPath` exists as an object, creating empties for the + /// missing suffix (and starting from `{}` for an empty/whitespace document). + static func ensureContainer(_ source: String, _ keyPath: [String]) throws -> String { + let src = source.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "{}\n" : source + var existing = 0 + while existing < keyPath.count { + if try JsonSurgeon.locate(src, keyPath: Array(keyPath[0...existing])) != nil { existing += 1 } + else { break } + } + if existing == keyPath.count { return src } + let parent = Array(keyPath[0..) -> String { + guard let first = keys.first else { return "{}" } + return "{ \(JsonSurgeon.renderJsonString(first)): \(nestedObject(keys.dropFirst())) }" + } + + // MARK: - Value rendering + + static func renderValue(harness: McpHarness, def: PortableMcpDefinition, + enabled: Bool, schema: OpencodeSchema, preserved: [String]) -> String { + var parts: [String] = [] + switch harness { + case .claudeCode: + if def.kind == .remote { + parts.append(field("type", claudeRemoteType(def.remoteTransport))) + if let u = def.url { parts.append(field("url", u)) } + } else { + parts.append(contentsOf: stdioJsonFields(def, envKey: "env", dialect: .dollar)) + } + case .cursor: + if def.kind == .remote { + if let u = def.url { parts.append(field("url", u)) } // transport inferred, no type + } else { + parts.append(contentsOf: stdioJsonFields(def, envKey: "env", dialect: .dollar)) + } + case .opencode: + if def.kind == .remote { + parts.append(field("type", "remote")) + if let u = def.url { parts.append(field("url", u)) } + } else { + parts.append(field("type", "local")) + let merged = ([def.command].compactMap { $0 }) + def.args + parts.append("\"command\": \(renderArray(merged))") + if !def.env.isEmpty { parts.append("\"environment\": \(renderEnv(def.env, .opencode))") } + if let cwd = def.cwd { parts.append(field("cwd", cwd)) } + } + switch schema { + case .v1: parts.append("\"enabled\": \(enabled)") + case .v2: if !enabled { parts.append("\"disabled\": true") } + } + case .codex: + break // handled by the TOML writer + } + parts.append(contentsOf: preserved) + return "{ " + parts.joined(separator: ", ") + " }" + } + + private static func stdioJsonFields(_ def: PortableMcpDefinition, + envKey: String, dialect: McpValueExpr.Dialect) -> [String] { + var parts: [String] = [] + if let c = def.command { parts.append(field("command", c)) } + if !def.args.isEmpty { parts.append("\"args\": \(renderArray(def.args))") } + if !def.env.isEmpty { parts.append("\"\(envKey)\": \(renderEnv(def.env, dialect))") } + if let cwd = def.cwd { parts.append(field("cwd", cwd)) } + return parts + } + + // MARK: - Render helpers + + private static func field(_ key: String, _ value: String) -> String { + "\(JsonSurgeon.renderJsonString(key)): \(JsonSurgeon.renderJsonString(value))" + } + + private static func renderArray(_ items: [String]) -> String { + "[" + items.map { JsonSurgeon.renderJsonString($0) }.joined(separator: ", ") + "]" + } + + private static func renderEnv(_ env: [String: McpValueExpr], _ dialect: McpValueExpr.Dialect) -> String { + let body = env.keys.sorted().map { k in + "\(JsonSurgeon.renderJsonString(k)): \(JsonSurgeon.renderJsonString(renderExpr(env[k]!, dialect)))" + }.joined(separator: ", ") + return "{ \(body) }" + } + + /// Render a typed interpolation back into a harness's dialect. Cross-dialect features + /// without an equivalent (opencode has no `:-default`; the `${}` dialects have no + /// file-ref) degrade to the closest representable form rather than emitting something + /// the target can't parse. + private static func renderExpr(_ e: McpValueExpr, _ dialect: McpValueExpr.Dialect) -> String { + switch dialect { + case .dollar: + switch e { + case .literal(let s): return s + case .envVar(let v): return "${\(v)}" + case .envVarDefault(let v, let d): return "${\(v):-\(d)}" + case .fileRef(let p): return p + } + case .opencode: + switch e { + case .literal(let s): return s + case .envVar(let v): return "{env:\(v)}" + case .envVarDefault(let v, _): return "{env:\(v)}" + case .fileRef(let p): return "{file:\(p)}" + } + case .literal: + switch e { + case .literal(let s): return s + case .envVar(let v): return v + case .envVarDefault(let v, _): return v + case .fileRef(let p): return p + } + } + } + + private static func claudeRemoteType(_ t: McpTransport?) -> String { + switch t { + case .sse: return "sse" + case .ws: return "ws" + case .streamableHttp: return "streamable-http" + default: return "http" + } + } +} diff --git a/loadout/apps/macOS/Sources/Services/Mcp/McpWriteEngine.swift b/loadout/apps/macOS/Sources/Services/Mcp/McpWriteEngine.swift new file mode 100644 index 0000000..8803b17 --- /dev/null +++ b/loadout/apps/macOS/Sources/Services/Mcp/McpWriteEngine.swift @@ -0,0 +1,189 @@ +import Foundation + +/// The write pipeline for a single server in a single harness config. Ties the format +/// codecs (`McpJsonWriter` / `TomlMcpWriter`) to the atomic, verified disk write that the +/// safety invariants demand: +/// +/// read bytes → refuse if malformed → patch in memory → verify the result parses → +/// confirm on-disk bytes are unchanged since the read → write a temp file in the same dir +/// (perms preserved) → fsync → atomic rename → re-parse the written file, restoring the +/// original on any mismatch. +/// +/// Idempotent: a patch that produces byte-identical text writes nothing. Never "fixes" a +/// malformed file. opencode's schema (v1/v2) is detected from the existing file and +/// preserved; a brand-new file uses v1. +enum McpWriteEngine { + struct WriteError: Error, CustomStringConvertible { + let message: String + var description: String { message } + } + + enum Op { + case upsert(name: String, def: PortableMcpDefinition, enabled: Bool) + case remove(name: String) + } + + // MARK: - Pure patch (no disk) — also the unit-testable core + + /// Compute the new file text for `op` at `location`, given the file's current contents + /// (nil/empty if absent). Throws if the current file is malformed (we never edit blind). + static func patchedText(current: String?, location: McpConfigLocation, op: Op) throws -> String { + let text = current ?? "" + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + try assertParses(text, format: location.format) // refuse to edit a malformed file + } + let schema = detectSchema(text, location: location) + let keyPath = effectiveKeyPath(location, schema: schema) + + switch location.format { + case .json, .jsonc: + switch op { + case let .upsert(name, def, enabled): + return try McpJsonWriter.upsert(source: text, harness: location.harness, + keyPath: keyPath, name: name, def: def, + enabled: enabled, schema: schema) + case let .remove(name): + return try McpJsonWriter.remove(source: text, keyPath: keyPath, name: name) + } + case .toml: + switch op { + case let .upsert(name, def, enabled): + return try TomlMcpWriter.upsert(source: text, name: name, def: def, enabled: enabled) + case let .remove(name): + return try TomlMcpWriter.remove(source: text, name: name) + } + } + } + + // MARK: - Disk apply (atomic, verified) + + static func apply(_ op: Op, at location: McpConfigLocation) throws { + let fm = FileManager.default + let path = location.url.path + let existed = fm.fileExists(atPath: path) + let originalData: Data? = existed ? try Data(contentsOf: location.url) : nil + let originalPerms = existed + ? (try? fm.attributesOfItem(atPath: path))?[.posixPermissions] as? NSNumber : nil + let current = originalData.map { String(decoding: $0, as: UTF8.self) } + + let newText = try patchedText(current: current, location: location, op: op) + + // Idempotent: nothing to write. + if newText == (current ?? "") { return } + if !existed, case .remove = op { return } + + // Verify the patched text parses (catch any writer bug BEFORE touching disk). + try assertParses(newText, format: location.format) + try assertMembership(newText, location: location, op: op) + + // Concurrent-modification guard: bytes must be what we read. + if existed, try Data(contentsOf: location.url) != originalData { + throw WriteError(message: "\(location.url.lastPathComponent) changed on disk during the edit — aborted, nothing written") + } + + try atomicWrite(Data(newText.utf8), to: location.url, perms: originalPerms) + + // Re-parse the written file; restore the original on any mismatch. + do { + let back = String(decoding: try Data(contentsOf: location.url), as: UTF8.self) + try assertParses(back, format: location.format) + try assertMembership(back, location: location, op: op) + } catch { + if let originalData { try? originalData.write(to: location.url) } + throw WriteError(message: "post-write verification failed; restored \(location.url.lastPathComponent)") + } + } + + // MARK: - Atomic write + + private static func atomicWrite(_ data: Data, to url: URL, perms: NSNumber?) throws { + let fm = FileManager.default + let dir = url.deletingLastPathComponent() + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + let tmp = dir.appendingPathComponent(".\(url.lastPathComponent).loadout-\(UUID().uuidString)") + do { + try data.write(to: tmp, options: .atomic) + if let fh = try? FileHandle(forWritingTo: tmp) { + try? fh.synchronize() // fsync the bytes before the rename + try? fh.close() + } + if let perms { try? fm.setAttributes([.posixPermissions: perms], ofItemAtPath: tmp.path) } + // Atomic, same-filesystem replace. + if rename(tmp.path, url.path) != 0 { + throw WriteError(message: "atomic rename failed (errno \(errno))") + } + } catch { + try? fm.removeItem(at: tmp) + throw error is WriteError ? error : WriteError(message: "write failed: \(error.localizedDescription)") + } + } + + // MARK: - Schema / key path + + private static func effectiveKeyPath(_ loc: McpConfigLocation, schema: OpencodeSchema) -> [String] { + if loc.harness == .opencode, schema == .v2 { return loc.keyPath + ["servers"] } + return loc.keyPath + } + + private static func detectSchema(_ text: String, location: McpConfigLocation) -> OpencodeSchema { + guard location.harness == .opencode, + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let top = jsonObject(text, format: location.format), + let mcp = navigate(top, location.keyPath), + mcp["servers"] is [String: Any] else { return .v1 } + return .v2 + } + + // MARK: - Verification + + private static func assertParses(_ text: String, format: McpConfigFormat) throws { + switch format { + case .json, .jsonc: + if jsonObject(text, format: format) == nil { + throw WriteError(message: "config is not valid JSON") + } + case .toml: + if (try? TomlMiniReader.parse(text)) == nil { + throw WriteError(message: "config is not valid TOML") + } + } + } + + /// Confirm the op actually took: the server is present after upsert, absent after remove. + private static func assertMembership(_ text: String, location: McpConfigLocation, op: Op) throws { + let map: [String: Any]? + switch location.format { + case .json, .jsonc: + let schema = detectSchema(text, location: location) + map = jsonObject(text, format: location.format) + .flatMap { navigate($0, effectiveKeyPath(location, schema: schema)) } + case .toml: + map = (try? TomlMiniReader.parse(text))?["mcp_servers"] as? [String: Any] + } + switch op { + case let .upsert(name, _, _): + if map?[name] == nil { throw WriteError(message: "server '\(name)' missing from patched config") } + case let .remove(name): + if map?[name] != nil { throw WriteError(message: "server '\(name)' still present after removal") } + } + } + + // MARK: - JSON helpers + + private static func jsonObject(_ text: String, format: McpConfigFormat) -> [String: Any]? { + // Strip comments/trailing commas for BOTH json and jsonc: the surgeon edits + // comment-bearing files fine, so verification must accept them too (and real + // `.mcp.json` files do sometimes carry comments). + let src = McpConfigCodec.stripJsonc(text) + return (try? JSONSerialization.jsonObject(with: Data(src.utf8))) as? [String: Any] + } + + private static func navigate(_ top: [String: Any], _ keyPath: [String]) -> [String: Any]? { + var cur = top + for key in keyPath { + guard let next = cur[key] as? [String: Any] else { return nil } + cur = next + } + return cur + } +} diff --git a/loadout/apps/macOS/Sources/Services/Mcp/TomlMcpWriter.swift b/loadout/apps/macOS/Sources/Services/Mcp/TomlMcpWriter.swift new file mode 100644 index 0000000..b2de3b6 --- /dev/null +++ b/loadout/apps/macOS/Sources/Services/Mcp/TomlMcpWriter.swift @@ -0,0 +1,338 @@ +import Foundation + +/// Codex (`config.toml`) writer. TOML has no closing delimiter for a table, so edits are +/// done textually and precisely: locate the target `[mcp_servers.]` block, re-render +/// only the keys this writer manages, and copy every other line — comments, agent-local +/// keys (`bearer_token_env_var`, `http_headers`, `env_http_headers`, …) and non-`env` +/// subtables — through verbatim. Everything outside the one entry's block is byte-preserved. +/// +/// It FAILS CLOSED: if `mcp_servers` is expressed in a shape it can't safely rewrite +/// (top-level dotted keys `mcp_servers.x. …`, an inline `mcp_servers = { … }` table, a plain +/// `[mcp_servers]` table, or an array-of-tables), it refuses to edit and surfaces why, +/// rather than risk corrupting the file. +enum TomlMcpWriter { + struct WriteError: Error { let message: String } + + static let managedKeys: Set = ["command", "args", "env", "url", "enabled"] + + static func upsert(source: String, name: String, + def: PortableMcpDefinition, enabled: Bool) throws -> String { + try assertSafe(source) + // An empty/whitespace file starts from no lines, so a fresh block isn't preceded by + // a spurious blank line. + var lines = source.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? [] : source.components(separatedBy: "\n") + let rendered = renderBlock(name: name, def: def, enabled: enabled) + + if let block = try findBlock(lines, name: name) { + let preserved = preservedLines(lines, block: block, name: name) + let newBlock = ["[mcp_servers.\(tomlKeySegment(name))]"] + rendered + preserved + lines.replaceSubrange(block, with: newBlock) + } else { + // INSERT: append a fresh block at EOF, separated by one blank line. + if let last = lines.last, !last.trimmingCharacters(in: .whitespaces).isEmpty { + lines.append("") + } else if lines.isEmpty { + // empty file: no separator needed + } + lines.append("[mcp_servers.\(tomlKeySegment(name))]") + lines.append(contentsOf: rendered) + } + return lines.joined(separator: "\n") + } + + static func remove(source: String, name: String) throws -> String { + try assertSafe(source) + var lines = source.components(separatedBy: "\n") + guard let block = try findBlock(lines, name: name) else { return source } + // Also swallow a single trailing blank line so blocks don't accumulate gaps. + var upper = block.upperBound + if upper < lines.count, lines[upper].trimmingCharacters(in: .whitespaces).isEmpty { upper += 1 } + lines.removeSubrange(block.lowerBound..] entries first") + } + if isArray, segs.first == "mcp_servers" { + throw WriteError(message: "refusing to edit: mcp_servers uses an array-of-tables form this editor can't safely rewrite") + } + atRoot = false + } else if atRoot { + // A root-scope assignment to mcp_servers in dotted or inline form is unsafe. + if t.hasPrefix("mcp_servers") { + let after = t.dropFirst("mcp_servers".count).drop(while: { $0 == " " }) + if after.first == "." || after.first == "=" { + throw WriteError(message: "refusing to edit: mcp_servers uses a dotted/inline key form this editor can't safely rewrite") + } + } + } + } + } + + // MARK: - Block location + + /// The line range of `[mcp_servers.]` and all of its subtables. Throws if the entry + /// is defined more than once — duplicate tables are invalid TOML, and editing only the + /// first would silently leave a stale second copy that slips past write verification. + private static func findBlock(_ lines: [String], name: String) throws -> Range? { + func isEntryHeader(_ line: String) -> Bool { + let t = line.trimmingCharacters(in: .whitespaces) + guard t.hasPrefix("[") else { return false } + let (segs, isArray) = headerSegments(t) + return !isArray && segs == ["mcp_servers", name] + } + let starts = lines.indices.filter { isEntryHeader(lines[$0]) } + guard let start = starts.first else { return nil } + if starts.count > 1 { + throw WriteError(message: "refusing to edit: \(starts.count) [mcp_servers.\(name)] tables present — remove the duplicate first") + } + + var end = start + 1 + while end < lines.count { + let t = lines[end].trimmingCharacters(in: .whitespaces) + if t.hasPrefix("[") { + let (segs, _) = headerSegments(t) + // A subtable of this entry (mcp_servers..*) stays in the block. + if segs.count >= 2, segs[0] == "mcp_servers", segs[1] == name { end += 1; continue } + break + } + end += 1 + } + return start.., name: String) -> [String] { + var out: [String] = [] + var i = block.lowerBound + 1 // skip the header itself + while i < block.upperBound { + let line = lines[i] + let t = line.trimmingCharacters(in: .whitespaces) + if t.hasPrefix("[") { + // a subtable group: header + body until next header / block end + let subStart = i + let (segs, _) = headerSegments(t) + // segs = [mcp_servers, , , …]; the first subtable key decides. + let subKey = segs.count >= 3 ? segs[2] : "" + i += 1 + while i < block.upperBound, !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("[") { i += 1 } + if subKey != "env" { + out.append(contentsOf: lines[subStart.. subStart, lines[j - 1].trimmingCharacters(in: .whitespaces).isEmpty { j -= 1 } + out.append(contentsOf: lines[j.. 0 { + bal += bracketBalance(lines[i]); i += 1 + } + let key = keyName(line) + if !managedKeys.contains(key) { out.append(contentsOf: lines[grpStart.. [String] { + var out: [String] = [] + switch def.kind { + case .remote: + if let u = def.url { out.append("url = \(tomlString(u))") } + case .stdio: + if let c = def.command { out.append("command = \(tomlString(c))") } + if !def.args.isEmpty { out.append("args = \(tomlArray(def.args))") } + if !def.env.isEmpty { out.append("env = \(tomlInlineTable(def.env))") } + } + if !enabled { out.append("enabled = false") } + return out + } + + // MARK: - TOML literal helpers + + private static func tomlString(_ s: String) -> String { + var out = "\"" + for ch in s.unicodeScalars { + switch ch { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\t": out += "\\t" + case "\r": out += "\\r" + default: + if ch.value < 0x20 { out += String(format: "\\u%04X", ch.value) } + else { out.unicodeScalars.append(ch) } + } + } + return out + "\"" + } + + private static func tomlArray(_ items: [String]) -> String { + "[" + items.map { tomlString($0) }.joined(separator: ", ") + "]" + } + + private static func tomlInlineTable(_ env: [String: McpValueExpr]) -> String { + let body = env.keys.sorted().map { k in + "\(tomlKeySegment(k)) = \(tomlString(exprLiteral(env[k]!)))" + }.joined(separator: ", ") + return "{ \(body) }" + } + + /// Codex values are literal TOML strings — render the typed expression back to text. + private static func exprLiteral(_ e: McpValueExpr) -> String { + switch e { + case .literal(let s): return s + case .envVar(let v): return "${\(v)}" + case .envVarDefault(let v, let d): return "${\(v):-\(d)}" + case .fileRef(let p): return p + } + } + + /// A TOML key segment: bare if it's a valid bare key, otherwise a quoted key. + private static func tomlKeySegment(_ s: String) -> String { + let bare = !s.isEmpty && s.allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" } + return bare ? s : tomlString(s) + } + + // MARK: - Parsing helpers + + /// `[a."b.c".d]` / `[[a.b]]` → (unquoted dotted segments, isArray). Quoted segments are + /// unquoted so a name like `my.server` (written as `[mcp_servers."my.server"]`) compares + /// equal to the raw `name` the codec carries — otherwise insert/find/remove disagree and + /// the writer appends duplicate blocks. Stops at the first top-level `]`. + private static func headerSegments(_ t: String) -> (segments: [String], isArray: Bool) { + var s = Substring(t) + var isArray = false + if s.hasPrefix("[[") { isArray = true; s = s.dropFirst(2) } + else if s.hasPrefix("[") { s = s.dropFirst(1) } + // The header ends at the first `]` that's NOT inside a quoted segment. + var inBasic = false, inLiteral = false, prevBackslash = false + var end = s.endIndex + var k = s.startIndex + while k < s.endIndex { + let ch = s[k] + if inBasic { + if ch == "\"" && !prevBackslash { inBasic = false } + prevBackslash = (ch == "\\" && !prevBackslash) + } else if inLiteral { + if ch == "'" { inLiteral = false } + } else if ch == "\"" { inBasic = true; prevBackslash = false } + else if ch == "'" { inLiteral = true } + else if ch == "]" { end = k; break } + k = s.index(after: k) + } + return (splitDottedKey(String(s[s.startIndex.. [String] { + var segments: [String] = [] + var cur = "" + var inBasic = false, inLiteral = false, prevBackslash = false + func flush() { + segments.append(unquoteSegment(cur.trimmingCharacters(in: .whitespaces))) + cur = "" + } + for ch in s { + if inBasic { + cur.append(ch) + if ch == "\"" && !prevBackslash { inBasic = false } + prevBackslash = (ch == "\\" && !prevBackslash) + } else if inLiteral { + cur.append(ch) + if ch == "'" { inLiteral = false } + } else if ch == "\"" { inBasic = true; prevBackslash = false; cur.append(ch) } + else if ch == "'" { inLiteral = true; cur.append(ch) } + else if ch == "." { flush() } + else { cur.append(ch) } + } + flush() + return segments + } + + /// Strip basic/literal quotes from a single key segment (literal verbatim; basic decodes + /// the escapes `tomlString` emits). A bare segment passes through unchanged. + private static func unquoteSegment(_ seg: String) -> String { + if seg.count >= 2, seg.hasPrefix("\""), seg.hasSuffix("\"") { + return decodeBasicEscapes(String(seg.dropFirst().dropLast())) + } + if seg.count >= 2, seg.hasPrefix("'"), seg.hasSuffix("'") { + return String(seg.dropFirst().dropLast()) + } + return seg + } + + private static func decodeBasicEscapes(_ s: String) -> String { + var out = "" + var esc = false + for ch in s { + if esc { + switch ch { + case "n": out.append("\n") + case "t": out.append("\t") + case "r": out.append("\r") + default: out.append(ch) // \" \\ and anything else → literal char + } + esc = false + } else if ch == "\\" { esc = true } + else { out.append(ch) } + } + return out + } + + private static func keyName(_ line: String) -> String { + let t = line.trimmingCharacters(in: .whitespaces) + var out = "" + for ch in t { + if ch.isLetter || ch.isNumber || ch == "_" || ch == "-" { out.append(ch) } + else { break } + } + return out + } + + /// Net bracket/brace depth on a line, ignoring brackets inside TOML strings. + private static func bracketBalance(_ line: String) -> Int { + var depth = 0 + var inBasic = false, inLiteral = false + var prevBackslash = false + for ch in line { + if inBasic { + if ch == "\"" && !prevBackslash { inBasic = false } + prevBackslash = (ch == "\\" && !prevBackslash) + continue + } + if inLiteral { if ch == "'" { inLiteral = false }; continue } + switch ch { + case "\"": inBasic = true; prevBackslash = false + case "'": inLiteral = true + case "[", "{": depth += 1 + case "]", "}": depth -= 1 + case "#": return depth // rest is a comment + default: break + } + } + return depth + } +} diff --git a/loadout/apps/macOS/Sources/Services/McpConfigCodec.swift b/loadout/apps/macOS/Sources/Services/McpConfigCodec.swift new file mode 100644 index 0000000..c26ddad --- /dev/null +++ b/loadout/apps/macOS/Sources/Services/McpConfigCodec.swift @@ -0,0 +1,275 @@ +import Foundation + +/// READ side of the MCP config layer: locate a config file, parse it in its format, find +/// the server map at the location's key path, and normalize each raw entry into a +/// per-harness `AgentMcpEntry` (portable definition + enabled + agent-local field names). +/// +/// Parsing is tolerant of ABSENCE (missing file or missing key → no servers) but strict on +/// MALFORMED content: an unreadable/invalid file returns `.malformed` and is never "fixed". +/// opencode is read as a versioned codec — v1 (`mcp` map, `enabled`) and v2 +/// (`mcp.servers`, `disabled`) both decode here. +enum McpConfigCodec { + enum ReadResult { + case absent // file or key path not present → no servers + case malformed(String) // file present but unparsable / wrong shape + case ok([String: AgentMcpEntry]) // serverName → normalized entry + } + + static func read(_ loc: McpConfigLocation) -> ReadResult { + let fm = FileManager.default + guard fm.fileExists(atPath: loc.url.path) else { return .absent } + guard let data = try? Data(contentsOf: loc.url) else { + return .malformed("could not read \(loc.url.lastPathComponent)") + } + + let top: [String: Any] + switch loc.format { + case .json, .jsonc: + // Comment/trailing-comma tolerant for both: the write path edits such files + // losslessly, so the read path must not reject them as malformed. + let stripped = stripJsonc(String(decoding: data, as: UTF8.self)) + guard let obj = try? JSONSerialization.jsonObject( + with: Data(stripped.utf8)) as? [String: Any] else { + return .malformed("invalid JSON in \(loc.url.lastPathComponent)") + } + top = obj + case .toml: + do { + top = try TomlMiniReader.parse(String(decoding: data, as: UTF8.self)) + } catch { + let reason = (error as? TomlMiniReader.ParseError)?.message ?? "\(error)" + return .malformed("invalid TOML in \(loc.url.lastPathComponent): \(reason)") + } + } + + // Navigate the key path; a missing intermediate just means "no servers here". + guard let container = navigate(top, loc.keyPath) else { return .absent } + + // Resolve the actual server map (opencode may nest it under v2 `servers`). + let serverMap: [String: Any] + let schema: OpencodeSchema + if loc.harness == .opencode, let nested = container["servers"] as? [String: Any] { + serverMap = nested + schema = .v2 + } else { + serverMap = container + schema = .v1 + } + + var out: [String: AgentMcpEntry] = [:] + for (name, raw) in serverMap { + guard let def = raw as? [String: Any] else { + out[name] = AgentMcpEntry(portable: nil, enabled: true, agentLocalFields: []) + continue + } + out[name] = normalize(def, harness: loc.harness, schema: schema) + } + return .ok(out) + } + + // MARK: - Navigation + + private static func navigate(_ top: [String: Any], _ keyPath: [String]) -> [String: Any]? { + var cur: [String: Any] = top + for key in keyPath { + guard let next = cur[key] as? [String: Any] else { return nil } + cur = next + } + return cur + } + + // MARK: - Per-harness normalization + + private static func normalize(_ def: [String: Any], harness: McpHarness, + schema: OpencodeSchema) -> AgentMcpEntry { + switch harness { + case .claudeCode, .cursor: + return normalizeJsonStyle(def, harness: harness) + case .codex: + return normalizeCodex(def) + case .opencode: + return normalizeOpencode(def, schema: schema) + } + } + + /// Claude Code & Cursor: `command`/`args`/`env` (string + array), remote via `type`/`url`. + /// Interpolation dialect is `${VAR}`. No per-entry enable flag → always enabled when present. + private static func normalizeJsonStyle(_ def: [String: Any], harness: McpHarness) -> AgentMcpEntry { + let localKeys = harness == .cursor + ? ["auth", "envFile", "headers"] + : ["oauth", "headers", "headersHelper"] + let agentLocal = localKeys.filter { def[$0] != nil } + + let typeStr = (def["type"] as? String)?.lowercased() + let url = def["url"] as? String + if url != nil || isRemoteType(typeStr) { + let portable = PortableMcpDefinition( + kind: .remote, command: nil, args: [], env: [:], cwd: nil, + url: url, remoteTransport: remoteTransport(from: typeStr)) + return AgentMcpEntry(portable: portable, enabled: true, + agentLocalFields: agentLocal) + } + guard let command = def["command"] as? String else { + return AgentMcpEntry(portable: nil, enabled: true, agentLocalFields: agentLocal) + } + let portable = PortableMcpDefinition( + kind: .stdio, command: command, args: stringArray(def["args"]), + env: envMap(def["env"], dialect: .dollar), cwd: def["cwd"] as? String, + url: nil, remoteTransport: nil) + return AgentMcpEntry(portable: portable, enabled: true, + agentLocalFields: agentLocal) + } + + /// Codex: stdio `command`/`args`/`env` (literal values), remote inferred from `url` + /// (streamable-HTTP only). Disable via `enabled = false`. + private static func normalizeCodex(_ def: [String: Any]) -> AgentMcpEntry { + let agentLocal = ["bearer_token_env_var", "bearers", "http_headers", "env_http_headers", "env_vars"] + .filter { def[$0] != nil } + let enabled = (def["enabled"] as? Bool) ?? true + + if let url = def["url"] as? String { + let portable = PortableMcpDefinition( + kind: .remote, command: nil, args: [], env: [:], cwd: nil, + url: url, remoteTransport: .streamableHttp) + return AgentMcpEntry(portable: portable, enabled: enabled, + agentLocalFields: agentLocal) + } + guard let command = def["command"] as? String else { + return AgentMcpEntry(portable: nil, enabled: enabled, agentLocalFields: agentLocal) + } + let portable = PortableMcpDefinition( + kind: .stdio, command: command, args: stringArray(def["args"]), + env: envMap(def["env"], dialect: .literal), cwd: def["cwd"] as? String, + url: nil, remoteTransport: nil) + return AgentMcpEntry(portable: portable, enabled: enabled, + agentLocalFields: agentLocal) + } + + /// opencode: `type:"local"` with `command:[cmd,...args]` (MERGED array) + `environment` + /// (full word), or `type:"remote"` with `url`. Interpolation dialect `{env:VAR}`. + /// Disable: v1 `enabled:false`, v2 `disabled:true`. + private static func normalizeOpencode(_ def: [String: Any], schema: OpencodeSchema) -> AgentMcpEntry { + let agentLocal = ["headers"].filter { def[$0] != nil } + let enabled: Bool + switch schema { + case .v1: enabled = (def["enabled"] as? Bool) ?? true + case .v2: enabled = !((def["disabled"] as? Bool) ?? false) + } + + let type = (def["type"] as? String)?.lowercased() + if type == "remote" || def["url"] != nil { + let portable = PortableMcpDefinition( + kind: .remote, command: nil, args: [], env: [:], cwd: nil, + url: def["url"] as? String, remoteTransport: .http) + return AgentMcpEntry(portable: portable, enabled: enabled, + agentLocalFields: agentLocal) + } + // local: command is a single array merging command + args. + let merged = stringArray(def["command"]) + guard let command = merged.first else { + return AgentMcpEntry(portable: nil, enabled: enabled, agentLocalFields: agentLocal) + } + let portable = PortableMcpDefinition( + kind: .stdio, command: command, args: Array(merged.dropFirst()), + env: envMap(def["environment"], dialect: .opencode), cwd: def["cwd"] as? String, + url: nil, remoteTransport: nil) + return AgentMcpEntry(portable: portable, enabled: enabled, + agentLocalFields: agentLocal) + } + + // MARK: - Field helpers + + private static func isRemoteType(_ t: String?) -> Bool { + guard let t else { return false } + return ["http", "streamable-http", "streamable_http", "sse", "ws"].contains(t) + } + + private static func remoteTransport(from t: String?) -> McpTransport { + switch t { + case "sse": return .sse + case "ws": return .ws + case "streamable-http", "streamable_http": return .streamableHttp + default: return .http + } + } + + private static func stringArray(_ v: Any?) -> [String] { + if let a = v as? [String] { return a } + if let a = v as? [Any] { return a.compactMap { $0 as? String } } + return [] + } + + private static func envMap(_ v: Any?, dialect: McpValueExpr.Dialect) -> [String: McpValueExpr] { + guard let dict = v as? [String: Any] else { return [:] } + var out: [String: McpValueExpr] = [:] + for (k, raw) in dict { + let s = (raw as? String) ?? String(describing: raw) + out[k] = McpValueExpr.parse(s, dialect: dialect) + } + return out + } + + // MARK: - JSONC + + /// Strip `//` and `/* */` comments and trailing commas, respecting string literals, so + /// an opencode `.jsonc` can be fed to `JSONSerialization`. Read-only; never written back. + static func stripJsonc(_ text: String) -> String { + var out = String.UnicodeScalarView() + let s = Array(text.unicodeScalars) + var i = 0 + var inString = false + while i < s.count { + let c = s[i] + if inString { + out.append(c) + if c == "\\", i + 1 < s.count { out.append(s[i + 1]); i += 2; continue } + if c == "\"" { inString = false } + i += 1 + continue + } + if c == "\"" { inString = true; out.append(c); i += 1; continue } + if c == "/", i + 1 < s.count, s[i + 1] == "/" { + i += 2 + while i < s.count, s[i] != "\n" { i += 1 } + continue + } + if c == "/", i + 1 < s.count, s[i + 1] == "*" { + i += 2 + while i + 1 < s.count, !(s[i] == "*" && s[i + 1] == "/") { i += 1 } + i += 2 + continue + } + out.append(c) + i += 1 + } + return stripTrailingCommas(String(out)) + } + + /// Remove commas that immediately precede `}` or `]` (whitespace allowed between), + /// respecting strings. Cheap second pass over the comment-free text. + private static func stripTrailingCommas(_ text: String) -> String { + let s = Array(text.unicodeScalars) + var keep = [Bool](repeating: true, count: s.count) + var inString = false + var i = 0 + while i < s.count { + let c = s[i] + if inString { + if c == "\\" { i += 2; continue } + if c == "\"" { inString = false } + i += 1 + continue + } + if c == "\"" { inString = true; i += 1; continue } + if c == "," { + var j = i + 1 + while j < s.count, s[j] == " " || s[j] == "\t" || s[j] == "\n" || s[j] == "\r" { j += 1 } + if j < s.count, s[j] == "}" || s[j] == "]" { keep[i] = false } + } + i += 1 + } + var out = String.UnicodeScalarView() + for k in 0.. McpScanResult { + var feeds: [Feed] = [] + for h in McpHarness.allCases { + feeds.append(Feed(logicalLocation: "", harness: h, + locations: McpConfigDescriptor.globalLocations(h))) + } + return collapse(feeds, scope: .global) + } + + static func scanProject(root: URL) -> McpScanResult { + var feeds: [Feed] = [] + for base in SkillScanner.projectBases(from: root, io: LocalHostIO()) { + let logical = locationLabel(base: base, root: root) + for h in McpHarness.allCases { + feeds.append(Feed(logicalLocation: logical, harness: h, + locations: McpConfigDescriptor.projectLocations(h, base: base))) + } + } + return collapse(feeds, scope: .project(root: root.path)) + } + + /// Paths to hand the FileWatcher. FSEvents watches a directory RECURSIVELY, so we must + /// never hand it the home directory or a project root — both churn constantly (and + /// `~/.claude.json` in particular is rewritten by Claude Code itself many times a + /// minute), which would put the MCP view into an endless reload loop. + /// + /// Rule: watch a config file's PARENT only when that parent is a dedicated config dir + /// (`.codex`, `.cursor`, `.config/opencode`); when the parent is home or a project root, + /// watch the file itself instead — except `~/.claude.json`, which is too high-churn to + /// watch at all (its MCP edits are picked up on the next manual refresh). + static func configWatchPaths(global: Bool, root: URL?) -> [String] { + let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + let claudeUserFile = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude.json").standardizedFileURL.path + var excludedDirs: Set = [homePath] + + var locations: [McpConfigLocation] = [] + if global { + for h in McpHarness.allCases { locations += McpConfigDescriptor.globalLocations(h) } + } else if let root { + for base in SkillScanner.projectBases(from: root, io: LocalHostIO()) { + excludedDirs.insert(base.standardizedFileURL.path) + for h in McpHarness.allCases { locations += McpConfigDescriptor.projectLocations(h, base: base) } + } + } + + var paths = Set() + for l in locations { + let parent = l.url.deletingLastPathComponent().standardizedFileURL.path + if excludedDirs.contains(parent) { + // Parent too broad to watch recursively — watch the file directly, unless + // it's the constantly-rewritten Claude user config. + if l.url.standardizedFileURL.path != claudeUserFile { paths.insert(l.url.path) } + } else { + paths.insert(parent) + } + } + return Array(paths) + } + + // MARK: - Core + + private struct Feed { + let logicalLocation: String + let harness: McpHarness + let locations: [McpConfigLocation] + } + + private struct Key: Hashable { let logicalLocation: String; let name: String } + + private struct Acc { + var entries: [McpHarness: AgentMcpEntry] = [:] + var origins: [McpHarness: [McpConfigLocation]] = [:] + var conflicted: Set = [] + var primaryPath: [McpHarness: String] = [:] // file path of the primary origin + } + + private static func collapse(_ feeds: [Feed], scope: ResourceScope) -> McpScanResult { + var byKey: [Key: Acc] = [:] + var issues: [McpConfigIssue] = [] + + for feed in feeds { + // Primary location ordered first → first write wins, later origins only annotate. + for loc in feed.locations { + switch McpConfigCodec.read(loc) { + case .absent: + continue + case .malformed(let reason): + issues.append(McpConfigIssue(harness: feed.harness, label: loc.label, + path: loc.url.path, reason: reason)) + case .ok(let entriesByName): + for (name, entry) in entriesByName { + let key = Key(logicalLocation: feed.logicalLocation, name: name) + var acc = byKey[key] ?? Acc() + if let existing = acc.entries[feed.harness] { + // A second origin for the same harness — keep the primary, but + // flag a conflict if the definitions genuinely disagree. + if existing.portable?.signature != entry.portable?.signature { + acc.conflicted.insert(feed.harness) + } + acc.origins[feed.harness, default: []].append(loc) + } else { + acc.entries[feed.harness] = entry + acc.origins[feed.harness] = [loc] + acc.primaryPath[feed.harness] = loc.url.path + } + byKey[key] = acc + } + } + } + } + + // Git status: classify every primary config-file path in one batch. + let allPaths = byKey.values.flatMap { Array($0.primaryPath.values) } + let git = GitStatusService.classify(paths: Array(Set(allPaths)), io: LocalHostIO()) + + var servers: [McpServer] = [] + for (key, acc) in byKey { + var gitByHarness: [McpHarness: GitStatus] = [:] + for (h, path) in acc.primaryPath { gitByHarness[h] = git[path] ?? .notInRepo } + servers.append(McpServer( + name: key.name, + scope: scope, + logicalLocation: key.logicalLocation, + entries: acc.entries, + origins: acc.origins, + gitStatusByHarness: gitByHarness, + conflictedHarnesses: acc.conflicted)) + } + + servers.sort { + if $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedSame { + return $0.logicalLocation < $1.logicalLocation + } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + return McpScanResult(servers: servers, issues: issues.sorted { $0.path < $1.path }) + } + + /// Project-relative label for a config base dir: "" = chosen root, "↑ " = an + /// ancestor above it, otherwise the path relative to the root (a monorepo subpackage). + private static func locationLabel(base: URL, root: URL) -> String { + let bp = base.standardizedFileURL.path + let rp = root.standardizedFileURL.path + if bp == rp { return "" } + if bp.hasPrefix(rp + "/") { return String(bp.dropFirst(rp.count + 1)) } + return "↑ " + base.lastPathComponent + } +} diff --git a/loadout/apps/macOS/Sources/Services/RemoteHostIO.swift b/loadout/apps/macOS/Sources/Services/RemoteHostIO.swift new file mode 100644 index 0000000..c9ecae7 --- /dev/null +++ b/loadout/apps/macOS/Sources/Services/RemoteHostIO.swift @@ -0,0 +1,225 @@ +import Foundation + +/// In-memory, session-only store of remote passwords keyed by ssh target. Populated when +/// the user is prompted (key auth failed); never written to disk or Keychain, and cleared +/// when the host is deselected/removed or the app quits. RemoteHostIO reads it per call. +enum RemoteCredentials { + private static let lock = NSLock() + nonisolated(unsafe) private static var store: [String: String] = [:] + + static func password(for target: String) -> String? { + lock.lock(); defer { lock.unlock() } + return store[target] + } + static func set(_ password: String, for target: String) { + lock.lock(); defer { lock.unlock() } + store[target] = password + } + static func clear(_ target: String) { + lock.lock(); defer { lock.unlock() } + store[target] = nil + } +} + +/// The outcome of a connectivity probe — drives the "prompt for password" flow. +enum ConnectResult: Equatable { + case ok // authenticated (key, or a previously-entered password) + case needsPassword // reachable but auth was refused → ask the user + case failed(String) // unreachable / DNS / timeout / other — show the message +} + +/// `HostIO` over the user's own `ssh` (already configured with their keys / `~/.ssh/config` +/// / `known_hosts`), so Loadout scans a remote machine with the same scanner code as local. +/// +/// **Auth.** Key-based by default (`BatchMode=yes`, never blocks). If no key works, the UI +/// prompts for a password (held only in `RemoteCredentials` for the session); password auth +/// is delivered to ssh via `SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=force` (no `sshpass` +/// dependency, no controlling tty needed). The password reaches the helper through a child +/// env var (`LOADOUT_SSH_PASSWORD`) — never on disk. +/// // ponytail: the password is in the ssh process's environment during connect, so it's +/// // visible to the same user via `ps -E`. Acceptable for a single-user desktop; +/// // tighten to an fd-passed secret only if that threat model ever matters. +/// +/// **Performance.** A multiplexed ControlMaster socket means the first call pays the +/// TCP+auth handshake and every later `ssh` reuses it over a new channel. +/// +/// READ-ONLY: implements only the read/run surface of `HostIO` (see D7). `final class` + +/// `NSLock` because resolved `home`/`xdg` are cached once. +final class RemoteHostIO: HostIO, @unchecked Sendable { + private let target: String // "user@host" or an ~/.ssh/config alias + private let sshPath = "/usr/bin/ssh" + private let controlPath: String + + private let lock = NSLock() + private var cachedHome: URL? + private var cachedXDG: URL? + + init(target: String) { + self.target = target + let tag = String(UInt(bitPattern: target.hashValue), radix: 36).prefix(12) + // Short ControlPath under ~/.ssh to stay under the 104-byte AF_UNIX `sun_path` limit. + controlPath = (NSHomeDirectory() as NSString) + .appendingPathComponent(".ssh/loadout-cm-\(tag)") + } + + // MARK: - Connectivity probe + + /// Try to reach the host with whatever auth is currently available (key, or a password + /// already entered this session). Used before scanning so the UI can prompt on refusal. + func connect() -> ConnectResult { + let r = ssh("true") + return Self.classifyConnect(exit: r.exit, stderr: String(decoding: r.stderr, as: UTF8.self)) + } + + /// Classify a probe result (pure — unit-tested). Auth refusal ⇒ needsPassword; anything + /// else non-zero ⇒ failed with the ssh message. + static func classifyConnect(exit: Int32, stderr: String) -> ConnectResult { + if exit == 0 { return .ok } + let s = stderr.lowercased() + if s.contains("permission denied") || s.contains("publickey") + || s.contains("authentication failed") || s.contains("no more authentication methods") { + return .needsPassword + } + let msg = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return .failed(msg.isEmpty ? "Could not connect (ssh exit \(exit))." : msg) + } + + // MARK: - HostIO + + var home: URL { + lock.lock(); defer { lock.unlock() } + if let cachedHome { return cachedHome } + let h = firstLine(ssh("printf %s \"$HOME\"").stdout) + let url = URL(fileURLWithPath: h.isEmpty ? "/" : h) + cachedHome = url + return url + } + + var xdgConfigHome: URL { + lock.lock(); defer { lock.unlock() } + if let cachedXDG { return cachedXDG } + let x = firstLine(ssh("printf %s \"${XDG_CONFIG_HOME:-$HOME/.config}\"").stdout) + let url = URL(fileURLWithPath: x.isEmpty ? "/.config" : x) + cachedXDG = url + return url + } + + func exists(_ path: String) -> Bool { + ssh("test -e \(q(path))").exit == 0 + } + + func readFile(_ path: String) throws -> Data { + let r = ssh("cat -- \(q(path))") + guard r.exit == 0 else { + throw HostIOError.io(String(decoding: r.stderr, as: UTF8.self)) + } + return r.stdout + } + + func listDir(_ path: String) throws -> [DirEntry] { + // One round-trip: a POSIX sh snippet emits delimited records (unit-sep \037 between + // fields, record-sep \036 between entries) so filenames with spaces/newlines survive. + // `for f in *` skips dotfiles (matches local `.skipsHiddenFiles`); isSymlink via `-L` + // (entry's own type), isDir via `-d` (FOLLOWS links, matching local resourceValues). + // ponytail: assumes a POSIX shell + readlink on the remote (true for macOS/Linux); + // a remote without them yields an empty listing → "no skills". + let script = "cd -- \(q(path)) 2>/dev/null || exit 0; " + + "for f in *; do [ -e \"$f\" ] || [ -L \"$f\" ] || continue; " + + "if [ -L \"$f\" ]; then sl=1; lt=$(readlink \"$f\"); else sl=0; lt=; fi; " + + "if [ -d \"$f\" ]; then d=1; else d=0; fi; " + + "printf '%s\\037%s\\037%s\\037%s\\036' \"$f\" \"$d\" \"$sl\" \"$lt\"; done" + let r = ssh(script) + guard r.exit == 0 else { + throw HostIOError.io(String(decoding: r.stderr, as: UTF8.self)) + } + return Self.parseListDir(r.stdout) + } + + func realpath(_ path: String) -> String { + let r = ssh("realpath \(q(path)) 2>/dev/null || readlink -f \(q(path)) 2>/dev/null") + let resolved = firstLine(r.stdout) + return resolved.isEmpty ? path : resolved + } + + func run(_ argv: [String], cwd: String?, stdin: Data?) -> (exit: Int32, stdout: Data, stderr: Data) { + let prefix = cwd.map { "cd -- \(q($0)) && " } ?? "" + let remote = prefix + argv.map(q).joined(separator: " ") + return ssh(remote, stdin: stdin) + } + + // MARK: - Parsing (pure — unit-tested without a live host) + + /// Parse the delimited `listDir` output into entries. Records split on \036, fields on + /// \037: name, isDir(0/1), isSymlink(0/1), linkTarget. + static func parseListDir(_ data: Data) -> [DirEntry] { + let text = String(decoding: data, as: UTF8.self) + return text.split(separator: "\u{1E}", omittingEmptySubsequences: true).compactMap { rec in + let f = rec.components(separatedBy: "\u{1F}") + guard f.count == 4 else { return nil } + let isSymlink = f[2] == "1" + return DirEntry( + name: f[0], + isDir: f[1] == "1", + isSymlink: isSymlink, + linkTarget: isSymlink && !f[3].isEmpty ? f[3] : nil + ) + } + } + + // MARK: - ssh plumbing + + /// One `ssh` invocation against the multiplexed master. `remote` is run by the remote + /// login shell; callers quote untrusted paths with `q`. Uses key auth unless a session + /// password is set for this target, in which case SSH_ASKPASS delivers it. + private func ssh(_ remote: String, stdin: Data? = nil) -> (exit: Int32, stdout: Data, stderr: Data) { + let password = RemoteCredentials.password(for: target) + let argv = [sshPath] + Self.baseArgs(controlPath: controlPath, password: password != nil) + + [target, remote] + var env: [String: String]? + if let password { + env = [ + "SSH_ASKPASS": Self.askpassPath, + "SSH_ASKPASS_REQUIRE": "force", + "LOADOUT_SSH_PASSWORD": password, + ] + } + return spawnProcess(argv, cwd: nil, stdin: stdin, env: env) + } + + private static func baseArgs(controlPath: String, password: Bool) -> [String] { + var args = ["-o", "StrictHostKeyChecking=accept-new", + "-o", "ConnectTimeout=10", + "-o", "ControlMaster=auto", + "-o", "ControlPersist=300", + "-o", "ControlPath=\(controlPath)"] + if password { + // Password mode: BatchMode would disable SSH_ASKPASS, so don't set it. + args += ["-o", "PreferredAuthentications=password,keyboard-interactive", + "-o", "PubkeyAuthentication=no", + "-o", "NumberOfPasswordPrompts=1"] + } else { + args += ["-o", "BatchMode=yes"] // key auth only; never blocks on a prompt + } + return args + } + + /// Path to the SSH_ASKPASS helper, written once. The helper holds NO secret — it just + /// echoes the `LOADOUT_SSH_PASSWORD` env var ssh passes to it. + private static let askpassPath: String = { + let path = (NSTemporaryDirectory() as NSString).appendingPathComponent("loadout-askpass.sh") + let script = "#!/bin/sh\nprintf '%s\\n' \"$LOADOUT_SSH_PASSWORD\"\n" + try? script.write(toFile: path, atomically: true, encoding: .utf8) + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: path) + return path + }() + + /// POSIX single-quote a string for safe interpolation into a remote command. + private func q(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private func firstLine(_ data: Data) -> String { + String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/loadout/apps/macOS/Sources/Services/SkillLockReader.swift b/loadout/apps/macOS/Sources/Services/SkillLockReader.swift new file mode 100644 index 0000000..4830209 --- /dev/null +++ b/loadout/apps/macOS/Sources/Services/SkillLockReader.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Reads skills.sh lock files into per-skill provenance, keyed by skill name as the +/// lock stores it. Handles both schemas: +/// - global `~/.agents/.skill-lock.json` (v3, `skillFolderHash`, timestamps) +/// - project `/skills-lock.json` (v1, `computedHash`) +/// Best-effort: missing file / unknown keys / schema drift are tolerated. +enum SkillLockReader { + static func readGlobal(io: HostIO) -> [String: SkillProvenance] { + read(url: io.home.appendingPathComponent(".agents/.skill-lock.json"), io: io) + } + + static func readProject(root: URL, io: HostIO) -> [String: SkillProvenance] { + read(url: root.appendingPathComponent("skills-lock.json"), io: io) + } + + static func read(url: URL, io: HostIO) -> [String: SkillProvenance] { + guard + let data = try? io.readFile(url.path), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let skills = root["skills"] as? [String: Any] + else { return [:] } + + var out: [String: SkillProvenance] = [:] + for (name, value) in skills { + guard let e = value as? [String: Any] else { continue } + out[name] = SkillProvenance( + source: e["source"] as? String ?? "", + sourceURL: e["sourceUrl"] as? String, + skillPath: e["skillPath"] as? String, + folderHash: (e["skillFolderHash"] as? String) ?? (e["computedHash"] as? String), + installedAt: e["installedAt"] as? String, + updatedAt: e["updatedAt"] as? String, + pluginName: e["pluginName"] as? String + ) + } + return out + } +} diff --git a/loadout/apps/macOS/Sources/Services/SkillScanner.swift b/loadout/apps/macOS/Sources/Services/SkillScanner.swift new file mode 100644 index 0000000..490266f --- /dev/null +++ b/loadout/apps/macOS/Sources/Services/SkillScanner.swift @@ -0,0 +1,287 @@ +import Foundation + +/// Discovers skills on disk and enriches them with provenance, declared agents, +/// git status, CLI-managed and diverged flags. Identity is the canonical +/// (symlink-resolved) directory; the same canonical skill referenced by several +/// agents collapses into one `Skill` with the union of `wiredAgents`. +enum SkillScanner { + static func scanGlobal(host: Host) -> [Skill] { + let io = host.makeIO() + return scan( + scope: .global, + dirs: Agent.allCases.map { agent in (agent, agent.globalSkillDirs(io)) }, + lock: SkillLockReader.readGlobal(io: io), + declared: declaredByPath(SkillsCLIService.listGlobalJSON(io: io), io: io), + io: io, + host: host + ) + } + + static func scanProject(root: URL, host: Host) -> [Skill] { + let io = host.makeIO() + var dirs: [(Agent, [URL])] = [] + for base in projectBases(from: root, io: io) { + for agent in Agent.allCases { + dirs.append((agent, agent.projectSkillDirs.map { base.appendingPathComponent($0) })) + } + } + return scan( + scope: .project(root: root.path), + dirs: dirs, + lock: SkillLockReader.readProject(root: root, io: io), + declared: declaredByPath(SkillsCLIService.listProjectJSON(in: root, io: io), io: io), + io: io, + host: host + ) + } + + // MARK: - Core + + private static func scan( + scope: ResourceScope, + dirs: [(Agent, [URL])], + lock: [String: SkillProvenance], + declared: [String: Set], + io: HostIO, + host: Host + ) -> [Skill] { + var byCanonical: [String: Skill] = [:] + var linkPaths: [String: Set] = [:] // canonicalPath -> entry (possibly symlink) paths + + for (agent, agentDirs) in dirs { + for dir in agentDirs { + let entries = (try? io.listDir(dir.path)) ?? [] + + for entry in entries { + let entryPath = URL(fileURLWithPath: dir.path).appendingPathComponent(entry.name) + let skillMd = entryPath.appendingPathComponent("SKILL.md") + guard io.exists(skillMd.path) else { continue } + + // Is this entry an actual symlink (→ canonical store), or a real directory + // living in the agent's own dir? lstat (doesn't follow) via DirEntry. + let isLink = entry.isSymlink + + let canonical = URL(fileURLWithPath: io.realpath(entryPath.path)) + let key = canonical.path + linkPaths[key, default: []].insert(entryPath.path) + + if byCanonical[key] != nil { + byCanonical[key]?.wiredAgents.insert(agent) + if isLink { byCanonical[key]?.symlinkedAgents.insert(agent) } + continue + } + + let canonicalMd = canonical.appendingPathComponent("SKILL.md") + let content = (try? io.readFile(canonicalMd.path)) + .flatMap { String(data: $0, encoding: .utf8) } ?? "" + let parsed = FrontmatterParser.parse(content) + let basename = canonical.lastPathComponent + let name = (parsed.frontmatter["name"] as? String) ?? basename + // Per-canonical provenance: lock files key by the dir name under skills/, + // so match strictly by basename — avoids cross-contaminating diverged + // same-name copies (a name-keyed fallback would reintroduce that bug). + let prov = lock[basename] + + var skill = Skill( + canonicalPath: key, + scope: scope, + name: name, + directoryURL: canonical, + skillMdURL: canonicalMd, + summary: parsed.frontmatter["description"] as? String, + bodyMarkdown: parsed.body, + frontmatterKeys: parsed.frontmatter.keys.sorted(), + rawFrontmatter: parsed.rawFrontmatter, + wiredAgents: [agent], + provenance: prov + ) + skill.host = host + skill.isCLIManaged = (prov != nil) + if isLink { skill.symlinkedAgents = [agent] } + skill.declaredAgents = declared[key] ?? [] + skill.bundledFiles = bundledFiles(in: canonical, io: io) + skill.searchHaystack = + (name + " " + (skill.summary ?? "") + " " + parsed.body).lowercased() + byCanonical[key] = skill + } + } + } + + return finalize(byCanonical, linkPaths: linkPaths, projectRoot: scope.projectRoot, io: io) + } + + private static func finalize( + _ byCanonical: [String: Skill], + linkPaths: [String: Set], + projectRoot: String?, + io: HostIO + ) -> [Skill] { + var skills = Array(byCanonical.values) + + // Git status — classify canonical dirs + their (unresolved) link paths in one batch. + var allPaths = Set(byCanonical.keys) + for (_, links) in linkPaths { allPaths.formUnion(links) } + let git = GitStatusService.classify(paths: Array(allPaths), io: io) + + // Project-relative locations, from the (unresolved) reference paths. + if let root = projectRoot { + for i in skills.indices { + let links = linkPaths[skills[i].canonicalPath] ?? [] + let locs = Set(links.map { location(ofEntry: $0, underRoot: root) }) + skills[i].projectLocations = locs.sorted() + } + } + + for i in skills.indices { + let canon = skills[i].canonicalPath + let status = git[canon] ?? .notInRepo + skills[i].gitStatus = status + if let links = linkPaths[canon] { + skills[i].linksDiverge = links.contains { (git[$0] ?? status) != status } + } + // Diverged = a *genuine* clash: another skill with the same name that shares a + // location. Same name in different monorepo subpackages is expected, not drift. + // In global scope every skill's location set is {""}, so this reduces to the + // original "same name across ≥2 canonical paths" rule. + let myLocs = locationKeys(skills[i]) + skills[i].diverged = skills.contains { other in + other.canonicalPath != canon + && other.name == skills[i].name + && !locationKeys(other).isDisjoint(with: myLocs) + } + } + + return skills.sorted { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + + /// Location keys for divergence comparison; empty (global) collapses to a single + /// shared "" bucket so global same-name skills still count as diverged. + private static func locationKeys(_ s: Skill) -> Set { + s.projectLocations.isEmpty ? [""] : Set(s.projectLocations) + } + + /// Project-relative subpackage of a skill reference path `/.X/skills/`. + /// "" = the chosen root; "↑ " = an ancestor above it. + private static func location(ofEntry entry: String, underRoot root: String) -> String { + var base = URL(fileURLWithPath: entry) + for _ in 0..<3 { base = base.deletingLastPathComponent() } // drop /skills/.X + let bp = base.standardizedFileURL.path + let rp = URL(fileURLWithPath: root).standardizedFileURL.path + if bp == rp { return "" } + if bp.hasPrefix(rp + "/") { return String(bp.dropFirst(rp.count + 1)) } + return "↑ " + base.lastPathComponent + } + + // MARK: - Base discovery + + /// Bases to probe for agent skill dirs: ancestors (chosen dir → git root) plus + /// descendants up to `maxDescentDepth` levels (monorepos nest .claude/.agents in + /// subpackages). Deduped by standardized path, preserving discovery order. + static func projectBases(from root: URL, io: HostIO) -> [URL] { + var bases = ancestorRoots(from: root, io: io) + bases.append(contentsOf: descendantRoots(from: root, io: io)) + var seen = Set() + return bases.filter { seen.insert($0.standardizedFileURL.path).inserted } + } + + /// Every agent skill dir the project scan looks at, for the FileWatcher. Covers the + /// same ancestor+descendant bases `scanProject` scans — so edits to a nested package's + /// `.claude/skills` fire a reload, not just the chosen root's. FileWatcher filters out + /// paths that don't exist, so we needn't stat here. + static func projectSkillDirPaths(from root: URL, io: HostIO) -> [String] { + projectBases(from: root, io: io).flatMap { base in + Agent.allCases.flatMap { agent in + agent.projectSkillDirs.map { base.appendingPathComponent($0).path } + } + } + } + + // MARK: - Helpers + + /// How many directory levels below the chosen root we look for nested agent dirs. + /// Depth 1 = `root//.claude/skills`, depth 3 = `root/a/b/c/.claude/skills`. + private static let maxDescentDepth = 3 + + /// Dirs we never traverse into — VCS, build output, and dependency trees that would + /// blow up the walk (and whose vendored skills aren't "the project's" anyway). + private static let pruneDirNames: Set = [ + ".git", "node_modules", ".build", "build", "dist", "out", ".next", ".nuxt", + "target", "vendor", "Pods", ".venv", "venv", "__pycache__", ".turbo", + "DerivedData", ".gradle", ".cache", "Carthage" + ] + + /// Breadth-first walk from `root` (exclusive) down to `maxDescentDepth` levels, + /// returning every subdirectory that could host an agent skills dir. Skips hidden + /// dirs (incl. the agent dirs themselves — we probe those by name at each base), + /// symlinks (cycle-safe), and pruned build/dependency dirs. + private static func descendantRoots(from root: URL, io: HostIO) -> [URL] { + var result: [URL] = [] + var frontier: [(dir: URL, depth: Int)] = [(root.standardizedFileURL, 0)] + + while !frontier.isEmpty { + let (dir, depth) = frontier.removeFirst() + if depth >= maxDescentDepth { continue } + let children = (try? io.listDir(dir.path)) ?? [] + + for entry in children { + // listDir already skips hidden entries; only descend into real dirs (not symlinks). + guard entry.isDir, !entry.isSymlink else { continue } + if pruneDirNames.contains(entry.name) { continue } + let child = dir.appendingPathComponent(entry.name) + result.append(child) + frontier.append((child, depth + 1)) + } + } + return result + } + + /// Walk from `start` up to (and including) the git repo root; if not in a repo, + /// just return `start`. Used so project skills in ancestor dirs are discovered. + private static func ancestorRoots(from start: URL, io: HostIO) -> [URL] { + let top = gitTopLevel(for: start, io: io) + var dirs: [URL] = [] + var cur = start.standardizedFileURL + while true { + dirs.append(cur) + if let top, cur.path == top.path { break } + let parent = cur.deletingLastPathComponent() + if parent.path == cur.path { break } // filesystem root + if top == nil { break } // not in a repo → only scan start + cur = parent + } + return dirs + } + + private static func gitTopLevel(for dir: URL, io: HostIO) -> URL? { + guard let git = GitStatusService.gitPath(io) else { return nil } + let r = io.run([git, "-C", dir.path, "rev-parse", "--show-toplevel"], cwd: nil, stdin: nil) + guard r.exit == 0 else { return nil } + let s = String(data: r.stdout, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return s.isEmpty ? nil : URL(fileURLWithPath: s) + } + + /// Top-level entries packaged with a skill (excluding SKILL.md), dirs-first then by + /// name. One `listDir` per unique skill — a cheap FS read, no subprocess. + private static func bundledFiles(in canonical: URL, io: HostIO) -> [BundledFile] { + let entries = (try? io.listDir(canonical.path)) ?? [] + return entries + .filter { $0.name != "SKILL.md" } + .map { BundledFile(url: canonical.appendingPathComponent($0.name), isDirectory: $0.isDir) } + .sorted { + if $0.isDirectory != $1.isDirectory { return $0.isDirectory && !$1.isDirectory } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + + private static func declaredByPath(_ items: [CLISkill], io: HostIO) -> [String: Set] { + var out: [String: Set] = [:] + for item in items { + let p = io.realpath(item.path) + out[p, default: []].formUnion(item.agents.compactMap(Agent.from(cliDisplayName:))) + } + return out + } +} diff --git a/loadout/apps/macOS/Sources/Services/SkillsCLIService.swift b/loadout/apps/macOS/Sources/Services/SkillsCLIService.swift new file mode 100644 index 0000000..2cd1423 --- /dev/null +++ b/loadout/apps/macOS/Sources/Services/SkillsCLIService.swift @@ -0,0 +1,137 @@ +import Foundation + +/// One entry from `skills list --json`. +struct CLISkill: Decodable { + let name: String + let path: String + let scope: String + let agents: [String] +} + +/// Outcome of a mutating CLI call — exit code + captured streams, so failures +/// surface to the UI instead of being swallowed (the read-only `run` returns []). +struct CLIResult { + let exitCode: Int32 + let stdout: String + let stderr: String + var ok: Bool { exitCode == 0 } + /// Best human-readable message: prefer stderr, fall back to stdout. + var message: String { + let e = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return e.isEmpty ? stdout.trimmingCharacters(in: .whitespacesAndNewlines) : e + } +} + +/// Bridge to the skills.sh `skills` CLI. +/// +/// GUI apps don't inherit the user's shell PATH, so we locate the binary at known +/// install locations rather than relying on `npx` (which can be slow / networked / +/// a different version). All mutations should route through this service. +enum SkillsCLIService { + static func locate(_ io: HostIO) -> URL? { + let home = io.home.path + let candidates = [ + "/opt/homebrew/bin/skills", + "/usr/local/bin/skills", + "\(home)/.local/bin/skills", + ] + for path in candidates where io.exists(path) { + return URL(fileURLWithPath: path) + } + return nil + } + + static func isAvailable(_ io: HostIO) -> Bool { locate(io) != nil } + + /// `skills list -g --json` — global skills with their declared agent targets. + static func listGlobalJSON(io: HostIO) -> [CLISkill] { + run(arguments: ["list", "-g", "--json"], io: io) + } + + /// `skills list --json` run in `directory` — project-scoped skills. + static func listProjectJSON(in directory: URL, io: HostIO) -> [CLISkill] { + run(arguments: ["list", "--json"], cwd: directory, io: io) + } + + private static func run(arguments: [String], cwd: URL? = nil, io: HostIO) -> [CLISkill] { + guard let bin = locate(io) else { return [] } + let r = io.run([bin.path] + arguments, cwd: cwd?.path, stdin: nil) + guard r.exit != -1 else { return [] } + return (try? JSONDecoder().decode([CLISkill].self, from: r.stdout)) ?? [] + } + + // MARK: - Mutations + + /// CLI `-a`/`-s` expect SLUGS; `list --json` emits DISPLAY names. Map Agent→slug. + /// `.agents` (the canonical store) is not a wireable agent target → nil. + static func slug(for agent: Agent) -> String? { + switch agent { + case .claude: return "claude-code" + case .opencode: return "opencode" + case .codex: return "codex" + case .pi: return "pi" + case .agents: return nil + } + } + + /// Generic capturing runner (parallel to `run`, but keeps exit code + streams). + /// Runs synchronously — callers must invoke it off the main thread. + @discardableResult + static func runCapturing(arguments: [String], cwd: URL? = nil, io: HostIO) -> CLIResult { + guard let bin = locate(io) else { + return CLIResult(exitCode: 127, stdout: "", stderr: "skills CLI not found on PATH") + } + let r = io.run([bin.path] + arguments, cwd: cwd?.path, stdin: nil) + if r.exit == -1 { + return CLIResult(exitCode: 126, stdout: "", stderr: "failed to launch skills CLI") + } + return CLIResult(exitCode: r.exit, + stdout: String(decoding: r.stdout, as: UTF8.self), + stderr: String(decoding: r.stderr, as: UTF8.self)) + } + + /// `-a` takes the agent slugs as separate following tokens (one flag, many values) — + /// NOT a comma-joined string, which the CLI would treat as a single invalid slug. + /// Placed last by callers so the variadic flag can't swallow a trailing option. + private static func agentArgs(_ agents: [Agent]) -> [String] { + let slugs = agents.compactMap { slug(for: $0) } + return slugs.isEmpty ? [] : ["-a"] + slugs + } + + /// Scope→(extra args, cwd). Project ops are cwd-sensitive: pass `-p` AND set cwd. + private static func scopeArgs(_ scope: ResourceScope) -> (args: [String], cwd: URL?) { + if scope.isGlobal { return (["-g"], nil) } + let root = scope.projectRoot.map { URL(fileURLWithPath: $0) } + return (["-p"], root) + } + + /// INSTALL a whole package or a specific skill into specific agents. + /// `skills add [-s ] (-g|-p) -y [--copy] [-a ...]` + static func add(ref: String, skill: String? = nil, agents: [Agent] = [], scope: ResourceScope, copy: Bool = false, io: HostIO) -> CLIResult { + let s = scopeArgs(scope) + var a = ["add", ref] + s.args + ["-y"] + if let skill { a += ["-s", skill] } + if copy { a += ["--copy"] } + a += agentArgs(agents) // variadic flag last + return runCapturing(arguments: a, cwd: s.cwd, io: io) + } + + /// REMOVE a skill fully, or unwire from specific agents only (`-a`). + /// `skills remove (-g|-p) -y [-a ...]` + static func remove(name: String, agents: [Agent] = [], scope: ResourceScope, io: HostIO) -> CLIResult { + let s = scopeArgs(scope) + var a = ["remove", name] + s.args + ["-y"] + a += agentArgs(agents) // variadic flag last + return runCapturing(arguments: a, cwd: s.cwd, io: io) + } + + /// UPDATE installed skill(s) to latest from source. + /// `skills update [] (-g|-p) -y` + static func update(name: String?, scope: ResourceScope, io: HostIO) -> CLIResult { + let s = scopeArgs(scope) + var a = ["update"] + if let name { a.append(name) } + a += s.args + ["-y"] + return runCapturing(arguments: a, cwd: s.cwd, io: io) + } +} diff --git a/loadout/apps/macOS/Sources/Services/TomlMiniReader.swift b/loadout/apps/macOS/Sources/Services/TomlMiniReader.swift new file mode 100644 index 0000000..50625d2 --- /dev/null +++ b/loadout/apps/macOS/Sources/Services/TomlMiniReader.swift @@ -0,0 +1,335 @@ +import Foundation + +/// A small, defensive, READ-ONLY TOML decoder — enough to pull a Codex `config.toml`'s +/// `[mcp_servers.*]` tables into a nested `[String: Any]`, while parsing the rest of the +/// file well enough not to be fooled by it (multiline strings that contain `[`, comments, +/// inline tables, arrays spanning lines, array-of-tables elsewhere). +/// +/// It is deliberately NOT a full TOML implementation. Anything it doesn't understand makes +/// it `throw` — and the codec turns that into a `.malformed` result, which the scanner +/// surfaces and the writer refuses to touch. Failing closed is the correct, safe outcome: +/// phase 1 never writes, and the writer (phase 2) does textual block-replace, not a +/// re-render through this reader. +/// +/// Values decode to: `String`, `Bool`, `Int`, `Double`, `[Any]`, `[String: Any]` +/// (inline / sub-tables), and `[[String: Any]]` (array-of-tables). +enum TomlMiniReader { + struct ParseError: Error { let message: String } + + static func parse(_ text: String) throws -> [String: Any] { + let parser = Parser(Array(text.unicodeScalars)) + return try parser.parseDocument() + } + + // A mutable tree so table headers can re-point "current table" cheaply. + private final class Node { + var values: [String: Any] = [:] // values may hold Node / [Node] / scalars / [Any] + } + + private final class Parser { + private let s: [Unicode.Scalar] + private var i = 0 + private let root = Node() + private var current: Node + + init(_ scalars: [Unicode.Scalar]) { + s = scalars + current = root + } + + func parseDocument() throws -> [String: Any] { + while true { + skipWhitespaceAndComments(newlines: true) + if atEnd { break } + if peek() == "[" { + try parseTableHeader() + } else { + try parseKeyValue(into: current) + skipInlineSpace() + skipCommentToEOL() + try expectLineEndOrEOF() + } + } + return materialize(root) + } + + // MARK: tables + + private func parseTableHeader() throws { + advance() // [ + let isArray = (peek() == "[") + if isArray { advance() } + let path = try parseKeyPath() + skipInlineSpace() + try expect("]") + if isArray { try expect("]") } + skipInlineSpace(); skipCommentToEOL(); try expectLineEndOrEOF() + + // Navigate/create from root. + var node = root + for (idx, key) in path.enumerated() { + let isLast = idx == path.count - 1 + if isLast && isArray { + var arr = (node.values[key] as? [Node]) ?? [] + let fresh = Node() + arr.append(fresh) + node.values[key] = arr + node = fresh + } else { + node = try descend(node, key) + } + } + current = node + } + + /// Descend into a child table, creating it if absent. If the slot holds an + /// array-of-tables, the last element is the active table. + private func descend(_ node: Node, _ key: String) throws -> Node { + if let existing = node.values[key] { + if let child = existing as? Node { return child } + if let arr = existing as? [Node], let last = arr.last { return last } + throw ParseError(message: "key '\(key)' is not a table") + } + let child = Node() + node.values[key] = child + return child + } + + // MARK: key/value + + private func parseKeyValue(into node: Node) throws { + let path = try parseKeyPath() + skipInlineSpace() + try expect("=") + skipInlineSpace() + let value = try parseValue() + // Dotted key x.y = v → create intermediate tables. + var target = node + for key in path.dropLast() { target = try descend(target, key) } + target.values[path.last!] = value + } + + /// A dotted key path: bare or quoted segments separated by `.`. + private func parseKeyPath() throws -> [String] { + var keys: [String] = [] + while true { + skipInlineSpace() + keys.append(try parseKeySegment()) + skipInlineSpace() + if peek() == "." { advance(); continue } + break + } + return keys + } + + private func parseKeySegment() throws -> String { + guard let c = peek() else { throw ParseError(message: "expected key") } + if c == "\"" || c == "'" { return try parseQuotedString() } + var out = "" + while let c = peek(), isBareKeyChar(c) { out.unicodeScalars.append(c); advance() } + if out.isEmpty { throw ParseError(message: "empty key") } + return out + } + + private func parseValue() throws -> Any { + guard let c = peek() else { throw ParseError(message: "expected value") } + switch c { + case "\"", "'": return try parseString() + case "[": return try parseArray() + case "{": return try parseInlineTable() + default: return try parseScalar() + } + } + + // MARK: strings + + private func parseString() throws -> String { + // Triple-quoted multiline? + if matches("\"\"\"") { return try parseMultiline(delim: "\"", basic: true) } + if matches("'''") { return try parseMultiline(delim: "'", basic: false) } + return try parseQuotedString() + } + + private func parseQuotedString() throws -> String { + let quote = peek()! + let basic = (quote == "\"") + advance() + var out = "" + while let c = peek() { + if c == "\\" && basic { + advance() + out.unicodeScalars.append(try parseEscape()) + continue + } + if c == quote { advance(); return out } + if c == "\n" { throw ParseError(message: "unterminated string") } + out.unicodeScalars.append(c); advance() + } + throw ParseError(message: "unterminated string") + } + + private func parseMultiline(delim: Unicode.Scalar, basic: Bool) throws -> String { + // Opening triple-delim already consumed by `matches`. + // A newline immediately after the opening delimiter is trimmed (TOML rule). + if peek() == "\n" { advance() } + else if peek() == "\r", peekAt(1) == "\n" { advance(); advance() } + var out = "" + while !atEnd { + if matches(String(repeating: String(delim), count: 3)) { return out } + let c = peek()! + if c == "\\" && basic { + // Line-ending backslash trims following whitespace; otherwise normal escape. + advance() + if let n = peek(), n == "\n" || n == "\r" || n == " " || n == "\t" { + // Trim whitespace/newlines ONLY — a `#` here is literal string content, + // not a comment (skipWhitespaceAndComments would wrongly swallow it). + while let w = peek(), w == " " || w == "\t" || w == "\n" || w == "\r" { advance() } + continue + } + out.unicodeScalars.append(try parseEscape()) + continue + } + out.unicodeScalars.append(c); advance() + } + throw ParseError(message: "unterminated multiline string") + } + + private func parseEscape() throws -> Unicode.Scalar { + guard let c = peek() else { throw ParseError(message: "dangling escape") } + advance() + switch c { + case "n": return "\n" + case "t": return "\t" + case "r": return "\r" + case "\"": return "\"" + case "\\": return "\\" + case "b": return "\u{08}" + case "f": return "\u{0C}" + case "u", "U": + let count = (c == "u") ? 4 : 8 + var hex = "" + for _ in 0.. [Any] { + advance() // [ + var out: [Any] = [] + while true { + skipWhitespaceAndComments(newlines: true) + if peek() == "]" { advance(); return out } + if atEnd { throw ParseError(message: "unterminated array") } + out.append(try parseValue()) + skipWhitespaceAndComments(newlines: true) + if peek() == "," { advance(); continue } + if peek() == "]" { advance(); return out } + throw ParseError(message: "expected , or ] in array") + } + } + + private func parseInlineTable() throws -> [String: Any] { + advance() // { + let node = Node() + skipInlineSpace() + if peek() == "}" { advance(); return materialize(node) } + while true { + skipInlineSpace() + try parseKeyValue(into: node) + skipInlineSpace() + if peek() == "," { advance(); continue } + if peek() == "}" { advance(); return materialize(node) } + throw ParseError(message: "expected , or } in inline table") + } + } + + // MARK: scalars (bool / number / leave-as-string for datetimes etc.) + + private func parseScalar() throws -> Any { + var tok = "" + while let c = peek(), !isScalarTerminator(c) { tok.unicodeScalars.append(c); advance() } + let t = tok.trimmingCharacters(in: .whitespaces) + if t == "true" { return true } + if t == "false" { return false } + if t.isEmpty { throw ParseError(message: "empty value") } + // mcp_servers values are only string/bool/array/table — numbers and datetimes + // never need interpreting, so keep the literal text. + return t + } + + // MARK: scanning helpers + + private var atEnd: Bool { i >= s.count } + private func peek() -> Unicode.Scalar? { i < s.count ? s[i] : nil } + private func peekAt(_ n: Int) -> Unicode.Scalar? { i + n < s.count ? s[i + n] : nil } + private func advance() { i += 1 } + + private func matches(_ str: String) -> Bool { + let scalars = Array(str.unicodeScalars) + guard i + scalars.count <= s.count else { return false } + for (k, sc) in scalars.enumerated() where s[i + k] != sc { return false } + i += scalars.count + return true + } + + private func expect(_ ch: Unicode.Scalar) throws { + guard peek() == ch else { throw ParseError(message: "expected '\(ch)'") } + advance() + } + + private func expectLineEndOrEOF() throws { + skipInlineSpace() + if atEnd { return } + if peek() == "\n" { advance(); return } + if peek() == "\r", peekAt(1) == "\n" { advance(); advance(); return } + throw ParseError(message: "expected end of line") + } + + private func skipInlineSpace() { + while let c = peek(), c == " " || c == "\t" { advance() } + } + + private func skipCommentToEOL() { + if peek() == "#" { while let c = peek(), c != "\n" { advance() } } + } + + private func skipWhitespaceAndComments(newlines: Bool) { + while let c = peek() { + if c == " " || c == "\t" || c == "\r" { advance() } + else if newlines && c == "\n" { advance() } + else if c == "#" { while let c = peek(), c != "\n" { advance() } } + else { break } + } + } + + private func isScalarTerminator(_ c: Unicode.Scalar) -> Bool { + c == "," || c == "]" || c == "}" || c == "\n" || c == "\r" || c == "#" + } + private func isBareKeyChar(_ c: Unicode.Scalar) -> Bool { + (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_" || c == "-" + } + private func isHex(_ c: Unicode.Scalar) -> Bool { + (c >= "0" && c <= "9") || (c >= "a" && c <= "f") || (c >= "A" && c <= "F") + } + + private func materialize(_ node: Node) -> [String: Any] { + var out: [String: Any] = [:] + for (k, v) in node.values { + if let n = v as? Node { out[k] = materialize(n) } + else if let arr = v as? [Node] { out[k] = arr.map { materialize($0) } } + else { out[k] = v } + } + return out + } + } +} diff --git a/loadout/apps/macOS/Sources/Theme/Color+Hex.swift b/loadout/apps/macOS/Sources/Theme/Color+Hex.swift new file mode 100644 index 0000000..f3ca500 --- /dev/null +++ b/loadout/apps/macOS/Sources/Theme/Color+Hex.swift @@ -0,0 +1,19 @@ +import SwiftUI + +extension Color { + /// Build a Color from a 0xRRGGBB literal. + init(hex: UInt) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255, + opacity: 1 + ) + } +} + +/// Shared, non-agent color tokens (agent colors live on `Agent.color`). +enum Theme { + static let drift = Color(hex: 0xE5A50A) +} diff --git a/loadout/apps/macOS/Sources/Views/McpDetailView.swift b/loadout/apps/macOS/Sources/Views/McpDetailView.swift new file mode 100644 index 0000000..d3eca5d --- /dev/null +++ b/loadout/apps/macOS/Sources/Views/McpDetailView.swift @@ -0,0 +1,332 @@ +import SwiftUI + +struct McpDetailView: View { + let server: McpServer + @EnvironmentObject var state: AppState + @State private var editing = false + @State private var confirmRemoveAll = false + @State private var removeTarget: McpHarness? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + header + managementRow + harnessSection + perHarnessControls + if server.definitionDiverges { divergenceCard } + if let portable = server.representativePortable { definitionSection(portable) } + authSection + originsSection + } + .padding(22) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle(server.name) + .sheet(isPresented: $editing) { McpServerSheet(mode: .edit(server)) } + .confirmationDialog("Remove \(server.name) from all harnesses?", + isPresented: $confirmRemoveAll, titleVisibility: .visible) { + Button("Remove everywhere", role: .destructive) { state.removeMcpServer(server) } + Button("Cancel", role: .cancel) {} + } message: { + Text("Deletes the server entry from \(server.presentIn.count) harness config(s). Agent-local auth stays only where it already is.") + } + .confirmationDialog( + "Remove \(server.name) from \(removeTarget?.displayName ?? "")?", + isPresented: Binding(get: { removeTarget != nil }, set: { if !$0 { removeTarget = nil } }), + titleVisibility: .visible + ) { + Button("Remove", role: .destructive) { + if let h = removeTarget { state.removeMcpServer(server, from: [h]); removeTarget = nil } + } + Button("Cancel", role: .cancel) { removeTarget = nil } + } + } + + // MARK: - Management + + private var managementRow: some View { + HStack(spacing: 8) { + if !server.supportedButMissing.isEmpty { + Button { + state.applyToSupported(server) + } label: { + if state.actionStatus.isRunning(.mcpApply(server.id)) { + HStack(spacing: 5) { ProgressView().controlSize(.small); Text("Applying…") } + } else { + Label("Apply to \(server.supportedButMissing.count) supported", systemImage: "square.on.square") + } + } + .buttonStyle(.glassProminent) + .controlSize(.small) + .tint(McpHarness.claudeCode.color) + .disabled(state.actionStatus.isRunning) + .help("Add this server to every harness that supports its transport but doesn't have it") + } + + Spacer() + + Button { editing = true } label: { Label("Edit", systemImage: "pencil") } + .buttonStyle(.glass).controlSize(.small) + .disabled(state.actionStatus.isRunning || server.representativePortable == nil) + .help("Edit the definition and re-apply it to every harness this server lives in") + + Button(role: .destructive) { confirmRemoveAll = true } label: { Label("Remove…", systemImage: "trash") } + .buttonStyle(.bordered).controlSize(.small) + .disabled(state.actionStatus.isRunning) + .help("Remove this server from all harnesses") + } + } + + /// Per-harness controls: enable/disable (only where the harness can express it) and a + /// per-harness remove. Locked harnesses (unsupported / missing) aren't listed here. + private var perHarnessControls: some View { + VStack(alignment: .leading, spacing: 8) { + if !presentHarnesses.isEmpty { sectionTitle("Manage per harness") } + ForEach(presentHarnesses, id: \.self) { harness in + HStack(spacing: 8) { + Circle().fill(harness.color).frame(width: 7, height: 7) + Text(harness.displayName).font(.callout) + Text(server.state(harness).label).font(.caption2).foregroundStyle(.tertiary) + Spacer() + if canToggle(harness) { + let enabled = server.entries[harness]?.enabled ?? true + Button { + state.setMcpEnabled(server, harness: harness, enabled: !enabled) + } label: { + if state.actionStatus.isRunning(.mcpToggle(server.id, harness)) { + ProgressView().controlSize(.small) + } else { + Text(enabled ? "Disable" : "Enable") + } + } + .buttonStyle(.bordered).controlSize(.small) + .disabled(state.actionStatus.isRunning) + } + Button(role: .destructive) { removeTarget = harness } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless).controlSize(.small) + .disabled(state.actionStatus.isRunning) + .help("Remove from \(harness.displayName)") + } + } + } + } + + /// Only opencode & Codex can persist a disabled-but-present server; Claude/Cursor would + /// have to omit it (i.e. remove), so we don't offer a toggle there. + private func canToggle(_ harness: McpHarness) -> Bool { + harness == .opencode || harness == .codex + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 9) { + Text(server.name).font(.largeTitle).fontWeight(.bold) + if server.definitionDiverges { + Tag(text: "diverged", color: Theme.drift) + .help("Harnesses define this server differently.") + } + if !server.conflictedHarnesses.isEmpty { + Tag(text: "conflict", color: Theme.drift) + .help("Two config origins for the same harness disagree.") + } + } + if let s = server.summary { + Text(s).font(.title3).foregroundStyle(.secondary).textSelection(.enabled) + } + if case .project = server.scope, !server.logicalLocation.isEmpty { + HStack(spacing: 5) { + Image(systemName: "folder").font(.caption2) + Text(server.logicalLocation).font(.system(.caption, design: .monospaced)) + } + .foregroundStyle(.tertiary) + } + } + } + + // MARK: - Harness matrix (the four states) + + private var harnessSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Across harnesses") + FlowRow(spacing: 7) { + ForEach(McpHarness.allCases) { harness in + harnessChip(harness) + } + } + } + } + + @ViewBuilder + private func harnessChip(_ harness: McpHarness) -> some View { + let st = server.state(harness) + switch st { + case .enabled: + chip("\(harness.displayName) · enabled", color: harness.color, + systemImage: st.systemImage, filled: true) + case .disabled: + chip("\(harness.displayName) · disabled", color: harness.color, + systemImage: st.systemImage, filled: false) + .help("Present but turned off in \(harness.displayName).") + case .missing: + chip("\(harness.displayName) · missing", color: .secondary, + systemImage: st.systemImage, ghost: true) + .help("Supported by \(harness.displayName) but not configured.") + case .unsupported: + chip("\(harness.displayName) · unsupported", color: .secondary, + systemImage: st.systemImage, ghost: true) + .help("\(harness.displayName) can't express this server's transport (\(server.transport?.label ?? "?")).") + } + } + + // MARK: - Definition + + private func definitionSection(_ p: PortableMcpDefinition) -> some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Definition") + kv("Transport", p.transport.label) + switch p.kind { + case .stdio: + if let c = p.command { kv("Command", c) } + if !p.args.isEmpty { kv("Args", p.args.joined(separator: " ")) } + if let cwd = p.cwd { kv("Cwd", cwd) } + case .remote: + if let url = p.url { kv("URL", url) } + } + if !p.env.isEmpty { + VStack(alignment: .leading, spacing: 3) { + Text("ENV").font(.caption2.weight(.semibold)).foregroundStyle(.tertiary).tracking(0.5) + ForEach(p.env.keys.sorted(), id: \.self) { key in + kv(key, server.representativePortable?.env[key]?.display ?? "") + } + } + } + if server.definitionDiverges { + Text("Shown from the first harness that defines it — see the divergence above for differences.") + .font(.caption2).foregroundStyle(.tertiary) + } + } + } + + // MARK: - Divergence + + private var divergenceCard: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Definitions diverge", systemImage: "arrow.triangle.branch") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color(hex: 0x8A6400)) + ForEach(presentHarnesses, id: \.self) { harness in + if let p = server.entries[harness]?.portable { + HStack(alignment: .top, spacing: 8) { + Circle().fill(harness.color).frame(width: 7, height: 7).padding(.top, 5) + VStack(alignment: .leading, spacing: 1) { + Text(harness.displayName).font(.caption.weight(.semibold)) + Text(p.summary).font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary).textSelection(.enabled) + } + } + } + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect(.regular.tint(Theme.drift.opacity(0.18)), in: .rect(cornerRadius: 12)) + } + + // MARK: - Auth / agent-local fields + + @ViewBuilder + private var authSection: some View { + let fieldsByHarness = presentHarnesses.compactMap { h -> (McpHarness, [String])? in + let fields = server.entries[h]?.agentLocalFields ?? [] + return fields.isEmpty ? nil : (h, fields) + } + if !fieldsByHarness.isEmpty { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Auth / harness-local fields") + ForEach(fieldsByHarness, id: \.0) { harness, fields in + kv(harness.displayName, fields.joined(separator: ", ")) + } + Text("Field names only — values are never read or displayed, and are preserved on edit.") + .font(.caption2).foregroundStyle(.tertiary) + } + } + } + + // MARK: - Config origins + + private var originsSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Config files") + ForEach(presentHarnesses, id: \.self) { harness in + ForEach(server.origins[harness] ?? [], id: \.self) { loc in + HStack(alignment: .top, spacing: 8) { + Circle().fill(harness.color).frame(width: 7, height: 7).padding(.top, 5) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text("\(harness.displayName) · \(loc.label)").font(.caption.weight(.medium)) + if let g = server.gitStatusByHarness[harness], loc.isPrimary { + Image(systemName: g.systemImage).font(.system(size: 9)) + .foregroundStyle(g.color).help(g.label) + } + } + Text(loc.url.path).font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary).textSelection(.enabled) + } + } + } + } + } + } + + // MARK: - Helpers + + private var presentHarnesses: [McpHarness] { + McpHarness.allCases.filter { server.presentIn.contains($0) } + } + + private func sectionTitle(_ t: String) -> some View { + Text(t.uppercased()) + .font(.caption).fontWeight(.semibold) + .foregroundStyle(.secondary) + .tracking(0.6) + } + + private func kv(_ k: String, _ v: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Text(k).font(.caption).foregroundStyle(.secondary).frame(width: 90, alignment: .leading) + Text(v).font(.system(.caption, design: .monospaced)).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func chip(_ text: String, color: Color, systemImage: String?, + filled: Bool = false, ghost: Bool = false) -> some View { + HStack(spacing: 5) { + if let systemImage { Image(systemName: systemImage).font(.caption2) } + Text(text).font(.caption.weight(.medium)) + } + .padding(.horizontal, 9).padding(.vertical, 4) + .background(ghost ? Color.clear : color.opacity(filled ? 0.18 : 0.10), in: Capsule()) + .overlay { + if ghost { Capsule().strokeBorder(color.opacity(0.45), style: StrokeStyle(lineWidth: 1, dash: [3, 2])) } + } + .foregroundStyle(color == .secondary ? .secondary : color) + } +} + +extension McpValueExpr { + /// How an interpolation expression reads back in the UI (canonical `${VAR}` form, etc.). + var display: String { + switch self { + case .literal(let s): return s + case .envVar(let v): return "${\(v)}" + case .envVarDefault(let v, let d): return "${\(v):-\(d)}" + case .fileRef(let p): return "{file:\(p)}" + } + } +} diff --git a/loadout/apps/macOS/Sources/Views/McpListView.swift b/loadout/apps/macOS/Sources/Views/McpListView.swift new file mode 100644 index 0000000..abaf2ef --- /dev/null +++ b/loadout/apps/macOS/Sources/Views/McpListView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct McpListView: View { + @EnvironmentObject var state: AppState + + var body: some View { + List(state.filteredMcpServers, selection: $state.mcpSelection) { server in + McpRow(server: server) + } + .searchable(text: $state.searchText, placement: .toolbar, prompt: "Search MCP servers") + .safeAreaInset(edge: .top, spacing: 0) { + if !state.mcpIssues.isEmpty { issuesBanner } + } + .overlay { + if state.isLoading && state.mcpServers.isEmpty { + ProgressView() + } else if state.filteredMcpServers.isEmpty { + ContentUnavailableView("No MCP servers", systemImage: "puzzlepiece.extension", + description: Text(emptyHint)) + } + } + } + + /// Unreadable configs are surfaced, never silently dropped — MCP state for those + /// harnesses is genuinely unknown until the file is fixed by hand. + private var issuesBanner: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(state.mcpIssues) { issue in + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill").font(.caption2) + Text("\(issue.harness.displayName): \(issue.label) unreadable") + .font(.caption.weight(.medium)) + Spacer() + } + .help(issue.reason + " · " + issue.path) + } + } + .foregroundStyle(Theme.drift) + .padding(.horizontal, 12).padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.drift.opacity(0.12)) + .overlay(alignment: .bottom) { Divider() } + } + + private var emptyHint: String { + if state.scopeMode == .project && state.selectedProject == nil { + return "Choose a project to scan its MCP configs." + } + return "No MCP servers found in any harness config for this scope." + } +} + +struct McpRow: View { + let server: McpServer + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(server.name).fontWeight(.medium) + if server.definitionDiverges { + Tag(text: "diverged", color: Theme.drift) + .help("Harnesses define this server differently (a definition clash).") + } + if !server.conflictedHarnesses.isEmpty { + Tag(text: "conflict", color: Theme.drift) + .help("Two config origins for the same harness disagree.") + } + Spacer() + if let loc = locationBadge { + Text(loc) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(1).truncationMode(.head) + .help("Config location: \(loc)") + } + Image(systemName: server.gitStatus.systemImage) + .font(.caption2) + .foregroundStyle(server.gitStatus.color) + .help("\(server.gitStatus.label) — \(server.gitStatus.helpText)") + } + if let s = server.summary { + Text(s).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + HStack(spacing: 6) { + ForEach(McpHarness.allCases) { harness in + harnessDot(harness) + } + if server.carriesAuth { + Image(systemName: "key.fill") + .font(.system(size: 8)) + .foregroundStyle(.tertiary) + .help("Carries auth/secret-bearing fields (preserved on edit, never shown)") + } + } + } + .padding(.vertical, 3) + } + + /// A dot per harness: filled = enabled, hollow = disabled, faint outline = unsupported, + /// absent (no dot) = simply missing. Mirrors the skill side's colored-presence language. + @ViewBuilder + private func harnessDot(_ harness: McpHarness) -> some View { + let st = server.state(harness) + switch st { + case .enabled: + Circle().fill(harness.color).frame(width: 7, height: 7) + .help("\(harness.displayName) · enabled") + case .disabled: + Circle().strokeBorder(harness.color, lineWidth: 1.5).frame(width: 7, height: 7) + .help("\(harness.displayName) · disabled") + case .unsupported: + Image(systemName: "nosign").font(.system(size: 8)).foregroundStyle(.quaternary) + .help("\(harness.displayName) · can't express this server's transport") + case .missing: + EmptyView() + } + } + + private var locationBadge: String? { + guard case .project = server.scope, !server.logicalLocation.isEmpty else { return nil } + return server.logicalLocation + } +} diff --git a/loadout/apps/macOS/Sources/Views/McpServerSheet.swift b/loadout/apps/macOS/Sources/Views/McpServerSheet.swift new file mode 100644 index 0000000..0b1de7f --- /dev/null +++ b/loadout/apps/macOS/Sources/Views/McpServerSheet.swift @@ -0,0 +1,232 @@ +import SwiftUI + +/// Add or edit an MCP server. In add mode the user picks the transport, fills connection +/// details, and chooses which harnesses to write into (harnesses that can't express the +/// chosen transport are shown locked, never silently coerced). In edit mode the portable +/// definition is re-applied to every harness the server already lives in, preserving each +/// harness's enabled state and agent-local fields. +struct McpServerSheet: View { + enum Mode: Equatable { case add; case edit(McpServer) } + + let mode: Mode + @EnvironmentObject var state: AppState + @Environment(\.dismiss) private var dismiss + + @State private var name: String + @State private var isRemote: Bool + @State private var command: String + @State private var argsText: String + @State private var url: String + @State private var remoteTransport: McpTransport + @State private var envRows: [EnvRow] + @State private var selected: Set + @State private var didSubmit = false + + init(mode: Mode) { + self.mode = mode + switch mode { + case .add: + _name = State(initialValue: "") + _isRemote = State(initialValue: false) + _command = State(initialValue: "") + _argsText = State(initialValue: "") + _url = State(initialValue: "") + _remoteTransport = State(initialValue: .http) + _envRows = State(initialValue: []) + _selected = State(initialValue: Set(McpHarness.allCases)) + case .edit(let s): + let p = s.representativePortable + _name = State(initialValue: s.name) + _isRemote = State(initialValue: p?.kind == .remote) + _command = State(initialValue: p?.command ?? "") + _argsText = State(initialValue: (p?.args ?? []).joined(separator: "\n")) + _url = State(initialValue: p?.url ?? "") + _remoteTransport = State(initialValue: p?.remoteTransport ?? .http) + _envRows = State(initialValue: (p?.env ?? [:]).sorted { $0.key < $1.key } + .map { EnvRow(key: $0.key, value: $0.value.display) }) + _selected = State(initialValue: s.presentIn) + } + } + + private var isEdit: Bool { if case .edit = mode { return true }; return false } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(isEdit ? "Edit MCP server" : "Add MCP server").font(.title2).fontWeight(.semibold) + + field("Name") { + TextField("server name", text: $name).textFieldStyle(.roundedBorder) + .disabled(isEdit) // identity is fixed when editing + } + + Picker("Transport", selection: $isRemote) { + Text("Local (stdio)").tag(false) + Text("Remote (URL)").tag(true) + } + .pickerStyle(.segmented) + + if isRemote { + field("URL") { + TextField("https://…", text: $url).textFieldStyle(.roundedBorder) + } + field("Remote transport") { + Picker("", selection: $remoteTransport) { + Text("HTTP").tag(McpTransport.http) + Text("Streamable HTTP").tag(McpTransport.streamableHttp) + Text("SSE").tag(McpTransport.sse) + Text("WebSocket").tag(McpTransport.ws) + } + .labelsHidden().pickerStyle(.menu) + } + } else { + field("Command") { + TextField("npx", text: $command).textFieldStyle(.roundedBorder) + } + field("Arguments (one per line)") { + TextEditor(text: $argsText) + .font(.system(.body, design: .monospaced)) + .frame(height: 54) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.quaternary)) + } + envEditor + } + + harnessTargets + + if let msg = state.actionStatus.failureMessage(submitID), didSubmit { + Text(msg).font(.caption).foregroundStyle(Theme.drift).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8).background(Theme.drift.opacity(0.1), in: RoundedRectangle(cornerRadius: 6)) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button(isEdit ? "Save" : "Add") { submit() } + .buttonStyle(.glassProminent) + .tint(McpHarness.claudeCode.color) + .keyboardShortcut(.defaultAction) + .disabled(!isValid || state.actionStatus.isRunning) + } + } + .padding(20) + .frame(width: 460) + .onChange(of: state.actionStatus) { _, status in + if didSubmit, status.didSucceed(submitID) { dismiss() } + } + } + + // MARK: - Sub-views + + private var envEditor: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Environment").font(.caption).foregroundStyle(.secondary) + Spacer() + Button { envRows.append(EnvRow()) } label: { Image(systemName: "plus") } + .buttonStyle(.borderless).controlSize(.small) + } + ForEach($envRows) { $row in + HStack(spacing: 6) { + TextField("KEY", text: $row.key).textFieldStyle(.roundedBorder).frame(width: 130) + TextField("value or ${VAR}", text: $row.value).textFieldStyle(.roundedBorder) + Button { envRows.removeAll { $0.id == row.id } } label: { Image(systemName: "minus.circle") } + .buttonStyle(.borderless).controlSize(.small) + } + } + } + } + + private var harnessTargets: some View { + VStack(alignment: .leading, spacing: 6) { + Text(isEdit ? "Applies to" : "Add to harnesses").font(.caption).foregroundStyle(.secondary) + ForEach(McpHarness.allCases) { h in + let unsupported = !h.transportSupport.contains(portable.transport) + Toggle(isOn: Binding( + get: { selected.contains(h) && !unsupported }, + set: { if $0 { selected.insert(h) } else { selected.remove(h) } } + )) { + HStack(spacing: 6) { + Circle().fill(h.color).frame(width: 7, height: 7) + Text(h.displayName) + if unsupported { + Text("· can't express \(portable.transport.label)") + .font(.caption2).foregroundStyle(.tertiary) + } + } + } + .toggleStyle(.checkbox) + .disabled(unsupported || (isEdit)) // edit re-applies to existing harnesses only + } + if isEdit { + Text("Editing re-applies the definition to the harnesses this server already lives in.") + .font(.caption2).foregroundStyle(.tertiary) + } + } + } + + private func field(_ label: String, @ViewBuilder _ content: () -> C) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).font(.caption).foregroundStyle(.secondary) + content() + } + } + + // MARK: - Logic + + private var submitID: ActionID { + if case .edit(let s) = mode { return .mcpEdit(s.id) } + return .mcpAdd + } + + private var portable: PortableMcpDefinition { + if isRemote { + return PortableMcpDefinition(kind: .remote, command: nil, args: [], env: [:], cwd: nil, + url: url.trimmingCharacters(in: .whitespaces), + remoteTransport: remoteTransport) + } + let args = argsText.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + var env: [String: McpValueExpr] = [:] + for r in envRows { + let k = r.key.trimmingCharacters(in: .whitespaces) + if !k.isEmpty { env[k] = McpValueExpr.parse(r.value, dialect: .dollar) } + } + return PortableMcpDefinition(kind: .stdio, + command: command.trimmingCharacters(in: .whitespaces), + args: args, env: env, cwd: nil, url: nil, remoteTransport: nil) + } + + private var isValid: Bool { + guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return false } + if isRemote { + guard !url.trimmingCharacters(in: .whitespaces).isEmpty else { return false } + } else { + guard !command.trimmingCharacters(in: .whitespaces).isEmpty else { return false } + } + if !isEdit { return !effectiveTargets.isEmpty } + return true + } + + /// Selected harnesses minus any that can't express the current transport. + private var effectiveTargets: [McpHarness] { + selected.filter { $0.transportSupport.contains(portable.transport) }.sorted { $0.rawValue < $1.rawValue } + } + + private func submit() { + didSubmit = true + switch mode { + case .add: + state.addMcpServer(name: name.trimmingCharacters(in: .whitespaces), + def: portable, targets: effectiveTargets) + case .edit(let s): + state.editMcpServer(s, def: portable) + } + } +} + +struct EnvRow: Identifiable { + let id = UUID() + var key: String = "" + var value: String = "" +} diff --git a/loadout/apps/macOS/Sources/Views/SidebarView.swift b/loadout/apps/macOS/Sources/Views/SidebarView.swift new file mode 100644 index 0000000..bf66c0c --- /dev/null +++ b/loadout/apps/macOS/Sources/Views/SidebarView.swift @@ -0,0 +1,245 @@ +import SwiftUI + +struct SidebarView: View { + @EnvironmentObject var state: AppState + + /// Native single-selection binding: nil (deselect) falls back to All. + private var selectionBinding: Binding { + Binding( + get: { state.sidebarFilter }, + set: { state.sidebarFilter = $0 ?? .library(.all) } + ) + } + + var body: some View { + List(selection: selectionBinding) { + // Kind switcher — Skills now, MCP reserved (DECISIONS D3). + Picker("Kind", selection: $state.kind) { + ForEach(ResourceKind.allCases, id: \.self) { Text($0.displayName).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .listRowInsets(EdgeInsets(top: 4, leading: 4, bottom: 8, trailing: 4)) + .listRowSeparator(.hidden) + .selectionDisabled() + + Section("Machine") { + machineRow("Local", systemImage: "desktopcomputer", active: !state.isRemote) { + state.selectLocal() + } + ForEach(state.savedHosts, id: \.self) { target in + machineRow(target, systemImage: "server.rack", + active: state.selectedHostTarget == target) { + state.selectHost(target) + } + .contextMenu { + Button(role: .destructive) { state.removeHost(target) } label: { + Label("Remove host", systemImage: "trash") + } + } + } + Button { state.newHostInput = ""; state.showAddHost = true } label: { + Label("Add remote host…", systemImage: "plus") + } + .help("Scan a machine over SSH (user@host or an ~/.ssh/config alias)") + if state.isRemote { + Button { state.reload() } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .help("Re-scan the remote machine (remote scopes don't live-update)") + } + } + .selectionDisabled() + + Section("Scope") { + Picker("Scope", selection: $state.scopeMode) { + ForEach(ScopeMode.allCases, id: \.self) { Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .disabled(state.isRemote) // remote is global-only (D7) + .onChange(of: state.scopeMode) { _, mode in + state.resetFilters() + if mode == .project { + state.enterProjectScope() + } else { + state.reload() + } + } + } + .selectionDisabled() + + // Saved projects live under the Project tab only — hidden in Global scope. + if state.scopeMode == .project { + Section("Projects") { + ForEach(state.savedProjects, id: \.self) { url in + projectRow(url) + } + Button { state.chooseProject() } label: { + Label("Add project…", systemImage: "plus") + } + .help("Choose a project folder to scan and keep") + } + .selectionDisabled() + } + + switch state.kind { + case .skill: + Section("Library") { + row(.library(.all), "All", systemImage: "square.stack", count: state.skills.count) + row(.library(.drift), "Drift", systemImage: "exclamationmark.triangle", + count: state.driftCount, iconTint: state.driftCount > 0 ? Theme.drift : nil) + row(.library(.diverged), "Diverged", systemImage: "arrow.triangle.branch", + count: state.divergedCount) + } + + Section("Agents") { + ForEach(Agent.sidebarAgents) { agent in + row(.agent(agent), agent.displayName, dot: agent.color, count: state.count(for: agent)) + } + } + + if !state.sources.isEmpty { + Section("Sources") { + ForEach(state.sources, id: \.name) { src in + let isLocal = src.name.hasPrefix("Local") + row(.source(src.name), src.name, + systemImage: isLocal ? "folder" : "shippingbox", + count: src.count, truncate: true) + } + } + } + case .mcp: + Section("Library") { + row(.library(.all), "All", systemImage: "square.stack", count: state.mcpServers.count) + row(.library(.drift), "Missing", systemImage: "circle.dashed", + count: state.mcpDriftCount, iconTint: state.mcpDriftCount > 0 ? Theme.drift : nil) + row(.library(.diverged), "Diverged", systemImage: "arrow.triangle.branch", + count: state.mcpDivergedCount) + } + + Section("Harnesses") { + ForEach(McpHarness.allCases) { harness in + row(.mcpHarness(harness), harness.displayName, + dot: harness.color, count: state.mcpCount(for: harness)) + } + } + } + } + .listStyle(.sidebar) + .tint(Agent.claude.color) // Liquid Glass selection picks up the accent + .alert("Add remote host", isPresented: $state.showAddHost) { + TextField("user@host or ssh alias", text: $state.newHostInput) + Button("Add") { state.addRemoteHost(state.newHostInput) } + Button("Cancel", role: .cancel) { } + } message: { + Text("Scan this machine's skills over SSH. Tries your ssh keys first; if none work you'll be asked for a password. Read-only.") + } + .sheet(isPresented: Binding( + get: { state.passwordPromptTarget != nil }, + set: { if !$0 { state.cancelPassword() } } + )) { + passwordPrompt + } + } + + /// Secure password prompt shown when key auth is refused. The password is kept only in + /// memory for the session (never stored), so it's re-asked after relaunch. + private var passwordPrompt: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Password for \(state.passwordPromptTarget ?? "")") + .font(.headline) + Text("No SSH key worked for this host. Enter a password to scan it (read-only). Kept only for this session.") + .font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + SecureField("Password", text: $state.passwordInput) + .textFieldStyle(.roundedBorder) + .onSubmit { state.submitPassword() } + HStack { + Spacer() + Button("Cancel", role: .cancel) { state.cancelPassword() } + Button("Connect") { state.submitPassword() } + .keyboardShortcut(.defaultAction) + .disabled(state.passwordInput.isEmpty) + } + } + .padding(20) + .frame(width: 380) + } + + /// A machine row (Local or a saved remote): switch on click, check the active one. + @ViewBuilder + private func machineRow(_ title: String, systemImage: String, + active: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: systemImage) + .foregroundStyle(active ? Agent.claude.color : .secondary) + Text(title) + .fontWeight(active ? .semibold : .regular) + .lineLimit(1).truncationMode(.middle) + Spacer() + if active { + Image(systemName: "checkmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(Agent.claude.color) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(title) + } + + /// A saved-project row: switch on click, mark the active one, remove via context menu. + @ViewBuilder + private func projectRow(_ url: URL) -> some View { + let active = state.scopeMode == .project && state.selectedProject?.path == url.path + Button { state.setProject(url) } label: { + HStack(spacing: 8) { + Image(systemName: active ? "folder.fill" : "folder") + .foregroundStyle(active ? Agent.claude.color : .secondary) + Text(url.lastPathComponent) + .fontWeight(active ? .semibold : .regular) + .lineLimit(1).truncationMode(.middle) + Spacer() + if active { + Image(systemName: "checkmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(Agent.claude.color) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(url.path) + .contextMenu { + Button(role: .destructive) { state.removeProject(url) } label: { + Label("Remove from list", systemImage: "trash") + } + } + } + + // MARK: - Rows + + /// One selectable, tagged sidebar row. Native List selection renders the macOS + /// Liquid Glass highlight; we only supply the content + the `.tag` it selects. + @ViewBuilder + private func row(_ tag: SidebarFilter, _ title: String, + systemImage: String? = nil, dot: Color? = nil, + count: Int, iconTint: Color? = nil, truncate: Bool = false) -> some View { + HStack(spacing: 8) { + if let dot { + Circle().fill(dot).frame(width: 9, height: 9) + } else if let systemImage { + Image(systemName: systemImage).foregroundStyle(iconTint ?? .secondary) + } + Text(title) + .lineLimit(1) + .truncationMode(truncate ? .middle : .tail) + Spacer() + Text("\(count)").font(.caption.monospaced()).foregroundStyle(.secondary) + } + .tag(tag) + } +} diff --git a/loadout/apps/macOS/Sources/Views/SkillDetailView.swift b/loadout/apps/macOS/Sources/Views/SkillDetailView.swift new file mode 100644 index 0000000..ed16e02 --- /dev/null +++ b/loadout/apps/macOS/Sources/Views/SkillDetailView.swift @@ -0,0 +1,327 @@ +import SwiftUI + +struct SkillDetailView: View { + let skill: Skill + @EnvironmentObject var state: AppState + @State private var wireTarget: Agent? + @State private var confirmRemove = false + + /// This skill is a candidate for `skills update` only when CLI-managed and provenanced. + private var canUpdate: Bool { + skill.isCLIManaged && (skill.provenance?.source.isEmpty == false) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + header + managementRow + agentsSection + if !skill.driftMissing.isEmpty { driftCard } + if let p = skill.provenance, !p.source.isEmpty { provenanceSection(p) } + if !skill.frontmatterKeys.isEmpty { frontmatterSection } + if !skill.bundledFiles.isEmpty { filesSection } + bodySection + } + .padding(22) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle(skill.name) + .confirmationDialog( + "Wire \(skill.name) into \(wireTarget?.displayName ?? "")?", + isPresented: Binding(get: { wireTarget != nil }, set: { if !$0 { wireTarget = nil } }), + titleVisibility: .visible + ) { + Button("Create symlink") { if let a = wireTarget { state.wire(skill, into: a); wireTarget = nil } } + Button("Cancel", role: .cancel) { wireTarget = nil } + } message: { + Text("Creates a symlink in the agent's skills directory pointing at the canonical skill.") + } + } + + // MARK: - Sections + + private var header: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 9) { + Text(skill.name).font(.largeTitle).fontWeight(.bold) + if skill.diverged { + Tag(text: "diverged", color: Theme.drift) + .help("Another skill with this name exists in this scope (a same-name clash).") + } + } + if let s = skill.summary { + Text(s).font(.title3).foregroundStyle(.secondary) + } + HStack(spacing: 5) { + Image(systemName: "link").font(.caption2) + Text(skill.canonicalPath).font(.system(.caption, design: .monospaced)) + } + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + } + + private var managementRow: some View { + HStack(spacing: 8) { + if let loc = skill.locationBadge { + pill(loc, systemImage: "folder", color: .secondary) + .help(skill.locationHelp ?? "Where this skill lives in the project") + } + pill(skill.gitStatus.label, systemImage: skill.gitStatus.systemImage, color: skill.gitStatus.color) + .help("\(skill.gitStatus.label) — \(skill.gitStatus.helpText)") + if skill.linksDiverge { + pill("links diverge", systemImage: "arrow.triangle.branch", color: Theme.drift) + .help("The agent-dir symlink(s) differ in git status from the skill's canonical files.") + } + pill(skill.isCLIManaged ? "skills.sh" : "Manual", + systemImage: skill.isCLIManaged ? "terminal" : "hand.raised", + color: .secondary) + .help(skill.isCLIManaged + ? "Tracked in skills-lock.json (managed by the skills CLI)" + : "Not in skills-lock.json — a manual/local skill") + + Spacer() + + if !state.isRemote && canUpdate { + Button { + state.updateSkill(skill) + } label: { + if state.actionStatus.isRunning(.update(skill.id)) { + HStack(spacing: 5) { ProgressView().controlSize(.small); Text("Updating…") } + } else { + Label("Update", systemImage: "arrow.triangle.2.circlepath") + } + } + .buttonStyle(.glass) + .controlSize(.small) + .disabled(!state.cliAvailable || state.actionStatus.isRunning) + .help(state.cliAvailable ? "Update to the latest version of its source" : "Requires the skills CLI") + } + + if !state.isRemote { // remote scopes are read-only (D7) + Button(role: .destructive) { + confirmRemove = true + } label: { + Label("Remove…", systemImage: "trash") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!state.cliAvailable || state.actionStatus.isRunning) + .help(state.cliAvailable ? "Uninstall this skill" : "Requires the skills CLI") + .confirmationDialog( + "Remove \(skill.name)?", + isPresented: $confirmRemove, + titleVisibility: .visible + ) { + Button("Remove \(skill.name)", role: .destructive) { state.removeSkill(skill) } + Button("Cancel", role: .cancel) {} + } message: { + Text("Uninstalls the skill via the skills CLI.") + } + } + } + } + + private var agentsSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Available to") + FlowRow(spacing: 7) { + // Canonical store = every universal agent at once. One chip, not a list. + if skill.canonicalPresent { + chip("Canonical · .agents/skills", color: Agent.agents.color, + systemImage: "shippingbox.fill", filled: true) + } + // Claude Code reaches it via its own dir — say honestly whether that's a + // symlink (→ canonical store) or real files living in .claude/skills. + if skill.access(.claude) == .wired { + let local = skill.isLocalDir(.claude) + chip("Claude Code · \(local ? "local files" : "symlinked")", + color: Agent.claude.color, + systemImage: local ? "folder.fill" : "link.circle.fill", filled: true) + } + ForEach(Array(skill.driftMissing).sorted { $0.rawValue < $1.rawValue }) { agent in + chip("\(agent.displayName) · declared", color: Theme.drift, systemImage: nil, ghost: true) + } + } + if skill.canonicalPresent { + Text("Codex, OpenCode & Pi read from .agents/skills directly — only Claude Code needs a symlink.") + .font(.caption2).foregroundStyle(.tertiary) + } + } + } + + private var driftCard: some View { + VStack(alignment: .leading, spacing: 10) { + Label("Drift", systemImage: "exclamationmark.triangle.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color(hex: 0x8A6400)) + Text("Declared for \(skill.driftMissing.map(\.displayName).joined(separator: ", ")) but not wired on disk.") + .font(.callout) + .foregroundStyle(Color(hex: 0x7A5800)) + if !state.isRemote { // wiring is a write — disabled for read-only remote scopes (D7) + HStack(spacing: 8) { + ForEach(Array(skill.driftMissing).sorted { $0.rawValue < $1.rawValue }) { agent in + Button { + wireTarget = agent + } label: { + if state.actionStatus.isRunning(.wire(skill.id, agent)) { + HStack(spacing: 5) { + ProgressView().controlSize(.small) + Text("Wiring…") + } + } else { + Text("Wire into \(agent.displayName)") + } + } + .buttonStyle(.glassProminent) + .controlSize(.small) + .tint(Theme.drift) + .disabled(state.actionStatus.isRunning) + } + } + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect(.regular.tint(Theme.drift.opacity(0.18)), in: .rect(cornerRadius: 12)) + } + + private func provenanceSection(_ p: SkillProvenance) -> some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Provenance") + kv("Source", p.source) + if let u = p.sourceURL { kv("Repo", u) } + if let path = p.skillPath { kv("Path", path) } + if let up = p.updatedAt { kv("Updated", up) } + if let h = p.folderHash { kv("Hash", String(h.prefix(12))) } + if let plugin = p.pluginName { kv("Plugin", plugin) } + } + } + + private var frontmatterSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Frontmatter") + Text(skill.frontmatterKeys.joined(separator: ", ")) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + + private var filesSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Packaged files") + FlowRow(spacing: 7) { + ForEach(skill.bundledFiles) { file in + Button { state.openBundledFile(file) } label: { + HStack(spacing: 5) { + Image(systemName: file.isDirectory ? "folder.fill" : "doc") + .font(.caption2) + Text(file.name + (file.isDirectory ? "/" : "")) + .font(.caption.weight(.medium)) + } + .padding(.horizontal, 9).padding(.vertical, 4) + .background(Color.secondary.opacity(0.12), in: Capsule()) + } + .buttonStyle(.plain) + .help(file.isDirectory ? "Reveal \(file.name) in Finder" : "Open \(file.name)") + } + } + Text("Files bundled with this skill alongside SKILL.md.") + .font(.caption2).foregroundStyle(.tertiary) + } + } + + private var bodySection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + sectionTitle("SKILL.md") + Spacer() + if !state.isRemote { // opens a local file — not meaningful for remote skills (D7) + Button { state.openInEditor(skill) } label: { + Label("Edit", systemImage: "pencil").font(.caption) + } + .buttonStyle(.glass) + .controlSize(.small) + } + } + Text(skill.bodyMarkdown) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.quaternary)) + } + } + + // MARK: - Helpers + + private func sectionTitle(_ t: String) -> some View { + Text(t.uppercased()) + .font(.caption).fontWeight(.semibold) + .foregroundStyle(.secondary) + .tracking(0.6) + } + + private func kv(_ k: String, _ v: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Text(k).font(.caption).foregroundStyle(.secondary).frame(width: 70, alignment: .leading) + Text(v).font(.system(.caption, design: .monospaced)).textSelection(.enabled) + } + } + + private func pill(_ text: String, systemImage: String, color: Color) -> some View { + Label(text, systemImage: systemImage) + .font(.caption.weight(.medium)) + .padding(.horizontal, 9).padding(.vertical, 4) + .background(color.opacity(0.13), in: Capsule()) + .foregroundStyle(color == .secondary ? .secondary : color) + } + + private func chip(_ text: String, color: Color, systemImage: String?, filled: Bool = false, ghost: Bool = false) -> some View { + HStack(spacing: 5) { + if let systemImage { Image(systemName: systemImage).font(.caption2) } + Text(text).font(.caption.weight(.medium)) + } + .padding(.horizontal, 9).padding(.vertical, 4) + .background(ghost ? Color.clear : color.opacity(0.14), in: Capsule()) + .overlay { + if ghost { Capsule().strokeBorder(color.opacity(0.55), style: StrokeStyle(lineWidth: 1, dash: [3, 2])) } + } + .foregroundStyle(color) + } +} + +/// Simple wrapping HStack for chips. +struct FlowRow: Layout { + var spacing: CGFloat = 7 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0 + for s in subviews { + let size = s.sizeThatFits(.unspecified) + if x + size.width > maxWidth, x > 0 { + x = 0; y += rowHeight + spacing; rowHeight = 0 + } + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + return CGSize(width: maxWidth == .infinity ? x : maxWidth, height: y + rowHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0 + for s in subviews { + let size = s.sizeThatFits(.unspecified) + if x + size.width > bounds.maxX, x > bounds.minX { + x = bounds.minX; y += rowHeight + spacing; rowHeight = 0 + } + s.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} diff --git a/loadout/apps/macOS/Sources/Views/SkillListView.swift b/loadout/apps/macOS/Sources/Views/SkillListView.swift new file mode 100644 index 0000000..dc44316 --- /dev/null +++ b/loadout/apps/macOS/Sources/Views/SkillListView.swift @@ -0,0 +1,256 @@ +import SwiftUI + +struct SkillListView: View { + @EnvironmentObject var state: AppState + @State private var showInstall = false + @State private var showAddMcp = false + + /// True when in Project scope but no project is chosen yet — install needs a target. + private var needsProject: Bool { + state.scopeMode == .project && state.selectedProject == nil + } + + var body: some View { + Group { + switch state.kind { + case .mcp: + McpListView() + case .skill: + // Compute the filtered list once per render — body re-evaluates on every + // @Published change and this branch reads it for both the List and the overlay. + let skills = state.filteredSkills + List(skills, selection: $state.selection) { skill in + SkillRow(skill: skill) + } + .searchable(text: $state.searchText, placement: .toolbar, prompt: "Search skills") + .overlay { + if state.isLoading && state.skills.isEmpty { + ProgressView() + } else if skills.isEmpty { + ContentUnavailableView("No skills", systemImage: "tray", + description: Text(emptyHint)) + } + } + } + } + .toolbar { + // Fix-all-drift — only when there's drift; reflects in-progress status. + if state.driftCount > 0 && state.kind == .skill { + ToolbarItem(placement: .automatic) { + Button { + state.fixAllDrift() + } label: { + if let label = state.actionStatus.runningLabel(.fixAllDrift) { + Text(label) + } else { + Text("Fix all drift") + } + } + .tint(Theme.drift) + .controlSize(.small) + .disabled(state.actionStatus.isRunning || state.isLoading) + .help("Wire every skill that's declared but not linked") + } + } + ToolbarItem(placement: .primaryAction) { + switch state.kind { + case .mcp: + Button { showAddMcp = true } label: { + Label("Add MCP server…", systemImage: "plus") + } + .tint(McpHarness.claudeCode.color) + .disabled(needsProject) + .help(needsProject ? "Choose a project first" : "Add an MCP server to your harnesses") + case .skill: + Button { showInstall = true } label: { + Label("Install skill…", systemImage: "plus") + } + .tint(Agent.claude.color) + .disabled(needsProject) + .help(needsProject ? "Choose a project first" : "Install a skill from a source") + } + } + ToolbarItem(placement: .primaryAction) { + // Small inline refresh spinner so a reload of an already-populated list shows progress. + if state.isLoading && !state.skills.isEmpty { + ProgressView().controlSize(.small) + } + } + ToolbarItem(placement: .primaryAction) { + Button { state.reload() } label: { Image(systemName: "arrow.clockwise") } + .help("Refresh (⌘R)") + .disabled(state.isLoading) + } + } + .sheet(isPresented: $showInstall) { InstallSheet() } + .sheet(isPresented: $showAddMcp) { McpServerSheet(mode: .add) } + } + + private var emptyHint: String { + if state.scopeMode == .project && state.selectedProject == nil { + return "Choose a project to scan its skills." + } + return "Nothing matches the current filters." + } +} + +/// Install a skill (or whole package) from a source ref via the skills CLI. +struct InstallSheet: View { + @EnvironmentObject var state: AppState + @Environment(\.dismiss) private var dismiss + + @State private var ref = "" + @State private var skillName = "" + /// Snapshot of the status owned by THIS install, so an overlapping mutation can't fool us. + @State private var didSubmit = false + + private var scopeReadout: String { + if state.scopeMode == .project { + return "Project: \(state.selectedProject?.lastPathComponent ?? "—")" + } + return "Global" + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Install skill").font(.title2).fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 6) { + Text("Source").font(.caption).foregroundStyle(.secondary) + TextField("owner/repo or URL", text: $ref) + .textFieldStyle(.roundedBorder) + } + VStack(alignment: .leading, spacing: 6) { + Text("Skill name (optional)").font(.caption).foregroundStyle(.secondary) + TextField("install one named skill, or leave blank for all", text: $skillName) + .textFieldStyle(.roundedBorder) + } + + HStack(spacing: 6) { + Image(systemName: "scope").font(.caption2) + Text(scopeReadout).font(.caption).foregroundStyle(.secondary) + } + + if !state.cliAvailable { + Label("Requires the skills CLI on PATH", systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(Theme.drift) + } + + // In-flight / failure feedback owned by THIS submission (scoped to .install, + // so a background reload or overlapping mutation can't alter it). + if didSubmit { + if let label = state.actionStatus.runningLabel(.install) { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(label).font(.caption).foregroundStyle(.secondary) + } + } else if let msg = state.actionStatus.failureMessage(.install) { + Text(msg) + .font(.caption) + .foregroundStyle(Theme.drift) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(Theme.drift.opacity(0.1), in: RoundedRectangle(cornerRadius: 6)) + } + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Install") { + didSubmit = true + let trimmedSkill = skillName.trimmingCharacters(in: .whitespaces) + state.install(ref: ref.trimmingCharacters(in: .whitespaces), + skill: trimmedSkill.isEmpty ? nil : trimmedSkill) + } + .buttonStyle(.glassProminent) + .tint(Agent.claude.color) + .keyboardShortcut(.defaultAction) + .disabled(ref.trimmingCharacters(in: .whitespaces).isEmpty + || !state.cliAvailable + || state.actionStatus.isRunning) + } + } + .padding(20) + .frame(width: 420) + // Dismiss once THIS install succeeds (ignores other actions' success). + .onChange(of: state.actionStatus) { _, status in + if didSubmit, status.didSucceed(.install) { dismiss() } + } + } +} + +struct SkillRow: View { + let skill: Skill + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(skill.name).fontWeight(.medium) + if skill.diverged { + Tag(text: "diverged", color: Theme.drift) + .help("Another skill with this name exists in this scope (a same-name clash).") + } + if !skill.driftMissing.isEmpty { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(Theme.drift) + .help("Declared for \(skill.driftMissing.map(\.displayName).joined(separator: ", ")) but not wired") + } + Spacer() + if let loc = skill.locationBadge { + Text(loc) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.head) + .help(skill.locationHelp ?? "Project location") + } + Image(systemName: skill.gitStatus.systemImage) + .font(.caption2) + .foregroundStyle(skill.gitStatus.color) + .help("\(skill.gitStatus.label) — \(skill.gitStatus.helpText)\(skill.linksDiverge ? " · links diverge" : "")") + } + if let s = skill.summary { + Text(s).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + HStack(spacing: 6) { + // Canonical store covers all universal agents — one indicator, not a list. + if skill.canonicalPresent { + Image(systemName: "shippingbox.fill") + .font(.system(size: 9)) + .foregroundStyle(Agent.agents.color) + .help("In .agents/skills — Codex, OpenCode & Pi read it directly") + } + // Claude Code reaches it via its own dir (symlink or real local files). + if skill.access(.claude) == .wired { + Circle().fill(Agent.claude.color).frame(width: 7, height: 7) + .help(skill.isLocalDir(.claude) + ? "Claude Code · local files in .claude/skills" + : "Claude Code · symlinked") + } + if skill.isCLIManaged { + Image(systemName: "terminal") + .font(.system(size: 8)) + .foregroundStyle(.tertiary) + .help("Managed by the skills CLI") + } + } + } + .padding(.vertical, 3) + } +} + +struct Tag: View { + let text: String + let color: Color + var body: some View { + Text(text) + .font(.system(size: 9, weight: .bold)) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(color.opacity(0.16), in: Capsule()) + .foregroundStyle(color) + } +} diff --git a/loadout/apps/macOS/Tests/CodecTests.swift b/loadout/apps/macOS/Tests/CodecTests.swift new file mode 100644 index 0000000..021e07b --- /dev/null +++ b/loadout/apps/macOS/Tests/CodecTests.swift @@ -0,0 +1,255 @@ +import XCTest +@testable import Loadout + +/// Round-trip + regression tests for the MCP config codec/write path — the only code that +/// edits the user's real config files, where a bug silently corrupts rather than crashes. +/// Everything here drives the pure, disk-free `McpWriteEngine.patchedText` except the small +/// `apply` smoke tests at the end, which use a temp dir. +final class CodecTests: XCTestCase { + + // MARK: - Fixtures + + private func loc(_ harness: McpHarness, _ format: McpConfigFormat, + _ keyPath: [String], _ name: String = "x") -> McpConfigLocation { + McpConfigLocation(harness: harness, url: URL(fileURLWithPath: "/tmp/\(name)"), + format: format, keyPath: keyPath, label: "test", isPrimary: true) + } + private var claudeLoc: McpConfigLocation { loc(.claudeCode, .json, ["mcpServers"]) } + private var opencodeLoc: McpConfigLocation { loc(.opencode, .jsonc, ["mcp"]) } + private var codexLoc: McpConfigLocation { loc(.codex, .toml, ["mcp_servers"]) } + + private func stdio(_ command: String = "npx", _ args: [String] = ["-y", "pkg"], + env: [String: McpValueExpr] = [:]) -> PortableMcpDefinition { + PortableMcpDefinition(kind: .stdio, command: command, args: args, env: env, + cwd: nil, url: nil, remoteTransport: nil) + } + private func upsert(_ name: String, _ def: PortableMcpDefinition, + _ enabled: Bool = true) -> McpWriteEngine.Op { + .upsert(name: name, def: def, enabled: enabled) + } + private func patched(_ current: String?, _ location: McpConfigLocation, + _ op: McpWriteEngine.Op) throws -> String { + try McpWriteEngine.patchedText(current: current, location: location, op: op) + } + private func parsesAsJSON(_ text: String) -> Bool { + let stripped = McpConfigCodec.stripJsonc(text) + return (try? JSONSerialization.jsonObject(with: Data(stripped.utf8))) != nil + } + + // MARK: - JSON (Claude Code) + + func testJsonUpsertIntoEmptyProducesValidServer() throws { + let t = try patched(nil, claudeLoc, upsert("foo", stdio())) + XCTAssertTrue(parsesAsJSON(t)) + XCTAssertTrue(t.contains("\"foo\"")) + XCTAssertTrue(t.contains("\"npx\"")) + } + + func testJsonUpsertIsIdempotent() throws { + let t1 = try patched(nil, claudeLoc, upsert("foo", stdio())) + let t2 = try patched(t1, claudeLoc, upsert("foo", stdio())) + XCTAssertEqual(t1, t2, "re-applying the same upsert must be byte-identical") + } + + func testJsonRemoveLeavesValidJson() throws { + let t1 = try patched(nil, claudeLoc, upsert("foo", stdio())) + let t2 = try patched(t1, claudeLoc, upsert("bar", stdio("node"))) + let t3 = try patched(t2, claudeLoc, .remove(name: "foo")) + XCTAssertTrue(parsesAsJSON(t3)) + XCTAssertFalse(t3.contains("\"foo\"")) + XCTAssertTrue(t3.contains("\"bar\"")) + } + + func testJsonMalformedIsRefusedNotFixed() { + XCTAssertThrowsError(try patched("{ not valid json", claudeLoc, upsert("foo", stdio()))) + } + + // MARK: - JSONC (opencode) — comments, trailing commas, unmanaged keys survive + + func testJsoncPreservesCommentsAndUnmanagedKeysOnSiblingUpsert() throws { + let src = """ + { + // top comment + "mcp": { + "existing": { "type": "local", "command": ["a"], "headers": { "X": "y" }, "enabled": true } // keep me + } + } + """ + let t = try patched(src, opencodeLoc, upsert("added", stdio("svr", []))) + XCTAssertTrue(t.contains("// top comment"), "block comment preserved") + XCTAssertTrue(t.contains("// keep me"), "trailing line comment preserved") + XCTAssertTrue(t.contains("\"headers\""), "unmanaged auth key preserved") + XCTAssertTrue(t.contains("\"existing\"") && t.contains("\"added\"")) + XCTAssertTrue(parsesAsJSON(t), "result still parses after JSONC strip") + } + + func testOpencodeEnvDialectRoundTrip() throws { + // A ${VAR} interpolation normalized to .envVar must render in opencode's {env:VAR}. + let def = stdio("x", [], env: ["TOKEN": .envVar("TOKEN")]) + let t = try patched(nil, opencodeLoc, upsert("s", def)) + XCTAssertTrue(t.contains("{env:TOKEN}")) + } + + // MARK: - TOML (Codex) — the quoted-key regression + duplicate guard + + func testTomlUpsertPlainNameIdempotent() throws { + let t1 = try patched(nil, codexLoc, upsert("github", stdio())) + let t2 = try patched(t1, codexLoc, upsert("github", stdio())) + XCTAssertEqual(t1, t2) + XCTAssertTrue(t1.contains("[mcp_servers.github]")) + } + + /// Regression: a name needing a quoted key segment must be FOUND again after insert — + /// before the fix, the header was rendered quoted but looked up unquoted, so a second + /// upsert appended a duplicate block and remove no-op'd. + func testTomlQuotedNameDoesNotDuplicateAndCanBeRemoved() throws { + let name = "my.server" + let t1 = try patched(nil, codexLoc, upsert(name, stdio("a"))) + XCTAssertTrue(t1.contains("[mcp_servers.\"my.server\"]")) + + let t2 = try patched(t1, codexLoc, upsert(name, stdio("b"))) + let headers = t2.components(separatedBy: "[mcp_servers.\"my.server\"]").count - 1 + XCTAssertEqual(headers, 1, "second upsert must replace, not append a duplicate block") + XCTAssertTrue(t2.contains("\"b\"") && !t2.contains("\"a\""), "value was actually updated") + + let t3 = try patched(t2, codexLoc, .remove(name: name)) + XCTAssertFalse(t3.contains("my.server"), "remove must locate the quoted block") + } + + func testTomlPreservesCommentsAndUnmanagedKeys() throws { + let src = """ + [mcp_servers.foo] + command = "old" + # a note + bearer_token_env_var = "TOK" + """ + let t = try patched(src, codexLoc, upsert("foo", stdio("new", []))) + XCTAssertTrue(t.contains("# a note"), "comment preserved") + XCTAssertTrue(t.contains("bearer_token_env_var = \"TOK\""), "unmanaged key preserved") + XCTAssertTrue(t.contains("command = \"new\"") && !t.contains("\"old\""), "managed key rewritten") + } + + func testTomlDuplicateTablesRefused() { + let dup = """ + [mcp_servers.foo] + command = "a" + + [mcp_servers.foo] + command = "b" + """ + XCTAssertThrowsError(try patched(dup, codexLoc, upsert("foo", stdio())), + "duplicate tables must fail closed, not edit only the first") + } + + func testTomlPlainServersTableRefused() { + XCTAssertThrowsError(try patched("[mcp_servers]\nfoo = 1\n", codexLoc, upsert("foo", stdio()))) + } + + // MARK: - TomlMiniReader: line-continuation backslash must not eat a literal '#' + + func testTomlMultilineLineContinuationKeepsHash() throws { + let src = "x = \"\"\"abc\\\n#keep\"\"\"\n" + let parsed = try TomlMiniReader.parse(src) + XCTAssertEqual(parsed["x"] as? String, "abc#keep") + } + + // MARK: - Disk apply (atomic, verified) — touches only its own temp file + + func testApplyWritesParseableFileAndIsIdempotent() throws { + let dir = try tempDir() + let url = dir.appendingPathComponent(".mcp.json") + let location = McpConfigLocation(harness: .claudeCode, url: url, format: .json, + keyPath: ["mcpServers"], label: "t", isPrimary: true) + try McpWriteEngine.apply(upsert("foo", stdio()), at: location) + let back = try String(contentsOf: url, encoding: .utf8) + XCTAssertTrue(parsesAsJSON(back)) + XCTAssertTrue(back.contains("\"foo\"")) + + // Second identical apply must be a no-op: bytes unchanged. + let before = try Data(contentsOf: url) + try McpWriteEngine.apply(upsert("foo", stdio()), at: location) + XCTAssertEqual(before, try Data(contentsOf: url)) + } + + func testApplyRefusesMalformedAndLeavesFileUntouched() throws { + let dir = try tempDir() + let url = dir.appendingPathComponent(".mcp.json") + let original = "{ this is broken" + try original.write(to: url, atomically: true, encoding: .utf8) + let location = McpConfigLocation(harness: .claudeCode, url: url, format: .json, + keyPath: ["mcpServers"], label: "t", isPrimary: true) + XCTAssertThrowsError(try McpWriteEngine.apply(upsert("foo", stdio()), at: location)) + XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), original, + "a malformed file must never be rewritten") + } + + func testReadRoundTripsThroughCodec() throws { + let dir = try tempDir() + let url = dir.appendingPathComponent(".mcp.json") + let location = McpConfigLocation(harness: .claudeCode, url: url, format: .json, + keyPath: ["mcpServers"], label: "t", isPrimary: true) + try McpWriteEngine.apply(upsert("foo", stdio("npx", ["-y", "pkg"])), at: location) + guard case .ok(let map) = McpConfigCodec.read(location) else { + return XCTFail("expected .ok") + } + XCTAssertEqual(map["foo"]?.portable?.command, "npx") + XCTAssertEqual(map["foo"]?.portable?.args, ["-y", "pkg"]) + } + + // MARK: - RemoteHostIO.parseListDir (the SSH listing parser, no live host needed) + + func testParseListDir() { + // Records sep \036, fields sep \037: name, isDir, isSymlink, linkTarget. + let blob = "real-skill\u{1F}1\u{1F}0\u{1F}\u{1E}" + + "linked\u{1F}1\u{1F}1\u{1F}../../.agents/skills/linked\u{1E}" + + "notes.md\u{1F}0\u{1F}0\u{1F}\u{1E}" + let entries = RemoteHostIO.parseListDir(Data(blob.utf8)) + XCTAssertEqual(entries.count, 3) + + XCTAssertEqual(entries[0].name, "real-skill") + XCTAssertTrue(entries[0].isDir) + XCTAssertFalse(entries[0].isSymlink) + XCTAssertNil(entries[0].linkTarget) + + XCTAssertTrue(entries[1].isSymlink) + XCTAssertTrue(entries[1].isDir) // symlink-to-dir: dir flag follows the link + XCTAssertEqual(entries[1].linkTarget, "../../.agents/skills/linked") + + XCTAssertFalse(entries[2].isDir) + XCTAssertNil(entries[2].linkTarget) + } + + func testParseListDirEmptyAndMalformed() { + XCTAssertTrue(RemoteHostIO.parseListDir(Data()).isEmpty) + // A short (malformed) record is dropped, a valid one survives. + let blob = "bad\u{1F}1\u{1E}good\u{1F}0\u{1F}0\u{1F}\u{1E}" + let entries = RemoteHostIO.parseListDir(Data(blob.utf8)) + XCTAssertEqual(entries.map(\.name), ["good"]) + } + + // MARK: - RemoteHostIO.classifyConnect (key-fail → prompt routing) + + func testClassifyConnect() { + XCTAssertEqual(RemoteHostIO.classifyConnect(exit: 0, stderr: ""), .ok) + // Auth refusals route to the password prompt. + XCTAssertEqual( + RemoteHostIO.classifyConnect(exit: 255, stderr: "zackbart@h: Permission denied (publickey,password)."), + .needsPassword) + XCTAssertEqual( + RemoteHostIO.classifyConnect(exit: 255, stderr: "Received disconnect... No more authentication methods available"), + .needsPassword) + // Network/DNS failures surface as an error, not a prompt. + if case .failed = RemoteHostIO.classifyConnect(exit: 255, stderr: "ssh: Could not resolve hostname nope") { + } else { XCTFail("expected .failed for DNS error") } + } + + // MARK: - Helpers + + private func tempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("loadout-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: dir) } + return dir + } +} diff --git a/loadout/clay-drop-v2.png b/loadout/clay-drop-v2.png new file mode 100644 index 0000000..08ed2f9 Binary files /dev/null and b/loadout/clay-drop-v2.png differ diff --git a/loadout/clay-supply-drop-icon.png b/loadout/clay-supply-drop-icon.png new file mode 100644 index 0000000..fd5ee56 Binary files /dev/null and b/loadout/clay-supply-drop-icon.png differ diff --git a/loadout/project.yml b/loadout/project.yml new file mode 100644 index 0000000..36f2402 --- /dev/null +++ b/loadout/project.yml @@ -0,0 +1,110 @@ +# XcodeGen project definition — the maintained source of truth. Generate with: +# +# brew install xcodegen +# xcodegen generate && open Loadout.xcodeproj +# +# A monorepo with two apps: +# - Loadout (macOS) — skills + MCP management; sources in apps/macOS/Sources +# - LoadoutiOS (iOS) — Herdr terminal client (uses HerdrKit); apps/iOS/Sources, +# displayed as "Loadout" +name: Loadout +options: + bundleIdPrefix: dev.zackbart + createIntermediateGroups: true + deploymentTarget: + macOS: "26.0" + iOS: "17.0" +packages: + Yams: + url: https://github.com/jpsim/Yams + from: "5.0.6" + HerdrKit: + path: Packages/HerdrKit + # iOS-only SSH stack — powers apps/iOS/Sources/Connection/SSHTransport.swift. + Citadel: + url: https://github.com/orlandos-nl/Citadel.git + from: "0.7.0" + swift-nio: + url: https://github.com/apple/swift-nio.git + from: "2.0.0" + # Same fork + range Citadel pins; used by the TOFU host-key validator. + 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: + Loadout: + type: application + platform: macOS + sources: + - path: apps/macOS/Sources + dependencies: + - package: Yams + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.zackbart.loadout + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "2" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + SWIFT_VERSION: "5.0" + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: Loadout + INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.developer-tools" + INFOPLIST_KEY_NSHumanReadableCopyright: "MIT Licensed" + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_STYLE: Automatic + scheme: + testTargets: + - LoadoutTests + LoadoutTests: + type: bundle.unit-test + platform: macOS + sources: + - path: apps/macOS/Tests + dependencies: + - target: Loadout + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.zackbart.loadoutTests + SWIFT_VERSION: "5.0" + GENERATE_INFOPLIST_FILE: YES + LoadoutiOS: + type: application + platform: iOS + sources: + - path: apps/iOS/Sources + 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.zackbart.loadout.ios + PRODUCT_NAME: Loadout + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + SWIFT_VERSION: "5.9" + TARGETED_DEVICE_FAMILY: "1,2" + DEVELOPMENT_TEAM: F2J8ZU2NQJ + CODE_SIGN_STYLE: Automatic + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: Loadout + # iOS gates LAN connections (private IPs / .local) behind Local Network + # Privacy — required so SSHing into a Herdr box on your network works. + INFOPLIST_KEY_NSLocalNetworkUsageDescription: "Loadout connects over SSH to machines on your local network to control their terminal sessions." + # Standard SSH crypto only → exempt; declared so uploads skip the 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/mrmouse/.gitignore b/mrmouse/.gitignore new file mode 100644 index 0000000..e528f4a --- /dev/null +++ b/mrmouse/.gitignore @@ -0,0 +1,26 @@ +# Xcode +DerivedData/ +xcuserdata/ +*.xcworkspace/ +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 +*.xccheckout +*.moved-aside + +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Claude / motif +.motif/ + +# Logs +*.log diff --git a/mrmouse/CONTRIBUTING.md b/mrmouse/CONTRIBUTING.md new file mode 100644 index 0000000..1844229 --- /dev/null +++ b/mrmouse/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing to MrMouse + +Thanks for your interest in contributing! MrMouse is a lightweight macOS driver for Logitech MX Master mice, and contributions are welcome. + +## Getting Started + +1. Fork the repo and clone your fork +2. Open `mrmouse/mrmouse.xcodeproj` in Xcode +3. Build and run the `mrmouse` scheme (requires macOS 26+) + +## Requirements + +- macOS 26 (Tahoe) or later +- Xcode 26+ +- A Logitech MX Master mouse (3S tested, others may work) + +## Permissions + +MrMouse requires **Accessibility** permission for CGEventTap. Grant this in System Settings > Privacy & Security > Accessibility when prompted. + +## How to Contribute + +1. Check existing issues or open a new one to discuss your idea +2. Create a branch from `main` +3. Make your changes +4. Test with a real device if possible +5. Open a pull request + +## Areas Where Help is Needed + +- **Desktop switching** — finding a working approach on macOS 26 Tahoe (see README for details on the OS-level limitation) +- **Additional Logitech devices** — testing and adding support for other MX Master models +- **BLE transport** — completing the CoreBluetooth path as an alternative to the Bolt receiver +- **UI polish** — improving the settings window + +## Code Style + +- Follow existing patterns in the codebase +- Use `NSLog` for debug logging (will be cleaned up before 1.0) +- Keep HID++ protocol code in `HIDPPCore/`, event handling in `EventEngine/`, config in `Config/` + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/mrmouse/LICENSE b/mrmouse/LICENSE new file mode 100644 index 0000000..95d5303 --- /dev/null +++ b/mrmouse/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zack Bart + +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/mrmouse/README.md b/mrmouse/README.md new file mode 100644 index 0000000..23ad55f --- /dev/null +++ b/mrmouse/README.md @@ -0,0 +1,139 @@ +# MrMouse + +Lightweight macOS driver for Logitech MX Master 3S, replacing Logi Options+. + +## Current Status + +### Working + +**Gesture button detection via CGEventTap:** +- Gesture button (thumb button) on MX Master 3S generates CGEvent button 5 through the standard HID mouse interface +- `EventTapManager` intercepts button 5 press/release events +- Button 5 events are routed to `GestureRecognizer` (press → accumulate motion → release → classify) +- Gesture types recognized: `tap`, `swipeUp`, `swipeDown`, `swipeLeft`, `swipeRight` +- System Mission Control trigger from button 5 is suppressed (prevents default behavior) + +**Action dispatch via virtual key codes (macOS 26 Tahoe):** +- **Mission Control** — key code 160 (verified working) +- **App Exposé** — `AXShowExpose` on the frontmost app's Dock `AXDockItem` via Accessibility API +- **Show Desktop** — key code 103 +- **Launchpad** — key code 131 +- **Spotlight, Screenshot, Lock Screen** — keyboard shortcuts via CGEvent (no modifiers) +- **Volume/Media keys** — `NSEvent.otherEvent` with `NSSystemDefined` subtype + +**HID++ protocol over Bolt receiver:** +- Device discovery, feature enumeration (33 features), gesture button diversion via `SetControlReporting` +- Bolt receiver works for init/response but does NOT forward unsolicited device notifications on the vendor interface — macOS kernel blocks them +- `kIOHIDOptionsTypeSeizeDevice` was tested — same result +- HID++ notifications are NOT needed for gesture button — we use CGEventTap instead + +**BLETransport (partially implemented):** +- CoreBluetooth-based transport in `BLETransport.swift` +- Scans for Logitech devices by name, discovers GATT service +- When mouse is paired via macOS Bluetooth, CoreBluetooth can't connect (system owns the GATT connection) +- BLE path exists but is not the primary working path + +### Not Working + +**Desktop switching (Ctrl+Left/Right arrows):** +- **FIXED** — Implemented using synthetic trackpad gesture technique from [InstantSpaceSwitcher](https://github.com/jurplel/InstantSpaceSwitcher) (MIT license) +- On macOS 26 Tahoe, CGEvent modifier flags are stripped by the WindowServer, and CGS APIs are broken +- The workaround synthesizes a trackpad swipe gesture with artificially high velocity (400.0), which causes macOS to perform an instant space switch while bypassing the broken APIs +- Requires no SIP disable, no private entitlements +- Direction is controlled by the sign of `swipeVelocity` (+400.0 for right, -400.0 for left) + +**What doesn't work (attempted and rejected):** +- `CoreDockSendNotification` — `Dock.framework` does not exist on macOS 26 (moved to `WindowManager.framework`, no public API) +- `DistributedNotificationCenter` (`com.apple.expose.awake`, etc.) — posts succeed but Dock ignores them +- AX actions on Dock app element — all return `kAXErrorActionUnsupported` (-25206) +- `CGWarpMouseCursorPosition` for hot corners — synthetic mouse events don't trigger hot corners +- CGEvent keyboard events via `cgSessionEventTap` — same limitation as `cghidEventTap` + +## Architecture + +``` +mrmouse/mrmouse/ + HIDPPCore/ + Transport/ + HIDTransport.swift - IOKit HID (Bolt receiver) + HIDTransportProtocol.swift + BLETransport.swift - CoreBluetooth (experimental) + Protocol/ + HIDPPMessage.swift - HID++ 2.0 message format + HIDPPError.swift + Device/ + HIDPPDevice.swift - Feature discovery, request-response + ReceiverEnumerator.swift + Features/ + ReprogControls.swift - Button diversion (0x00C3) + AdjustableDPI.swift + SmartShift.swift + UnifiedBattery.swift + FeatureRegistry.swift + EventEngine/ + GestureRecognizer.swift - Classify thumb button + motion + EventTapManager.swift - CGEventTap (receives button 5) + ActionDispatcher.swift - Execute actions (key codes + AX) + AppProfileManager.swift - Per-app profiles + Config/ + ConfigManager.swift - JSON persistence + ConfigModels.swift - Config structs + DefaultConfig.swift - Default gesture mappings + AppState.swift - Central coordinator + MrMouseApp.swift - SwiftUI menu bar app + SettingsWindow.swift - Gesture + Button mapping settings + Views/ + ButtonMappingView.swift - Per-button action picker +``` + +## Key Technical Findings + +### Gesture button mechanism +- The MX Master 3S gesture button sends standard CGEvent `otherMouseDown`/`otherMouseUp` with button number 5 +- This works through the standard HID mouse interface (usagePage 0x0001) — no HID++ needed +- The CGEventTap intercepts these events and suppresses them before the system can trigger Mission Control +- Mouse movement deltas between press/release are accumulated for swipe classification + +### macOS 26 Tahoe virtual key codes +System UI functions that bypass the CGEvent modifier flag limitation: + +| Function | Key Code | Method | +|---|---|---| +| Mission Control | 160 | CGEvent keyboardEvent | +| Show Desktop | 103 | CGEvent keyboardEvent | +| Launchpad | 131 | CGEvent keyboardEvent | +| Emoji Picker | 179 | CGEvent keyboardEvent | +| App Exposé | N/A | AXShowExpose on Dock AXDockItem | +| Volume/Media | N/A | NSSystemDefined NSEvent | +| Desktop Switch | N/A | Synthetic trackpad gesture (ISS.c) | + +### Why HID++ notifications don't work +- macOS kernel HID driver blocks unsolicited vendor-defined HID++ notifications from USB receivers +- `setControlReporting` succeeds (device accepts diversion) but notifications never arrive at user-space +- `kIOHIDOptionsTypeSeizeDevice` doesn't bypass this — same result + +### Why Dock.framework / CoreDockSendNotification doesn't work +- `Dock.framework` (`/System/Library/PrivateFrameworks/Dock.framework/`) was removed in macOS 26 +- The Dock binary now lives at `/System/Library/CoreServices/Dock.app/Contents/MacOS/Dock` +- Mission Control logic moved to `WindowManager.framework` (Swift-only, no public API) +- The Dock's internal `enterExitMissionControl` selector is not accessible from other processes + +### Logi Options+ status on macOS 26 +- Logi Options+ is [broken on Tahoe](https://www.reddit.com/r/logitech/comments/1q4fisd/) — loading screen stuck, features broken +- Logitech [released a fix in Jan 2026](https://www.logitech.com/blog/2026/01/07/restoring-access-to-options-and-g-hub-on-macos/) but desktop switching likely still broken (same OS limitation) +- Logi Options+ uses Apple-entitled kernel extensions / DriverKit for some features — not available to third-party apps + +## Building + +Open `mrmouse/mrmouse.xcodeproj` in Xcode and build the `mrmouse` scheme. Requires macOS 26+ (Tahoe). + +### Permissions needed +- **Accessibility** — for CGEventTap (mouse event interception) and AXShowExpose +- **Bluetooth** — for BLETransport (Info.plist keys already added) + +## Next Steps + +1. **Desktop switching** — monitor macOS updates for a fix; may become possible if Apple restores CGEvent modifier support or provides a new API +2. **Remove debug logging** — clean up verbose per-report logging in HIDTransport and HIDPPDevice +3. **BLE transport** — complete if Bolt receiver path is undesirable +4. **Remove dead code** — DPI, scroll, and profile views are no longer in the UI but files still exist diff --git a/mrmouse/mrmouse/Info.plist b/mrmouse/mrmouse/Info.plist new file mode 100644 index 0000000..f4a429c --- /dev/null +++ b/mrmouse/mrmouse/Info.plist @@ -0,0 +1,16 @@ + + + + + LSUIElement + + NSInputMonitoringUsageDescription + MrMouse needs Input Monitoring access to communicate with your Logitech mouse via HID++. + NSAccessibilityUsageDescription + MrMouse needs Accessibility access to remap mouse buttons and perform gesture actions. + NSBluetoothUsageDescription + MrMouse uses Bluetooth to communicate with your Logitech mouse for gesture support. + NSBluetoothAlwaysUsageDescription + MrMouse uses Bluetooth to communicate with your Logitech mouse for gesture support. + + diff --git a/mrmouse/mrmouse/mrmouse.xcodeproj/project.pbxproj b/mrmouse/mrmouse/mrmouse.xcodeproj/project.pbxproj new file mode 100644 index 0000000..dccf7e9 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse.xcodeproj/project.pbxproj @@ -0,0 +1,353 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + D8E3125C2F75EC5B0006ADEE /* ISS.c in Sources */ = {isa = PBXBuildFile; fileRef = D8E3125B2F75EC5B0006ADEE /* ISS.c */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + D8E3124C2F75EC5A0006ADEE /* mrmouse.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mrmouse.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D8E3125A2F75EC5B0006ADEE /* ISS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ISS.h; sourceTree = ""; }; + D8E3125B2F75EC5B0006ADEE /* ISS.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = ISS.c; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + D8E3124E2F75EC5A0006ADEE /* mrmouse */ = { + isa = PBXFileSystemSynchronizedRootGroup; + children = ( + D8E3125A2F75EC5B0006ADEE /* ISS.h */, + D8E3125B2F75EC5B0006ADEE /* ISS.c */, + ); + path = mrmouse; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + D8E312492F75EC5A0006ADEE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + D8E312432F75EC5A0006ADEE = { + isa = PBXGroup; + children = ( + D8E3124E2F75EC5A0006ADEE /* mrmouse */, + D8E3124D2F75EC5A0006ADEE /* Products */, + ); + sourceTree = ""; + }; + D8E3124D2F75EC5A0006ADEE /* Products */ = { + isa = PBXGroup; + children = ( + D8E3124C2F75EC5A0006ADEE /* mrmouse.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + D8E3124B2F75EC5A0006ADEE /* mrmouse */ = { + isa = PBXNativeTarget; + buildConfigurationList = D8E312572F75EC5B0006ADEE /* Build configuration list for PBXNativeTarget "mrmouse" */; + buildPhases = ( + D8E312482F75EC5A0006ADEE /* Sources */, + D8E312492F75EC5A0006ADEE /* Frameworks */, + D8E3124A2F75EC5A0006ADEE /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + D8E3124E2F75EC5A0006ADEE /* mrmouse */, + ); + name = mrmouse; + packageProductDependencies = ( + ); + productName = mrmouse; + productReference = D8E3124C2F75EC5A0006ADEE /* mrmouse.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + D8E312442F75EC5A0006ADEE /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2630; + LastUpgradeCheck = 2630; + TargetAttributes = { + D8E3124B2F75EC5A0006ADEE = { + CreatedOnToolsVersion = 26.3; + }; + }; + }; + buildConfigurationList = D8E312472F75EC5A0006ADEE /* Build configuration list for PBXProject "mrmouse" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = D8E312432F75EC5A0006ADEE; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = D8E3124D2F75EC5A0006ADEE /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + D8E3124B2F75EC5A0006ADEE /* mrmouse */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + D8E3124A2F75EC5A0006ADEE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + D8E312482F75EC5A0006ADEE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D8E3125C2F75EC5B0006ADEE /* ISS.c in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + D8E312552F75EC5B0006ADEE /* 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; + }; + D8E312562F75EC5B0006ADEE /* 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; + }; + D8E312582F75EC5B0006ADEE /* 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 = NO; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = MrMouse; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cursorkittens.mrmouse.mrmouse; + 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; + SWIFT_OBJC_BRIDGING_HEADER = mrmouse/EventEngine/ISS.h; + }; + name = Debug; + }; + D8E312592F75EC5B0006ADEE /* 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 = NO; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = MrMouse; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.cursorkittens.mrmouse.mrmouse; + 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; + SWIFT_OBJC_BRIDGING_HEADER = mrmouse/EventEngine/ISS.h; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + D8E312472F75EC5A0006ADEE /* Build configuration list for PBXProject "mrmouse" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D8E312552F75EC5B0006ADEE /* Debug */, + D8E312562F75EC5B0006ADEE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D8E312572F75EC5B0006ADEE /* Build configuration list for PBXNativeTarget "mrmouse" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D8E312582F75EC5B0006ADEE /* Debug */, + D8E312592F75EC5B0006ADEE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = D8E312442F75EC5A0006ADEE /* Project object */; +} diff --git a/mrmouse/mrmouse/mrmouse/AppState.swift b/mrmouse/mrmouse/mrmouse/AppState.swift new file mode 100644 index 0000000..a4ce81b --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/AppState.swift @@ -0,0 +1,596 @@ +import SwiftUI +import Combine + +/// Snapshot of a connected device's state, published for the UI. +public struct ConnectedDevice: Identifiable { + public let id: String // unique key: serialNumber or "name-transport-index" + public let name: String + public let transport: HIDDeviceInfo.TransportType + public var batteryLevel: Int? + public var batteryCharging: Bool = false + public var firmwareVersion: String? + public var currentDPI: Int = 1000 + let hidppDevice: HIDPPDevice // internal, not for the UI +} + +/// Central observable state for the menu bar UI. +/// Owns and coordinates HIDPPCore, EventEngine, and Config layers. +@MainActor +public final class AppState: ObservableObject { + + // MARK: - Multi-device state + + @Published public var devices: [ConnectedDevice] = [] + @Published public var selectedDeviceID: String? = nil + + /// The currently selected device for settings changes. + public var selectedDevice: ConnectedDevice? { + guard let id = selectedDeviceID else { return devices.first } + return devices.first(where: { $0.id == id }) ?? devices.first + } + + /// Convenience: is anything connected? + public var isConnected: Bool { !devices.isEmpty } + + // MARK: - Active app + + @Published public var currentApp: String? = nil + + // MARK: - Config + + public let configManager: ConfigManager = .shared + + // MARK: - Private subsystems + + private let transport = HIDTransport() + private let bleTransport = BLETransport() + private let eventTap = EventTapManager() + private let gestureRecognizer = GestureRecognizer() + private let appProfileManager = AppProfileManager() + + /// Devices currently undergoing feature discovery — reports are forwarded to them + /// so request-response correlation works before they join `devices`. + /// Guarded by `discoveringDevicesLock`, accessed from multiple threads. + private let discoveringDevicesLock = NSLock() + nonisolated(unsafe) private var discoveringDevices: [HIDPPDevice] = [] + + // MARK: - Init + + public init() { + setupSubsystems() + } + + // MARK: - Setup + + private func setupSubsystems() { + transport.delegate = self + bleTransport.delegate = self + + gestureRecognizer.onGesture = { [weak self] gestureType in + guard let self else { return } + NSLog("[AppState] Gesture recognized: %@", "\(gestureType)") + let profile = self.configManager.profileForApp(self.currentApp) + let action = profile.gestureActions.action(for: gestureType) + NSLog("[AppState] Dispatching action: %@", "\(action)") + ActionDispatcher.perform(action) + } + + eventTap.buttonHandler = { [weak self] buttonNumber, isDown in + guard let self else { return false } + return self.handleButtonEvent(buttonNumber: buttonNumber, isDown: isDown) + } + + // Feed mouse movement deltas into the gesture recognizer. + // This is the primary source for gesture swipe detection — HID++ rawXY + // is a fallback that may not fire on all firmware versions. + eventTap.mouseMoveHandler = { [weak self] dx, dy in + guard let self else { return } + self.gestureRecognizer.addDelta(dx: dx, dy: dy) + } + + appProfileManager.onAppChanged = { [weak self] bundleID in + guard let self else { return } + Task { @MainActor in + self.currentApp = bundleID + } + } + + currentApp = appProfileManager.currentBundleID + + appProfileManager.start() + eventTap.start() + transport.start() + bleTransport.start() + } + + // MARK: - Button event handling (from CGEventTap) + + private nonisolated func handleButtonEvent(buttonNumber: Int, isDown: Bool) -> Bool { + // Button 5 = gesture button (MX Master 3S thumb button) + if buttonNumber == 5 { + if isDown { + DispatchQueue.main.async { + self.gestureRecognizer.buttonDown() + } + } else { + DispatchQueue.main.async { + self.gestureRecognizer.buttonUp() + } + } + return true // suppress so system doesn't trigger Mission Control etc. + } + return false // pass through everything else + } + + // MARK: - HID++ notification handling + + /// Previously-pressed diverted control IDs, used to detect press/release transitions. + nonisolated(unsafe) private var previousDivertedCIDs: [UInt16] = [0, 0, 0, 0] + + nonisolated private func handleNotification(_ message: HIDPPMessage, device: HIDPPDevice) { + if let batteryIdx = device.featureTable[HIDPPFeature.unifiedBattery.rawValue], + message.featureIndex == batteryIdx { + if let status = UnifiedBattery.parse(notification: message) { + Task { @MainActor in + self.updateDevice(device) { dev in + dev.batteryLevel = status.percentage + dev.batteryCharging = status.charging + } + } + } + return + } + + if let reprogIdx = device.featureTable[HIDPPFeature.reprogControlsV4.rawValue], + message.featureIndex == reprogIdx { + handleDivertedButton(message: message) + return + } + } + + nonisolated private func handleDivertedButton(message: HIDPPMessage) { + NSLog("[AppState] handleDivertedButton called: fn=0x%02X params=%@", + message.functionID, + message.parameters.prefix(8).map { String(format: "%02X", $0) }.joined(separator: " ")) + // ReprogControlsV4 notification format (per Solaar source): + // address 0x00 (fn 0x00): [cid1_be16, cid2_be16, cid3_be16, cid4_be16] + // — all currently-pressed diverted controls; diff against previous to detect transitions + // address 0x10 (fn 0x01): [dx_be16_signed, dy_be16_signed] + // — raw pointer deltas while a .rawXY button is held + if message.functionID == 0x00, message.parameters.count >= 8 { + let cids = (0..<4).map { i in + UInt16(message.parameters[i * 2]) << 8 | UInt16(message.parameters[i * 2 + 1]) + } + + // Detect press: in new set but not in old set + for cid in cids where cid != 0 && !previousDivertedCIDs.contains(cid) { + if cid == 0x00C3 { + Task { @MainActor in + self.gestureRecognizer.buttonDown() + } + } else { + let controlID = cid + Task { @MainActor in + let profile = self.configManager.profileForApp(self.currentApp) + if let mapping = profile.buttonMappings.first(where: { $0.controlID == controlID }) { + ActionDispatcher.perform(mapping.action) + } + } + } + } + + // Detect release: in old set but not in new set + for oldCid in previousDivertedCIDs where oldCid != 0 && !cids.contains(oldCid) { + if oldCid == 0x00C3 { + Task { @MainActor in + self.gestureRecognizer.buttonUp() + } + } + } + + previousDivertedCIDs = cids + } else if message.functionID == 0x01, message.parameters.count >= 4 { + // Raw XY delta + let dx = CGFloat(Int16(message.parameters[0]) << 8 | Int16(message.parameters[1])) + let dy = CGFloat(Int16(message.parameters[2]) << 8 | Int16(message.parameters[3])) + Task { @MainActor in + self.gestureRecognizer.addDelta(dx: dx, dy: dy) + } + } + } + + // MARK: - Device list management + + private func addDevice(_ device: ConnectedDevice) { + if let idx = devices.firstIndex(where: { $0.id == device.id }) { + devices[idx] = device + } else { + devices.append(device) + } + // Auto-select if it's the only device + if selectedDeviceID == nil { + selectedDeviceID = device.id + } + } + + private func removeDevice(matching info: HIDDeviceInfo) { + devices.removeAll { dev in + dev.hidppDevice.info.vendorID == info.vendorID && + dev.hidppDevice.info.productID == info.productID + } + if let sel = selectedDeviceID, !devices.contains(where: { $0.id == sel }) { + selectedDeviceID = devices.first?.id + } + } + + private func updateDevice(_ hidppDevice: HIDPPDevice, mutation: (inout ConnectedDevice) -> Void) { + if let idx = devices.firstIndex(where: { $0.hidppDevice === hidppDevice }) { + mutation(&devices[idx]) + } + } + + /// The HIDPPDevice for the currently selected device. + var activeHIDPPDevice: HIDPPDevice? { + selectedDevice?.hidppDevice + } + + // MARK: - Public UI-facing methods + + public func setDPI(_ dpi: Int) { + guard let device = activeHIDPPDevice else { return } + updateDevice(device) { $0.currentDPI = dpi } + let key = device.info.serialNumber ?? device.info.name + try? configManager.update { config in + var dev = config.devices[key] ?? DeviceConfig() + dev.dpi = dpi + config.devices[key] = dev + } + DispatchQueue.global(qos: .userInitiated).async { + try? AdjustableDPI.setDPI(device: device, sensor: 0, dpi: dpi) + } + } + + public func setSmartShift(enabled: Bool, threshold: Int) { + guard let device = activeHIDPPDevice else { return } + let key = device.info.serialNumber ?? device.info.name + try? configManager.update { config in + var dev = config.devices[key] ?? DeviceConfig() + dev.smartShiftEnabled = enabled + dev.smartShiftThreshold = threshold + config.devices[key] = dev + } + let clampedThreshold = Swift.max(0, Swift.min(threshold, 255)) + DispatchQueue.global(qos: .userInitiated).async { + let config = SmartShiftConfig( + enabled: enabled, + autoDisengageThreshold: UInt8(clampedThreshold) + ) + try? SmartShift.setConfig(device: device, config: config) + } + } +} + +// MARK: - HIDTransportDelegate + +extension AppState: HIDTransportDelegate { + + public nonisolated func transport( + _ transport: any HIDTransportProtocol, + didConnectDevice deviceInfo: HIDDeviceInfo + ) { + NSLog("[AppState] Device connected: %@ (PID 0x%04X, transport: %@)", + deviceInfo.name, deviceInfo.productID, deviceInfo.transport.rawValue) + + let isReceiver = deviceInfo.transport == .unifying || deviceInfo.transport == .bolt + + nonisolated(unsafe) let capturedTransport = transport + DispatchQueue.global(qos: .userInitiated).async { + if isReceiver { + NSLog("[AppState] Receiver detected, probing device indices 1-6...") + var foundDevices: [HIDPPDevice] = [] + + for idx: UInt8 in 1...6 { + let device = HIDPPDevice(info: deviceInfo, transport: capturedTransport, deviceIndex: idx) + + // Register so didReceiveReport forwards HID++ responses during discovery + self.discoveringDevicesLock.lock() + self.discoveringDevices.append(device) + self.discoveringDevicesLock.unlock() + + do { + device.featureTable = [:] + try device.discoverFeatures() + NSLog("[AppState] Found device at receiver index %d (%d features)", idx, device.featureTable.count) + foundDevices.append(device) + } catch { + NSLog("[AppState] No device at receiver index %d: %@", idx, error.localizedDescription) + } + + // Remove failed devices immediately; keep found ones for initialization + if !foundDevices.contains(where: { $0 === device }) { + self.discoveringDevicesLock.lock() + self.discoveringDevices.removeAll { $0 === device } + self.discoveringDevicesLock.unlock() + } + } + + NSLog("[AppState] Found %d paired device(s) on receiver", foundDevices.count) + for hidppDevice in foundDevices { + // Keep in discoveringDevices through initialization so report + // forwarding works for config writes (DPI, SmartShift, button diversion) + self.initializeDevice(hidppDevice, transportType: deviceInfo.transport) + + self.discoveringDevicesLock.lock() + self.discoveringDevices.removeAll { $0 === hidppDevice } + self.discoveringDevicesLock.unlock() + } + } else { + let hidppDevice = HIDPPDevice(info: deviceInfo, transport: capturedTransport) + + self.discoveringDevicesLock.lock() + self.discoveringDevices.append(hidppDevice) + self.discoveringDevicesLock.unlock() + + Self.discoverFeaturesWithRetry(device: hidppDevice, maxAttempts: 3, delay: 0.5) + + // Keep in discoveringDevices through initialization + self.initializeDevice(hidppDevice, transportType: deviceInfo.transport) + + self.discoveringDevicesLock.lock() + self.discoveringDevices.removeAll { $0 === hidppDevice } + self.discoveringDevicesLock.unlock() + } + } + } + + /// Retry feature discovery for devices that return transient errors (hwError / timeout) + /// when waking from sleep. Clears partial state between attempts. + nonisolated private static func discoverFeaturesWithRetry( + device: HIDPPDevice, + maxAttempts: Int, + delay: TimeInterval + ) { + for attempt in 1...maxAttempts { + device.featureTable = [:] + + do { + try device.discoverFeatures() + NSLog("[AppState] Feature discovery succeeded on attempt %d (%d features)", + attempt, device.featureTable.count) + return + } catch let error as HIDPPError where error == .hwError || error == .busy { + NSLog("[AppState] Feature discovery attempt %d/%d failed (retryable): %@", + attempt, maxAttempts, error.description) + } catch let error as HIDPPDeviceError where error.isTimeout { + NSLog("[AppState] Feature discovery attempt %d/%d timed out", + attempt, maxAttempts) + } catch { + NSLog("[AppState] Feature discovery attempt %d/%d failed (non-retryable): %@", + attempt, maxAttempts, error.localizedDescription) + return + } + + if attempt < maxAttempts { + Thread.sleep(forTimeInterval: delay) + } + } + NSLog("[AppState] Feature discovery failed after %d attempts", maxAttempts) + } + + nonisolated private func initializeDevice(_ hidppDevice: HIDPPDevice, transportType: HIDDeviceInfo.TransportType) { + hidppDevice.onNotification = { [weak self] message in + guard let self else { return } + self.handleNotification(message, device: hidppDevice) + } + + var resolvedName = hidppDevice.info.name + if hidppDevice.hasFeature(HIDPPFeature.deviceName.rawValue) { + if let name = try? DeviceInfo.getDeviceName(device: hidppDevice), !name.isEmpty { + resolvedName = name + } + } + + var fwString: String? = nil + if hidppDevice.hasFeature(HIDPPFeature.deviceFWVersion.rawValue) { + if let fw = try? DeviceInfo.getFirmwareVersion(device: hidppDevice) { + fwString = "\(fw.version.major).\(fw.version.minor).\(fw.build)" + } + } + + var battery: BatteryStatus? = nil + if hidppDevice.hasFeature(HIDPPFeature.unifiedBattery.rawValue) { + battery = try? UnifiedBattery.getStatus(device: hidppDevice) + } + + var dpi: Int = 1000 + if hidppDevice.hasFeature(HIDPPFeature.adjustableDPI.rawValue) { + dpi = (try? AdjustableDPI.getDPI(device: hidppDevice, sensor: 0).dpi) ?? 1000 + } + + Self.applyDeviceConfigBackground(device: hidppDevice, configManager: ConfigManager.shared) + + let deviceID = hidppDevice.info.serialNumber + ?? "\(resolvedName)-\(transportType.rawValue)" + + let connectedDevice = ConnectedDevice( + id: deviceID, + name: resolvedName, + transport: transportType, + batteryLevel: battery?.percentage, + batteryCharging: battery?.charging ?? false, + firmwareVersion: fwString, + currentDPI: dpi, + hidppDevice: hidppDevice + ) + + NSLog("[AppState] Device initialized: %@ via %@ (features: %d)", + resolvedName, transportType.rawValue, hidppDevice.featureTable.count) + + Task { @MainActor in + self.addDevice(connectedDevice) + } + } + + public nonisolated func transport( + _ transport: any HIDTransportProtocol, + didDisconnectDevice deviceInfo: HIDDeviceInfo + ) { + NSLog("[AppState] Device disconnected: %@", deviceInfo.name) + Task { @MainActor in + self.removeDevice(matching: deviceInfo) + } + } + + public nonisolated func transport( + _ transport: any HIDTransportProtocol, + didReceiveReport data: Data, + fromDevice deviceInfo: HIDDeviceInfo + ) { + // Forward to devices undergoing discovery (synchronous — they block on semaphore) + discoveringDevicesLock.lock() + let discovering = discoveringDevices + discoveringDevicesLock.unlock() + for dev in discovering { + dev.handleReport(data) + } + + Task { @MainActor in + for dev in self.devices { + dev.hidppDevice.handleReport(data) + } + } + } + + nonisolated private static func applyDeviceConfigBackground(device: HIDPPDevice, + configManager: ConfigManager) { + let key = device.info.serialNumber ?? device.info.name + let config = configManager.deviceConfig(for: key) + + if device.hasFeature(HIDPPFeature.adjustableDPI.rawValue) { + do { + try AdjustableDPI.setDPI(device: device, sensor: 0, dpi: config.dpi) + } catch { + NSLog("[AppState] setDPI failed: %@", error.localizedDescription) + } + } + + if device.hasFeature(HIDPPFeature.smartShift.rawValue) { + do { + let clampedThreshold = Swift.max(0, Swift.min(config.smartShiftThreshold, 255)) + let ssConfig = SmartShiftConfig( + enabled: config.smartShiftEnabled, + autoDisengageThreshold: UInt8(clampedThreshold) + ) + try SmartShift.setConfig(device: device, config: ssConfig) + } catch { + NSLog("[AppState] setSmartShift failed: %@", error.localizedDescription) + } + } + + if device.hasFeature(HIDPPFeature.hiResWheel.rawValue) { + do { + let mode = HiResWheelMode( + hiRes: config.hiResScrollEnabled, + invert: config.scrollInverted, + target: false + ) + try HiResWheel.setMode(device: device, mode: mode) + } catch { + NSLog("[AppState] setHiResWheel failed: %@", error.localizedDescription) + } + } + + let hasReprog = device.hasFeature(HIDPPFeature.reprogControlsV4.rawValue) + if hasReprog { + // Log available controls first + do { + let count = try ReprogControls.getControlCount(device: device) + NSLog("[AppState] ReprogControls: %d controls available", count) + for i in 0.. ButtonAction { + switch gesture { + case .tap: return tap + case .swipeUp: return swipeUp + case .swipeDown: return swipeDown + case .swipeLeft: return swipeLeft + case .swipeRight: return swipeRight + } + } +} diff --git a/mrmouse/mrmouse/mrmouse/Assets.xcassets/AccentColor.colorset/Contents.json b/mrmouse/mrmouse/mrmouse/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mrmouse/mrmouse/mrmouse/Assets.xcassets/AppIcon.appiconset/Contents.json b/mrmouse/mrmouse/mrmouse/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/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/mrmouse/mrmouse/mrmouse/Assets.xcassets/AppIcon.png b/mrmouse/mrmouse/mrmouse/Assets.xcassets/AppIcon.png new file mode 100644 index 0000000..c4800ec Binary files /dev/null and b/mrmouse/mrmouse/mrmouse/Assets.xcassets/AppIcon.png differ diff --git a/mrmouse/mrmouse/mrmouse/Assets.xcassets/Contents.json b/mrmouse/mrmouse/mrmouse/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mrmouse/mrmouse/mrmouse/Config/ConfigManager.swift b/mrmouse/mrmouse/mrmouse/Config/ConfigManager.swift new file mode 100644 index 0000000..281546f --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/Config/ConfigManager.swift @@ -0,0 +1,53 @@ +import Foundation +import Combine + +public final class ConfigManager: ObservableObject { + @Published public private(set) var config: MrMouseConfig + + private let configURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public static let shared = ConfigManager() + + public init(directory: URL? = nil) { + let dir = directory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("MrMouse") + + self.configURL = dir.appendingPathComponent("config.json") + self.encoder = JSONEncoder() + self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.decoder = JSONDecoder() + + // Load existing or create default + if let data = try? Data(contentsOf: configURL), + let loaded = try? decoder.decode(MrMouseConfig.self, from: data) { + self.config = loaded + } else { + self.config = MrMouseConfig() + } + } + + public func save() throws { + let dir = configURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let data = try encoder.encode(config) + try data.write(to: configURL, options: .atomic) + } + + public func update(_ mutation: (inout MrMouseConfig) -> Void) throws { + mutation(&config) + try save() + } + + public func profileForApp(_ bundleID: String?) -> Profile { + if let id = bundleID, let appProfile = config.appProfiles[id] { + return appProfile + } + return config.globalProfile + } + + public func deviceConfig(for key: String) -> DeviceConfig { + config.devices[key] ?? DeviceConfig() + } +} diff --git a/mrmouse/mrmouse/mrmouse/Config/ConfigModels.swift b/mrmouse/mrmouse/mrmouse/Config/ConfigModels.swift new file mode 100644 index 0000000..a94cbec --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/Config/ConfigModels.swift @@ -0,0 +1,152 @@ +import Foundation + +public struct MrMouseConfig: Codable, Equatable { + public var devices: [String: DeviceConfig] // keyed by serial number or product name + public var globalProfile: Profile + public var appProfiles: [String: Profile] // keyed by bundle identifier + public var launchAtLogin: Bool + + public init( + devices: [String: DeviceConfig] = [:], + globalProfile: Profile = .default, + appProfiles: [String: Profile] = [:], + launchAtLogin: Bool = false + ) { + self.devices = devices + self.globalProfile = globalProfile + self.appProfiles = appProfiles + self.launchAtLogin = launchAtLogin + } +} + +public struct DeviceConfig: Codable, Equatable { + public var dpi: Int + public var smartShiftEnabled: Bool + public var smartShiftThreshold: Int // 1-255, higher = harder to trigger free-spin + public var hiResScrollEnabled: Bool + public var scrollInverted: Bool + public var thumbWheelInverted: Bool + + public init( + dpi: Int = 1000, + smartShiftEnabled: Bool = true, + smartShiftThreshold: Int = 30, + hiResScrollEnabled: Bool = true, + scrollInverted: Bool = false, + thumbWheelInverted: Bool = false + ) { + self.dpi = dpi + self.smartShiftEnabled = smartShiftEnabled + self.smartShiftThreshold = smartShiftThreshold + self.hiResScrollEnabled = hiResScrollEnabled + self.scrollInverted = scrollInverted + self.thumbWheelInverted = thumbWheelInverted + } +} + +public struct Profile: Codable, Equatable { + public var buttonMappings: [ButtonMapping] + public var gestureActions: GestureActions + + public init( + buttonMappings: [ButtonMapping] = ButtonMapping.defaults, + gestureActions: GestureActions = .default + ) { + self.buttonMappings = buttonMappings + self.gestureActions = gestureActions + } + + public static let `default` = Profile() +} + +public struct ButtonMapping: Codable, Equatable { + public var controlID: UInt16 // HID++ control ID + public var action: ButtonAction + public var diverted: Bool // whether to divert from standard HID + + public init(controlID: UInt16, action: ButtonAction, diverted: Bool = false) { + self.controlID = controlID + self.action = action + self.diverted = diverted + } + + public static let defaults: [ButtonMapping] = [ + ButtonMapping(controlID: 0x0050, action: .default), // Left + ButtonMapping(controlID: 0x0051, action: .default), // Right + ButtonMapping(controlID: 0x0052, action: .default), // Middle + ButtonMapping(controlID: 0x0053, action: .default), // Back + ButtonMapping(controlID: 0x0056, action: .default), // Forward + ButtonMapping(controlID: 0x00C3, action: .gestureButton, diverted: true), // Gesture + ] +} + +public enum ButtonAction: Codable, Equatable { + case `default` // pass through to OS + case keyboardShortcut(KeyCombo) + case gestureButton // handle as gesture (tap + swipe directions) + case disabled + case openApp(bundleID: String) + case systemAction(SystemAction) +} + +public struct KeyCombo: Codable, Equatable { + public var keyCode: UInt16 + public var modifiers: Modifiers + + public struct Modifiers: OptionSet, Codable, Equatable { + public let rawValue: UInt8 + public init(rawValue: UInt8) { self.rawValue = rawValue } + + public static let command = Modifiers(rawValue: 1 << 0) + public static let option = Modifiers(rawValue: 1 << 1) + public static let control = Modifiers(rawValue: 1 << 2) + public static let shift = Modifiers(rawValue: 1 << 3) + } + + public init(keyCode: UInt16, modifiers: Modifiers = []) { + self.keyCode = keyCode + self.modifiers = modifiers + } +} + +public enum SystemAction: String, Codable { + case missionControl = "mission_control" + case appExpose = "app_expose" + case switchDesktopLeft = "switch_desktop_left" + case switchDesktopRight = "switch_desktop_right" + case launchpad = "launchpad" + case showDesktop = "show_desktop" + case volumeUp = "volume_up" + case volumeDown = "volume_down" + case mute = "mute" + case playPause = "play_pause" + case nextTrack = "next_track" + case prevTrack = "prev_track" + case screenshot = "screenshot" + case spotlight = "spotlight" + case lockScreen = "lock_screen" +} + +public struct GestureActions: Codable, Equatable { + public var tap: ButtonAction + public var swipeUp: ButtonAction + public var swipeDown: ButtonAction + public var swipeLeft: ButtonAction + public var swipeRight: ButtonAction + + public init( + tap: ButtonAction = .systemAction(.missionControl), + swipeUp: ButtonAction = .systemAction(.missionControl), + swipeDown: ButtonAction = .systemAction(.appExpose), + swipeLeft: ButtonAction = .systemAction(.switchDesktopLeft), + swipeRight: ButtonAction = .systemAction(.switchDesktopRight) + ) { + self.tap = tap + self.swipeUp = swipeUp + self.swipeDown = swipeDown + self.swipeLeft = swipeLeft + self.swipeRight = swipeRight + } + + public static let `default` = GestureActions() +} diff --git a/mrmouse/mrmouse/mrmouse/Config/DefaultConfig.swift b/mrmouse/mrmouse/mrmouse/Config/DefaultConfig.swift new file mode 100644 index 0000000..7ae556d --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/Config/DefaultConfig.swift @@ -0,0 +1,25 @@ +import Foundation + +extension MrMouseConfig { + public static let `default` = MrMouseConfig( + devices: [:], + globalProfile: .default, + appProfiles: [:], + launchAtLogin: false + ) +} + +extension DeviceConfig { + public static let mxMaster3S = DeviceConfig( + dpi: 1000, + smartShiftEnabled: true, + smartShiftThreshold: 30, + hiResScrollEnabled: true, + scrollInverted: false, + thumbWheelInverted: false + ) + + public var dpiRange: ClosedRange { 200...8000 } + public var dpiStep: Int { 50 } + public var smartShiftRange: ClosedRange { 1...255 } +} diff --git a/mrmouse/mrmouse/mrmouse/EventEngine/ActionDispatcher.swift b/mrmouse/mrmouse/mrmouse/EventEngine/ActionDispatcher.swift new file mode 100644 index 0000000..5176258 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/EventEngine/ActionDispatcher.swift @@ -0,0 +1,253 @@ +import AppKit +import ApplicationServices +import CoreGraphics +import Foundation + +/// Executes a `ButtonAction` against the current system state. +public enum ActionDispatcher { + + // MARK: - Public entry point + + @discardableResult + public static func perform(_ action: ButtonAction) -> Bool { + switch action { + case .default: + return false + + case .disabled: + return true + + case .keyboardShortcut(let combo): + postKeyCombo(combo) + return true + + case .systemAction(let sysAction): + postSystemAction(sysAction) + return true + + case .openApp(let bundleID): + openApp(bundleID: bundleID) + return true + + case .gestureButton: + return false + } + } + + // MARK: - Keyboard shortcut + + private static let ctrlKeyCode: CGKeyCode = 59 + + private static func postKeyCombo(_ combo: KeyCombo) { + let src = CGEventSource(stateID: .combinedSessionState) + guard let src else { return } + + var cgFlags = CGEventFlags() + if combo.modifiers.contains(.command) { cgFlags.insert(.maskCommand) } + if combo.modifiers.contains(.option) { cgFlags.insert(.maskAlternate) } + if combo.modifiers.contains(.control) { cgFlags.insert(.maskControl) } + if combo.modifiers.contains(.shift) { cgFlags.insert(.maskShift) } + + if combo.modifiers.contains(.control) { + if let ctrlDown = CGEvent(keyboardEventSource: src, virtualKey: ctrlKeyCode, keyDown: true) { + ctrlDown.post(tap: .cghidEventTap) + } + Thread.sleep(forTimeInterval: 0.01) + } + if let down = CGEvent(keyboardEventSource: src, virtualKey: combo.keyCode, keyDown: true) { + down.flags = cgFlags + down.post(tap: .cghidEventTap) + } + Thread.sleep(forTimeInterval: 0.05) + if let up = CGEvent(keyboardEventSource: src, virtualKey: combo.keyCode, keyDown: false) { + up.flags = cgFlags + up.post(tap: .cghidEventTap) + } + if combo.modifiers.contains(.control) { + Thread.sleep(forTimeInterval: 0.01) + if let ctrlUp = CGEvent(keyboardEventSource: src, virtualKey: ctrlKeyCode, keyDown: false) { + ctrlUp.post(tap: .cghidEventTap) + } + } + } + + // MARK: - System actions + + // macOS 26 (Tahoe) key codes for system functions. + // These are virtual key codes that directly trigger system UI + // without requiring modifier flags (which CGEvent ignores on Tahoe). + private enum SK { + static let missionControl: CGKeyCode = 160 + static let showDesktop: CGKeyCode = 103 + static let launchpad: CGKeyCode = 131 + static let emojiPicker: CGKeyCode = 179 + } + + private static func postSystemAction(_ action: SystemAction) { + switch action { + case .missionControl: + pressKey(SK.missionControl) + + case .appExpose: + triggerAppExpose() + + case .launchpad: + pressKey(SK.launchpad) + + case .switchDesktopLeft: + DesktopSwitcher.switchDesktop(.left) + + case .switchDesktopRight: + DesktopSwitcher.switchDesktop(.right) + + case .showDesktop: + pressKey(SK.showDesktop) + + case .volumeUp: + postMediaKey(NX_KEYTYPE_SOUND_UP) + + case .volumeDown: + postMediaKey(NX_KEYTYPE_SOUND_DOWN) + + case .mute: + postMediaKey(NX_KEYTYPE_MUTE) + + case .playPause: + postMediaKey(NX_KEYTYPE_PLAY) + + case .nextTrack: + postMediaKey(NX_KEYTYPE_NEXT) + + case .prevTrack: + postMediaKey(NX_KEYTYPE_PREVIOUS) + + case .screenshot: + postKeyCombo(KeyCombo(keyCode: 23, modifiers: [.shift, .command])) + + case .spotlight: + postKeyCombo(KeyCombo(keyCode: 49, modifiers: .command)) + + case .lockScreen: + postKeyCombo(KeyCombo(keyCode: 12, modifiers: [.control, .command])) + } + } + + // MARK: - Key press helper + + private static func pressKey(_ code: CGKeyCode) { + let src = CGEventSource(stateID: .combinedSessionState) + guard let src else { return } + if let down = CGEvent(keyboardEventSource: src, virtualKey: code, keyDown: true) { + down.post(tap: .cghidEventTap) + } + Thread.sleep(forTimeInterval: 0.02) + if let up = CGEvent(keyboardEventSource: src, virtualKey: code, keyDown: false) { + up.post(tap: .cghidEventTap) + } + } + + // MARK: - App Expose via Dock AX + + private static func triggerAppExpose() { + guard let frontApp = NSWorkspace.shared.frontmostApplication, + let appName = frontApp.localizedName else { return } + + guard let dockApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dock").first else { + return + } + let dockAX = AXUIElementCreateApplication(dockApps.processIdentifier) + + if let item = findDockItemByName(dockAX, name: appName, action: "AXShowExpose") { + AXUIElementPerformAction(item, "AXShowExpose" as CFString) + return + } + + // Fall back to any dock item with AXShowExpose + if let item = findDockItemWithAction(dockAX, action: "AXShowExpose") { + AXUIElementPerformAction(item, "AXShowExpose" as CFString) + } + } + + private static func findDockItemWithAction(_ element: AXUIElement, action: String) -> AXUIElement? { + var actionsRef: CFArray? + AXUIElementCopyActionNames(element, &actionsRef) + if let actions = actionsRef as? [String], actions.contains(action) { + var roleRef: AnyObject? + AXUIElementCopyAttributeValue(element, kAXRoleAttribute as CFString, &roleRef) + if roleRef as? String == "AXDockItem" { + return element + } + } + var childrenRef: AnyObject? + AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenRef) + if let children = childrenRef as? [AXUIElement] { + for child in children { + if let found = findDockItemWithAction(child, action: action) { return found } + } + } + return nil + } + + private static func findDockItemByName(_ element: AXUIElement, name: String, action: String) -> AXUIElement? { + var titleRef: AnyObject? + AXUIElementCopyAttributeValue(element, kAXTitleAttribute as CFString, &titleRef) + if titleRef as? String == name { + var actionsRef: CFArray? + AXUIElementCopyActionNames(element, &actionsRef) + if let actions = actionsRef as? [String], actions.contains(action) { + return element + } + } + var childrenRef: AnyObject? + AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenRef) + if let children = childrenRef as? [AXUIElement] { + for child in children { + if let found = findDockItemByName(child, name: name, action: action) { return found } + } + } + return nil + } + + // MARK: - Media keys (NX system-defined events) + + private static func postMediaKey(_ keyType: Int32) { + func post(keyDown: Bool) { + let flags: Int = keyDown ? 0xa00 : 0xb00 + let data1: Int = Int(keyType) << 16 | flags + let event = NSEvent.otherEvent( + with: .systemDefined, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + subtype: 8, + data1: data1, + data2: -1 + ) + event?.cgEvent?.post(tap: .cghidEventTap) + } + post(keyDown: true) + post(keyDown: false) + } + + // MARK: - App launch + + private static func openApp(bundleID: String) { + let workspace = NSWorkspace.shared + if let url = workspace.urlForApplication(withBundleIdentifier: bundleID) { + let config = NSWorkspace.OpenConfiguration() + config.activates = true + workspace.openApplication(at: url, configuration: config) + } + } +} + +// MARK: - NX key type constants + +private let NX_KEYTYPE_SOUND_UP: Int32 = 0 +private let NX_KEYTYPE_SOUND_DOWN: Int32 = 1 +private let NX_KEYTYPE_MUTE: Int32 = 7 +private let NX_KEYTYPE_PLAY: Int32 = 16 +private let NX_KEYTYPE_NEXT: Int32 = 17 +private let NX_KEYTYPE_PREVIOUS: Int32 = 18 diff --git a/mrmouse/mrmouse/mrmouse/EventEngine/AppProfileManager.swift b/mrmouse/mrmouse/mrmouse/EventEngine/AppProfileManager.swift new file mode 100644 index 0000000..a5bbfe1 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/EventEngine/AppProfileManager.swift @@ -0,0 +1,60 @@ +import AppKit +import Foundation + +/// Observes NSWorkspace for foreground-application changes and maintains the +/// current bundle identifier. Zero CPU cost when apps are not switching. +public final class AppProfileManager { + + // MARK: - Public interface + + /// The bundle identifier of the currently active application, or nil if + /// it cannot be determined. + public private(set) var currentBundleID: String? + + /// Called whenever the foreground application changes. + /// The parameter is the new bundle identifier (may be nil). + public var onAppChanged: ((String?) -> Void)? + + // MARK: - Private state + + private var observer: NSObjectProtocol? + + // MARK: - Init / deinit + + public init() {} + + deinit { + stop() + } + + // MARK: - Lifecycle + + /// Starts observing workspace notifications. Safe to call multiple times. + public func start() { + guard observer == nil else { return } + + // Seed the current value immediately + currentBundleID = NSWorkspace.shared.frontmostApplication?.bundleIdentifier + + observer = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication + let bundleID = app?.bundleIdentifier + self.currentBundleID = bundleID + self.onAppChanged?(bundleID) + } + } + + /// Stops observing. Safe to call even if `start()` was never called. + public func stop() { + if let observer { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + } + observer = nil + } +} diff --git a/mrmouse/mrmouse/mrmouse/EventEngine/DesktopSwitcher.swift b/mrmouse/mrmouse/mrmouse/EventEngine/DesktopSwitcher.swift new file mode 100644 index 0000000..3c63055 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/EventEngine/DesktopSwitcher.swift @@ -0,0 +1,60 @@ +// +// DesktopSwitcher.swift +// +// Desktop switching implementation using synthetic trackpad gesture technique. +// Based on InstantSpaceSwitcher by jurplel (https://github.com/jurplel/InstantSpaceSwitcher) +// MIT License +// +// This technique synthesizes a trackpad swipe gesture with artificially high velocity +// to trigger instant space switching on macOS 26 Tahoe, bypassing broken CGS APIs. +// + +import Foundation + +public enum DesktopSwitcher { + + public enum Direction { + case left + case right + } + + private static var isInitialized = false + + private static func ensureInitialized() { + guard !isInitialized else { return } + iss_init() + isInitialized = true + } + + @discardableResult + public static func switchDesktop(_ direction: Direction) -> Bool { + ensureInitialized() + let issDirection: ISSDirection = direction == .right ? ISSDirectionRight : ISSDirectionLeft + return iss_switch(issDirection) + } + + public static func canSwitch(_ direction: Direction) -> Bool { + ensureInitialized() + var info = ISSSpaceInfo() + guard iss_get_space_info(&info) else { return true } + let issDirection: ISSDirection = direction == .right ? ISSDirectionRight : ISSDirectionLeft + return iss_can_move(info, issDirection) + } + + public struct SpaceInfo { + public let spaceCount: Int + public let currentIndex: Int + + public init(spaceCount: Int, currentIndex: Int) { + self.spaceCount = spaceCount + self.currentIndex = currentIndex + } + } + + public static func getSpaceInfo() -> SpaceInfo? { + ensureInitialized() + var info = ISSSpaceInfo() + guard iss_get_space_info(&info) else { return nil } + return SpaceInfo(spaceCount: Int(info.spaceCount), currentIndex: Int(info.currentIndex)) + } +} \ No newline at end of file diff --git a/mrmouse/mrmouse/mrmouse/EventEngine/EventTapManager.swift b/mrmouse/mrmouse/mrmouse/EventEngine/EventTapManager.swift new file mode 100644 index 0000000..d55578b --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/EventEngine/EventTapManager.swift @@ -0,0 +1,150 @@ +import CoreGraphics +import CoreFoundation +import Foundation + +/// Manages a CGEventTap that intercepts extra mouse button events. +/// +/// The callback is a C function and must be fast — it looks up the button +/// number and dispatches to `buttonHandler` asynchronously so HID++ work +/// never blocks the event tap. +public final class EventTapManager { + + // MARK: - Public interface + + /// Called on every other-mouse down/up event. + /// - Parameters: + /// - Int: button number (2 = middle, 3 = back, 4 = forward, …) + /// - Bool: true = button down, false = button up + /// - Returns: true to suppress the event, false to pass it through + public var buttonHandler: ((Int, Bool) -> Bool)? + + /// Called on every mouse movement with (deltaX, deltaY). + public var mouseMoveHandler: ((CGFloat, CGFloat) -> Void)? + + // MARK: - Private state + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var selfPtr: Unmanaged? + + // MARK: - Init / deinit + + public init() {} + + deinit { + stop() + } + + // MARK: - Lifecycle + + /// Creates and activates the event tap. Must be called from the main thread. + public func start() { + guard eventTap == nil else { return } + + let mask: CGEventMask = + (1 << CGEventType.otherMouseDown.rawValue) | + (1 << CGEventType.otherMouseUp.rawValue) | + (1 << CGEventType.otherMouseDragged.rawValue) | + (1 << CGEventType.mouseMoved.rawValue) + + selfPtr = Unmanaged.passRetained(self) + + guard let tap = CGEvent.tapCreate( + tap: .cghidEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: mask, + callback: eventTapCallback, + userInfo: selfPtr!.toOpaque() + ) else { + selfPtr?.release() + selfPtr = nil + print("[EventTapManager] Failed to create event tap — Accessibility permission required") + return + } + + eventTap = tap + + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + runLoopSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + + CGEvent.tapEnable(tap: tap, enable: true) + } + + /// Disables and tears down the event tap. + public func stop() { + if let tap = eventTap { + CGEvent.tapEnable(tap: tap, enable: false) + } + if let source = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + } + eventTap = nil + runLoopSource = nil + selfPtr?.release() + selfPtr = nil + } + + // MARK: - Internal re-enable (called from C callback) + + /// Called when macOS disables the tap (e.g. due to slow handling). + fileprivate func handleTapDisabled() { + guard let tap = eventTap else { return } + print("[EventTapManager] Event tap was disabled by macOS — re-enabling") + CGEvent.tapEnable(tap: tap, enable: true) + } + + // MARK: - Synchronous callback dispatch + + /// Invoked synchronously on the run loop thread — must return quickly. + fileprivate func handleEvent( + type: CGEventType, + event: CGEvent + ) -> Unmanaged? { + if type == .mouseMoved || type == .otherMouseDragged { + if let moveHandler = mouseMoveHandler { + let dx = CGFloat(event.getIntegerValueField(.mouseEventDeltaX)) + let dy = CGFloat(event.getIntegerValueField(.mouseEventDeltaY)) + moveHandler(dx, dy) + } + return Unmanaged.passRetained(event) + } + + let buttonNumber = Int(event.getIntegerValueField(.mouseEventButtonNumber)) + let isDown = (type == .otherMouseDown) + + guard let handler = buttonHandler else { + return Unmanaged.passRetained(event) + } + + let suppress = handler(buttonNumber, isDown) + if suppress { + return nil + } else { + return Unmanaged.passRetained(event) + } + } +} + +// MARK: - C callback + +/// Top-level C function required by CGEventTapCreate. +/// Bridges into the EventTapManager instance via the userInfo pointer. +private func eventTapCallback( + proxy: CGEventTapProxy, + type: CGEventType, + event: CGEvent, + userInfo: UnsafeMutableRawPointer? +) -> Unmanaged? { + guard let ptr = userInfo else { return Unmanaged.passRetained(event) } + let manager = Unmanaged.fromOpaque(ptr).takeUnretainedValue() + + // macOS disables taps that are too slow; re-enable immediately. + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + manager.handleTapDisabled() + return Unmanaged.passRetained(event) + } + + return manager.handleEvent(type: type, event: event) +} diff --git a/mrmouse/mrmouse/mrmouse/EventEngine/GestureRecognizer.swift b/mrmouse/mrmouse/mrmouse/EventEngine/GestureRecognizer.swift new file mode 100644 index 0000000..d3c4c4d --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/EventEngine/GestureRecognizer.swift @@ -0,0 +1,101 @@ +import CoreGraphics +import Foundation + +/// The set of gestures the recognizer can classify. +public enum GestureType: Equatable { + case tap + case swipeUp + case swipeDown + case swipeLeft + case swipeRight +} + +/// Tracks gesture-button state and classifies the motion as a gesture when the +/// button is released. +/// +/// Usage: +/// 1. Feed button down/up events via `buttonDown()` / `buttonUp()`. +/// 2. While the button is held, feed mouse-move deltas via `addDelta(dx:dy:)`. +/// 3. On button-up the recognizer classifies the accumulated motion and fires +/// `onGesture` with the result. +/// +/// All methods must be called from the main actor. +@MainActor +public final class GestureRecognizer { + + // MARK: - Configuration + + /// Minimum displacement (in points) along the dominant axis to count as a + /// swipe rather than a tap. + public var minSwipeDistance: CGFloat + + // MARK: - Callbacks + + /// Called on button-up with the classified gesture. + public var onGesture: ((GestureType) -> Void)? + + // MARK: - Private state + + private var isPressed = false + private var accumulatedDX: CGFloat = 0 + private var accumulatedDY: CGFloat = 0 + private var skipNextDelta = false + + // MARK: - Init + + public init(minSwipeDistance: CGFloat = 30) { + self.minSwipeDistance = minSwipeDistance + } + + // MARK: - Input + + /// Call when the gesture button is pressed. + public func buttonDown() { + isPressed = true + accumulatedDX = 0 + accumulatedDY = 0 + skipNextDelta = true + } + + /// Call when the gesture button is released. Fires `onGesture` synchronously. + public func buttonUp() { + guard isPressed else { return } + isPressed = false + skipNextDelta = false + let gesture = classify(dx: accumulatedDX, dy: accumulatedDY) + onGesture?(gesture) + } + + /// Feed a mouse-movement delta. Always accumulates — `buttonDown()` resets + /// the counters so only movement since the last press is classified. + /// The first delta after a button-down is skipped to avoid the initial + /// movement spike that occurs when the thumb button is pressed. + public func addDelta(dx: CGFloat, dy: CGFloat) { + if skipNextDelta { + skipNextDelta = false + return + } + accumulatedDX += dx + accumulatedDY += dy + } + + // MARK: - Classification + + private func classify(dx: CGFloat, dy: CGFloat) -> GestureType { + let absX = abs(dx) + let absY = abs(dy) + + // If neither axis exceeds the threshold it's a tap + guard absX >= minSwipeDistance || absY >= minSwipeDistance else { + return .tap + } + + // Dominant axis determines swipe direction + if absX >= absY { + return dx > 0 ? .swipeRight : .swipeLeft + } else { + // Screen Y increases downward in CoreGraphics + return dy > 0 ? .swipeDown : .swipeUp + } + } +} diff --git a/mrmouse/mrmouse/mrmouse/EventEngine/ISS.c b/mrmouse/mrmouse/mrmouse/EventEngine/ISS.c new file mode 100644 index 0000000..6093f03 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/EventEngine/ISS.c @@ -0,0 +1,378 @@ +#include "ISS.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static const CGEventField kCGSEventTypeField = (CGEventField)55; +static const CGEventField kCGEventGestureHIDType = (CGEventField)110; +static const CGEventField kCGEventGestureScrollY = (CGEventField)119; +static const CGEventField kCGEventGestureSwipeMotion = (CGEventField)123; +static const CGEventField kCGEventGestureSwipeProgress = (CGEventField)124; +static const CGEventField kCGEventGestureSwipeVelocityX = (CGEventField)129; +static const CGEventField kCGEventGestureSwipeVelocityY = (CGEventField)130; +static const CGEventField kCGEventGesturePhase = (CGEventField)132; +static const CGEventField kCGEventScrollGestureFlagBits = (CGEventField)135; +static const CGEventField kCGEventGestureZoomDeltaX = (CGEventField)139; + +static const uint32_t kIOHIDEventTypeDockSwipe = 23; + +typedef uint32_t CGSEventType; +enum { + kCGSEventScrollWheel = 22, + kCGSEventZoom = 28, + kCGSEventGesture = 29, + kCGSEventDockControl = 30, + kCGSEventFluidTouchGesture = 31, +}; + +typedef CF_ENUM(uint8_t, CGSGesturePhase) { + kCGSGesturePhaseNone = 0, + kCGSGesturePhaseBegan = 1, + kCGSGesturePhaseChanged = 2, + kCGSGesturePhaseEnded = 4, + kCGSGesturePhaseCancelled = 8, + kCGSGesturePhaseMayBegin = 128, +}; + +typedef CF_ENUM(uint16_t, CGGestureMotion) { + kCGGestureMotionHorizontal = 1, +}; + +typedef int32_t CGSConnectionID; +typedef uint64_t CGSSpaceID; + +extern CFArrayRef CGSCopyManagedDisplaySpaces(CGSConnectionID connection, CFStringRef display) __attribute__((weak_import)); +extern CFStringRef CGSCopyActiveMenuBarDisplayIdentifier(CGSConnectionID connection) __attribute__((weak_import)); +extern CGSConnectionID CGSMainConnectionID(void) __attribute__((weak_import)); +extern CGSSpaceID CGSGetActiveSpace(CGSConnectionID connection) __attribute__((weak_import)); + +static CFMachPortRef globalTap = NULL; +static CFRunLoopSourceRef globalSource = NULL; + +static bool extract_space_info_from_display(CFDictionaryRef displayDict, + CGSSpaceID activeSpace, + bool hasActiveSpace, + ISSSpaceInfo *outInfo); +static bool load_space_info_for_display(ISSSpaceInfo *info, bool useCursorDisplay); +static bool iss_post_switch_gesture(ISSDirection direction); +static bool iss_should_block_switch(const ISSSpaceInfo *info, ISSDirection direction); + +static CGEventRef eventTapCallback(CGEventTapProxy proxy, CGEventType type, + CGEventRef event, void *refcon) { + (void)proxy; + (void)type; + (void)refcon; + return event; +} + +static bool cgs_symbols_available(void) { + return (&CGSMainConnectionID != NULL) && + (&CGSGetActiveSpace != NULL) && + (&CGSCopyManagedDisplaySpaces != NULL); +} + +static bool extract_space_info_from_display(CFDictionaryRef displayDict, + CGSSpaceID activeSpace, + bool hasActiveSpace, + ISSSpaceInfo *outInfo) { + if (!displayDict || !outInfo) { + return false; + } + + const void *spacesValue = CFDictionaryGetValue(displayDict, CFSTR("Spaces")); + if (!spacesValue || CFGetTypeID(spacesValue) != CFArrayGetTypeID()) { + return false; + } + + CGSSpaceID displayActiveSpace = 0; + const void *currentSpaceValue = CFDictionaryGetValue(displayDict, CFSTR("Current Space")); + if (currentSpaceValue && CFGetTypeID(currentSpaceValue) == CFDictionaryGetTypeID()) { + CFDictionaryRef currentSpaceDict = (CFDictionaryRef)currentSpaceValue; + CFNumberRef currentSpaceID = (CFNumberRef)CFDictionaryGetValue(currentSpaceDict, CFSTR("id64")); + if (currentSpaceID && CFGetTypeID(currentSpaceID) == CFNumberGetTypeID()) { + CFNumberGetValue(currentSpaceID, kCFNumberSInt64Type, &displayActiveSpace); + } + } + + CGSSpaceID targetActiveSpace = displayActiveSpace != 0 ? displayActiveSpace : activeSpace; + bool hasTargetActiveSpace = displayActiveSpace != 0 || hasActiveSpace; + + CFArrayRef spaces = (CFArrayRef)spacesValue; + const CFIndex spaceCount = CFArrayGetCount(spaces); + + unsigned int totalSpaces = 0; + unsigned int activeIndex = 0; + bool foundActive = false; + + for (CFIndex i = 0; i < spaceCount; i++) { + const void *spaceValue = CFArrayGetValueAtIndex(spaces, i); + if (!spaceValue || CFGetTypeID(spaceValue) != CFDictionaryGetTypeID()) { + continue; + } + + CFDictionaryRef spaceDict = (CFDictionaryRef)spaceValue; + CFNumberRef idNumber = (CFNumberRef)CFDictionaryGetValue(spaceDict, CFSTR("id64")); + if (!idNumber || CFGetTypeID(idNumber) != CFNumberGetTypeID()) { + continue; + } + + CGSSpaceID candidate = 0; + if (CFNumberGetValue(idNumber, kCFNumberSInt64Type, &candidate)) { + if (!foundActive && hasTargetActiveSpace && candidate == targetActiveSpace) { + activeIndex = totalSpaces; + foundActive = true; + } + totalSpaces++; + } + } + + if (totalSpaces == 0 || (hasTargetActiveSpace && !foundActive)) { + return false; + } + + outInfo->spaceCount = totalSpaces; + outInfo->currentIndex = foundActive ? activeIndex : 0; + return true; +} + +static bool load_space_info_for_display(ISSSpaceInfo *info, bool useCursorDisplay) { + if (!cgs_symbols_available()) { + return false; + } + + CGSConnectionID connection = CGSMainConnectionID(); + if (connection == 0) { + return false; + } + + CGSSpaceID activeSpace = 0; + bool hasActiveSpace = false; + if (&CGSGetActiveSpace != NULL) { + activeSpace = CGSGetActiveSpace(connection); + if (activeSpace != 0) { + hasActiveSpace = true; + } else { + return false; + } + } + + CFStringRef activeDisplayIdentifier = NULL; + + if (useCursorDisplay) { + CGEventRef tempEvent = CGEventCreate(NULL); + CGPoint cursorLocation = CGEventGetLocation(tempEvent); + CFRelease(tempEvent); + + CGDirectDisplayID cursorDisplay = 0; + uint32_t cursorDisplayCount = 0; + + if (CGGetDisplaysWithPoint(cursorLocation, 1, &cursorDisplay, &cursorDisplayCount) == kCGErrorSuccess && cursorDisplayCount > 0) { + CFUUIDRef displayUUID = CGDisplayCreateUUIDFromDisplayID(cursorDisplay); + if (displayUUID) { + activeDisplayIdentifier = CFUUIDCreateString(NULL, displayUUID); + CFRelease(displayUUID); + } + } + } else { + if (&CGSCopyActiveMenuBarDisplayIdentifier != NULL) { + activeDisplayIdentifier = CGSCopyActiveMenuBarDisplayIdentifier(connection); + } + } + + CFArrayRef displays = CGSCopyManagedDisplaySpaces(connection, activeDisplayIdentifier); + if (!displays && activeDisplayIdentifier) { + displays = CGSCopyManagedDisplaySpaces(connection, NULL); + } + if (!displays) { + if (activeDisplayIdentifier) { + CFRelease(activeDisplayIdentifier); + } + return false; + } + + const CFIndex displayCount = CFArrayGetCount(displays); + CFDictionaryRef targetDisplay = NULL; + CFDictionaryRef fallbackDisplay = NULL; + + for (CFIndex i = 0; i < displayCount; i++) { + const void *displayValue = CFArrayGetValueAtIndex(displays, i); + if (!displayValue || CFGetTypeID(displayValue) != CFDictionaryGetTypeID()) { + continue; + } + + CFDictionaryRef displayDict = (CFDictionaryRef)displayValue; + + if (!fallbackDisplay) { + fallbackDisplay = displayDict; + } + + if (!activeDisplayIdentifier || targetDisplay) { + continue; + } + + CFStringRef identifier = (CFStringRef)CFDictionaryGetValue(displayDict, CFSTR("Display Identifier")); + if (identifier && CFGetTypeID(identifier) == CFStringGetTypeID() && CFEqual(identifier, activeDisplayIdentifier)) { + targetDisplay = displayDict; + } + } + + if (!targetDisplay) { + targetDisplay = fallbackDisplay; + } + + bool success = false; + if (targetDisplay) { + success = extract_space_info_from_display(targetDisplay, activeSpace, hasActiveSpace, info); + } + + if (activeDisplayIdentifier) { + CFRelease(activeDisplayIdentifier); + } + CFRelease(displays); + + return success; +} + +static bool iss_should_block_switch(const ISSSpaceInfo *info, ISSDirection direction) { + if (!info) { + return false; + } + if (info->spaceCount == 0) { + return true; + } + + if (direction == ISSDirectionLeft) { + return info->currentIndex == 0; + } + + return info->currentIndex + 1 >= info->spaceCount; +} + +bool iss_can_move(ISSSpaceInfo info, ISSDirection direction) { + return !iss_should_block_switch(&info, direction); +} + +static bool iss_post_switch_gesture(ISSDirection direction) { + const bool isRight = (direction == ISSDirectionRight); + + int32_t scrollGestureFlagDirection = isRight ? 1 : 0; + const double swipeProgress = isRight ? 2.0 : -2.0; + const double swipeVelocity = isRight ? 400.0 : -400.0; + + CGEventRef evA = CGEventCreate(NULL); + if (!evA) { + return false; + } + CGEventSetIntegerValueField(evA, kCGSEventTypeField, kCGSEventGesture); + + CGEventRef evB = CGEventCreate(NULL); + if (!evB) { + CFRelease(evA); + return false; + } + CGEventSetIntegerValueField(evB, kCGSEventTypeField, kCGSEventDockControl); + CGEventSetIntegerValueField(evB, kCGEventGestureHIDType, kIOHIDEventTypeDockSwipe); + CGEventSetIntegerValueField(evB, kCGEventGesturePhase, kCGSGesturePhaseBegan); + CGEventSetIntegerValueField(evB, kCGEventScrollGestureFlagBits, scrollGestureFlagDirection); + CGEventSetIntegerValueField(evB, kCGEventGestureSwipeMotion, kCGGestureMotionHorizontal); + CGEventSetDoubleValueField(evB, kCGEventGestureScrollY, 0); + CGEventSetDoubleValueField(evB, kCGEventGestureZoomDeltaX, FLT_TRUE_MIN); + + CGEventPost(kCGSessionEventTap, evB); + CGEventPost(kCGSessionEventTap, evA); + CFRelease(evA); + CFRelease(evB); + + evA = CGEventCreate(NULL); + if (!evA) { + return false; + } + CGEventSetIntegerValueField(evA, kCGSEventTypeField, kCGSEventGesture); + + evB = CGEventCreate(NULL); + if (!evB) { + CFRelease(evA); + return false; + } + CGEventSetIntegerValueField(evB, kCGSEventTypeField, kCGSEventDockControl); + CGEventSetIntegerValueField(evB, kCGEventGestureHIDType, kIOHIDEventTypeDockSwipe); + CGEventSetIntegerValueField(evB, kCGEventGesturePhase, kCGSGesturePhaseEnded); + CGEventSetDoubleValueField(evB, kCGEventGestureSwipeProgress, swipeProgress); + CGEventSetIntegerValueField(evB, kCGEventScrollGestureFlagBits, scrollGestureFlagDirection); + CGEventSetIntegerValueField(evB, kCGEventGestureSwipeMotion, kCGGestureMotionHorizontal); + CGEventSetDoubleValueField(evB, kCGEventGestureScrollY, 0); + CGEventSetDoubleValueField(evB, kCGEventGestureSwipeVelocityX, swipeVelocity); + CGEventSetDoubleValueField(evB, kCGEventGestureSwipeVelocityY, 0); + CGEventSetDoubleValueField(evB, kCGEventGestureZoomDeltaX, FLT_TRUE_MIN); + + CGEventPost(kCGSessionEventTap, evB); + CGEventPost(kCGSessionEventTap, evA); + CFRelease(evA); + CFRelease(evB); + + return true; +} + +bool iss_init(void) { + if (globalTap) { + return true; + } + + CGEventMask mask = CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp); + globalTap = CGEventTapCreate( + kCGSessionEventTap, + kCGHeadInsertEventTap, + kCGEventTapOptionDefault, + mask, + eventTapCallback, + NULL + ); + + if (!globalTap) { + return false; + } + + globalSource = CFMachPortCreateRunLoopSource(NULL, globalTap, 0); + CFRunLoopAddSource(CFRunLoopGetMain(), globalSource, kCFRunLoopCommonModes); + CGEventTapEnable(globalTap, true); + + return true; +} + +void iss_destroy(void) { + if (globalTap) { + CGEventTapEnable(globalTap, false); + if (globalSource) { + CFRunLoopRemoveSource(CFRunLoopGetMain(), globalSource, kCFRunLoopCommonModes); + CFRelease(globalSource); + globalSource = NULL; + } + CFRelease(globalTap); + globalTap = NULL; + } +} + +bool iss_get_space_info(ISSSpaceInfo *info) { + if (!info) { + return false; + } + + memset(info, 0, sizeof(*info)); + return load_space_info_for_display(info, true); +} + +bool iss_switch(ISSDirection direction) { + ISSSpaceInfo info; + if (iss_get_space_info(&info)) { + if (iss_should_block_switch(&info, direction)) { + return false; + } + } + return iss_post_switch_gesture(direction); +} \ No newline at end of file diff --git a/mrmouse/mrmouse/mrmouse/EventEngine/ISS.h b/mrmouse/mrmouse/mrmouse/EventEngine/ISS.h new file mode 100644 index 0000000..b6481f8 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/EventEngine/ISS.h @@ -0,0 +1,23 @@ +#ifndef ISS_h +#define ISS_h + +#include + +bool iss_init(void); +void iss_destroy(void); + +typedef enum { + ISSDirectionLeft = 0, + ISSDirectionRight = 1 +} ISSDirection; + +typedef struct { + unsigned int currentIndex; + unsigned int spaceCount; +} ISSSpaceInfo; + +bool iss_switch(ISSDirection direction); +bool iss_get_space_info(ISSSpaceInfo *info); +bool iss_can_move(ISSSpaceInfo info, ISSDirection direction); + +#endif /* ISS_h */ \ No newline at end of file diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Device/HIDPPDevice.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Device/HIDPPDevice.swift new file mode 100644 index 0000000..194071d --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Device/HIDPPDevice.swift @@ -0,0 +1,274 @@ +import Foundation + +public final class HIDPPDevice: @unchecked Sendable { + public let info: HIDDeviceInfo + public internal(set) var featureTable: [UInt16: UInt8] = [:] // featureID -> featureIndex + + private let transport: any HIDTransportProtocol + private let deviceIndex: UInt8 + + // Request-response correlation + private var pendingRequests: [RequestKey: PendingRequest] = [:] + private let requestLock = NSLock() + private let softwareID: UInt8 = 0x01 + + // Notification handler for diverted buttons, battery, etc. + public var onNotification: ((HIDPPMessage) -> Void)? + + public init(info: HIDDeviceInfo, transport: any HIDTransportProtocol, deviceIndex: UInt8 = 0xFF, standalone: Bool = false) { + self.info = info + self.transport = transport + self.deviceIndex = deviceIndex + // Only register self as delegate in standalone/test mode. + // When used with a coordinator (AppState), the coordinator owns the delegate. + if standalone { + transport.delegate = self + } + } + + // MARK: - Feature Discovery + + public func discoverFeatures() throws { + // IRoot (0x0000) is always at feature index 0 + featureTable[0x0000] = 0x00 + + // Discover IFeatureSet (0x0001) + let featureSetIndex = try getFeatureIndex(featureID: 0x0001) + featureTable[0x0001] = featureSetIndex + + // Get feature count + let countResponse = try featureRequest( + featureIndex: featureSetIndex, + functionID: 0x00 // GetCount + ) + guard countResponse.parameters.count >= 1 else { + throw HIDPPDeviceError.unexpectedResponse + } + let featureCount = min(Int(countResponse.parameters[0]), 32) + + // Enumerate all features + for i in 1...featureCount { + let response = try featureRequest( + featureIndex: featureSetIndex, + functionID: 0x01, // GetFeatureID + parameters: Data([UInt8(i)]) + ) + guard response.parameters.count >= 2 else { continue } + let featureID = UInt16(response.parameters[0]) << 8 | UInt16(response.parameters[1]) + if featureID != 0x0000 { + featureTable[featureID] = UInt8(i) + } + } + } + + public func getFeatureIndex(featureID: UInt16) throws -> UInt8 { + if let cached = featureTable[featureID] { + return cached + } + + // Query IRoot (index 0) function 0 (GetFeature) + let msb = UInt8((featureID >> 8) & 0xFF) + let lsb = UInt8(featureID & 0xFF) + let response = try featureRequest( + featureIndex: 0x00, + functionID: 0x00, + parameters: Data([msb, lsb]) + ) + + guard response.parameters.count >= 1 else { + throw HIDPPDeviceError.unexpectedResponse + } + + let index = response.parameters[0] + if index == 0 { + throw HIDPPDeviceError.featureNotSupported(featureID) + } + + featureTable[featureID] = index + return index + } + + public func hasFeature(_ featureID: UInt16) -> Bool { + featureTable[featureID] != nil + } + + // MARK: - Feature Request + + public func featureRequest( + featureIndex: UInt8, + functionID: UInt8, + parameters: Data = Data() + ) throws -> HIDPPMessage { + // Bolt receiver and BLE both require long reports (0x11); USB can use short (0x10) for small params + let useLong = info.transport == .bluetooth || info.transport == .bolt || parameters.count > 3 + let reportID: HIDPPReportID = useLong ? .long : .short + + let message = HIDPPMessage( + reportID: reportID, + deviceIndex: deviceIndex, + featureIndex: featureIndex, + functionID: functionID, + softwareID: softwareID, + parameters: parameters + ) + + let key = RequestKey(featureIndex: featureIndex, functionID: functionID) + let pending = PendingRequest() + + requestLock.lock() + pendingRequests[key] = pending + requestLock.unlock() + + try transport.sendReport(message.bytes, to: info) + + // Wait for response with timeout + let result = pending.semaphore.wait(timeout: .now() + 2.0) + + requestLock.lock() + pendingRequests.removeValue(forKey: key) + requestLock.unlock() + + guard result == .success else { + throw HIDPPDeviceError.timeout + } + + guard let response = pending.response else { + throw HIDPPDeviceError.unexpectedResponse + } + + if let error = response.errorCode, error != .noError { + throw error + } + + return response + } + + // MARK: - Report Handling + + public func handleReport(_ data: Data) { + guard let message = HIDPPMessage.parse(data) else { return } + + // Check if this matches the device index + guard message.deviceIndex == deviceIndex else { return } + + // Only log non-request notifications (skip during discovery/request-response) + do { + let logKey = RequestKey(featureIndex: message.featureIndex, functionID: message.functionID) + requestLock.lock() + let isPending = pendingRequests[logKey] != nil + requestLock.unlock() + if !isPending { + NSLog("[HIDPPDevice] idx=%d notification: feat=0x%02X func=0x%02X params=%@", + deviceIndex, message.featureIndex, message.functionID, + message.parameters.prefix(8).map { String(format: "%02X", $0) }.joined(separator: " ")) + } + } + + // HID++ 2.0 error responses (featureIndex == 0xFF): + // byte 3 = original featureIndex (parsed as functionID<<4|softwareID by HIDPPMessage) + // params[0] = original (functionID<<4|softwareID) byte + // params[1] = error code + // HID++ 1.0 receiver errors (0x8F): ignored — race with valid 2.0 responses. + let key: RequestKey + if message.isError, message.parameters.count >= 2 { + // byte 3 holds the original featureIndex as a raw byte + let originalFeatureIndex = (message.functionID << 4) | (message.softwareID & 0x0F) + let originalFuncSw = message.parameters[0] + let originalFunctionID = (originalFuncSw >> 4) & 0x0F + key = RequestKey(featureIndex: originalFeatureIndex, + functionID: originalFunctionID) + NSLog("[HIDPPDevice] idx=%d error response: origFeat=0x%02X origFunc=0x%02X errCode=0x%02X", + deviceIndex, originalFeatureIndex, originalFunctionID, message.parameters[1]) + } else if message.isReceiverError { + return + } else { + key = RequestKey(featureIndex: message.featureIndex, functionID: message.functionID) + } + + requestLock.lock() + let pending = pendingRequests[key] + requestLock.unlock() + + if let pending { + pending.response = message + pending.semaphore.signal() + } else { + onNotification?(message) + } + } +} + +// MARK: - HIDTransportDelegate (self-registration for standalone / test use) + +extension HIDPPDevice: HIDTransportDelegate { + + /// Forwards incoming report bytes to `handleReport`. Only called when `HIDPPDevice` is + /// the transport's delegate (i.e. standalone/test use without a higher-level coordinator). + public func transport( + _ transport: any HIDTransportProtocol, + didReceiveReport data: Data, + fromDevice device: HIDDeviceInfo + ) { + handleReport(data) + } + + /// Connect / disconnect events are not meaningful at the device level — they are + /// handled by the higher-level coordinator (e.g. AppState). These are no-ops. + public func transport( + _ transport: any HIDTransportProtocol, + didConnectDevice device: HIDDeviceInfo + ) {} + + public func transport( + _ transport: any HIDTransportProtocol, + didDisconnectDevice device: HIDDeviceInfo + ) {} +} + +// MARK: - Internal Types + +private struct RequestKey: Hashable { + let featureIndex: UInt8 + let functionID: UInt8 +} + +private final class PendingRequest { + let semaphore = DispatchSemaphore(value: 0) + var response: HIDPPMessage? +} + +// MARK: - Feature Registry + +public enum HIDPPFeature: UInt16 { + case iRoot = 0x0000 + case iFeatureSet = 0x0001 + case deviceFWVersion = 0x0003 + case deviceName = 0x0005 + case unifiedBattery = 0x1004 + case reprogControlsV4 = 0x1B04 + case adjustableDPI = 0x2201 + case smartShift = 0x2110 + case hiResWheel = 0x2121 + case thumbWheel = 0x2150 + case gesture2 = 0x6501 + case changeHost = 0x1814 + case hostsInfo = 0x1815 + + public var name: String { + switch self { + case .iRoot: return "IRoot" + case .iFeatureSet: return "IFeatureSet" + case .deviceFWVersion: return "Device FW Version" + case .deviceName: return "Device Name" + case .unifiedBattery: return "Unified Battery" + case .reprogControlsV4: return "Reprog Controls V4" + case .adjustableDPI: return "Adjustable DPI" + case .smartShift: return "Smart Shift" + case .hiResWheel: return "HiRes Wheel" + case .thumbWheel: return "Thumb Wheel" + case .gesture2: return "Gesture 2" + case .changeHost: return "Change Host" + case .hostsInfo: return "Hosts Info" + } + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Device/ReceiverEnumerator.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Device/ReceiverEnumerator.swift new file mode 100644 index 0000000..248ba96 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Device/ReceiverEnumerator.swift @@ -0,0 +1,145 @@ +import Foundation + +/// Information about a device paired to a Unifying/Bolt receiver. +public struct PairedDeviceInfo: Sendable { + public let deviceIndex: UInt8 // 1-6 on the receiver + public let destinationID: UInt8 + public let wpid: UInt16 // wireless PID + public let deviceType: UInt8 // 0=keyboard, 3=mouse, etc. + + public var isMouse: Bool { deviceType == 3 || deviceType == 4 } +} + +/// Enumerates devices paired to a Logitech Unifying or Bolt receiver using +/// HID++ 1.0 register reads. +/// +/// The receiver itself is addressed at device index 0xFF. Paired devices +/// live at indices 1..6. +public enum ReceiverEnumerator { + + /// Enumerate all devices paired to a receiver. + /// `transport` must be started and the receiver must be connected. + /// This sends HID++ 1.0 short reports to the receiver. + public static func enumeratePairedDevices( + receiverInfo: HIDDeviceInfo, + transport: any HIDTransportProtocol + ) -> [PairedDeviceInfo] { + var devices: [PairedDeviceInfo] = [] + + // Try device indices 1 through 6 + for idx: UInt8 in 1...6 { + // HID++ 1.0 register read: register 0xB5 (pairing info), param = device index + // Short report: [0x10, 0xFF, 0x00, funcID|swID, register, param0, param1] + // For register reads: featureIndex = 0x00 (for receiver), funcID = 0x08 (GET_LONG_REGISTER) + // Actually, the standard way to get pairing info is: + // GET_LONG_REGISTER (0x83) on register 0xB5 with param[0] = 0x20 + deviceIndex + // This returns device type and wireless PID. + + let register: UInt8 = 0xB5 + let param: UInt8 = 0x20 + idx // 0x20 = pairing info sub-function + + // Build a short HID++ 1.0 message to the receiver (device index 0xFF) + // Report ID 0x10, device 0xFF, sub-ID 0x83 (GET_LONG_REGISTER), register, param + // HID++ 1.0 uses sub-IDs in byte 2 instead of feature indices + let request = Data([ + 0x10, // short report + 0xFF, // receiver device index + 0x83, // GET_LONG_REGISTER + register, // register address + param, // sub-parameter: 0x20+idx = pairing info for device idx + 0x00, // padding + 0x00 // padding + ]) + + do { + try transport.sendReport(request, to: receiverInfo) + } catch { + continue + } + + // For receiver enumeration we don't use the full request-response correlation + // because HID++ 1.0 has different response format. We'll use a simpler approach: + // just check if the device responds to a HID++ 2.0 ping at that index. + + // Instead, let's just try to ping each device index with a HID++ 2.0 IRoot request. + // If the device exists, it will respond. If not, we'll get a timeout or error. + } + + // Simpler approach: just try HID++ 2.0 feature requests at each device index. + // If a device is paired and connected at that index, it will respond. + devices = pingDeviceIndices(receiverInfo: receiverInfo, transport: transport) + + return devices + } + + /// Try to reach a HID++ 2.0 device at each index (1-6) by sending an IRoot + /// GetFeature request. If we get a valid response, the device is there. + private static func pingDeviceIndices( + receiverInfo: HIDDeviceInfo, + transport: any HIDTransportProtocol + ) -> [PairedDeviceInfo] { + var found: [PairedDeviceInfo] = [] + + for idx: UInt8 in 1...6 { + // Send IRoot.GetFeature(0x0001) — a harmless request that any HID++ 2.0 device answers + let message = HIDPPMessage( + reportID: .short, + deviceIndex: idx, + featureIndex: 0x00, // IRoot is always at index 0 + functionID: 0x00, // GetFeature + softwareID: 0x01, + parameters: Data([0x00, 0x01]) // looking up feature 0x0001 (IFeatureSet) + ) + + do { + try transport.sendReport(message.bytes, to: receiverInfo) + } catch { + continue + } + + // We found a potential device at this index. + // We can't easily wait for the response here without the correlation machinery, + // so we just record the index. The actual response will be handled by whoever + // is listening on the transport delegate. + found.append(PairedDeviceInfo( + deviceIndex: idx, + destinationID: 0, + wpid: 0, + deviceType: 3 // assume mouse — we'll verify during feature discovery + )) + } + + return found + } + + /// Create HIDPPDevice instances for each device index on a receiver. + /// Each device gets the receiver's HIDDeviceInfo but its own device index. + /// Call discoverFeatures() on each to verify it's actually connected. + public static func createDevicesForReceiver( + receiverInfo: HIDDeviceInfo, + transport: any HIDTransportProtocol + ) -> [HIDPPDevice] { + var devices: [HIDPPDevice] = [] + + for idx: UInt8 in 1...6 { + let device = HIDPPDevice( + info: receiverInfo, + transport: transport, + deviceIndex: idx + ) + // Try feature discovery — if it succeeds, there's a device at this index + do { + try device.discoverFeatures() + NSLog("[ReceiverEnumerator] Found device at index %d with %d features", + idx, device.featureTable.count) + devices.append(device) + } catch { + // No device at this index, or device is off/asleep — skip + NSLog("[ReceiverEnumerator] No device at index %d: %@", + idx, error.localizedDescription) + } + } + + return devices + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/AdjustableDPI.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/AdjustableDPI.swift new file mode 100644 index 0000000..85c2b44 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/AdjustableDPI.swift @@ -0,0 +1,73 @@ +import Foundation + +// MARK: - AdjustableDPI (Feature ID: 0x2201) +// +// Wraps the HID++ 2.0 AdjustableDPI feature. DPI values are transmitted as +// big-endian UInt16 pairs in the parameter bytes. + +public enum AdjustableDPI { + + // MARK: - Public API + + /// Returns the number of DPI-capable sensors on the device. + /// Function 0x00 — GetSensorCount. + public static func getSensorCount(device: HIDPPDevice) throws -> Int { + let idx = try FeatureRegistry.index(for: .adjustableDPI, on: device) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x00) + guard response.parameters.count >= 1 else { + throw HIDPPDeviceError.unexpectedResponse + } + return Int(response.parameters[0]) + } + + /// Returns the current and default DPI for a sensor. + /// Function 0x01 — GetSensorDPI. + /// + /// - Parameters: + /// - sensor: Zero-based sensor index (use 0 for single-sensor devices). + /// - Returns: A tuple of (dpi: current DPI, defaultDPI: factory default DPI). + public static func getDPI(device: HIDPPDevice, sensor: Int) throws -> (dpi: Int, defaultDPI: Int) { + let idx = try FeatureRegistry.index(for: .adjustableDPI, on: device) + let params = Data([UInt8(sensor)]) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x01, parameters: params) + // Response: [sensor, dpi_hi, dpi_lo, default_dpi_hi, default_dpi_lo, ...] + guard response.parameters.count >= 5 else { + throw HIDPPDeviceError.unexpectedResponse + } + let dpi = Int(response.parameters[1]) << 8 | Int(response.parameters[2]) + let defaultDPI = Int(response.parameters[3]) << 8 | Int(response.parameters[4]) + return (dpi: dpi, defaultDPI: defaultDPI) + } + + /// Sets the DPI for a sensor. + /// Function 0x02 — SetSensorDPI. + /// + /// - Parameters: + /// - sensor: Zero-based sensor index. + /// - dpi: Desired DPI value (e.g. 200–8000 for MX Master 3S). + public static func setDPI(device: HIDPPDevice, sensor: Int, dpi: Int) throws { + let idx = try FeatureRegistry.index(for: .adjustableDPI, on: device) + let dpiHi = UInt8((dpi >> 8) & 0xFF) + let dpiLo = UInt8(dpi & 0xFF) + let params = Data([UInt8(sensor), dpiHi, dpiLo]) + _ = try device.featureRequest(featureIndex: idx, functionID: 0x02, parameters: params) + } + + /// Returns the DPI range supported by a sensor. + /// Function 0x03 — GetSensorDPIRange. + /// + /// - Returns: A tuple of (min: minimum DPI, max: maximum DPI, step: DPI step size). + public static func getDPIRange(device: HIDPPDevice, sensor: Int) throws -> (min: Int, max: Int, step: Int) { + let idx = try FeatureRegistry.index(for: .adjustableDPI, on: device) + let params = Data([UInt8(sensor)]) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x03, parameters: params) + // Response: [sensor, min_hi, min_lo, max_hi, max_lo, step_hi, step_lo, ...] + guard response.parameters.count >= 7 else { + throw HIDPPDeviceError.unexpectedResponse + } + let minDPI = Int(response.parameters[1]) << 8 | Int(response.parameters[2]) + let maxDPI = Int(response.parameters[3]) << 8 | Int(response.parameters[4]) + let stepDPI = Int(response.parameters[5]) << 8 | Int(response.parameters[6]) + return (min: minDPI, max: maxDPI, step: stepDPI) + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/DeviceInfo.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/DeviceInfo.swift new file mode 100644 index 0000000..29ee82a --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/DeviceInfo.swift @@ -0,0 +1,163 @@ +import Foundation + +// MARK: - DeviceInfo +// +// Reads device identification data using two features: +// • DeviceName (0x0005) — GetNameLength (fn 0x00) + GetName (fn 0x01) +// • DeviceFWVersion (0x0003) — GetEntityCount (fn 0x00) + GetFWInfo (fn 0x01) + +// MARK: - Data Models + +/// Identifies the type of firmware entity returned by GetFWInfo. +public enum FirmwareEntityType: UInt8 { + case mainFirmware = 0x00 + case bootloader = 0x01 + case hardwareRevision = 0x02 + case other = 0xFF + + public init(raw: UInt8) { + self = FirmwareEntityType(rawValue: raw) ?? .other + } +} + +/// Version information for one firmware entity on the device. +public struct FirmwareInfo: Equatable { + /// Entity type (main firmware, bootloader, etc.). + public var type: FirmwareEntityType + /// Version prefix string (up to 3 ASCII chars, e.g. "RQM"). + public var prefix: String + /// Major.minor version numbers. + public var version: (major: UInt8, minor: UInt8) + /// Firmware build number. + public var build: UInt16 + + public init(type: FirmwareEntityType, prefix: String, version: (major: UInt8, minor: UInt8), build: UInt16) { + self.type = type + self.prefix = prefix + self.version = version + self.build = build + } + + // Synthesise Equatable since tuples don't conform automatically. + public static func == (lhs: FirmwareInfo, rhs: FirmwareInfo) -> Bool { + lhs.type == rhs.type && + lhs.prefix == rhs.prefix && + lhs.version.major == rhs.version.major && + lhs.version.minor == rhs.version.minor && + lhs.build == rhs.build + } +} + +// MARK: - Feature Module + +public enum DeviceInfo { + + // MARK: Device Name (0x0005) + + /// Reads the full UTF-8 device name from the DeviceName feature. + /// + /// The name may be longer than the 16-byte parameter window of a single + /// long report, so this function calls GetName in a loop, advancing the + /// byte offset until the entire name is assembled. + /// + /// Protocol: + /// fn 0x00 GetNameLength → params[0] = total byte length + /// fn 0x01 GetName(offset) → params[0..] = up to 15 UTF-8 bytes of name + public static func getDeviceName(device: HIDPPDevice) throws -> String { + let idx = try FeatureRegistry.index(for: .deviceName, on: device) + + // 1. Get total name length + let lengthResponse = try device.featureRequest(featureIndex: idx, functionID: 0x00) + guard lengthResponse.parameters.count >= 1 else { + throw HIDPPDeviceError.unexpectedResponse + } + let totalLength = Int(lengthResponse.parameters[0]) + guard totalLength > 0 else { return "" } + + // 2. Fetch name in chunks (up to 16 bytes per call — long report has 16 params). + var nameBytes = Data() + let chunkSize = 16 + var offset = 0 + + while nameBytes.count < totalLength { + let params = Data([UInt8(offset)]) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x01, parameters: params) + // HID++ 2.0 GetName returns name bytes directly starting at the requested offset + // (no echoed offset byte in the response). + guard response.parameters.count >= 1 else { + throw HIDPPDeviceError.unexpectedResponse + } + let chunk = response.parameters + let remaining = totalLength - nameBytes.count + nameBytes.append(contentsOf: chunk.prefix(remaining)) + offset += chunkSize + } + + return String(bytes: nameBytes, encoding: .utf8) ?? String(bytes: nameBytes, encoding: .isoLatin1) ?? "" + } + + // MARK: Firmware Version (0x0003) + + /// Reads firmware version information for the primary firmware entity. + /// + /// The device may expose multiple entities (firmware, bootloader, HW rev). + /// This function reads entity 0 (main firmware) by default, which is + /// the most useful for display and compatibility checks. + /// + /// Protocol: + /// fn 0x00 GetEntityCount → params[0] = count + /// fn 0x01 GetFWInfo(entityIndex) → type, prefix, version, build + /// + /// - Parameter entityIndex: Which firmware entity to read (0 = main firmware). + public static func getFirmwareVersion(device: HIDPPDevice, entityIndex: Int = 0) throws -> FirmwareInfo { + let idx = try FeatureRegistry.index(for: .deviceFWVersion, on: device) + + // 1. Verify the entity index is within range. + let countResponse = try device.featureRequest(featureIndex: idx, functionID: 0x00) + guard countResponse.parameters.count >= 1 else { + throw HIDPPDeviceError.unexpectedResponse + } + let entityCount = Int(countResponse.parameters[0]) + guard entityIndex < entityCount else { + throw HIDPPDeviceError.unexpectedResponse + } + + // 2. Fetch info for the requested entity. + let params = Data([UInt8(entityIndex)]) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x01, parameters: params) + + // Response layout: + // [0]: entity type + // [1-3]: prefix (3 ASCII chars, zero-padded) + // [4]: major version + // [5]: minor version + // [6-7]: build number (big-endian) + guard response.parameters.count >= 8 else { + throw HIDPPDeviceError.unexpectedResponse + } + let p = response.parameters + let entityType = FirmwareEntityType(raw: p[0]) + let prefixBytes = p[1...3].filter { $0 != 0 } + let prefix = String(bytes: prefixBytes, encoding: .ascii) ?? "" + let major = p[4] + let minor = p[5] + let build = UInt16(p[6]) << 8 | UInt16(p[7]) + + return FirmwareInfo( + type: entityType, + prefix: prefix, + version: (major: major, minor: minor), + build: build + ) + } + + /// Returns the total number of firmware entities exposed by the device. + public static func getFirmwareEntityCount(device: HIDPPDevice) throws -> Int { + let idx = try FeatureRegistry.index(for: .deviceFWVersion, on: device) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x00) + guard response.parameters.count >= 1 else { + throw HIDPPDeviceError.unexpectedResponse + } + return Int(response.parameters[0]) + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/FeatureRegistry.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/FeatureRegistry.swift new file mode 100644 index 0000000..1b9bc4b --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/FeatureRegistry.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Resolves and caches feature indices for a device. +/// Wraps `device.getFeatureIndex(featureID:)` with typed `HIDPPFeature` enum values. +public enum FeatureRegistry { + + /// Returns the runtime feature index for the given feature on the device. + /// The index is cached inside `HIDPPDevice.featureTable` after the first lookup. + /// + /// - Parameters: + /// - feature: The well-known feature to look up. + /// - device: The target device. + /// - Returns: The runtime 8-bit feature index. + /// - Throws: `HIDPPDeviceError.featureNotSupported` if the device does not implement + /// the feature, or any transport/timeout error from the underlying request. + public static func index(for feature: HIDPPFeature, on device: HIDPPDevice) throws -> UInt8 { + try device.getFeatureIndex(featureID: feature.rawValue) + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/HiResWheel.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/HiResWheel.swift new file mode 100644 index 0000000..22b2867 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/HiResWheel.swift @@ -0,0 +1,108 @@ +import Foundation + +// MARK: - HiResWheel (Feature ID: 0x2121) +// +// Controls high-resolution wheel mode, inversion, and HID target routing. +// Mode flags are packed into a single byte: +// Bit 4: hiRes (1 = high-resolution, 0 = standard) +// Bit 3: invert (1 = inverted scroll direction) +// Bit 2: target (1 = HID++ notifications, 0 = standard HID) + +// MARK: - Data Models + +/// The current operating mode of the scroll wheel. +public struct HiResWheelMode: Equatable { + /// When true the wheel sends fine-grained (high-resolution) scroll events. + public var hiRes: Bool + /// When true scroll direction is inverted. + public var invert: Bool + /// When true scroll events are delivered via HID++ notifications instead of standard HID. + public var target: Bool + + public init(hiRes: Bool, invert: Bool, target: Bool) { + self.hiRes = hiRes + self.invert = invert + self.target = target + } + + // MARK: Bit packing + + /// Construct a mode from the raw flags byte returned by the device. + public init(flagsByte: UInt8) { + hiRes = (flagsByte & 0x10) != 0 + invert = (flagsByte & 0x08) != 0 + target = (flagsByte & 0x04) != 0 + } + + /// The packed flags byte to send to the device. + public var flagsByte: UInt8 { + var byte: UInt8 = 0 + if hiRes { byte |= 0x10 } + if invert { byte |= 0x08 } + if target { byte |= 0x04 } + return byte + } +} + +/// Static capabilities reported by the device (read-only). +public struct HiResWheelCapabilities: Equatable { + /// Whether the hardware supports high-resolution mode. + public var supportsHiRes: Bool + /// Whether the hardware supports scroll direction inversion. + public var supportsInvert: Bool + /// Whether HID++ notification routing is available. + public var supportsTarget: Bool + /// Multiplier applied to scroll deltas in hi-res mode. + public var multiplier: Int + + public init(supportsHiRes: Bool, supportsInvert: Bool, supportsTarget: Bool, multiplier: Int) { + self.supportsHiRes = supportsHiRes + self.supportsInvert = supportsInvert + self.supportsTarget = supportsTarget + self.multiplier = multiplier + } +} + +// MARK: - Feature Module + +public enum HiResWheel { + + /// Returns the current wheel mode. + /// Function 0x00 — GetWheelMode. + public static func getMode(device: HIDPPDevice) throws -> HiResWheelMode { + let idx = try FeatureRegistry.index(for: .hiResWheel, on: device) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x00) + // Response: [flags, ...] + guard response.parameters.count >= 1 else { + throw HIDPPDeviceError.unexpectedResponse + } + return HiResWheelMode(flagsByte: response.parameters[0]) + } + + /// Writes a new wheel mode to the device. + /// Function 0x01 — SetWheelMode. + public static func setMode(device: HIDPPDevice, mode: HiResWheelMode) throws { + let idx = try FeatureRegistry.index(for: .hiResWheel, on: device) + let params = Data([mode.flagsByte]) + _ = try device.featureRequest(featureIndex: idx, functionID: 0x01, parameters: params) + } + + /// Returns the static capabilities of the wheel hardware. + /// Function 0x02 — GetWheelCapabilities. + public static func getCapabilities(device: HIDPPDevice) throws -> HiResWheelCapabilities { + let idx = try FeatureRegistry.index(for: .hiResWheel, on: device) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x02) + // Response: [multiplier, flags, ...] (flags same bit layout as mode) + guard response.parameters.count >= 2 else { + throw HIDPPDeviceError.unexpectedResponse + } + let multiplier = Int(response.parameters[0]) + let flags = response.parameters[1] + return HiResWheelCapabilities( + supportsHiRes: (flags & 0x10) != 0, + supportsInvert: (flags & 0x08) != 0, + supportsTarget: (flags & 0x04) != 0, + multiplier: multiplier + ) + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/ReprogControls.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/ReprogControls.swift new file mode 100644 index 0000000..abb7582 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/ReprogControls.swift @@ -0,0 +1,159 @@ +import Foundation + +// MARK: - ReprogControls (Feature ID: 0x1B04) +// +// ReprogControlsV4 — button remapping and diversion for extra mouse buttons. +// Diverting a button causes its press/release to be delivered as HID++ +// notifications rather than standard HID events, enabling custom handling +// (e.g. gesture detection on the gesture button). + +// MARK: - Data Models + +/// Static information about a single remappable control. +public struct ControlInfo: Equatable { + /// Stable hardware control identifier. + public var controlID: UInt16 + /// Default task (logical function) assigned to this control. + public var taskID: UInt16 + /// Capability flags (see Logitech spec). + public var flags: UInt16 + /// Physical position index. + public var position: UInt8 + /// Control group (for mutual-exclusion rules). + public var group: UInt8 + /// Group mask. + public var groupMask: UInt8 + + public init(controlID: UInt16, taskID: UInt16, flags: UInt16, + position: UInt8, group: UInt8, groupMask: UInt8) { + self.controlID = controlID + self.taskID = taskID + self.flags = flags + self.position = position + self.group = group + self.groupMask = groupMask + } +} + +// MARK: ControlReporting flags + +/// Bitmask flags that govern how a control's events are reported. +public struct ControlReportingFlags: OptionSet, Equatable { + public let rawValue: UInt8 + public init(rawValue: UInt8) { self.rawValue = rawValue } + + /// Divert button — deliver events via HID++ notifications instead of HID. + public static let divert = ControlReportingFlags(rawValue: 0x01) + /// Persist diversion across power cycles. + public static let persist = ControlReportingFlags(rawValue: 0x02) + /// Also deliver raw (X, Y) pointer deltas via HID++ while button is held. + public static let rawXY = ControlReportingFlags(rawValue: 0x04) + /// Force the button into its "force" gesture mode. + public static let force = ControlReportingFlags(rawValue: 0x08) +} + +/// The current reporting configuration for a single control. +public struct ControlReporting: Equatable { + /// Target task / control ID the button is remapped to (0 = default). + public var remapped: UInt16 + /// Reporting mode flags. + public var flags: ControlReportingFlags + + public init(remapped: UInt16, flags: ControlReportingFlags) { + self.remapped = remapped + self.flags = flags + } +} + +// MARK: - Feature Module + +public enum ReprogControls { + + /// Returns the total number of remappable controls on the device. + /// Function 0x00 — GetControlCount. + public static func getControlCount(device: HIDPPDevice) throws -> Int { + let idx = try FeatureRegistry.index(for: .reprogControlsV4, on: device) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x00) + guard response.parameters.count >= 1 else { + throw HIDPPDeviceError.unexpectedResponse + } + return Int(response.parameters[0]) + } + + /// Returns the static description of the control at the given index. + /// Function 0x01 — GetControlInfo. + /// + /// - Parameter index: Zero-based position in the control table. + public static func getControlInfo(device: HIDPPDevice, index: Int) throws -> ControlInfo { + let idx = try FeatureRegistry.index(for: .reprogControlsV4, on: device) + let params = Data([UInt8(index)]) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x01, parameters: params) + // Response layout (byte offsets within parameters): + // 0-1: controlID (big-endian) + // 2-3: taskID (big-endian) + // 4-5: flags (big-endian) + // 6: position + // 7: group + // 8: groupMask + guard response.parameters.count >= 9 else { + throw HIDPPDeviceError.unexpectedResponse + } + let p = response.parameters + let controlID = UInt16(p[0]) << 8 | UInt16(p[1]) + let taskID = UInt16(p[2]) << 8 | UInt16(p[3]) + let flags = UInt16(p[4]) << 8 | UInt16(p[5]) + return ControlInfo( + controlID: controlID, + taskID: taskID, + flags: flags, + position: p[6], + group: p[7], + groupMask: p[8] + ) + } + + /// Returns the current reporting configuration for a control by its hardware ID. + /// Function 0x02 — GetControlReporting. + /// + /// - Parameter controlID: The hardware control identifier from `ControlInfo.controlID`. + public static func getControlReporting(device: HIDPPDevice, controlID: UInt16) throws -> ControlReporting { + let idx = try FeatureRegistry.index(for: .reprogControlsV4, on: device) + let idHi = UInt8((controlID >> 8) & 0xFF) + let idLo = UInt8(controlID & 0xFF) + let params = Data([idHi, idLo]) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x02, parameters: params) + // Response layout: + // 0-1: controlID echo + // 2-3: remapped taskID (big-endian) + // 4: reporting flags + guard response.parameters.count >= 5 else { + throw HIDPPDeviceError.unexpectedResponse + } + let p = response.parameters + let remapped = UInt16(p[2]) << 8 | UInt16(p[3]) + let flags = ControlReportingFlags(rawValue: p[4]) + return ControlReporting(remapped: remapped, flags: flags) + } + + /// Writes a new reporting configuration for a control. + /// Function 0x03 — SetControlReporting. + /// + /// To intercept button events via HID++ notifications, set `.divert` in + /// `reporting.flags`. Optionally add `.persist` to survive reboots, and + /// `.rawXY` to also receive pointer deltas while the button is held. + /// + /// - Parameters: + /// - controlID: The hardware control identifier. + /// - reporting: The desired reporting configuration. + public static func setControlReporting(device: HIDPPDevice, controlID: UInt16, reporting: ControlReporting) throws { + let idx = try FeatureRegistry.index(for: .reprogControlsV4, on: device) + let idHi = UInt8((controlID >> 8) & 0xFF) + let idLo = UInt8(controlID & 0xFF) + // V4 SetControlReporting: [controlID_hi, controlID_lo, flags, remapped_hi, remapped_lo] + // remapped = the CID this button maps to when not diverted (usually the same CID). + let params = Data([idHi, idLo, reporting.flags.rawValue, idHi, idLo]) + NSLog("[ReprogControls] setControlReporting: idx=0x%02X params=%@", + idx, params.map { String(format: "%02X", $0) }.joined(separator: " ")) + _ = try device.featureRequest(featureIndex: idx, functionID: 0x03, parameters: params) + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/SmartShift.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/SmartShift.swift new file mode 100644 index 0000000..9efaf02 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/SmartShift.swift @@ -0,0 +1,49 @@ +import Foundation + +// MARK: - SmartShift (Feature ID: 0x2110) +// +// Controls the SmartShift ratchet/free-spin wheel behaviour. +// Param byte layout: [enabled (0x00 / 0x01), threshold (1–255)] + +// MARK: - Data Model + +/// Configuration for the SmartShift ratchet mechanism. +public struct SmartShiftConfig: Equatable { + /// Whether SmartShift is active (auto-switches between ratchet and free-spin). + public var enabled: Bool + /// Threshold torque at which the wheel auto-disengages the ratchet (1–255). + public var autoDisengageThreshold: UInt8 + + public init(enabled: Bool, autoDisengageThreshold: UInt8) { + self.enabled = enabled + self.autoDisengageThreshold = autoDisengageThreshold + } +} + +// MARK: - Feature Module + +public enum SmartShift { + + /// Returns the current SmartShift configuration. + /// Function 0x00 — GetSmartShift. + public static func getConfig(device: HIDPPDevice) throws -> SmartShiftConfig { + let idx = try FeatureRegistry.index(for: .smartShift, on: device) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x00) + // Response: [enabled, threshold, ...] + guard response.parameters.count >= 2 else { + throw HIDPPDeviceError.unexpectedResponse + } + let enabled = response.parameters[0] != 0x00 + let threshold = response.parameters[1] + return SmartShiftConfig(enabled: enabled, autoDisengageThreshold: threshold) + } + + /// Writes a new SmartShift configuration to the device. + /// Function 0x01 — SetSmartShift. + public static func setConfig(device: HIDPPDevice, config: SmartShiftConfig) throws { + let idx = try FeatureRegistry.index(for: .smartShift, on: device) + let enabledByte: UInt8 = config.enabled ? 0x01 : 0x00 + let params = Data([enabledByte, config.autoDisengageThreshold]) + _ = try device.featureRequest(featureIndex: idx, functionID: 0x01, parameters: params) + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/ThumbWheel.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/ThumbWheel.swift new file mode 100644 index 0000000..e10f98f --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/ThumbWheel.swift @@ -0,0 +1,65 @@ +import Foundation + +// MARK: - ThumbWheel (Feature ID: 0x2150) +// +// Controls the horizontal thumb wheel on MX Master devices. +// Param byte layout: [flags] +// Bit 0: inverted (1 = inverted scroll direction) +// Bit 1: diverted (1 = deliver events via HID++ notifications) + +// MARK: - Data Model + +/// Configuration for the horizontal thumb wheel. +public struct ThumbWheelConfig: Equatable { + /// When true the scroll direction is inverted. + public var inverted: Bool + /// When true thumb wheel events are delivered via HID++ notifications + /// instead of standard HID horizontal scroll reports. + public var diverted: Bool + + public init(inverted: Bool, diverted: Bool) { + self.inverted = inverted + self.diverted = diverted + } + + // MARK: Bit packing + + /// Construct config from the raw flags byte returned by the device. + public init(flagsByte: UInt8) { + inverted = (flagsByte & 0x01) != 0 + diverted = (flagsByte & 0x02) != 0 + } + + /// The packed flags byte to send to the device. + public var flagsByte: UInt8 { + var byte: UInt8 = 0 + if inverted { byte |= 0x01 } + if diverted { byte |= 0x02 } + return byte + } +} + +// MARK: - Feature Module + +public enum ThumbWheel { + + /// Returns the current thumb wheel configuration. + /// Function 0x00 — GetThumbWheelConfig. + public static func getConfig(device: HIDPPDevice) throws -> ThumbWheelConfig { + let idx = try FeatureRegistry.index(for: .thumbWheel, on: device) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x00) + // Response: [flags, ...] + guard response.parameters.count >= 1 else { + throw HIDPPDeviceError.unexpectedResponse + } + return ThumbWheelConfig(flagsByte: response.parameters[0]) + } + + /// Writes a new thumb wheel configuration to the device. + /// Function 0x01 — SetThumbWheelConfig. + public static func setConfig(device: HIDPPDevice, config: ThumbWheelConfig) throws { + let idx = try FeatureRegistry.index(for: .thumbWheel, on: device) + let params = Data([config.flagsByte]) + _ = try device.featureRequest(featureIndex: idx, functionID: 0x01, parameters: params) + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/UnifiedBattery.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/UnifiedBattery.swift new file mode 100644 index 0000000..f2b3e61 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Features/UnifiedBattery.swift @@ -0,0 +1,98 @@ +import Foundation + +// MARK: - UnifiedBattery (Feature ID: 0x1004) +// +// Reads battery status from devices that implement the Unified Battery feature. +// The same feature index / function 0x00 is used both for polling and as the +// notification that the device pushes when battery state changes. + +// MARK: - Data Models + +/// Coarse battery level reported alongside the percentage. +public enum BatteryLevel: UInt8 { + case critical = 1 + case low = 2 + case good = 4 + case full = 8 + + /// Human-readable label. + public var description: String { + switch self { + case .critical: return "Critical" + case .low: return "Low" + case .good: return "Good" + case .full: return "Full" + } + } +} + +/// Current battery status for the device. +public struct BatteryStatus: Equatable { + /// State-of-charge in whole percent (0–100). Some devices report 0 when + /// discharging below the minimum measurable threshold. + public var percentage: Int + /// Coarse battery level (matches the level flags in the HID++ response). + public var level: BatteryLevel? + /// Whether the device is currently being charged. + public var charging: Bool + + public init(percentage: Int, level: BatteryLevel?, charging: Bool) { + self.percentage = percentage + self.level = level + self.charging = charging + } +} + +// MARK: - Feature Module + +public enum UnifiedBattery { + + /// Queries the device for its current battery status. + /// Function 0x00 — GetBatteryStatus. + /// + /// The device also pushes this same function (0x00) as an unsolicited + /// notification when the battery level changes; callers should parse + /// incoming `HIDPPMessage` objects with `parse(notification:)`. + public static func getStatus(device: HIDPPDevice) throws -> BatteryStatus { + let idx = try FeatureRegistry.index(for: .unifiedBattery, on: device) + let response = try device.featureRequest(featureIndex: idx, functionID: 0x00) + return try parse(parameters: response.parameters) + } + + /// Parses a battery status notification or poll response. + /// + /// Call this from your `device.onNotification` handler when + /// `message.featureIndex` matches the UnifiedBattery feature index. + /// + /// - Parameter notification: An incoming HID++ message from the device. + /// - Returns: The parsed battery status, or nil if the message is not a + /// battery status report. + public static func parse(notification: HIDPPMessage) -> BatteryStatus? { + // Notifications use function 0 (same as the poll response) + guard notification.functionID == 0x00 else { return nil } + return try? parse(parameters: notification.parameters) + } + + // MARK: - Private Helpers + + /// Decodes the parameter bytes that appear in both poll responses and push notifications. + /// + /// Protocol layout: + /// [0] percentage (0–100) + /// [1] battery level bitmask (critical=1, low=2, good=4, full=8) + /// [2] charging status flags (bit 0 = charging) + private static func parse(parameters: Data) throws -> BatteryStatus { + guard parameters.count >= 3 else { + throw HIDPPDeviceError.unexpectedResponse + } + let percentage = Int(parameters[0]) + let levelRaw = parameters[1] + let chargingByte = parameters[2] + + // The level field is a bitmask; pick the highest set bit. + let level = BatteryLevel(rawValue: levelRaw) + let charging = (chargingByte & 0x01) != 0 + + return BatteryStatus(percentage: percentage, level: level, charging: charging) + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Protocol/HIDPPError.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Protocol/HIDPPError.swift new file mode 100644 index 0000000..036bc24 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Protocol/HIDPPError.swift @@ -0,0 +1,54 @@ +import Foundation + +public enum HIDPPError: UInt8, Error, CustomStringConvertible { + case noError = 0x00 + case unknown = 0x01 + case invalidArgument = 0x02 + case outOfRange = 0x03 + case hwError = 0x04 + case logitechInternal = 0x05 + case invalidFeatureIndex = 0x06 + case invalidFunctionID = 0x07 + case busy = 0x08 + case unsupported = 0x09 + + public var description: String { + switch self { + case .noError: return "No error" + case .unknown: return "Unknown error" + case .invalidArgument: return "Invalid argument" + case .outOfRange: return "Out of range" + case .hwError: return "Hardware error" + case .logitechInternal: return "Logitech internal error" + case .invalidFeatureIndex: return "Invalid feature index" + case .invalidFunctionID: return "Invalid function ID" + case .busy: return "Device busy" + case .unsupported: return "Unsupported" + } + } +} + +public enum HIDPPDeviceError: Error, CustomStringConvertible { + case deviceNotFound + case connectionLost + case transportError(String) + case featureNotSupported(UInt16) + case timeout + case unexpectedResponse + + public var isTimeout: Bool { + if case .timeout = self { return true } + return false + } + + public var description: String { + switch self { + case .deviceNotFound: return "Device not found" + case .connectionLost: return "Connection lost" + case .transportError(let msg): return "Transport error: \(msg)" + case .featureNotSupported(let id): return String(format: "Feature 0x%04X not supported", id) + case .timeout: return "Request timed out" + case .unexpectedResponse: return "Unexpected response" + } + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Protocol/HIDPPMessage.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Protocol/HIDPPMessage.swift new file mode 100644 index 0000000..dd9c1b5 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Protocol/HIDPPMessage.swift @@ -0,0 +1,112 @@ +import Foundation + +public enum HIDPPReportID: UInt8 { + case short = 0x10 // 7 bytes total + case long = 0x11 // 20 bytes total +} + +public struct HIDPPMessage { + public static let shortLength = 7 + public static let longLength = 20 + + public let reportID: HIDPPReportID + public let deviceIndex: UInt8 + public let featureIndex: UInt8 + public let functionID: UInt8 // high 4 bits of byte 3 + public let softwareID: UInt8 // low 4 bits of byte 3 + public let parameters: Data + + public init( + reportID: HIDPPReportID = .long, + deviceIndex: UInt8, + featureIndex: UInt8, + functionID: UInt8, + softwareID: UInt8 = 0x01, + parameters: Data = Data() + ) { + self.reportID = reportID + self.deviceIndex = deviceIndex + self.featureIndex = featureIndex + self.functionID = functionID + self.softwareID = softwareID + self.parameters = parameters + } + + public var bytes: Data { + let totalLength = reportID == .short ? Self.shortLength : Self.longLength + let paramCapacity = totalLength - 4 + var data = Data(count: totalLength) + data[0] = reportID.rawValue + data[1] = deviceIndex + data[2] = featureIndex + data[3] = (functionID << 4) | (softwareID & 0x0F) + let copyCount = min(parameters.count, paramCapacity) + if copyCount > 0 { + data.replaceSubrange(4..<(4 + copyCount), with: parameters.prefix(copyCount)) + } + return data + } + + public static func parse(_ data: Data) -> HIDPPMessage? { + guard data.count >= shortLength else { return nil } + guard let reportID = HIDPPReportID(rawValue: data[0]) else { return nil } + + let expectedLength = reportID == .short ? shortLength : longLength + guard data.count >= expectedLength else { return nil } + + return HIDPPMessage( + reportID: reportID, + deviceIndex: data[1], + featureIndex: data[2], + functionID: (data[3] >> 4) & 0x0F, + softwareID: data[3] & 0x0F, + parameters: data.subdata(in: 4..= 3 { + return HIDPPError(rawValue: parameters[2]) + } + if isReceiverError, parameters.count >= 2 { + // HID++ 1.0 error: byte3=subID, params[0]=address, params[1]=errorCode + return HIDPPError(rawValue: parameters[1]) + } + return nil + } + + // Convenience: create a long report forced (for Bluetooth) + public var asLongReport: HIDPPMessage { + guard reportID == .short else { return self } + var paddedParams = parameters + let needed = Self.longLength - 4 + if paddedParams.count < needed { + paddedParams.append(Data(count: needed - paddedParams.count)) + } + return HIDPPMessage( + reportID: .long, + deviceIndex: deviceIndex, + featureIndex: featureIndex, + functionID: functionID, + softwareID: softwareID, + parameters: paddedParams + ) + } +} + +extension HIDPPMessage: CustomStringConvertible { + public var description: String { + let hex = bytes.map { String(format: "%02X", $0) }.joined(separator: " ") + return "HIDPPMessage(\(hex))" + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Transport/BLETransport.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Transport/BLETransport.swift new file mode 100644 index 0000000..42db73e --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Transport/BLETransport.swift @@ -0,0 +1,360 @@ +import CoreBluetooth +import Foundation + +// MARK: - Logitech BLE GATT Constants + +/// Logitech vendor-specific GATT service UUID. +/// Contains HID++ protocol commands/responses/notifications. +private let kLogitechServiceUUID = CBUUID(string: "00010000-0000-1000-8000-011f2000046d") + +/// Known Bluetooth product IDs for Logitech mice (used for name-based matching fallback). +private let kLogitechNamePrefixes = ["MX Master", "MX Anywhere", "MX Ergo", "MX Vertical"] + +// MARK: - BLETransport + +/// CoreBluetooth-based transport for Logitech HID++ devices. +/// +/// Communicates directly with Logitech mice over BLE GATT, bypassing the Bolt receiver +/// and macOS kernel-level HID++ blocking. Uses the Logitech vendor-specific GATT service +/// for HID++ commands, responses, and unsolicited notifications. +/// +/// BLE GATT flow: +/// 1. Scan for peripherals advertising the Logitech service UUID +/// 2. Connect → discover service → discover characteristics +/// 3. Enable notifications on the report characteristic +/// 4. Write HID++ commands to the write characteristic (write-without-response) +/// 5. Receive HID++ responses/notifications via GATT notify callbacks +public final class BLETransport: NSObject, HIDTransportProtocol { + + // MARK: Public interface + + public weak var delegate: HIDTransportDelegate? + + public private(set) var connectedDevices: [HIDDeviceInfo] = [] + + // MARK: CoreBluetooth + + private var centralManager: CBCentralManager? + private var connectedPeripherals: [UUID: CBPeripheral] = [:] + private var deviceInfoMap: [UUID: HIDDeviceInfo] = [:] + private var logitechServiceMap: [UUID: CBService] = [:] + private var writeCharMap: [UUID: CBCharacteristic] = [:] + private var reportCharMap: [UUID: CBCharacteristic] = [:] + private var isScanning = false + private let lock = NSLock() + + /// Used to match peripherals in sendReport by serialNumber (UUID string). + private var peripheralIDBySerial: [String: UUID] = [:] + + // MARK: - Lifecycle + + public override init() { + super.init() + } + + deinit { + stop() + } + + // MARK: - HIDTransportProtocol + + public func start() { + guard centralManager == nil else { return } + NSLog("[BLETransport] Starting BLE transport") + centralManager = CBCentralManager(delegate: self, queue: nil) // main queue + } + + public func stop() { + isScanning = false + centralManager?.stopScan() + if let manager = centralManager { + for peripheral in connectedPeripherals.values { + manager.cancelPeripheralConnection(peripheral) + } + } + centralManager = nil + connectedPeripherals.removeAll() + deviceInfoMap.removeAll() + logitechServiceMap.removeAll() + writeCharMap.removeAll() + reportCharMap.removeAll() + peripheralIDBySerial.removeAll() + connectedDevices = [] + } + + public func sendReport(_ data: Data, to device: HIDDeviceInfo) throws { + let serial = device.serialNumber ?? "" + guard let peripheralUUID = peripheralIDBySerial[serial], + let peripheral = connectedPeripherals[peripheralUUID], + let writeChar = writeCharMap[peripheralUUID] + else { + throw BLETransportError.notConnected + } + + let hex = data.prefix(min(data.count, 20)).map { String(format: "%02X", $0) }.joined(separator: " ") + NSLog("[BLETransport] TX: %@", hex) + + peripheral.writeValue(data, for: writeChar, type: .withoutResponse) + } + + // MARK: - Scanning + + private func startScanning() { + guard let manager = centralManager, manager.state == .poweredOn, !isScanning else { return } + + isScanning = true + NSLog("[BLETransport] Scanning for peripherals (name-based matching)") + // Don't filter by service UUID — Logitech devices may not advertise it. + // Match by name in didDiscover callback instead. + manager.scanForPeripherals(withServices: nil, options: [ + CBCentralManagerScanOptionAllowDuplicatesKey: false + ]) + } + + private func stopScanning() { + guard isScanning else { return } + centralManager?.stopScan() + isScanning = false + NSLog("[BLETransport] Stopped scanning") + } +} + +// MARK: - CBCentralManagerDelegate + +extension BLETransport: CBCentralManagerDelegate { + + public func centralManagerDidUpdateState(_ central: CBCentralManager) { + switch central.state { + case .poweredOn: + NSLog("[BLETransport] Bluetooth powered on — starting scan") + startScanning() + case .poweredOff: + NSLog("[BLETransport] Bluetooth powered off") + isScanning = false + case .unauthorized: + NSLog("[BLETransport] Bluetooth unauthorized — check System Settings > Privacy") + isScanning = false + case .unsupported: + NSLog("[BLETransport] Bluetooth unsupported on this device") + default: + NSLog("[BLETransport] Bluetooth state: %d", central.state.rawValue) + } + } + + public func centralManager(_ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber) { + let name = peripheral.name ?? "Unknown" + NSLog("[BLETransport] Discovered: %@ (RSSI: %@)", name, RSSI) + + // Prefer name-based filtering as secondary gate — only connect to known Logitech mice + let isLogitech = kLogitechNamePrefixes.contains { name.hasPrefix($0) } + || (advertisementData[CBAdvertisementDataLocalNameKey] as? String) + .map { prefix in kLogitechNamePrefixes.contains { prefix.hasPrefix($0) } } ?? false + + guard isLogitech else { + NSLog("[BLETransport] Skipping non-Logitech device: %@", name) + return + } + + guard connectedPeripherals[peripheral.identifier] == nil else { + NSLog("[BLETransport] Already connected/connected to %@", name) + return + } + + NSLog("[BLETransport] Connecting to %@", name) + central.connect(peripheral, options: nil) + } + + public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + NSLog("[BLETransport] Connected to %@", peripheral.name ?? "Unknown") + peripheral.delegate = self + peripheral.discoverServices([kLogitechServiceUUID]) + } + + public func centralManager(_ central: CBCentralManager, + didFailToConnect peripheral: CBPeripheral, + error: Error?) { + NSLog("[BLETransport] Failed to connect to %@: %@", + peripheral.name ?? "Unknown", error?.localizedDescription ?? "unknown error") + } + + public func centralManager(_ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + error: Error?) { + let id = peripheral.identifier + NSLog("[BLETransport] Disconnected from %@: %@", + peripheral.name ?? "Unknown", error?.localizedDescription ?? "clean disconnect") + + lock.lock() + connectedPeripherals.removeValue(forKey: id) + let info = deviceInfoMap.removeValue(forKey: id) + logitechServiceMap.removeValue(forKey: id) + writeCharMap.removeValue(forKey: id) + reportCharMap.removeValue(forKey: id) + if let serial = info?.serialNumber { peripheralIDBySerial.removeValue(forKey: serial) } + updateConnectedDevices() + lock.unlock() + + if let info { + delegate?.transport(self, didDisconnectDevice: info) + } + } + + private func updateConnectedDevices() { + connectedDevices = Array(deviceInfoMap.values) + } +} + +// MARK: - CBPeripheralDelegate + +extension BLETransport: CBPeripheralDelegate { + + public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + NSLog("[BLETransport] Service discovery error: %@", error.localizedDescription) + return + } + + guard let services = peripheral.services else { return } + + for service in services { + if service.uuid == kLogitechServiceUUID { + NSLog("[BLETransport] Found Logitech service — discovering characteristics") + logitechServiceMap[peripheral.identifier] = service + peripheral.discoverCharacteristics(nil, for: service) + } + } + } + + public func peripheral(_ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error: Error?) { + if let error { + NSLog("[BLETransport] Characteristic discovery error: %@", error.localizedDescription) + return + } + + guard let characteristics = service.characteristics else { return } + + var writeChar: CBCharacteristic? + var reportChar: CBCharacteristic? + + for char in characteristics { + NSLog("[BLETransport] Characteristic %@: properties=%@", + char.uuid.uuidString, char.properties.debugDescription) + + if char.properties.contains(.writeWithoutResponse) { + writeChar = char + NSLog("[BLETransport] → Write characteristic: %@", char.uuid.uuidString) + } + + if char.properties.contains(.notify) { + reportChar = char + NSLog("[BLETransport] → Report characteristic: %@", char.uuid.uuidString) + peripheral.setNotifyValue(true, for: char) + } + } + + // Some Logitech BLE devices have a single characteristic that supports both + // write and notify. Fall back to the first characteristic if needed. + if writeChar == nil, let first = characteristics.first { + writeChar = first + NSLog("[BLETransport] → Fallback write characteristic: %@", first.uuid.uuidString) + } + + let id = peripheral.identifier + lock.lock() + if let wc = writeChar { writeCharMap[id] = wc } + if let rc = reportChar { reportCharMap[id] = rc } + lock.unlock() + + // Build HIDDeviceInfo and notify delegate + let name = peripheral.name ?? "Logitech BLE Device" + let serial = peripheral.identifier.uuidString + let info = HIDDeviceInfo( + vendorID: 0x046D, + productID: 0xB034, // MX Master 3S BLE PID + name: name, + serialNumber: serial, + transport: .bluetooth + ) + + lock.lock() + connectedPeripherals[peripheral.identifier] = peripheral + deviceInfoMap[peripheral.identifier] = info + peripheralIDBySerial[serial] = peripheral.identifier + updateConnectedDevices() + lock.unlock() + + NSLog("[BLETransport] Device ready: %@ (write=%@, report=%@)", + name, + writeChar?.uuid.uuidString ?? "none", + reportChar?.uuid.uuidString ?? "none") + + delegate?.transport(self, didConnectDevice: info) + } + + public func peripheral(_ peripheral: CBPeripheral, + didUpdateValueFor characteristic: CBCharacteristic, + error: Error?) { + if let error { + NSLog("[BLETransport] Read error: %@", error.localizedDescription) + return + } + + guard let data = characteristic.value, !data.isEmpty else { return } + + let hex = data.prefix(min(data.count, 20)).map { String(format: "%02X", $0) }.joined(separator: " ") + NSLog("[BLETransport] RX: %@", hex) + + guard let info = deviceInfoMap[peripheral.identifier] else { + NSLog("[BLETransport] RX from unknown peripheral — ignoring") + return + } + + delegate?.transport(self, didReceiveReport: data, fromDevice: info) + } + + public func peripheral(_ peripheral: CBPeripheral, + didWriteValueFor characteristic: CBCharacteristic, + error: Error?) { + if let error { + NSLog("[BLETransport] Write error: %@", error.localizedDescription) + } + } +} + +// MARK: - Errors + +public enum BLETransportError: Error, CustomStringConvertible { + case notConnected + case noWriteCharacteristic + case bluetoothNotReady + + public var description: String { + switch self { + case .notConnected: return "BLE device not connected" + case .noWriteCharacteristic: return "No writable BLE characteristic found" + case .bluetoothNotReady: return "Bluetooth not powered on or unauthorized" + } + } +} + +// MARK: - CBCharacteristicProperties helpers + +extension CBCharacteristicProperties { + var debugDescription: String { + var parts: [String] = [] + if contains(.broadcast) { parts.append("broadcast") } + if contains(.read) { parts.append("read") } + if contains(.writeWithoutResponse) { parts.append("writeWithoutResponse") } + if contains(.write) { parts.append("write") } + if contains(.notify) { parts.append("notify") } + if contains(.indicate) { parts.append("indicate") } + if contains(.authenticatedSignedWrites) { parts.append("authenticatedSignedWrites") } + if contains(.extendedProperties) { parts.append("extendedProperties") } + return parts.joined(separator: ", ") + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Transport/HIDTransport.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Transport/HIDTransport.swift new file mode 100644 index 0000000..7b38c9f --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Transport/HIDTransport.swift @@ -0,0 +1,588 @@ +import Foundation +import IOKit +import IOKit.hid + +// MARK: - Logitech Constants + +private let kLogitechVID: Int = 0x046D + +/// Known Logitech product IDs used for matching and transport-type classification. +private enum LogitechPID { + // Bluetooth PIDs — MX Master family + static let mxMaster3SBluetooth: Int = 0xB023 + static let mxMaster3SBLE: Int = 0xB034 // MX Master 3S via BLE + static let mxMaster3Bluetooth: Int = 0x4082 + static let mxMaster2SBluetooth: Int = 0xB012 + static let mxMasterBluetooth: Int = 0xB006 + + // USB receiver PIDs + static let unifyingReceiver: Int = 0xC52B + static let boltReceiver: Int = 0xC548 + + // BLE PIDs — may have different VendorIDSource on macOS + static let mxMaster3SBLEAlt: Int = 45108 // 0xB034 as reported by IOKit for BLE + + static let bluetoothPIDs: Set = [ + mxMaster3SBluetooth, + mxMaster3SBLE, + mxMaster3Bluetooth, + mxMaster2SBluetooth, + mxMasterBluetooth, + mxMaster3SBLEAlt, + ] + + // PIDs to match without VID (BLE devices may not report USB VID) + static let blePIDs: Set = [ + mxMaster3SBLE, + mxMaster3SBLEAlt, + ] + + static let unifyingPIDs: Set = [unifyingReceiver] + static let boltPIDs: Set = [boltReceiver] +} + +// MARK: - HID report buffer size + +private let kHIDReportBufferSize: Int = 64 + +// MARK: - HIDTransport + +/// Concrete IOKit HID transport for Logitech HID++ devices. +/// +/// All IOKit callbacks fire on a dedicated background thread that owns a private +/// CFRunLoop. Delegate notifications are always dispatched to the main queue. +public final class HIDTransport: HIDTransportProtocol { + + // MARK: Public interface + + public weak var delegate: HIDTransportDelegate? + + public var connectedDevices: [HIDDeviceInfo] { + lock.lock(); defer { lock.unlock() } + return deviceMap.values.map(\.info) + } + + // MARK: Private state + + // Key: a stable integer derived from the IOHIDDevice pointer value. + private var deviceMap: [Int: DeviceState] = [:] + // Track which physical devices we've already notified about (VID-PID-serial). + private var notifiedDevices: Set = [] + private let lock = NSLock() + + private var manager: IOHIDManager? + private var hidThread: HIDRunLoopThread? + + // MARK: Init / deinit + + public init() {} + + deinit { + stop() + } + + // MARK: - HIDTransportProtocol + + public func start() { + guard hidThread == nil else { return } + + let thread = HIDRunLoopThread() + thread.qualityOfService = .userInteractive + hidThread = thread + thread.start() + + // Wait on a background queue to avoid priority inversion on the main thread + DispatchQueue.global(qos: .userInteractive).async { + thread.waitUntilReady() + thread.perform { [weak self] in + guard let self, let rl = thread.cfRunLoop else { return } + self.setupManager(runLoop: rl) + } + } + } + + public func stop() { + guard let thread = hidThread else { return } + hidThread = nil + + thread.perform { [weak self] in + guard let self else { return } + if let mgr = self.manager { + IOHIDManagerUnscheduleFromRunLoop( + mgr, + CFRunLoopGetCurrent(), + CFRunLoopMode.defaultMode.rawValue + ) + IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) + self.manager = nil + } + } + + thread.cancel() + + lock.lock() + deviceMap.removeAll() + notifiedDevices.removeAll() + lock.unlock() + } + + public func sendReport(_ data: Data, to device: HIDDeviceInfo) throws { + guard data.count >= 1 else { + throw HIDPPDeviceError.transportError("Empty report data") + } + + lock.lock() + let matchingStates = deviceMap.values.filter { + $0.info.vendorID == device.vendorID && $0.info.productID == device.productID + } + lock.unlock() + + guard !matchingStates.isEmpty else { + throw HIDPPDeviceError.deviceNotFound + } + + let reportID = CFIndex(data[0]) + + // Prefer vendor HID++ interfaces (0xFF00, 0xFF43) over standard HID (0x0001). + let sorted = matchingStates.sorted { a, b in + let aVendor = a.usagePage >= 0xFF00 + let bVendor = b.usagePage >= 0xFF00 + if aVendor != bVendor { return aVendor } + return false + } + + var lastError: IOReturn = kIOReturnSuccess + var sent = false + + for state in sorted { + // macOS IOKit requires report ID included in the data buffer for SetReport + let result: IOReturn = data.withUnsafeBytes { ptr -> IOReturn in + guard let base = ptr.baseAddress else { return kIOReturnBadArgument } + return IOHIDDeviceSetReport( + state.device, + kIOHIDReportTypeOutput, + reportID, + base.assumingMemoryBound(to: UInt8.self), + CFIndex(data.count) + ) + } + + if result == kIOReturnSuccess { + sent = true + break + } else { + lastError = result + } + } + + if !sent { + throw HIDPPDeviceError.transportError( + String(format: "IOHIDDeviceSetReport failed on all %d interfaces: 0x%08X", + sorted.count, lastError) + ) + } + } + + // MARK: - Manager setup (HID thread) + + private func setupManager(runLoop: CFRunLoop) { + let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) + self.manager = mgr + + IOHIDManagerSetDeviceMatchingMultiple(mgr, buildMatchingArray() as CFArray) + + let ctx = Unmanaged.passUnretained(self).toOpaque() + IOHIDManagerRegisterDeviceMatchingCallback(mgr, deviceMatchingCallback, ctx) + IOHIDManagerRegisterDeviceRemovalCallback(mgr, deviceRemovalCallback, ctx) + + IOHIDManagerScheduleWithRunLoop(mgr, runLoop, CFRunLoopMode.defaultMode.rawValue) + + let openResult = IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) + if openResult != kIOReturnSuccess { + NSLog("[HIDTransport] IOHIDManagerOpen returned 0x%08X", openResult) + } + } + + // MARK: - Matching dictionaries + + private func buildMatchingArray() -> [[String: Any]] { + var dicts: [[String: Any]] = [] + + // Explicit VID+PID for every known Bluetooth MX Master PID. + for pid in LogitechPID.bluetoothPIDs { + dicts.append(pidMatchDict(pid: pid)) + } + + // BLE devices may report PID differently — also match by PID alone + // (without VID constraint) for known BLE PIDs + for pid in LogitechPID.blePIDs { + dicts.append([kIOHIDProductIDKey as String: pid]) + } + + // Unifying and Bolt receivers. + dicts.append(pidMatchDict(pid: LogitechPID.unifyingReceiver)) + dicts.append(pidMatchDict(pid: LogitechPID.boltReceiver)) + + // Logitech vendor usage page 0xFF00 (USB HID++) + dicts.append([ + kIOHIDVendorIDKey as String: kLogitechVID, + kIOHIDPrimaryUsagePageKey as String: 0xFF00, + ]) + + // Logitech BLE vendor usage page 0xFF43 (BLE HID++) + dicts.append([ + kIOHIDPrimaryUsagePageKey as String: 0xFF43, + ]) + + // Catch-all: any Logitech VID device + dicts.append([ + kIOHIDVendorIDKey as String: kLogitechVID, + ]) + + return dicts + } + + private func pidMatchDict(pid: Int) -> [String: Any] { + [ + kIOHIDVendorIDKey as String: kLogitechVID, + kIOHIDProductIDKey as String: pid, + ] + } + + // MARK: - Device connect / disconnect (HID thread) + + fileprivate func handleDeviceMatched(_ device: IOHIDDevice) { + let vid = intProperty(of: device, key: kIOHIDVendorIDKey) ?? 0 + let pid = intProperty(of: device, key: kIOHIDProductIDKey) ?? 0 + let name = stringProperty(of: device, key: kIOHIDProductKey) ?? "Unknown Device" + let serial = stringProperty(of: device, key: kIOHIDSerialNumberKey) + let transportStr = stringProperty(of: device, key: kIOHIDTransportKey) ?? "unknown" + let usagePage = intProperty(of: device, key: kIOHIDPrimaryUsagePageKey) ?? 0 + let usage = intProperty(of: device, key: kIOHIDPrimaryUsageKey) ?? 0 + + NSLog("[HIDTransport] Device matched: '%@' VID=0x%04X PID=0x%04X transport=%@ usagePage=0x%04X usage=0x%04X serial=%@", + name, vid, pid, transportStr, usagePage, usage, serial ?? "none") + let xport = resolveTransportType(pid: pid, device: device) + + let info = HIDDeviceInfo( + vendorID: vid, + productID: pid, + name: name, + serialNumber: serial, + transport: xport + ) + + // Seize vendor interfaces (0xFF00) to bypass kernel notification filtering. + // Standard HID interfaces (keyboard/mouse) are opened normally. + let isVendorInterface = (usagePage == 0xFF00) + let openOptions: IOOptionBits = isVendorInterface + ? IOOptionBits(kIOHIDOptionsTypeSeizeDevice) + : IOOptionBits(kIOHIDOptionsTypeNone) + + var didSeize = isVendorInterface + var openResult = IOHIDDeviceOpen(device, openOptions) + if openResult != kIOReturnSuccess { + if isVendorInterface { + NSLog("[HIDTransport] Seize failed (0x%08X), falling back to normal open for '%@'", + openResult, name) + didSeize = false + openResult = IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeNone)) + } + if openResult != kIOReturnSuccess { + NSLog("[HIDTransport] IOHIDDeviceOpen failed (0x%08X) for '%@' usagePage=0x%04X", + openResult, name, usagePage) + return + } + } + NSLog("[HIDTransport] IOHIDDeviceOpen %@ for '%@' usagePage=0x%04X usage=0x%04X", + didSeize ? "SEIZED" : "OK", name, usagePage, usage) + + let state = DeviceState(device: device, info: info, usagePage: usagePage, transport: self) + let key = deviceKey(for: device) + + lock.lock() + deviceMap[key] = state + lock.unlock() + + // Register input callback on ALL interfaces — HID++ reports may arrive + // on the vendor interface (0xFF00) while standard mouse reports arrive on 0x0001. + let stateCtx = Unmanaged.passUnretained(state).toOpaque() + state.reportBuffer.withUnsafeMutableBytes { bufPtr in + guard let base = bufPtr.baseAddress else { return } + IOHIDDeviceRegisterInputReportCallback( + device, + base.assumingMemoryBound(to: UInt8.self), + CFIndex(kHIDReportBufferSize), + inputReportCallback, + stateCtx + ) + } + + // Only notify delegate once per physical device. + let dedupeKey = "\(vid)-\(pid)-\(serial ?? "none")" + lock.lock() + let alreadyNotified = notifiedDevices.contains(dedupeKey) + if !alreadyNotified { notifiedDevices.insert(dedupeKey) } + lock.unlock() + + guard !alreadyNotified else { + NSLog("[HIDTransport] Skipping duplicate for '%@' (%@)", name, dedupeKey) + return + } + + let capturedDelegate = delegate + let capturedSelf = self + DispatchQueue.main.async { + capturedDelegate?.transport(capturedSelf, didConnectDevice: info) + } + } + + fileprivate func handleDeviceRemoved(_ device: IOHIDDevice) { + let key = deviceKey(for: device) + + lock.lock() + let state = deviceMap.removeValue(forKey: key) + // Remove from dedup set so reconnection fires a new notification. + if let state { + let vid = state.info.vendorID + let pid = state.info.productID + let serial = state.info.serialNumber ?? "none" + let dedupeKey = "\(vid)-\(pid)-\(serial)" + // Only remove from notifiedDevices if no other interfaces remain for this physical device. + let hasOtherInterfaces = deviceMap.values.contains { s in + s.info.vendorID == vid && s.info.productID == pid && + (s.info.serialNumber ?? "none") == serial + } + if !hasOtherInterfaces { + notifiedDevices.remove(dedupeKey) + } + } + lock.unlock() + + guard let state else { return } + + IOHIDDeviceClose(device, IOOptionBits(kIOHIDOptionsTypeNone)) + + let capturedDelegate = delegate + let capturedSelf = self + let info = state.info + DispatchQueue.main.async { + capturedDelegate?.transport(capturedSelf, didDisconnectDevice: info) + } + } + + // Dedup: track last HID++ report to avoid forwarding duplicates from multiple interfaces + private var lastHIDPPReport: Data? + + fileprivate func handleInputReport( + from state: DeviceState, + reportType: IOHIDReportType, + reportID: UInt32, + bytes: UnsafeMutablePointer, + length: CFIndex + ) { + guard length > 0 else { return } + + // Build report data with report ID as first byte. + // On some macOS versions the callback buffer already includes the report ID; + // on others it doesn't. Detect by checking if byte 0 matches reportID. + let reportData: Data + if length > 0, bytes[0] == UInt8(reportID & 0xFF) { + reportData = Data(bytes: bytes, count: Int(length)) + } else { + var data = Data(count: Int(length) + 1) + data[0] = UInt8(reportID & 0xFF) + data.withUnsafeMutableBytes { dest in + guard let destBase = dest.baseAddress else { return } + memcpy(destBase.advanced(by: 1), bytes, Int(length)) + } + reportData = data + } + + let rid = reportData[0] + + // DIAGNOSTIC: log all reports arriving on vendor interfaces after init + let up = state.usagePage + if rid == 0x10 || rid == 0x11 { + let hex = reportData.prefix(min(reportData.count, 20)).map { String(format: "%02X", $0) }.joined(separator: " ") + NSLog("[HIDTransport] RX HID++ report: usagePage=0x%04X len=%d rid=0x%02X data=[%@]", + up, reportData.count, rid, hex) + } else { + NSLog("[HIDTransport] RX non-HID++ report: usagePage=0x%04X len=%d rid=0x%02X", + up, reportData.count, rid) + } + + // Deduplicate HID++ reports (0x10/0x11) — multiple interfaces fire for the same report. + // Use content comparison only; discard exact byte-for-byte duplicates of the previous report. + if rid == 0x10 || rid == 0x11 { + lock.lock() + let isDuplicate = (reportData == lastHIDPPReport) + lastHIDPPReport = reportData + lock.unlock() + + if isDuplicate { + NSLog("[HIDTransport] Deduped duplicate HID++ report") + return + } + } + + let info = state.info + let capturedDelegate = delegate + let capturedSelf = self + DispatchQueue.main.async { + capturedDelegate?.transport(capturedSelf, didReceiveReport: reportData, fromDevice: info) + } + } + + // MARK: - Transport type resolution + + private func resolveTransportType(pid: Int, device: IOHIDDevice) -> HIDDeviceInfo.TransportType { + // PID-first classification is most reliable. + if LogitechPID.bluetoothPIDs.contains(pid) { return .bluetooth } + if LogitechPID.boltPIDs.contains(pid) { return .bolt } + if LogitechPID.unifyingPIDs.contains(pid) { return .unifying } + + // Fall back to IOKit transport property. + if let t = stringProperty(of: device, key: kIOHIDTransportKey) { + switch t.lowercased() { + case "bluetooth", "bluetooth low energy", "bluetoothlowenergy", "ble": + return .bluetooth + case "usb": + if pid == LogitechPID.boltReceiver { return .bolt } + if pid == LogitechPID.unifyingReceiver { return .unifying } + default: break + } + } + + return .usb + } + + // MARK: - IOKit property helpers + + private func intProperty(of device: IOHIDDevice, key: String) -> Int? { + guard let val = IOHIDDeviceGetProperty(device, key as CFString) else { return nil } + if let n = val as? Int { return n } + if let n = val as? NSNumber { return n.intValue } + return nil + } + + private func stringProperty(of device: IOHIDDevice, key: String) -> String? { + IOHIDDeviceGetProperty(device, key as CFString) as? String + } + + // MARK: - Stable key for IOHIDDevice + + /// Derive a stable dictionary key from the IOHIDDevice CF reference. + private func deviceKey(for device: IOHIDDevice) -> Int { + // IOHIDDevice is a CF type; its pointer value is unique during its lifetime. + Int(bitPattern: ObjectIdentifier(device as AnyObject)) + } +} + +// MARK: - DeviceState + +/// Mutable per-device state owned exclusively by `HIDTransport.deviceMap`. +final class DeviceState { + let device: IOHIDDevice + let info: HIDDeviceInfo + let usagePage: Int + /// Buffer passed to `IOHIDDeviceRegisterInputReportCallback`. + /// Must not be moved or deallocated while the callback is registered. + var reportBuffer: [UInt8] + /// Weak reference back to the owning transport, used inside the C callback. + weak var transport: HIDTransport? + + init(device: IOHIDDevice, info: HIDDeviceInfo, usagePage: Int, transport: HIDTransport) { + self.device = device + self.info = info + self.usagePage = usagePage + self.reportBuffer = [UInt8](repeating: 0, count: kHIDReportBufferSize) + self.transport = transport + } +} + +// MARK: - C Callbacks + +/// Device matching callback — fires on the HID run loop thread. +private let deviceMatchingCallback: IOHIDDeviceCallback = { + context, result, sender, device in + guard result == kIOReturnSuccess, let context else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue() + .handleDeviceMatched(device) +} + +/// Device removal callback — fires on the HID run loop thread. +private let deviceRemovalCallback: IOHIDDeviceCallback = { + context, result, sender, device in + guard let context else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue() + .handleDeviceRemoved(device) +} + +/// Input report callback — fires on the HID run loop thread. +/// +/// `context` points to the `DeviceState` for the reporting device. The state +/// holds a weak reference to its owning `HIDTransport`. +private let inputReportCallback: IOHIDReportCallback = { + context, result, sender, reportType, reportID, report, reportLength in + guard result == kIOReturnSuccess, + let context + else { return } + + let state = Unmanaged.fromOpaque(context).takeUnretainedValue() + state.transport?.handleInputReport( + from: state, + reportType: reportType, + reportID: reportID, + bytes: report, + length: reportLength + ) +} + +// MARK: - HIDRunLoopThread + +/// Background `Thread` subclass that owns and spins a `CFRunLoop`. +/// All IOKit callbacks for the HID manager are delivered on this thread. +private final class HIDRunLoopThread: Thread { + + private(set) var cfRunLoop: CFRunLoop? + private let readySemaphore = DispatchSemaphore(value: 0) + + /// Wait until `main()` has obtained the run loop reference. + func waitUntilReady() { + readySemaphore.wait() + } + + /// Schedule `block` on the HID run loop (asynchronous w.r.t. the caller). + func perform(_ block: @escaping () -> Void) { + guard let rl = cfRunLoop else { + // Should not happen after waitUntilReady() has returned. + assertionFailure("[HIDRunLoopThread] perform called before run loop is ready") + return + } + CFRunLoopPerformBlock(rl, CFRunLoopMode.defaultMode.rawValue, block) + CFRunLoopWakeUp(rl) + } + + override func main() { + // Capture the run loop reference before signalling the semaphore so + // callers of waitUntilReady() always see a non-nil cfRunLoop. + cfRunLoop = CFRunLoopGetCurrent() + readySemaphore.signal() + + // A CFRunLoopSource with no callbacks keeps the run loop from exiting + // when there are no other sources pending. + var sourceCtx = CFRunLoopSourceContext() + sourceCtx.version = 0 + let keepAliveSource = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &sourceCtx) + CFRunLoopAddSource(cfRunLoop, keepAliveSource, CFRunLoopMode.defaultMode) + + while !isCancelled { + // Run for up to 0.5 s then check isCancelled; IOKit callbacks fire + // in between without waiting for the timeout. + CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.5, false) + } + + CFRunLoopRemoveSource(cfRunLoop, keepAliveSource, CFRunLoopMode.defaultMode) + } +} diff --git a/mrmouse/mrmouse/mrmouse/HIDPPCore/Transport/HIDTransportProtocol.swift b/mrmouse/mrmouse/mrmouse/HIDPPCore/Transport/HIDTransportProtocol.swift new file mode 100644 index 0000000..f8f882c --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/HIDPPCore/Transport/HIDTransportProtocol.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct HIDDeviceInfo: Sendable { + public let vendorID: Int + public let productID: Int + public let name: String + public let serialNumber: String? + public let transport: TransportType + + public enum TransportType: String, Sendable { + case bluetooth = "Bluetooth" + case usb = "USB" + case bolt = "Bolt" + case unifying = "Unifying" + } + + public init(vendorID: Int, productID: Int, name: String, serialNumber: String?, transport: TransportType) { + self.vendorID = vendorID + self.productID = productID + self.name = name + self.serialNumber = serialNumber + self.transport = transport + } +} + +public protocol HIDTransportDelegate: AnyObject { + func transport(_ transport: any HIDTransportProtocol, didReceiveReport data: Data, fromDevice device: HIDDeviceInfo) + func transport(_ transport: any HIDTransportProtocol, didConnectDevice device: HIDDeviceInfo) + func transport(_ transport: any HIDTransportProtocol, didDisconnectDevice device: HIDDeviceInfo) +} + +public protocol HIDTransportProtocol: AnyObject { + var delegate: HIDTransportDelegate? { get set } + var connectedDevices: [HIDDeviceInfo] { get } + + func start() + func stop() + func sendReport(_ data: Data, to device: HIDDeviceInfo) throws +} diff --git a/mrmouse/mrmouse/mrmouse/MrMouseApp.swift b/mrmouse/mrmouse/mrmouse/MrMouseApp.swift new file mode 100644 index 0000000..991fd52 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/MrMouseApp.swift @@ -0,0 +1,83 @@ +import SwiftUI +import AppKit + +@main +struct MrMouseApp: App { + @StateObject private var appState = AppState() + @Environment(\.openWindow) private var openWindow + + static var logPath: String { + let logsDir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! + .appendingPathComponent("Logs/MrMouse", isDirectory: true) + try? FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) + return logsDir.appendingPathComponent("mrmouse.log").path + } + + init() { + Self.setupFileLogging() + } + + private static func setupFileLogging() { + let fileManager = FileManager.default + let path = logPath + + if !fileManager.fileExists(atPath: path) { + fileManager.createFile(atPath: path, contents: nil) + } + + if let handle = FileHandle(forWritingAtPath: path) { + handle.seekToEndOfFile() + let separator = "\n=== RUN \(Date()) ===\n" + handle.write(separator.data(using: .utf8)!) + dup2(handle.fileDescriptor, STDERR_FILENO) + } + NSLog("[MrMouse] Logging to %@", path) + } + + var body: some Scene { + MenuBarExtra("MrMouse", systemImage: "computermouse.fill") { + Button("Open Settings...") { + openWindow(id: "settings") + NSApplication.shared.activate(ignoringOtherApps: true) + } + .keyboardShortcut(",", modifiers: .command) + + Divider() + + if appState.devices.isEmpty { + Text("No devices connected") + .foregroundStyle(.secondary) + } else { + ForEach(appState.devices) { device in + let isSel = device.id == (appState.selectedDeviceID ?? appState.devices.first?.id) + Button { + appState.selectedDeviceID = device.id + } label: { + HStack { + if isSel { Image(systemName: "checkmark") } + Text("\(device.name) (\(device.transport.rawValue))") + if let battery = device.batteryLevel { + Text("\(battery)%") + .foregroundStyle(.secondary) + } + } + } + } + } + + Divider() + + Button("Quit MrMouse") { + NSApplication.shared.terminate(nil) + } + .keyboardShortcut("q", modifiers: .command) + } + + Window("MrMouse Settings", id: "settings") { + SettingsWindow() + .environmentObject(appState) + } + .defaultSize(width: 480, height: 600) + .windowResizability(.contentSize) + } +} diff --git a/mrmouse/mrmouse/mrmouse/SettingsWindow.swift b/mrmouse/mrmouse/mrmouse/SettingsWindow.swift new file mode 100644 index 0000000..1f867c1 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/SettingsWindow.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct SettingsWindow: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + TabView { + GesturesTab() + .environmentObject(appState) + .tabItem { Label("Gestures", systemImage: "hand.draw") } + + ButtonsTab() + .environmentObject(appState) + .tabItem { Label("Buttons", systemImage: "computermouse") } + } + .frame(minWidth: 420, minHeight: 380) + .padding() + } +} + +// MARK: - Gestures Tab + +private struct GesturesTab: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + Form { + Section("Gesture Button (thumb)") { + Text("Hold and move mouse to swipe. Tap or swipe to trigger.") + .font(.callout) + .foregroundStyle(.secondary) + } + + Section("Actions") { + GestureRow(label: "Tap", icon: "hand.tap", + action: gestureBinding(\.tap)) + GestureRow(label: "Swipe Up", icon: "arrow.up", + action: gestureBinding(\.swipeUp)) + GestureRow(label: "Swipe Down", icon: "arrow.down", + action: gestureBinding(\.swipeDown)) + GestureRow(label: "Swipe Left", icon: "arrow.left", + action: gestureBinding(\.swipeLeft)) + GestureRow(label: "Swipe Right", icon: "arrow.right", + action: gestureBinding(\.swipeRight)) + } + } + .formStyle(.grouped) + } + + private func gestureBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { + if case .systemAction(let action) = appState.configManager.config.globalProfile.gestureActions[keyPath: keyPath] { + return action + } + return .missionControl + }, + set: { newAction in + try? appState.configManager.update { config in + config.globalProfile.gestureActions[keyPath: keyPath] = .systemAction(newAction) + } + } + ) + } +} + +private struct GestureRow: View { + let label: String + let icon: String + @Binding var action: SystemAction + + var body: some View { + LabeledContent { + Picker("", selection: $action) { + Text("Mission Control").tag(SystemAction.missionControl) + Text("App Exposé").tag(SystemAction.appExpose) + Text("Show Desktop").tag(SystemAction.showDesktop) + Text("Launchpad").tag(SystemAction.launchpad) + Divider() + Text("Desktop Left").tag(SystemAction.switchDesktopLeft) + Text("Desktop Right").tag(SystemAction.switchDesktopRight) + Divider() + Text("Spotlight").tag(SystemAction.spotlight) + Text("Screenshot").tag(SystemAction.screenshot) + Text("Lock Screen").tag(SystemAction.lockScreen) + Divider() + Text("Volume Up").tag(SystemAction.volumeUp) + Text("Volume Down").tag(SystemAction.volumeDown) + Text("Mute").tag(SystemAction.mute) + Text("Play/Pause").tag(SystemAction.playPause) + Text("Next Track").tag(SystemAction.nextTrack) + Text("Previous Track").tag(SystemAction.prevTrack) + } + .labelsHidden() + .frame(width: 180) + } label: { + Label(label, systemImage: icon) + } + } +} + +// MARK: - Buttons Tab + +private struct ButtonsTab: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + Form { + Section("Button Mappings") { + ButtonMappingView() + .environmentObject(appState) + } + } + .formStyle(.grouped) + } +} + +#if DEBUG +#Preview { + SettingsWindow() + .environmentObject(AppState()) +} +#endif diff --git a/mrmouse/mrmouse/mrmouse/StatusMenu.swift b/mrmouse/mrmouse/mrmouse/StatusMenu.swift new file mode 100644 index 0000000..5b16d3c --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/StatusMenu.swift @@ -0,0 +1,99 @@ +import SwiftUI + +/// Root view rendered inside the MenuBarExtra window. +/// Fixed ~320 pt wide; content scrolls if it overflows. +struct StatusMenu: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + + // ── Device status ───────────────────────────────────────── + DeviceStatusView() + .environmentObject(appState) + .padding(.horizontal, 16) + .padding(.vertical, 12) + + Divider() + + // ── DPI ─────────────────────────────────────────────────── + SectionContainer(title: "DPI") { + DPISettingsView() + .environmentObject(appState) + } + + Divider() + + // ── Scrolling ───────────────────────────────────────────── + SectionContainer(title: "Scrolling") { + ScrollSettingsView() + .environmentObject(appState) + } + + Divider() + + // ── Buttons ─────────────────────────────────────────────── + SectionContainer(title: "Buttons") { + ButtonMappingView() + .environmentObject(appState) + } + + Divider() + + // ── Profiles ────────────────────────────────────────────── + SectionContainer(title: "Profiles") { + ProfilesView() + .environmentObject(appState) + } + + Divider() + + // ── Footer ──────────────────────────────────────────────── + HStack { + Spacer() + Button("Quit MrMouse") { + NSApplication.shared.terminate(nil) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .font(.footnote) + Spacer() + } + .padding(.vertical, 10) + } + } + .frame(width: 320) + } +} + +// MARK: - Section container + +private struct SectionContainer: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title.uppercased()) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .padding(.horizontal, 16) + .padding(.top, 10) + + content() + .padding(.horizontal, 16) + .padding(.bottom, 10) + } + } +} + +// MARK: - Preview + +#if DEBUG +#Preview { + StatusMenu() + .environmentObject(AppState()) +} +#endif diff --git a/mrmouse/mrmouse/mrmouse/Views/ButtonMappingView.swift b/mrmouse/mrmouse/mrmouse/Views/ButtonMappingView.swift new file mode 100644 index 0000000..8298bc9 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/Views/ButtonMappingView.swift @@ -0,0 +1,264 @@ +import SwiftUI + +/// List of button mappings for the active profile. +/// Each row shows the button name and current action; tapping opens an action picker. +struct ButtonMappingView: View { + @EnvironmentObject private var appState: AppState + + /// Index of the mapping being edited (nil = picker closed) + @State private var editingIndex: Int? = nil + + private var profile: Profile { + appState.configManager.profileForApp(appState.currentApp) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(profile.buttonMappings.indices, id: \.self) { idx in + let mapping = profile.buttonMappings[idx] + + Button { + editingIndex = idx + } label: { + HStack { + Text(buttonName(for: mapping.controlID)) + .font(.subheadline) + Spacer() + Text(actionLabel(mapping.action)) + .font(.caption) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if idx < profile.buttonMappings.count - 1 { + Divider() + } + } + } + .sheet(item: $editingIndex) { idx in + ActionPickerView( + buttonName: buttonName(for: profile.buttonMappings[idx].controlID), + currentAction: profile.buttonMappings[idx].action + ) { newAction in + saveAction(newAction, at: idx) + editingIndex = nil + } onCancel: { + editingIndex = nil + } + } + } + + // MARK: - Helpers + + private func saveAction(_ action: ButtonAction, at index: Int) { + let isGlobal = appState.currentApp == nil + try? appState.configManager.update { config in + if isGlobal { + if index < config.globalProfile.buttonMappings.count { + var mapping = config.globalProfile.buttonMappings[index] + mapping.action = action + mapping.diverted = (action != .default && action != .disabled) + config.globalProfile.buttonMappings[index] = mapping + } + } else if let bundleID = appState.currentApp { + var profile = config.appProfiles[bundleID] ?? config.globalProfile + if index < profile.buttonMappings.count { + var mapping = profile.buttonMappings[index] + mapping.action = action + mapping.diverted = (action != .default && action != .disabled) + profile.buttonMappings[index] = mapping + } + config.appProfiles[bundleID] = profile + } + } + } + + private func buttonName(for controlID: UInt16) -> String { + switch controlID { + case 0x0050: return "Left Click" + case 0x0051: return "Right Click" + case 0x0052: return "Middle Click" + case 0x0053: return "Back" + case 0x0056: return "Forward" + case 0x00C3: return "Gesture Button" + case 0x00C4: return "Smart Shift" + default: return "Button 0x\(String(controlID, radix: 16, uppercase: true))" + } + } + + private func actionLabel(_ action: ButtonAction) -> String { + switch action { + case .default: return "Default" + case .disabled: return "Disabled" + case .gestureButton: return "Gesture" + case .keyboardShortcut(let combo): return keyComboLabel(combo) + case .systemAction(let action): return systemActionLabel(action) + case .openApp(let bundleID): return appName(bundleID: bundleID) + } + } + + private func keyComboLabel(_ combo: KeyCombo) -> String { + var parts: [String] = [] + if combo.modifiers.contains(.control) { parts.append("^") } + if combo.modifiers.contains(.option) { parts.append("⌥") } + if combo.modifiers.contains(.shift) { parts.append("⇧") } + if combo.modifiers.contains(.command) { parts.append("⌘") } + parts.append("Key(\(combo.keyCode))") + return parts.joined() + } + + private func systemActionLabel(_ action: SystemAction) -> String { + switch action { + case .missionControl: return "Mission Control" + case .appExpose: return "App Exposé" + case .switchDesktopLeft: return "Switch Desktop Left" + case .switchDesktopRight:return "Switch Desktop Right" + case .launchpad: return "Launchpad" + case .showDesktop: return "Show Desktop" + case .volumeUp: return "Volume Up" + case .volumeDown: return "Volume Down" + case .mute: return "Mute" + case .playPause: return "Play/Pause" + case .nextTrack: return "Next Track" + case .prevTrack: return "Previous Track" + case .screenshot: return "Screenshot" + case .spotlight: return "Spotlight" + case .lockScreen: return "Lock Screen" + } + } + + private func appName(bundleID: String) -> String { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID)? + .deletingPathExtension().lastPathComponent ?? bundleID + } +} + +// MARK: - Int as Identifiable (for sheet(item:)) + +extension Int: @retroactive Identifiable { + public var id: Int { self } +} + +// MARK: - Action picker sheet + +private struct ActionPickerView: View { + let buttonName: String + let currentAction: ButtonAction + let onSave: (ButtonAction) -> Void + let onCancel: () -> Void + + @State private var selected: ActionKind = .default + + enum ActionKind: String, CaseIterable, Identifiable { + case `default` = "Default" + case systemAction = "System Action" + case disabled = "Disabled" + case openApp = "Open App" + var id: String { rawValue } + } + + @State private var selectedSystemAction: SystemAction = .missionControl + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Assign: \(buttonName)") + .font(.headline) + + Picker("Action", selection: $selected) { + ForEach(ActionKind.allCases) { kind in + Text(kind.rawValue).tag(kind) + } + } + .pickerStyle(.segmented) + + if selected == .systemAction { + Picker("System Action", selection: $selectedSystemAction) { + ForEach(SystemAction.allCases, id: \.self) { action in + Text(systemActionLabel(action)).tag(action) + } + } + .pickerStyle(.menu) + } + + HStack { + Button("Cancel", action: onCancel) + .keyboardShortcut(.cancelAction) + Spacer() + Button("Save") { + onSave(buildAction()) + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + } + } + .padding(20) + .frame(width: 320) + .onAppear { syncFromCurrent() } + } + + private func syncFromCurrent() { + switch currentAction { + case .default: selected = .default + case .disabled: selected = .disabled + case .openApp: selected = .openApp + case .systemAction(let a): selected = .systemAction; selectedSystemAction = a + case .keyboardShortcut, .gestureButton: + selected = .default + } + } + + private func buildAction() -> ButtonAction { + switch selected { + case .default: return .default + case .disabled: return .disabled + case .systemAction: return .systemAction(selectedSystemAction) + case .openApp: return .default // full app picker out of scope here + } + } + + private func systemActionLabel(_ action: SystemAction) -> String { + switch action { + case .missionControl: return "Mission Control" + case .appExpose: return "App Exposé" + case .switchDesktopLeft: return "Switch Desktop Left" + case .switchDesktopRight:return "Switch Desktop Right" + case .launchpad: return "Launchpad" + case .showDesktop: return "Show Desktop" + case .volumeUp: return "Volume Up" + case .volumeDown: return "Volume Down" + case .mute: return "Mute" + case .playPause: return "Play/Pause" + case .nextTrack: return "Next Track" + case .prevTrack: return "Previous Track" + case .screenshot: return "Screenshot" + case .spotlight: return "Spotlight" + case .lockScreen: return "Lock Screen" + } + } +} + +extension SystemAction: CaseIterable { + public static var allCases: [SystemAction] = [ + .missionControl, .appExpose, .switchDesktopLeft, .switchDesktopRight, + .launchpad, .showDesktop, + .volumeUp, .volumeDown, .mute, .playPause, .nextTrack, .prevTrack, + .screenshot, .spotlight, .lockScreen + ] +} + +// MARK: - Preview + +#if DEBUG +#Preview { + ButtonMappingView() + .environmentObject(AppState()) + .padding() + .frame(width: 288) +} +#endif diff --git a/mrmouse/mrmouse/mrmouse/Views/DPISettingsView.swift b/mrmouse/mrmouse/mrmouse/Views/DPISettingsView.swift new file mode 100644 index 0000000..c5af725 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/Views/DPISettingsView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct DPISettingsView: View { + @EnvironmentObject private var appState: AppState + + private let presets: [Int] = [800, 1000, 1200, 1600, 2400, 4000] + private let dpiRange: ClosedRange = 200...8000 + private let dpiStep: Double = 50 + + @State private var sliderValue: Double = 1000 + + private var currentDPI: Int { + appState.selectedDevice?.currentDPI ?? 1000 + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Sensitivity") + .font(.subheadline) + Spacer() + Text("\(Int(sliderValue)) DPI") + .font(.subheadline) + .monospacedDigit() + .foregroundStyle(.secondary) + } + + Slider( + value: $sliderValue, + in: dpiRange, + step: dpiStep + ) { + EmptyView() + } minimumValueLabel: { + Text("200").font(.caption2).foregroundStyle(.tertiary) + } maximumValueLabel: { + Text("8K").font(.caption2).foregroundStyle(.tertiary) + } + .onChange(of: sliderValue) { + appState.setDPI(Int(sliderValue)) + } + + HStack(spacing: 6) { + ForEach(presets, id: \.self) { preset in + Button { + sliderValue = Double(preset) + appState.setDPI(preset) + } label: { + Text(presetLabel(preset)) + .font(.caption) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(Int(sliderValue) == preset ? .accentColor : nil) + } + } + } + .onAppear { sliderValue = Double(currentDPI) } + .onChange(of: currentDPI) { sliderValue = Double(currentDPI) } + .onChange(of: appState.selectedDeviceID) { sliderValue = Double(currentDPI) } + } + + private func presetLabel(_ dpi: Int) -> String { + dpi >= 1000 ? "\(dpi / 1000)K" : "\(dpi)" + } +} diff --git a/mrmouse/mrmouse/mrmouse/Views/DeviceStatusView.swift b/mrmouse/mrmouse/mrmouse/Views/DeviceStatusView.swift new file mode 100644 index 0000000..d1c6ca6 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/Views/DeviceStatusView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +struct DeviceStatusView: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if appState.devices.isEmpty { + HStack(spacing: 6) { + Circle() + .fill(Color.red) + .frame(width: 8, height: 8) + Text("No devices connected") + .font(.headline) + } + } else { + // Device picker (if multiple) + if appState.devices.count > 1 { + Picker("Device", selection: Binding( + get: { appState.selectedDeviceID ?? appState.devices.first?.id ?? "" }, + set: { appState.selectedDeviceID = $0 } + )) { + ForEach(appState.devices) { device in + Text("\(device.name) (\(device.transport.rawValue))") + .tag(device.id) + } + } + .pickerStyle(.menu) + } + + // Selected device info + if let device = appState.selectedDevice { + HStack(spacing: 6) { + Circle() + .fill(Color.green) + .frame(width: 8, height: 8) + Text("Connected") + .font(.headline) + Spacer() + } + + HStack { + Image(systemName: "computermouse.fill") + .foregroundStyle(.secondary) + .frame(width: 16) + Text(device.name) + .font(.subheadline) + Text("via \(device.transport.rawValue)") + .font(.caption) + .foregroundStyle(.tertiary) + if let fw = device.firmwareVersion { + Text("(\(fw))") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + + if let level = device.batteryLevel { + HStack(spacing: 6) { + Image(systemName: batteryIconName(level: level, charging: device.batteryCharging)) + .foregroundStyle(batteryColor(level: level)) + .frame(width: 16) + Text("\(level)%") + .font(.subheadline) + if device.batteryCharging { + Text("Charging") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + } + + private func batteryIconName(level: Int, charging: Bool) -> String { + if charging { return "battery.100.bolt" } + switch level { + case 88...: return "battery.100" + case 63...: return "battery.75" + case 38...: return "battery.50" + case 13...: return "battery.25" + default: return "battery.0" + } + } + + private func batteryColor(level: Int) -> Color { + switch level { + case 21...: return .primary + case 11...: return .yellow + default: return .red + } + } +} diff --git a/mrmouse/mrmouse/mrmouse/Views/ProfilesView.swift b/mrmouse/mrmouse/mrmouse/Views/ProfilesView.swift new file mode 100644 index 0000000..b89a8f9 --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/Views/ProfilesView.swift @@ -0,0 +1,251 @@ +import SwiftUI +import AppKit + +// MARK: - Model + +struct AppProfileEntry: Identifiable { + let bundleID: String + let name: String + var id: String { bundleID } +} + +/// Per-app profiles list. Add profiles from running apps; delete them. +/// Each entry is keyed by bundle ID and overrides button mappings. +struct ProfilesView: View { + @EnvironmentObject private var appState: AppState + @State private var showingAppPicker = false + @State private var selectedBundleID: String? = nil + + private var appProfiles: [AppProfileEntry] { + appState.configManager.config.appProfiles.keys + .sorted() + .map { AppProfileEntry(bundleID: $0, name: appDisplayName($0)) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + if appProfiles.isEmpty { + Text("No per-app profiles. Add one to override button mappings for a specific app.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 8) + } else { + profileListRows + } + + HStack(spacing: 10) { + Button { + showingAppPicker = true + } label: { + Label("Add Profile", systemImage: "plus") + .font(.subheadline) + } + .buttonStyle(.bordered) + .controlSize(.small) + + if let id = selectedBundleID { + Button(role: .destructive) { + deleteProfile(id) + } label: { + Label("Remove", systemImage: "trash") + .font(.subheadline) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.top, 6) + } + .sheet(isPresented: $showingAppPicker) { + AppPickerSheet { bundleID in + addProfile(bundleID: bundleID) + showingAppPicker = false + } onCancel: { + showingAppPicker = false + } + } + } + + // MARK: - Profile list rows + + @ViewBuilder + private var profileListRows: some View { + let profiles: [AppProfileEntry] = appProfiles + ForEach(profiles, id: \.id, content: profileRow(entry:)) + } + + @ViewBuilder + private func profileRow(entry: AppProfileEntry) -> some View { + Button { + selectedBundleID = entry.bundleID + } label: { + HStack(spacing: 8) { + appIcon(bundleID: entry.bundleID) + .frame(width: 20, height: 20) + Text(entry.name) + .font(.subheadline) + Spacer() + if selectedBundleID == entry.bundleID { + Image(systemName: "checkmark") + .font(.caption) + .foregroundStyle(Color.accentColor) + } + } + .padding(.vertical, 5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Divider() + } + + // MARK: - Mutations + + private func addProfile(bundleID: String) { + try? appState.configManager.update { config in + if config.appProfiles[bundleID] == nil { + config.appProfiles[bundleID] = config.globalProfile + } + } + selectedBundleID = bundleID + } + + private func deleteProfile(_ bundleID: String) { + try? appState.configManager.update { config in + config.appProfiles.removeValue(forKey: bundleID) + } + if selectedBundleID == bundleID { + selectedBundleID = nil + } + } + + // MARK: - Helpers + + private func appDisplayName(_ bundleID: String) -> String { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID)? + .deletingPathExtension().lastPathComponent ?? bundleID + } + + private func appIcon(bundleID: String) -> some View { + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) + let nsImage = url.flatMap { NSWorkspace.shared.icon(forFile: $0.path) } + return Group { + if let img = nsImage { + Image(nsImage: img) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "app.fill") + .foregroundStyle(.secondary) + } + } + } +} + +// MARK: - App picker sheet + +private struct RunningAppEntry: Identifiable { + let bundleID: String + let name: String + var id: String { bundleID } +} + +private struct AppPickerSheet: View { + let onSelect: (String) -> Void + let onCancel: () -> Void + + @State private var runningApps: [RunningAppEntry] = [] + @State private var searchText: String = "" + + private var filtered: [RunningAppEntry] { + guard !searchText.isEmpty else { return runningApps } + let q = searchText.lowercased() + return runningApps.filter { + $0.name.lowercased().contains(q) || $0.bundleID.lowercased().contains(q) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Select App") + .font(.headline) + + TextField("Search", text: $searchText) + .textFieldStyle(.roundedBorder) + + List(filtered) { app in + Button { + onSelect(app.bundleID) + } label: { + HStack(spacing: 8) { + appIcon(bundleID: app.bundleID) + .frame(width: 20, height: 20) + VStack(alignment: .leading, spacing: 1) { + Text(app.name).font(.subheadline) + Text(app.bundleID) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .listStyle(.bordered(alternatesRowBackgrounds: true)) + .frame(height: 240) + + HStack { + Button("Cancel", action: onCancel) + .keyboardShortcut(.cancelAction) + Spacer() + } + } + .padding(20) + .frame(width: 360) + .onAppear { loadRunningApps() } + } + + private func loadRunningApps() { + runningApps = NSWorkspace.shared.runningApplications + .compactMap { app -> RunningAppEntry? in + guard + app.activationPolicy == .regular, + let id = app.bundleIdentifier, + let name = app.localizedName + else { return nil } + return RunningAppEntry(bundleID: id, name: name) + } + .sorted { $0.name < $1.name } + } + + private func appIcon(bundleID: String) -> some View { + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) + let nsImage = url.flatMap { NSWorkspace.shared.icon(forFile: $0.path) } + return Group { + if let img = nsImage { + Image(nsImage: img) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "app.fill") + .foregroundStyle(.secondary) + } + } + } +} + +// MARK: - Preview + +#if DEBUG +#Preview { + ProfilesView() + .environmentObject({ + let s = AppState() + return s + }()) + .padding() + .frame(width: 288) +} +#endif diff --git a/mrmouse/mrmouse/mrmouse/Views/ScrollSettingsView.swift b/mrmouse/mrmouse/mrmouse/Views/ScrollSettingsView.swift new file mode 100644 index 0000000..acf188a --- /dev/null +++ b/mrmouse/mrmouse/mrmouse/Views/ScrollSettingsView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct ScrollSettingsView: View { + @EnvironmentObject private var appState: AppState + + @State private var smartShiftEnabled: Bool = true + @State private var smartShiftThreshold: Double = 30 + @State private var hiResScrollEnabled: Bool = true + @State private var scrollInverted: Bool = false + @State private var thumbWheelInverted: Bool = false + + private var deviceKey: String? { + guard let dev = appState.selectedDevice else { return nil } + return dev.hidppDevice.info.serialNumber ?? dev.name + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Toggle("Smart Shift", isOn: $smartShiftEnabled) + .font(.subheadline) + .onChange(of: smartShiftEnabled) { commit() } + + if smartShiftEnabled { + HStack { + Text("Threshold") + .font(.caption) + .foregroundStyle(.secondary) + Slider(value: $smartShiftThreshold, in: 1...255, step: 1) + .onChange(of: smartShiftThreshold) { commit() } + Text("\(Int(smartShiftThreshold))") + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 28, alignment: .trailing) + } + } + } + + Divider() + + Toggle("High-Resolution Scrolling", isOn: $hiResScrollEnabled) + .font(.subheadline) + .onChange(of: hiResScrollEnabled) { commit() } + + Divider() + + Toggle("Reverse Scroll Direction", isOn: $scrollInverted) + .font(.subheadline) + .onChange(of: scrollInverted) { commit() } + + Toggle("Reverse Thumb Wheel", isOn: $thumbWheelInverted) + .font(.subheadline) + .onChange(of: thumbWheelInverted) { commit() } + } + .onAppear { loadFromConfig() } + .onChange(of: appState.selectedDeviceID) { loadFromConfig() } + } + + private func loadFromConfig() { + guard let key = deviceKey else { return } + let dev = appState.configManager.deviceConfig(for: key) + smartShiftEnabled = dev.smartShiftEnabled + smartShiftThreshold = Double(dev.smartShiftThreshold) + hiResScrollEnabled = dev.hiResScrollEnabled + scrollInverted = dev.scrollInverted + thumbWheelInverted = dev.thumbWheelInverted + } + + private func commit() { + guard let key = deviceKey else { return } + let enabled = smartShiftEnabled + let threshold = Int(smartShiftThreshold) + try? appState.configManager.update { config in + var dev = config.devices[key] ?? DeviceConfig() + dev.smartShiftEnabled = enabled + dev.smartShiftThreshold = threshold + dev.hiResScrollEnabled = hiResScrollEnabled + dev.scrollInverted = scrollInverted + dev.thumbWheelInverted = thumbWheelInverted + config.devices[key] = dev + } + appState.setSmartShift(enabled: enabled, threshold: threshold) + } +} diff --git a/windo/.gitignore b/windo/.gitignore new file mode 100644 index 0000000..019acd5 --- /dev/null +++ b/windo/.gitignore @@ -0,0 +1,10 @@ +# XcodeGen generates this from project.yml +Windo.xcodeproj/ + +# Build output +build/ +DerivedData/ +*.dmg +*.app + +.DS_Store diff --git a/windo/ExportOptions.plist b/windo/ExportOptions.plist new file mode 100644 index 0000000..4f1750a --- /dev/null +++ b/windo/ExportOptions.plist @@ -0,0 +1,9 @@ + + + + + method developer-id + teamID F2J8ZU2NQJ + signingStyle manual + + diff --git a/windo/LICENSE b/windo/LICENSE new file mode 100644 index 0000000..8e4d52f --- /dev/null +++ b/windo/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zackery 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/windo/README.md b/windo/README.md new file mode 100644 index 0000000..57d7486 --- /dev/null +++ b/windo/README.md @@ -0,0 +1,76 @@ +

+ + Windo + +

+ +

+ + Download the latest Windo.dmg + + + Latest release + + + License + + + Stars + +

+ +A floating, always-on-top web window for macOS — watch YouTube, live sports, or +any non-DRM web video on top of other apps, including over full-screen apps. + +A menu-bar utility (no Dock icon). Liquid Glass control pill, ambient title bar +that tints to the video, global hotkey, favorites, opacity, and compact mode. + +## Install + +Grab the latest `.dmg` from [Releases](https://github.com/zackbart/windo/releases/latest), or install via Homebrew: + +```bash +brew install --cask zackbart/tap/windo +``` + +Requires macOS 26 (Tahoe) — uses the native Liquid Glass APIs. + +## Develop + +```bash +brew install xcodegen +xcodegen generate && open Windo.xcodeproj # then ⌘R +``` + +`project.yml` is the source of truth; `Windo.xcodeproj` is generated and gitignored. +All app code is one file: `Sources/main.swift` (AppKit + WKWebView, no dependencies). + +## Shortcuts + +| Action | Key | +|---|---| +| Show/hide from anywhere | ⌥⌘W | +| Focus URL | ⌘L | +| Reload | ⌘R | +| Add to favorites | ⌘D | +| Compact mode | ⌘. | + +Drag the title bar to move; hold ⌥ and drag anywhere as a backup. The control +pill (bottom-left) expands on hover. + +## Releasing + +Tag-driven. Bump `MARKETING_VERSION` in `project.yml`, commit, then: + +```bash +git tag windo-v0.1.7 +git push origin windo-v0.1.7 +``` + +The repository release workflow builds on `macos-26`, signs with Developer ID, +notarizes + staples, publishes a `Windo-.dmg` to GitHub Releases, and +bumps the Homebrew cask in `zackbart/homebrew-tap`. + +## License + +MIT diff --git a/windo/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/windo/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..74b3caf --- /dev/null +++ b/windo/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/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..7318df2 Binary files /dev/null and b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 0000000..a0586ac Binary files /dev/null and b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 0000000..0c8a4da Binary files /dev/null and b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 0000000..051f2c2 Binary files /dev/null and b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 0000000..dd079e6 Binary files /dev/null and b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 0000000..e26e60a Binary files /dev/null and b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png new file mode 100644 index 0000000..f73847a Binary files /dev/null and b/windo/Sources/Assets.xcassets/AppIcon.appiconset/icon_64.png differ diff --git a/windo/Sources/Assets.xcassets/Contents.json b/windo/Sources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..d8b757a --- /dev/null +++ b/windo/Sources/Assets.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/windo/Sources/main.swift b/windo/Sources/main.swift new file mode 100644 index 0000000..74f81c2 --- /dev/null +++ b/windo/Sources/main.swift @@ -0,0 +1,909 @@ +import Cocoa +import WebKit +import Carbon.HIToolbox +import CoreImage +import CoreLocation + +private let ciContext = CIContext(options: nil) + +extension NSImage { + // Average color of the top `frac` of the image (the strip just under the title bar). + func topAverageColor(_ frac: CGFloat) -> NSColor? { + guard let cg = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil } + let ci = CIImage(cgImage: cg) + let e = ci.extent + let region = CGRect(x: e.minX, y: e.maxY - e.height * frac, width: e.width, height: e.height * frac) + guard let f = CIFilter(name: "CIAreaAverage", + parameters: [kCIInputImageKey: ci, kCIInputExtentKey: CIVector(cgRect: region)]), + let out = f.outputImage else { return nil } + var px = [UInt8](repeating: 0, count: 4) + ciContext.render(out, toBitmap: &px, rowBytes: 4, + bounds: CGRect(x: 0, y: 0, width: 1, height: 1), + format: .RGBA8, colorSpace: CGColorSpaceCreateDeviceRGB()) + return NSColor(srgbRed: CGFloat(px[0]) / 255, green: CGFloat(px[1]) / 255, + blue: CGFloat(px[2]) / 255, alpha: 1) + } +} + +func lerp(_ a: NSColor, _ b: NSColor, _ t: CGFloat) -> NSColor { + guard let a = a.usingColorSpace(.sRGB), let b = b.usingColorSpace(.sRGB) else { return b } + return NSColor(srgbRed: a.redComponent + (b.redComponent - a.redComponent) * t, + green: a.greenComponent + (b.greenComponent - a.greenComponent) * t, + blue: a.blueComponent + (b.blueComponent - a.blueComponent) * t, alpha: 1) +} + +// Windo — floating web window that stays on top of everything, including +// other apps' native fullscreen. Menu-bar utility, no Dock icon. +// Controls live in a floating Liquid Glass bar that collapses to a pill. +// ponytail: one file, AppKit + WKWebView, no deps. State in UserDefaults. + +let kDefaultURL = "https://www.youtube.com" +let kUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15" + +extension Notification.Name { static let windoToggle = Notification.Name("windoToggle") } + +// Make the page's Fullscreen API fill the webview (= the Windo window) instead of +// the screen. We replace requestFullscreen/exitFullscreen with CSS that pins the +// element to the viewport, and fake fullscreenElement + fullscreenchange so sites +// like YouTube believe the request succeeded. +let kFullscreenShim = """ +(function () { + if (window.__windoFS) return; window.__windoFS = true; + var d = document, E = Element.prototype, cur = null; + function def(name, getter) { + try { Object.defineProperty(d, name, { get: getter, configurable: true }); } catch (e) {} + } + // Fake the Fullscreen API so the page believes it entered fullscreen; YouTube + // calls requestFullscreen on and then resizes its own player to fill the + // window. We don't reparent or hard-size anything — just report state + fire the + // event, and let the site lay itself out. + def('fullscreenElement', function () { return cur; }); + def('webkitFullscreenElement', function () { return cur; }); + def('webkitCurrentFullScreenElement', function () { return cur; }); + def('fullscreenEnabled', function () { return true; }); + def('webkitFullscreenEnabled', function () { return true; }); + def('webkitIsFullScreen', function () { return !!cur; }); // YouTube checks this one + function post(o) { try { window.webkit.messageHandlers.windo.postMessage(JSON.stringify(o)); } catch (e) {} } + function fire() { + setTimeout(function () { // native dispatches async; let listeners attach first + ['fullscreenchange','webkitfullscreenchange'].forEach(function (n) { d.dispatchEvent(new Event(n)); }); + }, 0); + } + function enter(el) { if (cur || !el) return; cur = el; el.classList && el.classList.add('windo-fs'); fire(); post({fs:true}); } + function leave() { if (!cur) return; cur.classList && cur.classList.remove('windo-fs'); cur = null; fire(); post({fs:false}); } + E.requestFullscreen = function () { enter(this); return Promise.resolve(); }; + E.webkitRequestFullscreen = E.webkitRequestFullScreen = function () { enter(this); }; + d.exitFullscreen = function () { leave(); return Promise.resolve(); }; + d.webkitExitFullscreen = function () { leave(); }; + d.addEventListener('keydown', function (e) { if (e.key === 'Escape' && cur) leave(); }, true); + // Size with viewport units, not %, so the video doesn't collapse to 0 height + // against YouTube's unsized (faked-fullscreen) player container. object-fit + // keeps aspect / letterboxes against the black root. + var css = '.windo-fs{position:fixed!important;inset:0!important;width:100vw!important;height:100vh!important;' + + 'max-width:none!important;max-height:none!important;margin:0!important;overflow:hidden!important;background:#000!important;}' + + '.windo-fs #movie_player,.windo-fs .html5-video-player,.windo-fs .html5-video-container{' + + 'left:0!important;top:0!important;width:100vw!important;height:100vh!important;max-width:none!important;max-height:none!important;}' + + '.windo-fs video{position:absolute!important;left:0!important;top:0!important;width:100vw!important;height:100vh!important;' + + 'max-width:none!important;max-height:none!important;object-fit:contain!important;transform:none!important;}'; + var s = d.createElement('style'); s.textContent = css; + (d.head || d.documentElement).appendChild(s); +})(); +""" + +// WKWebView's built-in geolocation is unreliable on macOS (needs private UI-delegate +// SPI and often just denies). So we shim navigator.geolocation the same way we shim +// fullscreen: override the JS methods, post requests to native (GeoBridge answers with +// a real CLLocationManager), and resolve back through window.__windoGeo* callbacks. +// Sites like YouTube TV that gate content on location now get a real fix. +let kGeoShim = """ +(function () { + if (window.__windoGeo) return; window.__windoGeo = true; + var cbs = {}, next = 1; + function post(o) { try { window.webkit.messageHandlers.windoGeo.postMessage(o); } catch (e) {} } + var g = navigator.geolocation; + if (!g) { g = {}; try { navigator.geolocation = g; } catch (e) {} } + // Own properties shadow the (non-writable) prototype methods — reliable as long + // as the instance isn't frozen, which it isn't. + g.getCurrentPosition = function (ok, err, options) { + var id = next++; cbs[id] = { ok: ok, err: err, watch: false }; + post({ id: id, watch: false, options: options || {} }); + }; + g.watchPosition = function (ok, err, options) { + var id = next++; cbs[id] = { ok: ok, err: err, watch: true }; + post({ id: id, watch: true, options: options || {} }); + return id; + }; + g.clearWatch = function (id) { post({ clear: id }); delete cbs[id]; }; + window.__windoGeoResolve = function (id, coords) { + var cb = cbs[id]; if (!cb) return; + cb.ok({ coords: coords, timestamp: Date.now() }); + if (!cb.watch) delete cbs[id]; // one-shot: native stops after the first fix + }; + window.__windoGeoReject = function (id, code, message) { + var cb = cbs[id]; if (!cb) return; + delete cbs[id]; + if (cb.err) cb.err({ code: code, message: message, PERMISSION_DENIED: 1, POSITION_UNAVAILABLE: 2, TIMEOUT: 3 }); + }; +})(); +""" + +// Bridges the page's navigator.geolocation (via kGeoShim) to macOS CoreLocation. +// One shared CLLocationManager serves every tab; requests carry the WKWebView they +// came from so replies land in the right page. macOS shows the location-consent +// prompt on first use (needs NSLocation*UsageDescription in Info.plist). +final class GeoBridge: NSObject, CLLocationManagerDelegate, WKScriptMessageHandler { + private let manager = CLLocationManager() + private struct Req { weak var webView: WKWebView?; let id: Int; let watch: Bool } + private var pending: [Req] = [] + private var updating = false + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + + func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) { + guard m.name == "windoGeo", let body = m.body as? [String: Any] else { return } + if let clearId = body["clear"] as? Int { // clearWatch() + pending.removeAll { $0.id == clearId } + stopIfIdle() + return + } + guard let id = body["id"] as? Int, let wv = m.webView else { return } + pending.append(Req(webView: wv, id: id, watch: (body["watch"] as? Bool) ?? false)) + switch manager.authorizationStatus { + case .notDetermined: + manager.requestWhenInUseAuthorization() // prompt; resolved in didChangeAuthorization + case .authorized, .authorizedAlways, .authorizedWhenInUse: + startUpdating() + case .denied, .restricted: + fail(code: 1, message: "User denied Geolocation") + @unknown default: + fail(code: 2, message: "Location unavailable") + } + } + + private func startUpdating() { + guard !updating, !pending.isEmpty else { return } + updating = true + manager.startUpdatingLocation() + } + private func stopIfIdle() { + if pending.isEmpty { manager.stopUpdatingLocation(); updating = false } + } + + func locationManagerDidChangeAuthorization(_ m: CLLocationManager) { + switch m.authorizationStatus { + case .authorized, .authorizedAlways, .authorizedWhenInUse: startUpdating() + case .denied, .restricted: fail(code: 1, message: "User denied Geolocation") + default: break + } + } + + func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) { + guard let loc = locs.last else { return } + let c = loc.coordinate + // Negative course/speed mean "invalid" in CoreLocation → null in the JS coords. + let coords = "{latitude:\(c.latitude),longitude:\(c.longitude),accuracy:\(loc.horizontalAccuracy)," + + "altitude:\(loc.altitude),altitudeAccuracy:\(loc.verticalAccuracy)," + + "heading:\(loc.course >= 0 ? String(loc.course) : "null")," + + "speed:\(loc.speed >= 0 ? String(loc.speed) : "null")}" + for req in pending { + req.webView?.evaluateJavaScript("window.__windoGeoResolve(\(req.id), \(coords))") + } + pending.removeAll { !$0.watch } // one-shots done; watches keep streaming + stopIfIdle() + } + + func locationManager(_ m: CLLocationManager, didFailWithError e: Error) { + fail(code: 2, message: e.localizedDescription) // POSITION_UNAVAILABLE + } + + // JS PositionError codes: 1 PERMISSION_DENIED, 2 POSITION_UNAVAILABLE, 3 TIMEOUT. + private func fail(code: Int, message: String) { + let msg = jsString(message) + for req in pending { + req.webView?.evaluateJavaScript("window.__windoGeoReject(\(req.id), \(code), \(msg))") + } + pending.removeAll() + stopIfIdle() + } + + private func jsString(_ s: String) -> String { + "\"" + s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: " ") + "\"" + } +} + +struct Favorite: Codable { var name: String; var url: String } + +// One browser tab = one WKWebView. Title KVO keeps the tab label live. +final class Tab { + let webView: WKWebView + var titleObs: NSKeyValueObservation? + init(webView: WKWebView) { self.webView = webView } +} + +// Transparent overlay over the webview: hold Option to drag the window from +// anywhere (the page itself otherwise swallows drag events, e.g. Plex). +final class DragOverlay: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + NSEvent.modifierFlags.contains(.option) ? self : nil + } + override func mouseDown(with e: NSEvent) { window?.performDrag(with: e) } + override var mouseDownCanMoveWindow: Bool { true } +} + +// Icon button: dim when idle, brightens with a soft fill on hover, deeper on press. +final class HoverButton: NSButton { + private var hover: NSTrackingArea? + var idleTint: NSColor = .secondaryLabelColor + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let h = hover { removeTrackingArea(h) } + let h = NSTrackingArea(rect: bounds, options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect], owner: self) + addTrackingArea(h); hover = h + } + override func mouseEntered(with e: NSEvent) { paint(fill: 0.10, bright: true) } + override func mouseExited(with e: NSEvent) { paint(fill: 0, bright: false) } + override func mouseDown(with e: NSEvent) { + paint(fill: 0.18, bright: true) + super.mouseDown(with: e) // blocks until mouseUp + let inside = bounds.contains(convert(window?.mouseLocationOutsideOfEventStream ?? .zero, from: nil)) + paint(fill: inside ? 0.10 : 0, bright: inside) + } + private func paint(fill: CGFloat, bright: Bool) { + wantsLayer = true + layer?.cornerRadius = 8 + NSAnimationContext.runAnimationGroup { c in + c.duration = 0.18 + c.timingFunction = CAMediaTimingFunction(name: .easeOut) + c.allowsImplicitAnimation = true + layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(fill).cgColor + animator().contentTintColor = bright ? .labelColor : idleTint // icons "light up" + } + } +} + +// Floating Liquid Glass control bar in the bottom-left. A fixed window-glyph +// handle on the left; the bar grows from it on hover. Grab the handle to drag. +final class GlassBar: NSView { + private let glass = NSGlassEffectView() + private let content = NSView() + private let icon = NSImageView() + private let controls: NSStackView + private let row: NSStackView + private var widthC: NSLayoutConstraint! + private let collapsedW: CGFloat = 40 + private let minExpandedW: CGFloat + private let barH: CGFloat = 38 + private(set) var expanded = false + + // Expanded width fits the controls (tabs make this variable), never below the minimum. + private var expandedW: CGFloat { max(minExpandedW, 11 + row.fittingSize.width + 14) } + + init(controls ctrls: [NSView], expandedWidth: CGFloat) { + controls = NSStackView(views: ctrls) + minExpandedW = expandedWidth + row = NSStackView(views: []) + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + + glass.cornerRadius = barH / 2 + glass.translatesAutoresizingMaskIntoConstraints = false + addSubview(glass) + + icon.image = NSImage(systemSymbolName: "square.split.2x2", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 13, weight: .medium)) + icon.contentTintColor = .secondaryLabelColor + icon.translatesAutoresizingMaskIntoConstraints = false + + controls.orientation = .horizontal + controls.spacing = 5 + controls.translatesAutoresizingMaskIntoConstraints = false + controls.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + controls.alphaValue = 0 + + row.setViews([icon, controls], in: .leading) + row.orientation = .horizontal + row.spacing = 6 + row.translatesAutoresizingMaskIntoConstraints = false + content.wantsLayer = true + content.layer?.cornerRadius = barH / 2 + content.layer?.masksToBounds = true // rounded clip, no square corners + content.addSubview(row) + glass.contentView = content + + widthC = widthAnchor.constraint(equalToConstant: collapsedW) + NSLayoutConstraint.activate([ + widthC, + heightAnchor.constraint(equalToConstant: barH), + glass.leadingAnchor.constraint(equalTo: leadingAnchor), + glass.trailingAnchor.constraint(equalTo: trailingAnchor), + glass.topAnchor.constraint(equalTo: topAnchor), + glass.bottomAnchor.constraint(equalTo: bottomAnchor), + row.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 11), + row.centerYAnchor.constraint(equalTo: content.centerYAnchor), + icon.widthAnchor.constraint(equalToConstant: 18), + ]) + } + required init?(coder: NSCoder) { fatalError() } + + func setExpanded(_ on: Bool) { + guard on != expanded else { return } + expanded = on + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = on ? 0.34 : 0.26 // open slower, close quicker + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + ctx.allowsImplicitAnimation = true + widthC.animator().constant = on ? expandedW : collapsedW + controls.animator().alphaValue = on ? 1 : 0 // controls fade in; handle never moves + icon.animator().contentTintColor = on ? .labelColor : .secondaryLabelColor + superview?.layoutSubtreeIfNeeded() + } + } + + // Tabs changed: regrow to fit the new content while open. + func relayout() { + guard expanded else { return } + layoutSubtreeIfNeeded() + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.2 + ctx.allowsImplicitAnimation = true + widthC.animator().constant = expandedW + superview?.layoutSubtreeIfNeeded() + } + } + + // Buttons/field take their own clicks; the handle and glass drag the window. + override func hitTest(_ point: NSPoint) -> NSView? { + guard let hit = super.hitTest(point) else { return nil } + if hit is NSButton || hit is NSTextField || hit is NSTextView { return hit } + return self + } + override func mouseDown(with e: NSEvent) { window?.performDrag(with: e) } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea(rect: bounds, + options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect], owner: self)) + } + override func mouseEntered(with e: NSEvent) { + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(collapse), object: nil) + setExpanded(true) + } + override func mouseExited(with e: NSEvent) { + if window?.firstResponder is NSText { return } // keep open while typing + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(collapse), object: nil) + perform(#selector(collapse), with: nil, afterDelay: 0.4) + } + @objc private func collapse() { setExpanded(false) } +} + +final class AppDelegate: NSObject, NSApplicationDelegate, NSTextFieldDelegate, WKNavigationDelegate, WKScriptMessageHandler { + var pageFullscreen = false + // The fullscreen shim posts {fs:true/false}. While a video is in (faked) page + // fullscreen, stop the ambient-tint takeSnapshot — snapshotting the hardware + // video layer blanks it (black picture, audio keeps playing). + func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) { + guard m.name == "windo", let s = m.body as? String, + let data = s.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let fs = obj["fs"] as? Bool else { return } + pageFullscreen = fs + } + + let geoBridge = GeoBridge() // shared across tabs; bridges navigator.geolocation → CoreLocation + var window: NSWindow! + var tabs: [Tab] = [] + var activeIndex = 0 + var webView: WKWebView { tabs[activeIndex].webView } // the visible tab + var webContainer: NSView! + var tabBar: NSStackView! + var urlField: NSTextField! + var muteButton: NSButton! + var statusItem: NSStatusItem! + var glassBar: GlassBar! + var hotKeyRef: EventHotKeyRef? + var muted = false + var pinned = true + var compact = false + var opacity: CGFloat = 1.0 + var favorites: [Favorite] = [] + var sampleTimer: Timer? + var lerpTimer: Timer? + var targetColor = NSColor(white: 0.1, alpha: 1) + var currentColor = NSColor(white: 0.1, alpha: 1) + + func applicationDidFinishLaunching(_ note: Notification) { + opacity = CGFloat(UserDefaults.standard.object(forKey: "opacity") as? Double ?? 1.0) + compact = UserDefaults.standard.bool(forKey: "compact") + loadFavorites() + buildWindow() + buildStatusItem() + buildMainMenu() + registerHotKey() + NotificationCenter.default.addObserver(self, selector: #selector(hotKeyToggle), + name: .windoToggle, object: nil) + window.alphaValue = opacity + addTab(url: UserDefaults.standard.string(forKey: "lastURL") ?? kDefaultURL, activate: true) + setCompact(compact) + showWindow() + startTinting() + } + + // MARK: - Ambient titlebar: tint the bar to the video's top-strip color + + func startTinting() { + sampleTimer = Timer.scheduledTimer(withTimeInterval: 0.35, repeats: true) { [weak self] _ in self?.sampleTopColor() } + lerpTimer = Timer.scheduledTimer(withTimeInterval: 0.045, repeats: true) { [weak self] _ in self?.tickTint() } + } + func sampleTopColor() { + guard !compact, !pageFullscreen, window.isVisible, webView.bounds.width > 1 else { return } + let cfg = WKSnapshotConfiguration() + cfg.snapshotWidth = 64 // downscale for speed + webView.takeSnapshot(with: cfg) { [weak self] img, _ in + if let c = img?.topAverageColor(0.18) { self?.targetColor = c } + } + } + func tickTint() { + guard !compact, window.isVisible else { return } + currentColor = lerp(currentColor, targetColor, 0.12) // ease toward the sampled color + window.backgroundColor = currentColor + } + + func applicationShouldTerminateAfterLastWindowClosed(_ s: NSApplication) -> Bool { false } + + // MARK: - Web view + + func makeWebView() -> WKWebView { + let cfg = WKWebViewConfiguration() + cfg.mediaTypesRequiringUserActionForPlayback = [] + cfg.allowsAirPlayForMediaPlayback = true + // Native pref ON so the FS API surface exists and its getters are + // configurable (with it off, YouTube's button does nothing). The shim then + // overrides requestFullscreen and reparents into a clean fixed host so the + // video's compositing layer isn't blacked out. + if #available(macOS 12.3, *) { cfg.preferences.isElementFullscreenEnabled = true } + cfg.userContentController.addUserScript( + WKUserScript(source: kFullscreenShim, injectionTime: .atDocumentStart, forMainFrameOnly: false)) + cfg.userContentController.add(self, name: "windo") // fullscreen state ← shim + cfg.userContentController.addUserScript( + WKUserScript(source: kGeoShim, injectionTime: .atDocumentStart, forMainFrameOnly: false)) + cfg.userContentController.add(geoBridge, name: "windoGeo") // geolocation ← shim + let wv = WKWebView(frame: webContainer.bounds, configuration: cfg) + wv.autoresizingMask = [.width, .height] + wv.customUserAgent = kUserAgent + wv.allowsBackForwardNavigationGestures = true + wv.navigationDelegate = self + if #available(macOS 13.3, *) { wv.isInspectable = true } + return wv + } + + // MARK: - Tabs + + @objc func newTab() { addTab(url: kDefaultURL, activate: true) } + @objc func closeActiveTab() { closeTab(activeIndex) } + @objc func selectTabAction(_ s: NSButton) { selectTab(s.tag) } + @objc func closeTabAction(_ s: NSButton) { closeTab(s.tag) } + + func addTab(url: String, activate: Bool) { + let wv = makeWebView() + wv.isHidden = true + webContainer.addSubview(wv, positioned: .below, relativeTo: nil) + let tab = Tab(webView: wv) + tab.titleObs = wv.observe(\.title) { [weak self] _, _ in self?.refreshTabBar() } + tabs.append(tab) + if activate { selectTab(tabs.count - 1) } else { refreshTabBar() } + wv.load(URLRequest(url: normalizedURL(url) ?? URL(string: kDefaultURL)!)) + } + + func selectTab(_ i: Int) { + guard tabs.indices.contains(i) else { return } + activeIndex = i + for (j, t) in tabs.enumerated() { t.webView.isHidden = (j != i) } + urlField.stringValue = webView.url?.absoluteString ?? "" + if muted { applyMute() } // carry mute state onto the now-visible tab + refreshTabBar() + } + + func closeTab(_ i: Int) { + guard tabs.count > 1, tabs.indices.contains(i) else { return } // keep one tab alive + tabs[i].titleObs?.invalidate() + tabs[i].webView.removeFromSuperview() + tabs.remove(at: i) + if i < activeIndex { activeIndex -= 1 } + else if i == activeIndex { activeIndex = min(i, tabs.count - 1) } + selectTab(activeIndex) + } + + func refreshTabBar() { + guard tabBar != nil else { return } + tabBar.arrangedSubviews.forEach { $0.removeFromSuperview() } + for (i, t) in tabs.enumerated() { + tabBar.addArrangedSubview(tabItem(index: i, title: t.webView.title, active: i == activeIndex)) + } + let add = button("plus", #selector(newTab)) + add.toolTip = "New Tab" + tabBar.addArrangedSubview(add) + glassBar?.relayout() // grow the bar to fit the new tab count + } + + func tabItem(index: Int, title: String?, active: Bool) -> NSView { + let name = (title?.isEmpty == false) ? title! : "New Tab" + let sel = HoverButton() + sel.isBordered = false + sel.title = name.count > 22 ? String(name.prefix(22)) + "…" : name + sel.font = .systemFont(ofSize: 11, weight: active ? .semibold : .regular) + sel.idleTint = active ? .labelColor : .secondaryLabelColor + sel.contentTintColor = sel.idleTint + sel.tag = index; sel.target = self; sel.action = #selector(selectTabAction(_:)) + + let row = NSStackView() + row.orientation = .horizontal + row.spacing = 2 + row.edgeInsets = NSEdgeInsets(top: 2, left: 8, bottom: 2, right: 4) + row.addArrangedSubview(sel) + if tabs.count > 1 { + let close = HoverButton() + close.isBordered = false + close.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 9, weight: .bold)) + close.idleTint = .secondaryLabelColor + close.contentTintColor = close.idleTint + close.tag = index; close.target = self; close.action = #selector(closeTabAction(_:)) + close.widthAnchor.constraint(equalToConstant: 18).isActive = true + row.addArrangedSubview(close) + } + if active { + row.wantsLayer = true + row.layer?.cornerRadius = 7 + row.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.12).cgColor + } + return row + } + + // MARK: - Window (pure video; controls float on glass) + + func buildWindow() { + let frame = NSRect(x: 0, y: 0, width: 860, height: 500) + window = NSWindow( + contentRect: frame, + styleMask: [.titled, .closable, .miniaturizable, .resizable], // real titlebar = reliable drag + backing: .buffered, defer: false) + window.title = "Windo" + window.titlebarAppearsTransparent = true // titlebar shows the window bg color (our ambient tint) + window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + window.backgroundColor = currentColor + window.setFrameAutosaveName("WindoMain") + window.center() + window.isReleasedWhenClosed = false + + let container = NSView(frame: frame) + webContainer = NSView(frame: container.bounds) // holds all tab webviews + webContainer.autoresizingMask = [.width, .height] + container.addSubview(webContainer) + let overlay = DragOverlay(frame: container.bounds) + overlay.autoresizingMask = [.width, .height] + container.addSubview(overlay) + window.contentView = container + + buildGlassBar(in: container) + applyPin() + } + + func buildGlassBar(in container: NSView) { + let back = button("chevron.left", #selector(goBack)) + let reload = button("arrow.clockwise", #selector(reload)) + muteButton = button("speaker.wave.2.fill", #selector(toggleMute)) + + urlField = NSTextField() + urlField.placeholderString = "URL or search…" + urlField.delegate = self + urlField.bezelStyle = .roundedBezel + urlField.font = .systemFont(ofSize: 12) + urlField.usesSingleLineMode = true // never wrap — scroll horizontally + urlField.lineBreakMode = .byTruncatingHead + urlField.maximumNumberOfLines = 1 + urlField.cell?.wraps = false + urlField.cell?.isScrollable = true + urlField.widthAnchor.constraint(equalToConstant: 160).isActive = true + + tabBar = NSStackView() + tabBar.orientation = .horizontal + tabBar.spacing = 4 + + glassBar = GlassBar(controls: [back, reload, urlField, muteButton, tabBar], expandedWidth: 300) + container.addSubview(glassBar) + NSLayoutConstraint.activate([ + glassBar.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), + glassBar.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16), + ]) + } + + func button(_ symbol: String, _ action: Selector) -> NSButton { + let b = HoverButton() + b.isBordered = false + b.bezelStyle = .regularSquare + b.imageScaling = .scaleProportionallyDown + b.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 13, weight: .medium)) + b.contentTintColor = .labelColor + b.target = self + b.action = action + b.setContentHuggingPriority(.required, for: .horizontal) + b.widthAnchor.constraint(equalToConstant: 26).isActive = true + b.heightAnchor.constraint(equalToConstant: 26).isActive = true + return b + } + + // MARK: - Always-on-top, including over other apps' fullscreen + + func applyPin() { + NSApp.setActivationPolicy(.accessory) // the unlock for floating over fullscreen apps + window.level = pinned ? .floating : .normal + window.collectionBehavior = pinned + ? [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + : [.fullScreenAuxiliary] + } + + // MARK: - Menu-bar item + + func buildStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem.button?.image = NSImage(systemSymbolName: "square.split.2x2", + accessibilityDescription: "Windo") // simple 4-pane window + rebuildMenu() + } + + func rebuildMenu() { + let menu = NSMenu() + let tw = menu.addItem(withTitle: (window?.isVisible ?? false) ? "Hide Window" : "Show Window", + action: #selector(toggleWindow), keyEquivalent: "") + tw.target = self + menu.addItem(.separator()) + + if favorites.isEmpty { + menu.addItem(withTitle: "No favorites yet", action: nil, keyEquivalent: "").isEnabled = false + } else { + for (i, f) in favorites.enumerated() { + let item = menu.addItem(withTitle: f.name, action: #selector(openFavorite(_:)), keyEquivalent: "") + item.target = self; item.tag = i + } + } + menu.addItem(.separator()) + menu.addItem(withTitle: "Add Current Page…", action: #selector(addFavorite), keyEquivalent: "d").target = self + + if !favorites.isEmpty { + let manage = NSMenuItem(title: "Manage Favorites", action: nil, keyEquivalent: "") + let sub = NSMenu() + for (i, f) in favorites.enumerated() { + let rm = sub.addItem(withTitle: "Remove “\(f.name)”", action: #selector(removeFavorite(_:)), keyEquivalent: "") + rm.target = self; rm.tag = i + } + manage.submenu = sub + menu.addItem(manage) + } + menu.addItem(.separator()) + + let c = menu.addItem(withTitle: "Compact Mode", action: #selector(toggleCompact), keyEquivalent: "") + c.target = self; c.state = compact ? .on : .off + menu.addItem(opacityMenuItem()) + + menu.addItem(.separator()) + menu.addItem(withTitle: "Quit Windo", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q") + statusItem.menu = menu + } + + func opacityMenuItem() -> NSMenuItem { + let item = NSMenuItem() + let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 34)) + let label = NSTextField(labelWithString: "Opacity") + label.frame = NSRect(x: 14, y: 8, width: 54, height: 18) + label.font = .systemFont(ofSize: 12) + label.textColor = .secondaryLabelColor + let s = NSSlider(value: Double(opacity), minValue: 0.25, maxValue: 1.0, + target: self, action: #selector(opacityChanged(_:))) + s.frame = NSRect(x: 70, y: 7, width: 116, height: 20) + s.isContinuous = true + container.addSubview(label) + container.addSubview(s) + item.view = container + return item + } + + // MARK: - Favorites persistence + + func loadFavorites() { + if let d = UserDefaults.standard.data(forKey: "favorites"), + let f = try? JSONDecoder().decode([Favorite].self, from: d) { + favorites = f + } else { + favorites = [Favorite(name: "YouTube", url: "https://www.youtube.com")] + } + } + func saveFavorites() { + if let d = try? JSONEncoder().encode(favorites) { + UserDefaults.standard.set(d, forKey: "favorites") + } + rebuildMenu() + } + + @objc func openFavorite(_ s: NSMenuItem) { + guard favorites.indices.contains(s.tag) else { return } + showWindow(); load(favorites[s.tag].url) + } + @objc func removeFavorite(_ s: NSMenuItem) { + guard favorites.indices.contains(s.tag) else { return } + favorites.remove(at: s.tag); saveFavorites() + } + @objc func addFavorite() { + let alert = NSAlert() + alert.messageText = "Add to Favorites" + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24)) + field.stringValue = webView.title?.isEmpty == false ? webView.title! : (webView.url?.host ?? "Page") + alert.accessoryView = field + alert.addButton(withTitle: "Add") + alert.addButton(withTitle: "Cancel") + showWindow() + if alert.runModal() == .alertFirstButtonReturn, let url = webView.url?.absoluteString { + favorites.append(Favorite(name: field.stringValue, url: url)) + saveFavorites() + } + } + + // MARK: - Navigation + + func normalizedURL(_ raw: String) -> URL? { + var s = raw.trimmingCharacters(in: .whitespaces) + if s.isEmpty { return nil } + if !s.contains("://") { + if s.contains(" ") || !s.contains(".") { + s = "https://www.youtube.com/results?search_query=" + + (s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? s) + } else { + s = "https://" + s + } + } + return URL(string: s) + } + + func load(_ raw: String) { + guard let url = normalizedURL(raw) else { return } + webView.load(URLRequest(url: url)) + urlField.stringValue = url.absoluteString + } + + @objc func goBack() { webView.goBack() } + @objc func reload() { webView.reload() } + @objc func focusURL() { + showWindow() + glassBar.setExpanded(true) + window.makeFirstResponder(urlField) + } + // ponytail: JS mute — no public WKWebView mute API. + func applyMute() { + webView.evaluateJavaScript("document.querySelectorAll('video,audio').forEach(m=>m.muted=\(muted))") + } + @objc func toggleMute() { + muted.toggle() + applyMute() + muteButton.image = NSImage(systemSymbolName: muted ? "speaker.slash.fill" : "speaker.wave.2.fill", + accessibilityDescription: nil) + } + @objc func toggleWindow() { + if window.isVisible { window.orderOut(nil) } else { showWindow() } + rebuildMenu() + } + func showWindow() { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + rebuildMenu() + } + + // MARK: - Opacity / compact / global hotkey + + @objc func opacityChanged(_ s: NSSlider) { + opacity = CGFloat(s.doubleValue) + window.alphaValue = opacity + UserDefaults.standard.set(Double(opacity), forKey: "opacity") + } + + @objc func toggleCompact() { setCompact(!compact) } + func setCompact(_ on: Bool) { + compact = on + glassBar?.isHidden = on // glass bar (now incl. tabs) hides in compact + // Compact reclaims the titlebar strip for full-bleed video. + if on { window.styleMask.insert(.fullSizeContentView) } + else { window.styleMask.remove(.fullSizeContentView) } + for b: NSWindow.ButtonType in [.closeButton, .miniaturizeButton, .zoomButton] { + window.standardWindowButton(b)?.isHidden = on + } + UserDefaults.standard.set(on, forKey: "compact") + rebuildMenu() + } + + func registerHotKey() { + let id = EventHotKeyID(signature: 0x57494e44, id: 1) // 'WIND' + var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), + eventKind: OSType(kEventHotKeyPressed)) + InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in + NotificationCenter.default.post(name: .windoToggle, object: nil) + return noErr + }, 1, &spec, nil, nil) + // ⌃⌘H toggles the window from anywhere (global, no permission needed). + // Not ⌥⌘H: that's the system "Hide Others" command and AppKit eats it + // while Windo is the front app. + RegisterEventHotKey(UInt32(kVK_ANSI_H), UInt32(controlKey | cmdKey), + id, GetApplicationEventTarget(), 0, &hotKeyRef) + } + + @objc func hotKeyToggle() { + if window.isVisible && window.isKeyWindow { window.orderOut(nil) } else { showWindow() } + rebuildMenu() + } + + // MARK: - WKNavigationDelegate (remember where we are) + + func webView(_ wv: WKWebView, didFinish nav: WKNavigation!) { + guard let u = wv.url?.absoluteString else { return } + if wv === webView { // only the visible tab drives the URL field + urlField.stringValue = u + UserDefaults.standard.set(u, forKey: "lastURL") + } + refreshTabBar() + } + + // MARK: - URL field + + func control(_ c: NSControl, textView: NSTextView, doCommandBy sel: Selector) -> Bool { + if sel == #selector(NSResponder.insertNewline(_:)) { + load(urlField.stringValue) + window.makeFirstResponder(webView) + glassBar.setExpanded(false) + return true + } + return false + } + + // Minimal main menu so keyboard shortcuts work without a visible menu bar. + func buildMainMenu() { + let main = NSMenu() + let appItem = NSMenuItem() + let appMenu = NSMenu() + appMenu.addItem(withTitle: "New Tab", action: #selector(newTab), keyEquivalent: "t").target = self + appMenu.addItem(withTitle: "Close Tab", action: #selector(closeActiveTab), keyEquivalent: "w").target = self + appMenu.addItem(withTitle: "Focus URL", action: #selector(focusURL), keyEquivalent: "l").target = self + appMenu.addItem(withTitle: "Reload", action: #selector(reload), keyEquivalent: "r").target = self + appMenu.addItem(withTitle: "Add to Favorites", action: #selector(addFavorite), keyEquivalent: "d").target = self + appMenu.addItem(withTitle: "Compact Mode", action: #selector(toggleCompact), keyEquivalent: ".").target = self + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Quit Windo", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q") + appItem.submenu = appMenu + main.addItem(appItem) + + // Edit menu — required for ⌘A/⌘C/⌘X/⌘V/⌘Z to reach the URL field editor. + let editItem = NSMenuItem() + let editMenu = NSMenu(title: "Edit") + editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") + editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Cut", action: Selector(("cut:")), keyEquivalent: "x") + editMenu.addItem(withTitle: "Copy", action: Selector(("copy:")), keyEquivalent: "c") + editMenu.addItem(withTitle: "Paste", action: Selector(("paste:")), keyEquivalent: "v") + editMenu.addItem(withTitle: "Select All", action: Selector(("selectAll:")), keyEquivalent: "a") + editItem.submenu = editMenu + main.addItem(editItem) + + NSApp.mainMenu = main + } +} + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/windo/icon-source.png b/windo/icon-source.png new file mode 100644 index 0000000..c965ca0 Binary files /dev/null and b/windo/icon-source.png differ diff --git a/windo/project.yml b/windo/project.yml new file mode 100644 index 0000000..c0ce87a --- /dev/null +++ b/windo/project.yml @@ -0,0 +1,36 @@ +# XcodeGen project definition — the maintained source of truth. Generate with: +# +# brew install xcodegen +# xcodegen generate && open Windo.xcodeproj +# +# Windo — a floating, always-on-top web window for watching video over other apps. +name: Windo +options: + bundleIdPrefix: dev.zackbart + createIntermediateGroups: true + deploymentTarget: + macOS: "26.0" # NSGlassEffectView (Liquid Glass) requires macOS 26 +targets: + Windo: + type: application + platform: macOS + sources: + - path: Sources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.zackbart.windo + 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: Windo + INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.entertainment" + INFOPLIST_KEY_LSUIElement: YES # menu-bar utility, no Dock icon + INFOPLIST_KEY_NSHumanReadableCopyright: "MIT Licensed" + # Location prompt for sites that need it (e.g. YouTube TV) — bridged to + # CoreLocation by GeoBridge. Non-sandboxed, so the usage string is enough. + INFOPLIST_KEY_NSLocationUsageDescription: "Windo shares your location with sites that request it, like YouTube TV." + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription: "Windo shares your location with sites that request it, like YouTube TV." + ENABLE_HARDENED_RUNTIME: YES # required for notarization + CODE_SIGN_STYLE: Automatic # CI overrides to Manual + Developer ID