Zum Inhalt

tvOS T2a — Editor-Remote Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Checkbox steps. Pure-logic tasks (protocol, framing, apply, throttle) are full TDD on the macOS host; the NWConnection/NWListener/DeviceDiscoveryUI transport + UI are build-verified and confirmed by device sign-off (no simulator can pair two devices).

Goal: The iPhone/iPad full editor discovers an Apple TV running Physarum and mirrors its session (selection + live params + prev/next/cycle) to it over the local network; on disconnect the ATV resumes autonomous T1 cycling.

Architecture: The remote is another source for the same AmbientController sink (plus live param edits via the existing setParams path). Shared Remote/ core (Codable message + pure framing + NWConnection link); tvOS AmbientHost (NWListener) applies messages; iOS/iPad RemoteController observes AppState and sends; discovery is behind a protocol (DeviceDiscoveryUI now, NWBrowser/macOS later).

Tech Stack: Swift 5, Network.framework (NWListener/NWConnection), DeviceDiscoveryUI (iOS/iPadOS), SwiftUI, XCTest (macOS host), XcodeGen.

Spec: docs/superpowers/specs/2026-06-16-tvos-t2a-editor-remote-design.md

File map

  • Create Sources/Remote/RemoteMessage.swift — Codable wire types (shared).
  • Modify Sources/Ambient/ControlIntent.swift — add Codable to ControlIntent.
  • Create Sources/Remote/RemoteFraming.swift — pure length-prefix encode/decode (shared, host-tested).
  • Create Sources/Remote/RemoteLink.swift — NWConnection wrapper over RemoteFraming (shared).
  • Create Sources/Remote/AmbientHost.swift — tvOS NWListener + message-apply (apply host-tested).
  • Create Sources/Remote/RemoteController.swift — iOS/iPad observe-and-send + throttle (throttle host-tested).
  • Create Sources/Remote/RemoteDiscovery.swift — RemoteDiscovering protocol + iOS DeviceDiscoveryUI impl.
  • Modify Sources/TV/TVRootView.swift — own + start AmbientHost.
  • Modify Sources/<iOS RootView> — "Connect to Apple TV" affordance + status + RemoteController.
  • Modify apple/PhysarumApp/project.yml — per-SDK NSLocalNetworkUsageDescription + NSBonjourServices.
  • Tests: Tests/RemoteMessageTests.swift, Tests/RemoteFramingTests.swift, Tests/AmbientHostApplyTests.swift, Tests/RemoteControllerThrottleTests.swift.

Task 1: Wire protocol (RemoteMessage + ControlIntent: Codable)

Files: Create Sources/Remote/RemoteMessage.swift; Modify Sources/Ambient/ControlIntent.swift; Test Tests/RemoteMessageTests.swift.

  • [ ] Step 1: Failing test
import XCTest
import PhysarumEngine
@testable import Physarum

final class RemoteMessageTests: XCTestCase {
    private func roundTrip(_ m: RemoteMessage) throws -> RemoteMessage {
        try JSONDecoder().decode(RemoteMessage.self, from: JSONEncoder().encode(m))
    }
    func testIntentRoundTrips() throws {
        for i in [ControlIntent.next, .previous, .togglePlaylist, .jumpTo("p-1"), .showOverlay, .dismissOverlay] {
            XCTAssertEqual(try roundTrip(.intent(i)), .intent(i))
        }
    }
    func testParamsAndHelloRoundTrip() throws {
        let p = PresetCatalog.loadAll()[0].params
        XCTAssertEqual(try roundTrip(.params(p)), .params(p))
        let state = RemoteState(selectedID: "p-1", params: p, cycling: true)
        XCTAssertEqual(try roundTrip(.hello(state)), .hello(state))
    }
}
  • [ ] Step 2: Run → fails (no RemoteMessage; ControlIntent not Codable).
  • [ ] Step 3: Add Codable to ControlIntent — in ControlIntent.swift, change enum ControlIntent: Equatable to enum ControlIntent: Equatable, Codable (associated String is codable; synthesis works).
  • [ ] Step 4: Create RemoteMessage.swift:
import PhysarumEngine

/// The wire protocol between the iPhone/iPad editor-remote and the ATV host. Codable + Equatable
/// so it round-trips over JSON and is host-testable. `PresetParams` is already Codable.
enum RemoteMessage: Codable, Equatable {
    case hello(RemoteState)      // full sync on connect
    case intent(ControlIntent)  // next / previous / jumpTo / togglePlaylist / show/dismiss overlay
    case params(PresetParams)   // live edited params (throttled by the sender)
}

struct RemoteState: Codable, Equatable {
    let selectedID: String
    let params: PresetParams
    let cycling: Bool
}
  • [ ] Step 5: Run → passes. xcodebuild test … -only-testing:PhysarumAppTests/RemoteMessageTests -destination 'platform=macOS'.
  • [ ] Step 6: Commit feat(remote): Codable RemoteMessage wire protocol + ControlIntent Codable.

Task 2: Pure framing (RemoteFraming)

Files: Create Sources/Remote/RemoteFraming.swift; Test Tests/RemoteFramingTests.swift.

  • [ ] Step 1: Failing test
final class RemoteFramingTests: XCTestCase {
    func testEncodeThenDecodeStreamYieldsMessages() throws {
        let p = PresetCatalog.loadAll()[0].params
        let msgs: [RemoteMessage] = [.hello(.init(selectedID: "a", params: p, cycling: false)),
                                     .intent(.next), .params(p)]
        var stream = Data()
        for m in msgs { stream.append(try RemoteFraming.encode(m)) }
        var decoder = RemoteFraming.Decoder()
        var out: [RemoteMessage] = []
        // feed the stream in two arbitrary chunks to prove buffering across reads
        out += try decoder.consume(stream.prefix(stream.count / 3))
        out += try decoder.consume(stream.suffix(stream.count - stream.count / 3))
        XCTAssertEqual(out, msgs)
    }
}
  • [ ] Step 2: Run → fails.
  • [ ] Step 3: Create RemoteFraming.swift — length-prefixed (big-endian UInt32 + JSON), with a streaming decoder that buffers partial reads:
import Foundation

/// Length-prefixed JSON framing over a TCP byte stream. Pure (no Network.framework) so it's
/// host-testable; `RemoteLink` feeds NWConnection reads into `Decoder.consume`.
enum RemoteFraming {
    static func encode(_ message: RemoteMessage) throws -> Data {
        let body = try JSONEncoder().encode(message)
        var len = UInt32(body.count).bigEndian
        var out = Data(bytes: &len, count: 4)
        out.append(body)
        return out
    }

    struct Decoder {
        private var buffer = Data()
        mutating func consume(_ chunk: Data) throws -> [RemoteMessage] {
            buffer.append(chunk)
            var messages: [RemoteMessage] = []
            while buffer.count >= 4 {
                let len = buffer.prefix(4).reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
                guard buffer.count >= 4 + Int(len) else { break }
                let body = buffer.subdata(in: 4 ..< 4 + Int(len))
                messages.append(try JSONDecoder().decode(RemoteMessage.self, from: body))
                buffer.removeSubrange(0 ..< 4 + Int(len))
            }
            return messages
        }
    }
}
  • [ ] Step 4: Run → passes.
  • [ ] Step 5: Commit feat(remote): pure length-prefixed framing for the remote link.

Task 3: Host apply mapping (AmbientHost apply)

Files: Create Sources/Remote/AmbientHost.swift (apply method first, listener in Task 5); Test Tests/AmbientHostApplyTests.swift.

  • [ ] Step 1: Failing test (the apply mapping is pure-enough to host-test: it drives a real AppState + AmbientController):
final class AmbientHostApplyTests: XCTestCase {
    private func make() -> (AppState, AmbientController) {
        let presets = PresetCatalog.loadAll()
        let s = AppState(); s.select(id: presets[0].id)
        let c = AmbientController(appState: s, playlist: presets, fadeDuration: 0.5, blackHold: 0.2, dwell: 2.0)
        return (s, c)
    }
    func testHelloAdoptsStateParamsAndCycle() {
        let (s, c) = make(); let presets = s.presets
        AmbientHost.apply(.hello(.init(selectedID: presets[2].id, params: presets[2].params, cycling: false)),
                          appState: s, controller: c)
        // selection transitions toward the target; params adopted; cycle off
        c.tick(0.5 + 0.2 + 0.5)
        XCTAssertEqual(s.selectedID, presets[2].id)
        XCTAssertFalse(c.isCycling)
    }
    func testParamsMessageAppliesLiveEdit() {
        let (s, c) = make()
        var edited = s.edited; edited.bloomIntensity += 0.3
        AmbientHost.apply(.params(edited), appState: s, controller: c)
        XCTAssertEqual(s.edited.bloomIntensity, edited.bloomIntensity, accuracy: 1e-6)
    }
    func testDisconnectResumesCycling() {
        let (s, c) = make()
        c.apply(.togglePlaylist)                 // cycling off
        XCTAssertFalse(c.isCycling)
        AmbientHost.handleDisconnect(controller: c)
        XCTAssertTrue(c.isCycling)               // T1 resumes
    }
}
  • [ ] Step 2: Run → fails.
  • [ ] Step 3: Implement the apply mapping in AmbientHost.swift (static, @MainActor):
import PhysarumEngine

enum AmbientHost {
    /// Apply a decoded message to the model. Runs on main (model is main-only).
    @MainActor static func apply(_ message: RemoteMessage, appState: AppState, controller: AmbientController) {
        switch message {
        case .hello(let state):
            controller.adoptRemote(selectedID: state.selectedID, params: state.params, cycling: state.cycling)
        case .intent(let intent):
            controller.apply(intent)
        case .params(let p):
            appState.edited = p
        }
    }
    /// On disconnect the ATV returns to autonomous T1 cycling.
    @MainActor static func handleDisconnect(controller: AmbientController) {
        controller.resumeAutonomousCycle()
    }
}

Add to AmbientController (the only controller change): adoptRemote(selectedID:params:cycling:) — jump to the preset (reuse apply(.jumpTo:)), set appState.edited = params, and set cycling to match (toggle if needed); and resumeAutonomousCycle() — if !isCycling, apply(.togglePlaylist). Both @MainActor/main-only. (Expose minimal API; keep apply the single intent sink.)

  • [ ] Step 4: Run → passes.
  • [ ] Step 5: Commit feat(remote): AmbientHost apply mapping + AmbientController remote-adopt/resume.

Task 4: Sender throttle (RemoteController core)

Files: Create Sources/Remote/RemoteController.swift (the pure send-decision first; link wiring in Task 6); Test Tests/RemoteControllerThrottleTests.swift.

  • [ ] Step 1: Failing test — params are throttled to ~20 Hz, selection/intents pass immediately, unchanged params are dropped:
final class RemoteControllerThrottleTests: XCTestCase {
    func testParamsThrottledSelectionImmediate() {
        var t = RemoteSendGate(minParamInterval: 0.05)
        XCTAssertTrue(t.allowParams(at: 0.00))   // first
        XCTAssertFalse(t.allowParams(at: 0.02))  // < 50ms → drop
        XCTAssertTrue(t.allowParams(at: 0.06))   // >= 50ms → send
        XCTAssertTrue(t.allowSelection())        // selection never throttled
    }
}
  • [ ] Step 2: Run → fails.
  • [ ] Step 3: Implement RemoteSendGate (pure) in RemoteController.swift:
import Foundation

/// Pure throttle decision for the remote sender: params coalesced to `minParamInterval`,
/// selection/intents always allowed. Time is injected so it is host-testable.
struct RemoteSendGate {
    let minParamInterval: TimeInterval
    private var lastParamSend: TimeInterval = -.greatestFiniteMagnitude
    mutating func allowParams(at now: TimeInterval) -> Bool {
        guard now - lastParamSend >= minParamInterval else { return false }
        lastParamSend = now; return true
    }
    func allowSelection() -> Bool { true }
}

(The RemoteController class wrapping a RemoteLink + observing AppState is added in Task 6.)

  • [ ] Step 4: Run → passes.
  • [ ] Step 5: Commit feat(remote): pure send-gate throttle for the editor-remote.

Task 5: Transport + listener + discovery (device-empirical; build-verified)

Files: Create Sources/Remote/RemoteLink.swift, Sources/Remote/RemoteDiscovery.swift; extend Sources/Remote/AmbientHost.swift with the NWListener.

No unit tests (Network.framework/DeviceDiscoveryUI need real devices). Gate: tvOS + iOS builds compile; behavior confirmed in Task 7 device sign-off.

  • [ ] Step 1: RemoteLink — wrap an NWConnection: a dedicated DispatchQueue, send(_ message:) via RemoteFraming.encode, a receive loop feeding RemoteFraming.Decoder.consume and delivering each RemoteMessage on DispatchQueue.main, plus state callbacks (onReady/onFailed). (Full code authored at implementation; uses only Task 1/2 types.)
  • [ ] Step 2: AmbientHost listener — NWListener on _physarum._tcp (NWListener.Service), accept the first connection, build a RemoteLink, route messages through AmbientHost.apply(...), and on cancel/failure call handleDisconnect. Started by TVRootView.
  • [ ] Step 3: RemoteDiscovery — protocol RemoteDiscovering { func present(completion: (NWConnection) -> Void) }; iOS impl wraps DDDevicePickerViewController (DeviceDiscoveryUI) filtered to the Bonjour service, yielding the chosen device's NWConnection. Verify the exact DeviceDiscoveryUI API + required entitlement/Info.plist keys against the current SDK here; fall back to NWBrowser if needed (same RemoteLink core).
  • [ ] Step 4: tvOS-sim + iOS-sim builds compile.
  • [ ] Step 5: Commit feat(remote): NWConnection link + tvOS NWListener host + iOS DeviceDiscoveryUI discovery.

Task 6: Wiring + UI + Info.plist

Files: Modify Sources/TV/TVRootView.swift, the iOS RootView, Sources/Remote/RemoteController.swift, project.yml.

  • [ ] Step 1: TVRootView constructs + starts an AmbientHost (passing its AppState + AmbientController); stops it on disappear.
  • [ ] Step 2: RemoteController (iOS/iPad): holds a RemoteLink + RemoteSendGate; a start(connection:); observes AppState changes (selection/intent → send immediately, edited → send .params when allowParams); a connectionState for the UI; sends .hello on connect.
  • [ ] Step 3: iOS RootView: a "Connect to Apple TV" toolbar/menu entry that runs RemoteDiscovering.present → RemoteController.start; a small connected/disconnected status; disconnect action. Editor unchanged otherwise. (#if os(iOS).)
  • [ ] Step 4: project.yml info.properties (per SDK): NSLocalNetworkUsageDescription (both), NSBonjourServices: ["_physarum._tcp"] (both). Regenerate.
  • [ ] Step 5: tvOS + iOS builds compile; macOS host suite green.
  • [ ] Step 6: Commit feat(remote): wire ATV host into TVRootView + iOS connect UI + Bonjour Info.plist.

Task 7: Gates + device sign-off

  • [ ] Full macOS host suite green (-destination 'platform=macOS'); confirm new tests pass.
  • [ ] tvOS-sim + iOS-sim builds green.
  • [ ] Release deploy to the ATV (Wohnzimmer, id 00008110-000170C80A30A01E) + an iPhone/iPad.
  • [ ] Device sign-off (Johannes, same Wi-Fi): Connect to Apple TV from the phone (DeviceDiscoveryUI picker shows it); selecting a preset / dragging a slider / prev-next / cycle mirrors live to the ATV (crossfade on switch); disconnect → ATV resumes autonomous cycling; reconnect works.

Session progress — 2026-06-16 (host-testable core done + review-hardened)

Tasks 1–4 COMPLETE (TDD, macOS host). Suite: App 208 / Engine 43, all green. 7 commits (b9a6080…27faf56). Tasks 5–7 (transport + UI + Info.plist + device sign-off) remain — device-empirical.

Deviations from this plan (all necessary/sound, found by the Understand pass + an adversarial multi-lens review): - Engine Equatable added to PresetParams + nested SpeciesParams/AudioReactive/AudioRoute (PhysarumEngine/Model/{Preset,Types}.swift). The plan assumed PresetParams was already Equatable; it was Codable, Sendable only, so RemoteMessage: Equatable + the round-trip tests would not compile. Purely additive; distinct from the fuzzy paramsEqual() dirty-check (stays as-is). - RemoteSendGate lives in its own file (not a half-built RemoteController.swift). - AmbientHost.apply is (_:to controller:) (no appState param — all three cases route through the controller sink) and not @MainActor; instead a dispatchPrecondition(.onQueue(.main)) enforces the main-queue contract (matches the codebase's existing pattern; keeps host tests synchronous). - Adopt-params correctness: the crossfade reseed (appState.select at black) resets edited to the preset baseline, so a remote's live params are bound to the Transition struct (adoptParams) and re-applied right after that transition's reseed. setLiveParams() lets a .params edit arriving mid-crossfade supersede a stale hello payload. (Review fixed 3 real bugs here: swipe-back leak, mid-flight-retarget wrong-preset, mid-crossfade clobber — all covered by new tests.) - Framing robustness: RemoteFraming.Decoder advances past each frame before decoding and skips zero-length/undecodable frames (try?) instead of poisoning the stream; an oversized length (> maxFrameBytes 8 MB) throws so the link reconnects. Side benefit: undecodable messages from a version-skewed peer are skipped, not fatal (graceful forward-compat without a handshake).

Task 5 discovery decision — NWBrowser, NOT DeviceDiscoveryUI. Research confirmed DDDevicePickerViewController is built for the opposite direction (a tvOS app browsing for a companion iPhone, app-to-app via NSApplicationServices), and it does not browse arbitrary Bonjour services. T2a wants the phone to browse for the ATV's _physarum._tcp → NWBrowser is the correct, symmetric-to-NWListener, fully-buildable primitive (the plan's own named fallback), needs only NSLocalNetworkUsageDescription + NSBonjourServices, and gives the right phone-initiated UX. Kept behind the RemoteDiscovering protocol so a system-picker variant could swap in later.

Carried to Task 7 sign-off: validate exact Float JSON round-trip on real iOS/tvOS hardware (macOS-only so far).

Tasks 5–6 COMPLETE (device-empirical; build-verified iOS-sim + tvOS-sim + macOS, host suite 208/0)

Transport + UI authored and committed (53ad1ae, 0accd56), then a second adversarial review (transport/UI lens) drove a hardening pass (dc4cb0f): - RemoteLink (NWConnection: frame in/out, main-hop delivery, send-error → cancel), AmbientListener (tvOS NWListener on _physarum._tcp, one connection, replace without a cycle blip, post-start-failure handling), BonjourDiscovery (NWBrowser + DiscoveryState), RemoteController (iOS sender: hello-on-ready, throttled params). - TVRootView starts the listener (idempotent). iOS RootView: "Connect to Apple TV" topbar glyph (green when mirroring) → device picker with searching / 6s-timeout-empty / failure states + "Search again"; onChange(selectedID/edited/autoCycle) forwards to the ATV; picker onDismiss stops the browser. project.yml: NSLocalNetworkUsageDescription + NSBonjourServices. - Cycle semantics: the phone's own auto-cycle toggle now drives the ATV (re-hello, no crossfade).

Deferred (documented, not blocking T2a): prev/next/cycle buttons on iOS (selection via the library already covers navigation; sendIntent infra exists for a future control strip) → fast-follow; a protocol version handshake (framing already skips undecodable frames gracefully, so version skew degrades, doesn't crash); richer connection-status text (icon + picker states suffice for sign-off).

Task 7 — device sign-off checklist (Johannes, ATV 4K + iPhone/iPad, SAME Wi-Fi)

Deploy Release to the ATV (Wohnzimmer 00008110-000170C80A30A01E) + a phone, then: - [ ] Phone: tap the appletv glyph → picker lists the ATV (grant the local-network prompt). If the ATV app isn't open / wrong Wi-Fi → after ~6 s you get "No Apple TVs found", not an endless spinner. - [ ] Pick the ATV → glyph turns green; the ATV crossfades to the phone's current preset + params. - [ ] Drag a slider on the phone → the ATV mirrors the param live (no crossfade, ~20 Hz). - [ ] Select a different preset on the phone → the ATV crossfades to it (structure + params). - [ ] Toggle the phone's auto-cycle → the ATV starts/stops cycling to match. - [ ] Tap the green glyph (or background the phone) to disconnect → the ATV resumes autonomous T1. - [ ] Reconnect works; no "cycling resumed" blip when reconnecting. - [ ] Verify exact Float round-trip on device (a dragged value mirrors pixel-faithfully — macOS-only so far).

Self-Review

Spec coverage: wire protocol → T1; framing → T2; host apply + disconnect-resume → T3; throttle → T4; transport/listener/discovery → T5; wiring/UI/Info.plist → T6; gates+sign-off → T7. All spec sections covered. Placeholders: the DeviceDiscoveryUI exact-API step (T5.3) is a deliberate, SDK-versioned verification with a named fallback (NWBrowser), not a vague TODO. Type consistency: RemoteMessage(.hello/.intent/.params) + RemoteState defined T1, used T2/T3/T6; RemoteFraming.encode/Decoder.consume T2 used T5; AmbientHost.apply/handleDisconnect T3 used T5; AmbientController.adoptRemote/resumeAutonomousCycle T3 used T3; RemoteSendGate.allowParams/allowSelection T4 used T6. Consistent. Note: Network/DeviceDiscoveryUI tasks are intentionally test-light (device-empirical) — the protocol/framing/apply/throttle carry the host coverage.