Merge pull request 'Worktree field kleisty triage' (#69) from worktree-field-kleisty-triage into main
arch / build-publish (push) Failing after 40s
apple / swift (push) Successful in 1m26s
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 2m30s
deb / build-publish (push) Successful in 3m43s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Successful in 2m23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 6m51s
docker / builders-arm64cross (push) Failing after 25s
docker / deploy-docs (push) Failing after 1m57s
release / apple (push) Successful in 9m17s
deb / build-publish-host (push) Successful in 7m58s
android / android (push) Successful in 12m19s
ci / rust (push) Successful in 12m1s
flatpak / build-publish (push) Successful in 9m40s
apple / screenshots (push) Successful in 5m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 15m50s
windows-host / package (push) Canceled after 2m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 1s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s

Reviewed-on: #69
This commit was merged in pull request #69.
This commit is contained in:
2026-08-06 12:41:28 +00:00
20 changed files with 784 additions and 39 deletions
@@ -48,6 +48,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
@@ -62,6 +63,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<ActiveSession?>(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<String?>(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.
@@ -115,7 +121,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,
@@ -123,6 +142,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
@@ -234,11 +255,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<io.unom.punktfunk.kit.security.KnownHost?>(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 /
@@ -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(
@@ -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 lostthe 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 purposewhich 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)
@@ -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. */
@@ -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).
@@ -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
}
}
@@ -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
@@ -349,6 +349,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).
@@ -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
@@ -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).
@@ -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<UIPress>, 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<UIPress>, 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<UIPress>, 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 falsetrue 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 {
+21 -1
View File
@@ -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:?}")),
}
+5
View File
@@ -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"
+39
View File
@@ -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
+115
View File
@@ -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<NativeClient>` 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<Mutex<ProbeState>>,
shutdown: Arc<AtomicBool>,
/// A [`PunktfunkEndReason`] as `u8`, latched with `shutdown` — see
/// [`NativeClient::end_reason`].
end_reason: Arc<AtomicU8>,
/// 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::<crate::quic::CursorState>(CURSOR_STATE_QUEUE);
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
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 <host>", "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
+8 -2
View File
@@ -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);
});
}
@@ -68,6 +68,9 @@ pub(crate) struct WorkerArgs {
pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver<ClipCommand>,
pub(crate) ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
pub(crate) shutdown: Arc<AtomicBool>,
/// 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<AtomicU8>,
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
pub(crate) quit: Arc<AtomicBool>,
pub(crate) mode_slot: Arc<std::sync::Mutex<Mode>>,
+8 -1
View File
@@ -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**
+92 -16
View File
@@ -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<LeaseShared>, mut child: Option<std::process::Child>, 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<LeaseShared>, 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.
+87 -1
View File
@@ -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