Merge remote-tracking branch 'origin/main'
ci / web (push) Failing after 36s
ci / rust (push) Successful in 1m8s
ci / docs-site (push) Failing after 34s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
apple / swift (push) Successful in 1m17s
docker / deploy-docs (push) Successful in 16s

This commit is contained in:
2026-06-12 21:12:02 +00:00
3 changed files with 50 additions and 4 deletions
@@ -197,6 +197,17 @@ public final class StreamLayerView: NSView {
self?.releaseCapture() 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() attemptPendingCapture()
} }
@@ -301,8 +312,13 @@ public final class StreamLayerView: NSView {
private func attemptPendingCapture() { private func attemptPendingCapture() {
guard pendingAutoCapture, window != nil, bounds.width > 0 else { return } guard pendingAutoCapture, window != nil, bounds.width > 0 else { return }
pendingAutoCapture = false // one shot, even if the engage below is refused
engageCapture(fromClick: false) 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) { private func engageCapture(fromClick: Bool) {
@@ -172,6 +172,10 @@ public final class StreamViewController: UIViewController {
self.connection = connection self.connection = connection
loadViewIfNeeded() loadViewIfNeeded()
#if os(iOS) #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 // Read the LIVE mode per touch batch an accepted requestMode() mid-stream
// changes the letterbox, and touches must follow it. // changes the letterbox, and touches must follow it.
streamView.currentHostMode = { [weak connection] in streamView.currentHostMode = { [weak connection] in
@@ -247,7 +251,10 @@ public final class StreamViewController: UIViewController {
observers.append(NotificationCenter.default.addObserver( observers.append(NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in ) { [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 } else { return }
self.setCaptured(true) self.setCaptured(true)
}) })
+25 -2
View File
@@ -1100,6 +1100,26 @@ pub async fn clock_sync(
pub mod endpoint { pub mod endpoint {
use std::sync::{Arc, Mutex}; 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<quinn::TransportConfig> {
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 /// 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). /// persist an identity and use [`server_with_identity`] so clients can pin it).
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> { pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
@@ -1142,7 +1162,8 @@ pub mod endpoint {
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?; .map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg) let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?; .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)?) 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) let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?; .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())?; 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) Ok(ep)
})(); })();
(ep, observed) (ep, observed)