Six crates were still unguarded, and all six are OURS — none vendored: pf-console-ui, pf-gpu, punktfunk-tray, clients/windows, tools/display-disturb, wdk-probe. Two of them ship (the tray and the Windows client), so "small" was about item count, not exposure. Five are closed here. Only 17 of their 75 unsafe items actually lacked a proof — pf-gpu, punktfunk-tray and display-disturb were already fully documented and needed nothing but the deny, which is the good case: the convention was being followed, just not enforced. The 17 that were missing are the usual Win32/COM shapes, and two were worth stating properly. `clients/windows`'s `GetCurrentPackageFullName` is called with `len = 0` and no buffer — that is the documented identity PROBE, which writes nothing, and reading it as a normal query would be a mistake. `pf-console-ui`'s two `destroy_image_view` calls are the load-bearing ones: the comment above one already argued that in-flight sampling of that slot ended two presents ago (the ring alternates and the presenter waits its fence before each record), which is exactly the kind of reasoning a `// SAFETY:` should carry and it was sitting there unlabelled. Also fixes a real Windows-only clippy error this uncovered: `pf-gpu` had a `#[cfg(target_os = "windows")]` fn AFTER its `mod tests`, tripping `items_after_test_module`. It never fired on Linux (the item does not exist there) and no CI job clippies pf-gpu on Windows, so it sat unseen. Moved above the test module. Remaining: `wdk-probe` (26 items) alone, and only because it needs the WDK to build — .47 cannot, so nothing here can verify a deny on it. Verified: Linux .21 fmt + both CI clippy steps rc=0; Windows .47 the four Windows-relevant crates at `-D warnings` rc=0.
449 lines
16 KiB
Rust
449 lines
16 KiB
Rust
//! The console shell: the screen stack, the push/pop entrance/exit choreography, the
|
||
//! chrome every screen shares (pinned title, controller chip, hint bar), and the modal
|
||
//! overlays (connecting, waking, toasts). Screens draw CONTENT; the shell makes them
|
||
//! read — and move — as one coherent console.
|
||
//!
|
||
//! Transitions: a push slides the incoming screen up out of a fade while the outgoing
|
||
//! one recedes; a pop mirrors it. One eased 0→1 progress drives both layers (0.26 s,
|
||
//! ease-out cubic — the WinUI shell's entrance feel), each composited through a
|
||
//! `save_layer_alpha` so a screen fades as a unit, never element by element. The
|
||
//! backdrop crossfades in parallel when the screens disagree (aurora ↔ form).
|
||
|
||
use crate::anim::Progress;
|
||
use crate::glyphs::GlyphStyle;
|
||
use crate::library::{mesh_sksl, LibraryShared};
|
||
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
||
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
|
||
use anyhow::{anyhow, Result};
|
||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse, PadInfo};
|
||
use pf_client_core::trust;
|
||
use pf_presenter::overlay::OverlayAction;
|
||
use skia_safe::{Canvas, Color4f, Data, Paint, Rect, RuntimeEffect};
|
||
use std::collections::VecDeque;
|
||
use std::time::Instant;
|
||
|
||
mod overlays;
|
||
mod render;
|
||
|
||
const TRANSITION_S: f64 = 0.26;
|
||
/// Chrome bands (design units): the pinned title above, hints below.
|
||
const TOP_BAND: f64 = 64.0;
|
||
const BOTTOM_BAND: f64 = 86.0;
|
||
|
||
enum Motion {
|
||
None,
|
||
Push(Progress),
|
||
Pop { leaving: Box<Screen>, t: Progress },
|
||
}
|
||
|
||
struct Toast {
|
||
text: String,
|
||
at: f64,
|
||
}
|
||
|
||
struct Connecting {
|
||
title: String,
|
||
canceling: bool,
|
||
appear: f64,
|
||
/// A request-access wait (parked on the host until the operator approves) — the
|
||
/// takeover reads "Waiting for approval" rather than "Connecting".
|
||
request_access: bool,
|
||
}
|
||
|
||
/// What the session binary hands the shell at construction.
|
||
pub struct ConsoleOptions {
|
||
/// The machine's hostname — the default device name pairing registers.
|
||
pub device_name: String,
|
||
/// Steam Deck: Steam's keyboard types (SDL text input); ours never draws.
|
||
pub deck: bool,
|
||
}
|
||
|
||
pub(crate) struct Shell {
|
||
stack: Vec<Screen>,
|
||
motion: Motion,
|
||
console: ConsoleShared,
|
||
library: LibraryShared,
|
||
bus: ConsoleBus,
|
||
actions: VecDeque<OverlayAction>,
|
||
settings: trust::Settings,
|
||
hosts: Vec<HostRow>,
|
||
hosts_gen: u64,
|
||
device_name: String,
|
||
deck: bool,
|
||
pub(crate) in_stream: bool,
|
||
connecting: Option<Connecting>,
|
||
wake: Option<WakeStatus>,
|
||
/// True while `wake` is the shell's own optimistic placeholder — raised the instant a
|
||
/// screen queues `ConsoleCmd::Wake` (see [`Self::apply`]), before the service thread has
|
||
/// round-tripped its first real `WakeStatus` (~100 ms–1 s). `sync` must not clear the
|
||
/// placeholder in that window, or navigation would race the wake ungated (the "pressed A,
|
||
/// cursor drifted to Add Host, then got thrust into the stream" bug).
|
||
wake_optimistic: bool,
|
||
toast: Option<Toast>,
|
||
mesh: RuntimeEffect,
|
||
/// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition.
|
||
bg_mix: f64,
|
||
glyphs: GlyphStyle,
|
||
chip: Option<String>,
|
||
pads: Vec<PadInfo>,
|
||
t0: Instant,
|
||
last_frame: Option<Instant>,
|
||
}
|
||
|
||
impl Shell {
|
||
pub(crate) fn new(
|
||
console: ConsoleShared,
|
||
library: LibraryShared,
|
||
bus: ConsoleBus,
|
||
opts: ConsoleOptions,
|
||
stack: Vec<Screen>,
|
||
) -> Result<Shell> {
|
||
anyhow::ensure!(!stack.is_empty(), "the console needs a root screen");
|
||
let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None)
|
||
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
|
||
let bg_mix = match stack.last().expect("non-empty").background() {
|
||
Bg::Aurora => 0.0,
|
||
Bg::Form => 1.0,
|
||
};
|
||
Ok(Shell {
|
||
stack,
|
||
motion: Motion::None,
|
||
console,
|
||
library,
|
||
bus,
|
||
actions: VecDeque::new(),
|
||
settings: trust::Settings::load(),
|
||
hosts: Vec::new(),
|
||
hosts_gen: u64::MAX,
|
||
device_name: opts.device_name,
|
||
deck: opts.deck,
|
||
in_stream: false,
|
||
connecting: None,
|
||
wake: None,
|
||
wake_optimistic: false,
|
||
toast: None,
|
||
mesh,
|
||
bg_mix,
|
||
glyphs: GlyphStyle::Keyboard,
|
||
chip: None,
|
||
pads: Vec::new(),
|
||
t0: Instant::now(),
|
||
last_frame: None,
|
||
})
|
||
}
|
||
|
||
fn t(&self) -> f64 {
|
||
self.t0.elapsed().as_secs_f64()
|
||
}
|
||
|
||
pub(crate) fn editing(&self) -> bool {
|
||
!self.in_stream
|
||
&& self.connecting.is_none()
|
||
&& self.stack.last().is_some_and(Screen::editing)
|
||
}
|
||
|
||
pub(crate) fn take_action(&mut self) -> Option<OverlayAction> {
|
||
self.actions.pop_front()
|
||
}
|
||
|
||
// --- Session lifecycle edges (from the overlay's `session_phase`) --------------------
|
||
|
||
pub(crate) fn set_connecting(&mut self, title: Option<String>) {
|
||
match title {
|
||
Some(title) => {
|
||
self.connecting = Some(Connecting {
|
||
title,
|
||
canceling: false,
|
||
appear: 0.0,
|
||
request_access: false,
|
||
})
|
||
}
|
||
None => self.connecting = None,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn session_failed(&mut self, msg: &str) {
|
||
self.connecting = None;
|
||
self.in_stream = false;
|
||
self.show_toast(format!("Couldn't connect — {msg}"));
|
||
}
|
||
|
||
pub(crate) fn session_streaming(&mut self) {
|
||
self.connecting = None;
|
||
self.in_stream = true;
|
||
}
|
||
|
||
pub(crate) fn session_ended(&mut self, reason: Option<&str>) {
|
||
self.connecting = None;
|
||
self.in_stream = false;
|
||
if let Some(reason) = reason {
|
||
self.show_toast(format!("Session ended — {reason}"));
|
||
}
|
||
}
|
||
|
||
fn show_toast(&mut self, text: String) {
|
||
self.toast = Some(Toast { text, at: self.t() });
|
||
}
|
||
|
||
// --- Model sync (hosts, pairing, wake) — before input and before render --------------
|
||
|
||
fn sync(&mut self) {
|
||
if self.console.hosts_gen() != self.hosts_gen {
|
||
(self.hosts, self.hosts_gen) = self.console.hosts_snapshot();
|
||
}
|
||
|
||
let pair = self.console.pair();
|
||
match &pair {
|
||
PairPhase::Idle => {}
|
||
PairPhase::Paired { key } => {
|
||
let name = self
|
||
.hosts
|
||
.iter()
|
||
.find(|h| &h.key == key)
|
||
.map_or_else(|| "the host".to_string(), |h| h.name.clone());
|
||
self.show_toast(format!("Paired with {name}"));
|
||
self.console.set_pair(PairPhase::Idle);
|
||
if matches!(self.stack.last(), Some(Screen::Pair(_))) {
|
||
self.apply_nav(Nav::Pop);
|
||
}
|
||
}
|
||
phase => {
|
||
if let Some(Screen::Pair(p)) = self.stack.last_mut() {
|
||
p.apply_phase(phase);
|
||
}
|
||
if matches!(phase, PairPhase::Failed(_)) {
|
||
self.console.set_pair(PairPhase::Idle);
|
||
}
|
||
}
|
||
}
|
||
|
||
match self.console.wake() {
|
||
Some(w) => {
|
||
self.wake_optimistic = false;
|
||
self.wake = Some(w);
|
||
}
|
||
// No service status yet: keep an optimistic placeholder alive — clearing it here
|
||
// would reopen the ungated window it exists to close.
|
||
None if !self.wake_optimistic => self.wake = None,
|
||
None => {}
|
||
}
|
||
if let Some(w) = &self.wake {
|
||
if w.online {
|
||
// Awake: stop the wake loop, and connect if that's what A meant.
|
||
let intent = w.then_connect.then(|| {
|
||
self.hosts
|
||
.iter()
|
||
.find(|h| h.key == w.key)
|
||
.map(|h| ConnectIntent {
|
||
addr: h.addr.clone(),
|
||
port: h.port,
|
||
fp_hex: h.fp_hex.clone(),
|
||
launch: None,
|
||
title: h.name.clone(),
|
||
request_access: false,
|
||
})
|
||
});
|
||
self.bus.send(ConsoleCmd::CancelWake);
|
||
self.wake = None;
|
||
if let Some(Some(intent)) = intent {
|
||
self.start_connect(intent);
|
||
// The wake takeover was already full-screen; skip the connect fade-in so the
|
||
// Waking → Connecting handoff is seamless (no flash of the home behind).
|
||
if let Some(c) = &mut self.connecting {
|
||
c.appear = 1.0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn start_connect(&mut self, intent: ConnectIntent) {
|
||
self.set_connecting(Some(intent.title.clone()));
|
||
if let Some(c) = &mut self.connecting {
|
||
c.request_access = intent.request_access;
|
||
}
|
||
self.actions.push_back(OverlayAction::Launch {
|
||
addr: intent.addr,
|
||
port: intent.port,
|
||
fp_hex: intent.fp_hex,
|
||
launch: intent.launch,
|
||
title: intent.title,
|
||
request_access: intent.request_access,
|
||
});
|
||
}
|
||
|
||
// --- Input ---------------------------------------------------------------------------
|
||
|
||
pub(crate) fn handle_menu(&mut self, ev: MenuEvent) -> Option<MenuPulse> {
|
||
self.sync();
|
||
// Modal precedence: the connect card, then the wake card, then the screens.
|
||
if let Some(c) = &mut self.connecting {
|
||
if ev == MenuEvent::Back && !c.canceling {
|
||
c.canceling = true;
|
||
self.actions.push_back(OverlayAction::CancelConnect);
|
||
return Some(MenuPulse::Confirm);
|
||
}
|
||
return None;
|
||
}
|
||
if let Some(w) = &self.wake {
|
||
match ev {
|
||
MenuEvent::Back => {
|
||
self.bus.send(ConsoleCmd::CancelWake);
|
||
self.wake = None;
|
||
self.wake_optimistic = false;
|
||
return Some(MenuPulse::Confirm);
|
||
}
|
||
MenuEvent::Confirm if w.timed_out => {
|
||
self.bus.send(ConsoleCmd::Wake {
|
||
key: w.key.clone(),
|
||
then_connect: w.then_connect,
|
||
});
|
||
return Some(MenuPulse::Confirm);
|
||
}
|
||
_ => return None,
|
||
}
|
||
}
|
||
// Mid-transition input is dropped — 0.26 s, and it keeps a double-tapped A
|
||
// from pushing two screens.
|
||
if !matches!(self.motion, Motion::None) {
|
||
return None;
|
||
}
|
||
|
||
let mut fx = Outbox::default();
|
||
let pulse = {
|
||
let mut ctx = Ctx {
|
||
hosts: &self.hosts,
|
||
library: &self.library,
|
||
settings: &mut self.settings,
|
||
pads: &self.pads,
|
||
deck: self.deck,
|
||
device_name: &self.device_name,
|
||
t: self.t0.elapsed().as_secs_f64(),
|
||
};
|
||
self.stack
|
||
.last_mut()
|
||
.expect("non-empty stack")
|
||
.menu(ev, &mut ctx, &mut fx)
|
||
};
|
||
self.apply(fx);
|
||
pulse
|
||
}
|
||
|
||
/// The keyboard fallback — the console is fully drivable with no pad. Arrows and
|
||
/// Enter/Esc map onto menu events; Y/X mirror the pad's Secondary/Tertiary
|
||
/// (suppressed while editing, where letters are text).
|
||
pub(crate) fn key(&mut self, sc: sdl3::keyboard::Scancode, repeat: bool) -> bool {
|
||
use sdl3::keyboard::Scancode as S;
|
||
if self.editing() {
|
||
if let Some(top) = self.stack.last_mut() {
|
||
if top.edit_key(sc) {
|
||
return true;
|
||
}
|
||
}
|
||
// Arrows etc. still drive the OSK grid below.
|
||
}
|
||
let editing = self.stack.last().is_some_and(Screen::editing);
|
||
let ev = match sc {
|
||
S::Left => MenuEvent::Move(MenuDir::Left),
|
||
S::Right => MenuEvent::Move(MenuDir::Right),
|
||
S::Up => MenuEvent::Move(MenuDir::Up),
|
||
S::Down => MenuEvent::Move(MenuDir::Down),
|
||
S::Return | S::KpEnter | S::Space if !repeat => MenuEvent::Confirm,
|
||
S::Escape | S::Backspace if !repeat => MenuEvent::Back,
|
||
S::PageUp if !repeat => MenuEvent::JumpBack,
|
||
S::PageDown if !repeat => MenuEvent::JumpForward,
|
||
S::Y if !repeat && !editing => MenuEvent::Secondary,
|
||
S::X if !repeat && !editing => MenuEvent::Tertiary,
|
||
_ => return false,
|
||
};
|
||
self.handle_menu(ev); // no pad to pulse
|
||
true
|
||
}
|
||
|
||
pub(crate) fn text_input(&mut self, text: &str) {
|
||
if let Some(top) = self.stack.last_mut() {
|
||
top.text_input(text);
|
||
}
|
||
}
|
||
|
||
fn apply(&mut self, fx: Outbox) {
|
||
for cmd in fx.cmds {
|
||
// An input-initiated wake must gate input in the SAME call, exactly like
|
||
// `start_connect` gates via `connecting`: the service's first WakeStatus is
|
||
// ~100 ms–1 s away, and until it lands the screen would keep navigating —
|
||
// then the arriving status freezes the UI wherever the cursor drifted, with
|
||
// the "Waking…" card never shown for a fast wake. Raise it optimistically;
|
||
// `sync` lets the service's real status supersede this placeholder.
|
||
if let ConsoleCmd::Wake { key, then_connect } = &cmd {
|
||
let name = self
|
||
.hosts
|
||
.iter()
|
||
.find(|h| &h.key == key)
|
||
.map(|h| h.name.clone())
|
||
.unwrap_or_default();
|
||
self.wake = Some(WakeStatus {
|
||
key: key.clone(),
|
||
name,
|
||
seconds: 0,
|
||
timed_out: false,
|
||
online: false,
|
||
then_connect: *then_connect,
|
||
});
|
||
self.wake_optimistic = true;
|
||
}
|
||
self.bus.send(cmd);
|
||
}
|
||
if let Some(text) = fx.toast {
|
||
self.show_toast(text);
|
||
}
|
||
if let Some(intent) = fx.connect {
|
||
self.start_connect(intent);
|
||
}
|
||
if let Some(nav) = fx.nav {
|
||
self.apply_nav(nav);
|
||
}
|
||
}
|
||
|
||
fn apply_nav(&mut self, nav: Nav) {
|
||
match nav {
|
||
Nav::Push(screen) => {
|
||
self.stack.push(*screen);
|
||
self.motion = Motion::Push(Progress::new(TRANSITION_S));
|
||
}
|
||
Nav::Pop => {
|
||
if self.stack.len() > 1 {
|
||
let leaving = self.stack.pop().expect("len > 1");
|
||
self.motion = Motion::Pop {
|
||
leaving: Box::new(leaving),
|
||
t: Progress::new(TRANSITION_S),
|
||
};
|
||
} else {
|
||
// Popping the root quits the console (B at home).
|
||
self.actions.push_back(OverlayAction::Quit);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) {
|
||
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
|
||
// SAFETY: `uniforms` is a local `[f32; 3]` — exactly 12 bytes — and `f32` has no padding or
|
||
// invalid bit patterns, so reading it as bytes is sound; the slice is copied by
|
||
// `Data::new_copy` before `uniforms` goes out of scope.
|
||
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 12) };
|
||
match self.mesh.make_shader(Data::new_copy(bytes), &[], None) {
|
||
Some(shader) => {
|
||
let mut paint = Paint::default();
|
||
paint.set_shader(shader);
|
||
canvas.draw_rect(Rect::from_wh(w as f32, h as f32), &paint);
|
||
}
|
||
None => {
|
||
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|