diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index 0e33e3d..d77232b 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -208,6 +208,8 @@ fun GamepadShell( GamepadScreen.Library -> libraryHost?.let { host -> LibraryScreen( host = host, + settings = settings, + onLaunched = onConnected, onBack = { screen = GamepadScreen.Home; libraryHost = null }, navActive = s == screen, ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index 2be7467..2225f82 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -63,9 +63,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -/** Handshake budget for a normal connect (the prior hardcoded value, now passed explicitly). */ -private const val CONNECT_TIMEOUT_MS = 10_000 - /** * Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park * window (~180 s) so a slow operator approval still lands on this same parked connection rather than @@ -181,25 +178,10 @@ fun ConnectScreen( // it survives a DHCP address change; else by address:port). Mirrors the Apple client. val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } } - // The one place the full nativeConnect is issued (shared by the normal connect and the - // request-access path), including the HDR/gamepad derivation both need. - suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long { - // Advertise HDR only when the user enabled it AND this device's display can present it - // (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map). - val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context) - // "Automatic" resolves to a concrete pad type from the connected controller's VID/PID - // (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An - // explicit choice is passed through unchanged. - val gamepadPref = Gamepad.resolvePref(settings.gamepad) - return withContext(Dispatchers.IO) { - NativeBridge.nativeConnect( - targetHost, targetPort, w, h, hz, - id.certPem, id.privateKeyPem, pinHex, - settings.bitrateKbps, settings.compositor, gamepadPref, - hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs, - ) - } - } + // Issue the native connect (shared by the normal connect and the request-access path). A plain + // desktop connect (no library launch) — the library launcher calls [connectToHost] with an id. + suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long = + connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs) // The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint // the host presented (as an unpaired known host) so the next connect goes straight through and it diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt new file mode 100644 index 0000000..be16e50 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt @@ -0,0 +1,45 @@ +package io.unom.punktfunk + +import android.content.Context +import io.unom.punktfunk.kit.Gamepad +import io.unom.punktfunk.kit.NativeBridge +import io.unom.punktfunk.kit.security.ClientIdentity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** Handshake budget for a normal / library-launch connect (not the long request-access park). */ +const val CONNECT_TIMEOUT_MS = 10_000 + +/** + * The one place [NativeBridge.nativeConnect] is assembled — shared by [ConnectScreen] and the library + * launcher ([LibraryScreen]). Derives the mode / HDR / gamepad settings the host needs from + * [settings]. [pinHex] is the pinned fingerprint (empty ⇒ TOFU). [launch] is a store-qualified library + * id (`steam:` / `custom:`) to boot straight into a game, or `null` for the desktop. + * Returns the session handle, or `0` on failure. Call off the main thread. + */ +suspend fun connectToHost( + context: Context, + settings: Settings, + identity: ClientIdentity, + host: String, + port: Int, + pinHex: String, + launch: String?, + timeoutMs: Int = CONNECT_TIMEOUT_MS, +): Long { + // Advertise HDR only when the user enabled it AND this device's display can present it (else the + // host sends a proper SDR stream rather than PQ the panel would mis-tone-map). + val (w, h, hz) = settings.effectiveMode(context) + val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context) + // "Automatic" resolves to a concrete pad type from the connected controller's VID/PID. + val gamepadPref = Gamepad.resolvePref(settings.gamepad) + return withContext(Dispatchers.IO) { + NativeBridge.nativeConnect( + host, port, w, h, hz, + identity.certPem, identity.privateKeyPem, pinHex, + settings.bitrateKbps, settings.compositor, gamepadPref, + hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs, + launch, + ) + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index 9419057..1d2a8bd 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -1,8 +1,10 @@ package io.unom.punktfunk +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -57,6 +59,7 @@ import io.unom.punktfunk.kit.library.GameEntry import io.unom.punktfunk.kit.library.LibraryClient import io.unom.punktfunk.kit.library.LibraryResult import io.unom.punktfunk.kit.library.mtlsHttpClient +import io.unom.punktfunk.kit.security.ClientIdentity import io.unom.punktfunk.kit.security.IdentityStore import io.unom.punktfunk.kit.security.KnownHost import io.unom.punktfunk.kit.security.obtainIdentity @@ -73,17 +76,27 @@ import kotlinx.coroutines.withContext private sealed class LibState { object Loading : LibState() - data class Ready(val games: List, val loader: ImageLoader) : LibState() + // Carries the client identity so a launch can dial the host over the same pinned mTLS trust. + data class Ready(val games: List, val loader: ImageLoader, val identity: ClientIdentity) : LibState() data class Message(val text: String) : LibState() // unauthorized / empty / error } @Composable -fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) { +fun LibraryScreen( + host: KnownHost, + settings: Settings, + onLaunched: (Long) -> Unit, + onBack: () -> Unit, + navActive: Boolean = true, +) { BackHandler(onBack = onBack) val context = LocalContext.current + val scope = rememberCoroutineScope() val hazeState = remember { HazeState() } val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE var state by remember { mutableStateOf(LibState.Loading) } + // A launch (connect) in flight: shows an overlay + gates the pad so a second press can't dial twice. + var launching by remember { mutableStateOf(false) } LaunchedEffect(host.address, host.port, host.fpHex) { state = LibState.Loading @@ -101,7 +114,7 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true LibState.Message("No games found on this host.") } else { val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex) - LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build()) + LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build(), id) } is LibraryResult.Unauthorized -> LibState.Message(res.message) is LibraryResult.Error -> LibState.Message(res.message) @@ -118,11 +131,45 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true when (val s = state) { is LibState.Loading -> LoadingState() is LibState.Message -> MessageState(s.text) - is LibState.Ready -> Coverflow(s.games, s.loader, navActive) + is LibState.Ready -> Coverflow(s.games, s.loader, navActive && !launching) { game -> + if (!launching) { + launching = true + scope.launch { + // Dial the host over the same pinned mTLS trust, booting straight + // into this title (the host resolves `launch` = its library id). + val handle = connectToHost( + context, settings, s.identity, + host.address, host.port, host.fpHex, launch = game.id, + ) + launching = false + if (handle != 0L) onLaunched(handle) + else Toast.makeText( + context, + "Launch failed — check the host and try again.", + Toast.LENGTH_LONG, + ).show() + } + } + } } } } } + // Launching overlay — the connect + host-side game boot takes a moment; block the pad while it runs. + if (launching) { + Box( + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + CircularProgressIndicator(color = Color.White) + Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge) + } + } + } // Floating legend at the shared spot — same landscape-aware inset as every other console // screen (ignore the safe area in landscape, where the bottom edge isn't a tap target). Box( @@ -130,7 +177,13 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true .then(if (landscape) Modifier else Modifier.systemBarsPadding()) .padding(ConsoleLegendInset), ) { - GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState) + GamepadHintBar( + buildList { + if (state is LibState.Ready) add(PadGlyph.hint('A', "Launch")) + add(PadGlyph.hint('B', "Close", onClick = onBack)) + }, + hazeState = hazeState, + ) } } } @@ -155,7 +208,12 @@ private fun MessageState(text: String) { } @Composable -private fun Coverflow(games: List, loader: ImageLoader, navActive: Boolean) { +private fun Coverflow( + games: List, + loader: ImageLoader, + navActive: Boolean, + onLaunch: (GameEntry) -> Unit, +) { BoxWithConstraints(Modifier.fillMaxSize()) { // Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen. val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp) @@ -167,16 +225,15 @@ private fun Coverflow(games: List, loader: ImageLoader, navActive: Bo LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage } val current = games.getOrNull(navTarget) - // Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a - // coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes - // via the screen's BackHandler. + // Controller nav: the pad drives the coverflow. Left/right steps a coalesced target the pager + // chases; A launches the centred title; B closes via the screen's BackHandler. GamepadNavEffect( active = navActive && games.isNotEmpty(), onMove = { dir -> val t = (navTarget + dir).coerceIn(0, games.lastIndex) if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } } }, - onActivate = { /* launch a title — browse-only for now */ }, + onActivate = { games.getOrNull(navTarget)?.let(onLaunch) }, ) Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { @@ -198,6 +255,11 @@ private fun Coverflow(games: List, loader: ImageLoader, navActive: Bo .zIndex(-d) // centred cover on top, neighbours stacked behind .width(coverWidth) .height(coverHeight) + // Touch: tap the centred cover to launch it; tap a neighbour to bring it centre. + .clickable { + if (page == pagerState.currentPage) onLaunch(games[page]) + else scope.launch { pagerState.animateScrollToPage(page) } + } .graphicsLayer { // Centre at full size; EVERY neighbour settles to one size, so an even pitch // yields even VISUAL gaps. (A progressive shrink made the outer gaps grow — diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 34d297f..7a1c514 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -51,6 +51,9 @@ object NativeBridge { /** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */ preferredCodec: Int, timeoutMs: Int, + /** Store-qualified library id (`steam:` / `custom:`) to boot straight into a game, + * or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */ + launch: String?, ): Long /** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */ diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index e05fbf9..845481a 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -33,7 +33,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde } /// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps, -/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs): Long`. +/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`. +/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game. /// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty /// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). /// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` @@ -63,6 +64,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo audio_channels: jint, preferred_codec: jint, timeout_ms: jint, + launch: JString<'local>, ) -> jlong { let host: String = match env.get_string(&host) { Ok(s) => s.into(), @@ -74,6 +76,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo .unwrap_or_default(); let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default(); let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default(); + // A store-qualified library id (`steam:` / `custom:`) to boot straight into a game; + // null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`. + let launch: Option = env + .get_string(&launch) + .map(Into::into) + .ok() + .filter(|s: &String| !s.is_empty()); let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() { None @@ -124,7 +133,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo // + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below. punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC, preferred_codec.clamp(0, u8::MAX as jint) as u8, - None, // launch: default app + launch, // a store-qualified library id to boot into a game, or None for the desktop pin, // Some → Crypto on host-fp mismatch identity, // owned (cert, key) PEM, or None (anonymous) // Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"