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
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:
@@ -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 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)
|
||||
|
||||
@@ -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 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 {
|
||||
|
||||
@@ -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:?}")),
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user