Skip to content

feat(streaming): add Sunshine-based streaming for split-screen sessions#22

Merged
hikaps merged 16 commits into
developfrom
feat/sunshine
Jun 30, 2026
Merged

feat(streaming): add Sunshine-based streaming for split-screen sessions#22
hikaps merged 16 commits into
developfrom
feat/sunshine

Conversation

@hikaps

@hikaps hikaps commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Summary

Sunshine-based streaming for mixed physical + streaming split-screen sessions. Closes #21.

What it does

  • StreamManager — Sunshine subprocess lifecycle with crash recovery + configurable startup timeout
  • SunshineConfig — per-instance Sunshine config (port spacing, codec, credentials) generated to /tmp/couchplay-sunshine-<n>/
  • InstanceConfig extended with output mode (physical/streaming) + streaming fields (resolution, FPS, bitrate, codec, port)
  • D-Bus helper gains virtual-Wayland-display (CreateVirtualOutput) and PipeWire null-sink (CreateNullSink) creation, both Polkit-gated, with state save/reconcile
  • SessionRunner orchestration (mixed physical/streaming); VirtualDeviceWatcher recognises Sunshine virtual input
  • UI: output-mode selector + streaming options on SessionSetupPage

Status

Rebased onto develop (#24/#25/#26/#27). Reviewed by 8 parallel reviewer agents; their P1/P2 findings + test gaps are addressed (see below). The security-critical gating is clean — no command injection / auth bypass (username always validated before interpolation; sinkName regex-checked; execve not shell).

Review fixes (P1 — streaming was broken in any real/non-mock deployment)

  • StreamManager: pass the streaming user's uid (getpwnam) as compositorUid, not getuid() — the virtual output lives in /run/user/<streamingUserUid>, so getuid() pointed Sunshine at the GUI host compositor (wrong/privacy capture). The mock suites missed it because the mock never spawns Sunshine.
  • StreamManager: the attemptRestart sync-failure timer is now tracked in m_restartTimers so stopStream can cancel it (was an anonymous singleShot that could resurrect/corrupt a freshly-restarted instance).

Review fixes (P2)

  • helper: null-sink module index re-resolved by name before unload (PipeWire reuses indexes across session restarts; a stale saved index could unload the wrong module). Shared unloadNullSinkModule.
  • helper: CreateVirtualOutput polls for a genuinely new Wayland socket and errors if none appears (was falling back to a pre-existing socket → Sunshine bound the wrong compositor).
  • StreamManager: port-bump on startup crash now skips ports used by siblings (bumping by exactly PORT_SPACING landed on the next instance's slot).
  • SunshineConfig: check write() return in writeCredentialsFile (silent auth-path corruption).
  • SessionRunner: uninhibit the screen saver on the streaming-setup failure path (cookie leak).

Earlier fixes

  • borderless: restored the global Borderless windows toggle as the single source of truth; removed the unused per-instance field.
  • SunshineConfig: sanitized output_name/encoder/sink against CR/LF config injection; documented the /tmp credentials world-readability limitation.

Tests — closed the gaps that hid the P1s

  • test_streammanager: auto-restart state machine (startup-crash port bump, no-bump after streaming, autoRestart=false, stopStream cancels the tracked restart timer).
  • test_sunshine_config: injection test for sanitizeConfValue; honest framing on the hash test (self-consistency guard, not Sunshine-correctness).
  • test_sunshine_integration: asserts unique config values, not just key names (Sunshine's defaults contain the same keys).
  • Real Sunshine is installed in the test(e2e): rootless container harness for the local e2e suite #27 image (upstream GitHub release RPM); the integration test feeds it real SunshineConfig output and confirms it accepts every key + runs headless (software render + nested-kwin, binds :47989).

Test results

Unit: test_streammanager 25/25, test_sunshine_config 19/19 (in container; full 17/17 host suite under dbus-run-session)
Appium (#27 container, real Sunshine): streaming suite 6 passed / 3 skipped (lower controls off-screen in headless viewport) / 0 failed

Known follow-ups

  1. Credentials file perms/tmp/couchplay-sunshine-<n>/credentials.json world-readable (cross-UID read; Sunshine writes log/state there); secure fix = helper-owned config dir. Documented at SunshineConfig::ensureConfigDirectory.
  2. Credential hash format unverified against real Sunshine pairing (needs a client + PIN).
  3. No end-to-end stream (capture→encode→client-decode) — rootless distrobox can't access /dev/dri, so Sunshine has no hardware encoder; stays manual/beta (would need a GPU-passthrough container + Moonlight client).
  4. Three appium tests skip on the small headless viewport (lower controls off-screen).

@hikaps hikaps force-pushed the feat/sunshine branch 2 times, most recently from 87279d7 to 634f755 Compare June 29, 2026 18:16
hikaps added 15 commits June 30, 2026 12:14
…tance streaming

Add standalone spike scripts that validate Sunshine can run as a
managed subprocess with per-display capture. Covers multi-instance
concurrent execution, virtual display creation, config generation,
and virtual input device detection patterns.
…ine config generator

Add per-instance output mode (physical/streaming) to InstanceConfig with
streaming-specific fields. Extend D-Bus helper with virtual display and
PipeWire null-sink creation. Add SunshineConfig utility for per-instance
config generation with port spacing and codec support.
Add StreamManager for Sunshine subprocess lifecycle with crash recovery
and configurable startup timeout. Extend GamescopeInstance with virtual
output mode for streaming targets. Wire SessionRunner for mixed physical
and streaming sessions with Sunshine device attribution. Extend
VirtualDeviceWatcher to recognize Sunshine virtual input patterns.
Add output mode selector and streaming options (resolution, FPS, bitrate,
codec) to SessionSetupPage with i18nc strings and streaming profile
indicator. Add integration tests for mixed session configuration and
StreamManager link fix in test CMake targets.
Spike validation scripts served their purpose proving the Sunshine
subprocess approach. Remove before merge — production code is in
src/core/StreamManager and src/core/SunshineConfig.
P1 fixes:
- Fix command injection in DestroyNullSink (validate sinkName)
- Remove global D-Bus disconnect breaking multi-stream monitoring
- Guard startStream against entry overwrite during auto-restart
- Write Sunshine credentials.json with proper SHA256(salt+password) format
- Clean up streaming resources on natural gamescope exit
- Fix log_file -> log_path config key

P2 fixes:
- Persist virtual display/null-sink state across helper restarts
- Add port range validation (clamp to [MIN_PORT, MAX_PORT])
- Fix startup timeout dead code (Waiting -> Streaming on grace period)
- Fix stopInstance cleanup order (stop gamescope before destroying display)
- Use per-instance borderless instead of global setting
- Fix ComboBox currentIndex bindings breaking on user interaction
- Add cleanupTestCase to test_sunshine_config
- Fix Sunshine credential hash: password+salt with reversed digest (P1)
- Stop orphaned systemd units on CreateVirtualOutput failure paths
- Wire PULSE_SINK + Sunshine sink config for per-instance audio routing
- Remove dead port-conflict retry; add crash-during-startup port bump
- Fix bitrateSlider reactivity with Connections pattern
- Restore borderless default to true (regression from per-instance migration)
- Fix stop()/cleanupInstances() streaming teardown paths
- Track restart timers to prevent stale lambda race
- Move padding from invalid RowLayout to Kirigami.Heading
- Populate virtualDisplaySocket member at launch
…tance field

The streaming integration repointed SessionRunner's borderless flag to a
per-instance InstanceConfig.borderless field, orphaning the global 'Borderless
windows' toggle in SettingsPage (it wrote settingsManager.borderlessWindows,
which SessionRunner no longer read). Restore the global setting as the single
source of truth and remove the per-instance field (no UI, no remaining
consumer). GamescopeInstance still consumes config["borderless"] unchanged.
…mitation

Strip CR/LF from output_name/encoder/sink before writing sunshine.conf so a
malformed value cannot inject extra config lines. Also note at the permissions
site that the config dir is world-traversable by design (cross-UID read for the
streaming user) and that tightening it needs a helper-owned config dir +
runtime validation against real Sunshine -- deferred.
…trols

Add objectName + Accessible to the output-mode, resolution, frame-rate, bitrate
and codec controls so they are reachable by the selenium-webdriver-at-spi suite
(same id-first hybrid strategy as PR #26). Add select_combo_option /
wait_for_absence helpers and a new test_streaming.py covering the conditional
reveal of the streaming fields (hidden in physical mode, shown when 'Moonlight
Stream' is selected, hidden again when switched back).
Add CreateVirtualOutput/DestroyVirtualOutput/CreateNullSink/DestroyNullSink to
the mock helper (signatures mirror helper/CouchPlayHelper.h) so the streaming
orchestration can be driven end-to-end; each records to the mock launch log.

Add test_streaming_session_calls_helper (requires_helper tier) that switches an
instance to 'Moonlight Stream', starts the session, and asserts via the launch
log that CreateVirtualOutput + CreateNullSink reach the helper -- proving the
streaming path through SessionRunner.setupStreamingInstance works, independent
of the (xfail) window-positioning side-effect.
… selectability

Helper-tier test now also asserts a sunshine LaunchInstance (gameCommand
contains 'sunshine' + '/sunshine.conf') fires -- the end-to-end streaming
launch proof, independent of the physical-session window-class xfail.

Smoke tier: add resolution + frame-rate selectability tests exercising the
streaming combo onActivated -> InstanceConfig write-back bindings.
…tests

Qt6 ComboBox popup items are not exposed with accessible names (ListModel text
isn't promoted), so selection-by-NAME cannot work. Switch select_combo_option
to popup type-ahead (focus the combo, type the option text, Enter) -- verified
green in the #27 rootless container harness.

Lower streaming controls (frame rate/codec/bitrate) fall below the fold in the
headless container's small viewport; those tests now skip cleanly there and run
in a full-size session. The streaming->helper e2e test is xfail until the
harness pre-assigns a user to the streaming instance (logic is C++ unit-covered
by test_streaming_session).
… green

test_streaming_session_calls_helper was xfail because the streaming instance
had no username, so SessionRunner::setupStreamingInstance aborted on its
empty-username guard before any helper call. Assign player2 via comboUser
before Start Session; the test now passes in the #27 container harness,
verifying CreateVirtualOutput + CreateNullSink + the sunshine LaunchInstance
(gameCommand referencing sunshine.conf) reach the helper end-to-end through
the real app UI.
…ce test

Install Sunshine into appiumtests/Dockerfile from its upstream GitHub release
RPM (Sunshine-<ver>-1.fc43.x86_64) -- repo.lizardbyte.dev is unreachable from
some sandboxes, but GitHub releases are not. Pinned for reproducibility.

Add a tiny sunshine_config_generator binary (links SunshineConfig) that emits a
REAL SunshineConfig config set, and test_sunshine_integration.py which feeds it
to the real sunshine binary and asserts Sunshine accepts every key
(logs config: 'key' = value) and stays up. This is the test the unit suite
cannot be: it catches drift between SunshineConfig and what Sunshine actually
parses -- the silent-failure class that would make pairing/streaming fail.

Verified in the #27 rootless container: Sunshine runs headless (software
render + nested-kwin Wayland capture), connects to wayland-0, binds 47989, and
accepts the SunshineConfig format. Full streaming suite: 6 passed, 3 skipped
(lower controls off-screen in the headless viewport), 0 failed.
P1 (streaming was broken in any real/non-mock deployment):
- StreamManager: pass the STREAMING user's uid (getpwnam) as compositorUid,
  not getuid() -- the virtual output lives in /run/user/<streamingUserUid>, so
  getuid() pointed Sunshine at the GUI host compositor (wrong/privacy capture).
- StreamManager: track the attemptRestart sync-failure timer in m_restartTimers
  so stopStream can cancel it (was an anonymous singleShot that could resurrect
  and corrupt a freshly-restarted instance).

P2 code defects:
- helper: re-resolve the null-sink module index by name before unload (PipeWire
  reuses indexes across session restarts; a saved index could unload the wrong
  module). Shared unloadNullSinkModule used by DestroyNullSink + destructor.
- helper: CreateVirtualOutput polls for a genuinely NEW Wayland socket and
  errors if none appears (was falling back to a pre-existing socket -> Sunshine
  bound the wrong compositor).
- StreamManager: port-bump on startup crash now skips ports used by sibling
  streams (bumping by exactly PORT_SPACING landed on the next instance's slot).
- SunshineConfig: check write() return in writeCredentialsFile (silent
  corruption on the auth path; inconsistent with sibling writers).
- SessionRunner: uninhibit the screen saver on the streaming-setup failure path
  (inhibit ran before the loop; the failure return leaked the cookie).

Test gaps (the mock suites missed the P1s):
- test_streammanager: cover the auto-restart state machine (startup-crash port
  bump, no-bump after streaming, autoRestart=false immediate removal, and
  stopStream cancelling the tracked restart timer).
- test_sunshine_config: injection test for sanitizeConfValue; honest framing on
  testCustomCredentials (self-consistency guard, not Sunshine-correctness).
- test_sunshine_integration: assert unique config VALUES, not just key names
  (Sunshine's defaults contain the same keys).

Verified in the #27 container: couchplay + couchplay-helper + tests compile;
test_streammanager 25/25, test_sunshine_config 19/19, sunshine integration pass.
@hikaps hikaps merged commit bc0985d into develop Jun 30, 2026
1 check passed
@hikaps hikaps deleted the feat/sunshine branch June 30, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant