feat(client): the desktop session wears its access level — live grants on the connector, capture that asks first, the chip, and honest endings (per-client access WP9)
The connector now carries the session's LIVE access truth: the Welcome advert
seeds NativeClient::access_grants / access_deadline_unix (client-anchored, so
skew never moves the countdown), the control task folds every MSG_ACCESS_UPDATE
in latest-wins before waking next_access_update, and a typed mid-session close
latches as end_reject — an access expiry now ends as "your access to this host
has expired", not "the host ended the session with an error".
pf-client-core surfaces it as SessionEvent::Access { SessionAccess, notice }
(module `access`: derived preset labels, chip text, toast wording — the rules
the Apple/Android ports mirror), gates the mic uplink and clipboard bridge at
spawn on their grants (deny-at-setup, client half), and follows a live MIC edit
by stopping/starting the uplink without a reconnect.
The presenter gates capture on the mask (§7 "not capture what can't land"): no
pointer lock without POINTER, no keyboard grab without KEYBOARD, engage refuses
outright when neither is granted (the hint pill stays down), every wire send
funnels through the host's own classify(), and a live edit flushes what a
removed class still held. The overlay wears the chip — "Controller only · ends
in 1 h 58 m", top-right beside the mic badge, at every stats tier — and the
T−5 m / T−1 m warnings ride the pill slot as toasts. Full-control permanent
(every old host) renders exactly today's look.
This commit is contained in:
@@ -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<Instant>,
|
||||
}
|
||||
|
||||
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<Duration> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -50,6 +50,12 @@ struct Drawn {
|
||||
height: u32,
|
||||
stats: Option<String>,
|
||||
hint: Option<String>,
|
||||
/// 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<String>,
|
||||
/// The transient access toast (holds the hint pill's slot while up).
|
||||
notice: Option<String>,
|
||||
/// 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
|
||||
|
||||
@@ -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<NativeClient>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+167
-31
@@ -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<bool>,
|
||||
/// 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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
// 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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
true,
|
||||
desktop,
|
||||
inhibit_shortcuts,
|
||||
cap.grants(),
|
||||
);
|
||||
}
|
||||
flipped = true;
|
||||
@@ -917,7 +944,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
(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<Option<Outcome>
|
||||
}
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
}
|
||||
_ => (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<Option<Outcome>
|
||||
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";
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<Receiver<crate::quic::CursorShape>>,
|
||||
/// Inbound per-frame cursor state — `0xD0` datagrams (same negotiation gate as shapes).
|
||||
cursor_state: Mutex<Receiver<crate::quic::CursorState>>,
|
||||
/// 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<Receiver<crate::quic::AccessUpdate>>,
|
||||
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
||||
/// 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<AtomicU32>,
|
||||
/// The live access deadline (client wall clock, unix seconds; `0` = permanent) — see
|
||||
/// [`NativeClient::access_deadline_unix`].
|
||||
access_deadline_unix: Arc<AtomicU64>,
|
||||
/// The typed [`crate::reject::RejectReason`] close code a mid-session end carried
|
||||
/// (`0` = none) — see [`NativeClient::end_reject`].
|
||||
end_reject_code: Arc<AtomicU32>,
|
||||
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
||||
probe: Arc<Mutex<ProbeState>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
@@ -577,6 +609,8 @@ impl NativeClient {
|
||||
std::sync::mpsc::sync_channel::<crate::quic::CursorShape>(CURSOR_SHAPE_QUEUE);
|
||||
let (cursor_state_tx, cursor_state_rx) =
|
||||
std::sync::mpsc::sync_channel::<crate::quic::CursorState>(CURSOR_STATE_QUEUE);
|
||||
let (access_tx, access_rx) =
|
||||
std::sync::mpsc::sync_channel::<crate::quic::AccessUpdate>(ACCESS_QUEUE);
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
||||
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> {
|
||||
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<u64> {
|
||||
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<crate::quic::AccessUpdate> {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<AtomicU32>,
|
||||
/// 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<AtomicU32>,
|
||||
/// 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<std::sync::atomic::AtomicU64>,
|
||||
/// 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<crate::quic::AccessUpdate>,
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -256,6 +256,8 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
shard_payload: welcome.shard_payload,
|
||||
host_caps: welcome.host_caps,
|
||||
mgmt_port: welcome.mgmt_port,
|
||||
grants: welcome.grants,
|
||||
expires_in_secs: welcome.expires_in_secs,
|
||||
},
|
||||
welcome.host_caps,
|
||||
))
|
||||
|
||||
@@ -90,6 +90,23 @@ pub(crate) struct WorkerArgs {
|
||||
/// The live encoder-target mirror (see [`NativeClient::live_bitrate_kbps`]): the worker seeds
|
||||
/// it from the Welcome; the control task updates it on every `BitrateChanged` ack.
|
||||
pub(crate) live_bitrate: Arc<AtomicU32>,
|
||||
/// 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<AtomicU32>,
|
||||
/// 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<AtomicU64>,
|
||||
/// 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<crate::quic::AccessUpdate>,
|
||||
/// 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<AtomicU32>,
|
||||
}
|
||||
|
||||
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
|
||||
|
||||
Reference in New Issue
Block a user