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",
|
"opus",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
|
"rustls",
|
||||||
"sdl3",
|
"sdl3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ pipewire = "0.9"
|
|||||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||||
|
|
||||||
mdns-sd = "0.20"
|
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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
anyhow = "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
|
First connect does a one-time **SPAKE2 PIN pairing** (or TOFU on trusted LANs), then reconnects on
|
||||||
a pinned identity.
|
a pinned identity.
|
||||||
- **Per-host speed test** to pick a bitrate, plus compositor and mode preferences in Settings.
|
- **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
|
## 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
|
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
|
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`.
|
`PUNKTFUNK_DECODER=software|vaapi`.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
main.rs · app.rs entry point, GTK application, CLI paths
|
main.rs · app.rs entry point, GTK application, primary menu, CSS
|
||||||
ui_hosts.rs host list (mDNS + saved), pairing / trust dialogs
|
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_settings.rs resolution · refresh · decoder · bitrate · compositor · mic
|
||||||
ui_stream.rs the stream window (GtkGraphicsOffload present) + input capture
|
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
|
video.rs FFmpeg VAAPI / software decode → dmabuf / texture
|
||||||
audio.rs PipeWire playback + mic uplink
|
audio.rs PipeWire playback + mic uplink
|
||||||
gamepad.rs · keymap.rs SDL3 controllers + feedback; keyboard VK mapping
|
gamepad.rs · keymap.rs SDL3 controllers + feedback; keyboard VK mapping
|
||||||
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
|
trust.rs · discovery.rs persistent identity, known hosts + settings, mDNS browse
|
||||||
tools/screenshots.sh store screenshot capture
|
library.rs mgmt-API library client (mTLS + pinned fingerprint, art proxy)
|
||||||
|
tools/screenshots.sh store screenshot capture (app self-capture; Xvfb fallback)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Related
|
## 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::Settings;
|
||||||
use crate::trust::{KnownHost, KnownHosts, Settings};
|
use crate::ui_hosts::{ConnectRequest, HostsCallbacks, HostsUi};
|
||||||
use crate::ui_hosts::ConnectRequest;
|
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::{gdk, glib};
|
use gtk::{gdk, gio, glib};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref};
|
use punktfunk_core::config::{CompositorPref, GamepadPref};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
@@ -12,24 +13,58 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
const APP_ID: &str = "io.unom.Punktfunk";
|
const APP_ID: &str = "io.unom.Punktfunk";
|
||||||
|
|
||||||
struct App {
|
/// Custom styles on top of libadwaita for the host cards: status pills, presence pips,
|
||||||
window: adw::ApplicationWindow,
|
/// the most-recent accent bar, dashed discovered cards. Colours come from the adwaita
|
||||||
nav: adw::NavigationView,
|
/// named palette so dark mode just works.
|
||||||
toasts: adw::ToastOverlay,
|
const CSS: &str = "
|
||||||
settings: Rc<RefCell<Settings>>,
|
.pf-host-card { padding: 16px; }
|
||||||
identity: (String, String),
|
.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.
|
/// 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.
|
/// 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.
|
/// 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 {
|
impl App {
|
||||||
fn toast(&self, msg: &str) {
|
pub fn toast(&self, msg: &str) {
|
||||||
self.toasts.add_toast(adw::Toast::new(msg));
|
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 {
|
pub fn run() -> glib::ExitCode {
|
||||||
@@ -40,13 +75,17 @@ pub fn run() -> glib::ExitCode {
|
|||||||
.init();
|
.init();
|
||||||
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
|
// 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.
|
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
|
||||||
if let Some(pin) = arg_value("--pair") {
|
if let Some(pin) = crate::cli::arg_value("--pair") {
|
||||||
return headless_pair(&pin);
|
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);
|
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
|
// 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.
|
// 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);
|
builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE);
|
||||||
}
|
}
|
||||||
let app = builder.build();
|
let app = builder.build();
|
||||||
@@ -56,105 +95,6 @@ pub fn run() -> glib::ExitCode {
|
|||||||
app.run_with_args(&[] as &[&str])
|
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) {
|
fn build_ui(gtk_app: &adw::Application) {
|
||||||
let identity = match crate::trust::load_or_create_identity() {
|
let identity = match crate::trust::load_or_create_identity() {
|
||||||
Ok(i) => i,
|
Ok(i) => i,
|
||||||
@@ -163,6 +103,7 @@ fn build_ui(gtk_app: &adw::Application) {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
load_css();
|
||||||
|
|
||||||
let nav = adw::NavigationView::new();
|
let nav = adw::NavigationView::new();
|
||||||
let toasts = adw::ToastOverlay::new();
|
let toasts = adw::ToastOverlay::new();
|
||||||
@@ -170,8 +111,8 @@ fn build_ui(gtk_app: &adw::Application) {
|
|||||||
let window = adw::ApplicationWindow::builder()
|
let window = adw::ApplicationWindow::builder()
|
||||||
.application(gtk_app)
|
.application(gtk_app)
|
||||||
.title("Punktfunk")
|
.title("Punktfunk")
|
||||||
.default_width(1100)
|
.default_width(1200)
|
||||||
.default_height(720)
|
.default_height(780)
|
||||||
.content(&toasts)
|
.content(&toasts)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -183,318 +124,159 @@ fn build_ui(gtk_app: &adw::Application) {
|
|||||||
identity,
|
identity,
|
||||||
gamepad: crate::gamepad::GamepadService::start(),
|
gamepad: crate::gamepad::GamepadService::start(),
|
||||||
busy: std::cell::Cell::new(false),
|
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 hosts_ui = Rc::new(crate::ui_hosts::new(
|
||||||
{
|
app.settings.clone(),
|
||||||
let app = app.clone();
|
HostsCallbacks {
|
||||||
Rc::new(move |req| initiate_connect(app.clone(), req))
|
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();
|
*app.hosts.borrow_mut() = Some(hosts_ui.clone());
|
||||||
Rc::new(move || {
|
install_actions(&app, &hosts_ui);
|
||||||
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad)
|
nav.add(&hosts_ui.page);
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
let app = app.clone();
|
|
||||||
Rc::new(move |req| speed_test(app.clone(), req))
|
|
||||||
},
|
|
||||||
);
|
|
||||||
nav.add(&hosts_page);
|
|
||||||
window.present();
|
window.present();
|
||||||
|
|
||||||
// CI screenshot mode: render one scripted, host-free scene and signal readiness
|
// CI screenshot mode: render one scripted, host-free scene and signal readiness
|
||||||
// (clients/linux/tools/screenshots.sh). Mutually exclusive with a real connect.
|
// (clients/linux/tools/screenshots.sh). Mutually exclusive with a real connect.
|
||||||
if let Some(scene) = shot_scene() {
|
if let Some(scene) = crate::cli::shot_scene() {
|
||||||
run_shot(app, &scene);
|
crate::cli::run_shot(app, &scene);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(req) = cli_connect_request() {
|
if let Some(req) = crate::cli::cli_connect_request() {
|
||||||
initiate_connect(app, req);
|
crate::ui_trust::initiate_connect(app, req);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
|
fn load_css() {
|
||||||
fn shot_scene() -> Option<String> {
|
let provider = gtk::CssProvider::new();
|
||||||
std::env::var("PUNKTFUNK_SHOT_SCENE")
|
provider.load_from_string(CSS);
|
||||||
.ok()
|
if let Some(display) = gdk::Display::default() {
|
||||||
.filter(|s| !s.is_empty())
|
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
|
/// Window actions behind the hosts page's header: the primary (hamburger) menu entries
|
||||||
/// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture.
|
/// plus the "+" add-host button and the empty state's call to action.
|
||||||
/// No `NativeClient` or session is created. The stream scene is deliberately absent — its page
|
fn install_actions(app: &Rc<App>, hosts: &Rc<HostsUi>) {
|
||||||
/// requires a live connector (`ui_stream::new` takes an `Arc<NativeClient>`).
|
let add = |name: &str, f: Box<dyn Fn()>| {
|
||||||
fn run_shot(app: Rc<App>, scene: &str) {
|
let action = gio::SimpleAction::new(name, None);
|
||||||
// A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256).
|
action.connect_activate(move |_, _| f());
|
||||||
let mock_req = || ConnectRequest {
|
app.window.add_action(&action);
|
||||||
name: "Living Room PC".to_string(),
|
|
||||||
addr: "192.168.1.42".to_string(),
|
|
||||||
port: 9777,
|
|
||||||
fp_hex: Some(
|
|
||||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(),
|
|
||||||
),
|
|
||||||
pair_optional: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 app = app.clone();
|
||||||
let cancel = cancel.clone();
|
add(
|
||||||
waiting.connect_response(Some("cancel"), move |_, _| {
|
"preferences",
|
||||||
// Return the UI immediately; the in-flight connect is left to time out and is torn
|
Box::new(move || {
|
||||||
// down silently by the event loop (see StartOpts::cancel).
|
let refresh = {
|
||||||
cancel.set(true);
|
let app = app.clone();
|
||||||
app.busy.set(false);
|
// The library toggle changes the saved cards' menu — re-render on close.
|
||||||
app.toast("Cancelled — the request may still be pending on the host.");
|
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(
|
/// The Keyboard Shortcuts window (menu + the shortcuts scene). GtkShortcutsWindow is
|
||||||
app,
|
/// builder-XML-first, so it's assembled from a snippet rather than widget calls.
|
||||||
req,
|
pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow {
|
||||||
pin,
|
const UI: &str = r#"
|
||||||
StartOpts {
|
<interface>
|
||||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
<object class="GtkShortcutsWindow" id="shortcuts">
|
||||||
// approval still lands on this connection rather than timing the client out first.
|
<property name="modal">1</property>
|
||||||
connect_timeout: std::time::Duration::from_secs(185),
|
<child>
|
||||||
persist_paired: true,
|
<object class="GtkShortcutsSection">
|
||||||
waiting: Some(waiting),
|
<child>
|
||||||
cancel: Some(cancel),
|
<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…"):
|
/// 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 {
|
pub struct AudioPlayer {
|
||||||
pcm_tx: SyncSender<Vec<f32>>,
|
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>,
|
quit_tx: pipewire::channel::Sender<Terminate>,
|
||||||
thread: Option<std::thread::JoinHandle<()>>,
|
thread: Option<std::thread::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
@@ -33,22 +36,34 @@ impl AudioPlayer {
|
|||||||
pub fn spawn(channels: u32) -> Result<AudioPlayer> {
|
pub fn spawn(channels: u32) -> Result<AudioPlayer> {
|
||||||
// 64 × 5 ms = 320 ms of slack between the pump and the PipeWire loop.
|
// 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);
|
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 (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||||
let thread = std::thread::Builder::new()
|
let thread = std::thread::Builder::new()
|
||||||
.name("punktfunk-audio".into())
|
.name("punktfunk-audio".into())
|
||||||
.spawn(move || {
|
.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");
|
tracing::warn!(error = %e, "audio playback thread ended");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.context("spawn audio thread")?;
|
.context("spawn audio thread")?;
|
||||||
Ok(AudioPlayer {
|
Ok(AudioPlayer {
|
||||||
pcm_tx,
|
pcm_tx,
|
||||||
|
recycle_rx,
|
||||||
quit_tx,
|
quit_tx,
|
||||||
thread: Some(thread),
|
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
|
/// 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).
|
/// PipeWire side is wedged (the renderer conceals the gap; never block the session pump).
|
||||||
pub fn push(&self, pcm: Vec<f32>) {
|
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.
|
/// Producer-side state: incoming decoded PCM and the ring the process callback drains.
|
||||||
struct PlayerData {
|
struct PlayerData {
|
||||||
rx: Receiver<Vec<f32>>,
|
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>,
|
ring: VecDeque<f32>,
|
||||||
primed: bool,
|
primed: bool,
|
||||||
/// Interleaved channel count this stream was opened with (2/6/8).
|
/// Interleaved channel count this stream was opened with (2/6/8).
|
||||||
@@ -78,6 +95,7 @@ struct PlayerData {
|
|||||||
|
|
||||||
fn pw_thread(
|
fn pw_thread(
|
||||||
pcm_rx: Receiver<Vec<f32>>,
|
pcm_rx: Receiver<Vec<f32>>,
|
||||||
|
recycle_tx: SyncSender<Vec<f32>>,
|
||||||
quit_rx: pipewire::channel::Receiver<Terminate>,
|
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||||
channels: usize,
|
channels: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -117,6 +135,7 @@ fn pw_thread(
|
|||||||
|
|
||||||
let ud = PlayerData {
|
let ud = PlayerData {
|
||||||
rx: pcm_rx,
|
rx: pcm_rx,
|
||||||
|
recycle: recycle_tx,
|
||||||
ring: VecDeque::new(),
|
ring: VecDeque::new(),
|
||||||
primed: false,
|
primed: false,
|
||||||
channels,
|
channels,
|
||||||
@@ -132,8 +151,11 @@ fn pw_thread(
|
|||||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
while let Ok(chunk) = ud.rx.try_recv() {
|
while let Ok(mut chunk) = ud.rx.try_recv() {
|
||||||
ud.ring.extend(chunk);
|
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 stride = 4 * ud.channels; // F32LE interleaved
|
||||||
let datas = buffer.datas_mut();
|
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
|
//! 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
|
//! `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};
|
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ use mdns_sd::{ServiceDaemon, ServiceEvent};
|
|||||||
pub struct DiscoveredHost {
|
pub struct DiscoveredHost {
|
||||||
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
|
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
|
||||||
pub key: String,
|
pub key: String,
|
||||||
|
/// The mDNS service fullname — what a later `Removed` event identifies the advert by.
|
||||||
|
pub fullname: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub addr: String,
|
pub addr: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
@@ -15,11 +18,23 @@ pub struct DiscoveredHost {
|
|||||||
pub fp_hex: String,
|
pub fp_hex: String,
|
||||||
/// Pairing requirement: `"required"` or `"optional"`.
|
/// Pairing requirement: `"required"` or `"optional"`.
|
||||||
pub pair: String,
|
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
|
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||||
/// dropped (the send fails) or the daemon dies.
|
/// 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();
|
let (tx, rx) = async_channel::unbounded();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("punktfunk-mdns".into())
|
.name("punktfunk-mdns".into())
|
||||||
@@ -39,34 +54,42 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
while let Ok(event) = receiver.recv() {
|
while let Ok(event) = receiver.recv() {
|
||||||
if let ServiceEvent::ServiceResolved(info) = event {
|
let update = match event {
|
||||||
let props = info.get_properties();
|
ServiceEvent::ServiceResolved(info) => {
|
||||||
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
|
let props = info.get_properties();
|
||||||
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
|
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
|
||||||
else {
|
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
|
||||||
continue;
|
else {
|
||||||
};
|
continue;
|
||||||
let id = val("id");
|
};
|
||||||
let host = DiscoveredHost {
|
let id = val("id");
|
||||||
key: if id.is_empty() {
|
DiscoveryEvent::Resolved(DiscoveredHost {
|
||||||
info.get_fullname().to_string()
|
key: if id.is_empty() {
|
||||||
} else {
|
info.get_fullname().to_string()
|
||||||
id
|
} else {
|
||||||
},
|
id
|
||||||
name: info
|
},
|
||||||
.get_fullname()
|
fullname: info.get_fullname().to_string(),
|
||||||
.split('.')
|
name: info
|
||||||
.next()
|
.get_fullname()
|
||||||
.unwrap_or("?")
|
.split('.')
|
||||||
.to_string(),
|
.next()
|
||||||
addr,
|
.unwrap_or("?")
|
||||||
port: info.get_port(),
|
.to_string(),
|
||||||
fp_hex: val("fp"),
|
addr,
|
||||||
pair: val("pair"),
|
port: info.get_port(),
|
||||||
};
|
fp_hex: val("fp"),
|
||||||
if tx.send_blocking(host).is_err() {
|
pair: val("pair"),
|
||||||
break; // UI gone — stop browsing
|
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();
|
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.
|
/// 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
|
/// Enable bits select only the fields each update touches, so rumble (driven separately
|
||||||
/// through SDL) and untouched fields keep their state.
|
/// through SDL) and untouched fields keep their state.
|
||||||
#[derive(Default)]
|
|
||||||
struct Ds5Feedback;
|
struct Ds5Feedback;
|
||||||
|
|
||||||
impl Ds5Feedback {
|
impl Ds5Feedback {
|
||||||
@@ -275,8 +274,12 @@ impl Ds5Feedback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Worker {
|
struct Worker<'a> {
|
||||||
subsystem: sdl3::GamepadSubsystem,
|
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>,
|
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
|
||||||
/// Connection order; the most recently connected is the auto selection.
|
/// Connection order; the most recently connected is the auto selection.
|
||||||
order: Vec<u32>,
|
order: Vec<u32>,
|
||||||
@@ -303,7 +306,7 @@ struct Worker {
|
|||||||
disconnect_fired: bool,
|
disconnect_fired: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Worker {
|
impl Worker<'_> {
|
||||||
fn active_id(&self) -> Option<u32> {
|
fn active_id(&self) -> Option<u32> {
|
||||||
self.pinned
|
self.pinned
|
||||||
.filter(|id| self.opened.contains_key(id))
|
.filter(|id| self.opened.contains_key(id))
|
||||||
@@ -489,9 +492,245 @@ impl Worker {
|
|||||||
self.held_touches.remove(&(surface, finger));
|
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(
|
fn run(
|
||||||
pads_out: &Mutex<Vec<PadInfo>>,
|
pads_out: &Mutex<Vec<PadInfo>>,
|
||||||
active_out: &Mutex<Option<PadInfo>>,
|
active_out: &Mutex<Option<PadInfo>>,
|
||||||
@@ -516,6 +755,9 @@ fn run(
|
|||||||
|
|
||||||
let mut w = Worker {
|
let mut w = Worker {
|
||||||
subsystem,
|
subsystem,
|
||||||
|
pads_out,
|
||||||
|
active_out,
|
||||||
|
pinned_out,
|
||||||
opened: HashMap::new(),
|
opened: HashMap::new(),
|
||||||
order: Vec::new(),
|
order: Vec::new(),
|
||||||
pinned: None,
|
pinned: None,
|
||||||
@@ -531,181 +773,25 @@ fn run(
|
|||||||
disconnect_fired: false,
|
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 {
|
loop {
|
||||||
// Control plane from the UI thread.
|
// Control plane from the UI thread.
|
||||||
loop {
|
if !w.drain_ctl(ctl) {
|
||||||
match ctl.try_recv() {
|
return Ok(());
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
while let Some(event) = pump.poll_event() {
|
// Block in SDL's own event wait instead of a fixed-interval sleep+poll: input
|
||||||
use sdl3::event::Event;
|
// events are handled the moment they arrive (the old 2 ms sleep added up to 2 ms
|
||||||
let active = w.active_id();
|
// per event), while the timeout bounds the polled work below — ctl messages,
|
||||||
match event {
|
// rumble/HID feedback, and the escape-chord hold check all run once per wakeup,
|
||||||
Event::ControllerDeviceAdded { which, .. } => {
|
// so their worst case is one timeout (~10 ms attached, imperceptible for
|
||||||
if !w.opened.contains_key(&which) {
|
// haptics; DISCONNECT_HOLD is 1500 ms, so 10 ms hold-check granularity is far
|
||||||
match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) {
|
// inside tolerance). Idle (no session) wakes lazily at 30 ms for hotplug + ctl.
|
||||||
Ok(pad) => {
|
let timeout = Duration::from_millis(if w.attached.is_some() { 10 } else { 30 });
|
||||||
tracing::info!(
|
if let Some(event) = pump.wait_event_timeout(timeout) {
|
||||||
name = pad.name().unwrap_or_default(),
|
w.handle_event(event);
|
||||||
"gamepad attached"
|
// Drain whatever else queued while we were waiting or handling.
|
||||||
);
|
while let Some(event) = pump.poll_event() {
|
||||||
w.opened.insert(which, pad);
|
w.handle_event(event);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,59 +799,6 @@ fn run(
|
|||||||
// new button events; the chord itself is only detected while a session is attached).
|
// new button events; the chord itself is only detected while a session is attached).
|
||||||
w.maybe_fire_disconnect();
|
w.maybe_fire_disconnect();
|
||||||
|
|
||||||
// Feedback planes (this thread is their single consumer). The host re-sends
|
w.render_feedback();
|
||||||
// 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
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")]
|
#[cfg(target_os = "linux")]
|
||||||
mod audio;
|
mod audio;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
mod cli;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
mod discovery;
|
mod discovery;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod gamepad;
|
mod gamepad;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod keymap;
|
mod keymap;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
mod launch;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod library;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
mod session;
|
mod session;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod trust;
|
mod trust;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod ui_hosts;
|
mod ui_hosts;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
mod ui_library;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
mod ui_settings;
|
mod ui_settings;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod ui_stream;
|
mod ui_stream;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
mod ui_trust;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
mod video;
|
mod video;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
//! Session controller: one worker thread runs connect → pump (video pull + decode, audio
|
//! Session controller: the worker thread runs connect → pump (video pull + decode +
|
||||||
//! pull + Opus decode, stats), feeding the GTK main loop over channels. The UI keeps the
|
//! stats), a dedicated audio thread pulls + Opus-decodes the audio plane (Apple
|
||||||
//! `Arc<NativeClient>` from the `Connected` event for direct input sends (no extra hop on
|
//! `SessionAudio` parity — audio never waits behind a video decode), both feeding the GTK
|
||||||
//! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread:
|
//! main loop / PipeWire over channels. The UI keeps the `Arc<NativeClient>` from the
|
||||||
//! video+audio here, rumble+hidout on the gamepad thread.
|
//! `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::audio;
|
||||||
use crate::video::{DecodedFrame, Decoder};
|
use crate::video::{DecodedFrame, DecodedImage, Decoder};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
use punktfunk_core::PunktfunkError;
|
use punktfunk_core::PunktfunkError;
|
||||||
@@ -27,6 +29,12 @@ pub struct SessionParams {
|
|||||||
pub preferred_codec: u8,
|
pub preferred_codec: u8,
|
||||||
/// Stream the default microphone to the host's virtual mic source.
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
pub mic_enabled: bool,
|
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).
|
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||||
pub pin: Option<[u8; 32]>,
|
pub pin: Option<[u8; 32]>,
|
||||||
pub identity: (String, String),
|
pub identity: (String, String),
|
||||||
@@ -44,6 +52,9 @@ pub struct Stats {
|
|||||||
pub decode_ms: f32,
|
pub decode_ms: f32,
|
||||||
/// Median capture→decoded latency over the last window (host-clock corrected).
|
/// Median capture→decoded latency over the last window (host-clock corrected).
|
||||||
pub latency_ms: f32,
|
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 {
|
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()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_nanos() as u64)
|
.map(|d| d.as_nanos() as u64)
|
||||||
@@ -146,7 +157,7 @@ fn pump(
|
|||||||
params.audio_channels,
|
params.audio_channels,
|
||||||
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
|
||||||
params.preferred_codec, // the user's soft codec preference (0 = auto)
|
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,
|
params.pin,
|
||||||
Some(params.identity),
|
Some(params.identity),
|
||||||
params.connect_timeout,
|
params.connect_timeout,
|
||||||
@@ -175,14 +186,15 @@ fn pump(
|
|||||||
fingerprint: connector.host_fingerprint,
|
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);
|
let codec_id = crate::video::ffmpeg_codec_id(connector.codec);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
?codec_id,
|
?codec_id,
|
||||||
welcome_codec = connector.codec,
|
welcome_codec = connector.codec,
|
||||||
"negotiated video 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,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {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
|
// 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
|
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||||
// from the host-RESOLVED channel count (never the request), so an older/clamping host that
|
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||||
// resolves stereo is decoded as stereo.
|
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||||
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();
|
|
||||||
let _mic = params
|
let _mic = params
|
||||||
.mic_enabled
|
.mic_enabled
|
||||||
.then(|| {
|
.then(|| {
|
||||||
@@ -216,8 +221,10 @@ fn pump(
|
|||||||
let mut bytes_n = 0u64;
|
let mut bytes_n = 0u64;
|
||||||
let mut decode_us_sum = 0u64;
|
let mut decode_us_sum = 0u64;
|
||||||
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
|
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
|
// What actually decoded the last frame — a VAAPI failure demotes mid-session, so
|
||||||
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it climbs.
|
// 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_dropped = connector.frames_dropped();
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
|
|
||||||
@@ -225,16 +232,23 @@ fn pump(
|
|||||||
if stop.load(Ordering::SeqCst) {
|
if stop.load(Ordering::SeqCst) {
|
||||||
break None;
|
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) => {
|
Ok(frame) => {
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
match decoder.decode(&frame.data) {
|
match decoder.decode(&frame.data) {
|
||||||
Ok(Some(decoded)) => {
|
Ok(Some(image)) => {
|
||||||
total_frames += 1;
|
total_frames += 1;
|
||||||
|
dec_path = match &image {
|
||||||
|
DecodedImage::Cpu(_) => "software",
|
||||||
|
DecodedImage::Dmabuf(_) => "vaapi",
|
||||||
|
};
|
||||||
if total_frames == 1 {
|
if total_frames == 1 {
|
||||||
let (w, h, path) = match &decoded {
|
let (w, h, path) = match &image {
|
||||||
DecodedFrame::Cpu(c) => (c.width, c.height, "software"),
|
DecodedImage::Cpu(c) => (c.width, c.height, "software"),
|
||||||
DecodedFrame::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"),
|
DecodedImage::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"),
|
||||||
};
|
};
|
||||||
tracing::info!(width = w, height = h, path, "first frame decoded");
|
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;
|
decode_us_sum += t0.elapsed().as_micros() as u64;
|
||||||
frames_n += 1;
|
frames_n += 1;
|
||||||
bytes_n += frame.data.len() as u64;
|
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) => {}
|
Ok(None) => {}
|
||||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
// 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) {
|
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||||
let secs = window_start.elapsed().as_secs_f32();
|
let secs = window_start.elapsed().as_secs_f32();
|
||||||
lat_us.sort_unstable();
|
lat_us.sort_unstable();
|
||||||
@@ -306,6 +312,7 @@ fn pump(
|
|||||||
0.0
|
0.0
|
||||||
},
|
},
|
||||||
latency_ms: p50 as f32 / 1000.0,
|
latency_ms: p50 as f32 / 1000.0,
|
||||||
|
decoder: dec_path,
|
||||||
}));
|
}));
|
||||||
window_start = Instant::now();
|
window_start = Instant::now();
|
||||||
frames_n = 0;
|
frames_n = 0;
|
||||||
@@ -321,5 +328,52 @@ fn pump(
|
|||||||
"session ended"
|
"session ended"
|
||||||
);
|
);
|
||||||
stop.store(true, Ordering::SeqCst);
|
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));
|
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.
|
//! so a box pairs once whichever client it uses.
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
use punktfunk_core::quic::endpoint;
|
use punktfunk_core::quic::endpoint;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -55,6 +56,10 @@ pub struct KnownHost {
|
|||||||
pub fp_hex: String,
|
pub fp_hex: String,
|
||||||
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
||||||
pub paired: bool,
|
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)]
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
@@ -106,12 +111,64 @@ impl KnownHosts {
|
|||||||
h.addr = entry.addr;
|
h.addr = entry.addr;
|
||||||
h.port = entry.port;
|
h.port = entry.port;
|
||||||
h.paired |= entry.paired;
|
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 {
|
} else {
|
||||||
self.hosts.push(entry);
|
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
|
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[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.
|
/// preference — the host honors it when it can emit it, else falls back to the best shared codec.
|
||||||
#[serde(default = "default_codec")]
|
#[serde(default = "default_codec")]
|
||||||
pub codec: String,
|
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 {
|
fn default_codec() -> String {
|
||||||
@@ -170,6 +235,9 @@ impl Default for Settings {
|
|||||||
mic_enabled: false,
|
mic_enabled: false,
|
||||||
audio_channels: 2,
|
audio_channels: 2,
|
||||||
codec: "auto".into(),
|
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::discovery::{self, DiscoveredHost, DiscoveryEvent};
|
||||||
use crate::trust::KnownHosts;
|
use crate::trust::{KnownHost, KnownHosts, Settings};
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::glib;
|
use gtk::{gio, glib};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -20,252 +25,158 @@ pub struct ConnectRequest {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub fp_hex: Option<String>,
|
pub fp_hex: Option<String>,
|
||||||
pub pair_optional: bool,
|
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(
|
impl ConnectRequest {
|
||||||
on_connect: Rc<dyn Fn(ConnectRequest)>,
|
/// The key the hosts page tracks an in-flight connect under (the card that swaps its
|
||||||
on_settings: Rc<dyn Fn()>,
|
/// avatar for a spinner): the fingerprint when known, else the address.
|
||||||
on_speed_test: Rc<dyn Fn(ConnectRequest)>,
|
pub fn card_key(&self) -> String {
|
||||||
) -> adw::NavigationPage {
|
self.fp_hex
|
||||||
let list = gtk::ListBox::new();
|
.clone()
|
||||||
list.add_css_class("boxed-list");
|
.unwrap_or_else(|| format!("{}:{}", self.addr, self.port))
|
||||||
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));
|
|
||||||
|
|
||||||
// key → (row, latest advert); the activation closure looks the advert up by key so
|
/// The actions the page hands off to the app shell (trust gate, speed test, PIN pairing,
|
||||||
// re-adverts (new address, pairing flipped) take effect without rebuilding rows.
|
/// the library browser).
|
||||||
type Rows = Rc<RefCell<HashMap<String, (adw::ActionRow, DiscoveredHost)>>>;
|
pub struct HostsCallbacks {
|
||||||
let rows: Rows = Rc::new(RefCell::new(HashMap::new()));
|
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)>,
|
||||||
|
}
|
||||||
|
|
||||||
{
|
/// The page plus the handle the launch path drives: connect-failure banner and the
|
||||||
let rx = discovery::browse();
|
/// per-card connecting spinner. Held by the `App` (`app.hosts_ui()`).
|
||||||
let rows = rows.clone();
|
pub struct HostsUi {
|
||||||
let list = list.downgrade();
|
pub page: adw::NavigationPage,
|
||||||
let on_connect = on_connect.clone();
|
state: Rc<State>,
|
||||||
glib::spawn_future_local(async move {
|
}
|
||||||
while let Ok(host) = rx.recv().await {
|
|
||||||
let Some(list) = list.upgrade() else { break };
|
impl HostsUi {
|
||||||
let mut map = rows.borrow_mut();
|
/// Surface a connect failure at the top of the page (dismissible; replaces raw-error toasts).
|
||||||
let subtitle = format!(
|
pub fn show_error(&self, msg: &str) {
|
||||||
"{}:{} · pairing {}",
|
self.state.banner.set_title(msg);
|
||||||
host.addr,
|
self.state.banner.set_revealed(true);
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual connect: host:port (punktfunk/1 default port 9777).
|
pub fn clear_error(&self) {
|
||||||
let manual = adw::EntryRow::builder().title("host:port").build();
|
self.state.banner.set_revealed(false);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
manual.connect_entry_activated(move |_| submit());
|
|
||||||
|
|
||||||
let manual_list = gtk::ListBox::new();
|
/// Mark the card matching `key` (see `ConnectRequest::card_key`) as connecting —
|
||||||
manual_list.add_css_class("boxed-list");
|
/// spinner in place of the avatar, card insensitive. `None` restores all cards.
|
||||||
manual_list.set_selection_mode(gtk::SelectionMode::None);
|
pub fn set_connecting(&self, key: Option<String>) {
|
||||||
manual_list.append(&manual);
|
*self.state.connecting.borrow_mut() = key;
|
||||||
|
rebuild(&self.state);
|
||||||
|
}
|
||||||
|
|
||||||
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. Rebuilt every time
|
/// Feed one advert through the same path the mDNS stream uses (CI screenshot scenes).
|
||||||
// the page is shown, so fresh TOFU/pairing entries appear on return.
|
pub fn inject_advert(&self, host: DiscoveredHost) {
|
||||||
let saved_label = gtk::Label::new(Some("Saved hosts"));
|
self.state
|
||||||
saved_label.add_css_class("heading");
|
.adverts
|
||||||
saved_label.set_halign(gtk::Align::Start);
|
.borrow_mut()
|
||||||
let saved_list = gtk::ListBox::new();
|
.insert(host.key.clone(), host);
|
||||||
saved_list.add_css_class("boxed-list");
|
rebuild(&self.state);
|
||||||
saved_list.set_selection_mode(gtk::SelectionMode::None);
|
}
|
||||||
let rebuild_saved = {
|
|
||||||
let saved_list = saved_list.clone();
|
/// The "+" add-host dialog (name optional / address / port), also reachable from the
|
||||||
let saved_label = saved_label.clone();
|
/// empty state. Reuses the manual-connect plumbing: submit runs the trust gate.
|
||||||
let on_connect = on_connect.clone();
|
pub fn show_add_host(&self) {
|
||||||
let on_speed_test = on_speed_test.clone();
|
add_host_dialog(&self.state);
|
||||||
move || {
|
}
|
||||||
saved_list.remove_all();
|
|
||||||
let known = KnownHosts::load();
|
/// Re-render both grids (e.g. the library toggle changed in Preferences, which adds/
|
||||||
saved_label.set_visible(!known.hosts.is_empty());
|
/// removes the saved cards' "Browse library…" menu item).
|
||||||
saved_list.set_visible(!known.hosts.is_empty());
|
pub fn refresh(&self) {
|
||||||
for k in &known.hosts {
|
rebuild(&self.state);
|
||||||
let row = adw::ActionRow::builder()
|
}
|
||||||
.title(&k.name)
|
|
||||||
.subtitle(format!(
|
/// 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).
|
||||||
k.addr,
|
pub fn mgmt_port_for(&self, req: &ConnectRequest) -> Option<u16> {
|
||||||
k.port,
|
let adverts = self.state.adverts.borrow();
|
||||||
if k.paired {
|
adverts
|
||||||
" · paired"
|
.values()
|
||||||
} else {
|
.find(|a| {
|
||||||
" · trusted"
|
req.fp_hex
|
||||||
}
|
.as_deref()
|
||||||
))
|
.is_some_and(|fp| !a.fp_hex.is_empty() && a.fp_hex == fp)
|
||||||
.activatable(true)
|
|| (a.addr == req.addr && a.port == req.port)
|
||||||
.build();
|
})
|
||||||
let req = ConnectRequest {
|
.and_then(|a| a.mgmt_port)
|
||||||
name: k.name.clone(),
|
}
|
||||||
addr: k.addr.clone(),
|
}
|
||||||
port: k.port,
|
|
||||||
fp_hex: Some(k.fp_hex.clone()),
|
/// Everything the grids re-render from, plus the widgets they render into.
|
||||||
// Saved host: its fp is already pinned, so this routes to a silent
|
struct State {
|
||||||
// pinned connect; TOFU eligibility is irrelevant.
|
stack: gtk::Stack,
|
||||||
pair_optional: false,
|
banner: adw::Banner,
|
||||||
};
|
saved_heading: gtk::Label,
|
||||||
// Forget this host (drops the pinned fingerprint — a later connect re-pairs).
|
saved_flow: gtk::FlowBox,
|
||||||
// Confirmed first, since it's destructive and a misclick on the Deck is easy.
|
disc_flow: gtk::FlowBox,
|
||||||
let remove_btn = gtk::Button::from_icon_name("user-trash-symbolic");
|
searching: gtk::Box,
|
||||||
remove_btn.set_tooltip_text(Some("Remove saved host"));
|
/// Live mDNS adverts, keyed by the advert key — the source for the discovered grid,
|
||||||
remove_btn.set_valign(gtk::Align::Center);
|
/// the saved cards' online pips, and dedup.
|
||||||
remove_btn.add_css_class("flat");
|
adverts: RefCell<HashMap<String, DiscoveredHost>>,
|
||||||
{
|
/// `card_key` of the connect currently in flight, if any.
|
||||||
let fp = k.fp_hex.clone();
|
connecting: RefCell<Option<String>>,
|
||||||
let name = k.name.clone();
|
/// App settings — read on every rebuild for the experimental library-item gate.
|
||||||
let saved_list = saved_list.clone();
|
settings: Rc<RefCell<Settings>>,
|
||||||
let saved_label = saved_label.clone();
|
cbs: HostsCallbacks,
|
||||||
let row = row.clone();
|
}
|
||||||
remove_btn.connect_clicked(move |_| {
|
|
||||||
let dialog = adw::AlertDialog::new(
|
pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
|
||||||
Some("Remove saved host?"),
|
let make_flow = || {
|
||||||
Some(&format!(
|
gtk::FlowBox::builder()
|
||||||
"Forget “{name}”? You'll need to pair (or trust) it again to reconnect."
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
)),
|
.activate_on_single_click(true)
|
||||||
);
|
.homogeneous(true)
|
||||||
dialog.add_responses(&[("cancel", "Cancel"), ("remove", "Remove")]);
|
.min_children_per_line(1)
|
||||||
dialog.set_response_appearance(
|
.max_children_per_line(4)
|
||||||
"remove",
|
.column_spacing(12)
|
||||||
adw::ResponseAppearance::Destructive,
|
.row_spacing(12)
|
||||||
);
|
.build()
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
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_top(24);
|
||||||
content.set_margin_bottom(24);
|
content.set_margin_bottom(24);
|
||||||
content.set_margin_start(12);
|
content.set_margin_start(12);
|
||||||
content.set_margin_end(12);
|
content.set_margin_end(12);
|
||||||
content.append(&saved_label);
|
content.append(&saved_heading);
|
||||||
content.append(&saved_list);
|
content.append(&saved_flow);
|
||||||
let discovered_label = gtk::Label::new(Some("Hosts on this network"));
|
content.append(&disc_heading);
|
||||||
discovered_label.add_css_class("heading");
|
content.append(&searching);
|
||||||
discovered_label.set_halign(gtk::Align::Start);
|
content.append(&disc_flow);
|
||||||
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);
|
|
||||||
|
|
||||||
let clamp = adw::Clamp::builder()
|
let clamp = adw::Clamp::builder()
|
||||||
.maximum_size(560)
|
.maximum_size(1100)
|
||||||
.child(&content)
|
.child(&content)
|
||||||
.build();
|
.build();
|
||||||
let scrolled = gtk::ScrolledWindow::builder()
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
@@ -273,21 +184,489 @@ pub fn new(
|
|||||||
.child(&clamp)
|
.child(&clamp)
|
||||||
.build();
|
.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 header = adw::HeaderBar::new();
|
||||||
let settings_btn = gtk::Button::from_icon_name("preferences-system-symbolic");
|
let add_host_btn = gtk::Button::from_icon_name("list-add-symbolic");
|
||||||
settings_btn.set_tooltip_text(Some("Preferences"));
|
add_host_btn.set_tooltip_text(Some("Add host"));
|
||||||
settings_btn.connect_clicked(move |_| on_settings());
|
add_host_btn.set_action_name(Some("win.add-host"));
|
||||||
header.pack_end(&settings_btn);
|
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();
|
let toolbar = adw::ToolbarView::new();
|
||||||
toolbar.add_top_bar(&header);
|
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()
|
let page = adw::NavigationPage::builder()
|
||||||
.title("Punktfunk")
|
.title("Punktfunk")
|
||||||
.tag("hosts")
|
.tag("hosts")
|
||||||
.child(&toolbar)
|
.child(&toolbar)
|
||||||
.build();
|
.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.
|
/// Codec setting values (persisted) paired with their display labels below.
|
||||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||||||
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "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.
|
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||||
const APP_LICENSE: &str = concat!(
|
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).
|
/// 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");
|
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||||||
|
|
||||||
/// Show the About dialog (app license + the third-party-software Legal section).
|
/// Show the About dialog (app license + the third-party-software Legal section) — reached
|
||||||
fn show_about(parent: &impl IsA<gtk::Widget>) {
|
/// from the primary menu (app.rs `win.about`).
|
||||||
|
pub fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||||
let about = adw::AboutDialog::builder()
|
let about = adw::AboutDialog::builder()
|
||||||
.application_name("punktfunk")
|
.application_name("punktfunk")
|
||||||
.developer_name("unom")
|
.developer_name("unom")
|
||||||
@@ -65,10 +67,13 @@ fn show_about(parent: &impl IsA<gtk::Widget>) {
|
|||||||
about.present(Some(parent));
|
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(
|
pub fn show(
|
||||||
parent: &impl IsA<gtk::Widget>,
|
parent: &impl IsA<gtk::Widget>,
|
||||||
settings: Rc<RefCell<Settings>>,
|
settings: Rc<RefCell<Settings>>,
|
||||||
gamepads: &crate::gamepad::GamepadService,
|
gamepads: &crate::gamepad::GamepadService,
|
||||||
|
on_closed: impl Fn() + 'static,
|
||||||
) {
|
) {
|
||||||
let page = adw::PreferencesPage::new();
|
let page = adw::PreferencesPage::new();
|
||||||
|
|
||||||
@@ -120,10 +125,25 @@ pub fn show(
|
|||||||
"gamescope",
|
"gamescope",
|
||||||
]))
|
]))
|
||||||
.build();
|
.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(&res_row);
|
||||||
stream.add(&hz_row);
|
stream.add(&hz_row);
|
||||||
stream.add(&bitrate_row);
|
stream.add(&bitrate_row);
|
||||||
stream.add(&compositor_row);
|
stream.add(&compositor_row);
|
||||||
|
stream.add(&decoder_row);
|
||||||
|
stream.add(&stats_row);
|
||||||
|
|
||||||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||||
// Which physical controller forwards as pad 0: automatic = the most recently
|
// Which physical controller forwards as pad 0: automatic = the most recently
|
||||||
@@ -208,23 +228,24 @@ pub fn show(
|
|||||||
.build();
|
.build();
|
||||||
audio.add(&mic_row);
|
audio.add(&mic_row);
|
||||||
|
|
||||||
let about = adw::PreferencesGroup::builder().title("About").build();
|
// Experimental — mirrors the Apple client's Experimental section (wording included).
|
||||||
let licenses_row = adw::ActionRow::builder()
|
let experimental = adw::PreferencesGroup::builder()
|
||||||
.title("Third-party licenses")
|
.title("Experimental")
|
||||||
.subtitle("Open-source software used by punktfunk")
|
|
||||||
.activatable(true)
|
|
||||||
.build();
|
.build();
|
||||||
licenses_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
let library_row = adw::SwitchRow::builder()
|
||||||
{
|
.title("Show game library")
|
||||||
let about_parent: gtk::Widget = parent.clone().upcast();
|
.subtitle(
|
||||||
licenses_row.connect_activated(move |_| show_about(&about_parent));
|
"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",
|
||||||
about.add(&licenses_row);
|
)
|
||||||
|
.build();
|
||||||
|
experimental.add(&library_row);
|
||||||
|
|
||||||
|
// About (with the license/third-party Legal pages) lives in the primary menu now.
|
||||||
page.add(&stream);
|
page.add(&stream);
|
||||||
page.add(&input);
|
page.add(&input);
|
||||||
page.add(&audio);
|
page.add(&audio);
|
||||||
page.add(&about);
|
page.add(&experimental);
|
||||||
|
|
||||||
// Seed from the current settings.
|
// Seed from the current settings.
|
||||||
{
|
{
|
||||||
@@ -244,8 +265,12 @@ pub fn show(
|
|||||||
.position(|&c| c == s.compositor)
|
.position(|&c| c == s.compositor)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
compositor_row.set_selected(comp_i as u32);
|
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);
|
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||||
mic_row.set_active(s.mic_enabled);
|
mic_row.set_active(s.mic_enabled);
|
||||||
|
library_row.set_active(s.library_enabled);
|
||||||
surround_row.set_selected(match s.audio_channels {
|
surround_row.set_selected(match s.audio_channels {
|
||||||
6 => 1,
|
6 => 1,
|
||||||
8 => 2,
|
8 => 2,
|
||||||
@@ -267,6 +292,8 @@ pub fn show(
|
|||||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
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)]
|
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||||
.to_string();
|
.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.inhibit_shortcuts = inhibit_row.is_active();
|
||||||
s.mic_enabled = mic_row.is_active();
|
s.mic_enabled = mic_row.is_active();
|
||||||
s.audio_channels = match surround_row.selected() {
|
s.audio_channels = match surround_row.selected() {
|
||||||
@@ -275,7 +302,10 @@ pub fn show(
|
|||||||
_ => 2,
|
_ => 2,
|
||||||
};
|
};
|
||||||
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
|
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
|
||||||
|
s.library_enabled = library_row.is_active();
|
||||||
s.save();
|
s.save();
|
||||||
|
drop(s);
|
||||||
|
on_closed();
|
||||||
});
|
});
|
||||||
dialog.present(Some(parent));
|
dialog.present(Some(parent));
|
||||||
}
|
}
|
||||||
|
|||||||
+469
-267
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
use crate::keymap;
|
use crate::keymap;
|
||||||
use crate::session::Stats;
|
use crate::session::Stats;
|
||||||
use crate::video::DecodedFrame;
|
use crate::video::{DecodedFrame, DecodedImage};
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::{gdk, glib};
|
use gtk::{gdk, glib};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
@@ -26,21 +26,55 @@ use std::collections::HashSet;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
pub struct StreamPage {
|
pub struct StreamPage {
|
||||||
pub page: adw::NavigationPage,
|
pub page: adw::NavigationPage,
|
||||||
stats_label: gtk::Label,
|
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 {
|
impl StreamPage {
|
||||||
pub fn update_stats(&self, s: Stats) {
|
pub fn update_stats(&self, s: Stats) {
|
||||||
self.stats_label.set_text(&format!(
|
let mut line = format!(
|
||||||
"{:.0} fps · {:.1} Mbit/s · dec {:.1} ms · lat {:.1} ms",
|
"{:.0} fps · {:.1} Mbit/s · dec {:.1} ms · lat {:.1} ms · present {:.1} ms",
|
||||||
s.fps, s.mbps, s.decode_ms, s.latency_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) {
|
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||||
let _ = connector.send_input(&InputEvent {
|
let _ = connector.send_input(&InputEvent {
|
||||||
kind,
|
kind,
|
||||||
@@ -77,12 +111,26 @@ struct Capture {
|
|||||||
hint: gtk::Label,
|
hint: gtk::Label,
|
||||||
inhibit_shortcuts: bool,
|
inhibit_shortcuts: bool,
|
||||||
captured: Cell<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.
|
/// VKs / GameStream button ids currently held — flushed up on release.
|
||||||
held_keys: RefCell<HashSet<u8>>,
|
held_keys: RefCell<HashSet<u8>>,
|
||||||
held_buttons: RefCell<HashSet<u32>>,
|
held_buttons: RefCell<HashSet<u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Capture {
|
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) {
|
fn engage(&self) {
|
||||||
if self.captured.replace(true) {
|
if self.captured.replace(true) {
|
||||||
return;
|
return;
|
||||||
@@ -107,6 +155,7 @@ impl Capture {
|
|||||||
}
|
}
|
||||||
self.overlay.set_cursor(None);
|
self.overlay.set_cursor(None);
|
||||||
self.hint.set_visible(true);
|
self.hint.set_visible(true);
|
||||||
|
self.pending_abs.set(None); // never flush motion gathered while captured
|
||||||
if let Some(tl) = self
|
if let Some(tl) = self
|
||||||
.window
|
.window
|
||||||
.surface()
|
.surface()
|
||||||
@@ -124,17 +173,72 @@ impl Capture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
|
pub fn new(args: StreamPageArgs) -> StreamPage {
|
||||||
pub fn new(
|
let StreamPageArgs {
|
||||||
window: &adw::ApplicationWindow,
|
window,
|
||||||
connector: Arc<NativeClient>,
|
connector,
|
||||||
frames: async_channel::Receiver<DecodedFrame>,
|
frames,
|
||||||
escape_rx: async_channel::Receiver<()>,
|
clock_offset_ns,
|
||||||
disconnect_rx: async_channel::Receiver<()>,
|
escape_rx,
|
||||||
stop: Arc<AtomicBool>,
|
disconnect_rx,
|
||||||
inhibit_shortcuts: bool,
|
stop,
|
||||||
title: &str,
|
inhibit_shortcuts,
|
||||||
) -> StreamPage {
|
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();
|
let picture = gtk::Picture::new();
|
||||||
picture.set_content_fit(gtk::ContentFit::Contain);
|
picture.set_content_fit(gtk::ContentFit::Contain);
|
||||||
|
|
||||||
@@ -153,7 +257,7 @@ pub fn new(
|
|||||||
stats_label.set_margin_top(12);
|
stats_label.set_margin_top(12);
|
||||||
|
|
||||||
let hint = gtk::Label::new(Some(
|
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.add_css_class("osd");
|
||||||
hint.set_halign(gtk::Align::Center);
|
hint.set_halign(gtk::Align::Center);
|
||||||
@@ -180,17 +284,6 @@ pub fn new(
|
|||||||
overlay.add_overlay(&fs_hint);
|
overlay.add_overlay(&fs_hint);
|
||||||
overlay.set_focusable(true);
|
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 header = adw::HeaderBar::new();
|
||||||
let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic");
|
let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic");
|
||||||
fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)"));
|
fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)"));
|
||||||
@@ -233,270 +326,379 @@ pub fn new(
|
|||||||
.child(&toolbar)
|
.child(&toolbar)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// --- Frame consumer: newest texture wins, set on the GTK frame clock's cadence. ---
|
PageWidgets {
|
||||||
{
|
picture,
|
||||||
let picture = picture.downgrade();
|
stats_label,
|
||||||
// The host encodes BT.709 limited-range; without an explicit color state GDK
|
hint,
|
||||||
// would convert NV12 dmabufs with the (BT.601) dmabuf default.
|
overlay,
|
||||||
let rec709 = {
|
page,
|
||||||
let cicp = gdk::CicpParams::new();
|
fs_handler,
|
||||||
cicp.set_color_primaries(1);
|
}
|
||||||
cicp.set_transfer_function(1);
|
}
|
||||||
cicp.set_matrix_coefficients(1);
|
|
||||||
cicp.set_range(gdk::CicpRange::Narrow);
|
/// Frame consumer: each decoded frame becomes the picture's paintable as soon as it
|
||||||
cicp.build_color_state().ok()
|
/// 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
|
||||||
glib::spawn_future_local(async move {
|
/// channel closes or the picture is gone.
|
||||||
while let Ok(f) = frames.recv().await {
|
///
|
||||||
let Some(picture) = picture.upgrade() else {
|
/// Also the capture→present-ish measurement point: at each paintable set the frame's
|
||||||
break;
|
/// 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
|
||||||
match f {
|
/// capture→paintable-SET — GTK's own present adds one compositor cycle after this. The
|
||||||
DecodedFrame::Cpu(c) => {
|
/// 1 s p50 lands on the stats OSD (via `present_ms`) and in a "present window" debug
|
||||||
let bytes = glib::Bytes::from_owned(c.rgba);
|
/// line for headless validation.
|
||||||
let tex = gdk::MemoryTexture::new(
|
fn spawn_frame_consumer(
|
||||||
c.width as i32,
|
picture: >k::Picture,
|
||||||
c.height as i32,
|
frames: async_channel::Receiver<DecodedFrame>,
|
||||||
gdk::MemoryFormat::R8g8b8a8,
|
clock_offset_ns: i64,
|
||||||
&bytes,
|
present_ms: Rc<Cell<f32>>,
|
||||||
c.stride,
|
) {
|
||||||
);
|
let picture = picture.downgrade();
|
||||||
picture.set_paintable(Some(&tex));
|
// 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 guard = d.guard;
|
||||||
let mut b = gdk::DmabufTextureBuilder::new()
|
// GDK runs the release func whether the import succeeds or not.
|
||||||
.set_display(&picture.display())
|
match unsafe { b.build_with_release_func(move || drop(guard)) } {
|
||||||
.set_width(d.width)
|
Ok(tex) => {
|
||||||
.set_height(d.height)
|
picture.set_paintable(Some(&tex));
|
||||||
.set_fourcc(d.fourcc)
|
presented = true;
|
||||||
.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;
|
Err(e) => {
|
||||||
// GDK runs the release func whether the import succeeds or not.
|
// Import rejected (format/modifier) — surfaces once per
|
||||||
match unsafe { b.build_with_release_func(move || drop(guard)) } {
|
// session in practice; the stream continues on the next
|
||||||
Ok(tex) => picture.set_paintable(Some(&tex)),
|
// frame, and PUNKTFUNK_DECODER=software is the escape.
|
||||||
Err(e) => {
|
tracing::warn!(error = %e, "dmabuf texture import failed");
|
||||||
// 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 ---
|
/// 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
|
||||||
let key = gtk::EventControllerKey::new();
|
/// a VK on the wire while captured.
|
||||||
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
fn attach_keyboard(
|
||||||
let cap = capture.clone();
|
overlay: >k::Overlay,
|
||||||
let window_k = window.clone();
|
window: &adw::ApplicationWindow,
|
||||||
let stop_kb = stop.clone();
|
capture: &Rc<Capture>,
|
||||||
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
stop: &Arc<AtomicBool>,
|
||||||
let chord = gdk::ModifierType::CONTROL_MASK
|
stats: >k::Label,
|
||||||
| gdk::ModifierType::ALT_MASK
|
) {
|
||||||
| gdk::ModifierType::SHIFT_MASK;
|
let key = gtk::EventControllerKey::new();
|
||||||
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
|
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||||
if cap.captured.get() {
|
let cap = capture.clone();
|
||||||
cap.release();
|
let window_k = window.clone();
|
||||||
} else {
|
let stop_kb = stop.clone();
|
||||||
cap.engage();
|
let stats = stats.clone();
|
||||||
}
|
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
||||||
return glib::Propagation::Stop;
|
let chord = gdk::ModifierType::CONTROL_MASK
|
||||||
}
|
| gdk::ModifierType::ALT_MASK
|
||||||
// Ctrl+Alt+Shift+D — leave the session. Now that Steam / QAM pass through to the host,
|
| gdk::ModifierType::SHIFT_MASK;
|
||||||
// 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::q {
|
||||||
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| {
|
|
||||||
if cap.captured.get() {
|
if cap.captured.get() {
|
||||||
send_abs(&cap.overlay, &cap.connector, x, y);
|
cap.release();
|
||||||
|
} else {
|
||||||
|
cap.engage();
|
||||||
}
|
}
|
||||||
});
|
return glib::Propagation::Stop;
|
||||||
overlay.add_controller(motion);
|
}
|
||||||
}
|
// 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.
|
||||||
let click = gtk::GestureClick::builder().button(0).build();
|
if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
|
||||||
let cap = capture.clone();
|
cap.release();
|
||||||
click.connect_pressed(move |g, _n, x, y| {
|
stop_kb.store(true, Ordering::SeqCst);
|
||||||
cap.overlay.grab_focus();
|
return glib::Propagation::Stop;
|
||||||
if !cap.captured.get() {
|
}
|
||||||
cap.engage(); // the engaging click is suppressed toward the host
|
// Ctrl+Alt+Shift+S — toggle the stats OSD live (initial state = Settings).
|
||||||
return;
|
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);
|
return glib::Propagation::Stop;
|
||||||
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
|
}
|
||||||
cap.held_buttons.borrow_mut().insert(gs);
|
if !cap.captured.get() {
|
||||||
send(&cap.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
|
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| {
|
overlay.add_controller(key);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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();
|
let cap = capture.clone();
|
||||||
overlay.connect_map(move |w| {
|
overlay.connect_map(move |w| {
|
||||||
w.grab_focus();
|
w.grab_focus();
|
||||||
cap.engage();
|
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();
|
let cap = capture.clone();
|
||||||
overlay.connect_unmap(move |_| cap.release());
|
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
|
/// 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
|
/// 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.
|
/// chrome). Aborted on page-hidden so a stale future can't act on the shared window.
|
||||||
let escape_future = {
|
fn spawn_escape_watch(
|
||||||
let window = window.clone();
|
window: &adw::ApplicationWindow,
|
||||||
let cap = capture.clone();
|
capture: &Rc<Capture>,
|
||||||
glib::spawn_future_local(async move {
|
escape_rx: async_channel::Receiver<()>,
|
||||||
while escape_rx.recv().await.is_ok() {
|
) -> glib::JoinHandle<()> {
|
||||||
if window.is_fullscreen() {
|
let window = window.clone();
|
||||||
window.unfullscreen();
|
let cap = capture.clone();
|
||||||
}
|
glib::spawn_future_local(async move {
|
||||||
cap.release();
|
while escape_rx.recv().await.is_ok() {
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
if window.is_fullscreen() {
|
if window.is_fullscreen() {
|
||||||
window.unfullscreen();
|
window.unfullscreen();
|
||||||
}
|
}
|
||||||
stop_h.store(true, Ordering::SeqCst);
|
cap.release();
|
||||||
});
|
}
|
||||||
}
|
})
|
||||||
|
}
|
||||||
StreamPage { page, stats_label }
|
|
||||||
|
/// 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::os::fd::RawFd;
|
||||||
use std::ptr;
|
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),
|
Cpu(CpuFrame),
|
||||||
Dmabuf(DmabufFrame),
|
Dmabuf(DmabufFrame),
|
||||||
}
|
}
|
||||||
@@ -108,9 +117,17 @@ pub fn decodable_codecs() -> u8 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Decoder {
|
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")?;
|
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" {
|
if choice != "software" {
|
||||||
match VaapiDecoder::new(codec_id) {
|
match VaapiDecoder::new(codec_id) {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
@@ -138,17 +155,17 @@ impl Decoder {
|
|||||||
/// one-in/one-out). A software decode error after packet loss is survivable — log
|
/// 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
|
/// 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.
|
/// 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 {
|
match &mut self.backend {
|
||||||
Backend::Vaapi(v) => match v.decode(au) {
|
Backend::Vaapi(v) => match v.decode(au) {
|
||||||
Ok(f) => Ok(f.map(DecodedFrame::Dmabuf)),
|
Ok(f) => Ok(f.map(DecodedImage::Dmabuf)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software");
|
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software");
|
||||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||||
Ok(None)
|
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));
|
self.sws = Some((ctx, fmt, w, h));
|
||||||
}
|
}
|
||||||
let (sws, ..) = self.sws.as_mut().unwrap();
|
let (sws, ..) = self.sws.as_mut().unwrap();
|
||||||
let mut rgba = AvFrame::empty();
|
// Single-pass conversion: swscale writes straight into the Vec the texture will
|
||||||
sws.run(frame, &mut rgba).map_err(|e| anyhow!("sws: {e}"))?;
|
// 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 {
|
Ok(CpuFrame {
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
stride: rgba.stride(0),
|
stride: dst_linesize[0] as usize,
|
||||||
rgba: rgba.data(0).to_vec(),
|
rgba,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Capture host-free UI screenshots of the native Linux client under a virtual X
|
# Capture host-free UI screenshots of the native Linux client. Mirrors the iOS harness
|
||||||
# display. Mirrors the iOS harness (clients/apple/tools/screenshots.sh): one app
|
# (clients/apple/tools/screenshots.sh): one app launch per scene (PUNKTFUNK_SHOT_SCENE),
|
||||||
# launch per scene (PUNKTFUNK_SHOT_SCENE), the app renders a mock-populated REAL
|
# the app renders a mock-populated REAL view and — when the binary supports it — CAPTURES
|
||||||
# view and prints `PF_SHOT_READY`, then we grab the X root window. No host, GPU, or
|
# ITSELF (PUNKTFUNK_SHOT_OUT: widget snapshot → gsk render → PNG) before printing
|
||||||
# live stream — only the chrome scenes (the stream page needs a live connector).
|
# `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
|
# cargo build --release -p punktfunk-client-linux
|
||||||
# bash clients/linux/tools/screenshots.sh # → clients/linux/screenshots/<scene>.png
|
# bash clients/linux/tools/screenshots.sh # → clients/linux/screenshots/<scene>.png
|
||||||
# bash clients/linux/tools/screenshots.sh hosts pair # a subset
|
# bash clients/linux/tools/screenshots.sh hosts pair # a subset
|
||||||
#
|
#
|
||||||
# Env knobs: BIN (client binary), OUT (output dir), GEOMETRY (Xvfb WxHxDepth),
|
# Env knobs: BIN (client binary), OUT (output dir), GEOMETRY (Xvfb WxHxDepth),
|
||||||
# SETTLE (extra seconds after PF_SHOT_READY), SHOT_DISPLAY (X display), GSK_RENDERER
|
# SETTLE (extra seconds after PF_SHOT_READY, X11-fallback only), SHOT_DISPLAY (X display),
|
||||||
# (gl|ngl|cairo — gl/llvmpipe by default for full libadwaita fidelity).
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # clients/linux
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # clients/linux
|
||||||
BIN="${BIN:-$here/../../target/release/punktfunk-client}"
|
BIN="${BIN:-$here/../../target/release/punktfunk-client}"
|
||||||
OUT="${OUT:-$here/screenshots}"
|
OUT="${OUT:-$here/screenshots}"
|
||||||
# The client window maps at its 1100x720 default; with no WM under Xvfb it lands at the
|
# X11 fallback only: the client window maps at its 1200x780 default; with no WM under
|
||||||
# top-left, so keep the root just larger so the full window (incl. its CSD shadow) is
|
# Xvfb it lands at the top-left, so keep the root just larger so the full window (incl.
|
||||||
# captured by a root grab with only a thin margin to crop.
|
# its CSD shadow) is captured by a root grab with only a thin margin to crop.
|
||||||
GEOMETRY="${GEOMETRY:-1280x800x24}"
|
GEOMETRY="${GEOMETRY:-1380x860x24}"
|
||||||
SETTLE="${SETTLE:-1.2}"
|
SETTLE="${SETTLE:-1.2}"
|
||||||
SHOT_DISPLAY="${SHOT_DISPLAY:-:99}"
|
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" ] || {
|
[ -x "$BIN" ] || {
|
||||||
echo "client binary not found: $BIN (build it first: cargo build --release -p punktfunk-client-linux)" >&2
|
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
|
# 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
|
# 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)"
|
WORK="$(mktemp -d)"
|
||||||
export HOME="$WORK"
|
export HOME="$WORK"
|
||||||
mkdir -p "$HOME/.config/punktfunk"
|
mkdir -p "$HOME/.config/punktfunk"
|
||||||
@@ -42,7 +46,7 @@ cat >"$HOME/.config/punktfunk/client-known-hosts.json" <<'JSON'
|
|||||||
"hosts": [
|
"hosts": [
|
||||||
{ "name": "Living Room PC", "addr": "192.168.1.42", "port": 9777,
|
{ "name": "Living Room PC", "addr": "192.168.1.42", "port": 9777,
|
||||||
"fp_hex": "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
"fp_hex": "9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||||
"paired": true },
|
"paired": true, "last_used": 1780000000 },
|
||||||
{ "name": "Office", "addr": "192.168.1.50", "port": 9777,
|
{ "name": "Office", "addr": "192.168.1.50", "port": 9777,
|
||||||
"fp_hex": "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
|
"fp_hex": "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
|
||||||
"paired": false }
|
"paired": false }
|
||||||
@@ -50,34 +54,45 @@ cat >"$HOME/.config/punktfunk/client-known-hosts.json" <<'JSON'
|
|||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
|
|
||||||
# Software-rendered X session — no GPU/Wayland. GL/llvmpipe runs the real NGL renderer
|
XVFB_PID=""
|
||||||
# (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=$!
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
kill "$XVFB_PID" 2>/dev/null || true
|
if [ -n "$XVFB_PID" ]; then kill "$XVFB_PID" 2>/dev/null || true; fi
|
||||||
rm -rf "$WORK"
|
rm -rf "$WORK"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
# Wait for the display to accept connections.
|
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${FORCE_XVFB:-}" ]; then
|
||||||
for _ in $(seq 1 50); do
|
# Live Wayland session: self-capture only (there is no root grab on Wayland). The
|
||||||
if command -v xdpyinfo >/dev/null 2>&1; then
|
# window flashes up briefly per scene — this is a dev harness, not CI polish.
|
||||||
xdpyinfo -display "$SHOT_DISPLAY" >/dev/null 2>&1 && break
|
MODE=wayland
|
||||||
else
|
export GDK_BACKEND=wayland
|
||||||
[ -e "/tmp/.X11-unix/X${SHOT_DISPLAY#:}" ] && break
|
else
|
||||||
fi
|
# Software-rendered X session — no GPU/Wayland needed. GL/llvmpipe runs the real NGL
|
||||||
sleep 0.1
|
# renderer (cairo is documented-incomplete for 3D-transformed content / libadwaita
|
||||||
done
|
# 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"
|
local out="$1"
|
||||||
if command -v import >/dev/null 2>&1; then
|
if command -v import >/dev/null 2>&1; then
|
||||||
import -silent -window root "$out"
|
import -silent -window root "$out"
|
||||||
@@ -93,7 +108,9 @@ mkdir -p "$OUT"
|
|||||||
rc=0
|
rc=0
|
||||||
for scene in "${SCENES[@]}"; do
|
for scene in "${SCENES[@]}"; do
|
||||||
: >"$WORK/log"
|
: >"$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=$!
|
pid=$!
|
||||||
ready=0
|
ready=0
|
||||||
for _ in $(seq 1 200); do # up to ~20s
|
for _ in $(seq 1 200); do # up to ~20s
|
||||||
@@ -101,14 +118,27 @@ for scene in "${SCENES[@]}"; do
|
|||||||
ready=1
|
ready=1
|
||||||
break
|
break
|
||||||
fi
|
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
|
sleep 0.1
|
||||||
done
|
done
|
||||||
if [ "$ready" = 1 ]; then
|
if [ "$ready" = 1 ]; then
|
||||||
sleep "$SETTLE"
|
if [ -f "$OUT/$scene.png" ]; then
|
||||||
if capture "$OUT/$scene.png"; then
|
echo "✓ $scene → $OUT/$scene.png (self-capture)"
|
||||||
echo "✓ $scene → $OUT/$scene.png"
|
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
|
else
|
||||||
|
echo "✗ $scene: no PNG (self-capture failed — see log)" >&2
|
||||||
|
sed 's/^/ /' "$WORK/log" >&2 || true
|
||||||
rc=1
|
rc=1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user