refactor(client): relm4 desktop shell; delete legacy presenter + coverflow (phase 5)
The GTK client becomes a relm4 component tree: AppModel owns the window, trust gate (rules 1-3), and the spawned session child's lifecycle as typed messages; the hosts page is a child component with a FactoryVecDeque of host cards — the HostsCallbacks Rc<dyn Fn> bag, the busy Cell, and the Rc<RefCell<HostsUi>> cross-page pokes are gone. Trust/settings/library dialogs stay plain GTK, invoked from update with a ComponentSender. Deleted with the in-process presenter: ui_stream.rs, video_gl.rs, ui_gamepad_library.rs, launch.rs, the khronos-egl dep, and the PUNKTFUNK_LEGACY_PRESENTER hatch. Every stream (and the console library) now runs in punktfunk-session; the shell spawns it for card connects and exec's it for --connect/--browse so the Decky wrapper keeps working. The request-access flow gains --connect-timeout + a CancelHandle that kills the child. Screenshot scenes re-pointed onto the components (verified: hosts + library self-capture; the dialog scenes present correctly and capture under a GL renderer); the gamepad-library scene is gone with the page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+49
-1
@@ -1146,6 +1146,9 @@ name = "fastrand"
|
|||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fec-rs"
|
name = "fec-rs"
|
||||||
@@ -1233,6 +1236,7 @@ version = "0.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"spin",
|
"spin",
|
||||||
@@ -1259,6 +1263,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fragile"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fs-err"
|
name = "fs-err"
|
||||||
version = "3.3.0"
|
version = "3.3.0"
|
||||||
@@ -2995,10 +3008,10 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"khronos-egl",
|
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
|
"relm4",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -3445,6 +3458,41 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "relm4"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6420f090f0545e9ec9656469d139a4e1b66ff9c30b808fe2247892724f71a198"
|
||||||
|
dependencies = [
|
||||||
|
"flume",
|
||||||
|
"fragile",
|
||||||
|
"futures",
|
||||||
|
"gtk4",
|
||||||
|
"libadwaita",
|
||||||
|
"once_cell",
|
||||||
|
"relm4-css",
|
||||||
|
"relm4-macros",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "relm4-css"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3b81d263f784b103c815afa29124486b59741eca069ce7a5999efb14f13c368"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "relm4-macros"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ mod session_main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the
|
||||||
|
/// shell's request-access flow passes ~185 s because the host PARKS the connection
|
||||||
|
/// until the operator clicks Approve.
|
||||||
|
pub(crate) fn connect_timeout() -> Duration {
|
||||||
|
Duration::from_secs(
|
||||||
|
arg_value("--connect-timeout")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(15),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// One session's pump parameters from the Settings store — shared by `--connect`
|
/// One session's pump parameters from the Settings store — shared by `--connect`
|
||||||
/// and every `--browse` launch. Explicit settings, `0` fields resolved to the
|
/// and every `--browse` launch. Explicit settings, `0` fields resolved to the
|
||||||
/// window's display (the GTK client reads the monitor under its window — same
|
/// window's display (the GTK client reads the monitor under its window — same
|
||||||
@@ -117,7 +128,7 @@ mod session_main {
|
|||||||
launch,
|
launch,
|
||||||
pin: Some(pin),
|
pin: Some(pin),
|
||||||
identity,
|
identity,
|
||||||
connect_timeout: Duration::from_secs(15),
|
connect_timeout: connect_timeout(),
|
||||||
force_software,
|
force_software,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,8 @@ gtk = { package = "gtk4", version = "0.11", features = ["v4_16"] }
|
|||||||
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||||
async-channel = "2"
|
async-channel = "2"
|
||||||
|
|
||||||
# The VAAPI GL presenter (video_gl.rs): EGL dmabuf import into a GDK-shared context, dlopened
|
|
||||||
# at runtime (`dynamic`) so GPU-less boxes and the software path never touch libEGL.
|
|
||||||
khronos-egl = { version = "6", features = ["dynamic"] }
|
|
||||||
|
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
relm4 = { version = "0.11", features = ["libadwaita"] }
|
||||||
|
|||||||
+14
-18
@@ -57,32 +57,28 @@ cargo run -p punktfunk-client-linux -- --connect HOST[:PORT] # skip the host l
|
|||||||
cargo run -p punktfunk-client-linux -- --browse HOST # the gamepad library launcher
|
cargo run -p punktfunk-client-linux -- --browse HOST # the gamepad library launcher
|
||||||
```
|
```
|
||||||
|
|
||||||
The binary is named **`punktfunk-client`**. Handy flags: `--connect host[:port]` (start a session
|
The binary is named **`punktfunk-client`** — the relm4/libadwaita desktop shell (hosts,
|
||||||
immediately — for scripting and the Steam Deck launcher) with optional `--launch <id>` (ask the
|
pairing/trust, settings, the desktop library page). Every stream and the console game
|
||||||
host to launch that library title, id from `--library`), `--browse host[:port]` (the gamepad
|
library run in the sibling **`punktfunk-session`** Vulkan binary; the shell spawns it
|
||||||
library launcher; `--mgmt <port>` overrides the management port it fetches from),
|
for connects, and `--connect`/`--browse` on the shell exec it directly (so the Decky
|
||||||
`--pair <PIN> --connect host[:port]` (run the pairing ceremony headlessly), and
|
wrapper keeps working unchanged). Headless flags stay in the shell:
|
||||||
`--library host[:mgmt_port]` (print a host's game library headlessly). Force a decoder with
|
`--pair <PIN> --connect host[:port]` (pairing ceremony), `--wake host[:port]`, and
|
||||||
`PUNKTFUNK_DECODER=software|vaapi`; `PUNKTFUNK_FAKE_LIBRARY=<file.json>` feeds the launcher
|
`--library host[:mgmt_port]` (print a host's game library).
|
||||||
canned entries for UI work with no host.
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
main.rs · app.rs entry point, GTK application, primary menu, CSS
|
main.rs · app.rs entry point, relm4 AppModel (window, trust gate, session child
|
||||||
cli.rs CLI paths (--connect/--launch, --browse, headless --pair, screenshot scenes)
|
lifecycle, typed messages), primary menu, CSS
|
||||||
ui_hosts.rs host card grids (saved + discovered) · add-host dialog · banner
|
cli.rs headless paths (--pair/--wake/--library), the --connect/--browse
|
||||||
|
exec handoff to punktfunk-session, screenshot scenes
|
||||||
|
ui_hosts.rs hosts page component (FactoryVecDeque cards, saved + discovered
|
||||||
|
grids, add-host dialog, banner)
|
||||||
ui_library.rs game-library poster grid (per-host, launches titles)
|
ui_library.rs game-library poster grid (per-host, launches titles)
|
||||||
ui_gamepad_library.rs the --browse gamepad launcher (aurora · coverflow · hint bar)
|
|
||||||
ui_trust.rs TOFU / PIN-pairing / request-access dialogs
|
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
|
spawn.rs the session-child plumbing (stdout contract → AppMsg)
|
||||||
launch.rs session launch/UI glue over the shared session pump
|
|
||||||
spawn.rs desktop connects → the punktfunk-session Vulkan binary
|
|
||||||
(PUNKTFUNK_LEGACY_PRESENTER=1 keeps them in-process)
|
|
||||||
video_gl.rs VAAPI dmabuf → RGBA GL presenter (EGL import, CICP-driven CSC;
|
|
||||||
the legacy/fallback presenter)
|
|
||||||
tools/screenshots.sh store screenshot capture (app self-capture; Xvfb fallback)
|
tools/screenshots.sh store screenshot capture (app self-capture; Xvfb fallback)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+483
-352
@@ -1,17 +1,22 @@
|
|||||||
//! The application shell: window, navigation, and top-level glue. The trust/pairing
|
//! The application shell as a relm4 component tree (phase 5 of punktfunk-planning
|
||||||
//! dialogs live in `ui_trust`, session launch in `launch`, CLI entry paths in `cli`, the
|
//! `linux-client-rearchitecture.md`): [`AppModel`] owns the window, navigation, trust
|
||||||
//! hosts grid in `ui_hosts`.
|
//! gate, and the spawned session child's lifecycle; the hosts page is a child component
|
||||||
|
//! ([`crate::ui_hosts`]); dialogs (trust, settings, library) are plain GTK invoked from
|
||||||
|
//! `update`. Every stream runs in the `punktfunk-session` Vulkan binary — the shell
|
||||||
|
//! never touches video.
|
||||||
|
|
||||||
use crate::trust::Settings;
|
use crate::spawn::{self, SpawnOpts};
|
||||||
use crate::ui_hosts::{ConnectRequest, HostsCallbacks, HostsUi};
|
use crate::trust::{self, Settings};
|
||||||
|
use crate::ui_hosts::{ConnectRequest, HostsMsg, HostsOutput, HostsPage};
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::{gdk, gio, 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 relm4::prelude::*;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
const APP_ID: &str = "io.unom.Punktfunk";
|
pub const APP_ID: &str = "io.unom.Punktfunk";
|
||||||
|
|
||||||
/// Custom styles on top of libadwaita for the host cards: status pills, presence pips,
|
/// Custom styles on top of libadwaita for the host cards: status pills, presence pips,
|
||||||
/// the most-recent accent bar, dashed discovered cards. Colours come from the adwaita
|
/// the most-recent accent bar, dashed discovered cards. Colours come from the adwaita
|
||||||
@@ -34,80 +39,460 @@ const CSS: &str = "
|
|||||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
.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-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); }
|
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
|
||||||
/* Gaming-Mode launches: gamescope displays the window fullscreen but never ACKs the
|
|
||||||
xdg_toplevel fullscreen state, so GTK keeps the floating-CSD styling — libadwaita's
|
|
||||||
rounded corners + shadow margin stay visible over the stream. Flatten them outright. */
|
|
||||||
window.pf-chromeless { border-radius: 0; box-shadow: none; }
|
|
||||||
/* The gamepad library launcher (`--browse`, ui_gamepad_library) — always-dark console
|
|
||||||
chrome over the aurora, independent of the desktop theme. */
|
|
||||||
.pf-gl-page { background: black; color: white; }
|
|
||||||
.pf-gl-host { font-size: 1.15em; font-weight: bold; color: rgba(255, 255, 255, 0.9); }
|
|
||||||
.pf-gl-chip { font-size: 0.8em; color: rgba(255, 255, 255, 0.7);
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
border-radius: 999px; padding: 4px 12px; }
|
|
||||||
/* Solid face, not glass: coverflow side cards OVERLAP — a translucent card would bleed
|
|
||||||
the stack through the one on top. */
|
|
||||||
.pf-gl-poster { border-radius: 16px; background: rgb(30, 30, 37);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.07); }
|
|
||||||
.pf-gl-dim { background: black; border-radius: 16px; }
|
|
||||||
.pf-gl-detail-title { font-size: 1.7em; font-weight: bold; color: white; }
|
|
||||||
.pf-gl-detail-store { font-size: 0.75em; font-weight: 600; letter-spacing: 2px;
|
|
||||||
color: rgba(255, 255, 255, 0.5); }
|
|
||||||
.pf-gl-glyph { font-size: 0.85em; font-weight: bold; color: white;
|
|
||||||
background: rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 999px; min-width: 26px; min-height: 26px; padding: 2px 8px; }
|
|
||||||
.pf-gl-hint { color: rgba(255, 255, 255, 0.85); }
|
|
||||||
.pf-gl-status { font-size: 0.85em; color: #ff938a; }
|
|
||||||
.pf-gl-error-title { font-size: 1.4em; font-weight: bold; color: white; }
|
|
||||||
";
|
";
|
||||||
|
|
||||||
pub struct App {
|
/// Everything the shell shares below the component tree.
|
||||||
|
pub struct AppModel {
|
||||||
pub window: adw::ApplicationWindow,
|
pub window: adw::ApplicationWindow,
|
||||||
pub nav: adw::NavigationView,
|
pub nav: adw::NavigationView,
|
||||||
pub toasts: adw::ToastOverlay,
|
toasts: adw::ToastOverlay,
|
||||||
pub settings: Rc<RefCell<Settings>>,
|
pub settings: Rc<RefCell<Settings>>,
|
||||||
pub identity: (String, String),
|
pub identity: (String, String),
|
||||||
/// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback.
|
/// App-lifetime SDL gamepad service (Settings' controller list + pinning). Streams
|
||||||
|
/// run in the session binary, which has its own.
|
||||||
pub gamepad: crate::gamepad::GamepadService,
|
pub gamepad: crate::gamepad::GamepadService,
|
||||||
/// One session at a time — ignore connects while one is starting/running.
|
hosts: Controller<HostsPage>,
|
||||||
pub busy: std::cell::Cell<bool>,
|
/// One session child at a time — connects while one runs are ignored.
|
||||||
/// Steam Deck / Gaming-Mode launch: fullscreen the window (chrome-less) when a stream starts.
|
busy: bool,
|
||||||
pub fullscreen: bool,
|
/// The request-access "waiting for approval" dialog, closed on the first child
|
||||||
/// Quit when the session ends (Gaming-Mode `--connect` launch): the app IS the stream —
|
/// event. A shared slot (not a message): dialogs are main-thread GTK objects and
|
||||||
/// exiting ends the Steam "game" so the Deck returns to Gaming Mode instead of stranding
|
/// `AppMsg` must stay `Send` for the session child's reader thread.
|
||||||
/// the user on the client's own hosts page.
|
waiting: Rc<RefCell<Option<adw::AlertDialog>>>,
|
||||||
pub quit_on_session_end: 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>>>,
|
|
||||||
/// The gamepad library launcher — `Some` only under `--browse`, where it replaces the
|
|
||||||
/// hosts page as the root (and session end returns here instead of quitting).
|
|
||||||
pub browse: RefCell<Option<Rc<crate::ui_gamepad_library::LauncherUi>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
#[derive(Debug)]
|
||||||
|
pub enum AppMsg {
|
||||||
|
/// The trust gate in front of every connect (rules 1–3, see `update`).
|
||||||
|
Connect(ConnectRequest),
|
||||||
|
/// Wake an offline saved host, poll until it advertises, then `Connect`.
|
||||||
|
WakeConnect(ConnectRequest),
|
||||||
|
/// The SPAKE2 PIN ceremony dialog.
|
||||||
|
Pair(ConnectRequest),
|
||||||
|
SpeedTest(ConnectRequest),
|
||||||
|
/// The desktop library page (mgmt port from the live advert when known).
|
||||||
|
OpenLibrary(ConnectRequest, Option<u16>),
|
||||||
|
/// Spawn the session child now (trust already decided; `tofu` = persist the
|
||||||
|
/// fingerprint once the child proves it).
|
||||||
|
StartSession {
|
||||||
|
req: ConnectRequest,
|
||||||
|
fp_hex: String,
|
||||||
|
tofu: bool,
|
||||||
|
opts: SpawnOpts,
|
||||||
|
},
|
||||||
|
/// The child presented its first frame.
|
||||||
|
SessionReady {
|
||||||
|
req: ConnectRequest,
|
||||||
|
fp_hex: String,
|
||||||
|
tofu: bool,
|
||||||
|
persist_paired: bool,
|
||||||
|
},
|
||||||
|
/// The child exited (the session is over, or the connect failed).
|
||||||
|
SessionExited {
|
||||||
|
req: ConnectRequest,
|
||||||
|
code: i32,
|
||||||
|
error: Option<(String, bool)>,
|
||||||
|
ended: Option<String>,
|
||||||
|
tofu: bool,
|
||||||
|
},
|
||||||
|
/// Request-access Cancel: the child was killed; release busy quietly.
|
||||||
|
CancelPending,
|
||||||
|
/// The speed-test dialog resolved (either way) — release `busy`.
|
||||||
|
SpeedTestDone,
|
||||||
|
ShowPreferences,
|
||||||
|
ShowShortcuts,
|
||||||
|
ShowAbout,
|
||||||
|
ShowAddHost,
|
||||||
|
Toast(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppInit {
|
||||||
|
pub gamepad: crate::gamepad::GamepadService,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppWidgets {}
|
||||||
|
|
||||||
|
impl SimpleComponent for AppModel {
|
||||||
|
type Init = AppInit;
|
||||||
|
type Input = AppMsg;
|
||||||
|
type Output = ();
|
||||||
|
type Root = adw::ApplicationWindow;
|
||||||
|
type Widgets = AppWidgets;
|
||||||
|
|
||||||
|
fn init_root() -> Self::Root {
|
||||||
|
adw::ApplicationWindow::builder()
|
||||||
|
.title("Punktfunk")
|
||||||
|
.default_width(1200)
|
||||||
|
.default_height(780)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(
|
||||||
|
init: Self::Init,
|
||||||
|
window: Self::Root,
|
||||||
|
sender: ComponentSender<Self>,
|
||||||
|
) -> ComponentParts<Self> {
|
||||||
|
let identity = match trust::load_or_create_identity() {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("client identity: {e:#}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load_css();
|
||||||
|
// Screenshot scenes must capture settled frames: kill every GTK/libadwaita
|
||||||
|
// animation (a headless session may starve the frame clock and leave a
|
||||||
|
// transition frozen mid-flight in the capture).
|
||||||
|
if crate::cli::shot_scene().is_some() {
|
||||||
|
if let Some(s) = gtk::Settings::default() {
|
||||||
|
s.set_gtk_enable_animations(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = Rc::new(RefCell::new(Settings::load()));
|
||||||
|
// Re-apply the persisted forwarded-controller pin (stable key; the service
|
||||||
|
// matches it whenever such a pad connects).
|
||||||
|
{
|
||||||
|
let forward = settings.borrow().forward_pad.clone();
|
||||||
|
if !forward.is_empty() {
|
||||||
|
init.gamepad.set_pinned(Some(forward));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hosts = HostsPage::builder()
|
||||||
|
.launch(settings.clone())
|
||||||
|
.forward(sender.input_sender(), |out| match out {
|
||||||
|
HostsOutput::Connect(req) => AppMsg::Connect(req),
|
||||||
|
HostsOutput::WakeConnect(req) => AppMsg::WakeConnect(req),
|
||||||
|
HostsOutput::Pair(req) => AppMsg::Pair(req),
|
||||||
|
HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req),
|
||||||
|
HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt),
|
||||||
|
});
|
||||||
|
|
||||||
|
let nav = adw::NavigationView::new();
|
||||||
|
nav.add(hosts.widget());
|
||||||
|
let toasts = adw::ToastOverlay::new();
|
||||||
|
toasts.set_child(Some(&nav));
|
||||||
|
window.set_content(Some(&toasts));
|
||||||
|
// Gaming-mode fallback (a bare launch under gamescope): fullscreen the shell.
|
||||||
|
if crate::cli::fullscreen_mode() {
|
||||||
|
window.fullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = AppModel {
|
||||||
|
window: window.clone(),
|
||||||
|
nav,
|
||||||
|
toasts,
|
||||||
|
settings,
|
||||||
|
identity,
|
||||||
|
gamepad: init.gamepad,
|
||||||
|
hosts,
|
||||||
|
busy: false,
|
||||||
|
waiting: Rc::new(RefCell::new(None)),
|
||||||
|
};
|
||||||
|
install_actions(&model.window, &sender);
|
||||||
|
|
||||||
|
// CI screenshot mode: dispatch the scripted scene once the window is actually
|
||||||
|
// mapped (AdwDialogs need a live window; relm4 maps it after `init` returns, so
|
||||||
|
// this can't run inline like the pre-relm4 `activate` path did).
|
||||||
|
if let Some(scene) = crate::cli::shot_scene() {
|
||||||
|
let ctx = crate::cli::ShotCtx {
|
||||||
|
window: model.window.clone(),
|
||||||
|
nav: model.nav.clone(),
|
||||||
|
hosts: model.hosts.sender().clone(),
|
||||||
|
settings: model.settings.clone(),
|
||||||
|
gamepad: model.gamepad.clone(),
|
||||||
|
identity: model.identity.clone(),
|
||||||
|
sender: sender.clone(),
|
||||||
|
};
|
||||||
|
let fired = std::cell::Cell::new(false);
|
||||||
|
model.window.connect_map(move |_| {
|
||||||
|
if fired.replace(true) {
|
||||||
|
return; // map can fire more than once; the scene runs on the first
|
||||||
|
}
|
||||||
|
crate::cli::run_shot(&ctx, &scene);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.present();
|
||||||
|
|
||||||
|
ComponentParts {
|
||||||
|
model,
|
||||||
|
widgets: AppWidgets {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, msg: AppMsg, sender: ComponentSender<Self>) {
|
||||||
|
match msg {
|
||||||
|
// The trust gate (the host is the policy authority — it advertises
|
||||||
|
// `pair=optional` only when it accepts unpaired clients):
|
||||||
|
// 1. PINNED RECONNECT — a stored fingerprint connects silently.
|
||||||
|
// 2. FINGERPRINT CHANGED — known address, different fp: the impostor
|
||||||
|
// signal; force the PIN ceremony.
|
||||||
|
// 3a. NEW + pair=optional — offer TOFU alongside PIN.
|
||||||
|
// 3b. NEW otherwise — delegated approval (request access) or PIN.
|
||||||
|
AppMsg::Connect(req) => {
|
||||||
|
if self.busy {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let known = trust::KnownHosts::load();
|
||||||
|
match &req.fp_hex {
|
||||||
|
Some(fp_hex) => {
|
||||||
|
if known.find_by_fp(fp_hex).is_some() {
|
||||||
|
let fp_hex = fp_hex.clone();
|
||||||
|
sender.input(AppMsg::StartSession {
|
||||||
|
req,
|
||||||
|
fp_hex,
|
||||||
|
tofu: false,
|
||||||
|
opts: SpawnOpts::default(),
|
||||||
|
});
|
||||||
|
} else if known.find_by_addr(&req.addr, req.port).is_some() {
|
||||||
|
self.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||||
|
crate::ui_trust::pin_dialog(&self.window, &sender, self.identity.clone(), req);
|
||||||
|
} else if req.pair_optional {
|
||||||
|
crate::ui_trust::tofu_dialog(&self.window, &sender, req);
|
||||||
|
} else {
|
||||||
|
crate::ui_trust::approval_dialog(
|
||||||
|
&self.window,
|
||||||
|
&sender,
|
||||||
|
self.waiting.clone(),
|
||||||
|
req,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Manual entry: a known address connects on its stored pin;
|
||||||
|
// an unknown one must pair — never silent TOFU.
|
||||||
|
match known.find_by_addr(&req.addr, req.port).map(|k| k.fp_hex.clone()) {
|
||||||
|
Some(fp_hex) => sender.input(AppMsg::StartSession {
|
||||||
|
req,
|
||||||
|
fp_hex,
|
||||||
|
tofu: false,
|
||||||
|
opts: SpawnOpts::default(),
|
||||||
|
}),
|
||||||
|
None => crate::ui_trust::approval_dialog(
|
||||||
|
&self.window,
|
||||||
|
&sender,
|
||||||
|
self.waiting.clone(),
|
||||||
|
req,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMsg::WakeConnect(req) => {
|
||||||
|
if !self.busy {
|
||||||
|
crate::ui_trust::wake_and_connect(&self.window, &sender, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMsg::Pair(req) => {
|
||||||
|
if !self.busy {
|
||||||
|
crate::ui_trust::pin_dialog(&self.window, &sender, self.identity.clone(), req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMsg::SpeedTest(req) => self.speed_test(req, &sender),
|
||||||
|
AppMsg::SpeedTestDone => self.busy = false,
|
||||||
|
AppMsg::OpenLibrary(req, mgmt_port) => {
|
||||||
|
crate::ui_library::open(self, &sender, req, mgmt_port);
|
||||||
|
}
|
||||||
|
AppMsg::StartSession {
|
||||||
|
req,
|
||||||
|
fp_hex,
|
||||||
|
tofu,
|
||||||
|
opts,
|
||||||
|
} => {
|
||||||
|
if std::mem::replace(&mut self.busy, true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.hosts.emit(HostsMsg::ClearError);
|
||||||
|
self.hosts.emit(HostsMsg::SetConnecting(Some(req.card_key())));
|
||||||
|
let fullscreen = self.settings.borrow().fullscreen_on_stream;
|
||||||
|
if let Err(e) = spawn::spawn_session(
|
||||||
|
sender.input_sender().clone(),
|
||||||
|
req,
|
||||||
|
fp_hex,
|
||||||
|
tofu,
|
||||||
|
fullscreen,
|
||||||
|
opts,
|
||||||
|
) {
|
||||||
|
self.busy = false;
|
||||||
|
self.hosts.emit(HostsMsg::SetConnecting(None));
|
||||||
|
self.hosts.emit(HostsMsg::ShowError(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMsg::SessionReady {
|
||||||
|
req,
|
||||||
|
fp_hex,
|
||||||
|
tofu,
|
||||||
|
persist_paired,
|
||||||
|
} => {
|
||||||
|
self.close_waiting();
|
||||||
|
self.hosts.emit(HostsMsg::SetConnecting(None));
|
||||||
|
if persist_paired {
|
||||||
|
// Request-access: the operator approved this device — a trusted
|
||||||
|
// PAIRED host from now on, like after a PIN ceremony.
|
||||||
|
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true);
|
||||||
|
self.toast("Approved — connected");
|
||||||
|
} else if tofu {
|
||||||
|
// The advertised fingerprint proved itself on a real connect.
|
||||||
|
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, false);
|
||||||
|
self.toast(&format!(
|
||||||
|
"Trusted on first use — fingerprint {}…",
|
||||||
|
&fp_hex[..16.min(fp_hex.len())]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.hosts.emit(HostsMsg::Refresh);
|
||||||
|
}
|
||||||
|
AppMsg::SessionExited {
|
||||||
|
req,
|
||||||
|
code,
|
||||||
|
error,
|
||||||
|
ended,
|
||||||
|
tofu,
|
||||||
|
} => {
|
||||||
|
self.close_waiting();
|
||||||
|
self.busy = false;
|
||||||
|
self.hosts.emit(HostsMsg::SetConnecting(None));
|
||||||
|
match (code, error, ended) {
|
||||||
|
(0, _, None) => {} // clean end — back on the hosts page, no noise
|
||||||
|
(0, _, Some(reason)) => self.hosts.emit(HostsMsg::ShowError(reason)),
|
||||||
|
(_, Some((_, true)), _) if !tofu => {
|
||||||
|
// The stored pin no longer matches (rotated cert or impostor).
|
||||||
|
self.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||||||
|
crate::ui_trust::pin_dialog(&self.window, &sender, self.identity.clone(), req);
|
||||||
|
}
|
||||||
|
(_, Some((msg, _)), _) => self
|
||||||
|
.hosts
|
||||||
|
.emit(HostsMsg::ShowError(format!("Couldn't connect — {msg}"))),
|
||||||
|
(-1, None, _) => {} // killed (request-access cancel) — already handled
|
||||||
|
(code, None, _) => self.hosts.emit(HostsMsg::ShowError(format!(
|
||||||
|
"Stream session failed (punktfunk-session exit {code})"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMsg::CancelPending => {
|
||||||
|
self.close_waiting();
|
||||||
|
self.busy = false;
|
||||||
|
self.hosts.emit(HostsMsg::SetConnecting(None));
|
||||||
|
self.toast("Cancelled — the request may still be pending on the host.");
|
||||||
|
}
|
||||||
|
AppMsg::ShowPreferences => {
|
||||||
|
let hosts = self.hosts.sender().clone();
|
||||||
|
crate::ui_settings::show(&self.window, self.settings.clone(), &self.gamepad, move || {
|
||||||
|
// The library toggle changes the saved cards' menu — re-render.
|
||||||
|
let _ = hosts.send(HostsMsg::Refresh);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
AppMsg::ShowShortcuts => shortcuts_window(&self.window).present(),
|
||||||
|
AppMsg::ShowAbout => crate::ui_settings::show_about(&self.window),
|
||||||
|
AppMsg::ShowAddHost => self.hosts.emit(HostsMsg::ShowAddHost),
|
||||||
|
AppMsg::Toast(msg) => self.toast(&msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppModel {
|
||||||
pub 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>> {
|
fn close_waiting(&mut self) {
|
||||||
self.hosts.borrow().clone()
|
if let Some(w) = self.waiting.borrow_mut().take() {
|
||||||
}
|
w.close();
|
||||||
|
|
||||||
pub fn browse_ui(&self) -> Option<Rc<crate::ui_gamepad_library::LauncherUi>> {
|
|
||||||
self.browse.borrow().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Surface a connect failure: the launcher in browse mode, else the hosts page banner
|
|
||||||
/// (toast fallback pre-build).
|
|
||||||
pub fn connect_error(&self, msg: &str) {
|
|
||||||
match (self.browse_ui(), self.hosts_ui()) {
|
|
||||||
(Some(l), _) => l.show_error(msg),
|
|
||||||
(_, Some(h)) => h.show_error(msg),
|
|
||||||
_ => self.toast(msg),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Measure the path to a host over the real data plane: connect, burst probe filler
|
||||||
|
/// for 2 s, report goodput · loss · a recommended bitrate, and apply it in one tap.
|
||||||
|
fn speed_test(&mut self, req: ConnectRequest, sender: &ComponentSender<AppModel>) {
|
||||||
|
if std::mem::replace(&mut self.busy, true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||||
|
let status = gtk::Label::new(Some("Connecting…"));
|
||||||
|
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
|
||||||
|
dialog.set_extra_child(Some(&status));
|
||||||
|
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
|
||||||
|
dialog.set_response_enabled("apply", false);
|
||||||
|
dialog.set_close_response("close");
|
||||||
|
dialog.present(Some(&self.window));
|
||||||
|
|
||||||
|
let (tx, rx) =
|
||||||
|
async_channel::bounded::<Result<punktfunk_core::client::ProbeOutcome, String>>(1);
|
||||||
|
let identity = self.identity.clone();
|
||||||
|
let (host, port) = (req.addr.clone(), req.port);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = (|| {
|
||||||
|
let c = NativeClient::connect(
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
punktfunk_core::config::Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
CompositorPref::Auto,
|
||||||
|
GamepadPref::Auto,
|
||||||
|
0, // bitrate_kbps (host default)
|
||||||
|
0, // video_caps: probe connect, nothing presents
|
||||||
|
2, // audio_channels: stereo
|
||||||
|
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
||||||
|
0, // preferred_codec: no preference
|
||||||
|
None, // launch: probe connect, no game
|
||||||
|
pin,
|
||||||
|
Some(identity),
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("connect: {e:?}"))?;
|
||||||
|
c.request_probe(3_000_000, 2_000)
|
||||||
|
.map_err(|e| format!("probe: {e:?}"))?;
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
|
let r = c.probe_result();
|
||||||
|
if r.done {
|
||||||
|
// Let the last UDP shards land before tearing down.
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||||
|
return Ok(c.probe_result());
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() > deadline {
|
||||||
|
return Err("probe timed out".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
let _ = tx.send_blocking(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
let settings = self.settings.clone();
|
||||||
|
let toasts = self.toasts.clone();
|
||||||
|
let sender = sender.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let outcome = rx.recv().await;
|
||||||
|
sender.input(AppMsg::SpeedTestDone);
|
||||||
|
match outcome {
|
||||||
|
Ok(Ok(r)) => {
|
||||||
|
let mbps = f64::from(r.throughput_kbps) / 1000.0;
|
||||||
|
let recommended_kbps = r.throughput_kbps / 10 * 7;
|
||||||
|
status.set_text(&format!(
|
||||||
|
"{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s",
|
||||||
|
r.loss_pct,
|
||||||
|
f64::from(recommended_kbps) / 1000.0,
|
||||||
|
));
|
||||||
|
dialog.set_response_enabled("apply", true);
|
||||||
|
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.connect_response(Some("apply"), move |_, _| {
|
||||||
|
let mut s = settings.borrow_mut();
|
||||||
|
s.bitrate_kbps = recommended_kbps;
|
||||||
|
s.save();
|
||||||
|
toasts.add_toast(adw::Toast::new(&format!(
|
||||||
|
"Bitrate set to {:.0} Mbit/s",
|
||||||
|
f64::from(recommended_kbps) / 1000.0
|
||||||
|
)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Err(msg)) => status.set_text(&msg),
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() -> glib::ExitCode {
|
pub fn run() -> glib::ExitCode {
|
||||||
@@ -117,13 +502,8 @@ pub fn run() -> glib::ExitCode {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
|
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
|
||||||
// physical pad Steam Input has virtualized — SDL then hides the real device so games
|
// physical pad Steam Input has virtualized; the Settings controller list needs the
|
||||||
// only see the virtual X360 pad. Right for games, wrong for us: capturing the Deck's
|
// real devices (same rationale as the session binary).
|
||||||
// built-in controller (trackpads/paddles/gyro, 28DE:1205) needs SDL's HIDAPI driver
|
|
||||||
// to enumerate the REAL device, and the built-in pad can never leave Steam Input
|
|
||||||
// ("Steam Controller" is always-required), so this filter is the only off switch we
|
|
||||||
// get. Clear it while still single-threaded (the gamepad worker starts with the UI);
|
|
||||||
// we dedupe the virtual pad ourselves (`gamepad.rs` `active_id` skips steam_virtual).
|
|
||||||
for var in [
|
for var in [
|
||||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||||
@@ -133,160 +513,36 @@ pub fn run() -> glib::ExitCode {
|
|||||||
std::env::remove_var(var);
|
std::env::remove_var(var);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
|
// Headless paths (no GTK window).
|
||||||
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
|
|
||||||
if let Some(pin) = crate::cli::arg_value("--pair") {
|
if let Some(pin) = crate::cli::arg_value("--pair") {
|
||||||
return crate::cli::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") {
|
if let Some(target) = crate::cli::arg_value("--library") {
|
||||||
return crate::cli::headless_library(&target);
|
return crate::cli::headless_library(&target);
|
||||||
}
|
}
|
||||||
// Headless Wake-on-LAN (no GTK window): `--wake host[:port]`. The Decky wrapper calls this
|
|
||||||
// before the stream launch so a sleeping host is up by the time `--connect` runs.
|
|
||||||
if crate::cli::arg_value("--wake").is_some() {
|
if crate::cli::arg_value("--wake").is_some() {
|
||||||
return crate::cli::cli_wake();
|
return crate::cli::cli_wake();
|
||||||
}
|
}
|
||||||
let mut builder = adw::Application::builder().application_id(APP_ID);
|
// Streams and the console library live in the session binary now — exec it,
|
||||||
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each
|
// forwarding the relevant argv (the Decky wrapper keeps working through the shell
|
||||||
// launch its own primary instance instead of forwarding to a still-registered name.
|
// until it's repointed).
|
||||||
if crate::cli::shot_scene().is_some() {
|
if crate::cli::arg_value("--connect").is_some() || crate::cli::arg_value("--browse").is_some()
|
||||||
builder = builder.flags(gtk::gio::ApplicationFlags::NON_UNIQUE);
|
|
||||||
}
|
|
||||||
let app = builder.build();
|
|
||||||
// One SDL context for the whole process: `activate` fires again on every subsequent
|
|
||||||
// launch forwarded to this already-running singleton (another `--connect`, the desktop
|
|
||||||
// icon clicked twice, …). SDL only ever lets the FIRST thread that calls `sdl3::init()`
|
|
||||||
// hold the "main thread" — a second `GamepadService::start()` from a later `activate`
|
|
||||||
// would spawn a new thread that fails that check forever. Starting it once here and
|
|
||||||
// cloning it into each `build_ui` keeps the worker thread (and its pad state) shared
|
|
||||||
// across every window instead.
|
|
||||||
let gamepad = crate::gamepad::GamepadService::start();
|
|
||||||
app.connect_activate(move |gtk_app| build_ui(gtk_app, gamepad.clone()));
|
|
||||||
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
|
||||||
// keeps GApplication from rejecting unknown options.
|
|
||||||
app.run_with_args(&[] as &[&str])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_ui(gtk_app: &adw::Application, gamepad: crate::gamepad::GamepadService) {
|
|
||||||
let identity = match crate::trust::load_or_create_identity() {
|
|
||||||
Ok(i) => i,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("client identity: {e:#}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load_css();
|
|
||||||
// Screenshot scenes must capture settled frames: kill every GTK/libadwaita animation
|
|
||||||
// (nav-push slides especially — a headless session may starve the frame clock and
|
|
||||||
// leave a transition frozen mid-flight in the capture).
|
|
||||||
if crate::cli::shot_scene().is_some() {
|
|
||||||
if let Some(s) = gtk::Settings::default() {
|
|
||||||
s.set_gtk_enable_animations(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nav = adw::NavigationView::new();
|
|
||||||
let toasts = adw::ToastOverlay::new();
|
|
||||||
toasts.set_child(Some(&nav));
|
|
||||||
let window = adw::ApplicationWindow::builder()
|
|
||||||
.application(gtk_app)
|
|
||||||
.title("Punktfunk")
|
|
||||||
.default_width(1200)
|
|
||||||
.default_height(780)
|
|
||||||
.content(&toasts)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let fullscreen = crate::cli::fullscreen_mode();
|
|
||||||
if fullscreen {
|
|
||||||
// Chrome-less shell: no CSD rounding/shadow (see CSS — gamescope never ACKs the
|
|
||||||
// fullscreen state, so GTK would keep them), and ask for fullscreen up front.
|
|
||||||
window.add_css_class("pf-chromeless");
|
|
||||||
window.fullscreen();
|
|
||||||
}
|
|
||||||
|
|
||||||
let app = Rc::new(App {
|
|
||||||
window: window.clone(),
|
|
||||||
nav: nav.clone(),
|
|
||||||
toasts,
|
|
||||||
settings: Rc::new(RefCell::new(Settings::load())),
|
|
||||||
identity,
|
|
||||||
gamepad,
|
|
||||||
busy: std::cell::Cell::new(false),
|
|
||||||
fullscreen,
|
|
||||||
// (`--browse` makes cli_connect_request None — browse mode returns to the
|
|
||||||
// launcher on session end instead of quitting.)
|
|
||||||
quit_on_session_end: fullscreen && crate::cli::cli_connect_request().is_some(),
|
|
||||||
hosts: RefCell::new(None),
|
|
||||||
browse: RefCell::new(None),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-apply the persisted forwarded-controller pin (stable key; the service matches it
|
|
||||||
// whenever such a pad connects) — without this the pin silently resets to Automatic on
|
|
||||||
// every launch, and Automatic may resolve to a gyro-less pad (Steam's virtual gamepad).
|
|
||||||
{
|
{
|
||||||
let forward = app.settings.borrow().forward_pad.clone();
|
return crate::cli::exec_session();
|
||||||
if !forward.is_empty() {
|
|
||||||
app.gamepad.set_pinned(Some(forward));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browse mode (`--browse host`): the app IS the gamepad library launcher — it becomes
|
let mut builder = adw::Application::builder().application_id(APP_ID);
|
||||||
// the ONE root page. No hosts page (whose construction starts the mDNS browse), no
|
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps
|
||||||
// header-menu actions; `Settings::library_enabled` is deliberately ignored (the flag
|
// each launch its own primary instance.
|
||||||
// gates the desktop menu item — asking to browse IS the opt-in here).
|
if crate::cli::shot_scene().is_some() {
|
||||||
if let Some((req, paired, mgmt_port)) = crate::cli::cli_browse_request() {
|
builder = builder.flags(gio::ApplicationFlags::NON_UNIQUE);
|
||||||
let launcher = crate::ui_gamepad_library::open(app.clone(), req, paired, mgmt_port);
|
|
||||||
nav.add(&launcher.page);
|
|
||||||
*app.browse.borrow_mut() = Some(launcher);
|
|
||||||
window.present();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hosts_ui = Rc::new(crate::ui_hosts::new(
|
|
||||||
app.settings.clone(),
|
|
||||||
HostsCallbacks {
|
|
||||||
on_connect: {
|
|
||||||
let app = app.clone();
|
|
||||||
Rc::new(move |req| crate::ui_trust::initiate_connect(app.clone(), req))
|
|
||||||
},
|
|
||||||
on_wake_connect: {
|
|
||||||
let app = app.clone();
|
|
||||||
Rc::new(move |req| crate::ui_trust::wake_and_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))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
));
|
|
||||||
*app.hosts.borrow_mut() = Some(hosts_ui.clone());
|
|
||||||
install_actions(&app, &hosts_ui);
|
|
||||||
nav.add(&hosts_ui.page);
|
|
||||||
window.present();
|
|
||||||
|
|
||||||
// CI screenshot mode: render one scripted, host-free scene and signal readiness
|
|
||||||
// (clients/linux/tools/screenshots.sh). Mutually exclusive with a real connect.
|
|
||||||
if let Some(scene) = crate::cli::shot_scene() {
|
|
||||||
crate::cli::run_shot(app, &scene);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(req) = crate::cli::cli_connect_request() {
|
|
||||||
crate::ui_trust::initiate_connect(app, req);
|
|
||||||
}
|
}
|
||||||
|
let adw_app = builder.build();
|
||||||
|
// One SDL context for the whole process, started while single-threaded.
|
||||||
|
let gamepad = crate::gamepad::GamepadService::start();
|
||||||
|
let app = relm4::RelmApp::from_app(adw_app).with_args(Vec::new());
|
||||||
|
app.run::<AppModel>(AppInit { gamepad });
|
||||||
|
glib::ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_css() {
|
fn load_css() {
|
||||||
@@ -301,54 +557,23 @@ fn load_css() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Window actions behind the hosts page's header: the primary (hamburger) menu entries
|
/// Window actions behind the hosts page's header (the primary menu + "+") — thin
|
||||||
/// plus the "+" add-host button and the empty state's call to action.
|
/// forwards into the message loop.
|
||||||
fn install_actions(app: &Rc<App>, hosts: &Rc<HostsUi>) {
|
fn install_actions(window: &adw::ApplicationWindow, sender: &ComponentSender<AppModel>) {
|
||||||
let add = |name: &str, f: Box<dyn Fn()>| {
|
let add = |name: &str, msg: fn() -> AppMsg| {
|
||||||
let action = gio::SimpleAction::new(name, None);
|
let action = gio::SimpleAction::new(name, None);
|
||||||
action.connect_activate(move |_, _| f());
|
let sender = sender.clone();
|
||||||
app.window.add_action(&action);
|
action.connect_activate(move |_, _| sender.input(msg()));
|
||||||
|
action
|
||||||
};
|
};
|
||||||
{
|
window.add_action(&add("preferences", || AppMsg::ShowPreferences));
|
||||||
let app = app.clone();
|
window.add_action(&add("shortcuts", || AppMsg::ShowShortcuts));
|
||||||
add(
|
window.add_action(&add("about", || AppMsg::ShowAbout));
|
||||||
"preferences",
|
window.add_action(&add("add-host", || AppMsg::ShowAddHost));
|
||||||
Box::new(move || {
|
|
||||||
let refresh = {
|
|
||||||
let app = app.clone();
|
|
||||||
// The library toggle changes the saved cards' menu — re-render on close.
|
|
||||||
move || {
|
|
||||||
if let Some(h) = app.hosts_ui() {
|
|
||||||
h.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad, refresh)
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Keyboard Shortcuts window (menu + the shortcuts scene). GtkShortcutsWindow is
|
/// The Keyboard Shortcuts window — the SESSION window's keys (the shell itself has
|
||||||
/// builder-XML-first, so it's assembled from a snippet rather than widget calls.
|
/// none); kept here as discoverable documentation.
|
||||||
pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow {
|
pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow {
|
||||||
const UI: &str = r#"
|
const UI: &str = r#"
|
||||||
<interface>
|
<interface>
|
||||||
@@ -358,7 +583,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
|
|||||||
<object class="GtkShortcutsSection">
|
<object class="GtkShortcutsSection">
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkShortcutsGroup">
|
<object class="GtkShortcutsGroup">
|
||||||
<property name="title">Stream</property>
|
<property name="title">Stream (session window)</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkShortcutsShortcut">
|
<object class="GtkShortcutsShortcut">
|
||||||
<property name="title">Toggle fullscreen</property>
|
<property name="title">Toggle fullscreen</property>
|
||||||
@@ -397,97 +622,3 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
|
|||||||
window.set_transient_for(Some(parent));
|
window.set_transient_for(Some(parent));
|
||||||
window
|
window
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
|
||||||
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
|
||||||
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
|
||||||
fn speed_test(app: Rc<App>, req: ConnectRequest) {
|
|
||||||
if app.busy.replace(true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
|
|
||||||
let status = gtk::Label::new(Some("Connecting…"));
|
|
||||||
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
|
|
||||||
dialog.set_extra_child(Some(&status));
|
|
||||||
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
|
|
||||||
dialog.set_response_enabled("apply", false);
|
|
||||||
dialog.set_close_response("close");
|
|
||||||
dialog.present(Some(&app.window));
|
|
||||||
|
|
||||||
let (tx, rx) =
|
|
||||||
async_channel::bounded::<Result<punktfunk_core::client::ProbeOutcome, String>>(1);
|
|
||||||
let identity = app.identity.clone();
|
|
||||||
let (host, port) = (req.addr.clone(), req.port);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let result = (|| {
|
|
||||||
let c = NativeClient::connect(
|
|
||||||
&host,
|
|
||||||
port,
|
|
||||||
punktfunk_core::config::Mode {
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
refresh_hz: 60,
|
|
||||||
},
|
|
||||||
CompositorPref::Auto,
|
|
||||||
GamepadPref::Auto,
|
|
||||||
0, // bitrate_kbps (host default)
|
|
||||||
0, // video_caps: the Linux client has no 10-bit/HDR present path yet
|
|
||||||
2, // audio_channels: speed-test probe, stereo
|
|
||||||
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
|
|
||||||
0, // preferred_codec: no preference for a speed-test probe
|
|
||||||
None, // launch: speed-test probe connect, no game
|
|
||||||
pin,
|
|
||||||
Some(identity),
|
|
||||||
std::time::Duration::from_secs(15),
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("connect: {e:?}"))?;
|
|
||||||
c.request_probe(3_000_000, 2_000)
|
|
||||||
.map_err(|e| format!("probe: {e:?}"))?;
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
|
||||||
loop {
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
|
||||||
let r = c.probe_result();
|
|
||||||
if r.done {
|
|
||||||
// Let the last UDP shards land before tearing down.
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
|
||||||
return Ok(c.probe_result());
|
|
||||||
}
|
|
||||||
if std::time::Instant::now() > deadline {
|
|
||||||
return Err("probe timed out".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
let _ = tx.send_blocking(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
let outcome = rx.recv().await;
|
|
||||||
app.busy.set(false);
|
|
||||||
match outcome {
|
|
||||||
Ok(Ok(r)) => {
|
|
||||||
let mbps = f64::from(r.throughput_kbps) / 1000.0;
|
|
||||||
let recommended_kbps = r.throughput_kbps / 10 * 7;
|
|
||||||
status.set_text(&format!(
|
|
||||||
"{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s",
|
|
||||||
r.loss_pct,
|
|
||||||
f64::from(recommended_kbps) / 1000.0,
|
|
||||||
));
|
|
||||||
dialog.set_response_enabled("apply", true);
|
|
||||||
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
|
|
||||||
let settings = app.settings.clone();
|
|
||||||
let toasts = app.toasts.clone();
|
|
||||||
dialog.connect_response(Some("apply"), move |_, _| {
|
|
||||||
let mut s = settings.borrow_mut();
|
|
||||||
s.bitrate_kbps = recommended_kbps;
|
|
||||||
s.save();
|
|
||||||
toasts.add_toast(adw::Toast::new(&format!(
|
|
||||||
"Bitrate set to {:.0} Mbit/s",
|
|
||||||
f64::from(recommended_kbps) / 1000.0
|
|
||||||
)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(Err(msg)) => status.set_text(&msg),
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
+100
-141
@@ -1,12 +1,27 @@
|
|||||||
//! Command-line entry paths: argv helpers, headless pairing, `--connect`, and the CI
|
//! Command-line entry paths: argv helpers, the headless flows (pair/wake/library), the
|
||||||
//! screenshot scenes.
|
//! exec handoff to `punktfunk-session` for `--connect`/`--browse`, and the CI screenshot
|
||||||
|
//! scenes.
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::AppModel;
|
||||||
use crate::ui_hosts::ConnectRequest;
|
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
||||||
use gtk::glib;
|
use gtk::glib;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
use relm4::prelude::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
/// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the
|
||||||
|
/// component parts, so the scene can be dispatched from the window's `map` callback.
|
||||||
|
pub struct ShotCtx {
|
||||||
|
pub window: adw::ApplicationWindow,
|
||||||
|
pub nav: adw::NavigationView,
|
||||||
|
pub hosts: relm4::Sender<HostsMsg>,
|
||||||
|
pub settings: Rc<RefCell<crate::trust::Settings>>,
|
||||||
|
pub gamepad: crate::gamepad::GamepadService,
|
||||||
|
pub identity: (String, String),
|
||||||
|
pub sender: ComponentSender<AppModel>,
|
||||||
|
}
|
||||||
|
|
||||||
/// The value following `flag` in argv, if present (`--flag value`).
|
/// The value following `flag` in argv, if present (`--flag value`).
|
||||||
pub fn arg_value(flag: &str) -> Option<String> {
|
pub fn arg_value(flag: &str) -> Option<String> {
|
||||||
std::env::args()
|
std::env::args()
|
||||||
@@ -20,9 +35,8 @@ fn arg_flag(flag: &str) -> bool {
|
|||||||
std::env::args().any(|a| a == flag)
|
std::env::args().any(|a| a == flag)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the stream fullscreen with no window chrome — the Steam Deck / Gaming-Mode launch path.
|
/// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the
|
||||||
/// The Decky wrapper passes `--fullscreen`; we also honor the Deck/gamescope env as a fallback
|
/// console library exec the session binary, which handles its own fullscreen).
|
||||||
/// so a manual launch under Gaming Mode does the right thing too.
|
|
||||||
pub fn fullscreen_mode() -> bool {
|
pub fn fullscreen_mode() -> bool {
|
||||||
arg_flag("--fullscreen")
|
arg_flag("--fullscreen")
|
||||||
|| std::env::var_os("SteamDeck").is_some()
|
|| std::env::var_os("SteamDeck").is_some()
|
||||||
@@ -38,9 +52,42 @@ fn parse_host_port(target: &str) -> (String, Option<u16>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `--connect` / `--browse`: streams and the console library live in the
|
||||||
|
/// `punktfunk-session` Vulkan binary — replace this process with it, forwarding the
|
||||||
|
/// relevant argv verbatim. This keeps the Decky wrapper (which launches the SHELL with
|
||||||
|
/// these flags) working unchanged until it's repointed at the session binary.
|
||||||
|
pub fn exec_session() -> glib::ExitCode {
|
||||||
|
use std::os::unix::process::CommandExt as _;
|
||||||
|
let forward = [
|
||||||
|
"--connect",
|
||||||
|
"--browse",
|
||||||
|
"--fp",
|
||||||
|
"--launch",
|
||||||
|
"--mgmt",
|
||||||
|
"--connect-timeout",
|
||||||
|
];
|
||||||
|
let mut cmd = std::process::Command::new(crate::spawn::session_binary());
|
||||||
|
let mut args = std::env::args().skip(1).peekable();
|
||||||
|
while let Some(a) = args.next() {
|
||||||
|
if a == "--fullscreen" || a == "--stats" {
|
||||||
|
cmd.arg(a);
|
||||||
|
} else if forward.contains(&a.as_str()) {
|
||||||
|
cmd.arg(&a);
|
||||||
|
if let Some(v) = args.peek() {
|
||||||
|
if !v.starts_with("--") {
|
||||||
|
cmd.arg(args.next().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let err = cmd.exec(); // only returns on failure
|
||||||
|
eprintln!("exec punktfunk-session: {err}");
|
||||||
|
glib::ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
|
/// 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
|
/// 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.
|
/// store the streaming path uses, so pairing here makes the stream work.
|
||||||
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
||||||
pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||||
let Some(target) = arg_value("--connect") else {
|
let Some(target) = arg_value("--connect") else {
|
||||||
@@ -79,50 +126,8 @@ pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `--connect host[:port]` — skip the hosts page and start a session immediately
|
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit,
|
||||||
/// (scripting + headless testing). Trust follows the same rules as a manual entry: a host
|
/// without opening a window. The MAC comes from the known-hosts store (learned from the
|
||||||
/// 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).
|
|
||||||
///
|
|
||||||
/// `--launch <id>` asks the host to launch that library title (store-qualified id from
|
|
||||||
/// `--library`, e.g. `steam:570` — the Decky wrapper's `PF_LAUNCH`); the raw id doubles
|
|
||||||
/// as the stream title (best-effort — no extra fetch just for a prettier label).
|
|
||||||
pub fn cli_connect_request() -> Option<ConnectRequest> {
|
|
||||||
if arg_value("--browse").is_some() {
|
|
||||||
return None; // browse mode owns the session lifecycle (precedence over --connect)
|
|
||||||
}
|
|
||||||
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
|
|
||||||
let (addr, port) = parse_host_port(&target);
|
|
||||||
// An unparsable port (`host:notaport`) used to make the whole request `None` → the app
|
|
||||||
// silently landed on the hosts page with no session and no message. Fall back to the
|
|
||||||
// native default like the add-host dialog, and say so, instead of doing nothing.
|
|
||||||
let port = port.unwrap_or_else(|| {
|
|
||||||
eprintln!("--connect: unparsable port in '{target}', using default 9777");
|
|
||||||
9777
|
|
||||||
});
|
|
||||||
// Pull the wake MAC(s) from the store (learned from the host's mDNS `mac` TXT while it was
|
|
||||||
// online) so a `--connect` to a known host can still be woken if we add that later.
|
|
||||||
let mac = crate::trust::KnownHosts::load()
|
|
||||||
.hosts
|
|
||||||
.iter()
|
|
||||||
.find(|h| h.addr == addr && h.port == port)
|
|
||||||
.map(|h| h.mac.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
Some(ConnectRequest {
|
|
||||||
name: addr.clone(),
|
|
||||||
addr,
|
|
||||||
port,
|
|
||||||
fp_hex: None,
|
|
||||||
pair_optional: false,
|
|
||||||
launch: arg_value("--launch").map(|id| (id.clone(), id)),
|
|
||||||
mac,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit, without
|
|
||||||
/// opening a window. The Decky wrapper calls this before launching the stream so a sleeping host
|
|
||||||
/// is up by the time `--connect` runs. The MAC comes from the known-hosts store (learned from the
|
|
||||||
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
|
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
|
||||||
pub fn cli_wake() -> glib::ExitCode {
|
pub fn cli_wake() -> glib::ExitCode {
|
||||||
let Some(target) = arg_value("--wake") else {
|
let Some(target) = arg_value("--wake") else {
|
||||||
@@ -149,44 +154,9 @@ pub fn cli_wake() -> glib::ExitCode {
|
|||||||
glib::ExitCode::SUCCESS
|
glib::ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `--browse host[:port]` — open the gamepad library launcher for that host instead of
|
|
||||||
/// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must
|
|
||||||
/// already be paired: the stored pin is what lets the launcher fetch the library and
|
|
||||||
/// connect silently — no dialog can run under gamescope, so an unpaired target renders
|
|
||||||
/// the launcher's pair-first scene. Returns the request (name + stored fingerprint from
|
|
||||||
/// the known-hosts store), whether it's paired, and the mgmt port (`--mgmt <port>`, the
|
|
||||||
/// wrapper's `PF_MGMT`; default 47990 — browse mode runs no mDNS to learn it).
|
|
||||||
pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> {
|
|
||||||
let target = arg_value("--browse")?;
|
|
||||||
let (addr, port) = parse_host_port(&target);
|
|
||||||
let port = port.unwrap_or(9777);
|
|
||||||
let known = crate::trust::KnownHosts::load();
|
|
||||||
let k = known
|
|
||||||
.hosts
|
|
||||||
.iter()
|
|
||||||
.find(|h| h.addr == addr && h.port == port);
|
|
||||||
let mgmt = arg_value("--mgmt")
|
|
||||||
.and_then(|p| p.parse().ok())
|
|
||||||
.unwrap_or(crate::library::DEFAULT_MGMT_PORT);
|
|
||||||
Some((
|
|
||||||
ConnectRequest {
|
|
||||||
name: k.map_or_else(|| addr.clone(), |k| k.name.clone()),
|
|
||||||
addr,
|
|
||||||
port,
|
|
||||||
fp_hex: k.map(|k| k.fp_hex.clone()),
|
|
||||||
pair_optional: false,
|
|
||||||
launch: None,
|
|
||||||
mac: k.map(|k| k.mac.clone()).unwrap_or_default(),
|
|
||||||
},
|
|
||||||
k.is_some_and(|k| k.paired),
|
|
||||||
mgmt,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real
|
/// `--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
|
/// mTLS + pinned-fingerprint client, no GTK window. The pin comes from `--fp HEX` when
|
||||||
/// that the library HTTP path works against a real host). The pin comes from `--fp HEX`
|
/// given, else the known-hosts store (matched by address), else none (TOFU-accept).
|
||||||
/// when given, else the known-hosts store (matched by address), else none (TOFU-accept).
|
|
||||||
pub fn headless_library(target: &str) -> glib::ExitCode {
|
pub fn headless_library(target: &str) -> glib::ExitCode {
|
||||||
let (addr, port) = match target.rsplit_once(':') {
|
let (addr, port) = match target.rsplit_once(':') {
|
||||||
Some((a, p)) if p.parse::<u16>().is_ok() => (a.to_string(), p.parse().unwrap()),
|
Some((a, p)) if p.parse::<u16>().is_ok() => (a.to_string(), p.parse().unwrap()),
|
||||||
@@ -231,15 +201,14 @@ pub fn shot_scene() -> Option<String> {
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render one mock-populated, host-free scene over the already-presented window, then print
|
/// Render one mock-populated, host-free scene over the already-presented window, then
|
||||||
/// `PF_SHOT_READY` once it has had a moment to map + settle so the driver knows when to capture.
|
/// print `PF_SHOT_READY` once it has settled. When `PUNKTFUNK_SHOT_OUT=/path.png` is set
|
||||||
/// When `PUNKTFUNK_SHOT_OUT=/path.png` is set the app CAPTURES ITSELF first (widget snapshot →
|
/// the app CAPTURES ITSELF (widget snapshot → gsk render → PNG) — no Xvfb/ImageMagick
|
||||||
/// gsk render → PNG, see `save_png`) — no Xvfb/ImageMagick needed, and libadwaita dialogs are
|
/// needed. The stream and gamepad-library scenes are gone with the pages (both live in
|
||||||
/// in-window overlays so they land in the frame. No `NativeClient` or session is created. The
|
/// the session binary now).
|
||||||
/// stream scene is deliberately absent — its page requires a live connector (`ui_stream::new`
|
pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
||||||
/// takes an `Arc<NativeClient>`).
|
let sender = &ctx.sender;
|
||||||
pub fn run_shot(app: Rc<App>, scene: &str) {
|
// A plausible host for the trust/pair dialogs (fp_hex = 64 hex chars).
|
||||||
// A plausible host for the trust/pair dialogs (fp_hex is 64 hex chars, like a real SHA-256).
|
|
||||||
let mock_req = || ConnectRequest {
|
let mock_req = || ConnectRequest {
|
||||||
name: "Living Room PC".to_string(),
|
name: "Living Room PC".to_string(),
|
||||||
addr: "192.168.1.42".to_string(),
|
addr: "192.168.1.42".to_string(),
|
||||||
@@ -266,57 +235,45 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
|||||||
|
|
||||||
// What the self-capture renders: the main window, except for scenes that open their
|
// What the self-capture renders: the main window, except for scenes that open their
|
||||||
// own toplevel (the shortcuts window).
|
// own toplevel (the shortcuts window).
|
||||||
let mut target: gtk::Widget = app.window.clone().upcast();
|
let mut target: gtk::Widget = ctx.window.clone().upcast();
|
||||||
|
let hosts = &ctx.hosts;
|
||||||
match scene {
|
match scene {
|
||||||
// The saved-hosts grid reads ~/.config/punktfunk/client-known-hosts.json, which the
|
// Saved hosts come from the seeded known-hosts store; on top, inject synthetic
|
||||||
// driver seeds. On top, inject synthetic adverts through the same path the mDNS
|
// adverts through the same path the mDNS stream feeds.
|
||||||
// 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" => {
|
"hosts" | "02-hosts" => {
|
||||||
if let Some(h) = app.hosts_ui() {
|
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
||||||
h.inject_advert(mock_advert(
|
"mock-online",
|
||||||
"mock-online",
|
"Living Room PC",
|
||||||
"Living Room PC",
|
"192.168.1.42",
|
||||||
"192.168.1.42",
|
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
)));
|
||||||
));
|
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
||||||
h.inject_advert(mock_advert(
|
"mock-new",
|
||||||
"mock-new",
|
"steamdeck",
|
||||||
"steamdeck",
|
"192.168.1.77",
|
||||||
"192.168.1.77",
|
"00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
||||||
"00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
)));
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"settings" | "03-settings" => {
|
"settings" | "03-settings" => {
|
||||||
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad, || {});
|
crate::ui_settings::show(&ctx.window, ctx.settings.clone(), &ctx.gamepad, || {});
|
||||||
|
}
|
||||||
|
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
||||||
|
"pair" | "05-pair" => {
|
||||||
|
crate::ui_trust::pin_dialog(&ctx.window, sender, ctx.identity.clone(), mock_req())
|
||||||
}
|
}
|
||||||
"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" => {
|
"addhost" | "06-addhost" => {
|
||||||
if let Some(h) = app.hosts_ui() {
|
let _ = hosts.send(HostsMsg::ShowAddHost);
|
||||||
h.show_add_host();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"shortcuts" | "07-shortcuts" => {
|
"shortcuts" | "07-shortcuts" => {
|
||||||
let w = crate::app::shortcuts_window(&app.window);
|
let w = crate::app::shortcuts_window(&ctx.window);
|
||||||
w.present();
|
w.present();
|
||||||
target = w.upcast();
|
target = w.upcast();
|
||||||
}
|
}
|
||||||
// The library page with injected entries: mixed stores exercising the badge set,
|
// The library page with injected entries: mixed stores exercising the badge set,
|
||||||
// no-art placeholders (monogram tiles), and one solid-color texture standing in
|
// no-art placeholders, and one solid-color texture standing in for a poster.
|
||||||
// for a loaded poster (the real poster path, minus the network).
|
|
||||||
"library" | "08-library" => {
|
"library" | "08-library" => {
|
||||||
let (games, art) = mock_library();
|
let (games, art) = mock_library();
|
||||||
crate::ui_library::open_mock(app.clone(), mock_req(), games, art);
|
crate::ui_library::open_mock(&ctx.nav, ctx.identity.clone(), sender, mock_req(), games, art);
|
||||||
}
|
|
||||||
// The gamepad launcher (`--browse`) with the same injected entries — cursor sits
|
|
||||||
// at 1 so both recede directions show; aurora + easing render frozen (shot mode).
|
|
||||||
"gamepad-library" | "09-gamepad-library" => {
|
|
||||||
let (games, art) = mock_library();
|
|
||||||
let ui = crate::ui_gamepad_library::open_mock(app.clone(), mock_req(), games, art);
|
|
||||||
app.nav.push(&ui.page);
|
|
||||||
*app.browse.borrow_mut() = Some(ui);
|
|
||||||
}
|
}
|
||||||
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
||||||
}
|
}
|
||||||
@@ -328,6 +285,10 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
|||||||
let scene = scene.to_string();
|
let scene = scene.to_string();
|
||||||
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
|
// Self-capture of the dialog scenes (trust/pair/settings/addhost) needs a GL
|
||||||
|
// renderer: `WidgetPaintable(window)` under the cairo software renderer doesn't
|
||||||
|
// composite the `AdwDialog` overlay layer (the dialog IS presented — the
|
||||||
|
// page-content scenes capture fine either way; CI uses GL or the X11 root-grab).
|
||||||
let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT")
|
let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|p| !p.is_empty());
|
.filter(|p| !p.is_empty());
|
||||||
@@ -338,17 +299,16 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
|||||||
}
|
}
|
||||||
println!("PF_SHOT_READY scene={scene}");
|
println!("PF_SHOT_READY scene={scene}");
|
||||||
let _ = std::io::stdout().flush();
|
let _ = std::io::stdout().flush();
|
||||||
// Self-capture mode: the shot is on disk — exit so back-to-back scene runs don't
|
// Self-capture mode: the shot is on disk — exit so back-to-back scene runs
|
||||||
// stack windows on a live desktop. (The X11-fallback driver captures externally
|
// don't stack windows on a live desktop.
|
||||||
// after READY and kills us itself.)
|
|
||||||
if self_capture.is_some() {
|
if self_capture.is_some() {
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The mock game set shared by the `library` and `gamepad-library` scenes: mixed stores
|
/// The mock game set for the `library` scene: mixed stores exercising the badge set,
|
||||||
/// exercising the badge set, plus one solid-colour poster texture.
|
/// plus one solid-colour poster texture.
|
||||||
fn mock_library() -> (
|
fn mock_library() -> (
|
||||||
Vec<crate::library::GameEntry>,
|
Vec<crate::library::GameEntry>,
|
||||||
Vec<(String, gtk::gdk::Texture)>,
|
Vec<(String, gtk::gdk::Texture)>,
|
||||||
@@ -391,7 +351,6 @@ fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture {
|
|||||||
/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`.
|
/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`.
|
||||||
fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> {
|
fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> {
|
||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use gtk::prelude::*;
|
|
||||||
let (w, h) = (widget.width(), widget.height());
|
let (w, h) = (widget.width(), widget.height());
|
||||||
anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})");
|
anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})");
|
||||||
let paintable = gtk::WidgetPaintable::new(Some(widget));
|
let paintable = gtk::WidgetPaintable::new(Some(widget));
|
||||||
|
|||||||
@@ -1,415 +0,0 @@
|
|||||||
//! 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;
|
|
||||||
}
|
|
||||||
// Phase 3 (linux-client-rearchitecture.md): desktop windowed connects run in the
|
|
||||||
// punktfunk-session Vulkan binary; this in-process GTK presenter remains the legacy
|
|
||||||
// path for the cases spawn.rs documents. A failed spawn falls through here too.
|
|
||||||
let child_fp = pin.map(|p| trust::hex(&p)).or_else(|| req.fp_hex.clone());
|
|
||||||
let legacy = std::env::var_os("PUNKTFUNK_LEGACY_PRESENTER").is_some_and(|v| v != "0")
|
|
||||||
|| app.fullscreen
|
|
||||||
|| app.browse_ui().is_some()
|
|
||||||
|| opts.waiting.is_some()
|
|
||||||
|| opts.persist_paired;
|
|
||||||
if !legacy {
|
|
||||||
if let Some(fp_hex) = child_fp {
|
|
||||||
if crate::spawn::spawn_session(app.clone(), req.clone(), fp_hex, pin.is_none())
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
return; // the child owns the session; busy releases on its exit
|
|
||||||
}
|
|
||||||
tracing::warn!("falling back to the in-process presenter");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mode = resolve_mode(&app);
|
|
||||||
let s = app.settings.borrow();
|
|
||||||
// The presenter raises this when hardware frames can't be displayed; the session pump
|
|
||||||
// demotes the decoder to software (see `SessionParams::force_software`).
|
|
||||||
let force_software = Arc::new(AtomicBool::new(false));
|
|
||||||
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,
|
|
||||||
force_software: force_software.clone(),
|
|
||||||
};
|
|
||||||
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),
|
|
||||||
force_software,
|
|
||||||
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>>,
|
|
||||||
/// Shared with the session pump — the stream page's presenter raises it to demote
|
|
||||||
/// the decoder to software when hardware frames can't be displayed.
|
|
||||||
force_software: Arc<AtomicBool>,
|
|
||||||
/// 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"),
|
|
||||||
force_software: self.force_software.clone(),
|
|
||||||
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,
|
|
||||||
chromeless: self.app.fullscreen,
|
|
||||||
// The attach just went out, so a Deck's built-in pad may not have enumerated
|
|
||||||
// yet — chromeless (controller-first) shows the chord hint regardless.
|
|
||||||
pad_connected: self.app.gamepad.active().is_some(),
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
self.app.nav.push(&p.page);
|
|
||||||
// Streams start fullscreen by default (Settings toggle) — a streaming window with
|
|
||||||
// chrome is never what anyone wants mid-game; F11 / the controller chord / the
|
|
||||||
// top-edge header reveal lead back out. Gaming-Mode launches (`--fullscreen`)
|
|
||||||
// fullscreen regardless: gamescope fullscreens the window at its level but GTK
|
|
||||||
// doesn't know it, so the header bar would stay drawn.
|
|
||||||
if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream {
|
|
||||||
self.app.window.fullscreen();
|
|
||||||
}
|
|
||||||
// A Deck streaming without its raw built-in controller is invisible degradation:
|
|
||||||
// SDL sees only Steam's virtual X360 pad, so the right trackpad arrives at the
|
|
||||||
// host as whatever Steam's template synthesizes (a right stick by default) and
|
|
||||||
// the left trackpad, paddles and gyro not at all. The built-in pad can never
|
|
||||||
// leave Steam Input ("Steam Controller" is always-required in the shortcut's
|
|
||||||
// matrix — Disable Steam Input only affects other brands), so raw capture rides
|
|
||||||
// the session-scoped Valve HIDAPI drivers + the cleared SDL device filter (see
|
|
||||||
// `app::run`). The real 28DE:1205 identity enumerates shortly after attach —
|
|
||||||
// check once that settles and say so, instead of streaming silently degraded.
|
|
||||||
if crate::gamepad::is_steam_deck() {
|
|
||||||
let app = self.app.clone();
|
|
||||||
let stop = self.stop.clone();
|
|
||||||
glib::timeout_add_seconds_local_once(4, move || {
|
|
||||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
|
||||||
return; // session already over
|
|
||||||
}
|
|
||||||
if app.gamepad.active().is_none_or(|pad| pad.steam_virtual) {
|
|
||||||
tracing::warn!(
|
|
||||||
"the Deck's raw built-in controller (28DE:1205) never enumerated \
|
|
||||||
— only Steam's virtual pad is visible, so trackpads, paddles and \
|
|
||||||
gyro can't be captured (sticks + buttons still work). Check the \
|
|
||||||
startup log for SDL_GAMECONTROLLER_IGNORE_DEVICES and the \
|
|
||||||
Settings controller list."
|
|
||||||
);
|
|
||||||
let toast = adw::Toast::new(
|
|
||||||
"Steam is only exposing its virtual gamepad — trackpads, paddles \
|
|
||||||
and gyro won't reach the game (sticks and buttons still work).",
|
|
||||||
);
|
|
||||||
toast.set_timeout(12);
|
|
||||||
app.toasts.add_toast(toast);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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. Browse
|
|
||||||
// mode can't: gamescope never maps dialogs, so it renders the advice instead
|
|
||||||
// (re-pairing is the plugin's job there).
|
|
||||||
if trust_rejected && !self.tofu && self.app.browse_ui().is_none() {
|
|
||||||
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 if trust_rejected && !self.tofu {
|
|
||||||
self.app
|
|
||||||
.connect_error("Host identity changed — re-pair from the Punktfunk plugin.");
|
|
||||||
} else {
|
|
||||||
// Errors land on the hosts page banner / launcher strip, not a transient toast.
|
|
||||||
self.app.connect_error(&format!("Couldn't connect — {msg}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `Ended`: detach gamepads, pop back to the launcher (browse mode) or the hosts
|
|
||||||
/// page, and surface the reason.
|
|
||||||
fn on_ended(&mut self, err: Option<String>) {
|
|
||||||
self.close_waiting();
|
|
||||||
self.app.gamepad.detach();
|
|
||||||
// Gaming-Mode `--connect` launch: the app IS the stream. Quit so Steam ends the
|
|
||||||
// "game" and the Deck returns to Gaming Mode — popping to our own hosts page would
|
|
||||||
// strand the user in a fullscreen shell with no way back.
|
|
||||||
if self.app.quit_on_session_end {
|
|
||||||
if let Some(e) = err {
|
|
||||||
tracing::warn!(error = %e, "session ended");
|
|
||||||
}
|
|
||||||
self.app.window.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Browse mode: back to the launcher to pick the next game — B there quits to
|
|
||||||
// Gaming Mode. (The gamepad worker re-opened the pad and armed the held-state
|
|
||||||
// snapshot on the detach above, so the chord that ended the session fires nothing.)
|
|
||||||
if let Some(l) = self.app.browse_ui() {
|
|
||||||
self.app.nav.pop_to_tag("launcher");
|
|
||||||
l.on_session_ended();
|
|
||||||
if let Some(e) = err {
|
|
||||||
self.app.connect_error(&e);
|
|
||||||
}
|
|
||||||
self.app.busy.set(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +1,36 @@
|
|||||||
//! `punktfunk-client` — the native Linux punktfunk/1 client (design: Option A, 2026-06-12).
|
//! `punktfunk-client` — the native Linux punktfunk/1 desktop shell (relm4/libadwaita).
|
||||||
//!
|
//!
|
||||||
//! GTK4/libadwaita shell · `NativeClient` linked as a crate (no C ABI) · FFmpeg decode →
|
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
||||||
//! `GtkGraphicsOffload` present · PipeWire audio · SDL3 gamepads. The trust surface
|
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
||||||
//! mirrors the Apple client: persistent identity, TOFU prompt with the host fingerprint,
|
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
||||||
//! SPAKE2 PIN pairing.
|
|
||||||
|
|
||||||
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the upcoming Vulkan
|
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
|
||||||
// session binary (design: punktfunk-planning linux-client-rearchitecture.md, Phase 0).
|
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
|
||||||
// Root re-exports keep every existing `crate::video`-style path resolving unchanged.
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use pf_client_core::{audio, discovery, gamepad, keymap, library, session, trust, video, wol};
|
pub use pf_client_core::{discovery, gamepad, library, trust, video, wol};
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod app;
|
mod app;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod cli;
|
mod cli;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod launch;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod spawn;
|
mod spawn;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod ui_gamepad_library;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod ui_hosts;
|
mod ui_hosts;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod ui_library;
|
mod ui_library;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod ui_settings;
|
mod ui_settings;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod ui_stream;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod ui_trust;
|
mod ui_trust;
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod video_gl;
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn main() -> gtk::glib::ExitCode {
|
fn main() -> gtk::glib::ExitCode {
|
||||||
app::run()
|
app::run()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GTK4/PipeWire/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on
|
/// GTK4/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on macOS
|
||||||
/// macOS (the Mac client lives in clients/apple).
|
/// (the Mac client lives in clients/apple).
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn main() {
|
fn main() {
|
||||||
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
|
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
|
||||||
|
|||||||
+97
-107
@@ -1,33 +1,48 @@
|
|||||||
//! The shell↔session handoff (punktfunk-planning `linux-client-rearchitecture.md`,
|
//! The shell↔session handoff: every stream runs in the spawned `punktfunk-session`
|
||||||
//! Phase 3): desktop connects spawn the `punktfunk-session` Vulkan binary instead of
|
//! Vulkan binary (the legacy in-process presenter is gone — phase 5 of
|
||||||
//! streaming in-process, and this module bridges its stdout contract back into the
|
//! punktfunk-planning `linux-client-rearchitecture.md`). This module owns the child's
|
||||||
//! shell's UI — spinner until `{"ready":true}`, banner from the `{"error"|"ended": …}`
|
//! lifecycle plumbing — its stdout contract parsed into typed [`AppMsg`]s the relm4 app
|
||||||
//! JSON line, exit code 3 routed to the re-pair PIN ceremony.
|
//! consumes: spinner until `{"ready":true}`, banner from the `{"error"|"ended": …}`
|
||||||
//!
|
//! line, exit code 3 + `trust_rejected` routed to the re-pair PIN ceremony.
|
||||||
//! The in-process GTK presenter stays for: `PUNKTFUNK_LEGACY_PRESENTER=1`, Gaming-Mode /
|
|
||||||
//! `--fullscreen` launches and `--browse` (a second toplevel under gamescope is a focus
|
|
||||||
//! fight — the console path moves wholesale into the session binary in Phase 4), the
|
|
||||||
//! request-access flow (its "waiting for approval" dialog drives a parked in-process
|
|
||||||
//! connect), and connects with no fingerprint in hand (the session binary never TOFUs).
|
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::AppMsg;
|
||||||
use crate::trust;
|
|
||||||
use crate::ui_hosts::ConnectRequest;
|
use crate::ui_hosts::ConnectRequest;
|
||||||
use gtk::glib;
|
|
||||||
use std::io::BufRead as _;
|
use std::io::BufRead as _;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::rc::Rc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
/// One parsed stdout line / lifecycle event from the session child.
|
/// Spawn tunables beyond a plain connect.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct SpawnOpts {
|
||||||
|
/// Handshake budget override (`--connect-timeout`) — the request-access flow passes
|
||||||
|
/// ~185 s because the host PARKS the connection until the operator approves.
|
||||||
|
pub connect_timeout_secs: Option<u64>,
|
||||||
|
/// Persist the host as *paired* once the child reports ready (request-access: the
|
||||||
|
/// operator's approval IS the pairing). Plain TOFU persists unpaired.
|
||||||
|
pub persist_paired: bool,
|
||||||
|
/// A cancel handle to arm (request-access's waiting dialog): killing the child is
|
||||||
|
/// the only abort a parked connect has.
|
||||||
|
pub cancel: Option<CancelHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kills the spawned session child (the request-access Cancel button). Safe to call
|
||||||
|
/// any time; a child that already exited is a no-op.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct CancelHandle(Arc<Mutex<Option<Child>>>);
|
||||||
|
|
||||||
|
impl CancelHandle {
|
||||||
|
pub fn kill(&self) {
|
||||||
|
if let Some(child) = self.0.lock().unwrap().as_mut() {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One parsed stdout line from the session child's contract.
|
||||||
enum ChildEvent {
|
enum ChildEvent {
|
||||||
/// `{"ready":true}` — first frame presented; the connect spinner can stop.
|
|
||||||
Ready,
|
Ready,
|
||||||
/// `{"error": msg, "trust_rejected": bool}` — connect failed (the exit follows).
|
|
||||||
Error { msg: String, trust_rejected: bool },
|
Error { msg: String, trust_rejected: bool },
|
||||||
/// `{"ended": msg}` — the session ran and ended abnormally (host ended, transport…).
|
|
||||||
Ended(String),
|
Ended(String),
|
||||||
/// The child exited (code; -1 = killed by signal). Always the final event.
|
|
||||||
Exited(i32),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse one stdout line of the session contract; `None` for anything else (stats…).
|
/// Parse one stdout line of the session contract; `None` for anything else (stats…).
|
||||||
@@ -50,7 +65,7 @@ fn parse_line(line: &str) -> Option<ChildEvent> {
|
|||||||
|
|
||||||
/// The session binary: installed next to the shell, else `$PATH` (dev runs from
|
/// The session binary: installed next to the shell, else `$PATH` (dev runs from
|
||||||
/// `target/…` land on the sibling).
|
/// `target/…` land on the sibling).
|
||||||
fn session_binary() -> std::path::PathBuf {
|
pub fn session_binary() -> std::path::PathBuf {
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
let sibling = exe.with_file_name("punktfunk-session");
|
let sibling = exe.with_file_name("punktfunk-session");
|
||||||
if sibling.exists() {
|
if sibling.exists() {
|
||||||
@@ -60,21 +75,21 @@ fn session_binary() -> std::path::PathBuf {
|
|||||||
"punktfunk-session".into()
|
"punktfunk-session".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the session binary for a connect with `fp_hex` pinned and drive its lifecycle
|
/// Spawn the session binary for a connect with `fp_hex` pinned and translate its
|
||||||
/// into the UI. `tofu` = the fingerprint came from the host's advert rather than the
|
/// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert
|
||||||
/// store — persist it once the child reports ready (the child connects pinned to it, so
|
/// rather than the store — the app persists it once the child reports ready (the child
|
||||||
/// ready proves the host really holds that identity; stricter than the in-process TOFU,
|
/// connects pinned to it, so ready proves the host really holds that identity).
|
||||||
/// which pins whatever it observes).
|
|
||||||
///
|
///
|
||||||
/// The caller has already taken `busy`; it is released when the child exits. `Err` =
|
/// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` =
|
||||||
/// the spawn itself failed (binary missing?) — the caller falls back to the in-process
|
/// the spawn itself failed (binary missing?) — surfaced as a connect error.
|
||||||
/// presenter and `busy` stays with it.
|
|
||||||
pub fn spawn_session(
|
pub fn spawn_session(
|
||||||
app: Rc<App>,
|
sender: relm4::Sender<AppMsg>,
|
||||||
req: ConnectRequest,
|
req: ConnectRequest,
|
||||||
fp_hex: String,
|
fp_hex: String,
|
||||||
tofu: bool,
|
tofu: bool,
|
||||||
) -> Result<(), ()> {
|
fullscreen_on_stream: bool,
|
||||||
|
opts: SpawnOpts,
|
||||||
|
) -> Result<(), String> {
|
||||||
let mut cmd = Command::new(session_binary());
|
let mut cmd = Command::new(session_binary());
|
||||||
cmd.arg("--connect")
|
cmd.arg("--connect")
|
||||||
.arg(format!("{}:{}", req.addr, req.port))
|
.arg(format!("{}:{}", req.addr, req.port))
|
||||||
@@ -86,94 +101,69 @@ pub fn spawn_session(
|
|||||||
if let Some((id, _)) = &req.launch {
|
if let Some((id, _)) = &req.launch {
|
||||||
cmd.arg("--launch").arg(id);
|
cmd.arg("--launch").arg(id);
|
||||||
}
|
}
|
||||||
if app.settings.borrow().fullscreen_on_stream {
|
if let Some(secs) = opts.connect_timeout_secs {
|
||||||
|
cmd.arg("--connect-timeout").arg(secs.to_string());
|
||||||
|
}
|
||||||
|
if fullscreen_on_stream {
|
||||||
cmd.arg("--fullscreen");
|
cmd.arg("--fullscreen");
|
||||||
}
|
}
|
||||||
let mut child = match cmd.spawn() {
|
let mut child = cmd
|
||||||
Ok(c) => c,
|
.spawn()
|
||||||
Err(e) => {
|
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
||||||
tracing::warn!(error = %e, binary = %session_binary().display(),
|
|
||||||
"punktfunk-session spawn failed");
|
|
||||||
return Err(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tracing::info!(host = %req.addr, port = req.port, "session binary spawned");
|
tracing::info!(host = %req.addr, port = req.port, "session binary spawned");
|
||||||
|
|
||||||
if let Some(h) = app.hosts_ui() {
|
|
||||||
h.clear_error();
|
|
||||||
h.set_connecting(Some(req.card_key()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reader thread: stdout lines → events, then reap the child and send the final exit.
|
|
||||||
let (tx, rx) = async_channel::unbounded::<ChildEvent>();
|
|
||||||
let stdout = child.stdout.take().expect("piped stdout");
|
let stdout = child.stdout.take().expect("piped stdout");
|
||||||
|
// Park the child where the cancel handle (and the reader, for the final reap) can
|
||||||
|
// reach it.
|
||||||
|
let slot = opts
|
||||||
|
.cancel
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default();
|
||||||
|
*slot.0.lock().unwrap() = Some(child);
|
||||||
|
|
||||||
|
let persist_paired = opts.persist_paired;
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("punktfunk-session-io".into())
|
.name("punktfunk-session-io".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
|
let mut error: Option<(String, bool)> = None;
|
||||||
|
let mut ended: Option<String> = None;
|
||||||
for line in std::io::BufReader::new(stdout).lines() {
|
for line in std::io::BufReader::new(stdout).lines() {
|
||||||
let Ok(line) = line else { break };
|
let Ok(line) = line else { break };
|
||||||
if let Some(ev) = parse_line(&line) {
|
match parse_line(&line) {
|
||||||
let _ = tx.send_blocking(ev);
|
Some(ChildEvent::Ready) => {
|
||||||
|
let _ = sender.send(AppMsg::SessionReady {
|
||||||
|
req: req.clone(),
|
||||||
|
fp_hex: fp_hex.clone(),
|
||||||
|
tofu,
|
||||||
|
persist_paired,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(ChildEvent::Error { msg, trust_rejected }) => {
|
||||||
|
error = Some((msg, trust_rejected));
|
||||||
|
}
|
||||||
|
Some(ChildEvent::Ended(msg)) => ended = Some(msg),
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// EOF — the child is exiting (or crashed); reap it so nothing zombies.
|
// EOF — reap the child (killed-by-cancel lands here too; -1 = signal).
|
||||||
let code = child
|
let code = slot
|
||||||
.wait()
|
.0
|
||||||
.map(|s| s.code().unwrap_or(-1))
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.take()
|
||||||
|
.and_then(|mut c| c.wait().ok())
|
||||||
|
.and_then(|s| s.code())
|
||||||
.unwrap_or(-1);
|
.unwrap_or(-1);
|
||||||
let _ = tx.send_blocking(ChildEvent::Exited(code));
|
tracing::info!(code, "session binary exited");
|
||||||
|
let _ = sender.send(AppMsg::SessionExited {
|
||||||
|
req,
|
||||||
|
code,
|
||||||
|
error,
|
||||||
|
ended,
|
||||||
|
tofu,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.map_err(|e| {
|
.map_err(|e| format!("session reader thread: {e}"))?;
|
||||||
tracing::warn!(error = %e, "session reader thread failed to start");
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Main-loop side: spinner/banner/re-pair per event; busy releases on exit.
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
let mut error: Option<(String, bool)> = None;
|
|
||||||
let mut ended: Option<String> = None;
|
|
||||||
while let Ok(ev) = rx.recv().await {
|
|
||||||
match ev {
|
|
||||||
ChildEvent::Ready => {
|
|
||||||
if let Some(h) = app.hosts_ui() {
|
|
||||||
h.set_connecting(None);
|
|
||||||
}
|
|
||||||
if tofu {
|
|
||||||
// The advertised fingerprint just proved itself on a real
|
|
||||||
// connect — pin it (unpaired), like the in-process TOFU.
|
|
||||||
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, false);
|
|
||||||
app.toast(&format!(
|
|
||||||
"Trusted on first use — fingerprint {}…",
|
|
||||||
&fp_hex[..16]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ChildEvent::Error { msg, trust_rejected } => error = Some((msg, trust_rejected)),
|
|
||||||
ChildEvent::Ended(msg) => ended = Some(msg),
|
|
||||||
ChildEvent::Exited(code) => {
|
|
||||||
tracing::info!(code, "session binary exited");
|
|
||||||
app.busy.set(false);
|
|
||||||
if let Some(h) = app.hosts_ui() {
|
|
||||||
h.set_connecting(None);
|
|
||||||
}
|
|
||||||
match (code, error.take(), ended.take()) {
|
|
||||||
(0, _, None) => {} // clean end — back on the hosts page, no noise
|
|
||||||
(0, _, Some(reason)) => app.connect_error(&reason),
|
|
||||||
(_, Some((_, true)), _) if !tofu => {
|
|
||||||
// The stored pin no longer matches (rotated cert or
|
|
||||||
// impostor) — route to re-pairing, like the in-process path.
|
|
||||||
app.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
|
||||||
crate::ui_trust::pin_dialog(app.clone(), req.clone());
|
|
||||||
}
|
|
||||||
(_, Some((msg, _)), _) => app.connect_error(&format!("Couldn't connect — {msg}")),
|
|
||||||
(code, None, _) => app.connect_error(&format!(
|
|
||||||
"Stream session failed (punktfunk-session exit {code})"
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+758
-688
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,13 @@
|
|||||||
//! a title starts a session that asks the host to launch it (the library id rides the
|
//! a title starts a session that asks the host to launch it (the library id rides the
|
||||||
//! Hello via `ConnectRequest::launch`).
|
//! Hello via `ConnectRequest::launch`).
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::{AppModel, AppMsg};
|
||||||
use crate::library::{self, GameEntry};
|
use crate::library::{self, GameEntry};
|
||||||
use crate::trust;
|
use crate::trust;
|
||||||
use crate::ui_hosts::ConnectRequest;
|
use crate::ui_hosts::ConnectRequest;
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::{gdk, glib};
|
use gtk::{gdk, glib};
|
||||||
|
use relm4::prelude::*;
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -19,7 +20,10 @@ use std::rc::Rc;
|
|||||||
/// card activation); dropped when the page is popped, which also winds down any in-flight
|
/// card activation); dropped when the page is popped, which also winds down any in-flight
|
||||||
/// art consumer (its weak upgrade fails).
|
/// art consumer (its weak upgrade fails).
|
||||||
struct State {
|
struct State {
|
||||||
app: Rc<App>,
|
sender: ComponentSender<AppModel>,
|
||||||
|
identity: (String, String),
|
||||||
|
/// The advertised mgmt port when the host was live at open time (else the default).
|
||||||
|
mgmt_port: u16,
|
||||||
/// The host this library belongs to — cards clone it and add `launch`.
|
/// The host this library belongs to — cards clone it and add `launch`.
|
||||||
req: ConnectRequest,
|
req: ConnectRequest,
|
||||||
stack: gtk::Stack,
|
stack: gtk::Stack,
|
||||||
@@ -34,21 +38,29 @@ struct State {
|
|||||||
mock: Cell<bool>,
|
mock: Cell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the library page for a saved host and start the fetch.
|
/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from
|
||||||
pub fn open(app: Rc<App>, req: ConnectRequest) {
|
/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it).
|
||||||
let state = build(app.clone(), req);
|
pub fn open(
|
||||||
|
app: &AppModel,
|
||||||
|
sender: &ComponentSender<AppModel>,
|
||||||
|
req: ConnectRequest,
|
||||||
|
mgmt_port: Option<u16>,
|
||||||
|
) {
|
||||||
|
let state = build(&app.nav, app.identity.clone(), sender, req, mgmt_port);
|
||||||
load(&state);
|
load(&state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by
|
/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by
|
||||||
/// entry id) with no host and no network — the CI `library` scene.
|
/// entry id) with no host and no network — the CI `library` scene.
|
||||||
pub fn open_mock(
|
pub fn open_mock(
|
||||||
app: Rc<App>,
|
nav: &adw::NavigationView,
|
||||||
|
identity: (String, String),
|
||||||
|
sender: &ComponentSender<AppModel>,
|
||||||
req: ConnectRequest,
|
req: ConnectRequest,
|
||||||
games: Vec<GameEntry>,
|
games: Vec<GameEntry>,
|
||||||
art: Vec<(String, gdk::Texture)>,
|
art: Vec<(String, gdk::Texture)>,
|
||||||
) {
|
) {
|
||||||
let state = build(app.clone(), req);
|
let state = build(nav, identity, sender, req, None);
|
||||||
state.mock.set(true);
|
state.mock.set(true);
|
||||||
state.art.borrow_mut().extend(art);
|
state.art.borrow_mut().extend(art);
|
||||||
if games.is_empty() {
|
if games.is_empty() {
|
||||||
@@ -60,7 +72,13 @@ pub fn open_mock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build the page (loading / error / empty / grid states in a stack) and push it.
|
/// Build the page (loading / error / empty / grid states in a stack) and push it.
|
||||||
fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
fn build(
|
||||||
|
nav: &adw::NavigationView,
|
||||||
|
identity: (String, String),
|
||||||
|
sender: &ComponentSender<AppModel>,
|
||||||
|
req: ConnectRequest,
|
||||||
|
mgmt_port: Option<u16>,
|
||||||
|
) -> Rc<State> {
|
||||||
let flow = gtk::FlowBox::builder()
|
let flow = gtk::FlowBox::builder()
|
||||||
.selection_mode(gtk::SelectionMode::None)
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
.activate_on_single_click(true)
|
.activate_on_single_click(true)
|
||||||
@@ -142,7 +160,9 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let state = Rc::new(State {
|
let state = Rc::new(State {
|
||||||
app: app.clone(),
|
sender: sender.clone(),
|
||||||
|
identity,
|
||||||
|
mgmt_port: mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||||
req,
|
req,
|
||||||
stack,
|
stack,
|
||||||
flow,
|
flow,
|
||||||
@@ -159,20 +179,10 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
|||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
retry.connect_clicked(move |_| load(&state));
|
retry.connect_clicked(move |_| load(&state));
|
||||||
}
|
}
|
||||||
app.nav.push(&page);
|
nav.push(&page);
|
||||||
state
|
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
|
/// Fetch the library off the main thread and route the result into the grid or the
|
||||||
/// error/empty states.
|
/// error/empty states.
|
||||||
fn load(state: &Rc<State>) {
|
fn load(state: &Rc<State>) {
|
||||||
@@ -180,9 +190,9 @@ fn load(state: &Rc<State>) {
|
|||||||
return; // screenshot scene renders injected entries only
|
return; // screenshot scene renders injected entries only
|
||||||
}
|
}
|
||||||
state.stack.set_visible_child_name("loading");
|
state.stack.set_visible_child_name("loading");
|
||||||
let port = mgmt_port(state);
|
let port = state.mgmt_port;
|
||||||
let addr = state.req.addr.clone();
|
let addr = state.req.addr.clone();
|
||||||
let identity = state.app.identity.clone();
|
let identity = state.identity.clone();
|
||||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||||
let (tx, rx) = async_channel::bounded(1);
|
let (tx, rx) = async_channel::bounded(1);
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
@@ -268,10 +278,10 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
|||||||
|
|
||||||
let child = gtk::FlowBoxChild::new();
|
let child = gtk::FlowBoxChild::new();
|
||||||
child.set_child(Some(&card));
|
child.set_child(Some(&card));
|
||||||
let app = state.app.clone();
|
let sender = state.sender.clone();
|
||||||
let mut req = state.req.clone();
|
let mut req = state.req.clone();
|
||||||
req.launch = Some((game.id.clone(), game.title.clone()));
|
req.launch = Some((game.id.clone(), game.title.clone()));
|
||||||
child.connect_activate(move |_| crate::ui_trust::initiate_connect(app.clone(), req.clone()));
|
child.connect_activate(move |_| sender.input(AppMsg::Connect(req.clone())));
|
||||||
child
|
child
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,8 +289,7 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
|||||||
/// entry's candidates in the Apple fallback order (portrait → header → hero) and
|
/// entry's candidates in the Apple fallback order (portrait → header → hero) and
|
||||||
/// texturing the first that loads on the main loop.
|
/// texturing the first that loads on the main loop.
|
||||||
fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
||||||
let port = mgmt_port(state);
|
let base = library::base_url(&state.req.addr, state.mgmt_port);
|
||||||
let base = library::base_url(&state.req.addr, port);
|
|
||||||
let jobs: VecDeque<(String, Vec<String>)> = {
|
let jobs: VecDeque<(String, Vec<String>)> = {
|
||||||
let cache = state.art.borrow();
|
let cache = state.art.borrow();
|
||||||
games
|
games
|
||||||
@@ -293,7 +302,7 @@ fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
|||||||
if jobs.is_empty() {
|
if jobs.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let identity = state.app.identity.clone();
|
let identity = state.identity.clone();
|
||||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||||
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
||||||
let weak = Rc::downgrade(state);
|
let weak = Rc::downgrade(state);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+120
-131
@@ -1,76 +1,27 @@
|
|||||||
//! The trust gate and dialogs in front of every connect: TOFU, the SPAKE2 PIN ceremony,
|
//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and
|
||||||
//! and delegated (request-access) approval.
|
//! delegated (request-access) approval. The trust GATE itself (rules 1–3) lives in
|
||||||
|
//! `AppModel::update` (`AppMsg::Connect`); these are the interaction surfaces it opens,
|
||||||
|
//! each resolving into typed [`AppMsg`]s.
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::{AppModel, AppMsg};
|
||||||
use crate::launch::{start_session, start_session_with, StartOpts};
|
use crate::spawn::{CancelHandle, SpawnOpts};
|
||||||
use crate::trust;
|
use crate::trust;
|
||||||
use crate::ui_hosts::ConnectRequest;
|
use crate::ui_hosts::ConnectRequest;
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::glib;
|
use gtk::glib;
|
||||||
use std::rc::Rc;
|
use relm4::prelude::*;
|
||||||
|
|
||||||
/// The trust gate in front of every connect. The host is the policy authority (it
|
/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet,
|
||||||
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
|
/// then we poll mDNS until it comes back online — re-sending every few seconds up to a
|
||||||
/// its trust UI from that:
|
/// timeout — and route back into the trust gate, **re-keying the saved record if the
|
||||||
/// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently.
|
/// host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog lets the
|
||||||
/// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer
|
/// user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s).
|
||||||
/// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of
|
pub fn wake_and_connect(
|
||||||
/// the advertised policy.
|
window: &adw::ApplicationWindow,
|
||||||
/// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a);
|
sender: &ComponentSender<AppModel>,
|
||||||
/// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is
|
req: ConnectRequest,
|
||||||
/// mandatory (rule 3b).
|
) {
|
||||||
///
|
let cancel = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||||
/// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet, then we poll
|
|
||||||
/// mDNS until it comes back online — re-sending every few seconds up to a timeout — and dial it via
|
|
||||||
/// [`initiate_connect`], **re-keying the saved record if the host woke on a new DHCP IP** (matched by
|
|
||||||
/// fingerprint). A "Waking…" dialog lets the user cancel. Mirrors the Apple/Android `HostWaker` (a
|
|
||||||
/// 90 s budget, resend every 6 s). The online path stays on the fast [`initiate_connect`]; this runs
|
|
||||||
/// only from the hosts page's auto-wake when a saved host isn't advertising.
|
|
||||||
pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
|
||||||
if app.busy.get() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancel = Rc::new(std::cell::Cell::new(false));
|
|
||||||
let waiting = adw::AlertDialog::new(
|
let waiting = adw::AlertDialog::new(
|
||||||
Some("Waking Host"),
|
Some("Waking Host"),
|
||||||
Some(&format!(
|
Some(&format!(
|
||||||
@@ -84,15 +35,15 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
let cancel = cancel.clone();
|
let cancel = cancel.clone();
|
||||||
waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true));
|
waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true));
|
||||||
}
|
}
|
||||||
waiting.present(Some(&app.window));
|
waiting.present(Some(window));
|
||||||
|
|
||||||
|
let sender = sender.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
let events = crate::discovery::browse();
|
let events = crate::discovery::browse();
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let budget = Duration::from_secs(90);
|
let budget = Duration::from_secs(90);
|
||||||
let resend = Duration::from_secs(6);
|
let resend = Duration::from_secs(6);
|
||||||
// Fire the first packet now, then re-send on the resend cadence.
|
|
||||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||||
let mut last_wake = Instant::now();
|
let mut last_wake = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
@@ -104,7 +55,7 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||||
last_wake = Instant::now();
|
last_wake = Instant::now();
|
||||||
}
|
}
|
||||||
// Drain resolved adverts; a match (by fingerprint, else addr:port) means the host is up.
|
// Drain resolved adverts; a match (fingerprint, else addr:port) = it's up.
|
||||||
while let Ok(ev) = events.try_recv() {
|
while let Ok(ev) = events.try_recv() {
|
||||||
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
|
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
|
||||||
continue;
|
continue;
|
||||||
@@ -116,7 +67,8 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
if matched {
|
if matched {
|
||||||
waiting.close();
|
waiting.close();
|
||||||
let mut req = req.clone();
|
let mut req = req.clone();
|
||||||
// Re-key on a new DHCP lease so this + future connects dial the live address.
|
// Re-key on a new DHCP lease so this + future connects dial the
|
||||||
|
// live address.
|
||||||
if h.addr != req.addr || h.port != req.port {
|
if h.addr != req.addr || h.port != req.port {
|
||||||
if let Some(fp) = &req.fp_hex {
|
if let Some(fp) = &req.fp_hex {
|
||||||
trust::rekey_addr(fp, &h.addr, h.port);
|
trust::rekey_addr(fp, &h.addr, h.port);
|
||||||
@@ -124,16 +76,16 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
req.addr = h.addr;
|
req.addr = h.addr;
|
||||||
req.port = h.port;
|
req.port = h.port;
|
||||||
}
|
}
|
||||||
initiate_connect(app.clone(), req);
|
sender.input(AppMsg::Connect(req));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if started.elapsed() >= budget {
|
if started.elapsed() >= budget {
|
||||||
waiting.close();
|
waiting.close();
|
||||||
app.toast(&format!(
|
sender.input(AppMsg::Toast(format!(
|
||||||
"Couldn't reach “{}” — is it powered and on the network?",
|
"Couldn't reach “{}” — is it powered and on the network?",
|
||||||
req.name
|
req.name
|
||||||
));
|
)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
glib::timeout_future(Duration::from_millis(500)).await;
|
glib::timeout_future(Duration::from_millis(500)).await;
|
||||||
@@ -141,9 +93,8 @@ pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines
|
/// 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
|
/// far easier to compare against the host's log than one 64-char run.
|
||||||
/// one 64-char run.
|
|
||||||
fn grouped_fingerprint(fp: &str) -> String {
|
fn grouped_fingerprint(fp: &str) -> String {
|
||||||
let groups: Vec<&str> = fp
|
let groups: Vec<&str> = fp
|
||||||
.as_bytes()
|
.as_bytes()
|
||||||
@@ -157,9 +108,15 @@ fn grouped_fingerprint(fp: &str) -> String {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// First contact with a discovered host: show the advertised fingerprint and let the user
|
/// First contact with a discovered host that opted into TOFU: show the advertised
|
||||||
/// trust it (TOFU), run the PIN ceremony instead, or walk away.
|
/// fingerprint and let the user trust it, run the PIN ceremony instead, or walk away.
|
||||||
pub fn tofu_dialog(app: Rc<App>, req: ConnectRequest) {
|
/// Trusting starts a session pinned to the advertised fp; it persists once the child
|
||||||
|
/// proves the host holds that identity (`AppMsg::SessionReady` with `tofu`).
|
||||||
|
pub fn tofu_dialog(
|
||||||
|
window: &adw::ApplicationWindow,
|
||||||
|
sender: &ComponentSender<AppModel>,
|
||||||
|
req: ConnectRequest,
|
||||||
|
) {
|
||||||
let fp = req.fp_hex.clone().unwrap_or_default();
|
let fp = req.fp_hex.clone().unwrap_or_default();
|
||||||
let dialog = adw::AlertDialog::new(
|
let dialog = adw::AlertDialog::new(
|
||||||
Some("New Host"),
|
Some("New Host"),
|
||||||
@@ -183,29 +140,35 @@ pub fn tofu_dialog(app: Rc<App>, req: ConnectRequest) {
|
|||||||
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
||||||
dialog.set_default_response(Some("trust"));
|
dialog.set_default_response(Some("trust"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
let parent = app.window.clone();
|
let sender = sender.clone();
|
||||||
dialog.connect_response(None, move |_, response| match response {
|
dialog.connect_response(None, move |_, response| match response {
|
||||||
"trust" => {
|
"trust" => sender.input(AppMsg::StartSession {
|
||||||
trust::persist_host(&req.name, &req.addr, req.port, &fp, false);
|
req: req.clone(),
|
||||||
start_session(app.clone(), req.clone(), trust::parse_hex32(&fp));
|
fp_hex: fp.clone(),
|
||||||
}
|
tofu: true,
|
||||||
"pair" => pin_dialog(app.clone(), req.clone()),
|
opts: SpawnOpts::default(),
|
||||||
|
}),
|
||||||
|
"pair" => sender.input(AppMsg::Pair(req.clone())),
|
||||||
_ => {}
|
_ => {}
|
||||||
});
|
});
|
||||||
dialog.present(Some(&parent));
|
dialog.present(Some(window));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
|
/// 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
|
/// of it pins the host's certificate (and registers ours) with no offline-guessable
|
||||||
/// transcript.
|
/// transcript. Success persists the host as paired and connects.
|
||||||
pub fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
pub fn pin_dialog(
|
||||||
|
window: &adw::ApplicationWindow,
|
||||||
|
sender: &ComponentSender<AppModel>,
|
||||||
|
identity: (String, String),
|
||||||
|
req: ConnectRequest,
|
||||||
|
) {
|
||||||
let entry = gtk::Entry::builder()
|
let entry = gtk::Entry::builder()
|
||||||
.input_purpose(gtk::InputPurpose::Digits)
|
.input_purpose(gtk::InputPurpose::Digits)
|
||||||
.placeholder_text("4-digit PIN shown by the host")
|
.placeholder_text("4-digit PIN shown by the host")
|
||||||
.activates_default(true)
|
.activates_default(true)
|
||||||
.build();
|
.build();
|
||||||
// The label the HOST stores this client under (its paired-devices list) — prefilled
|
// The label the HOST stores this client under — prefilled with the hostname.
|
||||||
// with the machine hostname, editable (the Apple pair sheet does the same).
|
|
||||||
let name_entry = gtk::Entry::builder()
|
let name_entry = gtk::Entry::builder()
|
||||||
.text(glib::host_name().as_str())
|
.text(glib::host_name().as_str())
|
||||||
.activates_default(true)
|
.activates_default(true)
|
||||||
@@ -235,12 +198,12 @@ pub fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
|||||||
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
||||||
dialog.set_default_response(Some("pair"));
|
dialog.set_default_response(Some("pair"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
let parent = app.window.clone();
|
let sender = sender.clone();
|
||||||
dialog.connect_response(Some("pair"), move |_, _| {
|
dialog.connect_response(Some("pair"), move |_, _| {
|
||||||
let pin = entry.text().to_string();
|
let pin = entry.text().to_string();
|
||||||
let app = app.clone();
|
|
||||||
let req = req.clone();
|
let req = req.clone();
|
||||||
let identity = app.identity.clone();
|
let identity = identity.clone();
|
||||||
|
let sender = sender.clone();
|
||||||
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
||||||
let device = name_entry.text().trim().to_string();
|
let device = name_entry.text().trim().to_string();
|
||||||
let name = if device.is_empty() {
|
let name = if device.is_empty() {
|
||||||
@@ -257,22 +220,33 @@ pub fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(Ok(fp)) => {
|
Ok(Ok(fp)) => {
|
||||||
trust::persist_host(&req.name, &req.addr, req.port, &trust::hex(&fp), true);
|
let fp_hex = trust::hex(&fp);
|
||||||
app.toast("Paired — connecting…");
|
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true);
|
||||||
start_session(app.clone(), req, Some(fp));
|
sender.input(AppMsg::Toast("Paired — connecting…".into()));
|
||||||
|
sender.input(AppMsg::StartSession {
|
||||||
|
req,
|
||||||
|
fp_hex,
|
||||||
|
tofu: false,
|
||||||
|
opts: SpawnOpts::default(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(Err(msg)) => app.toast(&msg),
|
Ok(Err(msg)) => sender.input(AppMsg::Toast(msg)),
|
||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
dialog.present(Some(&parent));
|
dialog.present(Some(window));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN
|
/// A fresh host that requires pairing: "Request access" (connect and wait for the
|
||||||
/// path — connect and wait for the operator to click Approve in the host's console/web UI
|
/// operator to click Approve in the host's console — delegated approval) or the PIN
|
||||||
/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony.
|
/// ceremony.
|
||||||
fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
pub fn approval_dialog(
|
||||||
|
window: &adw::ApplicationWindow,
|
||||||
|
sender: &ComponentSender<AppModel>,
|
||||||
|
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||||||
|
req: ConnectRequest,
|
||||||
|
) {
|
||||||
let dialog = adw::AlertDialog::new(
|
let dialog = adw::AlertDialog::new(
|
||||||
Some("Pairing Required"),
|
Some("Pairing Required"),
|
||||||
Some(&format!(
|
Some(&format!(
|
||||||
@@ -289,24 +263,42 @@ fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
|||||||
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||||||
dialog.set_default_response(Some("request"));
|
dialog.set_default_response(Some("request"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
let parent = app.window.clone();
|
let parent = window.clone();
|
||||||
|
let window = window.clone();
|
||||||
|
let sender = sender.clone();
|
||||||
dialog.connect_response(None, move |_, response| match response {
|
dialog.connect_response(None, move |_, response| match response {
|
||||||
"request" => request_access(app.clone(), req.clone()),
|
"request" => request_access(&window, &sender, waiting_slot.clone(), req.clone()),
|
||||||
"pin" => pin_dialog(app.clone(), req.clone()),
|
"pin" => sender.input(AppMsg::Pair(req.clone())),
|
||||||
_ => {}
|
_ => {}
|
||||||
});
|
});
|
||||||
dialog.present(Some(&parent));
|
dialog.present(Some(&parent));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
/// The no-PIN "request access" flow: the session child opens an identified connect the
|
||||||
/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On
|
/// host PARKS until the operator approves it in the console; a cancelable "waiting"
|
||||||
/// approval the same connection is admitted (no reconnect) and the host is saved as paired.
|
/// dialog covers the wait. On approval the same connection is admitted and the host is
|
||||||
fn request_access(app: Rc<App>, req: ConnectRequest) {
|
/// saved as paired. Cancel kills the child (the only abort a parked connect has).
|
||||||
// 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.
|
/// The pinned fingerprint is the advertised one for a discovered host (defence against
|
||||||
let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
/// an impostor while we wait). A manually-typed host has no advertised fingerprint —
|
||||||
let cancel = Rc::new(std::cell::Cell::new(false));
|
/// the session binary refuses pinless connects, so this path requires the advert; a
|
||||||
|
/// manual entry's Request Access rides the same flow only when a fingerprint exists.
|
||||||
|
fn request_access(
|
||||||
|
window: &adw::ApplicationWindow,
|
||||||
|
sender: &ComponentSender<AppModel>,
|
||||||
|
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||||||
|
req: ConnectRequest,
|
||||||
|
) {
|
||||||
|
let Some(fp_hex) = req.fp_hex.clone() else {
|
||||||
|
// No fingerprint to pin (manual entry): the strict child can't do a
|
||||||
|
// trust-on-approval connect — route to the PIN ceremony instead.
|
||||||
|
sender.input(AppMsg::Toast(
|
||||||
|
"No advertised identity for this host — pair with a PIN instead.".into(),
|
||||||
|
));
|
||||||
|
sender.input(AppMsg::Pair(req));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let cancel = CancelHandle::default();
|
||||||
let waiting = adw::AlertDialog::new(
|
let waiting = adw::AlertDialog::new(
|
||||||
Some("Waiting for Approval"),
|
Some("Waiting for Approval"),
|
||||||
Some(&format!(
|
Some(&format!(
|
||||||
@@ -319,29 +311,26 @@ fn request_access(app: Rc<App>, req: ConnectRequest) {
|
|||||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||||
waiting.set_close_response("cancel");
|
waiting.set_close_response("cancel");
|
||||||
{
|
{
|
||||||
let app = app.clone();
|
let sender = sender.clone();
|
||||||
let cancel = cancel.clone();
|
let cancel = cancel.clone();
|
||||||
waiting.connect_response(Some("cancel"), move |_, _| {
|
waiting.connect_response(Some("cancel"), move |_, _| {
|
||||||
// Return the UI immediately; the in-flight connect is left to time out and is torn
|
cancel.kill();
|
||||||
// down silently by the event loop (see StartOpts::cancel).
|
sender.input(AppMsg::CancelPending);
|
||||||
cancel.set(true);
|
|
||||||
app.busy.set(false);
|
|
||||||
app.toast("Cancelled — the request may still be pending on the host.");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
waiting.present(Some(&app.window));
|
waiting.present(Some(window));
|
||||||
|
*waiting_slot.borrow_mut() = Some(waiting);
|
||||||
|
|
||||||
start_session_with(
|
sender.input(AppMsg::StartSession {
|
||||||
app,
|
|
||||||
req,
|
req,
|
||||||
pin,
|
fp_hex,
|
||||||
StartOpts {
|
tofu: false,
|
||||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
opts: SpawnOpts {
|
||||||
// approval still lands on this connection rather than timing the client out first.
|
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow
|
||||||
connect_timeout: std::time::Duration::from_secs(185),
|
// operator approval still lands on this connection.
|
||||||
|
connect_timeout_secs: Some(185),
|
||||||
persist_paired: true,
|
persist_paired: true,
|
||||||
waiting: Some(waiting),
|
|
||||||
cancel: Some(cancel),
|
cancel: Some(cancel),
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,664 +0,0 @@
|
|||||||
//! VAAPI dmabuf → RGBA GL texture converter — the Steam Deck's hardware-decode presenter.
|
|
||||||
//!
|
|
||||||
//! The direct path hands the decoder's NV12 dmabuf (fds + AMD tiled modifier) to
|
|
||||||
//! `GdkDmabufTexture` and lets GTK import + color-convert it. On the Deck that renders
|
|
||||||
//! corrupt/gray/washed-out: since Mesa 25.1 radeonsi exports VCN decode surfaces TILED, and
|
|
||||||
//! GTK's tiled-NV12 import mishandles the layout (the Flatpak runtime's Mesa drives both
|
|
||||||
//! sides). Moonlight-qt and mpv are clean on the same box because they never let a toolkit
|
|
||||||
//! near the YUV: they import the dmabuf into their own EGL context and convert with their
|
|
||||||
//! own shader. This module is that architecture for the GTK client:
|
|
||||||
//!
|
|
||||||
//! VAAPI frame → per-plane `EGLImage`s (R8 luma + GR88 chroma, modifier passed through)
|
|
||||||
//! → our YUV→RGB shader (matrix + range from the stream's real CICP signaling)
|
|
||||||
//! → an RGBA texture in a `GdkGLContext`-shared context → `GdkGLTexture` (fence-synced).
|
|
||||||
//!
|
|
||||||
//! GTK then composites a plain RGBA texture — no YUV format negotiation, no modifier
|
|
||||||
//! handling, no compositor CSC. Same-Mesa export/import is the exact proven-working path.
|
|
||||||
//! Everything runs on the GTK main thread (the converter is driven by the frame consumer);
|
|
||||||
//! one 800p–4K NV12→RGB pass is sub-millisecond GPU work.
|
|
||||||
//!
|
|
||||||
//! Failure at any step (GLX-backed GDK context, missing EGL extensions, import rejection)
|
|
||||||
//! is surfaced as an error — the caller falls back to software decode, never to the broken
|
|
||||||
//! direct path.
|
|
||||||
|
|
||||||
use crate::video::{ColorDesc, DmabufFrame};
|
|
||||||
use anyhow::{anyhow, bail, Context as _, Result};
|
|
||||||
use gtk::{gdk, prelude::*};
|
|
||||||
use khronos_egl as egl;
|
|
||||||
use std::ffi::c_void;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
// --- EGL_EXT_image_dma_buf_import(+_modifiers) constants (khronos-egl exposes none) ------
|
|
||||||
const EGL_LINUX_DMA_BUF_EXT: egl::Enum = 0x3270;
|
|
||||||
// eglCreateImageKHR takes 32-bit EGLint attribs (the core-1.5 eglCreateImage variant is the
|
|
||||||
// one with pointer-sized EGLAttrib) — using the wrong width feeds the driver garbage pairs.
|
|
||||||
const EGL_LINUX_DRM_FOURCC_EXT: i32 = 0x3271;
|
|
||||||
const EGL_DMA_BUF_PLANE0_FD_EXT: i32 = 0x3272;
|
|
||||||
const EGL_DMA_BUF_PLANE0_OFFSET_EXT: i32 = 0x3273;
|
|
||||||
const EGL_DMA_BUF_PLANE0_PITCH_EXT: i32 = 0x3274;
|
|
||||||
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: i32 = 0x3443;
|
|
||||||
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: i32 = 0x3444;
|
|
||||||
const EGL_WIDTH: i32 = 0x3057;
|
|
||||||
const EGL_HEIGHT: i32 = 0x3056;
|
|
||||||
const EGL_NONE: i32 = 0x3038;
|
|
||||||
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
|
|
||||||
|
|
||||||
/// `fourcc('N','V','1','2')` — the only decoder output today (8-bit 4:2:0). P010 joins when
|
|
||||||
/// the Linux host grows 10-bit.
|
|
||||||
const DRM_FORMAT_NV12: u32 = 0x3231_564e;
|
|
||||||
const DRM_FORMAT_R8: u32 = 0x2020_3852;
|
|
||||||
const DRM_FORMAT_GR88: u32 = 0x3838_5247;
|
|
||||||
|
|
||||||
// --- The slice of GL we use (loaded via eglGetProcAddress — Mesa/NVIDIA both implement
|
|
||||||
// --- EGL_KHR_get_all_proc_addresses, so core functions resolve too) ----------------------
|
|
||||||
const GL_TEXTURE_2D: u32 = 0x0DE1;
|
|
||||||
const GL_TEXTURE0: u32 = 0x84C0;
|
|
||||||
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
|
|
||||||
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
|
|
||||||
const GL_TEXTURE_WRAP_S: u32 = 0x2802;
|
|
||||||
const GL_TEXTURE_WRAP_T: u32 = 0x2803;
|
|
||||||
const GL_LINEAR: i32 = 0x2601;
|
|
||||||
const GL_CLAMP_TO_EDGE: i32 = 0x812F;
|
|
||||||
const GL_FRAMEBUFFER: u32 = 0x8D40;
|
|
||||||
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
|
|
||||||
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
|
|
||||||
const GL_RGBA8: u32 = 0x8058;
|
|
||||||
const GL_RGBA: u32 = 0x1908;
|
|
||||||
const GL_UNSIGNED_BYTE: u32 = 0x1401;
|
|
||||||
const GL_TRIANGLES: u32 = 0x0004;
|
|
||||||
const GL_VERTEX_SHADER: u32 = 0x8B31;
|
|
||||||
const GL_FRAGMENT_SHADER: u32 = 0x8B30;
|
|
||||||
const GL_COMPILE_STATUS: u32 = 0x8B81;
|
|
||||||
const GL_LINK_STATUS: u32 = 0x8B82;
|
|
||||||
const GL_SYNC_GPU_COMMANDS_COMPLETE: u32 = 0x9117;
|
|
||||||
|
|
||||||
macro_rules! gl_fns {
|
|
||||||
($($name:ident : fn($($arg:ty),*) $(-> $ret:ty)?;)*) => {
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
struct GlFns { $($name: unsafe extern "C" fn($($arg),*) $(-> $ret)?,)* }
|
|
||||||
impl GlFns {
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
fn load(egl: &Egl) -> Result<GlFns> {
|
|
||||||
$(
|
|
||||||
// eglGetProcAddress returns a plain fn pointer; the signature is fixed
|
|
||||||
// by the GL spec for each name.
|
|
||||||
let $name = egl
|
|
||||||
.get_proc_address(concat!("gl", stringify!($name)))
|
|
||||||
.ok_or_else(|| anyhow!(concat!("gl", stringify!($name), " unresolvable")))?;
|
|
||||||
)*
|
|
||||||
// SAFETY: each pointer came from eglGetProcAddress for exactly that GL entry
|
|
||||||
// point; the transmute only fixes the signature the spec defines for it.
|
|
||||||
unsafe {
|
|
||||||
Ok(GlFns { $($name: std::mem::transmute::<extern "system" fn(), unsafe extern "C" fn($($arg),*) $(-> $ret)?>($name),)* })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
gl_fns! {
|
|
||||||
GenTextures: fn(i32, *mut u32);
|
|
||||||
DeleteTextures: fn(i32, *const u32);
|
|
||||||
BindTexture: fn(u32, u32);
|
|
||||||
TexParameteri: fn(u32, u32, i32);
|
|
||||||
TexImage2D: fn(u32, i32, i32, i32, i32, i32, u32, u32, *const c_void);
|
|
||||||
ActiveTexture: fn(u32);
|
|
||||||
EGLImageTargetTexture2DOES: fn(u32, *const c_void);
|
|
||||||
GenFramebuffers: fn(i32, *mut u32);
|
|
||||||
DeleteFramebuffers: fn(i32, *const u32);
|
|
||||||
BindFramebuffer: fn(u32, u32);
|
|
||||||
FramebufferTexture2D: fn(u32, u32, u32, u32, i32);
|
|
||||||
CheckFramebufferStatus: fn(u32) -> u32;
|
|
||||||
Viewport: fn(i32, i32, i32, i32);
|
|
||||||
CreateShader: fn(u32) -> u32;
|
|
||||||
ShaderSource: fn(u32, i32, *const *const u8, *const i32);
|
|
||||||
CompileShader: fn(u32);
|
|
||||||
GetShaderiv: fn(u32, u32, *mut i32);
|
|
||||||
GetShaderInfoLog: fn(u32, i32, *mut i32, *mut u8);
|
|
||||||
DeleteShader: fn(u32);
|
|
||||||
CreateProgram: fn() -> u32;
|
|
||||||
AttachShader: fn(u32, u32);
|
|
||||||
LinkProgram: fn(u32);
|
|
||||||
GetProgramiv: fn(u32, u32, *mut i32);
|
|
||||||
UseProgram: fn(u32);
|
|
||||||
GetUniformLocation: fn(u32, *const u8) -> i32;
|
|
||||||
Uniform1i: fn(i32, i32);
|
|
||||||
Uniform3fv: fn(i32, i32, *const f32);
|
|
||||||
UniformMatrix3fv: fn(i32, i32, u8, *const f32);
|
|
||||||
GenVertexArrays: fn(i32, *mut u32);
|
|
||||||
DeleteVertexArrays: fn(i32, *const u32);
|
|
||||||
DeleteProgram: fn(u32);
|
|
||||||
BindVertexArray: fn(u32);
|
|
||||||
DrawArrays: fn(u32, i32, i32);
|
|
||||||
FenceSync: fn(u32, u32) -> *const c_void;
|
|
||||||
DeleteSync: fn(*const c_void);
|
|
||||||
Flush: fn();
|
|
||||||
GetError: fn() -> u32;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Egl = egl::DynamicInstance<egl::EGL1_4>;
|
|
||||||
type EglCreateImageKhr = unsafe extern "C" fn(
|
|
||||||
*mut c_void, // EGLDisplay
|
|
||||||
*mut c_void, // EGLContext (EGL_NO_CONTEXT for dmabuf)
|
|
||||||
egl::Enum,
|
|
||||||
*mut c_void, // EGLClientBuffer (null for dmabuf)
|
|
||||||
*const i32, // EGLint attrib list (KHR variant — NOT pointer-sized EGLAttrib)
|
|
||||||
) -> *const c_void;
|
|
||||||
type EglDestroyImageKhr = unsafe extern "C" fn(*mut c_void, *const c_void) -> egl::Boolean;
|
|
||||||
|
|
||||||
/// The YUV→RGB conversion for a stream's CICP signaling: `rgb = mat * (yuv + off)`, with the
|
|
||||||
/// limited/full-range expansion folded in. `mat` is column-major (GL convention). Pure —
|
|
||||||
/// unit-tested against the reference white/black points.
|
|
||||||
pub fn yuv_to_rgb(desc: ColorDesc) -> ([f32; 9], [f32; 3]) {
|
|
||||||
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
|
||||||
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
|
|
||||||
let (kr, kb) = match desc.matrix {
|
|
||||||
5 | 6 => (0.299, 0.114),
|
|
||||||
9 | 10 => (0.2627, 0.0593),
|
|
||||||
_ => (0.2126, 0.0722),
|
|
||||||
};
|
|
||||||
let kg = 1.0 - kr - kb;
|
|
||||||
let (sy, oy, sc) = if desc.full_range {
|
|
||||||
(1.0f32, 0.0f32, 1.0f32)
|
|
||||||
} else {
|
|
||||||
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
|
|
||||||
};
|
|
||||||
let (kr, kb, kg) = (kr as f32, kb as f32, kg as f32);
|
|
||||||
// Column-major: columns are the Y, U, V contributions to (R, G, B).
|
|
||||||
let mat = [
|
|
||||||
sy,
|
|
||||||
sy,
|
|
||||||
sy, // Y column
|
|
||||||
0.0,
|
|
||||||
-2.0 * (1.0 - kb) * kb / kg * sc,
|
|
||||||
2.0 * (1.0 - kb) * sc, // U column
|
|
||||||
2.0 * (1.0 - kr) * sc,
|
|
||||||
-2.0 * (1.0 - kr) * kr / kg * sc,
|
|
||||||
0.0, // V column
|
|
||||||
];
|
|
||||||
(mat, [oy, -0.5, -0.5])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An output texture GTK has released, waiting to be recycled (or its fence deleted). GL
|
|
||||||
/// objects can only be touched with our context current, so releases park here and
|
|
||||||
/// [`GlConverter::convert`] drains them.
|
|
||||||
struct Retired {
|
|
||||||
tex: u32,
|
|
||||||
sync: usize, // GLsync as usize — the release closure must be Send
|
|
||||||
size: (u32, u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct GlConverter {
|
|
||||||
ctx: gdk::GLContext,
|
|
||||||
egl: Egl,
|
|
||||||
egl_display: *mut c_void,
|
|
||||||
create_image: EglCreateImageKhr,
|
|
||||||
destroy_image: EglDestroyImageKhr,
|
|
||||||
gl: GlFns,
|
|
||||||
program: u32,
|
|
||||||
vao: u32,
|
|
||||||
fbo: u32,
|
|
||||||
u_mat: i32,
|
|
||||||
u_off: i32,
|
|
||||||
/// Uniforms match this signaling; a change (mid-stream SDR↔HDR) re-uploads them.
|
|
||||||
uniforms_for: Option<ColorDesc>,
|
|
||||||
/// Free output textures + fences returned by GTK's release funcs (shared with the
|
|
||||||
/// `Send` release closures; drained/recycled at each convert).
|
|
||||||
retired: Arc<Mutex<Vec<Retired>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GlConverter {
|
|
||||||
/// Build against the widget's display. Must run on the GTK main thread; fails cleanly
|
|
||||||
/// on a GLX-backed GDK context or missing EGL dmabuf-import extensions (the caller
|
|
||||||
/// falls back to software decode).
|
|
||||||
pub fn new(widget: &impl IsA<gtk::Widget>) -> Result<GlConverter> {
|
|
||||||
let display = widget.display();
|
|
||||||
let ctx = display.create_gl_context().context("create GdkGLContext")?;
|
|
||||||
ctx.realize().context("realize GdkGLContext")?;
|
|
||||||
ctx.make_current();
|
|
||||||
|
|
||||||
// SAFETY (whole block): the GdkGLContext is current on this thread, so EGL/GL
|
|
||||||
// queries and object creation target it; pointers are only used while it lives.
|
|
||||||
unsafe {
|
|
||||||
let egl = Egl::load_required().context("dlopen libEGL")?;
|
|
||||||
let egl_display = egl
|
|
||||||
.get_current_display()
|
|
||||||
.ok_or_else(|| anyhow!("GDK context is not EGL-backed (GLX?)"))?;
|
|
||||||
let exts = egl
|
|
||||||
.query_string(Some(egl_display), egl::EXTENSIONS)
|
|
||||||
.context("EGL_EXTENSIONS")?
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned();
|
|
||||||
for need in ["EGL_EXT_image_dma_buf_import", "EGL_KHR_image_base"] {
|
|
||||||
if !exts.contains(need) {
|
|
||||||
bail!("EGL lacks {need}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Tiled surfaces carry an explicit modifier — without the _modifiers extension
|
|
||||||
// the import would silently assume implied/linear and sample garbage.
|
|
||||||
if !exts.contains("EGL_EXT_image_dma_buf_import_modifiers") {
|
|
||||||
bail!("EGL lacks EGL_EXT_image_dma_buf_import_modifiers");
|
|
||||||
}
|
|
||||||
let create_image: EglCreateImageKhr =
|
|
||||||
std::mem::transmute::<extern "system" fn(), EglCreateImageKhr>(
|
|
||||||
egl.get_proc_address("eglCreateImageKHR")
|
|
||||||
.ok_or_else(|| anyhow!("no eglCreateImageKHR"))?,
|
|
||||||
);
|
|
||||||
let destroy_image: EglDestroyImageKhr =
|
|
||||||
std::mem::transmute::<extern "system" fn(), EglDestroyImageKhr>(
|
|
||||||
egl.get_proc_address("eglDestroyImageKHR")
|
|
||||||
.ok_or_else(|| anyhow!("no eglDestroyImageKHR"))?,
|
|
||||||
);
|
|
||||||
let gl = GlFns::load(&egl)?;
|
|
||||||
|
|
||||||
let es = ctx.api().contains(gdk::GLAPI::GLES);
|
|
||||||
let program = build_program(&gl, es)?;
|
|
||||||
(gl.UseProgram)(program);
|
|
||||||
let u_mat = (gl.GetUniformLocation)(program, c"u_mat".as_ptr() as *const u8);
|
|
||||||
let u_off = (gl.GetUniformLocation)(program, c"u_off".as_ptr() as *const u8);
|
|
||||||
let u_y = (gl.GetUniformLocation)(program, c"u_y".as_ptr() as *const u8);
|
|
||||||
let u_c = (gl.GetUniformLocation)(program, c"u_c".as_ptr() as *const u8);
|
|
||||||
(gl.Uniform1i)(u_y, 0);
|
|
||||||
(gl.Uniform1i)(u_c, 1);
|
|
||||||
let mut vao = 0u32;
|
|
||||||
(gl.GenVertexArrays)(1, &mut vao);
|
|
||||||
let mut fbo = 0u32;
|
|
||||||
(gl.GenFramebuffers)(1, &mut fbo);
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
gles = es,
|
|
||||||
"GL presenter ready — VAAPI dmabufs convert in-process (own EGL import + shader)"
|
|
||||||
);
|
|
||||||
Ok(GlConverter {
|
|
||||||
ctx,
|
|
||||||
egl,
|
|
||||||
egl_display: egl_display.as_ptr(),
|
|
||||||
create_image,
|
|
||||||
destroy_image,
|
|
||||||
gl,
|
|
||||||
program,
|
|
||||||
vao,
|
|
||||||
fbo,
|
|
||||||
u_mat,
|
|
||||||
u_off,
|
|
||||||
uniforms_for: None,
|
|
||||||
retired: Arc::new(Mutex::new(Vec::new())),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert one decoded frame into an RGBA `GdkTexture`. The source surface (guard) is
|
|
||||||
/// held until GTK releases the output texture — the GPU read is long finished by then.
|
|
||||||
/// `color_state` tags the output (full-range RGB, transfer left baked — same semantics
|
|
||||||
/// as the software path's tagged `GdkMemoryTexture`); `None` = untagged sRGB.
|
|
||||||
pub fn convert(
|
|
||||||
&mut self,
|
|
||||||
frame: DmabufFrame,
|
|
||||||
color_state: Option<&gdk::ColorState>,
|
|
||||||
) -> Result<gdk::Texture> {
|
|
||||||
if frame.fourcc != DRM_FORMAT_NV12 {
|
|
||||||
bail!("GL presenter handles NV12 only (got {:#x})", frame.fourcc);
|
|
||||||
}
|
|
||||||
if frame.planes.len() < 2 {
|
|
||||||
bail!("NV12 needs 2 planes (got {})", frame.planes.len());
|
|
||||||
}
|
|
||||||
self.ctx.make_current();
|
|
||||||
let gl = &self.gl;
|
|
||||||
|
|
||||||
// SAFETY (whole body): our context is current; every GL/EGL object created here is
|
|
||||||
// either destroyed before return or owned by the pool/release machinery.
|
|
||||||
unsafe {
|
|
||||||
// Recycle what GTK released since last frame (GL objects need the context, so
|
|
||||||
// the release closures only park entries — this is where they die/revive).
|
|
||||||
let size = (frame.width, frame.height);
|
|
||||||
let mut out_tex = 0u32;
|
|
||||||
{
|
|
||||||
let mut retired = self.retired.lock().unwrap();
|
|
||||||
retired.retain_mut(|r| {
|
|
||||||
if r.sync != 0 {
|
|
||||||
(gl.DeleteSync)(r.sync as *const c_void);
|
|
||||||
r.sync = 0;
|
|
||||||
}
|
|
||||||
if out_tex == 0 && r.size == size {
|
|
||||||
out_tex = r.tex;
|
|
||||||
false
|
|
||||||
} else if r.size != size {
|
|
||||||
(gl.DeleteTextures)(1, &r.tex); // stale size (mode change)
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
true // spare same-size texture for a later frame
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if out_tex == 0 {
|
|
||||||
(gl.GenTextures)(1, &mut out_tex);
|
|
||||||
(gl.BindTexture)(GL_TEXTURE_2D, out_tex);
|
|
||||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
|
||||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
||||||
(gl.TexImage2D)(
|
|
||||||
GL_TEXTURE_2D,
|
|
||||||
0,
|
|
||||||
GL_RGBA8 as i32,
|
|
||||||
frame.width as i32,
|
|
||||||
frame.height as i32,
|
|
||||||
0,
|
|
||||||
GL_RGBA,
|
|
||||||
GL_UNSIGNED_BYTE,
|
|
||||||
std::ptr::null(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import both planes with the surface's modifier — exactly the layer-wise
|
|
||||||
// import Moonlight/mpv drive on this hardware.
|
|
||||||
let y = &frame.planes[0];
|
|
||||||
let c = &frame.planes[1];
|
|
||||||
let img_y =
|
|
||||||
self.plane_image(frame.width, frame.height, DRM_FORMAT_R8, y, frame.modifier)?;
|
|
||||||
let img_c = match self.plane_image(
|
|
||||||
frame.width.div_ceil(2),
|
|
||||||
frame.height.div_ceil(2),
|
|
||||||
DRM_FORMAT_GR88,
|
|
||||||
c,
|
|
||||||
frame.modifier,
|
|
||||||
) {
|
|
||||||
Ok(img) => img,
|
|
||||||
Err(e) => {
|
|
||||||
(self.destroy_image)(self.egl_display, img_y);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut planes = [0u32; 2];
|
|
||||||
(gl.GenTextures)(2, planes.as_mut_ptr());
|
|
||||||
for (tex, img) in planes.iter().zip([img_y, img_c]) {
|
|
||||||
(gl.BindTexture)(GL_TEXTURE_2D, *tex);
|
|
||||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
|
||||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
||||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
|
||||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
|
||||||
(gl.EGLImageTargetTexture2DOES)(GL_TEXTURE_2D, img);
|
|
||||||
}
|
|
||||||
|
|
||||||
(gl.UseProgram)(self.program);
|
|
||||||
if self.uniforms_for != Some(frame.color) {
|
|
||||||
let (mat, off) = yuv_to_rgb(frame.color);
|
|
||||||
(gl.UniformMatrix3fv)(self.u_mat, 1, 0, mat.as_ptr());
|
|
||||||
(gl.Uniform3fv)(self.u_off, 1, off.as_ptr());
|
|
||||||
self.uniforms_for = Some(frame.color);
|
|
||||||
}
|
|
||||||
(gl.BindFramebuffer)(GL_FRAMEBUFFER, self.fbo);
|
|
||||||
(gl.FramebufferTexture2D)(
|
|
||||||
GL_FRAMEBUFFER,
|
|
||||||
GL_COLOR_ATTACHMENT0,
|
|
||||||
GL_TEXTURE_2D,
|
|
||||||
out_tex,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
let status = (gl.CheckFramebufferStatus)(GL_FRAMEBUFFER);
|
|
||||||
if status != GL_FRAMEBUFFER_COMPLETE {
|
|
||||||
(gl.BindFramebuffer)(GL_FRAMEBUFFER, 0);
|
|
||||||
(gl.DeleteTextures)(2, planes.as_ptr());
|
|
||||||
(self.destroy_image)(self.egl_display, img_y);
|
|
||||||
(self.destroy_image)(self.egl_display, img_c);
|
|
||||||
(gl.DeleteTextures)(1, &out_tex);
|
|
||||||
bail!("FBO incomplete ({status:#x})");
|
|
||||||
}
|
|
||||||
(gl.Viewport)(0, 0, frame.width as i32, frame.height as i32);
|
|
||||||
(gl.BindVertexArray)(self.vao);
|
|
||||||
(gl.ActiveTexture)(GL_TEXTURE0);
|
|
||||||
(gl.BindTexture)(GL_TEXTURE_2D, planes[0]);
|
|
||||||
(gl.ActiveTexture)(GL_TEXTURE0 + 1);
|
|
||||||
(gl.BindTexture)(GL_TEXTURE_2D, planes[1]);
|
|
||||||
(gl.DrawArrays)(GL_TRIANGLES, 0, 3);
|
|
||||||
(gl.BindFramebuffer)(GL_FRAMEBUFFER, 0);
|
|
||||||
|
|
||||||
let sync = (gl.FenceSync)(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
|
|
||||||
(gl.Flush)();
|
|
||||||
// The draw is queued: plane textures + images can go now (the driver keeps the
|
|
||||||
// underlying buffers alive until the queued commands execute).
|
|
||||||
(gl.DeleteTextures)(2, planes.as_ptr());
|
|
||||||
(self.destroy_image)(self.egl_display, img_y);
|
|
||||||
(self.destroy_image)(self.egl_display, img_c);
|
|
||||||
|
|
||||||
let err = (gl.GetError)();
|
|
||||||
if err != 0 {
|
|
||||||
(gl.DeleteTextures)(1, &out_tex);
|
|
||||||
bail!("GL error {err:#x} during convert");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut b = gdk::GLTextureBuilder::new()
|
|
||||||
.set_context(Some(&self.ctx))
|
|
||||||
.set_id(out_tex)
|
|
||||||
.set_width(frame.width as i32)
|
|
||||||
.set_height(frame.height as i32)
|
|
||||||
.set_format(gdk::MemoryFormat::R8g8b8a8)
|
|
||||||
.set_sync(Some(sync));
|
|
||||||
if let Some(state) = color_state {
|
|
||||||
b = b.set_color_state(state);
|
|
||||||
}
|
|
||||||
let retired = self.retired.clone();
|
|
||||||
let guard = frame.guard;
|
|
||||||
let sync_bits = sync as usize; // GLsync as usize — the closure must be Send
|
|
||||||
let texture = b.build_with_release_func(move || {
|
|
||||||
drop(guard); // the decoder surface outlived every GPU read of it
|
|
||||||
retired.lock().unwrap().push(Retired {
|
|
||||||
tex: out_tex,
|
|
||||||
sync: sync_bits,
|
|
||||||
size,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Ok(texture)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One single-plane `EGLImage` over a dmabuf plane (R8 luma / GR88 chroma), modifier
|
|
||||||
/// passed explicitly.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `self.ctx` must be current; the fd stays owned by the caller (EGL dups internally).
|
|
||||||
unsafe fn plane_image(
|
|
||||||
&self,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
fourcc: u32,
|
|
||||||
plane: &crate::video::DmabufPlane,
|
|
||||||
modifier: u64,
|
|
||||||
) -> Result<*const c_void> {
|
|
||||||
let mut attribs = vec![
|
|
||||||
EGL_WIDTH,
|
|
||||||
width as i32,
|
|
||||||
EGL_HEIGHT,
|
|
||||||
height as i32,
|
|
||||||
EGL_LINUX_DRM_FOURCC_EXT,
|
|
||||||
fourcc as i32,
|
|
||||||
EGL_DMA_BUF_PLANE0_FD_EXT,
|
|
||||||
plane.fd,
|
|
||||||
EGL_DMA_BUF_PLANE0_OFFSET_EXT,
|
|
||||||
plane.offset as i32,
|
|
||||||
EGL_DMA_BUF_PLANE0_PITCH_EXT,
|
|
||||||
plane.stride as i32,
|
|
||||||
];
|
|
||||||
if modifier != DRM_FORMAT_MOD_INVALID && modifier != 0 {
|
|
||||||
attribs.extend_from_slice(&[
|
|
||||||
EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT,
|
|
||||||
(modifier & 0xffff_ffff) as u32 as i32,
|
|
||||||
EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT,
|
|
||||||
(modifier >> 32) as u32 as i32,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
attribs.push(EGL_NONE);
|
|
||||||
// SAFETY: attribs is a valid EGL_NONE-terminated list; display/context are live.
|
|
||||||
let img = unsafe {
|
|
||||||
(self.create_image)(
|
|
||||||
self.egl_display,
|
|
||||||
std::ptr::null_mut(), // EGL_NO_CONTEXT — dmabuf import
|
|
||||||
EGL_LINUX_DMA_BUF_EXT,
|
|
||||||
std::ptr::null_mut(),
|
|
||||||
attribs.as_ptr(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if img.is_null() {
|
|
||||||
bail!(
|
|
||||||
"eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:?}",
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
fourcc,
|
|
||||||
modifier,
|
|
||||||
self.egl.get_error()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(img)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for GlConverter {
|
|
||||||
/// Delete our objects from the shared context group (the context lives in GDK's share
|
|
||||||
/// group — per-session leftovers would pile up across sessions). Textures GTK still
|
|
||||||
/// holds at this moment release into `retired` afterwards, where nobody drains them:
|
|
||||||
/// those names leak, but it's ≤ the pool depth once per session, not per frame.
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.ctx.make_current();
|
|
||||||
let gl = &self.gl;
|
|
||||||
// SAFETY: context current; only objects this converter created are deleted.
|
|
||||||
unsafe {
|
|
||||||
for r in self.retired.lock().unwrap().drain(..) {
|
|
||||||
if r.sync != 0 {
|
|
||||||
(gl.DeleteSync)(r.sync as *const c_void);
|
|
||||||
}
|
|
||||||
(gl.DeleteTextures)(1, &r.tex);
|
|
||||||
}
|
|
||||||
(gl.DeleteFramebuffers)(1, &self.fbo);
|
|
||||||
(gl.DeleteVertexArrays)(1, &self.vao);
|
|
||||||
(gl.DeleteProgram)(self.program);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compile the fullscreen-triangle NV12→RGB program (GLSL 300 es / 330 core per the GDK
|
|
||||||
/// context's API). `gl_VertexID` drives the geometry — no buffers at all.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// A GL context must be current; `gl` must belong to it.
|
|
||||||
unsafe fn build_program(gl: &GlFns, es: bool) -> Result<u32> {
|
|
||||||
let header = if es {
|
|
||||||
"#version 300 es\nprecision highp float;\n"
|
|
||||||
} else {
|
|
||||||
"#version 330 core\n"
|
|
||||||
};
|
|
||||||
let vs_src = format!(
|
|
||||||
"{header}
|
|
||||||
out vec2 v_uv;
|
|
||||||
void main() {{
|
|
||||||
vec2 p = vec2(float((gl_VertexID & 1) << 2) - 1.0, float((gl_VertexID & 2) << 1) - 1.0);
|
|
||||||
v_uv = p * 0.5 + 0.5;
|
|
||||||
gl_Position = vec4(p, 0.0, 1.0);
|
|
||||||
}}"
|
|
||||||
);
|
|
||||||
let fs_src = format!(
|
|
||||||
"{header}
|
|
||||||
in vec2 v_uv;
|
|
||||||
out vec4 frag;
|
|
||||||
uniform sampler2D u_y;
|
|
||||||
uniform sampler2D u_c;
|
|
||||||
uniform mat3 u_mat;
|
|
||||||
uniform vec3 u_off;
|
|
||||||
void main() {{
|
|
||||||
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg);
|
|
||||||
frag = vec4(clamp(u_mat * (yuv + u_off), 0.0, 1.0), 1.0);
|
|
||||||
}}"
|
|
||||||
);
|
|
||||||
// SAFETY: caller holds a current context; sources are valid UTF-8 with explicit lengths.
|
|
||||||
unsafe {
|
|
||||||
let compile = |kind: u32, src: &str| -> Result<u32> {
|
|
||||||
let sh = (gl.CreateShader)(kind);
|
|
||||||
let ptr = src.as_ptr();
|
|
||||||
let len = src.len() as i32;
|
|
||||||
(gl.ShaderSource)(sh, 1, &ptr, &len);
|
|
||||||
(gl.CompileShader)(sh);
|
|
||||||
let mut ok = 0i32;
|
|
||||||
(gl.GetShaderiv)(sh, GL_COMPILE_STATUS, &mut ok);
|
|
||||||
if ok == 0 {
|
|
||||||
let mut log = vec![0u8; 1024];
|
|
||||||
let mut n = 0i32;
|
|
||||||
(gl.GetShaderInfoLog)(sh, 1024, &mut n, log.as_mut_ptr());
|
|
||||||
(gl.DeleteShader)(sh);
|
|
||||||
bail!(
|
|
||||||
"shader compile: {}",
|
|
||||||
String::from_utf8_lossy(&log[..n.max(0) as usize])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(sh)
|
|
||||||
};
|
|
||||||
let vs = compile(GL_VERTEX_SHADER, &vs_src)?;
|
|
||||||
let fs = match compile(GL_FRAGMENT_SHADER, &fs_src) {
|
|
||||||
Ok(fs) => fs,
|
|
||||||
Err(e) => {
|
|
||||||
(gl.DeleteShader)(vs);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let prog = (gl.CreateProgram)();
|
|
||||||
(gl.AttachShader)(prog, vs);
|
|
||||||
(gl.AttachShader)(prog, fs);
|
|
||||||
(gl.LinkProgram)(prog);
|
|
||||||
(gl.DeleteShader)(vs);
|
|
||||||
(gl.DeleteShader)(fs);
|
|
||||||
let mut ok = 0i32;
|
|
||||||
(gl.GetProgramiv)(prog, GL_LINK_STATUS, &mut ok);
|
|
||||||
if ok == 0 {
|
|
||||||
bail!("program link failed");
|
|
||||||
}
|
|
||||||
Ok(prog)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
|
||||||
ColorDesc {
|
|
||||||
primaries: 1,
|
|
||||||
transfer: 1,
|
|
||||||
matrix,
|
|
||||||
full_range,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply(mat: &[f32; 9], off: &[f32; 3], yuv: [f32; 3]) -> [f32; 3] {
|
|
||||||
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
|
|
||||||
// Column-major: out[r] = Σ mat[col*3 + r] * v[col]
|
|
||||||
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0.
|
|
||||||
#[test]
|
|
||||||
fn bt709_limited_white_black() {
|
|
||||||
let (mat, off) = yuv_to_rgb(desc(1, false));
|
|
||||||
let white = apply(&mat, &off, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
|
||||||
let black = apply(&mat, &off, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
|
||||||
for (w, b) in white.iter().zip(black) {
|
|
||||||
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
|
|
||||||
assert!(b.abs() < 0.005, "black {black:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full-range identity points: Y=1 → white, Y=0 → black, and a 601-vs-709 red spot
|
|
||||||
/// check (pure V excursion produces R = 2(1−Kr)·0.5).
|
|
||||||
#[test]
|
|
||||||
fn full_range_and_red_excursion() {
|
|
||||||
let (mat, off) = yuv_to_rgb(desc(5, true));
|
|
||||||
let white = apply(&mat, &off, [1.0, 0.5, 0.5]);
|
|
||||||
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
|
|
||||||
let red = apply(&mat, &off, [0.0, 0.5, 1.0]);
|
|
||||||
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
|
|
||||||
// 709 differs from 601 in the same spot — guards the matrix-code dispatch.
|
|
||||||
let (mat709, off709) = yuv_to_rgb(desc(1, true));
|
|
||||||
let red709 = apply(&mat709, &off709, [0.0, 0.5, 1.0]);
|
|
||||||
assert!(
|
|
||||||
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
|
|
||||||
"{red709:?}"
|
|
||||||
);
|
|
||||||
assert!((red[0] - red709[0]).abs() > 0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ 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 addhost shortcuts library gamepad-library); 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
|
||||||
|
|||||||
Reference in New Issue
Block a user