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
|
||||
|
||||
Reference in New Issue
Block a user