A single tile-tap on the Rupelmonde kiosk currently spawns three separate Qt processes and at least as many window transitions:
HydraExperienceNet kiosk — shows the experience grid.HydraExperienceNet pair --headless <host> — pairs with Sunshine (v6.1.22+ has --headless so no window flashes here now, thanks to Phase 1).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.
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
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:
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.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.waitForStreamExit goroutine.KioskView.qml / supporting C++ to construct Session + NvComputer inside the kiosk process)./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).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).Session + ComputerManager verbatim.QQuickWindow with NSWindowCollectionBehaviorCanJoinAllSpaces.
Phase 2 status update after 2026-04-22 evening client-side hardening
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:
SDL_WINDOW_FULLSCREEN_DESKTOPas a creation flag instead of a post-createSDL_SetWindowFullscreencall, so no brief windowed flash./api/v1/screenshotendpoint and its Screen Recording TCC request removed. Screenshots are done externally via Terminal's TCC. Phase 2 keeps this deletion.streaming=truestate ("Starting castle viewer") after exit before the poll flips it to idle.KioskView.pollStreamStatusrecognises agent's"idle"status so the grid unsticks after exit.handleWindowShowre-enters fullscreen for non-Tool windows because macOS'hide()on a fullscreen NSWindow drops it out of its Space.--display-mode borderless(notwindowed) 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:
handleWindowHide/Showendpoints, 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/statuscan be replaced by a direct Qt signal/slot on the in-processSession— no poll interval, no race on state transitions.Loading experiencescreen 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:
hideKioskWindow/showKioskWindowfrom agent andhandleWindowHide/handleWindowShowfrom kiosk app.LocalAPI.waitForStreamExitgoroutine from agent.streamingpolling loop inKioskView.qml; connect directly toSessionsignals.quittingveil path inStreamOverlay.qml(the Space transition it covers will not exist).--absolute-mouseand--display-mode borderlessdefaults inmoonlightStreamArgsequivalents that end up in the in-process Session config.NSWindowCollectionBehaviorCanJoinAllSpaceson 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'sCanJoinAllSpaces | FullScreenAuxiliary | Stationaryaborted with-[NSWindow _validateCollectionBehavior:]. Phase 2 must avoid the same pitfall — stick toCanJoinAllSpacesalone or validated combos. Wrap in@try/@catchas v6.1.19 does.Qt.Tool-flagged windows becomeNSPanels on macOS with different Space behaviour than regular NSWindows. The exit overlay relies on this deliberately. Any rework should keepQt.Tool.Qt.FramelessWindowHintto the declaration-level binding via thekioskModecontext property).