diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index 43a66b76..54c2f02e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -47,6 +47,7 @@ import android.widget.Toast import io.unom.punktfunk.kit.link.DeepLinkResult import io.unom.punktfunk.kit.link.DeepLinks import io.unom.punktfunk.kit.link.HostResolution +import io.unom.punktfunk.kit.SessionEndReason import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.models.ActiveSession import io.unom.punktfunk.models.Tab @@ -61,6 +62,11 @@ fun App(forceGamepadUi: Boolean = false) { // so the stream screen never re-reads the store behind its own connect's back. var session by remember { mutableStateOf(null) } var tab by remember { mutableStateOf(Tab.Connect) } + // Set when a session ends because its game exited and it began as a library launch: the host + // whose library the console shell should come back to. Held HERE because the shell's own + // navigation state does not outlive the stream. Cleared once the shell has consumed it, so a + // later manual Back out of the library is not undone by a stale value. + var reopenLibraryHostId by remember { mutableStateOf(null) } // Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is // a TV OR the dev force flag). Flips live as controllers connect/disconnect. @@ -107,7 +113,20 @@ fun App(forceGamepadUi: Boolean = false) { ) { active -> if (active != null) { // Immersive: the stream takes the whole screen, no bottom bar. - StreamScreen(active, onDisconnect = { session = null }) + StreamScreen(active) { reason -> + // A game launched from a library exiting is a normal finish, and the player is + // almost certainly after the next title — so send them back to that library rather + // than all the way out to host selection. The console shell's own screen state does + // not survive the stream (StreamScreen replaces it in the composition, discarding + // its `remember`s), so the intent is hoisted here and handed back on the way in. + reopenLibraryHostId = + if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) { + active.hostId + } else { + null + } + session = null + } } else if (gamepadUi) { GamepadShell( settings = settings, @@ -115,6 +134,8 @@ fun App(forceGamepadUi: Boolean = false) { onConnected = { session = it }, deepLink = pendingLink, onDeepLinkHandled = { activity?.pendingDeepLink = null }, + reopenLibraryHostId = reopenLibraryHostId, + onReopenLibraryHandled = { reopenLibraryHostId = null }, ) } else { // Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail @@ -218,11 +239,32 @@ fun GamepadShell( onConnected: (ActiveSession) -> Unit, deepLink: String? = null, onDeepLinkHandled: () -> Unit = {}, + /** + * Open this saved host's library instead of Home on the way in — set when a game launched from + * it has just exited. Null (the default) starts on Home exactly as before. + */ + reopenLibraryHostId: String? = null, + onReopenLibraryHandled: () -> Unit = {}, ) { val context = LocalContext.current var screen by remember { mutableStateOf(GamepadScreen.Home) } var libraryHost by remember { mutableStateOf(null) } + // Consume the "come back to this library" intent once, on entry. Keyed on the id so a second + // game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out. + // A host that has since been forgotten simply leaves us on Home rather than failing. + LaunchedEffect(reopenLibraryHostId) { + val id = reopenLibraryHostId ?: return@LaunchedEffect + // Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys + // this effect and cancels the coroutine running it. Nothing suspends in between today, so + // either order happens to work — but this one cannot be broken by a later edit that adds a + // suspending call. A host that has since been forgotten just leaves us on Home. + KnownHostStore(context).all() + .firstOrNull { it.id == id } + ?.let { libraryHost = it; screen = GamepadScreen.Library } + onReopenLibraryHandled() + } + // On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the // effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the // panel reports fewer dp than that; a low-density TV that's already spacious, and every phone / diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index 93939b23..f54de80f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -145,7 +145,14 @@ fun LibraryScreen( launching = false if (handle != 0L) { onLaunched( - ActiveSession(handle, settings, host.clipboardSync), + ActiveSession( + handle, + settings, + host.clipboardSync, + hostId = host.id, + // Where to come back to when this game exits. + launchedFromLibrary = true, + ), ) } else Toast.makeText( diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index a1abd4ed..600804b7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -73,6 +73,7 @@ import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.deviceBodyVibrator import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.Sc2Capture +import io.unom.punktfunk.kit.SessionEndReason import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.models.ActiveSession import java.util.concurrent.atomic.AtomicBoolean @@ -86,7 +87,7 @@ import kotlinx.coroutines.delay * the connect that produced this handle. */ @Composable -fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { +fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> Unit) { val handle = session.handle val initialSettings = session.settings val micEnabled = initialSettings.micEnabled @@ -200,12 +201,32 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { while (true) { delay(1000) if (NativeBridge.nativeSessionEnded(handle)) { - Toast.makeText( - context, - "Connection lost — the host may be asleep. Wake it to reconnect.", - Toast.LENGTH_LONG, - ).show() - onDisconnect() + // WHY it ended decides what the user is told. This used to show the "host may be + // asleep" line for EVERY ending — including a game the player had just quit and a + // session the host ended on purpose — which reads as a failure report for + // something nobody did wrong. Only a connection that actually died says that now. + val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle)) + when (reason) { + SessionEndReason.LOST -> + Toast.makeText( + context, + "Connection lost — the host may be asleep. Wake it to reconnect.", + Toast.LENGTH_LONG, + ).show() + SessionEndReason.HOST_ERROR -> + Toast.makeText( + context, + "The host ended the session with an error.", + Toast.LENGTH_LONG, + ).show() + // Deliberate endings — the player quit the game, the host was stopped, or we + // closed it. Leaving the stream IS the feedback; a toast would only add noise. + SessionEndReason.GAME_EXITED, + SessionEndReason.HOST_ENDED, + SessionEndReason.LOCAL, + SessionEndReason.NONE -> {} + } + onSessionEnded(reason) return@LaunchedEffect } } @@ -330,7 +351,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it // (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream // the same way the Back gesture does. - activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } + activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) } router.onExitChord = { activity?.requestStreamExit?.invoke() } // Show a "hold to quit" hint the moment the chord completes (the router debounces the actual // exit); it clears when the buttons release early or the hold elapses. Runs on the main thread. @@ -617,7 +638,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { } // Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger). - BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } + BackHandler { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) } // Leaving the app (Home, task switch, screen off) MUST end the session. Android does not // suspend a process for going to background, so without this the native worker kept running and @@ -625,14 +646,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // host still saw a live client and held the session (and its display + encoder) open until the // OS eventually reclaimed the process, which on a TV box is effectively never. // - // Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real + // Route it through `onSessionEnded()` so the composable's `onDispose` above runs the one real // teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit", // so the host should linger the display and make coming straight back a fast reconnect. DisposableEffect(handle) { val lifecycle = (context as? LifecycleOwner)?.lifecycle val obs = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) { - onDisconnect() + onSessionEnded(SessionEndReason.LOCAL) } } lifecycle?.addObserver(obs) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt index b33f4ac2..7fe3d279 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt @@ -61,6 +61,16 @@ data class ActiveSession( * from "a different host" (a notice; a URL may never preempt a live session). */ val hostId: String? = null, + /** + * This session was started by launching a title from [hostId]'s library, rather than by + * connecting to the host's desktop. + * + * Decides where the client goes when the session ENDS: a title launched out of a library + * belongs back in that library when its game exits — one press from the next one — not on the + * host-selection screen. Only meaningful together with a + * [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending. + */ + val launchedFromLibrary: Boolean = false, ) /** Trust state of a host, shown as a colored pill on its card. */ diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index e8a93e60..77513e1f 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -87,6 +87,18 @@ object NativeBridge { */ external fun nativeSessionEnded(handle: Long): Boolean + /** + * WHY the session ended, as a [SessionEndReason] ordinal — decode with + * [SessionEndReason.fromNative]. `0` (NONE) before it ends, or on a `0` handle. + * + * The companion to [nativeSessionEnded], which only says THAT it ended. Both are needed: the + * flag to leave a dead stream, this to decide what to tell the user. A player quitting their + * game and a host falling off the network both end the session, and with no way to separate + * them the watchdog said "the host may be asleep" for all of them — wrong for every deliberate + * ending. Cheap (one atomic load); UI-safe. + */ + external fun nativeEndReason(handle: Long): Int + /** * Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified * fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable). diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt new file mode 100644 index 00000000..f918cb53 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt @@ -0,0 +1,56 @@ +package io.unom.punktfunk.kit + +/** + * Why a stream session ended — the Kotlin mirror of `punktfunk_core::client::PunktfunkEndReason`, + * read via [NativeBridge.nativeEndReason]. + * + * The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a + * player quitting their game and a host falling off the network both arrive as "the session + * ended". With no way to tell them apart this client showed one message for all of them — and it + * was the alarming one ("Connection lost — the host may be asleep"), in front of players who had + * just quit their own game. + * + * Ordinals are an ABI contract with the Rust side: append only, never renumber. + */ +enum class SessionEndReason { + /** Not ended, or ended before a reason could be observed. Also the fallback for an unknown value. */ + NONE, + + /** This client closed the session — the user pressed back or stop. Nothing to report. */ + LOCAL, + + /** + * The host's launched game exited. A normal finish, and the one reason worth acting on: go back + * to the library the title was launched from, so the next one is a tap away. + */ + GAME_EXITED, + + /** The host ended the session deliberately (an operator "End", or it simply finished). Normal. */ + HOST_ENDED, + + /** The host closed reporting a failure of its own. Worth showing; the host's log has the detail. */ + HOST_ERROR, + + /** + * The connection died rather than being closed: idle timeout, reset, the network going away. + * This — and only this — is the "the host may be asleep, wake it" case. + */ + LOST; + + /** + * Is this an ordinary outcome rather than something to alarm the user about? + * + * The question nearly every caller actually asks. [LOCAL], [GAME_EXITED] and [HOST_ENDED] were + * all meant to happen. [NONE] counts as normal — no evidence of trouble is not evidence of it. + */ + val isNormal: Boolean + get() = this != HOST_ERROR && this != LOST + + companion object { + /** + * Decode the JNI byte. An unrecognized value becomes [NONE] rather than throwing: this + * crosses an ABI where the native side may be newer than this code. + */ + fun fromNative(v: Int): SessionEndReason = entries.getOrNull(v) ?: NONE + } +} diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 982400f7..98480361 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -404,6 +404,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde }) } +/// `NativeBridge.nativeEndReason(handle): Int` — WHY the session ended, as a +/// `punktfunk_core::client::PunktfunkEndReason` byte (Kotlin mirrors it in `SessionEndReason`). +/// +/// Companion to `nativeSessionEnded`, which only says THAT it ended. Kotlin's watchdog needs both: +/// the flag to leave a dead stream, and this to decide what — if anything — to tell the user. A +/// player quitting their game and a host dropping off the network both end the session, and until +/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for +/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one +/// atomic load); safe on the UI thread. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason( + _env: JNIEnv, + _this: JObject, + handle: jlong, +) -> jint { + jni_guard(0, || { + if handle == 0 { + return 0; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.client.end_reason() as jint + }) +} + /// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN /// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint /// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index c28d6add..11c2471d 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -335,6 +335,16 @@ struct ContentView: View { active: fullscreenForSession && model.connection != nil, isFullscreen: $isFullscreen)) #endif + // A game launched from the library just exited, so the session ended on purpose: put the + // player back in that host's library rather than on host selection. Set on the outer Group + // (like the sheets below) so it survives the streaming → home transition the disconnect + // drives, and consumed here — the model hands the host over once and we clear it, so a + // later manual dismiss of the library can't be undone by a stale value. + .onChange(of: model.returnToLibrary) { _, host in + guard let host else { return } + model.returnToLibrary = nil + libraryTarget = host + } // On the outer Group so the sheet survives the trust-prompt → home transition // (the "Pair with PIN instead" path disconnects first — the host's accept loop // is sequential, a pairing connection would queue behind the live session). diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 8cdf017f..86dbb4cc 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -65,6 +65,14 @@ final class SessionModel: ObservableObject { @Published private(set) var connection: PunktfunkConnection? /// The host this session is for (a value copy; identity = id). @Published private(set) var activeHost: StoredHost? + /// The library entry this session was launched with (`connect(launchID:)`), or nil if the user + /// just connected to the host's desktop. Kept because where the client should go when the + /// session ends depends on where it came FROM: a title launched out of the library belongs back + /// in that library when its game exits, not on the host-selection screen. + private var launchedTitleID: String? + /// Set when a session ended because its game exited and it began as a library launch: the host + /// whose library to reopen. The view layer consumes it and sets it back to nil. + @Published var returnToLibrary: StoredHost? /// The settings THIS session runs on — the globals with its profile overlaid, resolved once at /// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for /// the readers that live in PunktfunkKit and can't see this model. @@ -249,6 +257,7 @@ final class SessionModel: ObservableObject { guard phase == .idle else { return } phase = .connecting activeHost = host + launchedTitleID = launchID errorMessage = nil settings = effective statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal @@ -607,6 +616,8 @@ final class SessionModel: ObservableObject { } connection = nil activeHost = nil + // Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer. + launchedTitleID = nil phase = .idle fps = 0 mbps = 0 @@ -626,10 +637,36 @@ final class SessionModel: ObservableObject { /// Called (via the main actor) when the pump hits end-of-session. func sessionEnded() { - guard connection != nil else { return } + guard let conn = connection else { return } let name = activeHost?.displayName ?? "host" + // WHY it ended, asked while the connection is still up — `disconnect` tears it down. + let reason = conn.sessionEndReason + // Where a game exit sends us: back into the library this title was launched from, so the + // next one is a tap away. Only for a launch that CAME from the library — a game exiting in + // a plain desktop session has no library to return to. + let host = activeHost + let cameFromLibrary = launchedTitleID != nil disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect - errorMessage = "Session ended by \(name)." + switch reason { + case .gameExited: + // The player quit their own game. Not a failure, and they are probably after the next + // title — so no banner, and back to the library it came from. + if cameFromLibrary, let host { + returnToLibrary = host + } + case .hostEnded, .local: + // Someone asked for this: an operator "End" on the host, or our own close racing in. + // Say it plainly, without the error framing. + errorMessage = "\(name) ended the session." + case .hostError: + errorMessage = "\(name) ended the session with an error." + case .lost: + errorMessage = "Lost the connection to \(name)." + case .none: + // No verdict (an older core, or the close raced the read): keep the wording this path + // has always used rather than inventing one. + errorMessage = "Session ended by \(name)." + } } /// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 93d319f0..350b44f6 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -1430,6 +1430,49 @@ public final class PunktfunkConnection { } } + /// Why a stream session ended — the Swift mirror of `PunktfunkEndReason` (ABI v17). + /// + /// The distinction that matters to a UI is normal vs alarming, and it is not a spectrum: a + /// player quitting their game and a host falling off the network both arrive as "the session + /// ended". Without this every client wrote one message for all of them, and every client chose + /// an error. + public enum SessionEndReason: UInt8, Sendable { + /// Not ended, or ended before a reason could be observed. Also the fallback for an + /// unrecognized value — the core may be newer than this code. + case none = 0 + /// This client closed the session. Nothing to report: the UI initiated it. + case local = 1 + /// The host's launched game exited. A normal finish, and the one reason worth acting on: + /// go back to the library the title was launched from. + case gameExited = 2 + /// The host ended the session deliberately (an operator "End", or it simply finished). + case hostEnded = 3 + /// The host closed reporting a failure of its own. + case hostError = 4 + /// The connection died rather than being closed: idle timeout, reset, network gone. This — + /// and only this — is the "the host may be asleep" case. + case lost = 5 + + /// Is this an ordinary outcome rather than something to alarm the user about? `.none` + /// counts as normal: no evidence of trouble is not evidence of it. + public var isNormal: Bool { self != .hostError && self != .lost } + } + + /// Why this session ended. Only meaningful once it HAS ended (a plane threw `.closed`, or + /// `onSessionEnd` fired) — before that it is `.none`. + /// + /// Read it before tearing the connection down: once `close()` has been requested this reports + /// `.none`, which is the safe direction (the caller falls back to its normal handling). + public var sessionEndReason: SessionEndReason { + guard let h = liveHandle() else { return .none } + var out: UInt8 = 0 + guard punktfunk_connection_end_reason(h, &out) == statusOK else { return .none } + return SessionEndReason(rawValue: out) ?? .none + } + + /// Shorthand for the single most actionable reason: the host's launched game exited. + public var endedBecauseGameExited: Bool { sessionEndReason == .gameExited } + deinit { close() } /// Snapshot the handle unless close is pending (callers hold their plane lock). diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index bc75217b..f0bca1e2 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -225,6 +225,15 @@ public final class StreamViewController: StreamViewControllerBase { /// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back, /// so the system observes a real transition instead of coalescing the flip away. private static let pointerLockForcedOffHold: TimeInterval = 0.05 + /// Attempts spent in the QUIET tail (see `scheduleQuietRelock()`), reset with the burst. + private var pointerRelockQuietAttempt = 0 + /// When the quiet tail re-asks, measured from the drop. The visible burst above spends its whole + /// budget inside ~0.6 s — and the pointer-lock cooldown the platform applies right after its own + /// Escape gesture is about a second, so every one of those attempts asks while the answer can + /// only be no. These land AFTER it. They are "quiet" because unlike the burst they do not hide + /// the cursor or mute motion: the pointer behaves exactly as it does today while they run, so + /// stretching the recovery costs the user nothing if it also fails. + private static let pointerRelockQuietDelays: [TimeInterval] = [1.2, 2.4] #endif /// Reads whether the scene's pointer is actually locked right now; nil = state @@ -340,12 +349,80 @@ public final class StreamViewController: StreamViewControllerBase { // SwiftUI places us in the hierarchy AFTER start()'s setCaptured(true), and may reparent us // later — re-anchor the chain here so a lock requested before we had a parent still lands. updatePointerLockChain() + anchorKeyResponder() } public override func didMove(toParent parent: UIViewController?) { super.didMove(toParent: parent) updatePointerLockChain() // chain shape changed — re-anchor (or no-op if not yet in a window) } + + /// Put THIS controller on the responder chain for hardware key presses. + /// + /// Nothing of ours is otherwise a first responder during a normal stream: keys arrive on the + /// GameController (`GCKeyboard`) path, which is a parallel HID feed that does not consume the + /// UIKit event, and `StreamLayerUIView` only becomes first responder to summon the SOFT + /// keyboard (it is `UIKeyInput`, so making it one for any other reason would raise the on-screen + /// keyboard mid-game). With no responder of ours in the chain, every hardware key press reaches + /// UIKit unclaimed — and an unclaimed press is what lets the system apply its own default for + /// that key. `pressesBegan` below is where we claim Escape; this is what gets it delivered. + /// + /// A controller is not `UIKeyInput`, so being first responder raises no keyboard. Deferred to + /// the soft keyboard whenever the view has taken over, so the three-finger-swipe keyboard is + /// unaffected. + /// + /// Only while captured — the whole claim is scoped to "the stream owns the keyboard", and + /// holding the chain outside that would sit in front of SwiftUI's focus for no reason. Safe to + /// call from anywhere: `start()` engages capture BEFORE SwiftUI puts us in a window (where + /// `becomeFirstResponder` cannot succeed), so `viewDidAppear` calls it again to catch up. + private func anchorKeyResponder() { + guard captured, !streamView.isFirstResponder, !isFirstResponder else { return } + becomeFirstResponder() + } + + public override var canBecomeFirstResponder: Bool { true } + + /// Claim Escape while the stream owns the keyboard, so the SYSTEM never gets to act on it. + /// + /// This is the fix for "Escape hands the mouse back to iPadOS": the platform releases the + /// scene's pointer lock on an Escape that nothing claimed — the same "let me out" the web + /// Pointer Lock API mandates. Every recovery attempt before this one fought that release AFTER + /// the fact (a re-lock burst, then a click), and the platform's post-Escape cooldown means the + /// burst is refused by construction. Claiming the press means there is nothing to recover from. + /// + /// Escape is forwarded to the host on the GCKeyboard path, which is untouched by this — that + /// path never sees the UIKit responder chain, so the host still receives the keystroke and + /// in-game menus still open. Only the system's own interpretation is suppressed. + /// + /// Strictly scoped: only while `captured` (the stream owns input), and only Escape. Anything + /// else — including every key while the pointer is released — goes to `super` untouched, so + /// Escape still dismisses sheets, exits full screen and does everything else it should whenever + /// we are not holding the keyboard. The deliberate ways out are unaffected: ⌘⎋ and ⌃⌥⇧Q are + /// recognized on the GCKeyboard path and clear `captured` themselves. + public override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + let unclaimed = presses.filter { !claimsPress($0) } + if !unclaimed.isEmpty || presses.isEmpty { + super.pressesBegan(unclaimed, with: event) + } + } + + public override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + let unclaimed = presses.filter { !claimsPress($0) } + if !unclaimed.isEmpty || presses.isEmpty { + super.pressesEnded(unclaimed, with: event) + } + } + + public override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { + // Never swallowed: a cancelled press is the system taking the key away from us, and + // dropping it here would strand UIKit's own bookkeeping for a press we did claim. + super.pressesCancelled(presses, with: event) + } + + /// Is this press one the stream owns outright (Escape while captured)? + private func claimsPress(_ press: UIPress) -> Bool { + captured && press.key?.keyCode == .keyboardEscape + } #endif #if os(tvOS) @@ -478,6 +555,7 @@ public final class StreamViewController: StreamViewControllerBase { if !down, self.wantsPointerLock, self.pointerLockWasEngaged, !self.pointerRelockPending, self.pointerLockEngaged() != true { self.pointerRelockAttempt = 0 + self.pointerRelockQuietAttempt = 0 // a real gesture buys a fresh tail too self.updatePointerLockChain() // a reparent since the drop would break the walk to us self.requestPointerRelock() } @@ -749,10 +827,16 @@ public final class StreamViewController: StreamViewControllerBase { guard captureEnabled, !captured, connection != nil else { return } inputCapture?.setForwarding(true, suppressClick: fromClick) captured = true + // Claim the responder chain for as long as we own the keyboard — `pressesBegan` has to + // be delivered to us before it can keep Escape away from the system. + anchorKeyResponder() } else { guard captured else { return } inputCapture?.setForwarding(false) captured = false + // Hand the chain back: released means Escape is the system's again, and staying first + // responder for a stream that no longer owns input would sit in front of SwiftUI focus. + if isFirstResponder { resignFirstResponder() } } setNeedsUpdateOfPrefersPointerLocked() updatePointerLockChain() // (re)anchor the SwiftUI ancestors so the lock actually resolves @@ -782,6 +866,7 @@ public final class StreamViewController: StreamViewControllerBase { pointerLockWasEngaged = true pointerRelockPending = false pointerRelockAttempt = 0 + pointerRelockQuietAttempt = 0 // granted — any scheduled tail finds nothing to do } else if wantsPointerLock, pointerLockWasEngaged { requestPointerRelock() } else { @@ -790,6 +875,7 @@ public final class StreamViewController: StreamViewControllerBase { if !wantsPointerLock { pointerLockWasEngaged = false } pointerRelockPending = false pointerRelockAttempt = 0 + pointerRelockQuietAttempt = 0 } let useGCMouse = captured && locked // Lock dropped (or capture ended) while the GCMouse path held a button down: once @@ -830,10 +916,12 @@ public final class StreamViewController: StreamViewControllerBase { pointerRelockAttempt = 0 } guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else { - // Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back - // and a click into the video re-captures. The caller invalidates the interaction, so - // the cursor can never stay hidden on a lock the system won't grant. + // Out of VISIBLE budget: give the cursor straight back (the caller invalidates the + // interaction, so it can never stay hidden on a lock the system won't grant) and hand + // off to the quiet tail, which keeps asking after the platform's post-Escape cooldown + // without costing the user anything while it does. pointerRelockPending = false + scheduleQuietRelock() return } pointerRelockAttempt += 1 @@ -881,6 +969,43 @@ public final class StreamViewController: StreamViewControllerBase { } } } + + /// Keep asking for the lock after the visible burst has given up — past the cooldown the + /// platform applies to its own Escape gesture, which is the window the burst spends entirely. + /// + /// Deliberately NOT a longer burst. `pointerRelockPending` hides the cursor and mutes absolute + /// motion, which is only tolerable for the couple of frames a fast re-grab takes; holding that + /// for seconds would trade a released pointer for a frozen one. These attempts leave the + /// pointer fully usable — if they all fail the user sees exactly today's behaviour, and a click + /// is still the immediate way back. + /// + /// Each attempt presents a real false→true transition (the same escalation the burst uses on + /// its later tries) because re-asserting a value the system already holds is what didn't take. + /// A grant arrives as a `didChange` → `syncPointerLock`, which resets the counters, so a + /// successful attempt silently ends the tail. + private func scheduleQuietRelock() { + guard pointerRelockQuietAttempt < Self.pointerRelockQuietDelays.count else { return } + let delay = Self.pointerRelockQuietDelays[pointerRelockQuietAttempt] + pointerRelockQuietAttempt += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + // Still wanted, still ours to want, and still not held — otherwise the tail is moot. + guard self.wantsPointerLock, self.pointerLockWasEngaged, + self.pointerLockEngaged() != true, + self.view.window?.windowScene?.activationState == .foregroundActive + else { return } + self.pointerLockForcedOff = true + self.setNeedsUpdateOfPrefersPointerLocked() + self.updatePointerLockChain() + DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) { + [weak self] in + guard let self else { return } + self.pointerLockForcedOff = false + self.setNeedsUpdateOfPrefersPointerLocked() + self.scheduleQuietRelock() // no-op once the delays are spent, or once granted + } + } + } #endif deinit { diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index c0949c02..11449db1 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -908,7 +908,27 @@ fn pump( } } Err(PunktfunkError::NoFrame) => {} - Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()), + // The session ended. `None` here means "normal finish" to every embedder — the browse + // console returns to the library with no status strip, the one-shot binary exits 0 + // quietly — so only an ending that actually went wrong should carry a message. + // Previously EVERY close reported "Host ended the session", which put an error-shaped + // line in front of the player for quitting their own game. + Err(PunktfunkError::Closed) => { + use punktfunk_core::client::PunktfunkEndReason as End; + break match connector.end_reason() { + // The player quit the game the host launched. Nothing to report; a launcher + // embedder returns to its library, which is where they were headed anyway. + End::GameExited => None, + // We closed it, or the host closed cleanly (an operator "End", or the session + // simply finishing). Both were asked for. + End::Local | End::HostEnded => None, + End::HostError => Some("The host ended the session with an error".to_string()), + End::Lost => Some("Connection lost".to_string()), + // No verdict (an older core, or the close raced the read): keep the wording + // this arm has always used rather than inventing a new one. + End::None => Some("Host ended the session".to_string()), + }; + } Err(e) => break Some(format!("session: {e:?}")), } diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index 25742f4d..428cd854 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -18,6 +18,11 @@ parse_deps = false # undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs # `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module). exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"] +# Reached by no exported SIGNATURE, so cbindgen's sweep misses it — but a C embedder needs the +# vocabulary: `punktfunk_connection_end_reason` writes one of these as a bare byte (deliberately, +# so the JNI/Swift sides can marshal a `u8` rather than an enum), which without this would leave +# the header documenting names it never defines. +include = ["PunktfunkEndReason"] [export.rename] "InputEvent" = "PunktfunkInputEvent" diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index bc1a9772..686b6c38 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -2273,6 +2273,45 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels( }) } +/// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +/// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable. +/// +/// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own +/// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable +/// while the connection is torn down, and a client that never calls it behaves exactly as it did +/// before this existed. +/// +/// **Most endings are not failures.** Before this, a client had no way to tell a player quitting +/// their game from a host falling off the network, so every client wrote one message for all of +/// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and +/// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy +/// for `HOST_ERROR` and `LOST`. +/// +/// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you. +/// +/// # Safety +/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_end_reason( + c: *mut PunktfunkConnection, + out: *mut u8, +) -> PunktfunkStatus { + guard(|| { + // SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller + // has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles. + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + if !out.is_null() { + // SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`. + unsafe { *out = c.inner.end_reason() as u8 }; + } + PunktfunkStatus::Ok + }) +} + /// One decoded audio frame from [`punktfunk_connection_next_audio_pcm`]: interleaved 32-bit /// float PCM at 48 kHz, in the canonical wire channel order `FL FR FC LFE RL RR SL SR` (the /// first `channels` of it). `samples` points at `frame_count * channels` floats and borrows diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index f9c15d8b..865f6b3f 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -110,6 +110,91 @@ pub struct MicUplinkStats { /// the control task is wedged, which callers treat as a closed session. const CTRL_QUEUE: usize = 32; +/// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the +/// C surface. +/// +/// The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a +/// player quitting their game and a host falling off the network both arrive as "the session +/// ended", and a client with no way to separate them has to word all of them the same. Every client +/// worded them as failures. +/// +/// Ordered loosely from "the user did this on purpose" to "something went wrong". Values are part +/// of the C ABI: append only, never renumber. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PunktfunkEndReason { + /// Not ended (or ended before a reason could be observed). Also what an unknown future value + /// decodes to, so an older client reading a newer core degrades to "no opinion". + None = 0, + /// **This client** closed the session — the user pressed stop, or the handle was dropped. + /// Nothing to report: the UI already knows, it initiated it. + Local = 1, + /// The host's launched game exited ([`crate::quic::APP_EXITED_CLOSE_CODE`]). A normal finish, + /// and the one reason a launcher client can act on: go back to the library the title was + /// launched from rather than all the way out to host selection. + GameExited = 2, + /// The host ended the session cleanly and deliberately — an operator "End" in the console, or + /// the session simply finishing. Normal; say so plainly or say nothing. + HostEnded = 3, + /// The host closed reporting a failure of its own. Worth showing, and the host's log has the + /// detail. + HostError = 4, + /// The connection died rather than being closed: idle timeout, reset, the network going away. + /// This — and only this — is the "the host may be asleep, wake it" case. + Lost = 5, +} + +impl PunktfunkEndReason { + /// Decode the wire/ABI byte. Unknown values become [`Self::None`] rather than panicking: this + /// crosses an ABI where the writer may be newer than the reader. + pub fn from_u8(v: u8) -> Self { + match v { + 1 => Self::Local, + 2 => Self::GameExited, + 3 => Self::HostEnded, + 4 => Self::HostError, + 5 => Self::Lost, + _ => Self::None, + } + } + + /// Whether this ending is an ordinary outcome rather than something to alarm the user about. + /// + /// The single question nearly every client actually asks. `Local`, `GameExited` and `HostEnded` + /// are all things that were *meant* to happen; only a host-side failure or a dead connection + /// are not. [`Self::None`] counts as normal — no evidence of trouble is not evidence of it. + pub fn is_normal(self) -> bool { + !matches!(self, Self::HostError | Self::Lost) + } +} + +#[cfg(feature = "quic")] +impl From<&quinn::ConnectionError> for PunktfunkEndReason { + /// Classify the QUIC close. + /// + /// Only two application codes ever arrive from a host at session end: `APP_EXITED` when the + /// game it launched quit, and the teardown's own `0` (clean) / `1` (the session returned an + /// error) from `native.rs`. Anything else with an application code is a deliberate host-side + /// close we do not have a name for, which is still closer to "the host ended it" than to a + /// dead link — but a code we have never issued is more likely a fault than a courtesy, so it + /// lands in `HostError` where it will at least be visible. + fn from(e: &quinn::ConnectionError) -> Self { + match e { + quinn::ConnectionError::LocallyClosed => Self::Local, + quinn::ConnectionError::ApplicationClosed(ac) => { + match u32::try_from(u64::from(ac.error_code)) { + Ok(crate::quic::APP_EXITED_CLOSE_CODE) => Self::GameExited, + Ok(0) => Self::HostEnded, + _ => Self::HostError, + } + } + // TimedOut, Reset, VersionMismatch, TransportError, CidsExhausted, and the peer's + // transport-level close: the link failed, nobody said goodbye. + _ => Self::Lost, + } + } +} + pub struct NativeClient { // Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust // embedders can share one `Arc` across their plane threads (the same @@ -180,6 +265,9 @@ pub struct NativeClient { /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: Arc, + /// A [`PunktfunkEndReason`] as `u8`, latched with `shutdown` — see + /// [`NativeClient::end_reason`]. + end_reason: Arc, /// Deliberate-quit flag: [`NativeClient::disconnect_quit`] sets it, so the worker closes the QUIC /// connection with [`crate::quic::QUIT_CLOSE_CODE`] (a user "stop") instead of code 0 — telling the /// host to skip the keep-alive linger. A plain drop leaves it false → an unwanted-disconnect close. @@ -448,6 +536,7 @@ impl NativeClient { std::sync::mpsc::sync_channel::(CURSOR_STATE_QUEUE); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); + let end_reason = Arc::new(AtomicU8::new(PunktfunkEndReason::None as u8)); let quit = Arc::new(AtomicBool::new(false)); let mode_slot = Arc::new(std::sync::Mutex::new(mode)); let probe = Arc::new(Mutex::new(ProbeState::default())); @@ -463,6 +552,7 @@ impl NativeClient { let host = host.to_string(); let frame_chan_w = frame_chan.clone(); let shutdown_w = shutdown.clone(); + let end_reason_w = end_reason.clone(); let quit_w = quit.clone(); let mode_slot_w = mode_slot.clone(); let probe_w = probe.clone(); @@ -538,6 +628,7 @@ impl NativeClient { clip_cmd_rx, ready_tx, shutdown: shutdown_w, + end_reason: end_reason_w, quit: quit_w, mode_slot: mode_slot_w, probe: probe_w, @@ -591,6 +682,7 @@ impl NativeClient { host_caps: negotiated.host_caps, probe, shutdown, + end_reason, quit, worker: Some(worker), frames_dropped, @@ -809,6 +901,29 @@ impl NativeClient { self.shutdown.load(Ordering::SeqCst) } + /// WHY the session ended — see [`PunktfunkEndReason`]. + /// + /// A refinement of [`is_session_ended`](Self::is_session_ended), never a substitute: it stays + /// [`PunktfunkEndReason::None`] until that is true, and every client that ignores it behaves + /// exactly as it did before this existed. + /// + /// What it is FOR: **most endings are not failures.** A client that cannot tell them apart has + /// to pick one wording for all of them, and every such client picked an error — "Session ended + /// by ", "Connection lost — the host may be asleep" — including when the player quit the + /// game themselves. This is the discriminator that lets each client stay quiet for a normal + /// finish, return to its library when a launched game exits, and reserve the alarming copy for + /// an ending that actually deserves it. + /// + /// Latches, so it is still readable while the connection is being torn down. + pub fn end_reason(&self) -> PunktfunkEndReason { + PunktfunkEndReason::from_u8(self.end_reason.load(Ordering::SeqCst)) + } + + /// Shorthand for the single most actionable reason: the host's launched game exited. + pub fn ended_because_game_exited(&self) -> bool { + self.end_reason() == PunktfunkEndReason::GameExited + } + /// Register the calling thread as latency-critical so a later /// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own /// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index e6ab8e58..8308f2e2 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -65,6 +65,7 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, ready_tx, shutdown, + end_reason, quit, mode_slot, probe, @@ -194,12 +195,17 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, )); - // Watch for connection close → stop the pump. + // Watch for connection close → stop the pump, and classify WHY. { let shutdown = shutdown.clone(); + let end_reason = end_reason.clone(); let conn = conn.clone(); tokio::spawn(async move { - conn.closed().await; + let why = conn.closed().await; + // Latch the reason BEFORE `shutdown`: the two are observed by different threads, and a + // client that reacts to the shutdown flag must never find the reason still unset. + let reason = crate::client::PunktfunkEndReason::from(&why); + end_reason.store(reason as u8, Ordering::SeqCst); shutdown.store(true, Ordering::SeqCst); }); } diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index 35685135..0b8e0fa5 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -68,6 +68,9 @@ pub(crate) struct WorkerArgs { pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) ready_tx: std::sync::mpsc::Sender>, pub(crate) shutdown: Arc, + /// A [`crate::client::PunktfunkEndReason`] as `u8`, classified from the connection's close and + /// latched alongside `shutdown` (see [`NativeClient::end_reason`]). + pub(crate) end_reason: Arc, /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. pub(crate) quit: Arc, pub(crate) mode_slot: Arc>, diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 122486f3..60b559dd 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -138,7 +138,14 @@ pub use stats::Stats; /// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never /// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and /// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. -pub const ABI_VERSION: u32 = 16; +/// v17: added `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` vocabulary — asks, +/// once a session has ended, WHY: this client closed it, the host's launched game exited (its close +/// carried [`quic::APP_EXITED_CLOSE_CODE`], which the host has sent since long before this bump +/// with nothing consuming it), the host ended it cleanly, the host reported a failure, or the +/// connection was simply lost. Purely a read of state the core already had: no new call is required +/// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same +/// bytes either way, so [`WIRE_VERSION`] is unchanged. +pub const ABI_VERSION: u32 = 17; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs index 975198a8..ed1885ec 100644 --- a/crates/punktfunk-host/src/gamelease.rs +++ b/crates/punktfunk-host/src/gamelease.rs @@ -52,6 +52,24 @@ const EXIT_CONFIRM: Duration = Duration::from_secs(3); const SHIM_WINDOW: Duration = Duration::from_secs(5); /// How long a game gets to close on its own after a polite request, before it is killed outright. const TERM_GRACE: Duration = Duration::from_secs(10); +/// How long [`crate::procscan::running_hint`] may hold off the exit once the game's processes have +/// all gone. +/// +/// The hint is a tie-breaker for a scan that momentarily cannot see the game — a launcher re-execing, +/// an engine relaunching itself into a new pid — and those gaps are over in seconds, an order of +/// magnitude inside this window. Past it, a game nothing can find is gone whatever the hint says. +/// +/// **Bounded because the hint's backing state is not guaranteed to be truthful.** Windows reads +/// Steam's per-app `Running` registry flag, which Steam leaves set whenever it does not cleanly +/// observe the exit (Steam crashed or was closed first, the game re-parented, a launcher appid stays +/// set) — and `steam_running_hint` believes the first hive that says so, including a stale one left +/// in another profile. An UNBOUNDED veto turns that into a session that never ends on its own: the +/// console shows the game running for as long as the host does, `session_on_game_exit` never fires, +/// and only a manual "End" gets the stream back (field report 2026-08-06, Windows host 0.24.0). +/// +/// Ending a moment too early is the cheaper failure: the stream drops while the game lives (the user +/// reconnects, and `finish` never kills anything). Ending never is the bug above. +const VETO_LIMIT: Duration = Duration::from_secs(30); /// A child process the host spawned for a launch, and what may safely be signalled for it. #[derive(Clone, Copy, Debug)] @@ -540,29 +558,59 @@ fn watch(shared: Arc, mut child: Option, on_ex gone_since = None; vetoed = false; shared.last_seen_ms.store(now_ms(), Ordering::Relaxed); - } else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM { - // Last check before ending a session: does anything outside the process scan still think - // the game is up? Only a veto, never a reason to call it running — see - // `procscan::running_hint`. The failure mode of honoring it is a stream that stays up. - if crate::procscan::running_hint(&shared.spec) == Some(true) { - if !vetoed { - vetoed = true; - tracing::info!( - title = %shared.game.title, - "no game processes found, but its launcher still reports it running — not \ - ending the session" - ); + } else { + // How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by + // the veto below — letting it run on is exactly what bounds the veto. + let gone_for = gone_since.get_or_insert_with(Instant::now).elapsed(); + if gone_for >= EXIT_CONFIRM { + // Last check before ending a session: does anything outside the process scan still + // think the game is up? Only a veto, never a reason to call it running — see + // `procscan::running_hint`. + let hint_running = crate::procscan::running_hint(&shared.spec) == Some(true); + if !exit_confirmed(gone_for, hint_running) { + if !vetoed { + vetoed = true; + tracing::info!( + title = %shared.game.title, + veto_limit_s = VETO_LIMIT.as_secs(), + "no game processes found, but its launcher still reports it running — \ + holding off on ending the session" + ); + } + } else { + if hint_running { + // The veto outlived its usefulness: nothing this scan can see has existed + // for VETO_LIMIT, so the launcher's opinion is stale, not early. + tracing::warn!( + title = %shared.game.title, + gone_for_s = gone_for.as_secs(), + "its launcher still reports the game running, but nothing of it has \ + been on the box for {}s — treating that as a stale flag and ending \ + the session", + VETO_LIMIT.as_secs() + ); + } + finish(&shared, &on_exit, "the game exited"); + return; } - gone_since = None; - } else { - finish(&shared, &on_exit, "the game exited"); - return; } } std::thread::sleep(POLL); } } +/// Whether a game nothing can find any more counts as exited: absent for at least [`EXIT_CONFIRM`], +/// and either unopposed or absent long enough that the opposition ([`crate::procscan::running_hint`] +/// saying `Some(true)`) has been overruled by [`VETO_LIMIT`]. +/// +/// Split out of the watch loop because it is the one rule in this file whose *bound* is the fix: +/// the loop itself polls a live process table and cannot be unit-tested, which is how an unbounded +/// veto shipped. Pure, so the table below is the whole contract. +#[cfg(any(target_os = "linux", windows))] +fn exit_confirmed(gone_for: Duration, hint_running: bool) -> bool { + gone_for >= EXIT_CONFIRM && (!hint_running || gone_for >= VETO_LIMIT) +} + /// Record the exit and, unless the host itself ended the game, run the session-ending action. #[cfg(any(target_os = "linux", windows))] fn finish(shared: &Arc, on_exit: &OnExit, why: &str) { @@ -1037,6 +1085,34 @@ mod tests { .any(|(s, _)| s.game.id.as_deref() == Some(id)) } + /// The exit rule, including the thing that was missing: the veto ENDS. + /// + /// Field 2026-08-06 (Windows 0.24.0): Steam's per-app `Running` flag was left set after the game + /// exited, the watcher honoured it on every pass and reset its own confirm window each time, so + /// the game read as running for the life of the host and the stream never auto-ended. The last + /// case below is that regression. + #[cfg(any(target_os = "linux", windows))] + #[test] + fn the_launcher_veto_expires_instead_of_pinning_a_session_open() { + let brief = EXIT_CONFIRM / 2; + let confirmed = EXIT_CONFIRM + Duration::from_secs(1); + let long = VETO_LIMIT + Duration::from_secs(1); + + // Too early to call it either way — a process swap is still plausible. + assert!(!exit_confirmed(brief, false)); + assert!(!exit_confirmed(brief, true)); + // Gone past the confirm window with nothing objecting: exited. + assert!(exit_confirmed(confirmed, false)); + // Same, but the launcher objects — that is what the veto is FOR, so hold off. + assert!(!exit_confirmed(confirmed, true)); + // …and this is the bound. Still objecting, but nothing of the game has existed for + // VETO_LIMIT, so the objection is stale and the session ends anyway. + assert!(exit_confirmed(long, true)); + assert!(exit_confirmed(long, false)); + // (The middle two cases together also pin VETO_LIMIT > EXIT_CONFIRM: a veto that did not + // outlast the window it overrides could never hold anything off in the first place.) + } + #[test] fn kind_follows_what_the_launch_gave_us() { // Nested wins over everything: the display layer owns the lifetime. diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 847b97ee..9bf9d924 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -76,7 +76,14 @@ // capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never // receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and // arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged. -#define PUNKTFUNK_ABI_VERSION 16 +// v17: added `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` vocabulary — asks, +// once a session has ended, WHY: this client closed it, the host's launched game exited (its close +// carried [`quic::APP_EXITED_CLOSE_CODE`], which the host has sent since long before this bump +// with nothing consuming it), the host ended it cleanly, the host reported a failure, or the +// connection was simply lost. Purely a read of state the core already had: no new call is required +// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same +// bytes either way, so [`WIRE_VERSION`] is unchanged. +#define PUNKTFUNK_ABI_VERSION 17 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -1616,6 +1623,63 @@ typedef uint8_t PunktfunkInputKind; #endif // __STDC_VERSION__ >= 202311L #endif // __cplusplus +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the +// C surface. +// +// The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a +// player quitting their game and a host falling off the network both arrive as "the session +// ended", and a client with no way to separate them has to word all of them the same. Every client +// worded them as failures. +// +// Ordered loosely from "the user did this on purpose" to "something went wrong". Values are part +// of the C ABI: append only, never renumber. +enum PunktfunkEndReason +#if defined(__cplusplus) || __STDC_VERSION__ >= 202311L + : uint8_t +#endif // defined(__cplusplus) || __STDC_VERSION__ >= 202311L + { +#if defined(PUNKTFUNK_FEATURE_QUIC) + // Not ended (or ended before a reason could be observed). Also what an unknown future value + // decodes to, so an older client reading a newer core degrades to "no opinion". + PUNKTFUNK_END_REASON_NONE = 0, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // **This client** closed the session — the user pressed stop, or the handle was dropped. + // Nothing to report: the UI already knows, it initiated it. + PUNKTFUNK_END_REASON_LOCAL = 1, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host's launched game exited ([`crate::quic::APP_EXITED_CLOSE_CODE`]). A normal finish, + // and the one reason a launcher client can act on: go back to the library the title was + // launched from rather than all the way out to host selection. + PUNKTFUNK_END_REASON_GAME_EXITED = 2, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host ended the session cleanly and deliberately — an operator "End" in the console, or + // the session simply finishing. Normal; say so plainly or say nothing. + PUNKTFUNK_END_REASON_HOST_ENDED = 3, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host closed reporting a failure of its own. Worth showing, and the host's log has the + // detail. + PUNKTFUNK_END_REASON_HOST_ERROR = 4, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The connection died rather than being closed: idle timeout, reset, the network going away. + // This — and only this — is the "the host may be asleep, wake it" case. + PUNKTFUNK_END_REASON_LOST = 5, +#endif +}; +#ifndef __cplusplus +#if __STDC_VERSION__ >= 202311L +typedef enum PunktfunkEndReason PunktfunkEndReason; +#else +typedef uint8_t PunktfunkEndReason; +#endif // __STDC_VERSION__ >= 202311L +#endif // __cplusplus +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Per-session colour signalling (CICP / ITU-T H.273 code points) the host resolved for the // encoded video, carried on [`Welcome`]. A client configures its decoder/presenter from these @@ -2498,6 +2562,28 @@ PunktfunkStatus punktfunk_connection_next_audio(PunktfunkConnection *c, PunktfunkStatus punktfunk_connection_audio_channels(PunktfunkConnection *c, uint8_t *out); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +// (`PUNKTFUNK_END_REASON_*`). The return status reports only whether the handle was usable. +// +// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own +// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable +// while the connection is torn down, and a client that never calls it behaves exactly as it did +// before this existed. +// +// **Most endings are not failures.** Before this, a client had no way to tell a player quitting +// their game from a host falling off the network, so every client wrote one message for all of +// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and +// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy +// for `HOST_ERROR` and `LOST`. +// +// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you. +// +// # Safety +// `c` is a valid connection handle; `out` is NULL or writable for one `u8`. +PunktfunkStatus punktfunk_connection_end_reason(PunktfunkConnection *c, uint8_t *out); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Pull the next audio frame and **decode it in-core** to interleaved f32 PCM — for embedders // without a multistream-capable Opus decoder (e.g. Apple, whose AudioToolbox Opus path is