diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 26c51431..86dbb4cc 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -640,20 +640,31 @@ final class SessionModel: ObservableObject { 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 + 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 - if gameExited { + 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 } - } else { + 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)." } } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 3cf6b5be..350b44f6 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -1430,24 +1430,49 @@ public final class PunktfunkConnection { } } - /// Did this session end because **the game the host launched for it exited**, rather than the - /// stream dropping out? + /// Why a stream session ended — the Swift mirror of `PunktfunkEndReason` (ABI v17). /// - /// 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 + /// The distinction that matters to a UI is normal vs alarming, and it is not a spectrum: a + /// player quitting their game and a host falling off the network both arrive as "the session + /// ended". Without this every client wrote one message for all of them, and every client chose + /// an error. + public enum SessionEndReason: UInt8, Sendable { + /// Not ended, or ended before a reason could be observed. Also the fallback for an + /// unrecognized value — the core may be newer than this code. + case none = 0 + /// This client closed the session. Nothing to report: the UI initiated it. + case local = 1 + /// The host's launched game exited. A normal finish, and the one reason worth acting on: + /// go back to the library the title was launched from. + case gameExited = 2 + /// The host ended the session deliberately (an operator "End", or it simply finished). + case hostEnded = 3 + /// The host closed reporting a failure of its own. + case hostError = 4 + /// The connection died rather than being closed: idle timeout, reset, network gone. This — + /// and only this — is the "the host may be asleep" case. + case lost = 5 + + /// Is this an ordinary outcome rather than something to alarm the user about? `.none` + /// counts as normal: no evidence of trouble is not evidence of it. + public var isNormal: Bool { self != .hostError && self != .lost } } + /// Why this session ended. Only meaningful once it HAS ended (a plane threw `.closed`, or + /// `onSessionEnd` fired) — before that it is `.none`. + /// + /// Read it before tearing the connection down: once `close()` has been requested this reports + /// `.none`, which is the safe direction (the caller falls back to its normal handling). + public var sessionEndReason: SessionEndReason { + guard let h = liveHandle() else { return .none } + var out: UInt8 = 0 + guard punktfunk_connection_end_reason(h, &out) == statusOK else { return .none } + return SessionEndReason(rawValue: out) ?? .none + } + + /// Shorthand for the single most actionable reason: the host's launched game exited. + public var endedBecauseGameExited: Bool { sessionEndReason == .gameExited } + deinit { close() } /// Snapshot the handle unless close is pending (callers hold their plane lock). diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index c0949c02..11449db1 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -908,7 +908,27 @@ fn pump( } } Err(PunktfunkError::NoFrame) => {} - Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()), + // The session ended. `None` here means "normal finish" to every embedder — the browse + // console returns to the library with no status strip, the one-shot binary exits 0 + // quietly — so only an ending that actually went wrong should carry a message. + // Previously EVERY close reported "Host ended the session", which put an error-shaped + // line in front of the player for quitting their own game. + Err(PunktfunkError::Closed) => { + use punktfunk_core::client::PunktfunkEndReason as End; + break match connector.end_reason() { + // The player quit the game the host launched. Nothing to report; a launcher + // embedder returns to its library, which is where they were headed anyway. + End::GameExited => None, + // We closed it, or the host closed cleanly (an operator "End", or the session + // simply finishing). Both were asked for. + End::Local | End::HostEnded => None, + End::HostError => Some("The host ended the session with an error".to_string()), + End::Lost => Some("Connection lost".to_string()), + // No verdict (an older core, or the close raced the read): keep the wording + // this arm has always used rather than inventing a new one. + End::None => Some("Host ended the session".to_string()), + }; + } Err(e) => break Some(format!("session: {e:?}")), }