diff --git a/crates/punktfunk-host/src/gamestream/apps.rs b/crates/punktfunk-host/src/gamestream/apps.rs index 9741c7f..b477ec2 100644 --- a/crates/punktfunk-host/src/gamestream/apps.rs +++ b/crates/punktfunk-host/src/gamestream/apps.rs @@ -170,18 +170,26 @@ pub fn appasset_bytes(appid: u32) -> Option<(Vec, String)> { /// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver /// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when /// true, Moonlight offers its per-app HDR toggle. +/// +/// The document is emitted **COMPACT — no whitespace between elements** — deliberately, to match +/// Sunshine/GFE. Moonlight-Android's `getAppListByReader` calls `appList.getLast()` on *every* XML +/// text node before it checks the current tag, and only fills `appList` on an `` start tag. A +/// pretty-print newline between `` and the first `` is a whitespace text node while +/// `appList` is still empty → `NoSuchElementException` → the Android app hard-crashes on host click. +/// (iOS/macOS parse via moonlight-common-c/expat and are unaffected; `serverinfo`/pairing use the +/// named-tag `getXmlString` scan, so their whitespace is harmless.) Keep this whitespace-free. pub fn applist_xml() -> String { let hdr = u8::from(crate::gamestream::host_hdr_capable()); let mut xml = - String::from("\n\n"); + String::from(""); for app in catalog() { xml.push_str(&format!( - "\n{hdr}\n{}\n{}\n\n", + "{hdr}{}{}", xml_escape(&app.title), app.id )); } - xml.push_str("\n"); + xml.push_str(""); xml } @@ -249,4 +257,27 @@ mod tests { assert!(xml.starts_with("").count(), xml.matches("").count()); } + + /// Regression: the applist MUST be whitespace-free between elements. Moonlight-Android's + /// `getAppListByReader` calls `appList.getLast()` on every text node before an `` has been + /// pushed, so a pretty-print newline between `` and the first `` crashes the app + /// (`NoSuchElementException`). Reproduced on 2 Android phones; iOS/macOS (moonlight-common-c) + /// were unaffected. Keep `applist_xml` compact like Sunshine/GFE. + #[test] + fn applist_xml_has_no_interelement_whitespace() { + let xml = applist_xml(); + // is immediately followed by the first — no whitespace text node while the + // parser's app list is still empty. + assert!( + xml.contains("status_code=\"200\">"), + "no whitespace between and the first : {xml}" + ); + // No pretty-print newlines anywhere in the element stream, and no whitespace-only text + // nodes between any adjacent tags. + assert!(!xml.contains('\n'), "applist must contain no newlines: {xml}"); + assert!( + !xml.contains("> <"), + "applist must contain no inter-element spaces: {xml}" + ); + } } diff --git a/crates/punktfunk-host/src/gamestream/nvhttp.rs b/crates/punktfunk-host/src/gamestream/nvhttp.rs index 988709c..be2c2ea 100644 --- a/crates/punktfunk-host/src/gamestream/nvhttp.rs +++ b/crates/punktfunk-host/src/gamestream/nvhttp.rs @@ -149,10 +149,9 @@ async fn h_launch( .unwrap() .as_ref() .map(|s| (s.owner_fp, (s.width, s.height, s.fps))); - let conflict = crate::vdisplay::policy::prefs() - .configured_effective() - .map(|e| e.mode_conflict) - .unwrap_or(crate::vdisplay::policy::ModeConflict::Separate); + // Same Windows default as the native path (separate → reject; see `effective_conflict`) so a + // 2nd Moonlight client gets a clean 503 rather than wedging the shared monitor's capture. + let conflict = crate::vdisplay::admission::effective_conflict(); match gamestream_admission(live, req_fp, conflict) { GsDecision::Serve => {} GsDecision::Join((w, h, f)) => { diff --git a/crates/punktfunk-host/src/vdisplay/admission.rs b/crates/punktfunk-host/src/vdisplay/admission.rs index 42bfb7a..1763793 100644 --- a/crates/punktfunk-host/src/vdisplay/admission.rs +++ b/crates/punktfunk-host/src/vdisplay/admission.rs @@ -90,34 +90,29 @@ pub fn decide( } } -/// Resolve the effective decision for a connecting session: read the console `mode_conflict` policy -/// (default `Separate` when unconfigured) and [`decide`] against the live set. -/// -/// **Windows** can't create SEPARATE virtual displays until the multi-monitor stage (§6.6), so a -/// `separate` outcome — including the **unconfigured default** — resolves to `join` (admit at the live -/// mode) rather than the old silent last-wins reconfigure of the shared monitor. This is the deliberate -/// Windows default change (release-note behavior fix); `steal` remains the way to force the new mode. -pub fn admit(req_identity: Option<[u8; 32]>) -> Admission { - #[allow(unused_mut)] - let mut conflict = policy::prefs() +/// The effective `mode_conflict` policy for THIS host: the console value (default `Separate` when +/// unconfigured), with the **Windows default applied**. On Windows `separate` — including the +/// unconfigured default — resolves to **`reject`**: two concurrent Windows sessions would both drive the +/// SAME pf-vdisplay monitor's single-capturer IDD-push channel ("newest-delivery-wins"), which freezes +/// the live client and can wedge the driver (true multi-session capture is §6.6 / Stage 7). So a 2nd +/// client gets a clean 503 and the live session is protected; `join`/`steal` stay as explicit opt-ins. +/// Linux keeps `separate` (real multi-view). Shared by the native + GameStream admission paths. +pub fn effective_conflict() -> ModeConflict { + let conflict = policy::prefs() .configured_effective() .map(|e| e.mode_conflict) .unwrap_or(ModeConflict::Separate); - let live = table().lock().unwrap(); #[cfg(windows)] if matches!(conflict, ModeConflict::Separate) { - if live - .iter() - .any(|s| !same_client(s.identity, req_identity)) - { - tracing::warn!( - "mode_conflict=separate is not yet supported on Windows (multi-monitor is §6.6) — \ - JOINing the live session's mode instead" - ); - } - conflict = ModeConflict::Join; + return ModeConflict::Reject; } - decide(conflict, req_identity, &live) + conflict +} + +/// Resolve the admission decision for a connecting native session: [`effective_conflict`] + [`decide`] +/// against the live set. +pub fn admit(req_identity: Option<[u8; 32]>) -> Admission { + decide(effective_conflict(), req_identity, &table().lock().unwrap()) } /// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call