diff --git a/crates/pf-client-core/src/access.rs b/crates/pf-client-core/src/access.rs new file mode 100644 index 00000000..0b246d4c --- /dev/null +++ b/crates/pf-client-core/src/access.rs @@ -0,0 +1,202 @@ +//! The session's effective access, client-side (design/per-client-access.md §7): one +//! snapshot type over the shared grant vocabulary, the preset label derived from the mask +//! (never stored — §3.2), the overlay chip's text, and the toast wording for a mid-session +//! [`AccessUpdate`](punktfunk_core::quic::AccessUpdate). Pure presentation logic on purpose — +//! the HOST enforces the mask whatever a client renders; everything here is the courtesy +//! that makes a limited session say what it is instead of feeling broken. +//! +//! The Apple/Android clients mirror these rules rather than link them — the labels, the +//! chip/notice wording and the derive-not-store rule below are the contract they copy. + +use punktfunk_core::quic::{GRANT_ALL, GRANT_PRESET_CONTROLLER_ONLY, GRANT_PRESET_VIEW_ONLY}; +use std::time::{Duration, Instant}; + +/// What this session may do and for how long — the client-side snapshot of the host's +/// [`Welcome`](punktfunk_core::quic::Welcome) advert, revised by every mid-session +/// [`AccessUpdate`](punktfunk_core::quic::AccessUpdate) (latest wins). Carried on +/// [`SessionEvent::Access`](crate::session::SessionEvent::Access); the default — full +/// control, permanent — is exactly what an old host's Welcome decodes to, so a session +/// against one renders today's chrome unchanged (no chip, everything enabled). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SessionAccess { + /// The effective grant bitmask ([`punktfunk_core::quic::GRANT_GAMEPAD`] family). + pub grants: u32, + /// When this access ends, on the CLIENT's monotonic clock; `None` = permanent. + /// Monotonic so the chip's countdown never jumps with a wall-clock step. + pub deadline: Option, +} + +impl Default for SessionAccess { + fn default() -> Self { + SessionAccess { + grants: GRANT_ALL, + deadline: None, + } + } +} + +impl SessionAccess { + /// Snapshot the connector's live access truth (grants + deadline), converting the + /// wall-clock deadline the core keeps into this process's monotonic clock. + pub fn from_connector(c: &punktfunk_core::client::NativeClient) -> SessionAccess { + let deadline = c.access_deadline_unix().map(|deadline_unix| { + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + Instant::now() + Duration::from_secs(deadline_unix.saturating_sub(now_unix)) + }); + SessionAccess { + grants: c.access_grants(), + deadline, + } + } + + /// Whether traffic needing `bit` (one `GRANT_*` constant) may land on the host. + pub fn allows(&self, bit: u32) -> bool { + self.grants & bit != 0 + } + + /// Full control, permanent — today's default look, which must stay unchanged: no chip, + /// no gating, no toasts (design §7; old-host degrade). + pub fn is_default(&self) -> bool { + self.grants == GRANT_ALL && self.deadline.is_none() + } + + /// Time left before this access expires — `None` = permanent, zero = already due + /// (the host's expiry close is on its way). + pub fn remaining(&self, now: Instant) -> Option { + self.deadline.map(|d| d.saturating_duration_since(now)) + } + + /// The overlay chip's text — "Controller only · ends in 1 h 58 m" — or `None` for the + /// default session, which shows no chip at all. + pub fn chip_text(&self, now: Instant) -> Option { + if self.is_default() { + return None; + } + let label = preset_label(self.grants); + match self.remaining(now) { + Some(left) => Some(format!("{label} · ends in {}", format_remaining(left))), + None => Some(label.to_string()), + } + } +} + +/// The user-facing preset name DERIVED from the mask (design §3.2 — never stored, no +/// drift): the three presets, and "Custom" for any other combination. +pub fn preset_label(grants: u32) -> &'static str { + match grants { + GRANT_ALL => "Full control", + GRANT_PRESET_CONTROLLER_ONLY => "Controller only", + GRANT_PRESET_VIEW_ONLY => "View only", + _ => "Custom", + } +} + +/// A remaining-time figure the chip/toast can wear: "1 h 58 m", "2 h", "58 m", and +/// "under 1 m" below the resolution the wire's whole seconds can honestly promise. +pub fn format_remaining(left: Duration) -> String { + let mins = left.as_secs() / 60; + match (mins / 60, mins % 60) { + (0, 0) => "under 1 m".to_string(), + (0, m) => format!("{m} m"), + (h, 0) => format!("{h} h"), + (h, m) => format!("{h} h {m} m"), + } +} + +/// The toast for a mid-session access change (design §7 "end honestly"): a grants edit +/// names the new level; an unchanged-grants update is the host's expiry warning (T−5 m / +/// T−1 m) and names the time left. `None` = nothing worth interrupting for (an update +/// that reaffirmed a permanent, unchanged mask). +pub fn update_notice(prev_grants: u32, next: &SessionAccess, now: Instant) -> Option { + if next.grants != prev_grants { + return Some(format!("Access is now {}", preset_label(next.grants))); + } + match next.remaining(now) { + Some(left) if left > Duration::ZERO => { + Some(format!("Access ends in {}", format_remaining(left))) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use punktfunk_core::quic::{GRANT_CLIPBOARD, GRANT_GAMEPAD, GRANT_KEYBOARD, GRANT_POINTER}; + + #[test] + fn labels_derive_from_the_mask_per_the_design() { + assert_eq!(preset_label(GRANT_ALL), "Full control"); + assert_eq!(preset_label(GRANT_GAMEPAD), "Controller only"); + assert_eq!(preset_label(0), "View only"); + // Anything off the three presets is Custom — including "controller + clipboard", + // the media-remote example, and a full mask missing one bit. + assert_eq!(preset_label(GRANT_GAMEPAD | GRANT_CLIPBOARD), "Custom"); + assert_eq!(preset_label(GRANT_ALL & !GRANT_KEYBOARD), "Custom"); + } + + #[test] + fn the_default_session_wears_no_chip() { + let now = Instant::now(); + assert!(SessionAccess::default().is_default()); + assert_eq!(SessionAccess::default().chip_text(now), None); + // …and each departure from the default brings one: a narrower mask, or a deadline. + let limited = SessionAccess { + grants: GRANT_GAMEPAD, + deadline: None, + }; + assert_eq!(limited.chip_text(now).as_deref(), Some("Controller only")); + let expiring = SessionAccess { + grants: GRANT_ALL, + deadline: Some(now + Duration::from_secs(2 * 3600 - 120)), + }; + assert_eq!( + expiring.chip_text(now).as_deref(), + Some("Full control · ends in 1 h 58 m") + ); + } + + #[test] + fn remaining_time_formats_at_honest_granularity() { + assert_eq!(format_remaining(Duration::from_secs(0)), "under 1 m"); + assert_eq!(format_remaining(Duration::from_secs(59)), "under 1 m"); + assert_eq!(format_remaining(Duration::from_secs(60)), "1 m"); + assert_eq!(format_remaining(Duration::from_secs(58 * 60)), "58 m"); + assert_eq!(format_remaining(Duration::from_secs(2 * 3600)), "2 h"); + assert_eq!( + format_remaining(Duration::from_secs(3600 + 58 * 60 + 30)), + "1 h 58 m" + ); + } + + #[test] + fn notices_name_a_grants_change_first_and_warnings_by_time_left() { + let now = Instant::now(); + // A console edit: the new level is the news, even with a deadline running. + let narrowed = SessionAccess { + grants: GRANT_GAMEPAD, + deadline: Some(now + Duration::from_secs(300)), + }; + assert_eq!( + update_notice(GRANT_ALL, &narrowed, now).as_deref(), + Some("Access is now Controller only") + ); + // The host's T−5 m warning: same grants, a deadline — name the time. + let warned = SessionAccess { + grants: GRANT_GAMEPAD, + deadline: Some(now + Duration::from_secs(300)), + }; + assert_eq!( + update_notice(GRANT_GAMEPAD, &warned, now).as_deref(), + Some("Access ends in 5 m") + ); + // An update that reaffirmed a permanent, unchanged mask: nothing to say. + let same = SessionAccess { + grants: GRANT_POINTER, + deadline: None, + }; + assert_eq!(update_notice(GRANT_POINTER, &same, now), None); + } +} diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index b84c6ba7..40b438df 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -42,6 +42,13 @@ pub mod deeplink; // state machine every front-end drives, and the session spawn + stdout contract. #[cfg(any(target_os = "linux", windows))] pub mod orchestrate; +// The session's effective access, client-side (design/per-client-access.md §7): the +// snapshot type over the shared grant vocabulary, the derived preset label, the overlay +// chip's text and the AccessUpdate toast wording. Pure presentation logic — the +// Apple/Android ports mirror its rules rather than link it. Gated with the session +// modules only because macOS has no punktfunk-core dependency to name the grants with. +#[cfg(any(target_os = "linux", windows))] +pub mod access; // The host's OS-identity chain (mDNS `os=` TXT): sanitize + icon-walk order. Pure string // logic, built everywhere (the Apple/Android ports mirror it rather than link it). pub mod os; diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index ca66765f..d4c3bfa2 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -347,6 +347,21 @@ pub enum SessionEvent { msg: String, }, Stats(Stats), + /// The session's effective access (design/per-client-access.md §7): emitted once right + /// after [`Self::Connected`] with the Welcome's advert, then again for every mid-session + /// `AccessUpdate` the host sends (a console edit, the T−5 m / T−1 m expiry warnings) — + /// latest wins. `notice` is the toast-worthy one-liner for a mid-session change + /// ("Access is now Controller only", "Access ends in 5 m"); `None` on the initial + /// snapshot and on updates with nothing worth interrupting for. + /// + /// Courtesy chrome only — the HOST enforces the mask whatever an embedder does with + /// this. Embedders use it to gate capture (no pointer lock / keyboard grab without the + /// bits) and to wear the overlay chip; a default access (full control, permanent — every + /// old host) must render exactly today's look. + Access { + access: crate::access::SessionAccess, + notice: Option, + }, } /// How many times THIS PROCESS has had a session's codec exhaust the decode ladder — the @@ -606,6 +621,14 @@ fn pump( mode: connector.mode(), fingerprint: connector.host_fingerprint, }); + // The Welcome's access advert, straight after Connected so the embedder can gate its + // capture BEFORE it engages (design §7 "not capture what can't land"). Old hosts decode + // to full-control/permanent and the embedder renders today's look unchanged. + let mut access = crate::access::SessionAccess::from_connector(&connector); + let _ = ev_tx.send_blocking(SessionEvent::Access { + access, + notice: None, + }); // Build the decoder for the codec the host resolved (never assume HEVC), honoring the // Settings backend preference (auto/native-*/software). @@ -728,30 +751,35 @@ fn pump( .flatten(); // The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since // `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight - // away when the host has no clipboard capability, so spawning is unconditional. - let clipboard_thread = params - .clipboard - .then(|| { - let c = connector.clone(); - let s = stop.clone(); - std::thread::Builder::new() - .name("pf-clipboard".into()) - .spawn(move || crate::clipboard::run(c, s)) - .ok() - }) - .flatten(); + // away when the host has no clipboard capability, so spawning is gated only by the + // setting — and by the session's CLIPBOARD grant (the client half of design §5.4 + // "deny at setup": the host's coordinator never starts for an ungranted session, so a + // bridge here would only ever collect NOT_PERMITTED refusals). + let clipboard_thread = (params.clipboard + && access.allows(punktfunk_core::quic::GRANT_CLIPBOARD)) + .then(|| { + let c = connector.clone(); + let s = stop.clone(); + std::thread::Builder::new() + .name("pf-clipboard".into()) + .spawn(move || crate::clipboard::run(c, s)) + .ok() + }) + .flatten(); // The uplink, and with it the mute the embedder's chord drives. `set_live` is what makes - // the chord (and its indicator) real: a mic turned off in Settings, or a capture device - // that wouldn't open, leaves it false and the chord stays an honest no-op. - let _mic = params - .mic_enabled + // the chord (and its indicator) real: a mic turned off in Settings, a capture device + // that wouldn't open, OR a session without the MIC grant (the host would drop the + // datagrams — don't open the capture device for a plane that can't land) leaves it + // false and the chord stays an honest no-op. `mut`: a mid-session AccessUpdate moves + // the grant, and the uplink follows it live below. + let mut mic_uplink = (params.mic_enabled && access.allows(punktfunk_core::quic::GRANT_MIC)) .then(|| { audio::MicStreamer::spawn(connector.clone(), mic.flag(), params.echo_cancel) .map_err(|e| tracing::warn!(error = %e, "mic uplink disabled")) .ok() }) .flatten(); - mic.set_live(_mic.is_some()); + mic.set_live(mic_uplink.is_some()); // Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP // step, drift) keep the capture-clock latency stats honest — never cached at session start. @@ -874,6 +902,39 @@ fn pump( debug_reconfig = None; } } + // Mid-session access updates (a console edit, the T−5 m / T−1 m expiry warnings). + // Drain the queue and re-read the connector's live truth ONCE — latest wins per + // design, and the connector already folded every update before waking us. The mic + // uplink follows its grant live: removed → the capture device closes now (the host + // is dropping the plane anyway); granted back (and wanted in Settings) → it starts + // again without a reconnect. + { + let mut updated = false; + while connector.next_access_update(Duration::ZERO).is_ok() { + updated = true; + } + if updated { + let prev = access; + access = crate::access::SessionAccess::from_connector(&connector); + let notice = crate::access::update_notice(prev.grants, &access, Instant::now()); + let mic_on = params.mic_enabled && access.allows(punktfunk_core::quic::GRANT_MIC); + if !mic_on && mic_uplink.is_some() { + tracing::info!("MIC grant removed mid-session — stopping the mic uplink"); + mic_uplink = None; + mic.set_live(false); + } else if mic_on && mic_uplink.is_none() { + mic_uplink = audio::MicStreamer::spawn( + connector.clone(), + mic.flag(), + params.echo_cancel, + ) + .map_err(|e| tracing::warn!(error = %e, "mic uplink disabled")) + .ok(); + mic.set_live(mic_uplink.is_some()); + } + let _ = ev_tx.send_blocking(SessionEvent::Access { access, notice }); + } + } // 20 ms wait: audio has its own thread now, so this only bounds stop-flag // responsiveness and the per-iteration keyframe-recovery check (a frame arrives // every ~8–16 ms at 60–120 Hz anyway, so this rarely times out mid-stream). @@ -1270,6 +1331,14 @@ fn pump( // line in front of the player for quitting their own game. Err(PunktfunkError::Closed) => { use punktfunk_core::client::PunktfunkEndReason as End; + // A typed mid-session rejection names itself — today that is the access + // expiry (close 0x69, after the host's T−5 m / T−1 m warnings), which + // would otherwise file under HostError and render as "the host ended the + // session with an error": true, and exactly the wrong sentence. Same + // wording as the connect-time path, one vocabulary (design §7). + if let Some(reason) = connector.end_reject() { + break Some(crate::trust::connect_reject_message(reason)); + } break match connector.end_reason() { // The player quit the game the host launched. Nothing to report; a launcher // embedder returns to its library, which is where they were headed anyway. diff --git a/crates/pf-console-ui/src/skia_overlay.rs b/crates/pf-console-ui/src/skia_overlay.rs index 20e99ce4..2fb8e205 100644 --- a/crates/pf-console-ui/src/skia_overlay.rs +++ b/crates/pf-console-ui/src/skia_overlay.rs @@ -50,6 +50,12 @@ struct Drawn { height: u32, stats: Option, hint: Option, + /// The access chip's text ("Controller only · ends in 1 h 58 m"). Its countdown moves + /// once a minute, which is exactly one damage redraw a minute — a steady chip costs + /// nothing per frame. + access: Option, + /// The transient access toast (holds the hint pill's slot while up). + notice: Option, /// The mic-mute badge is up. Part of the damage key like everything else here — the badge /// is static once drawn, so a muted stream still re-renders nothing per frame. mic_muted: bool, @@ -465,6 +471,8 @@ impl Overlay for SkiaOverlay { let resize_step = resize_phase.map_or(0, |p| (p * 120.0) as u16 + 1); if ctx.stats.is_none() && ctx.hint.is_none() + && ctx.access.is_none() + && ctx.notice.is_none() && !ctx.mic_muted && banner_step == 0 && resize_step == 0 @@ -480,6 +488,8 @@ impl Overlay for SkiaOverlay { height: ctx.height, stats: ctx.stats.map(str::to_owned), hint: ctx.hint.map(str::to_owned), + access: ctx.access.map(str::to_owned), + notice: ctx.notice.map(str::to_owned), mic_muted: ctx.mic_muted, scale_pct: (scale * 100.0).round() as u16, banner_step, @@ -521,7 +531,17 @@ impl Overlay for SkiaOverlay { if want.mic_muted { draw_mic_muted_badge(canvas, font, ctx.width, scale); } - if let Some(hint) = &want.hint { + // The access chip shares the top-right corner (same tier-independence argument as + // the badge — "what may this session do" must survive the stats overlay being + // Off), stacking under the badge when both are up. + if let Some(access) = &want.access { + draw_access_chip(canvas, font, access, ctx.width, want.mic_muted, scale); + } + // The access toast outranks the capture hint for its few seconds — an "Access + // ends in 1 m" must not lose the slot to "click to capture". + if let Some(notice) = &want.notice { + draw_hint_pill(canvas, font, notice, ctx.width, ctx.height, 1.0, scale); + } else if let Some(hint) = &want.hint { draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0, scale); } else if banner_step > 0 { // The start banner: the leave/stats shortcuts, fading out on its own — @@ -787,6 +807,48 @@ fn draw_mic_muted_badge(canvas: &Canvas, base_font: &Font, width: u32, scale: f3 ); } +/// The access chip (per-client access §7 "say what this session is"): the session's +/// derived preset label and its countdown — "Controller only · ends in 1 h 58 m" — on the +/// same translucent pill as the rest of the chrome, pinned to the TOP-RIGHT corner and +/// stacked under the mic badge when both are up. +/// +/// Standing by design, like the badge and unlike the toasts: "why does my keyboard do +/// nothing" and "when does my access end" must be answerable ten minutes in, at every +/// stats tier including Off. Never drawn for a full-control permanent session — the run +/// loop passes `None` and today's default look stays untouched. +fn draw_access_chip( + canvas: &Canvas, + base_font: &Font, + text: &str, + width: u32, + below_badge: bool, + scale: f32, +) { + // Short line (label + countdown) — fits any window the stream runs in. + let font = &chrome_font(base_font, scale); + let (_, metrics) = font.metrics(); + let line_h = metrics.descent - metrics.ascent; + let (pad_x, pad_y) = (base::PILL_PAD_X * scale, base::PILL_PAD_Y * scale); + let text_w = font.measure_str(text, None).0; + let w = text_w + 2.0 * pad_x; + let h = line_h + 2.0 * pad_y; + let margin = base::OSD_MARGIN * scale; + // One row down when the mic badge holds the corner (its height is the same formula, + // sans dot — the dot fits inside the shared line height). + let y = margin + if below_badge { h + 8.0 * scale } else { 0.0 }; + let x = width as f32 - w - margin; + canvas.draw_rrect( + RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0), + &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None), + ); + canvas.draw_str( + text, + Point::new(x + pad_x, y + pad_y - metrics.ascent), + font, + &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None), + ); +} + /// The mid-stream-resize cover: a full-screen dark scrim, the shared rotating spinner, and /// a "Resizing…" label centered over it — so the host's 0.3–2 s virtual-display + encoder /// rebuild reads as a deliberate pause rather than the stream stretching to the changed diff --git a/crates/pf-presenter/src/input.rs b/crates/pf-presenter/src/input.rs index ca713619..0c0aabe7 100644 --- a/crates/pf-presenter/src/input.rs +++ b/crates/pf-presenter/src/input.rs @@ -27,6 +27,7 @@ use crate::touch::{Abs, Act, Gestures}; use pf_client_core::trust::{MouseMode, TouchMode}; use punktfunk_core::client::NativeClient; use punktfunk_core::input::{InputEvent, InputKind}; +use punktfunk_core::quic::{classify, GRANT_KEYBOARD, GRANT_POINTER}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -70,9 +71,30 @@ pub struct Capture { /// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]). invert_scroll: bool, gestures: Gestures, + /// The session's effective access grants (per-client access §7) — the courtesy gate in + /// front of every wire send here, keyed by the SAME `classify()` the host's filter uses: + /// an event whose class the mask doesn't cover never leaves this struct (the host would + /// drop it anyway; not sending is what keeps "my keyboard does nothing" from being a + /// mystery — the run loop pairs this with not grabbing what can't land). Moved live by + /// [`Capture::set_grants`] on a mid-session `AccessUpdate`. + grants: u32, } -fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) { +/// Forward one event IF the session's grants cover its class — the client half of the +/// host's classify-and-drop filter, sharing its exhaustive [`classify`] so a future +/// `InputKind` can't slip past one side and not the other. +fn send( + connector: &NativeClient, + grants: u32, + kind: InputKind, + code: u32, + x: i32, + y: i32, + flags: u32, +) { + if grants & classify(kind).bit() == 0 { + return; + } let _ = connector.send_input(&InputEvent { kind, _pad: [0; 3], @@ -86,12 +108,15 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl impl Capture { /// `abs_ok` = the host injector accepts absolute pointer events; without it the /// desktop model is unavailable and `mouse_mode` silently resolves to capture. + /// `grants` = the session's effective access mask (the Welcome advert — the run loop + /// keeps it live through [`Capture::set_grants`]). pub fn new( connector: Arc, touch_mode: TouchMode, invert_scroll: bool, mouse_mode: MouseMode, abs_ok: bool, + grants: u32, ) -> Capture { Capture { connector, @@ -108,6 +133,7 @@ impl Capture { touch_mode, invert_scroll, gestures: Gestures::new(touch_mode == TouchMode::Trackpad), + grants, } } @@ -115,6 +141,73 @@ impl Capture { self.captured } + /// The session's effective access grants — what the run loop passes to + /// `apply_capture` so pointer lock and the keyboard grab track the mask. + pub fn grants(&self) -> u32 { + self.grants + } + + /// Whether engaging capture buys anything at all: with neither POINTER nor KEYBOARD + /// granted there is nothing to lock or grab FOR (a view-only or controller-only + /// session), so [`Capture::engage`] refuses and the "click to capture" hint stays + /// down — the worst failure mode is a locked pointer whose motion lands nowhere. + pub fn can_capture(&self) -> bool { + self.grants & (GRANT_POINTER | GRANT_KEYBOARD) != 0 + } + + /// Fold a mid-session `AccessUpdate` into the gate. A class REMOVED while something + /// of its kind is held flushes the held state up first, under the OLD mask — the + /// host may still honor the ups, and either way nothing stays pressed locally. The + /// run loop re-applies pointer lock / keyboard grab (and releases capture entirely + /// when [`Capture::can_capture`] went false) right after this. + pub fn set_grants(&mut self, grants: u32) { + if grants == self.grants { + return; + } + let lost = self.grants & !grants; + if lost & GRANT_KEYBOARD != 0 { + for vk in self.held_keys.drain() { + send( + &self.connector, + self.grants, + InputKind::KeyUp, + vk as u32, + 0, + 0, + 0, + ); + } + } + if lost & GRANT_POINTER != 0 { + self.pending_rel = (0, 0); + self.pending_abs = None; + for b in self.held_buttons.drain() { + send( + &self.connector, + self.grants, + InputKind::MouseButtonUp, + b, + 0, + 0, + 0, + ); + } + for slot in self.touch_slots.drain().map(|(_, slot)| slot) { + send( + &self.connector, + self.grants, + InputKind::TouchUp, + slot, + 0, + 0, + 0, + ); + } + self.gestures.reset(); + } + self.grants = grants; + } + /// The desktop (absolute, uncaptured) mouse model is active. pub fn desktop(&self) -> bool { self.desktop @@ -153,10 +246,17 @@ impl Capture { !self.captured && !self.user_released } - /// Engage capture. The caller flips SDL relative mouse mode on (pointer lock). + /// Engage capture. The caller flips SDL relative mouse mode on (pointer lock) — + /// only on `true`: a session whose grants cover neither pointer nor keyboard + /// refuses (see [`Capture::can_capture`]), and the caller must leave the pointer + /// free rather than lock it over input that can't land. pub fn engage(&mut self) -> bool { + if !self.can_capture() { + return false; + } self.user_released = false; - !std::mem::replace(&mut self.captured, true) + self.captured = true; + true } /// Release capture, flushing everything held so nothing sticks down on the host. @@ -172,13 +272,37 @@ impl Capture { self.pending_rel = (0, 0); // never flush motion gathered while captured self.pending_abs = None; for vk in self.held_keys.drain() { - send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0); + send( + &self.connector, + self.grants, + InputKind::KeyUp, + vk as u32, + 0, + 0, + 0, + ); } for b in self.held_buttons.drain() { - send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0); + send( + &self.connector, + self.grants, + InputKind::MouseButtonUp, + b, + 0, + 0, + 0, + ); } for slot in self.touch_slots.drain().map(|(_, slot)| slot) { - send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0); + send( + &self.connector, + self.grants, + InputKind::TouchUp, + slot, + 0, + 0, + 0, + ); } // The gesture engine's held left button (a tap-drag in progress) rides in // `held_buttons` above, so it was just flushed — here we only forget its state. @@ -191,11 +315,20 @@ impl Capture { pub fn flush_motion(&mut self) { let (dx, dy) = std::mem::take(&mut self.pending_rel); if dx != 0 || dy != 0 { - send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0); + send( + &self.connector, + self.grants, + InputKind::MouseMove, + 0, + dx, + dy, + 0, + ); } if let Some(a) = self.pending_abs.take() { send( &self.connector, + self.grants, InputKind::MouseMoveAbs, 0, a.x, @@ -231,7 +364,15 @@ impl Capture { // when the key lands (e.g. "press E at the crosshair"). self.flush_motion(); self.held_keys.insert(vk); - send(&self.connector, InputKind::KeyDown, vk as u32, 0, 0, 0); + send( + &self.connector, + self.grants, + InputKind::KeyDown, + vk as u32, + 0, + 0, + 0, + ); } } @@ -239,7 +380,15 @@ impl Capture { if let Some(vk) = keymap_sdl::scancode_to_vk(sc) { // Flush-on-release may have beaten us to it — only forward if still held. if self.held_keys.remove(&vk) { - send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0); + send( + &self.connector, + self.grants, + InputKind::KeyUp, + vk as u32, + 0, + 0, + 0, + ); } } } @@ -254,7 +403,15 @@ impl Capture { self.flush_motion(); if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) { self.held_buttons.insert(gs); - send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0); + send( + &self.connector, + self.grants, + InputKind::MouseButtonDown, + gs, + 0, + 0, + 0, + ); } } @@ -262,7 +419,15 @@ impl Capture { self.flush_motion(); // the release must not beat the motion before it if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) { if self.held_buttons.remove(&gs) { - send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0); + send( + &self.connector, + self.grants, + InputKind::MouseButtonUp, + gs, + 0, + 0, + 0, + ); } } } @@ -282,12 +447,28 @@ impl Capture { let vy = ay.trunc() as i32; if vy != 0 { ay -= f64::from(vy); - send(&self.connector, InputKind::MouseScroll, 0, vy, 0, 0); + send( + &self.connector, + self.grants, + InputKind::MouseScroll, + 0, + vy, + 0, + 0, + ); } let vx = ax.trunc() as i32; if vx != 0 { ax -= f64::from(vx); - send(&self.connector, InputKind::MouseScroll, 1, vx, 0, 0); + send( + &self.connector, + self.grants, + InputKind::MouseScroll, + 1, + vx, + 0, + 0, + ); } self.scroll_acc = (ax, ay); } @@ -319,6 +500,7 @@ impl Capture { let slot = self.touch_slot(finger_id); send( &self.connector, + self.grants, InputKind::TouchDown, slot, x, @@ -336,6 +518,7 @@ impl Capture { if let Some(&slot) = self.touch_slots.get(&finger_id) { send( &self.connector, + self.grants, InputKind::TouchMove, slot, x, @@ -350,7 +533,15 @@ impl Capture { /// no-ops), but a stray up must never strand a pressed contact on the host. pub fn on_touch_up(&mut self, finger_id: u64) { if let Some(slot) = self.touch_slots.remove(&finger_id) { - send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0); + send( + &self.connector, + self.grants, + InputKind::TouchUp, + slot, + 0, + 0, + 0, + ); } } @@ -409,15 +600,31 @@ impl Capture { if down { self.flush_motion(); // the press lands where the cursor now is self.held_buttons.insert(gs); - send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0); + send( + &self.connector, + self.grants, + InputKind::MouseButtonDown, + gs, + 0, + 0, + 0, + ); } else if self.held_buttons.remove(&gs) { self.flush_motion(); - send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0); + send( + &self.connector, + self.grants, + InputKind::MouseButtonUp, + gs, + 0, + 0, + 0, + ); } } other => { if let Some((kind, code, x, y, flags)) = other.wire() { - send(&self.connector, kind, code, x, y, flags); + send(&self.connector, self.grants, kind, code, x, y, flags); } } } diff --git a/crates/pf-presenter/src/overlay.rs b/crates/pf-presenter/src/overlay.rs index 0e28e576..e525e6e8 100644 --- a/crates/pf-presenter/src/overlay.rs +++ b/crates/pf-presenter/src/overlay.rs @@ -56,6 +56,13 @@ pub struct FrameCtx<'a> { pub stats: Option<&'a str>, /// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden. pub hint: Option<&'a str>, + /// The access chip (per-client access §7 "say what this session is"): a small standing + /// pill — "Controller only · ends in 1 h 58 m" — drawn at every stats tier, `None` for + /// a full-control permanent session (today's default look, and every old host). + pub access: Option<&'a str>, + /// A transient access toast ("Access is now Controller only", "Access ends in 5 m") — + /// takes the hint pill's slot with priority while up. The run loop owns its timing. + pub notice: Option<&'a str>, /// The user muted their microphone mid-stream (Ctrl+Alt+Shift+V). Draws a persistent /// badge, deliberately independent of the stats tier: a muted mic is a fact about what /// the host is hearing, and "did my mute take?" must be answerable with the overlay off. diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 55a7c1a8..d7d695db 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -349,6 +349,16 @@ struct StreamState { /// `None` = nothing sent yet. Edge-detected each iteration from the live mouse model, so /// the chord, the M3 auto-flip, and engage/release all reconcile through one path. sent_client_draws: Option, + /// The session's effective access (per-client access §7): the Welcome's advert, then + /// every mid-session `AccessUpdate` (latest wins). Drives the capture gating, the + /// overlay chip, and which held state a live edit flushes. The default — full + /// control, permanent, what every old host decodes to — renders today's look + /// unchanged: no chip, everything enabled. + access: pf_client_core::access::SessionAccess, + /// A transient access toast ("Access is now Controller only", "Access ends in 5 m") + /// and when it went up — cleared after [`ACCESS_NOTICE_S`]. Rides the hint-pill slot + /// with priority: an access change outranks "click to capture" for a few seconds. + access_notice: Option<(String, Instant)>, /// The params this session was started with, kept so a codec fallback can re-dial /// with `exclude_codecs` widened — see [`SessionEvent::CodecFallback`]. Cloned once /// per session start, so anything the SESSION changed after launch (an accepted mode @@ -398,6 +408,8 @@ impl StreamState { connector: None, capture: None, cursor_chan: None, + access: pf_client_core::access::SessionAccess::default(), + access_notice: None, last_hint: None, hint_override: false, sent_client_draws: None, @@ -779,7 +791,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result WindowEvent::FocusLost => { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.release(false) { - apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); + apply_capture( + &mut window, + &mouse, + false, + false, + inhibit_shortcuts, + 0, + ); tracing::info!("focus lost — input released"); } } @@ -797,14 +816,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result // An auto-release (Alt-Tab) undoes itself; a chord release // stays released until the user opts back in. if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { - if cap.should_reengage() { - cap.engage(); + if cap.should_reengage() && cap.engage() { apply_capture( &mut window, &mouse, true, cap.desktop(), inhibit_shortcuts, + cap.grants(), ); tracing::info!("focus gained — input recaptured"); } @@ -864,15 +883,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.captured() { cap.release(true); - apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); - } else { - cap.engage(); + apply_capture( + &mut window, + &mouse, + false, + false, + inhibit_shortcuts, + 0, + ); + } else if cap.engage() { apply_capture( &mut window, &mouse, true, cap.desktop(), inhibit_shortcuts, + cap.grants(), ); } tracing::info!(captured = cap.captured(), "chord: release/engage"); @@ -894,6 +920,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result true, desktop, inhibit_shortcuts, + cap.grants(), ); } flipped = true; @@ -917,7 +944,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(st) = &mut stream { tracing::info!("chord: disconnect"); st.request_quit(); - apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0); // The pump emits Ended(None); the end path routes per mode. } continue; @@ -1000,15 +1027,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result Event::MouseButtonDown { mouse_btn, .. } => { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if !cap.captured() { - // The engaging click is suppressed toward the host. - cap.engage(); - apply_capture( - &mut window, - &mouse, - true, - cap.desktop(), - inhibit_shortcuts, - ); + // The engaging click is suppressed toward the host. `engage` + // refuses on a session whose access covers neither pointer nor + // keyboard — the click then does nothing, which is the honest + // rendering of "there is nothing to capture for". + if cap.engage() { + apply_capture( + &mut window, + &mouse, + true, + cap.desktop(), + inhibit_shortcuts, + cap.grants(), + ); + } } else { cap.on_button_down(mouse_btn); } @@ -1182,6 +1214,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result true, cap.desktop(), inhibit_shortcuts, + cap.grants(), ); if cap.desktop() { // Reappear where the host last had the pointer, so the @@ -1225,7 +1258,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result while escape_rx.try_recv().is_ok() { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.release(true) { - apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0); } } if fullscreen && !opts.fullscreen { @@ -1238,7 +1271,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(st) = &mut stream { tracing::info!("controller chord: disconnect"); st.request_quit(); - apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0); } } @@ -1379,15 +1412,32 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result (relative-only input) — using capture" ); } + // The session's access truth, straight off the Welcome (the pump's + // Access event lands in this same drain, but the capture below must + // be built gated, not re-gated a beat later). + st.access = pf_client_core::access::SessionAccess::from_connector(&c); let mut cap = Capture::new( c.clone(), opts.touch_mode, opts.invert_scroll, opts.mouse_mode, abs_ok, + st.access.grants, ); - cap.engage(); // capture engages when the stream starts (ui_stream parity) - apply_capture(&mut window, &mouse, true, cap.desktop(), inhibit_shortcuts); + // Capture engages when the stream starts (ui_stream parity) — unless + // this session's access covers neither pointer nor keyboard (view-only + // / controller-only), where `engage` refuses and the pointer stays + // free over the stream (§7 "not capture what can't land"). + if cap.engage() { + apply_capture( + &mut window, + &mouse, + true, + cap.desktop(), + inhibit_shortcuts, + cap.grants(), + ); + } st.capture = Some(cap); st.cursor_chan = Some(crate::cursor::CursorChannel::new(&c)); // Read the mgmt port BEFORE `c` is moved into `st` — the Welcome's answer to @@ -1430,6 +1480,44 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } st.last_stats = Some(s); } + // The session's access — the Welcome's advert first, then every + // mid-session AccessUpdate (design §7). Re-gate the live capture to the + // new mask: a removed POINTER/KEYBOARD bit releases the pointer lock / + // keyboard grab it backed, and with neither class left the capture drops + // entirely (auto-release, so a later re-grant re-engages on click). + // Courtesy chrome — the host enforces the mask regardless. + SessionEvent::Access { access, notice } => { + st.access = access; + if let Some(n) = notice { + tracing::info!(notice = %n, "session access changed"); + st.access_notice = Some((n, Instant::now())); + } + if let Some(cap) = st.capture.as_mut() { + cap.set_grants(access.grants); + if cap.captured() { + if cap.can_capture() { + apply_capture( + &mut window, + &mouse, + true, + cap.desktop(), + inhibit_shortcuts, + cap.grants(), + ); + } else { + cap.release(false); + apply_capture( + &mut window, + &mouse, + false, + false, + inhibit_shortcuts, + 0, + ); + } + } + } + } SessionEvent::Failed { msg, trust_rejected, @@ -1446,7 +1534,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(st) = stream.take() { st.shutdown(); } - apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0); if let Some(o) = overlay.as_mut() { // A user-canceled dial ends silently — no error scene. if canceled { @@ -1463,7 +1551,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(cap) = &mut st.capture { cap.release(true); } - apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0); match &mode { ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)), ModeCtl::Browse(_) => { @@ -1512,7 +1600,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(cap) = &mut st.capture { cap.release(true); } - apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts); + apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0); // Widen the exclusion rather than replace it: a second fallback in the // same run must not re-offer what the first one already ruled out. let mut params = st.params.clone(); @@ -1608,17 +1696,32 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result st.resize_overlay.tick(Instant::now()); } + // Access toast expiry — before the overlay borrows the stream immutably. + if let Some(st) = stream.as_mut() { + if st + .access_notice + .as_ref() + .is_some_and(|(_, at)| at.elapsed() >= Duration::from_secs(ACCESS_NOTICE_S)) + { + st.access_notice = None; + } + } + // --- Console UI: damage-driven overlay re-render for this iteration -------------- if let Some(o) = overlay.as_mut() { let (pw, ph) = window.size_in_pixels(); let (stats, hint) = match &stream { Some(st) if st.connector.is_some() => { + // No "click to capture" over a session with nothing to capture FOR + // (view-only / controller-only — the chip says what this session is). let hint = match &st.capture { - Some(cap) if !cap.captured() => Some(if gamepad.active().is_some() { - HINT_WITH_PAD - } else { - HINT_KEYBOARD - }), + Some(cap) if !cap.captured() && cap.can_capture() => { + Some(if gamepad.active().is_some() { + HINT_WITH_PAD + } else { + HINT_KEYBOARD + }) + } _ => None, }; ( @@ -1629,6 +1732,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } _ => (None, None), }; + // The access chip (design §7 "say what this session is"): a small standing + // pill — "Controller only · ends in 1 h 58 m" — in the same overlay family as + // the stats HUD, at every stats tier including Off. `None` (and so exactly + // today's look) for a full-control permanent session, which is every session + // against an old host. The countdown re-derives per pass; the overlay's + // damage gate turns its once-a-minute text change into a redraw. + let access_chip = match &stream { + Some(st) if st.connector.is_some() => st.access.chip_text(Instant::now()), + _ => None, + }; + let access_notice = stream + .as_ref() + .filter(|st| st.connector.is_some()) + .and_then(|st| st.access_notice.as_ref().map(|(n, _)| n.as_str())); let pad = gamepad.active(); let pads = gamepad.pads(); let resizing = stream @@ -1647,6 +1764,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result scale: overlay_scale(window.display_scale(), osd_scale_pref), stats, hint, + access: access_chip.as_deref(), + notice: access_notice, mic_muted, resizing, pad: pad.as_ref().map(|p| p.name.as_str()), @@ -2464,16 +2583,29 @@ impl ResizeIndicator { /// tracking our absolute sends, is the one you see (until the M2 cursor channel flips /// who draws it) — and system chords stay local (a remote desktop is something you /// Alt-Tab away from, not into). `desktop` only matters while `on`. +/// +/// `grants` is the session's effective access mask (per-client access §7 "not capture +/// what can't land"): no pointer lock without the POINTER bit, no keyboard grab without +/// KEYBOARD — a locked pointer whose motion the host drops, or grabbed system chords +/// over dead keys, is the "my input does nothing and nobody says why" failure mode this +/// exists to prevent. On-sites pass `Capture::grants()`; off-sites pass `0` (with `on` +/// false every term is off regardless). fn apply_capture( window: &mut sdl3::video::Window, mouse: &sdl3::mouse::MouseUtil, on: bool, desktop: bool, inhibit: bool, + grants: u32, ) { - mouse.set_relative_mouse_mode(window, on && !desktop); - mouse.show_cursor(!on); - let grab = on && !desktop && inhibit; + use punktfunk_core::quic::{GRANT_KEYBOARD, GRANT_POINTER}; + let pointer = grants & GRANT_POINTER != 0; + mouse.set_relative_mouse_mode(window, on && !desktop && pointer); + // The local cursor hides only while the HOST's cursor stands in for it — without the + // POINTER grant no absolute/relative send lands, so hiding it would leave a + // keyboard-only session with no cursor at all. + mouse.show_cursor(!(on && pointer)); + let grab = on && !desktop && inhibit && grants & GRANT_KEYBOARD != 0; if !window.set_keyboard_grab(grab) && grab { // The one refusal SDL reports is a missing mechanism — a Wayland compositor with no // shortcuts-inhibit global. Said once per process: the answer never changes @@ -2763,6 +2895,10 @@ struct PresentedWindow { forced: u32, } +/// How long an access toast holds the pill slot (an "Access ends in…" warning must be +/// seen, not studied — the chip keeps the standing truth). +const ACCESS_NOTICE_S: u64 = 6; + /// The capture hints (`ui_stream` parity — the words the user reads while released). const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \ Ctrl+Alt+Shift+M mouse mode · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats"; diff --git a/crates/punktfunk-core/src/client/control.rs b/crates/punktfunk-core/src/client/control.rs index 8310d3a9..ef780b66 100644 --- a/crates/punktfunk-core/src/client/control.rs +++ b/crates/punktfunk-core/src/client/control.rs @@ -77,4 +77,13 @@ pub(crate) struct Negotiated { /// advertise one. Surfaced to the embedder via [`crate::NativeClient::mgmt_port`] so a client /// can reach the game library without ever having seen an mDNS advert. pub(crate) mgmt_port: u16, + /// The session's effective access grants ([`crate::quic::Welcome::grants`]) — the + /// [`crate::quic::GRANT_GAMEPAD`] family. An old host's Welcome decodes to + /// [`crate::quic::GRANT_ALL`], the pre-grants behavior. This is only the STARTING truth: + /// a mid-session [`crate::quic::AccessUpdate`] moves the live mask the control task keeps + /// (see [`crate::NativeClient::access_grants`]). + pub(crate) grants: u32, + /// Seconds until this device's access expires ([`crate::quic::Welcome::expires_in_secs`]); + /// `0` = permanent. Like `grants`, the connect-time seed for the live deadline. + pub(crate) expires_in_secs: u32, } diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index f28106fc..1d0ce4ce 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -116,6 +116,23 @@ pub struct MicUplinkStats { /// the control task is wedged, which callers treat as a closed session. const CTRL_QUEUE: usize = 32; +/// Inbound access-update queue depth. The traffic is a console edit or an expiry warning — +/// a handful per session at most; the live grants/deadline slots hold the truth, so a full +/// queue drops news the embedder would re-derive from them anyway. +const ACCESS_QUEUE: usize = 8; + +/// The absolute access deadline (client wall clock, unix seconds) a relative +/// `expires_in_secs` / `remaining_secs` resolves to at `now_ns`; `0` stays `0` (permanent). +/// Anchored to the CLIENT's clock on purpose: the wire value is relative, so host/client +/// skew never moves the countdown a chip renders from this. +pub(crate) fn access_deadline_from(now_ns: u64, remaining_secs: u32) -> u64 { + if remaining_secs == 0 { + 0 + } else { + now_ns / 1_000_000_000 + u64::from(remaining_secs) + } +} + /// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the /// C surface. /// @@ -234,6 +251,11 @@ pub struct NativeClient { cursor_shape: Mutex>, /// Inbound per-frame cursor state — `0xD0` datagrams (same negotiation gate as shapes). cursor_state: Mutex>, + /// Inbound mid-session access updates (control-stream [`crate::quic::AccessUpdate`]) — + /// the wake-up plane behind [`NativeClient::next_access_update`]. The live TRUTH is + /// `access_grants` / `access_deadline_unix` below, already updated when an event lands + /// here, so a dropped event (full queue) loses news but never accuracy. + access: Mutex>, input_tx: tokio::sync::mpsc::UnboundedSender, /// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker. /// Bounded ([`MIC_QUEUE`]): the pump sheds stale frames oldest-first and a full queue drops @@ -271,6 +293,16 @@ pub struct NativeClient { /// The host's management-API port ([`crate::quic::Welcome::mgmt_port`]), or `0` when the host /// did not advertise one — see [`NativeClient::mgmt_port`]. pub mgmt_port: u16, + /// The session's LIVE effective access grants (the [`crate::quic::GRANT_GAMEPAD`] family): + /// seeded from the Welcome advert, moved by every mid-session + /// [`crate::quic::AccessUpdate`] (latest wins) — see [`NativeClient::access_grants`]. + access_grants: Arc, + /// The live access deadline (client wall clock, unix seconds; `0` = permanent) — see + /// [`NativeClient::access_deadline_unix`]. + access_deadline_unix: Arc, + /// The typed [`crate::reject::RejectReason`] close code a mid-session end carried + /// (`0` = none) — see [`NativeClient::end_reject`]. + end_reject_code: Arc, /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: Arc, @@ -577,6 +609,8 @@ impl NativeClient { std::sync::mpsc::sync_channel::(CURSOR_SHAPE_QUEUE); let (cursor_state_tx, cursor_state_rx) = std::sync::mpsc::sync_channel::(CURSOR_STATE_QUEUE); + let (access_tx, access_rx) = + std::sync::mpsc::sync_channel::(ACCESS_QUEUE); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); let end_reason = Arc::new(AtomicU8::new(PunktfunkEndReason::None as u8)); @@ -594,6 +628,12 @@ impl NativeClient { let decode_lat = Arc::new(Mutex::new(DecodeLatAcc::default())); // Seeded by the pump from the Welcome (before ready_tx), then follows every ack. let live_bitrate = Arc::new(AtomicU32::new(0)); + // Access truth (same seeding discipline as `live_bitrate`): the pump writes the + // Welcome advert into both before ready_tx, the control task follows every + // AccessUpdate. GRANT_ALL/permanent here is only the pre-handshake placeholder. + let access_grants = Arc::new(AtomicU32::new(crate::quic::GRANT_ALL)); + let access_deadline_unix = Arc::new(AtomicU64::new(0)); + let end_reject_code = Arc::new(AtomicU32::new(0)); let host = host.to_string(); let frame_chan_w = frame_chan.clone(); @@ -610,6 +650,9 @@ impl NativeClient { let decode_lat_w = decode_lat.clone(); let live_bitrate_w = live_bitrate.clone(); let pad_audio_caps_w = pad_audio_caps.clone(); + let access_grants_w = access_grants.clone(); + let access_deadline_w = access_deadline_unix.clone(); + let end_reject_w = end_reject_code.clone(); let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports let worker = std::thread::Builder::new() .name("punktfunk-client".into()) @@ -685,6 +728,10 @@ impl NativeClient { clock_offset: clock_offset_w, decode_lat: decode_lat_w, live_bitrate: live_bitrate_w, + access_grants: access_grants_w, + access_deadline_unix: access_deadline_w, + access_tx, + end_reject_code: end_reject_w, })); }) .map_err(PunktfunkError::Io)?; @@ -716,6 +763,10 @@ impl NativeClient { host_timing: Mutex::new(host_timing_rx), cursor_shape: Mutex::new(cursor_shape_rx), cursor_state: Mutex::new(cursor_state_rx), + access: Mutex::new(access_rx), + access_grants, + access_deadline_unix, + end_reject_code, input_tx, mic_tx, mic_stats, @@ -983,6 +1034,17 @@ impl NativeClient { self.end_reason() == PunktfunkEndReason::GameExited } + /// The typed [`crate::reject::RejectReason`] a MID-SESSION close carried, if any — an + /// access expiry (`0x69`) being the case this exists for: [`end_reason`](Self::end_reason) + /// can only file an unrecognized deliberate close under `HostError`, and "the host ended + /// the session with an error" is the wrong sentence for "your access expired". Latches + /// with `end_reason` (same ordering discipline); `None` for every ordinary end. The + /// CONNECT-time rejections never land here — they surface as + /// [`PunktfunkError::Rejected`] from [`connect`](Self::connect) itself. + pub fn end_reject(&self) -> Option { + crate::reject::RejectReason::from_close_code(self.end_reject_code.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 @@ -1394,6 +1456,43 @@ impl NativeClient { self.mgmt_port } + /// The session's LIVE effective access grants — the [`crate::quic::GRANT_GAMEPAD`] family, + /// seeded from the `Welcome` advert and moved by every mid-session + /// [`crate::quic::AccessUpdate`] (latest wins). An old host advertises nothing and this + /// reads [`crate::quic::GRANT_ALL`] — full control, the pre-grants behavior, so an + /// embedder keying UI off it changes nothing there. + /// + /// Courtesy truth only: the HOST enforces the mask whatever a client renders. Read it per + /// use (one relaxed load), never cache across an [`next_access_update`](Self::next_access_update) + /// wake. + pub fn access_grants(&self) -> u32 { + self.access_grants.load(Ordering::Relaxed) + } + + /// When this session's access expires, as CLIENT wall clock unix seconds — `None` = + /// permanent (today's default, and everything an old host's Welcome decodes to). Anchored + /// client-side from the wire's relative seconds, so host/client clock skew never moves a + /// countdown rendered from it; re-anchored by every `AccessUpdate`. + pub fn access_deadline_unix(&self) -> Option { + match self.access_deadline_unix.load(Ordering::Relaxed) { + 0 => None, + d => Some(d), + } + } + + /// Pull the next mid-session [`crate::quic::AccessUpdate`] (a console edit, or the host's + /// T−5 m / T−1 m expiry warnings). One consumer, like every plane. The live truth is + /// already in [`access_grants`](Self::access_grants) / + /// [`access_deadline_unix`](Self::access_deadline_unix) when this wakes — the event is the + /// UI's cue to re-gate capture and toast, not the data's source of record. + pub fn next_access_update(&self, timeout: Duration) -> Result { + match self.access.lock().unwrap().recv_timeout(timeout) { + Ok(u) => Ok(u), + Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), + Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed), + } + } + /// Enable or disable the shared clipboard for this session (`design/clipboard-and-file-transfer.md` /// §3.1). Opt-in: nothing is announced or served until this crosses with `enabled = true`. /// `flags` carries [`crate::quic::CLIP_FLAG_FILES`]. Non-blocking; the host replies with a diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index 8308f2e2..1dae3421 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -76,6 +76,10 @@ pub(super) async fn run_pump(args: WorkerArgs) { clock_offset, decode_lat, live_bitrate, + access_grants, + access_deadline_unix, + access_tx, + end_reject_code, .. } = args; // Copies the pump needs after `negotiated` is handed over to `connect`. @@ -88,6 +92,15 @@ pub(super) async fn run_pump(args: WorkerArgs) { // Same discipline for the live encoder target: the Welcome resolve is the starting truth // (0 against an old host that reports none); every BitrateChanged ack moves it from there. live_bitrate.store(negotiated.bitrate_kbps, Ordering::Relaxed); + // …and for the live access truth: the Welcome advert seeds both slots before the embedder + // can observe the client, so `access_grants()` never reads a pre-handshake GRANT_ALL on a + // limited session. The deadline is anchored to the CLIENT's wall clock here — the wire + // carries a relative `expires_in_secs`, so host/client skew never moves the countdown. + access_grants.store(negotiated.grants, Ordering::Relaxed); + access_deadline_unix.store( + access_deadline_from(wall_clock_ns(), negotiated.expires_in_secs), + Ordering::Relaxed, + ); // Bumped by the control task each time a re-sync batch is APPLIED; the pump watches it to // reset its staleness counters and re-arm the clock-based jump-to-live detector. let clock_gen = Arc::new(AtomicU32::new(0)); @@ -166,6 +179,9 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_event_tx: clip_event_tx.clone(), cursor_shape_tx, mode_gen: mode_gen.clone(), + access_grants, + access_deadline_unix, + access_tx, } .run(), ); @@ -205,6 +221,12 @@ pub(super) async fn run_pump(args: WorkerArgs) { // Latch the reason BEFORE `shutdown`: the two are observed by different threads, and a // client that reacts to the shutdown flag must never find the reason still unset. let reason = crate::client::PunktfunkEndReason::from(&why); + // A typed rejection code on a MID-SESSION close (access expiry, and whatever the + // vocabulary grows next) rides beside the coarse reason, same ordering discipline, + // so the embedder's end path can say the real sentence instead of "host error". + if let Some(r) = reject_from_close(&conn) { + end_reject_code.store(r.close_code(), Ordering::SeqCst); + } end_reason.store(reason as u8, Ordering::SeqCst); shutdown.store(true, Ordering::SeqCst); }); diff --git a/crates/punktfunk-core/src/client/pump/control_task.rs b/crates/punktfunk-core/src/client/pump/control_task.rs index 2af7b82e..1ff1792f 100644 --- a/crates/punktfunk-core/src/client/pump/control_task.rs +++ b/crates/punktfunk-core/src/client/pump/control_task.rs @@ -35,6 +35,17 @@ pub(super) struct ControlTask { /// resets the bitrate controller's mode-scoped learned state — the encoder ceiling / compute /// knee it was taught belong to the OLD mode. pub(super) mode_gen: Arc, + /// The session's LIVE access grants ([`NativeClient::access_grants`]): every inbound + /// [`AccessUpdate`] overwrites it (latest wins) BEFORE the event is forwarded, so a reader + /// woken by the event never sees the pre-update mask. + pub(super) access_grants: Arc, + /// The live access deadline (client wall clock, unix seconds; `0` = permanent) — re-anchored + /// from every `AccessUpdate`'s relative `remaining_secs`. + pub(super) access_deadline_unix: Arc, + /// Access updates → the embedder's event plane ([`NativeClient::next_access_update`]). + /// try_send like the clipboard/cursor planes: a lagging embedder drops the oldest news, + /// and the two live slots above already hold the latest truth it would re-derive. + pub(super) access_tx: std::sync::mpsc::SyncSender, } impl ControlTask { @@ -54,6 +65,9 @@ impl ControlTask { clip_event_tx, cursor_shape_tx, mode_gen, + access_grants, + access_deadline_unix, + access_tx, } = self; // Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every // CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after @@ -275,6 +289,26 @@ impl ControlTask { "out-of-bounds shard-payload change — ignoring (no ack)" ); } + } else if let Ok(upd) = crate::quic::AccessUpdate::decode(&msg) { + // Mid-session access change (a console edit) or an expiry warning + // (T−5 m / T−1 m). Latest-wins per design: fold the update into the + // live slots FIRST, then wake the embedder — the host enforces + // regardless, this is the courtesy that lets the client release a + // grab it no longer backs and warn before the expiry close. + tracing::info!( + grants = upd.grants, + remaining_secs = upd.remaining_secs, + "host updated this session's access" + ); + access_grants.store(upd.grants, Ordering::Relaxed); + access_deadline_unix.store( + crate::client::access_deadline_from( + wall_clock_ns(), + upd.remaining_secs, + ), + Ordering::Relaxed, + ); + let _ = access_tx.try_send(upd); } else if let Ok(shape) = crate::quic::CursorShape::decode(&msg) { // Pointer bitmap changed (cursor channel, only when negotiated). try_send: // an overflowing ring drops the newest shape — the next change resends. diff --git a/crates/punktfunk-core/src/client/pump/handshake.rs b/crates/punktfunk-core/src/client/pump/handshake.rs index b8e944cb..0c6332df 100644 --- a/crates/punktfunk-core/src/client/pump/handshake.rs +++ b/crates/punktfunk-core/src/client/pump/handshake.rs @@ -256,6 +256,8 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result, + /// The session's LIVE access grants (see [`NativeClient::access_grants`]): seeded from the + /// Welcome advert; every [`crate::quic::AccessUpdate`] moves it (latest wins, per design). + pub(crate) access_grants: Arc, + /// The live access deadline as client wall clock, unix seconds; `0` = permanent. Seeded + /// from the Welcome's `expires_in_secs`, re-anchored by every `AccessUpdate` — see + /// [`NativeClient::access_deadline_unix`]. + pub(crate) access_deadline_unix: Arc, + /// Inbound access updates → the embedder's event plane + /// ([`NativeClient::next_access_update`]), pushed by the control task AFTER it folded the + /// update into the two live slots above. + pub(crate) access_tx: SyncSender, + /// The typed close code a MID-SESSION end carried, when it is one of the shared + /// [`crate::reject::RejectReason`] vocabulary; `0` = none. Latched by the worker's + /// close watch beside `end_reason`, so an access-expiry close (0x69) can render its + /// real sentence instead of the generic host-error one — see + /// [`NativeClient::end_reject`]. + pub(crate) end_reject_code: Arc, } /// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking