feat(linux): game library browser; split app.rs into cli/launch/ui_trust
- library.rs + ui_library.rs: the host's unified game library over the management API (the Apple LibraryClient/LibraryView ported) — mTLS with the paired identity, host verified by its pinned cert fingerprint (ureq + rustls, unified with the workspace rustls 0.23); posters load async with monogram placeholders, and picking a title starts a session that asks the host to launch it (the library id rides the Hello). - app.rs (~800 lines lighter) splits into cli.rs (argv/headless pairing/--connect/screenshot scenes), launch.rs (mode resolve + session worker + event stream into the UI) and ui_trust.rs (TOFU / SPAKE2 PIN / delegated-approval dialogs); ui_hosts/ui_stream reworked around the split. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+2
@@ -2745,11 +2745,13 @@ dependencies = [
|
||||
"opus",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -33,6 +33,11 @@ pipewire = "0.9"
|
||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
|
||||
mdns-sd = "0.20"
|
||||
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
|
||||
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
|
||||
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
||||
ureq = "2"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
|
||||
+15
-6
@@ -22,6 +22,10 @@ Built in Rust, it links the shared **`punktfunk-core`** directly (no C ABI) and
|
||||
First connect does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on
|
||||
a pinned identity.
|
||||
- **Per-host speed test** to pick a bitrate, plus compositor and mode preferences in Settings.
|
||||
- **Game library browser** *(experimental, off by default)* — "Browse library…" on a saved host
|
||||
shows its games (Steam + custom) as a poster grid; click one to launch it in the session.
|
||||
Fetched from the host's management API over mTLS — paired devices are authorized by their
|
||||
certificate, no extra host setup.
|
||||
|
||||
## Get it
|
||||
|
||||
@@ -51,23 +55,28 @@ cargo run -p punktfunk-client-linux -- --connect HOST[:PORT] # skip the host l
|
||||
|
||||
The binary is named **`punktfunk-client`**. Handy flags: `--connect host[:port]` (start a session
|
||||
immediately — for scripting and the Steam Deck launcher), `--discover [secs]`, and
|
||||
`--pair <PIN> --connect host[:port]` (run the pairing ceremony headlessly). Force a decoder with
|
||||
`--pair <PIN> --connect host[:port]` (run the pairing ceremony headlessly), and
|
||||
`--library host[:mgmt_port]` (print a host's game library headlessly). Force a decoder with
|
||||
`PUNKTFUNK_DECODER=software|vaapi`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs · app.rs entry point, GTK application, CLI paths
|
||||
ui_hosts.rs host list (mDNS + saved), pairing / trust dialogs
|
||||
main.rs · app.rs entry point, GTK application, primary menu, CSS
|
||||
cli.rs CLI paths (--connect, headless --pair, screenshot scenes)
|
||||
ui_hosts.rs host card grids (saved + discovered) · add-host dialog · banner
|
||||
ui_library.rs game-library poster grid (per-host, launches titles)
|
||||
ui_trust.rs TOFU / PIN-pairing / request-access dialogs
|
||||
ui_settings.rs resolution · refresh · decoder · bitrate · compositor · mic
|
||||
ui_stream.rs the stream window (GtkGraphicsOffload present) + input capture
|
||||
session.rs session lifecycle over the NativeClient connector
|
||||
launch.rs · session.rs session launch/UI glue; lifecycle over the NativeClient connector
|
||||
video.rs FFmpeg VAAPI / software decode → dmabuf / texture
|
||||
audio.rs PipeWire playback + mic uplink
|
||||
gamepad.rs · keymap.rs SDL3 controllers + feedback; keyboard VK mapping
|
||||
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
|
||||
tools/screenshots.sh store screenshot capture
|
||||
trust.rs · discovery.rs persistent identity, known hosts + settings, mDNS browse
|
||||
library.rs mgmt-API library client (mTLS + pinned fingerprint, art proxy)
|
||||
tools/screenshots.sh store screenshot capture (app self-capture; Xvfb fallback)
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
+193
-646
@@ -1,10 +1,11 @@
|
||||
//! The application shell: window, navigation, trust dialogs, session lifecycle.
|
||||
//! The application shell: window, navigation, and top-level glue. The trust/pairing
|
||||
//! dialogs live in `ui_trust`, session launch in `launch`, CLI entry paths in `cli`, the
|
||||
//! hosts grid in `ui_hosts`.
|
||||
|
||||
use crate::session::{SessionEvent, SessionParams};
|
||||
use crate::trust::{KnownHost, KnownHosts, Settings};
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use crate::trust::Settings;
|
||||
use crate::ui_hosts::{ConnectRequest, HostsCallbacks, HostsUi};
|
||||
use adw::prelude::*;
|
||||
use gtk::{gdk, glib};
|
||||
use gtk::{gdk, gio, glib};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref};
|
||||
use std::cell::RefCell;
|
||||
@@ -12,24 +13,58 @@ use std::rc::Rc;
|
||||
|
||||
const APP_ID: &str = "io.unom.Punktfunk";
|
||||
|
||||
struct App {
|
||||
window: adw::ApplicationWindow,
|
||||
nav: adw::NavigationView,
|
||||
toasts: adw::ToastOverlay,
|
||||
settings: Rc<RefCell<Settings>>,
|
||||
identity: (String, String),
|
||||
/// Custom styles on top of libadwaita for the host cards: status pills, presence pips,
|
||||
/// the most-recent accent bar, dashed discovered cards. Colours come from the adwaita
|
||||
/// named palette so dark mode just works.
|
||||
const CSS: &str = "
|
||||
.pf-host-card { padding: 16px; }
|
||||
.pf-pill { font-size: 0.72em; font-weight: bold; padding: 2px 10px; border-radius: 999px;
|
||||
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); }
|
||||
.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); }
|
||||
.pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); }
|
||||
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
|
||||
background: alpha(currentColor, 0.35); }
|
||||
.pf-pip.pf-online { background: @success_color; }
|
||||
.pf-recent { box-shadow: inset 3px 0 0 0 @accent_bg_color; }
|
||||
.pf-discovered { border: 1px dashed alpha(currentColor, 0.35); }
|
||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
||||
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
||||
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
|
||||
";
|
||||
|
||||
pub struct App {
|
||||
pub window: adw::ApplicationWindow,
|
||||
pub nav: adw::NavigationView,
|
||||
pub toasts: adw::ToastOverlay,
|
||||
pub settings: Rc<RefCell<Settings>>,
|
||||
pub identity: (String, String),
|
||||
/// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback.
|
||||
gamepad: crate::gamepad::GamepadService,
|
||||
pub gamepad: crate::gamepad::GamepadService,
|
||||
/// One session at a time — ignore connects while one is starting/running.
|
||||
busy: std::cell::Cell<bool>,
|
||||
pub busy: std::cell::Cell<bool>,
|
||||
/// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts.
|
||||
fullscreen: bool,
|
||||
pub fullscreen: bool,
|
||||
/// The hosts page handle (banner + per-card connecting spinner), set right after the
|
||||
/// page is built — `None` only during construction.
|
||||
pub hosts: RefCell<Option<Rc<HostsUi>>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn toast(&self, msg: &str) {
|
||||
pub fn toast(&self, msg: &str) {
|
||||
self.toasts.add_toast(adw::Toast::new(msg));
|
||||
}
|
||||
|
||||
pub fn hosts_ui(&self) -> Option<Rc<HostsUi>> {
|
||||
self.hosts.borrow().clone()
|
||||
}
|
||||
|
||||
/// Surface a connect failure on the hosts page banner (toast fallback pre-build).
|
||||
pub fn connect_error(&self, msg: &str) {
|
||||
match self.hosts_ui() {
|
||||
Some(h) => h.show_error(msg),
|
||||
None => self.toast(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> glib::ExitCode {
|
||||
@@ -40,13 +75,17 @@ pub fn run() -> glib::ExitCode {
|
||||
.init();
|
||||
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
|
||||
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
|
||||
if let Some(pin) = arg_value("--pair") {
|
||||
return headless_pair(&pin);
|
||||
if let Some(pin) = crate::cli::arg_value("--pair") {
|
||||
return crate::cli::headless_pair(&pin);
|
||||
}
|
||||
// Headless library fetch (no GTK window): `--library host[:mgmt_port] [--fp HEX]`.
|
||||
if let Some(target) = crate::cli::arg_value("--library") {
|
||||
return crate::cli::headless_library(&target);
|
||||
}
|
||||
let mut builder = adw::Application::builder().application_id(APP_ID);
|
||||
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each
|
||||
// launch its own primary instance instead of forwarding to a still-registered name.
|
||||
if shot_scene().is_some() {
|
||||
if crate::cli::shot_scene().is_some() {
|
||||
builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE);
|
||||
}
|
||||
let app = builder.build();
|
||||
@@ -56,105 +95,6 @@ pub fn run() -> glib::ExitCode {
|
||||
app.run_with_args(&[] as &[&str])
|
||||
}
|
||||
|
||||
/// The value following `flag` in argv, if present (`--flag value`).
|
||||
fn arg_value(flag: &str) -> Option<String> {
|
||||
std::env::args()
|
||||
.skip_while(|a| a != flag)
|
||||
.nth(1)
|
||||
.filter(|v| !v.starts_with("--"))
|
||||
}
|
||||
|
||||
/// True if argv contains `flag` (a valueless switch).
|
||||
fn arg_flag(flag: &str) -> bool {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// Run the stream fullscreen with no window chrome — the Steam Deck / Gaming-Mode launch path.
|
||||
/// The Decky wrapper passes `--fullscreen`; we also honor the Deck/gamescope env as a fallback
|
||||
/// so a manual launch under Gaming Mode does the right thing too.
|
||||
fn fullscreen_mode() -> bool {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
|
||||
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
|
||||
/// store the streaming path uses (same binary), so pairing here makes the stream work.
|
||||
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
||||
fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!("--pair requires --connect host[:port]");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
let (addr, port) = match target.rsplit_once(':') {
|
||||
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
|
||||
None => (target.clone(), 9777),
|
||||
};
|
||||
// The label the HOST stores this client under (its paired-devices list).
|
||||
let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string());
|
||||
|
||||
let identity = match crate::trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
match NativeClient::pair(
|
||||
&addr,
|
||||
port,
|
||||
(&identity.0, &identity.1),
|
||||
pin.trim(),
|
||||
&name,
|
||||
std::time::Duration::from_secs(90),
|
||||
) {
|
||||
Ok(fp) => {
|
||||
let fp_hex = crate::trust::hex(&fp);
|
||||
let mut known = KnownHosts::load();
|
||||
known.upsert(KnownHost {
|
||||
name: arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
fp_hex: fp_hex.clone(),
|
||||
paired: true,
|
||||
});
|
||||
let _ = known.save();
|
||||
println!("paired {addr}:{port} fp={fp_hex}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--connect host[:port]` — skip the hosts page and start a session immediately
|
||||
/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host
|
||||
/// already pinned at this address connects silently on its stored pin; an unknown host is
|
||||
/// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are
|
||||
/// unset, so `initiate_connect`'s manual arm mandates pairing).
|
||||
fn cli_connect_request() -> Option<ConnectRequest> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let target = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--connect")
|
||||
.nth(1)?
|
||||
.clone();
|
||||
let (addr, port) = match target.rsplit_once(':') {
|
||||
Some((a, p)) => (a.to_string(), p.parse().ok()?),
|
||||
None => (target.clone(), 9777),
|
||||
};
|
||||
Some(ConnectRequest {
|
||||
name: addr.clone(),
|
||||
addr,
|
||||
port,
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_ui(gtk_app: &adw::Application) {
|
||||
let identity = match crate::trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
@@ -163,6 +103,7 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
load_css();
|
||||
|
||||
let nav = adw::NavigationView::new();
|
||||
let toasts = adw::ToastOverlay::new();
|
||||
@@ -170,8 +111,8 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(gtk_app)
|
||||
.title("Punktfunk")
|
||||
.default_width(1100)
|
||||
.default_height(720)
|
||||
.default_width(1200)
|
||||
.default_height(780)
|
||||
.content(&toasts)
|
||||
.build();
|
||||
|
||||
@@ -183,318 +124,159 @@ fn build_ui(gtk_app: &adw::Application) {
|
||||
identity,
|
||||
gamepad: crate::gamepad::GamepadService::start(),
|
||||
busy: std::cell::Cell::new(false),
|
||||
fullscreen: fullscreen_mode(),
|
||||
fullscreen: crate::cli::fullscreen_mode(),
|
||||
hosts: RefCell::new(None),
|
||||
});
|
||||
|
||||
let hosts_page = crate::ui_hosts::new(
|
||||
{
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| initiate_connect(app.clone(), req))
|
||||
let hosts_ui = Rc::new(crate::ui_hosts::new(
|
||||
app.settings.clone(),
|
||||
HostsCallbacks {
|
||||
on_connect: {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| crate::ui_trust::initiate_connect(app.clone(), req))
|
||||
},
|
||||
on_speed_test: {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| speed_test(app.clone(), req))
|
||||
},
|
||||
on_pair: {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| {
|
||||
if !app.busy.get() {
|
||||
crate::ui_trust::pin_dialog(app.clone(), req);
|
||||
}
|
||||
})
|
||||
},
|
||||
on_library: {
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| crate::ui_library::open(app.clone(), req))
|
||||
},
|
||||
},
|
||||
{
|
||||
let app = app.clone();
|
||||
Rc::new(move || {
|
||||
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad)
|
||||
})
|
||||
},
|
||||
{
|
||||
let app = app.clone();
|
||||
Rc::new(move |req| speed_test(app.clone(), req))
|
||||
},
|
||||
);
|
||||
nav.add(&hosts_page);
|
||||
));
|
||||
*app.hosts.borrow_mut() = Some(hosts_ui.clone());
|
||||
install_actions(&app, &hosts_ui);
|
||||
nav.add(&hosts_ui.page);
|
||||
window.present();
|
||||
|
||||
// CI screenshot mode: render one scripted, host-free scene and signal readiness
|
||||
// (clients/linux/tools/screenshots.sh). Mutually exclusive with a real connect.
|
||||
if let Some(scene) = shot_scene() {
|
||||
run_shot(app, &scene);
|
||||
if let Some(scene) = crate::cli::shot_scene() {
|
||||
crate::cli::run_shot(app, &scene);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(req) = cli_connect_request() {
|
||||
initiate_connect(app, req);
|
||||
if let Some(req) = crate::cli::cli_connect_request() {
|
||||
crate::ui_trust::initiate_connect(app, req);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
|
||||
fn shot_scene() -> Option<String> {
|
||||
std::env::var("PUNKTFUNK_SHOT_SCENE")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
fn load_css() {
|
||||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_string(CSS);
|
||||
if let Some(display) = gdk::Display::default() {
|
||||
gtk::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one mock-populated, host-free scene over the already-presented window, then print
|
||||
/// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture.
|
||||
/// No `NativeClient` or session is created. The stream scene is deliberately absent — its page
|
||||
/// requires a live connector (`ui_stream::new` takes an `Arc<NativeClient>`).
|
||||
fn run_shot(app: Rc<App>, scene: &str) {
|
||||
// A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256).
|
||||
let mock_req = || ConnectRequest {
|
||||
name: "Living Room PC".to_string(),
|
||||
addr: "192.168.1.42".to_string(),
|
||||
port: 9777,
|
||||
fp_hex: Some(
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(),
|
||||
),
|
||||
pair_optional: true,
|
||||
/// Window actions behind the hosts page's header: the primary (hamburger) menu entries
|
||||
/// plus the "+" add-host button and the empty state's call to action.
|
||||
fn install_actions(app: &Rc<App>, hosts: &Rc<HostsUi>) {
|
||||
let add = |name: &str, f: Box<dyn Fn()>| {
|
||||
let action = gio::SimpleAction::new(name, None);
|
||||
action.connect_activate(move |_, _| f());
|
||||
app.window.add_action(&action);
|
||||
};
|
||||
|
||||
match scene {
|
||||
// The saved-hosts grid reads ~/.config/punktfunk/client-known-hosts.json, which the
|
||||
// driver seeds — so the already-shown hosts page is the scene; nothing to do here.
|
||||
"hosts" | "02-hosts" => {}
|
||||
"settings" | "03-settings" => {
|
||||
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad);
|
||||
}
|
||||
"trust" | "04-trust" => tofu_dialog(app.clone(), mock_req()),
|
||||
"pair" | "05-pair" => pin_dialog(app.clone(), mock_req()),
|
||||
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
||||
}
|
||||
|
||||
let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(900);
|
||||
let scene = scene.to_string();
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
||||
use std::io::Write as _;
|
||||
println!("PF_SHOT_READY scene={scene}");
|
||||
let _ = std::io::stdout().flush();
|
||||
});
|
||||
}
|
||||
|
||||
/// The trust gate in front of every connect. The host is the policy authority (it
|
||||
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
|
||||
/// its trust UI from that:
|
||||
/// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently.
|
||||
/// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer
|
||||
/// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of
|
||||
/// the advertised policy.
|
||||
/// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a);
|
||||
/// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is
|
||||
/// mandatory (rule 3b).
|
||||
///
|
||||
/// A new host is never auto-connected without a stored pin or an explicit trust decision.
|
||||
fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
if app.busy.get() {
|
||||
return;
|
||||
}
|
||||
let known = KnownHosts::load();
|
||||
match &req.fp_hex {
|
||||
Some(fp_hex) => {
|
||||
if known.find_by_fp(fp_hex).is_some() {
|
||||
// Rule 1: pinned fingerprint matches — silent connect.
|
||||
start_session(app, req.clone(), crate::trust::parse_hex32(fp_hex));
|
||||
} else if known.find_by_addr(&req.addr, req.port).is_some() {
|
||||
// Rule 2: we trust a host at this address but the fingerprint changed —
|
||||
// the impostor signal. Re-pair via the PIN ceremony (no TOFU shortcut).
|
||||
app.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||
pin_dialog(app, req);
|
||||
} else if req.pair_optional {
|
||||
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
||||
tofu_dialog(app, req);
|
||||
} else {
|
||||
// Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval
|
||||
// (request access → approve in the console) or the PIN ceremony.
|
||||
approval_dialog(app, req);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Manual entry (no advertised fingerprint). A known address connects silently
|
||||
// on its stored pin (rule 1); an unknown one must pair — request access (approve in
|
||||
// the console) or use a PIN; never silent TOFU.
|
||||
match known
|
||||
.find_by_addr(&req.addr, req.port)
|
||||
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex))
|
||||
{
|
||||
Some(pin) => start_session(app, req, Some(pin)),
|
||||
None => approval_dialog(app, req), // rule 3b
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// First contact with a discovered host: show the advertised fingerprint and let the user
|
||||
/// trust it (TOFU), run the PIN ceremony instead, or walk away.
|
||||
fn tofu_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
let fp = req.fp_hex.clone().unwrap_or_default();
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("New Host"),
|
||||
Some(&format!(
|
||||
"{} at {}:{}\n\nCertificate fingerprint:\n{}\n\nPairing with a PIN verifies it; \
|
||||
trusting accepts it as-is.",
|
||||
req.name, req.addr, req.port, fp
|
||||
)),
|
||||
);
|
||||
dialog.add_responses(&[
|
||||
("cancel", "Cancel"),
|
||||
("pair", "Pair with PIN…"),
|
||||
("trust", "Trust & Connect"),
|
||||
]);
|
||||
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("trust"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = app.window.clone();
|
||||
dialog.connect_response(None, move |_, response| match response {
|
||||
"trust" => {
|
||||
let mut known = KnownHosts::load();
|
||||
known.upsert(KnownHost {
|
||||
name: req.name.clone(),
|
||||
addr: req.addr.clone(),
|
||||
port: req.port,
|
||||
fp_hex: fp.clone(),
|
||||
paired: false,
|
||||
});
|
||||
let _ = known.save();
|
||||
start_session(app.clone(), req.clone(), crate::trust::parse_hex32(&fp));
|
||||
}
|
||||
"pair" => pin_dialog(app.clone(), req.clone()),
|
||||
_ => {}
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
}
|
||||
|
||||
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
|
||||
/// of it pins the host's certificate (and registers ours) with no offline-guessable
|
||||
/// transcript.
|
||||
fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
let entry = gtk::Entry::builder()
|
||||
.input_purpose(gtk::InputPurpose::Digits)
|
||||
.placeholder_text("4-digit PIN shown by the host")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Pair with PIN"),
|
||||
Some(&format!(
|
||||
"Arm pairing on {} (console or web UI), then enter the PIN it displays.",
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
dialog.set_extra_child(Some(&entry));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]);
|
||||
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("pair"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = app.window.clone();
|
||||
dialog.connect_response(Some("pair"), move |_, _| {
|
||||
let pin = entry.text().to_string();
|
||||
let app = app.clone();
|
||||
let req = req.clone();
|
||||
let identity = app.identity.clone();
|
||||
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
||||
let (host, port, name) = (req.addr.clone(), req.port, glib::host_name().to_string());
|
||||
std::thread::spawn(move || {
|
||||
let result = NativeClient::pair(
|
||||
&host,
|
||||
port,
|
||||
(&identity.0, &identity.1),
|
||||
pin.trim(),
|
||||
&name,
|
||||
std::time::Duration::from_secs(90),
|
||||
)
|
||||
.map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)"));
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(fp)) => {
|
||||
let fp_hex = crate::trust::hex(&fp);
|
||||
let mut known = KnownHosts::load();
|
||||
known.upsert(KnownHost {
|
||||
name: req.name.clone(),
|
||||
addr: req.addr.clone(),
|
||||
port: req.port,
|
||||
fp_hex,
|
||||
paired: true,
|
||||
});
|
||||
let _ = known.save();
|
||||
app.toast("Paired — connecting…");
|
||||
start_session(app.clone(), req, Some(fp));
|
||||
}
|
||||
Ok(Err(msg)) => app.toast(&msg),
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
}
|
||||
|
||||
/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN
|
||||
/// path — connect and wait for the operator to click Approve in the host's console/web UI
|
||||
/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony.
|
||||
fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Pairing Required"),
|
||||
Some(&format!(
|
||||
"{} requires pairing.\n\nRequest access and approve this device in the host's console \
|
||||
(or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.",
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
dialog.add_responses(&[
|
||||
("cancel", "Cancel"),
|
||||
("pin", "Use a PIN instead…"),
|
||||
("request", "Request Access"),
|
||||
]);
|
||||
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("request"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = app.window.clone();
|
||||
dialog.connect_response(None, move |_, response| match response {
|
||||
"request" => request_access(app.clone(), req.clone()),
|
||||
"pin" => pin_dialog(app.clone(), req.clone()),
|
||||
_ => {}
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
}
|
||||
|
||||
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||
/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On
|
||||
/// approval the same connection is admitted (no reconnect) and the host is saved as paired.
|
||||
fn request_access(app: Rc<App>, req: ConnectRequest) {
|
||||
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||||
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
|
||||
let cancel = Rc::new(std::cell::Cell::new(false));
|
||||
|
||||
let waiting = adw::AlertDialog::new(
|
||||
Some("Waiting for Approval"),
|
||||
Some(&format!(
|
||||
"Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \
|
||||
connects automatically once you approve it.",
|
||||
glib::host_name(),
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||
waiting.set_close_response("cancel");
|
||||
{
|
||||
let app = app.clone();
|
||||
let cancel = cancel.clone();
|
||||
waiting.connect_response(Some("cancel"), move |_, _| {
|
||||
// Return the UI immediately; the in-flight connect is left to time out and is torn
|
||||
// down silently by the event loop (see StartOpts::cancel).
|
||||
cancel.set(true);
|
||||
app.busy.set(false);
|
||||
app.toast("Cancelled — the request may still be pending on the host.");
|
||||
});
|
||||
add(
|
||||
"preferences",
|
||||
Box::new(move || {
|
||||
let refresh = {
|
||||
let app = app.clone();
|
||||
// The library toggle changes the saved cards' menu — re-render on close.
|
||||
move || {
|
||||
if let Some(h) = app.hosts_ui() {
|
||||
h.refresh();
|
||||
}
|
||||
}
|
||||
};
|
||||
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad, refresh)
|
||||
}),
|
||||
);
|
||||
}
|
||||
waiting.present(Some(&app.window));
|
||||
{
|
||||
let window = app.window.clone();
|
||||
add(
|
||||
"shortcuts",
|
||||
Box::new(move || shortcuts_window(&window).present()),
|
||||
);
|
||||
}
|
||||
{
|
||||
let window = app.window.clone();
|
||||
add(
|
||||
"about",
|
||||
Box::new(move || crate::ui_settings::show_about(&window)),
|
||||
);
|
||||
}
|
||||
{
|
||||
let hosts = hosts.clone();
|
||||
add("add-host", Box::new(move || hosts.show_add_host()));
|
||||
}
|
||||
}
|
||||
|
||||
start_session_with(
|
||||
app,
|
||||
req,
|
||||
pin,
|
||||
StartOpts {
|
||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||||
// approval still lands on this connection rather than timing the client out first.
|
||||
connect_timeout: std::time::Duration::from_secs(185),
|
||||
persist_paired: true,
|
||||
waiting: Some(waiting),
|
||||
cancel: Some(cancel),
|
||||
},
|
||||
);
|
||||
/// The Keyboard Shortcuts window (menu + the shortcuts scene). GtkShortcutsWindow is
|
||||
/// builder-XML-first, so it's assembled from a snippet rather than widget calls.
|
||||
pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow {
|
||||
const UI: &str = r#"
|
||||
<interface>
|
||||
<object class="GtkShortcutsWindow" id="shortcuts">
|
||||
<property name="modal">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="title">Stream</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title">Toggle fullscreen</property>
|
||||
<property name="accelerator">F11</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title">Release captured input (click the stream to capture)</property>
|
||||
<property name="accelerator"><Control><Alt><Shift>q</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title">Disconnect</property>
|
||||
<property name="accelerator"><Control><Alt><Shift>d</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title">Toggle statistics overlay</property>
|
||||
<property name="accelerator"><Control><Alt><Shift>s</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
"#;
|
||||
let builder = gtk::Builder::from_string(UI);
|
||||
let window: gtk::ShortcutsWindow = builder
|
||||
.object("shortcuts")
|
||||
.expect("shortcuts window in builder XML");
|
||||
window.set_transient_for(Some(parent));
|
||||
window
|
||||
}
|
||||
|
||||
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
||||
@@ -590,238 +372,3 @@ fn speed_test(app: Rc<App>, req: ConnectRequest) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The mode to request: explicit settings, with `0` fields resolved to the native
|
||||
/// size/refresh of the monitor the window currently occupies (mirrors the Swift client's
|
||||
/// native-display default).
|
||||
fn resolve_mode(app: &App) -> punktfunk_core::config::Mode {
|
||||
let s = app.settings.borrow();
|
||||
let mut mode = punktfunk_core::config::Mode {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
refresh_hz: s.refresh_hz,
|
||||
};
|
||||
if mode.width == 0 || mode.refresh_hz == 0 {
|
||||
// Prefer the monitor the window is on; fall back to the display's first monitor. On a
|
||||
// `--connect` launch the window may not be mapped yet when this runs, and without the
|
||||
// fallback we'd drop to the 1920×1080 floor below — wrong on the Deck (1280×800).
|
||||
let monitor = app
|
||||
.window
|
||||
.surface()
|
||||
.zip(gdk::Display::default())
|
||||
.and_then(|(surf, d)| d.monitor_at_surface(&surf))
|
||||
.or_else(|| {
|
||||
gdk::Display::default()
|
||||
.and_then(|d| d.monitors().item(0))
|
||||
.and_then(|o| o.downcast::<gdk::Monitor>().ok())
|
||||
});
|
||||
if let Some(m) = monitor {
|
||||
let geo = m.geometry();
|
||||
let scale = m.scale_factor().max(1);
|
||||
if mode.width == 0 {
|
||||
mode.width = (geo.width() * scale) as u32;
|
||||
mode.height = (geo.height() * scale) as u32;
|
||||
}
|
||||
if mode.refresh_hz == 0 {
|
||||
mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No monitor info (early call, odd compositor) — a sane floor.
|
||||
if mode.width == 0 {
|
||||
(mode.width, mode.height) = (1920, 1080);
|
||||
}
|
||||
if mode.refresh_hz == 0 {
|
||||
mode.refresh_hz = 60;
|
||||
}
|
||||
mode
|
||||
}
|
||||
|
||||
/// Tunables for a session start that differ between the normal connect and the "request access"
|
||||
/// (delegated-approval) flow. `Default` is the normal connect.
|
||||
struct StartOpts {
|
||||
/// Handshake budget. The request-access flow uses a long one because the host PARKS the
|
||||
/// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`).
|
||||
connect_timeout: std::time::Duration,
|
||||
/// Persist the host as *paired* on a successful connect. Set for request-access, where the
|
||||
/// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
|
||||
/// persists the host *unpaired* (pinned, but not PIN/approval-verified).
|
||||
persist_paired: bool,
|
||||
/// A "waiting for approval" dialog to dismiss on the first session event (request-access only).
|
||||
waiting: Option<adw::AlertDialog>,
|
||||
/// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with
|
||||
/// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves
|
||||
/// the in-flight connect to time out; when it finally resolves, the event loop sees this flag
|
||||
/// and tears down silently (drops the connector → closes the connection) without touching the
|
||||
/// UI a new session may already own.
|
||||
cancel: Option<Rc<std::cell::Cell<bool>>>,
|
||||
}
|
||||
|
||||
impl Default for StartOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_timeout: std::time::Duration::from_secs(15),
|
||||
persist_paired: false,
|
||||
waiting: None,
|
||||
cancel: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||
start_session_with(app, req, pin, StartOpts::default());
|
||||
}
|
||||
|
||||
fn start_session_with(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>, opts: StartOpts) {
|
||||
if app.busy.replace(true) {
|
||||
return;
|
||||
}
|
||||
let mode = resolve_mode(&app);
|
||||
let s = app.settings.borrow();
|
||||
let params = SessionParams {
|
||||
host: req.addr.clone(),
|
||||
port: req.port,
|
||||
mode,
|
||||
compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto),
|
||||
// "Automatic" matches the physical pad (Swift parity); an explicit choice wins.
|
||||
gamepad: match GamepadPref::from_name(&s.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(),
|
||||
Some(explicit) => explicit,
|
||||
},
|
||||
bitrate_kbps: s.bitrate_kbps,
|
||||
mic_enabled: s.mic_enabled,
|
||||
audio_channels: s.audio_channels,
|
||||
preferred_codec: s.preferred_codec(),
|
||||
pin,
|
||||
identity: app.identity.clone(),
|
||||
connect_timeout: opts.connect_timeout,
|
||||
};
|
||||
let inhibit = s.inhibit_shortcuts;
|
||||
drop(s);
|
||||
let tofu = pin.is_none();
|
||||
let persist_paired = opts.persist_paired;
|
||||
let mut waiting = opts.waiting;
|
||||
let cancel = opts.cancel;
|
||||
|
||||
let mut handle = crate::session::start(params);
|
||||
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
||||
glib::spawn_future_local(async move {
|
||||
let mut frames = Some(frames);
|
||||
let mut page: Option<crate::ui_stream::StreamPage> = None;
|
||||
while let Ok(event) = handle.events.recv().await {
|
||||
// A cancelled request-access connect resolved late: tear down silently. Don't touch
|
||||
// app.busy — Cancel already cleared it, and a fresh session may now own it.
|
||||
if cancel.as_ref().is_some_and(|c| c.get()) {
|
||||
if let Some(w) = waiting.take() {
|
||||
w.close();
|
||||
}
|
||||
break;
|
||||
}
|
||||
match event {
|
||||
SessionEvent::Connected {
|
||||
connector,
|
||||
mode,
|
||||
fingerprint,
|
||||
} => {
|
||||
// Dismiss the "waiting for approval" dialog (request-access flow), if any.
|
||||
if let Some(w) = waiting.take() {
|
||||
w.close();
|
||||
}
|
||||
if persist_paired {
|
||||
// Request-access: the operator approved this device, so record the host as
|
||||
// a trusted PAIRED host (pinning the fingerprint we observed) — future
|
||||
// connects are then silent (rule 1), exactly like after a PIN ceremony.
|
||||
let fp_hex = crate::trust::hex(&fingerprint);
|
||||
let mut known = KnownHosts::load();
|
||||
known.upsert(KnownHost {
|
||||
name: req.name.clone(),
|
||||
addr: req.addr.clone(),
|
||||
port: req.port,
|
||||
fp_hex,
|
||||
paired: true,
|
||||
});
|
||||
let _ = known.save();
|
||||
app.toast("Approved — connecting…");
|
||||
} else if tofu {
|
||||
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
||||
let fp_hex = crate::trust::hex(&fingerprint);
|
||||
let mut known = KnownHosts::load();
|
||||
known.upsert(KnownHost {
|
||||
name: req.name.clone(),
|
||||
addr: req.addr.clone(),
|
||||
port: req.port,
|
||||
fp_hex: fp_hex.clone(),
|
||||
paired: false,
|
||||
});
|
||||
let _ = known.save();
|
||||
app.toast(&format!(
|
||||
"Trusted on first use — fingerprint {}…",
|
||||
&fp_hex[..16]
|
||||
));
|
||||
}
|
||||
tracing::debug!(?mode, "connected — pushing stream page");
|
||||
let title = format!(
|
||||
"{} · {}×{}@{}",
|
||||
req.name, mode.width, mode.height, mode.refresh_hz
|
||||
);
|
||||
app.gamepad.attach(connector.clone());
|
||||
let p = crate::ui_stream::new(
|
||||
&app.window,
|
||||
connector,
|
||||
frames.take().expect("Connected delivered once"),
|
||||
app.gamepad.escape_events(),
|
||||
app.gamepad.disconnect_events(),
|
||||
handle.stop.clone(),
|
||||
inhibit,
|
||||
&title,
|
||||
);
|
||||
app.nav.push(&p.page);
|
||||
// Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't
|
||||
// know it, so its header bar stays drawn. Enter GTK fullscreen explicitly —
|
||||
// the stream page's `connect_fullscreened_notify` then hides all chrome.
|
||||
if app.fullscreen {
|
||||
app.window.fullscreen();
|
||||
}
|
||||
page = Some(p);
|
||||
}
|
||||
SessionEvent::Stats(s) => {
|
||||
if let Some(p) = &page {
|
||||
p.update_stats(s);
|
||||
}
|
||||
}
|
||||
SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
} => {
|
||||
if let Some(w) = waiting.take() {
|
||||
w.close();
|
||||
}
|
||||
tracing::warn!(%msg, trust_rejected, "connect failed");
|
||||
app.busy.set(false);
|
||||
// A pinned connect rejected on trust grounds means the host's cert no
|
||||
// longer matches the stored pin (rotated cert or impostor) — route to
|
||||
// the PIN ceremony to re-establish trust rather than dead-ending.
|
||||
if trust_rejected && !tofu {
|
||||
app.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||
pin_dialog(app.clone(), req.clone());
|
||||
} else {
|
||||
app.toast(&msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
SessionEvent::Ended(err) => {
|
||||
if let Some(w) = waiting.take() {
|
||||
w.close();
|
||||
}
|
||||
app.gamepad.detach();
|
||||
app.nav.pop_to_tag("hosts");
|
||||
if let Some(e) = err {
|
||||
app.toast(&e);
|
||||
}
|
||||
app.busy.set(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ struct Terminate;
|
||||
|
||||
pub struct AudioPlayer {
|
||||
pcm_tx: SyncSender<Vec<f32>>,
|
||||
/// Drained chunk Vecs coming back from the PipeWire consumer for reuse (the pool half
|
||||
/// of the pcm channel — see [`AudioPlayer::take_buffer`]).
|
||||
recycle_rx: Receiver<Vec<f32>>,
|
||||
quit_tx: pipewire::channel::Sender<Terminate>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
@@ -33,22 +36,34 @@ impl AudioPlayer {
|
||||
pub fn spawn(channels: u32) -> Result<AudioPlayer> {
|
||||
// 64 × 5 ms = 320 ms of slack between the pump and the PipeWire loop.
|
||||
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||
// Return path: the process callback sends each drained Vec back for reuse, so
|
||||
// steady-state playback stops allocating (~200 chunks/s otherwise). Same capacity
|
||||
// as the data channel; a full pool just drops the Vec (plain deallocation).
|
||||
let (recycle_tx, recycle_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("punktfunk-audio".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = pw_thread(pcm_rx, quit_rx, channels as usize) {
|
||||
if let Err(e) = pw_thread(pcm_rx, recycle_tx, quit_rx, channels as usize) {
|
||||
tracing::warn!(error = %e, "audio playback thread ended");
|
||||
}
|
||||
})
|
||||
.context("spawn audio thread")?;
|
||||
Ok(AudioPlayer {
|
||||
pcm_tx,
|
||||
recycle_rx,
|
||||
quit_tx,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
/// A recycled chunk Vec from the pool, empty but with its capacity intact — fill it
|
||||
/// and hand it back through [`push`](Self::push). Allocates only when the pool is dry
|
||||
/// (startup, or after the PipeWire side dropped chunks).
|
||||
pub fn take_buffer(&self) -> Vec<f32> {
|
||||
self.recycle_rx.try_recv().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Queue one interleaved f32 chunk (in the session's channel layout). Drops the chunk if the
|
||||
/// PipeWire side is wedged (the renderer conceals the gap; never block the session pump).
|
||||
pub fn push(&self, pcm: Vec<f32>) {
|
||||
@@ -70,6 +85,8 @@ impl Drop for AudioPlayer {
|
||||
/// Producer-side state: incoming decoded PCM and the ring the process callback drains.
|
||||
struct PlayerData {
|
||||
rx: Receiver<Vec<f32>>,
|
||||
/// Drained chunk Vecs go back here for the decode side to refill (allocation pool).
|
||||
recycle: SyncSender<Vec<f32>>,
|
||||
ring: VecDeque<f32>,
|
||||
primed: bool,
|
||||
/// Interleaved channel count this stream was opened with (2/6/8).
|
||||
@@ -78,6 +95,7 @@ struct PlayerData {
|
||||
|
||||
fn pw_thread(
|
||||
pcm_rx: Receiver<Vec<f32>>,
|
||||
recycle_tx: SyncSender<Vec<f32>>,
|
||||
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||
channels: usize,
|
||||
) -> Result<()> {
|
||||
@@ -117,6 +135,7 @@ fn pw_thread(
|
||||
|
||||
let ud = PlayerData {
|
||||
rx: pcm_rx,
|
||||
recycle: recycle_tx,
|
||||
ring: VecDeque::new(),
|
||||
primed: false,
|
||||
channels,
|
||||
@@ -132,8 +151,11 @@ fn pw_thread(
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
};
|
||||
while let Ok(chunk) = ud.rx.try_recv() {
|
||||
ud.ring.extend(chunk);
|
||||
while let Ok(mut chunk) = ud.rx.try_recv() {
|
||||
ud.ring.extend(chunk.iter().copied());
|
||||
// Return the drained Vec to the pool; a full/closed pool drops it.
|
||||
chunk.clear();
|
||||
let _ = ud.recycle.try_send(chunk);
|
||||
}
|
||||
let stride = 4 * ud.channels; // F32LE interleaved
|
||||
let datas = buffer.datas_mut();
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Command-line entry paths: argv helpers, headless pairing, `--connect`, and the CI
|
||||
//! screenshot scenes.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The value following `flag` in argv, if present (`--flag value`).
|
||||
pub fn arg_value(flag: &str) -> Option<String> {
|
||||
std::env::args()
|
||||
.skip_while(|a| a != flag)
|
||||
.nth(1)
|
||||
.filter(|v| !v.starts_with("--"))
|
||||
}
|
||||
|
||||
/// True if argv contains `flag` (a valueless switch).
|
||||
fn arg_flag(flag: &str) -> bool {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// Run the stream fullscreen with no window chrome — the Steam Deck / Gaming-Mode launch path.
|
||||
/// The Decky wrapper passes `--fullscreen`; we also honor the Deck/gamescope env as a fallback
|
||||
/// so a manual launch under Gaming Mode does the right thing too.
|
||||
pub fn fullscreen_mode() -> bool {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// Split `host[:port]`: no colon defaults the port to 9777; a colon with an unparsable
|
||||
/// port yields `None` for it (callers decide whether to default or bail).
|
||||
fn parse_host_port(target: &str) -> (String, Option<u16>) {
|
||||
match target.rsplit_once(':') {
|
||||
Some((a, p)) => (a.to_string(), p.parse().ok()),
|
||||
None => (target.to_string(), Some(9777)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
|
||||
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
|
||||
/// store the streaming path uses (same binary), so pairing here makes the stream work.
|
||||
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
||||
pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!("--pair requires --connect host[:port]");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
let port = port.unwrap_or(9777);
|
||||
// The label the HOST stores this client under (its paired-devices list).
|
||||
let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string());
|
||||
|
||||
let identity = match crate::trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
match crate::trust::pair_with_host(&addr, port, &identity, pin, &name) {
|
||||
Ok(fp) => {
|
||||
let fp_hex = crate::trust::hex(&fp);
|
||||
crate::trust::persist_host(
|
||||
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||
&addr,
|
||||
port,
|
||||
&fp_hex,
|
||||
true,
|
||||
);
|
||||
println!("paired {addr}:{port} fp={fp_hex}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--connect host[:port]` — skip the hosts page and start a session immediately
|
||||
/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host
|
||||
/// already pinned at this address connects silently on its stored pin; an unknown host is
|
||||
/// routed to the PIN ceremony (never a silent TOFU connect — `fp_hex`/`pair_optional` are
|
||||
/// unset, so `initiate_connect`'s manual arm mandates pairing).
|
||||
pub fn cli_connect_request() -> Option<ConnectRequest> {
|
||||
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
Some(ConnectRequest {
|
||||
name: addr.clone(),
|
||||
addr,
|
||||
port: port?,
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real
|
||||
/// mTLS + pinned-fingerprint client, no GTK window (scripting, and the live-API proof
|
||||
/// that the library HTTP path works against a real host). The pin comes from `--fp HEX`
|
||||
/// when given, else the known-hosts store (matched by address), else none (TOFU-accept).
|
||||
pub fn headless_library(target: &str) -> glib::ExitCode {
|
||||
let (addr, port) = match target.rsplit_once(':') {
|
||||
Some((a, p)) if p.parse::<u16>().is_ok() => (a.to_string(), p.parse().unwrap()),
|
||||
_ => (target.to_string(), crate::library::DEFAULT_MGMT_PORT),
|
||||
};
|
||||
let identity = match crate::trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let pin = arg_value("--fp")
|
||||
.as_deref()
|
||||
.and_then(crate::trust::parse_hex32)
|
||||
.or_else(|| {
|
||||
crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr)
|
||||
.and_then(|h| crate::trust::parse_hex32(&h.fp_hex))
|
||||
});
|
||||
match crate::library::fetch_games(&addr, port, &identity, pin) {
|
||||
Ok(games) => {
|
||||
for g in &games {
|
||||
println!("{}\t{}\t{}", g.id, g.store, g.title);
|
||||
}
|
||||
println!("{} game(s)", games.len());
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("library: {e}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
|
||||
pub fn shot_scene() -> Option<String> {
|
||||
std::env::var("PUNKTFUNK_SHOT_SCENE")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Render one mock-populated, host-free scene over the already-presented window, then print
|
||||
/// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture.
|
||||
/// When `PUNKTFUNK_SHOT_OUT=/path.png` is set the app CAPTURES ITSELF first (widget snapshot →
|
||||
/// gsk render → PNG, see `save_png`) — no Xvfb/ImageMagick needed, and libadwaita dialogs are
|
||||
/// in-window overlays so they land in the frame. No `NativeClient` or session is created. The
|
||||
/// stream scene is deliberately absent — its page requires a live connector (`ui_stream::new`
|
||||
/// takes an `Arc<NativeClient>`).
|
||||
pub fn run_shot(app: Rc<App>, scene: &str) {
|
||||
// A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256).
|
||||
let mock_req = || ConnectRequest {
|
||||
name: "Living Room PC".to_string(),
|
||||
addr: "192.168.1.42".to_string(),
|
||||
port: 9777,
|
||||
fp_hex: Some(
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(),
|
||||
),
|
||||
pair_optional: true,
|
||||
launch: None,
|
||||
};
|
||||
let mock_advert =
|
||||
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
|
||||
key: key.to_string(),
|
||||
fullname: format!("{key}._punktfunk._udp.local."),
|
||||
name: name.to_string(),
|
||||
addr: addr.to_string(),
|
||||
port: 9777,
|
||||
fp_hex: fp.to_string(),
|
||||
pair: "required".to_string(),
|
||||
mgmt_port: None,
|
||||
};
|
||||
|
||||
// What the self-capture renders: the main window, except for scenes that open their
|
||||
// own toplevel (the shortcuts window).
|
||||
let mut target: gtk::Widget = app.window.clone().upcast();
|
||||
match scene {
|
||||
// The saved-hosts grid reads ~/.config/punktfunk/client-known-hosts.json, which the
|
||||
// driver seeds. On top, inject synthetic adverts through the same path the mDNS
|
||||
// stream feeds: one matching the seeded saved host (ONLINE pip + dedup out of the
|
||||
// discovered grid) and one unknown pair=required host (PIN pill).
|
||||
"hosts" | "02-hosts" => {
|
||||
if let Some(h) = app.hosts_ui() {
|
||||
h.inject_advert(mock_advert(
|
||||
"mock-online",
|
||||
"Living Room PC",
|
||||
"192.168.1.42",
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||
));
|
||||
h.inject_advert(mock_advert(
|
||||
"mock-new",
|
||||
"steamdeck",
|
||||
"192.168.1.77",
|
||||
"00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
||||
));
|
||||
}
|
||||
}
|
||||
"settings" | "03-settings" => {
|
||||
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad, || {});
|
||||
}
|
||||
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(app.clone(), mock_req()),
|
||||
"pair" | "05-pair" => crate::ui_trust::pin_dialog(app.clone(), mock_req()),
|
||||
"addhost" | "06-addhost" => {
|
||||
if let Some(h) = app.hosts_ui() {
|
||||
h.show_add_host();
|
||||
}
|
||||
}
|
||||
"shortcuts" | "07-shortcuts" => {
|
||||
let w = crate::app::shortcuts_window(&app.window);
|
||||
w.present();
|
||||
target = w.upcast();
|
||||
}
|
||||
// The library page with injected entries: mixed stores exercising the badge set,
|
||||
// no-art placeholders (monogram tiles), and one solid-color texture standing in
|
||||
// for a loaded poster (the real poster path, minus the network).
|
||||
"library" | "08-library" => {
|
||||
let game = |id: &str, store: &str, title: &str| crate::library::GameEntry {
|
||||
id: id.to_string(),
|
||||
store: store.to_string(),
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
game("steam:1091500", "steam", "Cyberpunk 2077"),
|
||||
game("custom:emu-1", "custom", "RetroArch"),
|
||||
game("heroic:fortnite", "heroic", "Fortnite"),
|
||||
game("gog:witcher3", "gog", "The Witcher 3"),
|
||||
game("lutris:osu", "lutris", "osu!"),
|
||||
];
|
||||
let art = vec![(
|
||||
"steam:570".to_string(),
|
||||
solid_texture(300, 450, 0x35, 0x84, 0xe4),
|
||||
)];
|
||||
crate::ui_library::open_mock(app.clone(), mock_req(), games, art);
|
||||
}
|
||||
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
||||
}
|
||||
|
||||
let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(900);
|
||||
let scene = scene.to_string();
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
||||
use std::io::Write as _;
|
||||
let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT")
|
||||
.ok()
|
||||
.filter(|p| !p.is_empty());
|
||||
if let Some(out) = &self_capture {
|
||||
if let Err(e) = save_png(&target, out) {
|
||||
eprintln!("PF_SHOT_ERROR scene={scene}: {e:#}");
|
||||
}
|
||||
}
|
||||
println!("PF_SHOT_READY scene={scene}");
|
||||
let _ = std::io::stdout().flush();
|
||||
// Self-capture mode: the shot is on disk — exit so back-to-back scene runs don't
|
||||
// stack windows on a live desktop. (The X11-fallback driver captures externally
|
||||
// after READY and kills us itself.)
|
||||
if self_capture.is_some() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster.
|
||||
fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture {
|
||||
let px = [r, g, b, 0xff].repeat((w * h) as usize);
|
||||
gtk::gdk::MemoryTexture::new(
|
||||
w,
|
||||
h,
|
||||
gtk::gdk::MemoryFormat::R8g8b8a8,
|
||||
&glib::Bytes::from_owned(px),
|
||||
(w * 4) as usize,
|
||||
)
|
||||
.upcast()
|
||||
}
|
||||
|
||||
/// Snapshot `widget` (the whole window, dialogs included) into a PNG: WidgetPaintable →
|
||||
/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`.
|
||||
fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> {
|
||||
use anyhow::Context as _;
|
||||
use gtk::prelude::*;
|
||||
let (w, h) = (widget.width(), widget.height());
|
||||
anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})");
|
||||
let paintable = gtk::WidgetPaintable::new(Some(widget));
|
||||
let snapshot = gtk::Snapshot::new();
|
||||
paintable.snapshot(&snapshot, f64::from(w), f64::from(h));
|
||||
let node = snapshot.to_node().context("empty snapshot")?;
|
||||
let renderer = widget
|
||||
.native()
|
||||
.context("widget not realized")?
|
||||
.renderer()
|
||||
.context("no gsk renderer")?;
|
||||
let texture = renderer.render_texture(node, None);
|
||||
texture
|
||||
.save_to_png(path)
|
||||
.with_context(|| format!("save {path}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! LAN host discovery: browse the host's mDNS advert (`_punktfunk._udp`, TXT keys
|
||||
//! `fp`/`pair`/`id` — see the host crate's `discovery.rs`) on a worker thread and stream
|
||||
//! results to the UI.
|
||||
//! results to the UI. Removal events are forwarded too, so the hosts page can drop stale
|
||||
//! cards and flip a saved host's online pip when its advert disappears.
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
|
||||
@@ -8,6 +9,8 @@ use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
pub struct DiscoveredHost {
|
||||
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
|
||||
pub key: String,
|
||||
/// The mDNS service fullname — what a later `Removed` event identifies the advert by.
|
||||
pub fullname: String,
|
||||
pub name: String,
|
||||
pub addr: String,
|
||||
pub port: u16,
|
||||
@@ -15,11 +18,23 @@ pub struct DiscoveredHost {
|
||||
pub fp_hex: String,
|
||||
/// Pairing requirement: `"required"` or `"optional"`.
|
||||
pub pair: String,
|
||||
/// The management API's port (mDNS `mgmt` TXT) — where the game library is served.
|
||||
/// `None` when not advertised (older host / standalone `punktfunk1-host`); the
|
||||
/// library client then falls back to the well-known default.
|
||||
pub mgmt_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// One discovery update for the UI's advert map.
|
||||
pub enum DiscoveryEvent {
|
||||
/// A host advert appeared or refreshed (new address, pairing flipped, …).
|
||||
Resolved(DiscoveredHost),
|
||||
/// The advert went away (host stopped / left the network).
|
||||
Removed { fullname: String },
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||
/// dropped (the send fails) or the daemon dies.
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-mdns".into())
|
||||
@@ -39,34 +54,42 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
}
|
||||
};
|
||||
while let Ok(event) = receiver.recv() {
|
||||
if let ServiceEvent::ServiceResolved(info) = event {
|
||||
let props = info.get_properties();
|
||||
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
|
||||
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let id = val("id");
|
||||
let host = DiscoveredHost {
|
||||
key: if id.is_empty() {
|
||||
info.get_fullname().to_string()
|
||||
} else {
|
||||
id
|
||||
},
|
||||
name: info
|
||||
.get_fullname()
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or("?")
|
||||
.to_string(),
|
||||
addr,
|
||||
port: info.get_port(),
|
||||
fp_hex: val("fp"),
|
||||
pair: val("pair"),
|
||||
};
|
||||
if tx.send_blocking(host).is_err() {
|
||||
break; // UI gone — stop browsing
|
||||
let update = match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
let props = info.get_properties();
|
||||
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
|
||||
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let id = val("id");
|
||||
DiscoveryEvent::Resolved(DiscoveredHost {
|
||||
key: if id.is_empty() {
|
||||
info.get_fullname().to_string()
|
||||
} else {
|
||||
id
|
||||
},
|
||||
fullname: info.get_fullname().to_string(),
|
||||
name: info
|
||||
.get_fullname()
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or("?")
|
||||
.to_string(),
|
||||
addr,
|
||||
port: info.get_port(),
|
||||
fp_hex: val("fp"),
|
||||
pair: val("pair"),
|
||||
mgmt_port: val("mgmt").parse().ok(),
|
||||
})
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
||||
DiscoveryEvent::Removed { fullname }
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if tx.send_blocking(update).is_err() {
|
||||
break; // UI gone — stop browsing
|
||||
}
|
||||
}
|
||||
let _ = daemon.shutdown();
|
||||
|
||||
+262
-229
@@ -236,7 +236,6 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||||
/// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim.
|
||||
/// Enable bits select only the fields each update touches, so rumble (driven separately
|
||||
/// through SDL) and untouched fields keep their state.
|
||||
#[derive(Default)]
|
||||
struct Ds5Feedback;
|
||||
|
||||
impl Ds5Feedback {
|
||||
@@ -275,8 +274,12 @@ impl Ds5Feedback {
|
||||
}
|
||||
}
|
||||
|
||||
struct Worker {
|
||||
struct Worker<'a> {
|
||||
subsystem: sdl3::GamepadSubsystem,
|
||||
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
|
||||
pads_out: &'a Mutex<Vec<PadInfo>>,
|
||||
active_out: &'a Mutex<Option<PadInfo>>,
|
||||
pinned_out: &'a Mutex<Option<u32>>,
|
||||
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
|
||||
/// Connection order; the most recently connected is the auto selection.
|
||||
order: Vec<u32>,
|
||||
@@ -303,7 +306,7 @@ struct Worker {
|
||||
disconnect_fired: bool,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
impl Worker<'_> {
|
||||
fn active_id(&self) -> Option<u32> {
|
||||
self.pinned
|
||||
.filter(|id| self.opened.contains_key(id))
|
||||
@@ -489,9 +492,245 @@ impl Worker {
|
||||
self.held_touches.remove(&(surface, finger));
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish the pad list, active pad, and pin to the UI-facing mutexes.
|
||||
fn publish(&self) {
|
||||
let mut list: Vec<PadInfo> = self
|
||||
.order
|
||||
.iter()
|
||||
.filter_map(|&id| self.pad_info(id))
|
||||
.collect();
|
||||
list.reverse(); // most recent first — the Settings list order
|
||||
*self.pads_out.lock().unwrap() = list;
|
||||
*self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id));
|
||||
*self.pinned_out.lock().unwrap() = self.pinned;
|
||||
}
|
||||
|
||||
/// Apply queued control-plane messages from the UI thread. Returns false when the
|
||||
/// app side is gone and the worker should exit.
|
||||
fn drain_ctl(&mut self, ctl: &Receiver<Ctl>) -> bool {
|
||||
loop {
|
||||
match ctl.try_recv() {
|
||||
Ok(Ctl::Attach(c)) => {
|
||||
self.attached = Some(c);
|
||||
self.last_axis = [i32::MIN; 6];
|
||||
self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||
self.set_sensors(true);
|
||||
}
|
||||
Ok(Ctl::Detach) => {
|
||||
self.flush_held();
|
||||
self.set_sensors(false);
|
||||
self.attached = None;
|
||||
}
|
||||
Ok(Ctl::Pin(id)) => {
|
||||
let before = self.active_id();
|
||||
self.pinned = id;
|
||||
if self.active_id() != before {
|
||||
self.flush_held();
|
||||
if self.attached.is_some() {
|
||||
self.set_sensors(true);
|
||||
}
|
||||
}
|
||||
self.publish();
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return true,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one SDL event: pad hotplug bookkeeping, and — while a session is attached —
|
||||
/// buttons/axes/touchpads/motion of the active pad onto the wire.
|
||||
fn handle_event(&mut self, event: sdl3::event::Event) {
|
||||
use sdl3::event::Event;
|
||||
let active = self.active_id();
|
||||
match event {
|
||||
Event::ControllerDeviceAdded { which, .. } => {
|
||||
if !self.opened.contains_key(&which) {
|
||||
match self
|
||||
.subsystem
|
||||
.open(sdl3::sys::joystick::SDL_JoystickID(which))
|
||||
{
|
||||
Ok(pad) => {
|
||||
tracing::info!(
|
||||
name = pad.name().unwrap_or_default(),
|
||||
"gamepad attached"
|
||||
);
|
||||
self.opened.insert(which, pad);
|
||||
self.order.push(which);
|
||||
if self.attached.is_some() && self.active_id() == Some(which) {
|
||||
self.set_sensors(true);
|
||||
}
|
||||
self.publish();
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ControllerDeviceRemoved { which, .. } => {
|
||||
if self.opened.remove(&which).is_some() {
|
||||
self.order.retain(|&id| id != which);
|
||||
if active == Some(which) {
|
||||
self.flush_held();
|
||||
}
|
||||
tracing::info!("gamepad detached");
|
||||
self.publish();
|
||||
}
|
||||
}
|
||||
Event::ControllerButtonDown { which, button, .. } if active == Some(which) => {
|
||||
let Some(c) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(bit) = button_bit(button) {
|
||||
self.held_buttons.push(bit);
|
||||
send(&c, InputKind::GamepadButton, bit, 1);
|
||||
self.maybe_fire_escape();
|
||||
}
|
||||
}
|
||||
Event::ControllerButtonUp { which, button, .. } if active == Some(which) => {
|
||||
let Some(c) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(bit) = button_bit(button) {
|
||||
self.held_buttons.retain(|&b| b != bit);
|
||||
send(&c, InputKind::GamepadButton, bit, 0);
|
||||
self.rearm_escape();
|
||||
}
|
||||
}
|
||||
Event::ControllerAxisMotion {
|
||||
which, axis, value, ..
|
||||
} if active == Some(which) => {
|
||||
let Some(c) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
let (id, v) = axis_value(axis, value);
|
||||
if self.last_axis[id as usize] != v {
|
||||
self.last_axis[id as usize] = v;
|
||||
send(&c, InputKind::GamepadAxis, id, v);
|
||||
}
|
||||
}
|
||||
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
|
||||
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
|
||||
Event::ControllerTouchpadDown {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
}
|
||||
| Event::ControllerTouchpadMotion {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && self.attached.is_some() => {
|
||||
self.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||
}
|
||||
Event::ControllerTouchpadUp {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && self.attached.is_some() => {
|
||||
self.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||
}
|
||||
// Motion: accel events update the cache; each gyro event ships a sample
|
||||
// (the DualSense reports both at ~250 Hz). Scale convention shared with
|
||||
// the Swift client — sign/scale derived, not yet live-verified.
|
||||
Event::ControllerSensorUpdated {
|
||||
which,
|
||||
sensor,
|
||||
data,
|
||||
..
|
||||
} if active == Some(which) => {
|
||||
let Some(c) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
use sdl3::sensor::SensorType;
|
||||
match sensor {
|
||||
SensorType::Accelerometer => {
|
||||
for (i, v) in data.iter().enumerate() {
|
||||
self.last_accel[i] =
|
||||
(v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16;
|
||||
}
|
||||
}
|
||||
SensorType::Gyroscope => {
|
||||
let mut gyro = [0i16; 3];
|
||||
for (i, v) in data.iter().enumerate() {
|
||||
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
|
||||
}
|
||||
let _ = c.send_rich_input(RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro,
|
||||
accel: self.last_accel,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar /
|
||||
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single
|
||||
/// consumer. The host re-sends rumble state periodically, so a generous duration with
|
||||
/// refresh-on-update is safe — a dropped stop heals within ~500 ms.
|
||||
fn render_feedback(&mut self) {
|
||||
let Some(connector) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||
if pad == 0 {
|
||||
if let Some(p) = self.active_id().and_then(|id| self.opened.get_mut(&id)) {
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||
// client-render.
|
||||
if let Err(e) = p.set_rumble(low, high, 5_000) {
|
||||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: rendered");
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: received but no active pad to render");
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let Some(id) = self.active_id() else { continue };
|
||||
let is_ds = self.pad_info(id).is_some_and(|p| p.is_dualsense());
|
||||
let Some(pad) = self.opened.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
match hid {
|
||||
HidOutput::Led { pad: 0, r, g, b } if is_ds => {
|
||||
let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
|
||||
}
|
||||
HidOutput::Led { pad: 0, r, g, b } => {
|
||||
let _ = pad.set_led(r, g, b);
|
||||
}
|
||||
HidOutput::PlayerLeds { pad: 0, bits } if is_ds => {
|
||||
let _ = pad.send_effect(&Ds5Feedback::player_packet(bits));
|
||||
}
|
||||
HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which,
|
||||
ref effect,
|
||||
} if is_ds => {
|
||||
let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn run(
|
||||
pads_out: &Mutex<Vec<PadInfo>>,
|
||||
active_out: &Mutex<Option<PadInfo>>,
|
||||
@@ -516,6 +755,9 @@ fn run(
|
||||
|
||||
let mut w = Worker {
|
||||
subsystem,
|
||||
pads_out,
|
||||
active_out,
|
||||
pinned_out,
|
||||
opened: HashMap::new(),
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
@@ -531,181 +773,25 @@ fn run(
|
||||
disconnect_fired: false,
|
||||
};
|
||||
|
||||
let publish = |w: &Worker| {
|
||||
let mut list: Vec<PadInfo> = w.order.iter().filter_map(|&id| w.pad_info(id)).collect();
|
||||
list.reverse(); // most recent first — the Settings list order
|
||||
*pads_out.lock().unwrap() = list;
|
||||
*active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id));
|
||||
*pinned_out.lock().unwrap() = w.pinned;
|
||||
};
|
||||
|
||||
loop {
|
||||
// Control plane from the UI thread.
|
||||
loop {
|
||||
match ctl.try_recv() {
|
||||
Ok(Ctl::Attach(c)) => {
|
||||
w.attached = Some(c);
|
||||
w.last_axis = [i32::MIN; 6];
|
||||
w.reset_chord(); // every session starts un-latched (Attach doesn't flush)
|
||||
w.set_sensors(true);
|
||||
}
|
||||
Ok(Ctl::Detach) => {
|
||||
w.flush_held();
|
||||
w.set_sensors(false);
|
||||
w.attached = None;
|
||||
}
|
||||
Ok(Ctl::Pin(id)) => {
|
||||
let before = w.active_id();
|
||||
w.pinned = id;
|
||||
if w.active_id() != before {
|
||||
w.flush_held();
|
||||
if w.attached.is_some() {
|
||||
w.set_sensors(true);
|
||||
}
|
||||
}
|
||||
publish(&w);
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone
|
||||
}
|
||||
if !w.drain_ctl(ctl) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
while let Some(event) = pump.poll_event() {
|
||||
use sdl3::event::Event;
|
||||
let active = w.active_id();
|
||||
match event {
|
||||
Event::ControllerDeviceAdded { which, .. } => {
|
||||
if !w.opened.contains_key(&which) {
|
||||
match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) {
|
||||
Ok(pad) => {
|
||||
tracing::info!(
|
||||
name = pad.name().unwrap_or_default(),
|
||||
"gamepad attached"
|
||||
);
|
||||
w.opened.insert(which, pad);
|
||||
w.order.push(which);
|
||||
if w.attached.is_some() && w.active_id() == Some(which) {
|
||||
w.set_sensors(true);
|
||||
}
|
||||
publish(&w);
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ControllerDeviceRemoved { which, .. } => {
|
||||
if w.opened.remove(&which).is_some() {
|
||||
w.order.retain(|&id| id != which);
|
||||
if active == Some(which) {
|
||||
w.flush_held();
|
||||
}
|
||||
tracing::info!("gamepad detached");
|
||||
publish(&w);
|
||||
}
|
||||
}
|
||||
Event::ControllerButtonDown { which, button, .. }
|
||||
if active == Some(which) && w.attached.is_some() =>
|
||||
{
|
||||
if let Some(bit) = button_bit(button) {
|
||||
w.held_buttons.push(bit);
|
||||
send(
|
||||
w.attached.as_ref().unwrap(),
|
||||
InputKind::GamepadButton,
|
||||
bit,
|
||||
1,
|
||||
);
|
||||
w.maybe_fire_escape();
|
||||
}
|
||||
}
|
||||
Event::ControllerButtonUp { which, button, .. }
|
||||
if active == Some(which) && w.attached.is_some() =>
|
||||
{
|
||||
if let Some(bit) = button_bit(button) {
|
||||
w.held_buttons.retain(|&b| b != bit);
|
||||
send(
|
||||
w.attached.as_ref().unwrap(),
|
||||
InputKind::GamepadButton,
|
||||
bit,
|
||||
0,
|
||||
);
|
||||
w.rearm_escape();
|
||||
}
|
||||
}
|
||||
Event::ControllerAxisMotion {
|
||||
which, axis, value, ..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let (id, v) = axis_value(axis, value);
|
||||
if w.last_axis[id as usize] != v {
|
||||
w.last_axis[id as usize] = v;
|
||||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
||||
}
|
||||
}
|
||||
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
|
||||
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
|
||||
Event::ControllerTouchpadDown {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
}
|
||||
| Event::ControllerTouchpadMotion {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||
}
|
||||
Event::ControllerTouchpadUp {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||
}
|
||||
// Motion: accel events update the cache; each gyro event ships a sample
|
||||
// (the DualSense reports both at ~250 Hz). Scale convention shared with
|
||||
// the Swift client — sign/scale derived, not yet live-verified.
|
||||
Event::ControllerSensorUpdated {
|
||||
which,
|
||||
sensor,
|
||||
data,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
use sdl3::sensor::SensorType;
|
||||
match sensor {
|
||||
SensorType::Accelerometer => {
|
||||
for (i, v) in data.iter().enumerate() {
|
||||
w.last_accel[i] =
|
||||
(v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16;
|
||||
}
|
||||
}
|
||||
SensorType::Gyroscope => {
|
||||
let mut gyro = [0i16; 3];
|
||||
for (i, v) in data.iter().enumerate() {
|
||||
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
|
||||
}
|
||||
let _ =
|
||||
w.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro,
|
||||
accel: w.last_accel,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
// Block in SDL's own event wait instead of a fixed-interval sleep+poll: input
|
||||
// events are handled the moment they arrive (the old 2 ms sleep added up to 2 ms
|
||||
// per event), while the timeout bounds the polled work below — ctl messages,
|
||||
// rumble/HID feedback, and the escape-chord hold check all run once per wakeup,
|
||||
// so their worst case is one timeout (~10 ms attached, imperceptible for
|
||||
// haptics; DISCONNECT_HOLD is 1500 ms, so 10 ms hold-check granularity is far
|
||||
// inside tolerance). Idle (no session) wakes lazily at 30 ms for hotplug + ctl.
|
||||
let timeout = Duration::from_millis(if w.attached.is_some() { 10 } else { 30 });
|
||||
if let Some(event) = pump.wait_event_timeout(timeout) {
|
||||
w.handle_event(event);
|
||||
// Drain whatever else queued while we were waiting or handling.
|
||||
while let Some(event) = pump.poll_event() {
|
||||
w.handle_event(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,59 +799,6 @@ fn run(
|
||||
// new button events; the chord itself is only detected while a session is attached).
|
||||
w.maybe_fire_disconnect();
|
||||
|
||||
// Feedback planes (this thread is their single consumer). The host re-sends
|
||||
// rumble state periodically, so a generous duration with refresh-on-update is
|
||||
// safe — a dropped stop heals within ~500 ms.
|
||||
if let Some(connector) = w.attached.clone() {
|
||||
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||
if pad == 0 {
|
||||
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
|
||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
||||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||||
// client-render.
|
||||
if let Err(e) = p.set_rumble(low, high, 5_000) {
|
||||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: rendered");
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(low, high, "rumble: received but no active pad to render");
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let Some(id) = w.active_id() else { continue };
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
|
||||
let Some(pad) = w.opened.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
match hid {
|
||||
HidOutput::Led { pad: 0, r, g, b } if is_ds => {
|
||||
let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
|
||||
}
|
||||
HidOutput::Led { pad: 0, r, g, b } => {
|
||||
let _ = pad.set_led(r, g, b);
|
||||
}
|
||||
HidOutput::PlayerLeds { pad: 0, bits } if is_ds => {
|
||||
let _ = pad.send_effect(&Ds5Feedback::player_packet(bits));
|
||||
}
|
||||
HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which,
|
||||
ref effect,
|
||||
} if is_ds => {
|
||||
let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(if w.attached.is_some() {
|
||||
2
|
||||
} else {
|
||||
30
|
||||
}));
|
||||
w.render_feedback();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Session launch: resolve the stream mode, spawn the session worker, and drive its
|
||||
//! event stream into the UI (trust persistence, stream-page push, teardown).
|
||||
|
||||
use crate::app::App;
|
||||
use crate::session::{SessionEvent, SessionParams, Stats};
|
||||
use crate::trust;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use crate::video::DecodedFrame;
|
||||
use adw::prelude::*;
|
||||
use gtk::{gdk, glib};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The mode to request: explicit settings, with `0` fields resolved to the native
|
||||
/// size/refresh of the monitor the window currently occupies (mirrors the Swift client's
|
||||
/// native-display default).
|
||||
fn resolve_mode(app: &App) -> Mode {
|
||||
let s = app.settings.borrow();
|
||||
let mut mode = Mode {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
refresh_hz: s.refresh_hz,
|
||||
};
|
||||
if mode.width == 0 || mode.refresh_hz == 0 {
|
||||
// Prefer the monitor the window is on; fall back to the display's first monitor. On a
|
||||
// `--connect` launch the window may not be mapped yet when this runs, and without the
|
||||
// fallback we'd drop to the 1920×1080 floor below — wrong on the Deck (1280×800).
|
||||
let monitor = app
|
||||
.window
|
||||
.surface()
|
||||
.zip(gdk::Display::default())
|
||||
.and_then(|(surf, d)| d.monitor_at_surface(&surf))
|
||||
.or_else(|| {
|
||||
gdk::Display::default()
|
||||
.and_then(|d| d.monitors().item(0))
|
||||
.and_then(|o| o.downcast::<gdk::Monitor>().ok())
|
||||
});
|
||||
if let Some(m) = monitor {
|
||||
let geo = m.geometry();
|
||||
let scale = m.scale_factor().max(1);
|
||||
if mode.width == 0 {
|
||||
mode.width = (geo.width() * scale) as u32;
|
||||
mode.height = (geo.height() * scale) as u32;
|
||||
}
|
||||
if mode.refresh_hz == 0 {
|
||||
mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No monitor info (early call, odd compositor) — a sane floor.
|
||||
if mode.width == 0 {
|
||||
(mode.width, mode.height) = (1920, 1080);
|
||||
}
|
||||
if mode.refresh_hz == 0 {
|
||||
mode.refresh_hz = 60;
|
||||
}
|
||||
mode
|
||||
}
|
||||
|
||||
/// Tunables for a session start that differ between the normal connect and the "request access"
|
||||
/// (delegated-approval) flow. `Default` is the normal connect.
|
||||
pub struct StartOpts {
|
||||
/// Handshake budget. The request-access flow uses a long one because the host PARKS the
|
||||
/// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`).
|
||||
pub connect_timeout: std::time::Duration,
|
||||
/// Persist the host as *paired* on a successful connect. Set for request-access, where the
|
||||
/// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
|
||||
/// persists the host *unpaired* (pinned, but not PIN/approval-verified).
|
||||
pub persist_paired: bool,
|
||||
/// A "waiting for approval" dialog to dismiss on the first session event (request-access only).
|
||||
pub waiting: Option<adw::AlertDialog>,
|
||||
/// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with
|
||||
/// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves
|
||||
/// the in-flight connect to time out; when it finally resolves, the event loop sees this flag
|
||||
/// and tears down silently (drops the connector → closes the connection) without touching the
|
||||
/// UI a new session may already own.
|
||||
pub cancel: Option<Rc<std::cell::Cell<bool>>>,
|
||||
}
|
||||
|
||||
impl Default for StartOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_timeout: std::time::Duration::from_secs(15),
|
||||
persist_paired: false,
|
||||
waiting: None,
|
||||
cancel: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||
start_session_with(app, req, pin, StartOpts::default());
|
||||
}
|
||||
|
||||
pub fn start_session_with(
|
||||
app: Rc<App>,
|
||||
req: ConnectRequest,
|
||||
pin: Option<[u8; 32]>,
|
||||
opts: StartOpts,
|
||||
) {
|
||||
if app.busy.replace(true) {
|
||||
return;
|
||||
}
|
||||
let mode = resolve_mode(&app);
|
||||
let s = app.settings.borrow();
|
||||
let params = SessionParams {
|
||||
host: req.addr.clone(),
|
||||
port: req.port,
|
||||
mode,
|
||||
compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto),
|
||||
// "Automatic" matches the physical pad (Swift parity); an explicit choice wins.
|
||||
gamepad: match GamepadPref::from_name(&s.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(),
|
||||
Some(explicit) => explicit,
|
||||
},
|
||||
bitrate_kbps: s.bitrate_kbps,
|
||||
mic_enabled: s.mic_enabled,
|
||||
audio_channels: s.audio_channels,
|
||||
preferred_codec: s.preferred_codec(),
|
||||
decoder: s.decoder.clone(),
|
||||
launch: req.launch.as_ref().map(|(id, _)| id.clone()),
|
||||
pin,
|
||||
identity: app.identity.clone(),
|
||||
connect_timeout: opts.connect_timeout,
|
||||
};
|
||||
let inhibit = s.inhibit_shortcuts;
|
||||
let show_stats = s.show_stats;
|
||||
drop(s);
|
||||
let cancel = opts.cancel;
|
||||
|
||||
// Card feedback while the connect is in flight: spinner on the matching hosts card,
|
||||
// stale failure banner dismissed. Cleared again on Connected/Failed/Ended.
|
||||
if let Some(h) = app.hosts_ui() {
|
||||
h.clear_error();
|
||||
h.set_connecting(Some(req.card_key()));
|
||||
}
|
||||
|
||||
let mut handle = crate::session::start(params);
|
||||
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
||||
let mut ctx = SessionUi {
|
||||
stop: handle.stop.clone(),
|
||||
app,
|
||||
req,
|
||||
persist_paired: opts.persist_paired,
|
||||
tofu: pin.is_none(),
|
||||
inhibit,
|
||||
show_stats,
|
||||
frames: Some(frames),
|
||||
waiting: opts.waiting,
|
||||
page: None,
|
||||
};
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(event) = handle.events.recv().await {
|
||||
// A cancelled request-access connect resolved late: tear down silently. Don't touch
|
||||
// app.busy — Cancel already cleared it, and a fresh session may now own it.
|
||||
if cancel.as_ref().is_some_and(|c| c.get()) {
|
||||
ctx.close_waiting();
|
||||
break;
|
||||
}
|
||||
match event {
|
||||
SessionEvent::Connected {
|
||||
connector,
|
||||
mode,
|
||||
fingerprint,
|
||||
} => ctx.on_connected(connector, mode, fingerprint),
|
||||
SessionEvent::Stats(s) => ctx.on_stats(s),
|
||||
SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
} => {
|
||||
ctx.on_failed(&msg, trust_rejected);
|
||||
break;
|
||||
}
|
||||
SessionEvent::Ended(err) => {
|
||||
ctx.on_ended(err);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// UI-side state one session's event loop carries between events.
|
||||
struct SessionUi {
|
||||
app: Rc<App>,
|
||||
req: ConnectRequest,
|
||||
/// Persist the host as PAIRED on `Connected` (request-access — the approval IS the pairing).
|
||||
persist_paired: bool,
|
||||
/// This is a TOFU connect (no stored pin): pin the observed fingerprint on `Connected`.
|
||||
tofu: bool,
|
||||
/// Grab compositor shortcuts while input is captured (Settings).
|
||||
inhibit: bool,
|
||||
/// Show the stats OSD when the stream page opens (Settings; live-toggled on-page).
|
||||
show_stats: bool,
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Decoded-frame receiver, handed to the stream page once on `Connected`.
|
||||
frames: Option<async_channel::Receiver<DecodedFrame>>,
|
||||
/// The "waiting for approval" dialog (request-access flow), dismissed on the first event.
|
||||
waiting: Option<adw::AlertDialog>,
|
||||
page: Option<crate::ui_stream::StreamPage>,
|
||||
}
|
||||
|
||||
impl SessionUi {
|
||||
/// Dismiss the "waiting for approval" dialog (request-access flow), if any.
|
||||
fn close_waiting(&mut self) {
|
||||
if let Some(w) = self.waiting.take() {
|
||||
w.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// `Connected`: record the configured trust decision, attach gamepads, and push the
|
||||
/// stream page.
|
||||
fn on_connected(&mut self, connector: Arc<NativeClient>, mode: Mode, fingerprint: [u8; 32]) {
|
||||
self.close_waiting();
|
||||
if let Some(h) = self.app.hosts_ui() {
|
||||
h.set_connecting(None);
|
||||
}
|
||||
if self.persist_paired {
|
||||
// Request-access: the operator approved this device, so record the host as
|
||||
// a trusted PAIRED host (pinning the fingerprint we observed) — future
|
||||
// connects are then silent (rule 1), exactly like after a PIN ceremony.
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
trust::persist_host(&self.req.name, &self.req.addr, self.req.port, &fp_hex, true);
|
||||
self.app.toast("Approved — connecting…");
|
||||
} else if self.tofu {
|
||||
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
||||
let fp_hex = trust::hex(&fingerprint);
|
||||
trust::persist_host(
|
||||
&self.req.name,
|
||||
&self.req.addr,
|
||||
self.req.port,
|
||||
&fp_hex,
|
||||
false,
|
||||
);
|
||||
self.app.toast(&format!(
|
||||
"Trusted on first use — fingerprint {}…",
|
||||
&fp_hex[..16]
|
||||
));
|
||||
}
|
||||
// Stamp the successful connect — this host's card carries the accent bar now.
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
tracing::debug!(?mode, "connected — pushing stream page");
|
||||
// A library launch titles the stream with the game, not the host.
|
||||
let name = self
|
||||
.req
|
||||
.launch
|
||||
.as_ref()
|
||||
.map_or(self.req.name.as_str(), |(_, game)| game.as_str());
|
||||
let title = format!(
|
||||
"{name} · {}×{}@{}",
|
||||
mode.width, mode.height, mode.refresh_hz
|
||||
);
|
||||
self.app.gamepad.attach(connector.clone());
|
||||
let clock_offset_ns = connector.clock_offset_ns;
|
||||
let p = crate::ui_stream::new(crate::ui_stream::StreamPageArgs {
|
||||
window: self.app.window.clone(),
|
||||
connector,
|
||||
frames: self.frames.take().expect("Connected delivered once"),
|
||||
clock_offset_ns,
|
||||
escape_rx: self.app.gamepad.escape_events(),
|
||||
disconnect_rx: self.app.gamepad.disconnect_events(),
|
||||
stop: self.stop.clone(),
|
||||
inhibit_shortcuts: self.inhibit,
|
||||
show_stats: self.show_stats,
|
||||
title,
|
||||
});
|
||||
self.app.nav.push(&p.page);
|
||||
// Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't
|
||||
// know it, so its header bar stays drawn. Enter GTK fullscreen explicitly —
|
||||
// the stream page's `connect_fullscreened_notify` then hides all chrome.
|
||||
if self.app.fullscreen {
|
||||
self.app.window.fullscreen();
|
||||
}
|
||||
self.page = Some(p);
|
||||
}
|
||||
|
||||
fn on_stats(&self, s: Stats) {
|
||||
if let Some(p) = &self.page {
|
||||
p.update_stats(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Failed`: surface the error; a trust rejection on a pinned connect routes to re-pairing.
|
||||
fn on_failed(&mut self, msg: &str, trust_rejected: bool) {
|
||||
self.close_waiting();
|
||||
tracing::warn!(%msg, trust_rejected, "connect failed");
|
||||
self.app.busy.set(false);
|
||||
if let Some(h) = self.app.hosts_ui() {
|
||||
h.set_connecting(None);
|
||||
}
|
||||
// A pinned connect rejected on trust grounds means the host's cert no
|
||||
// longer matches the stored pin (rotated cert or impostor) — route to
|
||||
// the PIN ceremony to re-establish trust rather than dead-ending.
|
||||
if trust_rejected && !self.tofu {
|
||||
self.app
|
||||
.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||
crate::ui_trust::pin_dialog(self.app.clone(), self.req.clone());
|
||||
} else {
|
||||
// Errors land on the hosts page banner, not a transient toast.
|
||||
self.app.connect_error(&format!("Couldn't connect — {msg}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ended`: detach gamepads, pop back to the hosts page, and surface the reason.
|
||||
fn on_ended(&mut self, err: Option<String>) {
|
||||
self.close_waiting();
|
||||
self.app.gamepad.detach();
|
||||
self.app.nav.pop_to_tag("hosts");
|
||||
if let Some(h) = self.app.hosts_ui() {
|
||||
h.set_connecting(None);
|
||||
}
|
||||
if let Some(e) = err {
|
||||
self.app.connect_error(&e);
|
||||
}
|
||||
self.app.busy.set(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Game-library client for the host's management REST API (the Apple `LibraryClient`
|
||||
//! ported): `GET https://<host>:<mgmt>/api/v1/library` plus the per-title art proxy.
|
||||
//! Authentication is **mTLS** — this client presents its persistent identity (the same
|
||||
//! cert the host paired over QUIC) and the host authorizes paired certificates for the
|
||||
//! read-only library routes, no bearer token. The host's self-signed certificate is
|
||||
//! verified by its pinned SHA-256 fingerprint (`KnownHost::fp_hex`), not a CA chain.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The management API's default port — matches `mgmt::DEFAULT_PORT` on the host. A
|
||||
/// discovered host may override it via its mDNS `mgmt` TXT (`DiscoveredHost::mgmt_port`);
|
||||
/// saved-but-not-advertising hosts fall back here (Apple parity).
|
||||
pub const DEFAULT_MGMT_PORT: u16 = 47990;
|
||||
|
||||
/// Cover-art URLs, mirroring the host's `library::Artwork`: absolute CDN URLs for custom
|
||||
/// entries, host-relative proxy paths (`/api/v1/library/art/...`) for Steam titles. The
|
||||
/// wire shape also carries a `logo` (a transparent title logo) — not a poster kind, so
|
||||
/// serde just skips it here.
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct Artwork {
|
||||
#[serde(default)]
|
||||
pub portrait: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hero: Option<String>,
|
||||
#[serde(default)]
|
||||
pub header: Option<String>,
|
||||
}
|
||||
|
||||
impl Artwork {
|
||||
/// Poster candidates in the Apple client's fallback order — portrait (the 600×900
|
||||
/// capsule) → header (near-universal) → hero — with host-relative paths resolved
|
||||
/// against `base` so the loader only ever sees absolute URLs.
|
||||
pub fn poster_candidates(&self, base: &str) -> Vec<String> {
|
||||
[&self.portrait, &self.header, &self.hero]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|u| {
|
||||
if u.starts_with('/') {
|
||||
format!("{base}{u}")
|
||||
} else {
|
||||
u.clone()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// One title in the host's unified library. `id` is store-qualified (`steam:<appid>`,
|
||||
/// `custom:<id>`) and is also the launch handle the Hello carries when a session is
|
||||
/// started from the library. The host's `launch` spec field is deliberately not
|
||||
/// deserialized — launching goes by id, the host resolves the spec itself.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct GameEntry {
|
||||
pub id: String,
|
||||
/// Which store surfaced it (`"steam"`, `"custom"`, future `"heroic"`/`"gog"`/…) —
|
||||
/// drives the poster's store badge.
|
||||
pub store: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub art: Artwork,
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
#[derive(Debug)]
|
||||
pub enum LibraryError {
|
||||
/// The host rejected our certificate — this device isn't on its paired list.
|
||||
NotPaired,
|
||||
/// The host's certificate didn't hash to the pinned fingerprint (impostor/rotated cert).
|
||||
PinMismatch,
|
||||
Http(u16),
|
||||
Unreachable(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LibraryError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
LibraryError::NotPaired => f.write_str(
|
||||
"The host didn't recognize this device. Pair with the host first — the \
|
||||
library is authorized by this device's certificate (no token needed).",
|
||||
),
|
||||
LibraryError::PinMismatch => f.write_str(
|
||||
"The host's certificate doesn't match the pinned fingerprint. \
|
||||
Re-pair with a PIN to re-establish trust.",
|
||||
),
|
||||
LibraryError::Http(code) => {
|
||||
write!(f, "The management API returned HTTP {code}.")
|
||||
}
|
||||
LibraryError::Unreachable(why) => write!(
|
||||
f,
|
||||
"Couldn't reach the host's management API: {why}. Check the host is \
|
||||
updated and reachable (a host pinned to --mgmt-bind 127.0.0.1 is \
|
||||
loopback-only and can't be browsed remotely)."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `https://addr:port`, IPv6 literals bracketed.
|
||||
pub fn base_url(addr: &str, mgmt_port: u16) -> String {
|
||||
if addr.contains(':') {
|
||||
format!("https://[{addr}]:{mgmt_port}")
|
||||
} else {
|
||||
format!("https://{addr}:{mgmt_port}")
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTPS agent presenting `identity` via TLS client auth and verifying the server by
|
||||
/// `pin` (`None` = accept any cert, the TOFU special case — same semantics as the QUIC
|
||||
/// connect). Reused across a whole grid's worth of poster loads.
|
||||
pub fn agent(
|
||||
identity: &(String, String),
|
||||
pin: Option<[u8; 32]>,
|
||||
) -> Result<ureq::Agent, LibraryError> {
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
let bad =
|
||||
|what: &str, e: &dyn std::fmt::Display| LibraryError::Unreachable(format!("{what}: {e}"));
|
||||
// The ring provider, explicitly — the same one core's QUIC endpoints install, so the
|
||||
// process never mixes rustls crypto providers.
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let builder = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| bad("tls config", &e))?
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(PinVerify { pin }));
|
||||
let cert = rustls::pki_types::CertificateDer::from_pem_slice(identity.0.as_bytes())
|
||||
.map_err(|e| bad("client cert pem", &e))?;
|
||||
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(identity.1.as_bytes())
|
||||
.map_err(|e| bad("client key pem", &e))?;
|
||||
let cfg = builder
|
||||
.with_client_auth_cert(vec![cert], key)
|
||||
.map_err(|e| bad("client auth", &e))?;
|
||||
Ok(ureq::AgentBuilder::new()
|
||||
.tls_config(Arc::new(cfg))
|
||||
.timeout_connect(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build())
|
||||
}
|
||||
|
||||
/// Fetch the host's unified library. Errors are pre-classified for the UI (401/403 →
|
||||
/// [`LibraryError::NotPaired`], a pin-verifier rejection → [`LibraryError::PinMismatch`]).
|
||||
pub fn fetch_games(
|
||||
addr: &str,
|
||||
mgmt_port: u16,
|
||||
identity: &(String, String),
|
||||
pin: Option<[u8; 32]>,
|
||||
) -> Result<Vec<GameEntry>, LibraryError> {
|
||||
let agent = agent(identity, pin)?;
|
||||
let url = format!("{}/api/v1/library", base_url(addr, mgmt_port));
|
||||
let body = match agent.get(&url).call() {
|
||||
Ok(resp) => resp
|
||||
.into_string()
|
||||
.map_err(|e| LibraryError::Unreachable(format!("read body: {e}")))?,
|
||||
Err(e) => return Err(classify(e)),
|
||||
};
|
||||
serde_json::from_str(&body).map_err(|e| LibraryError::Unreachable(format!("bad JSON: {e}")))
|
||||
}
|
||||
|
||||
/// Poster-art byte fetch cap — largest Steam hero assets run a few MB; anything bigger is
|
||||
/// not an image we want to hand to the texture decoder.
|
||||
const ART_MAX_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
/// Fetch one cover-art image. URLs on the host itself (under `base`) go through the
|
||||
/// pinned mTLS agent (the host's art proxy requires the paired cert); any other origin —
|
||||
/// a public CDN URL on a custom entry — uses ureq's default agent with normal webpki
|
||||
/// trust and no client cert (Apple's `LibraryTLSDelegate` does the same split).
|
||||
pub fn fetch_art(pinned: &ureq::Agent, base: &str, url: &str) -> Result<Vec<u8>, LibraryError> {
|
||||
let resp = if url.starts_with(base) {
|
||||
pinned.get(url).call()
|
||||
} else {
|
||||
ureq::get(url).timeout(Duration::from_secs(10)).call()
|
||||
}
|
||||
.map_err(classify)?;
|
||||
let mut bytes = Vec::new();
|
||||
resp.into_reader()
|
||||
.take(ART_MAX_BYTES)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| LibraryError::Unreachable(format!("read image: {e}")))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn classify(e: ureq::Error) -> LibraryError {
|
||||
match e {
|
||||
ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired,
|
||||
ureq::Error::Status(code, _) => LibraryError::Http(code),
|
||||
ureq::Error::Transport(t) => {
|
||||
// A pin rejection surfaces as a TLS alert wrapped in a transport error; the
|
||||
// verifier's error kind survives in the message.
|
||||
let msg = t.to_string();
|
||||
if msg.contains("ApplicationVerificationFailure") || msg.contains("InvalidCertificate")
|
||||
{
|
||||
LibraryError::PinMismatch
|
||||
} else {
|
||||
LibraryError::Unreachable(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fingerprint-pinning verifier — the client-HTTP twin of core's (private) QUIC
|
||||
/// `PinVerify`: trust is the SHA-256 of the host's self-signed leaf cert. The handshake
|
||||
/// signatures MUST still be verified for real: CertificateVerify is what proves the peer
|
||||
/// *holds the pinned cert's private key* — skip it and an active MITM can replay the
|
||||
/// host's (public) certificate, match the pin, and complete the handshake with its own key.
|
||||
#[derive(Debug)]
|
||||
struct PinVerify {
|
||||
pin: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
if let Some(expected) = self.pin {
|
||||
let fp = punktfunk_core::quic::endpoint::cert_fingerprint(end_entity.as_ref());
|
||||
if fp != expected {
|
||||
return Err(rustls::Error::InvalidCertificate(
|
||||
rustls::CertificateError::ApplicationVerificationFailure,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn poster_candidates_order_and_resolution() {
|
||||
// Fallback order is portrait → header → hero, host-relative paths resolved.
|
||||
let art = Artwork {
|
||||
portrait: Some("/api/v1/library/art/steam:570/portrait".into()),
|
||||
hero: Some("https://cdn.example/hero.jpg".into()),
|
||||
header: Some("/api/v1/library/art/steam:570/header".into()),
|
||||
};
|
||||
assert_eq!(
|
||||
art.poster_candidates("https://192.168.1.42:47990"),
|
||||
vec![
|
||||
"https://192.168.1.42:47990/api/v1/library/art/steam:570/portrait",
|
||||
"https://192.168.1.42:47990/api/v1/library/art/steam:570/header",
|
||||
"https://cdn.example/hero.jpg",
|
||||
]
|
||||
);
|
||||
assert!(Artwork::default()
|
||||
.poster_candidates("https://h:47990")
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_entry_decodes_the_wire_shape() {
|
||||
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
|
||||
let json = r#"[
|
||||
{"id":"steam:570","store":"steam","title":"Dota 2",
|
||||
"art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
|
||||
"launch":{"kind":"steam_appid","value":"570"}},
|
||||
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
|
||||
]"#;
|
||||
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(games.len(), 2);
|
||||
assert_eq!(games[0].id, "steam:570");
|
||||
assert!(games[1].art.portrait.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_base_url_is_bracketed() {
|
||||
assert_eq!(base_url("fe80::1", 47990), "https://[fe80::1]:47990");
|
||||
assert_eq!(base_url("192.168.1.42", 1234), "https://192.168.1.42:1234");
|
||||
}
|
||||
}
|
||||
@@ -10,22 +10,32 @@ mod app;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod audio;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod cli;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod discovery;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod gamepad;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod keymap;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod launch;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod library;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod session;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod trust;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_hosts;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_library;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_settings;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_stream;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_trust;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod video;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Session controller: one worker thread runs connect → pump (video pull + decode, audio
|
||||
//! pull + Opus decode, stats), feeding the GTK main loop over channels. The UI keeps the
|
||||
//! `Arc<NativeClient>` from the `Connected` event for direct input sends (no extra hop on
|
||||
//! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread:
|
||||
//! video+audio here, rumble+hidout on the gamepad thread.
|
||||
//! Session controller: the worker thread runs connect → pump (video pull + decode +
|
||||
//! stats), a dedicated audio thread pulls + Opus-decodes the audio plane (Apple
|
||||
//! `SessionAudio` parity — audio never waits behind a video decode), both feeding the GTK
|
||||
//! main loop / PipeWire over channels. The UI keeps the `Arc<NativeClient>` from the
|
||||
//! `Connected` event for direct input sends (no extra hop on the input path) —
|
||||
//! `NativeClient` is `Sync`, planes stay one-consumer-per-thread: video here, audio on
|
||||
//! its own thread, rumble+hidout on the gamepad thread.
|
||||
|
||||
use crate::audio;
|
||||
use crate::video::{DecodedFrame, Decoder};
|
||||
use crate::video::{DecodedFrame, DecodedImage, Decoder};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use punktfunk_core::PunktfunkError;
|
||||
@@ -27,6 +29,12 @@ pub struct SessionParams {
|
||||
pub preferred_codec: u8,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
pub mic_enabled: bool,
|
||||
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
|
||||
/// `video::Decoder::new`).
|
||||
pub decoder: String,
|
||||
/// Library id for the host to launch this session (`"steam:570"`, from the library
|
||||
/// page); `None` = plain desktop session.
|
||||
pub launch: Option<String>,
|
||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||
pub pin: Option<[u8; 32]>,
|
||||
pub identity: (String, String),
|
||||
@@ -44,6 +52,9 @@ pub struct Stats {
|
||||
pub decode_ms: f32,
|
||||
/// Median capture→decoded latency over the last window (host-clock corrected).
|
||||
pub latency_ms: f32,
|
||||
/// The decode path frames actually took this window (`"vaapi"`/`"software"`, empty
|
||||
/// until the first frame) — the OSD's trailing tag; tracks a mid-session fallback.
|
||||
pub decoder: &'static str,
|
||||
}
|
||||
|
||||
pub enum SessionEvent {
|
||||
@@ -86,7 +97,7 @@ pub fn start(params: SessionParams) -> SessionHandle {
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ns() -> u64 {
|
||||
pub fn now_ns() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
@@ -146,7 +157,7 @@ fn pump(
|
||||
params.audio_channels,
|
||||
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
||||
None, // launch: the Linux client has no library picker yet
|
||||
params.launch.clone(),
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
params.connect_timeout,
|
||||
@@ -175,14 +186,15 @@ fn pump(
|
||||
fingerprint: connector.host_fingerprint,
|
||||
});
|
||||
|
||||
// Build the decoder for the codec the host resolved (never assume HEVC).
|
||||
// Build the decoder for the codec the host resolved (never assume HEVC), honoring the
|
||||
// Settings backend preference (auto/vaapi/software).
|
||||
let codec_id = crate::video::ffmpeg_codec_id(connector.codec);
|
||||
tracing::info!(
|
||||
?codec_id,
|
||||
welcome_codec = connector.codec,
|
||||
"negotiated video codec"
|
||||
);
|
||||
let mut decoder = match Decoder::new(codec_id) {
|
||||
let mut decoder = match Decoder::new(codec_id, ¶ms.decoder) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
|
||||
@@ -190,16 +202,9 @@ fn pump(
|
||||
}
|
||||
};
|
||||
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Build the decoder + playback
|
||||
// from the host-RESOLVED channel count (never the request), so an older/clamping host that
|
||||
// resolves stereo is decoded as stereo.
|
||||
let channels = connector.audio_channels;
|
||||
let player = audio::AudioPlayer::spawn(channels as u32)
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
|
||||
.ok();
|
||||
let mut opus_dec = AudioDec::new(channels)
|
||||
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
|
||||
.ok();
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||
let _mic = params
|
||||
.mic_enabled
|
||||
.then(|| {
|
||||
@@ -216,8 +221,10 @@ fn pump(
|
||||
let mut bytes_n = 0u64;
|
||||
let mut decode_us_sum = 0u64;
|
||||
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||
// What actually decoded the last frame — a VAAPI failure demotes mid-session, so
|
||||
// this is read off each frame's image variant rather than fixed at startup.
|
||||
let mut dec_path: &'static str = "";
|
||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
||||
let mut last_dropped = connector.frames_dropped();
|
||||
let mut last_kf_req: Option<Instant> = None;
|
||||
|
||||
@@ -225,16 +232,23 @@ fn pump(
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break None;
|
||||
}
|
||||
match connector.next_frame(Duration::from_millis(4)) {
|
||||
// 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).
|
||||
match connector.next_frame(Duration::from_millis(20)) {
|
||||
Ok(frame) => {
|
||||
let t0 = Instant::now();
|
||||
match decoder.decode(&frame.data) {
|
||||
Ok(Some(decoded)) => {
|
||||
Ok(Some(image)) => {
|
||||
total_frames += 1;
|
||||
dec_path = match &image {
|
||||
DecodedImage::Cpu(_) => "software",
|
||||
DecodedImage::Dmabuf(_) => "vaapi",
|
||||
};
|
||||
if total_frames == 1 {
|
||||
let (w, h, path) = match &decoded {
|
||||
DecodedFrame::Cpu(c) => (c.width, c.height, "software"),
|
||||
DecodedFrame::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"),
|
||||
let (w, h, path) = match &image {
|
||||
DecodedImage::Cpu(c) => (c.width, c.height, "software"),
|
||||
DecodedImage::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"),
|
||||
};
|
||||
tracing::info!(width = w, height = h, path, "first frame decoded");
|
||||
}
|
||||
@@ -248,7 +262,10 @@ fn pump(
|
||||
decode_us_sum += t0.elapsed().as_micros() as u64;
|
||||
frames_n += 1;
|
||||
bytes_n += frame.data.len() as u64;
|
||||
let _ = frame_tx.force_send(decoded);
|
||||
let _ = frame_tx.force_send(DecodedFrame {
|
||||
pts_ns: frame.pts_ns,
|
||||
image,
|
||||
});
|
||||
}
|
||||
Ok(None) => {}
|
||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||
@@ -276,17 +293,6 @@ fn pump(
|
||||
}
|
||||
}
|
||||
|
||||
// Drain audio between frames (packets land every 5 ms; the queue holds 320 ms).
|
||||
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
|
||||
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
|
||||
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
||||
Ok(samples) => player.push(pcm[..samples * channels as usize].to_vec()),
|
||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||
let secs = window_start.elapsed().as_secs_f32();
|
||||
lat_us.sort_unstable();
|
||||
@@ -306,6 +312,7 @@ fn pump(
|
||||
0.0
|
||||
},
|
||||
latency_ms: p50 as f32 / 1000.0,
|
||||
decoder: dec_path,
|
||||
}));
|
||||
window_start = Instant::now();
|
||||
frames_n = 0;
|
||||
@@ -321,5 +328,52 @@ fn pump(
|
||||
"session ended"
|
||||
);
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
if let Some(t) = audio_thread {
|
||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||
}
|
||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
|
||||
}
|
||||
|
||||
/// The dedicated audio thread: owns the Opus decoder, the PCM scratch, and the PipeWire
|
||||
/// player, and blocks on `next_audio` (the plane's single consumer — packets land every
|
||||
/// 5 ms). Decoded chunks are pushed in Vecs recycled from the player's pool, so the
|
||||
/// steady state allocates nothing. Best-effort like before: any setup failure logs and
|
||||
/// the session streams video-only. Exits on the stop flag or a closed plane.
|
||||
fn spawn_audio(
|
||||
connector: Arc<NativeClient>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Option<std::thread::JoinHandle<()>> {
|
||||
// Decoder + playback are built from the host-RESOLVED channel count (never the
|
||||
// request), so an older/clamping host that resolves stereo is decoded as stereo.
|
||||
let channels = connector.audio_channels;
|
||||
let player = audio::AudioPlayer::spawn(channels as u32)
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
|
||||
.ok()?;
|
||||
let mut dec = AudioDec::new(channels)
|
||||
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
|
||||
.ok()?;
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-audio-rx".into())
|
||||
.spawn(move || {
|
||||
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
match connector.next_audio(Duration::from_millis(100)) {
|
||||
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
||||
Ok(samples) => {
|
||||
let n = samples * channels as usize;
|
||||
let mut buf = player.take_buffer();
|
||||
buf.extend_from_slice(&pcm[..n]);
|
||||
player.push(buf);
|
||||
}
|
||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||
},
|
||||
Err(PunktfunkError::NoFrame) => {}
|
||||
Err(_) => break, // plane closed — the session is ending
|
||||
}
|
||||
}
|
||||
tracing::debug!("audio pull thread exited");
|
||||
})
|
||||
.map_err(|e| tracing::warn!(error = %e, "audio thread failed to start — audio disabled"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! so a box pairs once whichever client it uses.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::quic::endpoint;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
@@ -55,6 +56,10 @@ pub struct KnownHost {
|
||||
pub fp_hex: String,
|
||||
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
||||
pub paired: bool,
|
||||
/// Unix seconds of the last successful connect — the hosts page marks the
|
||||
/// most-recent card with the accent bar. `default` so pre-existing stores load.
|
||||
#[serde(default)]
|
||||
pub last_used: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
@@ -106,12 +111,64 @@ impl KnownHosts {
|
||||
h.addr = entry.addr;
|
||||
h.port = entry.port;
|
||||
h.paired |= entry.paired;
|
||||
// A refresh without a timestamp must not erase the stored one.
|
||||
if entry.last_used.is_some() {
|
||||
h.last_used = entry.last_used;
|
||||
}
|
||||
} else {
|
||||
self.hosts.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load-upsert-save in one step — the pin every trust decision (TOFU accept, PIN
|
||||
/// ceremony, delegated approval, headless pairing) ends in.
|
||||
pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: bool) {
|
||||
let mut known = KnownHosts::load();
|
||||
known.upsert(KnownHost {
|
||||
name: name.to_string(),
|
||||
addr: addr.to_string(),
|
||||
port,
|
||||
fp_hex: fp_hex.to_string(),
|
||||
paired,
|
||||
last_used: None,
|
||||
});
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Stamp "now" as this host's last successful connect (drives the hosts page's
|
||||
/// most-recent accent). No-op when the fingerprint isn't stored.
|
||||
pub fn touch_last_used(fp_hex: &str) {
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp_hex) {
|
||||
h.last_used = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.ok();
|
||||
let _ = known.save();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the SPAKE2 PIN ceremony against a host. `device_name` is the label the HOST
|
||||
/// stores this client under (its paired-devices list); the 90 s budget covers a
|
||||
/// human-typed PIN. Returns the host's now-verified certificate fingerprint to pin.
|
||||
pub fn pair_with_host(
|
||||
addr: &str,
|
||||
port: u16,
|
||||
identity: &(String, String),
|
||||
pin: &str,
|
||||
device_name: &str,
|
||||
) -> std::result::Result<[u8; 32], punktfunk_core::PunktfunkError> {
|
||||
NativeClient::pair(
|
||||
addr,
|
||||
port,
|
||||
(&identity.0, &identity.1),
|
||||
pin.trim(),
|
||||
device_name,
|
||||
std::time::Duration::from_secs(90),
|
||||
)
|
||||
}
|
||||
|
||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
@@ -139,6 +196,14 @@ pub struct Settings {
|
||||
/// preference — the host honors it when it can emit it, else falls back to the best shared codec.
|
||||
#[serde(default = "default_codec")]
|
||||
pub codec: String,
|
||||
/// Video decoder preference: `"auto"` (VAAPI → software), `"vaapi"`, `"software"`.
|
||||
/// The `PUNKTFUNK_DECODER` env var overrides this (see `video::Decoder::new`).
|
||||
pub decoder: String,
|
||||
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
|
||||
pub show_stats: bool,
|
||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||
pub library_enabled: bool,
|
||||
}
|
||||
|
||||
fn default_codec() -> String {
|
||||
@@ -170,6 +235,9 @@ impl Default for Settings {
|
||||
mic_enabled: false,
|
||||
audio_channels: 2,
|
||||
codec: "auto".into(),
|
||||
decoder: "auto".into(),
|
||||
show_stats: true,
|
||||
library_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+619
-240
@@ -1,9 +1,14 @@
|
||||
//! The hosts page: saved (trusted) hosts, live mDNS discovery, manual connect entry.
|
||||
//! The hosts page: adaptive card grids for saved (trusted/paired) and mDNS-discovered
|
||||
//! hosts, matching the other clients' look — avatar + name + `addr:port` + status pills,
|
||||
//! online pips on saved cards, dashed discovered cards, an overflow menu, an add-host
|
||||
//! dialog, and a connect-failure banner. Both grids re-render from one state snapshot
|
||||
//! (known hosts on disk + the live advert map), so dedup and the online pips stay
|
||||
//! consistent on every change.
|
||||
|
||||
use crate::discovery::{self, DiscoveredHost};
|
||||
use crate::trust::KnownHosts;
|
||||
use crate::discovery::{self, DiscoveredHost, DiscoveryEvent};
|
||||
use crate::trust::{KnownHost, KnownHosts, Settings};
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use gtk::{gio, glib};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
@@ -20,252 +25,158 @@ pub struct ConnectRequest {
|
||||
pub port: u16,
|
||||
pub fp_hex: Option<String>,
|
||||
pub pair_optional: bool,
|
||||
/// A library title to launch on connect (`(library id, display name)`, e.g.
|
||||
/// `("steam:570", "Dota 2")`) — set by the library page's card activation; the id
|
||||
/// rides the Hello and the name titles the stream page. `None` = plain desktop session.
|
||||
pub launch: Option<(String, String)>,
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
on_connect: Rc<dyn Fn(ConnectRequest)>,
|
||||
on_settings: Rc<dyn Fn()>,
|
||||
on_speed_test: Rc<dyn Fn(ConnectRequest)>,
|
||||
) -> adw::NavigationPage {
|
||||
let list = gtk::ListBox::new();
|
||||
list.add_css_class("boxed-list");
|
||||
list.set_selection_mode(gtk::SelectionMode::None);
|
||||
let placeholder = gtk::Label::new(Some("Searching the LAN for hosts…"));
|
||||
placeholder.add_css_class("dim-label");
|
||||
placeholder.set_margin_top(24);
|
||||
placeholder.set_margin_bottom(24);
|
||||
list.set_placeholder(Some(&placeholder));
|
||||
impl ConnectRequest {
|
||||
/// The key the hosts page tracks an in-flight connect under (the card that swaps its
|
||||
/// avatar for a spinner): the fingerprint when known, else the address.
|
||||
pub fn card_key(&self) -> String {
|
||||
self.fp_hex
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}:{}", self.addr, self.port))
|
||||
}
|
||||
}
|
||||
|
||||
// key → (row, latest advert); the activation closure looks the advert up by key so
|
||||
// re-adverts (new address, pairing flipped) take effect without rebuilding rows.
|
||||
type Rows = Rc<RefCell<HashMap<String, (adw::ActionRow, DiscoveredHost)>>>;
|
||||
let rows: Rows = Rc::new(RefCell::new(HashMap::new()));
|
||||
/// The actions the page hands off to the app shell (trust gate, speed test, PIN pairing,
|
||||
/// the library browser).
|
||||
pub struct HostsCallbacks {
|
||||
pub on_connect: Rc<dyn Fn(ConnectRequest)>,
|
||||
pub on_speed_test: Rc<dyn Fn(ConnectRequest)>,
|
||||
pub on_pair: Rc<dyn Fn(ConnectRequest)>,
|
||||
pub on_library: Rc<dyn Fn(ConnectRequest)>,
|
||||
}
|
||||
|
||||
{
|
||||
let rx = discovery::browse();
|
||||
let rows = rows.clone();
|
||||
let list = list.downgrade();
|
||||
let on_connect = on_connect.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(host) = rx.recv().await {
|
||||
let Some(list) = list.upgrade() else { break };
|
||||
let mut map = rows.borrow_mut();
|
||||
let subtitle = format!(
|
||||
"{}:{} · pairing {}",
|
||||
host.addr,
|
||||
host.port,
|
||||
if host.pair.is_empty() {
|
||||
"optional"
|
||||
} else {
|
||||
&host.pair
|
||||
}
|
||||
);
|
||||
if let Some((row, stored)) = map.get_mut(&host.key) {
|
||||
row.set_title(&host.name);
|
||||
row.set_subtitle(&subtitle);
|
||||
*stored = host;
|
||||
} else {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&host.name)
|
||||
.subtitle(&subtitle)
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
{
|
||||
let rows = rows.clone();
|
||||
let key = host.key.clone();
|
||||
let on_connect = on_connect.clone();
|
||||
row.connect_activated(move |_| {
|
||||
if let Some((_, h)) = rows.borrow().get(&key) {
|
||||
on_connect(ConnectRequest {
|
||||
name: h.name.clone(),
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
// TOFU is offered only when the host explicitly opts in
|
||||
// with pair=optional; required/empty means mandatory PIN.
|
||||
pair_optional: h.pair == "optional",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
list.append(&row);
|
||||
map.insert(host.key.clone(), (row, host));
|
||||
}
|
||||
}
|
||||
});
|
||||
/// The page plus the handle the launch path drives: connect-failure banner and the
|
||||
/// per-card connecting spinner. Held by the `App` (`app.hosts_ui()`).
|
||||
pub struct HostsUi {
|
||||
pub page: adw::NavigationPage,
|
||||
state: Rc<State>,
|
||||
}
|
||||
|
||||
impl HostsUi {
|
||||
/// Surface a connect failure at the top of the page (dismissible; replaces raw-error toasts).
|
||||
pub fn show_error(&self, msg: &str) {
|
||||
self.state.banner.set_title(msg);
|
||||
self.state.banner.set_revealed(true);
|
||||
}
|
||||
|
||||
// Manual connect: host:port (punktfunk/1 default port 9777).
|
||||
let manual = adw::EntryRow::builder().title("host:port").build();
|
||||
let connect_btn = gtk::Button::with_label("Connect");
|
||||
connect_btn.set_valign(gtk::Align::Center);
|
||||
connect_btn.add_css_class("suggested-action");
|
||||
manual.add_suffix(&connect_btn);
|
||||
let submit = {
|
||||
let manual = manual.clone();
|
||||
let on_connect = on_connect.clone();
|
||||
move || {
|
||||
let text = manual.text().to_string();
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (addr, port) = match text.rsplit_once(':') {
|
||||
Some((a, p)) => match p.parse::<u16>() {
|
||||
Ok(port) => (a.to_string(), port),
|
||||
Err(_) => return,
|
||||
},
|
||||
None => (text.to_string(), 9777),
|
||||
};
|
||||
on_connect(ConnectRequest {
|
||||
name: addr.clone(),
|
||||
addr,
|
||||
port,
|
||||
fp_hex: None,
|
||||
// Manual entry carries no advertised policy — never eligible for TOFU.
|
||||
pair_optional: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
{
|
||||
let submit = submit.clone();
|
||||
connect_btn.connect_clicked(move |_| submit());
|
||||
pub fn clear_error(&self) {
|
||||
self.state.banner.set_revealed(false);
|
||||
}
|
||||
manual.connect_entry_activated(move |_| submit());
|
||||
|
||||
let manual_list = gtk::ListBox::new();
|
||||
manual_list.add_css_class("boxed-list");
|
||||
manual_list.set_selection_mode(gtk::SelectionMode::None);
|
||||
manual_list.append(&manual);
|
||||
/// Mark the card matching `key` (see `ConnectRequest::card_key`) as connecting —
|
||||
/// spinner in place of the avatar, card insensitive. `None` restores all cards.
|
||||
pub fn set_connecting(&self, key: Option<String>) {
|
||||
*self.state.connecting.borrow_mut() = key;
|
||||
rebuild(&self.state);
|
||||
}
|
||||
|
||||
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. Rebuilt every time
|
||||
// the page is shown, so fresh TOFU/pairing entries appear on return.
|
||||
let saved_label = gtk::Label::new(Some("Saved hosts"));
|
||||
saved_label.add_css_class("heading");
|
||||
saved_label.set_halign(gtk::Align::Start);
|
||||
let saved_list = gtk::ListBox::new();
|
||||
saved_list.add_css_class("boxed-list");
|
||||
saved_list.set_selection_mode(gtk::SelectionMode::None);
|
||||
let rebuild_saved = {
|
||||
let saved_list = saved_list.clone();
|
||||
let saved_label = saved_label.clone();
|
||||
let on_connect = on_connect.clone();
|
||||
let on_speed_test = on_speed_test.clone();
|
||||
move || {
|
||||
saved_list.remove_all();
|
||||
let known = KnownHosts::load();
|
||||
saved_label.set_visible(!known.hosts.is_empty());
|
||||
saved_list.set_visible(!known.hosts.is_empty());
|
||||
for k in &known.hosts {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&k.name)
|
||||
.subtitle(format!(
|
||||
"{}:{}{}",
|
||||
k.addr,
|
||||
k.port,
|
||||
if k.paired {
|
||||
" · paired"
|
||||
} else {
|
||||
" · trusted"
|
||||
}
|
||||
))
|
||||
.activatable(true)
|
||||
.build();
|
||||
let req = ConnectRequest {
|
||||
name: k.name.clone(),
|
||||
addr: k.addr.clone(),
|
||||
port: k.port,
|
||||
fp_hex: Some(k.fp_hex.clone()),
|
||||
// Saved host: its fp is already pinned, so this routes to a silent
|
||||
// pinned connect; TOFU eligibility is irrelevant.
|
||||
pair_optional: false,
|
||||
};
|
||||
// Forget this host (drops the pinned fingerprint — a later connect re-pairs).
|
||||
// Confirmed first, since it's destructive and a misclick on the Deck is easy.
|
||||
let remove_btn = gtk::Button::from_icon_name("user-trash-symbolic");
|
||||
remove_btn.set_tooltip_text(Some("Remove saved host"));
|
||||
remove_btn.set_valign(gtk::Align::Center);
|
||||
remove_btn.add_css_class("flat");
|
||||
{
|
||||
let fp = k.fp_hex.clone();
|
||||
let name = k.name.clone();
|
||||
let saved_list = saved_list.clone();
|
||||
let saved_label = saved_label.clone();
|
||||
let row = row.clone();
|
||||
remove_btn.connect_clicked(move |_| {
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Remove saved host?"),
|
||||
Some(&format!(
|
||||
"Forget “{name}”? You'll need to pair (or trust) it again to reconnect."
|
||||
)),
|
||||
);
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("remove", "Remove")]);
|
||||
dialog.set_response_appearance(
|
||||
"remove",
|
||||
adw::ResponseAppearance::Destructive,
|
||||
);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
{
|
||||
// Scoped clones for the response handler so `row` survives for present().
|
||||
let fp = fp.clone();
|
||||
let saved_list = saved_list.clone();
|
||||
let saved_label = saved_label.clone();
|
||||
let row = row.clone();
|
||||
dialog.connect_response(Some("remove"), move |_, _| {
|
||||
let mut known = KnownHosts::load();
|
||||
known.remove_by_fp(&fp);
|
||||
let _ = known.save();
|
||||
saved_list.remove(&row);
|
||||
let empty = known.hosts.is_empty();
|
||||
saved_list.set_visible(!empty);
|
||||
saved_label.set_visible(!empty);
|
||||
});
|
||||
}
|
||||
dialog.present(Some(&row));
|
||||
});
|
||||
}
|
||||
row.add_suffix(&remove_btn);
|
||||
let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic");
|
||||
speed_btn.set_tooltip_text(Some("Test network speed"));
|
||||
speed_btn.set_valign(gtk::Align::Center);
|
||||
speed_btn.add_css_class("flat");
|
||||
{
|
||||
let on_speed_test = on_speed_test.clone();
|
||||
let req = req.clone();
|
||||
speed_btn.connect_clicked(move |_| on_speed_test(req.clone()));
|
||||
}
|
||||
row.add_suffix(&speed_btn);
|
||||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
let on_connect = on_connect.clone();
|
||||
row.connect_activated(move |_| on_connect(req.clone()));
|
||||
saved_list.append(&row);
|
||||
}
|
||||
}
|
||||
/// Feed one advert through the same path the mDNS stream uses (CI screenshot scenes).
|
||||
pub fn inject_advert(&self, host: DiscoveredHost) {
|
||||
self.state
|
||||
.adverts
|
||||
.borrow_mut()
|
||||
.insert(host.key.clone(), host);
|
||||
rebuild(&self.state);
|
||||
}
|
||||
|
||||
/// The "+" add-host dialog (name optional / address / port), also reachable from the
|
||||
/// empty state. Reuses the manual-connect plumbing: submit runs the trust gate.
|
||||
pub fn show_add_host(&self) {
|
||||
add_host_dialog(&self.state);
|
||||
}
|
||||
|
||||
/// Re-render both grids (e.g. the library toggle changed in Preferences, which adds/
|
||||
/// removes the saved cards' "Browse library…" menu item).
|
||||
pub fn refresh(&self) {
|
||||
rebuild(&self.state);
|
||||
}
|
||||
|
||||
/// The advertised mgmt port for the host `req` points at, when a matching live advert
|
||||
/// carries the `mgmt` TXT — the library client's port override (default otherwise).
|
||||
pub fn mgmt_port_for(&self, req: &ConnectRequest) -> Option<u16> {
|
||||
let adverts = self.state.adverts.borrow();
|
||||
adverts
|
||||
.values()
|
||||
.find(|a| {
|
||||
req.fp_hex
|
||||
.as_deref()
|
||||
.is_some_and(|fp| !a.fp_hex.is_empty() && a.fp_hex == fp)
|
||||
|| (a.addr == req.addr && a.port == req.port)
|
||||
})
|
||||
.and_then(|a| a.mgmt_port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the grids re-render from, plus the widgets they render into.
|
||||
struct State {
|
||||
stack: gtk::Stack,
|
||||
banner: adw::Banner,
|
||||
saved_heading: gtk::Label,
|
||||
saved_flow: gtk::FlowBox,
|
||||
disc_flow: gtk::FlowBox,
|
||||
searching: gtk::Box,
|
||||
/// Live mDNS adverts, keyed by the advert key — the source for the discovered grid,
|
||||
/// the saved cards' online pips, and dedup.
|
||||
adverts: RefCell<HashMap<String, DiscoveredHost>>,
|
||||
/// `card_key` of the connect currently in flight, if any.
|
||||
connecting: RefCell<Option<String>>,
|
||||
/// App settings — read on every rebuild for the experimental library-item gate.
|
||||
settings: Rc<RefCell<Settings>>,
|
||||
cbs: HostsCallbacks,
|
||||
}
|
||||
|
||||
pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
|
||||
let make_flow = || {
|
||||
gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.activate_on_single_click(true)
|
||||
.homogeneous(true)
|
||||
.min_children_per_line(1)
|
||||
.max_children_per_line(4)
|
||||
.column_spacing(12)
|
||||
.row_spacing(12)
|
||||
.build()
|
||||
};
|
||||
rebuild_saved();
|
||||
let heading = |text: &str| {
|
||||
let l = gtk::Label::new(Some(text));
|
||||
l.add_css_class("heading");
|
||||
l.set_halign(gtk::Align::Start);
|
||||
l
|
||||
};
|
||||
let saved_heading = heading("Saved hosts");
|
||||
let saved_flow = make_flow();
|
||||
let disc_heading = heading("On this network");
|
||||
let disc_flow = make_flow();
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 18);
|
||||
// Shown under the discovered heading while no (unsaved) advert is live yet.
|
||||
let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
let spinner = gtk::Spinner::new();
|
||||
spinner.start();
|
||||
searching.append(&spinner);
|
||||
let searching_label = gtk::Label::new(Some("Searching the LAN…"));
|
||||
searching_label.add_css_class("dim-label");
|
||||
searching.append(&searching_label);
|
||||
searching.set_margin_top(6);
|
||||
searching.set_margin_bottom(6);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
||||
content.set_margin_top(24);
|
||||
content.set_margin_bottom(24);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&saved_label);
|
||||
content.append(&saved_list);
|
||||
let discovered_label = gtk::Label::new(Some("Hosts on this network"));
|
||||
discovered_label.add_css_class("heading");
|
||||
discovered_label.set_halign(gtk::Align::Start);
|
||||
content.append(&discovered_label);
|
||||
content.append(&list);
|
||||
let manual_label = gtk::Label::new(Some("Manual connection"));
|
||||
manual_label.add_css_class("heading");
|
||||
manual_label.set_halign(gtk::Align::Start);
|
||||
content.append(&manual_label);
|
||||
content.append(&manual_list);
|
||||
content.append(&saved_heading);
|
||||
content.append(&saved_flow);
|
||||
content.append(&disc_heading);
|
||||
content.append(&searching);
|
||||
content.append(&disc_flow);
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(560)
|
||||
.maximum_size(1100)
|
||||
.child(&content)
|
||||
.build();
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
@@ -273,21 +184,489 @@ pub fn new(
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
// No saved hosts AND nothing on the LAN → the whole page is the empty state.
|
||||
let empty = adw::StatusPage::builder()
|
||||
.icon_name("network-workgroup-symbolic")
|
||||
.title("No hosts yet")
|
||||
.description("Hosts on your network appear here automatically.\nAdd one by address with +.")
|
||||
.build();
|
||||
let add_btn = gtk::Button::with_label("Add host");
|
||||
add_btn.add_css_class("pill");
|
||||
add_btn.add_css_class("suggested-action");
|
||||
add_btn.set_halign(gtk::Align::Center);
|
||||
add_btn.set_action_name(Some("win.add-host"));
|
||||
empty.set_child(Some(&add_btn));
|
||||
|
||||
let stack = gtk::Stack::new();
|
||||
stack.add_named(&scrolled, Some("grid"));
|
||||
stack.add_named(&empty, Some("empty"));
|
||||
|
||||
// Connect failures land here (launch.rs routes on_failed/on_ended), not in toasts.
|
||||
let banner = adw::Banner::new("");
|
||||
banner.set_button_label(Some("Dismiss"));
|
||||
banner.connect_button_clicked(|b| b.set_revealed(false));
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let settings_btn = gtk::Button::from_icon_name("preferences-system-symbolic");
|
||||
settings_btn.set_tooltip_text(Some("Preferences"));
|
||||
settings_btn.connect_clicked(move |_| on_settings());
|
||||
header.pack_end(&settings_btn);
|
||||
let add_host_btn = gtk::Button::from_icon_name("list-add-symbolic");
|
||||
add_host_btn.set_tooltip_text(Some("Add host"));
|
||||
add_host_btn.set_action_name(Some("win.add-host"));
|
||||
header.pack_start(&add_host_btn);
|
||||
// Primary menu — the actions live on the window (installed in app.rs).
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
||||
menu.append(Some("About Punktfunk"), Some("win.about"));
|
||||
let menu_btn = gtk::MenuButton::builder()
|
||||
.icon_name("open-menu-symbolic")
|
||||
.menu_model(&menu)
|
||||
.primary(true)
|
||||
.tooltip_text("Main menu")
|
||||
.build();
|
||||
header.pack_end(&menu_btn);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&scrolled));
|
||||
toolbar.add_top_bar(&banner);
|
||||
toolbar.set_content(Some(&stack));
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Punktfunk")
|
||||
.tag("hosts")
|
||||
.child(&toolbar)
|
||||
.build();
|
||||
page.connect_shown(move |_| rebuild_saved());
|
||||
page
|
||||
|
||||
let state = Rc::new(State {
|
||||
stack,
|
||||
banner,
|
||||
saved_heading,
|
||||
saved_flow,
|
||||
disc_flow,
|
||||
searching,
|
||||
adverts: RefCell::new(HashMap::new()),
|
||||
connecting: RefCell::new(None),
|
||||
settings,
|
||||
cbs,
|
||||
});
|
||||
rebuild(&state);
|
||||
|
||||
// Rebuilt every time the page is shown, so fresh TOFU/pairing entries appear on return.
|
||||
{
|
||||
let state = state.clone();
|
||||
page.connect_shown(move |_| rebuild(&state));
|
||||
}
|
||||
|
||||
// Stream mDNS adverts into the map; every add/remove re-evaluates both grids (online
|
||||
// pips + dedup included).
|
||||
{
|
||||
let rx = discovery::browse();
|
||||
let weak = Rc::downgrade(&state);
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
let Some(state) = weak.upgrade() else { break };
|
||||
match event {
|
||||
DiscoveryEvent::Resolved(h) => {
|
||||
state.adverts.borrow_mut().insert(h.key.clone(), h);
|
||||
}
|
||||
DiscoveryEvent::Removed { fullname } => {
|
||||
state
|
||||
.adverts
|
||||
.borrow_mut()
|
||||
.retain(|_, a| a.fullname != fullname);
|
||||
}
|
||||
}
|
||||
rebuild(&state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
HostsUi { page, state }
|
||||
}
|
||||
|
||||
/// Re-render both grids from disk + the advert map. Cheap (a handful of widgets) and
|
||||
/// keeps every derived view — online pips, dedup, most-recent accent, spinner — in one
|
||||
/// straight-line pass instead of incremental row surgery.
|
||||
fn rebuild(state: &Rc<State>) {
|
||||
let known = KnownHosts::load();
|
||||
let adverts = state.adverts.borrow();
|
||||
let connecting = state.connecting.borrow().clone();
|
||||
|
||||
// A saved host is ONLINE iff a live advert matches it (fingerprint, or address when
|
||||
// the advert carries no fp) — same rule the Apple client uses.
|
||||
let matches = |k: &KnownHost, a: &DiscoveredHost| {
|
||||
(!a.fp_hex.is_empty() && a.fp_hex == k.fp_hex) || (a.addr == k.addr && a.port == k.port)
|
||||
};
|
||||
let most_recent = known
|
||||
.hosts
|
||||
.iter()
|
||||
.filter_map(|h| h.last_used.map(|t| (h.fp_hex.clone(), t)))
|
||||
.max_by_key(|&(_, t)| t)
|
||||
.map(|(fp, _)| fp);
|
||||
|
||||
state.saved_flow.remove_all();
|
||||
for k in &known.hosts {
|
||||
let online = adverts.values().any(|a| matches(k, a));
|
||||
let recent = most_recent.as_deref() == Some(k.fp_hex.as_str());
|
||||
state
|
||||
.saved_flow
|
||||
.append(&saved_card(state, k, online, recent, connecting.as_deref()));
|
||||
}
|
||||
|
||||
// The discovered grid only surfaces genuinely-new hosts: anything matching a saved
|
||||
// entry renders as that saved card (with its pip now green) instead.
|
||||
let mut fresh: Vec<&DiscoveredHost> = adverts
|
||||
.values()
|
||||
.filter(|a| !known.hosts.iter().any(|k| matches(k, a)))
|
||||
.collect();
|
||||
fresh.sort_by(|a, b| a.name.cmp(&b.name).then(a.key.cmp(&b.key)));
|
||||
state.disc_flow.remove_all();
|
||||
for a in &fresh {
|
||||
state
|
||||
.disc_flow
|
||||
.append(&discovered_card(state, a, connecting.as_deref()));
|
||||
}
|
||||
|
||||
let have_saved = !known.hosts.is_empty();
|
||||
let have_disc = !fresh.is_empty();
|
||||
state.saved_heading.set_visible(have_saved);
|
||||
state.saved_flow.set_visible(have_saved);
|
||||
state.disc_flow.set_visible(have_disc);
|
||||
state.searching.set_visible(!have_disc);
|
||||
state
|
||||
.stack
|
||||
.set_visible_child_name(if have_saved || have_disc {
|
||||
"grid"
|
||||
} else {
|
||||
"empty"
|
||||
});
|
||||
}
|
||||
|
||||
/// The shared card scaffold: avatar (or a spinner while connecting) over name over
|
||||
/// `addr:port` over a status row, in a `.card` overlay (the overlay hosts the saved
|
||||
/// card's corner menu). Returned as the FlowBox child so callers wire activation on it.
|
||||
fn card_scaffold(
|
||||
name: &str,
|
||||
addr_line: &str,
|
||||
status_row: >k::Box,
|
||||
connecting: bool,
|
||||
) -> (gtk::FlowBoxChild, gtk::Overlay) {
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
if connecting {
|
||||
let spinner = gtk::Spinner::new();
|
||||
spinner.set_size_request(48, 48);
|
||||
spinner.start();
|
||||
spinner.set_halign(gtk::Align::Center);
|
||||
content.append(&spinner);
|
||||
} else {
|
||||
let avatar = adw::Avatar::new(48, Some(name), true);
|
||||
avatar.set_halign(gtk::Align::Center);
|
||||
content.append(&avatar);
|
||||
}
|
||||
let name_label = gtk::Label::new(Some(name));
|
||||
name_label.add_css_class("heading");
|
||||
name_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle);
|
||||
content.append(&name_label);
|
||||
let addr_label = gtk::Label::new(Some(addr_line));
|
||||
addr_label.add_css_class("caption");
|
||||
addr_label.add_css_class("dim-label");
|
||||
addr_label.add_css_class("numeric");
|
||||
addr_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle);
|
||||
content.append(&addr_label);
|
||||
status_row.set_halign(gtk::Align::Center);
|
||||
status_row.set_margin_top(4);
|
||||
content.append(status_row);
|
||||
|
||||
let overlay = gtk::Overlay::new();
|
||||
overlay.set_child(Some(&content));
|
||||
overlay.add_css_class("card");
|
||||
overlay.add_css_class("pf-host-card");
|
||||
|
||||
let child = gtk::FlowBoxChild::new();
|
||||
child.set_child(Some(&overlay));
|
||||
if connecting {
|
||||
child.set_sensitive(false);
|
||||
}
|
||||
(child, overlay)
|
||||
}
|
||||
|
||||
/// A small rounded status chip (`.pf-pill` + a colour variant class).
|
||||
fn pill(text: &str, class: &str) -> gtk::Label {
|
||||
let l = gtk::Label::new(Some(text));
|
||||
l.add_css_class("pf-pill");
|
||||
l.add_css_class(class);
|
||||
l
|
||||
}
|
||||
|
||||
fn saved_card(
|
||||
state: &Rc<State>,
|
||||
k: &KnownHost,
|
||||
online: bool,
|
||||
recent: bool,
|
||||
connecting: Option<&str>,
|
||||
) -> gtk::FlowBoxChild {
|
||||
let req = ConnectRequest {
|
||||
name: k.name.clone(),
|
||||
addr: k.addr.clone(),
|
||||
port: k.port,
|
||||
fp_hex: Some(k.fp_hex.clone()),
|
||||
// Saved host: its fp is already pinned, so this routes to a silent pinned
|
||||
// connect; TOFU eligibility is irrelevant.
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
};
|
||||
|
||||
// Presence pip + spelled-out state, then the trust pill.
|
||||
let status = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
let pip = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
pip.add_css_class("pf-pip");
|
||||
if online {
|
||||
pip.add_css_class("pf-online");
|
||||
}
|
||||
pip.set_valign(gtk::Align::Center);
|
||||
status.append(&pip);
|
||||
let presence = gtk::Label::new(Some(if online { "Online" } else { "Offline" }));
|
||||
presence.add_css_class("caption");
|
||||
presence.add_css_class("dim-label");
|
||||
status.append(&presence);
|
||||
status.append(&if k.paired {
|
||||
pill("Paired", "pf-green")
|
||||
} else {
|
||||
pill("Trusted", "pf-accent")
|
||||
});
|
||||
|
||||
let (child, overlay) = card_scaffold(
|
||||
&k.name,
|
||||
&format!("{}:{}", k.addr, k.port),
|
||||
&status,
|
||||
connecting == Some(k.fp_hex.as_str()),
|
||||
);
|
||||
if recent {
|
||||
overlay.add_css_class("pf-recent");
|
||||
}
|
||||
|
||||
// Overflow menu (top-right; also on right-click): pair / speed test / rename / forget.
|
||||
let actions = gio::SimpleActionGroup::new();
|
||||
let add = |name: &str, f: Box<dyn Fn()>| {
|
||||
let a = gio::SimpleAction::new(name, None);
|
||||
a.connect_activate(move |_, _| f());
|
||||
actions.add_action(&a);
|
||||
};
|
||||
{
|
||||
let cb = state.cbs.on_pair.clone();
|
||||
let req = req.clone();
|
||||
add("pair", Box::new(move || cb(req.clone())));
|
||||
}
|
||||
{
|
||||
let cb = state.cbs.on_speed_test.clone();
|
||||
let req = req.clone();
|
||||
add("speed", Box::new(move || cb(req.clone())));
|
||||
}
|
||||
{
|
||||
let cb = state.cbs.on_library.clone();
|
||||
let req = req.clone();
|
||||
add("library", Box::new(move || cb(req.clone())));
|
||||
}
|
||||
{
|
||||
let state = state.clone();
|
||||
let fp = k.fp_hex.clone();
|
||||
let name = k.name.clone();
|
||||
add(
|
||||
"rename",
|
||||
Box::new(move || rename_dialog(&state, &fp, &name)),
|
||||
);
|
||||
}
|
||||
{
|
||||
let state = state.clone();
|
||||
let fp = k.fp_hex.clone();
|
||||
let name = k.name.clone();
|
||||
add(
|
||||
"forget",
|
||||
Box::new(move || forget_dialog(&state, &fp, &name)),
|
||||
);
|
||||
}
|
||||
overlay.insert_action_group("card", Some(&actions));
|
||||
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Pair with PIN…"), Some("card.pair"));
|
||||
menu.append(Some("Test network speed…"), Some("card.speed"));
|
||||
// Experimental (Preferences gate, Apple parity): browse the host's game library. The
|
||||
// item is offered on every saved card — an unpaired host answers with the friendly
|
||||
// "not paired" error state rather than the entry hiding itself.
|
||||
if state.settings.borrow().library_enabled {
|
||||
menu.append(Some("Browse library…"), Some("card.library"));
|
||||
}
|
||||
menu.append(Some("Rename…"), Some("card.rename"));
|
||||
menu.append(Some("Forget"), Some("card.forget"));
|
||||
let menu_btn = gtk::MenuButton::builder()
|
||||
.icon_name("view-more-symbolic")
|
||||
.menu_model(&menu)
|
||||
.halign(gtk::Align::End)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
menu_btn.add_css_class("flat");
|
||||
overlay.add_overlay(&menu_btn);
|
||||
let right_click = gtk::GestureClick::builder().button(3).build();
|
||||
{
|
||||
let menu_btn = menu_btn.clone();
|
||||
right_click.connect_pressed(move |_, _, _, _| menu_btn.popup());
|
||||
}
|
||||
overlay.add_controller(right_click);
|
||||
|
||||
let on_connect = state.cbs.on_connect.clone();
|
||||
child.connect_activate(move |_| on_connect(req.clone()));
|
||||
child
|
||||
}
|
||||
|
||||
fn discovered_card(
|
||||
state: &Rc<State>,
|
||||
a: &DiscoveredHost,
|
||||
connecting: Option<&str>,
|
||||
) -> gtk::FlowBoxChild {
|
||||
let req = ConnectRequest {
|
||||
name: a.name.clone(),
|
||||
addr: a.addr.clone(),
|
||||
port: a.port,
|
||||
fp_hex: (!a.fp_hex.is_empty()).then(|| a.fp_hex.clone()),
|
||||
// TOFU is offered only when the host explicitly opts in with pair=optional;
|
||||
// required/empty means mandatory PIN.
|
||||
pair_optional: a.pair == "optional",
|
||||
launch: None,
|
||||
};
|
||||
|
||||
let status = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
status.append(&if req.pair_optional {
|
||||
pill("Open", "pf-neutral")
|
||||
} else {
|
||||
pill("PIN", "pf-accent")
|
||||
});
|
||||
|
||||
let is_connecting = connecting == Some(req.card_key().as_str());
|
||||
let (child, overlay) = card_scaffold(
|
||||
&a.name,
|
||||
&format!("{}:{}", a.addr, a.port),
|
||||
&status,
|
||||
is_connecting,
|
||||
);
|
||||
overlay.add_css_class("pf-discovered");
|
||||
|
||||
// Tap-to-connect only (parity with Android's discovered cards).
|
||||
let on_connect = state.cbs.on_connect.clone();
|
||||
child.connect_activate(move |_| on_connect(req.clone()));
|
||||
child
|
||||
}
|
||||
|
||||
/// Rename a saved host — an entry in an alert, then upsert + refresh.
|
||||
fn rename_dialog(state: &Rc<State>, fp_hex: &str, current: &str) {
|
||||
let entry = gtk::Entry::builder()
|
||||
.text(current)
|
||||
.activates_default(true)
|
||||
.build();
|
||||
let dialog = adw::AlertDialog::new(Some("Rename Host"), None);
|
||||
dialog.set_extra_child(Some(&entry));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("rename", "Rename")]);
|
||||
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("rename"));
|
||||
dialog.set_close_response("cancel");
|
||||
{
|
||||
let state = state.clone();
|
||||
let fp = fp_hex.to_string();
|
||||
dialog.connect_response(Some("rename"), move |_, _| {
|
||||
let name = entry.text().trim().to_string();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||
h.name = name;
|
||||
let _ = known.save();
|
||||
}
|
||||
rebuild(&state);
|
||||
});
|
||||
}
|
||||
dialog.present(Some(&state.stack));
|
||||
}
|
||||
|
||||
/// Forget this host (drops the pinned fingerprint — a later connect re-pairs).
|
||||
/// Confirmed first, since it's destructive and a misclick on the Deck is easy.
|
||||
fn forget_dialog(state: &Rc<State>, fp_hex: &str, name: &str) {
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Remove saved host?"),
|
||||
Some(&format!(
|
||||
"Forget “{name}”? You'll need to pair (or trust) it again to reconnect."
|
||||
)),
|
||||
);
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("remove", "Remove")]);
|
||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
{
|
||||
let state = state.clone();
|
||||
let fp = fp_hex.to_string();
|
||||
dialog.connect_response(Some("remove"), move |_, _| {
|
||||
let mut known = KnownHosts::load();
|
||||
known.remove_by_fp(&fp);
|
||||
let _ = known.save();
|
||||
rebuild(&state);
|
||||
});
|
||||
}
|
||||
dialog.present(Some(&state.stack));
|
||||
}
|
||||
|
||||
/// "+": name (optional) / address / port — the Apple AddHostSheet / Android dialog
|
||||
/// equivalent of the old inline entry. Submit runs the normal trust gate (`on_connect`).
|
||||
fn add_host_dialog(state: &Rc<State>) {
|
||||
let list = gtk::ListBox::new();
|
||||
list.add_css_class("boxed-list");
|
||||
list.set_selection_mode(gtk::SelectionMode::None);
|
||||
let name_row = adw::EntryRow::builder().title("Name (optional)").build();
|
||||
let addr_row = adw::EntryRow::builder().title("Address").build();
|
||||
let port_row = adw::EntryRow::builder().title("Port").text("9777").build();
|
||||
list.append(&name_row);
|
||||
list.append(&addr_row);
|
||||
list.append(&port_row);
|
||||
list.set_size_request(320, -1);
|
||||
|
||||
let dialog = adw::AlertDialog::new(Some("Add Host"), None);
|
||||
dialog.set_extra_child(Some(&list));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("connect", "Connect")]);
|
||||
dialog.set_response_appearance("connect", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("connect"));
|
||||
dialog.set_close_response("cancel");
|
||||
dialog.set_response_enabled("connect", false);
|
||||
{
|
||||
let dialog = dialog.clone();
|
||||
addr_row.connect_changed(move |row| {
|
||||
dialog.set_response_enabled("connect", !row.text().trim().is_empty());
|
||||
});
|
||||
}
|
||||
{
|
||||
let on_connect = state.cbs.on_connect.clone();
|
||||
let (name_row, addr_row, port_row) = (name_row.clone(), addr_row.clone(), port_row.clone());
|
||||
dialog.connect_response(Some("connect"), move |_, _| {
|
||||
let text = addr_row.text().trim().to_string();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
// A pasted `host:port` wins over the port field; otherwise the field (default 9777).
|
||||
let (addr, port) = match text.rsplit_once(':') {
|
||||
Some((a, p)) if p.parse::<u16>().is_ok() => {
|
||||
(a.to_string(), p.parse::<u16>().unwrap())
|
||||
}
|
||||
_ => (
|
||||
text.clone(),
|
||||
port_row.text().trim().parse::<u16>().unwrap_or(9777),
|
||||
),
|
||||
};
|
||||
let name = name_row.text().trim().to_string();
|
||||
on_connect(ConnectRequest {
|
||||
name: if name.is_empty() { addr.clone() } else { name },
|
||||
addr,
|
||||
port,
|
||||
fp_hex: None,
|
||||
// Manual entry carries no advertised policy — never eligible for TOFU.
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
});
|
||||
});
|
||||
}
|
||||
dialog.present(Some(&state.stack));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! The game-library page (the Apple `LibraryView` ported): a poster grid of the host's
|
||||
//! unified library fetched over the management API (`library.rs`), pushed onto the nav
|
||||
//! stack from a saved card's "Browse library…" action. Poster art loads asynchronously
|
||||
//! (worker threads → texture on the main loop) with a monogram placeholder, and tapping
|
||||
//! a title starts a session that asks the host to launch it (the library id rides the
|
||||
//! Hello via `ConnectRequest::launch`).
|
||||
|
||||
use crate::app::App;
|
||||
use crate::library::{self, GameEntry};
|
||||
use crate::trust;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use adw::prelude::*;
|
||||
use gtk::{gdk, glib};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a
|
||||
/// big library into a connection burst.
|
||||
const ART_WORKERS: usize = 3;
|
||||
|
||||
/// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/
|
||||
/// card activation); dropped when the page is popped, which also winds down any in-flight
|
||||
/// art consumer (its weak upgrade fails).
|
||||
struct State {
|
||||
app: Rc<App>,
|
||||
/// The host this library belongs to — cards clone it and add `launch`.
|
||||
req: ConnectRequest,
|
||||
stack: gtk::Stack,
|
||||
flow: gtk::FlowBox,
|
||||
error_page: adw::StatusPage,
|
||||
/// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching.
|
||||
art: RefCell<HashMap<String, gdk::Texture>>,
|
||||
/// The Picture each entry currently renders into (rebuilt per render), so async art
|
||||
/// results land on the right card.
|
||||
pics: RefCell<HashMap<String, gtk::Picture>>,
|
||||
/// Screenshot mode: render injected entries only, never touch the network.
|
||||
mock: Cell<bool>,
|
||||
}
|
||||
|
||||
/// Open the library page for a saved host and start the fetch.
|
||||
pub fn open(app: Rc<App>, req: ConnectRequest) {
|
||||
let state = build(app.clone(), req);
|
||||
load(&state);
|
||||
}
|
||||
|
||||
/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by
|
||||
/// entry id) with no host and no network — the CI `library` scene.
|
||||
pub fn open_mock(
|
||||
app: Rc<App>,
|
||||
req: ConnectRequest,
|
||||
games: Vec<GameEntry>,
|
||||
art: Vec<(String, gdk::Texture)>,
|
||||
) {
|
||||
let state = build(app.clone(), req);
|
||||
state.mock.set(true);
|
||||
state.art.borrow_mut().extend(art);
|
||||
if games.is_empty() {
|
||||
state.stack.set_visible_child_name("empty");
|
||||
} else {
|
||||
render(&state, &games);
|
||||
state.stack.set_visible_child_name("grid");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the page (loading / error / empty / grid states in a stack) and push it.
|
||||
fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
||||
let flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.activate_on_single_click(true)
|
||||
.homogeneous(true)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(6)
|
||||
.column_spacing(12)
|
||||
.row_spacing(18)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
content.set_margin_top(24);
|
||||
content.set_margin_bottom(24);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&flow);
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(1100)
|
||||
.child(&content)
|
||||
.build();
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
let loading = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
||||
loading.set_valign(gtk::Align::Center);
|
||||
let spinner = gtk::Spinner::new();
|
||||
spinner.set_size_request(32, 32);
|
||||
spinner.start();
|
||||
spinner.set_halign(gtk::Align::Center);
|
||||
loading.append(&spinner);
|
||||
let loading_label = gtk::Label::new(Some("Loading library…"));
|
||||
loading_label.add_css_class("dim-label");
|
||||
loading.append(&loading_label);
|
||||
|
||||
let error_page = adw::StatusPage::builder()
|
||||
.icon_name("dialog-error-symbolic")
|
||||
.title("Couldn't load the library")
|
||||
.build();
|
||||
let retry = gtk::Button::with_label("Retry");
|
||||
retry.add_css_class("pill");
|
||||
retry.add_css_class("suggested-action");
|
||||
retry.set_halign(gtk::Align::Center);
|
||||
error_page.set_child(Some(&retry));
|
||||
|
||||
let empty = adw::StatusPage::builder()
|
||||
.icon_name("applications-games-symbolic")
|
||||
.title("No games found")
|
||||
.description(
|
||||
"No games found on this host. Install Steam titles or add custom \
|
||||
entries in the host's web console.",
|
||||
)
|
||||
.build();
|
||||
|
||||
let stack = gtk::Stack::new();
|
||||
stack.add_named(&loading, Some("loading"));
|
||||
stack.add_named(&error_page, Some("error"));
|
||||
stack.add_named(&empty, Some("empty"));
|
||||
stack.add_named(&scrolled, Some("grid"));
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let reload = gtk::Button::from_icon_name("view-refresh-symbolic");
|
||||
reload.set_tooltip_text(Some("Reload"));
|
||||
header.pack_end(&reload);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&stack));
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title(format!("{} — Library", req.name))
|
||||
.child(&toolbar)
|
||||
.build();
|
||||
|
||||
let state = Rc::new(State {
|
||||
app: app.clone(),
|
||||
req,
|
||||
stack,
|
||||
flow,
|
||||
error_page,
|
||||
art: RefCell::new(HashMap::new()),
|
||||
pics: RefCell::new(HashMap::new()),
|
||||
mock: Cell::new(false),
|
||||
});
|
||||
{
|
||||
let state = state.clone();
|
||||
reload.connect_clicked(move |_| load(&state));
|
||||
}
|
||||
{
|
||||
let state = state.clone();
|
||||
retry.connect_clicked(move |_| load(&state));
|
||||
}
|
||||
app.nav.push(&page);
|
||||
state
|
||||
}
|
||||
|
||||
/// The mgmt port for this host: the live mDNS `mgmt` TXT when the host is advertising,
|
||||
/// else the well-known default (Apple's `effectiveMgmtPort`).
|
||||
fn mgmt_port(state: &State) -> u16 {
|
||||
state
|
||||
.app
|
||||
.hosts_ui()
|
||||
.and_then(|h| h.mgmt_port_for(&state.req))
|
||||
.unwrap_or(library::DEFAULT_MGMT_PORT)
|
||||
}
|
||||
|
||||
/// Fetch the library off the main thread and route the result into the grid or the
|
||||
/// error/empty states.
|
||||
fn load(state: &Rc<State>) {
|
||||
if state.mock.get() {
|
||||
return; // screenshot scene renders injected entries only
|
||||
}
|
||||
state.stack.set_visible_child_name("loading");
|
||||
let port = mgmt_port(state);
|
||||
let addr = state.req.addr.clone();
|
||||
let identity = state.app.identity.clone();
|
||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-library".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send_blocking(library::fetch_games(&addr, port, &identity, pin));
|
||||
})
|
||||
.expect("spawn library thread");
|
||||
let weak = Rc::downgrade(state);
|
||||
glib::spawn_future_local(async move {
|
||||
let Ok(result) = rx.recv().await else { return };
|
||||
let Some(state) = weak.upgrade() else { return };
|
||||
match result {
|
||||
Ok(games) if games.is_empty() => state.stack.set_visible_child_name("empty"),
|
||||
Ok(games) => {
|
||||
render(&state, &games);
|
||||
state.stack.set_visible_child_name("grid");
|
||||
load_art(&state, &games);
|
||||
}
|
||||
Err(e) => {
|
||||
state.error_page.set_description(Some(&e.to_string()));
|
||||
state.stack.set_visible_child_name("error");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// (Re)build the poster grid from one library snapshot. Cached textures apply
|
||||
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
|
||||
fn render(state: &Rc<State>, games: &[GameEntry]) {
|
||||
state.flow.remove_all();
|
||||
state.pics.borrow_mut().clear();
|
||||
for game in games {
|
||||
state.flow.append(&game_card(state, game));
|
||||
}
|
||||
}
|
||||
|
||||
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
|
||||
/// monogram placeholder underneath the async art. Activation starts a session launching
|
||||
/// this title (silent on a pinned host — the normal trust gate applies).
|
||||
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
let monogram = gtk::Label::new(Some(&initials(&game.title)));
|
||||
monogram.add_css_class("pf-poster-monogram");
|
||||
monogram.set_halign(gtk::Align::Center);
|
||||
monogram.set_valign(gtk::Align::Center);
|
||||
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
placeholder.append(&monogram);
|
||||
monogram.set_vexpand(true);
|
||||
|
||||
let pic = gtk::Picture::new();
|
||||
pic.set_content_fit(gtk::ContentFit::Cover);
|
||||
if let Some(tex) = state.art.borrow().get(&game.id) {
|
||||
pic.set_paintable(Some(tex));
|
||||
}
|
||||
state.pics.borrow_mut().insert(game.id.clone(), pic.clone());
|
||||
|
||||
let badge = gtk::Label::new(Some(store_label(&game.store)));
|
||||
badge.add_css_class("pf-pill");
|
||||
badge.add_css_class("pf-store-badge");
|
||||
badge.set_halign(gtk::Align::Start);
|
||||
badge.set_valign(gtk::Align::Start);
|
||||
badge.set_margin_start(6);
|
||||
badge.set_margin_top(6);
|
||||
|
||||
let poster = gtk::Overlay::new();
|
||||
poster.set_child(Some(&placeholder));
|
||||
poster.add_overlay(&pic);
|
||||
poster.add_overlay(&badge);
|
||||
poster.add_css_class("pf-poster");
|
||||
poster.set_overflow(gtk::Overflow::Hidden);
|
||||
poster.set_size_request(150, 225);
|
||||
poster.set_halign(gtk::Align::Center);
|
||||
|
||||
let title = gtk::Label::new(Some(&game.title));
|
||||
title.add_css_class("caption");
|
||||
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
||||
title.set_max_width_chars(16);
|
||||
title.set_tooltip_text(Some(&game.title));
|
||||
|
||||
let card = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
card.append(&poster);
|
||||
card.append(&title);
|
||||
|
||||
let child = gtk::FlowBoxChild::new();
|
||||
child.set_child(Some(&card));
|
||||
let app = state.app.clone();
|
||||
let mut req = state.req.clone();
|
||||
req.launch = Some((game.id.clone(), game.title.clone()));
|
||||
child.connect_activate(move |_| crate::ui_trust::initiate_connect(app.clone(), req.clone()));
|
||||
child
|
||||
}
|
||||
|
||||
/// Fetch poster art for every uncached entry on a small worker pool, walking each
|
||||
/// entry's candidates in the Apple fallback order (portrait → header → hero) and
|
||||
/// texturing the first that loads on the main loop.
|
||||
fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
||||
let port = mgmt_port(state);
|
||||
let base = library::base_url(&state.req.addr, port);
|
||||
let jobs: VecDeque<(String, Vec<String>)> = {
|
||||
let cache = state.art.borrow();
|
||||
games
|
||||
.iter()
|
||||
.filter(|g| !cache.contains_key(&g.id))
|
||||
.map(|g| (g.id.clone(), g.art.poster_candidates(&base)))
|
||||
.filter(|(_, candidates)| !candidates.is_empty())
|
||||
.collect()
|
||||
};
|
||||
if jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let identity = state.app.identity.clone();
|
||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let queue = Arc::new(Mutex::new(jobs));
|
||||
let (tx, rx) = async_channel::unbounded::<(String, Vec<u8>)>();
|
||||
for _ in 0..ART_WORKERS {
|
||||
let queue = queue.clone();
|
||||
let tx = tx.clone();
|
||||
let base = base.clone();
|
||||
let identity = identity.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-lib-art".into())
|
||||
.spawn(move || {
|
||||
let Ok(agent) = library::agent(&identity, pin) else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
let job = queue.lock().unwrap().pop_front();
|
||||
let Some((id, candidates)) = job else { break };
|
||||
for url in &candidates {
|
||||
match library::fetch_art(&agent, &base, url) {
|
||||
Ok(bytes) => {
|
||||
// Receiver gone (page popped) — stop fetching.
|
||||
if tx.send_blocking((id, bytes)).is_err() {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// 404 on a guessed CDN path is routine — try the next kind.
|
||||
Err(e) => tracing::debug!(%id, url, error = %e, "poster miss"),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn art thread");
|
||||
}
|
||||
let weak = Rc::downgrade(state);
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok((id, bytes)) = rx.recv().await {
|
||||
let Some(state) = weak.upgrade() else { break };
|
||||
// Texture decode happens here on the main loop — posters are small (tens of
|
||||
// KB), and `from_bytes` handles jpeg/png alike.
|
||||
match gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) {
|
||||
Ok(tex) => {
|
||||
if let Some(pic) = state.pics.borrow().get(&id) {
|
||||
pic.set_paintable(Some(&tex));
|
||||
}
|
||||
state.art.borrow_mut().insert(id, tex);
|
||||
}
|
||||
Err(e) => tracing::debug!(%id, error = %e, "undecodable poster"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The store badge text — `store` comes from the entry (today `steam`/`custom`; future
|
||||
/// stores per the host's provider list), with the id prefix as a fallback spelling.
|
||||
fn store_label(store: &str) -> &'static str {
|
||||
match store {
|
||||
"steam" => "Steam",
|
||||
"custom" => "Custom",
|
||||
"heroic" => "Heroic",
|
||||
"lutris" => "Lutris",
|
||||
"epic" => "Epic",
|
||||
"gog" => "GOG",
|
||||
"xbox" => "Xbox",
|
||||
_ => "Game",
|
||||
}
|
||||
}
|
||||
|
||||
/// Monogram for the placeholder tile: the first letters of the first two words.
|
||||
fn initials(title: &str) -> String {
|
||||
title
|
||||
.split_whitespace()
|
||||
.take(2)
|
||||
.filter_map(|w| w.chars().next())
|
||||
.flat_map(char::to_uppercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn initials_take_two_words() {
|
||||
assert_eq!(initials("Dota 2"), "D2");
|
||||
assert_eq!(initials("half-life"), "H");
|
||||
assert_eq!(initials("The Witness III"), "TW");
|
||||
assert_eq!(initials(""), "");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]
|
||||
/// Codec setting values (persisted) paired with their display labels below.
|
||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||||
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
|
||||
const DECODERS: &[&str] = &["auto", "vaapi", "software"];
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||
const APP_LICENSE: &str = concat!(
|
||||
@@ -34,8 +35,9 @@ const APP_LICENSE: &str = concat!(
|
||||
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog).
|
||||
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||||
|
||||
/// Show the About dialog (app license + the third-party-software Legal section).
|
||||
fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||
/// Show the About dialog (app license + the third-party-software Legal section) — reached
|
||||
/// from the primary menu (app.rs `win.about`).
|
||||
pub fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||
let about = adw::AboutDialog::builder()
|
||||
.application_name("punktfunk")
|
||||
.developer_name("unom")
|
||||
@@ -65,10 +67,13 @@ fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||
about.present(Some(parent));
|
||||
}
|
||||
|
||||
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
|
||||
/// there so the experimental library toggle takes effect without a nav round-trip).
|
||||
pub fn show(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
settings: Rc<RefCell<Settings>>,
|
||||
gamepads: &crate::gamepad::GamepadService,
|
||||
on_closed: impl Fn() + 'static,
|
||||
) {
|
||||
let page = adw::PreferencesPage::new();
|
||||
|
||||
@@ -120,10 +125,25 @@ pub fn show(
|
||||
"gamescope",
|
||||
]))
|
||||
.build();
|
||||
let decoder_row = adw::ComboRow::builder()
|
||||
.title("Video decoder")
|
||||
.subtitle("Automatic tries VAAPI hardware decode, then software")
|
||||
.model(>k::StringList::new(&[
|
||||
"Automatic (VAAPI → software)",
|
||||
"Hardware (VAAPI)",
|
||||
"Software",
|
||||
]))
|
||||
.build();
|
||||
let stats_row = adw::SwitchRow::builder()
|
||||
.title("Show statistics overlay")
|
||||
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live")
|
||||
.build();
|
||||
stream.add(&res_row);
|
||||
stream.add(&hz_row);
|
||||
stream.add(&bitrate_row);
|
||||
stream.add(&compositor_row);
|
||||
stream.add(&decoder_row);
|
||||
stream.add(&stats_row);
|
||||
|
||||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||
// Which physical controller forwards as pad 0: automatic = the most recently
|
||||
@@ -208,23 +228,24 @@ pub fn show(
|
||||
.build();
|
||||
audio.add(&mic_row);
|
||||
|
||||
let about = adw::PreferencesGroup::builder().title("About").build();
|
||||
let licenses_row = adw::ActionRow::builder()
|
||||
.title("Third-party licenses")
|
||||
.subtitle("Open-source software used by punktfunk")
|
||||
.activatable(true)
|
||||
// Experimental — mirrors the Apple client's Experimental section (wording included).
|
||||
let experimental = adw::PreferencesGroup::builder()
|
||||
.title("Experimental")
|
||||
.build();
|
||||
licenses_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
{
|
||||
let about_parent: gtk::Widget = parent.clone().upcast();
|
||||
licenses_row.connect_activated(move |_| show_about(&about_parent));
|
||||
}
|
||||
about.add(&licenses_row);
|
||||
let library_row = adw::SwitchRow::builder()
|
||||
.title("Show game library")
|
||||
.subtitle(
|
||||
"Adds a “Browse library…” action to each saved host that lists its games \
|
||||
(Steam + custom) via the host's management API — works once you've paired",
|
||||
)
|
||||
.build();
|
||||
experimental.add(&library_row);
|
||||
|
||||
// About (with the license/third-party Legal pages) lives in the primary menu now.
|
||||
page.add(&stream);
|
||||
page.add(&input);
|
||||
page.add(&audio);
|
||||
page.add(&about);
|
||||
page.add(&experimental);
|
||||
|
||||
// Seed from the current settings.
|
||||
{
|
||||
@@ -244,8 +265,12 @@ pub fn show(
|
||||
.position(|&c| c == s.compositor)
|
||||
.unwrap_or(0);
|
||||
compositor_row.set_selected(comp_i as u32);
|
||||
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
|
||||
decoder_row.set_selected(dec_i as u32);
|
||||
stats_row.set_active(s.show_stats);
|
||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||
mic_row.set_active(s.mic_enabled);
|
||||
library_row.set_active(s.library_enabled);
|
||||
surround_row.set_selected(match s.audio_channels {
|
||||
6 => 1,
|
||||
8 => 2,
|
||||
@@ -267,6 +292,8 @@ pub fn show(
|
||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||
.to_string();
|
||||
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
||||
s.show_stats = stats_row.is_active();
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.audio_channels = match surround_row.selected() {
|
||||
@@ -275,7 +302,10 @@ pub fn show(
|
||||
_ => 2,
|
||||
};
|
||||
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
|
||||
s.library_enabled = library_row.is_active();
|
||||
s.save();
|
||||
drop(s);
|
||||
on_closed();
|
||||
});
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
+469
-267
@@ -16,7 +16,7 @@
|
||||
|
||||
use crate::keymap;
|
||||
use crate::session::Stats;
|
||||
use crate::video::DecodedFrame;
|
||||
use crate::video::{DecodedFrame, DecodedImage};
|
||||
use adw::prelude::*;
|
||||
use gtk::{gdk, glib};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
@@ -26,21 +26,55 @@ use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct StreamPage {
|
||||
pub page: adw::NavigationPage,
|
||||
stats_label: gtk::Label,
|
||||
/// Median capture→paintable-set latency (ms) over the frame consumer's last 1 s
|
||||
/// window — written there, folded into the OSD on each `Stats` event.
|
||||
present_ms: Rc<Cell<f32>>,
|
||||
}
|
||||
|
||||
impl StreamPage {
|
||||
pub fn update_stats(&self, s: Stats) {
|
||||
self.stats_label.set_text(&format!(
|
||||
"{:.0} fps · {:.1} Mbit/s · dec {:.1} ms · lat {:.1} ms",
|
||||
s.fps, s.mbps, s.decode_ms, s.latency_ms
|
||||
));
|
||||
let mut line = format!(
|
||||
"{:.0} fps · {:.1} Mbit/s · dec {:.1} ms · lat {:.1} ms · present {:.1} ms",
|
||||
s.fps,
|
||||
s.mbps,
|
||||
s.decode_ms,
|
||||
s.latency_ms,
|
||||
self.present_ms.get()
|
||||
);
|
||||
// Which decoder actually ran this window (vaapi/software) — tracks a fallback.
|
||||
if !s.decoder.is_empty() {
|
||||
line.push_str(" · ");
|
||||
line.push_str(s.decoder);
|
||||
}
|
||||
self.stats_label.set_text(&line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the stream page needs from the app + session that own it.
|
||||
pub struct StreamPageArgs {
|
||||
pub window: adw::ApplicationWindow,
|
||||
pub connector: Arc<NativeClient>,
|
||||
pub frames: async_channel::Receiver<DecodedFrame>,
|
||||
/// Host-clock offset from the session's clock handshake — added to the local wall
|
||||
/// clock to express paintable-set time in the host's capture clock (present latency).
|
||||
pub clock_offset_ns: i64,
|
||||
/// Controller escape chord — leave fullscreen + release capture.
|
||||
pub escape_rx: async_channel::Receiver<()>,
|
||||
/// Escape chord held past the hold threshold — end the session.
|
||||
pub disconnect_rx: async_channel::Receiver<()>,
|
||||
pub stop: Arc<AtomicBool>,
|
||||
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
|
||||
pub inhibit_shortcuts: bool,
|
||||
/// Show the stats OSD initially (Settings); Ctrl+Alt+Shift+S toggles it live.
|
||||
pub show_stats: bool,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||
let _ = connector.send_input(&InputEvent {
|
||||
kind,
|
||||
@@ -77,12 +111,26 @@ struct Capture {
|
||||
hint: gtk::Label,
|
||||
inhibit_shortcuts: bool,
|
||||
captured: Cell<bool>,
|
||||
/// Newest absolute pointer position not yet on the wire. Motion events only store
|
||||
/// here; a frame-clock tick flushes at most one `MouseMoveAbs` per tick (a 1000 Hz
|
||||
/// mouse would otherwise send a datagram — and take the connector's mode lock — per
|
||||
/// event). Button/scroll/key sends flush it first so they land at the latest
|
||||
/// position. This client has no relative-motion capture to coalesce — absolute only
|
||||
/// (pointer-lock is the stage-2 presenter's job).
|
||||
pending_abs: Cell<Option<(f64, f64)>>,
|
||||
/// VKs / GameStream button ids currently held — flushed up on release.
|
||||
held_keys: RefCell<HashSet<u8>>,
|
||||
held_buttons: RefCell<HashSet<u32>>,
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
/// Send the coalesced pointer position, if any — one datagram, one fresh mode read.
|
||||
fn flush_pending_motion(&self) {
|
||||
if let Some((x, y)) = self.pending_abs.take() {
|
||||
send_abs(&self.overlay, &self.connector, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
fn engage(&self) {
|
||||
if self.captured.replace(true) {
|
||||
return;
|
||||
@@ -107,6 +155,7 @@ impl Capture {
|
||||
}
|
||||
self.overlay.set_cursor(None);
|
||||
self.hint.set_visible(true);
|
||||
self.pending_abs.set(None); // never flush motion gathered while captured
|
||||
if let Some(tl) = self
|
||||
.window
|
||||
.surface()
|
||||
@@ -124,17 +173,72 @@ impl Capture {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
window: &adw::ApplicationWindow,
|
||||
connector: Arc<NativeClient>,
|
||||
frames: async_channel::Receiver<DecodedFrame>,
|
||||
escape_rx: async_channel::Receiver<()>,
|
||||
disconnect_rx: async_channel::Receiver<()>,
|
||||
stop: Arc<AtomicBool>,
|
||||
inhibit_shortcuts: bool,
|
||||
title: &str,
|
||||
) -> StreamPage {
|
||||
pub fn new(args: StreamPageArgs) -> StreamPage {
|
||||
let StreamPageArgs {
|
||||
window,
|
||||
connector,
|
||||
frames,
|
||||
clock_offset_ns,
|
||||
escape_rx,
|
||||
disconnect_rx,
|
||||
stop,
|
||||
inhibit_shortcuts,
|
||||
show_stats,
|
||||
title,
|
||||
} = args;
|
||||
let w = build_widgets(&window, &title);
|
||||
w.stats_label.set_visible(show_stats);
|
||||
|
||||
let capture = Rc::new(Capture {
|
||||
connector,
|
||||
window: window.clone(),
|
||||
overlay: w.overlay.clone(),
|
||||
hint: w.hint.clone(),
|
||||
inhibit_shortcuts,
|
||||
captured: Cell::new(false),
|
||||
pending_abs: Cell::new(None),
|
||||
held_keys: RefCell::new(HashSet::new()),
|
||||
held_buttons: RefCell::new(HashSet::new()),
|
||||
});
|
||||
|
||||
let present_ms = Rc::new(Cell::new(0.0f32));
|
||||
spawn_frame_consumer(&w.picture, frames, clock_offset_ns, present_ms.clone());
|
||||
attach_keyboard(&w.overlay, &window, &capture, &stop, &w.stats_label);
|
||||
attach_mouse(&w.overlay, &capture);
|
||||
attach_scroll(&w.overlay, &capture);
|
||||
let active_handler = attach_capture_lifecycle(&w.overlay, &window, &capture);
|
||||
let escape_future = spawn_escape_watch(&window, &capture, escape_rx);
|
||||
let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx);
|
||||
wire_teardown(
|
||||
&w.page,
|
||||
&window,
|
||||
&stop,
|
||||
(w.fs_handler, active_handler),
|
||||
escape_future,
|
||||
disconnect_future,
|
||||
);
|
||||
|
||||
StreamPage {
|
||||
page: w.page,
|
||||
stats_label: w.stats_label,
|
||||
present_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's widget tree, built in one place so `new` reads as assembly.
|
||||
struct PageWidgets {
|
||||
picture: gtk::Picture,
|
||||
stats_label: gtk::Label,
|
||||
hint: gtk::Label,
|
||||
overlay: gtk::Overlay,
|
||||
page: adw::NavigationPage,
|
||||
/// Fullscreen-notify handler on the shared window — disconnected on page teardown.
|
||||
fs_handler: glib::SignalHandlerId,
|
||||
}
|
||||
|
||||
/// The offloaded picture under an overlay (stats HUD, capture hint, fullscreen hint), a
|
||||
/// header bar with the fullscreen toggle, and the window's fullscreen behavior.
|
||||
fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
|
||||
let picture = gtk::Picture::new();
|
||||
picture.set_content_fit(gtk::ContentFit::Contain);
|
||||
|
||||
@@ -153,7 +257,7 @@ pub fn new(
|
||||
stats_label.set_margin_top(12);
|
||||
|
||||
let hint = gtk::Label::new(Some(
|
||||
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects",
|
||||
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats",
|
||||
));
|
||||
hint.add_css_class("osd");
|
||||
hint.set_halign(gtk::Align::Center);
|
||||
@@ -180,17 +284,6 @@ pub fn new(
|
||||
overlay.add_overlay(&fs_hint);
|
||||
overlay.set_focusable(true);
|
||||
|
||||
let capture = Rc::new(Capture {
|
||||
connector: connector.clone(),
|
||||
window: window.clone(),
|
||||
overlay: overlay.clone(),
|
||||
hint: hint.clone(),
|
||||
inhibit_shortcuts,
|
||||
captured: Cell::new(false),
|
||||
held_keys: RefCell::new(HashSet::new()),
|
||||
held_buttons: RefCell::new(HashSet::new()),
|
||||
});
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic");
|
||||
fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)"));
|
||||
@@ -233,270 +326,379 @@ pub fn new(
|
||||
.child(&toolbar)
|
||||
.build();
|
||||
|
||||
// --- Frame consumer: newest texture wins, set on the GTK frame clock's cadence. ---
|
||||
{
|
||||
let picture = picture.downgrade();
|
||||
// The host encodes BT.709 limited-range; without an explicit color state GDK
|
||||
// would convert NV12 dmabufs with the (BT.601) dmabuf default.
|
||||
let rec709 = {
|
||||
let cicp = gdk::CicpParams::new();
|
||||
cicp.set_color_primaries(1);
|
||||
cicp.set_transfer_function(1);
|
||||
cicp.set_matrix_coefficients(1);
|
||||
cicp.set_range(gdk::CicpRange::Narrow);
|
||||
cicp.build_color_state().ok()
|
||||
};
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(f) = frames.recv().await {
|
||||
let Some(picture) = picture.upgrade() else {
|
||||
break;
|
||||
};
|
||||
match f {
|
||||
DecodedFrame::Cpu(c) => {
|
||||
let bytes = glib::Bytes::from_owned(c.rgba);
|
||||
let tex = gdk::MemoryTexture::new(
|
||||
c.width as i32,
|
||||
c.height as i32,
|
||||
gdk::MemoryFormat::R8g8b8a8,
|
||||
&bytes,
|
||||
c.stride,
|
||||
);
|
||||
picture.set_paintable(Some(&tex));
|
||||
PageWidgets {
|
||||
picture,
|
||||
stats_label,
|
||||
hint,
|
||||
overlay,
|
||||
page,
|
||||
fs_handler,
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame consumer: each decoded frame becomes the picture's paintable as soon as it
|
||||
/// arrives (the session's tiny `force_send` queue already dropped anything older); GTK
|
||||
/// then draws whatever paintable is current on its own frame clock. Ends itself when the
|
||||
/// channel closes or the picture is gone.
|
||||
///
|
||||
/// Also the capture→present-ish measurement point: at each paintable set the frame's
|
||||
/// host capture pts is compared against the local wall clock expressed in the host clock
|
||||
/// (`clock_offset_ns`, same math as the session's decode latency). This is
|
||||
/// capture→paintable-SET — GTK's own present adds one compositor cycle after this. The
|
||||
/// 1 s p50 lands on the stats OSD (via `present_ms`) and in a "present window" debug
|
||||
/// line for headless validation.
|
||||
fn spawn_frame_consumer(
|
||||
picture: >k::Picture,
|
||||
frames: async_channel::Receiver<DecodedFrame>,
|
||||
clock_offset_ns: i64,
|
||||
present_ms: Rc<Cell<f32>>,
|
||||
) {
|
||||
let picture = picture.downgrade();
|
||||
// The host encodes BT.709 limited-range; without an explicit color state GDK
|
||||
// would convert NV12 dmabufs with the (BT.601) dmabuf default.
|
||||
let rec709 = {
|
||||
let cicp = gdk::CicpParams::new();
|
||||
cicp.set_color_primaries(1);
|
||||
cicp.set_transfer_function(1);
|
||||
cicp.set_matrix_coefficients(1);
|
||||
cicp.set_range(gdk::CicpRange::Narrow);
|
||||
cicp.build_color_state().ok()
|
||||
};
|
||||
glib::spawn_future_local(async move {
|
||||
let mut win_lat_us: Vec<u64> = Vec::with_capacity(256);
|
||||
let mut win_start = Instant::now();
|
||||
while let Ok(f) = frames.recv().await {
|
||||
let Some(picture) = picture.upgrade() else {
|
||||
break;
|
||||
};
|
||||
let mut presented = false;
|
||||
match f.image {
|
||||
DecodedImage::Cpu(c) => {
|
||||
let bytes = glib::Bytes::from_owned(c.rgba);
|
||||
let tex = gdk::MemoryTexture::new(
|
||||
c.width as i32,
|
||||
c.height as i32,
|
||||
gdk::MemoryFormat::R8g8b8a8,
|
||||
&bytes,
|
||||
c.stride,
|
||||
);
|
||||
picture.set_paintable(Some(&tex));
|
||||
presented = true;
|
||||
}
|
||||
DecodedImage::Dmabuf(d) => {
|
||||
let mut b = gdk::DmabufTextureBuilder::new()
|
||||
.set_display(&picture.display())
|
||||
.set_width(d.width)
|
||||
.set_height(d.height)
|
||||
.set_fourcc(d.fourcc)
|
||||
.set_modifier(d.modifier)
|
||||
.set_n_planes(d.planes.len() as u32)
|
||||
.set_color_state(rec709.as_ref());
|
||||
for (i, p) in d.planes.iter().enumerate() {
|
||||
b = unsafe { b.set_fd(i as u32, p.fd) }
|
||||
.set_offset(i as u32, p.offset)
|
||||
.set_stride(i as u32, p.stride);
|
||||
}
|
||||
DecodedFrame::Dmabuf(d) => {
|
||||
let mut b = gdk::DmabufTextureBuilder::new()
|
||||
.set_display(&picture.display())
|
||||
.set_width(d.width)
|
||||
.set_height(d.height)
|
||||
.set_fourcc(d.fourcc)
|
||||
.set_modifier(d.modifier)
|
||||
.set_n_planes(d.planes.len() as u32)
|
||||
.set_color_state(rec709.as_ref());
|
||||
for (i, p) in d.planes.iter().enumerate() {
|
||||
b = unsafe { b.set_fd(i as u32, p.fd) }
|
||||
.set_offset(i as u32, p.offset)
|
||||
.set_stride(i as u32, p.stride);
|
||||
let guard = d.guard;
|
||||
// GDK runs the release func whether the import succeeds or not.
|
||||
match unsafe { b.build_with_release_func(move || drop(guard)) } {
|
||||
Ok(tex) => {
|
||||
picture.set_paintable(Some(&tex));
|
||||
presented = true;
|
||||
}
|
||||
let guard = d.guard;
|
||||
// GDK runs the release func whether the import succeeds or not.
|
||||
match unsafe { b.build_with_release_func(move || drop(guard)) } {
|
||||
Ok(tex) => picture.set_paintable(Some(&tex)),
|
||||
Err(e) => {
|
||||
// Import rejected (format/modifier) — surfaces once per
|
||||
// session in practice; the stream continues on the next
|
||||
// frame, and PUNKTFUNK_DECODER=software is the escape.
|
||||
tracing::warn!(error = %e, "dmabuf texture import failed");
|
||||
}
|
||||
Err(e) => {
|
||||
// Import rejected (format/modifier) — surfaces once per
|
||||
// session in practice; the stream continues on the next
|
||||
// frame, and PUNKTFUNK_DECODER=software is the escape.
|
||||
tracing::warn!(error = %e, "dmabuf texture import failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Capture→paintable-set latency, host-clock corrected (same math and sanity
|
||||
// bound as the session's decode-latency window).
|
||||
if presented {
|
||||
let lat = (crate::session::now_ns() as i128 + clock_offset_ns as i128
|
||||
- f.pts_ns as i128)
|
||||
.max(0) as u64;
|
||||
if lat > 0 && lat < 10_000_000_000 {
|
||||
win_lat_us.push(lat / 1000);
|
||||
}
|
||||
}
|
||||
if win_start.elapsed() >= Duration::from_secs(1) {
|
||||
win_lat_us.sort_unstable();
|
||||
let p50 = win_lat_us.get(win_lat_us.len() / 2).copied().unwrap_or(0);
|
||||
tracing::debug!(
|
||||
frames = win_lat_us.len(),
|
||||
present_p50_us = p50,
|
||||
"present window"
|
||||
);
|
||||
present_ms.set(p50 as f32 / 1000.0);
|
||||
win_lat_us.clear();
|
||||
win_start = Instant::now();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Keyboard ---
|
||||
{
|
||||
let key = gtk::EventControllerKey::new();
|
||||
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||
let cap = capture.clone();
|
||||
let window_k = window.clone();
|
||||
let stop_kb = stop.clone();
|
||||
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
||||
let chord = gdk::ModifierType::CONTROL_MASK
|
||||
| gdk::ModifierType::ALT_MASK
|
||||
| gdk::ModifierType::SHIFT_MASK;
|
||||
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
|
||||
if cap.captured.get() {
|
||||
cap.release();
|
||||
} else {
|
||||
cap.engage();
|
||||
}
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
|
||||
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
|
||||
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
|
||||
cap.release();
|
||||
stop_kb.store(true, Ordering::SeqCst);
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
if keyval == gdk::Key::F11 {
|
||||
if window_k.is_fullscreen() {
|
||||
window_k.unfullscreen();
|
||||
} else {
|
||||
window_k.fullscreen();
|
||||
}
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
if !cap.captured.get() {
|
||||
return glib::Propagation::Proceed;
|
||||
}
|
||||
if let Some(vk) = keycode
|
||||
.checked_sub(8)
|
||||
.and_then(|c| keymap::evdev_to_vk(c as u16))
|
||||
{
|
||||
cap.held_keys.borrow_mut().insert(vk);
|
||||
send(&cap.connector, InputKind::KeyDown, vk as u32, 0, 0, 0);
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
let cap = capture.clone();
|
||||
key.connect_key_released(move |_, _keyval, keycode, _state| {
|
||||
if let Some(vk) = keycode
|
||||
.checked_sub(8)
|
||||
.and_then(|c| keymap::evdev_to_vk(c as u16))
|
||||
{
|
||||
// Flush-on-release may have beaten us to it — only forward if still held.
|
||||
if cap.held_keys.borrow_mut().remove(&vk) {
|
||||
send(&cap.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
overlay.add_controller(key);
|
||||
}
|
||||
|
||||
// --- Mouse: absolute motion, buttons, wheel — forwarded only while captured ---
|
||||
{
|
||||
let motion = gtk::EventControllerMotion::new();
|
||||
let cap = capture.clone();
|
||||
motion.connect_motion(move |_, x, y| {
|
||||
/// Keyboard, capture-phase: the release (Ctrl+Alt+Shift+Q) / disconnect (Ctrl+Alt+Shift+D)
|
||||
/// / stats (Ctrl+Alt+Shift+S) chords and F11 are handled locally; everything else becomes
|
||||
/// a VK on the wire while captured.
|
||||
fn attach_keyboard(
|
||||
overlay: >k::Overlay,
|
||||
window: &adw::ApplicationWindow,
|
||||
capture: &Rc<Capture>,
|
||||
stop: &Arc<AtomicBool>,
|
||||
stats: >k::Label,
|
||||
) {
|
||||
let key = gtk::EventControllerKey::new();
|
||||
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||
let cap = capture.clone();
|
||||
let window_k = window.clone();
|
||||
let stop_kb = stop.clone();
|
||||
let stats = stats.clone();
|
||||
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
||||
let chord = gdk::ModifierType::CONTROL_MASK
|
||||
| gdk::ModifierType::ALT_MASK
|
||||
| gdk::ModifierType::SHIFT_MASK;
|
||||
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
|
||||
if cap.captured.get() {
|
||||
send_abs(&cap.overlay, &cap.connector, x, y);
|
||||
cap.release();
|
||||
} else {
|
||||
cap.engage();
|
||||
}
|
||||
});
|
||||
overlay.add_controller(motion);
|
||||
}
|
||||
{
|
||||
let click = gtk::GestureClick::builder().button(0).build();
|
||||
let cap = capture.clone();
|
||||
click.connect_pressed(move |g, _n, x, y| {
|
||||
cap.overlay.grab_focus();
|
||||
if !cap.captured.get() {
|
||||
cap.engage(); // the engaging click is suppressed toward the host
|
||||
return;
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
|
||||
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit.
|
||||
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
|
||||
cap.release();
|
||||
stop_kb.store(true, Ordering::SeqCst);
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
// Ctrl+Alt+Shift+S — toggle the stats OSD live (initial state = Settings).
|
||||
if state.contains(chord) && keyval.to_lower() == gdk::Key::s {
|
||||
stats.set_visible(!stats.is_visible());
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
if keyval == gdk::Key::F11 {
|
||||
if window_k.is_fullscreen() {
|
||||
window_k.unfullscreen();
|
||||
} else {
|
||||
window_k.fullscreen();
|
||||
}
|
||||
send_abs(&cap.overlay, &cap.connector, x, y);
|
||||
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
|
||||
cap.held_buttons.borrow_mut().insert(gs);
|
||||
send(&cap.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
if !cap.captured.get() {
|
||||
return glib::Propagation::Proceed;
|
||||
}
|
||||
if let Some(vk) = keycode
|
||||
.checked_sub(8)
|
||||
.and_then(|c| keymap::evdev_to_vk(c as u16))
|
||||
{
|
||||
// Keep the wire ordered: the host must see the cursor where the user does
|
||||
// when the key lands (e.g. "press E at the crosshair").
|
||||
cap.flush_pending_motion();
|
||||
cap.held_keys.borrow_mut().insert(vk);
|
||||
send(&cap.connector, InputKind::KeyDown, vk as u32, 0, 0, 0);
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
let cap = capture.clone();
|
||||
key.connect_key_released(move |_, _keyval, keycode, _state| {
|
||||
if let Some(vk) = keycode
|
||||
.checked_sub(8)
|
||||
.and_then(|c| keymap::evdev_to_vk(c as u16))
|
||||
{
|
||||
// Flush-on-release may have beaten us to it — only forward if still held.
|
||||
if cap.held_keys.borrow_mut().remove(&vk) {
|
||||
send(&cap.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||
}
|
||||
});
|
||||
let cap = capture.clone();
|
||||
click.connect_released(move |g, _n, _x, _y| {
|
||||
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
|
||||
if cap.held_buttons.borrow_mut().remove(&gs) {
|
||||
send(&cap.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
overlay.add_controller(click);
|
||||
}
|
||||
{
|
||||
let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES);
|
||||
let cap = capture.clone();
|
||||
scroll.connect_scroll(move |_, dx, dy| {
|
||||
if !cap.captured.get() {
|
||||
return glib::Propagation::Proceed;
|
||||
}
|
||||
// The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
|
||||
// positive = down. Smooth fractions survive — libei's discrete scroll is
|
||||
// 120-based too.
|
||||
let vy = (-dy * 120.0) as i32;
|
||||
if vy != 0 {
|
||||
send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0);
|
||||
}
|
||||
let vx = (dx * 120.0) as i32;
|
||||
if vx != 0 {
|
||||
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
overlay.add_controller(scroll);
|
||||
}
|
||||
}
|
||||
});
|
||||
overlay.add_controller(key);
|
||||
}
|
||||
|
||||
// --- Capture lifecycle ---
|
||||
/// Mouse: absolute motion + buttons — forwarded only while captured; the click that
|
||||
/// engages capture is suppressed toward the host. Motion is COALESCED: each event only
|
||||
/// stores the newest position; the overlay's frame-clock tick flushes at most one
|
||||
/// `MouseMoveAbs` per tick (the paintable set on every stream frame keeps the clock
|
||||
/// ticking while streaming). Buttons flush the pending position first so a click lands
|
||||
/// exactly where the cursor last was.
|
||||
fn attach_mouse(overlay: >k::Overlay, capture: &Rc<Capture>) {
|
||||
let motion = gtk::EventControllerMotion::new();
|
||||
let cap = capture.clone();
|
||||
motion.connect_motion(move |_, x, y| {
|
||||
if cap.captured.get() {
|
||||
cap.pending_abs.set(Some((x, y)));
|
||||
}
|
||||
});
|
||||
overlay.add_controller(motion);
|
||||
|
||||
// The per-tick flush. (The tick callback dies with the overlay, so no teardown.)
|
||||
let cap = capture.clone();
|
||||
overlay.add_tick_callback(move |_, _| {
|
||||
cap.flush_pending_motion();
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
|
||||
let click = gtk::GestureClick::builder().button(0).build();
|
||||
let cap = capture.clone();
|
||||
click.connect_pressed(move |g, _n, x, y| {
|
||||
cap.overlay.grab_focus();
|
||||
if !cap.captured.get() {
|
||||
cap.engage(); // the engaging click is suppressed toward the host
|
||||
return;
|
||||
}
|
||||
// The click's own coordinates are the freshest position — supersede any pending
|
||||
// motion, then flush so the button-down lands there.
|
||||
cap.pending_abs.set(Some((x, y)));
|
||||
cap.flush_pending_motion();
|
||||
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
|
||||
cap.held_buttons.borrow_mut().insert(gs);
|
||||
send(&cap.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
|
||||
}
|
||||
});
|
||||
let cap = capture.clone();
|
||||
click.connect_released(move |g, _n, _x, _y| {
|
||||
cap.flush_pending_motion(); // the release must not beat the motion before it
|
||||
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
|
||||
if cap.held_buttons.borrow_mut().remove(&gs) {
|
||||
send(&cap.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
overlay.add_controller(click);
|
||||
}
|
||||
|
||||
/// Wheel — forwarded only while captured.
|
||||
fn attach_scroll(overlay: >k::Overlay, capture: &Rc<Capture>) {
|
||||
let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES);
|
||||
let cap = capture.clone();
|
||||
scroll.connect_scroll(move |_, dx, dy| {
|
||||
if !cap.captured.get() {
|
||||
return glib::Propagation::Proceed;
|
||||
}
|
||||
cap.flush_pending_motion(); // scroll happens at the latest cursor position
|
||||
// The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
|
||||
// positive = down. Smooth fractions survive — libei's discrete scroll is
|
||||
// 120-based too.
|
||||
let vy = (-dy * 120.0) as i32;
|
||||
if vy != 0 {
|
||||
send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0);
|
||||
}
|
||||
let vx = (dx * 120.0) as i32;
|
||||
if vx != 0 {
|
||||
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
overlay.add_controller(scroll);
|
||||
}
|
||||
|
||||
/// Capture lifecycle: engaged when the page maps (the stream just started — trust is
|
||||
/// already confirmed by then), released on focus loss (Alt-Tab away, another window —
|
||||
/// Swift does the same) and on unmap. Returns the window-level focus handler for
|
||||
/// teardown (the window outlives the page).
|
||||
fn attach_capture_lifecycle(
|
||||
overlay: >k::Overlay,
|
||||
window: &adw::ApplicationWindow,
|
||||
capture: &Rc<Capture>,
|
||||
) -> glib::SignalHandlerId {
|
||||
{
|
||||
// Engaged when the stream starts (trust is already confirmed by then).
|
||||
let cap = capture.clone();
|
||||
overlay.connect_map(move |w| {
|
||||
w.grab_focus();
|
||||
cap.engage();
|
||||
});
|
||||
}
|
||||
// Focus loss releases (Alt-Tab away, another window) — Swift does the same.
|
||||
let active_handler = {
|
||||
let cap = capture.clone();
|
||||
window.connect_is_active_notify(move |w| {
|
||||
if !w.is_active() {
|
||||
cap.release();
|
||||
}
|
||||
})
|
||||
};
|
||||
{
|
||||
let cap = capture.clone();
|
||||
overlay.connect_unmap(move |_| cap.release());
|
||||
}
|
||||
let cap = capture.clone();
|
||||
window.connect_is_active_notify(move |w| {
|
||||
if !w.is_active() {
|
||||
cap.release();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Controller escape chord (gamepad service) → leave fullscreen + release capture. The
|
||||
// chord is the only fullscreen exit a controller has (no F11 key; fullscreen hides the
|
||||
// chrome). Aborted on page-hidden so a stale future can't act on the shared window.
|
||||
let escape_future = {
|
||||
let window = window.clone();
|
||||
let cap = capture.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
while escape_rx.recv().await.is_ok() {
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
}
|
||||
cap.release();
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Controller disconnect (escape chord held past the hold threshold) → end the session, the
|
||||
// controller equivalent of Ctrl+Alt+Shift+D. Setting `stop` ends the session pump, which pops
|
||||
// this page (and fires `hidden` below). One-shot — the session is going away.
|
||||
let disconnect_future = {
|
||||
let window = window.clone();
|
||||
let cap = capture.clone();
|
||||
let stop_d = stop.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if disconnect_rx.recv().await.is_ok() {
|
||||
cap.release();
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
}
|
||||
stop_d.store(true, Ordering::SeqCst);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// The page's `hidden` fires once navigation away completes (back button, pop on
|
||||
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
||||
{
|
||||
let window = window.clone();
|
||||
let stop_h = stop.clone();
|
||||
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
||||
let escape_future = RefCell::new(Some(escape_future));
|
||||
let disconnect_future = RefCell::new(Some(disconnect_future));
|
||||
page.connect_hidden(move |_| {
|
||||
tracing::debug!("stream page hidden — ending session");
|
||||
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
||||
window.disconnect(fs);
|
||||
window.disconnect(active);
|
||||
}
|
||||
if let Some(f) = escape_future.borrow_mut().take() {
|
||||
f.abort();
|
||||
}
|
||||
if let Some(f) = disconnect_future.borrow_mut().take() {
|
||||
f.abort();
|
||||
}
|
||||
/// Controller escape chord (gamepad service) → leave fullscreen + release capture. The
|
||||
/// chord is the only fullscreen exit a controller has (no F11 key; fullscreen hides the
|
||||
/// chrome). Aborted on page-hidden so a stale future can't act on the shared window.
|
||||
fn spawn_escape_watch(
|
||||
window: &adw::ApplicationWindow,
|
||||
capture: &Rc<Capture>,
|
||||
escape_rx: async_channel::Receiver<()>,
|
||||
) -> glib::JoinHandle<()> {
|
||||
let window = window.clone();
|
||||
let cap = capture.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
while escape_rx.recv().await.is_ok() {
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
}
|
||||
stop_h.store(true, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
StreamPage { page, stats_label }
|
||||
cap.release();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Controller disconnect (escape chord held past the hold threshold) → end the session,
|
||||
/// the controller equivalent of Ctrl+Alt+Shift+D. Setting `stop` ends the session pump,
|
||||
/// which pops this page (and fires `hidden` — see `wire_teardown`). One-shot — the
|
||||
/// session is going away.
|
||||
fn spawn_disconnect_watch(
|
||||
window: &adw::ApplicationWindow,
|
||||
capture: &Rc<Capture>,
|
||||
stop: &Arc<AtomicBool>,
|
||||
disconnect_rx: async_channel::Receiver<()>,
|
||||
) -> glib::JoinHandle<()> {
|
||||
let window = window.clone();
|
||||
let cap = capture.clone();
|
||||
let stop_d = stop.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if disconnect_rx.recv().await.is_ok() {
|
||||
cap.release();
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
}
|
||||
stop_d.store(true, Ordering::SeqCst);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The page's `hidden` fires once navigation away completes (back button, pop on
|
||||
/// session end) — NOT on the transient unmap/map cycle a NavigationView push performs:
|
||||
/// disconnect the window-level handlers, abort the chord futures, and stop the session.
|
||||
fn wire_teardown(
|
||||
page: &adw::NavigationPage,
|
||||
window: &adw::ApplicationWindow,
|
||||
stop: &Arc<AtomicBool>,
|
||||
handlers: (glib::SignalHandlerId, glib::SignalHandlerId),
|
||||
escape_future: glib::JoinHandle<()>,
|
||||
disconnect_future: glib::JoinHandle<()>,
|
||||
) {
|
||||
let window = window.clone();
|
||||
let stop_h = stop.clone();
|
||||
let handlers = RefCell::new(Some(handlers));
|
||||
let escape_future = RefCell::new(Some(escape_future));
|
||||
let disconnect_future = RefCell::new(Some(disconnect_future));
|
||||
page.connect_hidden(move |_| {
|
||||
tracing::debug!("stream page hidden — ending session");
|
||||
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
||||
window.disconnect(fs);
|
||||
window.disconnect(active);
|
||||
}
|
||||
if let Some(f) = escape_future.borrow_mut().take() {
|
||||
f.abort();
|
||||
}
|
||||
if let Some(f) = disconnect_future.borrow_mut().take() {
|
||||
f.abort();
|
||||
}
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
}
|
||||
stop_h.store(true, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! The trust gate and dialogs in front of every connect: TOFU, the SPAKE2 PIN ceremony,
|
||||
//! and delegated (request-access) approval.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::launch::{start_session, start_session_with, StartOpts};
|
||||
use crate::trust;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The trust gate in front of every connect. The host is the policy authority (it
|
||||
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
|
||||
/// its trust UI from that:
|
||||
/// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently.
|
||||
/// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer
|
||||
/// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of
|
||||
/// the advertised policy.
|
||||
/// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a);
|
||||
/// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is
|
||||
/// mandatory (rule 3b).
|
||||
///
|
||||
/// A new host is never auto-connected without a stored pin or an explicit trust decision.
|
||||
pub fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
if app.busy.get() {
|
||||
return;
|
||||
}
|
||||
let known = trust::KnownHosts::load();
|
||||
match &req.fp_hex {
|
||||
Some(fp_hex) => {
|
||||
if known.find_by_fp(fp_hex).is_some() {
|
||||
// Rule 1: pinned fingerprint matches — silent connect.
|
||||
start_session(app, req.clone(), trust::parse_hex32(fp_hex));
|
||||
} else if known.find_by_addr(&req.addr, req.port).is_some() {
|
||||
// Rule 2: we trust a host at this address but the fingerprint changed —
|
||||
// the impostor signal. Re-pair via the PIN ceremony (no TOFU shortcut).
|
||||
app.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||
pin_dialog(app, req);
|
||||
} else if req.pair_optional {
|
||||
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
||||
tofu_dialog(app, req);
|
||||
} else {
|
||||
// Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval
|
||||
// (request access → approve in the console) or the PIN ceremony.
|
||||
approval_dialog(app, req);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Manual entry (no advertised fingerprint). A known address connects silently
|
||||
// on its stored pin (rule 1); an unknown one must pair — request access (approve in
|
||||
// the console) or use a PIN; never silent TOFU.
|
||||
match known
|
||||
.find_by_addr(&req.addr, req.port)
|
||||
.and_then(|k| trust::parse_hex32(&k.fp_hex))
|
||||
{
|
||||
Some(pin) => start_session(app, req, Some(pin)),
|
||||
None => approval_dialog(app, req), // rule 3b
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines
|
||||
/// (the Apple TrustCardView format), far easier to compare against the host's log than
|
||||
/// one 64-char run.
|
||||
fn grouped_fingerprint(fp: &str) -> String {
|
||||
let groups: Vec<&str> = fp
|
||||
.as_bytes()
|
||||
.chunks(4)
|
||||
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
||||
.collect();
|
||||
groups
|
||||
.chunks(8)
|
||||
.map(|line| line.join(" "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// First contact with a discovered host: show the advertised fingerprint and let the user
|
||||
/// trust it (TOFU), run the PIN ceremony instead, or walk away.
|
||||
pub fn tofu_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
let fp = req.fp_hex.clone().unwrap_or_default();
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("New Host"),
|
||||
Some(&format!(
|
||||
"{} at {}:{}\n\nPairing with a PIN verifies the certificate fingerprint below; \
|
||||
trusting accepts it as-is.",
|
||||
req.name, req.addr, req.port
|
||||
)),
|
||||
);
|
||||
let fp_label = gtk::Label::new(Some(&grouped_fingerprint(&fp)));
|
||||
fp_label.add_css_class("monospace");
|
||||
fp_label.set_selectable(true);
|
||||
fp_label.set_justify(gtk::Justification::Center);
|
||||
fp_label.set_halign(gtk::Align::Center);
|
||||
dialog.set_extra_child(Some(&fp_label));
|
||||
dialog.add_responses(&[
|
||||
("cancel", "Cancel"),
|
||||
("pair", "Pair with PIN…"),
|
||||
("trust", "Trust & Connect"),
|
||||
]);
|
||||
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("trust"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = app.window.clone();
|
||||
dialog.connect_response(None, move |_, response| match response {
|
||||
"trust" => {
|
||||
trust::persist_host(&req.name, &req.addr, req.port, &fp, false);
|
||||
start_session(app.clone(), req.clone(), trust::parse_hex32(&fp));
|
||||
}
|
||||
"pair" => pin_dialog(app.clone(), req.clone()),
|
||||
_ => {}
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
}
|
||||
|
||||
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
|
||||
/// of it pins the host's certificate (and registers ours) with no offline-guessable
|
||||
/// transcript.
|
||||
pub fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
let entry = gtk::Entry::builder()
|
||||
.input_purpose(gtk::InputPurpose::Digits)
|
||||
.placeholder_text("4-digit PIN shown by the host")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
// The label the HOST stores this client under (its paired-devices list) — prefilled
|
||||
// with the machine hostname, editable (the Apple pair sheet does the same).
|
||||
let name_entry = gtk::Entry::builder()
|
||||
.text(glib::host_name().as_str())
|
||||
.activates_default(true)
|
||||
.build();
|
||||
let name_caption = gtk::Label::new(Some("This device"));
|
||||
name_caption.add_css_class("caption");
|
||||
name_caption.add_css_class("dim-label");
|
||||
name_caption.set_halign(gtk::Align::Start);
|
||||
let fields = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
fields.append(&name_caption);
|
||||
fields.append(&name_entry);
|
||||
let pin_caption = gtk::Label::new(Some("PIN"));
|
||||
pin_caption.add_css_class("caption");
|
||||
pin_caption.add_css_class("dim-label");
|
||||
pin_caption.set_halign(gtk::Align::Start);
|
||||
fields.append(&pin_caption);
|
||||
fields.append(&entry);
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Pair with PIN"),
|
||||
Some(&format!(
|
||||
"Arm pairing on {} (console or web UI), then enter the PIN it displays.",
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
dialog.set_extra_child(Some(&fields));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]);
|
||||
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("pair"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = app.window.clone();
|
||||
dialog.connect_response(Some("pair"), move |_, _| {
|
||||
let pin = entry.text().to_string();
|
||||
let app = app.clone();
|
||||
let req = req.clone();
|
||||
let identity = app.identity.clone();
|
||||
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
||||
let device = name_entry.text().trim().to_string();
|
||||
let name = if device.is_empty() {
|
||||
glib::host_name().to_string()
|
||||
} else {
|
||||
device
|
||||
};
|
||||
let (host, port) = (req.addr.clone(), req.port);
|
||||
std::thread::spawn(move || {
|
||||
let result = trust::pair_with_host(&host, port, &identity, &pin, &name)
|
||||
.map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)"));
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(fp)) => {
|
||||
trust::persist_host(&req.name, &req.addr, req.port, &trust::hex(&fp), true);
|
||||
app.toast("Paired — connecting…");
|
||||
start_session(app.clone(), req, Some(fp));
|
||||
}
|
||||
Ok(Err(msg)) => app.toast(&msg),
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
}
|
||||
|
||||
/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN
|
||||
/// path — connect and wait for the operator to click Approve in the host's console/web UI
|
||||
/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony.
|
||||
fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Pairing Required"),
|
||||
Some(&format!(
|
||||
"{} requires pairing.\n\nRequest access and approve this device in the host's console \
|
||||
(or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.",
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
dialog.add_responses(&[
|
||||
("cancel", "Cancel"),
|
||||
("pin", "Use a PIN instead…"),
|
||||
("request", "Request Access"),
|
||||
]);
|
||||
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("request"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = app.window.clone();
|
||||
dialog.connect_response(None, move |_, response| match response {
|
||||
"request" => request_access(app.clone(), req.clone()),
|
||||
"pin" => pin_dialog(app.clone(), req.clone()),
|
||||
_ => {}
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
}
|
||||
|
||||
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||
/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On
|
||||
/// approval the same connection is admitted (no reconnect) and the host is saved as paired.
|
||||
fn request_access(app: Rc<App>, req: ConnectRequest) {
|
||||
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||||
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||
let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let cancel = Rc::new(std::cell::Cell::new(false));
|
||||
|
||||
let waiting = adw::AlertDialog::new(
|
||||
Some("Waiting for Approval"),
|
||||
Some(&format!(
|
||||
"Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \
|
||||
connects automatically once you approve it.",
|
||||
glib::host_name(),
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||
waiting.set_close_response("cancel");
|
||||
{
|
||||
let app = app.clone();
|
||||
let cancel = cancel.clone();
|
||||
waiting.connect_response(Some("cancel"), move |_, _| {
|
||||
// Return the UI immediately; the in-flight connect is left to time out and is torn
|
||||
// down silently by the event loop (see StartOpts::cancel).
|
||||
cancel.set(true);
|
||||
app.busy.set(false);
|
||||
app.toast("Cancelled — the request may still be pending on the host.");
|
||||
});
|
||||
}
|
||||
waiting.present(Some(&app.window));
|
||||
|
||||
start_session_with(
|
||||
app,
|
||||
req,
|
||||
pin,
|
||||
StartOpts {
|
||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||||
// approval still lands on this connection rather than timing the client out first.
|
||||
connect_timeout: std::time::Duration::from_secs(185),
|
||||
persist_paired: true,
|
||||
waiting: Some(waiting),
|
||||
cancel: Some(cancel),
|
||||
},
|
||||
);
|
||||
}
|
||||
+74
-10
@@ -23,7 +23,16 @@ use ffmpeg_next as ffmpeg;
|
||||
use std::os::fd::RawFd;
|
||||
use std::ptr;
|
||||
|
||||
pub enum DecodedFrame {
|
||||
/// One decoded frame headed for the presenter, carrying the host capture timestamp so the
|
||||
/// UI can measure capture→paintable-set latency at the moment it presents.
|
||||
pub struct DecodedFrame {
|
||||
/// Host-clock capture pts (ns) of the AU this image decoded from — compare against
|
||||
/// the local wall clock + `clock_offset_ns` at paintable-set time.
|
||||
pub pts_ns: u64,
|
||||
pub image: DecodedImage,
|
||||
}
|
||||
|
||||
pub enum DecodedImage {
|
||||
Cpu(CpuFrame),
|
||||
Dmabuf(DmabufFrame),
|
||||
}
|
||||
@@ -108,9 +117,17 @@ pub fn decodable_codecs() -> u8 {
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
pub fn new(codec_id: ffmpeg::codec::Id) -> Result<Decoder> {
|
||||
/// `codec_id` is the codec the host resolved in the Welcome (never assume HEVC).
|
||||
/// `pref` is the Settings "Video decoder" value (`auto`/`vaapi`/`software`).
|
||||
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
||||
/// hatch, and the documented knob), then the setting; both default to auto
|
||||
/// (VAAPI → software).
|
||||
pub fn new(codec_id: ffmpeg::codec::Id, pref: &str) -> Result<Decoder> {
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
let choice = std::env::var("PUNKTFUNK_DECODER").unwrap_or_default();
|
||||
let choice = std::env::var("PUNKTFUNK_DECODER")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| pref.to_string());
|
||||
if choice != "software" {
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
@@ -138,17 +155,17 @@ impl Decoder {
|
||||
/// one-in/one-out). A software decode error after packet loss is survivable — log
|
||||
/// upstream and keep feeding. A VAAPI error demotes to software for the rest of the
|
||||
/// session (broken driver, e.g. nvidia-vaapi-driver) — the next IDR resynchronizes.
|
||||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedFrame>> {
|
||||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
|
||||
match &mut self.backend {
|
||||
Backend::Vaapi(v) => match v.decode(au) {
|
||||
Ok(f) => Ok(f.map(DecodedFrame::Dmabuf)),
|
||||
Ok(f) => Ok(f.map(DecodedImage::Dmabuf)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
Backend::Software(s) => Ok(s.decode(au)?.map(DecodedFrame::Cpu)),
|
||||
Backend::Software(s) => Ok(s.decode(au)?.map(DecodedImage::Cpu)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,13 +236,60 @@ impl SoftwareDecoder {
|
||||
self.sws = Some((ctx, fmt, w, h));
|
||||
}
|
||||
let (sws, ..) = self.sws.as_mut().unwrap();
|
||||
let mut rgba = AvFrame::empty();
|
||||
sws.run(frame, &mut rgba).map_err(|e| anyhow!("sws: {e}"))?;
|
||||
// Single-pass conversion: swscale writes straight into the Vec the texture will
|
||||
// wrap. (The old path scaled into a scratch AVFrame and then copied `data(0)` out
|
||||
// — a second full-frame pass per frame.) 64-byte row alignment keeps swscale on
|
||||
// aligned SIMD stores; `GdkMemoryTexture` takes the resulting stride explicitly.
|
||||
const ALIGN: i32 = 64;
|
||||
use ffmpeg::ffi;
|
||||
let dst_fmt = ffi::AVPixelFormat::AV_PIX_FMT_RGBA;
|
||||
// SAFETY: pure size computation from format/dimensions; no pointers involved.
|
||||
let size = unsafe { ffi::av_image_get_buffer_size(dst_fmt, w as i32, h as i32, ALIGN) };
|
||||
if size < 0 {
|
||||
return Err(averr("av_image_get_buffer_size", size));
|
||||
}
|
||||
let rgba = vec![0u8; size as usize];
|
||||
let mut dst_data: [*mut u8; 4] = [ptr::null_mut(); 4];
|
||||
let mut dst_linesize: [i32; 4] = [0; 4];
|
||||
// SAFETY: fill_arrays only derives plane pointers/strides into `rgba` (sized by
|
||||
// av_image_get_buffer_size above, same format/align) — no allocation, no
|
||||
// ownership transfer; `rgba` outlives the scale below.
|
||||
let r = unsafe {
|
||||
ffi::av_image_fill_arrays(
|
||||
dst_data.as_mut_ptr(),
|
||||
dst_linesize.as_mut_ptr(),
|
||||
rgba.as_ptr(),
|
||||
dst_fmt,
|
||||
w as i32,
|
||||
h as i32,
|
||||
ALIGN,
|
||||
)
|
||||
};
|
||||
if r < 0 {
|
||||
return Err(averr("av_image_fill_arrays", r));
|
||||
}
|
||||
// SAFETY: src pointers/strides belong to the decoder-owned `frame` (alive for the
|
||||
// call); dst pointers were just filled over `rgba`, and sws_scale writes rows
|
||||
// [0, h) only — exactly the buffer fill_arrays sized.
|
||||
let r = unsafe {
|
||||
ffi::sws_scale(
|
||||
sws.as_mut_ptr(),
|
||||
(*frame.as_ptr()).data.as_ptr() as *const *const u8,
|
||||
(*frame.as_ptr()).linesize.as_ptr(),
|
||||
0,
|
||||
h as i32,
|
||||
dst_data.as_ptr(),
|
||||
dst_linesize.as_ptr(),
|
||||
)
|
||||
};
|
||||
if r < 0 {
|
||||
return Err(averr("sws_scale", r));
|
||||
}
|
||||
Ok(CpuFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
stride: rgba.stride(0),
|
||||
rgba: rgba.data(0).to_vec(),
|
||||
stride: dst_linesize[0] as usize,
|
||||
rgba,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Capture host-free UI screenshots of the native Linux client under a virtual X
|
||||
# display. Mirrors the iOS harness (clients/apple/tools/screenshots.sh): one app
|
||||
# launch per scene (PUNKTFUNK_SHOT_SCENE), the app renders a mock-populated REAL
|
||||
# view and prints `PF_SHOT_READY`, then we grab the X root window. No host, GPU, or
|
||||
# live stream — only the chrome scenes (the stream page needs a live connector).
|
||||
# Capture host-free UI screenshots of the native Linux client. Mirrors the iOS harness
|
||||
# (clients/apple/tools/screenshots.sh): one app launch per scene (PUNKTFUNK_SHOT_SCENE),
|
||||
# the app renders a mock-populated REAL view and — when the binary supports it — CAPTURES
|
||||
# ITSELF (PUNKTFUNK_SHOT_OUT: widget snapshot → gsk render → PNG) before printing
|
||||
# `PF_SHOT_READY`. Self-capture needs no Xvfb/ImageMagick and runs under a live Wayland
|
||||
# session too; the X11 root-grab path is kept as a fallback for old binaries. No host,
|
||||
# GPU, or live stream — only the chrome scenes (the stream page needs a live connector).
|
||||
#
|
||||
# cargo build --release -p punktfunk-client-linux
|
||||
# bash clients/linux/tools/screenshots.sh # → clients/linux/screenshots/<scene>.png
|
||||
# bash clients/linux/tools/screenshots.sh hosts pair # a subset
|
||||
#
|
||||
# Env knobs: BIN (client binary), OUT (output dir), GEOMETRY (Xvfb WxHxDepth),
|
||||
# SETTLE (extra seconds after PF_SHOT_READY), SHOT_DISPLAY (X display), GSK_RENDERER
|
||||
# (gl|ngl|cairo — gl/llvmpipe by default for full libadwaita fidelity).
|
||||
# SETTLE (extra seconds after PF_SHOT_READY, X11-fallback only), SHOT_DISPLAY (X display),
|
||||
# GSK_RENDERER (gl|ngl|cairo — cairo is the safe headless/no-GPU choice), FORCE_XVFB=1
|
||||
# (ignore a live Wayland session and go through Xvfb anyway).
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # clients/linux
|
||||
BIN="${BIN:-$here/../../target/release/punktfunk-client}"
|
||||
OUT="${OUT:-$here/screenshots}"
|
||||
# The client window maps at its 1100x720 default; with no WM under Xvfb it lands at the
|
||||
# top-left, so keep the root just larger so the full window (incl. its CSD shadow) is
|
||||
# captured by a root grab with only a thin margin to crop.
|
||||
GEOMETRY="${GEOMETRY:-1280x800x24}"
|
||||
# X11 fallback only: the client window maps at its 1200x780 default; with no WM under
|
||||
# Xvfb it lands at the top-left, so keep the root just larger so the full window (incl.
|
||||
# its CSD shadow) is captured by a root grab with only a thin margin to crop.
|
||||
GEOMETRY="${GEOMETRY:-1380x860x24}"
|
||||
SETTLE="${SETTLE:-1.2}"
|
||||
SHOT_DISPLAY="${SHOT_DISPLAY:-:99}"
|
||||
|
||||
if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair); fi
|
||||
if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair addhost shortcuts library); fi
|
||||
|
||||
[ -x "$BIN" ] || {
|
||||
echo "client binary not found: $BIN (build it first: cargo build --release -p punktfunk-client-linux)" >&2
|
||||
@@ -33,7 +36,8 @@ if [ "$#" -gt 0 ]; then SCENES=("$@"); else SCENES=(hosts settings trust pair);
|
||||
|
||||
# Isolated scratch HOME: the client generates its identity here on first run, and the
|
||||
# saved-hosts grid is read from client-known-hosts.json, so seed mock hosts for the
|
||||
# `hosts` scene (the dialogs/settings build their own mock state in-app).
|
||||
# `hosts` scene (the dialogs/settings build their own mock state in-app). `last_used`
|
||||
# on the first entry renders the most-recent accent bar.
|
||||
WORK="$(mktemp -d)"
|
||||
export HOME="$WORK"
|
||||
mkdir -p "$HOME/.config/punktfunk"
|
||||
@@ -42,7 +46,7 @@ cat >"$HOME/.config/punktfunk/client-known-hosts.json" <<'JSON'
|
||||
"hosts": [
|
||||
{ "name": "Living Room PC", "addr": "192.168.1.42", "port": 9777,
|
||||
"fp_hex": "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||
"paired": true },
|
||||
"paired": true, "last_used": 1780000000 },
|
||||
{ "name": "Office", "addr": "192.168.1.50", "port": 9777,
|
||||
"fp_hex": "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
|
||||
"paired": false }
|
||||
@@ -50,34 +54,45 @@ cat >"$HOME/.config/punktfunk/client-known-hosts.json" <<'JSON'
|
||||
}
|
||||
JSON
|
||||
|
||||
# Software-rendered X session — no GPU/Wayland. GL/llvmpipe runs the real NGL renderer
|
||||
# (cairo is documented-incomplete for 3D-transformed content / libadwaita transitions).
|
||||
unset WAYLAND_DISPLAY
|
||||
export DISPLAY="$SHOT_DISPLAY"
|
||||
export GDK_BACKEND=x11
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
export GALLIUM_DRIVER="${GALLIUM_DRIVER:-llvmpipe}"
|
||||
export GSK_RENDERER="${GSK_RENDERER:-gl}"
|
||||
|
||||
Xvfb "$SHOT_DISPLAY" -screen 0 "$GEOMETRY" -nolisten tcp >"$WORK/xvfb.log" 2>&1 &
|
||||
XVFB_PID=$!
|
||||
XVFB_PID=""
|
||||
cleanup() {
|
||||
kill "$XVFB_PID" 2>/dev/null || true
|
||||
if [ -n "$XVFB_PID" ]; then kill "$XVFB_PID" 2>/dev/null || true; fi
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Wait for the display to accept connections.
|
||||
for _ in $(seq 1 50); do
|
||||
if command -v xdpyinfo >/dev/null 2>&1; then
|
||||
xdpyinfo -display "$SHOT_DISPLAY" >/dev/null 2>&1 && break
|
||||
else
|
||||
[ -e "/tmp/.X11-unix/X${SHOT_DISPLAY#:}" ] && break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${FORCE_XVFB:-}" ]; then
|
||||
# Live Wayland session: self-capture only (there is no root grab on Wayland). The
|
||||
# window flashes up briefly per scene — this is a dev harness, not CI polish.
|
||||
MODE=wayland
|
||||
export GDK_BACKEND=wayland
|
||||
else
|
||||
# Software-rendered X session — no GPU/Wayland needed. GL/llvmpipe runs the real NGL
|
||||
# renderer (cairo is documented-incomplete for 3D-transformed content / libadwaita
|
||||
# transitions).
|
||||
MODE=x11
|
||||
unset WAYLAND_DISPLAY
|
||||
export DISPLAY="$SHOT_DISPLAY"
|
||||
export GDK_BACKEND=x11
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
export GALLIUM_DRIVER="${GALLIUM_DRIVER:-llvmpipe}"
|
||||
export GSK_RENDERER="${GSK_RENDERER:-gl}"
|
||||
|
||||
capture() {
|
||||
Xvfb "$SHOT_DISPLAY" -screen 0 "$GEOMETRY" -nolisten tcp >"$WORK/xvfb.log" 2>&1 &
|
||||
XVFB_PID=$!
|
||||
# Wait for the display to accept connections.
|
||||
for _ in $(seq 1 50); do
|
||||
if command -v xdpyinfo >/dev/null 2>&1; then
|
||||
xdpyinfo -display "$SHOT_DISPLAY" >/dev/null 2>&1 && break
|
||||
else
|
||||
[ -e "/tmp/.X11-unix/X${SHOT_DISPLAY#:}" ] && break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
fi
|
||||
|
||||
# X11 root grab — the fallback for binaries without self-capture.
|
||||
capture_x11() {
|
||||
local out="$1"
|
||||
if command -v import >/dev/null 2>&1; then
|
||||
import -silent -window root "$out"
|
||||
@@ -93,7 +108,9 @@ mkdir -p "$OUT"
|
||||
rc=0
|
||||
for scene in "${SCENES[@]}"; do
|
||||
: >"$WORK/log"
|
||||
PUNKTFUNK_SHOT_SCENE="$scene" "$BIN" >"$WORK/log" 2>&1 &
|
||||
rm -f "$OUT/$scene.png"
|
||||
PUNKTFUNK_SHOT_SCENE="$scene" PUNKTFUNK_SHOT_OUT="$OUT/$scene.png" \
|
||||
"$BIN" >"$WORK/log" 2>&1 &
|
||||
pid=$!
|
||||
ready=0
|
||||
for _ in $(seq 1 200); do # up to ~20s
|
||||
@@ -101,14 +118,27 @@ for scene in "${SCENES[@]}"; do
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$pid" 2>/dev/null; then break; fi
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
# Self-capture binaries exit(0) right after READY — check the log once more.
|
||||
grep -q "PF_SHOT_READY" "$WORK/log" && ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [ "$ready" = 1 ]; then
|
||||
sleep "$SETTLE"
|
||||
if capture "$OUT/$scene.png"; then
|
||||
echo "✓ $scene → $OUT/$scene.png"
|
||||
if [ -f "$OUT/$scene.png" ]; then
|
||||
echo "✓ $scene → $OUT/$scene.png (self-capture)"
|
||||
elif [ "$MODE" = x11 ]; then
|
||||
# Old binary (no PUNKTFUNK_SHOT_OUT support) — grab the X root instead.
|
||||
sleep "$SETTLE"
|
||||
if capture_x11 "$OUT/$scene.png"; then
|
||||
echo "✓ $scene → $OUT/$scene.png (x11 grab)"
|
||||
else
|
||||
rc=1
|
||||
fi
|
||||
else
|
||||
echo "✗ $scene: no PNG (self-capture failed — see log)" >&2
|
||||
sed 's/^/ /' "$WORK/log" >&2 || true
|
||||
rc=1
|
||||
fi
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user