diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index c28d6add..11c2471d 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -335,6 +335,16 @@ struct ContentView: View { active: fullscreenForSession && model.connection != nil, isFullscreen: $isFullscreen)) #endif + // A game launched from the library just exited, so the session ended on purpose: put the + // player back in that host's library rather than on host selection. Set on the outer Group + // (like the sheets below) so it survives the streaming → home transition the disconnect + // drives, and consumed here — the model hands the host over once and we clear it, so a + // later manual dismiss of the library can't be undone by a stale value. + .onChange(of: model.returnToLibrary) { _, host in + guard let host else { return } + model.returnToLibrary = nil + libraryTarget = host + } // On the outer Group so the sheet survives the trust-prompt → home transition // (the "Pair with PIN instead" path disconnects first — the host's accept loop // is sequential, a pairing connection would queue behind the live session). diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 8cdf017f..26c51431 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -65,6 +65,14 @@ final class SessionModel: ObservableObject { @Published private(set) var connection: PunktfunkConnection? /// The host this session is for (a value copy; identity = id). @Published private(set) var activeHost: StoredHost? + /// The library entry this session was launched with (`connect(launchID:)`), or nil if the user + /// just connected to the host's desktop. Kept because where the client should go when the + /// session ends depends on where it came FROM: a title launched out of the library belongs back + /// in that library when its game exits, not on the host-selection screen. + private var launchedTitleID: String? + /// Set when a session ended because its game exited and it began as a library launch: the host + /// whose library to reopen. The view layer consumes it and sets it back to nil. + @Published var returnToLibrary: StoredHost? /// The settings THIS session runs on — the globals with its profile overlaid, resolved once at /// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for /// the readers that live in PunktfunkKit and can't see this model. @@ -249,6 +257,7 @@ final class SessionModel: ObservableObject { guard phase == .idle else { return } phase = .connecting activeHost = host + launchedTitleID = launchID errorMessage = nil settings = effective statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal @@ -607,6 +616,8 @@ final class SessionModel: ObservableObject { } connection = nil activeHost = nil + // Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer. + launchedTitleID = nil phase = .idle fps = 0 mbps = 0 @@ -626,10 +637,25 @@ 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. + // The host closes with APP_EXITED when the game it launched for this session quit, which is + // a normal finish the player just performed, not a failure to report. + let gameExited = conn.endedBecauseGameExited + // 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)." + if gameExited { + if cameFromLibrary, let host { + returnToLibrary = host + } + } else { + errorMessage = "Session ended by \(name)." + } } /// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 93d319f0..3cf6b5be 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -1430,6 +1430,24 @@ public final class PunktfunkConnection { } } + /// Did this session end because **the game the host launched for it exited**, rather than the + /// stream dropping out? + /// + /// Only meaningful once the session HAS ended (a plane threw `.closed`, or `onSessionEnd` + /// fired) — before that it is simply false. False also covers every other ending: a user stop, + /// the host going away, network loss, an idle timeout. Read it before tearing the connection + /// down; once `close()` has been requested this reports false like any other ending, which is + /// the safe direction (the caller falls back to its normal end-of-session handling). + /// + /// A game ending is a normal finish, not a failure — that is the whole point of asking. See + /// `punktfunk_connection_game_exited` (ABI v17). + public var endedBecauseGameExited: Bool { + guard let h = liveHandle() else { return false } + var out: UInt8 = 0 + guard punktfunk_connection_game_exited(h, &out) == statusOK else { return false } + return out != 0 + } + deinit { close() } /// Snapshot the handle unless close is pending (callers hold their plane lock). diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index bc1a9772..f894e340 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -2273,6 +2273,42 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels( }) } +/// Did this session end because **the game the host launched for it exited**? `*out` is set to 1 +/// when it did and 0 otherwise; the return status reports only whether the handle was usable. +/// +/// A refinement of "the session ended", never a substitute — read it only once a plane has +/// returned [`PunktfunkStatus::Closed`] (or the embedder's own end-of-session signal fired), and +/// treat 0 as "ended for some other reason" (user stop, host gone, network loss, idle timeout). +/// It latches, so it is still readable while the connection is being torn down, and a client that +/// never calls it behaves exactly as it did before this existed. +/// +/// The point is that a game ending is a normal finish, not a failure: a launcher client can send +/// the player back to the host's library — one tap from the next title — rather than reporting an +/// error and dropping to host selection for something the player just did on purpose. +/// +/// # 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_game_exited( + 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 = u8::from(c.inner.ended_because_game_exited()) }; + } + PunktfunkStatus::Ok + }) +} + /// One decoded audio frame from [`punktfunk_connection_next_audio_pcm`]: interleaved 32-bit /// float PCM at 48 kHz, in the canonical wire channel order `FL FR FC LFE RL RR SL SR` (the /// first `channels` of it). `samples` points at `frame_count * channels` floats and borrows diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index f9c15d8b..1db75644 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -180,6 +180,9 @@ pub struct NativeClient { /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: Arc, + /// Set with `shutdown` when the host's close carried [`crate::quic::APP_EXITED_CLOSE_CODE`] — + /// see [`NativeClient::ended_because_game_exited`]. + game_exited: Arc, /// Deliberate-quit flag: [`NativeClient::disconnect_quit`] sets it, so the worker closes the QUIC /// connection with [`crate::quic::QUIT_CLOSE_CODE`] (a user "stop") instead of code 0 — telling the /// host to skip the keep-alive linger. A plain drop leaves it false → an unwanted-disconnect close. @@ -448,6 +451,7 @@ impl NativeClient { std::sync::mpsc::sync_channel::(CURSOR_STATE_QUEUE); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); + let game_exited = Arc::new(AtomicBool::new(false)); 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 +467,7 @@ impl NativeClient { let host = host.to_string(); let frame_chan_w = frame_chan.clone(); let shutdown_w = shutdown.clone(); + let game_exited_w = game_exited.clone(); let quit_w = quit.clone(); let mode_slot_w = mode_slot.clone(); let probe_w = probe.clone(); @@ -538,6 +543,7 @@ impl NativeClient { clip_cmd_rx, ready_tx, shutdown: shutdown_w, + game_exited: game_exited_w, quit: quit_w, mode_slot: mode_slot_w, probe: probe_w, @@ -591,6 +597,7 @@ impl NativeClient { host_caps: negotiated.host_caps, probe, shutdown, + game_exited, quit, worker: Some(worker), frames_dropped, @@ -809,6 +816,24 @@ impl NativeClient { self.shutdown.load(Ordering::SeqCst) } + /// Whether the session ended because **the game the host launched for it exited** — the host + /// closed with [`crate::quic::APP_EXITED_CLOSE_CODE`] rather than dropping out. + /// + /// A refinement of [`is_session_ended`](Self::is_session_ended), never a substitute: it is only + /// ever true once that is, and false covers every other ending (user stop, host gone, network + /// loss, idle timeout) — so a client that ignores it behaves exactly as before. + /// + /// What it is FOR: a game ending is a normal, expected finish, not a failure. A launcher client + /// can read this and go back to the host's library — where the player is one tap from the next + /// title — instead of showing "session ended by " and dropping to host selection, which + /// reads as an error for something the player just did on purpose. + /// + /// Poll it after the session ends (a `Closed` on any plane, or `is_session_ended`); it latches, + /// so it is still readable while the connection is being torn down. + pub fn ended_because_game_exited(&self) -> bool { + self.game_exited.load(Ordering::SeqCst) + } + /// Register the calling thread as latency-critical so a later /// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own /// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index e6ab8e58..08694306 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -65,6 +65,7 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, ready_tx, shutdown, + game_exited, quit, mode_slot, probe, @@ -194,12 +195,22 @@ 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 record WHY if the host said so. { let shutdown = shutdown.clone(); + let game_exited = game_exited.clone(); let conn = conn.clone(); tokio::spawn(async move { - conn.closed().await; + let why = conn.closed().await; + // The host closes with APP_EXITED when the game it launched for this session exited. + // Latch that before `shutdown`, so any client that reacts to the shutdown flag can + // already read the reason — the two are observed by different threads. + if let quinn::ConnectionError::ApplicationClosed(ac) = &why { + if u32::try_from(u64::from(ac.error_code)) == Ok(crate::quic::APP_EXITED_CLOSE_CODE) + { + game_exited.store(true, Ordering::SeqCst); + } + } shutdown.store(true, Ordering::SeqCst); }); } diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index 35685135..d8029e75 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -68,6 +68,10 @@ pub(crate) struct WorkerArgs { pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) ready_tx: std::sync::mpsc::Sender>, pub(crate) shutdown: Arc, + /// Set alongside `shutdown` when the HOST's close carried + /// [`crate::quic::APP_EXITED_CLOSE_CODE`] — the launched game exited (see + /// [`NativeClient::ended_because_game_exited`]). + pub(crate) game_exited: Arc, /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. pub(crate) quit: Arc, pub(crate) mode_slot: Arc>, diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 122486f3..1c8530b9 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -138,7 +138,13 @@ 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_game_exited` — asks, once a session has ended, whether it +/// ended because the game the host launched for it EXITED (the host's close carried +/// [`quic::APP_EXITED_CLOSE_CODE`], which it has sent since long before this bump; nothing +/// consumed it). Purely a read of state the core already had: no new call is required of an +/// embedder, a client that never calls it is unchanged, and the host sends exactly the same bytes +/// either way, so [`WIRE_VERSION`] is unchanged. +pub const ABI_VERSION: u32 = 17; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 847b97ee..ce197656 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -76,7 +76,13 @@ // 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_game_exited` — asks, once a session has ended, whether it +// ended because the game the host launched for it EXITED (the host's close carried +// [`quic::APP_EXITED_CLOSE_CODE`], which it has sent since long before this bump; nothing +// consumed it). 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** @@ -2498,6 +2504,26 @@ PunktfunkStatus punktfunk_connection_next_audio(PunktfunkConnection *c, PunktfunkStatus punktfunk_connection_audio_channels(PunktfunkConnection *c, uint8_t *out); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Did this session end because **the game the host launched for it exited**? `*out` is set to 1 +// when it did and 0 otherwise; the return status reports only whether the handle was usable. +// +// A refinement of "the session ended", never a substitute — read it only once a plane has +// returned [`PunktfunkStatus::Closed`] (or the embedder's own end-of-session signal fired), and +// treat 0 as "ended for some other reason" (user stop, host gone, network loss, idle timeout). +// It latches, so it is still readable while the connection is being torn down, and a client that +// never calls it behaves exactly as it did before this existed. +// +// The point is that a game ending is a normal finish, not a failure: a launcher client can send +// the player back to the host's library — one tap from the next title — rather than reporting an +// error and dropping to host selection for something the player just did on purpose. +// +// # Safety +// `c` is a valid connection handle; `out` is NULL or writable for one `u8`. +PunktfunkStatus punktfunk_connection_game_exited(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