Description
## Context
A single tile-tap on the Rupelmonde kiosk currently spawns **three separate Qt processes** and at least as many window transitions:
1. `HydraExperienceNet kiosk` — shows the experience grid.
2. `HydraExperienceNet pair --headless <host>` — pairs with Sunshine (v6.1.22+ has `--headless` so no window flashes here now, thanks to Phase 1).
3. `HydraExperienceNet stream <host> <app>` — loads the CliStartStreamSegue then StreamSegue then stream; has its own ApplicationWindow and its own fullscreen Space.
Each one produces a visible transition. The kiosk window is hidden via hydraheadflatscreen's `/api/v1/window/hide` during streaming and shown again via `/api/v1/window/show` after exit. Process lifecycle, Space transitions, pair subprocess, stream subprocess — a lot of state to manage for what should be a single UX of grid → loading → stream → grid.
Phase 1 (issue #99 / v6.1.22) removed the pair window flash by making the pair subcommand headless. That leaves two processes and two window transitions per launch.
## Goal
Run the whole kiosk flow in a single Qt process with a single ApplicationWindow. Transitions happen as StackView pushes/pops inside that one window.
```
kiosk process (and only process): grid → loading → stream → grid
```
## Technical approach
Moonlight-Qt already has all the primitives as QObjects usable from QML without subprocesses:
- `ComputerManager` / `NvComputer` — handles pairing via existing APIs. Already used by the `pair` subcommand; can be instantiated and driven inside the kiosk process.
- `Session` (in `streaming/session.{h,cpp}`) — regular QObject. `StreamSegue.qml` already takes a `Session` property and drives the whole stream lifecycle.
Changes in `hydra-experiencenet`:
1. In `KioskView.qml`'s `startStream(experienceName)`: instead of POSTing to the agent's `/api/v1/stream/start` (which spawns a stream subprocess), construct a `Session` in C++ / expose one to QML with the right `NvComputer` + `NvApp`, push `StreamSegue.qml` onto the StackView (which already exists in the kiosk ApplicationWindow), pass it the Session. The stream renders inside the kiosk's own SDL/Metal surface.
2. Pairing runs in-process before the Session is created — call into ComputerManager directly; no subprocess invocation.
3. Exit overlay's `triggerExitFromMenu` calls `Session::setShouldExit(false)` (already does); `StreamSegue`'s existing `sessionFinished` handler pops back to the grid with `stackView.pop()`.
Changes in `hydraheadflatscreen`:
- `startMoonlightStream`, `hideKioskWindow`, `showKioskWindow`, `waitForStreamExit` all become unused. Delete.
- `pairWithSunshine` becomes unused (pair happens in-process on the Qt side). Delete.
- `LocalAPI.startStream` — optionally keep as a thin RPC the kiosk can call back into for auto-update / config refresh, but the actual stream launch moves into the kiosk.
- Agent's role shrinks to: auto-update the kiosk binary, fetch head config, maintain WireGuard tunnel. No subprocess spawning, no window hide/show dance.
## Benefits
- **Zero process switches** between grid and stream.
- **Zero window flashes** — everything in one NSWindow / QQuickWindow.
- **Simpler state** — no pair subprocess, no stream subprocess, no hide/show RPC, no `waitForStreamExit` goroutine.
- **No Space transition** between kiosk and stream — same Qt window just switches which QML view is on the StackView.
## Tradeoffs / risks
- Moderate refactor: ~200-300 LOC net (mostly deletion from the agent, moderate addition in `KioskView.qml` / supporting C++ to construct Session + NvComputer inside the kiosk process).
- Coupling: the kiosk process now depends directly on the Moonlight streaming pipeline (ComputerManager, Session, decoder init). Currently it only depends on its own grid UI plus an HTTP call to the agent.
- Any crash in the streaming pipeline now also takes down the kiosk. Today a stream subprocess crash is contained. Need to ensure the streaming code is robust or add a supervisor that can re-launch the kiosk process on crash.
- Debugging: stream logs today land in `/tmp/Moonlight-<pid>.log` per stream subprocess. Once merged into kiosk, they go to whatever log Qt uses for the kiosk (likely `/tmp/Moonlight-*.log` still, but only one rolling file per kiosk lifetime).
## Verification plan
1. Grid → tile tap → stream renders. Single NSWindow throughout (verify with `osascript -e 'tell application "System Events" to count windows of process "HydraExperienceNet"'` returning 1 at each step, plus the always-on-top exit overlay Tool window = 2).
2. Tap exit overlay → stream disconnects cleanly → grid reappears in the same window with no flash.
3. 5-minute stability test on peppy-dumpling-32 (no process respawns, no window flickers).
## Scope
- Does NOT include: rewriting the streaming pipeline. We reuse Moonlight-Qt's existing `Session` + `ComputerManager` verbatim.
- Does NOT include: changing the exit overlay — that stays as its own `QQuickWindow` with `NSWindowCollectionBehaviorCanJoinAllSpaces`.
- Blocked by: nothing. Can be started after Phase 1 is field-verified.
Phase 1 (`v6.1.22` / `v2.0.29`) shipped: pair subcommand is now headless. That removes one of the three subprocess windows.
The stream subprocess window is the remaining transition-visible one. We layered several mitigations tonight that produce a polished-but-not-perfect UX; each of them becomes obsolete once Phase 2 lands:
- **v6.1.23** — stream SDL window now gets `SDL_WINDOW_FULLSCREEN_DESKTOP` as a creation flag instead of a post-create `SDL_SetWindowFullscreen` call, so no brief windowed flash.
- **v6.1.24** — kiosk app's `/api/v1/screenshot` endpoint and its Screen Recording TCC request removed. Screenshots are done externally via Terminal's TCC. Phase 2 keeps this deletion.
- **v6.1.25** — exit overlay briefly expands to fullscreen with a black "Quitting experience" veil to cover the macOS Space handoff between stream subprocess closing and kiosk regaining its Space. Safety timer collapses it back after 1.5 s. Works, still imperfect: kiosk briefly flashes its `streaming=true` state ("Starting castle viewer") after exit before the poll flips it to idle.
- **v6.1.20** — `KioskView.pollStreamStatus` recognises agent's `"idle"` status so the grid unsticks after exit.
- **v6.1.20** — `handleWindowShow` re-enters fullscreen for non-Tool windows because macOS' `hide()` on a fullscreen NSWindow drops it out of its Space.
- **v2.0.28** — agent stream args use `--display-mode borderless` (not `windowed`) to avoid window chrome in the stream subprocess.
All of these mitigations exist solely because the kiosk grid, pairing, and stream live in separate OS processes today. Phase 2 collapses them into one process with one Qt window, which means:
- No Space transition → no need for the veil (v6.1.25).
- No SDL_Window creation by a separate subprocess → no creation-flag timing mitigations (v6.1.23).
- No kiosk window hide / show RPC → the `handleWindowHide/Show` endpoints, the agent's goroutine, and the fullscreen-re-entry guard (v6.1.20) all go away.
- `KioskView`'s polling loop talking to the agent's `/api/v1/stream/status` can be replaced by a direct Qt signal/slot on the in-process `Session` — no poll interval, no race on state transitions.
- The branded `Loading experience` screen just becomes a StackView push — no new Qt process spawn.
## Updated scope for Phase 2
In addition to what's described in the original ticket:
- Delete `hideKioskWindow` / `showKioskWindow` from agent and `handleWindowHide` / `handleWindowShow` from kiosk app.
- Delete `LocalAPI.waitForStreamExit` goroutine from agent.
- Delete the `streaming` polling loop in `KioskView.qml`; connect directly to `Session` signals.
- Remove the `quitting` veil path in `StreamOverlay.qml` (the Space transition it covers will not exist).
- Keep the `--absolute-mouse` and `--display-mode borderless` defaults in `moonlightStreamArgs` equivalents that end up in the in-process Session config.
- Keep the `NSWindowCollectionBehaviorCanJoinAllSpaces` on the overlay — only necessary if the main kiosk window still enters a macOS fullscreen Space, which it will continue to. Verify the overlay continues to float above the stream rendering surface once the stream is in the same window/Space.
## Risk notes unearthed tonight
- `NSWindow setCollectionBehavior:` throws on invalid flag combos on macOS 26. v6.1.18's `CanJoinAllSpaces | FullScreenAuxiliary | Stationary` aborted with `-[NSWindow _validateCollectionBehavior:]`. Phase 2 must avoid the same pitfall — stick to `CanJoinAllSpaces` alone or validated combos. Wrap in `@try/@catch` as v6.1.19 does.
- `Qt.Tool`-flagged windows become `NSPanel`s on macOS with different Space behaviour than regular NSWindows. The exit overlay relies on this deliberately. Any rework should keep `Qt.Tool`.
- Qt flags set at ApplicationWindow declaration time are honoured; flags set from QML at runtime do not recreate the NSWindow styleMask on macOS (the kiosk's title bar stayed visible in v6.1.15 until we moved `Qt.FramelessWindowHint` to the declaration-level binding via the `kioskMode` context property).