diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 3354a63..65370fe 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -9,6 +9,7 @@ import android.os.Build import android.view.SurfaceHolder import android.view.SurfaceView import android.view.WindowManager +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -82,6 +83,31 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { } } + // Host-gone watchdog. When the host suspends/sleeps (or crashes, or drops off the network) it + // stops answering the QUIC keep-alive and the connection idle-times out (~8 s) — no more frames + // arrive and the decoder would otherwise sit frozen on its last decoded frame until the user + // manually backed out. Poll the native session-liveness flag (one atomic load, independent of the + // stats HUD) and, the moment the session is dead, drop back to the menu so the user can + // Wake-on-LAN the host instead of being stranded on a frozen picture. Mirrors the Apple client's + // onSessionEnd → sessionEnded() → disconnect(). The 1 s cadence + the ~8 s idle timeout is a + // deliberately generous window: the keep-alive holds a merely-quiet connection (a static desktop) + // open, so this fires only on a genuinely dead peer, never a false positive. Keyed on `handle`, so + // it stops the moment we navigate away (the handle is only freed later, in onDispose). + LaunchedEffect(handle) { + 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() + return@LaunchedEffect + } + } + } + // One-shot teardown guard. Both the SurfaceView callback and DisposableEffect tear down on the // way out, but `nativeClose` frees the handle — so once it's closed, NO path may touch the handle // again (use-after-free → SIGSEGV: the consistent back-while-streaming crash). Both run on the 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 c83f37e..34d297f 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 @@ -56,6 +56,15 @@ object NativeBridge { /** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */ external fun nativeHostFingerprint(handle: Long): String + /** + * Has the underlying QUIC session ended? `true` once the connection closed — a host suspend / + * crash / network drop idle-timed it out (~8 s), or the host closed it — from then on no frame + * ever arrives and the video sits frozen on its last one. The stream watchdog polls this (~1 Hz) + * to leave a dead stream and return to the menu, where the user can Wake-on-LAN the host, instead + * of stranding them on a frozen frame. `false` on a `0` handle. Cheap (one atomic load); UI-safe. + */ + external fun nativeSessionEnded(handle: Long): Boolean + /** * 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). diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index f406ffa..e05fbf9 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -192,6 +192,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp } } +/// `NativeBridge.nativeSessionEnded(handle): Boolean` — has the underlying QUIC session ended? +/// `true` once the connection closed (a host suspend / crash / network drop idle-timed it out, or the +/// host closed it) — from then on no more frames arrive and the video sits frozen on its last one. +/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where +/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0` +/// handle. Cheap (one atomic load); safe on the UI thread. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded( + _env: JNIEnv, + _this: JObject, + handle: jlong, +) -> jboolean { + jni_guard(0, || { + if handle == 0 { + return 0; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + jboolean::from(h.client.is_session_ended()) + }) +} + /// `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 diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index c1d9171..666c3ad 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -432,6 +432,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; + INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; @@ -471,6 +472,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Punktfunk; + INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone."; diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index c18bf4b..5f56093 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -582,6 +582,17 @@ impl NativeClient { self.frames_dropped.load(Ordering::Relaxed) } + /// Whether the underlying QUIC session has ended — the worker's connection-close watcher set the + /// shutdown flag (`conn.closed()` fired: a host suspend / crash / network drop idle-timed the + /// connection out, or the host closed it), or a deliberate [`disconnect_quit`](Self::disconnect_quit) + /// / drop did. Once `true`, every `next_*` plane returns [`PunktfunkError::Closed`] and no more + /// frames will ever arrive. A client watchdog polls this so it can leave a frozen stream and + /// return to the menu (where the user can wake the host) instead of sitting on the last decoded + /// frame forever — the poll-friendly counterpart to reacting to a `Closed` in a plane loop. + pub fn is_session_ended(&self) -> bool { + self.shutdown.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