diff --git a/clients/apple/Sources/PunktfunkKit/StreamView.swift b/clients/apple/Sources/PunktfunkKit/StreamView.swift index 91bac85..a72792e 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamView.swift @@ -197,6 +197,17 @@ public final class StreamLayerView: NSView { self?.releaseCapture() }) } + // Becoming key RETRIES a still-pending session-start auto-capture — the case where a + // session began (reconnect) while this window wasn't key yet, so engageCapture(fromClick: + // false) was refused by its key-window guard and, with no retry, capture stayed off and + // input dead. This is a no-op once capture engaged (pendingAutoCapture is cleared) and + // after a manual ⌘⎋/focus-loss release (the flag is already false), so it does NOT + // resurrect the deliberately-rejected "auto-grab on every activation" behavior. + windowObservers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main + ) { [weak self] _ in + self?.attemptPendingCapture() + }) attemptPendingCapture() } @@ -301,8 +312,13 @@ public final class StreamLayerView: NSView { private func attemptPendingCapture() { guard pendingAutoCapture, window != nil, bounds.width > 0 else { return } - pendingAutoCapture = false // one shot, even if the engage below is refused engageCapture(fromClick: false) + // Clear the one-shot only once it ACTUALLY engaged. If the engage was refused — the + // app/window isn't key yet (common right after a reconnect), or the cursor grab raced + // app activation — leave it armed so didBecomeKey (or the next layout pass) retries. + // This stays scoped to session start: a later manual release (⌘⎋, focus loss) doesn't + // re-arm it, so it never resurrects auto-grab-on-activation. + if captured { pendingAutoCapture = false } } private func engageCapture(fromClick: Bool) { diff --git a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift index 9268376..5bea2c8 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift @@ -172,6 +172,10 @@ public final class StreamViewController: UIViewController { self.connection = connection loadViewIfNeeded() #if os(iOS) + // Fresh session: drop any resign/foreground capture-restore state left over from a + // prior session (stop() doesn't clear it). Otherwise a stale `true` could later + // re-engage capture on a foreground that the new session never asked for. + wasCapturedOnResign = false // Read the LIVE mode per touch batch — an accepted requestMode() mid-stream // changes the letterbox, and touches must follow it. streamView.currentHostMode = { [weak connection] in @@ -247,7 +251,10 @@ public final class StreamViewController: UIViewController { observers.append(NotificationCenter.default.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in - guard let self, self.wasCapturedOnResign, self.captureEnabled, self.connection != nil + // inputCapture != nil: don't try to restore before this session's capture is wired + // up — setForwarding would silently no-op on the nil handlers and leave input dead. + guard let self, self.wasCapturedOnResign, self.captureEnabled, + self.connection != nil, self.inputCapture != nil else { return } self.setCaptured(true) }) diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index fe0df30..3e21f69 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -1100,6 +1100,26 @@ pub async fn clock_sync( pub mod endpoint { use std::sync::{Arc, Mutex}; + /// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the + /// load-bearing setting: with quinn's defaults it is OFF, so any quiet stretch on the + /// connection (no input, audio muted or stalled, a capture hiccup, a mode change) lets the + /// idle timer run out and quinn closes the session — surfacing to the embedder as + /// `next_au` → Closed. The native equivalent of Moonlight's ENet keepalive: a small PING + /// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so + /// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false + /// close, while a genuinely dead peer is still detected within `MAX_IDLE`. + fn stream_transport() -> Arc { + use std::time::Duration; + const MAX_IDLE: Duration = Duration::from_secs(20); + const KEEP_ALIVE: Duration = Duration::from_secs(4); + let mut t = quinn::TransportConfig::default(); + t.max_idle_timeout(Some( + quinn::IdleTimeout::try_from(MAX_IDLE).expect("20s is a valid QUIC idle timeout"), + )); + t.keep_alive_interval(Some(KEEP_ALIVE)); + Arc::new(t) + } + /// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts /// persist an identity and use [`server_with_identity`] so clients can pin it). pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result { @@ -1142,7 +1162,8 @@ pub mod endpoint { .map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?; let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg) .map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?; - let server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg)); + let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg)); + server_config.transport_config(stream_transport()); // keep-alive — see stream_transport Ok(quinn::Endpoint::server(server_config, addr)?) } @@ -1233,8 +1254,10 @@ pub mod endpoint { }; let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg) .map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?; + let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg)); + client_cfg.transport_config(stream_transport()); // keep-alive — see stream_transport let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?; - ep.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic_cfg))); + ep.set_default_client_config(client_cfg); Ok(ep) })(); (ep, observed)