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— addCodabletoControlIntent. - Create
Sources/Remote/RemoteFraming.swift— pure length-prefix encode/decode (shared, host-tested). - Create
Sources/Remote/RemoteLink.swift—NWConnectionwrapper overRemoteFraming(shared). - Create
Sources/Remote/AmbientHost.swift— tvOSNWListener+ message-apply (apply host-tested). - Create
Sources/Remote/RemoteController.swift— iOS/iPad observe-and-send + throttle (throttle host-tested). - Create
Sources/Remote/RemoteDiscovery.swift—RemoteDiscoveringprotocol + iOS DeviceDiscoveryUI impl. - Modify
Sources/TV/TVRootView.swift— own + startAmbientHost. - Modify
Sources/<iOS RootView>— "Connect to Apple TV" affordance + status +RemoteController. - Modify
apple/PhysarumApp/project.yml— per-SDKNSLocalNetworkUsageDescription+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;ControlIntentnotCodable). - [ ] Step 3: Add
CodabletoControlIntent— inControlIntent.swift, changeenum ControlIntent: Equatabletoenum ControlIntent: Equatable, Codable(associatedStringis 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-endianUInt32+ 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) inRemoteController.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 anNWConnection: a dedicatedDispatchQueue,send(_ message:)viaRemoteFraming.encode, a receive loop feedingRemoteFraming.Decoder.consumeand delivering eachRemoteMessageonDispatchQueue.main, plus state callbacks (onReady/onFailed). (Full code authored at implementation; uses only Task 1/2 types.) - [ ] Step 2:
AmbientHostlistener —NWListeneron_physarum._tcp(NWListener.Service), accept the first connection, build aRemoteLink, route messages throughAmbientHost.apply(...), and on cancel/failure callhandleDisconnect. Started byTVRootView. - [ ] Step 3:
RemoteDiscovery—protocol RemoteDiscovering { func present(completion: (NWConnection) -> Void) }; iOS impl wrapsDDDevicePickerViewController(DeviceDiscoveryUI) filtered to the Bonjour service, yielding the chosen device'sNWConnection. Verify the exact DeviceDiscoveryUI API + required entitlement/Info.plist keys against the current SDK here; fall back toNWBrowserif needed (sameRemoteLinkcore). - [ ] 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:
TVRootViewconstructs + starts anAmbientHost(passing itsAppState+AmbientController); stops it on disappear. - [ ] Step 2:
RemoteController(iOS/iPad): holds aRemoteLink+RemoteSendGate; astart(connection:); observesAppStatechanges (selection/intent → send immediately,edited→ send.paramswhenallowParams); aconnectionStatefor the UI; sends.helloon connect. - [ ] Step 3: iOS
RootView: a "Connect to Apple TV" toolbar/menu entry that runsRemoteDiscovering.present→RemoteController.start; a small connected/disconnected status; disconnect action. Editor unchanged otherwise. (#if os(iOS).) - [ ] Step 4:
project.ymlinfo.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.